SIPSorcery.Cli 0.2.0-beta

Prefix Reserved
This is a prerelease version of SIPSorcery.Cli.
dotnet tool install --global SIPSorcery.Cli --version 0.2.0-beta
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local SIPSorcery.Cli --version 0.2.0-beta
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=SIPSorcery.Cli&version=0.2.0-beta&prerelease
                    
nuke :add-package SIPSorcery.Cli --version 0.2.0-beta
                    

SIPSorcery.Cli

The sipsorcery command: route and bridge live media streams between SIP, WebRTC and the major realtime fabrics (Cloudflare Realtime, LiveKit) and AI agents (OpenAI Realtime), built on the SIPSorcery library.

Where the sibling sipsorcery-diags tool is a probe/test/benchmark harness, this tool treats a stream as the primitive and lets you attach edges to it. It is human and agent friendly: every verb supports --json and meaningful exit codes.

Install

dotnet tool install -g SIPSorcery.Cli --prerelease

The installed command is sipsorcery:

sipsorcery route --from testpattern --to out.ivf -d 10

Usage

# Route (v0.1): wire a source edge to one or more sink edges over a stream graph. The stream is the
# noun; --from / --to attach edges to it. The graph repacketises, it does not transcode -- frames
# travel encoded from source to sink. See "The route verb" below.
sipsorcery route --from testpattern --to out.h264 -d 10         # generate H264, record to Annex B
sipsorcery route --from testpattern --to play                  # generate H264, watch in ffplay
sipsorcery route --from testpattern --to play --to out.h264    # tee: watch AND record at once
sipsorcery route --from whep:https://b.siobud.com/api/whep --to out.ivf --token key  # record a live WebRTC stream
sipsorcery route --from sip:music@iptel.org --to whip:http://localhost:8080/whip      # bridge a SIP call to WebRTC
sipsorcery route --from sip:music@iptel.org --to whip:http://localhost:8080/whip --scope  # ...and add an audio-scope video
sipsorcery route --from testpattern --to whip:https://b.siobud.com/api/whip --token key  # publish to Broadcast Box (H264 + opus, both now the default)
sipsorcery route --from testpattern --to web                   # watch in a browser: self-hosts a WHEP server + player on http://localhost:8080
sipsorcery route --from sip:music@iptel.org --to web:9000 --scope          # bridge a SIP call (opus if the peer supports it, else G.711 auto-transcoded) to a browser page with an audio-scope video
sipsorcery route --from testpattern --to livekit:demo-room                 # publish into a LiveKit room (creds via --url/--api-key/--api-secret or LIVEKIT_* env)
sipsorcery route --from livekit:demo-room --to web                         # ...and in a SEPARATE process, subscribe to that room and watch it in a browser
sipsorcery route --from testpattern --to cloudflare                        # publish into a Cloudflare Realtime SFU session (creds via --app-id/--token or CLOUDFLARE_APPID/CLOUDFLARE_API_TOKEN)
sipsorcery route --from cloudflare:<sessionId> --to web                    # ...and in a SEPARATE process, pull that session (id printed by the sink) and watch it in a browser

# Bridge: connect two duplex endpoints BOTH ways (full duplex). Where route is directional (--from/--to),
# bridge is symmetric and order-agnostic. v0.1 endpoints: web (a browser mic+speaker page), agent (an
# Azure speech-to-text -> LLM -> Azure text-to-speech voice agent), openai (the OpenAI Realtime API) and
# sip:<uri> (a phone call, transcoded G.711 <-> Opus). See "The bridge verb" below.
sipsorcery bridge web agent                    # open a browser and TALK to an Azure voice agent (e.g. Max Headroom)
sipsorcery bridge web openai                   # ...or talk to an OpenAI Realtime agent (OPENAI_API_KEY)
sipsorcery bridge web openai --avatar          # ...with a lip-synced video face (mouth driven by the speech envelope)
sipsorcery bridge sip:alice@example.com agent  # ...or let a PHONE caller talk to the agent (G.711 <-> Opus transcoded)

