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); + } } }