diff --git a/README.md b/README.md
index cefa22707..a7c7b4c53 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ This repository includes **[70+ example projects](examples/)** demonstrating var
- **[SIP Scenarios](examples/SIPScenarios/)** - Call transfers, load testing, complex call flows ([README](examples/SIPScenarios/README.md))
- **[WebRTC Scenarios](examples/WebRTCScenarios/)** - Advanced WebRTC use cases ([README](examples/WebRTCScenarios/README.md))
- **[Softphone](examples/Softphone/)** - Full-featured Windows Forms softphone application ([README](examples/Softphone/README.md))
-- **[OpenAI](examples/OpenAIExamples/)** - Example applications for interacting with OpenAI's Realtime WebRTC and SIP end poiints ([README](examples/OpenAIExamples/GetStarted/README.md))
+- **[OpenAI](examples/OpenAIExamples/)** - Example applications for interacting with OpenAI's Realtime WebRTC and SIP end points ([README](examples/OpenAIExamples/GetStarted/README.md))
---
diff --git a/SIPSorcery.slnx b/SIPSorcery.slnx
index 08044c335..4aa79192a 100644
--- a/SIPSorcery.slnx
+++ b/SIPSorcery.slnx
@@ -133,6 +133,7 @@
+
diff --git a/src/SIPSorcery.Cli/Commands/AudioSink.cs b/src/SIPSorcery.Cli/Commands/AudioSink.cs
new file mode 100644
index 000000000..c6e12034e
--- /dev/null
+++ b/src/SIPSorcery.Cli/Commands/AudioSink.cs
@@ -0,0 +1,335 @@
+//-----------------------------------------------------------------------------
+// Filename: AudioSink.cs
+//
+// Description: Routes received, decoded PCM audio to one of three sinks:
+// - "play": a spawned ffplay child process rendering to the speakers,
+// leaving the verb's stdout untouched.
+// - : a WAV file (header patched with the final sizes on close).
+// - "-": raw s16le PCM on stdout. The caller is responsible for
+// routing its result object to stderr in this mode, per the
+// rule that stdout carries exactly one payload.
+//
+// The sink initialises lazily on the first write because the sample rate is
+// only known once the audio format has been negotiated.
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 12 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+
+namespace SIPSorcery.Cli.Commands;
+
+public sealed class AudioSink : IDisposable
+{
+ private enum SinkMode
+ {
+ None,
+ Wav,
+ Stdout,
+ Play
+ }
+
+ private readonly SinkMode _mode;
+ private readonly string? _filePath;
+ private readonly ILogger _logger;
+ private readonly object _lock = new();
+
+ private Stream? _out;
+ private FileStream? _wavFile;
+ private Process? _ffplay;
+ private bool _failed;
+ private long _bytesWritten;
+
+ public bool IsActive => _mode != SinkMode.None;
+ public bool IsStdout => _mode == SinkMode.Stdout;
+ public long BytesWritten { get { lock (_lock) { return _bytesWritten; } } }
+
+ private AudioSink(SinkMode mode, string? filePath, ILogger logger)
+ {
+ _mode = mode;
+ _filePath = filePath;
+ _logger = logger;
+ }
+
+ public static AudioSink Create(string? spec, ILogger logger, out string? error)
+ {
+ error = null;
+
+ if (string.IsNullOrWhiteSpace(spec))
+ {
+ return new AudioSink(SinkMode.None, null, logger);
+ }
+
+ if (spec == "-")
+ {
+ return new AudioSink(SinkMode.Stdout, null, logger);
+ }
+
+ if (spec.Equals("play", StringComparison.OrdinalIgnoreCase))
+ {
+ return new AudioSink(SinkMode.Play, null, logger);
+ }
+
+ if (spec.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
+ {
+ return new AudioSink(SinkMode.Wav, spec, logger);
+ }
+
+ error = $"--audio must be \"play\", \"-\" or a .wav file path. Got \"{spec}\".";
+ return new AudioSink(SinkMode.None, null, logger);
+ }
+
+ ///
+ /// Writes a block of decoded mono PCM. The first call fixes the sample rate for the sink.
+ ///
+ public void Write(short[] pcm, int sampleRate)
+ {
+ if (_mode == SinkMode.None || _failed || pcm.Length == 0)
+ {
+ return;
+ }
+
+ lock (_lock)
+ {
+ if (_out == null && !Init(sampleRate))
+ {
+ return;
+ }
+
+ var bytes = new byte[pcm.Length * sizeof(short)];
+ Buffer.BlockCopy(pcm, 0, bytes, 0, bytes.Length);
+
+ try
+ {
+ _out!.Write(bytes, 0, bytes.Length);
+ _out.Flush();
+ _bytesWritten += bytes.Length;
+ }
+ catch (Exception excp)
+ {
+ // e.g. ffplay was closed by the user, or the downstream pipe broke.
+ _logger.LogWarning("Audio sink write failed, no further audio will be written: {Error}", excp.Message);
+ _failed = true;
+ }
+ }
+ }
+
+ private bool Init(int sampleRate)
+ {
+ try
+ {
+ switch (_mode)
+ {
+ case SinkMode.Wav:
+ _wavFile = new FileStream(_filePath!, FileMode.Create, FileAccess.ReadWrite);
+ WavFile.WriteHeader(_wavFile, sampleRate);
+ _out = _wavFile;
+ _logger.LogDebug("Writing received audio to {FilePath} at {SampleRate}Hz.", _filePath, sampleRate);
+ return true;
+
+ case SinkMode.Stdout:
+ _out = Console.OpenStandardOutput();
+ Console.Error.WriteLine($"Writing raw PCM to stdout: s16le, {sampleRate} Hz, mono.");
+ return true;
+
+ case SinkMode.Play:
+ var startInfo = new ProcessStartInfo("ffplay")
+ {
+ // Note -ch_layout rather than the -ac option which was removed in ffplay 8.
+ Arguments = $"-hide_banner -loglevel error -nodisp -autoexit -f s16le -ar {sampleRate} -ch_layout mono -i -",
+ UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardError = true
+ };
+
+ _ffplay = Process.Start(startInfo);
+ if (_ffplay == null)
+ {
+ throw new ApplicationException("ffplay did not start.");
+ }
+
+ // Drain ffplay's stderr so it cannot block, surfacing anything it says as debug.
+ _ = Task.Run(async () =>
+ {
+ string? line;
+ while ((line = await _ffplay.StandardError.ReadLineAsync().ConfigureAwait(false)) != null)
+ {
+ _logger.LogDebug("ffplay: {Line}", line);
+ }
+ });
+
+ _out = _ffplay.StandardInput.BaseStream;
+ Console.Error.WriteLine($"Rendering received audio with ffplay ({sampleRate} Hz mono).");
+ return true;
+
+ default:
+ return false;
+ }
+ }
+ catch (Exception excp) when (_mode == SinkMode.Play)
+ {
+ _logger.LogError("Could not start ffplay: {Error}. Install ffmpeg (which includes ffplay) and ensure it is on the PATH.", excp.Message);
+ _failed = true;
+ return false;
+ }
+ catch (Exception excp)
+ {
+ _logger.LogError("Could not initialise the audio sink: {Error}", excp.Message);
+ _failed = true;
+ return false;
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_lock)
+ {
+ try
+ {
+ if (_wavFile != null)
+ {
+ WavFile.PatchHeader(_wavFile, _bytesWritten);
+ _wavFile.Dispose();
+ }
+ else if (_ffplay != null)
+ {
+ // Closing stdin lets ffplay drain its buffer and exit (-autoexit).
+ _ffplay.StandardInput.Close();
+ if (!_ffplay.WaitForExit(2000))
+ {
+ _ffplay.Kill();
+ }
+ _ffplay.Dispose();
+ }
+ else
+ {
+ _out?.Flush();
+ }
+ }
+ catch (Exception excp)
+ {
+ _logger.LogDebug("Audio sink close error: {Error}", excp.Message);
+ }
+ }
+ }
+}
+
+///
+/// Minimal 16 bit mono PCM WAV reading/writing, just enough for the audio verbs.
+///
+public static class WavFile
+{
+ private const int HEADER_LENGTH = 44;
+
+ public static void WriteHeader(Stream stream, int sampleRate)
+ {
+ using var writer = new BinaryWriter(stream, System.Text.Encoding.ASCII, leaveOpen: true);
+ writer.Write("RIFF"u8);
+ writer.Write(0); // RIFF chunk size, patched on close.
+ writer.Write("WAVE"u8);
+ writer.Write("fmt "u8);
+ writer.Write(16); // fmt chunk size.
+ writer.Write((short)1); // PCM.
+ writer.Write((short)1); // Mono.
+ writer.Write(sampleRate);
+ writer.Write(sampleRate * 2); // Byte rate.
+ writer.Write((short)2); // Block align.
+ writer.Write((short)16); // Bits per sample.
+ writer.Write("data"u8);
+ writer.Write(0); // Data chunk size, patched on close.
+ }
+
+ public static void PatchHeader(FileStream stream, long dataLength)
+ {
+ using var writer = new BinaryWriter(stream, System.Text.Encoding.ASCII, leaveOpen: true);
+ stream.Seek(4, SeekOrigin.Begin);
+ writer.Write((int)(dataLength + HEADER_LENGTH - 8));
+ stream.Seek(40, SeekOrigin.Begin);
+ writer.Write((int)dataLength);
+ }
+
+ ///
+ /// Reads a 16 bit mono PCM WAV file sampled at 8 or 16KHz, the formats the audio source
+ /// can stream.
+ ///
+ public static bool TryReadPcm(string path, out byte[]? pcm, out int sampleRate, out string? error)
+ {
+ pcm = null;
+ sampleRate = 0;
+ error = null;
+
+ try
+ {
+ using var stream = File.OpenRead(path);
+ using var reader = new BinaryReader(stream);
+
+ if (reader.ReadBytes(4) is not [0x52, 0x49, 0x46, 0x46]) // "RIFF"
+ {
+ error = $"\"{path}\" is not a WAV file (missing RIFF header).";
+ return false;
+ }
+
+ reader.ReadInt32(); // RIFF chunk size.
+
+ if (reader.ReadBytes(4) is not [0x57, 0x41, 0x56, 0x45]) // "WAVE"
+ {
+ error = $"\"{path}\" is not a WAV file (missing WAVE marker).";
+ return false;
+ }
+
+ short channels = 0;
+ short bitsPerSample = 0;
+
+ // Walk the chunks looking for fmt and data.
+ while (stream.Position + 8 <= stream.Length)
+ {
+ string chunkId = new(reader.ReadChars(4));
+ int chunkSize = reader.ReadInt32();
+
+ if (chunkId == "fmt ")
+ {
+ short audioFormat = reader.ReadInt16();
+ channels = reader.ReadInt16();
+ sampleRate = reader.ReadInt32();
+ reader.ReadInt32(); // Byte rate.
+ reader.ReadInt16(); // Block align.
+ bitsPerSample = reader.ReadInt16();
+ stream.Seek(chunkSize - 16, SeekOrigin.Current); // Skip any fmt extension.
+
+ if (audioFormat != 1 || channels != 1 || bitsPerSample != 16 || (sampleRate != 8000 && sampleRate != 16000))
+ {
+ error = $"\"{path}\" must be 16 bit mono PCM at 8000 or 16000 Hz " +
+ $"(found format {audioFormat}, {channels} channel(s), {bitsPerSample} bit, {sampleRate} Hz). " +
+ "Convert with: ffmpeg -i in.wav -ar 8000 -ac 1 -c:a pcm_s16le out.wav";
+ return false;
+ }
+ }
+ else if (chunkId == "data")
+ {
+ pcm = reader.ReadBytes(chunkSize);
+ return true;
+ }
+ else
+ {
+ stream.Seek(chunkSize, SeekOrigin.Current);
+ }
+ }
+
+ error = $"\"{path}\" has no data chunk.";
+ return false;
+ }
+ catch (Exception excp)
+ {
+ error = $"Could not read \"{path}\": {excp.Message}";
+ return false;
+ }
+ }
+}
diff --git a/src/SIPSorcery.Cli/Commands/CommandBase.cs b/src/SIPSorcery.Cli/Commands/CommandBase.cs
new file mode 100644
index 000000000..2a099f5dd
--- /dev/null
+++ b/src/SIPSorcery.Cli/Commands/CommandBase.cs
@@ -0,0 +1,107 @@
+//-----------------------------------------------------------------------------
+// Filename: CommandBase.cs
+//
+// Description: Base class for sipsorcery CLI verbs. Centralises the cross
+// cutting conventions every verb must follow:
+//
+// - Results go to stdout: human readable by default, a single JSON object
+// with --json. Logs and diagnostics always go to stderr so stdout stays
+// pipeable.
+// - The common options (--timeout/-t, --json, --verbose/-v) have the same
+// names, aliases and descriptions on every verb.
+// - Meaningful exit codes, see ExitCodes.
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 12 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using System.CommandLine;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+
+namespace SIPSorcery.Cli.Commands;
+
+public abstract class CommandBase
+{
+ private static readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = true
+ };
+
+ protected Option TimeoutOption { get; }
+
+ protected Option JsonOption { get; } = new("--json")
+ {
+ Description = "Write the result to stdout as a single JSON object instead of human readable text."
+ };
+
+ protected Option VerboseOption { get; } = new("--verbose", "-v")
+ {
+ Description = "Write diagnostic logs, including protocol traces, to stderr."
+ };
+
+ protected CommandBase(int defaultTimeoutSeconds)
+ {
+ TimeoutOption = new Option("--timeout", "-t")
+ {
+ Description = "The number of seconds to wait for the operation to complete.",
+ DefaultValueFactory = _ => defaultTimeoutSeconds
+ };
+ }
+
+ ///
+ /// Builds the System.CommandLine command for this verb.
+ ///
+ public abstract Command Build();
+
+ ///
+ /// Adds the options every verb supports. Call after adding the verb specific arguments and
+ /// options so the common ones are listed last in help output.
+ ///
+ protected void AddCommonOptions(Command command)
+ {
+ command.Options.Add(TimeoutOption);
+ command.Options.Add(JsonOption);
+ command.Options.Add(VerboseOption);
+ }
+
+ ///
+ /// Creates a console logger factory for the duration of a verb and wires it into the
+ /// SIPSorcery library. All log levels are routed to stderr so stdout carries only the result.
+ /// Verbose enables Trace, not Debug: the library logs one line summaries at Debug but the raw
+ /// SIP/STUN messages at Trace (see SIPTransport.EnableTraceLogs).
+ ///
+ protected static ILoggerFactory InitLogging(bool verbose)
+ {
+ var loggerFactory = LoggerFactory.Create(builder =>
+ builder.AddConsole(opts => opts.LogToStandardErrorThreshold = LogLevel.Trace)
+ .SetMinimumLevel(verbose ? LogLevel.Trace : LogLevel.Warning));
+
+ SIPSorcery.LogFactory.Set(loggerFactory);
+
+ return loggerFactory;
+ }
+
+ ///
+ /// Writes a verb's result object to stdout as JSON. Result records should use stable field
+ /// names with additive changes only, since scripts and agents parse them.
+ ///
+ protected static void WriteJson(T result) =>
+ Console.WriteLine(SerializeResult(result));
+
+ ///
+ /// Serialises a result object with the standard JSON settings. For verbs whose stdout may be
+ /// claimed by a media payload (--audio -), allowing the result to be written to stderr instead.
+ ///
+ protected static string SerializeResult(T result) =>
+ JsonSerializer.Serialize(result, _jsonOptions);
+}
diff --git a/src/SIPSorcery.Cli/Commands/RtpStreamStats.cs b/src/SIPSorcery.Cli/Commands/RtpStreamStats.cs
new file mode 100644
index 000000000..94428ea32
--- /dev/null
+++ b/src/SIPSorcery.Cli/Commands/RtpStreamStats.cs
@@ -0,0 +1,146 @@
+//-----------------------------------------------------------------------------
+// Filename: RtpStreamStats.cs
+//
+// Description: Tracks RTP sequence numbers for one media stream to detect
+// gaps, reordering and duplicates. Shared by the verbs that receive media
+// (webrtc whep, webrtc whip-server).
+//
+// A gap (lost packet) at this layer means the packet never reached the
+// application: genuine network loss, but also packets discarded inside the
+// library, e.g. SRTP authentication failures, which makes a non-zero value a
+// prompt to rerun with --verbose and look closer.
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 12 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using Microsoft.Extensions.Logging;
+using SIPSorcery.Net;
+using SIPSorceryMedia.Abstractions;
+
+namespace SIPSorcery.Cli.Commands;
+
+public sealed class RtpStreamStats
+{
+ public enum RecordKind
+ {
+ InOrder,
+ OutOfOrder,
+ Duplicate
+ }
+
+ public readonly record struct RecordOutcome(RecordKind Kind, ushort PreviousHighestSeq);
+
+ private readonly object _lock = new();
+ private bool _hasFirst;
+ private ushort _highestSeq;
+ private long _cycles; // count of 16 bit sequence number wraps observed.
+ private long _firstExtended;
+ private long _highestExtended;
+
+ public int Packets { get; private set; }
+ public int OutOfOrder { get; private set; }
+ public int Duplicates { get; private set; }
+
+ /// Expected packet count from first to highest sequence number, inclusive.
+ public long Expected
+ {
+ get { lock (_lock) { return _hasFirst ? _highestExtended - _firstExtended + 1 : 0; } }
+ }
+
+ /// Sequence gaps: expected minus received (never negative).
+ public long Lost => Math.Max(0, Expected - Packets);
+
+ public RecordOutcome Record(ushort seq)
+ {
+ lock (_lock)
+ {
+ if (!_hasFirst)
+ {
+ _hasFirst = true;
+ _highestSeq = seq;
+ _firstExtended = seq;
+ _highestExtended = seq;
+ Packets = 1;
+ return new RecordOutcome(RecordKind.InOrder, seq);
+ }
+
+ Packets++;
+ ushort previousHighest = _highestSeq;
+
+ // Extend the 16 bit sequence number relative to the highest seen, allowing for
+ // wraps in both directions (the same estimation problem as RFC 3711 Appendix A).
+ long extended;
+ if (seq >= _highestSeq)
+ {
+ extended = seq - _highestSeq < 32768
+ ? _cycles * 65536 + seq // in order or small forward jump.
+ : (_cycles - 1) * 65536 + seq; // straggler from before a recent wrap.
+ }
+ else
+ {
+ if (_highestSeq - seq < 32768)
+ {
+ extended = _cycles * 65536 + seq; // late packet in the current cycle.
+ }
+ else
+ {
+ _cycles++; // the sequence number wrapped forward.
+ extended = _cycles * 65536 + seq;
+ }
+ }
+
+ if (extended > _highestExtended)
+ {
+ _highestExtended = extended;
+ _highestSeq = seq;
+ return new RecordOutcome(RecordKind.InOrder, previousHighest);
+ }
+
+ // A repeat of the current highest is reported as a duplicate (a duplicate of an
+ // older packet cannot be distinguished from a late arrival without a full window
+ // and is reported as out of order).
+ if (extended == _highestExtended)
+ {
+ Duplicates++;
+ return new RecordOutcome(RecordKind.Duplicate, previousHighest);
+ }
+
+ OutOfOrder++;
+ return new RecordOutcome(RecordKind.OutOfOrder, previousHighest);
+ }
+ }
+
+ ///
+ /// Creates an RTP packet handler that records audio and video packets against the supplied
+ /// stats and logs each anomaly at debug level.
+ ///
+ public static Action CreateRtpHandler(
+ RtpStreamStats audioStats, RtpStreamStats videoStats, ILogger logger)
+ {
+ return (remoteEndPoint, mediaType, rtpPacket) =>
+ {
+ var stats = mediaType == SDPMediaTypesEnum.audio ? audioStats
+ : mediaType == SDPMediaTypesEnum.video ? videoStats
+ : null;
+
+ if (stats != null)
+ {
+ ushort seq = (ushort)rtpPacket.Header.SequenceNumber;
+ var outcome = stats.Record(seq);
+
+ if (outcome.Kind != RecordKind.InOrder)
+ {
+ logger.LogDebug("{MediaType} packet seq {Seq} arrived {Kind} (highest seen {Highest}, ssrc {Ssrc}).",
+ mediaType, seq, outcome.Kind, outcome.PreviousHighestSeq, rtpPacket.Header.SyncSource);
+ }
+ }
+ };
+ }
+}
diff --git a/src/SIPSorcery.Cli/Commands/SipDestination.cs b/src/SIPSorcery.Cli/Commands/SipDestination.cs
new file mode 100644
index 000000000..bc5aa2c95
--- /dev/null
+++ b/src/SIPSorcery.Cli/Commands/SipDestination.cs
@@ -0,0 +1,58 @@
+//-----------------------------------------------------------------------------
+// Filename: SipDestination.cs
+//
+// Description: Parses the SIP destination argument shared by the sip verbs.
+// Accepts both SIP URIs (sip:100@host, music@host) and serialised SIP end
+// points (udp:host:port, tls:host). The same convention as the sipcmdline
+// example.
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 12 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using SIPSorcery.SIP;
+
+namespace SIPSorcery.Cli.Commands;
+
+public static class SipDestination
+{
+ public static bool TryParse(string destination, out SIPURI uri, out string? error)
+ {
+ uri = SIPURI.None;
+ error = null;
+
+ try
+ {
+ // SIPURI.TryParse is lenient, e.g. it accepts host names containing spaces, so apply
+ // a sanity check to route nonsense to an invalid argument error rather than a DNS failure.
+ if (!HasTransportPrefix(destination) && SIPURI.TryParse(destination, out var parsedUri)
+ && !string.IsNullOrWhiteSpace(parsedUri.Host) && !parsedUri.Host.Contains(' '))
+ {
+ uri = parsedUri;
+ return true;
+ }
+
+ var endPoint = SIPEndPoint.ParseSIPEndPoint(destination);
+ uri = new SIPURI(SIPSchemesEnum.sip, endPoint);
+ return true;
+ }
+ catch
+ {
+ error = $"Could not parse \"{destination}\" as a SIP URI or end point.";
+ return false;
+ }
+ }
+
+ private static bool HasTransportPrefix(string destination) =>
+ destination.StartsWith("udp:", StringComparison.OrdinalIgnoreCase) ||
+ destination.StartsWith("tcp:", StringComparison.OrdinalIgnoreCase) ||
+ destination.StartsWith("tls:", StringComparison.OrdinalIgnoreCase) ||
+ destination.StartsWith("ws:", StringComparison.OrdinalIgnoreCase) ||
+ destination.StartsWith("wss:", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/SIPSorcery.Cli/Commands/TerminalAudioScope.cs b/src/SIPSorcery.Cli/Commands/TerminalAudioScope.cs
new file mode 100644
index 000000000..7c06dc821
--- /dev/null
+++ b/src/SIPSorcery.Cli/Commands/TerminalAudioScope.cs
@@ -0,0 +1,213 @@
+//-----------------------------------------------------------------------------
+// Filename: TerminalAudioScope.cs
+//
+// Description: A single line terminal audio visualiser: a bank of Goertzel
+// filters renders a log-spaced frequency spectrum as Unicode block glyphs,
+// alongside an RMS level readout. The line is redrawn in place with a
+// carriage return, which works on every terminal without ANSI cursor
+// support, and is written to STDERR so it composes with any stdout payload
+// (JSON result or raw PCM).
+//
+// Example output, redrawn at ~10fps:
+// ♪ ▂▃▅█▇▅▃▂▁▁▂▁▁▁▁▁ -18 dBFS PCMU/8000 pkts 142
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 13 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using System.Text;
+
+namespace SIPSorcery.Cli.Commands;
+
+public sealed class TerminalAudioScope : IDisposable
+{
+ private const int BAND_COUNT = 16;
+ private const int WINDOW_SIZE = 256; // Analysis window in samples.
+ private const int RENDER_INTERVAL_MILLISECONDS = 100;
+ private const double MIN_BAND_FREQUENCY = 100.0;
+ private const double FLOOR_DB = -50.0; // Spectrum bar floor.
+
+ private static readonly char[] _glyphs = { '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█' };
+
+ private readonly object _lock = new();
+ private readonly short[] _window = new short[WINDOW_SIZE];
+ private readonly Func? _statusProvider;
+ private readonly bool _enabled;
+
+ private int _windowPosn;
+ private bool _windowFilled;
+ private int _sampleRate;
+ private double[]? _bandCoefficients;
+ private Timer? _renderTimer;
+ private int _lastLineLength;
+
+ public TerminalAudioScope(Func? statusProvider = null)
+ {
+ _statusProvider = statusProvider;
+
+ // The scope draws over itself with carriage returns, which only makes sense on an
+ // interactive terminal.
+ _enabled = !Console.IsErrorRedirected || Environment.GetEnvironmentVariable("SIPSORCERY_SCOPE_FORCE") == "1";
+
+ if (!_enabled)
+ {
+ Console.Error.WriteLine("The audio scope is disabled because stderr is redirected.");
+ return;
+ }
+
+ try
+ {
+ // The block glyphs need a Unicode capable output encoding (legacy code pages render
+ // them as question marks).
+ Console.OutputEncoding = Encoding.UTF8;
+ }
+ catch
+ {
+ // Best effort; modern terminals are UTF-8 by default.
+ }
+
+ _renderTimer = new Timer(_ => Render(), null, RENDER_INTERVAL_MILLISECONDS, RENDER_INTERVAL_MILLISECONDS);
+ }
+
+ ///
+ /// Feeds decoded mono PCM into the analysis window. The first call fixes the sample rate.
+ ///
+ public void Write(short[] pcm, int sampleRate)
+ {
+ if (!_enabled || pcm.Length == 0)
+ {
+ return;
+ }
+
+ lock (_lock)
+ {
+ if (_sampleRate == 0)
+ {
+ _sampleRate = sampleRate;
+ _bandCoefficients = CreateBandCoefficients(sampleRate);
+ }
+
+ foreach (short sample in pcm)
+ {
+ _window[_windowPosn] = sample;
+ _windowPosn = (_windowPosn + 1) % WINDOW_SIZE;
+ if (_windowPosn == 0)
+ {
+ _windowFilled = true;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Pre-computes the Goertzel coefficient for each log-spaced band centre frequency.
+ ///
+ private static double[] CreateBandCoefficients(int sampleRate)
+ {
+ double maxFrequency = sampleRate / 2.0 * 0.9;
+ var coefficients = new double[BAND_COUNT];
+
+ for (int band = 0; band < BAND_COUNT; band++)
+ {
+ // Log spacing from MIN_BAND_FREQUENCY to maxFrequency.
+ double fraction = band / (double)(BAND_COUNT - 1);
+ double frequency = MIN_BAND_FREQUENCY * Math.Pow(maxFrequency / MIN_BAND_FREQUENCY, fraction);
+ coefficients[band] = 2.0 * Math.Cos(2.0 * Math.PI * frequency / sampleRate);
+ }
+
+ return coefficients;
+ }
+
+ private void Render()
+ {
+ short[] snapshot;
+ double[]? coefficients;
+
+ lock (_lock)
+ {
+ if (!_windowFilled || _bandCoefficients == null)
+ {
+ return;
+ }
+
+ // Unroll the ring buffer into time order.
+ snapshot = new short[WINDOW_SIZE];
+ for (int i = 0; i < WINDOW_SIZE; i++)
+ {
+ snapshot[i] = _window[(_windowPosn + i) % WINDOW_SIZE];
+ }
+ coefficients = _bandCoefficients;
+ }
+
+ // Normalise to +/-1 with a Hann window, accumulating the RMS as we go.
+ var samples = new double[WINDOW_SIZE];
+ double sumSquares = 0;
+ for (int i = 0; i < WINDOW_SIZE; i++)
+ {
+ double normalised = snapshot[i] / 32768.0;
+ sumSquares += normalised * normalised;
+ double hann = 0.5 * (1.0 - Math.Cos(2.0 * Math.PI * i / (WINDOW_SIZE - 1)));
+ samples[i] = normalised * hann;
+ }
+
+ // Clamp the readout floor so digital silence shows as -60 rather than the log epsilon.
+ double rmsDb = Math.Max(-60.0, 20.0 * Math.Log10(Math.Sqrt(sumSquares / WINDOW_SIZE) + 1e-9));
+
+ var bars = new StringBuilder(BAND_COUNT);
+ foreach (double coefficient in coefficients)
+ {
+ bars.Append(_glyphs[GlyphLevel(GoertzelPower(samples, coefficient))]);
+ }
+
+ string status = _statusProvider?.Invoke() ?? string.Empty;
+ string line = $"♪ {bars} {rmsDb,4:0} dBFS {status}";
+
+ // Redraw in place, padding to wipe any longer previous line.
+ int pad = Math.Max(0, _lastLineLength - line.Length);
+ _lastLineLength = line.Length;
+ Console.Error.Write($"\r{line}{new string(' ', pad)}");
+ }
+
+ ///
+ /// Standard Goertzel power for one band over the windowed samples.
+ ///
+ private static double GoertzelPower(double[] samples, double coefficient)
+ {
+ double s1 = 0, s2 = 0;
+ foreach (double sample in samples)
+ {
+ double s0 = sample + coefficient * s1 - s2;
+ s2 = s1;
+ s1 = s0;
+ }
+ return s1 * s1 + s2 * s2 - coefficient * s1 * s2;
+ }
+
+ private static int GlyphLevel(double power)
+ {
+ // Normalise so a full scale sine in the band centre is ~0dB, then scale FLOOR_DB..0
+ // onto the eight glyphs.
+ double db = 10.0 * Math.Log10(power / (WINDOW_SIZE * WINDOW_SIZE / 16.0) + 1e-12);
+ double fraction = Math.Clamp((db - FLOOR_DB) / -FLOOR_DB, 0.0, 1.0);
+ return (int)Math.Round(fraction * (_glyphs.Length - 1));
+ }
+
+ public void Dispose()
+ {
+ if (_renderTimer != null)
+ {
+ _renderTimer.Dispose();
+ _renderTimer = null;
+
+ // Leave the last frame visible and move off the scope line so subsequent output
+ // starts cleanly.
+ Console.Error.WriteLine();
+ }
+ }
+}
diff --git a/src/SIPSorcery.Cli/Commands/VideoSink.cs b/src/SIPSorcery.Cli/Commands/VideoSink.cs
new file mode 100644
index 000000000..e4a7d99d3
--- /dev/null
+++ b/src/SIPSorcery.Cli/Commands/VideoSink.cs
@@ -0,0 +1,349 @@
+//-----------------------------------------------------------------------------
+// Filename: VideoSink.cs
+//
+// Description: Routes received, depacketised (still encoded) video frames to
+// one of three sinks, mirroring AudioSink:
+// - "play": a spawned ffplay window. Decode is delegated to ffplay so no
+// video decoder is needed in-process and both H264 and VP8 work.
+// - : a raw bitstream file (H264 Annex B, or VP8 wrapped in IVF).
+// - "-": the bitstream on stdout. The caller is responsible for routing
+// its result object to stderr in this mode. Enables arbitrary
+// downstream renderers, e.g. "| mpv --vo=tct -" for video in the
+// terminal.
+//
+// Container selection is by codec: H264 frames arrive as Annex B NAL units
+// and are written as-is; VP8 frames are wrapped in an IVF container, with
+// the dimensions parsed from the first key frame.
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 13 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+using SIPSorceryMedia.Abstractions;
+
+namespace SIPSorcery.Cli.Commands;
+
+public sealed class VideoSink : IDisposable
+{
+ private enum SinkMode
+ {
+ None,
+ File,
+ Stdout,
+ Play
+ }
+
+ private const int IVF_HEADER_LENGTH = 32;
+ private const uint IVF_TIMEBASE = 90000; // Matches the RTP video clock.
+
+ private readonly SinkMode _mode;
+ private readonly string? _filePath;
+ private readonly ILogger _logger;
+ private readonly object _lock = new();
+
+ private Stream? _out;
+ private FileStream? _file;
+ private Process? _ffplay;
+ private bool _failed;
+ private bool _disposed;
+ private bool _isVp8;
+ private bool _awaitingVp8KeyFrame;
+ private bool _awaitingH264Sps;
+ private uint _firstTimestamp;
+ private bool _hasFirstTimestamp;
+ private long _bytesWritten;
+ private int _framesWritten;
+
+ public bool IsActive => _mode != SinkMode.None;
+ public bool IsStdout => _mode == SinkMode.Stdout;
+ public long BytesWritten { get { lock (_lock) { return _bytesWritten; } } }
+ public int FramesWritten { get { lock (_lock) { return _framesWritten; } } }
+
+ private VideoSink(SinkMode mode, string? filePath, ILogger logger)
+ {
+ _mode = mode;
+ _filePath = filePath;
+ _logger = logger;
+ }
+
+ public static VideoSink Create(string? spec, ILogger logger, out string? error)
+ {
+ error = null;
+
+ if (string.IsNullOrWhiteSpace(spec))
+ {
+ return new VideoSink(SinkMode.None, null, logger);
+ }
+
+ if (spec == "-")
+ {
+ return new VideoSink(SinkMode.Stdout, null, logger);
+ }
+
+ if (spec.Equals("play", StringComparison.OrdinalIgnoreCase))
+ {
+ return new VideoSink(SinkMode.Play, null, logger);
+ }
+
+ return new VideoSink(SinkMode.File, spec, logger);
+ }
+
+ ///
+ /// Writes one depacketised video frame. The first frame fixes the codec and, for VP8,
+ /// writing is deferred until a key frame supplies the dimensions for the IVF header.
+ ///
+ public void WriteFrame(byte[] frame, uint rtpTimestamp, VideoFormat format)
+ {
+ if (_mode == SinkMode.None || _failed || frame.Length == 0)
+ {
+ return;
+ }
+
+ lock (_lock)
+ {
+ if (_disposed)
+ {
+ // Frames can still arrive between the sink closing and the peer connection
+ // closing; drop them silently.
+ return;
+ }
+
+ if (_out == null)
+ {
+ _isVp8 = format.Codec == VideoCodecsEnum.VP8;
+ _awaitingVp8KeyFrame = _isVp8;
+ _awaitingH264Sps = format.Codec == VideoCodecsEnum.H264;
+
+ if (!Init(format))
+ {
+ return;
+ }
+ }
+
+ try
+ {
+ if (_isVp8)
+ {
+ if (_awaitingVp8KeyFrame)
+ {
+ if (!TryParseVp8KeyFrameDimensions(frame, out ushort width, out ushort height))
+ {
+ return; // Inter frame before the first key frame; not decodable yet.
+ }
+
+ WriteIvfFileHeader(width, height);
+ _awaitingVp8KeyFrame = false;
+ }
+
+ if (!_hasFirstTimestamp)
+ {
+ _firstTimestamp = rtpTimestamp;
+ _hasFirstTimestamp = true;
+ }
+
+ WriteIvfFrameHeader(frame.Length, unchecked(rtpTimestamp - _firstTimestamp));
+ }
+ else if (_awaitingH264Sps)
+ {
+ if (!ContainsH264Sps(frame))
+ {
+ return; // Frames before the first SPS/PPS reference parameter sets the decoder hasn't seen.
+ }
+
+ _awaitingH264Sps = false;
+ }
+
+ _out!.Write(frame, 0, frame.Length);
+ _out.Flush();
+ _bytesWritten += frame.Length;
+ _framesWritten++;
+ }
+ catch (Exception excp)
+ {
+ _logger.LogWarning("Video sink write failed, no further video will be written: {Error}", excp.Message);
+ _failed = true;
+ }
+ }
+ }
+
+ private bool Init(VideoFormat format)
+ {
+ try
+ {
+ switch (_mode)
+ {
+ case SinkMode.File:
+ _file = new FileStream(_filePath!, FileMode.Create, FileAccess.ReadWrite);
+ _out = _file;
+ _logger.LogDebug("Writing received {Codec} video to {FilePath}.", format.Codec, _filePath);
+ return true;
+
+ case SinkMode.Stdout:
+ _out = Console.OpenStandardOutput();
+ Console.Error.WriteLine(_isVp8
+ ? "Writing VP8 in an IVF container to stdout."
+ : $"Writing raw {format.Codec} Annex B bitstream to stdout.");
+ return true;
+
+ case SinkMode.Play:
+ string demuxer = _isVp8 ? "ivf" : format.Codec.ToString().ToLowerInvariant();
+ var startInfo = new ProcessStartInfo("ffplay")
+ {
+ Arguments = $"-hide_banner -loglevel error -fflags nobuffer -f {demuxer} -i -",
+ UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardError = true
+ };
+
+ _ffplay = Process.Start(startInfo);
+ if (_ffplay == null)
+ {
+ throw new ApplicationException("ffplay did not start.");
+ }
+
+ _ = Task.Run(async () =>
+ {
+ string? line;
+ while ((line = await _ffplay.StandardError.ReadLineAsync().ConfigureAwait(false)) != null)
+ {
+ _logger.LogDebug("ffplay: {Line}", line);
+ }
+ });
+
+ _out = _ffplay.StandardInput.BaseStream;
+ Console.Error.WriteLine($"Rendering received {format.Codec} video with ffplay.");
+ return true;
+
+ default:
+ return false;
+ }
+ }
+ catch (Exception excp) when (_mode == SinkMode.Play)
+ {
+ _logger.LogError("Could not start ffplay: {Error}. Install ffmpeg (which includes ffplay) and ensure it is on the PATH.", excp.Message);
+ _failed = true;
+ return false;
+ }
+ catch (Exception excp)
+ {
+ _logger.LogError("Could not initialise the video sink: {Error}", excp.Message);
+ _failed = true;
+ return false;
+ }
+ }
+
+ ///
+ /// A VP8 key frame starts with a frame tag whose low bit is 0, followed by the
+ /// 9D 01 2A start code and 14 bit width and height fields (RFC 6386 section 9.1).
+ ///
+ private static bool TryParseVp8KeyFrameDimensions(byte[] frame, out ushort width, out ushort height)
+ {
+ width = 0;
+ height = 0;
+
+ if (frame.Length < 10 || (frame[0] & 0x01) != 0 || frame[3] != 0x9D || frame[4] != 0x01 || frame[5] != 0x2A)
+ {
+ return false;
+ }
+
+ width = (ushort)((frame[6] | (frame[7] << 8)) & 0x3FFF);
+ height = (ushort)((frame[8] | (frame[9] << 8)) & 0x3FFF);
+ return width > 0 && height > 0;
+ }
+
+ ///
+ /// Scans an Annex B frame for an SPS NAL unit (type 7). Encoders send SPS/PPS in-band
+ /// with each key frame, so the first SPS marks the first decodable point in the stream.
+ ///
+ private static bool ContainsH264Sps(byte[] frame)
+ {
+ for (int i = 0; i + 3 < frame.Length; i++)
+ {
+ if (frame[i] == 0x00 && frame[i + 1] == 0x00 &&
+ (frame[i + 2] == 0x01 || (frame[i + 2] == 0x00 && i + 4 < frame.Length && frame[i + 3] == 0x01)))
+ {
+ int nalStart = frame[i + 2] == 0x01 ? i + 3 : i + 4;
+ if ((frame[nalStart] & 0x1F) == 7)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private void WriteIvfFileHeader(ushort width, ushort height)
+ {
+ using var writer = new BinaryWriter(_out!, System.Text.Encoding.ASCII, leaveOpen: true);
+ writer.Write("DKIF"u8);
+ writer.Write((ushort)0); // Version.
+ writer.Write((ushort)IVF_HEADER_LENGTH);
+ writer.Write("VP80"u8);
+ writer.Write(width);
+ writer.Write(height);
+ writer.Write(IVF_TIMEBASE); // Timebase denominator.
+ writer.Write(1u); // Timebase numerator.
+ writer.Write(0u); // Frame count, patched on close for files.
+ writer.Write(0u); // Unused.
+ }
+
+ private void WriteIvfFrameHeader(int frameLength, uint pts)
+ {
+ using var writer = new BinaryWriter(_out!, System.Text.Encoding.ASCII, leaveOpen: true);
+ writer.Write((uint)frameLength);
+ writer.Write((ulong)pts);
+ }
+
+ public void Dispose()
+ {
+ lock (_lock)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+ _disposed = true;
+
+ try
+ {
+ if (_file != null)
+ {
+ if (_isVp8 && _framesWritten > 0)
+ {
+ // Patch the IVF frame count.
+ _file.Seek(24, SeekOrigin.Begin);
+ using var writer = new BinaryWriter(_file, System.Text.Encoding.ASCII, leaveOpen: true);
+ writer.Write((uint)_framesWritten);
+ }
+ _file.Dispose();
+ }
+ else if (_ffplay != null)
+ {
+ _ffplay.StandardInput.Close();
+ if (!_ffplay.WaitForExit(2000))
+ {
+ _ffplay.Kill();
+ }
+ _ffplay.Dispose();
+ }
+ else
+ {
+ _out?.Flush();
+ }
+ }
+ catch (Exception excp)
+ {
+ _logger.LogDebug("Video sink close error: {Error}", excp.Message);
+ }
+ }
+ }
+}
diff --git a/src/SIPSorcery.Cli/Commands/ice/IceProbeCommand.cs b/src/SIPSorcery.Cli/Commands/ice/IceProbeCommand.cs
new file mode 100644
index 000000000..ae28c6faa
--- /dev/null
+++ b/src/SIPSorcery.Cli/Commands/ice/IceProbeCommand.cs
@@ -0,0 +1,243 @@
+//-----------------------------------------------------------------------------
+// Filename: IceProbeCommand.cs
+//
+// Description: The "sipsorcery ice probe" verb. Runs ICE candidate gathering,
+// optionally against STUN and TURN servers, and reports the candidates
+// obtained. Answers "what connectivity paths does this machine have" and
+// doubles as a STUN/TURN server health check: if a requested server type
+// yields no candidate the probe fails.
+//
+// Note this verb deliberately stops at the ICE gathering stage. No SDP is
+// exchanged and no DTLS handshake is attempted, which is why it lives under
+// the "ice" noun rather than "webrtc".
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 12 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using System.CommandLine;
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+using SIPSorcery.Net;
+
+namespace SIPSorcery.Cli.Commands;
+
+public sealed class IceProbeCommand : CommandBase
+{
+ private const int DEFAULT_TIMEOUT_SECONDS = 10;
+
+ private sealed record CandidateResult(
+ string Type,
+ string Protocol,
+ string Address,
+ ushort Port,
+ string? RelatedAddress,
+ ushort? RelatedPort);
+
+ ///
+ /// The result shape written to stdout with --json. Stable field names; additive changes only.
+ ///
+ private sealed record ProbeResult(
+ bool Success,
+ string GatheringState,
+ long DurationMs,
+ IReadOnlyList Candidates,
+ string? Error);
+
+ public IceProbeCommand() : base(DEFAULT_TIMEOUT_SECONDS)
+ { }
+
+ public override Command Build()
+ {
+ var stunOption = new Option("--stun")
+ {
+ Description = "A STUN server to gather server reflexive candidates from, format stun:host[:port]. May be specified multiple times.",
+ Arity = ArgumentArity.ZeroOrMore
+ };
+
+ var turnOption = new Option("--turn")
+ {
+ Description = "A TURN server to gather relay candidates from, format \"turn:host[:port];username;password\". May be specified multiple times.",
+ Arity = ArgumentArity.ZeroOrMore
+ };
+
+ var relayOnlyOption = new Option("--relay-only")
+ {
+ Description = "Only gather relay candidates (sets the ICE transport policy to relay)."
+ };
+
+ var command = new Command("probe", "Gather ICE candidates and report them. Fails if a requested STUN/TURN server produces no candidate.");
+ command.Options.Add(stunOption);
+ command.Options.Add(turnOption);
+ command.Options.Add(relayOnlyOption);
+ AddCommonOptions(command);
+
+ command.SetAction((parseResult, cancellationToken) => RunAsync(
+ parseResult.GetValue(stunOption) ?? [],
+ parseResult.GetValue(turnOption) ?? [],
+ parseResult.GetValue(relayOnlyOption),
+ parseResult.GetValue(TimeoutOption),
+ parseResult.GetValue(JsonOption),
+ parseResult.GetValue(VerboseOption),
+ cancellationToken));
+
+ return command;
+ }
+
+ private static async Task RunAsync(string[] stunServers, string[] turnServers, bool relayOnly,
+ int timeoutSeconds, bool asJson, bool verbose, CancellationToken ct)
+ {
+ using var loggerFactory = InitLogging(verbose);
+
+ var iceServers = new List();
+
+ foreach (var stun in stunServers)
+ {
+ iceServers.Add(new RTCIceServer { urls = stun });
+ }
+
+ foreach (var turn in turnServers)
+ {
+ // Format matches the webrtccmdline example: url;username;password.
+ string[] fields = turn.Split(';');
+ if (fields.Length != 3 || string.IsNullOrWhiteSpace(fields[0]))
+ {
+ return WriteResult(asJson,
+ new ProbeResult(false, RTCIceGatheringState.@new.ToString(), 0, [],
+ $"Could not parse TURN server \"{turn}\". Expected format \"turn:host[:port];username;password\"."),
+ ExitCodes.InvalidArgument);
+ }
+
+ iceServers.Add(new RTCIceServer
+ {
+ urls = fields[0],
+ username = fields[1],
+ credential = fields[2],
+ credentialType = RTCIceCredentialType.password
+ });
+ }
+
+ if (relayOnly && turnServers.Length == 0)
+ {
+ return WriteResult(asJson,
+ new ProbeResult(false, RTCIceGatheringState.@new.ToString(), 0, [],
+ "--relay-only requires at least one --turn server."),
+ ExitCodes.InvalidArgument);
+ }
+
+ var iceChannel = new RtpIceChannel(
+ null,
+ RTCIceComponent.rtp,
+ iceServers.Count > 0 ? iceServers : null,
+ relayOnly ? RTCIceTransportPolicy.relay : RTCIceTransportPolicy.all,
+ includeAllInterfaceAddresses: true);
+
+ try
+ {
+ var gatheringComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ iceChannel.OnIceGatheringStateChange += (state) =>
+ {
+ if (state == RTCIceGatheringState.complete)
+ {
+ gatheringComplete.TrySetResult(true);
+ }
+ };
+
+ iceChannel.OnIceCandidateError += (candidate, error) =>
+ loggerFactory.CreateLogger(nameof(IceProbeCommand)).LogWarning("ICE candidate error: {Error}", error);
+
+ var stopwatch = Stopwatch.StartNew();
+
+ iceChannel.StartGathering();
+
+ var completed = await Task.WhenAny(gatheringComplete.Task, Task.Delay(TimeSpan.FromSeconds(timeoutSeconds), ct)).ConfigureAwait(false);
+
+ stopwatch.Stop();
+
+ var candidates = iceChannel.Candidates
+ .Select(x => new CandidateResult(
+ x.type.ToString(),
+ x.protocol.ToString(),
+ x.address,
+ x.port,
+ string.IsNullOrWhiteSpace(x.relatedAddress) ? null : x.relatedAddress,
+ x.relatedPort > 0 ? x.relatedPort : null))
+ .ToList();
+
+ if (completed != gatheringComplete.Task)
+ {
+ return WriteResult(asJson,
+ new ProbeResult(false, iceChannel.IceGatheringState.ToString(), stopwatch.ElapsedMilliseconds, candidates,
+ ct.IsCancellationRequested ? "Cancelled." : $"Gathering did not complete within {timeoutSeconds}s."),
+ ExitCodes.Timeout);
+ }
+
+ // The probe contract: each requested capability must have produced a candidate.
+ string? error = null;
+ if (stunServers.Length > 0 && !candidates.Any(x => x.Type == RTCIceCandidateType.srflx.ToString()))
+ {
+ error = "No server reflexive candidate was obtained from the STUN server(s).";
+ }
+ else if (turnServers.Length > 0 && !candidates.Any(x => x.Type == RTCIceCandidateType.relay.ToString()))
+ {
+ error = "No relay candidate was obtained from the TURN server(s).";
+ }
+ else if (candidates.Count == 0)
+ {
+ error = "No candidates were gathered.";
+ }
+
+ return WriteResult(asJson,
+ new ProbeResult(error == null, iceChannel.IceGatheringState.ToString(), stopwatch.ElapsedMilliseconds, candidates, error),
+ error == null ? ExitCodes.Ok : ExitCodes.Failed);
+ }
+ catch (Exception excp)
+ {
+ return WriteResult(asJson,
+ new ProbeResult(false, iceChannel.IceGatheringState.ToString(), 0, [], excp.Message),
+ ExitCodes.TransportError);
+ }
+ finally
+ {
+ iceChannel.Close("probe complete");
+ }
+ }
+
+ private static int WriteResult(bool asJson, ProbeResult result, int exitCode)
+ {
+ if (asJson)
+ {
+ WriteJson(result);
+ }
+ else
+ {
+ foreach (var candidate in result.Candidates)
+ {
+ string related = candidate.RelatedAddress != null ? $" (related {candidate.RelatedAddress}:{candidate.RelatedPort})" : string.Empty;
+ Console.WriteLine($"{candidate.Type,-6} {candidate.Protocol} {candidate.Address}:{candidate.Port}{related}");
+ }
+
+ int host = result.Candidates.Count(x => x.Type == "host");
+ int srflx = result.Candidates.Count(x => x.Type == "srflx");
+ int relay = result.Candidates.Count(x => x.Type == "relay");
+
+ if (result.Success)
+ {
+ Console.WriteLine($"Gathering complete in {result.DurationMs}ms: {result.Candidates.Count} candidates ({host} host, {srflx} srflx, {relay} relay).");
+ }
+ else
+ {
+ Console.Error.WriteLine($"ICE probe failed after {result.DurationMs}ms ({host} host, {srflx} srflx, {relay} relay): {result.Error}");
+ }
+ }
+
+ return exitCode;
+ }
+}
diff --git a/src/SIPSorcery.Cli/Commands/sip/HepCapture.cs b/src/SIPSorcery.Cli/Commands/sip/HepCapture.cs
new file mode 100644
index 000000000..51ae2eec4
--- /dev/null
+++ b/src/SIPSorcery.Cli/Commands/sip/HepCapture.cs
@@ -0,0 +1,158 @@
+//-----------------------------------------------------------------------------
+// Filename: HepCapture.cs
+//
+// Description: Mirrors all SIP traffic on a SIPTransport to a HEPv3 (Homer
+// Encapsulation Protocol) capture server such as HOMER or heplify-server
+// (sipcapture.org), which renders the requests and responses as SIP call
+// ladder diagrams. Each SIP message in or out is duplicated into a HEP
+// packet, stamped with the original source and destination end points, and
+// fired at the capture server over UDP. Capture is passive: failures are
+// logged at debug level and never affect the SIP operation being mirrored.
+//
+// The --hep option value follows the same packed form as --turn:
+// host[:port][;password[;agentId]]
+// e.g. --hep homer.example.com or --hep "192.168.0.10:9060;myHep;42".
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 13 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using System.CommandLine;
+using System.Net;
+using System.Net.Sockets;
+using Microsoft.Extensions.Logging;
+using SIPSorcery.Net;
+using SIPSorcery.SIP;
+
+namespace SIPSorcery.Cli.Commands;
+
+public sealed class HepCapture : IDisposable
+{
+ private const int DEFAULT_PORT = 9060; // Standard HOMER/heplify-server HEP listen port.
+ private const uint DEFAULT_AGENT_ID = 4242; // Arbitrary; identifies this CLI to the capture server.
+
+ private readonly UdpClient _client;
+ private readonly IPEndPoint _server;
+ private readonly uint _agentId;
+ private readonly string? _password;
+ private readonly ILogger _logger;
+
+ private int _packetsMirrored;
+
+ public int PacketsMirrored => _packetsMirrored;
+
+ private HepCapture(IPEndPoint server, uint agentId, string? password, ILogger logger)
+ {
+ _server = server;
+ _agentId = agentId;
+ _password = password;
+ _logger = logger;
+ _client = new UdpClient(0, server.AddressFamily);
+ }
+
+ public static Option CreateOption() => new("--hep")
+ {
+ Description = "Mirror the SIP traffic to a HEPv3 capture server (HOMER/heplify-server) so the call shows " +
+ "up as a ladder diagram, as host[:port][;password[;agentId]]. The port defaults to 9060. " +
+ "HOMER's default capture password is \"myHep\"; omit it if the server does not use one."
+ };
+
+ ///
+ /// Parses the --hep option value and creates the capture sender. Returns null with no error
+ /// when no value was supplied, and null with an error message when the value is invalid.
+ ///
+ public static HepCapture? Create(string? spec, ILogger logger, out string? error)
+ {
+ error = null;
+
+ if (string.IsNullOrWhiteSpace(spec))
+ {
+ return null;
+ }
+
+ string[] fields = spec.Split(';');
+
+ string host = fields[0];
+ int port = DEFAULT_PORT;
+ int hostPortSep = host.LastIndexOf(':');
+ if (hostPortSep != -1 && host.IndexOf(':') == hostPortSep) // A single colon; bare IPv6 has many.
+ {
+ if (!int.TryParse(host[(hostPortSep + 1)..], out port) || port <= 0 || port > ushort.MaxValue)
+ {
+ error = $"Could not parse a port from the --hep value \"{spec}\".";
+ return null;
+ }
+ host = host[..hostPortSep];
+ }
+
+ string? password = fields.Length > 1 && fields[1].Length > 0 ? fields[1] : null;
+
+ uint agentId = DEFAULT_AGENT_ID;
+ if (fields.Length > 2 && !uint.TryParse(fields[2], out agentId))
+ {
+ error = $"Could not parse a numeric agent ID from the --hep value \"{spec}\".";
+ return null;
+ }
+
+ IPAddress? address;
+ try
+ {
+ address = IPAddress.TryParse(host, out var literal)
+ ? literal
+ : Dns.GetHostAddresses(host).OrderBy(a => a.AddressFamily == AddressFamily.InterNetwork ? 0 : 1).FirstOrDefault();
+ }
+ catch (SocketException)
+ {
+ address = null;
+ }
+
+ if (address == null)
+ {
+ error = $"Could not resolve the HEP capture server host \"{host}\".";
+ return null;
+ }
+
+ return new HepCapture(new IPEndPoint(address, port), agentId, password, logger);
+ }
+
+ ///
+ /// Hooks the transport's trace events so every SIP message in or out is mirrored. The HEP
+ /// source/destination are the original SIP end points, which is what lets the capture
+ /// server reconstruct the ladder.
+ ///
+ public void Attach(SIPTransport transport)
+ {
+ transport.SIPRequestOutTraceEvent += (localEP, remoteEP, req) => Send(localEP, remoteEP, req.ToString());
+ transport.SIPRequestInTraceEvent += (localEP, remoteEP, req) => Send(remoteEP, localEP, req.ToString());
+ transport.SIPResponseOutTraceEvent += (localEP, remoteEP, resp) => Send(localEP, remoteEP, resp.ToString());
+ transport.SIPResponseInTraceEvent += (localEP, remoteEP, resp) => Send(remoteEP, localEP, resp.ToString());
+
+ Console.Error.WriteLine($"Mirroring SIP traffic to HEP capture server udp:{_server} (agent ID {_agentId}).");
+ }
+
+ private void Send(SIPEndPoint srcEndPoint, SIPEndPoint dstEndPoint, string payload)
+ {
+ try
+ {
+ var buffer = HepPacket.GetBytes(srcEndPoint, dstEndPoint, DateTime.Now, _agentId, _password, payload);
+ _client.Send(buffer, buffer.Length, _server);
+ Interlocked.Increment(ref _packetsMirrored);
+ }
+ catch (Exception excp)
+ {
+ _logger.LogDebug("HEP capture send failed: {Error}", excp.Message);
+ }
+ }
+
+ public void Dispose()
+ {
+ _logger.LogDebug("HEP capture mirrored {PacketsMirrored} SIP messages to {Server}.", _packetsMirrored, _server);
+ _client.Dispose();
+ }
+}
diff --git a/src/SIPSorcery.Cli/Commands/sip/SipCallCommand.cs b/src/SIPSorcery.Cli/Commands/sip/SipCallCommand.cs
new file mode 100644
index 000000000..0b9940ccf
--- /dev/null
+++ b/src/SIPSorcery.Cli/Commands/sip/SipCallCommand.cs
@@ -0,0 +1,432 @@
+//-----------------------------------------------------------------------------
+// Filename: SipCallCommand.cs
+//
+// Description: The "sipsorcery sip call" verb. Places a SIP call, sends a
+// device-less audio source (music, tone, silence or a file) and reports on
+// the media received in return.
+//
+// Received audio can be routed three ways via --audio, following the rule
+// that stdout carries exactly one payload:
+// - play: spawn ffplay and render to the speakers; stdout untouched.
+// - write a WAV file; stdout untouched.
+// - "-" raw s16le PCM on stdout; the result object moves to stderr.
+//
+// In addition --scope renders a live frequency spectrum and level meter for
+// the received audio as a single self-redrawing line on stderr. Because the
+// scope is display (stderr) rather than payload (stdout) it is independent
+// of --audio and the two compose freely, e.g. listen AND watch with:
+//
+// sipsorcery sip call music@iptel.org --audio play --scope
+//
+// No audio devices are used, so the verb behaves identically on every OS.
+// For microphone input pipe PCM in via external tools (ffmpeg/sox), planned
+// as --play -.
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 12 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using System.CommandLine;
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+using SIPSorcery.Media;
+using SIPSorcery.Net;
+using SIPSorcery.SIP;
+using SIPSorcery.SIP.App;
+using SIPSorceryMedia.Abstractions;
+
+namespace SIPSorcery.Cli.Commands;
+
+public sealed class SipCallCommand : CommandBase
+{
+ private const int DEFAULT_RING_TIMEOUT_SECONDS = 30;
+ private const int DEFAULT_CALL_DURATION_SECONDS = 10;
+ private const int DTMF_INTER_TONE_GAP_MILLISECONDS = 250;
+
+ ///
+ /// The result shape written with --json. Stable field names; additive changes only. Written
+ /// to stdout unless the received audio has claimed stdout (--audio -), in which case the
+ /// result moves to stderr.
+ ///
+ private sealed record CallResult(
+ bool Success,
+ string Destination,
+ bool Answered,
+ int? StatusCode,
+ string? Codec,
+ long? ConnectTimeMs,
+ long? CallDurationMs,
+ int AudioPackets,
+ long AudioLost,
+ int AudioOutOfOrder,
+ int AudioDuplicates,
+ long? AudioBytesWritten,
+ string? Error);
+
+ public SipCallCommand() : base(DEFAULT_RING_TIMEOUT_SECONDS)
+ { }
+
+ public override Command Build()
+ {
+ var destinationArg = new Argument("destination")
+ {
+ Description = "The SIP destination in the form [sip:|sips:|udp:|tcp:|tls:][user@]host[:port][;transport=x], " +
+ "e.g. music@iptel.org."
+ };
+
+ var playOption = new Option("--play")
+ {
+ Description = "The audio to send: music, tone, silence, or the path to a 8/16KHz 16 bit mono PCM WAV file.",
+ DefaultValueFactory = _ => "music"
+ };
+
+ var audioOption = new Option("--audio")
+ {
+ Description = "Where to send the received audio: \"play\" to render with ffplay, a .wav file path, " +
+ "or \"-\" for raw s16le PCM on stdout (the result then moves to stderr)."
+ };
+
+ var dtmfOption = new Option("--send-dtmf")
+ {
+ Description = "DTMF digits (0-9, * and #) to send once the call is answered."
+ };
+
+ var usernameOption = new Option("--username", "-u")
+ {
+ Description = "Optional username for authenticating the call."
+ };
+
+ var passwordOption = new Option("--password")
+ {
+ Description = "Optional password for authenticating the call."
+ };
+
+ var durationOption = new Option("--duration", "-d")
+ {
+ Description = "The number of seconds to stay on the call after it is answered. The call also ends if the remote party hangs up.",
+ DefaultValueFactory = _ => DEFAULT_CALL_DURATION_SECONDS
+ };
+
+ var scopeOption = new Option("--scope")
+ {
+ Description = "Render a live frequency spectrum and level meter for the received audio on stderr. " +
+ "Composes with any --audio mode."
+ };
+
+ var hepOption = HepCapture.CreateOption();
+
+ var command = new Command("call", "Place a SIP call, send a test audio source and report on the media received.");
+ command.Arguments.Add(destinationArg);
+ command.Options.Add(playOption);
+ command.Options.Add(audioOption);
+ command.Options.Add(scopeOption);
+ command.Options.Add(dtmfOption);
+ command.Options.Add(usernameOption);
+ command.Options.Add(passwordOption);
+ command.Options.Add(durationOption);
+ command.Options.Add(hepOption);
+ AddCommonOptions(command);
+
+ command.SetAction((parseResult, cancellationToken) => RunAsync(
+ parseResult.GetValue(destinationArg)!,
+ parseResult.GetValue(playOption)!,
+ parseResult.GetValue(audioOption),
+ parseResult.GetValue(scopeOption),
+ parseResult.GetValue(dtmfOption),
+ parseResult.GetValue(usernameOption),
+ parseResult.GetValue(passwordOption),
+ parseResult.GetValue(durationOption),
+ parseResult.GetValue(hepOption),
+ parseResult.GetValue(TimeoutOption),
+ parseResult.GetValue(JsonOption),
+ parseResult.GetValue(VerboseOption),
+ cancellationToken));
+
+ return command;
+ }
+
+ private static async Task RunAsync(string destination, string play, string? audioOut, bool showScope, string? dtmf,
+ string? username, string? password, int durationSeconds, string? hep, int ringTimeoutSeconds, bool asJson, bool verbose,
+ CancellationToken ct)
+ {
+ using var loggerFactory = InitLogging(verbose);
+ var logger = loggerFactory.CreateLogger(nameof(SipCallCommand));
+
+ using var audioSink = AudioSink.Create(audioOut, logger, out string? sinkError);
+
+ if (sinkError != null)
+ {
+ return WriteResult(asJson, audioSink,
+ new CallResult(false, destination, false, null, null, null, null, 0, 0, 0, 0, null, sinkError),
+ ExitCodes.InvalidArgument);
+ }
+
+ if (!SipDestination.TryParse(destination, out var dstUri, out var parseError))
+ {
+ return WriteResult(asJson, audioSink,
+ new CallResult(false, destination, false, null, null, null, null, 0, 0, 0, 0, null, parseError),
+ ExitCodes.InvalidArgument);
+ }
+
+ if (!TryGetAudioSource(play, out var sourceOptions, out byte[]? filePcm, out var fileRate, out var playError))
+ {
+ return WriteResult(asJson, audioSink,
+ new CallResult(false, dstUri.ToString(), false, null, null, null, null, 0, 0, 0, 0, null, playError),
+ ExitCodes.InvalidArgument);
+ }
+
+ using var hepCapture = HepCapture.Create(hep, logger, out string? hepError);
+
+ if (hepError != null)
+ {
+ return WriteResult(asJson, audioSink,
+ new CallResult(false, dstUri.ToString(), false, null, null, null, null, 0, 0, 0, 0, null, hepError),
+ ExitCodes.InvalidArgument);
+ }
+
+ var sipTransport = new SIPTransport();
+ hepCapture?.Attach(sipTransport);
+
+ if (verbose)
+ {
+ sipTransport.EnableTraceLogs();
+ }
+
+ var mediaSession = new VoIPMediaSession();
+ mediaSession.AcceptRtpFromAny = true;
+ mediaSession.AudioExtrasSource.SetSource(sourceOptions);
+
+ using var audioDecoder = new AudioEncoder();
+ AudioFormat negotiatedFormat = AudioFormat.Empty;
+ mediaSession.OnAudioFormatsNegotiated += (formats) => negotiatedFormat = formats.First();
+
+ var audioStats = new RtpStreamStats();
+ var dummyVideoStats = new RtpStreamStats();
+ var recordPacket = RtpStreamStats.CreateRtpHandler(audioStats, dummyVideoStats, logger);
+
+ using var scope = showScope
+ ? new TerminalAudioScope(() => $"{(negotiatedFormat.IsEmpty() ? "?" : $"{negotiatedFormat.Codec}/{negotiatedFormat.ClockRate}")} pkts {audioStats.Packets}")
+ : null;
+
+ mediaSession.OnRtpPacketReceived += (remoteEndPoint, mediaType, rtpPacket) =>
+ {
+ recordPacket(remoteEndPoint, mediaType, rtpPacket);
+
+ if (mediaType == SDPMediaTypesEnum.audio && (audioSink.IsActive || scope != null) && !negotiatedFormat.IsEmpty())
+ {
+ try
+ {
+ var pcm = audioDecoder.DecodeAudio(rtpPacket.GetPayloadBytes(), negotiatedFormat);
+ audioSink.Write(pcm, negotiatedFormat.ClockRate);
+ scope?.Write(pcm, negotiatedFormat.ClockRate);
+ }
+ catch (Exception excp)
+ {
+ logger.LogWarning("Failed to decode received audio packet: {Error}", excp.Message);
+ }
+ }
+ };
+
+ var ua = new SIPUserAgent(sipTransport, null);
+ SIPResponse? failureResponse = null;
+ var remoteHungup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ ua.ClientCallTrying += (uac, resp) => Console.Error.WriteLine($"Trying: {resp.StatusCode} {resp.ReasonPhrase}.");
+ ua.ClientCallRinging += (uac, resp) => Console.Error.WriteLine($"Ringing: {resp.StatusCode} {resp.ReasonPhrase}.");
+ ua.ClientCallFailed += (uac, error, resp) =>
+ {
+ failureResponse = resp;
+ Console.Error.WriteLine($"Call failed: {error}.");
+ };
+ ua.OnCallHungup += (dialog) =>
+ {
+ Console.Error.WriteLine("Remote party hung up.");
+ remoteHungup.TrySetResult(true);
+ };
+
+ try
+ {
+ Console.Error.WriteLine($"Calling {dstUri} ...");
+
+ var stopwatch = Stopwatch.StartNew();
+
+ bool answered = await ua.Call(dstUri.ToString(), username, password, mediaSession, ringTimeoutSeconds).ConfigureAwait(false);
+
+ long connectTimeMs = stopwatch.ElapsedMilliseconds;
+
+ if (!answered)
+ {
+ int? statusCode = failureResponse != null ? (int)failureResponse.Status : null;
+ return WriteResult(asJson, audioSink,
+ new CallResult(false, dstUri.ToString(), false, statusCode, null, connectTimeMs, null, 0, 0, 0, 0, null,
+ statusCode != null
+ ? $"The call was not answered: {statusCode} {failureResponse!.ReasonPhrase}."
+ : $"The call was not answered within {ringTimeoutSeconds}s."),
+ statusCode != null ? ExitCodes.Failed : ExitCodes.Timeout);
+ }
+
+ Console.Error.WriteLine($"Answered in {connectTimeMs}ms. Staying on the call for up to {durationSeconds}s.");
+
+ // If a WAV file was supplied for the send audio, stream it now (interrupts the
+ // configured source for its duration).
+ if (filePcm != null)
+ {
+ _ = mediaSession.AudioExtrasSource.SendAudioFromStream(new MemoryStream(filePcm), fileRate);
+ }
+
+ if (!string.IsNullOrWhiteSpace(dtmf))
+ {
+ foreach (char digit in dtmf)
+ {
+ if (TryGetDtmfByte(digit, out byte tone))
+ {
+ logger.LogDebug("Sending DTMF tone {Digit}.", digit);
+ await ua.SendDtmf(tone).ConfigureAwait(false);
+ await Task.Delay(DTMF_INTER_TONE_GAP_MILLISECONDS, ct).ConfigureAwait(false);
+ }
+ else
+ {
+ logger.LogWarning("Ignoring invalid DTMF digit '{Digit}'.", digit);
+ }
+ }
+ }
+
+ var callWindow = Stopwatch.StartNew();
+ await Task.WhenAny(Task.Delay(TimeSpan.FromSeconds(durationSeconds), ct), remoteHungup.Task).ConfigureAwait(false);
+ callWindow.Stop();
+
+ if (ua.IsCallActive)
+ {
+ ua.Hangup();
+ // Give the BYE a moment to be transmitted.
+ await Task.Delay(500, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ bool gotMedia = audioStats.Packets > 0;
+
+ return WriteResult(asJson, audioSink,
+ new CallResult(gotMedia, dstUri.ToString(), true, (int)SIPResponseStatusCodesEnum.Ok,
+ negotiatedFormat.IsEmpty() ? null : $"{negotiatedFormat.Codec}/{negotiatedFormat.ClockRate}",
+ connectTimeMs, callWindow.ElapsedMilliseconds,
+ audioStats.Packets, audioStats.Lost, audioStats.OutOfOrder, audioStats.Duplicates,
+ audioSink.IsActive ? audioSink.BytesWritten : null,
+ gotMedia ? null : "The call was answered but no audio was received."),
+ gotMedia ? ExitCodes.Ok : ExitCodes.Failed);
+ }
+ catch (OperationCanceledException)
+ {
+ return WriteResult(asJson, audioSink,
+ new CallResult(false, dstUri.ToString(), false, null, null, null, null,
+ audioStats.Packets, audioStats.Lost, audioStats.OutOfOrder, audioStats.Duplicates, null, "Cancelled."),
+ ExitCodes.Timeout);
+ }
+ catch (Exception excp)
+ {
+ return WriteResult(asJson, audioSink,
+ new CallResult(false, dstUri.ToString(), false, null, null, null, null,
+ audioStats.Packets, audioStats.Lost, audioStats.OutOfOrder, audioStats.Duplicates, null, excp.Message),
+ ExitCodes.TransportError);
+ }
+ finally
+ {
+ sipTransport.Shutdown();
+ }
+ }
+
+ private static bool TryGetAudioSource(string play, out AudioSourceOptions options, out byte[]? filePcm,
+ out AudioSamplingRatesEnum fileRate, out string? error)
+ {
+ options = new AudioSourceOptions { AudioSource = AudioSourcesEnum.Music };
+ filePcm = null;
+ fileRate = AudioSamplingRatesEnum.Rate8KHz;
+ error = null;
+
+ switch (play.ToLowerInvariant())
+ {
+ case "music":
+ return true;
+ case "tone":
+ options.AudioSource = AudioSourcesEnum.SineWave;
+ return true;
+ case "silence":
+ options.AudioSource = AudioSourcesEnum.Silence;
+ return true;
+ default:
+ if (!File.Exists(play))
+ {
+ error = $"The --play value \"{play}\" is not music, tone, silence or an existing file.";
+ return false;
+ }
+
+ if (!WavFile.TryReadPcm(play, out filePcm, out int sampleRate, out string? wavError))
+ {
+ error = wavError;
+ return false;
+ }
+
+ fileRate = sampleRate == 16000 ? AudioSamplingRatesEnum.Rate16KHz : AudioSamplingRatesEnum.Rate8KHz;
+ options.AudioSource = AudioSourcesEnum.Silence;
+ return true;
+ }
+ }
+
+ private static bool TryGetDtmfByte(char digit, out byte tone)
+ {
+ switch (digit)
+ {
+ case >= '0' and <= '9':
+ tone = (byte)(digit - '0');
+ return true;
+ case '*':
+ tone = 10;
+ return true;
+ case '#':
+ tone = 11;
+ return true;
+ default:
+ tone = 0;
+ return false;
+ }
+ }
+
+ private static int WriteResult(bool asJson, AudioSink audioSink, CallResult result, int exitCode)
+ {
+ // The stdout payload rule: when the received audio has claimed stdout, the result is
+ // commentary and moves to stderr.
+ var output = audioSink.IsStdout ? Console.Error : Console.Out;
+
+ if (asJson)
+ {
+ output.WriteLine(SerializeResult(result));
+ }
+ else if (result.Success)
+ {
+ string sink = result.AudioBytesWritten != null ? $", {result.AudioBytesWritten} bytes of audio written" : string.Empty;
+ output.WriteLine($"Call to {result.Destination} answered in {result.ConnectTimeMs}ms, codec {result.Codec}. " +
+ $"Received {result.AudioPackets} audio packets ({FormatAnomalies(result.AudioLost, result.AudioOutOfOrder, result.AudioDuplicates)}) " +
+ $"in {result.CallDurationMs}ms{sink}.");
+ }
+ else
+ {
+ Console.Error.WriteLine($"Call to {result.Destination} failed: {result.Error}");
+ }
+
+ return exitCode;
+ }
+
+ private static string FormatAnomalies(long lost, int outOfOrder, int duplicates)
+ {
+ if (lost == 0 && outOfOrder == 0 && duplicates == 0)
+ {
+ return "clean";
+ }
+
+ return $"{lost} lost, {outOfOrder} reordered, {duplicates} duplicate";
+ }
+}
diff --git a/src/SIPSorcery.Cli/Commands/sip/SipOptionsCommand.cs b/src/SIPSorcery.Cli/Commands/sip/SipOptionsCommand.cs
new file mode 100644
index 000000000..81135c3f9
--- /dev/null
+++ b/src/SIPSorcery.Cli/Commands/sip/SipOptionsCommand.cs
@@ -0,0 +1,189 @@
+//-----------------------------------------------------------------------------
+// Filename: SipOptionsCommand.cs
+//
+// Description: The "sipsorcery sip options" verb. Sends a SIP OPTIONS request
+// to a destination and reports the response. The SIP equivalent of ping:
+// proves the server is up, reachable on the chosen transport and parsing
+// requests, and measures the round trip time.
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 12 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using System.CommandLine;
+using System.Diagnostics;
+using System.Net.Sockets;
+using SIPSorcery.SIP;
+
+namespace SIPSorcery.Cli.Commands;
+
+public sealed class SipOptionsCommand : CommandBase
+{
+ private const int DEFAULT_TIMEOUT_SECONDS = 5;
+
+ ///
+ /// The result shape written to stdout with --json. Stable field names; additive changes only.
+ ///
+ private sealed record OptionsResult(
+ bool Success,
+ string Destination,
+ int? StatusCode,
+ string? ReasonPhrase,
+ string? Server,
+ string? RemoteEndPoint,
+ long? DurationMs,
+ string? Error);
+
+ public SipOptionsCommand() : base(DEFAULT_TIMEOUT_SECONDS)
+ { }
+
+ public override Command Build()
+ {
+ var destinationArg = new Argument("destination")
+ {
+ Description = "The SIP destination in the form [sip:|sips:|udp:|tcp:|tls:|ws:|wss:][user@]host[:port][;transport=x], " +
+ "e.g. music@iptel.org, tcp:sip.example.com:5060, sips:secure.example.com."
+ };
+
+ var hepOption = HepCapture.CreateOption();
+
+ var command = new Command("options", "Send a SIP OPTIONS request and report the response (SIP ping).");
+ command.Arguments.Add(destinationArg);
+ command.Options.Add(hepOption);
+ AddCommonOptions(command);
+
+ command.SetAction((parseResult, cancellationToken) => RunAsync(
+ parseResult.GetValue(destinationArg)!,
+ parseResult.GetValue(hepOption),
+ parseResult.GetValue(TimeoutOption),
+ parseResult.GetValue(JsonOption),
+ parseResult.GetValue(VerboseOption),
+ cancellationToken));
+
+ return command;
+ }
+
+ private static async Task RunAsync(string destination, string? hep, int timeoutSeconds, bool asJson, bool verbose, CancellationToken ct)
+ {
+ using var loggerFactory = InitLogging(verbose);
+ var logger = loggerFactory.CreateLogger(nameof(SipOptionsCommand));
+
+ if (!SipDestination.TryParse(destination, out var dstUri, out var parseError))
+ {
+ return WriteResult(asJson,
+ new OptionsResult(false, destination, null, null, null, null, null, parseError),
+ ExitCodes.InvalidArgument);
+ }
+
+ using var hepCapture = HepCapture.Create(hep, logger, out string? hepError);
+
+ if (hepError != null)
+ {
+ return WriteResult(asJson,
+ new OptionsResult(false, destination, null, null, null, null, null, hepError),
+ ExitCodes.InvalidArgument);
+ }
+
+ var sipTransport = new SIPTransport();
+ hepCapture?.Attach(sipTransport);
+
+ if (verbose)
+ {
+ sipTransport.EnableTraceLogs();
+ }
+
+ try
+ {
+ var optionsRequest = SIPRequest.GetRequest(SIPMethodsEnum.OPTIONS, dstUri);
+
+ var gotResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ SIPEndPoint? responseRemoteEndPoint = null;
+
+ sipTransport.SIPTransportResponseReceived += (localEndPoint, remoteEndPoint, response) =>
+ {
+ if (response.Header.CSeqMethod == SIPMethodsEnum.OPTIONS && response.Header.CallId == optionsRequest.Header.CallId)
+ {
+ responseRemoteEndPoint = remoteEndPoint;
+ gotResponse.TrySetResult(response);
+ }
+
+ return Task.CompletedTask;
+ };
+
+ var stopwatch = Stopwatch.StartNew();
+
+ var sendResult = await sipTransport.SendRequestAsync(optionsRequest, true).ConfigureAwait(false);
+
+ if (sendResult != SocketError.Success)
+ {
+ return WriteResult(asJson,
+ new OptionsResult(false, dstUri.ToString(), null, null, null, null, stopwatch.ElapsedMilliseconds,
+ $"The send failed with socket error {sendResult}."),
+ ExitCodes.TransportError);
+ }
+
+ var completed = await Task.WhenAny(gotResponse.Task, Task.Delay(TimeSpan.FromSeconds(timeoutSeconds), ct)).ConfigureAwait(false);
+
+ if (completed != gotResponse.Task)
+ {
+ return WriteResult(asJson,
+ new OptionsResult(false, dstUri.ToString(), null, null, null, null, stopwatch.ElapsedMilliseconds,
+ ct.IsCancellationRequested ? "Cancelled." : $"No response received within {timeoutSeconds}s."),
+ ExitCodes.Timeout);
+ }
+
+ stopwatch.Stop();
+
+ var sipResponse = await gotResponse.Task.ConfigureAwait(false);
+ int statusCode = (int)sipResponse.Status;
+ bool success = statusCode >= 200 && statusCode < 300;
+
+ var result = new OptionsResult(
+ success,
+ dstUri.ToString(),
+ statusCode,
+ sipResponse.ReasonPhrase,
+ !string.IsNullOrWhiteSpace(sipResponse.Header.Server) ? sipResponse.Header.Server : sipResponse.Header.UserAgent,
+ responseRemoteEndPoint?.ToString(),
+ stopwatch.ElapsedMilliseconds,
+ null);
+
+ return WriteResult(asJson, result, success ? ExitCodes.Ok : ExitCodes.Failed);
+ }
+ catch (Exception excp)
+ {
+ return WriteResult(asJson,
+ new OptionsResult(false, dstUri.ToString(), null, null, null, null, null, excp.Message),
+ ExitCodes.TransportError);
+ }
+ finally
+ {
+ sipTransport.Shutdown();
+ }
+ }
+
+ private static int WriteResult(bool asJson, OptionsResult result, int exitCode)
+ {
+ if (asJson)
+ {
+ WriteJson(result);
+ }
+ else if (result.StatusCode != null)
+ {
+ string server = result.Server != null ? $" ({result.Server})" : string.Empty;
+ Console.WriteLine($"{result.StatusCode} {result.ReasonPhrase} from {result.RemoteEndPoint} in {result.DurationMs}ms{server}.");
+ }
+ else
+ {
+ Console.Error.WriteLine($"OPTIONS to {result.Destination} failed: {result.Error}");
+ }
+
+ return exitCode;
+ }
+}
diff --git a/src/SIPSorcery.Cli/Commands/stun/StunLookupCommand.cs b/src/SIPSorcery.Cli/Commands/stun/StunLookupCommand.cs
new file mode 100644
index 000000000..6b0f210ba
--- /dev/null
+++ b/src/SIPSorcery.Cli/Commands/stun/StunLookupCommand.cs
@@ -0,0 +1,137 @@
+//-----------------------------------------------------------------------------
+// Filename: StunLookupCommand.cs
+//
+// Description: The "sipsorcery stun lookup" verb. Sends a STUN binding request
+// to a server and reports the mapped (public) address and port, i.e. answers
+// "what does the internet see when this machine sends UDP?".
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 12 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using System.CommandLine;
+using System.Diagnostics;
+using System.Net;
+using SIPSorcery.Net;
+
+namespace SIPSorcery.Cli.Commands;
+
+public sealed class StunLookupCommand : CommandBase
+{
+ private const int DEFAULT_TIMEOUT_SECONDS = 5;
+
+ ///
+ /// The result shape written to stdout with --json. Stable field names; additive changes only.
+ ///
+ private sealed record LookupResult(
+ bool Success,
+ string Server,
+ string? MappedAddress,
+ int? MappedPort,
+ long? DurationMs,
+ string? Error);
+
+ public StunLookupCommand() : base(DEFAULT_TIMEOUT_SECONDS)
+ { }
+
+ public override Command Build()
+ {
+ var serverArg = new Argument("server")
+ {
+ Description = "The STUN server in the form [stun:]host[:port], e.g. stun.cloudflare.com, " +
+ "stun:stun.l.google.com:19302. The port defaults to 3478."
+ };
+
+ var command = new Command("lookup", "Send a STUN binding request and report this machine's public IP address and port.");
+ command.Arguments.Add(serverArg);
+ AddCommonOptions(command);
+
+ command.SetAction((parseResult, cancellationToken) => RunAsync(
+ parseResult.GetValue(serverArg)!,
+ parseResult.GetValue(TimeoutOption),
+ parseResult.GetValue(JsonOption),
+ parseResult.GetValue(VerboseOption),
+ cancellationToken));
+
+ return command;
+ }
+
+ private static async Task RunAsync(string server, int timeoutSeconds, bool asJson, bool verbose, CancellationToken ct)
+ {
+ using var loggerFactory = InitLogging(verbose);
+
+ if (!STUNUri.TryParse(server, out var stunUri) || string.IsNullOrWhiteSpace(stunUri.Host) || stunUri.Host.Contains(' '))
+ {
+ return WriteResult(asJson,
+ new LookupResult(false, server, null, null, null, $"Could not parse \"{server}\" as a STUN server."),
+ ExitCodes.InvalidArgument);
+ }
+
+ string serverDescription = $"{stunUri.Host}:{stunUri.Port}";
+
+ try
+ {
+ var stopwatch = Stopwatch.StartNew();
+
+ // GetPublicIPEndPoint is synchronous with its own internal response timeout, so run it
+ // on the thread pool and race it against this command's timeout.
+ var lookupTask = Task.Run(() => STUNClient.GetPublicIPEndPoint(stunUri.Host, stunUri.Port), ct);
+ var completed = await Task.WhenAny(lookupTask, Task.Delay(TimeSpan.FromSeconds(timeoutSeconds), ct)).ConfigureAwait(false);
+
+ if (completed != lookupTask)
+ {
+ return WriteResult(asJson,
+ new LookupResult(false, serverDescription, null, null, stopwatch.ElapsedMilliseconds,
+ ct.IsCancellationRequested ? "Cancelled." : $"No response received within {timeoutSeconds}s."),
+ ExitCodes.Timeout);
+ }
+
+ stopwatch.Stop();
+
+ IPEndPoint? mappedEndPoint = await lookupTask.ConfigureAwait(false);
+
+ if (mappedEndPoint == null)
+ {
+ return WriteResult(asJson,
+ new LookupResult(false, serverDescription, null, null, stopwatch.ElapsedMilliseconds,
+ "No binding response was received from the server."),
+ ExitCodes.Failed);
+ }
+
+ return WriteResult(asJson,
+ new LookupResult(true, serverDescription, mappedEndPoint.Address.ToString(), mappedEndPoint.Port,
+ stopwatch.ElapsedMilliseconds, null),
+ ExitCodes.Ok);
+ }
+ catch (Exception excp)
+ {
+ return WriteResult(asJson,
+ new LookupResult(false, serverDescription, null, null, null, excp.Message),
+ ExitCodes.TransportError);
+ }
+ }
+
+ private static int WriteResult(bool asJson, LookupResult result, int exitCode)
+ {
+ if (asJson)
+ {
+ WriteJson(result);
+ }
+ else if (result.Success)
+ {
+ Console.WriteLine($"Public end point {result.MappedAddress}:{result.MappedPort} (via {result.Server} in {result.DurationMs}ms).");
+ }
+ else
+ {
+ Console.Error.WriteLine($"STUN lookup via {result.Server} failed: {result.Error}");
+ }
+
+ return exitCode;
+ }
+}
diff --git a/src/SIPSorcery.Cli/Commands/webrtc/WebRtcWhepCommand.cs b/src/SIPSorcery.Cli/Commands/webrtc/WebRtcWhepCommand.cs
new file mode 100644
index 000000000..fe7beba3d
--- /dev/null
+++ b/src/SIPSorcery.Cli/Commands/webrtc/WebRtcWhepCommand.cs
@@ -0,0 +1,361 @@
+//-----------------------------------------------------------------------------
+// Filename: WebRtcWhepCommand.cs
+//
+// Description: The "sipsorcery webrtc whep" verb. Performs a full WebRTC
+// connection to a WHEP (WebRTC-HTTP Egress Protocol) endpoint: SDP offer via
+// HTTP POST, ICE connectivity checks, DTLS handshake and SRTP media reception.
+// Reports whether the connection succeeded and how many media packets arrived,
+// distinguishing "could not connect" from the quieter failure mode of
+// "connected but no media flowed".
+//
+// Signalling is a single HTTP POST of application/sdp returning the answer
+// (WHIP is RFC 9725; WHEP is the equivalent egress draft). Trickle ICE is
+// avoided by gathering all candidates before sending the offer.
+//
+// Received video can be rendered or captured with --video: "play" spawns an
+// ffplay window, a file path captures the bitstream (H264 Annex B, VP8 in
+// IVF) and "-" writes it to stdout for piping (the result then moves to
+// stderr), e.g. "--video - | mpv --vo=tct -" renders video in the terminal.
+// Decode is delegated to the consumer so no video codecs run in-process.
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 12 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using System.CommandLine;
+using System.Diagnostics;
+using System.Net.Http.Headers;
+using System.Text;
+using Microsoft.Extensions.Logging;
+using SIPSorcery.Net;
+using SIPSorceryMedia.Abstractions;
+
+namespace SIPSorcery.Cli.Commands;
+
+public sealed class WebRtcWhepCommand : CommandBase
+{
+ private const int DEFAULT_TIMEOUT_SECONDS = 10;
+ private const int DEFAULT_MEDIA_DURATION_SECONDS = 5;
+ private const int VP8_PAYLOAD_ID = 96;
+ private const int H264_PAYLOAD_ID = 100;
+
+ ///
+ /// The result shape written to stdout with --json. Stable field names; additive changes only.
+ /// Lost counts gaps in the RTP sequence numbers: genuine network loss, but ALSO packets the
+ /// library dropped between the wire and the application, e.g. SRTP authentication failures,
+ /// which makes a non-zero value a prompt to rerun with --verbose and look closer.
+ ///
+ private sealed record WhepResult(
+ bool Success,
+ string Url,
+ int? HttpStatus,
+ string ConnectionState,
+ long? ConnectTimeMs,
+ int? MediaDurationMs,
+ int AudioPackets,
+ long AudioLost,
+ int AudioOutOfOrder,
+ int AudioDuplicates,
+ int VideoPackets,
+ long VideoLost,
+ int VideoOutOfOrder,
+ int VideoDuplicates,
+ string? Error,
+ int? VideoFrames = null,
+ long? VideoBytesWritten = null);
+
+ public WebRtcWhepCommand() : base(DEFAULT_TIMEOUT_SECONDS)
+ { }
+
+ public override Command Build()
+ {
+ var urlArg = new Argument("url")
+ {
+ Description = "The WHEP endpoint URL, e.g. https://b.siobud.com/api/whep."
+ };
+
+ var tokenOption = new Option("--token")
+ {
+ Description = "Optional bearer token for the Authorization header. For Broadcast Box this is the stream key."
+ };
+
+ var durationOption = new Option("--duration", "-d")
+ {
+ Description = "The number of seconds to receive media for after the connection is established.",
+ DefaultValueFactory = _ => DEFAULT_MEDIA_DURATION_SECONDS
+ };
+
+ var videoOption = new Option("--video")
+ {
+ Description = "Where to send the received video: \"play\" to render in an ffplay window, a file path " +
+ "(H264 is written as Annex B, VP8 in an IVF container), or \"-\" for the bitstream on stdout " +
+ "(the result then moves to stderr), e.g. pipe to \"mpv --vo=tct -\" for video in the terminal."
+ };
+
+ var command = new Command("whep", "Connect to a WHEP endpoint (full ICE/DTLS/SRTP) and verify media is received.");
+ command.Arguments.Add(urlArg);
+ command.Options.Add(tokenOption);
+ command.Options.Add(durationOption);
+ command.Options.Add(videoOption);
+ AddCommonOptions(command);
+
+ command.SetAction((parseResult, cancellationToken) => RunAsync(
+ parseResult.GetValue(urlArg)!,
+ parseResult.GetValue(tokenOption),
+ parseResult.GetValue(durationOption),
+ parseResult.GetValue(videoOption),
+ parseResult.GetValue(TimeoutOption),
+ parseResult.GetValue(JsonOption),
+ parseResult.GetValue(VerboseOption),
+ cancellationToken));
+
+ return command;
+ }
+
+ private static async Task RunAsync(string url, string? token, int durationSeconds, string? videoOut,
+ int timeoutSeconds, bool asJson, bool verbose, CancellationToken ct)
+ {
+ using var loggerFactory = InitLogging(verbose);
+ var logger = loggerFactory.CreateLogger(nameof(WebRtcWhepCommand));
+
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var endpointUri) ||
+ (endpointUri.Scheme != Uri.UriSchemeHttp && endpointUri.Scheme != Uri.UriSchemeHttps))
+ {
+ return WriteResult(asJson, stdoutClaimed: false,
+ new WhepResult(false, url, null, "new", null, null, 0, 0, 0, 0, 0, 0, 0, 0,
+ $"Could not parse \"{url}\" as an HTTP or HTTPS URL."),
+ ExitCodes.InvalidArgument);
+ }
+
+ using var videoSink = VideoSink.Create(videoOut, logger, out string? videoSinkError);
+
+ if (videoSinkError != null)
+ {
+ return WriteResult(asJson, videoSink.IsStdout,
+ new WhepResult(false, url, null, "new", null, null, 0, 0, 0, 0, 0, 0, 0, 0, videoSinkError),
+ ExitCodes.InvalidArgument);
+ }
+
+ var pc = new RTCPeerConnection();
+ Uri? resourceUri = null;
+ using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(timeoutSeconds) };
+
+ try
+ {
+ // Receive only tracks. Offer the codecs the library can negotiate so the server can
+ // match whatever the publisher is sending. No decoding is done, packets are counted.
+ var audioTrack = new MediaStreamTrack(new List
+ {
+ AudioCommonlyUsedFormats.OpusWebRTC,
+ new AudioFormat(SDPWellKnownMediaFormatsEnum.PCMU),
+ new AudioFormat(SDPWellKnownMediaFormatsEnum.PCMA)
+ }, MediaStreamStatusEnum.RecvOnly);
+ pc.addTrack(audioTrack);
+
+ var videoTrack = new MediaStreamTrack(new List
+ {
+ new VideoFormat(VideoCodecsEnum.VP8, VP8_PAYLOAD_ID),
+ new VideoFormat(VideoCodecsEnum.H264, H264_PAYLOAD_ID, parameters: "packetization-mode=1")
+ }, MediaStreamStatusEnum.RecvOnly);
+ pc.addTrack(videoTrack);
+
+ var audioStats = new RtpStreamStats();
+ var videoStats = new RtpStreamStats();
+ pc.OnRtpPacketReceived += RtpStreamStats.CreateRtpHandler(audioStats, videoStats, logger);
+
+ if (videoSink.IsActive)
+ {
+ // Depacketised (still encoded) frames; decode is delegated to the sink's consumer.
+ pc.OnVideoFrameReceived += (remoteEndPoint, timestamp, frame, format) =>
+ videoSink.WriteFrame(frame, timestamp, format);
+ }
+
+ var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ pc.onconnectionstatechange += (state) =>
+ {
+ logger.LogDebug("Peer connection state changed to {State}.", state);
+
+ if (state == RTCPeerConnectionState.connected)
+ {
+ connected.TrySetResult(true);
+ }
+ else if (state == RTCPeerConnectionState.failed || state == RTCPeerConnectionState.closed)
+ {
+ connected.TrySetResult(false);
+ }
+ };
+
+ // Gather all ICE candidates up front so the offer is complete and no trickle (HTTP
+ // PATCH) support is needed.
+ var offer = pc.createOffer(new RTCOfferOptions { X_WaitForIceGatheringToComplete = true });
+ await pc.setLocalDescription(offer).ConfigureAwait(false);
+
+ var stopwatch = Stopwatch.StartNew();
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, endpointUri)
+ {
+ Content = new StringContent(offer.sdp, Encoding.UTF8, "application/sdp")
+ };
+ if (!string.IsNullOrWhiteSpace(token))
+ {
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ }
+
+ using var response = await httpClient.SendAsync(request, ct).ConfigureAwait(false);
+ string responseBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ string detail = responseBody.Length > 200 ? responseBody[..200] : responseBody;
+ return WriteResult(asJson, videoSink.IsStdout,
+ new WhepResult(false, url, (int)response.StatusCode, pc.connectionState.ToString(), null, null, 0, 0, 0, 0, 0, 0, 0, 0,
+ $"The WHEP endpoint returned HTTP {(int)response.StatusCode}. {detail}".TrimEnd()),
+ ExitCodes.Failed);
+ }
+
+ // The Location header identifies the session resource and is used for the DELETE teardown.
+ if (response.Headers.Location != null)
+ {
+ resourceUri = response.Headers.Location.IsAbsoluteUri
+ ? response.Headers.Location
+ : new Uri(endpointUri, response.Headers.Location);
+ }
+
+ var setAnswerResult = pc.setRemoteDescription(new RTCSessionDescriptionInit
+ {
+ type = RTCSdpType.answer,
+ sdp = responseBody
+ });
+
+ if (setAnswerResult != SetDescriptionResultEnum.OK)
+ {
+ return WriteResult(asJson, videoSink.IsStdout,
+ new WhepResult(false, url, (int)response.StatusCode, pc.connectionState.ToString(), null, null, 0, 0, 0, 0, 0, 0, 0, 0,
+ $"The SDP answer could not be applied: {setAnswerResult}."),
+ ExitCodes.Failed);
+ }
+
+ var connectCompleted = await Task.WhenAny(connected.Task, Task.Delay(TimeSpan.FromSeconds(timeoutSeconds), ct)).ConfigureAwait(false);
+
+ if (connectCompleted != connected.Task || !await connected.Task.ConfigureAwait(false))
+ {
+ return WriteResult(asJson, videoSink.IsStdout,
+ new WhepResult(false, url, (int)response.StatusCode, pc.connectionState.ToString(),
+ stopwatch.ElapsedMilliseconds, null,
+ audioStats.Packets, audioStats.Lost, audioStats.OutOfOrder, audioStats.Duplicates,
+ videoStats.Packets, videoStats.Lost, videoStats.OutOfOrder, videoStats.Duplicates,
+ ct.IsCancellationRequested ? "Cancelled." :
+ connectCompleted == connected.Task
+ ? $"The peer connection failed (state {pc.connectionState})."
+ : $"The peer connection did not reach connected within {timeoutSeconds}s."),
+ ExitCodes.Timeout);
+ }
+
+ long connectTimeMs = stopwatch.ElapsedMilliseconds;
+ logger.LogDebug("Connected in {ConnectTimeMs}ms, receiving media for {Duration}s.", connectTimeMs, durationSeconds);
+
+ await Task.Delay(TimeSpan.FromSeconds(durationSeconds), ct).ConfigureAwait(false);
+
+ bool gotMedia = audioStats.Packets + videoStats.Packets > 0;
+
+ // Dispose the sink before writing the result so files are finalised, ffplay drains
+ // and, in stdout mode, the bitstream completes before the result lands on stderr.
+ videoSink.Dispose();
+
+ return WriteResult(asJson, videoSink.IsStdout,
+ new WhepResult(gotMedia, url, (int)response.StatusCode, pc.connectionState.ToString(),
+ connectTimeMs, durationSeconds * 1000,
+ audioStats.Packets, audioStats.Lost, audioStats.OutOfOrder, audioStats.Duplicates,
+ videoStats.Packets, videoStats.Lost, videoStats.OutOfOrder, videoStats.Duplicates,
+ gotMedia ? null : "The connection succeeded but no media packets were received. Is anything publishing to the stream?",
+ videoSink.IsActive ? videoSink.FramesWritten : null,
+ videoSink.IsActive ? videoSink.BytesWritten : null),
+ gotMedia ? ExitCodes.Ok : ExitCodes.Failed);
+ }
+ catch (OperationCanceledException)
+ {
+ return WriteResult(asJson, videoSink.IsStdout,
+ new WhepResult(false, url, null, pc.connectionState.ToString(), null, null, 0, 0, 0, 0, 0, 0, 0, 0, "Cancelled or HTTP request timed out."),
+ ExitCodes.Timeout);
+ }
+ catch (Exception excp)
+ {
+ return WriteResult(asJson, videoSink.IsStdout,
+ new WhepResult(false, url, null, pc.connectionState.ToString(), null, null, 0, 0, 0, 0, 0, 0, 0, 0, excp.Message),
+ ExitCodes.TransportError);
+ }
+ finally
+ {
+ // Best effort WHEP session teardown.
+ if (resourceUri != null)
+ {
+ try
+ {
+ using var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, resourceUri);
+ if (!string.IsNullOrWhiteSpace(token))
+ {
+ deleteRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ }
+ await httpClient.SendAsync(deleteRequest, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception excp)
+ {
+ logger.LogDebug("WHEP session DELETE failed: {Error}", excp.Message);
+ }
+ }
+
+ pc.Close("whep probe complete");
+ }
+ }
+
+ private static int WriteResult(bool asJson, bool stdoutClaimed, WhepResult result, int exitCode)
+ {
+ // The stdout payload rule: when the video bitstream has claimed stdout (--video -), the
+ // result is commentary and moves to stderr.
+ var output = stdoutClaimed ? Console.Error : Console.Out;
+
+ if (asJson)
+ {
+ output.WriteLine(SerializeResult(result));
+ }
+ else if (result.Success)
+ {
+ string videoSink = result.VideoFrames != null ? $", {result.VideoFrames} video frames ({result.VideoBytesWritten} bytes) written" : string.Empty;
+ output.WriteLine($"Connected to {result.Url} in {result.ConnectTimeMs}ms. " +
+ $"Received {result.AudioPackets} audio ({FormatAnomalies(result.AudioLost, result.AudioOutOfOrder, result.AudioDuplicates)}) and " +
+ $"{result.VideoPackets} video ({FormatAnomalies(result.VideoLost, result.VideoOutOfOrder, result.VideoDuplicates)}) packets in {result.MediaDurationMs}ms{videoSink}.");
+ }
+ else
+ {
+ Console.Error.WriteLine($"WHEP connection to {result.Url} failed (state {result.ConnectionState}): {result.Error}");
+ }
+
+ if (result.AudioLost + result.VideoLost > 0)
+ {
+ // A sequence gap means the packet never reached the application. That is genuine
+ // network loss OR packets the library dropped on the way up, e.g. SRTP
+ // authentication failures, which only show up in the verbose logs.
+ Console.Error.WriteLine($"Warning: {result.AudioLost + result.VideoLost} packet(s) missing from the RTP sequence. " +
+ "This can be network loss or packets dropped internally (e.g. SRTP authentication failures). Rerun with --verbose to investigate.");
+ }
+
+ return exitCode;
+ }
+
+ private static string FormatAnomalies(long lost, int outOfOrder, int duplicates)
+ {
+ if (lost == 0 && outOfOrder == 0 && duplicates == 0)
+ {
+ return "clean";
+ }
+
+ return $"{lost} lost, {outOfOrder} reordered, {duplicates} duplicate";
+ }
+}
+
diff --git a/src/SIPSorcery.Cli/Commands/webrtc/WebRtcWhipServerCommand.cs b/src/SIPSorcery.Cli/Commands/webrtc/WebRtcWhipServerCommand.cs
new file mode 100644
index 000000000..7a998cf00
--- /dev/null
+++ b/src/SIPSorcery.Cli/Commands/webrtc/WebRtcWhipServerCommand.cs
@@ -0,0 +1,373 @@
+//-----------------------------------------------------------------------------
+// Filename: WebRtcWhipServerCommand.cs
+//
+// Description: The "sipsorcery webrtc whip-server" verb. Acts as a WHIP
+// (WebRTC-HTTP Ingestion Protocol, RFC 9725) endpoint: accepts a publisher's
+// SDP offer over HTTP POST, answers it, completes ICE/DTLS and receives the
+// published media, reporting packet counts and sequence anomalies.
+//
+// The motivating use case is isolating where stream problems originate: a
+// publisher (e.g. ffmpeg's whip muxer, OBS) can publish DIRECTLY to this verb
+// over the loopback or LAN, removing the SFU and internet path from the
+// equation. Reordering observed here is the publisher's send order; a clean
+// result here with anomalies via an SFU points upstream.
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 12 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using System.CommandLine;
+using System.Diagnostics;
+using System.Net;
+using System.Text;
+using Microsoft.Extensions.Logging;
+using SIPSorcery.Net;
+using SIPSorceryMedia.Abstractions;
+
+namespace SIPSorcery.Cli.Commands;
+
+public sealed class WebRtcWhipServerCommand : CommandBase
+{
+ private const int DEFAULT_TIMEOUT_SECONDS = 60;
+ private const int DEFAULT_MEDIA_DURATION_SECONDS = 10;
+ private const string DEFAULT_LISTEN_URL = "http://localhost:8080/whip";
+ private const int VP8_PAYLOAD_ID = 96;
+ private const int H264_PAYLOAD_ID = 100;
+
+ ///
+ /// The result shape written to stdout with --json. Stable field names; additive changes only.
+ ///
+ private sealed record WhipServerResult(
+ bool Success,
+ string ListenUrl,
+ string ConnectionState,
+ long? ConnectTimeMs,
+ int? MediaDurationMs,
+ int AudioPackets,
+ long AudioLost,
+ int AudioOutOfOrder,
+ int AudioDuplicates,
+ int VideoPackets,
+ long VideoLost,
+ int VideoOutOfOrder,
+ int VideoDuplicates,
+ string? Error);
+
+ public WebRtcWhipServerCommand() : base(DEFAULT_TIMEOUT_SECONDS)
+ { }
+
+ public override Command Build()
+ {
+ var listenOption = new Option("--listen")
+ {
+ Description = $"The HTTP URL to accept WHIP publish offers on. Defaults to {DEFAULT_LISTEN_URL}.",
+ DefaultValueFactory = _ => DEFAULT_LISTEN_URL
+ };
+
+ var tokenOption = new Option("--token")
+ {
+ Description = "Optional bearer token publishers must supply in the Authorization header."
+ };
+
+ var durationOption = new Option("--duration", "-d")
+ {
+ Description = "The number of seconds to receive media for after the connection is established.",
+ DefaultValueFactory = _ => DEFAULT_MEDIA_DURATION_SECONDS
+ };
+
+ var command = new Command("whip-server", "Act as a WHIP endpoint: accept a publisher's offer (e.g. from ffmpeg or OBS) and report on the received media.");
+ command.Options.Add(listenOption);
+ command.Options.Add(tokenOption);
+ command.Options.Add(durationOption);
+ AddCommonOptions(command);
+
+ command.SetAction((parseResult, cancellationToken) => RunAsync(
+ parseResult.GetValue(listenOption)!,
+ parseResult.GetValue(tokenOption),
+ parseResult.GetValue(durationOption),
+ parseResult.GetValue(TimeoutOption),
+ parseResult.GetValue(JsonOption),
+ parseResult.GetValue(VerboseOption),
+ cancellationToken));
+
+ return command;
+ }
+
+ private static async Task RunAsync(string listenUrl, string? token, int durationSeconds,
+ int timeoutSeconds, bool asJson, bool verbose, CancellationToken ct)
+ {
+ using var loggerFactory = InitLogging(verbose);
+ var logger = loggerFactory.CreateLogger(nameof(WebRtcWhipServerCommand));
+
+ if (!Uri.TryCreate(listenUrl, UriKind.Absolute, out var listenUri) || listenUri.Scheme != Uri.UriSchemeHttp)
+ {
+ return WriteResult(asJson,
+ new WhipServerResult(false, listenUrl, "new", null, null, 0, 0, 0, 0, 0, 0, 0, 0,
+ $"Could not parse \"{listenUrl}\" as an HTTP URL (HTTPS is not supported for the local listener)."),
+ ExitCodes.InvalidArgument);
+ }
+
+ using var listener = new HttpListener();
+ listener.Prefixes.Add($"http://{listenUri.Authority}/");
+
+ RTCPeerConnection? pc = null;
+
+ try
+ {
+ listener.Start();
+
+ // Operator guidance, deliberately on stderr so stdout remains the result channel.
+ Console.Error.WriteLine($"Waiting up to {timeoutSeconds}s for a WHIP publish offer on {listenUrl} ...");
+ Console.Error.WriteLine($"e.g. ffmpeg -re -f lavfi -i testsrc=size=640x360 -f lavfi -i sine=frequency=440 " +
+ $"-pix_fmt yuv420p -c:v libx264 -profile:v baseline -r 25 -g 50 -c:a libopus -ar 48000 -ac 2 " +
+ $"-f whip{(token != null ? $" -authorization \"{token}\"" : string.Empty)} \"{listenUrl}\"");
+
+ using var overallCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+
+ // ---- Wait for an acceptable POST with the SDP offer. ----
+ HttpListenerContext? offerContext = null;
+ var offerDeadline = Task.Delay(TimeSpan.FromSeconds(timeoutSeconds), overallCts.Token);
+
+ while (offerContext == null)
+ {
+ var getContext = listener.GetContextAsync();
+ var completed = await Task.WhenAny(getContext, offerDeadline).ConfigureAwait(false);
+
+ if (completed == offerDeadline)
+ {
+ return WriteResult(asJson,
+ new WhipServerResult(false, listenUrl, "new", null, null, 0, 0, 0, 0, 0, 0, 0, 0,
+ ct.IsCancellationRequested ? "Cancelled." : $"No publish offer was received within {timeoutSeconds}s."),
+ ExitCodes.Timeout);
+ }
+
+ var context = await getContext.ConfigureAwait(false);
+ var request = context.Request;
+
+ if (request.HttpMethod != "POST")
+ {
+ Respond(context, HttpStatusCode.MethodNotAllowed);
+ }
+ else if (token != null && request.Headers["Authorization"] != $"Bearer {token}")
+ {
+ logger.LogWarning("Rejected publish offer with missing or incorrect bearer token.");
+ Respond(context, HttpStatusCode.Unauthorized);
+ }
+ else
+ {
+ offerContext = context;
+ }
+ }
+
+ string offerSdp;
+ using (var reader = new StreamReader(offerContext.Request.InputStream, Encoding.UTF8))
+ {
+ offerSdp = await reader.ReadToEndAsync().ConfigureAwait(false);
+ }
+
+ logger.LogDebug("Received WHIP offer on {Path} from {Remote}.", offerContext.Request.Url?.AbsolutePath, offerContext.Request.RemoteEndPoint);
+
+ // ---- Create the receiving peer connection. ----
+ pc = new RTCPeerConnection();
+
+ pc.addTrack(new MediaStreamTrack(new List
+ {
+ AudioCommonlyUsedFormats.OpusWebRTC,
+ new AudioFormat(SDPWellKnownMediaFormatsEnum.PCMU),
+ new AudioFormat(SDPWellKnownMediaFormatsEnum.PCMA)
+ }, MediaStreamStatusEnum.RecvOnly));
+
+ pc.addTrack(new MediaStreamTrack(new List
+ {
+ new VideoFormat(VideoCodecsEnum.VP8, VP8_PAYLOAD_ID),
+ new VideoFormat(VideoCodecsEnum.H264, H264_PAYLOAD_ID, parameters: "packetization-mode=1")
+ }, MediaStreamStatusEnum.RecvOnly));
+
+ var audioStats = new RtpStreamStats();
+ var videoStats = new RtpStreamStats();
+ pc.OnRtpPacketReceived += RtpStreamStats.CreateRtpHandler(audioStats, videoStats, logger);
+
+ var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ pc.onconnectionstatechange += (state) =>
+ {
+ logger.LogDebug("Peer connection state changed to {State}.", state);
+
+ if (state == RTCPeerConnectionState.connected)
+ {
+ connected.TrySetResult(true);
+ }
+ else if (state == RTCPeerConnectionState.failed || state == RTCPeerConnectionState.closed)
+ {
+ connected.TrySetResult(false);
+ }
+ };
+
+ var setOfferResult = pc.setRemoteDescription(new RTCSessionDescriptionInit
+ {
+ type = RTCSdpType.offer,
+ sdp = offerSdp
+ });
+
+ if (setOfferResult != SetDescriptionResultEnum.OK)
+ {
+ Respond(offerContext, HttpStatusCode.BadRequest);
+ return WriteResult(asJson,
+ new WhipServerResult(false, listenUrl, pc.connectionState.ToString(), null, null, 0, 0, 0, 0, 0, 0, 0, 0,
+ $"The publisher's SDP offer could not be applied: {setOfferResult}."),
+ ExitCodes.Failed);
+ }
+
+ var answer = pc.createAnswer();
+ await pc.setLocalDescription(answer).ConfigureAwait(false);
+
+ var stopwatch = Stopwatch.StartNew();
+
+ // 201 + answer SDP + a Location header identifying the session resource (RFC 9725).
+ string resourcePath = $"{offerContext.Request.Url?.AbsolutePath?.TrimEnd('/')}/{Guid.NewGuid().ToString("N")[..8]}";
+ var answerBytes = Encoding.UTF8.GetBytes(answer.sdp);
+ offerContext.Response.StatusCode = (int)HttpStatusCode.Created;
+ offerContext.Response.ContentType = "application/sdp";
+ offerContext.Response.AddHeader("Location", resourcePath);
+ offerContext.Response.ContentLength64 = answerBytes.Length;
+ await offerContext.Response.OutputStream.WriteAsync(answerBytes, overallCts.Token).ConfigureAwait(false);
+ offerContext.Response.Close();
+
+ // ---- Service subsequent HTTP requests (DELETE = publisher hangup) in the background. ----
+ var publisherEnded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ while (!overallCts.IsCancellationRequested)
+ {
+ var context = await listener.GetContextAsync().ConfigureAwait(false);
+ if (context.Request.HttpMethod == "DELETE")
+ {
+ logger.LogDebug("Publisher sent DELETE for {Path}, ending the session.", context.Request.Url?.AbsolutePath);
+ Respond(context, HttpStatusCode.OK);
+ publisherEnded.TrySetResult(true);
+ }
+ else
+ {
+ // PATCH (trickle ICE) is not supported; all candidates are in the answer.
+ Respond(context, HttpStatusCode.MethodNotAllowed);
+ }
+ }
+ }
+ catch (Exception)
+ {
+ // Listener stopped; the session is over.
+ }
+ }, overallCts.Token);
+
+ // ---- Wait for the connection, then the media window. ----
+ var connectCompleted = await Task.WhenAny(connected.Task, Task.Delay(TimeSpan.FromSeconds(timeoutSeconds), overallCts.Token)).ConfigureAwait(false);
+
+ if (connectCompleted != connected.Task || !await connected.Task.ConfigureAwait(false))
+ {
+ return WriteResult(asJson,
+ new WhipServerResult(false, listenUrl, pc.connectionState.ToString(), stopwatch.ElapsedMilliseconds, null,
+ audioStats.Packets, audioStats.Lost, audioStats.OutOfOrder, audioStats.Duplicates,
+ videoStats.Packets, videoStats.Lost, videoStats.OutOfOrder, videoStats.Duplicates,
+ ct.IsCancellationRequested ? "Cancelled." :
+ connectCompleted == connected.Task
+ ? $"The peer connection failed (state {pc.connectionState})."
+ : $"The peer connection did not reach connected within {timeoutSeconds}s."),
+ ExitCodes.Timeout);
+ }
+
+ long connectTimeMs = stopwatch.ElapsedMilliseconds;
+ logger.LogDebug("Publisher connected in {ConnectTimeMs}ms, receiving media for {Duration}s.", connectTimeMs, durationSeconds);
+
+ var mediaWindow = Stopwatch.StartNew();
+ await Task.WhenAny(Task.Delay(TimeSpan.FromSeconds(durationSeconds), overallCts.Token), publisherEnded.Task).ConfigureAwait(false);
+ mediaWindow.Stop();
+
+ bool gotMedia = audioStats.Packets + videoStats.Packets > 0;
+
+ return WriteResult(asJson,
+ new WhipServerResult(gotMedia, listenUrl, pc.connectionState.ToString(),
+ connectTimeMs, (int)mediaWindow.ElapsedMilliseconds,
+ audioStats.Packets, audioStats.Lost, audioStats.OutOfOrder, audioStats.Duplicates,
+ videoStats.Packets, videoStats.Lost, videoStats.OutOfOrder, videoStats.Duplicates,
+ gotMedia ? null : "The publisher connected but no media packets were received."),
+ gotMedia ? ExitCodes.Ok : ExitCodes.Failed);
+ }
+ catch (HttpListenerException excp)
+ {
+ return WriteResult(asJson,
+ new WhipServerResult(false, listenUrl, "new", null, null, 0, 0, 0, 0, 0, 0, 0, 0,
+ $"Could not listen on {listenUrl}: {excp.Message}"),
+ ExitCodes.TransportError);
+ }
+ catch (OperationCanceledException)
+ {
+ return WriteResult(asJson,
+ new WhipServerResult(false, listenUrl, pc?.connectionState.ToString() ?? "new", null, null, 0, 0, 0, 0, 0, 0, 0, 0, "Cancelled."),
+ ExitCodes.Timeout);
+ }
+ catch (Exception excp)
+ {
+ return WriteResult(asJson,
+ new WhipServerResult(false, listenUrl, pc?.connectionState.ToString() ?? "new", null, null, 0, 0, 0, 0, 0, 0, 0, 0, excp.Message),
+ ExitCodes.TransportError);
+ }
+ finally
+ {
+ pc?.Close("whip server probe complete");
+ if (listener.IsListening)
+ {
+ listener.Stop();
+ }
+ }
+ }
+
+ private static void Respond(HttpListenerContext context, HttpStatusCode statusCode)
+ {
+ try
+ {
+ context.Response.StatusCode = (int)statusCode;
+ context.Response.Close();
+ }
+ catch (Exception)
+ {
+ // The connection may already be gone; nothing to do.
+ }
+ }
+
+ private static int WriteResult(bool asJson, WhipServerResult result, int exitCode)
+ {
+ if (asJson)
+ {
+ WriteJson(result);
+ }
+ else if (result.Success)
+ {
+ Console.WriteLine($"Publisher connected in {result.ConnectTimeMs}ms. " +
+ $"Received {result.AudioPackets} audio ({FormatAnomalies(result.AudioLost, result.AudioOutOfOrder, result.AudioDuplicates)}) and " +
+ $"{result.VideoPackets} video ({FormatAnomalies(result.VideoLost, result.VideoOutOfOrder, result.VideoDuplicates)}) packets in {result.MediaDurationMs}ms.");
+ }
+ else
+ {
+ Console.Error.WriteLine($"WHIP server on {result.ListenUrl} failed (state {result.ConnectionState}): {result.Error}");
+ }
+
+ return exitCode;
+ }
+
+ private static string FormatAnomalies(long lost, int outOfOrder, int duplicates)
+ {
+ if (lost == 0 && outOfOrder == 0 && duplicates == 0)
+ {
+ return "clean";
+ }
+
+ return $"{lost} lost, {outOfOrder} reordered, {duplicates} duplicate";
+ }
+}
diff --git a/src/SIPSorcery.Cli/ExitCodes.cs b/src/SIPSorcery.Cli/ExitCodes.cs
new file mode 100644
index 000000000..97cbb91bc
--- /dev/null
+++ b/src/SIPSorcery.Cli/ExitCodes.cs
@@ -0,0 +1,36 @@
+//-----------------------------------------------------------------------------
+// Filename: ExitCodes.cs
+//
+// Description: Process exit codes for the sipsorcery command line tool. Kept
+// stable so scripts and agents can branch on the failure mode.
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 12 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+namespace SIPSorcery.Cli;
+
+public static class ExitCodes
+{
+ /// The operation completed and got the expected result.
+ public const int Ok = 0;
+
+ /// The operation completed but the result was a failure, e.g. an error SIP response.
+ public const int Failed = 1;
+
+ /// An argument could not be parsed. System.CommandLine also uses 1 for usage errors;
+ /// this code is for values that parse as strings but are semantically invalid.
+ public const int InvalidArgument = 2;
+
+ /// No response was received within the timeout.
+ public const int Timeout = 3;
+
+ /// A network send failed, e.g. socket error or no route.
+ public const int TransportError = 4;
+}
diff --git a/src/SIPSorcery.Cli/Program.cs b/src/SIPSorcery.Cli/Program.cs
new file mode 100644
index 000000000..59138b140
--- /dev/null
+++ b/src/SIPSorcery.Cli/Program.cs
@@ -0,0 +1,56 @@
+//-----------------------------------------------------------------------------
+// Filename: Program.cs
+//
+// Description: Entry point for the sipsorcery command line tool. Wires up the
+// noun/verb command tree. Each verb lives in its own class under Commands.
+//
+// Design rules for every verb:
+// - Human readable output to stdout by default, a single JSON object to
+// stdout with --json. Logs and diagnostics always go to stderr so JSON
+// output remains pipeable.
+// - Meaningful exit codes, see ExitCodes.
+// - No hidden interactivity and a timeout on anything that waits.
+//
+// Author(s):
+// Aaron Clauson (aaron@sipsorcery.com)
+//
+// History:
+// 12 Jun 2026 Aaron Clauson Created, Wexford, Ireland.
+//
+// License:
+// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
+//-----------------------------------------------------------------------------
+
+using System.CommandLine;
+using System.CommandLine.Help;
+using SIPSorcery.Cli.Commands;
+
+// A plain Command is used as the root rather than RootCommand so the name shown in help is
+// the installed tool command, "sipsorcery", instead of the entry assembly name. RootCommand
+// derives its name from the assembly, which cannot be renamed (see the note in the csproj),
+// and Command.Name is read only. The help and version options RootCommand would normally
+// contribute are added explicitly.
+var rootCommand = new Command("sipsorcery",
+ "SIP and WebRTC diagnostics from the command line, built on the SIPSorcery library.");
+rootCommand.Options.Add(new HelpOption());
+rootCommand.Options.Add(new VersionOption());
+
+var sipCommand = new Command("sip", "SIP operations: send requests, make test calls, registrations.");
+sipCommand.Subcommands.Add(new SipOptionsCommand().Build());
+sipCommand.Subcommands.Add(new SipCallCommand().Build());
+rootCommand.Subcommands.Add(sipCommand);
+
+var stunCommand = new Command("stun", "STUN operations: public address lookups, NAT diagnostics.");
+stunCommand.Subcommands.Add(new StunLookupCommand().Build());
+rootCommand.Subcommands.Add(stunCommand);
+
+var iceCommand = new Command("ice", "ICE operations: candidate gathering, STUN/TURN connectivity probes.");
+iceCommand.Subcommands.Add(new IceProbeCommand().Build());
+rootCommand.Subcommands.Add(iceCommand);
+
+var webrtcCommand = new Command("webrtc", "WebRTC operations: full connection probes with ICE, DTLS and media.");
+webrtcCommand.Subcommands.Add(new WebRtcWhepCommand().Build());
+webrtcCommand.Subcommands.Add(new WebRtcWhipServerCommand().Build());
+rootCommand.Subcommands.Add(webrtcCommand);
+
+return await rootCommand.Parse(args).InvokeAsync();
diff --git a/src/SIPSorcery.Cli/README.md b/src/SIPSorcery.Cli/README.md
new file mode 100644
index 000000000..e1107bbb9
--- /dev/null
+++ b/src/SIPSorcery.Cli/README.md
@@ -0,0 +1,107 @@
+# sipsorcery CLI
+
+SIP and WebRTC diagnostics from the command line, built on the
+[SIPSorcery](https://github.com/sipsorcery-org/sipsorcery) library.
+
+## Install
+
+```bash
+dotnet tool install -g SIPSorcery.Cli --prerelease
+```
+
+## Usage
+
+```bash
+# SIP ping: send an OPTIONS request and report the response.
+sipsorcery sip options music@iptel.org
+sipsorcery sip options tcp:sip.example.com:5060
+sipsorcery sip options sips:secure.example.com -t 10 -v
+
+# SIP call: place a call, send a test audio source and report on the media received.
+# No audio devices are used; received audio renders via ffplay, a WAV file, or raw PCM
+# on stdout. ffmpeg/ffplay act as the cross platform audio device layer
+# (winget/brew/apt install ffmpeg).
+sipsorcery sip call music@iptel.org --audio play # listen via ffplay
+sipsorcery sip call music@iptel.org --scope # live spectrum + level in the terminal
+sipsorcery sip call music@iptel.org --audio play --scope # listen AND watch: --scope renders on
+ # stderr so it composes with any --audio
+sipsorcery sip call music@iptel.org --audio rx.wav -d 10 # record 10s to a WAV file
+sipsorcery sip call music@iptel.org --audio - > rx.pcm # raw s16le PCM on stdout
+ # (the result moves to stderr)
+sipsorcery sip call 100@pbx.example.com -u user --password pass --play tone --send-dtmf 123
+
+# Both SIP verbs can mirror their traffic to a HEPv3 capture server (HOMER, heplify-server,
+# sipcapture.org) so the exchange shows up as a call ladder diagram:
+sipsorcery sip options music@iptel.org --hep 192.168.0.10
+sipsorcery sip call music@iptel.org --hep "192.168.0.10:9060;myHep;42" # host:port;password;agentId
+
+# STUN lookup: report this machine's public IP address and port.
+sipsorcery stun lookup stun.cloudflare.com
+sipsorcery stun lookup stun:stun.l.google.com:19302
+
+# ICE probe: gather candidates and verify STUN/TURN connectivity.
+sipsorcery ice probe
+sipsorcery ice probe --stun stun:stun.cloudflare.com
+sipsorcery ice probe --turn "turn:turn.example.com;user;pass" --relay-only
+
+# WebRTC WHEP: full connection (ICE, DTLS, SRTP) to a WHEP endpoint, verifies media arrives.
+# Publish to the same stream key first, e.g. with OBS's WHIP output, to get media flowing.
+sipsorcery webrtc whep https://b.siobud.com/api/whep --token mystreamkey
+
+# Received video can be rendered or captured (decode is delegated to the consumer, so no
+# video codecs are needed in-process). H264 is written as Annex B, VP8 in an IVF container.
+sipsorcery webrtc whep https://b.siobud.com/api/whep --token key --video play # ffplay window
+sipsorcery webrtc whep https://b.siobud.com/api/whep --token key --video rx.h264 # capture to file
+sipsorcery webrtc whep https://b.siobud.com/api/whep --token key --video - \
+ | mpv --vo=tct - # bitstream on stdout: video IN the terminal
+ # (the result moves to stderr)
+# mpv is a media player with terminal renderers (https://mpv.io/installation/):
+# winget install mpv (Windows)
+# brew install mpv (macOS)
+# sudo apt install mpv (Debian/Ubuntu)
+# Windows PowerShell 5.1 corrupts binary data in pipes; run pipelines like the above
+# under cmd or PowerShell 7.4+. If mpv does not detect the format from a pipe, add
+# --demuxer-lavf-format=h264 (or ivf for VP8).
+
+# WebRTC WHIP server: accept a publish directly from ffmpeg/OBS and report on the media,
+# including sequence anomalies. Useful for isolating where stream problems originate.
+sipsorcery webrtc whip-server --listen http://localhost:8080/whip --token test -d 10
+ffmpeg -re -f lavfi -i testsrc=size=640x360 -f lavfi -i sine=frequency=440 \
+ -pix_fmt yuv420p -c:v libx264 -profile:v baseline -r 25 -g 50 \
+ -c:a libopus -ar 48000 -ac 2 -f whip -authorization "test" "http://localhost:8080/whip"
+```
+
+Every verb supports `--json` for a machine readable result on stdout (logs always go to
+stderr, so JSON output is pipeable):
+
+```bash
+sipsorcery sip options music@iptel.org --json
+```
+
+```json
+{
+ "success": true,
+ "destination": "sip:music@iptel.org",
+ "statusCode": 200,
+ "reasonPhrase": "OK",
+ "server": "kamailio",
+ "remoteEndPoint": "udp:212.79.111.155:5060",
+ "durationMs": 86
+}
+```
+
+### Exit codes
+
+| Code | Meaning |
+| --- | --- |
+| 0 | Success. |
+| 1 | The operation completed but failed, e.g. an error SIP response. |
+| 2 | An argument value was invalid. |
+| 3 | No response within the timeout. |
+| 4 | A network send failed. |
+
+## Status
+
+Early preview. The planned command surface covers SIP (calls, registrations, load testing),
+WebRTC (offer/answer exchange, echo test peers, ICE connectivity probes), STUN/TURN checks,
+SIP DNS resolution and integrations for services such as LiveKit and the OpenAI Realtime API.
diff --git a/src/SIPSorcery.Cli/SIPSorcery.Cli.csproj b/src/SIPSorcery.Cli/SIPSorcery.Cli.csproj
new file mode 100644
index 000000000..50e6b2683
--- /dev/null
+++ b/src/SIPSorcery.Cli/SIPSorcery.Cli.csproj
@@ -0,0 +1,46 @@
+
+
+
+ Exe
+
+ net8.0
+ latest
+ enable
+ enable
+
+ Major
+
+
+
+ true
+ sipsorcery
+ SIPSorcery.Cli
+ 0.1.0-alpha
+ Aaron Clauson
+ Command line diagnostics for SIP and WebRTC: OPTIONS pings, test calls, STUN/TURN/ICE connectivity checks and more, built on the SIPSorcery library. Both human and agent friendly with JSON output and meaningful exit codes.
+ BSD-3-Clause
+ https://sipsorcery-org.github.io/sipsorcery/
+ https://github.com/sipsorcery-org/sipsorcery
+ git
+ SIP;VoIP;WebRTC;STUN;TURN;ICE;CLI;diagnostics
+ README.md
+ icon.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SIPSorcery/net/RTP/Streams/MediaStream.cs b/src/SIPSorcery/net/RTP/Streams/MediaStream.cs
index f30684e72..973619ac0 100644
--- a/src/SIPSorcery/net/RTP/Streams/MediaStream.cs
+++ b/src/SIPSorcery/net/RTP/Streams/MediaStream.cs
@@ -925,12 +925,15 @@ protected virtual bool AddPendingPackage(RTPHeader hdr, int localPort, IPEndPoin
}
protected void LogIfWrongSeqNumber(string trackType, RTPHeader header, MediaStreamTrack track)
- {
- if (track.LastRemoteSeqNum != 0 &&
- header.SequenceNumber != (track.LastRemoteSeqNum + 1) &&
- !(header.SequenceNumber == 0 && track.LastRemoteSeqNum == ushort.MaxValue))
- {
- logger.LogWarning("{TrackType} stream sequence number jumped from {LastRemoteSeqNum} to {SequenceNumber}.", trackType, track.LastRemoteSeqNum, header.SequenceNumber);
+ {
+ if (logger.IsEnabled(LogLevel.Trace))
+ {
+ if (track.LastRemoteSeqNum != 0 &&
+ header.SequenceNumber != (track.LastRemoteSeqNum + 1) &&
+ !(header.SequenceNumber == 0 && track.LastRemoteSeqNum == ushort.MaxValue))
+ {
+ logger.LogTrace("{TrackType} stream sequence number jumped from {LastRemoteSeqNum} to {SequenceNumber}.", trackType, track.LastRemoteSeqNum, header.SequenceNumber);
+ }
}
}