|
Download docs/sip.md from graphene-lab/AgentBridge: direct link, hf CLI and curl.
- Browser
- Download file 28.9 kB
-
https://huggingface.co/graphene-lab/AgentBridge/resolve/main/docs/sip.md
- Command line
-
hf download hf://graphene-lab/AgentBridge/docs/sip.md
-
curl -L -o sip.md https://huggingface.co/graphene-lab/AgentBridge/resolve/main/docs/sip.md
28.9 kB
| # SIP telephony β AgentBridge as a phone endpoint | |
| The server can act as a **SIP user agent**: incoming calls are auto-answered behind a | |
| DTMF-PIN gate (or a trusted-caller allow-list), outgoing calls can be placed from the TUI, | |
| and while a call is up the caller talks to the agents by voice β speech is recognized | |
| (whisper), sent through the exact same `AgentHarness` path as the HTTP API, and the | |
| agent replies are spoken back over the RTP audio with the in-process Kokoro TTS. | |
| Implemented with [SIPSorcery](https://github.com/sipsorcery-org/sipsorcery) 10.0.15 | |
| (BSD 3-Clause): the SIP signalling (UAS/UAC, REGISTER, RFC 4733 DTMF) and the RTP transport | |
| (G.711 codecs) are library-provided; the speech/agent loop is `SipBridge.cs`. | |
| ## Architecture | |
| ``` | |
| [Softphone / PSTN trunk] β SIP INVITE (UDP 5060) β SIPUserAgent β VoIPMediaSession | |
| β | |
| DTMF (RFC 4733) ββββββββββββββββββ€βββΊ PIN gate (3 attempts, 24 h lockout) | |
| β | |
| RTP in (G.711 8 kHz) ββΊ G.711 decode ββΊ PCM16 ββ΄βββΊ persistent voice subprocess | |
| (AIOffice.VoiceAgent --pipe-audio: | |
| VAD + whisper STT, Kokoro/SAPI TTS) | |
| β | |
| SessionStore + AgentHarness.ExecuteAction | |
| β | |
| RTP out βββ resample+encode βββ PCM 24 kHz βββ {"cmd":"speak","render":true} chunks | |
| ``` | |
| - **One call at a time.** A second incoming call gets `486 Busy Here`; `/sip call` while a | |
| call is active is refused. | |
| - **Wideband-first codecs.** The media offers **Opus (RFC 7587, mono) β G.722 β PCMA β PCMU** | |
| so the STT hears real 16 kHz audio (50 Hzβ7 kHz) instead of the upsampled narrowband G.711 | |
| band. Whisper is trained on 16 kHz; G.711's 300 Hzβ3.4 kHz band cuts the fricative energy | |
| (4β8 kHz) that matters for consonants β the same reason Google Cloud STT ships a dedicated | |
| `telephony` model for 8 kHz and rates 16 kHz as "optimal". G.722 (payload type 9) is the | |
| classic SIP wideband codec (16 kHz, 64 kbps β same bandwidth as G.711); Opus decodes to | |
| 16 kHz mono. The entry point relays RTP transparently, so the codec is negotiated | |
| end-to-end with the softphone (enable G.722/Opus in the client's codec list); narrowband | |
| clients/trunks fall back to G.711 automatically. The in-band DTMF detector adapts to the | |
| negotiated sample rate (8 or 16 kHz). | |
| - **Speech engines live in ONE subprocess** β architectural rule (see AIOrchestrator | |
| ARCHITECTURE.md "Media as I/O"): the media is only transport. `SipBridge` runs the | |
| cross-platform `AIOffice.VoiceAgent --pipe-audio` as a **persistent** process: decoded RTP | |
| PCM (16 kHz straight through for G.722/Opus; G.711 is upsampled 8β16 kHz) is pushed via | |
| `{"cmd":"audio"}`; **VAD + whisper stay in the | |
| subprocess** (no duplicated VAD in the bridge); replies are rendered with | |
| `{"cmd":"speak","render":true}` and the 24 kHz PCM chunks come back as `{"type":"audio"}`. | |
| The process lives for the whole server lifetime β the whisper model stays loaded | |
| (**persistent STT**: no per-utterance model-load cost). **Recognition is continuous**: the | |
| subprocess never stops/restarts the recognizer between turns (whisper.net has no streaming | |
| mode β the continuous part is the capture+VAD, which stays up); instead the caller's capture | |
| is **muted** while the agent's TTS plays (RTP packets are dropped β a hands-free caller would | |
| otherwise echo the reply back and the VAD would transcribe our own answer into an endless | |
| loop) and for **500 ms after** the last chunk, because the echo of the reply comes back | |
| delayed by network + phone latency. The VAD state ("speech"/"end") is streamed to the bridge | |
| as `{"type":"vad","state":β¦}` so it can time the processing indicator. | |
| - The **conversation** is the SHARED agentic voice conversation (`AIOrchestrator | |
| ExecuteActionStream` with `isVoiceChat: true` β see `VoiceConversation.StreamToMediaAsync`): | |
| the same loop the AIOffice Voice panel uses, so the phone gets the same tools, the same | |
| concise "speakable" prompt, the same markdown/emoji stripping and sentence splitting. The | |
| only difference between Voice and SIP is the renderer (RTP TTS here, subprocess there) and | |
| the input path. The conversation session is created per call (`SessionStore.Create`) and | |
| disposed by the normal idle cleanup: the SIP path never touches the HTTP sessions, so a | |
| chat going on via the API cannot be polluted by phone calls (and vice versa). | |
| - The **PIN gate** is the shared `PinAuthGate` (AIOrchestrator) β media-agnostic, wired here | |
| for DTMF; the lockout persistence (sipstate.json) stays in SipBridge. | |
| ## Configuration (`appsettings.json` β `Sip`) | |
| Every key is overridable from the command line (`--Sip:Pin 12345`, ...). | |
| | Key | Default | Description | | |
| |---|---|---| | |
| | `Enabled` | `false` | Master switch β the SIP channel binds only when true | | |
| | `ListenPort` | `5060` | UDP port for SIP signalling | | |
| | `Registrar` | `""` | Optional provider/trunk, e.g. `sip:provider.example:5060`. When set, the server REGISTERs with `Username`/`Password` and receives/places calls through the provider (see [Provider/NAT](#provider-trunk-and-nat)) | | |
| | `Username` / `Password` | `""` | Credentials for REGISTER and authenticated calls | | |
| | `AnswerMode` | `"pin"` | Incoming-call gate: `pin` (default), `allowlist` (P-Asserted-Identity), `none` | | |
| | `Pin` | `""` | The 5-digit DTMF PIN (any length; DTMF validates when the buffer matches it) | | |
| | `MaxPinAttempts` | `3` | Wrong-PIN attempts before the lockout β **cumulative across calls**: a redial does not reset the counter; only a correct PIN or the lockout expiry does | | |
| | `LockoutHours` | `24` | Lockout duration after the attempts are exhausted | | |
| | `RegisterExpiry` | `60` | REGISTER expiry/refresh interval in seconds. Keep it low (60) behind home NAT: consumer routers drop the NAT mapping long before the SIP default 300 s, and inbound calls between refreshes would go unanswered | | |
| | `PinTimeoutSeconds` | `60` | Seconds to wait for the PIN before the server hangs up the call (min 10). A call left in the PIN gate with no digits ends itself with a spoken notice | | |
| | `IndicatorDelaySeconds` | `2` | Seconds after the caller's speech ends before the looped "data processing" cue starts (the processing indicator). The cue is armed by the subprocess VAD `end` event (speech ended β STT/LLM processing began) β never by the transcript, which can arrive seconds later or not at all if background noise keeps the utterance open | | |
| | `AllowedCallers` | `[]` | Trusted caller URIs that skip the PIN in `allowlist` mode | | |
| | `Agent` | `"default-agent"` | Agent set used for the conversation β any preset id (see `GET /v1/models`): `default-agent`, `web-agent`, `search-agent`, `research-agent`, `document-files`, `spreadsheet-files`, `email-agent`, `office-files`, `multi-files`, `all-files` | | |
| | `Lang` | system language | Two-letter ISO language for STT/TTS and the announcements (Italian default when empty on an Italian machine) | | |
| | `SttExePath` | `<server>\voiceagent-stt\` | Path to the `AIOffice.VoiceAgent` executable | | |
| | `SttModel` | `small` | Whisper model for the STT subprocess (`tiny/base/small/medium/largev2/largev3`). **`base` is the latency pick** β re-tested 2026-08-22 with the current pipeline (8β16 kHz upsample + adaptive VAD + multicore): it transcribes real Italian correctly and is ~2x faster than small-q8_0 (VadTest: 2.2β4.4 s vs 7.0β8.3 s whisper inference per 7.4 s utterance). `small` keeps an extra accuracy margin on very noisy lines. Never go below base β tiny is unusable | | |
| | `SttQuant` | `q8_0` | Whisper quantization (`empty/q4_0/q4_1/q5_0/q5_1/q8_0`). `q8_0` is ~15% faster than FP16 with minimal accuracy loss β kept as the default; the bigger win is the **multicore** transcription (all logical processors; measured 8.3 s β 4.9 s per utterance on a 6c/12t CPU) | | |
| | `SttDevice` | `auto` | STT accelerator passed to the voice subprocess as `AIOFFICE_WHISPER_DEVICE`: `auto` (default) lets the whisper.net loader probe Cuda β Cuda12 β Vulkan β CPU and **falls back to CPU automatically** when no usable GPU/driver is present; `cuda`/`vulkan` force that backend; `cpu` skips the probe (no one-time delay on machines whose GPU cannot run whisper, e.g. Turing sm_75). GPU is an acceleration, never a prerequisite β the distributed package works on every OS as-is (the CPU runtime is always bundled) | | |
| | `RtpPortRange` | `""` | Fixed RTP port range, e.g. `"40000-41000"` (firewalled deployments); empty = ephemeral ports | | |
| ## TUI commands | |
| | Command | Meaning | | |
| |---|---| | |
| | `/sip status` | Live state: enabled/listening, registered, answer gate, call phase, PIN attempts left, lockout expiry, STT/TTS availability | | |
| | `/sip config` | Show the effective configuration (secrets masked) β keys match appsettings.json | | |
| | `/sip config set <key> <value>` | Change one config key and persist it to appsettings.json (live-apply when possible; see [Configuring from the TUI](#configuring-from-the-tui)) | | |
| | `/sip config reload` | Re-read the `Sip` section from appsettings.json (hand edits made outside the TUI) and apply it live | | |
| | `/sip call <sip-uri>` | Place an outgoing call (`sip:user@host`, or a bare number routed via the registrar) | | |
| | `/sip answer on\|off` | Toggle the incoming-call auto-answer gate | | |
| | `/sip hangup` | Hang up the active call | | |
| The status bar shows a `sip:` segment (β idle, `ring`, `pin`, `call`) refreshed by a | |
| 3-second poll. The same operations are exposed over HTTP for scripts/clients: | |
| | Endpoint | Body | Purpose | | |
| |---|---|---| | |
| | `GET /v1/sip/status` | β | Same state as `/sip status` | | |
| | `GET /v1/sip/config` | β | Same state as `/sip config` (secrets masked) | | |
| | `POST /v1/sip/config` | `{"key": "Pin", "value": "12345"}` | Set one config key (persisted to appsettings.json) | | |
| | `POST /v1/sip/config/reload` | `{}` | Re-read the `Sip` section from appsettings.json | | |
| | `POST /v1/sip/call` | `{"uri": "sip:user@host"}` | Outgoing call (blocks until answered/failed, ring timeout 60 s) | | |
| | `POST /v1/sip/hangup` | `{}` | Hang up | | |
| | `POST /v1/sip/answer` | `{"on": false}` | Toggle the answer gate | | |
| ## Configuring from the TUI | |
| The single source of truth is the `Sip` section of `appsettings.json` (a runtime file: | |
| updates never overwrite it β see docs-dev/RELEASING.md (developers, not shipped)). `/sip config` reads it through the server, | |
| `/sip config set` writes it back, so TUI edits and manual JSON edits converge on the same | |
| file: | |
| ``` | |
| /sip config β show the effective config (PIN/password masked) | |
| /sip config set Pin 12345 β change + persist, live if possible | |
| /sip config set AnswerMode none β change + persist | |
| /sip config set AllowedCallers +393331234567,+390212345678 β comma-separated list | |
| /sip config reload β re-apply hand edits made directly in appsettings.json | |
| ``` | |
| - **Keys** are the appsettings.json property names, case-insensitive: `Enabled`, | |
| `ListenPort`, `Registrar`, `Username`, `Password`, `AnswerMode`, `Pin`, | |
| `MaxPinAttempts`, `LockoutHours`, `RegisterExpiry`, `PinTimeoutSeconds`, | |
| `IndicatorDelaySeconds`, `AllowedCallers` | |
| (comma-separated), `Agent`, `Lang`, `SttExePath`, `SttModel`, `SttQuant`, `SttDevice`, | |
| `RtpPortRange`. Booleans accept `true/false/1/on`. | |
| - **Live vs restart.** PIN-policy keys (`Pin`, `MaxPinAttempts`, `LockoutHours`) and the | |
| per-call keys (`AnswerMode`, `AllowedCallers`, `Agent`, `Lang`, `SttExePath`) apply | |
| immediately to the next call. Transport-level keys (`Enabled`, `ListenPort`, `Registrar`, | |
| `Username`, `Password`, `RtpPortRange`, `RegisterExpiry`) restart the SIP channel | |
| automatically (a new REGISTER is sent, the UDP socket is re-bound); if a call is active | |
| the change is refused with "hang up first". A lockout in progress survives a PIN change | |
| (the 24 h counter is carried over); the wrong-attempt counter resets with a new PIN. | |
| - **Secrets never leave the server**: `/sip config` reports only whether `Pin`/`Password` | |
| are set; `set` never echoes them back in the response message. | |
| ## Incoming call flow | |
| 1. INVITE β if another call is active, the server answers `486 Busy Here` at the transport | |
| level (the shared user agent alone would silently drop the INVITE); if the answer gate is | |
| off the same rejection is sent. | |
| 2. **Locked out** (attempts exhausted in the last 24 h) β the call is answered, a spoken | |
| notice explains the lockout, then the server hangs up. | |
| 3. Gate check: | |
| - `AnswerMode: "none"` β straight to the conversation. | |
| - `AnswerMode: "allowlist"` β the P-Asserted-Identity (set by an **authenticating** | |
| provider/trunk) is matched against `AllowedCallers` (full URI or user part). PAI is | |
| honored only when the INVITE actually comes from the configured `Registrar` host β in | |
| direct SIP it is ignored (spoofable). A match skips the PIN; otherwise it falls back | |
| to the PIN when one is configured. A startup warning is logged when `allowlist` is | |
| used without a registrar. | |
| - `AnswerMode: "pin"` (default) β the caller hears a welcome prompt and must type the | |
| PIN on the keypad (DTMF accepted from any transport β see step 4). | |
| 4. **PIN validation** β the keypad digits are accepted from **any transport**: RFC 4733 RTP | |
| events (the standard), SIP INFO bodies (`application/dtmf-relay` β handled at the | |
| transport level and routed by the entry point) and **in-band tones** (Goertzel detection, | |
| active only during the PIN phase β once the conversation starts, speech is never misread | |
| as digits). Each PIN-length burst is one attempt; overflow digits stay in the buffer for | |
| the next attempt. Wrong β "attempts left: N". After `MaxPinAttempts` wrong attempts | |
| **in total** (across calls β hanging up and redialing does not reset the counter) the | |
| server announces the lockout, hangs up and persists `locked_until` to | |
| `%LocalAppData%\agent\sipstate.json` (survives restarts). Only a correct PIN or the | |
| lockout expiry resets the counter; the digit buffer is cleared on every new call so a | |
| partially typed PIN never concatenates across calls. If no digit arrives within | |
| `PinTimeoutSeconds`, the call ends with a spoken notice. | |
| 5. **Conversation** β the caller's speech is transcribed per utterance (VAD: adaptive noise | |
| floor + 500 ms silence) and fed to `AgentHarness.ExecuteAction` with the configured | |
| agent set; the reply is chunked into sentences and spoken back. Replies are sanitized | |
| before synthesis (emoji/symbols stripped β the Kokoro phonemizer rejects them). | |
| **Robustness to background noise**: the decoded G.711 stream passes through an 80 Hz | |
| high-pass filter (removes hum/rumble) before the VAD; the audio is upsampled 8β16 kHz | |
| before transcription (whisper is trained on 16 kHz); and transcripts consisting ONLY of | |
| non-speech placeholders (`[Musica]`, `[Rumore]`, ...) are dropped β background music is | |
| never sent to the LLM, so the agent cannot "answer the music". | |
| 6. **Processing indicator** β while the agent computes (STT/LLM/tools, which can take several | |
| seconds), the caller would hear silence and wonder if the line dropped. So `IndicatorDelaySeconds` | |
| (default 2 s) after the caller's speech ENDS, a looped "data processing" cue | |
| (`assets/processing-indicator.wav`, 10 s, 24 kHz mono β trimmed from a freesound preview via | |
| `e2e/Mp3ToWav`) is sent to the caller over RTP, repeating until the first reply chunk arrives. | |
| The cue is **armed by the subprocess VAD `end` event** (speech closed β transcription began), | |
| not by the transcript: whisper can take seconds to return, and with background noise the | |
| utterance may never close at all β arming on the VAD guarantees the caller always gets feedback | |
| the moment processing starts. While the caller speaks again (VAD `speech`), the cue is paused | |
| so it never beeps over their own voice; a 25 s hard cap stops it if STT/LLM stalls. The agent's | |
| reply is streamed in chunks (the LLM produces each next part with seconds of latency in | |
| between): the cue stops while a reply part plays and **re-arms between parts**, so the caller | |
| always knows the turn is still working β the cue stopping for good is the "turn is over" | |
| signal. The cue is | |
| sent through the SAME media path as the replies (never played locally); the TTS queue tags the | |
| cue pieces and discards them the moment the real reply starts (400 ms pieces β the reply is | |
| delayed by at most ~400 ms). The asset is normalized to near-full-scale before playback (RTP | |
| has no volume knob β the PCM amplitude IS the volume); it ships with the server, and if missing | |
| the indicator is simply skipped. | |
| ## Robustness | |
| - **Self-healing user agent**: SIPSorcery's shared user agent occasionally fails to clear | |
| its internal dialog state after a hangup, which would silently drop every later INVITE. | |
| A watchdog (5 s) rebuilds it whenever the server and the agent disagree on the call state | |
| β both when the agent still believes a call is active without one, and when the server | |
| still holds an orphaned call the agent reports as inactive. New calls are never lost for | |
| long. The orphan-clear only fires once the call is fully established (`MediaAttached`): | |
| between "Call registered" and "Answer/Attach complete" a fresh call has no dialog yet and | |
| must never be misread as an orphan β clearing it mid-setup would make the DTMF PIN gate go | |
| dead (reproduced by an immediate call colliding with the 5 s tick). The same guard skips | |
| the rebuild while an INVITE is being set up (the call gate is held for the whole setup). | |
| - **Serialized TTS rendering**: the voice subprocess renders ONE speak at a time. A new speak | |
| can start while a previous one is still rendering (the PIN accepted while the welcome is | |
| still streaming, a lockout notice landing mid-announcement, an agent-initiative turn racing | |
| the reply loop). The newcomer WAITS for the in-flight render instead of failing: an | |
| exception there would fault a fire-and-forget task and silently kill the conversation | |
| (phase stays Conversation, `StartConversation` never runs, transcripts are dropped β the | |
| call goes dead with no reply and no processing indicator). The slot is cleared only by its | |
| owner (`ReferenceEquals`), so a newer speak can never lose its "done" resolution. | |
| - **In-call DTMF** (RFC 4733 events) is filtered out of the speech path β keypad tones never | |
| reach the STT. | |
| - **Hold re-INVITE** from the remote party is answered by the library and the call survives. | |
| ## Identity and security | |
| - The SIP `From` header is **spoofable** by any client β it is never trusted alone. | |
| - `P-Asserted-Identity` (RFC 3325) is trusted only when the INVITE comes from the | |
| configured `Registrar` host β a SIP domain that authenticated the caller before | |
| forwarding (provider/trunk). That is the only setup where the allow-list can replace the | |
| PIN with certainty; direct SIP ignores PAI (the code never trusts it from an arbitrary | |
| source address). | |
| - Default configuration is `pin` mode: safe even when the server is dialled directly by IP. | |
| - RTP/DTMF travel in clear text unless SRTP is negotiated; the lockout bounds brute force | |
| anyway. TLS for the signalling (provider requirement) is not implemented yet. | |
| ## Provider/trunk and NAT | |
| - **Direct dial (public IP / LAN)**: bind `ListenPort` (e.g. 5060) and open the RTP range | |
| (`RtpPortRange`, e.g. 40000-41000 UDP) on the firewall. Softphones call | |
| `sip:agent@<host>`. | |
| - **Via a provider/trunk (DID/PSTN or a VoIP trunk with authentication)**: set `Registrar`, | |
| `Username`, `Password`. The server REGISTERs and the provider routes calls to it β no | |
| public SIP port needed; set `AnswerMode: "allowlist"` with your own numbers to skip the | |
| PIN (PAID from the trunk is reliable). | |
| ## Minimal SIP entry point (smartphone β AgentBridge) | |
| When AgentBridge lives behind NAT (home PC, no router access) and the smartphone is on | |
| mobile data, a tiny VPS can act as the **SIP entry point**: a pure relay that **only | |
| smista** signalling and RTP β **no codec handling, no transcoding, no voicemail** β so a | |
| low-end box (~256 MB) is enough. Both sides are *clients* of the entry point: AgentBridge | |
| REGISTERs to it (outbound β no inbound port needed on the home router), the smartphone | |
| dials it, and the entry point routes the call to AgentBridge's registration. RTP is | |
| force-relayed through **rtpengine**, so neither side ever needs to reach the other directly. | |
| ``` | |
| A = AgentBridge (PC, NAT) B = 195.20.235.5 (entry point) C = smartphone (mobile NAT) | |
| A ββREGISTERβββΊ B βββINVITEββ C (signalling) | |
| A ββββRTPβββββββΊ B βββββRTPβββ C (media, relayed, no codecs) | |
| A βββββββββββββ B βββββββββββΊ C | |
| ``` | |
| This is also the production architecture: AgentBridge behaves like a normal SIP client β | |
| it initiates the connection and stays registered, so no firewall changes are ever needed | |
| at its location. | |
| > **Full guide + unattended installer**: see `docs/sip-entry/README.md` (step-by-step) and | |
| > `docs/sip-entry/setup-entrypoint.sh` β one command installs Kamailio + rtpengine, writes | |
| > the configs, generates the shared secret and prints the AgentBridge/phone settings. | |
| > Interactive config generators for the AgentBridge side (same keys as `/sip config`): | |
| > `scripts/sip-config.bat` / `sip-config.sh`. | |
| **Software** (Debian/Ubuntu, on the entry point) β installs `kamailio kamailio-extra-modules | |
| rtpengine` (rtpengine, not rtpproxy: Debian 12 has no rtpproxy package) and deploys the | |
| configs, all in one command (the script in `docs/sip-entry/`, auto-generated credentials): | |
| ```bash | |
| sudo bash docs/sip-entry/setup-entrypoint.sh | |
| ``` | |
| `kamailio-extra-modules` provides the `rtpengine`/`nathelper` modules. `rtpengine` runs as a | |
| daemon (`systemctl enable --now rtpengine-daemon`). | |
| The authoritative Kamailio config (digest-auth REGISTER for the AgentBridge AOR, any-user | |
| 200-OK registration for the phone, rtpengine relay, NAT handling) lives in | |
| **`docs/sip-entry/kamailio.cfg`** and is deployed by `setup-entrypoint.sh` β use those | |
| files, not a pasted copy. | |
| **AgentBridge side** (on the home PC) β the server becomes a client of the entry point: | |
| ``` | |
| /sip config set Enabled true | |
| /sip config set Registrar sip:195.20.235.5:5060 | |
| /sip config set Username agent | |
| /sip config set Password <shared secret> # must match the HA1 on the server | |
| /sip config set ListenPort 6070 # non-standard port: many home ISPs (e.g. Italian | |
| # lines) silently drop INBOUND UDP on 5060 β the | |
| # REGISTER replies would never reach the agent | |
| /sip config set RegisterExpiry 60 # keep the home-NAT mapping alive (see below) | |
| /sip config set Pin 12345 | |
| /sip config set Lang it | |
| ``` | |
| Each `set` persists to `appsettings.json` and the transport restarts automatically (the | |
| REGISTER goes out with the new credentials). `/sip status` shows `registered on` when the | |
| entry point accepted it. **ListenPort matters**: the ISP/modem often blocks inbound UDP on | |
| the standard SIP port 5060 while allowing it on any other port β if the registration never | |
| completes (the agent keeps retransmitting REGISTER without ever receiving the 401/200), | |
| move the local listener to a non-standard port (e.g. 6070) with `/sip config set ListenPort | |
| 6070`. The entry point routes replies to the port the REGISTER came from, so no change is | |
| needed on the server side. | |
| **Firewall** on the entry point: open `5060/udp` (SIP) and `40000-41000/udp` (RTP relay) | |
| to the internet. On Debian 12 the relay is **rtpengine** (no `rtpproxy` package exists): | |
| userspace-only (`table = -1`), control socket `udp:127.0.0.1:2223`, media ports | |
| 40000-41000 (see `docs/sip-entry/rtpengine.conf`). AgentBridge and the phone need no open | |
| ports at all. | |
| **Verification** from the entry point (needs the `ctl` module loaded β see | |
| `docs/sip-entry/kamailio.cfg`; without it `kamcmd` is unavailable and the registration can | |
| be confirmed from the agent side with `/sip status`): | |
| ```bash | |
| sudo kamcmd usrloc.dump | grep -A3 agent # AgentBridge's registration present | |
| sudo kamcmd core.uptime | |
| ``` | |
| Then dial `sip:agent@195.20.235.5` from the phone (Linphone: account with the proxy set | |
| to `195.20.235.5`, transport UDP, no auth for calls) β the PIN prompt is spoken, DTMF | |
| keys validate (`/sip status` on the PC shows the phase: `pin` β `call`). | |
| **Variant β static forwarding** (only when AgentBridge has a reachable IP, e.g. a public | |
| VPS): skip the `usrloc`/`registrar` modules and forward every INVITE with | |
| `$du = "sip:<agentbridge-ip>:5060"` instead of `lookup("location")`. No registration | |
| needed on the AgentBridge side. | |
| ## Deploying the speech-to-text executable | |
| `voiceagent-stt/` must contain the **cross-platform** `AIOffice.VoiceAgent` executable | |
| (whisper-based β not the Windows microphone one). The whisper model (~75-490 MB depending | |
| on `AIOFFICE_WHISPER_MODEL`) downloads automatically on first use. | |
| - **Windows (dev)**: the build copies it automatically from the sibling | |
| `AIOffice.VoiceAgent` repo into `voiceagent-stt/`. | |
| - **Linux/macOS (server)**: build/publish `AIOffice.VoiceAgent` for the target RID and copy | |
| the output folder to `voiceagent-stt/` next to `agent`. `GET /v1/sip/status` reports | |
| `stt_available`/`tts_available` so a missing executable is visible immediately. | |
| - The server falls back to a plain text-only loop if STT or TTS is unavailable (the SIP | |
| signalling and PIN gate keep working). | |
| ## Testing | |
| `e2e/SipSmoke` is a loopback end-to-end test: it launches the real `agent.exe` with SIP | |
| enabled and acts as a SIPSorcery softphone. It covers the PIN gate (correct PIN, | |
| partial-PIN buffer reset across calls, wrong-then-correct recovery, overflow digits, | |
| attempts cumulative across calls, empty-PIN config), DTMF isolation, hold re-INVITE, busy | |
| rejection (486), the answer-gate toggle, outbound calls (rejected and answering remotes, | |
| `/v1/sip/hangup`), invalid destinations, the 24 h lockout (arming, locked-notice, restart | |
| persistence, expiry), the allow-list, answer mode `none`, and the full voice loop (RTP | |
| speech β whisper β agent β TTS β RTP, twice): | |
| ```bash | |
| dotnet run --project e2e\SipSmoke -- path\to\agent.exe http://localhost:5390 5070 | |
| dotnet run --project e2e\SipSmoke -- --skip-voice path\to\agent.exe ... # no LLM needed | |
| dotnet run --project e2e\SipSmoke -- --voice-only path\to\agent.exe ... # media-path iteration: test 18 only | |
| ``` | |
| Test 18 (the voice loop) requires the default LLM provider to be reachable and is skipped | |
| with `--skip-voice`; the LLM bridge can stall or produce very long replies, so the voice | |
| loop runs last β it never contaminates the other checks. `--voice-only` skips straight to | |
| it (with the lockout state pre-expired) for fast iteration on the media path. | |
| **`e2e/SipClientTest`** is a standalone softphone for testing a REMOTE chain (entry point + | |
| AgentBridge): it dials `sip:agent@<host>`, answers the spoken PIN prompt with DTMF, speaks | |
| a bundled Italian TTS greeting and captures the agent's reply (saved as `reply.wav`): | |
| ```bash | |
| dotnet run --project e2e\SipClientTest # defaults: sip:agent@195.20.235.5, pin 12345 | |
| dotnet run --project e2e\SipClientTest -- sip:agent@host 12345 5071 45 --pin-wait=10 | |
| ``` | |
| ## Known limitations | |
| - Codecs: Opus / G.722 / G.711 (PCMU/PCMA) are offered (wideband first); no G.729, no SRTP | |
| variants, and the caller's endpoint must support at least one of the four. | |
| - One call at a time. | |
| - No SRTP, no SIP-over-TLS, no attended transfer, no voicemail. | |
| - While the agent's reply is spoken, the caller's speech is not captured (no barge-in). | |
| - The PIN prompt is spoken; keypad confirmation beeps are not emitted. | |
| - **GPU acceleration is opt-in**: build the STT subprocess with `-p:WhisperGpu=true` to bundle | |
| the whisper.cpp CUDA12/Vulkan runtimes (Vulkan is Windows-x64 only; on Linux the only GPU | |
| backend is CUDA). The default CPU-only build works on every OS with zero prerequisites; | |
| a GPU-enabled build still works on GPU-less machines (the whisper.net loader probes and | |
| falls back to CPU automatically). Tested 2026-08-23 on a GTX 1650 Ti (Turing sm_75): CUDA | |
| fails to load (as documented) and Vulkan loads but gives no speedup over CPU β the switch | |
| `SttDevice=cpu` skips the probe entirely. | |