# The realtime fabrics (Cloudflare, LiveKit, OpenAI) have no standalone verbs: each is reached as a
# route/bridge edge, shown above. Probe/test commands for them live in the sibling sipsorcery-diags tool.

The route verb

route is the first cut of the stream router: rather than a one-shot diagnostic, it treats a media stream as the primitive and lets you attach edges to it. A graph is a source node, one or more sink nodes, and the directed edges between them; a routing decision is just a mutation of that graph. v0.1 implements the simplest shape — one source fanned out to N sinks (a free tee).

Two design rules carry through from the bigger picture:

  • Repacketise, not transcode. Frames travel the edges still encoded (VP8 stays VP8, H264 stays H264). The tool stands in the media path only far enough to depacketise and re-emit; it never decodes to samples. Per-sample work (a real transcode) is the job of a future transform node backed by ffmpeg, not managed code.
  • Edges are addressable. A --from / --to spec is either a bare keyword/path (out.ivf, play, null, -) or scheme:rest (whep:https://host/whep). Adding a transport edge is one new case in the edge factory and nothing else.

Edges:

Direction Edge Notes
--from testpattern A generated H264 video pattern + music (codec via --audio-codec: opus default / pcmu / pcma), so it can feed a whip: sink with audio and video. --fps sets the video rate.
--from whep:<url> A live WebRTC ingress (full ICE/DTLS/SRTP). --token sets a bearer/stream key.
--from sip:<uri> Place a SIP call and forward its received audio. It offers opus first, then a G.711 fallback, so a modern endpoint is carried in opus end to end and a G.711-only gateway is auto-transcoded up to the graph codec (opus by default) — no flag needed either way. sip:music@iptel.org, sips:… or a bare user@host; -u/--password authenticate.
--from livekit:<room> Subscribe to a LiveKit room and emit the received H264 video + OPUS audio. Needs --url/--api-key/--api-secret (or LIVEKIT_* env). Mints its own subscribe-only token, so it only needs the room name + creds. Start the publisher first so the track is in the room when this joins.
--from cloudflare:<sessionId> Pull (subscribe to) a Cloudflare Realtime SFU session published by the cloudflare sink, emitting its received video + audio. The session id is the one the sink printed; the pulled track names are the sink's (cli-video/cli-audio). Needs --app-id/--token (or CLOUDFLARE_APPID/CLOUDFLARE_API_TOKEN). Start the publisher first.
--to file path VP8 written as IVF, H264/H265 as Annex B.
--to play An ffplay window (decode delegated to ffplay).
--to null Discard — exercises the pipeline headlessly and reports throughput.
--to - The bitstream on stdout (the result JSON then moves to stderr).
--to whip:<url> Publish to a WebRTC (WHIP) endpoint as a send-only audio (--audio-codec: opus default / pcmu / pcma) + H264 video peer connection. --token sets the Authorization. Opus is the default, so endpoints that require it (e.g. Broadcast Box) just work.
--to web[:port] Self-host a WHEP server + player page on http://localhost:<port> (default 8080) and stream to any browser that opens it. Many tabs can watch at once — each gets its own peer connection fanned from the one graph tee. H264 video + --audio-codec audio (opus is the most broadly supported). --token gates the WHEP endpoint; --no-open suppresses the browser auto-launch (also never opened with --json).
--to livekit[:room] Publish into a LiveKit room (default a random name) as a send-only H264 + OPUS participant. Needs --url/--api-key/--api-secret (or LIVEKIT_WEBSOCKET_URL/LIVEKIT_API_KEY/LIVEKIT_API_SECRET); requires opus, which is the default. The CLI mints its own publisher token locally, so a separate subscriber only needs the same room name + creds.
--to cloudflare Publish into a Cloudflare Realtime SFU session as a send-only H264 + --audio-codec peer (opus recommended). Needs --app-id/--token (or CLOUDFLARE_APPID/CLOUDFLARE_API_TOKEN). Cloudflare allocates the session id, so the sink prints it plus the track names; a subscriber pulls the media by (session id, track name).
The --scope audio-scope video

With a sip: source, --scope adds a second video track that is a live visualisation of the call audio — a moving waveform (--scope-mode waves, the default) or a scrolling spectrum (--scope-mode spectrum), sized with --scope-size WxH. The forwarded audio still travels repacketised; the scope is the one transform that decodes to samples, and that rendering plus its H264 encoding is delegated to an external ffmpeg process (the showwaves/showspectrum filters + libx264, the same way the sinks shell out to ffplay) — never a managed node. ffmpeg must be on the PATH or located with --ffmpeg-path.

sipsorcery route --from sip:music@iptel.org --to whip:http://localhost:8080/whip --scope --scope-mode spectrum
The web sink (watch in a browser)

--to web turns the CLI into its own viewer. It binds a local HttpListener that serves a small auto-connecting HTML player at http://localhost:8080/ (pick another port with web:9000) and answers the browser's WHEP request, streaming the graph's H264 video + audio over WebRTC. Unlike whip:, which dials out to a remote endpoint and blocks until connected, this listens for viewers — so it never blocks waiting for one. Open the URL whenever, in as many tabs as you like, each becoming its own peer connection fanned from the one graph tee. The browser opens for you (suppress with --no-open, or --json); the run continues until ctrl-c. The page and the WHEP signalling share one port (GET / serves the player, POST /whep does the offer/answer), and it binds localhost only.

sipsorcery route --from testpattern --to web                 # open http://localhost:8080 and watch
sipsorcery route --from sip:music@iptel.org --to web --scope

The livekit: and cloudflare: source and sink are both wired — publish into a room / SFU session, and subscribe to (pull) one — so running the two in separate processes is a full round trip (route --from testpattern --to cloudflare in one, route --from cloudflare:<id> --to web in another). sip: as a sink is recognised but reports it is not wired into route yet; it becomes a route edge in a later version, at which point route --from sip:… --to livekit:room bridges a call into a room with no bespoke command.

The bridge verb

route is directional — --from--to. A conversation is duplex, and --from/--to reads wrong for that, so the duplex case has its own verb: bridge <a> <b> connects two endpoints both ways (ab and ba). The endpoints are symmetric / order-agnosticbridge web agent and bridge agent web are the same.

v0.1 endpoints:

Endpoint Role
web A browser as a microphone + speaker over one send/recv Opus peer connection (self-hosts the page on --port, default 8080, and opens it automatically — suppress with --no-open, also never opened with --json). When the other side is an agent, the live conversation transcript is logged to the browser console ([you] … / [ai] …) over a data channel.
agent A locally-assembled voice agent: Azure speech-to-text → LLM → Azure text-to-speech, with a Max Headroom persona by default. You speak, it listens, thinks and replies.
openai A voice agent backed by the OpenAI Realtime API — OpenAI does the listening, thinking and speaking in one hop. Opus passes straight through both ways (repacketise, not transcode). Needs an OpenAI key (--api-key or OPENAI_API_KEY); --voice picks a Realtime voice (marin, alloy, verse, …), --persona sets the system prompt.
sip:<uri> A SIP call as a full-duplex peer — place a call to sip:user@host (or a bare user@host) and bridge the caller to the other side, so a phone can talk to a voice agent. The G.711 call is transcoded to/from 48 kHz Opus at the boundary (audio is cheap to transcode in managed code), so it drops in against web, agent or openai. --user/--password authenticate if challenged. With --avatar the call becomes a video call (a send-only H264 m-line carrying the agent's face); a phone that can't do video just declines it and the call stays audio-only.
# Set the agent's credentials (or pass --azure-key/--azure-region/--llm), then:
export AZURE_SPEECH_KEY=…  AZURE_SPEECH_REGION=westeurope
export LLM_ENDPOINT=http://localhost:11434/v1/chat/completions  LLM_MODEL=llama3.2   # e.g. Ollama
sipsorcery bridge web agent

Open the page, grant the microphone, and talk to Max — full duplex (the browser's echo canceller keeps the agent out of its own mic, so you can interrupt). --voice, --persona, --greeting, --llm /--llm-model/--api-key tune it; without an --llm the agent just repeats what it heard.

Or talk to an OpenAI Realtime agent instead — same browser, no Azure or local LLM, OpenAI does the whole listen→think→speak in one hop:

export OPENAI_API_KEY=sk-…
sipsorcery bridge web openai                                 # talk to OpenAI Realtime
sipsorcery bridge web openai --voice verse --persona "You are a terse pirate."

Add --avatar to put a lip-synced video face (the SkiaSharp Max Headroom avatar) on the voice — the browser then shows his face alongside the audio, on either agent:

sipsorcery bridge web agent  --avatar                       # mouth driven by Azure TTS visemes (phoneme-accurate)
sipsorcery bridge web openai --avatar                       # mouth driven by the speech envelope (no visemes)

The two avatars differ in how the mouth is driven: the Azure agent has a viseme timeline, so its lip-sync is phoneme-accurate; the openai voice has none, so its mouth is audio-driven from the speech envelope (reads as generic talking that tracks the speech).

Either agent also answers the phone: a sip:<uri> peer places a SIP call and bridges the caller to the other side, so someone on a handset can speak to the agent. The endpoints are symmetric, so the agent can be on either side:

sipsorcery bridge sip:alice@example.com agent          # call alice, bridge her to the Azure agent
sipsorcery bridge sip:alice@example.com openai         # ...or to the OpenAI Realtime agent
sipsorcery bridge sip:1001@pbx.local agent --user 1000 --password secret   # authenticate the call

The SIP leg is G.711 (8 kHz); every other endpoint speaks 48 kHz Opus, so this peer transcodes G.711 ↔ Opus at its boundary (audio is light enough to transcode in managed code — only video is delegated to ffmpeg). The agent greets the moment the call is answered.

Adding --avatar turns it into a video call: the SIP leg offers a send-only H264 m-line and the agent's lip-synced face is sent down it, so a video softphone (Linphone, Zoiper, …) shows the avatar while you talk to it — the same --avatar that paints the face in the browser, no extra flag:

sipsorcery bridge sip:alice@example.com agent  --avatar    # Azure agent, viseme lip-sync, on the phone
sipsorcery bridge sip:alice@example.com openai --avatar    # OpenAI agent, envelope lip-sync, on the phone

An audio-only phone simply rejects the video m-line and the call carries on as audio. Pluggable/external avatars are planned.

Exit codes

Code Meaning
0 Success.
1 Ran but did not achieve its goal (e.g. connected but no media flowed).
2 Invalid argument or option.
3 Timed out.
4 Transport/network error (DNS, connect, TLS).

Status

Early. The route engine carries audio and video over a single source → N sinks (a free tee), with whep:/sip:/livekit:/cloudflare: sources, whip:/web/livekit:/cloudflare sinks and an ffmpeg-backed audio-scope transform wired in. The bridge verb adds the duplex case (two endpoints both ways), with web, a local Azure voice agent, an openai Realtime agent and a sip: phone peer (transcoded G.711 ↔ Opus) as the endpoints, the agents each with an optional lip-synced --avatar. Streaming verbs default to -d 0 (run until ctrl-c); pass a positive --duration for a fixed window. Fan-in, richer transforms, the remaining transport edges, pluggable/external avatars, and the authoring layers above the graph (declarative routing, scripted policies, an external control API) are planned. Feedback and issues welcome on the SIPSorcery repo.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
0.2.0-beta 60 7/13/2026
0.1.0-beta 71 6/27/2026