/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Threading;
using System.Threading.Tasks;
using Opc.Ua;
using Opc.Ua.Bindings;
namespace Quickstarts.ConsoleDataChannelAudio
{
///
/// A data channel source that plays a canned melody on repeat.
///
///
/// The source writes in real time rather than as fast as the channel will
/// take it: a media source is paced by its own clock, and writing faster
/// would only fill the receiver's buffer and add latency. Credit still
/// bounds it if the consumer falls behind, and the channel reports the
/// stall through its diagnostics rather than growing without limit.
///
internal sealed class AudioStreamingSource : IDataChannelSource, IDisposable
{
///
/// Creates the source.
///
/// How much audio one frame carries.
public AudioStreamingSource(int frameMilliseconds)
{
m_pcm = MelodyGenerator.Render();
m_frameBytes = AudioFormat.BytesForDuration(frameMilliseconds);
m_frameMilliseconds = frameMilliseconds;
SourceNodeId = new NodeId("Speaker1", 1);
Capabilities = new DataChannelSourceCapabilities
{
Direction = DataChannelDirection.SourceToSink,
SupportedDeliveryModes = [DataChannelDeliveryMode.ReliableOrdered],
ContentType = AudioFormat.ContentType,
MaxFrameSize = (uint)m_frameBytes,
MaxChannels = 1,
Priority = 1
};
}
///
/// The Node a Client opens the channel on.
///
public NodeId SourceNodeId { get; }
///
public NodeId NodeId => SourceNodeId;
///
public DataChannelSourceCapabilities Capabilities { get; }
///
public int ActiveChannelCount => m_channel == null ? 0 : 1;
///
/// The length of the rendered melody, for the console banner.
///
public TimeSpan LoopDuration
=> TimeSpan.FromSeconds(
(double)m_pcm.Length / (AudioFormat.SampleRate * AudioFormat.BytesPerSample));
///
public void OnChannelOpened(DataChannel channel)
{
m_channel = channel;
m_stop = new CancellationTokenSource();
m_pump = Task.Run(() => PumpAsync(channel, m_stop.Token));
}
///
public void OnChannelClosed(DataChannel channel, StatusCode reason)
{
m_channel = null;
m_stop?.Cancel();
}
///
/// Stops the pump and waits for it, so the sample shuts down cleanly.
///
public async Task StopAsync()
{
m_stop?.Cancel();
if (m_pump != null)
{
await m_pump.ConfigureAwait(false);
}
}
///
public void Dispose()
{
m_stop?.Dispose();
m_stop = null;
}
private async Task PumpAsync(DataChannel channel, CancellationToken ct)
{
int offset = 0;
var next = DateTime.UtcNow;
try
{
while (!ct.IsCancellationRequested &&
channel.State is DataChannelState.Open or DataChannelState.Opening)
{
if (channel.State == DataChannelState.Opening)
{
await Task.Delay(5, ct).ConfigureAwait(false);
continue;
}
int count = Math.Min(m_frameBytes, m_pcm.Length - offset);
// MessageStart and MessageEnd on every frame: each one is a
// self contained unit of audio, so a sink can play what it
// has without waiting for a boundary.
channel.Write(
m_pcm.AsSpan(offset, count),
DataChannelFrameFlags.MessageStart | DataChannelFrameFlags.MessageEnd);
offset += count;
if (offset >= m_pcm.Length)
{
offset = 0;
}
// Paced against a fixed schedule rather than by sleeping for
// the frame duration, so the send rate does not drift by the
// time each iteration takes.
next = next.AddMilliseconds(m_frameMilliseconds);
TimeSpan delay = next - DateTime.UtcNow;
if (delay > TimeSpan.Zero)
{
await Task.Delay(delay, ct).ConfigureAwait(false);
}
else
{
next = DateTime.UtcNow;
}
}
}
catch (OperationCanceledException)
{
// Shutting down.
}
catch (ServiceResultException e)
{
// The channel closed underneath the pump, which is how a
// CloseDataChannel while writing surfaces. Anything else is
// worth seeing rather than losing in an unobserved task.
if (e.StatusCode != StatusCodes.BadDataChannelClosed)
{
Console.Error.WriteLine($"audio pump stopped: {e.Message}");
}
}
#pragma warning disable CA1031 // A sample must not die silently.
catch (Exception e)
#pragma warning restore CA1031
{
Console.Error.WriteLine($"audio pump failed: {e}");
}
}
private readonly byte[] m_pcm;
private readonly int m_frameBytes;
private readonly int m_frameMilliseconds;
private DataChannel? m_channel;
private CancellationTokenSource? m_stop;
private Task? m_pump;
}
}