30 Commits

Author SHA1 Message Date
0d82a626b5 v1.25.1
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-14 18:58:48 +00:00
30d056f376 fix(proxy-engine): respect explicit inbound route targets and store voicemail in the configured mailbox 2026-04-14 18:58:48 +00:00
89ae12318e v1.25.0
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-14 18:52:13 +00:00
feb3514de4 feat(proxy-engine): add live TTS streaming interactions and incoming number range support 2026-04-14 18:52:13 +00:00
adfc4726fd v1.24.0
Some checks failed
Docker (tags) / release (push) Failing after 2s
2026-04-14 16:35:54 +00:00
06c86d7e81 feat(routing): require explicit inbound DID routes and normalize SIP identities for provider-based number matching 2026-04-14 16:35:54 +00:00
cff70ab179 v1.23.0
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-14 10:45:59 +00:00
51f7560730 feat(runtime): refactor runtime state and proxy event handling for typed WebRTC linking and shared status models 2026-04-14 10:45:59 +00:00
5a280c5c41 v1.22.0
Some checks failed
Docker (tags) / release (push) Failing after 2s
2026-04-12 20:45:08 +00:00
59d8c2557c feat(proxy-engine): add on-demand TTS caching for voicemail and IVR prompts 2026-04-12 20:45:08 +00:00
cfadd7a2b6 v1.21.0
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-11 20:04:56 +00:00
80f710f6d8 feat(providers): replace provider creation modal with a guided multi-step setup flow 2026-04-11 20:04:56 +00:00
9ea57cd659 v1.20.5
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-11 19:20:14 +00:00
c40c726dc3 fix(readme): improve architecture and call flow documentation with Mermaid diagrams 2026-04-11 19:20:14 +00:00
37ba7501fa v1.20.4
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-11 19:17:39 +00:00
24924a1aea fix(deps): bump @design.estate/dees-catalog to ^3.71.1 2026-04-11 19:17:38 +00:00
7ed76a9488 v1.20.3
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-11 19:02:52 +00:00
a9fdfe5733 fix(ts-config,proxybridge,voicebox): align voicebox config types and add missing proxy bridge command definitions 2026-04-11 19:02:52 +00:00
6fcdf4291a v1.20.2
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-11 18:40:56 +00:00
81441e7853 fix(proxy-engine): fix inbound route browser ringing and provider-facing SDP advertisement while preventing RTP port exhaustion 2026-04-11 18:40:56 +00:00
21ffc1d017 v1.20.1
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-11 12:32:46 +00:00
2f16c5efae fix(docker): install required native build tools for Rust dependencies in the build image 2026-04-11 12:32:46 +00:00
254d7f3633 v1.20.0
Some checks failed
Docker (tags) / release (push) Failing after 3m53s
2026-04-11 12:01:54 +00:00
67537664df feat(docker): add multi-arch Docker build and tagged release pipeline 2026-04-11 12:01:54 +00:00
54129dcdae v1.19.2 2026-04-11 08:24:47 +00:00
8c6556dae3 fix(web-ui): normalize lucide icon names across SIP proxy views 2026-04-11 08:24:47 +00:00
291beb1da4 v1.19.1 2026-04-10 21:21:29 +00:00
79147f1e40 fix(readme): refresh documentation for jitter buffering, voicemail, and WebSocket signaling details 2026-04-10 21:21:29 +00:00
c3a63a4092 v1.19.0 2026-04-10 21:15:34 +00:00
7c4756402e feat(proxy-engine,codec-lib): add adaptive RTP jitter buffering with Opus packet loss concealment and stable 20ms resampling 2026-04-10 21:15:34 +00:00
63 changed files with 5936 additions and 3149 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
node_modules/
.nogit/
nogit/
.git/
.playwright-mcp/
.vscode/
test/
dist_rust/
dist_ts_web/
rust/target/
sip_trace.log
sip_trace_*.log
proxy.out
proxy_v2.out
*.pid
.server.pid

View File

@@ -0,0 +1,32 @@
name: Docker (tags)
on:
push:
tags:
- '*'
env:
IMAGE: code.foss.global/host.today/ht-docker-node:dbase_dind
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
jobs:
release:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @git.zone/tsdocker
- name: Release
run: |
tsdocker login
tsdocker build
tsdocker push

View File

@@ -8,5 +8,16 @@
"production": true "production": true
} }
] ]
},
"@git.zone/tsrust": {
"targets": ["linux_amd64", "linux_arm64"]
},
"@git.zone/tsdocker": {
"registries": ["code.foss.global"],
"registryRepoMap": {
"code.foss.global": "serve.zone/siprouter",
"dockerregistry.lossless.digital": "serve.zone/siprouter"
},
"platforms": ["linux/amd64", "linux/arm64"]
} }
} }

114
CLAUDE.md
View File

@@ -1,41 +1,103 @@
# Project Notes # Project Notes
## Architecture: Hub Model (Call as Centerpiece) ## Architecture: Hub Model in Rust (Call as Centerpiece)
All call logic lives in `ts/call/`. The Call is the central entity with N legs. The call hub lives in the Rust proxy-engine (`rust/crates/proxy-engine/`). TypeScript is the **control plane only** — it configures the engine, sends high-level commands (`hangup`, `make_call`, `webrtc_offer`, etc.), and receives events (`incoming_call`, `call_answered`, `device_registered`, `webrtc_audio_rx`, …). No raw SIP/RTP ever touches TypeScript.
### Key Files The `Call` is still the central entity: it owns N legs and a central mixer task that provides mix-minus audio to all participants. Legs can be `SipProvider`, `SipDevice`, `WebRtc`, or `Tool` (recording/transcription observer).
- `ts/call/call-manager.ts` — singleton registry, factory methods, SIP routing
- `ts/call/call.ts` — the hub: owns legs, media forwarding
- `ts/call/sip-leg.ts` — SIP device/provider connection (wraps SipDialog)
- `ts/call/webrtc-leg.ts` — browser WebRTC connection (wraps werift PeerConnection)
- `ts/call/rtp-port-pool.ts` — unified RTP port pool
- `ts/sipproxy.ts` — thin bootstrap wiring everything together
- `ts/webrtcbridge.ts` — browser device registration (signaling only)
### WebRTC Browser Call Flow (Critical) ### Key Rust files (`rust/crates/proxy-engine/src/`)
The browser call flow has a specific signaling order that MUST be followed: - `call_manager.rs` — singleton registry, call factory methods, SIP routing (inbound/outbound/passthrough), B2BUA state machine, inbound route resolution
- `call.rs` — the `Call` hub + `LegInfo` struct, owns legs and the mixer task
- `sip_leg.rs` — full SIP dialog management for B2BUA legs (INVITE, 407 auth retry, BYE, CANCEL, early media)
- `rtp.rs` — RTP port pool (uses `Weak<UdpSocket>` so calls auto-release ports on drop) + RTP header helpers
- `mixer.rs` — 20 ms-tick mix-minus engine (48 kHz f32 internal, per-leg transcoding via `codec-lib`, per-leg denoising)
- `jitter_buffer.rs` — per-leg reordering/packet-loss compensation
- `leg_io.rs` — spawns inbound/outbound RTP I/O tasks per SIP leg
- `webrtc_engine.rs` — browser WebRTC sessions (werift-rs based), ICE/DTLS/SRTP
- `provider.rs` — SIP trunk registrations, public-IP detection via Via `received=`
- `registrar.rs` — accepts REGISTER from SIP phones, tracks contacts (push-based device status)
- `config.rs``AppConfig` deserialized from TS, route resolvers (`resolve_outbound_route`, `resolve_inbound_route`)
- `main.rs` — IPC command dispatcher (`handle_command`), event emitter, top-level SIP packet router
- `sip_transport.rs` — owning wrapper around the main SIP UDP socket
- `voicemail.rs` / `recorder.rs` / `audio_player.rs` / `tts.rs` — media subsystems
- `tool_leg.rs` — per-source observer audio for recording/transcription tools
- `ipc.rs` — event-emission helper used throughout
1. `POST /api/call` with browser deviceId → CallManager creates Call, saves pending state, notifies browser via `webrtc-incoming` ### Key TS files (control plane)
2. Browser sends `webrtc-offer` (with its own `sessionId`) → CallManager creates a **standalone** WebRtcLeg (NOT attached to any call yet)
3. Browser sends `webrtc-accept` (with `callId` + `sessionId`) → CallManager links the standalone WebRtcLeg to the Call, then starts the SIP provider leg
**The WebRtcLeg CANNOT be created at call creation time** because the browser's session ID is unknown until the `webrtc-offer` arrives. - `ts/sipproxy.ts` — entrypoint, wires the proxy engine bridge + web UI + WebRTC signaling
- `ts/proxybridge.ts``@push.rocks/smartrust` bridge to the Rust binary, typed `TProxyCommands` map
- `ts/config.ts` — JSON config loader (`IAppConfig`, `IProviderConfig`, etc.), sent to Rust via `configure`
- `ts/voicebox.ts` — voicemail metadata persistence (WAV files live in `.nogit/voicemail/{boxId}/`)
- `ts/webrtcbridge.ts` — browser WebSocket signaling, browser device registry (`deviceIdToWs`)
- `ts/call/prompt-cache.ts` — the only remaining file under `ts/call/` (IVR prompt caching)
### WebRTC Audio Return Channel (Critical) ### Rust SIP protocol library
The SIP→browser audio path works through the Call hub: `rust/crates/sip-proto/` is a zero-dependency SIP data library (parse/build/mutate/serialize messages, dialog management, SDP helpers, digest auth). Do not add transport or timer logic there — it's purely data-level.
1. Provider sends RTP to SipLeg's socket ## Event-push architecture for device status
2. SipLeg's `onRtpReceived` fires → Call hub's `forwardRtp`
3. Call hub calls `webrtcLeg.sendRtp(data)` → which calls `forwardToBrowser()`
4. `forwardToBrowser` transcodes (G.722→Opus) and sends via `sender.sendRtp()` (WebRTC PeerConnection)
**`WebRtcLeg.sendRtp()` MUST feed into `forwardToBrowser()`** (the WebRTC PeerConnection path), NOT send to a UDP address. This was a bug that caused one-way audio. Device status flows **via push events**, not pull-based IPC queries:
The browser→SIP direction works independently: `ontrack.onReceiveRtp``forwardToSip()` → transcodes → sends directly to provider's media endpoint via UDP. 1. Rust emits `device_registered` when a phone REGISTERs
2. TS `sipproxy.ts` maintains a `deviceStatuses` Map, updated from the event
3. Map snapshot goes into the WebSocket `status` broadcast
4. Web UI (`ts_web/elements/sipproxy-devices.ts`) reads it from the push stream
### SIP Protocol Library There used to be a `get_status` pull IPC for this, but it was never called from TS and has been removed. If a new dashboard ever needs a pull-based snapshot, the push Map is the right source to read from.
`ts/sip/` is a zero-dependency SIP protocol library. Do not add transport or timer logic there — it's purely data-level (parse/build/mutate/serialize). ## Inbound routing (wired in Commit 4 of the cleanup PR)
Inbound route resolution goes through `config.resolve_inbound_route(provider_id, called_number, caller_number)` inside `create_inbound_call` (call_manager.rs). The result carries a `ring_browsers` flag that propagates to the `incoming_call` event; `ts/sipproxy.ts` gates the `webrtc-incoming` browser fan-out behind that flag.
**Known limitations / TODOs** (documented in code at `create_inbound_call`):
- Multi-target inbound fork is not yet implemented — only the first registered device from `route.device_ids` is rung.
- `ring_browsers` is **informational only**: browsers see a toast but do not race the SIP device to answer. True first-to-answer-wins requires a multi-leg fork + per-leg CANCEL, which is not built yet.
- `voicemail_box`, `ivr_menu_id`, `no_answer_timeout` are resolved but not yet honored downstream.
## WebRTC Browser Call Flow (Critical)
The browser call signaling order is strict:
1. Browser initiates outbound via a TS API (e.g. `POST /api/call`) — TS creates a pending call in the Rust engine via `make_call` and notifies the browser with a `webrtc-incoming` push.
2. Browser sends `webrtc-offer` (with its own `sessionId`) → Rust `handle_webrtc_offer` creates a **standalone** WebRTC session (NOT attached to any call yet).
3. Browser sends `webrtc_link` (with `callId` + `sessionId`) → Rust links the standalone session to the Call and wires the WebRTC leg through the mixer.
**The WebRTC leg cannot be fully attached at call-creation time** because the browser's session ID is unknown until the `webrtc-offer` arrives.
### WebRTC audio return channel (Critical)
The SIP→browser audio path goes through the mixer, not a direct RTP relay:
1. Provider sends RTP → received on the provider leg's UDP socket (`leg_io::spawn_sip_inbound`)
2. Packet flows through `jitter_buffer` → mixer's inbound mpsc channel
3. Mixer decodes/resamples/denoises, computes mix-minus per leg
4. WebRTC leg receives its mix-minus frame, encodes to Opus, and pushes via the WebRTC engine's peer connection sender
Browser→SIP works symmetrically: `ontrack.onReceiveRtp` → WebRTC leg's outbound mpsc → mixer → other legs' inbound channels.
## SDP/Record-Route NAT (fixed in Commit 3 of the cleanup PR)
The proxy tracks a `public_ip: Option<String>` on every `LegInfo` (populated from provider-leg construction sites). When `route_passthrough_message` rewrites SDP (`c=` line) or emits a `Record-Route`, it picks `advertise_ip` based on the destination leg's kind:
- `SipProvider``other.public_ip.unwrap_or(lan_ip)` (provider reaches us via public IP)
- `SipDevice` / `WebRtc` / `Tool` / `Media``lan_ip` (everything else is LAN or proxy-internal)
This fixed a real NAT-traversal bug where the proxy advertised its RFC1918 LAN IP to the provider in SDP, causing one-way or no audio for device-originated inbound traffic behind NAT.
## Build & development
- **Build:** `pnpm run buildRust` (never `cargo build` directly — tsrust cross-compiles for both `x86_64-unknown-linux-gnu` and `aarch64-unknown-linux-gnu`)
- **Cross-compile setup:** the aarch64 target requires `gcc-aarch64-linux-gnu` + `libstdc++6-arm64-cross` (Debian/Ubuntu). See `rust/.cargo/config.toml` for the linker wiring. A committed symlink at `rust/.cargo/crosslibs/aarch64/libstdc++.so``/usr/aarch64-linux-gnu/lib/libstdc++.so.6` avoids needing the `libstdc++-13-dev-arm64-cross` package.
- **Bundle web UI:** `pnpm run bundle` (esbuild, output: `dist_ts_web/bundle.js`)
- **Full build:** `pnpm run build` (= `buildRust && bundle`)
- **Start server:** `pnpm run start` (runs `tsx ts/sipproxy.ts`)
## Persistent files
- `.nogit/config.json` — app config (providers, devices, routes, voiceboxes, IVR menus)
- `.nogit/voicemail/{boxId}/` — voicemail WAV files + `messages.json` index
- `.nogit/prompts/` — cached TTS prompts for IVR menus

74
Dockerfile Normal file
View File

@@ -0,0 +1,74 @@
# gitzone dockerfile_service
## STAGE 1 // BUILD
FROM code.foss.global/host.today/ht-docker-node:lts AS build
# System build tools that the Rust dep tree needs beyond the base image:
# - cmake : used by the `cmake` crate (transitive via ort_sys / a webrtc
# sub-crate) to build a C/C++ library from source when a
# prebuilt-binary download path doesn't apply.
# - pkg-config : used by audiopus_sys and other *-sys crates to locate libs
# on the native target (safe no-op if they vendor their own).
# These are normally pre-installed on dev machines but not in ht-docker-node:lts.
RUN apt-get update && apt-get install -y --no-install-recommends \
cmake \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# buildx sets TARGETARCH automatically for each platform it's building:
# linux/amd64 -> TARGETARCH=amd64
# linux/arm64 -> TARGETARCH=arm64
# We use it to tell tsrust to build ONLY the current container's arch. This
# overrides the `@git.zone/tsrust.targets` list in .smartconfig.json, which is
# right for local dev / CI (where you want both binaries) but wrong for per-
# platform Docker stages (each stage would then also try to cross-compile to
# the OTHER arch — which fails in the arm64 stage because no reverse cross-
# toolchain is installed).
#
# With --target set, tsrust builds a single target natively within whichever
# platform this stage is running under (native on amd64, QEMU-emulated on arm64).
ARG TARGETARCH
COPY ./ /app
WORKDIR /app
RUN pnpm config set store-dir .pnpm-store
RUN rm -rf node_modules && pnpm install
# tsrust --target takes precedence over .smartconfig.json's targets array.
# Writes dist_rust/proxy-engine_linux_amd64 or dist_rust/proxy-engine_linux_arm64.
# The TS layer (ts/proxybridge.ts buildLocalPaths) picks the right one at runtime
# via process.arch.
RUN pnpm exec tsrust --target linux_${TARGETARCH}
# Web bundle (esbuild — pure JS, uses the platform's native esbuild binary
# installed by pnpm above, so no cross-bundling concerns).
RUN pnpm run bundle
# Drop pnpm store to keep the image smaller. node_modules stays because the
# runtime entrypoint is tsx and siprouter has no separate dist_ts/ to run from.
RUN rm -rf .pnpm-store
## STAGE 2 // PRODUCTION
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
# gcompat + libstdc++ let the glibc-linked proxy-engine binary run on Alpine.
RUN apk add --no-cache gcompat libstdc++
WORKDIR /app
COPY --from=build /app /app
ENV SIPROUTER_MODE=OCI_CONTAINER
ENV NODE_ENV=production
LABEL org.opencontainers.image.title="siprouter" \
org.opencontainers.image.description="SIP proxy with Rust data plane and WebRTC bridge" \
org.opencontainers.image.source="https://code.foss.global/serve.zone/siprouter"
# 5070 SIP signaling (UDP+TCP)
# 5061 SIP-TLS (optional, UDP+TCP)
# 3060 Web UI / WebSocket (HTTP or HTTPS, auto-detected from .nogit/cert.pem)
# 20000-20200/udp RTP media range (must match config.proxy.rtpPortRange)
EXPOSE 5070/udp 5070/tcp 5061/udp 5061/tcp 3060/tcp 20000-20200/udp
# exec replaces sh as PID 1 with tsx, so SIGINT/SIGTERM reach Node and
# ts/sipproxy.ts' shutdown handler (which calls shutdownProxyEngine) runs cleanly.
CMD ["sh", "-c", "exec ./node_modules/.bin/tsx ts/sipproxy.ts"]

View File

@@ -1,5 +1,117 @@
# Changelog # Changelog
## 2026-04-14 - 1.25.1 - fix(proxy-engine)
respect explicit inbound route targets and store voicemail in the configured mailbox
- Prevent inbound routes with an explicit empty target list from ringing arbitrary registered devices by distinguishing omitted targets from empty targets.
- Route unrouted or no-target inbound calls to voicemail with a generated unrouted greeting instead of falling back to random devices.
- Pass voicemail box identifiers through proxy events and runtime handling so recordings are saved and indexed under the correct mailbox instead of always using default.
## 2026-04-14 - 1.25.0 - feat(proxy-engine)
add live TTS streaming interactions and incoming number range support
- add a new start_tts_interaction command and bridge API to begin IVR or leg interactions before full TTS rendering completes
- stream synthesized TTS chunks into the mixer with cancellation handling so prompts can stop cleanly on digit match, leg removal, or shutdown
- extract PCM-to-mixer frame conversion for reusable live prompt processing
- extend routing pattern matching to support numeric number ranges like start..end, including + prefixed values
- add incomingNumbers config typing and frontend config update support for single, range, and regex number modes
## 2026-04-14 - 1.24.0 - feat(routing)
require explicit inbound DID routes and normalize SIP identities for provider-based number matching
- Inbound route resolution now returns no match unless a configured inbound route explicitly matches the provider and called number.
- Normalized routing identities were added for SIP/TEL URIs so inbound DIDs and outbound dialed numbers match consistently across provider-specific formats.
- Call handling and incoming call events now use normalized numbers, improving routing accuracy for shared trunk providers.
- Route configuration docs and the web route editor were updated to support explicit inbound DID ownership, voicemail fallback, and IVR selection.
- Mixer RTP handling was enhanced to better support variable packet durations, timestamp-based gap fill, and non-blocking output drop reporting.
## 2026-04-14 - 1.23.0 - feat(runtime)
refactor runtime state and proxy event handling for typed WebRTC linking and shared status models
- extract proxy event handling into dedicated runtime modules for status tracking and WebRTC session-to-call linking
- introduce shared typed proxy event and status interfaces used by both backend and web UI
- update web UI server initialization to use structured options and await async config save hooks
- simplify browser signaling by routing WebRTC offer/ICE handling through frontend-to-Rust integration
- align device status rendering with the new address/port fields in dashboard views
## 2026-04-12 - 1.22.0 - feat(proxy-engine)
add on-demand TTS caching for voicemail and IVR prompts
- Route inbound calls directly to configured IVR menus and track them with a dedicated IVR call state
- Generate voicemail greetings and IVR menu prompts inside the Rust proxy engine on demand instead of precomputing prompts in TypeScript
- Add cacheable TTS output with sidecar metadata and enable Kokoro CMUdict support for improved prompt generation
- Extend proxy configuration to include voiceboxes and IVR menus, and update documentation to reflect Kokoro-only prompt generation
## 2026-04-11 - 1.21.0 - feat(providers)
replace provider creation modal with a guided multi-step setup flow
- Adds a stepper-based provider creation flow with provider type selection, connection, credentials, advanced settings, and review steps.
- Applies built-in templates for Sipgate and O2/Alice from the selected provider type instead of separate add actions.
- Adds a final review step with generated provider ID preview and duplicate ID collision handling before saving.
## 2026-04-11 - 1.20.5 - fix(readme)
improve architecture and call flow documentation with Mermaid diagrams
- Replace ASCII architecture and audio pipeline diagrams with Mermaid diagrams for better readability
- Document the WebRTC browser call setup sequence, including offer handling and session-to-call linking
## 2026-04-11 - 1.20.4 - fix(deps)
bump @design.estate/dees-catalog to ^3.71.1
- Updates the @design.estate/dees-catalog dependency from ^3.70.0 to ^3.71.1 in package.json.
## 2026-04-11 - 1.20.3 - fix(ts-config,proxybridge,voicebox)
align voicebox config types and add missing proxy bridge command definitions
- Reuses the canonical IVoiceboxConfig type from voicebox.ts in config.ts to eliminate duplicated type definitions and optionality mismatches.
- Makes voicemail timing and limits optional in voicebox config so defaults can be applied consistently during initialization.
- Adds VoiceboxManager.addMessage and updates recording handling to use it directly for persisted voicemail metadata.
- Extends proxy bridge command typings with add_leg, remove_leg, and WebRTC signaling commands, and tightens sendCommand typing.
## 2026-04-11 - 1.20.2 - fix(proxy-engine)
fix inbound route browser ringing and provider-facing SDP advertisement while preventing RTP port exhaustion
- Honor inbound routing `ringBrowsers` when emitting incoming call events so browser toast notifications can be suppressed per route.
- Rewrite SDP and Record-Route using the destination leg's routable address, using `public_ip` for provider legs and LAN IP for device and internal legs.
- Store provider leg public IP metadata on legs to support correct per-destination SIP message rewriting.
- Change the RTP port pool to track sockets with `Weak<UdpSocket>` so ports are reclaimed automatically after calls end, avoiding leaked allocations and eventual 503 failures on new calls.
- Remove unused dashboard/status, DTMF, relay, and transport helper code paths as part of engine cleanup.
## 2026-04-11 - 1.20.1 - fix(docker)
install required native build tools for Rust dependencies in the build image
- Add cmake and pkg-config to the Docker build stage so Rust native dependencies can compile successfully in the container
- Document why these tools are needed for transitive Rust crates that build or detect native libraries
## 2026-04-11 - 1.20.0 - feat(docker)
add multi-arch Docker build and tagged release pipeline
- Add a production Dockerfile for building and running the SIP router with the Rust proxy engine and web bundle
- Configure tsdocker and tsrust for linux/amd64 and linux/arm64 image builds and registry mapping
- Add a tag-triggered Gitea workflow to build and push Docker images
- Update runtime binary resolution to load architecture-specific Rust artifacts in Docker and CI environments
- Add Docker-related package scripts, dependency updates, and ignore rules for container builds
## 2026-04-11 - 1.19.2 - fix(web-ui)
normalize lucide icon names across SIP proxy views
- Updates icon identifiers to the expected PascalCase lucide format in app navigation, calls, IVR, overview, providers, and voicemail views.
- Fixes UI icon rendering for stats cards and action menus such as transfer, delete, status, and call direction indicators.
## 2026-04-10 - 1.19.1 - fix(readme)
refresh documentation for jitter buffering, voicemail, and WebSocket signaling details
- Add adaptive jitter buffer and packet loss concealment details to the audio pipeline documentation
- Document voicemail unheard count and heard-state API endpoints
- Update WebSocket event and browser signaling examples to reflect current message types
## 2026-04-10 - 1.19.0 - feat(proxy-engine,codec-lib)
add adaptive RTP jitter buffering with Opus packet loss concealment and stable 20ms resampling
- introduces a per-leg adaptive jitter buffer in the mixer to reorder RTP packets, gate initial playout, and deliver one frame per 20ms tick
- adds Opus PLC support to synthesize missing audio frames when packets are lost, with fade-based fallback handling for non-Opus codecs
- updates i16 and f32 resamplers to use canonical 20ms chunks so cached resamplers preserve filter state and avoid variable-size cache thrashing
## 2026-04-10 - 1.18.0 - feat(readme) ## 2026-04-10 - 1.18.0 - feat(readme)
expand documentation for voicemail, IVR, audio engine, and API capabilities expand documentation for voicemail, IVR, audio engine, and API capabilities

View File

