Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a280c5c41 | |||
| 59d8c2557c | |||
| cfadd7a2b6 | |||
| 80f710f6d8 | |||
| 9ea57cd659 | |||
| c40c726dc3 | |||
| 37ba7501fa | |||
| 24924a1aea | |||
| 7ed76a9488 | |||
| a9fdfe5733 | |||
| 6fcdf4291a | |||
| 81441e7853 | |||
| 21ffc1d017 | |||
| 2f16c5efae | |||
| 254d7f3633 | |||
| 67537664df | |||
| 54129dcdae | |||
| 8c6556dae3 | |||
| 291beb1da4 | |||
| 79147f1e40 |
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
|
||||
32
.gitea/workflows/docker_tags.yaml
Normal file
32
.gitea/workflows/docker_tags.yaml
Normal 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
|
||||
@@ -8,5 +8,16 @@
|
||||
"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
114
CLAUDE.md
@@ -1,41 +1,103 @@
|
||||
# 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
|
||||
- `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)
|
||||
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).
|
||||
|
||||
### 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`
|
||||
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
|
||||
### Key TS files (control plane)
|
||||
|
||||
**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
|
||||
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)
|
||||
## Event-push architecture for device status
|
||||
|
||||
**`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
74
Dockerfile
Normal 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"]
|
||||
71
changelog.md
71
changelog.md
@@ -1,5 +1,76 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
{
|
||||
"name": "siprouter",
|
||||
"version": "1.19.0",
|
||||
"version": "1.22.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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",
|
||||
"buildRust": "tsrust",
|
||||
"build": "pnpm run buildRust && pnpm run bundle",
|
||||
"build:docker": "tsdocker build --verbose",
|
||||
"release:docker": "tsdocker push --verbose",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@design.estate/dees-catalog": "^3.70.0",
|
||||
"@design.estate/dees-catalog": "^3.77.0",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/smartrust": "^1.3.2",
|
||||
"@push.rocks/smartstate": "^2.3.0",
|
||||
"tsx": "^4.21.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsdocker": "^2.2.4",
|
||||
"@git.zone/tsrust": "^1.3.2",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@types/ws": "^8.18.1"
|
||||
|
||||
686
pnpm-lock.yaml
generated
686
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
144
readme.md
144
readme.md
@@ -1,6 +1,6 @@
|
||||
# @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
|
||||
|
||||
@@ -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
|
||||
- 🎧 **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
|
||||
- 🎯 **Adaptive Jitter Buffer** — Per-leg jitter buffering with sequence-based reordering, adaptive depth (60–120ms), Opus PLC for lost packets, and hold/resume detection
|
||||
- 📧 **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
|
||||
- 🗣️ **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
|
||||
- 🖥️ **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
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Browser Softphone │
|
||||
│ (WebRTC via WebSocket signaling) │
|
||||
└──────────────┬──────────────────────┘
|
||||
│ Opus/WebRTC
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ siprouter │
|
||||
│ │
|
||||
│ TypeScript Control Plane │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ Config · WebRTC Signaling │ │
|
||||
│ │ REST API · Web Dashboard │ │
|
||||
│ │ Voicebox Manager · TTS Cache │ │
|
||||
│ └────────────┬───────────────────┘ │
|
||||
│ JSON-over-stdio IPC │
|
||||
│ ┌────────────┴───────────────────┐ │
|
||||
│ │ Rust proxy-engine (data plane) │ │
|
||||
│ │ │ │
|
||||
│ │ 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 │
|
||||
└─────────────┘ └─────────────┘
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Browser["🌐 Browser Softphone<br/>(WebRTC via WebSocket signaling)"]
|
||||
Devices["📞 SIP Devices<br/>(HT801, desk phones, ATAs)"]
|
||||
Trunks["☎️ SIP Trunk Providers<br/>(sipgate, easybell, …)"]
|
||||
|
||||
subgraph Router["siprouter"]
|
||||
direction TB
|
||||
subgraph TS["TypeScript Control Plane"]
|
||||
TSBits["Config · WebRTC Signaling<br/>REST API · Web Dashboard<br/>Voicebox Manager · TTS Cache"]
|
||||
end
|
||||
subgraph Rust["Rust proxy-engine (data plane)"]
|
||||
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"]
|
||||
end
|
||||
TS <-->|"JSON-over-stdio IPC"| Rust
|
||||
end
|
||||
|
||||
Browser <-->|"Opus / WebRTC"| TS
|
||||
Rust <-->|"SIP / RTP"| Devices
|
||||
Rust <-->|"SIP / RTP"| Trunks
|
||||
```
|
||||
|
||||
### 🧠 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.
|
||||
- **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
|
||||
@@ -79,7 +98,6 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
|
||||
- **Node.js** ≥ 20 with `tsx` globally available
|
||||
- **pnpm** for package management
|
||||
- **Rust** toolchain (for building the proxy engine)
|
||||
- **espeak-ng** (optional, for TTS fallback)
|
||||
|
||||
### Install & Build
|
||||
|
||||
@@ -171,7 +189,7 @@ Create `.nogit/config.json`:
|
||||
|
||||
### 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
|
||||
mkdir -p .nogit/tts
|
||||
@@ -181,7 +199,7 @@ curl -L -o .nogit/tts/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
|
||||
|
||||
@@ -208,7 +226,6 @@ siprouter/
|
||||
│ ├── frontend.ts # Web dashboard HTTP/WS server + REST API
|
||||
│ ├── webrtcbridge.ts # WebRTC signaling layer
|
||||
│ ├── registrar.ts # Browser softphone registration
|
||||
│ ├── announcement.ts # TTS announcement generator (espeak-ng / Kokoro)
|
||||
│ ├── voicebox.ts # Voicemail box management
|
||||
│ └── call/
|
||||
│ └── prompt-cache.ts # Named audio prompt WAV management
|
||||
@@ -245,36 +262,45 @@ The `proxy-engine` binary handles all real-time audio processing with a **48kHz
|
||||
|
||||
### Audio Pipeline
|
||||
|
||||
```
|
||||
Inbound: Wire RTP → Decode → Resample to 48kHz → Denoise (RNNoise) → Mix Bus
|
||||
Outbound: Mix Bus → Mix-Minus → Resample to codec rate → Encode → Wire RTP
|
||||
```mermaid
|
||||
flowchart LR
|
||||
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 2–6 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)
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## 🗣️ 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
|
||||
- **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
|
||||
- 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
|
||||
|
||||
- Configurable voicemail boxes with custom TTS greetings
|
||||
- Automatic routing on no-answer timeout
|
||||
- Recording with configurable max duration and message count
|
||||
- Configurable voicemail boxes with custom TTS greetings (text + voice) or uploaded WAV
|
||||
- Automatic routing on no-answer timeout (configurable, default 25s)
|
||||
- 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
|
||||
- WAV storage in `.nogit/voicemail/`
|
||||
|
||||
@@ -319,8 +345,10 @@ Announcements and voicemail greetings are synthesized using [Kokoro TTS](https:/
|
||||
| `/api/config` | GET | Read current configuration |
|
||||
| `/api/config` | POST | Update configuration (hot-reload) |
|
||||
| `/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/heard` | POST | Mark a voicemail message as heard |
|
||||
| `/api/voicemail/:box/:id` | DELETE | Delete a voicemail message |
|
||||
|
||||
### WebSocket Events
|
||||
|
||||
@@ -329,8 +357,18 @@ Connect to `/ws` for real-time push:
|
||||
```jsonc
|
||||
{ "type": "status", "data": { ... } } // Full status snapshot (1s interval)
|
||||
{ "type": "log", "data": { "message": "..." } } // Log lines in real-time
|
||||
{ "type": "incoming_call", "data": { ... } } // Incoming call notification
|
||||
{ "type": "call_ended", "data": { ... } } // Call ended notification
|
||||
{ "type": "call-update", "data": { ... } } // Call state change 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 +403,7 @@ pnpm run restartBackground
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
30
rust/.cargo/config.toml
Normal file
30
rust/.cargo/config.toml
Normal 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"
|
||||
1
rust/.cargo/crosslibs/aarch64/libstdc++.so
Symbolic link
1
rust/.cargo/crosslibs/aarch64/libstdc++.so
Symbolic link
@@ -0,0 +1 @@
|
||||
/usr/aarch64-linux-gnu/lib/libstdc++.so.6
|
||||
10
rust/Cargo.lock
generated
10
rust/Cargo.lock
generated
@@ -532,6 +532,15 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmudict-fast"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9f73004e928ed46c3e7fd7406d2b12c8674153295f08af084b49860276dc02"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codec-lib"
|
||||
version = "0.1.0"
|
||||
@@ -1730,6 +1739,7 @@ dependencies = [
|
||||
"bincode 2.0.1",
|
||||
"cc",
|
||||
"chinese-number",
|
||||
"cmudict-fast",
|
||||
"futures",
|
||||
"jieba-rs",
|
||||
"log",
|
||||
|
||||
@@ -19,7 +19,7 @@ regex-lite = "0.1"
|
||||
webrtc = "0.8"
|
||||
rand = "0.8"
|
||||
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 = [
|
||||
"std", "download-binaries", "copy-dylibs", "ndarray",
|
||||
"tls-native-vendored"
|
||||
|
||||
@@ -23,16 +23,22 @@ pub enum CallState {
|
||||
Ringing,
|
||||
Connected,
|
||||
Voicemail,
|
||||
Ivr,
|
||||
Terminated,
|
||||
}
|
||||
|
||||
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 {
|
||||
match self {
|
||||
Self::SettingUp => "setting-up",
|
||||
Self::Ringing => "ringing",
|
||||
Self::Connected => "connected",
|
||||
Self::Voicemail => "voicemail",
|
||||
Self::Ivr => "ivr",
|
||||
Self::Terminated => "terminated",
|
||||
}
|
||||
}
|
||||
@@ -45,6 +51,8 @@ pub enum CallDirection {
|
||||
}
|
||||
|
||||
impl CallDirection {
|
||||
/// Wire-format string. See CallState::as_str.
|
||||
#[allow(dead_code)]
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Inbound => "inbound",
|
||||
@@ -59,8 +67,13 @@ pub enum LegKind {
|
||||
SipProvider,
|
||||
SipDevice,
|
||||
WebRtc,
|
||||
Media, // voicemail playback, IVR, recording
|
||||
Tool, // observer leg for recording, transcription, etc.
|
||||
/// 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.
|
||||
}
|
||||
|
||||
impl LegKind {
|
||||
@@ -107,11 +120,22 @@ pub struct LegInfo {
|
||||
/// For SIP legs: the SIP Call-ID for message routing.
|
||||
pub sip_call_id: Option<String>,
|
||||
/// 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>,
|
||||
/// The RTP socket allocated for this leg.
|
||||
pub rtp_socket: Option<Arc<UdpSocket>>,
|
||||
/// The RTP port number.
|
||||
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).
|
||||
pub remote_media: Option<SocketAddr>,
|
||||
/// SIP signaling address (provider or device).
|
||||
@@ -124,14 +148,21 @@ pub struct LegInfo {
|
||||
|
||||
/// A multiparty call with N legs and a central mixer.
|
||||
pub struct Call {
|
||||
// Duplicated from the HashMap key in CallManager. Kept for future
|
||||
// status-snapshot work.
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
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 created_at: Instant,
|
||||
|
||||
// Metadata.
|
||||
pub caller_number: Option<String>,
|
||||
pub callee_number: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub provider_id: String,
|
||||
|
||||
/// Original INVITE from the device (for device-originated outbound calls).
|
||||
@@ -211,42 +242,4 @@ impl Call {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,24 @@ use crate::mixer::spawn_mixer;
|
||||
use crate::registrar::Registrar;
|
||||
use crate::rtp::RtpPortPool;
|
||||
use crate::sip_leg::{SipLeg, SipLegAction, SipLegConfig};
|
||||
use crate::tts::TtsEngine;
|
||||
use sip_proto::helpers::{build_sdp, generate_call_id, generate_tag, parse_sdp_endpoint, SdpOptions};
|
||||
use sip_proto::message::{ResponseOptions, SipMessage};
|
||||
use sip_proto::rewrite::{rewrite_sdp, rewrite_sip_uri};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Result of creating an inbound call — carries both the call id and
|
||||
/// whether browsers should be notified (flows from the matched inbound
|
||||
/// route's `ring_browsers` flag, or the fallback default).
|
||||
pub struct InboundCallCreated {
|
||||
pub call_id: String,
|
||||
pub ring_browsers: bool,
|
||||
}
|
||||
|
||||
/// Emit a `leg_added` event with full leg information.
|
||||
/// Free function (not a method) to avoid `&self` borrow conflicts when `self.calls` is borrowed.
|
||||
@@ -94,26 +105,6 @@ impl CallManager {
|
||||
self.sip_index.contains_key(sip_call_id)
|
||||
}
|
||||
|
||||
/// Get an RTP socket for a call's provider leg (used by webrtc_link).
|
||||
pub fn get_call_provider_rtp_socket(&self, call_id: &str) -> Option<Arc<UdpSocket>> {
|
||||
let call = self.calls.get(call_id)?;
|
||||
for leg in call.legs.values() {
|
||||
if leg.kind == LegKind::SipProvider {
|
||||
return leg.rtp_socket.clone();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get all active call statuses for the dashboard.
|
||||
pub fn get_all_statuses(&self) -> Vec<serde_json::Value> {
|
||||
self.calls
|
||||
.values()
|
||||
.filter(|c| c.state != CallState::Terminated)
|
||||
.map(|c| c.to_status_json())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SIP message routing
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -426,8 +417,8 @@ impl CallManager {
|
||||
|
||||
// Find the counterpart leg.
|
||||
let other_leg = call.legs.values().find(|l| l.id != this_leg_id && l.state != LegState::Terminated);
|
||||
let (other_addr, other_rtp_port, other_leg_id) = match other_leg {
|
||||
Some(l) => (l.signaling_addr, l.rtp_port, l.id.clone()),
|
||||
let (other_addr, other_rtp_port, other_leg_id, other_kind, other_public_ip) = match other_leg {
|
||||
Some(l) => (l.signaling_addr, l.rtp_port, l.id.clone(), l.kind, l.public_ip.clone()),
|
||||
None => return false,
|
||||
};
|
||||
let forward_to = match other_addr {
|
||||
@@ -438,8 +429,14 @@ impl CallManager {
|
||||
let lan_ip = config.proxy.lan_ip.clone();
|
||||
let lan_port = config.proxy.lan_port;
|
||||
|
||||
// Get this leg's RTP port (for SDP rewriting — tell the other side to send RTP here).
|
||||
let this_rtp_port = call.legs.get(this_leg_id).map(|l| l.rtp_port).unwrap_or(0);
|
||||
// Pick the IP to advertise to the destination leg. Provider legs face
|
||||
// the public internet and need `public_ip`; every other leg kind is
|
||||
// on-LAN (or proxy-internal) and takes `lan_ip`. This rule is applied
|
||||
// both to the SDP `c=` line and the Record-Route header below.
|
||||
let advertise_ip: String = match other_kind {
|
||||
LegKind::SipProvider => other_public_ip.unwrap_or_else(|| lan_ip.clone()),
|
||||
_ => lan_ip.clone(),
|
||||
};
|
||||
|
||||
// Check if the other leg is a B2BUA leg (has SipLeg for proper dialog mgmt).
|
||||
let other_has_sip_leg = call.legs.get(&other_leg_id)
|
||||
@@ -533,10 +530,11 @@ impl CallManager {
|
||||
|
||||
// Forward other requests with SDP rewriting.
|
||||
let mut fwd = msg.clone();
|
||||
// Rewrite SDP to point the other side to this leg's RTP port
|
||||
// (so we receive their audio on our socket).
|
||||
// Rewrite SDP so the destination leg sends RTP to our proxy port
|
||||
// at an address that is routable from its vantage point
|
||||
// (public IP for provider legs, LAN IP for everything else).
|
||||
if fwd.has_sdp_body() {
|
||||
let (new_body, _) = rewrite_sdp(&fwd.body, &lan_ip, other_rtp_port);
|
||||
let (new_body, _) = rewrite_sdp(&fwd.body, &advertise_ip, other_rtp_port);
|
||||
fwd.body = new_body;
|
||||
fwd.update_content_length();
|
||||
}
|
||||
@@ -548,7 +546,8 @@ impl CallManager {
|
||||
}
|
||||
}
|
||||
if fwd.is_dialog_establishing() {
|
||||
fwd.prepend_header("Record-Route", &format!("<sip:{lan_ip}:{lan_port};lr>"));
|
||||
// Record-Route must also be routable from the destination leg.
|
||||
fwd.prepend_header("Record-Route", &format!("<sip:{advertise_ip}:{lan_port};lr>"));
|
||||
}
|
||||
let _ = socket.send_to(&fwd.serialize(), forward_to).await;
|
||||
return true;
|
||||
@@ -560,15 +559,10 @@ impl CallManager {
|
||||
let cseq_method = msg.cseq_method().unwrap_or("").to_uppercase();
|
||||
|
||||
let mut fwd = msg.clone();
|
||||
// Rewrite SDP so the forward-to side sends RTP to the correct leg port.
|
||||
// Rewrite SDP so the forward-to side sends RTP to the correct
|
||||
// leg port at a routable address (see `advertise_ip` above).
|
||||
if fwd.has_sdp_body() {
|
||||
let rewrite_ip = if this_kind == LegKind::SipDevice {
|
||||
// Response from device → send to provider: use LAN/public IP.
|
||||
&lan_ip
|
||||
} else {
|
||||
&lan_ip
|
||||
};
|
||||
let (new_body, _) = rewrite_sdp(&fwd.body, rewrite_ip, other_rtp_port);
|
||||
let (new_body, _) = rewrite_sdp(&fwd.body, &advertise_ip, other_rtp_port);
|
||||
fwd.body = new_body;
|
||||
fwd.update_content_length();
|
||||
}
|
||||
@@ -690,7 +684,8 @@ impl CallManager {
|
||||
rtp_pool: &mut RtpPortPool,
|
||||
socket: &UdpSocket,
|
||||
public_ip: Option<&str>,
|
||||
) -> Option<String> {
|
||||
tts_engine: Arc<Mutex<TtsEngine>>,
|
||||
) -> Option<InboundCallCreated> {
|
||||
let call_id = self.next_call_id();
|
||||
let lan_ip = &config.proxy.lan_ip;
|
||||
let lan_port = config.proxy.lan_port;
|
||||
@@ -707,17 +702,65 @@ impl CallManager {
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Resolve target device.
|
||||
let device_addr = match self.resolve_first_device(config, registrar) {
|
||||
// Resolve via the configured inbound routing table. This honors
|
||||
// user-defined routes from the UI (numberPattern, callerPattern,
|
||||
// sourceProvider, targets, ringBrowsers). If no route matches, the
|
||||
// fallback returns an empty `device_ids` and `ring_browsers: true`,
|
||||
// which preserves pre-routing behavior via the `resolve_first_device`
|
||||
// fallback below.
|
||||
//
|
||||
// TODO: Multi-target inbound fork is not yet implemented.
|
||||
// - `route.device_ids` beyond the first registered target are ignored.
|
||||
// - `ring_browsers` is informational only — browsers see a toast but
|
||||
// do not race the SIP device. First-to-answer-wins requires a
|
||||
// multi-leg fork + per-leg CANCEL, which is not built yet.
|
||||
let route = config.resolve_inbound_route(provider_id, &called_number, &caller_number);
|
||||
let ring_browsers = route.ring_browsers;
|
||||
|
||||
// IVR routing: if the route targets an IVR menu, go there directly.
|
||||
if let Some(ref ivr_menu_id) = route.ivr_menu_id {
|
||||
if let Some(ivr) = &config.ivr {
|
||||
if ivr.enabled {
|
||||
if let Some(menu) = ivr.menus.iter().find(|m| m.id == *ivr_menu_id) {
|
||||
let call_id = self
|
||||
.route_to_ivr(
|
||||
&call_id, invite, from_addr, &caller_number,
|
||||
provider_id, provider_config, config, rtp_pool, socket,
|
||||
public_ip, menu, &tts_engine,
|
||||
)
|
||||
.await?;
|
||||
return Some(InboundCallCreated { call_id, ring_browsers });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the first registered device from the matched targets, or fall
|
||||
// back to any-registered-device if the route has no resolved targets.
|
||||
let device_addr = route
|
||||
.device_ids
|
||||
.iter()
|
||||
.find_map(|id| registrar.get_device_contact(id))
|
||||
.or_else(|| self.resolve_first_device(config, registrar));
|
||||
|
||||
let device_addr = match device_addr {
|
||||
Some(addr) => addr,
|
||||
None => {
|
||||
// No device registered → voicemail.
|
||||
return self
|
||||
// Resolve greeting WAV on-demand (may trigger TTS generation).
|
||||
let greeting_wav = resolve_greeting_wav(
|
||||
config,
|
||||
route.voicemail_box.as_deref(),
|
||||
&tts_engine,
|
||||
).await;
|
||||
let call_id = self
|
||||
.route_to_voicemail(
|
||||
&call_id, invite, from_addr, &caller_number,
|
||||
provider_id, provider_config, config, rtp_pool, socket, public_ip,
|
||||
greeting_wav,
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
return Some(InboundCallCreated { call_id, ring_browsers });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -781,6 +824,7 @@ impl CallManager {
|
||||
webrtc_session_id: None,
|
||||
rtp_socket: Some(provider_rtp.socket.clone()),
|
||||
rtp_port: provider_rtp.port,
|
||||
public_ip: public_ip.map(|s| s.to_string()),
|
||||
remote_media: provider_media,
|
||||
signaling_addr: Some(from_addr),
|
||||
metadata: HashMap::new(),
|
||||
@@ -801,6 +845,7 @@ impl CallManager {
|
||||
webrtc_session_id: None,
|
||||
rtp_socket: Some(device_rtp.socket.clone()),
|
||||
rtp_port: device_rtp.port,
|
||||
public_ip: None,
|
||||
remote_media: None, // Learned from device's 200 OK.
|
||||
signaling_addr: Some(device_addr),
|
||||
metadata: HashMap::new(),
|
||||
@@ -844,7 +889,7 @@ impl CallManager {
|
||||
}
|
||||
}
|
||||
|
||||
Some(call_id)
|
||||
Some(InboundCallCreated { call_id, ring_browsers })
|
||||
}
|
||||
|
||||
/// Initiate an outbound B2BUA call from the dashboard.
|
||||
@@ -920,6 +965,7 @@ impl CallManager {
|
||||
webrtc_session_id: None,
|
||||
rtp_socket: Some(rtp_alloc.socket.clone()),
|
||||
rtp_port: rtp_alloc.port,
|
||||
public_ip: public_ip.map(|s| s.to_string()),
|
||||
remote_media: None,
|
||||
signaling_addr: Some(provider_dest),
|
||||
metadata: HashMap::new(),
|
||||
@@ -1030,6 +1076,7 @@ impl CallManager {
|
||||
sip_leg: None,
|
||||
sip_call_id: Some(device_sip_call_id.clone()),
|
||||
webrtc_session_id: None,
|
||||
public_ip: None,
|
||||
rtp_socket: Some(device_rtp.socket.clone()),
|
||||
rtp_port: device_rtp.port,
|
||||
remote_media: device_media,
|
||||
@@ -1076,6 +1123,7 @@ impl CallManager {
|
||||
webrtc_session_id: None,
|
||||
rtp_socket: Some(provider_rtp.socket.clone()),
|
||||
rtp_port: provider_rtp.port,
|
||||
public_ip: public_ip.map(|s| s.to_string()),
|
||||
remote_media: None,
|
||||
signaling_addr: Some(provider_dest),
|
||||
metadata: HashMap::new(),
|
||||
@@ -1114,7 +1162,7 @@ impl CallManager {
|
||||
public_ip: Option<&str>,
|
||||
registered_aor: &str,
|
||||
) -> Option<String> {
|
||||
let call = self.calls.get(call_id)?;
|
||||
self.calls.get(call_id)?; // existence check; the call is re-fetched via get_mut below
|
||||
let lan_ip = &config.proxy.lan_ip;
|
||||
let lan_port = config.proxy.lan_port;
|
||||
|
||||
@@ -1151,6 +1199,7 @@ impl CallManager {
|
||||
webrtc_session_id: None,
|
||||
rtp_socket: Some(rtp_alloc.socket.clone()),
|
||||
rtp_port: rtp_alloc.port,
|
||||
public_ip: public_ip.map(|s| s.to_string()),
|
||||
remote_media: None,
|
||||
signaling_addr: Some(provider_dest),
|
||||
metadata: HashMap::new(),
|
||||
@@ -1182,7 +1231,7 @@ impl CallManager {
|
||||
socket: &UdpSocket,
|
||||
) -> Option<String> {
|
||||
let device_addr = registrar.get_device_contact(device_id)?;
|
||||
let call = self.calls.get(call_id)?;
|
||||
self.calls.get(call_id)?; // existence check; the call is re-fetched via get_mut below
|
||||
let lan_ip = &config.proxy.lan_ip;
|
||||
let lan_port = config.proxy.lan_port;
|
||||
|
||||
@@ -1221,6 +1270,7 @@ impl CallManager {
|
||||
webrtc_session_id: None,
|
||||
rtp_socket: Some(rtp_alloc.socket.clone()),
|
||||
rtp_port: rtp_alloc.port,
|
||||
public_ip: None,
|
||||
remote_media: None,
|
||||
signaling_addr: Some(device_addr),
|
||||
metadata: HashMap::new(),
|
||||
@@ -1514,6 +1564,7 @@ impl CallManager {
|
||||
rtp_pool: &mut RtpPortPool,
|
||||
socket: &UdpSocket,
|
||||
public_ip: Option<&str>,
|
||||
greeting_wav: Option<String>,
|
||||
) -> Option<String> {
|
||||
let lan_ip = &config.proxy.lan_ip;
|
||||
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
|
||||
@@ -1581,6 +1632,7 @@ impl CallManager {
|
||||
webrtc_session_id: None,
|
||||
rtp_socket: Some(rtp_alloc.socket.clone()),
|
||||
rtp_port: rtp_alloc.port,
|
||||
public_ip: public_ip.map(|s| s.to_string()),
|
||||
remote_media: Some(provider_media),
|
||||
signaling_addr: Some(from_addr),
|
||||
metadata: HashMap::new(),
|
||||
@@ -1607,8 +1659,6 @@ impl CallManager {
|
||||
.as_millis();
|
||||
let recording_dir = "nogit/voicemail/default".to_string();
|
||||
let recording_path = format!("{recording_dir}/msg-{timestamp}.wav");
|
||||
let greeting_wav = find_greeting_wav();
|
||||
|
||||
let out_tx = self.out_tx.clone();
|
||||
let call_id_owned = call_id.to_string();
|
||||
let caller_owned = caller_number.to_string();
|
||||
@@ -1625,6 +1675,211 @@ impl CallManager {
|
||||
Some(call_id.to_string())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IVR routing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn route_to_ivr(
|
||||
&mut self,
|
||||
call_id: &str,
|
||||
invite: &SipMessage,
|
||||
from_addr: SocketAddr,
|
||||
caller_number: &str,
|
||||
provider_id: &str,
|
||||
provider_config: &ProviderConfig,
|
||||
config: &AppConfig,
|
||||
rtp_pool: &mut RtpPortPool,
|
||||
socket: &UdpSocket,
|
||||
public_ip: Option<&str>,
|
||||
menu: &crate::config::IvrMenuConfig,
|
||||
tts_engine: &Arc<Mutex<TtsEngine>>,
|
||||
) -> Option<String> {
|
||||
let lan_ip = &config.proxy.lan_ip;
|
||||
|
||||
let rtp_alloc = match rtp_pool.allocate().await {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
let resp = SipMessage::create_response(503, "Service Unavailable", invite, None);
|
||||
let _ = socket.send_to(&resp.serialize(), from_addr).await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let codec_pt = provider_config.codecs.first().copied().unwrap_or(9);
|
||||
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
|
||||
|
||||
let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions {
|
||||
ip: pub_ip,
|
||||
port: rtp_alloc.port,
|
||||
payload_types: &provider_config.codecs,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let response = SipMessage::create_response(
|
||||
200, "OK", invite,
|
||||
Some(sip_proto::message::ResponseOptions {
|
||||
to_tag: Some(sip_proto::helpers::generate_tag()),
|
||||
contact: Some(format!("<sip:{}:{}>", lan_ip, config.proxy.lan_port)),
|
||||
body: Some(sdp),
|
||||
content_type: Some("application/sdp".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let _ = socket.send_to(&response.serialize(), from_addr).await;
|
||||
|
||||
let provider_media = if invite.has_sdp_body() {
|
||||
parse_sdp_endpoint(&invite.body)
|
||||
.and_then(|ep| format!("{}:{}", ep.address, ep.port).parse().ok())
|
||||
} else {
|
||||
Some(from_addr)
|
||||
};
|
||||
let provider_media = provider_media.unwrap_or(from_addr);
|
||||
|
||||
// Create call with IVR state.
|
||||
let (mixer_cmd_tx, mixer_task) = spawn_mixer(call_id.to_string(), self.out_tx.clone());
|
||||
let mut call = Call::new(
|
||||
call_id.to_string(),
|
||||
CallDirection::Inbound,
|
||||
provider_id.to_string(),
|
||||
mixer_cmd_tx.clone(),
|
||||
mixer_task,
|
||||
);
|
||||
call.state = CallState::Ivr;
|
||||
call.caller_number = Some(caller_number.to_string());
|
||||
|
||||
let provider_leg_id = format!("{call_id}-prov");
|
||||
call.legs.insert(
|
||||
provider_leg_id.clone(),
|
||||
LegInfo {
|
||||
id: provider_leg_id.clone(),
|
||||
kind: LegKind::SipProvider,
|
||||
state: LegState::Connected,
|
||||
codec_pt,
|
||||
sip_leg: None,
|
||||
sip_call_id: Some(invite.call_id().to_string()),
|
||||
webrtc_session_id: None,
|
||||
rtp_socket: Some(rtp_alloc.socket.clone()),
|
||||
rtp_port: rtp_alloc.port,
|
||||
public_ip: public_ip.map(|s| s.to_string()),
|
||||
remote_media: Some(provider_media),
|
||||
signaling_addr: Some(from_addr),
|
||||
metadata: HashMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
self.sip_index.insert(
|
||||
invite.call_id().to_string(),
|
||||
(call_id.to_string(), provider_leg_id.clone()),
|
||||
);
|
||||
self.calls.insert(call_id.to_string(), call);
|
||||
|
||||
// Emit leg_added for the provider leg.
|
||||
if let Some(call) = self.calls.get(call_id) {
|
||||
for leg in call.legs.values() {
|
||||
emit_leg_added_event(&self.out_tx, call_id, leg);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate IVR prompt on-demand via TTS (cached).
|
||||
let voice = menu.prompt_voice.as_deref().unwrap_or("af_bella");
|
||||
let prompt_output = format!(".nogit/tts/ivr-menu-{}.wav", menu.id);
|
||||
let prompt_params = serde_json::json!({
|
||||
"model": ".nogit/tts/kokoro-v1.0.onnx",
|
||||
"voices": ".nogit/tts/voices.bin",
|
||||
"voice": voice,
|
||||
"text": &menu.prompt_text,
|
||||
"output": &prompt_output,
|
||||
"cacheable": true,
|
||||
});
|
||||
|
||||
let prompt_wav = {
|
||||
let mut tts = tts_engine.lock().await;
|
||||
match tts.generate(&prompt_params).await {
|
||||
Ok(_) => Some(prompt_output),
|
||||
Err(e) => {
|
||||
eprintln!("[ivr] TTS generation failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Load prompt and run interaction via the mixer.
|
||||
let out_tx = self.out_tx.clone();
|
||||
let call_id_owned = call_id.to_string();
|
||||
let expected_digits: Vec<char> = menu
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|e| e.digit.chars().next())
|
||||
.collect();
|
||||
let timeout_ms = menu.timeout_sec.unwrap_or(5) * 1000;
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Load prompt PCM frames if available.
|
||||
let prompt_frames = prompt_wav.as_ref().and_then(|wav| {
|
||||
crate::audio_player::load_prompt_pcm_frames(wav).ok()
|
||||
});
|
||||
|
||||
if let Some(frames) = prompt_frames {
|
||||
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
|
||||
let _ = mixer_cmd_tx
|
||||
.send(crate::mixer::MixerCommand::StartInteraction {
|
||||
leg_id: provider_leg_id.clone(),
|
||||
prompt_pcm_frames: frames,
|
||||
expected_digits: expected_digits.clone(),
|
||||
timeout_ms,
|
||||
result_tx,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Wait for digit or timeout.
|
||||
let safety = tokio::time::Duration::from_millis(timeout_ms as u64 + 30000);
|
||||
let result = match tokio::time::timeout(safety, result_rx).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(_)) => crate::mixer::InteractionResult::Cancelled,
|
||||
Err(_) => crate::mixer::InteractionResult::Timeout,
|
||||
};
|
||||
|
||||
match &result {
|
||||
crate::mixer::InteractionResult::Digit(d) => {
|
||||
eprintln!("[ivr] caller pressed '{d}' on call {call_id_owned}");
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"ivr_digit",
|
||||
serde_json::json!({
|
||||
"call_id": call_id_owned,
|
||||
"digit": d.to_string(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
crate::mixer::InteractionResult::Timeout => {
|
||||
eprintln!("[ivr] timeout on call {call_id_owned}");
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"ivr_timeout",
|
||||
serde_json::json!({ "call_id": call_id_owned }),
|
||||
);
|
||||
}
|
||||
crate::mixer::InteractionResult::Cancelled => {
|
||||
eprintln!("[ivr] cancelled on call {call_id_owned}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("[ivr] no prompt available for call {call_id_owned}, ending");
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"ivr_error",
|
||||
serde_json::json!({
|
||||
"call_id": call_id_owned,
|
||||
"error": "no prompt available",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Some(call_id.to_string())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1639,13 +1894,56 @@ impl CallManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn find_greeting_wav() -> Option<String> {
|
||||
let candidates = [
|
||||
/// Resolve the greeting WAV for a voicemail box.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. Pre-recorded WAV from voicebox config (`greetingWavPath`)
|
||||
/// 2. On-demand TTS generation from greeting text (cached via `cacheable: true`)
|
||||
/// 3. Legacy hardcoded paths (`.nogit/voicemail/default/greeting.wav`, etc.)
|
||||
/// 4. None — voicemail session plays beep only
|
||||
async fn resolve_greeting_wav(
|
||||
config: &AppConfig,
|
||||
voicebox_id: Option<&str>,
|
||||
tts_engine: &Arc<Mutex<TtsEngine>>,
|
||||
) -> Option<String> {
|
||||
// 1. Look up voicebox config.
|
||||
let vb = voicebox_id
|
||||
.and_then(|id| config.voiceboxes.iter().find(|v| v.id == id && v.enabled));
|
||||
|
||||
if let Some(vb) = vb {
|
||||
// 2. Pre-recorded WAV takes priority.
|
||||
if let Some(ref wav) = vb.greeting_wav_path {
|
||||
if Path::new(wav).exists() {
|
||||
return Some(wav.clone());
|
||||
}
|
||||
}
|
||||
// 3. TTS on-demand with caching.
|
||||
let text = vb.greeting_text.as_deref().unwrap_or(
|
||||
"The person you are trying to reach is not available. Please leave a message after the tone.",
|
||||
);
|
||||
let voice = vb.greeting_voice.as_deref().unwrap_or("af_bella");
|
||||
let output = format!(".nogit/tts/voicemail-greeting-{}.wav", vb.id);
|
||||
|
||||
let params = serde_json::json!({
|
||||
"model": ".nogit/tts/kokoro-v1.0.onnx",
|
||||
"voices": ".nogit/tts/voices.bin",
|
||||
"voice": voice,
|
||||
"text": text,
|
||||
"output": &output,
|
||||
"cacheable": true,
|
||||
});
|
||||
let mut tts = tts_engine.lock().await;
|
||||
if tts.generate(¶ms).await.is_ok() {
|
||||
return Some(output);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback: legacy hardcoded paths.
|
||||
for path in &[
|
||||
".nogit/voicemail/default/greeting.wav",
|
||||
".nogit/voicemail/greeting.wav",
|
||||
];
|
||||
for path in &candidates {
|
||||
if std::path::Path::new(path).exists() {
|
||||
] {
|
||||
if Path::new(path).exists() {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ impl Endpoint {
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct Quirks {
|
||||
#[serde(rename = "earlyMediaSilence")]
|
||||
@@ -44,6 +49,9 @@ pub struct Quirks {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub id: String,
|
||||
// UI label — populated by serde for parity with the TS config, not
|
||||
// consumed at runtime.
|
||||
#[allow(dead_code)]
|
||||
#[serde(rename = "displayName")]
|
||||
pub display_name: String,
|
||||
pub domain: String,
|
||||
@@ -54,6 +62,8 @@ pub struct ProviderConfig {
|
||||
#[serde(rename = "registerIntervalSec")]
|
||||
pub register_interval_sec: u32,
|
||||
pub codecs: Vec<u8>,
|
||||
// Workaround knobs populated by serde but not yet acted upon — see Quirks.
|
||||
#[allow(dead_code)]
|
||||
pub quirks: Quirks,
|
||||
}
|
||||
|
||||
@@ -84,6 +94,10 @@ pub struct RouteMatch {
|
||||
|
||||
/// Route action.
|
||||
#[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 targets: Option<Vec<String>>,
|
||||
#[serde(rename = "ringBrowsers")]
|
||||
@@ -106,7 +120,11 @@ pub struct RouteAction {
|
||||
/// A routing rule.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Route {
|
||||
// `id` and `name` are UI identifiers, populated by serde but not
|
||||
// consumed by the resolvers.
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
#[allow(dead_code)]
|
||||
pub name: String,
|
||||
pub priority: i32,
|
||||
pub enabled: bool,
|
||||
@@ -141,6 +159,10 @@ pub struct AppConfig {
|
||||
pub providers: Vec<ProviderConfig>,
|
||||
pub devices: Vec<DeviceConfig>,
|
||||
pub routing: RoutingConfig,
|
||||
#[serde(default)]
|
||||
pub voiceboxes: Vec<VoiceboxConfig>,
|
||||
#[serde(default)]
|
||||
pub ivr: Option<IvrConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -148,6 +170,59 @@ pub struct RoutingConfig {
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -192,10 +267,18 @@ pub fn matches_pattern(pattern: Option<&str>, value: &str) -> bool {
|
||||
/// Result of resolving an outbound route.
|
||||
pub struct OutboundRouteResult {
|
||||
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,
|
||||
}
|
||||
|
||||
/// Result of resolving an inbound route.
|
||||
//
|
||||
// `device_ids` 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 device_ids: Vec<String>,
|
||||
pub ring_browsers: bool,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ mod audio_player;
|
||||
mod call;
|
||||
mod call_manager;
|
||||
mod config;
|
||||
mod dtmf;
|
||||
mod ipc;
|
||||
mod jitter_buffer;
|
||||
mod leg_io;
|
||||
@@ -51,11 +50,12 @@ struct ProxyEngine {
|
||||
registrar: Registrar,
|
||||
call_mgr: CallManager,
|
||||
rtp_pool: Option<RtpPortPool>,
|
||||
tts_engine: Arc<Mutex<tts::TtsEngine>>,
|
||||
out_tx: OutTx,
|
||||
}
|
||||
|
||||
impl ProxyEngine {
|
||||
fn new(out_tx: OutTx) -> Self {
|
||||
fn new(out_tx: OutTx, tts_engine: Arc<Mutex<tts::TtsEngine>>) -> Self {
|
||||
Self {
|
||||
config: None,
|
||||
transport: None,
|
||||
@@ -63,6 +63,7 @@ impl ProxyEngine {
|
||||
registrar: Registrar::new(out_tx.clone()),
|
||||
call_mgr: CallManager::new(out_tx.clone()),
|
||||
rtp_pool: None,
|
||||
tts_engine,
|
||||
out_tx,
|
||||
}
|
||||
}
|
||||
@@ -89,15 +90,16 @@ async fn main() {
|
||||
// Emit ready event.
|
||||
emit_event(&out_tx, "ready", serde_json::json!({}));
|
||||
|
||||
// Shared engine state (SIP side).
|
||||
let engine = Arc::new(Mutex::new(ProxyEngine::new(out_tx.clone())));
|
||||
// TTS engine — separate internal lock, lazy-loads model on first use.
|
||||
let tts_engine = Arc::new(Mutex::new(tts::TtsEngine::new()));
|
||||
|
||||
// Shared engine state (SIP side). TTS engine is stored inside so the
|
||||
// SIP packet handler path can reach it for on-demand voicemail/IVR generation.
|
||||
let engine = Arc::new(Mutex::new(ProxyEngine::new(out_tx.clone(), tts_engine)));
|
||||
|
||||
// WebRTC engine — separate lock to avoid deadlock with SIP handlers.
|
||||
let webrtc = Arc::new(Mutex::new(WebRtcEngine::new(out_tx.clone())));
|
||||
|
||||
// TTS engine — separate lock, lazy-loads model on first use.
|
||||
let tts_engine = Arc::new(Mutex::new(tts::TtsEngine::new()));
|
||||
|
||||
// Read commands from stdin.
|
||||
let stdin = tokio::io::stdin();
|
||||
let reader = BufReader::new(stdin);
|
||||
@@ -118,12 +120,11 @@ async fn main() {
|
||||
|
||||
let engine = engine.clone();
|
||||
let webrtc = webrtc.clone();
|
||||
let tts_engine = tts_engine.clone();
|
||||
let out_tx = out_tx.clone();
|
||||
|
||||
// Handle commands — some are async, so we spawn.
|
||||
tokio::spawn(async move {
|
||||
handle_command(engine, webrtc, tts_engine, &out_tx, cmd).await;
|
||||
handle_command(engine, webrtc, &out_tx, cmd).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -131,7 +132,6 @@ async fn main() {
|
||||
async fn handle_command(
|
||||
engine: Arc<Mutex<ProxyEngine>>,
|
||||
webrtc: Arc<Mutex<WebRtcEngine>>,
|
||||
tts_engine: Arc<Mutex<tts::TtsEngine>>,
|
||||
out_tx: &OutTx,
|
||||
cmd: Command,
|
||||
) {
|
||||
@@ -140,7 +140,6 @@ async fn handle_command(
|
||||
"configure" => handle_configure(engine, out_tx, &cmd).await,
|
||||
"hangup" => handle_hangup(engine, out_tx, &cmd).await,
|
||||
"make_call" => handle_make_call(engine, out_tx, &cmd).await,
|
||||
"get_status" => handle_get_status(engine, out_tx, &cmd).await,
|
||||
"add_leg" => handle_add_leg(engine, out_tx, &cmd).await,
|
||||
"remove_leg" => handle_remove_leg(engine, out_tx, &cmd).await,
|
||||
// WebRTC commands — lock webrtc only (no engine contention).
|
||||
@@ -157,8 +156,8 @@ async fn handle_command(
|
||||
"add_tool_leg" => handle_add_tool_leg(engine, out_tx, &cmd).await,
|
||||
"remove_tool_leg" => handle_remove_tool_leg(engine, out_tx, &cmd).await,
|
||||
"set_leg_metadata" => handle_set_leg_metadata(engine, out_tx, &cmd).await,
|
||||
// TTS command — lock tts_engine only (no SIP/WebRTC contention).
|
||||
"generate_tts" => handle_generate_tts(tts_engine, out_tx, &cmd).await,
|
||||
// TTS command — gets tts_engine from inside ProxyEngine.
|
||||
"generate_tts" => handle_generate_tts(engine, out_tx, &cmd).await,
|
||||
_ => respond_err(out_tx, &cmd.id, &format!("unknown command: {}", cmd.method)),
|
||||
}
|
||||
}
|
||||
@@ -327,10 +326,12 @@ async fn handle_sip_packet(
|
||||
ref registrar,
|
||||
ref mut call_mgr,
|
||||
ref mut rtp_pool,
|
||||
ref tts_engine,
|
||||
..
|
||||
} = *eng;
|
||||
let tts_clone = tts_engine.clone();
|
||||
let rtp_pool = rtp_pool.as_mut().unwrap();
|
||||
let call_id = call_mgr
|
||||
let inbound = call_mgr
|
||||
.create_inbound_call(
|
||||
&msg,
|
||||
from_addr,
|
||||
@@ -341,10 +342,11 @@ async fn handle_sip_packet(
|
||||
rtp_pool,
|
||||
socket,
|
||||
public_ip.as_deref(),
|
||||
tts_clone,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(call_id) = call_id {
|
||||
if let Some(inbound) = inbound {
|
||||
// Emit event so TypeScript knows about the call (for dashboard, IVR routing, etc).
|
||||
let from_header = msg.get_header("From").unwrap_or("");
|
||||
let from_uri = SipMessage::extract_uri(from_header).unwrap_or("Unknown");
|
||||
@@ -357,10 +359,11 @@ async fn handle_sip_packet(
|
||||
&eng.out_tx,
|
||||
"incoming_call",
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"call_id": inbound.call_id,
|
||||
"from_uri": from_uri,
|
||||
"to_number": called_number,
|
||||
"provider_id": provider_id,
|
||||
"ring_browsers": inbound.ring_browsers,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -383,7 +386,7 @@ async fn handle_sip_packet(
|
||||
let route_result = config_ref.resolve_outbound_route(
|
||||
&dialed_number,
|
||||
device_id.as_deref(),
|
||||
&|pid: &str| {
|
||||
&|_pid: &str| {
|
||||
// Can't call async here — use a sync check.
|
||||
// For now, assume all configured providers are available.
|
||||
true
|
||||
@@ -454,13 +457,6 @@ async fn handle_sip_packet(
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle `get_status` — return active call statuses from Rust.
|
||||
async fn handle_get_status(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
|
||||
let eng = engine.lock().await;
|
||||
let calls = eng.call_mgr.get_all_statuses();
|
||||
respond_ok(out_tx, &cmd.id, serde_json::json!({ "calls": calls }));
|
||||
}
|
||||
|
||||
/// Handle `make_call` — initiate an outbound call to a number via a provider.
|
||||
async fn handle_make_call(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
|
||||
let number = match cmd.params.get("number").and_then(|v| v.as_str()) {
|
||||
@@ -665,6 +661,7 @@ async fn handle_webrtc_link(
|
||||
webrtc_session_id: Some(session_id.clone()),
|
||||
rtp_socket: None,
|
||||
rtp_port: 0,
|
||||
public_ip: None,
|
||||
remote_media: None,
|
||||
signaling_addr: None,
|
||||
metadata: std::collections::HashMap::new(),
|
||||
@@ -1116,6 +1113,7 @@ async fn handle_add_tool_leg(
|
||||
webrtc_session_id: None,
|
||||
rtp_socket: None,
|
||||
rtp_port: 0,
|
||||
public_ip: None,
|
||||
remote_media: None,
|
||||
signaling_addr: None,
|
||||
metadata,
|
||||
@@ -1237,10 +1235,11 @@ async fn handle_set_leg_metadata(
|
||||
|
||||
/// Handle `generate_tts` — synthesize text to a WAV file using Kokoro TTS.
|
||||
async fn handle_generate_tts(
|
||||
tts_engine: Arc<Mutex<tts::TtsEngine>>,
|
||||
engine: Arc<Mutex<ProxyEngine>>,
|
||||
out_tx: &OutTx,
|
||||
cmd: &Command,
|
||||
) {
|
||||
let tts_engine = engine.lock().await.tts_engine.clone();
|
||||
let mut tts = tts_engine.lock().await;
|
||||
match tts.generate(&cmd.params).await {
|
||||
Ok(result) => respond_ok(out_tx, &cmd.id, result),
|
||||
|
||||
@@ -39,6 +39,10 @@ pub struct RtpPacket {
|
||||
/// RTP sequence number for reordering.
|
||||
pub seq: u16,
|
||||
/// RTP timestamp from the original packet header.
|
||||
///
|
||||
/// Set on inbound RTP but not yet consumed downstream — reserved for
|
||||
/// future jitter/sync work in the mixer.
|
||||
#[allow(dead_code)]
|
||||
pub timestamp: u32,
|
||||
}
|
||||
|
||||
@@ -136,8 +140,6 @@ pub enum MixerCommand {
|
||||
timeout_ms: u32,
|
||||
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.
|
||||
AddToolLeg {
|
||||
@@ -295,16 +297,6 @@ async fn mixer_loop(
|
||||
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 {
|
||||
leg_id,
|
||||
tool_type,
|
||||
|
||||
@@ -331,17 +331,6 @@ impl ProviderManager {
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -178,5 +178,8 @@ impl Recorder {
|
||||
pub struct RecordingResult {
|
||||
pub file_path: String,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -19,11 +19,19 @@ const MAX_EXPIRES: u32 = 300;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegisteredDevice {
|
||||
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,
|
||||
#[allow(dead_code)]
|
||||
pub extension: String,
|
||||
pub contact_addr: SocketAddr,
|
||||
#[allow(dead_code)]
|
||||
pub registered_at: Instant,
|
||||
pub expires_at: Instant,
|
||||
#[allow(dead_code)]
|
||||
pub aor: String,
|
||||
}
|
||||
|
||||
@@ -134,11 +142,6 @@ impl Registrar {
|
||||
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.
|
||||
pub fn find_by_address(&self, addr: &SocketAddr) -> Option<&RegisteredDevice> {
|
||||
let ip = addr.ip().to_string();
|
||||
@@ -146,26 +149,4 @@ impl Registrar {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
//! Each port gets a bound tokio UdpSocket. Supports:
|
||||
//! - Direct forwarding (SIP-to-SIP, no transcoding)
|
||||
//! - Transcoding forwarding (via codec-lib, e.g. G.722 ↔ Opus)
|
||||
//! - Silence generation
|
||||
//! - NAT priming
|
||||
//! Manages a pool of even-numbered UDP ports for RTP media. `allocate()`
|
||||
//! hands back an `Arc<UdpSocket>` to the caller (stored on the owning
|
||||
//! `LegInfo`), while the pool itself keeps only a `Weak<UdpSocket>`. When
|
||||
//! the call terminates and `LegInfo` is dropped, the strong refcount
|
||||
//! reaches zero, the socket is closed, and `allocate()` prunes the dead
|
||||
//! 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::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Weak};
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
/// A single RTP port allocation.
|
||||
@@ -24,7 +26,7 @@ pub struct RtpAllocation {
|
||||
pub struct RtpPortPool {
|
||||
min: u16,
|
||||
max: u16,
|
||||
allocated: HashMap<u16, Arc<UdpSocket>>,
|
||||
allocated: HashMap<u16, Weak<UdpSocket>>,
|
||||
}
|
||||
|
||||
impl RtpPortPool {
|
||||
@@ -41,11 +43,19 @@ impl RtpPortPool {
|
||||
pub async fn allocate(&mut self) -> Option<RtpAllocation> {
|
||||
let mut port = self.min;
|
||||
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) {
|
||||
match UdpSocket::bind(format!("0.0.0.0:{port}")).await {
|
||||
Ok(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 });
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -57,83 +67,6 @@ impl RtpPortPool {
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -16,7 +16,6 @@ use sip_proto::helpers::{
|
||||
};
|
||||
use sip_proto::message::{RequestOptions, SipMessage};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
/// State of a SIP leg.
|
||||
@@ -40,6 +39,9 @@ pub struct SipLegConfig {
|
||||
/// SIP target endpoint (provider outbound proxy or device address).
|
||||
pub sip_target: SocketAddr,
|
||||
/// 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 password: Option<String>,
|
||||
pub registered_aor: Option<String>,
|
||||
@@ -51,6 +53,10 @@ pub struct SipLegConfig {
|
||||
|
||||
/// A SIP leg with full dialog management.
|
||||
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 state: LegState,
|
||||
pub config: SipLegConfig,
|
||||
@@ -411,11 +417,6 @@ impl SipLeg {
|
||||
dialog.terminate();
|
||||
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.
|
||||
|
||||
@@ -27,22 +27,6 @@ impl SipTransport {
|
||||
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.
|
||||
pub fn spawn_receiver<F>(
|
||||
&self,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
//! 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
|
||||
//! present, the generate command returns an error and the TS side falls back
|
||||
//! to espeak-ng.
|
||||
//! present, the generate command returns an error and the caller skips the prompt.
|
||||
//!
|
||||
//! 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 kokoro_tts::{KokoroTts, Voice};
|
||||
use std::path::Path;
|
||||
@@ -32,6 +37,8 @@ impl TtsEngine {
|
||||
/// - `voice`: voice name (e.g. "af_bella")
|
||||
/// - `text`: text to synthesize
|
||||
/// - `output`: output WAV file path
|
||||
/// - `cacheable`: if true, skip synthesis when the output WAV already
|
||||
/// 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")?;
|
||||
@@ -43,11 +50,19 @@ impl TtsEngine {
|
||||
.ok_or("missing 'text' param")?;
|
||||
let output_path = params.get("output").and_then(|v| v.as_str())
|
||||
.ok_or("missing 'output' param")?;
|
||||
let cacheable = params.get("cacheable").and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if text.is_empty() {
|
||||
return Err("empty text".into());
|
||||
}
|
||||
|
||||
// Cache check: if cacheable and the sidecar matches, return immediately.
|
||||
if cacheable && self.is_cache_hit(output_path, text, voice_name) {
|
||||
eprintln!("[tts] cache hit: {output_path}");
|
||||
return Ok(serde_json::json!({ "output": output_path }));
|
||||
}
|
||||
|
||||
// Check that model/voices files exist.
|
||||
if !Path::new(model_path).exists() {
|
||||
return Err(format!("model not found: {model_path}"));
|
||||
@@ -56,6 +71,11 @@ impl TtsEngine {
|
||||
return Err(format!("voices not found: {voices_path}"));
|
||||
}
|
||||
|
||||
// Ensure parent directory exists.
|
||||
if let Some(parent) = Path::new(output_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
// Lazy-load or reload if paths changed.
|
||||
if self.tts.is_none()
|
||||
|| self.loaded_model_path != model_path
|
||||
@@ -95,9 +115,41 @@ impl TtsEngine {
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
eprintln!("[tts] wrote {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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map voice name string to Kokoro Voice enum variant.
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.19.0',
|
||||
version: '1.22.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
26
ts/config.ts
26
ts/config.ts
@@ -8,6 +8,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { IVoiceboxConfig } from './voicebox.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared types (previously in ts/sip/types.ts, now inlined)
|
||||
@@ -160,24 +161,13 @@ export interface IContact {
|
||||
// Voicebox configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IVoiceboxConfig {
|
||||
/** Unique ID — typically matches device ID or extension. */
|
||||
id: string;
|
||||
/** Whether this voicebox is active. */
|
||||
enabled: boolean;
|
||||
/** Custom TTS greeting text. */
|
||||
greetingText?: string;
|
||||
/** 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;
|
||||
}
|
||||
// Canonical definition lives in voicebox.ts (imported at the top of this
|
||||
// file) — re-exported here so consumers can import everything from a
|
||||
// single config module without pulling in the voicebox implementation.
|
||||
// This used to be a duplicated interface and caused
|
||||
// "number | undefined is not assignable to number" type errors when
|
||||
// passing config.voiceboxes into VoiceboxManager.init().
|
||||
export type { IVoiceboxConfig };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IVR configuration
|
||||
|
||||
@@ -41,6 +41,14 @@ type TProxyCommands = {
|
||||
params: { call_id: string };
|
||||
result: { file_path: string; duration_ms: number };
|
||||
};
|
||||
add_leg: {
|
||||
params: { call_id: string; number: string; provider_id?: string };
|
||||
result: { leg_id: string };
|
||||
};
|
||||
remove_leg: {
|
||||
params: { call_id: string; leg_id: string };
|
||||
result: Record<string, never>;
|
||||
};
|
||||
add_device_leg: {
|
||||
params: { call_id: string; device_id: string };
|
||||
result: { leg_id: string };
|
||||
@@ -80,9 +88,37 @@ type TProxyCommands = {
|
||||
result: Record<string, never>;
|
||||
};
|
||||
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 };
|
||||
};
|
||||
// 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: {
|
||||
session_id: string;
|
||||
candidate: string;
|
||||
sdp_mid?: string;
|
||||
sdp_mline_index?: number;
|
||||
};
|
||||
result: Record<string, never>;
|
||||
};
|
||||
webrtc_link: {
|
||||
params: {
|
||||
session_id: string;
|
||||
call_id: string;
|
||||
provider_media_addr: string;
|
||||
provider_media_port: number;
|
||||
sip_pt?: number;
|
||||
};
|
||||
result: Record<string, never>;
|
||||
};
|
||||
webrtc_close: {
|
||||
params: { session_id: string };
|
||||
result: Record<string, never>;
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -94,6 +130,11 @@ export interface IIncomingCallEvent {
|
||||
from_uri: string;
|
||||
to_number: string;
|
||||
provider_id: string;
|
||||
/** Whether registered browsers should see a `webrtc-incoming` toast for
|
||||
* this call. Set by the Rust engine from the matched inbound route's
|
||||
* `ringBrowsers` flag (defaults to `true` when no route matches, so
|
||||
* deployments without explicit routes preserve pre-routing behavior). */
|
||||
ring_browsers?: boolean;
|
||||
}
|
||||
|
||||
export interface IOutboundCallEvent {
|
||||
@@ -134,8 +175,22 @@ let logFn: ((msg: string) => void) | undefined;
|
||||
|
||||
function buildLocalPaths(): string[] {
|
||||
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 [
|
||||
// 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'),
|
||||
// 3. Direct cargo builds for dev iteration.
|
||||
path.join(root, 'rust', 'target', 'release', 'proxy-engine'),
|
||||
path.join(root, 'rust', 'target', 'debug', 'proxy-engine'),
|
||||
];
|
||||
@@ -503,7 +558,7 @@ export async function sendProxyCommand<K extends keyof TProxyCommands>(
|
||||
params: TProxyCommands[K]['params'],
|
||||
): Promise<TProxyCommands[K]['result']> {
|
||||
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. */
|
||||
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
getAllBrowserDeviceIds,
|
||||
getBrowserDeviceWs,
|
||||
} from './webrtcbridge.ts';
|
||||
import { initAnnouncement } from './announcement.ts';
|
||||
import { PromptCache } from './call/prompt-cache.ts';
|
||||
import { VoiceboxManager } from './voicebox.ts';
|
||||
import {
|
||||
initProxyEngine,
|
||||
@@ -170,7 +168,6 @@ for (const d of appConfig.devices) {
|
||||
// Initialize subsystems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const promptCache = new PromptCache(log);
|
||||
const voiceboxManager = new VoiceboxManager(log);
|
||||
voiceboxManager.init(appConfig.voiceboxes ?? []);
|
||||
|
||||
@@ -273,15 +270,23 @@ async function startProxyEngine(): Promise<void> {
|
||||
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,
|
||||
});
|
||||
// Notify browsers of the incoming call, but only if the matched inbound
|
||||
// route asked for it. `ring_browsers !== false` preserves today's
|
||||
// ring-by-default behavior for any Rust release that predates this
|
||||
// field or for the fallback "no route matched" case (where Rust still
|
||||
// sends `true`). Note: this is an informational toast — browsers do
|
||||
// NOT race the SIP device to answer. First-to-answer-wins requires
|
||||
// a multi-leg fork which is not yet implemented.
|
||||
if (data.ring_browsers !== false) {
|
||||
const browserIds = getAllBrowserDeviceIds();
|
||||
for (const bid of browserIds) {
|
||||
sendToBrowserDevice(bid, {
|
||||
type: 'webrtc-incoming',
|
||||
callId: data.call_id,
|
||||
from: data.from_uri,
|
||||
deviceId: bid,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -493,7 +498,7 @@ async function startProxyEngine(): Promise<void> {
|
||||
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', {
|
||||
voiceboxManager.addMessage('default', {
|
||||
callerNumber: data.caller_number || 'Unknown',
|
||||
callerName: null,
|
||||
fileName: data.file_path,
|
||||
@@ -511,6 +516,8 @@ async function startProxyEngine(): Promise<void> {
|
||||
providers: appConfig.providers,
|
||||
devices: appConfig.devices,
|
||||
routing: appConfig.routing,
|
||||
voiceboxes: appConfig.voiceboxes ?? [],
|
||||
ivr: appConfig.ivr,
|
||||
});
|
||||
|
||||
if (!configured) {
|
||||
@@ -522,31 +529,8 @@ async function startProxyEngine(): Promise<void> {
|
||||
const deviceList = appConfig.devices.map((d) => d.displayName).join(', ');
|
||||
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}`);
|
||||
}
|
||||
// TTS prompts (voicemail greetings, IVR menus) are generated on-demand
|
||||
// by the Rust TTS engine when first needed. No startup pre-generation.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -612,6 +596,8 @@ initWebUi(
|
||||
providers: fresh.providers,
|
||||
devices: fresh.devices,
|
||||
routing: fresh.routing,
|
||||
voiceboxes: fresh.voiceboxes ?? [],
|
||||
ivr: fresh.ivr,
|
||||
}).then((ok) => {
|
||||
if (ok) log('[config] reloaded — proxy engine reconfigured');
|
||||
else log('[config] reload failed — proxy engine rejected config');
|
||||
|
||||
@@ -29,12 +29,14 @@ export interface IVoiceboxConfig {
|
||||
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;
|
||||
/** Seconds to wait before routing to voicemail. Defaults to 25 when
|
||||
* absent — both the config loader and `VoiceboxManager.init` apply
|
||||
* the default via `??=`. */
|
||||
noAnswerTimeoutSec?: number;
|
||||
/** Maximum recording duration in seconds. Defaults to 120. */
|
||||
maxRecordingSec?: number;
|
||||
/** Maximum stored messages per box. Defaults to 50. */
|
||||
maxMessages?: number;
|
||||
}
|
||||
|
||||
export interface IVoicemailMessage {
|
||||
@@ -148,6 +150,35 @@ export class VoiceboxManager {
|
||||
// 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.
|
||||
* The WAV file should already exist at the expected path.
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.19.0',
|
||||
version: '1.22.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const VIEW_TABS = [
|
||||
{ name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
|
||||
{ name: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes },
|
||||
{ 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: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
|
||||
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },
|
||||
|
||||
@@ -422,7 +422,7 @@ export class SipproxyViewCalls extends DeesElement {
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Transfer',
|
||||
iconName: 'lucide:arrow-right-left',
|
||||
iconName: 'lucide:ArrowRightLeft',
|
||||
action: async (modalRef: any) => {
|
||||
if (!targetCallId || !targetLegId) {
|
||||
deesCatalog.DeesToast.error('Please select both a target call and a leg');
|
||||
@@ -620,7 +620,7 @@ export class SipproxyViewCalls extends DeesElement {
|
||||
title: 'Inbound',
|
||||
value: inboundCount,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone-incoming',
|
||||
icon: 'lucide:PhoneIncoming',
|
||||
description: 'Incoming calls',
|
||||
},
|
||||
{
|
||||
@@ -628,7 +628,7 @@ export class SipproxyViewCalls extends DeesElement {
|
||||
title: 'Outbound',
|
||||
value: outboundCount,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone-outgoing',
|
||||
icon: 'lucide:PhoneOutgoing',
|
||||
description: 'Outgoing calls',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -140,7 +140,7 @@ export class SipproxyViewIvr extends DeesElement {
|
||||
title: 'Total Menus',
|
||||
value: ivr.menus.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:list-tree',
|
||||
icon: 'lucide:ListTree',
|
||||
description: 'IVR menu definitions',
|
||||
},
|
||||
{
|
||||
@@ -148,7 +148,7 @@ export class SipproxyViewIvr extends DeesElement {
|
||||
title: 'Entry Menu',
|
||||
value: entryMenu?.name || '(none)',
|
||||
type: 'text' as any,
|
||||
icon: 'lucide:door-open',
|
||||
icon: 'lucide:DoorOpen',
|
||||
description: entryMenu ? `ID: ${entryMenu.id}` : 'No entry menu set',
|
||||
},
|
||||
{
|
||||
@@ -156,7 +156,7 @@ export class SipproxyViewIvr extends DeesElement {
|
||||
title: 'Status',
|
||||
value: ivr.enabled ? 'Enabled' : 'Disabled',
|
||||
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%)',
|
||||
description: ivr.enabled ? 'IVR is active' : 'IVR is inactive',
|
||||
},
|
||||
@@ -228,7 +228,7 @@ export class SipproxyViewIvr extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Set as Entry',
|
||||
iconName: 'lucide:door-open' as any,
|
||||
iconName: 'lucide:DoorOpen' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
||||
await this.setEntryMenu(item.id);
|
||||
@@ -236,7 +236,7 @@ export class SipproxyViewIvr extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2' as any,
|
||||
iconName: 'lucide:Trash2' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
||||
await this.confirmDeleteMenu(item);
|
||||
@@ -295,7 +295,7 @@ export class SipproxyViewIvr extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
iconName: 'lucide:Trash2',
|
||||
action: async (modalRef: any) => {
|
||||
const ivr = this.getIvrConfig();
|
||||
const menus = ivr.menus.filter((m) => m.id !== menu.id);
|
||||
|
||||
@@ -107,7 +107,7 @@ export class SipproxyViewOverview extends DeesElement {
|
||||
title: 'Inbound Calls',
|
||||
value: inboundCalls,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone-incoming',
|
||||
icon: 'lucide:PhoneIncoming',
|
||||
description: 'Currently active',
|
||||
},
|
||||
{
|
||||
@@ -115,7 +115,7 @@ export class SipproxyViewOverview extends DeesElement {
|
||||
title: 'Outbound Calls',
|
||||
value: outboundCalls,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone-outgoing',
|
||||
icon: 'lucide:PhoneOutgoing',
|
||||
description: 'Currently active',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -86,7 +86,7 @@ export class SipproxyViewProviders extends DeesElement {
|
||||
title: 'Registered',
|
||||
value: registered,
|
||||
type: 'number',
|
||||
icon: 'lucide:check-circle',
|
||||
icon: 'lucide:CheckCircle',
|
||||
color: 'hsl(142.1 76.2% 36.3%)',
|
||||
description: 'Active registrations',
|
||||
},
|
||||
@@ -95,7 +95,7 @@ export class SipproxyViewProviders extends DeesElement {
|
||||
title: 'Unregistered',
|
||||
value: unregistered,
|
||||
type: 'number',
|
||||
icon: 'lucide:alert-circle',
|
||||
icon: 'lucide:AlertCircle',
|
||||
color: unregistered > 0 ? 'hsl(0 84.2% 60.2%)' : undefined,
|
||||
description: unregistered > 0 ? 'Needs attention' : 'All healthy',
|
||||
},
|
||||
@@ -153,7 +153,7 @@ export class SipproxyViewProviders extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
iconName: 'lucide:Trash2',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.confirmDelete(actionData.item);
|
||||
@@ -164,173 +164,346 @@ export class SipproxyViewProviders extends DeesElement {
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.openAddModal();
|
||||
},
|
||||
},
|
||||
{
|
||||
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');
|
||||
await this.openAddStepper();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- add provider modal --------------------------------------------------
|
||||
// ---- add provider stepper ------------------------------------------------
|
||||
|
||||
private async openAddModal(
|
||||
template?: typeof PROVIDER_TEMPLATES.sipgate,
|
||||
templateName?: string,
|
||||
) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
private async openAddStepper() {
|
||||
const { DeesStepper } = await import('@design.estate/dees-catalog');
|
||||
type TDeesStepper = InstanceType<typeof DeesStepper>;
|
||||
// IStep / menuOptions types: we keep content typing loose (`any[]`) to
|
||||
// avoid having to import tsclass IMenuItem just for one parameter annotation.
|
||||
|
||||
const formData = {
|
||||
displayName: templateName || '',
|
||||
domain: template?.domain || '',
|
||||
outboundProxyAddress: template?.outboundProxy?.address || '',
|
||||
outboundProxyPort: String(template?.outboundProxy?.port ?? 5060),
|
||||
type TProviderType = 'Custom' | 'Sipgate' | 'O2/Alice';
|
||||
interface IAccumulator {
|
||||
providerType: TProviderType;
|
||||
displayName: string;
|
||||
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: '',
|
||||
password: '',
|
||||
registerIntervalSec: String(template?.registerIntervalSec ?? 300),
|
||||
codecs: template?.codecs ? template.codecs.join(', ') : '9, 0, 8, 101',
|
||||
earlyMediaSilence: template?.quirks?.earlyMediaSilence ?? false,
|
||||
registerIntervalSec: '300',
|
||||
codecs: '9, 0, 8, 101',
|
||||
earlyMediaSilence: false,
|
||||
};
|
||||
|
||||
const heading = template
|
||||
? `Add ${templateName} Provider`
|
||||
: 'Add Provider';
|
||||
// Snapshot the currently-selected step's form (if any) into accumulator.
|
||||
const snapshotActiveForm = async (stepper: TDeesStepper) => {
|
||||
const form = stepper.activeForm;
|
||||
if (!form) return;
|
||||
const data: Record<string, any> = await form.collectFormData();
|
||||
Object.assign(accumulator, data);
|
||||
};
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading,
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
// Overwrite template-owned fields. Keep user-owned fields (username,
|
||||
// password) untouched. displayName is replaced only when empty or still
|
||||
// holds a branded auto-fill.
|
||||
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`
|
||||
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
||||
<dees-form>
|
||||
<dees-input-text
|
||||
.key=${'displayName'}
|
||||
.label=${'Display Name'}
|
||||
.value=${formData.displayName}
|
||||
@input=${(e: Event) => { formData.displayName = (e.target as any).value; }}
|
||||
.value=${accumulator.displayName}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'domain'}
|
||||
.label=${'Domain'}
|
||||
.value=${formData.domain}
|
||||
@input=${(e: Event) => { formData.domain = (e.target as any).value; }}
|
||||
.value=${accumulator.domain}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'outboundProxyAddress'}
|
||||
.label=${'Outbound Proxy Address'}
|
||||
.value=${formData.outboundProxyAddress}
|
||||
@input=${(e: Event) => { formData.outboundProxyAddress = (e.target as any).value; }}
|
||||
.value=${accumulator.outboundProxyAddress}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'outboundProxyPort'}
|
||||
.label=${'Outbound Proxy Port'}
|
||||
.value=${formData.outboundProxyPort}
|
||||
@input=${(e: Event) => { formData.outboundProxyPort = (e.target as any).value; }}
|
||||
.value=${accumulator.outboundProxyPort}
|
||||
></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
|
||||
.key=${'username'}
|
||||
.label=${'Username / Auth ID'}
|
||||
.value=${formData.username}
|
||||
@input=${(e: Event) => { formData.username = (e.target as any).value; }}
|
||||
.value=${accumulator.username}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'password'}
|
||||
.label=${'Password'}
|
||||
.isPasswordBool=${true}
|
||||
.value=${formData.password}
|
||||
@input=${(e: Event) => { formData.password = (e.target as any).value; }}
|
||||
.value=${accumulator.password}
|
||||
.required=${true}
|
||||
></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
|
||||
.key=${'registerIntervalSec'}
|
||||
.label=${'Register Interval (sec)'}
|
||||
.value=${formData.registerIntervalSec}
|
||||
@input=${(e: Event) => { formData.registerIntervalSec = (e.target as any).value; }}
|
||||
.value=${accumulator.registerIntervalSec}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'codecs'}
|
||||
.label=${'Codecs (comma-separated payload types)'}
|
||||
.value=${formData.codecs}
|
||||
@input=${(e: Event) => { formData.codecs = (e.target as any).value; }}
|
||||
.value=${accumulator.codecs}
|
||||
></dees-input-text>
|
||||
<dees-input-checkbox
|
||||
.key=${'earlyMediaSilence'}
|
||||
.label=${'Early Media Silence (quirk)'}
|
||||
.value=${formData.earlyMediaSilence}
|
||||
@newValue=${(e: CustomEvent) => { formData.earlyMediaSilence = e.detail; }}
|
||||
.value=${accumulator.earlyMediaSilence}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => {
|
||||
modalRef.destroy();
|
||||
name: 'Continue',
|
||||
iconName: 'lucide:arrow-right',
|
||||
action: async (stepper: TDeesStepper) => {
|
||||
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();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:check',
|
||||
action: async (modalRef: any) => {
|
||||
if (!formData.displayName.trim() || !formData.domain.trim()) {
|
||||
deesCatalog.DeesToast.error('Display name and domain are required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const providerId = slugify(formData.displayName);
|
||||
const codecs = formData.codecs
|
||||
],
|
||||
});
|
||||
|
||||
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 Provider',
|
||||
iconName: 'lucide:check',
|
||||
action: async (stepper: TDeesStepper) => {
|
||||
// Collision-resolve id against current state.
|
||||
const existing = (this.appData.providers || []).map((p) => p.id);
|
||||
let uniqueId = resolvedId;
|
||||
let suffix = 2;
|
||||
while (existing.includes(uniqueId)) {
|
||||
uniqueId = `${resolvedId}-${suffix++}`;
|
||||
}
|
||||
|
||||
const parsedCodecs = accumulator.codecs
|
||||
.split(',')
|
||||
.map((s: string) => parseInt(s.trim(), 10))
|
||||
.filter((n: number) => !isNaN(n));
|
||||
|
||||
const newProvider: any = {
|
||||
id: providerId,
|
||||
displayName: formData.displayName.trim(),
|
||||
domain: formData.domain.trim(),
|
||||
id: uniqueId,
|
||||
displayName: accumulator.displayName.trim(),
|
||||
domain: accumulator.domain.trim(),
|
||||
outboundProxy: {
|
||||
address: formData.outboundProxyAddress.trim() || formData.domain.trim(),
|
||||
port: parseInt(formData.outboundProxyPort, 10) || 5060,
|
||||
address:
|
||||
accumulator.outboundProxyAddress.trim() || accumulator.domain.trim(),
|
||||
port: parseInt(accumulator.outboundProxyPort, 10) || 5060,
|
||||
},
|
||||
username: formData.username.trim(),
|
||||
password: formData.password,
|
||||
registerIntervalSec: parseInt(formData.registerIntervalSec, 10) || 300,
|
||||
codecs,
|
||||
username: accumulator.username.trim(),
|
||||
password: accumulator.password,
|
||||
registerIntervalSec: parseInt(accumulator.registerIntervalSec, 10) || 300,
|
||||
codecs: parsedCodecs.length ? parsedCodecs : [9, 0, 8, 101],
|
||||
quirks: {
|
||||
earlyMediaSilence: formData.earlyMediaSilence,
|
||||
earlyMediaSilence: accumulator.earlyMediaSilence,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await appState.apiSaveConfig({
|
||||
addProvider: newProvider,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
modalRef.destroy();
|
||||
deesCatalog.DeesToast.success(`Provider "${formData.displayName}" created`);
|
||||
} else {
|
||||
deesCatalog.DeesToast.error('Failed to save provider');
|
||||
try {
|
||||
const result = await appState.apiSaveConfig({
|
||||
addProvider: newProvider,
|
||||
});
|
||||
if (result.ok) {
|
||||
await stepper.destroy();
|
||||
deesCatalog.DeesToast.success(
|
||||
`Provider "${newProvider.displayName}" created`,
|
||||
);
|
||||
} else {
|
||||
deesCatalog.DeesToast.error('Failed to save provider');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to create provider:', err);
|
||||
deesCatalog.DeesToast.error('Failed to create provider');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to create provider:', err);
|
||||
deesCatalog.DeesToast.error('Failed to create provider');
|
||||
}
|
||||
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 -------------------------------------------------
|
||||
@@ -579,7 +752,7 @@ export class SipproxyViewProviders extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
iconName: 'lucide:Trash2',
|
||||
action: async (modalRef: any) => {
|
||||
try {
|
||||
const result = await appState.apiSaveConfig({
|
||||
|
||||
@@ -239,7 +239,7 @@ export class SipproxyViewVoicemail extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
iconName: 'lucide:Trash2',
|
||||
action: async (modalRef: any) => {
|
||||
try {
|
||||
await fetch(
|
||||
@@ -281,7 +281,7 @@ export class SipproxyViewVoicemail extends DeesElement {
|
||||
title: 'Unheard Messages',
|
||||
value: unheard,
|
||||
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%)',
|
||||
description: unheard > 0 ? 'Needs attention' : 'All caught up',
|
||||
},
|
||||
@@ -372,7 +372,7 @@ export class SipproxyViewVoicemail extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
iconName: 'lucide:Trash2',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.deleteMessage(actionData.item as IVoicemailMessage);
|
||||
|
||||
Reference in New Issue
Block a user