@@ -1,23 +1,28 @@
{ {
"name": "siprouter", "name": "siprouter",
"version": "1.18.0", "version": "1.25.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"bundle": "node node_modules/.pnpm/esbuild@0.27.7/node_modules/esbuild/bin/esbuild ts_web/index.ts --bundle --format=esm --outfile=dist_ts_web/bundle.js --platform=browser --target=es2022 --minify", "bundle": "node node_modules/.pnpm/esbuild@0.27.7/node_modules/esbuild/bin/esbuild ts_web/index.ts --bundle --format=esm --outfile=dist_ts_web/bundle.js --platform=browser --target=es2022 --minify",
"buildRust": "tsrust", "buildRust": "tsrust",
"build": "pnpm run buildRust && pnpm run bundle",
"build:docker": "tsdocker build --verbose",
"release:docker": "tsdocker push --verbose",
"start": "tsx ts/sipproxy.ts", "start": "tsx ts/sipproxy.ts",
"restartBackground": "pnpm run buildRust && pnpm run bundle; test -f .server.pid && kill $(cat .server.pid) 2>/dev/null; sleep 1; rm -f sip_trace.log proxy.out && nohup tsx ts/sipproxy.ts > proxy.out 2>&1 & echo $! > .server.pid; sleep 2; cat proxy.out" "restartBackground": "pnpm run buildRust && pnpm run bundle; test -f .server.pid && kill $(cat .server.pid) 2>/dev/null; sleep 1; rm -f sip_trace.log proxy.out && nohup tsx ts/sipproxy.ts > proxy.out 2>&1 & echo $! > .server.pid; sleep 2; cat proxy.out"
}, },
"dependencies": { "dependencies": {
"@design.estate/dees-catalog": "^3.70.0", "@design.estate/dees-catalog": "^3.77.0",
"@design.estate/dees-element": "^2.2.4", "@design.estate/dees-element": "^2.2.4",
"@push.rocks/smartrust": "^1.3.2", "@push.rocks/smartrust": "^1.3.2",
"@push.rocks/smartstate": "^2.3.0", "@push.rocks/smartstate": "^2.3.0",
"tsx": "^4.21.0",
"ws": "^8.20.0" "ws": "^8.20.0"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbundle": "^2.10.0", "@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsdocker": "^2.2.4",
"@git.zone/tsrust": "^1.3.2", "@git.zone/tsrust": "^1.3.2",
"@git.zone/tswatch": "^3.3.2", "@git.zone/tswatch": "^3.3.2",
"@types/ws": "^8.18.1" "@types/ws": "^8.18.1"

686
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

179
readme.md
View File

@@ -1,6 +1,6 @@
# @serve.zone/siprouter # @serve.zone/siprouter
A production-grade **SIP B2BUA + WebRTC bridge** built with TypeScript and Rust. Routes calls between SIP providers, SIP hardware devices, and browser softphones — with real-time codec transcoding, ML noise suppression, neural TTS, voicemail, IVR menus, and a slick web dashboard. A production-grade **SIP B2BUA + WebRTC bridge** built with TypeScript and Rust. Routes calls between SIP providers, SIP hardware devices, and browser softphones — with real-time codec transcoding, adaptive jitter buffering, ML noise suppression, neural TTS, voicemail, IVR menus, and a slick web dashboard.
## Issue Reporting and Security ## Issue Reporting and Security
@@ -17,9 +17,10 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
- 🎛️ **Multi-Provider Trunking** — Register with multiple SIP providers simultaneously (sipgate, easybell, etc.) with automatic failover - 🎛️ **Multi-Provider Trunking** — Register with multiple SIP providers simultaneously (sipgate, easybell, etc.) with automatic failover
- 🎧 **48kHz f32 Audio Engine** — High-fidelity internal audio bus at 48kHz/32-bit float with native Opus float encode/decode, FFT-based resampling, and per-leg ML noise suppression - 🎧 **48kHz f32 Audio Engine** — High-fidelity internal audio bus at 48kHz/32-bit float with native Opus float encode/decode, FFT-based resampling, and per-leg ML noise suppression
- 🔀 **N-Leg Mix-Minus Mixer** — Conference-grade mixing with dynamic leg add/remove, transfer, and per-source audio separation - 🔀 **N-Leg Mix-Minus Mixer** — Conference-grade mixing with dynamic leg add/remove, transfer, and per-source audio separation
- 🎯 **Adaptive Jitter Buffer** — Per-leg jitter buffering with sequence-based reordering, adaptive depth (60120ms), Opus PLC for lost packets, and hold/resume detection
- 📧 **Voicemail** — Configurable voicemail boxes with TTS greetings, recording, and web playback - 📧 **Voicemail** — Configurable voicemail boxes with TTS greetings, recording, and web playback
- 🔢 **IVR Menus** — DTMF-navigable interactive voice response with nested menus, routing actions, and custom prompts - 🔢 **IVR Menus** — DTMF-navigable interactive voice response with nested menus, routing actions, and custom prompts
- 🗣️ **Neural TTS** — Kokoro-powered announcements and greetings with 25+ voice presets, backed by espeak-ng fallback - 🗣️ **Neural TTS** — Kokoro-powered greetings and IVR prompts with 25+ voice presets
- 🎙️ **Call Recording** — Per-source separated WAV recording at 48kHz via tool legs - 🎙️ **Call Recording** — Per-source separated WAV recording at 48kHz via tool legs
- 🖥️ **Web Dashboard** — Real-time SPA with 9 views: live calls, browser phone, routing, voicemail, IVR, contacts, providers, and streaming logs - 🖥️ **Web Dashboard** — Real-time SPA with 9 views: live calls, browser phone, routing, voicemail, IVR, contacts, providers, and streaming logs
@@ -27,39 +28,26 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
## 🏗️ Architecture ## 🏗️ Architecture
``` ```mermaid
┌─────────────────────────────────────┐ flowchart TB
Browser Softphone Browser["🌐 Browser Softphone<br/>(WebRTC via WebSocket signaling)"]
(WebRTC via WebSocket signaling) │ Devices["📞 SIP Devices<br/>(HT801, desk phones, ATAs)"]
└──────────────┬──────────────────────┘ Trunks["☎️ SIP Trunk Providers<br/>(sipgate, easybell, …)"]
│ Opus/WebRTC
subgraph Router["siprouter"]
┌──────────────────────────────────────┐ direction TB
siprouter │ subgraph TS["TypeScript Control Plane"]
TSBits["Config · WebRTC Signaling<br/>REST API · Web Dashboard<br/>Voicebox Manager · TTS Cache"]
TypeScript Control Plane │ end
│ ┌────────────────────────────────┐ │ subgraph Rust["Rust proxy-engine (data plane)"]
│ │ Config · WebRTC Signaling │ │ RustBits["SIP Stack · Dialog SM · Auth<br/>Call Manager · N-Leg Mixer<br/>48kHz f32 Bus · Jitter Buffer<br/>Codec Engine · RTP Port Pool<br/>WebRTC Engine · Kokoro TTS<br/>Voicemail · IVR · Recording"]
│ REST API · Web Dashboard │ │ end
│ Voicebox Manager · TTS Cache │ │ TS <-->|"JSON-over-stdio IPC"| Rust
└────────────┬───────────────────┘ │ end
│ JSON-over-stdio IPC │
┌────────────┴───────────────────┐ │ Browser <-->|"Opus / WebRTC"| TS
Rust proxy-engine (data plane) │ │ Rust <-->|"SIP / RTP"| Devices
│ │ Rust <-->|"SIP / RTP"| Trunks
│ │ SIP Stack · Dialog SM · Auth │ │
│ │ Call Manager · N-Leg Mixer │ │
│ │ 48kHz f32 Bus · RNNoise │ │
│ │ Codec Engine · RTP Port Pool │ │
│ │ WebRTC Engine · Kokoro TTS │ │
│ │ Voicemail · IVR · Recording │ │
│ └────┬──────────────────┬────────┘ │
└───────┤──────────────────┤───────────┘
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ SIP Devices │ │ SIP Trunk │
│ (HT801 etc) │ │ Providers │
└─────────────┘ └─────────────┘
``` ```
### 🧠 Key Design Decisions ### 🧠 Key Design Decisions
@@ -70,6 +58,37 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
- **Per-Session Codec Isolation** — Each call leg gets its own encoder/decoder/resampler/denoiser state — no cross-call corruption. - **Per-Session Codec Isolation** — Each call leg gets its own encoder/decoder/resampler/denoiser state — no cross-call corruption.
- **SDP Codec Negotiation** — Outbound encoding uses the codec actually negotiated in SDP answers, not just the first offered codec. - **SDP Codec Negotiation** — Outbound encoding uses the codec actually negotiated in SDP answers, not just the first offered codec.
### 📲 WebRTC Browser Call Flow
Browser calls are set up in a strict three-step dance — the WebRTC leg cannot be attached at call-creation time because the browser's session ID is only known once the SDP offer arrives:
```mermaid
sequenceDiagram
participant B as Browser
participant TS as TypeScript (sipproxy.ts)
participant R as Rust proxy-engine
participant P as SIP Provider
B->>TS: POST /api/call
TS->>R: make_call (pending call, no WebRTC leg yet)
R-->>TS: call_created
TS-->>B: webrtc-incoming (callId)
B->>TS: webrtc-offer (sessionId, SDP)
TS->>R: handle_webrtc_offer
R-->>TS: webrtc-answer (SDP)
TS-->>B: webrtc-answer
Note over R: Standalone WebRTC session<br/>(not yet attached to call)
B->>TS: webrtc_link (callId + sessionId)
TS->>R: link session → call
R->>R: wire WebRTC leg through mixer
R->>P: SIP INVITE
P-->>R: 200 OK + SDP
R-->>TS: call_answered
Note over B,P: Bidirectional Opus ↔ codec-transcoded<br/>audio flows through the mixer
```
--- ---
## 🚀 Getting Started ## 🚀 Getting Started
@@ -79,7 +98,6 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
- **Node.js** ≥ 20 with `tsx` globally available - **Node.js** ≥ 20 with `tsx` globally available
- **pnpm** for package management - **pnpm** for package management
- **Rust** toolchain (for building the proxy engine) - **Rust** toolchain (for building the proxy engine)
- **espeak-ng** (optional, for TTS fallback)
### Install & Build ### Install & Build
@@ -130,24 +148,41 @@ Create `.nogit/config.json`:
"routing": { "routing": {
"routes": [ "routes": [
{ {
"id": "inbound-default", "id": "inbound-main-did",
"name": "Ring all devices", "name": "Main DID",
"priority": 100, "priority": 200,
"enabled": true,
"match": {
"direction": "inbound", "direction": "inbound",
"match": {}, "sourceProvider": "my-trunk",
"numberPattern": "+49421219694"
},
"action": { "action": {
"targets": ["desk-phone"], "targets": ["desk-phone"],
"ringBrowsers": true, "ringBrowsers": true,
"voicemailBox": "main", "voicemailBox": "main"
"noAnswerTimeout": 25 }
},
{
"id": "inbound-support-did",
"name": "Support DID",
"priority": 190,
"enabled": true,
"match": {
"direction": "inbound",
"sourceProvider": "my-trunk",
"numberPattern": "+49421219695"
},
"action": {
"ivrMenuId": "support-menu"
} }
}, },
{ {
"id": "outbound-default", "id": "outbound-default",
"name": "Route via trunk", "name": "Route via trunk",
"priority": 100, "priority": 100,
"direction": "outbound", "enabled": true,
"match": {}, "match": { "direction": "outbound" },
"action": { "provider": "my-trunk" } "action": { "provider": "my-trunk" }
} }
] ]
@@ -169,9 +204,11 @@ Create `.nogit/config.json`:
} }
``` ```
Inbound number ownership is explicit: add one inbound route per DID (or DID prefix) and scope it with `sourceProvider` when a provider delivers multiple external numbers.
### TTS Setup (Optional) ### TTS Setup (Optional)
For neural announcements and voicemail greetings, download the Kokoro TTS model: For neural voicemail greetings and IVR prompts, download the Kokoro TTS model:
```bash ```bash
mkdir -p .nogit/tts mkdir -p .nogit/tts
@@ -181,7 +218,7 @@ curl -L -o .nogit/tts/voices.bin \
https://github.com/mzdk100/kokoro/releases/download/V1.0/voices.bin https://github.com/mzdk100/kokoro/releases/download/V1.0/voices.bin
``` ```
Without the model files, TTS falls back to `espeak-ng`. Without either, announcements are skipped — everything else works fine. Without the model files, TTS prompts (IVR menus, voicemail greetings) are skipped — everything else works fine.
### Run ### Run
@@ -208,7 +245,6 @@ siprouter/
│ ├── frontend.ts # Web dashboard HTTP/WS server + REST API │ ├── frontend.ts # Web dashboard HTTP/WS server + REST API
│ ├── webrtcbridge.ts # WebRTC signaling layer │ ├── webrtcbridge.ts # WebRTC signaling layer
│ ├── registrar.ts # Browser softphone registration │ ├── registrar.ts # Browser softphone registration
│ ├── announcement.ts # TTS announcement generator (espeak-ng / Kokoro)
│ ├── voicebox.ts # Voicemail box management │ ├── voicebox.ts # Voicemail box management
│ └── call/ │ └── call/
│ └── prompt-cache.ts # Named audio prompt WAV management │ └── prompt-cache.ts # Named audio prompt WAV management
@@ -245,36 +281,45 @@ The `proxy-engine` binary handles all real-time audio processing with a **48kHz
### Audio Pipeline ### Audio Pipeline
``` ```mermaid
Inbound: Wire RTP → Decode → Resample to 48kHz → Denoise (RNNoise) → Mix Bus flowchart LR
Outbound: Mix Bus → Mix-Minus → Resample to codec rate → Encode → Wire RTP subgraph Inbound["Inbound path (per leg)"]
direction LR
IN_RTP["Wire RTP"] --> IN_JB["Jitter Buffer"] --> IN_DEC["Decode"] --> IN_RS["Resample → 48 kHz"] --> IN_DN["Denoise (RNNoise)"] --> IN_BUS["Mix Bus"]
end
subgraph Outbound["Outbound path (per leg)"]
direction LR
OUT_BUS["Mix Bus"] --> OUT_MM["Mix-Minus"] --> OUT_RS["Resample → codec rate"] --> OUT_ENC["Encode"] --> OUT_RTP["Wire RTP"]
end
``` ```
- **FFT-based resampling** via `rubato` — high-quality sinc interpolation with cached resampler state for seamless inter-frame continuity - **Adaptive jitter buffer** — per-leg `BTreeMap`-based buffer keyed by RTP sequence number. Delivers exactly one frame per 20ms mixer tick in sequence order. Adaptive target depth starts at 3 frames (60ms) and adjusts between 26 frames based on observed network jitter. Handles hold/resume by detecting large forward sequence jumps and resetting cleanly.
- **Packet loss concealment (PLC)** — on missing packets, Opus legs invoke the decoder's built-in PLC (`decode(None)`) to synthesize a smooth fill frame. Non-Opus legs (G.722, PCMU) apply exponential fade (0.85×) toward silence to avoid hard discontinuities.
- **FFT-based resampling** via `rubato` — high-quality sinc interpolation with canonical 20ms chunk sizes to ensure consistent resampler state across frames, preventing filter discontinuities
- **ML noise suppression** via `nnnoiseless` (RNNoise) — per-leg inbound denoising with SIMD acceleration (AVX/SSE). Skipped for WebRTC legs (browsers already denoise via getUserMedia) - **ML noise suppression** via `nnnoiseless` (RNNoise) — per-leg inbound denoising with SIMD acceleration (AVX/SSE). Skipped for WebRTC legs (browsers already denoise via getUserMedia)
- **Mix-minus mixing** — each participant hears everyone except themselves, accumulated in f64 precision - **Mix-minus mixing** — each participant hears everyone except themselves, accumulated in f64 precision
- **In-tick packet reorder** — inbound RTP packets are sorted by sequence number before decoding, protecting G.722 ADPCM state from out-of-order delivery
- **RFC 3550 compliant header parsing** — properly handles CSRC lists and header extensions - **RFC 3550 compliant header parsing** — properly handles CSRC lists and header extensions
--- ---
## 🗣️ Neural TTS ## 🗣️ Neural TTS
Announcements and voicemail greetings are synthesized using [Kokoro TTS](https://github.com/mzdk100/kokoro) — an 82M parameter neural model running via ONNX Runtime directly in the Rust process: Voicemail greetings and IVR prompts are synthesized using [Kokoro TTS](https://github.com/mzdk100/kokoro) — an 82M parameter neural model running via ONNX Runtime directly in the Rust process:
- **24 kHz, 16-bit mono** output - **24 kHz, 16-bit mono** output
- **25+ voice presets** — American/British, male/female (e.g., `af_bella`, `am_adam`, `bf_emma`, `bm_george`) - **25+ voice presets** — American/British, male/female (e.g., `af_bella`, `am_adam`, `bf_emma`, `bm_george`)
- **~800ms** synthesis time for a 3-second phrase - **~800ms** synthesis time for a 3-second phrase
- Lazy-loaded on first use — no startup cost if TTS is unused - Lazy-loaded on first use — no startup cost if TTS is unused
- Falls back to `espeak-ng` if the ONNX model is not available
--- ---
## 📧 Voicemail ## 📧 Voicemail
- Configurable voicemail boxes with custom TTS greetings - Configurable voicemail boxes with custom TTS greetings (text + voice) or uploaded WAV
- Automatic routing on no-answer timeout - Automatic routing on no-answer timeout (configurable, default 25s)
- Recording with configurable max duration and message count - Recording with configurable max duration (default 120s) and message count limit (default 50)
- Unheard message tracking for MWI (message waiting indication)
- Web dashboard playback and management - Web dashboard playback and management
- WAV storage in `.nogit/voicemail/` - WAV storage in `.nogit/voicemail/`
@@ -319,8 +364,10 @@ Announcements and voicemail greetings are synthesized using [Kokoro TTS](https:/
| `/api/config` | GET | Read current configuration | | `/api/config` | GET | Read current configuration |
| `/api/config` | POST | Update configuration (hot-reload) | | `/api/config` | POST | Update configuration (hot-reload) |
| `/api/voicemail/:box` | GET | List voicemail messages | | `/api/voicemail/:box` | GET | List voicemail messages |
| `/api/voicemail/:box/:id` | DELETE | Delete a voicemail message | | `/api/voicemail/:box/unheard` | GET | Get unheard message count |
| `/api/voicemail/:box/:id/audio` | GET | Stream voicemail audio | | `/api/voicemail/:box/:id/audio` | GET | Stream voicemail audio |
| `/api/voicemail/:box/:id/heard` | POST | Mark a voicemail message as heard |
| `/api/voicemail/:box/:id` | DELETE | Delete a voicemail message |
### WebSocket Events ### WebSocket Events
@@ -329,8 +376,18 @@ Connect to `/ws` for real-time push:
```jsonc ```jsonc
{ "type": "status", "data": { ... } } // Full status snapshot (1s interval) { "type": "status", "data": { ... } } // Full status snapshot (1s interval)
{ "type": "log", "data": { "message": "..." } } // Log lines in real-time { "type": "log", "data": { "message": "..." } } // Log lines in real-time
{ "type": "incoming_call", "data": { ... } } // Incoming call notification { "type": "call-update", "data": { ... } } // Call state change notification
{ "type": "call_ended", "data": { ... } } // Call ended notification { "type": "webrtc-answer", "data": { ... } } // WebRTC SDP answer for browser calls
{ "type": "webrtc-error", "data": { ... } } // WebRTC signaling error
```
Browser → server signaling:
```jsonc
{ "type": "webrtc-offer", "data": { ... } } // Browser sends SDP offer
{ "type": "webrtc-accept", "data": { ... } } // Browser accepts incoming call
{ "type": "webrtc-ice", "data": { ... } } // ICE candidate exchange
{ "type": "webrtc-hangup", "data": { ... } } // Browser hangs up
``` ```
--- ---
@@ -365,7 +422,7 @@ pnpm run restartBackground
## License and Legal Information ## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file. This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

30
rust/.cargo/config.toml Normal file
View File

@@ -0,0 +1,30 @@
# Cross-compile configuration for the proxy-engine crate.
#
# tsrust builds for both x86_64-unknown-linux-gnu and aarch64-unknown-linux-gnu
# from an x86_64 host. Without this config, cargo invokes the host `cc` to
# link aarch64 objects and fails with
# rust-lld: error: <obj.o> is incompatible with elf64-x86-64
#
# Required Debian/Ubuntu packages for the aarch64 target to work:
# sudo apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \
# libc6-dev-arm64-cross libstdc++6-arm64-cross
#
# The `libstdc++.so` dev symlink (needed by the -lstdc++ flag that the
# kokoro-tts/ort build scripts emit) is provided by this repo at
# ./crosslibs/aarch64/libstdc++.so, pointing at the versioned shared
# library installed by `libstdc++6-arm64-cross`. This avoids requiring
# the `libstdc++-13-dev-arm64-cross` package, which is not always
# installed alongside the runtime.
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
rustflags = ["-C", "link-arg=-L.cargo/crosslibs/aarch64"]
# Tell cc-rs-based build scripts (ring, zstd-sys, audiopus_sys, ort-sys) to
# use the aarch64 cross toolchain when compiling C sources for the aarch64
# target. Without these, they'd default to the host `cc` and produce x86_64
# objects that the aarch64 linker then rejects.
[env]
CC_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-gcc"
CXX_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-g++"
AR_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-ar"

View File

@@ -0,0 +1 @@
/usr/aarch64-linux-gnu/lib/libstdc++.so.6

10
rust/Cargo.lock generated
View File

@@ -532,6 +532,15 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "cmudict-fast"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9f73004e928ed46c3e7fd7406d2b12c8674153295f08af084b49860276dc02"
dependencies = [
"thiserror",
]
[[package]] [[package]]
name = "codec-lib" name = "codec-lib"
version = "0.1.0" version = "0.1.0"
@@ -1730,6 +1739,7 @@ dependencies = [
"bincode 2.0.1", "bincode 2.0.1",
"cc", "cc",
"chinese-number", "chinese-number",
"cmudict-fast",
"futures", "futures",
"jieba-rs", "jieba-rs",
"log", "log",

View File

@@ -115,8 +115,7 @@ pub struct TranscodeState {
impl TranscodeState { impl TranscodeState {
/// Create a new transcoding session with fresh codec state. /// Create a new transcoding session with fresh codec state.
pub fn new() -> Result<Self, String> { pub fn new() -> Result<Self, String> {
let mut opus_enc = let mut opus_enc = OpusEncoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
OpusEncoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
.map_err(|e| format!("opus encoder: {e}"))?; .map_err(|e| format!("opus encoder: {e}"))?;
opus_enc opus_enc
.set_complexity(5) .set_complexity(5)
@@ -142,8 +141,10 @@ impl TranscodeState {
} }
/// High-quality sample rate conversion using rubato FFT resampler. /// High-quality sample rate conversion using rubato FFT resampler.
/// Resamplers are cached by (from_rate, to_rate, chunk_size) and reused, ///
/// maintaining proper inter-frame state for continuous audio streams. /// To maintain continuous filter state, the resampler always processes at a
/// canonical chunk size (20ms at the source rate). This prevents cache
/// thrashing from variable input sizes and preserves inter-frame filter state.
pub fn resample( pub fn resample(
&mut self, &mut self,
pcm: &[i16], pcm: &[i16],
@@ -154,28 +155,56 @@ impl TranscodeState {
return Ok(pcm.to_vec()); return Ok(pcm.to_vec());
} }
let chunk = pcm.len(); let canonical_chunk = (from_rate as usize) / 50; // 20ms
let key = (from_rate, to_rate, chunk); let key = (from_rate, to_rate, canonical_chunk);
if !self.resamplers.contains_key(&key) { if !self.resamplers.contains_key(&key) {
let r = let r =
FftFixedIn::<f64>::new(from_rate as usize, to_rate as usize, chunk, 1, 1) FftFixedIn::<f64>::new(from_rate as usize, to_rate as usize, canonical_chunk, 1, 1)
.map_err(|e| format!("resampler {from_rate}->{to_rate}: {e}"))?; .map_err(|e| format!("resampler {from_rate}->{to_rate}: {e}"))?;
self.resamplers.insert(key, r); self.resamplers.insert(key, r);
} }
let resampler = self.resamplers.get_mut(&key).unwrap(); let resampler = self.resamplers.get_mut(&key).unwrap();
let float_in: Vec<f64> = pcm.iter().map(|&s| s as f64 / 32768.0).collect(); let mut output = Vec::with_capacity(
let input = vec![float_in]; (pcm.len() as f64 * to_rate as f64 / from_rate as f64).ceil() as usize + 16,
);
let mut offset = 0;
while offset < pcm.len() {
let remaining = pcm.len() - offset;
let copy_len = remaining.min(canonical_chunk);
let mut chunk = vec![0.0f64; canonical_chunk];
for i in 0..copy_len {
chunk[i] = pcm[offset + i] as f64 / 32768.0;
}
let input = vec![chunk];
let result = resampler let result = resampler
.process(&input, None) .process(&input, None)
.map_err(|e| format!("resample {from_rate}->{to_rate}: {e}"))?; .map_err(|e| format!("resample {from_rate}->{to_rate}: {e}"))?;
Ok(result[0] if remaining < canonical_chunk {
let expected =
(copy_len as f64 * to_rate as f64 / from_rate as f64).round() as usize;
let take = expected.min(result[0].len());
output.extend(
result[0][..take]
.iter() .iter()
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16) .map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16),
.collect()) );
} else {
output.extend(
result[0]
.iter()
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16),
);
}
offset += canonical_chunk;
}
Ok(output)
} }
/// Apply RNNoise ML noise suppression to 48kHz PCM audio. /// Apply RNNoise ML noise suppression to 48kHz PCM audio.
@@ -249,8 +278,7 @@ impl TranscodeState {
match pt { match pt {
PT_OPUS => { PT_OPUS => {
let mut pcm = vec![0i16; 5760]; // up to 120ms at 48kHz let mut pcm = vec![0i16; 5760]; // up to 120ms at 48kHz
let packet = let packet = OpusPacket::try_from(data).map_err(|e| format!("opus packet: {e}"))?;
OpusPacket::try_from(data).map_err(|e| format!("opus packet: {e}"))?;
let out = let out =
MutSignals::try_from(&mut pcm[..]).map_err(|e| format!("opus signals: {e}"))?; MutSignals::try_from(&mut pcm[..]).map_err(|e| format!("opus signals: {e}"))?;
let n: usize = self let n: usize = self
@@ -308,8 +336,7 @@ impl TranscodeState {
match pt { match pt {
PT_OPUS => { PT_OPUS => {
let mut pcm = vec![0.0f32; 5760]; // up to 120ms at 48kHz let mut pcm = vec![0.0f32; 5760]; // up to 120ms at 48kHz
let packet = let packet = OpusPacket::try_from(data).map_err(|e| format!("opus packet: {e}"))?;
OpusPacket::try_from(data).map_err(|e| format!("opus packet: {e}"))?;
let out = let out =
MutSignals::try_from(&mut pcm[..]).map_err(|e| format!("opus signals: {e}"))?; MutSignals::try_from(&mut pcm[..]).map_err(|e| format!("opus signals: {e}"))?;
let n: usize = self let n: usize = self
@@ -329,6 +356,21 @@ impl TranscodeState {
} }
} }
/// Opus packet loss concealment — synthesize one frame to fill a gap.
/// Returns f32 PCM at 48kHz. `frame_size` should be 960 for 20ms.
pub fn opus_plc(&mut self, frame_size: usize) -> Result<Vec<f32>, String> {
let mut pcm = vec![0.0f32; frame_size];
let out =
MutSignals::try_from(&mut pcm[..]).map_err(|e| format!("opus plc signals: {e}"))?;
let n: usize = self
.opus_dec
.decode_float(None::<OpusPacket<'_>>, out, false)
.map_err(|e| format!("opus plc: {e}"))?
.into();
pcm.truncate(n);
Ok(pcm)
}
/// Encode f32 PCM samples ([-1.0, 1.0]) to an audio codec. /// Encode f32 PCM samples ([-1.0, 1.0]) to an audio codec.
/// ///
/// For Opus, uses native float encode (no i16 quantization). /// For Opus, uses native float encode (no i16 quantization).
@@ -357,7 +399,10 @@ impl TranscodeState {
} }
/// High-quality sample rate conversion for f32 PCM using rubato FFT resampler. /// High-quality sample rate conversion for f32 PCM using rubato FFT resampler.
/// Uses a separate cache from the i16 resampler. ///
/// To maintain continuous filter state, the resampler always processes at a
/// canonical chunk size (20ms at the source rate). This prevents cache
/// thrashing from variable input sizes and preserves inter-frame filter state.
pub fn resample_f32( pub fn resample_f32(
&mut self, &mut self,
pcm: &[f32], pcm: &[f32],
@@ -368,23 +413,45 @@ impl TranscodeState {
return Ok(pcm.to_vec()); return Ok(pcm.to_vec());
} }
let chunk = pcm.len(); let canonical_chunk = (from_rate as usize) / 50; // 20ms
let key = (from_rate, to_rate, chunk); let key = (from_rate, to_rate, canonical_chunk);
if !self.resamplers_f32.contains_key(&key) { if !self.resamplers_f32.contains_key(&key) {
let r = let r =
FftFixedIn::<f32>::new(from_rate as usize, to_rate as usize, chunk, 1, 1) FftFixedIn::<f32>::new(from_rate as usize, to_rate as usize, canonical_chunk, 1, 1)
.map_err(|e| format!("resampler f32 {from_rate}->{to_rate}: {e}"))?; .map_err(|e| format!("resampler f32 {from_rate}->{to_rate}: {e}"))?;
self.resamplers_f32.insert(key, r); self.resamplers_f32.insert(key, r);
} }
let resampler = self.resamplers_f32.get_mut(&key).unwrap(); let resampler = self.resamplers_f32.get_mut(&key).unwrap();
let input = vec![pcm.to_vec()]; let mut output = Vec::with_capacity(
(pcm.len() as f64 * to_rate as f64 / from_rate as f64).ceil() as usize + 16,
);
let mut offset = 0;
while offset < pcm.len() {
let remaining = pcm.len() - offset;
let mut chunk = vec![0.0f32; canonical_chunk];
let copy_len = remaining.min(canonical_chunk);
chunk[..copy_len].copy_from_slice(&pcm[offset..offset + copy_len]);
let input = vec![chunk];
let result = resampler let result = resampler
.process(&input, None) .process(&input, None)
.map_err(|e| format!("resample f32 {from_rate}->{to_rate}: {e}"))?; .map_err(|e| format!("resample f32 {from_rate}->{to_rate}: {e}"))?;
Ok(result[0].clone()) if remaining < canonical_chunk {
let expected =
(copy_len as f64 * to_rate as f64 / from_rate as f64).round() as usize;
output.extend_from_slice(&result[0][..expected.min(result[0].len())]);
} else {
output.extend_from_slice(&result[0]);
}
offset += canonical_chunk;
}
Ok(output)
} }
/// Apply RNNoise ML noise suppression to 48kHz f32 PCM audio. /// Apply RNNoise ML noise suppression to 48kHz f32 PCM audio.
@@ -428,8 +495,10 @@ mod tests {
let encoded = mulaw_encode(sample); let encoded = mulaw_encode(sample);
let decoded = mulaw_decode(encoded); let decoded = mulaw_decode(encoded);
// µ-law is lossy; verify the decoded value is close. // µ-law is lossy; verify the decoded value is close.
assert!((sample as i32 - decoded as i32).abs() < 1000, assert!(
"µ-law roundtrip failed for {sample}: got {decoded}"); (sample as i32 - decoded as i32).abs() < 1000,
"µ-law roundtrip failed for {sample}: got {decoded}"
);
} }
} }
@@ -438,8 +507,10 @@ mod tests {
for sample in [-32768i16, -1000, -1, 0, 1, 1000, 32767] { for sample in [-32768i16, -1000, -1, 0, 1, 1000, 32767] {
let encoded = alaw_encode(sample); let encoded = alaw_encode(sample);
let decoded = alaw_decode(encoded); let decoded = alaw_decode(encoded);
assert!((sample as i32 - decoded as i32).abs() < 1000, assert!(
"A-law roundtrip failed for {sample}: got {decoded}"); (sample as i32 - decoded as i32).abs() < 1000,
"A-law roundtrip failed for {sample}: got {decoded}"
);
} }
} }
@@ -463,7 +534,9 @@ mod tests {
fn pcmu_to_pcma_roundtrip() { fn pcmu_to_pcma_roundtrip() {
let mut st = TranscodeState::new().unwrap(); let mut st = TranscodeState::new().unwrap();
// 160 bytes = 20ms of PCMU at 8kHz // 160 bytes = 20ms of PCMU at 8kHz
let pcmu_data: Vec<u8> = (0..160).map(|i| mulaw_encode((i as i16 * 200) - 16000)).collect(); let pcmu_data: Vec<u8> = (0..160)
.map(|i| mulaw_encode((i as i16 * 200) - 16000))
.collect();
let pcma = st.transcode(&pcmu_data, PT_PCMU, PT_PCMA, None).unwrap(); let pcma = st.transcode(&pcmu_data, PT_PCMU, PT_PCMA, None).unwrap();
assert_eq!(pcma.len(), 160); // Same frame size assert_eq!(pcma.len(), 160); // Same frame size
let back = st.transcode(&pcma, PT_PCMA, PT_PCMU, None).unwrap(); let back = st.transcode(&pcma, PT_PCMA, PT_PCMU, None).unwrap();

View File

@@ -19,7 +19,7 @@ regex-lite = "0.1"
webrtc = "0.8" webrtc = "0.8"
rand = "0.8" rand = "0.8"
hound = "3.5" hound = "3.5"
kokoro-tts = { version = "0.3", default-features = false } kokoro-tts = { version = "0.3", default-features = false, features = ["use-cmudict"] }
ort = { version = "=2.0.0-rc.11", default-features = false, features = [ ort = { version = "=2.0.0-rc.11", default-features = false, features = [
"std", "download-binaries", "copy-dylibs", "ndarray", "std", "download-binaries", "copy-dylibs", "ndarray",
"tls-native-vendored" "tls-native-vendored"

View File

@@ -36,10 +36,7 @@ pub async fn play_wav_file(
// Read all samples as i16. // Read all samples as i16.
let samples: Vec<i16> = if spec.bits_per_sample == 16 { let samples: Vec<i16> = if spec.bits_per_sample == 16 {
reader reader.samples::<i16>().filter_map(|s| s.ok()).collect()
.samples::<i16>()
.filter_map(|s| s.ok())
.collect()
} else if spec.bits_per_sample == 32 && spec.sample_format == hound::SampleFormat::Float { } else if spec.bits_per_sample == 32 && spec.sample_format == hound::SampleFormat::Float {
reader reader
.samples::<f32>() .samples::<f32>()
@@ -199,10 +196,7 @@ pub fn load_prompt_pcm_frames(wav_path: &str) -> Result<Vec<Vec<f32>>, String> {
.map(|s| s as f32 / 32768.0) .map(|s| s as f32 / 32768.0)
.collect() .collect()
} else if spec.bits_per_sample == 32 && spec.sample_format == hound::SampleFormat::Float { } else if spec.bits_per_sample == 32 && spec.sample_format == hound::SampleFormat::Float {
reader reader.samples::<f32>().filter_map(|s| s.ok()).collect()
.samples::<f32>()
.filter_map(|s| s.ok())
.collect()
} else { } else {
return Err(format!( return Err(format!(
"unsupported WAV format: {}bit {:?}", "unsupported WAV format: {}bit {:?}",
@@ -214,14 +208,23 @@ pub fn load_prompt_pcm_frames(wav_path: &str) -> Result<Vec<Vec<f32>>, String> {
return Ok(vec![]); return Ok(vec![]);
} }
pcm_to_mix_frames(&samples, wav_rate)
}
/// Convert PCM samples at an arbitrary rate into 48kHz 20ms mixer frames.
pub fn pcm_to_mix_frames(samples: &[f32], sample_rate: u32) -> Result<Vec<Vec<f32>>, String> {
if samples.is_empty() {
return Ok(vec![]);
}
// Resample to MIX_RATE (48kHz) if needed. // Resample to MIX_RATE (48kHz) if needed.
let resampled = if wav_rate != MIX_RATE { let resampled = if sample_rate != MIX_RATE {
let mut transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?; let mut transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?;
transcoder transcoder
.resample_f32(&samples, wav_rate, MIX_RATE) .resample_f32(samples, sample_rate, MIX_RATE)
.map_err(|e| format!("resample: {e}"))? .map_err(|e| format!("resample: {e}"))?
} else { } else {
samples samples.to_vec()
}; };
// Split into MIX_FRAME_SIZE (960) sample frames. // Split into MIX_FRAME_SIZE (960) sample frames.

View File

@@ -23,16 +23,22 @@ pub enum CallState {
Ringing, Ringing,
Connected, Connected,
Voicemail, Voicemail,
Ivr,
Terminated, Terminated,
} }
impl CallState { impl CallState {
/// Wire-format string for events/dashboards. Not currently emitted —
/// call state changes flow as typed events (`call_answered`, etc.) —
/// but kept for future status-snapshot work.
#[allow(dead_code)]
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
Self::SettingUp => "setting-up", Self::SettingUp => "setting-up",
Self::Ringing => "ringing", Self::Ringing => "ringing",
Self::Connected => "connected", Self::Connected => "connected",
Self::Voicemail => "voicemail", Self::Voicemail => "voicemail",
Self::Ivr => "ivr",
Self::Terminated => "terminated", Self::Terminated => "terminated",
} }
} }
@@ -45,6 +51,8 @@ pub enum CallDirection {
} }
impl CallDirection { impl CallDirection {
/// Wire-format string. See CallState::as_str.
#[allow(dead_code)]
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
Self::Inbound => "inbound", Self::Inbound => "inbound",
@@ -59,7 +67,12 @@ pub enum LegKind {
SipProvider, SipProvider,
SipDevice, SipDevice,
WebRtc, WebRtc,
Media, // voicemail playback, IVR, recording /// Voicemail playback, IVR prompt playback, recording — not yet wired up
/// as a distinct leg kind (those paths currently use the mixer's role
/// system instead). Kept behind allow so adding a real media leg later
/// doesn't require re-introducing the variant.
#[allow(dead_code)]
Media,
Tool, // observer leg for recording, transcription, etc. Tool, // observer leg for recording, transcription, etc.
} }
@@ -107,11 +120,22 @@ pub struct LegInfo {
/// For SIP legs: the SIP Call-ID for message routing. /// For SIP legs: the SIP Call-ID for message routing.
pub sip_call_id: Option<String>, pub sip_call_id: Option<String>,
/// For WebRTC legs: the session ID in WebRtcEngine. /// For WebRTC legs: the session ID in WebRtcEngine.
///
/// Populated at leg creation but not yet consumed by the hub —
/// WebRTC session lookup currently goes through the session registry
/// directly. Kept for introspection/debugging.
#[allow(dead_code)]
pub webrtc_session_id: Option<String>, pub webrtc_session_id: Option<String>,
/// The RTP socket allocated for this leg. /// The RTP socket allocated for this leg.
pub rtp_socket: Option<Arc<UdpSocket>>, pub rtp_socket: Option<Arc<UdpSocket>>,
/// The RTP port number. /// The RTP port number.
pub rtp_port: u16, pub rtp_port: u16,
/// Public IP to advertise in SDP/Record-Route when THIS leg is the
/// destination of a rewrite. Populated only for provider legs; `None`
/// for LAN SIP devices, WebRTC browsers, media, and tool legs (which
/// are reachable via `lan_ip`). See `route_passthrough_message` for
/// the per-destination advertise-IP logic.
pub public_ip: Option<String>,
/// The remote media endpoint (learned from SDP or address learning). /// The remote media endpoint (learned from SDP or address learning).
pub remote_media: Option<SocketAddr>, pub remote_media: Option<SocketAddr>,
/// SIP signaling address (provider or device). /// SIP signaling address (provider or device).
@@ -124,14 +148,21 @@ pub struct LegInfo {
/// A multiparty call with N legs and a central mixer. /// A multiparty call with N legs and a central mixer.
pub struct Call { pub struct Call {
// Duplicated from the HashMap key in CallManager. Kept for future
// status-snapshot work.
#[allow(dead_code)]
pub id: String, pub id: String,
pub state: CallState, pub state: CallState,
// Populated at call creation but not currently consumed — dashboard
// pull snapshots are gone (push events only).
#[allow(dead_code)]
pub direction: CallDirection, pub direction: CallDirection,
pub created_at: Instant, pub created_at: Instant,
// Metadata. // Metadata.
pub caller_number: Option<String>, pub caller_number: Option<String>,
pub callee_number: Option<String>, pub callee_number: Option<String>,
#[allow(dead_code)]
pub provider_id: String, pub provider_id: String,
/// Original INVITE from the device (for device-originated outbound calls). /// Original INVITE from the device (for device-originated outbound calls).
@@ -211,42 +242,4 @@ impl Call {
handle.abort(); handle.abort();
} }
} }
/// Produce a JSON status snapshot for the dashboard.
pub fn to_status_json(&self) -> serde_json::Value {
let legs: Vec<serde_json::Value> = self
.legs
.values()
.filter(|l| l.state != LegState::Terminated)
.map(|l| {
let metadata: serde_json::Value = if l.metadata.is_empty() {
serde_json::json!({})
} else {
serde_json::Value::Object(
l.metadata.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
)
};
serde_json::json!({
"id": l.id,
"type": l.kind.as_str(),
"state": l.state.as_str(),
"codec": sip_proto::helpers::codec_name(l.codec_pt),
"rtpPort": l.rtp_port,
"remoteMedia": l.remote_media.map(|a| format!("{}:{}", a.ip(), a.port())),
"metadata": metadata,
})
})
.collect();
serde_json::json!({
"id": self.id,
"state": self.state.as_str(),
"direction": self.direction.as_str(),
"callerNumber": self.caller_number,
"calleeNumber": self.callee_number,
"providerUsed": self.provider_id,
"duration": self.duration_secs(),
"legs": legs,
})
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@
//! proxy engine via the `configure` command. These types mirror the TS interfaces. //! proxy engine via the `configure` command. These types mirror the TS interfaces.
use serde::Deserialize; use serde::Deserialize;
use sip_proto::message::SipMessage;
use std::net::SocketAddr; use std::net::SocketAddr;
/// Network endpoint. /// Network endpoint.
@@ -30,6 +31,11 @@ impl Endpoint {
} }
/// Provider quirks for codec/protocol workarounds. /// Provider quirks for codec/protocol workarounds.
//
// Deserialized from provider config for TS parity. Early-media silence
// injection and related workarounds are not yet ported to the Rust engine,
// so every field is populated by serde but not yet consumed.
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct Quirks { pub struct Quirks {
#[serde(rename = "earlyMediaSilence")] #[serde(rename = "earlyMediaSilence")]
@@ -44,6 +50,9 @@ pub struct Quirks {
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct ProviderConfig { pub struct ProviderConfig {
pub id: String, pub id: String,
// UI label — populated by serde for parity with the TS config, not
// consumed at runtime.
#[allow(dead_code)]
#[serde(rename = "displayName")] #[serde(rename = "displayName")]
pub display_name: String, pub display_name: String,
pub domain: String, pub domain: String,
@@ -54,6 +63,8 @@ pub struct ProviderConfig {
#[serde(rename = "registerIntervalSec")] #[serde(rename = "registerIntervalSec")]
pub register_interval_sec: u32, pub register_interval_sec: u32,
pub codecs: Vec<u8>, pub codecs: Vec<u8>,
// Workaround knobs populated by serde but not yet acted upon — see Quirks.
#[allow(dead_code)]
pub quirks: Quirks, pub quirks: Quirks,
} }
@@ -84,6 +95,10 @@ pub struct RouteMatch {
/// Route action. /// Route action.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
// Several fields (voicemail_box, ivr_menu_id, no_answer_timeout) are read
// by resolve_inbound_route but not yet honored downstream — see the
// multi-target TODO in CallManager::create_inbound_call.
#[allow(dead_code)]
pub struct RouteAction { pub struct RouteAction {
pub targets: Option<Vec<String>>, pub targets: Option<Vec<String>>,
#[serde(rename = "ringBrowsers")] #[serde(rename = "ringBrowsers")]
@@ -106,7 +121,11 @@ pub struct RouteAction {
/// A routing rule. /// A routing rule.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct Route { pub struct Route {
// `id` and `name` are UI identifiers, populated by serde but not
// consumed by the resolvers.
#[allow(dead_code)]
pub id: String, pub id: String,
#[allow(dead_code)]
pub name: String, pub name: String,
pub priority: i32, pub priority: i32,
pub enabled: bool, pub enabled: bool,
@@ -141,6 +160,10 @@ pub struct AppConfig {
pub providers: Vec<ProviderConfig>, pub providers: Vec<ProviderConfig>,
pub devices: Vec<DeviceConfig>, pub devices: Vec<DeviceConfig>,
pub routing: RoutingConfig, pub routing: RoutingConfig,
#[serde(default)]
pub voiceboxes: Vec<VoiceboxConfig>,
#[serde(default)]
pub ivr: Option<IvrConfig>,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -148,12 +171,158 @@ pub struct RoutingConfig {
pub routes: Vec<Route>, pub routes: Vec<Route>,
} }
// ---------------------------------------------------------------------------
// Voicebox config
// ---------------------------------------------------------------------------
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct VoiceboxConfig {
pub id: String,
#[serde(default)]
pub enabled: bool,
#[serde(rename = "greetingText")]
pub greeting_text: Option<String>,
#[serde(rename = "greetingVoice")]
pub greeting_voice: Option<String>,
#[serde(rename = "greetingWavPath")]
pub greeting_wav_path: Option<String>,
#[serde(rename = "maxRecordingSec")]
pub max_recording_sec: Option<u32>,
}
// ---------------------------------------------------------------------------
// IVR config
// ---------------------------------------------------------------------------
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct IvrConfig {
pub enabled: bool,
pub menus: Vec<IvrMenuConfig>,
#[serde(rename = "entryMenuId")]
pub entry_menu_id: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct IvrMenuConfig {
pub id: String,
#[serde(rename = "promptText")]
pub prompt_text: String,
#[serde(rename = "promptVoice")]
pub prompt_voice: Option<String>,
pub entries: Vec<IvrMenuEntry>,
#[serde(rename = "timeoutSec")]
pub timeout_sec: Option<u32>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct IvrMenuEntry {
pub digit: String,
pub action: String,
pub target: Option<String>,
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Pattern matching (ported from ts/config.ts) // Pattern matching (ported from ts/config.ts)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Extract the URI user part and normalize phone-like identities for routing.
///
/// This keeps inbound route matching stable across provider-specific URI shapes,
/// e.g. `sip:+49 421 219694@trunk.example` and `sip:0049421219694@trunk.example`
/// both normalize to `+49421219694`.
pub fn normalize_routing_identity(value: &str) -> String {
let extracted = SipMessage::extract_uri_user(value).unwrap_or(value).trim();
if extracted.is_empty() {
return String::new();
}
let mut digits = String::new();
let mut saw_plus = false;
for (idx, ch) in extracted.chars().enumerate() {
if ch.is_ascii_digit() {
digits.push(ch);
continue;
}
if ch == '+' && idx == 0 {
saw_plus = true;
continue;
}
if matches!(ch, ' ' | '\t' | '-' | '.' | '/' | '(' | ')') {
continue;
}
return extracted.to_string();
}
if digits.is_empty() {
return extracted.to_string();
}
if saw_plus {
return format!("+{digits}");
}
if digits.starts_with("00") && digits.len() > 2 {
return format!("+{}", &digits[2..]);
}
digits
}
fn parse_numeric_range_value(value: &str) -> Option<(bool, &str)> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let (has_plus, digits) = if let Some(rest) = trimmed.strip_prefix('+') {
(true, rest)
} else {
(false, trimmed)
};
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return None;
}
Some((has_plus, digits))
}
fn matches_numeric_range_pattern(pattern: &str, value: &str) -> bool {
let Some((start, end)) = pattern.split_once("..") else {
return false;
};
let Some((start_plus, start_digits)) = parse_numeric_range_value(start) else {
return false;
};
let Some((end_plus, end_digits)) = parse_numeric_range_value(end) else {
return false;
};
let Some((value_plus, value_digits)) = parse_numeric_range_value(value) else {
return false;
};
if start_plus != end_plus || value_plus != start_plus {
return false;
}
if start_digits.len() != end_digits.len() || value_digits.len() != start_digits.len() {
return false;
}
if start_digits > end_digits {
return false;
}
value_digits >= start_digits && value_digits <= end_digits
}
/// Test a value against a pattern string. /// Test a value against a pattern string.
/// - None/empty: matches everything (wildcard) /// - None/empty: matches everything (wildcard)
/// - `start..end`: numeric range match
/// - Trailing '*': prefix match /// - Trailing '*': prefix match
/// - Starts with '/': regex match /// - Starts with '/': regex match
/// - Otherwise: exact match /// - Otherwise: exact match
@@ -169,6 +338,10 @@ pub fn matches_pattern(pattern: Option<&str>, value: &str) -> bool {
return value.starts_with(&pattern[..pattern.len() - 1]); return value.starts_with(&pattern[..pattern.len() - 1]);
} }
if matches_numeric_range_pattern(pattern, value) {
return true;
}
// Regex match: "/^\\+49/" or "/pattern/i" // Regex match: "/^\\+49/" or "/pattern/i"
if pattern.starts_with('/') { if pattern.starts_with('/') {
if let Some(last_slash) = pattern[1..].rfind('/') { if let Some(last_slash) = pattern[1..].rfind('/') {
@@ -192,12 +365,22 @@ pub fn matches_pattern(pattern: Option<&str>, value: &str) -> bool {
/// Result of resolving an outbound route. /// Result of resolving an outbound route.
pub struct OutboundRouteResult { pub struct OutboundRouteResult {
pub provider: ProviderConfig, pub provider: ProviderConfig,
// TODO: prefix rewriting is unfinished — this is computed but the
// caller ignores it and uses the raw dialed number.
#[allow(dead_code)]
pub transformed_number: String, pub transformed_number: String,
} }
/// Result of resolving an inbound route. /// Result of resolving an inbound route.
//
// `device_ids`, `ring_all_devices`, and `ring_browsers` are consumed by
// create_inbound_call.
// The remaining fields (voicemail_box, ivr_menu_id, no_answer_timeout)
// are resolved but not yet acted upon — see the multi-target TODO.
#[allow(dead_code)]
pub struct InboundRouteResult { pub struct InboundRouteResult {
pub device_ids: Vec<String>, pub device_ids: Vec<String>,
pub ring_all_devices: bool,
pub ring_browsers: bool, pub ring_browsers: bool,
pub voicemail_box: Option<String>, pub voicemail_box: Option<String>,
pub ivr_menu_id: Option<String>, pub ivr_menu_id: Option<String>,
@@ -280,7 +463,7 @@ impl AppConfig {
provider_id: &str, provider_id: &str,
called_number: &str, called_number: &str,
caller_number: &str, caller_number: &str,
) -> InboundRouteResult { ) -> Option<InboundRouteResult> {
let mut routes: Vec<&Route> = self let mut routes: Vec<&Route> = self
.routing .routing
.routes .routes
@@ -304,22 +487,172 @@ impl AppConfig {
continue; continue;
} }
return InboundRouteResult { let explicit_targets = route.action.targets.clone();
device_ids: route.action.targets.clone().unwrap_or_default(), return Some(InboundRouteResult {
device_ids: explicit_targets.clone().unwrap_or_default(),
ring_all_devices: explicit_targets.is_none(),
ring_browsers: route.action.ring_browsers.unwrap_or(false), ring_browsers: route.action.ring_browsers.unwrap_or(false),
voicemail_box: route.action.voicemail_box.clone(), voicemail_box: route.action.voicemail_box.clone(),
ivr_menu_id: route.action.ivr_menu_id.clone(), ivr_menu_id: route.action.ivr_menu_id.clone(),
no_answer_timeout: route.action.no_answer_timeout, no_answer_timeout: route.action.no_answer_timeout,
}; });
} }
// Fallback: ring all devices + browsers. None
InboundRouteResult { }
device_ids: vec![], }
ring_browsers: true,
#[cfg(test)]
mod tests {
use super::*;
fn test_app_config(routes: Vec<Route>) -> AppConfig {
AppConfig {
proxy: ProxyConfig {
lan_ip: "127.0.0.1".to_string(),
lan_port: 5070,
public_ip_seed: None,
rtp_port_range: RtpPortRange {
min: 20_000,
max: 20_100,
},
},
providers: vec![ProviderConfig {
id: "provider-a".to_string(),
display_name: "Provider A".to_string(),
domain: "example.com".to_string(),
outbound_proxy: Endpoint {
address: "example.com".to_string(),
port: 5060,
},
username: "user".to_string(),
password: "pass".to_string(),
register_interval_sec: 300,
codecs: vec![9],
quirks: Quirks {
early_media_silence: false,
silence_payload_type: None,
silence_max_packets: None,
},
}],
devices: vec![DeviceConfig {
id: "desk".to_string(),
display_name: "Desk".to_string(),
expected_address: "127.0.0.1".to_string(),
extension: "100".to_string(),
}],
routing: RoutingConfig { routes },
voiceboxes: vec![],
ivr: None,
}
}
#[test]
fn normalize_routing_identity_extracts_uri_user_and_phone_number() {
assert_eq!(
normalize_routing_identity("sip:0049 421 219694@voip.easybell.de"),
"+49421219694"
);
assert_eq!(
normalize_routing_identity("<tel:+49 (421) 219694>"),
"+49421219694"
);
assert_eq!(normalize_routing_identity("sip:100@pbx.local"), "100");
assert_eq!(normalize_routing_identity("sip:alice@pbx.local"), "alice");
}
#[test]
fn resolve_inbound_route_requires_explicit_match() {
let cfg = test_app_config(vec![]);
assert!(cfg
.resolve_inbound_route("provider-a", "+49421219694", "+491701234567")
.is_none());
}
#[test]
fn resolve_inbound_route_matches_per_number_on_shared_provider() {
let cfg = test_app_config(vec![
Route {
id: "main".to_string(),
name: "Main DID".to_string(),
priority: 200,
enabled: true,
match_criteria: RouteMatch {
direction: "inbound".to_string(),
number_pattern: Some("+49421219694".to_string()),
caller_pattern: None,
source_provider: Some("provider-a".to_string()),
source_device: None,
},
action: RouteAction {
targets: Some(vec!["desk".to_string()]),
ring_browsers: Some(true),
voicemail_box: None, voicemail_box: None,
ivr_menu_id: None, ivr_menu_id: None,
no_answer_timeout: None, no_answer_timeout: None,
} provider: None,
failover_providers: None,
strip_prefix: None,
prepend_prefix: None,
},
},
Route {
id: "support".to_string(),
name: "Support DID".to_string(),
priority: 100,
enabled: true,
match_criteria: RouteMatch {
direction: "inbound".to_string(),
number_pattern: Some("+49421219695".to_string()),
caller_pattern: None,
source_provider: Some("provider-a".to_string()),
source_device: None,
},
action: RouteAction {
targets: None,
ring_browsers: Some(false),
voicemail_box: Some("support-box".to_string()),
ivr_menu_id: None,
no_answer_timeout: Some(20),
provider: None,
failover_providers: None,
strip_prefix: None,
prepend_prefix: None,
},
},
]);
let main = cfg
.resolve_inbound_route("provider-a", "+49421219694", "+491701234567")
.expect("main DID should match");
assert_eq!(main.device_ids, vec!["desk".to_string()]);
assert!(main.ring_browsers);
let support = cfg
.resolve_inbound_route("provider-a", "+49421219695", "+491701234567")
.expect("support DID should match");
assert_eq!(support.voicemail_box.as_deref(), Some("support-box"));
assert_eq!(support.no_answer_timeout, Some(20));
assert!(!support.ring_browsers);
}
#[test]
fn matches_pattern_supports_numeric_ranges() {
assert!(matches_pattern(
Some("042116767546..042116767548"),
"042116767547"
));
assert!(!matches_pattern(
Some("042116767546..042116767548"),
"042116767549"
));
assert!(matches_pattern(
Some("+4942116767546..+4942116767548"),
"+4942116767547"
));
assert!(!matches_pattern(
Some("+4942116767546..+4942116767548"),
"042116767547"
));
} }
} }

View File

@@ -1,200 +0,0 @@
//! DTMF detection — parses RFC 2833 telephone-event RTP packets.
//!
//! Deduplicates repeated packets (same digit sent multiple times with
//! increasing duration) and fires once per detected digit.
//!
//! Ported from ts/call/dtmf-detector.ts.
use crate::ipc::{emit_event, OutTx};
/// RFC 2833 event ID → character mapping.
const EVENT_CHARS: &[char] = &[
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#', 'A', 'B', 'C', 'D',
];
/// Safety timeout: report digit if no End packet arrives within this many ms.
const SAFETY_TIMEOUT_MS: u64 = 200;
/// DTMF detector for a single RTP stream.
pub struct DtmfDetector {
/// Negotiated telephone-event payload type (default 101).
telephone_event_pt: u8,
/// Clock rate for duration calculation (default 8000 Hz).
clock_rate: u32,
/// Call ID for event emission.
call_id: String,
// Deduplication state.
current_event_id: Option<u8>,
current_event_ts: Option<u32>,
current_event_reported: bool,
current_event_duration: u16,
out_tx: OutTx,
}
impl DtmfDetector {
pub fn new(call_id: String, out_tx: OutTx) -> Self {
Self {
telephone_event_pt: 101,
clock_rate: 8000,
call_id,
current_event_id: None,
current_event_ts: None,
current_event_reported: false,
current_event_duration: 0,
out_tx,
}
}
/// Feed an RTP packet. Checks PT; ignores non-DTMF packets.
/// Returns Some(digit_char) if a digit was detected.
pub fn process_rtp(&mut self, data: &[u8]) -> Option<char> {
if data.len() < 16 {
return None; // 12-byte header + 4-byte telephone-event minimum
}
let pt = data[1] & 0x7F;
if pt != self.telephone_event_pt {
return None;
}
let marker = (data[1] & 0x80) != 0;
let rtp_timestamp = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
// Parse telephone-event payload.
let event_id = data[12];
let end_bit = (data[13] & 0x80) != 0;
let duration = u16::from_be_bytes([data[14], data[15]]);
if event_id as usize >= EVENT_CHARS.len() {
return None;
}
// Detect new event.
let is_new = marker
|| self.current_event_id != Some(event_id)
|| self.current_event_ts != Some(rtp_timestamp);
if is_new {
// Report pending unreported event.
let pending = self.report_pending();
self.current_event_id = Some(event_id);
self.current_event_ts = Some(rtp_timestamp);
self.current_event_reported = false;
self.current_event_duration = duration;
if pending.is_some() {
return pending;
}
}
if duration > self.current_event_duration {
self.current_event_duration = duration;
}
// Report on End bit (first time only).
if end_bit && !self.current_event_reported {
self.current_event_reported = true;
let digit = EVENT_CHARS[event_id as usize];
let duration_ms = (self.current_event_duration as f64 / self.clock_rate as f64) * 1000.0;
emit_event(
&self.out_tx,
"dtmf_digit",
serde_json::json!({
"call_id": self.call_id,
"digit": digit.to_string(),
"duration_ms": duration_ms.round() as u32,
"source": "rfc2833",
}),
);
return Some(digit);
}
None
}
/// Report a pending unreported event.
fn report_pending(&mut self) -> Option<char> {
if let Some(event_id) = self.current_event_id {
if !self.current_event_reported && (event_id as usize) < EVENT_CHARS.len() {
self.current_event_reported = true;
let digit = EVENT_CHARS[event_id as usize];
let duration_ms =
(self.current_event_duration as f64 / self.clock_rate as f64) * 1000.0;
emit_event(
&self.out_tx,
"dtmf_digit",
serde_json::json!({
"call_id": self.call_id,
"digit": digit.to_string(),
"duration_ms": duration_ms.round() as u32,
"source": "rfc2833",
}),
);
return Some(digit);
}
}
None
}
/// Process a SIP INFO message body for DTMF.
pub fn process_sip_info(&mut self, content_type: &str, body: &str) -> Option<char> {
let ct = content_type.to_ascii_lowercase();
if ct.contains("application/dtmf-relay") {
// Format: "Signal= 5\r\nDuration= 160\r\n"
let signal = body
.lines()
.find(|l| l.to_ascii_lowercase().starts_with("signal"))
.and_then(|l| l.split('=').nth(1))
.map(|s| s.trim().to_string())?;
if signal.len() != 1 {
return None;
}
let digit = signal.chars().next()?.to_ascii_uppercase();
if !"0123456789*#ABCD".contains(digit) {
return None;
}
emit_event(
&self.out_tx,
"dtmf_digit",
serde_json::json!({
"call_id": self.call_id,
"digit": digit.to_string(),
"source": "sip-info",
}),
);
return Some(digit);
}
if ct.contains("application/dtmf") {
let digit = body.trim().chars().next()?.to_ascii_uppercase();
if !"0123456789*#ABCD".contains(digit) {
return None;
}
emit_event(
&self.out_tx,
"dtmf_digit",
serde_json::json!({
"call_id": self.call_id,
"digit": digit.to_string(),
"source": "sip-info",
}),
);
return Some(digit);
}
None
}
}

View File

@@ -19,7 +19,13 @@ pub struct Command {
} }
/// Send a response to a command. /// Send a response to a command.
pub fn respond(tx: &OutTx, id: &str, success: bool, result: Option<serde_json::Value>, error: Option<&str>) { pub fn respond(
tx: &OutTx,
id: &str,
success: bool,
result: Option<serde_json::Value>,
error: Option<&str>,
) {
let mut resp = serde_json::json!({ "id": id, "success": success }); let mut resp = serde_json::json!({ "id": id, "success": success });
if let Some(r) = result { if let Some(r) = result {
resp["result"] = r; resp["result"] = r;

View File

@@ -0,0 +1,188 @@
//! Per-leg adaptive jitter buffer for the audio mixer.
//!
//! Sits between inbound RTP packet reception and the mixer's decode step.
//! Reorders packets by sequence number and delivers exactly one frame per
//! 20ms mixer tick, smoothing out network jitter. When a packet is missing,
//! the mixer can invoke codec PLC to conceal the gap.
use crate::mixer::RtpPacket;
use std::collections::BTreeMap;
/// Per-leg jitter buffer. Collects RTP packets keyed by sequence number,
/// delivers one frame per 20ms tick in sequence order.
///
/// Adaptive target depth: starts at 3 frames (60ms), adjusts between
/// 26 frames based on observed jitter.
pub struct JitterBuffer {
/// Packets waiting for playout, keyed by seq number.
buffer: BTreeMap<u16, RtpPacket>,
/// Next expected sequence number for playout.
next_seq: Option<u16>,
/// Target buffer depth in frames (adaptive).
target_depth: u32,
/// Current fill level high-water mark (for adaptation).
max_fill_seen: u32,
/// Ticks since last adaptation adjustment.
adapt_counter: u32,
/// Consecutive ticks where buffer was empty (for ramp-up).
empty_streak: u32,
/// Consecutive ticks where buffer had excess (for ramp-down).
excess_streak: u32,
/// Whether we've started playout (initial fill complete).
playing: bool,
/// Number of frames consumed since start (for stats).
frames_consumed: u64,
/// Number of frames lost (gap in sequence).
frames_lost: u64,
}
/// What the mixer gets back each tick.
pub enum JitterResult {
/// A packet is available for decoding.
Packet(RtpPacket),
/// Packet was expected but missing — invoke PLC.
Missing,
/// Buffer is in initial fill phase — output silence.
Filling,
}
impl JitterBuffer {
pub fn new() -> Self {
Self {
buffer: BTreeMap::new(),
next_seq: None,
target_depth: 3, // 60ms initial target
max_fill_seen: 0,
adapt_counter: 0,
empty_streak: 0,
excess_streak: 0,
playing: false,
frames_consumed: 0,
frames_lost: 0,
}
}
/// Push a received RTP packet into the buffer.
pub fn push(&mut self, pkt: RtpPacket) {
// Ignore duplicates.
if self.buffer.contains_key(&pkt.seq) {
return;
}
// Detect large forward seq jump (hold/resume, SSRC change).
if let Some(next) = self.next_seq {
let jump = pkt.seq.wrapping_sub(next);
if jump > 1000 && jump < 0x8000 {
// Massive forward jump — reset buffer.
self.reset();
self.next_seq = Some(pkt.seq);
}
}
if self.next_seq.is_none() {
self.next_seq = Some(pkt.seq);
}
self.buffer.insert(pkt.seq, pkt);
}
/// Consume one frame for the current 20ms tick.
/// Called once per mixer tick per leg.
pub fn consume(&mut self) -> JitterResult {
// Track fill level for adaptation.
let fill = self.buffer.len() as u32;
if fill > self.max_fill_seen {
self.max_fill_seen = fill;
}
// Initial fill phase: wait until we have target_depth packets.
if !self.playing {
if fill >= self.target_depth {
self.playing = true;
} else {
return JitterResult::Filling;
}
}
let seq = match self.next_seq {
Some(s) => s,
None => return JitterResult::Filling,
};
// Advance next_seq (wrapping u16).
self.next_seq = Some(seq.wrapping_add(1));
// Try to pull the expected sequence number.
if let Some(pkt) = self.buffer.remove(&seq) {
self.frames_consumed += 1;
self.empty_streak = 0;
// Adaptive: if buffer is consistently deep, we can tighten.
if fill > self.target_depth + 2 {
self.excess_streak += 1;
} else {
self.excess_streak = 0;
}
JitterResult::Packet(pkt)
} else {
// Packet missing — PLC needed.
self.frames_lost += 1;
self.empty_streak += 1;
self.excess_streak = 0;
JitterResult::Missing
}
}
/// Run adaptation logic. Call every tick; internally gates to ~1s intervals.
pub fn adapt(&mut self) {
self.adapt_counter += 1;
if self.adapt_counter < 50 {
return;
}
self.adapt_counter = 0;
// If we had many empty ticks, increase depth.
if self.empty_streak > 3 && self.target_depth < 6 {
self.target_depth += 1;
}
// If buffer consistently overfull, decrease depth.
else if self.excess_streak > 25 && self.target_depth > 2 {
self.target_depth -= 1;
}
self.max_fill_seen = 0;
}
/// Discard packets that are too old (seq far behind next_seq).
/// Prevents unbounded memory growth from reordered/late packets.
pub fn prune_stale(&mut self) {
if let Some(next) = self.next_seq {
// Remove anything more than 100 frames behind playout point.
// Use wrapping arithmetic: if (next - seq) > 100, it's stale.
let stale: Vec<u16> = self
.buffer
.keys()
.filter(|&&seq| {
let age = next.wrapping_sub(seq);
age > 100 && age < 0x8000 // < 0x8000 means it's actually behind, not ahead
})
.copied()
.collect();
for seq in stale {
self.buffer.remove(&seq);
}
}
}
/// Reset the buffer (e.g., after re-INVITE / hold-resume).
pub fn reset(&mut self) {
self.buffer.clear();
self.next_seq = None;
self.playing = false;
self.empty_streak = 0;
self.excess_streak = 0;
self.adapt_counter = 0;
}
}

View File

@@ -63,7 +63,8 @@ pub fn spawn_sip_inbound(
if offset + 4 > n { if offset + 4 > n {
continue; // Malformed: extension header truncated. continue; // Malformed: extension header truncated.
} }
let ext_len = u16::from_be_bytes([buf[offset + 2], buf[offset + 3]]) as usize; let ext_len =
u16::from_be_bytes([buf[offset + 2], buf[offset + 3]]) as usize;
offset += 4 + ext_len * 4; offset += 4 + ext_len * 4;
} }
if offset >= n { if offset >= n {
@@ -74,7 +75,17 @@ pub fn spawn_sip_inbound(
if payload.is_empty() { if payload.is_empty() {
continue; continue;
} }
if inbound_tx.send(RtpPacket { payload, payload_type: pt, marker, seq, timestamp }).await.is_err() { if inbound_tx
.send(RtpPacket {
payload,
payload_type: pt,
marker,
seq,
timestamp,
})
.await
.is_err()
{
break; // Channel closed — leg removed. break; // Channel closed — leg removed.
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
//! All encoding/decoding happens at leg boundaries. Per-leg inbound denoising at 48kHz. //! All encoding/decoding happens at leg boundaries. Per-leg inbound denoising at 48kHz.
//! //!
//! The mixer runs a 20ms tick loop: //! The mixer runs a 20ms tick loop:
//! 1. Drain inbound channels, decode to f32, resample to 48kHz, denoise per-leg //! 1. Drain inbound channels, reorder RTP, decode variable-duration packets to 48kHz,
//! and queue them in per-leg PCM buffers
//! 2. Compute total mix (sum of all **participant** legs' f32 PCM as f64) //! 2. Compute total mix (sum of all **participant** legs' f32 PCM as f64)
//! 3. For each participant leg: mix-minus = total - own, resample to leg codec rate, encode, send //! 3. For each participant leg: mix-minus = total - own, resample to leg codec rate, encode, send
//! 4. For each isolated leg: play prompt frame or silence, check DTMF //! 4. For each isolated leg: play prompt frame or silence, check DTMF
@@ -15,11 +16,13 @@
//! 6. Forward DTMF between participant legs only //! 6. Forward DTMF between participant legs only
use crate::ipc::{emit_event, OutTx}; use crate::ipc::{emit_event, OutTx};
use crate::rtp::{build_rtp_header, rtp_clock_increment}; use crate::jitter_buffer::{JitterBuffer, JitterResult};
use crate::rtp::{build_rtp_header, rtp_clock_increment, rtp_clock_rate};
use crate::tts::TtsStreamMessage;
use codec_lib::{codec_sample_rate, new_denoiser, TranscodeState}; use codec_lib::{codec_sample_rate, new_denoiser, TranscodeState};
use nnnoiseless::DenoiseState; use nnnoiseless::DenoiseState;
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot, watch};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio::time::{self, Duration, MissedTickBehavior}; use tokio::time::{self, Duration, MissedTickBehavior};
@@ -28,6 +31,12 @@ use tokio::time::{self, Duration, MissedTickBehavior};
const MIX_RATE: u32 = 48000; const MIX_RATE: u32 = 48000;
/// Samples per 20ms frame at the mixing rate. /// Samples per 20ms frame at the mixing rate.
const MIX_FRAME_SIZE: usize = 960; // 48000 * 0.020 const MIX_FRAME_SIZE: usize = 960; // 48000 * 0.020
/// Safety cap for how much timestamp-derived gap fill we synthesize at once.
const MAX_GAP_FILL_SAMPLES: usize = MIX_FRAME_SIZE * 6; // 120ms
/// Bound how many decode / concealment steps a leg can consume in one tick.
const MAX_PACKET_STEPS_PER_TICK: usize = 24;
/// Report the first output drop immediately, then every N drops.
const DROP_REPORT_INTERVAL: u64 = 50;
/// A raw RTP payload received from a leg (no RTP header). /// A raw RTP payload received from a leg (no RTP header).
pub struct RtpPacket { pub struct RtpPacket {
@@ -56,6 +65,12 @@ enum LegRole {
struct IsolationState { struct IsolationState {
/// PCM frames at MIX_RATE (960 samples each, 48kHz f32) queued for playback. /// PCM frames at MIX_RATE (960 samples each, 48kHz f32) queued for playback.
prompt_frames: VecDeque<Vec<f32>>, prompt_frames: VecDeque<Vec<f32>>,
/// Live TTS frames arrive here while playback is already in progress.
prompt_stream_rx: Option<mpsc::Receiver<TtsStreamMessage>>,
/// Cancels the background TTS producer when the interaction ends early.
prompt_cancel_tx: Option<watch::Sender<bool>>,
/// Whether the live prompt stream has ended.
prompt_stream_finished: bool,
/// Digits that complete the interaction (e.g., ['1', '2']). /// Digits that complete the interaction (e.g., ['1', '2']).
expected_digits: Vec<char>, expected_digits: Vec<char>,
/// Ticks remaining before timeout (decremented each tick after prompt ends). /// Ticks remaining before timeout (decremented each tick after prompt ends).
@@ -104,6 +119,7 @@ struct ToolLegSlot {
#[allow(dead_code)] #[allow(dead_code)]
tool_type: ToolType, tool_type: ToolType,
audio_tx: mpsc::Sender<ToolAudioBatch>, audio_tx: mpsc::Sender<ToolAudioBatch>,
dropped_batches: u64,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -131,12 +147,14 @@ pub enum MixerCommand {
leg_id: String, leg_id: String,
/// PCM frames at MIX_RATE (48kHz f32), each 960 samples. /// PCM frames at MIX_RATE (48kHz f32), each 960 samples.
prompt_pcm_frames: Vec<Vec<f32>>, prompt_pcm_frames: Vec<Vec<f32>>,
/// Optional live prompt stream. Frames are appended as they are synthesized.
prompt_stream_rx: Option<mpsc::Receiver<TtsStreamMessage>>,
/// Optional cancellation handle for the live prompt stream.
prompt_cancel_tx: Option<watch::Sender<bool>>,
expected_digits: Vec<char>, expected_digits: Vec<char>,
timeout_ms: u32, timeout_ms: u32,
result_tx: oneshot::Sender<InteractionResult>, result_tx: oneshot::Sender<InteractionResult>,
}, },
/// Cancel an in-progress interaction (e.g., leg being removed).
CancelInteraction { leg_id: String },
/// Add a tool leg that receives per-source unmerged audio. /// Add a tool leg that receives per-source unmerged audio.
AddToolLeg { AddToolLeg {
@@ -160,23 +178,319 @@ struct MixerLegSlot {
denoiser: Box<DenoiseState<'static>>, denoiser: Box<DenoiseState<'static>>,
inbound_rx: mpsc::Receiver<RtpPacket>, inbound_rx: mpsc::Receiver<RtpPacket>,
outbound_tx: mpsc::Sender<Vec<u8>>, outbound_tx: mpsc::Sender<Vec<u8>>,
/// Decoded PCM waiting for playout. Variable-duration RTP packets are
/// decoded into this FIFO; the mixer consumes exactly one 20ms frame per tick.
pcm_buffer: VecDeque<f32>,
/// Last decoded+denoised PCM frame at MIX_RATE (960 samples, 48kHz f32). /// Last decoded+denoised PCM frame at MIX_RATE (960 samples, 48kHz f32).
last_pcm_frame: Vec<f32>, last_pcm_frame: Vec<f32>,
/// Next RTP timestamp expected from the inbound stream.
expected_rtp_timestamp: Option<u32>,
/// Best-effort estimate of packet duration in RTP clock units.
estimated_packet_ts: u32,
/// Number of consecutive ticks with no inbound packet. /// Number of consecutive ticks with no inbound packet.
silent_ticks: u32, silent_ticks: u32,
/// Per-leg jitter buffer for packet reordering and timing.
jitter: JitterBuffer,
// RTP output state. // RTP output state.
rtp_seq: u16, rtp_seq: u16,
rtp_ts: u32, rtp_ts: u32,
rtp_ssrc: u32, rtp_ssrc: u32,
/// Dropped outbound frames for this leg (queue full / closed).
outbound_drops: u64,
/// Current role of this leg in the mixer. /// Current role of this leg in the mixer.
role: LegRole, role: LegRole,
} }
fn mix_samples_to_rtp_ts(codec_pt: u8, mix_samples: usize) -> u32 {
let clock_rate = rtp_clock_rate(codec_pt).max(1) as u64;
(((mix_samples as u64 * clock_rate) + (MIX_RATE as u64 / 2)) / MIX_RATE as u64) as u32
}
fn rtp_ts_to_mix_samples(codec_pt: u8, rtp_ts: u32) -> usize {
let clock_rate = rtp_clock_rate(codec_pt).max(1) as u64;
(((rtp_ts as u64 * MIX_RATE as u64) + (clock_rate / 2)) / clock_rate) as usize
}
fn is_forward_rtp_delta(delta: u32) -> bool {
delta > 0 && delta < 0x8000_0000
}
fn should_emit_drop_event(total_drops: u64) -> bool {
total_drops == 1 || total_drops % DROP_REPORT_INTERVAL == 0
}
fn emit_output_drop_event(
out_tx: &OutTx,
call_id: &str,
leg_id: Option<&str>,
tool_leg_id: Option<&str>,
stream: &str,
reason: &str,
total_drops: u64,
) {
if !should_emit_drop_event(total_drops) {
return;
}
emit_event(
out_tx,
"mixer_output_drop",
serde_json::json!({
"call_id": call_id,
"leg_id": leg_id,
"tool_leg_id": tool_leg_id,
"stream": stream,
"reason": reason,
"total_drops": total_drops,
}),
);
}
fn fade_concealment_from_last_frame(slot: &mut MixerLegSlot, samples: usize, decay: f32) {
let mut template = if slot.last_pcm_frame.is_empty() {
vec![0.0f32; MIX_FRAME_SIZE]
} else {
slot.last_pcm_frame.clone()
};
let mut remaining = samples;
while remaining > 0 {
for sample in &mut template {
*sample *= decay;
}
let take = remaining.min(template.len());
slot.pcm_buffer.extend(template.iter().take(take).copied());
remaining -= take;
}
}
fn append_packet_loss_concealment(slot: &mut MixerLegSlot, samples: usize) {
let mut remaining = samples.max(1);
while remaining > 0 {
let chunk = remaining.min(MIX_FRAME_SIZE);
if slot.codec_pt == codec_lib::PT_OPUS {
match slot.transcoder.opus_plc(chunk) {
Ok(mut pcm) => {
pcm.resize(chunk, 0.0);
slot.pcm_buffer.extend(pcm);
}
Err(_) => fade_concealment_from_last_frame(slot, chunk, 0.8),
}
} else {
fade_concealment_from_last_frame(slot, chunk, 0.85);
}
remaining -= chunk;
}
}
fn decode_packet_to_mix_pcm(slot: &mut MixerLegSlot, pkt: &RtpPacket) -> Option<Vec<f32>> {
let (pcm, rate) = slot
.transcoder
.decode_to_f32(&pkt.payload, pkt.payload_type)
.ok()?;
let pcm_48k = if rate == MIX_RATE {
pcm
} else {
slot.transcoder
.resample_f32(&pcm, rate, MIX_RATE)
.unwrap_or_else(|_| vec![0.0f32; MIX_FRAME_SIZE])
};
let processed = if slot.codec_pt != codec_lib::PT_OPUS {
TranscodeState::denoise_f32(&mut slot.denoiser, &pcm_48k)
} else {
pcm_48k
};
Some(processed)
}
fn queue_inbound_packet(slot: &mut MixerLegSlot, pkt: RtpPacket) {
if let Some(pcm_48k) = decode_packet_to_mix_pcm(slot, &pkt) {
if pcm_48k.is_empty() {
return;
}
if let Some(expected_ts) = slot.expected_rtp_timestamp {
let gap_ts = pkt.timestamp.wrapping_sub(expected_ts);
if is_forward_rtp_delta(gap_ts) {
let gap_samples = rtp_ts_to_mix_samples(slot.codec_pt, gap_ts);
if gap_samples <= MAX_GAP_FILL_SAMPLES {
append_packet_loss_concealment(slot, gap_samples);
} else {
slot.pcm_buffer.clear();
}
}
}
let packet_ts = mix_samples_to_rtp_ts(slot.codec_pt, pcm_48k.len());
if packet_ts > 0 {
slot.estimated_packet_ts = packet_ts;
slot.expected_rtp_timestamp = Some(pkt.timestamp.wrapping_add(packet_ts));
}
slot.pcm_buffer.extend(pcm_48k);
}
}
fn fill_leg_playout_buffer(slot: &mut MixerLegSlot) {
let mut steps = 0usize;
while slot.pcm_buffer.len() < MIX_FRAME_SIZE && steps < MAX_PACKET_STEPS_PER_TICK {
steps += 1;
match slot.jitter.consume() {
JitterResult::Packet(pkt) => queue_inbound_packet(slot, pkt),
JitterResult::Missing => {
let conceal_ts = slot
.estimated_packet_ts
.max(rtp_clock_increment(slot.codec_pt));
let conceal_samples =
rtp_ts_to_mix_samples(slot.codec_pt, conceal_ts).clamp(1, MAX_GAP_FILL_SAMPLES);
append_packet_loss_concealment(slot, conceal_samples);
if let Some(expected_ts) = slot.expected_rtp_timestamp {
slot.expected_rtp_timestamp = Some(expected_ts.wrapping_add(conceal_ts));
}
}
JitterResult::Filling => break,
}
}
}
fn take_mix_frame(slot: &mut MixerLegSlot) -> Vec<f32> {
let mut frame = Vec::with_capacity(MIX_FRAME_SIZE);
while frame.len() < MIX_FRAME_SIZE {
if let Some(sample) = slot.pcm_buffer.pop_front() {
frame.push(sample);
} else {
frame.push(0.0);
}
}
frame
}
fn soft_limit_sample(sample: f32) -> f32 {
const KNEE: f32 = 0.85;
let abs = sample.abs();
if abs <= KNEE {
sample
} else {
let excess = abs - KNEE;
let compressed = KNEE + (excess / (1.0 + (excess / (1.0 - KNEE))));
sample.signum() * compressed.min(1.0)
}
}
fn try_send_leg_output(
out_tx: &OutTx,
call_id: &str,
leg_id: &str,
slot: &mut MixerLegSlot,
rtp: Vec<u8>,
stream: &str,
) {
let reason = match slot.outbound_tx.try_send(rtp) {
Ok(()) => return,
Err(mpsc::error::TrySendError::Full(_)) => "full",
Err(mpsc::error::TrySendError::Closed(_)) => "closed",
};
slot.outbound_drops += 1;
emit_output_drop_event(
out_tx,
call_id,
Some(leg_id),
None,
stream,
reason,
slot.outbound_drops,
);
}
fn try_send_tool_output(
out_tx: &OutTx,
call_id: &str,
tool_leg_id: &str,
tool: &mut ToolLegSlot,
batch: ToolAudioBatch,
) {
let reason = match tool.audio_tx.try_send(batch) {
Ok(()) => return,
Err(mpsc::error::TrySendError::Full(_)) => "full",
Err(mpsc::error::TrySendError::Closed(_)) => "closed",
};
tool.dropped_batches += 1;
emit_output_drop_event(
out_tx,
call_id,
None,
Some(tool_leg_id),
"tool-batch",
reason,
tool.dropped_batches,
);
}
fn cancel_prompt_producer(state: &mut IsolationState) {
if let Some(cancel_tx) = state.prompt_cancel_tx.take() {
let _ = cancel_tx.send(true);
}
}
fn cancel_isolated_interaction(state: &mut IsolationState) {
cancel_prompt_producer(state);
if let Some(tx) = state.result_tx.take() {
let _ = tx.send(InteractionResult::Cancelled);
}
}
fn drain_prompt_stream(
out_tx: &OutTx,
call_id: &str,
leg_id: &str,
state: &mut IsolationState,
) {
loop {
let Some(mut stream_rx) = state.prompt_stream_rx.take() else {
return;
};
match stream_rx.try_recv() {
Ok(TtsStreamMessage::Frames(frames)) => {
state.prompt_frames.extend(frames);
state.prompt_stream_rx = Some(stream_rx);
}
Ok(TtsStreamMessage::Finished) => {
state.prompt_stream_finished = true;
return;
}
Ok(TtsStreamMessage::Failed(error)) => {
emit_event(
out_tx,
"mixer_error",
serde_json::json!({
"call_id": call_id,
"leg_id": leg_id,
"error": format!("tts stream failed: {error}"),
}),
);
state.prompt_stream_finished = true;
return;
}
Err(mpsc::error::TryRecvError::Empty) => {
state.prompt_stream_rx = Some(stream_rx);
return;
}
Err(mpsc::error::TryRecvError::Disconnected) => {
state.prompt_stream_finished = true;
return;
}
}
}
}
/// Spawn the mixer task for a call. Returns the command sender and task handle. /// Spawn the mixer task for a call. Returns the command sender and task handle.
pub fn spawn_mixer( pub fn spawn_mixer(call_id: String, out_tx: OutTx) -> (mpsc::Sender<MixerCommand>, JoinHandle<()>) {
call_id: String,
out_tx: OutTx,
) -> (mpsc::Sender<MixerCommand>, JoinHandle<()>) {
let (cmd_tx, cmd_rx) = mpsc::channel::<MixerCommand>(32); let (cmd_tx, cmd_rx) = mpsc::channel::<MixerCommand>(32);
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
@@ -187,11 +501,7 @@ pub fn spawn_mixer(
} }
/// The 20ms mixing loop. /// The 20ms mixing loop.
async fn mixer_loop( async fn mixer_loop(call_id: String, mut cmd_rx: mpsc::Receiver<MixerCommand>, out_tx: OutTx) {
call_id: String,
mut cmd_rx: mpsc::Receiver<MixerCommand>,
out_tx: OutTx,
) {
let mut legs: HashMap<String, MixerLegSlot> = HashMap::new(); let mut legs: HashMap<String, MixerLegSlot> = HashMap::new();
let mut tool_legs: HashMap<String, ToolLegSlot> = HashMap::new(); let mut tool_legs: HashMap<String, ToolLegSlot> = HashMap::new();
let mut interval = time::interval(Duration::from_millis(20)); let mut interval = time::interval(Duration::from_millis(20));
@@ -232,12 +542,17 @@ async fn mixer_loop(
denoiser: new_denoiser(), denoiser: new_denoiser(),
inbound_rx, inbound_rx,
outbound_tx, outbound_tx,
pcm_buffer: VecDeque::new(),
last_pcm_frame: vec![0.0f32; MIX_FRAME_SIZE], last_pcm_frame: vec![0.0f32; MIX_FRAME_SIZE],
expected_rtp_timestamp: None,
estimated_packet_ts: rtp_clock_increment(codec_pt),
silent_ticks: 0, silent_ticks: 0,
rtp_seq: 0, rtp_seq: 0,
rtp_ts: 0, rtp_ts: 0,
rtp_ssrc: rand::random(), rtp_ssrc: rand::random(),
outbound_drops: 0,
role: LegRole::Participant, role: LegRole::Participant,
jitter: JitterBuffer::new(),
}, },
); );
} }
@@ -245,9 +560,7 @@ async fn mixer_loop(
// If the leg is isolated, send Cancelled before dropping. // If the leg is isolated, send Cancelled before dropping.
if let Some(slot) = legs.get_mut(&leg_id) { if let Some(slot) = legs.get_mut(&leg_id) {
if let LegRole::Isolated(ref mut state) = slot.role { if let LegRole::Isolated(ref mut state) = slot.role {
if let Some(tx) = state.result_tx.take() { cancel_isolated_interaction(state);
let _ = tx.send(InteractionResult::Cancelled);
}
} }
} }
legs.remove(&leg_id); legs.remove(&leg_id);
@@ -257,9 +570,7 @@ async fn mixer_loop(
// Cancel all outstanding interactions before shutting down. // Cancel all outstanding interactions before shutting down.
for slot in legs.values_mut() { for slot in legs.values_mut() {
if let LegRole::Isolated(ref mut state) = slot.role { if let LegRole::Isolated(ref mut state) = slot.role {
if let Some(tx) = state.result_tx.take() { cancel_isolated_interaction(state);
let _ = tx.send(InteractionResult::Cancelled);
}
} }
} }
return; return;
@@ -267,6 +578,8 @@ async fn mixer_loop(
Ok(MixerCommand::StartInteraction { Ok(MixerCommand::StartInteraction {
leg_id, leg_id,
prompt_pcm_frames, prompt_pcm_frames,
prompt_stream_rx,
prompt_cancel_tx,
expected_digits, expected_digits,
timeout_ms, timeout_ms,
result_tx, result_tx,
@@ -274,13 +587,14 @@ async fn mixer_loop(
if let Some(slot) = legs.get_mut(&leg_id) { if let Some(slot) = legs.get_mut(&leg_id) {
// Cancel any existing interaction first. // Cancel any existing interaction first.
if let LegRole::Isolated(ref mut old_state) = slot.role { if let LegRole::Isolated(ref mut old_state) = slot.role {
if let Some(tx) = old_state.result_tx.take() { cancel_isolated_interaction(old_state);
let _ = tx.send(InteractionResult::Cancelled);
}
} }
let timeout_ticks = timeout_ms / 20; let timeout_ticks = timeout_ms / 20;
slot.role = LegRole::Isolated(IsolationState { slot.role = LegRole::Isolated(IsolationState {
prompt_frames: VecDeque::from(prompt_pcm_frames), prompt_frames: VecDeque::from(prompt_pcm_frames),
prompt_stream_rx,
prompt_cancel_tx,
prompt_stream_finished: false,
expected_digits, expected_digits,
timeout_ticks_remaining: timeout_ticks, timeout_ticks_remaining: timeout_ticks,
prompt_done: false, prompt_done: false,
@@ -288,25 +602,25 @@ async fn mixer_loop(
}); });
} else { } else {
// Leg not found — immediately cancel. // Leg not found — immediately cancel.
if let Some(cancel_tx) = prompt_cancel_tx {
let _ = cancel_tx.send(true);
}
let _ = result_tx.send(InteractionResult::Cancelled); let _ = result_tx.send(InteractionResult::Cancelled);
} }
} }
Ok(MixerCommand::CancelInteraction { leg_id }) => {
if let Some(slot) = legs.get_mut(&leg_id) {
if let LegRole::Isolated(ref mut state) = slot.role {
if let Some(tx) = state.result_tx.take() {
let _ = tx.send(InteractionResult::Cancelled);
}
}
slot.role = LegRole::Participant;
}
}
Ok(MixerCommand::AddToolLeg { Ok(MixerCommand::AddToolLeg {
leg_id, leg_id,
tool_type, tool_type,
audio_tx, audio_tx,
}) => { }) => {
tool_legs.insert(leg_id, ToolLegSlot { tool_type, audio_tx }); tool_legs.insert(
leg_id,
ToolLegSlot {
tool_type,
audio_tx,
dropped_batches: 0,
},
);
} }
Ok(MixerCommand::RemoveToolLeg { leg_id }) => { Ok(MixerCommand::RemoveToolLeg { leg_id }) => {
tool_legs.remove(&leg_id); tool_legs.remove(&leg_id);
@@ -331,67 +645,43 @@ async fn mixer_loop(
for lid in &leg_ids { for lid in &leg_ids {
let slot = legs.get_mut(lid).unwrap(); let slot = legs.get_mut(lid).unwrap();
// Drain channel — collect DTMF separately, collect ALL audio packets. // Step 2a: Drain all pending packets into the jitter buffer.
let mut audio_packets: Vec<RtpPacket> = Vec::new(); let mut got_audio = false;
loop { loop {
match slot.inbound_rx.try_recv() { match slot.inbound_rx.try_recv() {
Ok(pkt) => { Ok(pkt) => {
if pkt.payload_type == 101 { if pkt.payload_type == 101 {
// DTMF telephone-event: collect for processing.
dtmf_forward.push((lid.clone(), pkt)); dtmf_forward.push((lid.clone(), pkt));
} else { } else {
audio_packets.push(pkt); got_audio = true;
slot.jitter.push(pkt);
} }
} }
Err(_) => break, Err(_) => break,
} }
} }
if !audio_packets.is_empty() { // Step 2b: Decode enough RTP to cover one 20ms playout frame.
slot.silent_ticks = 0; // Variable-duration packets (10ms, 20ms, 60ms, ...) accumulate in
// the per-leg PCM FIFO; we pop exactly one 20ms frame below.
fill_leg_playout_buffer(slot);
slot.last_pcm_frame = take_mix_frame(slot);
// Sort by sequence number for correct codec state progression. // Run jitter adaptation + prune stale packets.
// This prevents G.722 ADPCM state corruption from out-of-order packets. slot.jitter.adapt();
audio_packets.sort_by_key(|p| p.seq); slot.jitter.prune_stale();
// Decode ALL packets in order (maintains codec state), // Silent ticks: based on actual network reception, not jitter buffer state.
// but only keep the last decoded frame for mixing. if got_audio || dtmf_forward.iter().any(|(src, _)| src == lid) {
for pkt in &audio_packets {
match slot.transcoder.decode_to_f32(&pkt.payload, pkt.payload_type) {
Ok((pcm, rate)) => {
// Resample to 48kHz mixing rate if needed.
let pcm_48k = if rate == MIX_RATE {
pcm
} else {
slot.transcoder
.resample_f32(&pcm, rate, MIX_RATE)
.unwrap_or_else(|_| vec![0.0f32; MIX_FRAME_SIZE])
};
// Per-leg inbound denoising at 48kHz.
// Only for SIP telephony legs — WebRTC browsers
// already apply noise suppression via getUserMedia.
let processed = if slot.codec_pt != codec_lib::PT_OPUS {
TranscodeState::denoise_f32(&mut slot.denoiser, &pcm_48k)
} else {
pcm_48k
};
// Pad or truncate to exactly MIX_FRAME_SIZE.
let mut frame = processed;
frame.resize(MIX_FRAME_SIZE, 0.0);
slot.last_pcm_frame = frame;
}
Err(_) => {}
}
}
} else if dtmf_forward.iter().any(|(src, _)| src == lid) {
// Got DTMF but no audio — don't bump silent_ticks (DTMF counts as activity).
slot.silent_ticks = 0; slot.silent_ticks = 0;
} else { } else {
slot.silent_ticks += 1; slot.silent_ticks += 1;
// After 150 ticks (3 seconds) of silence, zero out to avoid stale audio. }
if slot.silent_ticks > 150 { if slot.silent_ticks > 150 {
slot.last_pcm_frame = vec![0.0f32; MIX_FRAME_SIZE]; slot.last_pcm_frame = vec![0.0f32; MIX_FRAME_SIZE];
} slot.pcm_buffer.clear();
slot.expected_rtp_timestamp = None;
slot.estimated_packet_ts = rtp_clock_increment(slot.codec_pt);
} }
} }
@@ -414,12 +704,12 @@ async fn mixer_loop(
for (lid, slot) in legs.iter_mut() { for (lid, slot) in legs.iter_mut() {
match &mut slot.role { match &mut slot.role {
LegRole::Participant => { LegRole::Participant => {
// Mix-minus: total minus this leg's own contribution, clamped to [-1.0, 1.0]. // Mix-minus: total minus this leg's own contribution.
// Apply a light soft limiter instead of hard clipping the sum.
let mut mix_minus = Vec::with_capacity(MIX_FRAME_SIZE); let mut mix_minus = Vec::with_capacity(MIX_FRAME_SIZE);
for i in 0..MIX_FRAME_SIZE { for i in 0..MIX_FRAME_SIZE {
let sample = let sample = (total_mix[i] - slot.last_pcm_frame[i] as f64) as f32;
(total_mix[i] - slot.last_pcm_frame[i] as f64) as f32; mix_minus.push(soft_limit_sample(sample));
mix_minus.push(sample.clamp(-1.0, 1.0));
} }
// Resample from 48kHz to the leg's codec native rate. // Resample from 48kHz to the leg's codec native rate.
@@ -433,8 +723,7 @@ async fn mixer_loop(
}; };
// Encode to the leg's codec (f32 → i16 → codec inside encode_from_f32). // Encode to the leg's codec (f32 → i16 → codec inside encode_from_f32).
let encoded = let encoded = match slot.transcoder.encode_from_f32(&resampled, slot.codec_pt) {
match slot.transcoder.encode_from_f32(&resampled, slot.codec_pt) {
Ok(e) if !e.is_empty() => e, Ok(e) if !e.is_empty() => e,
_ => continue, _ => continue,
}; };
@@ -448,10 +737,11 @@ async fn mixer_loop(
slot.rtp_seq = slot.rtp_seq.wrapping_add(1); slot.rtp_seq = slot.rtp_seq.wrapping_add(1);
slot.rtp_ts = slot.rtp_ts.wrapping_add(rtp_clock_increment(slot.codec_pt)); slot.rtp_ts = slot.rtp_ts.wrapping_add(rtp_clock_increment(slot.codec_pt));
// Non-blocking send — drop frame if channel is full. try_send_leg_output(&out_tx, &call_id, lid, slot, rtp, "participant-audio");
let _ = slot.outbound_tx.try_send(rtp);
} }
LegRole::Isolated(state) => { LegRole::Isolated(state) => {
drain_prompt_stream(&out_tx, &call_id, lid, state);
// Check for DTMF digit from this leg. // Check for DTMF digit from this leg.
let mut matched_digit: Option<char> = None; let mut matched_digit: Option<char> = None;
for (src_lid, dtmf_pkt) in &dtmf_forward { for (src_lid, dtmf_pkt) in &dtmf_forward {
@@ -475,12 +765,14 @@ async fn mixer_loop(
if let Some(digit) = matched_digit { if let Some(digit) = matched_digit {
// Interaction complete — digit matched. // Interaction complete — digit matched.
completed_interactions completed_interactions.push((lid.clone(), InteractionResult::Digit(digit)));
.push((lid.clone(), InteractionResult::Digit(digit)));
} else { } else {
// Play prompt frame or silence. // Play prompt frame, wait for live TTS, or move to timeout once the
// prompt stream has fully drained.
let pcm_frame = if let Some(frame) = state.prompt_frames.pop_front() { let pcm_frame = if let Some(frame) = state.prompt_frames.pop_front() {
frame frame
} else if !state.prompt_stream_finished {
vec![0.0f32; MIX_FRAME_SIZE]
} else { } else {
state.prompt_done = true; state.prompt_done = true;
vec![0.0f32; MIX_FRAME_SIZE] vec![0.0f32; MIX_FRAME_SIZE]
@@ -496,6 +788,7 @@ async fn mixer_loop(
.unwrap_or_default() .unwrap_or_default()
}; };
let mut prompt_rtp: Option<Vec<u8>> = None;
if let Ok(encoded) = if let Ok(encoded) =
slot.transcoder.encode_from_f32(&resampled, slot.codec_pt) slot.transcoder.encode_from_f32(&resampled, slot.codec_pt)
{ {
@@ -509,10 +802,9 @@ async fn mixer_loop(
let mut rtp = header.to_vec(); let mut rtp = header.to_vec();
rtp.extend_from_slice(&encoded); rtp.extend_from_slice(&encoded);
slot.rtp_seq = slot.rtp_seq.wrapping_add(1); slot.rtp_seq = slot.rtp_seq.wrapping_add(1);
slot.rtp_ts = slot slot.rtp_ts =
.rtp_ts slot.rtp_ts.wrapping_add(rtp_clock_increment(slot.codec_pt));
.wrapping_add(rtp_clock_increment(slot.codec_pt)); prompt_rtp = Some(rtp);
let _ = slot.outbound_tx.try_send(rtp);
} }
} }
@@ -525,6 +817,17 @@ async fn mixer_loop(
state.timeout_ticks_remaining -= 1; state.timeout_ticks_remaining -= 1;
} }
} }
if let Some(rtp) = prompt_rtp {
try_send_leg_output(
&out_tx,
&call_id,
lid,
slot,
rtp,
"isolated-prompt",
);
}
} }
} }
} }
@@ -534,6 +837,7 @@ async fn mixer_loop(
for (lid, result) in completed_interactions { for (lid, result) in completed_interactions {
if let Some(slot) = legs.get_mut(&lid) { if let Some(slot) = legs.get_mut(&lid) {
if let LegRole::Isolated(ref mut state) = slot.role { if let LegRole::Isolated(ref mut state) = slot.role {
cancel_prompt_producer(state);
if let Some(tx) = state.result_tx.take() { if let Some(tx) = state.result_tx.take() {
let _ = tx.send(result); let _ = tx.send(result);
} }
@@ -554,7 +858,7 @@ async fn mixer_loop(
}) })
.collect(); .collect();
for tool in tool_legs.values() { for (tool_leg_id, tool) in tool_legs.iter_mut() {
let batch = ToolAudioBatch { let batch = ToolAudioBatch {
sources: sources sources: sources
.iter() .iter()
@@ -564,8 +868,7 @@ async fn mixer_loop(
}) })
.collect(), .collect(),
}; };
// Non-blocking send — drop batch if tool can't keep up. try_send_tool_output(&out_tx, &call_id, tool_leg_id, tool, batch);
let _ = tool.audio_tx.try_send(batch);
} }
} }
@@ -598,7 +901,7 @@ async fn mixer_loop(
rtp_out.extend_from_slice(&dtmf_pkt.payload); rtp_out.extend_from_slice(&dtmf_pkt.payload);
target_slot.rtp_seq = target_slot.rtp_seq.wrapping_add(1); target_slot.rtp_seq = target_slot.rtp_seq.wrapping_add(1);
// Don't increment rtp_ts for DTMF — it shares timestamp context with audio. // Don't increment rtp_ts for DTMF — it shares timestamp context with audio.
let _ = target_slot.outbound_tx.try_send(rtp_out); try_send_leg_output(&out_tx, &call_id, target_lid, target_slot, rtp_out, "dtmf");
} }
} }
} }

View File

@@ -267,11 +267,7 @@ impl ProviderManager {
/// Try to handle a SIP response as a provider registration response. /// Try to handle a SIP response as a provider registration response.
/// Returns true if consumed. /// Returns true if consumed.
pub async fn handle_response( pub async fn handle_response(&self, msg: &SipMessage, socket: &UdpSocket) -> bool {
&self,
msg: &SipMessage,
socket: &UdpSocket,
) -> bool {
for ps_arc in &self.providers { for ps_arc in &self.providers {
let mut ps = ps_arc.lock().await; let mut ps = ps_arc.lock().await;
let was_registered = ps.is_registered; let was_registered = ps.is_registered;
@@ -322,7 +318,10 @@ impl ProviderManager {
} }
/// Find a provider by its config ID (e.g. "easybell"). /// Find a provider by its config ID (e.g. "easybell").
pub async fn find_by_provider_id(&self, provider_id: &str) -> Option<Arc<Mutex<ProviderState>>> { pub async fn find_by_provider_id(
&self,
provider_id: &str,
) -> Option<Arc<Mutex<ProviderState>>> {
for ps_arc in &self.providers { for ps_arc in &self.providers {
let ps = ps_arc.lock().await; let ps = ps_arc.lock().await;
if ps.config.id == provider_id { if ps.config.id == provider_id {
@@ -331,17 +330,6 @@ impl ProviderManager {
} }
None None
} }
/// Check if a provider is currently registered.
pub async fn is_registered(&self, provider_id: &str) -> bool {
for ps_arc in &self.providers {
let ps = ps_arc.lock().await;
if ps.config.id == provider_id {
return ps.is_registered;
}
}
false
}
} }
/// Registration loop for a single provider. /// Registration loop for a single provider.

View File

@@ -25,8 +25,7 @@ impl Recorder {
) -> Result<Self, String> { ) -> Result<Self, String> {
// Ensure parent directory exists. // Ensure parent directory exists.
if let Some(parent) = Path::new(file_path).parent() { if let Some(parent) = Path::new(file_path).parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent).map_err(|e| format!("create dir: {e}"))?;
.map_err(|e| format!("create dir: {e}"))?;
} }
let sample_rate = 8000u32; // Record at 8kHz (standard telephony) let sample_rate = 8000u32; // Record at 8kHz (standard telephony)
@@ -57,10 +56,13 @@ impl Recorder {
/// Create a recorder that writes raw PCM at a given sample rate. /// Create a recorder that writes raw PCM at a given sample rate.
/// Used by tool legs that already have decoded PCM (no RTP processing needed). /// Used by tool legs that already have decoded PCM (no RTP processing needed).
pub fn new_pcm(file_path: &str, sample_rate: u32, max_duration_ms: Option<u64>) -> Result<Self, String> { pub fn new_pcm(
file_path: &str,
sample_rate: u32,
max_duration_ms: Option<u64>,
) -> Result<Self, String> {
if let Some(parent) = Path::new(file_path).parent() { if let Some(parent) = Path::new(file_path).parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent).map_err(|e| format!("create dir: {e}"))?;
.map_err(|e| format!("create dir: {e}"))?;
} }
let spec = hound::WavSpec { let spec = hound::WavSpec {
@@ -178,5 +180,8 @@ impl Recorder {
pub struct RecordingResult { pub struct RecordingResult {
pub file_path: String, pub file_path: String,
pub duration_ms: u64, pub duration_ms: u64,
// Running-sample total kept for parity with the TS recorder; not yet
// surfaced through any event or dashboard field.
#[allow(dead_code)]
pub total_samples: u64, pub total_samples: u64,
} }

View File

@@ -19,11 +19,19 @@ const MAX_EXPIRES: u32 = 300;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RegisteredDevice { pub struct RegisteredDevice {
pub device_id: String, pub device_id: String,
// These fields are populated at REGISTER time for logging/debugging but are
// not read back — device identity flows via the `device_registered` push
// event, not via struct queries. Kept behind allow(dead_code) because
// removing them would churn handle_register for no runtime benefit.
#[allow(dead_code)]
pub display_name: String, pub display_name: String,
#[allow(dead_code)]
pub extension: String, pub extension: String,
pub contact_addr: SocketAddr, pub contact_addr: SocketAddr,
#[allow(dead_code)]
pub registered_at: Instant, pub registered_at: Instant,
pub expires_at: Instant, pub expires_at: Instant,
#[allow(dead_code)]
pub aor: String, pub aor: String,
} }
@@ -52,18 +60,17 @@ impl Registrar {
/// Try to handle a SIP REGISTER from a device. /// Try to handle a SIP REGISTER from a device.
/// Returns Some(response_bytes) if handled, None if not a known device. /// Returns Some(response_bytes) if handled, None if not a known device.
pub fn handle_register( pub fn handle_register(&mut self, msg: &SipMessage, from_addr: SocketAddr) -> Option<Vec<u8>> {
&mut self,
msg: &SipMessage,
from_addr: SocketAddr,
) -> Option<Vec<u8>> {
if msg.method() != Some("REGISTER") { if msg.method() != Some("REGISTER") {
return None; return None;
} }
// Find the device by matching the source IP against expectedAddress. // Find the device by matching the source IP against expectedAddress.
let from_ip = from_addr.ip().to_string(); let from_ip = from_addr.ip().to_string();
let device = self.devices.iter().find(|d| d.expected_address == from_ip)?; let device = self
.devices
.iter()
.find(|d| d.expected_address == from_ip)?;
let from_header = msg.get_header("From").unwrap_or(""); let from_header = msg.get_header("From").unwrap_or("");
let aor = SipMessage::extract_uri(from_header) let aor = SipMessage::extract_uri(from_header)
@@ -71,9 +78,7 @@ impl Registrar {
.unwrap_or_else(|| format!("sip:{}@{}", device.extension, from_ip)); .unwrap_or_else(|| format!("sip:{}@{}", device.extension, from_ip));
let expires_header = msg.get_header("Expires"); let expires_header = msg.get_header("Expires");
let requested: u32 = expires_header let requested: u32 = expires_header.and_then(|s| s.parse().ok()).unwrap_or(3600);
.and_then(|s| s.parse().ok())
.unwrap_or(3600);
let expires = requested.min(MAX_EXPIRES); let expires = requested.min(MAX_EXPIRES);
let entry = RegisteredDevice { let entry = RegisteredDevice {
@@ -114,10 +119,7 @@ impl Registrar {
Some(ResponseOptions { Some(ResponseOptions {
to_tag: Some(generate_tag()), to_tag: Some(generate_tag()),
contact: Some(contact), contact: Some(contact),
extra_headers: Some(vec![( extra_headers: Some(vec![("Expires".to_string(), expires.to_string())]),
"Expires".to_string(),
expires.to_string(),
)]),
..Default::default() ..Default::default()
}), }),
); );
@@ -134,38 +136,11 @@ impl Registrar {
Some(entry.contact_addr) Some(entry.contact_addr)
} }
/// Check if a source address belongs to a known device.
pub fn is_known_device_address(&self, addr: &str) -> bool {
self.devices.iter().any(|d| d.expected_address == addr)
}
/// Find a registered device by its source IP address. /// Find a registered device by its source IP address.
pub fn find_by_address(&self, addr: &SocketAddr) -> Option<&RegisteredDevice> { pub fn find_by_address(&self, addr: &SocketAddr) -> Option<&RegisteredDevice> {
let ip = addr.ip().to_string(); let ip = addr.ip().to_string();
self.registered.values().find(|e| { self.registered
e.contact_addr.ip().to_string() == ip && Instant::now() <= e.expires_at .values()
}) .find(|e| e.contact_addr.ip().to_string() == ip && Instant::now() <= e.expires_at)
}
/// Get all device statuses for the dashboard.
pub fn get_all_statuses(&self) -> Vec<serde_json::Value> {
let now = Instant::now();
let mut result = Vec::new();
for dc in &self.devices {
let reg = self.registered.get(&dc.id);
let connected = reg.map(|r| now <= r.expires_at).unwrap_or(false);
result.push(serde_json::json!({
"id": dc.id,
"displayName": dc.display_name,
"address": reg.filter(|_| connected).map(|r| r.contact_addr.ip().to_string()),
"port": reg.filter(|_| connected).map(|r| r.contact_addr.port()),
"aor": reg.map(|r| r.aor.as_str()).unwrap_or(""),
"connected": connected,
"isBrowser": false,
}));
}
result
} }
} }

View File

@@ -1,17 +1,19 @@
//! RTP port pool and media forwarding. //! RTP port pool for media sockets.
//! //!
//! Manages a pool of even-numbered UDP ports for RTP media. //! Manages a pool of even-numbered UDP ports for RTP media. `allocate()`
//! Each port gets a bound tokio UdpSocket. Supports: //! hands back an `Arc<UdpSocket>` to the caller (stored on the owning
//! - Direct forwarding (SIP-to-SIP, no transcoding) //! `LegInfo`), while the pool itself keeps only a `Weak<UdpSocket>`. When
//! - Transcoding forwarding (via codec-lib, e.g. G.722 ↔ Opus) //! the call terminates and `LegInfo` is dropped, the strong refcount
//! - Silence generation //! reaches zero, the socket is closed, and `allocate()` prunes the dead
//! - NAT priming //! weak ref the next time it scans that slot — so the port automatically
//! becomes available for reuse without any explicit `release()` plumbing.
//! //!
//! Ported from ts/call/rtp-port-pool.ts + sip-leg.ts RTP handling. //! This fixes the previous leak where the pool held `Arc<UdpSocket>` and
//! `release()` was never called, eventually exhausting the port range and
//! causing "503 Service Unavailable" on new calls.
use std::collections::HashMap; use std::collections::HashMap;
use std::net::SocketAddr; use std::sync::{Arc, Weak};
use std::sync::Arc;
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
/// A single RTP port allocation. /// A single RTP port allocation.
@@ -24,7 +26,7 @@ pub struct RtpAllocation {
pub struct RtpPortPool { pub struct RtpPortPool {
min: u16, min: u16,
max: u16, max: u16,
allocated: HashMap<u16, Arc<UdpSocket>>, allocated: HashMap<u16, Weak<UdpSocket>>,
} }
impl RtpPortPool { impl RtpPortPool {
@@ -41,11 +43,19 @@ impl RtpPortPool {
pub async fn allocate(&mut self) -> Option<RtpAllocation> { pub async fn allocate(&mut self) -> Option<RtpAllocation> {
let mut port = self.min; let mut port = self.min;
while port < self.max { while port < self.max {
// Prune a dead weak ref at this slot: if the last strong Arc
// (held by the owning LegInfo) was dropped when the call ended,
// the socket is already closed and the slot is free again.
if let Some(weak) = self.allocated.get(&port) {
if weak.strong_count() == 0 {
self.allocated.remove(&port);
}
}
if !self.allocated.contains_key(&port) { if !self.allocated.contains_key(&port) {
match UdpSocket::bind(format!("0.0.0.0:{port}")).await { match UdpSocket::bind(format!("0.0.0.0:{port}")).await {
Ok(sock) => { Ok(sock) => {
let sock = Arc::new(sock); let sock = Arc::new(sock);
self.allocated.insert(port, sock.clone()); self.allocated.insert(port, Arc::downgrade(&sock));
return Some(RtpAllocation { port, socket: sock }); return Some(RtpAllocation { port, socket: sock });
} }
Err(_) => { Err(_) => {
@@ -57,83 +67,6 @@ impl RtpPortPool {
} }
None // Pool exhausted. None // Pool exhausted.
} }
/// Release a port back to the pool.
pub fn release(&mut self, port: u16) {
self.allocated.remove(&port);
// Socket is dropped when the last Arc reference goes away.
}
pub fn size(&self) -> usize {
self.allocated.len()
}
pub fn capacity(&self) -> usize {
((self.max - self.min) / 2) as usize
}
}
/// An active RTP relay between two endpoints.
/// Receives on `local_socket` and forwards to `remote_addr`.
pub struct RtpRelay {
pub local_port: u16,
pub local_socket: Arc<UdpSocket>,
pub remote_addr: Option<SocketAddr>,
/// If set, transcode packets using this codec session before forwarding.
pub transcode: Option<TranscodeConfig>,
/// Packets received counter.
pub pkt_received: u64,
/// Packets sent counter.
pub pkt_sent: u64,
}
pub struct TranscodeConfig {
pub from_pt: u8,
pub to_pt: u8,
pub session_id: String,
}
impl RtpRelay {
pub fn new(port: u16, socket: Arc<UdpSocket>) -> Self {
Self {
local_port: port,
local_socket: socket,
remote_addr: None,
transcode: None,
pkt_received: 0,
pkt_sent: 0,
}
}
pub fn set_remote(&mut self, addr: SocketAddr) {
self.remote_addr = Some(addr);
}
}
/// Send a 1-byte NAT priming packet to open a pinhole.
pub async fn prime_nat(socket: &UdpSocket, remote: SocketAddr) {
let _ = socket.send_to(&[0u8], remote).await;
}
/// Build an RTP silence frame for PCMU (payload type 0).
pub fn silence_frame_pcmu() -> Vec<u8> {
// 12-byte RTP header + 160 bytes of µ-law silence (0xFF)
let mut frame = vec![0u8; 172];
frame[0] = 0x80; // V=2
frame[1] = 0; // PT=0 (PCMU)
// seq, timestamp, ssrc left as 0 — caller should set these
frame[12..].fill(0xFF); // µ-law silence
frame
}
/// Build an RTP silence frame for G.722 (payload type 9).
pub fn silence_frame_g722() -> Vec<u8> {
// 12-byte RTP header + 160 bytes of G.722 silence
let mut frame = vec![0u8; 172];
frame[0] = 0x80; // V=2
frame[1] = 9; // PT=9 (G.722)
// G.722 silence: all zeros is valid silence
frame
} }
/// Build an RTP header with the given parameters. /// Build an RTP header with the given parameters.
@@ -149,10 +82,15 @@ pub fn build_rtp_header(pt: u8, seq: u16, timestamp: u32, ssrc: u32) -> [u8; 12]
/// Get the RTP clock increment per 20ms frame for a payload type. /// Get the RTP clock increment per 20ms frame for a payload type.
pub fn rtp_clock_increment(pt: u8) -> u32 { pub fn rtp_clock_increment(pt: u8) -> u32 {
rtp_clock_rate(pt) / 50
}
/// Get the RTP clock rate for a payload type.
pub fn rtp_clock_rate(pt: u8) -> u32 {
match pt { match pt {
9 => 160, // G.722: 8000 Hz clock rate (despite 16kHz audio) × 0.02s 9 => 8000, // G.722 uses an 8kHz RTP clock despite 16kHz audio.
0 | 8 => 160, // PCMU/PCMA: 8000 × 0.02 0 | 8 => 8000, // PCMU/PCMA
111 => 960, // Opus: 48000 × 0.02 111 => 48000, // Opus
_ => 160, _ => 8000,
} }
} }

View File

@@ -16,7 +16,6 @@ use sip_proto::helpers::{
}; };
use sip_proto::message::{RequestOptions, SipMessage}; use sip_proto::message::{RequestOptions, SipMessage};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
/// State of a SIP leg. /// State of a SIP leg.
@@ -40,6 +39,9 @@ pub struct SipLegConfig {
/// SIP target endpoint (provider outbound proxy or device address). /// SIP target endpoint (provider outbound proxy or device address).
pub sip_target: SocketAddr, pub sip_target: SocketAddr,
/// Provider credentials (for 407 auth). /// Provider credentials (for 407 auth).
// username is carried for parity with the provider config but digest auth
// rebuilds the username from the registered AOR, so this slot is never read.
#[allow(dead_code)]
pub username: Option<String>, pub username: Option<String>,
pub password: Option<String>, pub password: Option<String>,
pub registered_aor: Option<String>, pub registered_aor: Option<String>,
@@ -51,6 +53,10 @@ pub struct SipLegConfig {
/// A SIP leg with full dialog management. /// A SIP leg with full dialog management.
pub struct SipLeg { pub struct SipLeg {
// Leg identity is tracked via the enclosing LegInfo's key in the call's
// leg map; SipLeg itself never reads this field back. Kept to preserve
// the (id, config) constructor shape used by the call manager.
#[allow(dead_code)]
pub id: String, pub id: String,
pub state: LegState, pub state: LegState,
pub config: SipLegConfig, pub config: SipLegConfig,
@@ -122,17 +128,24 @@ impl SipLeg {
max_forwards: Some(70), max_forwards: Some(70),
body: Some(sdp), body: Some(sdp),
content_type: Some("application/sdp".to_string()), content_type: Some("application/sdp".to_string()),
extra_headers: Some(vec![ extra_headers: Some(vec![(
("User-Agent".to_string(), "SipRouter/1.0".to_string()), "User-Agent".to_string(),
]), "SipRouter/1.0".to_string(),
)]),
}, },
); );
self.dialog = Some(SipDialog::from_uac_invite(&invite, ip, self.config.lan_port)); self.dialog = Some(SipDialog::from_uac_invite(
&invite,
ip,
self.config.lan_port,
));
self.invite = Some(invite.clone()); self.invite = Some(invite.clone());
self.state = LegState::Inviting; self.state = LegState::Inviting;
let _ = socket.send_to(&invite.serialize(), self.config.sip_target).await; let _ = socket
.send_to(&invite.serialize(), self.config.sip_target)
.await;
} }
/// Handle an incoming SIP message routed to this leg. /// Handle an incoming SIP message routed to this leg.
@@ -411,11 +424,6 @@ impl SipLeg {
dialog.terminate(); dialog.terminate();
Some(msg.serialize()) Some(msg.serialize())
} }
/// Get the SIP Call-ID for routing.
pub fn sip_call_id(&self) -> Option<&str> {
self.dialog.as_ref().map(|d| d.call_id.as_str())
}
} }
/// Actions produced by the SipLeg message handler. /// Actions produced by the SipLeg message handler.
@@ -442,10 +450,7 @@ pub enum SipLegAction {
/// Build an ACK for a non-2xx response (same transaction as the INVITE). /// Build an ACK for a non-2xx response (same transaction as the INVITE).
fn build_non_2xx_ack(original_invite: &SipMessage, response: &SipMessage) -> SipMessage { fn build_non_2xx_ack(original_invite: &SipMessage, response: &SipMessage) -> SipMessage {
let via = original_invite.get_header("Via").unwrap_or("").to_string(); let via = original_invite.get_header("Via").unwrap_or("").to_string();
let from = original_invite let from = original_invite.get_header("From").unwrap_or("").to_string();
.get_header("From")
.unwrap_or("")
.to_string();
let to = response.get_header("To").unwrap_or("").to_string(); let to = response.get_header("To").unwrap_or("").to_string();
let call_id = original_invite.call_id().to_string(); let call_id = original_invite.call_id().to_string();
let cseq_num: u32 = original_invite let cseq_num: u32 = original_invite

View File

@@ -27,27 +27,9 @@ impl SipTransport {
self.socket.clone() self.socket.clone()
} }
/// Send a raw SIP message to a destination.
pub async fn send_to(&self, data: &[u8], dest: SocketAddr) -> Result<usize, String> {
self.socket
.send_to(data, dest)
.await
.map_err(|e| format!("send to {dest}: {e}"))
}
/// Send a raw SIP message to an address:port pair.
pub async fn send_to_addr(&self, data: &[u8], addr: &str, port: u16) -> Result<usize, String> {
let dest: SocketAddr = format!("{addr}:{port}")
.parse()
.map_err(|e| format!("bad address {addr}:{port}: {e}"))?;
self.send_to(data, dest).await
}
/// Spawn the UDP receive loop. Calls the handler for every received packet. /// Spawn the UDP receive loop. Calls the handler for every received packet.
pub fn spawn_receiver<F>( pub fn spawn_receiver<F>(&self, handler: F)
&self, where
handler: F,
) where
F: Fn(&[u8], SocketAddr) + Send + 'static, F: Fn(&[u8], SocketAddr) + Send + 'static,
{ {
let socket = self.socket.clone(); let socket = self.socket.clone();

View File

@@ -51,7 +51,8 @@ pub fn spawn_recording_tool(
}); });
// Convert f32 [-1.0, 1.0] to i16 for WAV writing. // Convert f32 [-1.0, 1.0] to i16 for WAV writing.
let pcm_i16: Vec<i16> = source.pcm_48k let pcm_i16: Vec<i16> = source
.pcm_48k
.iter() .iter()
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16) .map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
.collect(); .collect();

View File

@@ -1,15 +1,49 @@
//! Text-to-speech engine — synthesizes text to WAV files using Kokoro neural TTS. //! Text-to-speech engine — synthesizes text to WAV files using Kokoro neural TTS.
//! //!
//! The model is loaded lazily on first use. If the model/voices files are not //! The model is loaded lazily on first use. If the model/voices files are not
//! present, the generate command returns an error and the TS side falls back //! present, the generate command returns an error and the caller skips the prompt.
//! to espeak-ng. //!
//! Caching is handled internally via a `.meta` sidecar file next to each WAV.
//! When `cacheable` is true, the engine checks whether the existing WAV was
//! generated from the same text+voice; if so it returns immediately (cache hit).
//! Callers never need to check for cached files — that is entirely this module's
//! responsibility.
use crate::audio_player::pcm_to_mix_frames;
use kokoro_tts::{KokoroTts, Voice}; use kokoro_tts::{KokoroTts, Voice};
use std::path::Path; use std::path::Path;
use std::sync::Arc;
use tokio::sync::{mpsc, watch};
pub const DEFAULT_MODEL_PATH: &str = ".nogit/tts/kokoro-v1.0.onnx";
pub const DEFAULT_VOICES_PATH: &str = ".nogit/tts/voices.bin";
const TTS_OUTPUT_RATE: u32 = 24000;
const MAX_CHUNK_CHARS: usize = 220;
const MIN_CHUNK_CHARS: usize = 80;
pub enum TtsStreamMessage {
Frames(Vec<Vec<f32>>),
Finished,
Failed(String),
}
pub struct TtsLivePrompt {
pub initial_frames: Vec<Vec<f32>>,
pub stream_rx: mpsc::Receiver<TtsStreamMessage>,
pub cancel_tx: watch::Sender<bool>,
}
#[derive(Clone)]
pub struct TtsPromptRequest {
pub model_path: String,
pub voices_path: String,
pub voice_name: String,
pub text: String,
}
/// Wraps the Kokoro TTS engine with lazy model loading. /// Wraps the Kokoro TTS engine with lazy model loading.
pub struct TtsEngine { pub struct TtsEngine {
tts: Option<KokoroTts>, tts: Option<Arc<KokoroTts>>,
/// Path that was used to load the current model (for cache invalidation). /// Path that was used to load the current model (for cache invalidation).
loaded_model_path: String, loaded_model_path: String,
loaded_voices_path: String, loaded_voices_path: String,
@@ -24,6 +58,69 @@ impl TtsEngine {
} }
} }
async fn ensure_loaded(
&mut self,
model_path: &str,
voices_path: &str,
) -> Result<Arc<KokoroTts>, String> {
if !Path::new(model_path).exists() {
return Err(format!("model not found: {model_path}"));
}
if !Path::new(voices_path).exists() {
return Err(format!("voices not found: {voices_path}"));
}
if self.tts.is_none()
|| self.loaded_model_path != model_path
|| self.loaded_voices_path != voices_path
{
eprintln!("[tts] loading model: {model_path}");
let tts = Arc::new(
KokoroTts::new(model_path, voices_path)
.await
.map_err(|e| format!("model load failed: {e:?}"))?,
);
self.tts = Some(tts);
self.loaded_model_path = model_path.to_string();
self.loaded_voices_path = voices_path.to_string();
}
Ok(self.tts.as_ref().unwrap().clone())
}
pub async fn start_live_prompt(
&mut self,
request: TtsPromptRequest,
) -> Result<TtsLivePrompt, String> {
if request.text.trim().is_empty() {
return Err("empty text".into());
}
let tts = self
.ensure_loaded(&request.model_path, &request.voices_path)
.await?;
let voice = select_voice(&request.voice_name);
let chunks = chunk_text(&request.text);
if chunks.is_empty() {
return Err("empty text".into());
}
let initial_frames = synth_text_to_mix_frames(&tts, chunks[0].as_str(), voice).await?;
let remaining_chunks: Vec<String> = chunks.into_iter().skip(1).collect();
let (stream_tx, stream_rx) = mpsc::channel(8);
let (cancel_tx, cancel_rx) = watch::channel(false);
tokio::spawn(async move {
stream_live_prompt_chunks(tts, voice, remaining_chunks, stream_tx, cancel_rx).await;
});
Ok(TtsLivePrompt {
initial_frames,
stream_rx,
cancel_tx,
})
}
/// Generate a WAV file from text. /// Generate a WAV file from text.
/// ///
/// Params (from IPC JSON): /// Params (from IPC JSON):
@@ -32,52 +129,64 @@ impl TtsEngine {
/// - `voice`: voice name (e.g. "af_bella") /// - `voice`: voice name (e.g. "af_bella")
/// - `text`: text to synthesize /// - `text`: text to synthesize
/// - `output`: output WAV file path /// - `output`: output WAV file path
pub async fn generate(&mut self, params: &serde_json::Value) -> Result<serde_json::Value, String> { /// - `cacheable`: if true, skip synthesis when the output WAV already
let model_path = params.get("model").and_then(|v| v.as_str()) /// matches the same text+voice (checked via a `.meta` sidecar file)
pub async fn generate(
&mut self,
params: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let model_path = params
.get("model")
.and_then(|v| v.as_str())
.ok_or("missing 'model' param")?; .ok_or("missing 'model' param")?;
let voices_path = params.get("voices").and_then(|v| v.as_str()) let voices_path = params
.get("voices")
.and_then(|v| v.as_str())
.ok_or("missing 'voices' param")?; .ok_or("missing 'voices' param")?;
let voice_name = params.get("voice").and_then(|v| v.as_str()) let voice_name = params
.get("voice")
.and_then(|v| v.as_str())
.unwrap_or("af_bella"); .unwrap_or("af_bella");
let text = params.get("text").and_then(|v| v.as_str()) let text = params
.get("text")
.and_then(|v| v.as_str())
.ok_or("missing 'text' param")?; .ok_or("missing 'text' param")?;
let output_path = params.get("output").and_then(|v| v.as_str()) let output_path = params
.get("output")
.and_then(|v| v.as_str())
.ok_or("missing 'output' param")?; .ok_or("missing 'output' param")?;
let cacheable = params
.get("cacheable")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if text.is_empty() { if text.is_empty() {
return Err("empty text".into()); return Err("empty text".into());
} }
// Check that model/voices files exist. // Cache check: if cacheable and the sidecar matches, return immediately.
if !Path::new(model_path).exists() { if cacheable && self.is_cache_hit(output_path, text, voice_name) {
return Err(format!("model not found: {model_path}")); eprintln!("[tts] cache hit: {output_path}");
} return Ok(serde_json::json!({ "output": output_path }));
if !Path::new(voices_path).exists() {
return Err(format!("voices not found: {voices_path}"));
} }
// Lazy-load or reload if paths changed. // Ensure parent directory exists.
if self.tts.is_none() if let Some(parent) = Path::new(output_path).parent() {
|| self.loaded_model_path != model_path let _ = std::fs::create_dir_all(parent);
|| self.loaded_voices_path != voices_path
{
eprintln!("[tts] loading model: {model_path}");
let tts = KokoroTts::new(model_path, voices_path)
.await
.map_err(|e| format!("model load failed: {e:?}"))?;
self.tts = Some(tts);
self.loaded_model_path = model_path.to_string();
self.loaded_voices_path = voices_path.to_string();
} }
let tts = self.tts.as_ref().unwrap(); let tts = self.ensure_loaded(model_path, voices_path).await?;
let voice = select_voice(voice_name); let voice = select_voice(voice_name);
eprintln!("[tts] synthesizing voice '{voice_name}': \"{text}\""); eprintln!("[tts] synthesizing WAV voice '{voice_name}' to {output_path}");
let (samples, duration) = tts.synth(text, voice) let (samples, duration) = tts
.synth(text, voice)
.await .await
.map_err(|e| format!("synthesis failed: {e:?}"))?; .map_err(|e| format!("synthesis failed: {e:?}"))?;
eprintln!("[tts] synthesized {} samples in {duration:?}", samples.len()); eprintln!(
"[tts] synthesized {} samples in {duration:?}",
samples.len()
);
// Write 24kHz 16-bit mono WAV. // Write 24kHz 16-bit mono WAV.
let spec = hound::WavSpec { let spec = hound::WavSpec {
@@ -91,13 +200,149 @@ impl TtsEngine {
.map_err(|e| format!("WAV create failed: {e}"))?; .map_err(|e| format!("WAV create failed: {e}"))?;
for &sample in &samples { for &sample in &samples {
let s16 = (sample * 32767.0).round().clamp(-32768.0, 32767.0) as i16; let s16 = (sample * 32767.0).round().clamp(-32768.0, 32767.0) as i16;
writer.write_sample(s16).map_err(|e| format!("WAV write: {e}"))?; writer
.write_sample(s16)
.map_err(|e| format!("WAV write: {e}"))?;
}
writer
.finalize()
.map_err(|e| format!("WAV finalize: {e}"))?;
// Write sidecar for future cache checks.
if cacheable {
self.write_cache_meta(output_path, text, voice_name);
} }
writer.finalize().map_err(|e| format!("WAV finalize: {e}"))?;
eprintln!("[tts] wrote {output_path}"); eprintln!("[tts] wrote {output_path}");
Ok(serde_json::json!({ "output": output_path })) Ok(serde_json::json!({ "output": output_path }))
} }
// -----------------------------------------------------------------------
// Cache helpers
// -----------------------------------------------------------------------
/// Check if the WAV + sidecar on disk match the given text+voice.
fn is_cache_hit(&self, output_path: &str, text: &str, voice: &str) -> bool {
let meta_path = format!("{output_path}.meta");
if !Path::new(output_path).exists() || !Path::new(&meta_path).exists() {
return false;
}
match std::fs::read_to_string(&meta_path) {
Ok(contents) => contents == Self::cache_key(text, voice),
Err(_) => false,
}
}
/// Write the sidecar `.meta` file next to the WAV.
fn write_cache_meta(&self, output_path: &str, text: &str, voice: &str) {
let meta_path = format!("{output_path}.meta");
let _ = std::fs::write(&meta_path, Self::cache_key(text, voice));
}
/// Build the cache key from text + voice.
fn cache_key(text: &str, voice: &str) -> String {
format!("{}\0{}", text, voice)
}
}
async fn synth_text_to_mix_frames(
tts: &Arc<KokoroTts>,
text: &str,
voice: Voice,
) -> Result<Vec<Vec<f32>>, String> {
let (samples, duration) = tts
.synth(text, voice)
.await
.map_err(|e| format!("synthesis failed: {e:?}"))?;
eprintln!(
"[tts] synthesized chunk ({} chars, {} samples) in {duration:?}",
text.chars().count(),
samples.len()
);
pcm_to_mix_frames(&samples, TTS_OUTPUT_RATE)
}
async fn stream_live_prompt_chunks(
tts: Arc<KokoroTts>,
voice: Voice,
chunks: Vec<String>,
stream_tx: mpsc::Sender<TtsStreamMessage>,
mut cancel_rx: watch::Receiver<bool>,
) {
for chunk in chunks {
if *cancel_rx.borrow() {
break;
}
match synth_text_to_mix_frames(&tts, &chunk, voice).await {
Ok(frames) => {
if *cancel_rx.borrow() {
break;
}
if stream_tx.send(TtsStreamMessage::Frames(frames)).await.is_err() {
return;
}
}
Err(error) => {
let _ = stream_tx.send(TtsStreamMessage::Failed(error)).await;
return;
}
}
if cancel_rx.has_changed().unwrap_or(false) && *cancel_rx.borrow_and_update() {
break;
}
}
let _ = stream_tx.send(TtsStreamMessage::Finished).await;
}
fn chunk_text(text: &str) -> Vec<String> {
let mut chunks = Vec::new();
let mut current = String::new();
for ch in text.chars() {
current.push(ch);
let len = current.chars().count();
let hard_split = len >= MAX_CHUNK_CHARS && (ch.is_whitespace() || is_soft_boundary(ch));
let natural_split = len >= MIN_CHUNK_CHARS && is_sentence_boundary(ch);
if natural_split || hard_split {
push_chunk(&mut chunks, &mut current);
}
}
push_chunk(&mut chunks, &mut current);
if chunks.len() >= 2 {
let last_len = chunks.last().unwrap().chars().count();
if last_len < (MIN_CHUNK_CHARS / 2) {
let tail = chunks.pop().unwrap();
if let Some(prev) = chunks.last_mut() {
prev.push(' ');
prev.push_str(tail.trim());
}
}
}
chunks
}
fn push_chunk(chunks: &mut Vec<String>, current: &mut String) {
let trimmed = current.trim();
if !trimmed.is_empty() {
chunks.push(trimmed.to_string());
}
current.clear();
}
fn is_sentence_boundary(ch: char) -> bool {
matches!(ch, '.' | '!' | '?' | '\n' | ';' | ':')
}
fn is_soft_boundary(ch: char) -> bool {
matches!(ch, ',' | ';' | ':' | ')' | ']' | '\n')
} }
/// Map voice name string to Kokoro Voice enum variant. /// Map voice name string to Kokoro Voice enum variant.

View File

@@ -19,6 +19,7 @@ pub async fn run_voicemail_session(
rtp_socket: Arc<UdpSocket>, rtp_socket: Arc<UdpSocket>,
provider_media: SocketAddr, provider_media: SocketAddr,
codec_pt: u8, codec_pt: u8,
voicebox_id: Option<String>,
greeting_wav: Option<String>, greeting_wav: Option<String>,
recording_path: String, recording_path: String,
max_recording_ms: u64, max_recording_ms: u64,
@@ -33,6 +34,7 @@ pub async fn run_voicemail_session(
"voicemail_started", "voicemail_started",
serde_json::json!({ serde_json::json!({
"call_id": call_id, "call_id": call_id,
"voicebox_id": voicebox_id,
"caller_number": caller_number, "caller_number": caller_number,
}), }),
); );
@@ -102,6 +104,7 @@ pub async fn run_voicemail_session(
"recording_done", "recording_done",
serde_json::json!({ serde_json::json!({
"call_id": call_id, "call_id": call_id,
"voicebox_id": voicebox_id,
"file_path": result.file_path, "file_path": result.file_path,
"duration_ms": result.duration_ms, "duration_ms": result.duration_ms,
"caller_number": caller_number, "caller_number": caller_number,

View File

@@ -58,9 +58,7 @@ impl WebRtcEngine {
.register_default_codecs() .register_default_codecs()
.map_err(|e| format!("register codecs: {e}"))?; .map_err(|e| format!("register codecs: {e}"))?;
let api = APIBuilder::new() let api = APIBuilder::new().with_media_engine(media_engine).build();
.with_media_engine(media_engine)
.build();
let config = RTCConfiguration { let config = RTCConfiguration {
ice_servers: vec![], ice_servers: vec![],
@@ -91,8 +89,7 @@ impl WebRtcEngine {
.map_err(|e| format!("add track: {e}"))?; .map_err(|e| format!("add track: {e}"))?;
// Shared mixer channel sender (populated when linked to a call). // Shared mixer channel sender (populated when linked to a call).
let mixer_tx: Arc<Mutex<Option<mpsc::Sender<RtpPacket>>>> = let mixer_tx: Arc<Mutex<Option<mpsc::Sender<RtpPacket>>>> = Arc::new(Mutex::new(None));
Arc::new(Mutex::new(None));
// ICE candidate handler. // ICE candidate handler.
let out_tx_ice = self.out_tx.clone(); let out_tx_ice = self.out_tx.clone();
@@ -256,7 +253,11 @@ impl WebRtcEngine {
pub async fn close_session(&mut self, session_id: &str) -> Result<(), String> { pub async fn close_session(&mut self, session_id: &str) -> Result<(), String> {
if let Some(session) = self.sessions.remove(session_id) { if let Some(session) = self.sessions.remove(session_id) {
session.pc.close().await.map_err(|e| format!("close: {e}"))?; session
.pc
.close()
.await
.map_err(|e| format!("close: {e}"))?;
} }
Ok(()) Ok(())
} }

View File

@@ -51,9 +51,7 @@ impl SipDialog {
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_else(generate_tag), .unwrap_or_else(generate_tag),
remote_tag: None, remote_tag: None,
local_uri: SipMessage::extract_uri(from) local_uri: SipMessage::extract_uri(from).unwrap_or("").to_string(),
.unwrap_or("")
.to_string(),
remote_uri: SipMessage::extract_uri(to).unwrap_or("").to_string(), remote_uri: SipMessage::extract_uri(to).unwrap_or("").to_string(),
local_cseq, local_cseq,
remote_cseq: 0, remote_cseq: 0,
@@ -181,10 +179,7 @@ impl SipDialog {
format!("<{}>{remote_tag_str}", self.remote_uri), format!("<{}>{remote_tag_str}", self.remote_uri),
), ),
("Call-ID".to_string(), self.call_id.clone()), ("Call-ID".to_string(), self.call_id.clone()),
( ("CSeq".to_string(), format!("{} {method}", self.local_cseq)),
"CSeq".to_string(),
format!("{} {method}", self.local_cseq),
),
("Max-Forwards".to_string(), "70".to_string()), ("Max-Forwards".to_string(), "70".to_string()),
]; ];
@@ -243,10 +238,7 @@ impl SipDialog {
format!("<{}>{remote_tag_str}", self.remote_uri), format!("<{}>{remote_tag_str}", self.remote_uri),
), ),
("Call-ID".to_string(), self.call_id.clone()), ("Call-ID".to_string(), self.call_id.clone()),
( ("CSeq".to_string(), format!("{} ACK", self.local_cseq)),
"CSeq".to_string(),
format!("{} ACK", self.local_cseq),
),
("Max-Forwards".to_string(), "70".to_string()), ("Max-Forwards".to_string(), "70".to_string()),
]; ];
@@ -271,10 +263,7 @@ impl SipDialog {
("From".to_string(), from), ("From".to_string(), from),
("To".to_string(), to), ("To".to_string(), to),
("Call-ID".to_string(), self.call_id.clone()), ("Call-ID".to_string(), self.call_id.clone()),
( ("CSeq".to_string(), format!("{} CANCEL", self.local_cseq)),
"CSeq".to_string(),
format!("{} CANCEL", self.local_cseq),
),
("Max-Forwards".to_string(), "70".to_string()), ("Max-Forwards".to_string(), "70".to_string()),
("Content-Length".to_string(), "0".to_string()), ("Content-Length".to_string(), "0".to_string()),
]; ];
@@ -284,11 +273,7 @@ impl SipDialog {
.unwrap_or(&self.remote_target) .unwrap_or(&self.remote_target)
.to_string(); .to_string();
SipMessage::new( SipMessage::new(format!("CANCEL {ruri} SIP/2.0"), headers, String::new())
format!("CANCEL {ruri} SIP/2.0"),
headers,
String::new(),
)
} }
/// Transition the dialog to terminated state. /// Transition the dialog to terminated state.

View File

@@ -27,7 +27,9 @@ pub fn generate_branch() -> String {
fn random_hex(bytes: usize) -> String { fn random_hex(bytes: usize) -> String {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
(0..bytes).map(|_| format!("{:02x}", rng.gen::<u8>())).collect() (0..bytes)
.map(|_| format!("{:02x}", rng.gen::<u8>()))
.collect()
} }
// ---- Codec registry -------------------------------------------------------- // ---- Codec registry --------------------------------------------------------
@@ -142,7 +144,9 @@ pub fn parse_digest_challenge(header: &str) -> Option<DigestChallenge> {
return Some(after[1..1 + end].to_string()); return Some(after[1..1 + end].to_string());
} }
// Unquoted value. // Unquoted value.
let end = after.find(|c: char| c == ',' || c.is_whitespace()).unwrap_or(after.len()); let end = after
.find(|c: char| c == ',' || c.is_whitespace())
.unwrap_or(after.len());
return Some(after[..end].to_string()); return Some(after[..end].to_string());
} }
None None
@@ -241,11 +245,7 @@ pub struct MwiResult {
pub extra_headers: Vec<(String, String)>, pub extra_headers: Vec<(String, String)>,
} }
pub fn build_mwi_body( pub fn build_mwi_body(new_messages: u32, old_messages: u32, account_uri: &str) -> MwiResult {
new_messages: u32,
old_messages: u32,
account_uri: &str,
) -> MwiResult {
let waiting = if new_messages > 0 { "yes" } else { "no" }; let waiting = if new_messages > 0 { "yes" } else { "no" };
let body = format!( let body = format!(
"Messages-Waiting: {waiting}\r\n\ "Messages-Waiting: {waiting}\r\n\

View File

@@ -4,9 +4,9 @@
//! SDP handling, Digest authentication, and URI rewriting. //! SDP handling, Digest authentication, and URI rewriting.
//! Ported from the TypeScript `ts/sip/` library. //! Ported from the TypeScript `ts/sip/` library.
pub mod message;
pub mod dialog; pub mod dialog;
pub mod helpers; pub mod helpers;
pub mod message;
pub mod rewrite; pub mod rewrite;
/// Network endpoint (address + port + optional negotiated codec). /// Network endpoint (address + port + optional negotiated codec).

View File

@@ -14,7 +14,11 @@ pub struct SipMessage {
impl SipMessage { impl SipMessage {
pub fn new(start_line: String, headers: Vec<(String, String)>, body: String) -> Self { pub fn new(start_line: String, headers: Vec<(String, String)>, body: String) -> Self {
Self { start_line, headers, body } Self {
start_line,
headers,
body,
}
} }
// ---- Parsing ----------------------------------------------------------- // ---- Parsing -----------------------------------------------------------
@@ -175,7 +179,8 @@ impl SipMessage {
/// Inserts a header at the top of the header list. /// Inserts a header at the top of the header list.
pub fn prepend_header(&mut self, name: &str, value: &str) -> &mut Self { pub fn prepend_header(&mut self, name: &str, value: &str) -> &mut Self {
self.headers.insert(0, (name.to_string(), value.to_string())); self.headers
.insert(0, (name.to_string(), value.to_string()));
self self
} }
@@ -233,10 +238,7 @@ impl SipMessage {
.to_display_name .to_display_name
.map(|d| format!("\"{d}\" ")) .map(|d| format!("\"{d}\" "))
.unwrap_or_default(); .unwrap_or_default();
let to_tag_str = opts let to_tag_str = opts.to_tag.map(|t| format!(";tag={t}")).unwrap_or_default();
.to_tag
.map(|t| format!(";tag={t}"))
.unwrap_or_default();
let mut headers = vec![ let mut headers = vec![
( (
@@ -364,7 +366,43 @@ impl SipMessage {
.find(|c: char| c == ';' || c == '>') .find(|c: char| c == ';' || c == '>')
.unwrap_or(trimmed.len()); .unwrap_or(trimmed.len());
let result = &trimmed[..end]; let result = &trimmed[..end];
if result.is_empty() { None } else { Some(result) } if result.is_empty() {
None
} else {
Some(result)
}
}
}
/// Extract the user part from a SIP/TEL URI or header value.
pub fn extract_uri_user(uri_or_header_value: &str) -> Option<&str> {
let raw = Self::extract_uri(uri_or_header_value).unwrap_or(uri_or_header_value);
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let user_part = if raw
.get(..5)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("sips:"))
{
&raw[5..]
} else if raw.get(..4).is_some_and(|prefix| {
prefix.eq_ignore_ascii_case("sip:") || prefix.eq_ignore_ascii_case("tel:")
}) {
&raw[4..]
} else {
raw
};
let end = user_part
.find(|c: char| matches!(c, '@' | ';' | '?' | '>'))
.unwrap_or(user_part.len());
let result = &user_part[..end];
if result.is_empty() {
None
} else {
Some(result)
} }
} }
} }
@@ -506,6 +544,19 @@ mod tests {
SipMessage::extract_uri("\"Name\" <sip:user@host>;tag=abc"), SipMessage::extract_uri("\"Name\" <sip:user@host>;tag=abc"),
Some("sip:user@host") Some("sip:user@host")
); );
assert_eq!(
SipMessage::extract_uri_user("\"Name\" <sip:+49 421 219694@host>;tag=abc"),
Some("+49 421 219694")
);
assert_eq!(
SipMessage::extract_uri_user("sip:0049421219694@voip.easybell.de"),
Some("0049421219694")
);
assert_eq!(
SipMessage::extract_uri_user("tel:+49421219694;phone-context=example.com"),
Some("+49421219694")
);
assert_eq!(SipMessage::extract_uri_user("SIP:user@host"), Some("user"));
} }
#[test] #[test]
@@ -535,7 +586,10 @@ mod tests {
); );
assert_eq!(invite.method(), Some("INVITE")); assert_eq!(invite.method(), Some("INVITE"));
assert_eq!(invite.call_id(), "test-123"); assert_eq!(invite.call_id(), "test-123");
assert!(invite.get_header("Via").unwrap().contains("192.168.1.1:5070")); assert!(invite
.get_header("Via")
.unwrap()
.contains("192.168.1.1:5070"));
let response = SipMessage::create_response( let response = SipMessage::create_response(
200, 200,

View File

@@ -92,7 +92,11 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>
.collect(); .collect();
let original = match (orig_addr, orig_port) { let original = match (orig_addr, orig_port) {
(Some(a), Some(p)) => Some(Endpoint { address: a, port: p, codec_pt: None }), (Some(a), Some(p)) => Some(Endpoint {
address: a,
port: p,
codec_pt: None,
}),
_ => None, _ => None,
}; };

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: 'siprouter', name: 'siprouter',
version: '1.18.0', version: '1.25.1',
description: 'undefined' description: 'undefined'
} }

View File

@@ -1,137 +0,0 @@
/**
* TTS announcement module — generates announcement WAV files at startup.
*
* Engine priority: espeak-ng (formant TTS, fast) → Kokoro neural TTS via
* proxy-engine → disabled.
*
* The generated WAV is left on disk for Rust's audio_player / start_interaction
* to play during calls. No encoding or RTP playback happens in TypeScript.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { sendProxyCommand, isProxyReady } from './proxybridge.ts';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const TTS_DIR = path.join(process.cwd(), '.nogit', 'tts');
const ANNOUNCEMENT_TEXT = "Hello. I'm connecting your call now.";
const CACHE_WAV = path.join(TTS_DIR, 'announcement.wav');
// Kokoro fallback constants.
const KOKORO_MODEL = 'kokoro-v1.0.onnx';
const KOKORO_VOICES = 'voices.bin';
const KOKORO_VOICE = 'af_bella';
// ---------------------------------------------------------------------------
// TTS generators
// ---------------------------------------------------------------------------
/** Check if espeak-ng is available on the system. */
function isEspeakAvailable(): boolean {
try {
execSync('which espeak-ng', { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/** Generate announcement WAV via espeak-ng (primary engine). */
function generateViaEspeak(wavPath: string, text: string, log: (msg: string) => void): boolean {
log('[tts] generating announcement audio via espeak-ng...');
try {
execSync(
`espeak-ng -v en-us -s 150 -w "${wavPath}" "${text}"`,
{ timeout: 10000, stdio: 'pipe' },
);
log('[tts] espeak-ng WAV generated');
return true;
} catch (e: any) {
log(`[tts] espeak-ng failed: ${e.message}`);
return false;
}
}
/** Generate announcement WAV via Kokoro TTS (fallback, runs inside proxy-engine). */
async function generateViaKokoro(wavPath: string, text: string, log: (msg: string) => void): Promise<boolean> {
const modelPath = path.join(TTS_DIR, KOKORO_MODEL);
const voicesPath = path.join(TTS_DIR, KOKORO_VOICES);
if (!fs.existsSync(modelPath) || !fs.existsSync(voicesPath)) {
log('[tts] Kokoro model/voices not found — Kokoro fallback unavailable');
return false;
}
if (!isProxyReady()) {
log('[tts] proxy-engine not ready — Kokoro fallback unavailable');
return false;
}
log('[tts] generating announcement audio via Kokoro TTS (fallback)...');
try {
await sendProxyCommand('generate_tts', {
model: modelPath,
voices: voicesPath,
voice: KOKORO_VOICE,
text,
output: wavPath,
});
log('[tts] Kokoro WAV generated (via proxy-engine)');
return true;
} catch (e: any) {
log(`[tts] Kokoro failed: ${e.message}`);
return false;
}
}
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
/**
* Pre-generate the announcement WAV file.
* Must be called after the proxy engine is initialized.
*
* Engine priority: espeak-ng → Kokoro → disabled.
*/
export async function initAnnouncement(log: (msg: string) => void): Promise<boolean> {
fs.mkdirSync(TTS_DIR, { recursive: true });
try {
if (!fs.existsSync(CACHE_WAV)) {
let generated = false;
// Try espeak-ng first.
if (isEspeakAvailable()) {
generated = generateViaEspeak(CACHE_WAV, ANNOUNCEMENT_TEXT, log);
} else {
log('[tts] espeak-ng not installed — trying Kokoro fallback');
}
// Fall back to Kokoro (via proxy-engine).
if (!generated) {
generated = await generateViaKokoro(CACHE_WAV, ANNOUNCEMENT_TEXT, log);
}
if (!generated) {
log('[tts] no TTS engine available — announcements disabled');
return false;
}
}
log('[tts] announcement WAV ready');
return true;
} catch (e: any) {
log(`[tts] init error: ${e.message}`);
return false;
}
}
/** Get the path to the cached announcement WAV, or null if not generated. */
export function getAnnouncementWavPath(): string | null {
return fs.existsSync(CACHE_WAV) ? CACHE_WAV : null;
}

View File

@@ -1,275 +0,0 @@
/**
* PromptCache — manages named audio prompt WAV files for IVR and voicemail.
*
* Generates WAV files via espeak-ng (primary) or Kokoro TTS through the
* proxy-engine (fallback). Also supports loading pre-existing WAV files
* and programmatic tone generation.
*
* All audio playback happens in Rust (audio_player / start_interaction).
* This module only manages WAV files on disk.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import { sendProxyCommand, isProxyReady } from '../proxybridge.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A cached prompt — just a WAV file path and metadata. */
export interface ICachedPrompt {
/** Unique prompt identifier. */
id: string;
/** Path to the WAV file on disk. */
wavPath: string;
/** Total duration in milliseconds (approximate, from WAV header). */
durationMs: number;
}
// ---------------------------------------------------------------------------
// TTS helpers
// ---------------------------------------------------------------------------
const TTS_DIR = path.join(process.cwd(), '.nogit', 'tts');
/** Check if espeak-ng is available. */
function isEspeakAvailable(): boolean {
try {
execSync('which espeak-ng', { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/** Generate WAV via espeak-ng. */
function generateViaEspeak(wavPath: string, text: string): boolean {
try {
execSync(
`espeak-ng -v en-us -s 150 -w "${wavPath}" "${text}"`,
{ timeout: 10000, stdio: 'pipe' },
);
return true;
} catch {
return false;
}
}
/** Generate WAV via Kokoro TTS (runs inside proxy-engine). */
async function generateViaKokoro(wavPath: string, text: string, voice: string): Promise<boolean> {
const modelPath = path.join(TTS_DIR, 'kokoro-v1.0.onnx');
const voicesPath = path.join(TTS_DIR, 'voices.bin');
if (!fs.existsSync(modelPath) || !fs.existsSync(voicesPath)) return false;
if (!isProxyReady()) return false;
try {
await sendProxyCommand('generate_tts', {
model: modelPath,
voices: voicesPath,
voice,
text,
output: wavPath,
});
return true;
} catch {
return false;
}
}
/** Read a WAV file's duration from its header. */
function getWavDurationMs(wavPath: string): number {
try {
const wav = fs.readFileSync(wavPath);
if (wav.length < 44) return 0;
if (wav.toString('ascii', 0, 4) !== 'RIFF') return 0;
let sampleRate = 16000;
let dataSize = 0;
let bitsPerSample = 16;
let channels = 1;
let offset = 12;
while (offset < wav.length - 8) {
const chunkId = wav.toString('ascii', offset, offset + 4);
const chunkSize = wav.readUInt32LE(offset + 4);
if (chunkId === 'fmt ') {
channels = wav.readUInt16LE(offset + 10);
sampleRate = wav.readUInt32LE(offset + 12);
bitsPerSample = wav.readUInt16LE(offset + 22);
}
if (chunkId === 'data') {
dataSize = chunkSize;
}
offset += 8 + chunkSize;
if (offset % 2 !== 0) offset++;
}
const bytesPerSample = (bitsPerSample / 8) * channels;
const totalSamples = bytesPerSample > 0 ? dataSize / bytesPerSample : 0;
return sampleRate > 0 ? Math.round((totalSamples / sampleRate) * 1000) : 0;
} catch {
return 0;
}
}
// ---------------------------------------------------------------------------
// PromptCache
// ---------------------------------------------------------------------------
export class PromptCache {
private prompts = new Map<string, ICachedPrompt>();
private log: (msg: string) => void;
private espeakAvailable: boolean | null = null;
constructor(log: (msg: string) => void) {
this.log = log;
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/** Get a cached prompt by ID. */
get(id: string): ICachedPrompt | null {
return this.prompts.get(id) ?? null;
}
/** Check if a prompt is cached. */
has(id: string): boolean {
return this.prompts.has(id);
}
/** List all cached prompt IDs. */
listIds(): string[] {
return [...this.prompts.keys()];
}
/**
* Generate a TTS prompt WAV and cache its path.
* Uses espeak-ng (primary) or Kokoro (fallback).
*/
async generatePrompt(id: string, text: string, voice = 'af_bella'): Promise<ICachedPrompt | null> {
fs.mkdirSync(TTS_DIR, { recursive: true });
const wavPath = path.join(TTS_DIR, `prompt-${id}.wav`);
// Check espeak availability once.
if (this.espeakAvailable === null) {
this.espeakAvailable = isEspeakAvailable();
}
// Generate WAV if not already on disk.
if (!fs.existsSync(wavPath)) {
let generated = false;
if (this.espeakAvailable) {
generated = generateViaEspeak(wavPath, text);
}
if (!generated) {
generated = await generateViaKokoro(wavPath, text, voice);
}
if (!generated) {
this.log(`[prompt-cache] failed to generate TTS for "${id}"`);
return null;
}
this.log(`[prompt-cache] generated WAV for "${id}"`);
}
return this.registerWav(id, wavPath);
}
/**
* Load a pre-existing WAV file as a prompt.
*/
async loadWavPrompt(id: string, wavPath: string): Promise<ICachedPrompt | null> {
if (!fs.existsSync(wavPath)) {
this.log(`[prompt-cache] WAV not found: ${wavPath}`);
return null;
}
return this.registerWav(id, wavPath);
}
/**
* Generate a beep tone WAV and cache it.
*/
async generateBeep(
id: string,
freqHz = 1000,
durationMs = 500,
amplitude = 8000,
): Promise<ICachedPrompt | null> {
fs.mkdirSync(TTS_DIR, { recursive: true });
const wavPath = path.join(TTS_DIR, `prompt-${id}.wav`);
if (!fs.existsSync(wavPath)) {
// Generate 16kHz 16-bit mono sine wave WAV.
const sampleRate = 16000;
const totalSamples = Math.floor((sampleRate * durationMs) / 1000);
const pcm = Buffer.alloc(totalSamples * 2);
for (let i = 0; i < totalSamples; i++) {
const t = i / sampleRate;
const fadeLen = Math.floor(sampleRate * 0.01); // 10ms fade
let envelope = 1.0;
if (i < fadeLen) envelope = i / fadeLen;
else if (i > totalSamples - fadeLen) envelope = (totalSamples - i) / fadeLen;
const sample = Math.round(Math.sin(2 * Math.PI * freqHz * t) * amplitude * envelope);
pcm.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i * 2);
}
// Write WAV file.
const headerSize = 44;
const dataSize = pcm.length;
const wav = Buffer.alloc(headerSize + dataSize);
// RIFF header
wav.write('RIFF', 0);
wav.writeUInt32LE(36 + dataSize, 4);
wav.write('WAVE', 8);
// fmt chunk
wav.write('fmt ', 12);
wav.writeUInt32LE(16, 16); // chunk size
wav.writeUInt16LE(1, 20); // PCM format
wav.writeUInt16LE(1, 22); // mono
wav.writeUInt32LE(sampleRate, 24);
wav.writeUInt32LE(sampleRate * 2, 28); // byte rate
wav.writeUInt16LE(2, 32); // block align
wav.writeUInt16LE(16, 34); // bits per sample
// data chunk
wav.write('data', 36);
wav.writeUInt32LE(dataSize, 40);
pcm.copy(wav, 44);
fs.writeFileSync(wavPath, wav);
this.log(`[prompt-cache] beep WAV generated for "${id}"`);
}
return this.registerWav(id, wavPath);
}
/** Remove a prompt from the cache. */
remove(id: string): void {
this.prompts.delete(id);
}
/** Clear all cached prompts. */
clear(): void {
this.prompts.clear();
}
// -------------------------------------------------------------------------
// Internal
// -------------------------------------------------------------------------
private registerWav(id: string, wavPath: string): ICachedPrompt {
const durationMs = getWavDurationMs(wavPath);
const prompt: ICachedPrompt = { id, wavPath, durationMs };
this.prompts.set(id, prompt);
this.log(`[prompt-cache] cached "${id}": ${wavPath} (${(durationMs / 1000).toFixed(1)}s)`);
return prompt;
}
}

View File

@@ -8,6 +8,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import type { IVoiceboxConfig } from './voicebox.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared types (previously in ts/sip/types.ts, now inlined) // Shared types (previously in ts/sip/types.ts, now inlined)
@@ -47,6 +48,24 @@ export interface IDeviceConfig {
extension: string; extension: string;
} }
export type TIncomingNumberMode = 'single' | 'range' | 'regex';
export interface IIncomingNumberConfig {
id: string;
label: string;
providerId?: string;
mode: TIncomingNumberMode;
countryCode?: string;
areaCode?: string;
localNumber?: string;
rangeEnd?: string;
pattern?: string;
// Legacy persisted fields kept for migration compatibility.
number?: string;
rangeStart?: string;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Match/Action routing model // Match/Action routing model
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -61,8 +80,11 @@ export interface ISipRouteMatch {
direction: 'inbound' | 'outbound'; direction: 'inbound' | 'outbound';
/** /**
* Match the dialed/called number (To/Request-URI for inbound DID, dialed digits for outbound). * Match the normalized called number.
* Supports: exact string, prefix with trailing '*' (e.g. "+4930*"), or regex ("/^\\+49/"). *
* Inbound: matches the provider-delivered DID / Request-URI user part.
* Outbound: matches the normalized dialed digits.
* Supports: exact string, numeric range `start..end`, prefix with trailing '*' (e.g. "+4930*"), or regex ("/^\\+49/").
*/ */
numberPattern?: string; numberPattern?: string;
@@ -88,13 +110,13 @@ export interface ISipRouteAction {
// --- Inbound actions (IVR / voicemail) --- // --- Inbound actions (IVR / voicemail) ---
/** Route directly to a voicemail box (skip ringing devices). */ /** Voicemail fallback for matched inbound routes. */
voicemailBox?: string; voicemailBox?: string;
/** Route to an IVR menu by menu ID (skip ringing devices). */ /** Route to an IVR menu by menu ID (skip ringing devices). */
ivrMenuId?: string; ivrMenuId?: string;
/** Override no-answer timeout (seconds) before routing to voicemail. */ /** Reserved for future no-answer handling. */
noAnswerTimeout?: number; noAnswerTimeout?: number;
// --- Outbound actions (provider selection) --- // --- Outbound actions (provider selection) ---
@@ -160,24 +182,13 @@ export interface IContact {
// Voicebox configuration // Voicebox configuration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface IVoiceboxConfig { // Canonical definition lives in voicebox.ts (imported at the top of this
/** Unique ID — typically matches device ID or extension. */ // file) — re-exported here so consumers can import everything from a
id: string; // single config module without pulling in the voicebox implementation.
/** Whether this voicebox is active. */ // This used to be a duplicated interface and caused
enabled: boolean; // "number | undefined is not assignable to number" type errors when
/** Custom TTS greeting text. */ // passing config.voiceboxes into VoiceboxManager.init().
greetingText?: string; export type { IVoiceboxConfig };
/** TTS voice ID (default 'af_bella'). */
greetingVoice?: string;
/** Path to uploaded WAV greeting (overrides TTS). */
greetingWavPath?: string;
/** Seconds to wait before routing to voicemail (default 25). */
noAnswerTimeoutSec?: number;
/** Maximum recording duration in seconds (default 120). */
maxRecordingSec?: number;
/** Maximum stored messages per box (default 50). */
maxMessages?: number;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// IVR configuration // IVR configuration
@@ -241,6 +252,7 @@ export interface IAppConfig {
proxy: IProxyConfig; proxy: IProxyConfig;
providers: IProviderConfig[]; providers: IProviderConfig[];
devices: IDeviceConfig[]; devices: IDeviceConfig[];
incomingNumbers?: IIncomingNumberConfig[];
routing: IRoutingConfig; routing: IRoutingConfig;
contacts: IContact[]; contacts: IContact[];
voiceboxes?: IVoiceboxConfig[]; voiceboxes?: IVoiceboxConfig[];
@@ -295,6 +307,14 @@ export function loadConfig(): IAppConfig {
d.extension ??= '100'; d.extension ??= '100';
} }
cfg.incomingNumbers ??= [];
for (const incoming of cfg.incomingNumbers) {
if (!incoming.id) incoming.id = `incoming-${Date.now()}`;
incoming.label ??= incoming.id;
incoming.mode ??= incoming.pattern ? 'regex' : incoming.rangeStart || incoming.rangeEnd ? 'range' : 'single';
incoming.countryCode ??= incoming.mode === 'regex' ? undefined : '+49';
}
cfg.routing ??= { routes: [] }; cfg.routing ??= { routes: [] };
cfg.routing.routes ??= []; cfg.routing.routes ??= [];

View File

@@ -14,12 +14,36 @@ import { WebSocketServer, WebSocket } from 'ws';
import { handleWebRtcSignaling } from './webrtcbridge.ts'; import { handleWebRtcSignaling } from './webrtcbridge.ts';
import type { VoiceboxManager } from './voicebox.ts'; import type { VoiceboxManager } from './voicebox.ts';
// CallManager was previously used for WebRTC call handling. Now replaced by Rust proxy-engine.
// Kept as `any` type for backward compat with the function signature until full WebRTC port.
type CallManager = any;
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json'); const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
interface IHandleRequestContext {
getStatus: () => unknown;
log: (msg: string) => void;
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null;
onHangupCall: (callId: string) => boolean;
onConfigSaved?: () => void | Promise<void>;
voiceboxManager?: VoiceboxManager;
}
interface IWebUiOptions extends IHandleRequestContext {
port: number;
onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise<void>;
onWebRtcIce?: (sessionId: string, candidate: unknown) => Promise<void>;
onWebRtcClose?: (sessionId: string) => Promise<void>;
onWebRtcAccept?: (callId: string, sessionId: string) => void;
}
interface IWebRtcSocketMessage {
type?: string;
sessionId?: string;
callId?: string;
sdp?: string;
candidate?: unknown;
userAgent?: string;
_remoteIp?: string | null;
[key: string]: unknown;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// WebSocket broadcast // WebSocket broadcast
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -82,14 +106,9 @@ function loadStaticFiles(): void {
async function handleRequest( async function handleRequest(
req: http.IncomingMessage, req: http.IncomingMessage,
res: http.ServerResponse, res: http.ServerResponse,
getStatus: () => unknown, context: IHandleRequestContext,
log: (msg: string) => void,
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null,
onHangupCall: (callId: string) => boolean,
onConfigSaved?: () => void,
callManager?: CallManager,
voiceboxManager?: VoiceboxManager,
): Promise<void> { ): Promise<void> {
const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager } = context;
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const method = req.method || 'GET'; const method = req.method || 'GET';
@@ -247,6 +266,7 @@ async function handleRequest(
if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName; if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName;
} }
} }
if (updates.incomingNumbers !== undefined) cfg.incomingNumbers = updates.incomingNumbers;
if (updates.routing) { if (updates.routing) {
if (updates.routing.routes) { if (updates.routing.routes) {
cfg.routing.routes = updates.routing.routes; cfg.routing.routes = updates.routing.routes;
@@ -258,7 +278,7 @@ async function handleRequest(
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n'); fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
log('[config] updated config.json'); log('[config] updated config.json');
onConfigSaved?.(); await onConfigSaved?.();
return sendJson(res, { ok: true }); return sendJson(res, { ok: true });
} catch (e: any) { } catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400); return sendJson(res, { ok: false, error: e.message }, 400);
@@ -339,21 +359,21 @@ async function handleRequest(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function initWebUi( export function initWebUi(
getStatus: () => unknown, options: IWebUiOptions,
log: (msg: string) => void,
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null,
onHangupCall: (callId: string) => boolean,
onConfigSaved?: () => void,
callManager?: CallManager,
voiceboxManager?: VoiceboxManager,
/** WebRTC signaling handlers — forwarded to Rust proxy-engine. */
onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise<void>,
onWebRtcIce?: (sessionId: string, candidate: any) => Promise<void>,
onWebRtcClose?: (sessionId: string) => Promise<void>,
/** Called when browser sends webrtc-accept (callId + sessionId linking). */
onWebRtcAccept?: (callId: string, sessionId: string) => void,
): void { ): void {
const WEB_PORT = 3060; const {
port,
getStatus,
log,
onStartCall,
onHangupCall,
onConfigSaved,
voiceboxManager,
onWebRtcOffer,
onWebRtcIce,
onWebRtcClose,
onWebRtcAccept,
} = options;
loadStaticFiles(); loadStaticFiles();
@@ -367,12 +387,12 @@ export function initWebUi(
const cert = fs.readFileSync(certPath, 'utf8'); const cert = fs.readFileSync(certPath, 'utf8');
const key = fs.readFileSync(keyPath, 'utf8'); const key = fs.readFileSync(keyPath, 'utf8');
server = https.createServer({ cert, key }, (req, res) => server = https.createServer({ cert, key }, (req, res) =>
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).catch(() => { res.writeHead(500); res.end(); }), handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
); );
useTls = true; useTls = true;
} catch { } catch {
server = http.createServer((req, res) => server = http.createServer((req, res) =>
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).catch(() => { res.writeHead(500); res.end(); }), handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
); );
} }
@@ -386,12 +406,12 @@ export function initWebUi(
socket.on('message', (raw) => { socket.on('message', (raw) => {
try { try {
const msg = JSON.parse(raw.toString()); const msg = JSON.parse(raw.toString()) as IWebRtcSocketMessage;
if (msg.type === 'webrtc-offer' && msg.sessionId) { if (msg.type === 'webrtc-offer' && msg.sessionId) {
// Forward to Rust proxy-engine for WebRTC handling. // Forward to Rust proxy-engine for WebRTC handling.
if (onWebRtcOffer) { if (onWebRtcOffer && typeof msg.sdp === 'string') {
log(`[webrtc-ws] offer msg keys: ${Object.keys(msg).join(',')}, sdp type: ${typeof msg.sdp}, sdp len: ${msg.sdp?.length || 0}`); log(`[webrtc-ws] offer msg keys: ${Object.keys(msg).join(',')}, sdp type: ${typeof msg.sdp}, sdp len: ${msg.sdp?.length || 0}`);
onWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) => onWebRtcOffer(msg.sessionId, msg.sdp, socket).catch((e: any) =>
log(`[webrtc] offer error: ${e.message}`)); log(`[webrtc] offer error: ${e.message}`));
} }
} else if (msg.type === 'webrtc-ice' && msg.sessionId) { } else if (msg.type === 'webrtc-ice' && msg.sessionId) {
@@ -409,7 +429,7 @@ export function initWebUi(
} }
} else if (msg.type?.startsWith('webrtc-')) { } else if (msg.type?.startsWith('webrtc-')) {
msg._remoteIp = remoteIp; msg._remoteIp = remoteIp;
handleWebRtcSignaling(socket as any, msg); handleWebRtcSignaling(socket, msg);
} }
} catch { /* ignore */ } } catch { /* ignore */ }
}); });
@@ -418,8 +438,8 @@ export function initWebUi(
socket.on('error', () => wsClients.delete(socket)); socket.on('error', () => wsClients.delete(socket));
}); });
server.listen(WEB_PORT, '0.0.0.0', () => { server.listen(port, '0.0.0.0', () => {
log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${WEB_PORT}`); log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${port}`);
}); });
setInterval(() => broadcastWs('status', getStatus()), 1000); setInterval(() => broadcastWs('status', getStatus()), 1000);

View File

@@ -4,13 +4,36 @@
* The proxy-engine handles ALL SIP protocol mechanics. TypeScript only: * The proxy-engine handles ALL SIP protocol mechanics. TypeScript only:
* - Sends configuration * - Sends configuration
* - Receives high-level events (incoming_call, call_ended, etc.) * - Receives high-level events (incoming_call, call_ended, etc.)
* - Sends high-level commands (hangup, make_call, play_audio) * - Sends high-level commands (hangup, make_call, add_leg, webrtc_offer)
* *
* No raw SIP ever touches TypeScript. * No raw SIP ever touches TypeScript.
*/ */
import path from 'node:path'; import path from 'node:path';
import { RustBridge } from '@push.rocks/smartrust'; import { RustBridge } from '@push.rocks/smartrust';
import type { TProxyEventMap } from './shared/proxy-events.ts';
export type {
ICallAnsweredEvent,
ICallEndedEvent,
ICallRingingEvent,
IDeviceRegisteredEvent,
IIncomingCallEvent,
ILegAddedEvent,
ILegRemovedEvent,
ILegStateChangedEvent,
IOutboundCallEvent,
IOutboundCallStartedEvent,
IProviderRegisteredEvent,
IRecordingDoneEvent,
ISipUnhandledEvent,
IVoicemailErrorEvent,
IVoicemailStartedEvent,
IWebRtcAudioRxEvent,
IWebRtcIceCandidateEvent,
IWebRtcStateEvent,
IWebRtcTrackEvent,
TProxyEventMap,
} from './shared/proxy-events.ts';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Command type map for smartrust // Command type map for smartrust
@@ -29,18 +52,14 @@ type TProxyCommands = {
params: { number: string; device_id?: string; provider_id?: string }; params: { number: string; device_id?: string; provider_id?: string };
result: { call_id: string }; result: { call_id: string };
}; };
play_audio: { add_leg: {
params: { call_id: string; leg_id?: string; file_path: string; codec?: number }; params: { call_id: string; number: string; provider_id?: string };
result: Record<string, never>; result: { leg_id: string };
}; };
start_recording: { remove_leg: {
params: { call_id: string; file_path: string; max_duration_ms?: number }; params: { call_id: string; leg_id: string };
result: Record<string, never>; result: Record<string, never>;
}; };
stop_recording: {
params: { call_id: string };
result: { file_path: string; duration_ms: number };
};
add_device_leg: { add_device_leg: {
params: { call_id: string; device_id: string }; params: { call_id: string; device_id: string };
result: { leg_id: string }; result: { leg_id: string };
@@ -63,6 +82,19 @@ type TProxyCommands = {
}; };
result: { result: 'digit' | 'timeout' | 'cancelled'; digit?: string }; result: { result: 'digit' | 'timeout' | 'cancelled'; digit?: string };
}; };
start_tts_interaction: {
params: {
call_id: string;
leg_id: string;
text: string;
voice?: string;
model?: string;
voices?: string;
expected_digits: string;
timeout_ms: number;
};
result: { result: 'digit' | 'timeout' | 'cancelled'; digit?: string };
};
add_tool_leg: { add_tool_leg: {
params: { params: {
call_id: string; call_id: string;
@@ -80,49 +112,38 @@ type TProxyCommands = {
result: Record<string, never>; result: Record<string, never>;
}; };
generate_tts: { generate_tts: {
params: { model: string; voices: string; voice: string; text: string; output: string }; params: { model: string; voices: string; voice: string; text: string; output: string; cacheable?: boolean };
result: { output: string }; result: { output: string };
}; };
// WebRTC signaling — bridged from the browser via the TS control plane.
webrtc_offer: {
params: { session_id: string; sdp: string };
result: { sdp: string };
}; };
webrtc_ice: {
// --------------------------------------------------------------------------- params: {
// Event types from Rust session_id: string;
// --------------------------------------------------------------------------- candidate: string;
sdp_mid?: string;
export interface IIncomingCallEvent { sdp_mline_index?: number;
};
result: Record<string, never>;
};
webrtc_link: {
params: {
session_id: string;
call_id: string; call_id: string;
from_uri: string; provider_media_addr: string;
to_number: string; provider_media_port: number;
provider_id: string; sip_pt?: number;
} };
result: Record<string, never>;
export interface IOutboundCallEvent { };
call_id: string; webrtc_close: {
from_device: string | null; params: { session_id: string };
to_number: string; result: Record<string, never>;
} };
};
export interface ICallEndedEvent {
call_id: string;
reason: string;
duration: number;
from_side?: string;
}
export interface IProviderRegisteredEvent {
provider_id: string;
registered: boolean;
public_ip: string | null;
}
export interface IDeviceRegisteredEvent {
device_id: string;
display_name: string;
address: string;
port: number;
aor: string;
expires: number;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Bridge singleton // Bridge singleton
@@ -132,10 +153,34 @@ let bridge: RustBridge<TProxyCommands> | null = null;
let initialized = false; let initialized = false;
let logFn: ((msg: string) => void) | undefined; let logFn: ((msg: string) => void) | undefined;
type TWebRtcIceCandidate = {
candidate?: string;
sdpMid?: string;
sdpMLineIndex?: number;
} | string;
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function buildLocalPaths(): string[] { function buildLocalPaths(): string[] {
const root = process.cwd(); const root = process.cwd();
// Map Node's process.arch to tsrust's friendly target name.
// tsrust writes multi-target binaries as <bin>_<os>_<arch>,
// e.g. proxy-engine_linux_amd64 / proxy-engine_linux_arm64.
const archSuffix =
process.arch === 'arm64' ? 'linux_arm64' :
process.arch === 'x64' ? 'linux_amd64' :
null;
const multiTarget = archSuffix
? [path.join(root, 'dist_rust', `proxy-engine_${archSuffix}`)]
: [];
return [ return [
// 1. Multi-target output matching the running host arch (Docker image, CI, multi-target dev).
...multiTarget,
// 2. Single-target (unsuffixed) output — legacy/fallback when tsrust runs without targets.
path.join(root, 'dist_rust', 'proxy-engine'), path.join(root, 'dist_rust', 'proxy-engine'),
// 3. Direct cargo builds for dev iteration.
path.join(root, 'rust', 'target', 'release', 'proxy-engine'), path.join(root, 'rust', 'target', 'release', 'proxy-engine'),
path.join(root, 'rust', 'target', 'debug', 'proxy-engine'), path.join(root, 'rust', 'target', 'debug', 'proxy-engine'),
]; ];
@@ -176,8 +221,8 @@ export async function initProxyEngine(log?: (msg: string) => void): Promise<bool
initialized = true; initialized = true;
log?.('[proxy-engine] spawned and ready'); log?.('[proxy-engine] spawned and ready');
return true; return true;
} catch (e: any) { } catch (error: unknown) {
log?.(`[proxy-engine] init error: ${e.message}`); log?.(`[proxy-engine] init error: ${errorMessage(error)}`);
bridge = null; bridge = null;
return false; return false;
} }
@@ -187,14 +232,14 @@ export async function initProxyEngine(log?: (msg: string) => void): Promise<bool
* Send the full app config to the proxy engine. * Send the full app config to the proxy engine.
* This binds the SIP socket, starts provider registrations, etc. * This binds the SIP socket, starts provider registrations, etc.
*/ */
export async function configureProxyEngine(config: Record<string, unknown>): Promise<boolean> { export async function configureProxyEngine(config: TProxyCommands['configure']['params']): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
const result = await bridge.sendCommand('configure', config as any); const result = await sendProxyCommand('configure', config);
logFn?.(`[proxy-engine] configured, SIP bound on ${(result as any)?.bound || '?'}`); logFn?.(`[proxy-engine] configured, SIP bound on ${result.bound || '?'}`);
return true; return true;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] configure error: ${e.message}`); logFn?.(`[proxy-engine] configure error: ${errorMessage(error)}`);
return false; return false;
} }
} }
@@ -205,14 +250,14 @@ export async function configureProxyEngine(config: Record<string, unknown>): Pro
export async function makeCall(number: string, deviceId?: string, providerId?: string): Promise<string | null> { export async function makeCall(number: string, deviceId?: string, providerId?: string): Promise<string | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('make_call', { const result = await sendProxyCommand('make_call', {
number, number,
device_id: deviceId, device_id: deviceId,
provider_id: providerId, provider_id: providerId,
} as any); });
return (result as any)?.call_id || null; return result.call_id || null;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] make_call error: ${e?.message || e}`); logFn?.(`[proxy-engine] make_call error: ${errorMessage(error)}`);
return null; return null;
} }
} }
@@ -223,7 +268,7 @@ export async function makeCall(number: string, deviceId?: string, providerId?: s
export async function hangupCall(callId: string): Promise<boolean> { export async function hangupCall(callId: string): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
await bridge.sendCommand('hangup', { call_id: callId } as any); await sendProxyCommand('hangup', { call_id: callId });
return true; return true;
} catch { } catch {
return false; return false;
@@ -236,10 +281,9 @@ export async function hangupCall(callId: string): Promise<boolean> {
export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp: string } | null> { export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp: string } | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('webrtc_offer', { session_id: sessionId, sdp } as any); return await sendProxyCommand('webrtc_offer', { session_id: sessionId, sdp });
return result as any; } catch (error: unknown) {
} catch (e: any) { logFn?.(`[proxy-engine] webrtc_offer error: ${errorMessage(error)}`);
logFn?.(`[proxy-engine] webrtc_offer error: ${e?.message || e}`);
return null; return null;
} }
} }
@@ -247,15 +291,15 @@ export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp
/** /**
* Forward an ICE candidate to the proxy engine. * Forward an ICE candidate to the proxy engine.
*/ */
export async function webrtcIce(sessionId: string, candidate: any): Promise<void> { export async function webrtcIce(sessionId: string, candidate: TWebRtcIceCandidate): Promise<void> {
if (!bridge || !initialized) return; if (!bridge || !initialized) return;
try { try {
await bridge.sendCommand('webrtc_ice', { await sendProxyCommand('webrtc_ice', {
session_id: sessionId, session_id: sessionId,
candidate: candidate?.candidate || candidate, candidate: typeof candidate === 'string' ? candidate : candidate.candidate || '',
sdp_mid: candidate?.sdpMid, sdp_mid: typeof candidate === 'string' ? undefined : candidate.sdpMid,
sdp_mline_index: candidate?.sdpMLineIndex, sdp_mline_index: typeof candidate === 'string' ? undefined : candidate.sdpMLineIndex,
} as any); });
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -266,16 +310,16 @@ export async function webrtcIce(sessionId: string, candidate: any): Promise<void
export async function webrtcLink(sessionId: string, callId: string, providerMediaAddr: string, providerMediaPort: number, sipPt: number = 9): Promise<boolean> { export async function webrtcLink(sessionId: string, callId: string, providerMediaAddr: string, providerMediaPort: number, sipPt: number = 9): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
await bridge.sendCommand('webrtc_link', { await sendProxyCommand('webrtc_link', {
session_id: sessionId, session_id: sessionId,
call_id: callId, call_id: callId,
provider_media_addr: providerMediaAddr, provider_media_addr: providerMediaAddr,
provider_media_port: providerMediaPort, provider_media_port: providerMediaPort,
sip_pt: sipPt, sip_pt: sipPt,
} as any); });
return true; return true;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] webrtc_link error: ${e?.message || e}`); logFn?.(`[proxy-engine] webrtc_link error: ${errorMessage(error)}`);
return false; return false;
} }
} }
@@ -286,14 +330,14 @@ export async function webrtcLink(sessionId: string, callId: string, providerMedi
export async function addLeg(callId: string, number: string, providerId?: string): Promise<string | null> { export async function addLeg(callId: string, number: string, providerId?: string): Promise<string | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('add_leg', { const result = await sendProxyCommand('add_leg', {
call_id: callId, call_id: callId,
number, number,
provider_id: providerId, provider_id: providerId,
} as any); });
return (result as any)?.leg_id || null; return result.leg_id || null;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] add_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] add_leg error: ${errorMessage(error)}`);
return null; return null;
} }
} }
@@ -304,10 +348,10 @@ export async function addLeg(callId: string, number: string, providerId?: string
export async function removeLeg(callId: string, legId: string): Promise<boolean> { export async function removeLeg(callId: string, legId: string): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
await bridge.sendCommand('remove_leg', { call_id: callId, leg_id: legId } as any); await sendProxyCommand('remove_leg', { call_id: callId, leg_id: legId });
return true; return true;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] remove_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] remove_leg error: ${errorMessage(error)}`);
return false; return false;
} }
} }
@@ -318,7 +362,7 @@ export async function removeLeg(callId: string, legId: string): Promise<boolean>
export async function webrtcClose(sessionId: string): Promise<void> { export async function webrtcClose(sessionId: string): Promise<void> {
if (!bridge || !initialized) return; if (!bridge || !initialized) return;
try { try {
await bridge.sendCommand('webrtc_close', { session_id: sessionId } as any); await sendProxyCommand('webrtc_close', { session_id: sessionId });
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -332,13 +376,13 @@ export async function webrtcClose(sessionId: string): Promise<void> {
export async function addDeviceLeg(callId: string, deviceId: string): Promise<string | null> { export async function addDeviceLeg(callId: string, deviceId: string): Promise<string | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('add_device_leg', { const result = await sendProxyCommand('add_device_leg', {
call_id: callId, call_id: callId,
device_id: deviceId, device_id: deviceId,
} as any); });
return (result as any)?.leg_id || null; return result.leg_id || null;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] add_device_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] add_device_leg error: ${errorMessage(error)}`);
return null; return null;
} }
} }
@@ -353,14 +397,14 @@ export async function transferLeg(
): Promise<boolean> { ): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
await bridge.sendCommand('transfer_leg', { await sendProxyCommand('transfer_leg', {
source_call_id: sourceCallId, source_call_id: sourceCallId,
leg_id: legId, leg_id: legId,
target_call_id: targetCallId, target_call_id: targetCallId,
} as any); });
return true; return true;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] transfer_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] transfer_leg error: ${errorMessage(error)}`);
return false; return false;
} }
} }
@@ -376,15 +420,15 @@ export async function replaceLeg(
): Promise<string | null> { ): Promise<string | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('replace_leg', { const result = await sendProxyCommand('replace_leg', {
call_id: callId, call_id: callId,
old_leg_id: oldLegId, old_leg_id: oldLegId,
number, number,
provider_id: providerId, provider_id: providerId,
} as any); });
return (result as any)?.new_leg_id || null; return result.new_leg_id || null;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] replace_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] replace_leg error: ${errorMessage(error)}`);
return null; return null;
} }
} }
@@ -402,16 +446,49 @@ export async function startInteraction(
): Promise<{ result: 'digit' | 'timeout' | 'cancelled'; digit?: string } | null> { ): Promise<{ result: 'digit' | 'timeout' | 'cancelled'; digit?: string } | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('start_interaction', { return await sendProxyCommand('start_interaction', {
call_id: callId, call_id: callId,
leg_id: legId, leg_id: legId,
prompt_wav: promptWav, prompt_wav: promptWav,
expected_digits: expectedDigits, expected_digits: expectedDigits,
timeout_ms: timeoutMs, timeout_ms: timeoutMs,
} as any); });
return result as any; } catch (error: unknown) {
} catch (e: any) { logFn?.(`[proxy-engine] start_interaction error: ${errorMessage(error)}`);
logFn?.(`[proxy-engine] start_interaction error: ${e?.message || e}`); return null;
}
}
/**
* Start a live TTS interaction on a specific leg. The first chunk is rendered
* up front and the rest streams into the mixer while playback is already live.
*/
export async function startTtsInteraction(
callId: string,
legId: string,
text: string,
expectedDigits: string,
timeoutMs: number,
options?: {
voice?: string;
model?: string;
voices?: string;
},
): Promise<{ result: 'digit' | 'timeout' | 'cancelled'; digit?: string } | null> {
if (!bridge || !initialized) return null;
try {
return await sendProxyCommand('start_tts_interaction', {
call_id: callId,
leg_id: legId,
text,
expected_digits: expectedDigits,
timeout_ms: timeoutMs,
voice: options?.voice,
model: options?.model,
voices: options?.voices,
});
} catch (error: unknown) {
logFn?.(`[proxy-engine] start_tts_interaction error: ${errorMessage(error)}`);
return null; return null;
} }
} }
@@ -427,14 +504,14 @@ export async function addToolLeg(
): Promise<string | null> { ): Promise<string | null> {
if (!bridge || !initialized) return null; if (!bridge || !initialized) return null;
try { try {
const result = await bridge.sendCommand('add_tool_leg', { const result = await sendProxyCommand('add_tool_leg', {
call_id: callId, call_id: callId,
tool_type: toolType, tool_type: toolType,
config, config,
} as any); });
return (result as any)?.tool_leg_id || null; return result.tool_leg_id || null;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] add_tool_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] add_tool_leg error: ${errorMessage(error)}`);
return null; return null;
} }
} }
@@ -445,13 +522,13 @@ export async function addToolLeg(
export async function removeToolLeg(callId: string, toolLegId: string): Promise<boolean> { export async function removeToolLeg(callId: string, toolLegId: string): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
await bridge.sendCommand('remove_tool_leg', { await sendProxyCommand('remove_tool_leg', {
call_id: callId, call_id: callId,
tool_leg_id: toolLegId, tool_leg_id: toolLegId,
} as any); });
return true; return true;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] remove_tool_leg error: ${e?.message || e}`); logFn?.(`[proxy-engine] remove_tool_leg error: ${errorMessage(error)}`);
return false; return false;
} }
} }
@@ -467,15 +544,15 @@ export async function setLegMetadata(
): Promise<boolean> { ): Promise<boolean> {
if (!bridge || !initialized) return false; if (!bridge || !initialized) return false;
try { try {
await bridge.sendCommand('set_leg_metadata', { await sendProxyCommand('set_leg_metadata', {
call_id: callId, call_id: callId,
leg_id: legId, leg_id: legId,
key, key,
value, value,
} as any); });
return true; return true;
} catch (e: any) { } catch (error: unknown) {
logFn?.(`[proxy-engine] set_leg_metadata error: ${e?.message || e}`); logFn?.(`[proxy-engine] set_leg_metadata error: ${errorMessage(error)}`);
return false; return false;
} }
} }
@@ -487,7 +564,7 @@ export async function setLegMetadata(
* dtmf_digit, recording_done, tool_recording_done, tool_transcription_done, * dtmf_digit, recording_done, tool_recording_done, tool_transcription_done,
* leg_added, leg_removed, sip_unhandled * leg_added, leg_removed, sip_unhandled
*/ */
export function onProxyEvent(event: string, handler: (data: any) => void): void { export function onProxyEvent<K extends keyof TProxyEventMap>(event: K, handler: (data: TProxyEventMap[K]) => void): void {
if (!bridge) throw new Error('proxy engine not initialized'); if (!bridge) throw new Error('proxy engine not initialized');
bridge.on(`management:${event}`, handler); bridge.on(`management:${event}`, handler);
} }
@@ -503,7 +580,7 @@ export async function sendProxyCommand<K extends keyof TProxyCommands>(
params: TProxyCommands[K]['params'], params: TProxyCommands[K]['params'],
): Promise<TProxyCommands[K]['result']> { ): Promise<TProxyCommands[K]['result']> {
if (!bridge || !initialized) throw new Error('proxy engine not initialized'); if (!bridge || !initialized) throw new Error('proxy engine not initialized');
return bridge.sendCommand(method as string, params as any) as any; return bridge.sendCommand(method, params) as Promise<TProxyCommands[K]['result']>;
} }
/** Shut down the proxy engine. */ /** Shut down the proxy engine. */

188
ts/runtime/proxy-events.ts Normal file
View File

@@ -0,0 +1,188 @@
import { onProxyEvent } from '../proxybridge.ts';
import type { VoiceboxManager } from '../voicebox.ts';
import type { StatusStore } from './status-store.ts';
import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts';
export interface IRegisterProxyEventHandlersOptions {
log: (msg: string) => void;
statusStore: StatusStore;
voiceboxManager: VoiceboxManager;
webRtcLinks: WebRtcLinkManager;
getBrowserDeviceIds: () => string[];
sendToBrowserDevice: (deviceId: string, data: unknown) => boolean;
broadcast: (type: string, data: unknown) => void;
onLinkWebRtcSession: (callId: string, sessionId: string, media: IProviderMediaInfo) => void;
onCloseWebRtcSession: (sessionId: string) => void;
}
export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersOptions): void {
const {
log,
statusStore,
voiceboxManager,
webRtcLinks,
getBrowserDeviceIds,
sendToBrowserDevice,
broadcast,
onLinkWebRtcSession,
onCloseWebRtcSession,
} = options;
onProxyEvent('provider_registered', (data) => {
const previous = statusStore.noteProviderRegistered(data);
if (previous) {
if (data.registered && !previous.wasRegistered) {
log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`);
} else if (!data.registered && previous.wasRegistered) {
log(`[provider:${data.provider_id}] registration lost`);
}
}
broadcast('registration', { providerId: data.provider_id, registered: data.registered });
});
onProxyEvent('device_registered', (data) => {
if (statusStore.noteDeviceRegistered(data)) {
log(`[registrar] ${data.display_name} registered from ${data.address}:${data.port}`);
}
});
onProxyEvent('incoming_call', (data) => {
log(`[call] incoming: ${data.from_uri} -> ${data.to_number} via ${data.provider_id} (${data.call_id})`);
statusStore.noteIncomingCall(data);
if (data.ring_browsers === false) {
return;
}
for (const deviceId of getBrowserDeviceIds()) {
sendToBrowserDevice(deviceId, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.from_uri,
deviceId,
});
}
});
onProxyEvent('outbound_device_call', (data) => {
log(`[call] outbound: device ${data.from_device} -> ${data.to_number} (${data.call_id})`);
statusStore.noteOutboundDeviceCall(data);
});
onProxyEvent('outbound_call_started', (data) => {
log(`[call] outbound started: ${data.call_id} -> ${data.number} via ${data.provider_id}`);
statusStore.noteOutboundCallStarted(data);
for (const deviceId of getBrowserDeviceIds()) {
sendToBrowserDevice(deviceId, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.number,
deviceId,
});
}
});
onProxyEvent('call_ringing', (data) => {
statusStore.noteCallRinging(data);
});
onProxyEvent('call_answered', (data) => {
if (statusStore.noteCallAnswered(data)) {
log(`[call] ${data.call_id} connected`);
}
if (!data.provider_media_addr || !data.provider_media_port) {
return;
}
const target = webRtcLinks.noteCallAnswered(data.call_id, {
addr: data.provider_media_addr,
port: data.provider_media_port,
sipPt: data.sip_pt ?? 9,
});
if (!target) {
log(`[webrtc] media info cached for call=${data.call_id}, waiting for session accept`);
return;
}
onLinkWebRtcSession(data.call_id, target.sessionId, target.media);
});
onProxyEvent('call_ended', (data) => {
if (statusStore.noteCallEnded(data)) {
log(`[call] ${data.call_id} ended: ${data.reason} (${data.duration}s)`);
}
broadcast('webrtc-call-ended', { callId: data.call_id });
const sessionId = webRtcLinks.cleanupCall(data.call_id);
if (sessionId) {
onCloseWebRtcSession(sessionId);
}
});
onProxyEvent('sip_unhandled', (data) => {
log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`);
});
onProxyEvent('leg_added', (data) => {
log(`[leg] added: call=${data.call_id} leg=${data.leg_id} kind=${data.kind} state=${data.state}`);
statusStore.noteLegAdded(data);
});
onProxyEvent('leg_removed', (data) => {
log(`[leg] removed: call=${data.call_id} leg=${data.leg_id}`);
statusStore.noteLegRemoved(data);
});
onProxyEvent('leg_state_changed', (data) => {
log(`[leg] state: call=${data.call_id} leg=${data.leg_id} -> ${data.state}`);
statusStore.noteLegStateChanged(data);
});
onProxyEvent('webrtc_ice_candidate', (data) => {
broadcast('webrtc-ice', {
sessionId: data.session_id,
candidate: {
candidate: data.candidate,
sdpMid: data.sdp_mid,
sdpMLineIndex: data.sdp_mline_index,
},
});
});
onProxyEvent('webrtc_state', (data) => {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} state=${data.state}`);
});
onProxyEvent('webrtc_track', (data) => {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} track=${data.kind} codec=${data.codec}`);
});
onProxyEvent('webrtc_audio_rx', (data) => {
if (data.packet_count === 1 || data.packet_count === 50) {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} browser audio rx #${data.packet_count}`);
}
});
onProxyEvent('voicemail_started', (data) => {
log(`[voicemail] started for call ${data.call_id} box=${data.voicebox_id || 'default'} caller=${data.caller_number}`);
});
onProxyEvent('recording_done', (data) => {
const boxId = data.voicebox_id || 'default';
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) box=${boxId} caller=${data.caller_number}`);
voiceboxManager.addMessage(boxId, {
callerNumber: data.caller_number || 'Unknown',
callerName: null,
fileName: data.file_path,
durationMs: data.duration_ms,
});
});
onProxyEvent('voicemail_error', (data) => {
log(`[voicemail] error: ${data.error} call=${data.call_id}`);
});
}

313
ts/runtime/status-store.ts Normal file
View File

@@ -0,0 +1,313 @@
import type { IAppConfig } from '../config.ts';
import type {
ICallAnsweredEvent,
ICallEndedEvent,
ICallRingingEvent,
IDeviceRegisteredEvent,
IIncomingCallEvent,
ILegAddedEvent,
ILegRemovedEvent,
ILegStateChangedEvent,
IOutboundCallEvent,
IOutboundCallStartedEvent,
IProviderRegisteredEvent,
} from '../shared/proxy-events.ts';
import type {
IActiveCall,
ICallHistoryEntry,
IDeviceStatus,
IProviderStatus,
IStatusSnapshot,
TLegType,
} from '../shared/status.ts';
const MAX_HISTORY = 100;
const CODEC_NAMES: Record<number, string> = {
0: 'PCMU',
8: 'PCMA',
9: 'G.722',
111: 'Opus',
};
export class StatusStore {
private appConfig: IAppConfig;
private providerStatuses = new Map<string, IProviderStatus>();
private deviceStatuses = new Map<string, IDeviceStatus>();
private activeCalls = new Map<string, IActiveCall>();
private callHistory: ICallHistoryEntry[] = [];
constructor(appConfig: IAppConfig) {
this.appConfig = appConfig;
this.rebuildConfigState();
}
updateConfig(appConfig: IAppConfig): void {
this.appConfig = appConfig;
this.rebuildConfigState();
}
buildStatusSnapshot(
instanceId: string,
startTime: number,
browserDeviceIds: string[],
voicemailCounts: Record<string, number>,
): IStatusSnapshot {
const devices = [...this.deviceStatuses.values()];
for (const deviceId of browserDeviceIds) {
devices.push({
id: deviceId,
displayName: 'Browser',
address: null,
port: 0,
aor: null,
connected: true,
isBrowser: true,
});
}
return {
instanceId,
uptime: Math.floor((Date.now() - startTime) / 1000),
lanIp: this.appConfig.proxy.lanIp,
providers: [...this.providerStatuses.values()],
devices,
calls: [...this.activeCalls.values()].map((call) => ({
...call,
duration: Math.floor((Date.now() - call.startedAt) / 1000),
legs: [...call.legs.values()].map((leg) => ({
...leg,
pktSent: 0,
pktReceived: 0,
transcoding: false,
})),
})),
callHistory: this.callHistory,
contacts: this.appConfig.contacts || [],
voicemailCounts,
};
}
noteDashboardCallStarted(callId: string, number: string, providerId?: string): void {
this.activeCalls.set(callId, {
id: callId,
direction: 'outbound',
callerNumber: null,
calleeNumber: number,
providerUsed: providerId || null,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
}
noteProviderRegistered(data: IProviderRegisteredEvent): { wasRegistered: boolean } | null {
const provider = this.providerStatuses.get(data.provider_id);
if (!provider) {
return null;
}
const wasRegistered = provider.registered;
provider.registered = data.registered;
provider.publicIp = data.public_ip;
return { wasRegistered };
}
noteDeviceRegistered(data: IDeviceRegisteredEvent): boolean {
const device = this.deviceStatuses.get(data.device_id);
if (!device) {
return false;
}
device.address = data.address;
device.port = data.port;
device.aor = data.aor;
device.connected = true;
return true;
}
noteIncomingCall(data: IIncomingCallEvent): void {
this.activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'inbound',
callerNumber: data.from_uri,
calleeNumber: data.to_number,
providerUsed: data.provider_id,
state: 'ringing',
startedAt: Date.now(),
legs: new Map(),
});
}
noteOutboundDeviceCall(data: IOutboundCallEvent): void {
this.activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'outbound',
callerNumber: data.from_device,
calleeNumber: data.to_number,
providerUsed: null,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
}
noteOutboundCallStarted(data: IOutboundCallStartedEvent): void {
this.activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'outbound',
callerNumber: null,
calleeNumber: data.number,
providerUsed: data.provider_id,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
}
noteCallRinging(data: ICallRingingEvent): void {
const call = this.activeCalls.get(data.call_id);
if (call) {
call.state = 'ringing';
}
}
noteCallAnswered(data: ICallAnsweredEvent): boolean {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return false;
}
call.state = 'connected';
if (data.provider_media_addr && data.provider_media_port) {
for (const leg of call.legs.values()) {
if (leg.type !== 'sip-provider') {
continue;
}
leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`;
if (data.sip_pt !== undefined) {
leg.codec = CODEC_NAMES[data.sip_pt] || `PT${data.sip_pt}`;
}
break;
}
}
return true;
}
noteCallEnded(data: ICallEndedEvent): boolean {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return false;
}
this.callHistory.unshift({
id: call.id,
direction: call.direction,
callerNumber: call.callerNumber,
calleeNumber: call.calleeNumber,
providerUsed: call.providerUsed,
startedAt: call.startedAt,
duration: data.duration,
legs: [...call.legs.values()].map((leg) => ({
id: leg.id,
type: leg.type,
metadata: leg.metadata || {},
})),
});
if (this.callHistory.length > MAX_HISTORY) {
this.callHistory.pop();
}
this.activeCalls.delete(data.call_id);
return true;
}
noteLegAdded(data: ILegAddedEvent): void {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return;
}
call.legs.set(data.leg_id, {
id: data.leg_id,
type: data.kind,
state: data.state,
codec: data.codec ?? null,
rtpPort: data.rtpPort ?? null,
remoteMedia: data.remoteMedia ?? null,
metadata: data.metadata || {},
});
}
noteLegRemoved(data: ILegRemovedEvent): void {
this.activeCalls.get(data.call_id)?.legs.delete(data.leg_id);
}
noteLegStateChanged(data: ILegStateChangedEvent): void {
const call = this.activeCalls.get(data.call_id);
if (!call) {
return;
}
const existingLeg = call.legs.get(data.leg_id);
if (existingLeg) {
existingLeg.state = data.state;
if (data.metadata) {
existingLeg.metadata = data.metadata;
}
return;
}
call.legs.set(data.leg_id, {
id: data.leg_id,
type: this.inferLegType(data.leg_id),
state: data.state,
codec: null,
rtpPort: null,
remoteMedia: null,
metadata: data.metadata || {},
});
}
private rebuildConfigState(): void {
const nextProviderStatuses = new Map<string, IProviderStatus>();
for (const provider of this.appConfig.providers) {
const previous = this.providerStatuses.get(provider.id);
nextProviderStatuses.set(provider.id, {
id: provider.id,
displayName: provider.displayName,
registered: previous?.registered ?? false,
publicIp: previous?.publicIp ?? null,
});
}
this.providerStatuses = nextProviderStatuses;
const nextDeviceStatuses = new Map<string, IDeviceStatus>();
for (const device of this.appConfig.devices) {
const previous = this.deviceStatuses.get(device.id);
nextDeviceStatuses.set(device.id, {
id: device.id,
displayName: device.displayName,
address: previous?.address ?? null,
port: previous?.port ?? 0,
aor: previous?.aor ?? null,
connected: previous?.connected ?? false,
isBrowser: false,
});
}
this.deviceStatuses = nextDeviceStatuses;
}
private inferLegType(legId: string): TLegType {
if (legId.includes('-prov')) {
return 'sip-provider';
}
if (legId.includes('-dev')) {
return 'sip-device';
}
return 'webrtc';
}
}

View File

@@ -0,0 +1,66 @@
export interface IProviderMediaInfo {
addr: string;
port: number;
sipPt: number;
}
export interface IWebRtcLinkTarget {
sessionId: string;
media: IProviderMediaInfo;
}
export class WebRtcLinkManager {
private sessionToCall = new Map<string, string>();
private callToSession = new Map<string, string>();
private pendingCallMedia = new Map<string, IProviderMediaInfo>();
acceptCall(callId: string, sessionId: string): IProviderMediaInfo | null {
const previousCallId = this.sessionToCall.get(sessionId);
if (previousCallId && previousCallId !== callId) {
this.callToSession.delete(previousCallId);
}
const previousSessionId = this.callToSession.get(callId);
if (previousSessionId && previousSessionId !== sessionId) {
this.sessionToCall.delete(previousSessionId);
}
this.sessionToCall.set(sessionId, callId);
this.callToSession.set(callId, sessionId);
const pendingMedia = this.pendingCallMedia.get(callId) ?? null;
if (pendingMedia) {
this.pendingCallMedia.delete(callId);
}
return pendingMedia;
}
noteCallAnswered(callId: string, media: IProviderMediaInfo): IWebRtcLinkTarget | null {
const sessionId = this.callToSession.get(callId);
if (!sessionId) {
this.pendingCallMedia.set(callId, media);
return null;
}
return { sessionId, media };
}
removeSession(sessionId: string): string | null {
const callId = this.sessionToCall.get(sessionId) ?? null;
this.sessionToCall.delete(sessionId);
if (callId) {
this.callToSession.delete(callId);
}
return callId;
}
cleanupCall(callId: string): string | null {
const sessionId = this.callToSession.get(callId) ?? null;
this.callToSession.delete(callId);
this.pendingCallMedia.delete(callId);
if (sessionId) {
this.sessionToCall.delete(sessionId);
}
return sessionId;
}
}

148
ts/shared/proxy-events.ts Normal file
View File

@@ -0,0 +1,148 @@
import type { TLegType } from './status.ts';
export interface IIncomingCallEvent {
call_id: string;
from_uri: string;
to_number: string;
provider_id: string;
ring_browsers?: boolean;
}
export interface IOutboundCallEvent {
call_id: string;
from_device: string | null;
to_number: string;
}
export interface IOutboundCallStartedEvent {
call_id: string;
number: string;
provider_id: string;
}
export interface ICallRingingEvent {
call_id: string;
}
export interface ICallAnsweredEvent {
call_id: string;
provider_media_addr?: string;
provider_media_port?: number;
sip_pt?: number;
}
export interface ICallEndedEvent {
call_id: string;
reason: string;
duration: number;
from_side?: string;
}
export interface IProviderRegisteredEvent {
provider_id: string;
registered: boolean;
public_ip: string | null;
}
export interface IDeviceRegisteredEvent {
device_id: string;
display_name: string;
address: string;
port: number;
aor: string;
expires: number;
}
export interface ISipUnhandledEvent {
method_or_status: string;
call_id?: string;
from_addr: string;
from_port: number;
}
export interface ILegAddedEvent {
call_id: string;
leg_id: string;
kind: TLegType;
state: string;
codec?: string | null;
rtpPort?: number | null;
remoteMedia?: string | null;
metadata?: Record<string, unknown>;
}
export interface ILegRemovedEvent {
call_id: string;
leg_id: string;
}
export interface ILegStateChangedEvent {
call_id: string;
leg_id: string;
state: string;
metadata?: Record<string, unknown>;
}
export interface IWebRtcIceCandidateEvent {
session_id: string;
candidate: string;
sdp_mid?: string;
sdp_mline_index?: number;
}
export interface IWebRtcStateEvent {
session_id?: string;
state: string;
}
export interface IWebRtcTrackEvent {
session_id?: string;
kind: string;
codec: string;
}
export interface IWebRtcAudioRxEvent {
session_id?: string;
packet_count: number;
}
export interface IVoicemailStartedEvent {
call_id: string;
voicebox_id?: string;
caller_number?: string;
}
export interface IRecordingDoneEvent {
call_id?: string;
voicebox_id?: string;
file_path: string;
duration_ms: number;
caller_number?: string;
}
export interface IVoicemailErrorEvent {
call_id: string;
error: string;
}
export type TProxyEventMap = {
provider_registered: IProviderRegisteredEvent;
device_registered: IDeviceRegisteredEvent;
incoming_call: IIncomingCallEvent;
outbound_device_call: IOutboundCallEvent;
outbound_call_started: IOutboundCallStartedEvent;
call_ringing: ICallRingingEvent;
call_answered: ICallAnsweredEvent;
call_ended: ICallEndedEvent;
sip_unhandled: ISipUnhandledEvent;
leg_added: ILegAddedEvent;
leg_removed: ILegRemovedEvent;
leg_state_changed: ILegStateChangedEvent;
webrtc_ice_candidate: IWebRtcIceCandidateEvent;
webrtc_state: IWebRtcStateEvent;
webrtc_track: IWebRtcTrackEvent;
webrtc_audio_rx: IWebRtcAudioRxEvent;
voicemail_started: IVoicemailStartedEvent;
recording_done: IRecordingDoneEvent;
voicemail_error: IVoicemailErrorEvent;
};

89
ts/shared/status.ts Normal file
View File

@@ -0,0 +1,89 @@
import type { IContact } from '../config.ts';
export type TLegType = 'sip-device' | 'sip-provider' | 'webrtc' | 'tool';
export type TCallDirection = 'inbound' | 'outbound';
export interface IProviderStatus {
id: string;
displayName: string;
registered: boolean;
publicIp: string | null;
}
export interface IDeviceStatus {
id: string;
displayName: string;
address: string | null;
port: number;
aor: string | null;
connected: boolean;
isBrowser: boolean;
}
export interface IActiveLeg {
id: string;
type: TLegType;
state: string;
codec: string | null;
rtpPort: number | null;
remoteMedia: string | null;
metadata: Record<string, unknown>;
}
export interface IActiveCall {
id: string;
direction: TCallDirection;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
state: string;
startedAt: number;
legs: Map<string, IActiveLeg>;
}
export interface IHistoryLeg {
id: string;
type: string;
metadata: Record<string, unknown>;
}
export interface ICallHistoryEntry {
id: string;
direction: TCallDirection;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
startedAt: number;
duration: number;
legs: IHistoryLeg[];
}
export interface ILegStatus extends IActiveLeg {
pktSent: number;
pktReceived: number;
transcoding: boolean;
}
export interface ICallStatus {
id: string;
direction: TCallDirection;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
state: string;
startedAt: number;
duration: number;
legs: ILegStatus[];
}
export interface IStatusSnapshot {
instanceId: string;
uptime: number;
lanIp: string;
providers: IProviderStatus[];
devices: IDeviceStatus[];
calls: ICallStatus[];
callHistory: ICallHistoryEntry[];
contacts: IContact[];
voicemailCounts: Record<string, number>;
}

View File

@@ -1,36 +1,20 @@
/** /**
* SIP proxy — entry point. * SIP proxy bootstrap.
* *
* Spawns the Rust proxy-engine which handles ALL SIP protocol mechanics. * Spawns the Rust proxy-engine, wires runtime state/event handling,
* TypeScript is the control plane: * and starts the web dashboard plus browser signaling layer.
* - Loads config and pushes it to Rust
* - Receives high-level events (incoming calls, registration, etc.)
* - Drives the web dashboard
* - Manages IVR, voicemail, announcements
* - Handles WebRTC browser signaling (forwarded to Rust in Phase 2)
*
* No raw SIP ever touches TypeScript.
*/ */
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { loadConfig } from './config.ts'; import { loadConfig, type IAppConfig } from './config.ts';
import type { IAppConfig } from './config.ts';
import { broadcastWs, initWebUi } from './frontend.ts'; import { broadcastWs, initWebUi } from './frontend.ts';
import { import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts';
initWebRtcSignaling,
sendToBrowserDevice,
getAllBrowserDeviceIds,
getBrowserDeviceWs,
} from './webrtcbridge.ts';
import { initAnnouncement } from './announcement.ts';
import { PromptCache } from './call/prompt-cache.ts';
import { VoiceboxManager } from './voicebox.ts'; import { VoiceboxManager } from './voicebox.ts';
import { import {
initProxyEngine, initProxyEngine,
configureProxyEngine, configureProxyEngine,
onProxyEvent,
hangupCall, hangupCall,
makeCall, makeCall,
shutdownProxyEngine, shutdownProxyEngine,
@@ -38,640 +22,200 @@ import {
webrtcIce, webrtcIce,
webrtcLink, webrtcLink,
webrtcClose, webrtcClose,
addLeg,
removeLeg,
} from './proxybridge.ts'; } from './proxybridge.ts';
import type { import { registerProxyEventHandlers } from './runtime/proxy-events.ts';
IIncomingCallEvent, import { StatusStore } from './runtime/status-store.ts';
IOutboundCallEvent, import { WebRtcLinkManager, type IProviderMediaInfo } from './runtime/webrtc-linking.ts';
ICallEndedEvent,
IProviderRegisteredEvent,
IDeviceRegisteredEvent,
} from './proxybridge.ts';
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
let appConfig: IAppConfig = loadConfig(); let appConfig: IAppConfig = loadConfig();
const LOG_PATH = path.join(process.cwd(), 'sip_trace.log'); const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
const startTime = Date.now(); const startTime = Date.now();
const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const statusStore = new StatusStore(appConfig);
const webRtcLinks = new WebRtcLinkManager();
const voiceboxManager = new VoiceboxManager(log);
voiceboxManager.init(appConfig.voiceboxes ?? []);
initWebRtcSignaling({ log });
function now(): string { function now(): string {
return new Date().toISOString().replace('T', ' ').slice(0, 19); return new Date().toISOString().replace('T', ' ').slice(0, 19);
} }
function log(msg: string): void { function log(message: string): void {
const line = `${now()} ${msg}\n`; const line = `${now()} ${message}\n`;
fs.appendFileSync(LOG_PATH, line); fs.appendFileSync(LOG_PATH, line);
process.stdout.write(line); process.stdout.write(line);
broadcastWs('log', { message: msg }); broadcastWs('log', { message });
} }
// --------------------------------------------------------------------------- function errorMessage(error: unknown): string {
// Shadow state — maintained from Rust events for the dashboard return error instanceof Error ? error.message : String(error);
// ---------------------------------------------------------------------------
interface IProviderStatus {
id: string;
displayName: string;
registered: boolean;
publicIp: string | null;
}
interface IDeviceStatus {
id: string;
displayName: string;
address: string | null;
port: number;
connected: boolean;
isBrowser: boolean;
}
interface IActiveLeg {
id: string;
type: 'sip-device' | 'sip-provider' | 'webrtc' | 'tool';
state: string;
codec: string | null;
rtpPort: number | null;
remoteMedia: string | null;
metadata: Record<string, unknown>;
}
interface IActiveCall {
id: string;
direction: string;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
state: string;
startedAt: number;
legs: Map<string, IActiveLeg>;
}
interface IHistoryLeg {
id: string;
type: string;
metadata: Record<string, unknown>;
}
interface ICallHistoryEntry {
id: string;
direction: string;
callerNumber: string | null;
calleeNumber: string | null;
startedAt: number;
duration: number;
legs: IHistoryLeg[];
}
const providerStatuses = new Map<string, IProviderStatus>();
const deviceStatuses = new Map<string, IDeviceStatus>();
const activeCalls = new Map<string, IActiveCall>();
const callHistory: ICallHistoryEntry[] = [];
const MAX_HISTORY = 100;
// WebRTC session ↔ call linking state.
// Both pieces (session accept + call media info) can arrive in any order.
const webrtcSessionToCall = new Map<string, string>(); // sessionId → callId
const webrtcCallToSession = new Map<string, string>(); // callId → sessionId
const pendingCallMedia = new Map<string, { addr: string; port: number; sipPt: number }>(); // callId → provider media info
// Initialize provider statuses from config (all start as unregistered).
for (const p of appConfig.providers) {
providerStatuses.set(p.id, {
id: p.id,
displayName: p.displayName,
registered: false,
publicIp: null,
});
}
// Initialize device statuses from config.
for (const d of appConfig.devices) {
deviceStatuses.set(d.id, {
id: d.id,
displayName: d.displayName,
address: null,
port: 0,
connected: false,
isBrowser: false,
});
}
// ---------------------------------------------------------------------------
// Initialize subsystems
// ---------------------------------------------------------------------------
const promptCache = new PromptCache(log);
const voiceboxManager = new VoiceboxManager(log);
voiceboxManager.init(appConfig.voiceboxes ?? []);
// WebRTC signaling (browser device registration).
initWebRtcSignaling({ log });
// ---------------------------------------------------------------------------
// Status snapshot (fed to web dashboard)
// ---------------------------------------------------------------------------
function getStatus() {
// Merge SIP devices (from Rust) + browser devices (from TS WebSocket).
const devices = [...deviceStatuses.values()];
for (const bid of getAllBrowserDeviceIds()) {
devices.push({
id: bid,
displayName: 'Browser',
address: null,
port: 0,
connected: true,
isBrowser: true,
});
} }
function buildProxyConfig(config: IAppConfig): Record<string, unknown> {
return { return {
instanceId, proxy: config.proxy,
uptime: Math.floor((Date.now() - startTime) / 1000), providers: config.providers,
lanIp: appConfig.proxy.lanIp, devices: config.devices,
providers: [...providerStatuses.values()], routing: config.routing,
devices, voiceboxes: config.voiceboxes ?? [],
calls: [...activeCalls.values()].map((c) => ({ ivr: config.ivr,
...c,
duration: Math.floor((Date.now() - c.startedAt) / 1000),
legs: [...c.legs.values()].map((l) => ({
id: l.id,
type: l.type,
state: l.state,
codec: l.codec,
rtpPort: l.rtpPort,
remoteMedia: l.remoteMedia,
metadata: l.metadata || {},
pktSent: 0,
pktReceived: 0,
transcoding: false,
})),
})),
callHistory,
contacts: appConfig.contacts || [],
voicemailCounts: voiceboxManager.getAllUnheardCounts(),
}; };
} }
// --------------------------------------------------------------------------- function getStatus() {
// Start Rust proxy engine return statusStore.buildStatusSnapshot(
// --------------------------------------------------------------------------- instanceId,
startTime,
getAllBrowserDeviceIds(),
voiceboxManager.getAllUnheardCounts(),
);
}
function requestWebRtcLink(callId: string, sessionId: string, media: IProviderMediaInfo): void {
log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${callId} media=${media.addr}:${media.port} pt=${media.sipPt}`);
void webrtcLink(sessionId, callId, media.addr, media.port, media.sipPt).then((ok) => {
log(`[webrtc] link result: ${ok}`);
});
}
async function configureRuntime(config: IAppConfig): Promise<boolean> {
return configureProxyEngine(buildProxyConfig(config));
}
async function reloadConfig(): Promise<void> {
try {
const previousConfig = appConfig;
const nextConfig = loadConfig();
appConfig = nextConfig;
statusStore.updateConfig(nextConfig);
voiceboxManager.init(nextConfig.voiceboxes ?? []);
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
log('[config] proxy.lanPort changed; restart required for SIP socket rebinding');
}
if (nextConfig.proxy.webUiPort !== previousConfig.proxy.webUiPort) {
log('[config] proxy.webUiPort changed; restart required for web UI rebinding');
}
const configured = await configureRuntime(nextConfig);
if (configured) {
log('[config] reloaded - proxy engine reconfigured');
} else {
log('[config] reload failed - proxy engine rejected config');
}
} catch (error: unknown) {
log(`[config] reload failed: ${errorMessage(error)}`);
}
}
async function startProxyEngine(): Promise<void> { async function startProxyEngine(): Promise<void> {
const ok = await initProxyEngine(log); const started = await initProxyEngine(log);
if (!ok) { if (!started) {
log('[FATAL] failed to start proxy engine'); log('[FATAL] failed to start proxy engine');
process.exit(1); process.exit(1);
} }
// Subscribe to events from Rust BEFORE sending configure. registerProxyEventHandlers({
onProxyEvent('provider_registered', (data: IProviderRegisteredEvent) => { log,
const ps = providerStatuses.get(data.provider_id); statusStore,
if (ps) { voiceboxManager,
const wasRegistered = ps.registered; webRtcLinks,
ps.registered = data.registered; getBrowserDeviceIds: getAllBrowserDeviceIds,
ps.publicIp = data.public_ip; sendToBrowserDevice,
if (data.registered && !wasRegistered) { broadcast: broadcastWs,
log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`); onLinkWebRtcSession: requestWebRtcLink,
} else if (!data.registered && wasRegistered) { onCloseWebRtcSession: (sessionId) => {
log(`[provider:${data.provider_id}] registration lost`); void webrtcClose(sessionId);
} },
broadcastWs('registration', { providerId: data.provider_id, registered: data.registered });
}
});
onProxyEvent('device_registered', (data: IDeviceRegisteredEvent) => {
const ds = deviceStatuses.get(data.device_id);
if (ds) {
ds.address = data.address;
ds.port = data.port;
ds.connected = true;
log(`[registrar] ${data.display_name} registered from ${data.address}:${data.port}`);
}
});
onProxyEvent('incoming_call', (data: IIncomingCallEvent) => {
log(`[call] incoming: ${data.from_uri}${data.to_number} via ${data.provider_id} (${data.call_id})`);
activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'inbound',
callerNumber: data.from_uri,
calleeNumber: data.to_number,
providerUsed: data.provider_id,
state: 'ringing',
startedAt: Date.now(),
legs: new Map(),
});
// Notify browsers of incoming call.
const browserIds = getAllBrowserDeviceIds();
for (const bid of browserIds) {
sendToBrowserDevice(bid, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.from_uri,
deviceId: bid,
});
}
});
onProxyEvent('outbound_device_call', (data: IOutboundCallEvent) => {
log(`[call] outbound: device ${data.from_device}${data.to_number} (${data.call_id})`);
activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'outbound',
callerNumber: data.from_device,
calleeNumber: data.to_number,
providerUsed: null,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
});
onProxyEvent('outbound_call_started', (data: any) => {
log(`[call] outbound started: ${data.call_id}${data.number} via ${data.provider_id}`);
activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'outbound',
callerNumber: null,
calleeNumber: data.number,
providerUsed: data.provider_id,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
// Notify all browser devices — they can connect via WebRTC to listen/talk.
const browserIds = getAllBrowserDeviceIds();
for (const bid of browserIds) {
sendToBrowserDevice(bid, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.number,
deviceId: bid,
});
}
});
onProxyEvent('call_ringing', (data: { call_id: string }) => {
const call = activeCalls.get(data.call_id);
if (call) call.state = 'ringing';
});
onProxyEvent('call_answered', (data: { call_id: string; provider_media_addr?: string; provider_media_port?: number; sip_pt?: number }) => {
const call = activeCalls.get(data.call_id);
if (call) {
call.state = 'connected';
log(`[call] ${data.call_id} connected`);
// Enrich provider leg with media info from the answered event.
if (data.provider_media_addr && data.provider_media_port) {
for (const leg of call.legs.values()) {
if (leg.type === 'sip-provider') {
leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`;
if (data.sip_pt !== undefined) {
const codecNames: Record<number, string> = { 0: 'PCMU', 8: 'PCMA', 9: 'G.722', 111: 'Opus' };
leg.codec = codecNames[data.sip_pt] || `PT${data.sip_pt}`;
}
break;
}
}
}
}
// Try to link WebRTC session to this call for audio bridging.
if (data.provider_media_addr && data.provider_media_port) {
const sessionId = webrtcCallToSession.get(data.call_id);
if (sessionId) {
// Both session and media info available — link now.
const sipPt = data.sip_pt ?? 9;
log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${data.call_id} media=${data.provider_media_addr}:${data.provider_media_port} pt=${sipPt}`);
webrtcLink(sessionId, data.call_id, data.provider_media_addr, data.provider_media_port, sipPt).then((ok) => {
log(`[webrtc] link result: ${ok}`);
});
} else {
// Session not yet accepted — store media info for when it arrives.
pendingCallMedia.set(data.call_id, {
addr: data.provider_media_addr,
port: data.provider_media_port,
sipPt: data.sip_pt ?? 9,
});
log(`[webrtc] media info cached for call=${data.call_id}, waiting for session accept`);
}
}
});
onProxyEvent('call_ended', (data: ICallEndedEvent) => {
const call = activeCalls.get(data.call_id);
if (call) {
log(`[call] ${data.call_id} ended: ${data.reason} (${data.duration}s)`);
// Snapshot legs with metadata for history.
const historyLegs: IHistoryLeg[] = [];
for (const [, leg] of call.legs) {
historyLegs.push({
id: leg.id,
type: leg.type,
metadata: leg.metadata || {},
});
}
// Move to history.
callHistory.unshift({
id: call.id,
direction: call.direction,
callerNumber: call.callerNumber,
calleeNumber: call.calleeNumber,
startedAt: call.startedAt,
duration: data.duration,
legs: historyLegs,
});
if (callHistory.length > MAX_HISTORY) callHistory.pop();
activeCalls.delete(data.call_id);
// Notify browser(s) that the call ended.
broadcastWs('webrtc-call-ended', { callId: data.call_id });
// Clean up WebRTC session mappings.
const sessionId = webrtcCallToSession.get(data.call_id);
if (sessionId) {
webrtcCallToSession.delete(data.call_id);
webrtcSessionToCall.delete(sessionId);
webrtcClose(sessionId).catch(() => {});
}
pendingCallMedia.delete(data.call_id);
}
});
onProxyEvent('sip_unhandled', (data: any) => {
log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`);
});
// Leg events (multiparty) — update shadow state so the dashboard shows legs.
onProxyEvent('leg_added', (data: any) => {
log(`[leg] added: call=${data.call_id} leg=${data.leg_id} kind=${data.kind} state=${data.state}`);
const call = activeCalls.get(data.call_id);
if (call) {
call.legs.set(data.leg_id, {
id: data.leg_id,
type: data.kind,
state: data.state,
codec: data.codec ?? null,
rtpPort: data.rtpPort ?? null,
remoteMedia: data.remoteMedia ?? null,
metadata: data.metadata || {},
});
}
});
onProxyEvent('leg_removed', (data: any) => {
log(`[leg] removed: call=${data.call_id} leg=${data.leg_id}`);
activeCalls.get(data.call_id)?.legs.delete(data.leg_id);
});
onProxyEvent('leg_state_changed', (data: any) => {
log(`[leg] state: call=${data.call_id} leg=${data.leg_id}${data.state}`);
const call = activeCalls.get(data.call_id);
if (!call) return;
const leg = call.legs.get(data.leg_id);
if (leg) {
leg.state = data.state;
if (data.metadata) leg.metadata = data.metadata;
} else {
// Initial legs (provider/device) don't emit leg_added — create on first state change.
const legId: string = data.leg_id;
const type = legId.includes('-prov') ? 'sip-provider' : legId.includes('-dev') ? 'sip-device' : 'webrtc';
call.legs.set(data.leg_id, {
id: data.leg_id,
type,
state: data.state,
codec: null,
rtpPort: null,
remoteMedia: null,
metadata: data.metadata || {},
});
}
});
// WebRTC events from Rust — forward ICE candidates to browser via WebSocket.
onProxyEvent('webrtc_ice_candidate', (data: any) => {
// Find the browser's WebSocket by session ID and send the ICE candidate.
broadcastWs('webrtc-ice', {
sessionId: data.session_id,
candidate: { candidate: data.candidate, sdpMid: data.sdp_mid, sdpMLineIndex: data.sdp_mline_index },
});
});
onProxyEvent('webrtc_state', (data: any) => {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} state=${data.state}`);
});
onProxyEvent('webrtc_track', (data: any) => {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} track=${data.kind} codec=${data.codec}`);
});
onProxyEvent('webrtc_audio_rx', (data: any) => {
if (data.packet_count === 1 || data.packet_count === 50) {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} browser audio rx #${data.packet_count}`);
}
});
// Voicemail events.
onProxyEvent('voicemail_started', (data: any) => {
log(`[voicemail] started for call ${data.call_id} caller=${data.caller_number}`);
});
onProxyEvent('recording_done', (data: any) => {
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) caller=${data.caller_number}`);
// Save voicemail metadata via VoiceboxManager.
voiceboxManager.addMessage?.('default', {
callerNumber: data.caller_number || 'Unknown',
callerName: null,
fileName: data.file_path,
durationMs: data.duration_ms,
});
});
onProxyEvent('voicemail_error', (data: any) => {
log(`[voicemail] error: ${data.error} call=${data.call_id}`);
});
// Send full config to Rust — this binds the SIP socket and starts registrations.
const configured = await configureProxyEngine({
proxy: appConfig.proxy,
providers: appConfig.providers,
devices: appConfig.devices,
routing: appConfig.routing,
}); });
const configured = await configureRuntime(appConfig);
if (!configured) { if (!configured) {
log('[FATAL] failed to configure proxy engine'); log('[FATAL] failed to configure proxy engine');
process.exit(1); process.exit(1);
} }
const providerList = appConfig.providers.map((p) => p.displayName).join(', '); const providerList = appConfig.providers.map((provider) => provider.displayName).join(', ');
const deviceList = appConfig.devices.map((d) => d.displayName).join(', '); const deviceList = appConfig.devices.map((device) => device.displayName).join(', ');
log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`); log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`);
// Generate TTS audio (WAV files on disk, played by Rust audio_player).
try {
await initAnnouncement(log);
// Pre-generate prompts.
await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000);
for (const vb of appConfig.voiceboxes ?? []) {
if (!vb.enabled) continue;
const promptId = `voicemail-greeting-${vb.id}`;
if (vb.greetingWavPath) {
await promptCache.loadWavPrompt(promptId, vb.greetingWavPath);
} else {
const text = vb.greetingText || 'The person you are trying to reach is not available. Please leave a message after the tone.';
await promptCache.generatePrompt(promptId, text, vb.greetingVoice || 'af_bella');
}
}
if (appConfig.ivr?.enabled) {
for (const menu of appConfig.ivr.menus) {
await promptCache.generatePrompt(`ivr-menu-${menu.id}`, menu.promptText, menu.promptVoice || 'af_bella');
}
}
log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`);
} catch (e) {
log(`[tts] init failed: ${e}`);
}
} }
// --------------------------------------------------------------------------- initWebUi({
// Web UI port: appConfig.proxy.webUiPort,
// ---------------------------------------------------------------------------
initWebUi(
getStatus, getStatus,
log, log,
(number, deviceId, providerId) => { onStartCall: (number, deviceId, providerId) => {
// Outbound calls from dashboard — send make_call command to Rust.
log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`); log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`);
// Fire-and-forget — the async result comes via events. void makeCall(number, deviceId, providerId).then((callId) => {
makeCall(number, deviceId, providerId).then((callId) => {
if (callId) { if (callId) {
log(`[dashboard] call started: ${callId}`); log(`[dashboard] call started: ${callId}`);
activeCalls.set(callId, { statusStore.noteDashboardCallStarted(callId, number, providerId);
id: callId,
direction: 'outbound',
callerNumber: null,
calleeNumber: number,
providerUsed: providerId || null,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
} else { } else {
log(`[dashboard] call failed for ${number}`); log(`[dashboard] call failed for ${number}`);
} }
}); });
// Return a temporary ID so the frontend doesn't show "failed" immediately.
return { id: `pending-${Date.now()}` }; return { id: `pending-${Date.now()}` };
}, },
(callId) => { onHangupCall: (callId) => {
hangupCall(callId); void hangupCall(callId);
return true; return true;
}, },
() => { onConfigSaved: reloadConfig,
// Config saved — reconfigure Rust engine. voiceboxManager,
try { onWebRtcOffer: async (sessionId, sdp, ws) => {
const fresh = loadConfig();
Object.assign(appConfig, fresh);
// Update shadow state.
for (const p of fresh.providers) {
if (!providerStatuses.has(p.id)) {
providerStatuses.set(p.id, {
id: p.id, displayName: p.displayName, registered: false, publicIp: null,
});
}
}
for (const d of fresh.devices) {
if (!deviceStatuses.has(d.id)) {
deviceStatuses.set(d.id, {
id: d.id, displayName: d.displayName, address: null, port: 0, connected: false, isBrowser: false,
});
}
}
// Re-send config to Rust.
configureProxyEngine({
proxy: fresh.proxy,
providers: fresh.providers,
devices: fresh.devices,
routing: fresh.routing,
}).then((ok) => {
if (ok) log('[config] reloaded — proxy engine reconfigured');
else log('[config] reload failed — proxy engine rejected config');
});
} catch (e: any) {
log(`[config] reload failed: ${e.message}`);
}
},
undefined, // callManager — legacy, replaced by Rust proxy-engine
voiceboxManager, // voiceboxManager
// WebRTC signaling → forwarded to Rust proxy-engine.
async (sessionId, sdp, ws) => {
log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`); log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`);
if (!sdp || typeof sdp !== 'string' || sdp.length < 10) { if (!sdp || typeof sdp !== 'string' || sdp.length < 10) {
log(`[webrtc] WARNING: invalid SDP (type=${typeof sdp}), skipping offer`); log(`[webrtc] WARNING: invalid SDP (type=${typeof sdp}), skipping offer`);
return; return;
} }
log(`[webrtc] sending offer to Rust (${sdp.length}b)...`); log(`[webrtc] sending offer to Rust (${sdp.length}b)...`);
const result = await webrtcOffer(sessionId, sdp); const result = await webrtcOffer(sessionId, sdp);
log(`[webrtc] Rust result: ${JSON.stringify(result)?.slice(0, 200)}`); log(`[webrtc] Rust result: ${JSON.stringify(result)?.slice(0, 200)}`);
if (result?.sdp) { if (result?.sdp) {
ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp })); ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp }));
log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`); log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`);
} else { return;
log(`[webrtc] ERROR: no answer SDP from Rust`);
} }
log('[webrtc] ERROR: no answer SDP from Rust');
}, },
async (sessionId, candidate) => { onWebRtcIce: async (sessionId, candidate) => {
await webrtcIce(sessionId, candidate); await webrtcIce(sessionId, candidate);
}, },
async (sessionId) => { onWebRtcClose: async (sessionId) => {
webRtcLinks.removeSession(sessionId);
await webrtcClose(sessionId); await webrtcClose(sessionId);
}, },
// onWebRtcAccept — browser has accepted a call, linking session to call. onWebRtcAccept: (callId, sessionId) => {
(callId: string, sessionId: string) => {
log(`[webrtc] accept: callId=${callId} sessionId=${sessionId.slice(0, 8)}`); log(`[webrtc] accept: callId=${callId} sessionId=${sessionId.slice(0, 8)}`);
// Store bidirectional mapping. const pendingMedia = webRtcLinks.acceptCall(callId, sessionId);
webrtcSessionToCall.set(sessionId, callId); if (pendingMedia) {
webrtcCallToSession.set(callId, sessionId); requestWebRtcLink(callId, sessionId, pendingMedia);
return;
// Check if we already have media info for this call (provider answered first).
const media = pendingCallMedia.get(callId);
if (media) {
pendingCallMedia.delete(callId);
log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${callId} media=${media.addr}:${media.port} pt=${media.sipPt}`);
webrtcLink(sessionId, callId, media.addr, media.port, media.sipPt).then((ok) => {
log(`[webrtc] link result: ${ok}`);
});
} else {
log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`);
} }
log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`);
}, },
); });
// --------------------------------------------------------------------------- void startProxyEngine();
// Start
// ---------------------------------------------------------------------------
startProxyEngine(); process.on('SIGINT', () => {
log('SIGINT, exiting');
shutdownProxyEngine();
process.exit(0);
});
process.on('SIGINT', () => { log('SIGINT, exiting'); shutdownProxyEngine(); process.exit(0); }); process.on('SIGTERM', () => {
process.on('SIGTERM', () => { log('SIGTERM, exiting'); shutdownProxyEngine(); process.exit(0); }); log('SIGTERM, exiting');
shutdownProxyEngine();
process.exit(0);
});

View File

@@ -29,12 +29,14 @@ export interface IVoiceboxConfig {
greetingVoice?: string; greetingVoice?: string;
/** Path to uploaded WAV greeting (overrides TTS). */ /** Path to uploaded WAV greeting (overrides TTS). */
greetingWavPath?: string; greetingWavPath?: string;
/** Seconds to wait before routing to voicemail (default 25). */ /** Seconds to wait before routing to voicemail. Defaults to 25 when
noAnswerTimeoutSec: number; * absent — both the config loader and `VoiceboxManager.init` apply
/** Maximum recording duration in seconds (default 120). */ * the default via `??=`. */
maxRecordingSec: number; noAnswerTimeoutSec?: number;
/** Maximum stored messages per box (default 50). */ /** Maximum recording duration in seconds. Defaults to 120. */
maxMessages: number; maxRecordingSec?: number;
/** Maximum stored messages per box. Defaults to 50. */
maxMessages?: number;
} }
export interface IVoicemailMessage { export interface IVoicemailMessage {
@@ -148,6 +150,35 @@ export class VoiceboxManager {
// Message CRUD // Message CRUD
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/**
* Convenience wrapper around `saveMessage` — used by the `recording_done`
* event handler, which has a raw recording path + caller info and needs
* to persist metadata. Generates `id`, sets `timestamp = now`, defaults
* `heard = false`, and normalizes `fileName` to a basename (the WAV is
* expected to already live in the box's directory).
*/
addMessage(
boxId: string,
info: {
callerNumber: string;
callerName?: string | null;
fileName: string;
durationMs: number;
},
): void {
const msg: IVoicemailMessage = {
id: crypto.randomUUID(),
boxId,
callerNumber: info.callerNumber,
callerName: info.callerName ?? undefined,
timestamp: Date.now(),
durationMs: info.durationMs,
fileName: path.basename(info.fileName),
heard: false,
};
this.saveMessage(msg);
}
/** /**
* Save a new voicemail message. * Save a new voicemail message.
* The WAV file should already exist at the expected path. * The WAV file should already exist at the expected path.

View File

@@ -5,8 +5,8 @@
* - Browser device registration/unregistration via WebSocket * - Browser device registration/unregistration via WebSocket
* - WS → deviceId mapping * - WS → deviceId mapping
* *
* All WebRTC media logic (PeerConnection, RTP, transcoding) lives in * All WebRTC media logic (PeerConnection, RTP, transcoding, mixer wiring)
* ts/call/webrtc-leg.ts and is managed by the CallManager. * lives in the Rust proxy-engine. This module only tracks browser sessions.
*/ */
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
@@ -39,7 +39,7 @@ export function initWebRtcSignaling(cfg: IWebRtcSignalingConfig): void {
/** /**
* Handle a WebRTC signaling message from a browser client. * Handle a WebRTC signaling message from a browser client.
* Only handles registration; offer/ice/hangup are routed through CallManager. * Only handles registration; offer/ice/hangup are routed through frontend.ts.
*/ */
export function handleWebRtcSignaling( export function handleWebRtcSignaling(
ws: WebSocket, ws: WebSocket,
@@ -51,7 +51,7 @@ export function handleWebRtcSignaling(
handleRegister(ws, message.sessionId!, message.userAgent, message._remoteIp); handleRegister(ws, message.sessionId!, message.userAgent, message._remoteIp);
} }
// Other webrtc-* types (offer, ice, hangup, accept) are handled // Other webrtc-* types (offer, ice, hangup, accept) are handled
// by the CallManager via frontend.ts WebSocket handler. // by the frontend.ts WebSocket handler and forwarded to Rust.
} }
/** /**
@@ -64,13 +64,6 @@ export function sendToBrowserDevice(deviceId: string, data: unknown): boolean {
return true; return true;
} }
/**
* Get the WebSocket for a browser device (used by CallManager to create WebRtcLegs).
*/
export function getBrowserDeviceWs(deviceId: string): WebSocket | null {
return deviceIdToWs.get(deviceId) ?? null;
}
/** /**
* Get all registered browser device IDs. * Get all registered browser device IDs.
*/ */

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: 'siprouter', name: 'siprouter',
version: '1.18.0', version: '1.25.1',
description: 'undefined' description: 'undefined'
} }

View File

@@ -18,7 +18,7 @@ const VIEW_TABS = [
{ name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone }, { name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
{ name: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes }, { name: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes },
{ name: 'Voicemail', iconName: 'lucide:voicemail', element: SipproxyViewVoicemail }, { name: 'Voicemail', iconName: 'lucide:voicemail', element: SipproxyViewVoicemail },
{ name: 'IVR', iconName: 'lucide:list-tree', element: SipproxyViewIvr }, { name: 'IVR', iconName: 'lucide:ListTree', element: SipproxyViewIvr },
{ name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts }, { name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
{ name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders }, { name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog }, { name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },

View File

@@ -41,11 +41,10 @@ export class SipproxyDevices extends DeesElement {
}, },
}, },
{ {
key: 'contact', key: 'address',
header: 'Contact', header: 'Contact',
renderer: (_val: any, row: any) => { renderer: (_val: any, row: any) => {
const c = row.contact; const text = row.address ? (row.port ? `${row.address}:${row.port}` : row.address) : '--';
const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--';
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`; return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`;
}, },
}, },

View File

@@ -422,7 +422,7 @@ export class SipproxyViewCalls extends DeesElement {
menuOptions: [ menuOptions: [
{ {
name: 'Transfer', name: 'Transfer',
iconName: 'lucide:arrow-right-left', iconName: 'lucide:ArrowRightLeft',
action: async (modalRef: any) => { action: async (modalRef: any) => {
if (!targetCallId || !targetLegId) { if (!targetCallId || !targetLegId) {
deesCatalog.DeesToast.error('Please select both a target call and a leg'); deesCatalog.DeesToast.error('Please select both a target call and a leg');
@@ -620,7 +620,7 @@ export class SipproxyViewCalls extends DeesElement {
title: 'Inbound', title: 'Inbound',
value: inboundCount, value: inboundCount,
type: 'number', type: 'number',
icon: 'lucide:phone-incoming', icon: 'lucide:PhoneIncoming',
description: 'Incoming calls', description: 'Incoming calls',
}, },
{ {
@@ -628,7 +628,7 @@ export class SipproxyViewCalls extends DeesElement {
title: 'Outbound', title: 'Outbound',
value: outboundCount, value: outboundCount,
type: 'number', type: 'number',
icon: 'lucide:phone-outgoing', icon: 'lucide:PhoneOutgoing',
description: 'Outgoing calls', description: 'Outgoing calls',
}, },
]; ];

View File

@@ -140,7 +140,7 @@ export class SipproxyViewIvr extends DeesElement {
title: 'Total Menus', title: 'Total Menus',
value: ivr.menus.length, value: ivr.menus.length,
type: 'number', type: 'number',
icon: 'lucide:list-tree', icon: 'lucide:ListTree',
description: 'IVR menu definitions', description: 'IVR menu definitions',
}, },
{ {
@@ -148,7 +148,7 @@ export class SipproxyViewIvr extends DeesElement {
title: 'Entry Menu', title: 'Entry Menu',
value: entryMenu?.name || '(none)', value: entryMenu?.name || '(none)',
type: 'text' as any, type: 'text' as any,
icon: 'lucide:door-open', icon: 'lucide:DoorOpen',
description: entryMenu ? `ID: ${entryMenu.id}` : 'No entry menu set', description: entryMenu ? `ID: ${entryMenu.id}` : 'No entry menu set',
}, },
{ {
@@ -156,7 +156,7 @@ export class SipproxyViewIvr extends DeesElement {
title: 'Status', title: 'Status',
value: ivr.enabled ? 'Enabled' : 'Disabled', value: ivr.enabled ? 'Enabled' : 'Disabled',
type: 'text' as any, type: 'text' as any,
icon: ivr.enabled ? 'lucide:check-circle' : 'lucide:x-circle', icon: ivr.enabled ? 'lucide:CheckCircle' : 'lucide:XCircle',
color: ivr.enabled ? 'hsl(142.1 76.2% 36.3%)' : 'hsl(0 84.2% 60.2%)', color: ivr.enabled ? 'hsl(142.1 76.2% 36.3%)' : 'hsl(0 84.2% 60.2%)',
description: ivr.enabled ? 'IVR is active' : 'IVR is inactive', description: ivr.enabled ? 'IVR is active' : 'IVR is inactive',
}, },
@@ -228,7 +228,7 @@ export class SipproxyViewIvr extends DeesElement {
}, },
{ {
name: 'Set as Entry', name: 'Set as Entry',
iconName: 'lucide:door-open' as any, iconName: 'lucide:DoorOpen' as any,
type: ['inRow'] as any, type: ['inRow'] as any,
actionFunc: async ({ item }: { item: IIvrMenu }) => { actionFunc: async ({ item }: { item: IIvrMenu }) => {
await this.setEntryMenu(item.id); await this.setEntryMenu(item.id);
@@ -236,7 +236,7 @@ export class SipproxyViewIvr extends DeesElement {
}, },
{ {
name: 'Delete', name: 'Delete',
iconName: 'lucide:trash-2' as any, iconName: 'lucide:Trash2' as any,
type: ['inRow'] as any, type: ['inRow'] as any,
actionFunc: async ({ item }: { item: IIvrMenu }) => { actionFunc: async ({ item }: { item: IIvrMenu }) => {
await this.confirmDeleteMenu(item); await this.confirmDeleteMenu(item);
@@ -295,7 +295,7 @@ export class SipproxyViewIvr extends DeesElement {
}, },
{ {
name: 'Delete', name: 'Delete',
iconName: 'lucide:trash-2', iconName: 'lucide:Trash2',
action: async (modalRef: any) => { action: async (modalRef: any) => {
const ivr = this.getIvrConfig(); const ivr = this.getIvrConfig();
const menus = ivr.menus.filter((m) => m.id !== menu.id); const menus = ivr.menus.filter((m) => m.id !== menu.id);

View File

@@ -107,7 +107,7 @@ export class SipproxyViewOverview extends DeesElement {
title: 'Inbound Calls', title: 'Inbound Calls',
value: inboundCalls, value: inboundCalls,
type: 'number', type: 'number',
icon: 'lucide:phone-incoming', icon: 'lucide:PhoneIncoming',
description: 'Currently active', description: 'Currently active',
}, },
{ {
@@ -115,7 +115,7 @@ export class SipproxyViewOverview extends DeesElement {
title: 'Outbound Calls', title: 'Outbound Calls',
value: outboundCalls, value: outboundCalls,
type: 'number', type: 'number',
icon: 'lucide:phone-outgoing', icon: 'lucide:PhoneOutgoing',
description: 'Currently active', description: 'Currently active',
}, },
{ {
@@ -186,11 +186,10 @@ export class SipproxyViewOverview extends DeesElement {
}, },
}, },
{ {
key: 'contact', key: 'address',
header: 'Contact', header: 'Contact',
renderer: (_val: any, row: any) => { renderer: (_val: any, row: any) => {
const c = row.contact; const text = row.address ? (row.port ? `${row.address}:${row.port}` : row.address) : '--';
const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--';
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`; return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`;
}, },
}, },

View File

@@ -86,7 +86,7 @@ export class SipproxyViewProviders extends DeesElement {
title: 'Registered', title: 'Registered',
value: registered, value: registered,
type: 'number', type: 'number',
icon: 'lucide:check-circle', icon: 'lucide:CheckCircle',
color: 'hsl(142.1 76.2% 36.3%)', color: 'hsl(142.1 76.2% 36.3%)',
description: 'Active registrations', description: 'Active registrations',
}, },
@@ -95,7 +95,7 @@ export class SipproxyViewProviders extends DeesElement {
title: 'Unregistered', title: 'Unregistered',
value: unregistered, value: unregistered,
type: 'number', type: 'number',
icon: 'lucide:alert-circle', icon: 'lucide:AlertCircle',
color: unregistered > 0 ? 'hsl(0 84.2% 60.2%)' : undefined, color: unregistered > 0 ? 'hsl(0 84.2% 60.2%)' : undefined,
description: unregistered > 0 ? 'Needs attention' : 'All healthy', description: unregistered > 0 ? 'Needs attention' : 'All healthy',
}, },
@@ -153,7 +153,7 @@ export class SipproxyViewProviders extends DeesElement {
}, },
{ {
name: 'Delete', name: 'Delete',
iconName: 'lucide:trash-2', iconName: 'lucide:Trash2',
type: ['inRow'] as any, type: ['inRow'] as any,
actionFunc: async (actionData: any) => { actionFunc: async (actionData: any) => {
await this.confirmDelete(actionData.item); await this.confirmDelete(actionData.item);
@@ -164,162 +164,269 @@ export class SipproxyViewProviders extends DeesElement {
iconName: 'lucide:plus', iconName: 'lucide:plus',
type: ['header'] as any, type: ['header'] as any,
actionFunc: async () => { actionFunc: async () => {
await this.openAddModal(); await this.openAddStepper();
},
},
{
name: 'Add Sipgate',
iconName: 'lucide:phone',
type: ['header'] as any,
actionFunc: async () => {
await this.openAddModal(PROVIDER_TEMPLATES.sipgate, 'Sipgate');
},
},
{
name: 'Add O2/Alice',
iconName: 'lucide:phone',
type: ['header'] as any,
actionFunc: async () => {
await this.openAddModal(PROVIDER_TEMPLATES.o2, 'O2/Alice');
}, },
}, },
]; ];
} }
// ---- add provider modal -------------------------------------------------- // ---- add provider stepper ------------------------------------------------
private async openAddModal( private async openAddStepper() {
template?: typeof PROVIDER_TEMPLATES.sipgate, const { DeesStepper } = await import('@design.estate/dees-catalog');
templateName?: string, type TDeesStepper = InstanceType<typeof DeesStepper>;
) { // IStep / menuOptions types: we keep content typing loose (`any[]`) to
const { DeesModal } = await import('@design.estate/dees-catalog'); // avoid having to import tsclass IMenuItem just for one parameter annotation.
const formData = { type TProviderType = 'Custom' | 'Sipgate' | 'O2/Alice';
displayName: templateName || '', interface IAccumulator {
domain: template?.domain || '', providerType: TProviderType;
outboundProxyAddress: template?.outboundProxy?.address || '', displayName: string;
outboundProxyPort: String(template?.outboundProxy?.port ?? 5060), domain: string;
outboundProxyAddress: string;
outboundProxyPort: string;
username: string;
password: string;
// Advanced — exposed in step 4
registerIntervalSec: string;
codecs: string;
earlyMediaSilence: boolean;
}
const accumulator: IAccumulator = {
providerType: 'Custom',
displayName: '',
domain: '',
outboundProxyAddress: '',
outboundProxyPort: '5060',
username: '', username: '',
password: '', password: '',
registerIntervalSec: String(template?.registerIntervalSec ?? 300), registerIntervalSec: '300',
codecs: template?.codecs ? template.codecs.join(', ') : '9, 0, 8, 101', codecs: '9, 0, 8, 101',
earlyMediaSilence: template?.quirks?.earlyMediaSilence ?? false, earlyMediaSilence: false,
}; };
const heading = template // Snapshot the currently-selected step's form (if any) into accumulator.
? `Add ${templateName} Provider` const snapshotActiveForm = async (stepper: TDeesStepper) => {
: 'Add Provider'; const form = stepper.activeForm;
if (!form) return;
const data: Record<string, any> = await form.collectFormData();
Object.assign(accumulator, data);
};
await DeesModal.createAndShow({ // Overwrite template-owned fields. Keep user-owned fields (username,
heading, // password) untouched. displayName is replaced only when empty or still
width: 'small', // holds a branded auto-fill.
showCloseButton: true, const applyTemplate = (type: TProviderType) => {
const tpl =
type === 'Sipgate' ? PROVIDER_TEMPLATES.sipgate
: type === 'O2/Alice' ? PROVIDER_TEMPLATES.o2
: null;
if (!tpl) return;
accumulator.domain = tpl.domain;
accumulator.outboundProxyAddress = tpl.outboundProxy.address;
accumulator.outboundProxyPort = String(tpl.outboundProxy.port);
accumulator.registerIntervalSec = String(tpl.registerIntervalSec);
accumulator.codecs = tpl.codecs.join(', ');
accumulator.earlyMediaSilence = tpl.quirks.earlyMediaSilence;
if (
!accumulator.displayName ||
accumulator.displayName === 'Sipgate' ||
accumulator.displayName === 'O2/Alice'
) {
accumulator.displayName = type;
}
};
// --- Step builders (called after step 1 so accumulator is populated) ---
const buildConnectionStep = (): any => ({
title: 'Connection',
content: html` content: html`
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;"> <dees-form>
<dees-input-text <dees-input-text
.key=${'displayName'} .key=${'displayName'}
.label=${'Display Name'} .label=${'Display Name'}
.value=${formData.displayName} .value=${accumulator.displayName}
@input=${(e: Event) => { formData.displayName = (e.target as any).value; }} .required=${true}
></dees-input-text> ></dees-input-text>
<dees-input-text <dees-input-text
.key=${'domain'} .key=${'domain'}
.label=${'Domain'} .label=${'Domain'}
.value=${formData.domain} .value=${accumulator.domain}
@input=${(e: Event) => { formData.domain = (e.target as any).value; }} .required=${true}
></dees-input-text> ></dees-input-text>
<dees-input-text <dees-input-text
.key=${'outboundProxyAddress'} .key=${'outboundProxyAddress'}
.label=${'Outbound Proxy Address'} .label=${'Outbound Proxy Address'}
.value=${formData.outboundProxyAddress} .value=${accumulator.outboundProxyAddress}
@input=${(e: Event) => { formData.outboundProxyAddress = (e.target as any).value; }}
></dees-input-text> ></dees-input-text>
<dees-input-text <dees-input-text
.key=${'outboundProxyPort'} .key=${'outboundProxyPort'}
.label=${'Outbound Proxy Port'} .label=${'Outbound Proxy Port'}
.value=${formData.outboundProxyPort} .value=${accumulator.outboundProxyPort}
@input=${(e: Event) => { formData.outboundProxyPort = (e.target as any).value; }}
></dees-input-text> ></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Continue',
iconName: 'lucide:arrow-right',
action: async (stepper: TDeesStepper) => {
await snapshotActiveForm(stepper);
stepper.goNext();
},
},
],
});
const buildCredentialsStep = (): any => ({
title: 'Credentials',
content: html`
<dees-form>
<dees-input-text <dees-input-text
.key=${'username'} .key=${'username'}
.label=${'Username / Auth ID'} .label=${'Username / Auth ID'}
.value=${formData.username} .value=${accumulator.username}
@input=${(e: Event) => { formData.username = (e.target as any).value; }} .required=${true}
></dees-input-text> ></dees-input-text>
<dees-input-text <dees-input-text
.key=${'password'} .key=${'password'}
.label=${'Password'} .label=${'Password'}
.isPasswordBool=${true} .isPasswordBool=${true}
.value=${formData.password} .value=${accumulator.password}
@input=${(e: Event) => { formData.password = (e.target as any).value; }} .required=${true}
></dees-input-text> ></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Continue',
iconName: 'lucide:arrow-right',
action: async (stepper: TDeesStepper) => {
await snapshotActiveForm(stepper);
stepper.goNext();
},
},
],
});
const buildAdvancedStep = (): any => ({
title: 'Advanced',
content: html`
<dees-form>
<dees-input-text <dees-input-text
.key=${'registerIntervalSec'} .key=${'registerIntervalSec'}
.label=${'Register Interval (sec)'} .label=${'Register Interval (sec)'}
.value=${formData.registerIntervalSec} .value=${accumulator.registerIntervalSec}
@input=${(e: Event) => { formData.registerIntervalSec = (e.target as any).value; }}
></dees-input-text> ></dees-input-text>
<dees-input-text <dees-input-text
.key=${'codecs'} .key=${'codecs'}
.label=${'Codecs (comma-separated payload types)'} .label=${'Codecs (comma-separated payload types)'}
.value=${formData.codecs} .value=${accumulator.codecs}
@input=${(e: Event) => { formData.codecs = (e.target as any).value; }}
></dees-input-text> ></dees-input-text>
<dees-input-checkbox <dees-input-checkbox
.key=${'earlyMediaSilence'} .key=${'earlyMediaSilence'}
.label=${'Early Media Silence (quirk)'} .label=${'Early Media Silence (quirk)'}
.value=${formData.earlyMediaSilence} .value=${accumulator.earlyMediaSilence}
@newValue=${(e: CustomEvent) => { formData.earlyMediaSilence = e.detail; }}
></dees-input-checkbox> ></dees-input-checkbox>
</div> </dees-form>
`, `,
menuOptions: [ menuOptions: [
{ {
name: 'Cancel', name: 'Continue',
iconName: 'lucide:x', iconName: 'lucide:arrow-right',
action: async (modalRef: any) => { action: async (stepper: TDeesStepper) => {
modalRef.destroy(); await snapshotActiveForm(stepper);
// Rebuild the review step so its rendering reflects the latest
// accumulator values (the review step captures values at build time).
stepper.steps = [...stepper.steps.slice(0, 4), buildReviewStep()];
await (stepper as any).updateComplete;
stepper.goNext();
}, },
}, },
],
});
const buildReviewStep = (): any => {
const resolvedId = slugify(accumulator.displayName);
return {
title: 'Review & Create',
content: html`
<dees-panel>
<div
style="display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:.85rem;padding:8px 4px;"
>
<div style="color:#94a3b8;">Type</div>
<div>${accumulator.providerType}</div>
<div style="color:#94a3b8;">Display Name</div>
<div>${accumulator.displayName}</div>
<div style="color:#94a3b8;">ID</div>
<div style="font-family:'JetBrains Mono',monospace;">${resolvedId}</div>
<div style="color:#94a3b8;">Domain</div>
<div>${accumulator.domain}</div>
<div style="color:#94a3b8;">Outbound Proxy</div>
<div>
${accumulator.outboundProxyAddress || accumulator.domain}:${accumulator.outboundProxyPort}
</div>
<div style="color:#94a3b8;">Username</div>
<div>${accumulator.username}</div>
<div style="color:#94a3b8;">Password</div>
<div>${'*'.repeat(Math.min(accumulator.password.length, 12))}</div>
<div style="color:#94a3b8;">Register Interval</div>
<div>${accumulator.registerIntervalSec}s</div>
<div style="color:#94a3b8;">Codecs</div>
<div>${accumulator.codecs}</div>
<div style="color:#94a3b8;">Early-Media Silence</div>
<div>${accumulator.earlyMediaSilence ? 'yes' : 'no'}</div>
</div>
</dees-panel>
`,
menuOptions: [
{ {
name: 'Create', name: 'Create Provider',
iconName: 'lucide:check', iconName: 'lucide:check',
action: async (modalRef: any) => { action: async (stepper: TDeesStepper) => {
if (!formData.displayName.trim() || !formData.domain.trim()) { // Collision-resolve id against current state.
deesCatalog.DeesToast.error('Display name and domain are required'); const existing = (this.appData.providers || []).map((p) => p.id);
return; let uniqueId = resolvedId;
let suffix = 2;
while (existing.includes(uniqueId)) {
uniqueId = `${resolvedId}-${suffix++}`;
} }
try {
const providerId = slugify(formData.displayName); const parsedCodecs = accumulator.codecs
const codecs = formData.codecs
.split(',') .split(',')
.map((s: string) => parseInt(s.trim(), 10)) .map((s: string) => parseInt(s.trim(), 10))
.filter((n: number) => !isNaN(n)); .filter((n: number) => !isNaN(n));
const newProvider: any = { const newProvider: any = {
id: providerId, id: uniqueId,
displayName: formData.displayName.trim(), displayName: accumulator.displayName.trim(),
domain: formData.domain.trim(), domain: accumulator.domain.trim(),
outboundProxy: { outboundProxy: {
address: formData.outboundProxyAddress.trim() || formData.domain.trim(), address:
port: parseInt(formData.outboundProxyPort, 10) || 5060, accumulator.outboundProxyAddress.trim() || accumulator.domain.trim(),
port: parseInt(accumulator.outboundProxyPort, 10) || 5060,
}, },
username: formData.username.trim(), username: accumulator.username.trim(),
password: formData.password, password: accumulator.password,
registerIntervalSec: parseInt(formData.registerIntervalSec, 10) || 300, registerIntervalSec: parseInt(accumulator.registerIntervalSec, 10) || 300,
codecs, codecs: parsedCodecs.length ? parsedCodecs : [9, 0, 8, 101],
quirks: { quirks: {
earlyMediaSilence: formData.earlyMediaSilence, earlyMediaSilence: accumulator.earlyMediaSilence,
}, },
}; };
try {
const result = await appState.apiSaveConfig({ const result = await appState.apiSaveConfig({
addProvider: newProvider, addProvider: newProvider,
}); });
if (result.ok) { if (result.ok) {
modalRef.destroy(); await stepper.destroy();
deesCatalog.DeesToast.success(`Provider "${formData.displayName}" created`); deesCatalog.DeesToast.success(
`Provider "${newProvider.displayName}" created`,
);
} else { } else {
deesCatalog.DeesToast.error('Failed to save provider'); deesCatalog.DeesToast.error('Failed to save provider');
} }
@@ -330,7 +437,73 @@ export class SipproxyViewProviders extends DeesElement {
}, },
}, },
], ],
}); };
};
// --- Step 1: Provider Type ------------------------------------------------
//
// Note: `DeesStepper.createAndShow()` dismisses on backdrop click; a user
// mid-form could lose work. Acceptable for v1 — revisit if users complain.
const typeOptions: { option: string; key: TProviderType }[] = [
{ option: 'Custom', key: 'Custom' },
{ option: 'Sipgate', key: 'Sipgate' },
{ option: 'O2 / Alice', key: 'O2/Alice' },
];
const currentTypeOption =
typeOptions.find((o) => o.key === accumulator.providerType) || null;
const typeStep: any = {
title: 'Choose provider type',
content: html`
<dees-form>
<dees-input-dropdown
.key=${'providerType'}
.label=${'Provider Type'}
.options=${typeOptions}
.selectedOption=${currentTypeOption}
.enableSearch=${false}
.required=${true}
></dees-input-dropdown>
</dees-form>
`,
menuOptions: [
{
name: 'Continue',
iconName: 'lucide:arrow-right',
action: async (stepper: TDeesStepper) => {
// `dees-input-dropdown.value` is an object `{option, key, payload?}`,
// not a plain string — extract the `key` directly instead of using
// the generic `snapshotActiveForm` helper (which would clobber
// `accumulator.providerType`'s string type via Object.assign).
const form = stepper.activeForm;
if (form) {
const data: Record<string, any> = await form.collectFormData();
const selected = data.providerType;
if (selected && typeof selected === 'object' && selected.key) {
accumulator.providerType = selected.key as TProviderType;
}
}
if (!accumulator.providerType) {
accumulator.providerType = 'Custom';
}
applyTemplate(accumulator.providerType);
// (Re)build steps 2-5 with current accumulator values.
stepper.steps = [
typeStep,
buildConnectionStep(),
buildCredentialsStep(),
buildAdvancedStep(),
buildReviewStep(),
];
await (stepper as any).updateComplete;
stepper.goNext();
},
},
],
};
await DeesStepper.createAndShow({ steps: [typeStep] });
} }
// ---- edit provider modal ------------------------------------------------- // ---- edit provider modal -------------------------------------------------
@@ -579,7 +752,7 @@ export class SipproxyViewProviders extends DeesElement {
}, },
{ {
name: 'Delete', name: 'Delete',
iconName: 'lucide:trash-2', iconName: 'lucide:Trash2',
action: async (modalRef: any) => { action: async (modalRef: any) => {
try { try {
const result = await appState.apiSaveConfig({ const result = await appState.apiSaveConfig({

File diff suppressed because it is too large Load Diff

View File

@@ -239,7 +239,7 @@ export class SipproxyViewVoicemail extends DeesElement {
}, },
{ {
name: 'Delete', name: 'Delete',
iconName: 'lucide:trash-2', iconName: 'lucide:Trash2',
action: async (modalRef: any) => { action: async (modalRef: any) => {
try { try {
await fetch( await fetch(
@@ -281,7 +281,7 @@ export class SipproxyViewVoicemail extends DeesElement {
title: 'Unheard Messages', title: 'Unheard Messages',
value: unheard, value: unheard,
type: 'number', type: 'number',
icon: 'lucide:bell-ring', icon: 'lucide:BellRing',
color: unheard > 0 ? 'hsl(0 84.2% 60.2%)' : 'hsl(142.1 76.2% 36.3%)', color: unheard > 0 ? 'hsl(0 84.2% 60.2%)' : 'hsl(142.1 76.2% 36.3%)',
description: unheard > 0 ? 'Needs attention' : 'All caught up', description: unheard > 0 ? 'Needs attention' : 'All caught up',
}, },
@@ -372,7 +372,7 @@ export class SipproxyViewVoicemail extends DeesElement {
}, },
{ {
name: 'Delete', name: 'Delete',
iconName: 'lucide:trash-2', iconName: 'lucide:Trash2',
type: ['inRow'] as any, type: ['inRow'] as any,
actionFunc: async (actionData: any) => { actionFunc: async (actionData: any) => {
await this.deleteMessage(actionData.item as IVoicemailMessage); await this.deleteMessage(actionData.item as IVoicemailMessage);

View File

@@ -2,72 +2,12 @@
* Application state — receives live updates from the proxy via WebSocket. * Application state — receives live updates from the proxy via WebSocket.
*/ */
export interface IProviderStatus { import type { IContact } from '../../ts/config.ts';
id: string; import type { ICallHistoryEntry, ICallStatus, IDeviceStatus, IProviderStatus } from '../../ts/shared/status.ts';
displayName: string;
registered: boolean;
publicIp: string | null;
}
export interface IDeviceStatus { export type { IContact };
id: string; export type { ICallHistoryEntry, ICallStatus, IDeviceStatus, IProviderStatus };
displayName: string; export type { ILegStatus } from '../../ts/shared/status.ts';
contact: { address: string; port: number } | null;
aor: string;
connected: boolean;
isBrowser: boolean;
}
export interface ILegStatus {
id: string;
type: 'sip-device' | 'sip-provider' | 'webrtc' | 'tool';
state: string;
remoteMedia: { address: string; port: number } | null;
rtpPort: number | null;
pktSent: number;
pktReceived: number;
codec: string | null;
transcoding: boolean;
metadata?: Record<string, unknown>;
}
export interface ICallStatus {
id: string;
state: string;
direction: 'inbound' | 'outbound' | 'internal';
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
createdAt: number;
duration: number;
legs: ILegStatus[];
}
export interface IHistoryLeg {
id: string;
type: string;
metadata: Record<string, unknown>;
}
export interface ICallHistoryEntry {
id: string;
direction: 'inbound' | 'outbound' | 'internal';
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
startedAt: number;
duration: number;
legs?: IHistoryLeg[];
}
export interface IContact {
id: string;
name: string;
number: string;
company?: string;
notes?: string;
starred?: boolean;
}
export interface IAppState { export interface IAppState {
connected: boolean; connected: boolean;