Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ed76a9488 | |||
| a9fdfe5733 | |||
| 6fcdf4291a | |||
| 81441e7853 | |||
| 21ffc1d017 | |||
| 2f16c5efae | |||
| 254d7f3633 | |||
| 67537664df | |||
| 54129dcdae | |||
| 8c6556dae3 | |||
| 291beb1da4 | |||
| 79147f1e40 | |||
| c3a63a4092 | |||
| 7c4756402e | |||
| b6950e11d2 | |||
| e4935fbf21 | |||
| f543ff1568 | |||
| c63a759689 | |||
| a02146633b | |||
| f78639dd19 | |||
| 2aca5f1510 | |||
| 73b28f5f57 |
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
|
"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
|
# Project Notes
|
||||||
|
|
||||||
## Architecture: Hub Model (Call as Centerpiece)
|
## Architecture: Hub Model in Rust (Call as Centerpiece)
|
||||||
|
|
||||||
All call logic lives in `ts/call/`. The Call is the central entity with N legs.
|
The call hub lives in the Rust proxy-engine (`rust/crates/proxy-engine/`). TypeScript is the **control plane only** — it configures the engine, sends high-level commands (`hangup`, `make_call`, `webrtc_offer`, etc.), and receives events (`incoming_call`, `call_answered`, `device_registered`, `webrtc_audio_rx`, …). No raw SIP/RTP ever touches TypeScript.
|
||||||
|
|
||||||
### Key Files
|
The `Call` is still the central entity: it owns N legs and a central mixer task that provides mix-minus audio to all participants. Legs can be `SipProvider`, `SipDevice`, `WebRtc`, or `Tool` (recording/transcription observer).
|
||||||
- `ts/call/call-manager.ts` — singleton registry, factory methods, SIP routing
|
|
||||||
- `ts/call/call.ts` — the hub: owns legs, media forwarding
|
|
||||||
- `ts/call/sip-leg.ts` — SIP device/provider connection (wraps SipDialog)
|
|
||||||
- `ts/call/webrtc-leg.ts` — browser WebRTC connection (wraps werift PeerConnection)
|
|
||||||
- `ts/call/rtp-port-pool.ts` — unified RTP port pool
|
|
||||||
- `ts/sipproxy.ts` — thin bootstrap wiring everything together
|
|
||||||
- `ts/webrtcbridge.ts` — browser device registration (signaling only)
|
|
||||||
|
|
||||||
### WebRTC Browser Call Flow (Critical)
|
### Key Rust files (`rust/crates/proxy-engine/src/`)
|
||||||
|
|
||||||
The browser call flow has a specific signaling order that MUST be followed:
|
- `call_manager.rs` — singleton registry, call factory methods, SIP routing (inbound/outbound/passthrough), B2BUA state machine, inbound route resolution
|
||||||
|
- `call.rs` — the `Call` hub + `LegInfo` struct, owns legs and the mixer task
|
||||||
|
- `sip_leg.rs` — full SIP dialog management for B2BUA legs (INVITE, 407 auth retry, BYE, CANCEL, early media)
|
||||||
|
- `rtp.rs` — RTP port pool (uses `Weak<UdpSocket>` so calls auto-release ports on drop) + RTP header helpers
|
||||||
|
- `mixer.rs` — 20 ms-tick mix-minus engine (48 kHz f32 internal, per-leg transcoding via `codec-lib`, per-leg denoising)
|
||||||
|
- `jitter_buffer.rs` — per-leg reordering/packet-loss compensation
|
||||||
|
- `leg_io.rs` — spawns inbound/outbound RTP I/O tasks per SIP leg
|
||||||
|
- `webrtc_engine.rs` — browser WebRTC sessions (werift-rs based), ICE/DTLS/SRTP
|
||||||
|
- `provider.rs` — SIP trunk registrations, public-IP detection via Via `received=`
|
||||||
|
- `registrar.rs` — accepts REGISTER from SIP phones, tracks contacts (push-based device status)
|
||||||
|
- `config.rs` — `AppConfig` deserialized from TS, route resolvers (`resolve_outbound_route`, `resolve_inbound_route`)
|
||||||
|
- `main.rs` — IPC command dispatcher (`handle_command`), event emitter, top-level SIP packet router
|
||||||
|
- `sip_transport.rs` — owning wrapper around the main SIP UDP socket
|
||||||
|
- `voicemail.rs` / `recorder.rs` / `audio_player.rs` / `tts.rs` — media subsystems
|
||||||
|
- `tool_leg.rs` — per-source observer audio for recording/transcription tools
|
||||||
|
- `ipc.rs` — event-emission helper used throughout
|
||||||
|
|
||||||
1. `POST /api/call` with browser deviceId → CallManager creates Call, saves pending state, notifies browser via `webrtc-incoming`
|
### Key TS files (control plane)
|
||||||
2. Browser sends `webrtc-offer` (with its own `sessionId`) → CallManager creates a **standalone** WebRtcLeg (NOT attached to any call yet)
|
|
||||||
3. Browser sends `webrtc-accept` (with `callId` + `sessionId`) → CallManager links the standalone WebRtcLeg to the Call, then starts the SIP provider leg
|
|
||||||
|
|
||||||
**The WebRtcLeg CANNOT be created at call creation time** because the browser's session ID is unknown until the `webrtc-offer` arrives.
|
- `ts/sipproxy.ts` — entrypoint, wires the proxy engine bridge + web UI + WebRTC signaling
|
||||||
|
- `ts/proxybridge.ts` — `@push.rocks/smartrust` bridge to the Rust binary, typed `TProxyCommands` map
|
||||||
|
- `ts/config.ts` — JSON config loader (`IAppConfig`, `IProviderConfig`, etc.), sent to Rust via `configure`
|
||||||
|
- `ts/voicebox.ts` — voicemail metadata persistence (WAV files live in `.nogit/voicemail/{boxId}/`)
|
||||||
|
- `ts/webrtcbridge.ts` — browser WebSocket signaling, browser device registry (`deviceIdToWs`)
|
||||||
|
- `ts/call/prompt-cache.ts` — the only remaining file under `ts/call/` (IVR prompt caching)
|
||||||
|
|
||||||
### WebRTC Audio Return Channel (Critical)
|
### Rust SIP protocol library
|
||||||
|
|
||||||
The SIP→browser audio path works through the Call hub:
|
`rust/crates/sip-proto/` is a zero-dependency SIP data library (parse/build/mutate/serialize messages, dialog management, SDP helpers, digest auth). Do not add transport or timer logic there — it's purely data-level.
|
||||||
|
|
||||||
1. Provider sends RTP to SipLeg's socket
|
## Event-push architecture for device status
|
||||||
2. SipLeg's `onRtpReceived` fires → Call hub's `forwardRtp`
|
|
||||||
3. Call hub calls `webrtcLeg.sendRtp(data)` → which calls `forwardToBrowser()`
|
|
||||||
4. `forwardToBrowser` transcodes (G.722→Opus) and sends via `sender.sendRtp()` (WebRTC PeerConnection)
|
|
||||||
|
|
||||||
**`WebRtcLeg.sendRtp()` MUST feed into `forwardToBrowser()`** (the WebRTC PeerConnection path), NOT send to a UDP address. This was a bug that caused one-way audio.
|
Device status flows **via push events**, not pull-based IPC queries:
|
||||||
|
|
||||||
The browser→SIP direction works independently: `ontrack.onReceiveRtp` → `forwardToSip()` → transcodes → sends directly to provider's media endpoint via UDP.
|
1. Rust emits `device_registered` when a phone REGISTERs
|
||||||
|
2. TS `sipproxy.ts` maintains a `deviceStatuses` Map, updated from the event
|
||||||
|
3. Map snapshot goes into the WebSocket `status` broadcast
|
||||||
|
4. Web UI (`ts_web/elements/sipproxy-devices.ts`) reads it from the push stream
|
||||||
|
|
||||||
### SIP Protocol Library
|
There used to be a `get_status` pull IPC for this, but it was never called from TS and has been removed. If a new dashboard ever needs a pull-based snapshot, the push Map is the right source to read from.
|
||||||
|
|
||||||
`ts/sip/` is a zero-dependency SIP protocol library. Do not add transport or timer logic there — it's purely data-level (parse/build/mutate/serialize).
|
## Inbound routing (wired in Commit 4 of the cleanup PR)
|
||||||
|
|
||||||
|
Inbound route resolution goes through `config.resolve_inbound_route(provider_id, called_number, caller_number)` inside `create_inbound_call` (call_manager.rs). The result carries a `ring_browsers` flag that propagates to the `incoming_call` event; `ts/sipproxy.ts` gates the `webrtc-incoming` browser fan-out behind that flag.
|
||||||
|
|
||||||
|
**Known limitations / TODOs** (documented in code at `create_inbound_call`):
|
||||||
|
- Multi-target inbound fork is not yet implemented — only the first registered device from `route.device_ids` is rung.
|
||||||
|
- `ring_browsers` is **informational only**: browsers see a toast but do not race the SIP device to answer. True first-to-answer-wins requires a multi-leg fork + per-leg CANCEL, which is not built yet.
|
||||||
|
- `voicemail_box`, `ivr_menu_id`, `no_answer_timeout` are resolved but not yet honored downstream.
|
||||||
|
|
||||||
|
## WebRTC Browser Call Flow (Critical)
|
||||||
|
|
||||||
|
The browser call signaling order is strict:
|
||||||
|
|
||||||
|
1. Browser initiates outbound via a TS API (e.g. `POST /api/call`) — TS creates a pending call in the Rust engine via `make_call` and notifies the browser with a `webrtc-incoming` push.
|
||||||
|
2. Browser sends `webrtc-offer` (with its own `sessionId`) → Rust `handle_webrtc_offer` creates a **standalone** WebRTC session (NOT attached to any call yet).
|
||||||
|
3. Browser sends `webrtc_link` (with `callId` + `sessionId`) → Rust links the standalone session to the Call and wires the WebRTC leg through the mixer.
|
||||||
|
|
||||||
|
**The WebRTC leg cannot be fully attached at call-creation time** because the browser's session ID is unknown until the `webrtc-offer` arrives.
|
||||||
|
|
||||||
|
### WebRTC audio return channel (Critical)
|
||||||
|
|
||||||
|
The SIP→browser audio path goes through the mixer, not a direct RTP relay:
|
||||||
|
|
||||||
|
1. Provider sends RTP → received on the provider leg's UDP socket (`leg_io::spawn_sip_inbound`)
|
||||||
|
2. Packet flows through `jitter_buffer` → mixer's inbound mpsc channel
|
||||||
|
3. Mixer decodes/resamples/denoises, computes mix-minus per leg
|
||||||
|
4. WebRTC leg receives its mix-minus frame, encodes to Opus, and pushes via the WebRTC engine's peer connection sender
|
||||||
|
|
||||||
|
Browser→SIP works symmetrically: `ontrack.onReceiveRtp` → WebRTC leg's outbound mpsc → mixer → other legs' inbound channels.
|
||||||
|
|
||||||
|
## SDP/Record-Route NAT (fixed in Commit 3 of the cleanup PR)
|
||||||
|
|
||||||
|
The proxy tracks a `public_ip: Option<String>` on every `LegInfo` (populated from provider-leg construction sites). When `route_passthrough_message` rewrites SDP (`c=` line) or emits a `Record-Route`, it picks `advertise_ip` based on the destination leg's kind:
|
||||||
|
|
||||||
|
- `SipProvider` → `other.public_ip.unwrap_or(lan_ip)` (provider reaches us via public IP)
|
||||||
|
- `SipDevice` / `WebRtc` / `Tool` / `Media` → `lan_ip` (everything else is LAN or proxy-internal)
|
||||||
|
|
||||||
|
This fixed a real NAT-traversal bug where the proxy advertised its RFC1918 LAN IP to the provider in SDP, causing one-way or no audio for device-originated inbound traffic behind NAT.
|
||||||
|
|
||||||
|
## Build & development
|
||||||
|
|
||||||
|
- **Build:** `pnpm run buildRust` (never `cargo build` directly — tsrust cross-compiles for both `x86_64-unknown-linux-gnu` and `aarch64-unknown-linux-gnu`)
|
||||||
|
- **Cross-compile setup:** the aarch64 target requires `gcc-aarch64-linux-gnu` + `libstdc++6-arm64-cross` (Debian/Ubuntu). See `rust/.cargo/config.toml` for the linker wiring. A committed symlink at `rust/.cargo/crosslibs/aarch64/libstdc++.so` → `/usr/aarch64-linux-gnu/lib/libstdc++.so.6` avoids needing the `libstdc++-13-dev-arm64-cross` package.
|
||||||
|
- **Bundle web UI:** `pnpm run bundle` (esbuild, output: `dist_ts_web/bundle.js`)
|
||||||
|
- **Full build:** `pnpm run build` (= `buildRust && bundle`)
|
||||||
|
- **Start server:** `pnpm run start` (runs `tsx ts/sipproxy.ts`)
|
||||||
|
|
||||||
|
## Persistent files
|
||||||
|
|
||||||
|
- `.nogit/config.json` — app config (providers, devices, routes, voiceboxes, IVR menus)
|
||||||
|
- `.nogit/voicemail/{boxId}/` — voicemail WAV files + `messages.json` index
|
||||||
|
- `.nogit/prompts/` — cached TTS prompts for IVR menus
|
||||||
|
|||||||
74
Dockerfile
Normal file
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"]
|
||||||
83
changelog.md
83
changelog.md
@@ -1,5 +1,88 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-11 - 1.20.3 - fix(ts-config,proxybridge,voicebox)
|
||||||
|
align voicebox config types and add missing proxy bridge command definitions
|
||||||
|
|
||||||
|
- Reuses the canonical IVoiceboxConfig type from voicebox.ts in config.ts to eliminate duplicated type definitions and optionality mismatches.
|
||||||
|
- Makes voicemail timing and limits optional in voicebox config so defaults can be applied consistently during initialization.
|
||||||
|
- Adds VoiceboxManager.addMessage and updates recording handling to use it directly for persisted voicemail metadata.
|
||||||
|
- Extends proxy bridge command typings with add_leg, remove_leg, and WebRTC signaling commands, and tightens sendCommand typing.
|
||||||
|
|
||||||
|
## 2026-04-11 - 1.20.2 - fix(proxy-engine)
|
||||||
|
fix inbound route browser ringing and provider-facing SDP advertisement while preventing RTP port exhaustion
|
||||||
|
|
||||||
|
- Honor inbound routing `ringBrowsers` when emitting incoming call events so browser toast notifications can be suppressed per route.
|
||||||
|
- Rewrite SDP and Record-Route using the destination leg's routable address, using `public_ip` for provider legs and LAN IP for device and internal legs.
|
||||||
|
- Store provider leg public IP metadata on legs to support correct per-destination SIP message rewriting.
|
||||||
|
- Change the RTP port pool to track sockets with `Weak<UdpSocket>` so ports are reclaimed automatically after calls end, avoiding leaked allocations and eventual 503 failures on new calls.
|
||||||
|
- Remove unused dashboard/status, DTMF, relay, and transport helper code paths as part of engine cleanup.
|
||||||
|
|
||||||
|
## 2026-04-11 - 1.20.1 - fix(docker)
|
||||||
|
install required native build tools for Rust dependencies in the build image
|
||||||
|
|
||||||
|
- Add cmake and pkg-config to the Docker build stage so Rust native dependencies can compile successfully in the container
|
||||||
|
- Document why these tools are needed for transitive Rust crates that build or detect native libraries
|
||||||
|
|
||||||
|
## 2026-04-11 - 1.20.0 - feat(docker)
|
||||||
|
add multi-arch Docker build and tagged release pipeline
|
||||||
|
|
||||||
|
- Add a production Dockerfile for building and running the SIP router with the Rust proxy engine and web bundle
|
||||||
|
- Configure tsdocker and tsrust for linux/amd64 and linux/arm64 image builds and registry mapping
|
||||||
|
- Add a tag-triggered Gitea workflow to build and push Docker images
|
||||||
|
- Update runtime binary resolution to load architecture-specific Rust artifacts in Docker and CI environments
|
||||||
|
- Add Docker-related package scripts, dependency updates, and ignore rules for container builds
|
||||||
|
|
||||||
|
## 2026-04-11 - 1.19.2 - fix(web-ui)
|
||||||
|
normalize lucide icon names across SIP proxy views
|
||||||
|
|
||||||
|
- Updates icon identifiers to the expected PascalCase lucide format in app navigation, calls, IVR, overview, providers, and voicemail views.
|
||||||
|
- Fixes UI icon rendering for stats cards and action menus such as transfer, delete, status, and call direction indicators.
|
||||||
|
|
||||||
|
## 2026-04-10 - 1.19.1 - fix(readme)
|
||||||
|
refresh documentation for jitter buffering, voicemail, and WebSocket signaling details
|
||||||
|
|
||||||
|
- Add adaptive jitter buffer and packet loss concealment details to the audio pipeline documentation
|
||||||
|
- Document voicemail unheard count and heard-state API endpoints
|
||||||
|
- Update WebSocket event and browser signaling examples to reflect current message types
|
||||||
|
|
||||||
|
## 2026-04-10 - 1.19.0 - feat(proxy-engine,codec-lib)
|
||||||
|
add adaptive RTP jitter buffering with Opus packet loss concealment and stable 20ms resampling
|
||||||
|
|
||||||
|
- introduces a per-leg adaptive jitter buffer in the mixer to reorder RTP packets, gate initial playout, and deliver one frame per 20ms tick
|
||||||
|
- adds Opus PLC support to synthesize missing audio frames when packets are lost, with fade-based fallback handling for non-Opus codecs
|
||||||
|
- updates i16 and f32 resamplers to use canonical 20ms chunks so cached resamplers preserve filter state and avoid variable-size cache thrashing
|
||||||
|
|
||||||
|
## 2026-04-10 - 1.18.0 - feat(readme)
|
||||||
|
expand documentation for voicemail, IVR, audio engine, and API capabilities
|
||||||
|
|
||||||
|
- Updates the feature overview to document voicemail, IVR menus, call recording, enhanced TTS, and the 48kHz float audio engine
|
||||||
|
- Refreshes the architecture section to describe the TypeScript control plane, Rust proxy-engine data plane, and JSON-over-stdio IPC
|
||||||
|
- Clarifies REST API and WebSocket coverage with voicemail endpoints, incoming call events, and refined endpoint descriptions
|
||||||
|
|
||||||
|
## 2026-04-10 - 1.17.2 - fix(proxy-engine)
|
||||||
|
use negotiated SDP payload types when wiring SIP legs and enable default nnnoiseless features for telephony denoising
|
||||||
|
|
||||||
|
- Select the negotiated codec payload type from SDP answers instead of always using the first offered codec
|
||||||
|
- Preserve the device leg's preferred payload type from its own INVITE SDP when attaching it to the mixer
|
||||||
|
- Enable default nnnoiseless features in codec-lib and proxy-engine dependencies
|
||||||
|
|
||||||
|
## 2026-04-10 - 1.17.1 - fix(proxy-engine,codec-lib,sip-proto,ts)
|
||||||
|
preserve negotiated media details and improve RTP audio handling across call legs
|
||||||
|
|
||||||
|
- Use native Opus float encode/decode to avoid unnecessary i16 quantization in the f32 audio path.
|
||||||
|
- Parse full RTP headers including extensions and sequence numbers, then sort inbound packets before decoding to keep codec state stable for out-of-order audio.
|
||||||
|
- Capture negotiated codec payload types from SDP offers and answers and include codec, RTP port, remote media, and metadata in leg_added events.
|
||||||
|
- Emit leg_state_changed and leg_removed events more consistently so the dashboard reflects leg lifecycle updates accurately.
|
||||||
|
|
||||||
|
## 2026-04-10 - 1.17.0 - feat(proxy-engine)
|
||||||
|
upgrade the internal audio bus to 48kHz f32 with per-leg denoising and improve SIP leg routing
|
||||||
|
|
||||||
|
- switch mixer, prompt playback, and tool leg audio handling from 16kHz i16 to 48kHz f32 for higher-quality internal processing
|
||||||
|
- add f32 decode/encode and resampling support plus standalone RNNoise denoiser creation in codec-lib
|
||||||
|
- apply per-leg inbound noise suppression in the mixer before mix-minus generation
|
||||||
|
- fix passthrough call routing by matching the actual leg from the signaling source address when Call-IDs are shared
|
||||||
|
- correct dialed number extraction from bare SIP request URIs by parsing the user part directly
|
||||||
|
|
||||||
## 2026-04-10 - 1.16.0 - feat(proxy-engine)
|
## 2026-04-10 - 1.16.0 - feat(proxy-engine)
|
||||||
integrate Kokoro TTS generation into proxy-engine and simplify TypeScript prompt handling to use cached WAV files
|
integrate Kokoro TTS generation into proxy-engine and simplify TypeScript prompt handling to use cached WAV files
|
||||||
|
|
||||||
|
|||||||
BIN
nogit/voicemail/default/msg-1775840000387.wav
Normal file
BIN
nogit/voicemail/default/msg-1775840000387.wav
Normal file
Binary file not shown.
BIN
nogit/voicemail/default/msg-1775840014276.wav
Normal file
BIN
nogit/voicemail/default/msg-1775840014276.wav
Normal file
Binary file not shown.
BIN
nogit/voicemail/default/msg-1775840439400.wav
Normal file
BIN
nogit/voicemail/default/msg-1775840439400.wav
Normal file
Binary file not shown.
BIN
nogit/voicemail/default/msg-1775840447441.wav
Normal file
BIN
nogit/voicemail/default/msg-1775840447441.wav
Normal file
Binary file not shown.
BIN
nogit/voicemail/default/msg-1775840454835.wav
Normal file
BIN
nogit/voicemail/default/msg-1775840454835.wav
Normal file
Binary file not shown.
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "siprouter",
|
"name": "siprouter",
|
||||||
"version": "1.16.0",
|
"version": "1.20.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"bundle": "node node_modules/.pnpm/esbuild@0.27.7/node_modules/esbuild/bin/esbuild ts_web/index.ts --bundle --format=esm --outfile=dist_ts_web/bundle.js --platform=browser --target=es2022 --minify",
|
"bundle": "node node_modules/.pnpm/esbuild@0.27.7/node_modules/esbuild/bin/esbuild ts_web/index.ts --bundle --format=esm --outfile=dist_ts_web/bundle.js --platform=browser --target=es2022 --minify",
|
||||||
"buildRust": "tsrust",
|
"buildRust": "tsrust",
|
||||||
|
"build": "pnpm run buildRust && pnpm run bundle",
|
||||||
|
"build:docker": "tsdocker build --verbose",
|
||||||
|
"release:docker": "tsdocker push --verbose",
|
||||||
"start": "tsx ts/sipproxy.ts",
|
"start": "tsx ts/sipproxy.ts",
|
||||||
"restartBackground": "pnpm run buildRust && pnpm run bundle; test -f .server.pid && kill $(cat .server.pid) 2>/dev/null; sleep 1; rm -f sip_trace.log proxy.out && nohup tsx ts/sipproxy.ts > proxy.out 2>&1 & echo $! > .server.pid; sleep 2; cat proxy.out"
|
"restartBackground": "pnpm run buildRust && pnpm run bundle; test -f .server.pid && kill $(cat .server.pid) 2>/dev/null; sleep 1; rm -f sip_trace.log proxy.out && nohup tsx ts/sipproxy.ts > proxy.out 2>&1 & echo $! > .server.pid; sleep 2; cat proxy.out"
|
||||||
},
|
},
|
||||||
@@ -14,10 +17,12 @@
|
|||||||
"@design.estate/dees-element": "^2.2.4",
|
"@design.estate/dees-element": "^2.2.4",
|
||||||
"@push.rocks/smartrust": "^1.3.2",
|
"@push.rocks/smartrust": "^1.3.2",
|
||||||
"@push.rocks/smartstate": "^2.3.0",
|
"@push.rocks/smartstate": "^2.3.0",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbundle": "^2.10.0",
|
"@git.zone/tsbundle": "^2.10.0",
|
||||||
|
"@git.zone/tsdocker": "^2.2.4",
|
||||||
"@git.zone/tsrust": "^1.3.2",
|
"@git.zone/tsrust": "^1.3.2",
|
||||||
"@git.zone/tswatch": "^3.3.2",
|
"@git.zone/tswatch": "^3.3.2",
|
||||||
"@types/ws": "^8.18.1"
|
"@types/ws": "^8.18.1"
|
||||||
|
|||||||
650
pnpm-lock.yaml
generated
650
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
326
readme.md
326
readme.md
@@ -1,6 +1,6 @@
|
|||||||
# @serve.zone/siprouter
|
# @serve.zone/siprouter
|
||||||
|
|
||||||
A production-grade **SIP B2BUA + WebRTC bridge** built with TypeScript and Rust. Routes calls between SIP providers, SIP hardware devices, and browser softphones — with real-time codec transcoding, ML noise suppression, neural TTS announcements, and a slick web dashboard.
|
A production-grade **SIP B2BUA + WebRTC bridge** built with TypeScript and Rust. Routes calls between SIP providers, SIP hardware devices, and browser softphones — with real-time codec transcoding, adaptive jitter buffering, ML noise suppression, neural TTS, voicemail, IVR menus, and a slick web dashboard.
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -12,14 +12,17 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
|
|
||||||
siprouter sits between your SIP trunk providers and your endpoints — hardware phones, ATAs, browser softphones — and handles **everything** in between:
|
siprouter sits between your SIP trunk providers and your endpoints — hardware phones, ATAs, browser softphones — and handles **everything** in between:
|
||||||
|
|
||||||
- 📞 **SIP B2BUA** — Terminates and re-originates calls with full RFC 3261 dialog state management
|
- 📞 **SIP B2BUA** — Terminates and re-originates calls with full RFC 3261 dialog state management, digest auth, and SDP negotiation
|
||||||
- 🌐 **WebRTC Bridge** — Browser-based softphone with bidirectional audio to the SIP network
|
- 🌐 **WebRTC Bridge** — Browser-based softphone with bidirectional Opus audio to the SIP network
|
||||||
- 🎛️ **Multi-Provider Trunking** — Register with multiple SIP providers simultaneously (sipgate, easybell, o2, etc.)
|
- 🎛️ **Multi-Provider Trunking** — Register with multiple SIP providers simultaneously (sipgate, easybell, etc.) with automatic failover
|
||||||
- 🔊 **Rust Codec Engine** — Real-time Opus ↔ G.722 ↔ PCMU ↔ PCMA transcoding in native Rust
|
- 🎧 **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
|
||||||
- 🤖 **ML Noise Suppression** — RNNoise denoiser with per-direction state (to SIP / to browser)
|
- 🔀 **N-Leg Mix-Minus Mixer** — Conference-grade mixing with dynamic leg add/remove, transfer, and per-source audio separation
|
||||||
- 🗣️ **Neural TTS** — Kokoro-powered "connecting your call" announcements, pre-encoded for instant playback
|
- 🎯 **Adaptive Jitter Buffer** — Per-leg jitter buffering with sequence-based reordering, adaptive depth (60–120ms), Opus PLC for lost packets, and hold/resume detection
|
||||||
- 🔀 **Hub Model Calls** — N-leg calls with dynamic add/remove, transfer, and RTP fan-out
|
- 📧 **Voicemail** — Configurable voicemail boxes with TTS greetings, recording, and web playback
|
||||||
- 🖥️ **Web Dashboard** — Real-time SPA with live call monitoring, browser phone, contact management, provider config
|
- 🔢 **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
|
||||||
|
- 🎙️ **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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,32 +38,38 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
|
|||||||
┌──────────────────────────────────────┐
|
┌──────────────────────────────────────┐
|
||||||
│ siprouter │
|
│ siprouter │
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────┐ ┌──────────────────┐ │
|
│ TypeScript Control Plane │
|
||||||
│ │ Call Hub │ │ Rust Transcoder │ │
|
│ ┌────────────────────────────────┐ │
|
||||||
│ │ N legs │──│ Opus/G.722/PCM │ │
|
│ │ Config · WebRTC Signaling │ │
|
||||||
│ │ fan-out │ │ + RNNoise │ │
|
│ │ REST API · Web Dashboard │ │
|
||||||
│ └────┬─────┘ └──────────────────┘ │
|
│ │ Voicebox Manager · TTS Cache │ │
|
||||||
│ │ │
|
│ └────────────┬───────────────────┘ │
|
||||||
│ ┌────┴─────┐ ┌──────────────────┐ │
|
│ JSON-over-stdio IPC │
|
||||||
│ │ SIP Stack│ │ Kokoro TTS │ │
|
│ ┌────────────┴───────────────────┐ │
|
||||||
│ │ Dialog SM│ │ (ONNX Runtime) │ │
|
│ │ Rust proxy-engine (data plane) │ │
|
||||||
│ └────┬─────┘ └──────────────────┘ │
|
│ │ │ │
|
||||||
│ │ │
|
│ │ SIP Stack · Dialog SM · Auth │ │
|
||||||
│ ┌────┴──────────────────────────┐ │
|
│ │ Call Manager · N-Leg Mixer │ │
|
||||||
│ │ Local Registrar + Provider │ │
|
│ │ 48kHz f32 Bus · Jitter Buffer │ │
|
||||||
│ │ Registration Engine │ │
|
│ │ Codec Engine · RTP Port Pool │ │
|
||||||
│ └───────────────────────────────┘ │
|
│ │ WebRTC Engine · Kokoro TTS │ │
|
||||||
└──────────┬──────────────┬────────────┘
|
│ │ Voicemail · IVR · Recording │ │
|
||||||
│ │
|
│ └────┬──────────────────┬────────┘ │
|
||||||
┌──────┴──────┐ ┌─────┴──────┐
|
└───────┤──────────────────┤───────────┘
|
||||||
│ SIP Devices │ │ SIP Trunk │
|
│ │
|
||||||
│ (HT801, etc)│ │ Providers │
|
┌──────┴──────┐ ┌──────┴──────┐
|
||||||
└─────────────┘ └────────────┘
|
│ SIP Devices │ │ SIP Trunk │
|
||||||
|
│ (HT801 etc) │ │ Providers │
|
||||||
|
└─────────────┘ └─────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### The Hub Model
|
### 🧠 Key Design Decisions
|
||||||
|
|
||||||
Every call is a **hub** with N legs. Each leg is either a `SipLeg` (hardware device or provider) or a `WebRtcLeg` (browser). RTP flows through the hub — each leg's received audio is forwarded to all other legs, with codec transcoding handled transparently by the Rust engine.
|
- **Hub Model** — Every call is a hub with N legs. Each leg is a `SipLeg` (device/provider) or `WebRtcLeg` (browser). Legs can be dynamically added, removed, or transferred without tearing down the call.
|
||||||
|
- **Rust Data Plane** — All SIP protocol handling, codec transcoding, mixing, and RTP I/O runs in native Rust for real-time performance. TypeScript handles config, signaling, REST API, and dashboard.
|
||||||
|
- **48kHz f32 Internal Bus** — Audio is processed at maximum quality internally. Encoding/decoding to wire format (G.722, PCMU, Opus) happens solely at the leg boundary.
|
||||||
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -70,15 +79,16 @@ Every call is a **hub** with N legs. Each leg is either a `SipLeg` (hardware dev
|
|||||||
|
|
||||||
- **Node.js** ≥ 20 with `tsx` globally available
|
- **Node.js** ≥ 20 with `tsx` globally available
|
||||||
- **pnpm** for package management
|
- **pnpm** for package management
|
||||||
- **Rust** toolchain (for building the codec engine and TTS)
|
- **Rust** toolchain (for building the proxy engine)
|
||||||
|
- **espeak-ng** (optional, for TTS fallback)
|
||||||
|
|
||||||
### Install & Build
|
### Install & Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone and install
|
# Clone and install dependencies
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# Build the Rust binaries (opus-codec + tts-engine)
|
# Build the Rust proxy-engine binary
|
||||||
pnpm run buildRust
|
pnpm run buildRust
|
||||||
|
|
||||||
# Bundle the web frontend
|
# Bundle the web frontend
|
||||||
@@ -87,57 +97,92 @@ pnpm run bundle
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Create `.nogit/config.json` with your setup:
|
Create `.nogit/config.json`:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"proxy": {
|
"proxy": {
|
||||||
"lanIp": "192.168.1.100", // Your server's LAN IP
|
"lanIp": "192.168.1.100", // Your server's LAN IP
|
||||||
"lanPort": 5070, // SIP signaling port
|
"lanPort": 5070, // SIP signaling port
|
||||||
"rtpPortRange": [20000, 20200],// RTP relay port pool (even ports)
|
"publicIpSeed": "stun.example.com", // STUN server for public IP discovery
|
||||||
"webUiPort": 3060 // Dashboard port
|
"rtpPortRange": { "min": 20000, "max": 20200 }, // RTP port pool (even ports)
|
||||||
|
"webUiPort": 3060 // Dashboard + REST API port
|
||||||
},
|
},
|
||||||
"providers": [
|
"providers": [
|
||||||
{
|
{
|
||||||
"id": "my-trunk",
|
"id": "my-trunk",
|
||||||
"name": "My SIP Provider",
|
"displayName": "My SIP Provider",
|
||||||
"host": "sip.provider.com",
|
"domain": "sip.provider.com",
|
||||||
"port": 5060,
|
"outboundProxy": { "address": "sip.provider.com", "port": 5060 },
|
||||||
"username": "user",
|
"username": "user",
|
||||||
"password": "pass",
|
"password": "pass",
|
||||||
"codecs": ["G.722", "PCMA", "PCMU"],
|
"codecs": [9, 0, 8, 101], // G.722, PCMU, PCMA, telephone-event
|
||||||
"registerExpiry": 3600
|
"registerIntervalSec": 300
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"devices": [
|
"devices": [
|
||||||
{
|
{
|
||||||
"id": "desk-phone",
|
"id": "desk-phone",
|
||||||
"name": "Desk Phone",
|
"displayName": "Desk Phone",
|
||||||
"type": "sip"
|
"expectedAddress": "192.168.1.50",
|
||||||
|
"extension": "100"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"routing": {
|
"routing": {
|
||||||
"inbound": {
|
"routes": [
|
||||||
"default": { "target": "all-devices", "ringBrowser": true }
|
{
|
||||||
|
"id": "inbound-default",
|
||||||
|
"name": "Ring all devices",
|
||||||
|
"priority": 100,
|
||||||
|
"direction": "inbound",
|
||||||
|
"match": {},
|
||||||
|
"action": {
|
||||||
|
"targets": ["desk-phone"],
|
||||||
|
"ringBrowsers": true,
|
||||||
|
"voicemailBox": "main",
|
||||||
|
"noAnswerTimeout": 25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "outbound-default",
|
||||||
|
"name": "Route via trunk",
|
||||||
|
"priority": 100,
|
||||||
|
"direction": "outbound",
|
||||||
|
"match": {},
|
||||||
|
"action": { "provider": "my-trunk" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"voiceboxes": [
|
||||||
|
{
|
||||||
|
"id": "main",
|
||||||
|
"enabled": true,
|
||||||
|
"greetingText": "Please leave a message after the beep.",
|
||||||
|
"greetingVoice": "af_bella",
|
||||||
|
"noAnswerTimeoutSec": 25,
|
||||||
|
"maxRecordingSec": 120,
|
||||||
|
"maxMessages": 50
|
||||||
}
|
}
|
||||||
}
|
],
|
||||||
|
"contacts": [
|
||||||
|
{ "id": "1", "name": "Alice", "number": "+491234567890", "starred": true }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### TTS Setup (Optional)
|
### TTS Setup (Optional)
|
||||||
|
|
||||||
For neural "connecting your call" announcements, download the Kokoro TTS model:
|
For neural announcements and voicemail greetings, download the Kokoro TTS model:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p .nogit/tts
|
mkdir -p .nogit/tts
|
||||||
# Download the full-quality model (310MB) + voices (27MB)
|
|
||||||
curl -L -o .nogit/tts/kokoro-v1.0.onnx \
|
curl -L -o .nogit/tts/kokoro-v1.0.onnx \
|
||||||
https://github.com/mzdk100/kokoro/releases/download/V1.0/kokoro-v1.0.onnx
|
https://github.com/mzdk100/kokoro/releases/download/V1.0/kokoro-v1.0.onnx
|
||||||
curl -L -o .nogit/tts/voices.bin \
|
curl -L -o .nogit/tts/voices.bin \
|
||||||
https://github.com/mzdk100/kokoro/releases/download/V1.0/voices.bin
|
https://github.com/mzdk100/kokoro/releases/download/V1.0/voices.bin
|
||||||
```
|
```
|
||||||
|
|
||||||
If the model files aren't present, the announcement feature is simply disabled — everything else works fine.
|
Without the model files, TTS falls back to `espeak-ng`. Without either, announcements are skipped — everything else works fine.
|
||||||
|
|
||||||
### Run
|
### Run
|
||||||
|
|
||||||
@@ -145,7 +190,7 @@ If the model files aren't present, the announcement feature is simply disabled
|
|||||||
pnpm start
|
pnpm start
|
||||||
```
|
```
|
||||||
|
|
||||||
The SIP proxy starts on the configured port and the web dashboard is available at `http://<your-ip>:3060`.
|
The SIP proxy starts on the configured port and the web dashboard is available at `https://<your-ip>:3060`.
|
||||||
|
|
||||||
### HTTPS (Optional)
|
### HTTPS (Optional)
|
||||||
|
|
||||||
@@ -157,68 +202,93 @@ Place `cert.pem` and `key.pem` in `.nogit/` for TLS on the dashboard.
|
|||||||
|
|
||||||
```
|
```
|
||||||
siprouter/
|
siprouter/
|
||||||
├── ts/ # TypeScript source
|
├── ts/ # TypeScript control plane
|
||||||
│ ├── sipproxy.ts # Main entry — bootstraps everything
|
│ ├── sipproxy.ts # Main entry — bootstraps everything
|
||||||
│ ├── config.ts # Config loader & validation
|
│ ├── config.ts # Config loader & validation
|
||||||
│ ├── registrar.ts # Local SIP registrar for devices
|
│ ├── proxybridge.ts # Rust proxy-engine IPC bridge (smartrust)
|
||||||
│ ├── providerstate.ts # Per-provider upstream registration engine
|
│ ├── frontend.ts # Web dashboard HTTP/WS server + REST API
|
||||||
│ ├── frontend.ts # Web dashboard HTTP/WS server + REST API
|
│ ├── webrtcbridge.ts # WebRTC signaling layer
|
||||||
│ ├── webrtcbridge.ts # WebRTC signaling layer
|
│ ├── registrar.ts # Browser softphone registration
|
||||||
│ ├── opusbridge.ts # Rust IPC bridge (smartrust)
|
│ ├── announcement.ts # TTS announcement generator (espeak-ng / Kokoro)
|
||||||
│ ├── codec.ts # High-level RTP transcoding interface
|
│ ├── voicebox.ts # Voicemail box management
|
||||||
│ ├── announcement.ts # Neural TTS announcement generator
|
│ └── call/
|
||||||
│ ├── sip/ # Zero-dependency SIP protocol library
|
│ └── prompt-cache.ts # Named audio prompt WAV management
|
||||||
│ │ ├── message.ts # SIP message parser/builder/mutator
|
│
|
||||||
│ │ ├── dialog.ts # RFC 3261 dialog state machine
|
├── ts_web/ # Web frontend (Lit-based SPA)
|
||||||
│ │ ├── helpers.ts # SDP builder, digest auth, codec registry
|
│ ├── elements/ # Web components (9 dashboard views)
|
||||||
│ │ └── rewrite.ts # SIP URI + SDP body rewriting
|
│ └── state/ # App state, WebRTC client, notifications
|
||||||
│ └── call/ # Hub-model call management
|
│
|
||||||
│ ├── call-manager.ts # Central registry, factory, routing
|
├── rust/ # Rust workspace (the data plane)
|
||||||
│ ├── call.ts # Call hub — owns N legs, media fan-out
|
|
||||||
│ ├── sip-leg.ts # SIP device/provider connection
|
|
||||||
│ ├── webrtc-leg.ts # Browser WebRTC connection
|
|
||||||
│ └── rtp-port-pool.ts # UDP port allocation
|
|
||||||
├── ts_web/ # Web frontend (Lit-based SPA)
|
|
||||||
│ ├── elements/ # Web components (dashboard, phone, etc.)
|
|
||||||
│ └── state/ # App state, WebRTC client, notifications
|
|
||||||
├── rust/ # Rust workspace
|
|
||||||
│ └── crates/
|
│ └── crates/
|
||||||
│ ├── opus-codec/ # Real-time audio transcoder (Opus/G.722/PCM)
|
│ ├── codec-lib/ # Audio codec library (Opus/G.722/PCMU/PCMA)
|
||||||
│ └── tts-engine/ # Kokoro neural TTS CLI
|
│ ├── sip-proto/ # Zero-dependency SIP protocol library
|
||||||
├── html/ # Static HTML shell
|
│ └── proxy-engine/ # Main binary — SIP engine + mixer + RTP
|
||||||
├── .nogit/ # Secrets, config, models (gitignored)
|
│
|
||||||
└── dist_rust/ # Compiled Rust binaries (gitignored)
|
├── html/ # Static HTML shell
|
||||||
|
├── .nogit/ # Secrets, config, TTS models (gitignored)
|
||||||
|
└── dist_rust/ # Compiled Rust binary (gitignored)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎧 Codec Engine (Rust)
|
## 🎧 Audio Engine (Rust)
|
||||||
|
|
||||||
The `opus-codec` binary handles all real-time audio processing via a JSON-over-stdio IPC protocol:
|
The `proxy-engine` binary handles all real-time audio processing with a **48kHz f32 internal bus** — encoding and decoding happens only at leg boundaries.
|
||||||
|
|
||||||
| Codec | Payload Type | Sample Rate | Use Case |
|
### Supported Codecs
|
||||||
|-------|-------------|-------------|----------|
|
|
||||||
| **Opus** | 111 | 48 kHz | WebRTC browsers |
|
| Codec | PT | Native Rate | Use Case |
|
||||||
| **G.722** | 9 | 16 kHz | HD SIP devices |
|
|-------|:--:|:-----------:|----------|
|
||||||
|
| **Opus** | 111 | 48 kHz | WebRTC browsers (native float encode/decode — zero i16 quantization) |
|
||||||
|
| **G.722** | 9 | 16 kHz | HD SIP devices & providers |
|
||||||
| **PCMU** (G.711 µ-law) | 0 | 8 kHz | Legacy SIP |
|
| **PCMU** (G.711 µ-law) | 0 | 8 kHz | Legacy SIP |
|
||||||
| **PCMA** (G.711 A-law) | 8 | 8 kHz | Legacy SIP |
|
| **PCMA** (G.711 A-law) | 8 | 8 kHz | Legacy SIP |
|
||||||
|
|
||||||
**Features:**
|
### Audio Pipeline
|
||||||
- Per-call isolated codec sessions (no cross-call state corruption)
|
|
||||||
- FFT-based sample rate conversion via `rubato`
|
```
|
||||||
- **RNNoise ML noise suppression** with per-direction state — denoises audio flowing to SIP separately from audio flowing to the browser
|
Inbound: Wire RTP → Jitter Buffer → Decode → Resample to 48kHz → Denoise (RNNoise) → Mix Bus
|
||||||
- Raw PCM encoding for TTS frame processing
|
Outbound: Mix Bus → Mix-Minus → Resample to codec rate → Encode → Wire RTP
|
||||||
|
```
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- **RFC 3550 compliant header parsing** — properly handles CSRC lists and header extensions
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🗣️ Neural TTS (Rust)
|
## 🗣️ Neural TTS
|
||||||
|
|
||||||
The `tts-engine` binary uses [Kokoro TTS](https://github.com/mzdk100/kokoro) (82M parameter neural model) to synthesize announcements at startup:
|
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:
|
||||||
|
|
||||||
- **24 kHz, 16-bit mono** output
|
- **24 kHz, 16-bit mono** output
|
||||||
- **25+ voice presets** — American/British, male/female (e.g., `af_bella`, `am_adam`, `bf_emma`, `bm_george`)
|
- **25+ voice presets** — American/British, male/female (e.g., `af_bella`, `am_adam`, `bf_emma`, `bm_george`)
|
||||||
- **~800ms** synthesis time for a 3-second announcement
|
- **~800ms** synthesis time for a 3-second phrase
|
||||||
- Pre-encoded to G.722 + Opus for zero-latency RTP playback during call setup
|
- 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 (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/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔢 IVR (Interactive Voice Response)
|
||||||
|
|
||||||
|
- DTMF-navigable menus with configurable entries
|
||||||
|
- Actions: route to extension, route to voicemail, transfer, submenu, hangup, repeat prompt
|
||||||
|
- Custom TTS prompts per menu
|
||||||
|
- Nested menu support
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -228,33 +298,54 @@ The `tts-engine` binary uses [Kokoro TTS](https://github.com/mzdk100/kokoro) (82
|
|||||||
|
|
||||||
| View | Description |
|
| View | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| **Overview** | Stats tiles — uptime, providers, devices, active calls |
|
| 📊 **Overview** | Stats tiles — uptime, providers, devices, active calls |
|
||||||
| **Calls** | Active calls with leg details, codec info, packet counters. Add/remove legs, transfer, hangup |
|
| 📞 **Calls** | Active calls with leg details, codec info, add/remove legs, transfer, hangup |
|
||||||
| **Phone** | Browser softphone — mic/speaker selection, audio meters, dial pad, incoming call popup |
|
| ☎️ **Phone** | Browser softphone — mic/speaker selection, audio meters, dial pad, incoming call popup |
|
||||||
| **Contacts** | Contact management with click-to-call |
|
| 🔀 **Routes** | Routing rule management — match/action model with priority |
|
||||||
| **Providers** | SIP trunk config with registration status |
|
| 📧 **Voicemail** | Voicemail box management + message playback |
|
||||||
| **Log** | Live streaming log viewer |
|
| 🔢 **IVR** | IVR menu builder — DTMF entries, TTS prompts, nested menus |
|
||||||
|
| 👤 **Contacts** | Contact management with click-to-call |
|
||||||
|
| 🔌 **Providers** | SIP trunk configuration and registration status |
|
||||||
|
| 📋 **Log** | Live streaming log viewer |
|
||||||
|
|
||||||
### REST API
|
### REST API
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| `/api/status` | GET | Full system status (providers, devices, calls) |
|
| `/api/status` | GET | Full system status (providers, devices, calls, history) |
|
||||||
| `/api/call` | POST | Originate a call |
|
| `/api/call` | POST | Originate a call |
|
||||||
| `/api/hangup` | POST | Hang up a call |
|
| `/api/hangup` | POST | Hang up a call |
|
||||||
| `/api/call/:id/addleg` | POST | Add a leg to an active call |
|
| `/api/call/:id/addleg` | POST | Add a device leg to an active call |
|
||||||
| `/api/call/:id/addexternal` | POST | Add an external participant |
|
| `/api/call/:id/addexternal` | POST | Add an external participant via provider |
|
||||||
| `/api/call/:id/removeleg` | POST | Remove a leg from a call |
|
| `/api/call/:id/removeleg` | POST | Remove a leg from a call |
|
||||||
| `/api/transfer` | POST | Transfer a call |
|
| `/api/transfer` | POST | Transfer a call |
|
||||||
| `/api/config` | GET/POST | Read or update configuration (hot-reload) |
|
| `/api/config` | GET | Read current configuration |
|
||||||
|
| `/api/config` | POST | Update configuration (hot-reload) |
|
||||||
|
| `/api/voicemail/:box` | GET | List voicemail messages |
|
||||||
|
| `/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
|
### WebSocket Events
|
||||||
|
|
||||||
Connect to `/ws` for real-time push:
|
Connect to `/ws` for real-time push:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{ "type": "status", "data": { ... } } // Full status snapshot (1s interval)
|
{ "type": "status", "data": { ... } } // Full status snapshot (1s interval)
|
||||||
{ "type": "log", "data": { "message": "..." } } // Log lines in real-time
|
{ "type": "log", "data": { "message": "..." } } // Log lines in real-time
|
||||||
|
{ "type": "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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -264,7 +355,7 @@ Connect to `/ws` for real-time push:
|
|||||||
| Port | Protocol | Purpose |
|
| Port | Protocol | Purpose |
|
||||||
|------|----------|---------|
|
|------|----------|---------|
|
||||||
| 5070 (configurable) | UDP | SIP signaling |
|
| 5070 (configurable) | UDP | SIP signaling |
|
||||||
| 20000–20200 (configurable) | UDP | RTP relay (even ports, per-call allocation) |
|
| 20000–20200 (configurable) | UDP | RTP media (even ports, per-call allocation) |
|
||||||
| 3060 (configurable) | TCP | Web dashboard + WebSocket + REST API |
|
| 3060 (configurable) | TCP | Web dashboard + WebSocket + REST API |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -275,28 +366,21 @@ Connect to `/ws` for real-time push:
|
|||||||
# Start in dev mode
|
# Start in dev mode
|
||||||
pnpm start
|
pnpm start
|
||||||
|
|
||||||
# Build Rust crates
|
# Build Rust proxy-engine
|
||||||
pnpm run buildRust
|
pnpm run buildRust
|
||||||
|
|
||||||
# Bundle web frontend
|
# Bundle web frontend
|
||||||
pnpm run bundle
|
pnpm run bundle
|
||||||
|
|
||||||
# Restart background server (build + bundle + restart)
|
# Build + bundle + restart background server
|
||||||
pnpm run restartBackground
|
pnpm run restartBackground
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Design Decisions
|
|
||||||
|
|
||||||
- **Hub Model** — Calls are N-leg hubs, not point-to-point. This enables multi-party, dynamic leg manipulation, and transfer without tearing down the call.
|
|
||||||
- **Zero-dependency SIP library** — `ts/sip/` is a pure data-level SIP stack (parse/build/mutate/serialize). No transport or timer logic — those live in the application layer.
|
|
||||||
- **Rust for the hot path** — Codec transcoding and noise suppression run in native Rust for real-time performance. TypeScript handles signaling and orchestration.
|
|
||||||
- **Per-session codec isolation** — Each call gets its own Opus/G.722 encoder/decoder state in the Rust process, preventing stateful codec prediction from leaking between concurrent calls.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
|||||||
30
rust/.cargo/config.toml
Normal file
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
|
||||||
227
rust/Cargo.lock
generated
227
rust/Cargo.lock
generated
@@ -237,6 +237,17 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atty"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
||||||
|
dependencies = [
|
||||||
|
"hermit-abi",
|
||||||
|
"libc",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "audiopus"
|
name = "audiopus"
|
||||||
version = "0.3.0-rc.0"
|
version = "0.3.0-rc.0"
|
||||||
@@ -487,6 +498,31 @@ dependencies = [
|
|||||||
"inout",
|
"inout",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "3.2.25"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
|
||||||
|
dependencies = [
|
||||||
|
"atty",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"clap_lex",
|
||||||
|
"indexmap 1.9.3",
|
||||||
|
"once_cell",
|
||||||
|
"strsim",
|
||||||
|
"termcolor",
|
||||||
|
"textwrap",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
|
||||||
|
dependencies = [
|
||||||
|
"os_str_bytes",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.58"
|
version = "0.1.58"
|
||||||
@@ -700,6 +736,125 @@ version = "0.3.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04"
|
checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7381b67da416b639690ac77c73b86a7b5e64a29e31d1f75fb3b1102301ef355a"
|
||||||
|
dependencies = [
|
||||||
|
"dasp_envelope",
|
||||||
|
"dasp_frame",
|
||||||
|
"dasp_interpolate",
|
||||||
|
"dasp_peak",
|
||||||
|
"dasp_ring_buffer",
|
||||||
|
"dasp_rms",
|
||||||
|
"dasp_sample",
|
||||||
|
"dasp_signal",
|
||||||
|
"dasp_slice",
|
||||||
|
"dasp_window",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_envelope"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ec617ce7016f101a87fe85ed44180839744265fae73bb4aa43e7ece1b7668b6"
|
||||||
|
dependencies = [
|
||||||
|
"dasp_frame",
|
||||||
|
"dasp_peak",
|
||||||
|
"dasp_ring_buffer",
|
||||||
|
"dasp_rms",
|
||||||
|
"dasp_sample",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_frame"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6"
|
||||||
|
dependencies = [
|
||||||
|
"dasp_sample",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_interpolate"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7fc975a6563bb7ca7ec0a6c784ead49983a21c24835b0bc96eea11ee407c7486"
|
||||||
|
dependencies = [
|
||||||
|
"dasp_frame",
|
||||||
|
"dasp_ring_buffer",
|
||||||
|
"dasp_sample",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_peak"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5cf88559d79c21f3d8523d91250c397f9a15b5fc72fbb3f87fdb0a37b79915bf"
|
||||||
|
dependencies = [
|
||||||
|
"dasp_frame",
|
||||||
|
"dasp_sample",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_ring_buffer"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07d79e19b89618a543c4adec9c5a347fe378a19041699b3278e616e387511ea1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_rms"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6c5dcb30b7e5014486e2822537ea2beae50b19722ffe2ed7549ab03774575aa"
|
||||||
|
dependencies = [
|
||||||
|
"dasp_frame",
|
||||||
|
"dasp_ring_buffer",
|
||||||
|
"dasp_sample",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_sample"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_signal"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aa1ab7d01689c6ed4eae3d38fe1cea08cba761573fbd2d592528d55b421077e7"
|
||||||
|
dependencies = [
|
||||||
|
"dasp_envelope",
|
||||||
|
"dasp_frame",
|
||||||
|
"dasp_interpolate",
|
||||||
|
"dasp_peak",
|
||||||
|
"dasp_ring_buffer",
|
||||||
|
"dasp_rms",
|
||||||
|
"dasp_sample",
|
||||||
|
"dasp_window",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_slice"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e1c7335d58e7baedafa516cb361360ff38d6f4d3f9d9d5ee2a2fc8e27178fa1"
|
||||||
|
dependencies = [
|
||||||
|
"dasp_frame",
|
||||||
|
"dasp_sample",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_window"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "99ded7b88821d2ce4e8b842c9f1c86ac911891ab89443cc1de750cae764c5076"
|
||||||
|
dependencies = [
|
||||||
|
"dasp_sample",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-encoding"
|
name = "data-encoding"
|
||||||
version = "2.10.0"
|
version = "2.10.0"
|
||||||
@@ -1214,6 +1369,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -1246,6 +1407,15 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hermit-abi"
|
||||||
|
version = "0.1.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hex"
|
name = "hex"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -1446,6 +1616,16 @@ dependencies = [
|
|||||||
"zstd",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "1.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"hashbrown 0.12.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -1739,7 +1919,13 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "805d5964d1e7a0006a7fdced7dae75084d66d18b35f1dfe81bd76929b1f8da0c"
|
checksum = "805d5964d1e7a0006a7fdced7dae75084d66d18b35f1dfe81bd76929b1f8da0c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"dasp",
|
||||||
|
"dasp_interpolate",
|
||||||
|
"dasp_ring_buffer",
|
||||||
"easyfft",
|
"easyfft",
|
||||||
|
"hound",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1905,6 +2091,12 @@ dependencies = [
|
|||||||
"ureq",
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "os_str_bytes"
|
||||||
|
version = "6.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "p256"
|
name = "p256"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -2179,6 +2371,7 @@ dependencies = [
|
|||||||
"codec-lib",
|
"codec-lib",
|
||||||
"hound",
|
"hound",
|
||||||
"kokoro-tts",
|
"kokoro-tts",
|
||||||
|
"nnnoiseless",
|
||||||
"ort",
|
"ort",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"regex-lite",
|
"regex-lite",
|
||||||
@@ -2882,6 +3075,21 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "termcolor"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "textwrap"
|
||||||
|
version = "0.16.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -3243,7 +3451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"indexmap",
|
"indexmap 2.14.0",
|
||||||
"wasm-encoder",
|
"wasm-encoder",
|
||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
@@ -3256,7 +3464,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"indexmap",
|
"indexmap 2.14.0",
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3514,6 +3722,15 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-util"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-x86_64-pc-windows-gnu"
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -3563,7 +3780,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"heck",
|
"heck",
|
||||||
"indexmap",
|
"indexmap 2.14.0",
|
||||||
"prettyplease",
|
"prettyplease",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
"wasm-metadata",
|
"wasm-metadata",
|
||||||
@@ -3594,7 +3811,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"indexmap",
|
"indexmap 2.14.0",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
@@ -3613,7 +3830,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"id-arena",
|
"id-arena",
|
||||||
"indexmap",
|
"indexmap 2.14.0",
|
||||||
"log",
|
"log",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ edition = "2021"
|
|||||||
audiopus = "0.3.0-rc.0"
|
audiopus = "0.3.0-rc.0"
|
||||||
ezk-g722 = "0.1"
|
ezk-g722 = "0.1"
|
||||||
rubato = "0.14"
|
rubato = "0.14"
|
||||||
nnnoiseless = { version = "0.5", default-features = false }
|
nnnoiseless = "0.5"
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ pub struct TranscodeState {
|
|||||||
g722_dec: libg722::decoder::Decoder,
|
g722_dec: libg722::decoder::Decoder,
|
||||||
/// Cached FFT resamplers keyed by (from_rate, to_rate, chunk_size).
|
/// Cached FFT resamplers keyed by (from_rate, to_rate, chunk_size).
|
||||||
resamplers: HashMap<(u32, u32, usize), FftFixedIn<f64>>,
|
resamplers: HashMap<(u32, u32, usize), FftFixedIn<f64>>,
|
||||||
|
/// Cached f32 FFT resamplers keyed by (from_rate, to_rate, chunk_size).
|
||||||
|
resamplers_f32: HashMap<(u32, u32, usize), FftFixedIn<f32>>,
|
||||||
/// ML noise suppression for the SIP-bound direction.
|
/// ML noise suppression for the SIP-bound direction.
|
||||||
denoiser_to_sip: Box<DenoiseState<'static>>,
|
denoiser_to_sip: Box<DenoiseState<'static>>,
|
||||||
/// ML noise suppression for the browser-bound direction.
|
/// ML noise suppression for the browser-bound direction.
|
||||||
@@ -133,14 +135,17 @@ impl TranscodeState {
|
|||||||
g722_enc,
|
g722_enc,
|
||||||
g722_dec,
|
g722_dec,
|
||||||
resamplers: HashMap::new(),
|
resamplers: HashMap::new(),
|
||||||
|
resamplers_f32: HashMap::new(),
|
||||||
denoiser_to_sip: DenoiseState::new(),
|
denoiser_to_sip: DenoiseState::new(),
|
||||||
denoiser_to_browser: DenoiseState::new(),
|
denoiser_to_browser: DenoiseState::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// High-quality sample rate conversion using rubato FFT resampler.
|
/// High-quality sample rate conversion using rubato FFT resampler.
|
||||||
/// Resamplers are cached by (from_rate, to_rate, chunk_size) and reused,
|
///
|
||||||
/// maintaining proper inter-frame state for continuous audio streams.
|
/// To maintain continuous filter state, the resampler always processes at a
|
||||||
|
/// canonical chunk size (20ms at the source rate). This prevents cache
|
||||||
|
/// thrashing from variable input sizes and preserves inter-frame filter state.
|
||||||
pub fn resample(
|
pub fn resample(
|
||||||
&mut self,
|
&mut self,
|
||||||
pcm: &[i16],
|
pcm: &[i16],
|
||||||
@@ -151,28 +156,61 @@ impl TranscodeState {
|
|||||||
return Ok(pcm.to_vec());
|
return Ok(pcm.to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
let chunk = pcm.len();
|
let canonical_chunk = (from_rate as usize) / 50; // 20ms
|
||||||
let key = (from_rate, to_rate, chunk);
|
let key = (from_rate, to_rate, canonical_chunk);
|
||||||
|
|
||||||
if !self.resamplers.contains_key(&key) {
|
if !self.resamplers.contains_key(&key) {
|
||||||
let r =
|
let r = FftFixedIn::<f64>::new(
|
||||||
FftFixedIn::<f64>::new(from_rate as usize, to_rate as usize, chunk, 1, 1)
|
from_rate as usize,
|
||||||
.map_err(|e| format!("resampler {from_rate}->{to_rate}: {e}"))?;
|
to_rate as usize,
|
||||||
|
canonical_chunk,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("resampler {from_rate}->{to_rate}: {e}"))?;
|
||||||
self.resamplers.insert(key, r);
|
self.resamplers.insert(key, r);
|
||||||
}
|
}
|
||||||
let resampler = self.resamplers.get_mut(&key).unwrap();
|
let resampler = self.resamplers.get_mut(&key).unwrap();
|
||||||
|
|
||||||
let float_in: Vec<f64> = pcm.iter().map(|&s| s as f64 / 32768.0).collect();
|
let mut output = Vec::with_capacity(
|
||||||
let input = vec![float_in];
|
(pcm.len() as f64 * to_rate as f64 / from_rate as f64).ceil() as usize + 16,
|
||||||
|
);
|
||||||
|
|
||||||
let result = resampler
|
let mut offset = 0;
|
||||||
.process(&input, None)
|
while offset < pcm.len() {
|
||||||
.map_err(|e| format!("resample {from_rate}->{to_rate}: {e}"))?;
|
let remaining = pcm.len() - offset;
|
||||||
|
let copy_len = remaining.min(canonical_chunk);
|
||||||
|
let mut chunk = vec![0.0f64; canonical_chunk];
|
||||||
|
for i in 0..copy_len {
|
||||||
|
chunk[i] = pcm[offset + i] as f64 / 32768.0;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(result[0]
|
let input = vec![chunk];
|
||||||
.iter()
|
let result = resampler
|
||||||
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
|
.process(&input, None)
|
||||||
.collect())
|
.map_err(|e| format!("resample {from_rate}->{to_rate}: {e}"))?;
|
||||||
|
|
||||||
|
if remaining < canonical_chunk {
|
||||||
|
let expected =
|
||||||
|
(copy_len as f64 * to_rate as f64 / from_rate as f64).round() as usize;
|
||||||
|
let take = expected.min(result[0].len());
|
||||||
|
output.extend(
|
||||||
|
result[0][..take]
|
||||||
|
.iter()
|
||||||
|
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
output.extend(
|
||||||
|
result[0]
|
||||||
|
.iter()
|
||||||
|
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += canonical_chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply RNNoise ML noise suppression to 48kHz PCM audio.
|
/// Apply RNNoise ML noise suppression to 48kHz PCM audio.
|
||||||
@@ -293,6 +331,171 @@ impl TranscodeState {
|
|||||||
_ => Err(format!("unsupported target PT {pt}")),
|
_ => Err(format!("unsupported target PT {pt}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- f32 API for high-quality internal bus ----------------------------
|
||||||
|
|
||||||
|
/// Decode an encoded audio payload to f32 PCM samples in [-1.0, 1.0].
|
||||||
|
/// Returns (samples, sample_rate).
|
||||||
|
///
|
||||||
|
/// For Opus, uses native float decode (no i16 quantization).
|
||||||
|
/// For G.722/G.711, decodes to i16 then converts (codec is natively i16).
|
||||||
|
pub fn decode_to_f32(&mut self, data: &[u8], pt: u8) -> Result<(Vec<f32>, u32), String> {
|
||||||
|
match pt {
|
||||||
|
PT_OPUS => {
|
||||||
|
let mut pcm = vec![0.0f32; 5760]; // up to 120ms at 48kHz
|
||||||
|
let packet =
|
||||||
|
OpusPacket::try_from(data).map_err(|e| format!("opus packet: {e}"))?;
|
||||||
|
let out =
|
||||||
|
MutSignals::try_from(&mut pcm[..]).map_err(|e| format!("opus signals: {e}"))?;
|
||||||
|
let n: usize = self
|
||||||
|
.opus_dec
|
||||||
|
.decode_float(Some(packet), out, false)
|
||||||
|
.map_err(|e| format!("opus decode_float: {e}"))?
|
||||||
|
.into();
|
||||||
|
pcm.truncate(n);
|
||||||
|
Ok((pcm, 48000))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// G.722, PCMU, PCMA: natively i16 codecs — decode then convert.
|
||||||
|
let (pcm_i16, rate) = self.decode_to_pcm(data, pt)?;
|
||||||
|
let pcm_f32 = pcm_i16.iter().map(|&s| s as f32 / 32768.0).collect();
|
||||||
|
Ok((pcm_f32, rate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opus packet loss concealment — synthesize one frame to fill a gap.
|
||||||
|
/// Returns f32 PCM at 48kHz. `frame_size` should be 960 for 20ms.
|
||||||
|
pub fn opus_plc(&mut self, frame_size: usize) -> Result<Vec<f32>, String> {
|
||||||
|
let mut pcm = vec![0.0f32; frame_size];
|
||||||
|
let out = MutSignals::try_from(&mut pcm[..])
|
||||||
|
.map_err(|e| format!("opus plc signals: {e}"))?;
|
||||||
|
let n: usize = self
|
||||||
|
.opus_dec
|
||||||
|
.decode_float(None::<OpusPacket<'_>>, out, false)
|
||||||
|
.map_err(|e| format!("opus plc: {e}"))?
|
||||||
|
.into();
|
||||||
|
pcm.truncate(n);
|
||||||
|
Ok(pcm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode f32 PCM samples ([-1.0, 1.0]) to an audio codec.
|
||||||
|
///
|
||||||
|
/// For Opus, uses native float encode (no i16 quantization).
|
||||||
|
/// For G.722/G.711, converts to i16 then encodes (codec is natively i16).
|
||||||
|
pub fn encode_from_f32(&mut self, pcm: &[f32], pt: u8) -> Result<Vec<u8>, String> {
|
||||||
|
match pt {
|
||||||
|
PT_OPUS => {
|
||||||
|
let mut buf = vec![0u8; 4000];
|
||||||
|
let n: usize = self
|
||||||
|
.opus_enc
|
||||||
|
.encode_float(pcm, &mut buf)
|
||||||
|
.map_err(|e| format!("opus encode_float: {e}"))?
|
||||||
|
.into();
|
||||||
|
buf.truncate(n);
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// G.722, PCMU, PCMA: natively i16 codecs.
|
||||||
|
let pcm_i16: Vec<i16> = pcm
|
||||||
|
.iter()
|
||||||
|
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
|
||||||
|
.collect();
|
||||||
|
self.encode_from_pcm(&pcm_i16, pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-quality sample rate conversion for f32 PCM using rubato FFT resampler.
|
||||||
|
///
|
||||||
|
/// To maintain continuous filter state, the resampler always processes at a
|
||||||
|
/// canonical chunk size (20ms at the source rate). This prevents cache
|
||||||
|
/// thrashing from variable input sizes and preserves inter-frame filter state.
|
||||||
|
pub fn resample_f32(
|
||||||
|
&mut self,
|
||||||
|
pcm: &[f32],
|
||||||
|
from_rate: u32,
|
||||||
|
to_rate: u32,
|
||||||
|
) -> Result<Vec<f32>, String> {
|
||||||
|
if from_rate == to_rate || pcm.is_empty() {
|
||||||
|
return Ok(pcm.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical_chunk = (from_rate as usize) / 50; // 20ms
|
||||||
|
let key = (from_rate, to_rate, canonical_chunk);
|
||||||
|
|
||||||
|
if !self.resamplers_f32.contains_key(&key) {
|
||||||
|
let r = FftFixedIn::<f32>::new(
|
||||||
|
from_rate as usize,
|
||||||
|
to_rate as usize,
|
||||||
|
canonical_chunk,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("resampler f32 {from_rate}->{to_rate}: {e}"))?;
|
||||||
|
self.resamplers_f32.insert(key, r);
|
||||||
|
}
|
||||||
|
let resampler = self.resamplers_f32.get_mut(&key).unwrap();
|
||||||
|
|
||||||
|
let mut output = Vec::with_capacity(
|
||||||
|
(pcm.len() as f64 * to_rate as f64 / from_rate as f64).ceil() as usize + 16,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut offset = 0;
|
||||||
|
while offset < pcm.len() {
|
||||||
|
let remaining = pcm.len() - offset;
|
||||||
|
let mut chunk = vec![0.0f32; canonical_chunk];
|
||||||
|
let copy_len = remaining.min(canonical_chunk);
|
||||||
|
chunk[..copy_len].copy_from_slice(&pcm[offset..offset + copy_len]);
|
||||||
|
|
||||||
|
let input = vec![chunk];
|
||||||
|
let result = resampler
|
||||||
|
.process(&input, None)
|
||||||
|
.map_err(|e| format!("resample f32 {from_rate}->{to_rate}: {e}"))?;
|
||||||
|
|
||||||
|
if remaining < canonical_chunk {
|
||||||
|
let expected =
|
||||||
|
(copy_len as f64 * to_rate as f64 / from_rate as f64).round() as usize;
|
||||||
|
output.extend_from_slice(&result[0][..expected.min(result[0].len())]);
|
||||||
|
} else {
|
||||||
|
output.extend_from_slice(&result[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += canonical_chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply RNNoise ML noise suppression to 48kHz f32 PCM audio.
|
||||||
|
/// Processes in 480-sample (10ms) frames. State persists across calls.
|
||||||
|
/// Operates natively in f32 — no i16 conversion overhead.
|
||||||
|
pub fn denoise_f32(denoiser: &mut DenoiseState, pcm: &[f32]) -> Vec<f32> {
|
||||||
|
let frame_size = DenoiseState::FRAME_SIZE; // 480
|
||||||
|
let total = pcm.len();
|
||||||
|
let whole = (total / frame_size) * frame_size;
|
||||||
|
let mut output = Vec::with_capacity(total);
|
||||||
|
let mut out_buf = [0.0f32; 480];
|
||||||
|
|
||||||
|
// nnnoiseless expects f32 samples scaled as i16 range (-32768..32767).
|
||||||
|
for offset in (0..whole).step_by(frame_size) {
|
||||||
|
let input: Vec<f32> = pcm[offset..offset + frame_size]
|
||||||
|
.iter()
|
||||||
|
.map(|&s| s * 32768.0)
|
||||||
|
.collect();
|
||||||
|
denoiser.process_frame(&mut out_buf, &input);
|
||||||
|
output.extend(out_buf.iter().map(|&s| s / 32768.0));
|
||||||
|
}
|
||||||
|
if whole < total {
|
||||||
|
output.extend_from_slice(&pcm[whole..]);
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new standalone denoiser for per-leg inbound processing.
|
||||||
|
pub fn new_denoiser() -> Box<DenoiseState<'static>> {
|
||||||
|
DenoiseState::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
codec-lib = { path = "../codec-lib" }
|
codec-lib = { path = "../codec-lib" }
|
||||||
sip-proto = { path = "../sip-proto" }
|
sip-proto = { path = "../sip-proto" }
|
||||||
|
nnnoiseless = "0.5"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ use tokio::net::UdpSocket;
|
|||||||
use tokio::time::{self, Duration};
|
use tokio::time::{self, Duration};
|
||||||
|
|
||||||
/// Mixing sample rate used by the mixer (must stay in sync with mixer::MIX_RATE).
|
/// Mixing sample rate used by the mixer (must stay in sync with mixer::MIX_RATE).
|
||||||
const MIX_RATE: u32 = 16000;
|
const MIX_RATE: u32 = 48000;
|
||||||
/// Samples per 20ms frame at the mixing rate.
|
/// Samples per 20ms frame at the mixing rate.
|
||||||
const MIX_FRAME_SIZE: usize = 320;
|
const MIX_FRAME_SIZE: usize = 960;
|
||||||
|
|
||||||
/// Play a WAV file as RTP to a destination.
|
/// Play a WAV file as RTP to a destination.
|
||||||
/// Returns when playback is complete.
|
/// Returns when playback is complete.
|
||||||
@@ -178,9 +178,9 @@ pub async fn play_beep(
|
|||||||
Ok((seq, ts))
|
Ok((seq, ts))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a WAV file and split it into 20ms PCM frames at 16kHz.
|
/// Load a WAV file and split it into 20ms f32 PCM frames at 48kHz.
|
||||||
/// Used by the leg interaction system to prepare prompt audio for the mixer.
|
/// Used by the leg interaction system to prepare prompt audio for the mixer.
|
||||||
pub fn load_prompt_pcm_frames(wav_path: &str) -> Result<Vec<Vec<i16>>, String> {
|
pub fn load_prompt_pcm_frames(wav_path: &str) -> Result<Vec<Vec<f32>>, String> {
|
||||||
let path = Path::new(wav_path);
|
let path = Path::new(wav_path);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(format!("WAV file not found: {wav_path}"));
|
return Err(format!("WAV file not found: {wav_path}"));
|
||||||
@@ -191,17 +191,17 @@ pub fn load_prompt_pcm_frames(wav_path: &str) -> Result<Vec<Vec<i16>>, String> {
|
|||||||
let spec = reader.spec();
|
let spec = reader.spec();
|
||||||
let wav_rate = spec.sample_rate;
|
let wav_rate = spec.sample_rate;
|
||||||
|
|
||||||
// Read all samples as i16.
|
// Read all samples as f32 in [-1.0, 1.0].
|
||||||
let samples: Vec<i16> = if spec.bits_per_sample == 16 {
|
let samples: Vec<f32> = if spec.bits_per_sample == 16 {
|
||||||
reader
|
reader
|
||||||
.samples::<i16>()
|
.samples::<i16>()
|
||||||
.filter_map(|s| s.ok())
|
.filter_map(|s| s.ok())
|
||||||
|
.map(|s| s as f32 / 32768.0)
|
||||||
.collect()
|
.collect()
|
||||||
} else if spec.bits_per_sample == 32 && spec.sample_format == hound::SampleFormat::Float {
|
} else if spec.bits_per_sample == 32 && spec.sample_format == hound::SampleFormat::Float {
|
||||||
reader
|
reader
|
||||||
.samples::<f32>()
|
.samples::<f32>()
|
||||||
.filter_map(|s| s.ok())
|
.filter_map(|s| s.ok())
|
||||||
.map(|s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
|
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -214,24 +214,24 @@ pub fn load_prompt_pcm_frames(wav_path: &str) -> Result<Vec<Vec<i16>>, String> {
|
|||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resample to MIX_RATE (16kHz) if needed.
|
// Resample to MIX_RATE (48kHz) if needed.
|
||||||
let resampled = if wav_rate != MIX_RATE {
|
let resampled = if wav_rate != MIX_RATE {
|
||||||
let mut transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?;
|
let mut transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?;
|
||||||
transcoder
|
transcoder
|
||||||
.resample(&samples, wav_rate, MIX_RATE)
|
.resample_f32(&samples, wav_rate, MIX_RATE)
|
||||||
.map_err(|e| format!("resample: {e}"))?
|
.map_err(|e| format!("resample: {e}"))?
|
||||||
} else {
|
} else {
|
||||||
samples
|
samples
|
||||||
};
|
};
|
||||||
|
|
||||||
// Split into MIX_FRAME_SIZE (320) sample frames.
|
// Split into MIX_FRAME_SIZE (960) sample frames.
|
||||||
let mut frames = Vec::new();
|
let mut frames = Vec::new();
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
while offset < resampled.len() {
|
while offset < resampled.len() {
|
||||||
let end = (offset + MIX_FRAME_SIZE).min(resampled.len());
|
let end = (offset + MIX_FRAME_SIZE).min(resampled.len());
|
||||||
let mut frame = resampled[offset..end].to_vec();
|
let mut frame = resampled[offset..end].to_vec();
|
||||||
// Pad short final frame with silence.
|
// Pad short final frame with silence.
|
||||||
frame.resize(MIX_FRAME_SIZE, 0);
|
frame.resize(MIX_FRAME_SIZE, 0.0);
|
||||||
frames.push(frame);
|
frames.push(frame);
|
||||||
offset += MIX_FRAME_SIZE;
|
offset += MIX_FRAME_SIZE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ pub enum CallState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CallState {
|
impl CallState {
|
||||||
|
/// Wire-format string for events/dashboards. Not currently emitted —
|
||||||
|
/// call state changes flow as typed events (`call_answered`, etc.) —
|
||||||
|
/// but kept for future status-snapshot work.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::SettingUp => "setting-up",
|
Self::SettingUp => "setting-up",
|
||||||
@@ -45,6 +49,8 @@ pub enum CallDirection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CallDirection {
|
impl CallDirection {
|
||||||
|
/// Wire-format string. See CallState::as_str.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Inbound => "inbound",
|
Self::Inbound => "inbound",
|
||||||
@@ -59,8 +65,13 @@ pub enum LegKind {
|
|||||||
SipProvider,
|
SipProvider,
|
||||||
SipDevice,
|
SipDevice,
|
||||||
WebRtc,
|
WebRtc,
|
||||||
Media, // voicemail playback, IVR, recording
|
/// Voicemail playback, IVR prompt playback, recording — not yet wired up
|
||||||
Tool, // observer leg for recording, transcription, etc.
|
/// 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 {
|
impl LegKind {
|
||||||
@@ -107,11 +118,22 @@ pub struct LegInfo {
|
|||||||
/// For SIP legs: the SIP Call-ID for message routing.
|
/// For SIP legs: the SIP Call-ID for message routing.
|
||||||
pub sip_call_id: Option<String>,
|
pub sip_call_id: Option<String>,
|
||||||
/// For WebRTC legs: the session ID in WebRtcEngine.
|
/// For WebRTC legs: the session ID in WebRtcEngine.
|
||||||
|
///
|
||||||
|
/// Populated at leg creation but not yet consumed by the hub —
|
||||||
|
/// WebRTC session lookup currently goes through the session registry
|
||||||
|
/// directly. Kept for introspection/debugging.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub webrtc_session_id: Option<String>,
|
pub webrtc_session_id: Option<String>,
|
||||||
/// The RTP socket allocated for this leg.
|
/// The RTP socket allocated for this leg.
|
||||||
pub rtp_socket: Option<Arc<UdpSocket>>,
|
pub rtp_socket: Option<Arc<UdpSocket>>,
|
||||||
/// The RTP port number.
|
/// The RTP port number.
|
||||||
pub rtp_port: u16,
|
pub rtp_port: u16,
|
||||||
|
/// Public IP to advertise in SDP/Record-Route when THIS leg is the
|
||||||
|
/// destination of a rewrite. Populated only for provider legs; `None`
|
||||||
|
/// for LAN SIP devices, WebRTC browsers, media, and tool legs (which
|
||||||
|
/// are reachable via `lan_ip`). See `route_passthrough_message` for
|
||||||
|
/// the per-destination advertise-IP logic.
|
||||||
|
pub public_ip: Option<String>,
|
||||||
/// The remote media endpoint (learned from SDP or address learning).
|
/// The remote media endpoint (learned from SDP or address learning).
|
||||||
pub remote_media: Option<SocketAddr>,
|
pub remote_media: Option<SocketAddr>,
|
||||||
/// SIP signaling address (provider or device).
|
/// SIP signaling address (provider or device).
|
||||||
@@ -124,14 +146,21 @@ pub struct LegInfo {
|
|||||||
|
|
||||||
/// A multiparty call with N legs and a central mixer.
|
/// A multiparty call with N legs and a central mixer.
|
||||||
pub struct Call {
|
pub struct Call {
|
||||||
|
// Duplicated from the HashMap key in CallManager. Kept for future
|
||||||
|
// status-snapshot work.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub state: CallState,
|
pub state: CallState,
|
||||||
|
// Populated at call creation but not currently consumed — dashboard
|
||||||
|
// pull snapshots are gone (push events only).
|
||||||
|
#[allow(dead_code)]
|
||||||
pub direction: CallDirection,
|
pub direction: CallDirection,
|
||||||
pub created_at: Instant,
|
pub created_at: Instant,
|
||||||
|
|
||||||
// Metadata.
|
// Metadata.
|
||||||
pub caller_number: Option<String>,
|
pub caller_number: Option<String>,
|
||||||
pub callee_number: Option<String>,
|
pub callee_number: Option<String>,
|
||||||
|
#[allow(dead_code)]
|
||||||
pub provider_id: String,
|
pub provider_id: String,
|
||||||
|
|
||||||
/// Original INVITE from the device (for device-originated outbound calls).
|
/// Original INVITE from the device (for device-originated outbound calls).
|
||||||
@@ -211,42 +240,4 @@ impl Call {
|
|||||||
handle.abort();
|
handle.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Produce a JSON status snapshot for the dashboard.
|
|
||||||
pub fn to_status_json(&self) -> serde_json::Value {
|
|
||||||
let legs: Vec<serde_json::Value> = self
|
|
||||||
.legs
|
|
||||||
.values()
|
|
||||||
.filter(|l| l.state != LegState::Terminated)
|
|
||||||
.map(|l| {
|
|
||||||
let metadata: serde_json::Value = if l.metadata.is_empty() {
|
|
||||||
serde_json::json!({})
|
|
||||||
} else {
|
|
||||||
serde_json::Value::Object(
|
|
||||||
l.metadata.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
serde_json::json!({
|
|
||||||
"id": l.id,
|
|
||||||
"type": l.kind.as_str(),
|
|
||||||
"state": l.state.as_str(),
|
|
||||||
"codec": sip_proto::helpers::codec_name(l.codec_pt),
|
|
||||||
"rtpPort": l.rtp_port,
|
|
||||||
"remoteMedia": l.remote_media.map(|a| format!("{}:{}", a.ip(), a.port())),
|
|
||||||
"metadata": metadata,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
serde_json::json!({
|
|
||||||
"id": self.id,
|
|
||||||
"state": self.state.as_str(),
|
|
||||||
"direction": self.direction.as_str(),
|
|
||||||
"callerNumber": self.caller_number,
|
|
||||||
"calleeNumber": self.callee_number,
|
|
||||||
"providerUsed": self.provider_id,
|
|
||||||
"duration": self.duration_secs(),
|
|
||||||
"legs": legs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,43 @@ use std::net::SocketAddr;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn emit_leg_added_event(tx: &OutTx, call_id: &str, leg: &LegInfo) {
|
||||||
|
let metadata: serde_json::Value = if leg.metadata.is_empty() {
|
||||||
|
serde_json::json!({})
|
||||||
|
} else {
|
||||||
|
serde_json::Value::Object(
|
||||||
|
leg.metadata
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
emit_event(
|
||||||
|
tx,
|
||||||
|
"leg_added",
|
||||||
|
serde_json::json!({
|
||||||
|
"call_id": call_id,
|
||||||
|
"leg_id": leg.id,
|
||||||
|
"kind": leg.kind.as_str(),
|
||||||
|
"state": leg.state.as_str(),
|
||||||
|
"codec": sip_proto::helpers::codec_name(leg.codec_pt),
|
||||||
|
"rtpPort": leg.rtp_port,
|
||||||
|
"remoteMedia": leg.remote_media.map(|a| format!("{}:{}", a.ip(), a.port())),
|
||||||
|
"metadata": metadata,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub struct CallManager {
|
pub struct CallManager {
|
||||||
/// All active calls, keyed by internal call ID.
|
/// All active calls, keyed by internal call ID.
|
||||||
pub calls: HashMap<String, Call>,
|
pub calls: HashMap<String, Call>,
|
||||||
@@ -65,26 +102,6 @@ impl CallManager {
|
|||||||
self.sip_index.contains_key(sip_call_id)
|
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
|
// SIP message routing
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -120,7 +137,19 @@ impl CallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Passthrough-style routing for inbound/outbound device↔provider calls.
|
// Passthrough-style routing for inbound/outbound device↔provider calls.
|
||||||
self.route_passthrough_message(&call_id, &leg_id, msg, from_addr, socket, config)
|
// The sip_index only stores one leg for shared Call-IDs, so we need to
|
||||||
|
// determine which leg the message actually belongs to by comparing from_addr.
|
||||||
|
let actual_leg_id = self
|
||||||
|
.calls
|
||||||
|
.get(&call_id)
|
||||||
|
.and_then(|call| {
|
||||||
|
call.legs
|
||||||
|
.values()
|
||||||
|
.find(|l| l.signaling_addr == Some(from_addr))
|
||||||
|
.map(|l| l.id.clone())
|
||||||
|
})
|
||||||
|
.unwrap_or(leg_id);
|
||||||
|
self.route_passthrough_message(&call_id, &actual_leg_id, msg, from_addr, socket, config)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +184,17 @@ impl CallManager {
|
|||||||
};
|
};
|
||||||
// Mutable borrow on call/leg is now released.
|
// Mutable borrow on call/leg is now released.
|
||||||
|
|
||||||
let sip_pt = codecs.first().copied().unwrap_or(9);
|
let mut sip_pt = codecs.first().copied().unwrap_or(9);
|
||||||
|
|
||||||
|
// If the message has SDP (e.g., 200 OK answer), use the negotiated codec
|
||||||
|
// instead of the offered one.
|
||||||
|
if msg.has_sdp_body() {
|
||||||
|
if let Some(ep) = parse_sdp_endpoint(&msg.body) {
|
||||||
|
if let Some(pt) = ep.codec_pt {
|
||||||
|
sip_pt = pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
SipLegAction::None => {}
|
SipLegAction::None => {}
|
||||||
@@ -253,14 +292,27 @@ impl CallManager {
|
|||||||
dev_leg.state = LegState::Connected;
|
dev_leg.state = LegState::Connected;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
emit_event(
|
||||||
|
&self.out_tx,
|
||||||
|
"leg_state_changed",
|
||||||
|
serde_json::json!({ "call_id": call_id, "leg_id": dev_leg_id, "state": "connected" }),
|
||||||
|
);
|
||||||
|
|
||||||
// Wire device leg to mixer.
|
// Wire device leg to mixer.
|
||||||
|
// Use the device's preferred codec from its INVITE SDP,
|
||||||
|
// not the provider's negotiated codec.
|
||||||
|
let dev_pt = device_invite
|
||||||
|
.has_sdp_body()
|
||||||
|
.then(|| parse_sdp_endpoint(&device_invite.body))
|
||||||
|
.flatten()
|
||||||
|
.and_then(|ep| ep.codec_pt)
|
||||||
|
.unwrap_or(sip_pt);
|
||||||
if let Some(dev_remote_addr) = dev_remote {
|
if let Some(dev_remote_addr) = dev_remote {
|
||||||
let dev_channels = create_leg_channels();
|
let dev_channels = create_leg_channels();
|
||||||
spawn_sip_inbound(dev_rtp_socket.clone(), dev_channels.inbound_tx);
|
spawn_sip_inbound(dev_rtp_socket.clone(), dev_channels.inbound_tx);
|
||||||
spawn_sip_outbound(dev_rtp_socket, dev_remote_addr, dev_channels.outbound_rx);
|
spawn_sip_outbound(dev_rtp_socket, dev_remote_addr, dev_channels.outbound_rx);
|
||||||
if let Some(call) = self.calls.get(call_id) {
|
if let Some(call) = self.calls.get(call_id) {
|
||||||
call.add_leg_to_mixer(&dev_leg_id, sip_pt, dev_channels.inbound_rx, dev_channels.outbound_tx)
|
call.add_leg_to_mixer(&dev_leg_id, dev_pt, dev_channels.inbound_rx, dev_channels.outbound_tx)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,6 +364,8 @@ impl CallManager {
|
|||||||
leg.state = LegState::Terminated;
|
leg.state = LegState::Terminated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
emit_event(&self.out_tx, "leg_state_changed",
|
||||||
|
serde_json::json!({ "call_id": call_id, "leg_id": leg_id, "state": "terminated" }));
|
||||||
emit_event(&self.out_tx, "call_ended",
|
emit_event(&self.out_tx, "call_ended",
|
||||||
serde_json::json!({ "call_id": call_id, "reason": reason, "duration": duration }));
|
serde_json::json!({ "call_id": call_id, "reason": reason, "duration": duration }));
|
||||||
self.terminate_call(call_id).await;
|
self.terminate_call(call_id).await;
|
||||||
@@ -360,8 +414,8 @@ impl CallManager {
|
|||||||
|
|
||||||
// Find the counterpart leg.
|
// Find the counterpart leg.
|
||||||
let other_leg = call.legs.values().find(|l| l.id != this_leg_id && l.state != LegState::Terminated);
|
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 {
|
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()),
|
Some(l) => (l.signaling_addr, l.rtp_port, l.id.clone(), l.kind, l.public_ip.clone()),
|
||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
let forward_to = match other_addr {
|
let forward_to = match other_addr {
|
||||||
@@ -372,8 +426,14 @@ impl CallManager {
|
|||||||
let lan_ip = config.proxy.lan_ip.clone();
|
let lan_ip = config.proxy.lan_ip.clone();
|
||||||
let lan_port = config.proxy.lan_port;
|
let lan_port = config.proxy.lan_port;
|
||||||
|
|
||||||
// Get this leg's RTP port (for SDP rewriting — tell the other side to send RTP here).
|
// Pick the IP to advertise to the destination leg. Provider legs face
|
||||||
let this_rtp_port = call.legs.get(this_leg_id).map(|l| l.rtp_port).unwrap_or(0);
|
// 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).
|
// 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)
|
let other_has_sip_leg = call.legs.get(&other_leg_id)
|
||||||
@@ -467,10 +527,11 @@ impl CallManager {
|
|||||||
|
|
||||||
// Forward other requests with SDP rewriting.
|
// Forward other requests with SDP rewriting.
|
||||||
let mut fwd = msg.clone();
|
let mut fwd = msg.clone();
|
||||||
// Rewrite SDP to point the other side to this leg's RTP port
|
// Rewrite SDP so the destination leg sends RTP to our proxy port
|
||||||
// (so we receive their audio on our socket).
|
// 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() {
|
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.body = new_body;
|
||||||
fwd.update_content_length();
|
fwd.update_content_length();
|
||||||
}
|
}
|
||||||
@@ -482,7 +543,8 @@ impl CallManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if fwd.is_dialog_establishing() {
|
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;
|
let _ = socket.send_to(&fwd.serialize(), forward_to).await;
|
||||||
return true;
|
return true;
|
||||||
@@ -494,15 +556,10 @@ impl CallManager {
|
|||||||
let cseq_method = msg.cseq_method().unwrap_or("").to_uppercase();
|
let cseq_method = msg.cseq_method().unwrap_or("").to_uppercase();
|
||||||
|
|
||||||
let mut fwd = msg.clone();
|
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() {
|
if fwd.has_sdp_body() {
|
||||||
let rewrite_ip = if this_kind == LegKind::SipDevice {
|
let (new_body, _) = rewrite_sdp(&fwd.body, &advertise_ip, other_rtp_port);
|
||||||
// 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);
|
|
||||||
fwd.body = new_body;
|
fwd.body = new_body;
|
||||||
fwd.update_content_length();
|
fwd.update_content_length();
|
||||||
}
|
}
|
||||||
@@ -517,21 +574,30 @@ impl CallManager {
|
|||||||
if let Some(leg) = call.legs.get_mut(this_leg_id) {
|
if let Some(leg) = call.legs.get_mut(this_leg_id) {
|
||||||
leg.state = LegState::Ringing;
|
leg.state = LegState::Ringing;
|
||||||
}
|
}
|
||||||
|
emit_event(&self.out_tx, "leg_state_changed",
|
||||||
|
serde_json::json!({ "call_id": call_id, "leg_id": this_leg_id, "state": "ringing" }));
|
||||||
} else if code >= 200 && code < 300 {
|
} else if code >= 200 && code < 300 {
|
||||||
let mut needs_wiring = false;
|
let mut needs_wiring = false;
|
||||||
if let Some(leg) = call.legs.get_mut(this_leg_id) {
|
if let Some(leg) = call.legs.get_mut(this_leg_id) {
|
||||||
leg.state = LegState::Connected;
|
leg.state = LegState::Connected;
|
||||||
// Learn remote media from SDP.
|
// Learn remote media and negotiated codec from SDP answer.
|
||||||
if msg.has_sdp_body() {
|
if msg.has_sdp_body() {
|
||||||
if let Some(ep) = parse_sdp_endpoint(&msg.body) {
|
if let Some(ep) = parse_sdp_endpoint(&msg.body) {
|
||||||
if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() {
|
if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() {
|
||||||
leg.remote_media = Some(addr);
|
leg.remote_media = Some(addr);
|
||||||
}
|
}
|
||||||
|
// Use the codec from the SDP answer (what the remote actually selected).
|
||||||
|
if let Some(pt) = ep.codec_pt {
|
||||||
|
leg.codec_pt = pt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
needs_wiring = true;
|
needs_wiring = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit_event(&self.out_tx, "leg_state_changed",
|
||||||
|
serde_json::json!({ "call_id": call_id, "leg_id": this_leg_id, "state": "connected" }));
|
||||||
|
|
||||||
if call.state != CallState::Connected {
|
if call.state != CallState::Connected {
|
||||||
call.state = CallState::Connected;
|
call.state = CallState::Connected;
|
||||||
emit_event(&self.out_tx, "call_answered", serde_json::json!({ "call_id": call_id }));
|
emit_event(&self.out_tx, "call_answered", serde_json::json!({ "call_id": call_id }));
|
||||||
@@ -615,7 +681,7 @@ impl CallManager {
|
|||||||
rtp_pool: &mut RtpPortPool,
|
rtp_pool: &mut RtpPortPool,
|
||||||
socket: &UdpSocket,
|
socket: &UdpSocket,
|
||||||
public_ip: Option<&str>,
|
public_ip: Option<&str>,
|
||||||
) -> Option<String> {
|
) -> Option<InboundCallCreated> {
|
||||||
let call_id = self.next_call_id();
|
let call_id = self.next_call_id();
|
||||||
let lan_ip = &config.proxy.lan_ip;
|
let lan_ip = &config.proxy.lan_ip;
|
||||||
let lan_port = config.proxy.lan_port;
|
let lan_port = config.proxy.lan_port;
|
||||||
@@ -632,17 +698,41 @@ impl CallManager {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Resolve target device.
|
// Resolve via the configured inbound routing table. This honors
|
||||||
let device_addr = match self.resolve_first_device(config, registrar) {
|
// 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.
|
||||||
|
// - `voicemail_box`, `ivr_menu_id`, `no_answer_timeout` are not honored.
|
||||||
|
let route = config.resolve_inbound_route(provider_id, &called_number, &caller_number);
|
||||||
|
let ring_browsers = route.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,
|
Some(addr) => addr,
|
||||||
None => {
|
None => {
|
||||||
// No device registered → voicemail.
|
// No device registered → voicemail.
|
||||||
return self
|
let call_id = self
|
||||||
.route_to_voicemail(
|
.route_to_voicemail(
|
||||||
&call_id, invite, from_addr, &caller_number,
|
&call_id, invite, from_addr, &caller_number,
|
||||||
provider_id, provider_config, config, rtp_pool, socket, public_ip,
|
provider_id, provider_config, config, rtp_pool, socket, public_ip,
|
||||||
)
|
)
|
||||||
.await;
|
.await?;
|
||||||
|
return Some(InboundCallCreated { call_id, ring_browsers });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -677,15 +767,19 @@ impl CallManager {
|
|||||||
call.callee_number = Some(called_number);
|
call.callee_number = Some(called_number);
|
||||||
call.state = CallState::Ringing;
|
call.state = CallState::Ringing;
|
||||||
|
|
||||||
let codec_pt = provider_config.codecs.first().copied().unwrap_or(9);
|
let mut codec_pt = provider_config.codecs.first().copied().unwrap_or(9);
|
||||||
|
|
||||||
// Provider leg — extract media from SDP.
|
// Provider leg — extract media and negotiated codec from SDP.
|
||||||
let mut provider_media: Option<SocketAddr> = None;
|
let mut provider_media: Option<SocketAddr> = None;
|
||||||
if invite.has_sdp_body() {
|
if invite.has_sdp_body() {
|
||||||
if let Some(ep) = parse_sdp_endpoint(&invite.body) {
|
if let Some(ep) = parse_sdp_endpoint(&invite.body) {
|
||||||
if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() {
|
if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() {
|
||||||
provider_media = Some(addr);
|
provider_media = Some(addr);
|
||||||
}
|
}
|
||||||
|
// Use the codec from the provider's SDP offer (what they actually want to use).
|
||||||
|
if let Some(pt) = ep.codec_pt {
|
||||||
|
codec_pt = pt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,6 +796,7 @@ impl CallManager {
|
|||||||
webrtc_session_id: None,
|
webrtc_session_id: None,
|
||||||
rtp_socket: Some(provider_rtp.socket.clone()),
|
rtp_socket: Some(provider_rtp.socket.clone()),
|
||||||
rtp_port: provider_rtp.port,
|
rtp_port: provider_rtp.port,
|
||||||
|
public_ip: public_ip.map(|s| s.to_string()),
|
||||||
remote_media: provider_media,
|
remote_media: provider_media,
|
||||||
signaling_addr: Some(from_addr),
|
signaling_addr: Some(from_addr),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
@@ -722,6 +817,7 @@ impl CallManager {
|
|||||||
webrtc_session_id: None,
|
webrtc_session_id: None,
|
||||||
rtp_socket: Some(device_rtp.socket.clone()),
|
rtp_socket: Some(device_rtp.socket.clone()),
|
||||||
rtp_port: device_rtp.port,
|
rtp_port: device_rtp.port,
|
||||||
|
public_ip: None,
|
||||||
remote_media: None, // Learned from device's 200 OK.
|
remote_media: None, // Learned from device's 200 OK.
|
||||||
signaling_addr: Some(device_addr),
|
signaling_addr: Some(device_addr),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
@@ -755,7 +851,17 @@ impl CallManager {
|
|||||||
// Store the call.
|
// Store the call.
|
||||||
self.calls.insert(call_id.clone(), call);
|
self.calls.insert(call_id.clone(), call);
|
||||||
|
|
||||||
Some(call_id)
|
// Emit leg_added for both initial legs.
|
||||||
|
if let Some(call) = self.calls.get(&call_id) {
|
||||||
|
if let Some(leg) = call.legs.get(&provider_leg_id) {
|
||||||
|
emit_leg_added_event(&self.out_tx, &call_id, leg);
|
||||||
|
}
|
||||||
|
if let Some(leg) = call.legs.get(&device_leg_id) {
|
||||||
|
emit_leg_added_event(&self.out_tx, &call_id, leg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(InboundCallCreated { call_id, ring_browsers })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initiate an outbound B2BUA call from the dashboard.
|
/// Initiate an outbound B2BUA call from the dashboard.
|
||||||
@@ -831,6 +937,7 @@ impl CallManager {
|
|||||||
webrtc_session_id: None,
|
webrtc_session_id: None,
|
||||||
rtp_socket: Some(rtp_alloc.socket.clone()),
|
rtp_socket: Some(rtp_alloc.socket.clone()),
|
||||||
rtp_port: rtp_alloc.port,
|
rtp_port: rtp_alloc.port,
|
||||||
|
public_ip: public_ip.map(|s| s.to_string()),
|
||||||
remote_media: None,
|
remote_media: None,
|
||||||
signaling_addr: Some(provider_dest),
|
signaling_addr: Some(provider_dest),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
@@ -842,6 +949,14 @@ impl CallManager {
|
|||||||
.insert(sip_call_id, (call_id.clone(), leg_id));
|
.insert(sip_call_id, (call_id.clone(), leg_id));
|
||||||
|
|
||||||
self.calls.insert(call_id.clone(), call);
|
self.calls.insert(call_id.clone(), 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Some(call_id)
|
Some(call_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,11 +981,18 @@ impl CallManager {
|
|||||||
let lan_port = config.proxy.lan_port;
|
let lan_port = config.proxy.lan_port;
|
||||||
let device_sip_call_id = invite.call_id().to_string();
|
let device_sip_call_id = invite.call_id().to_string();
|
||||||
|
|
||||||
|
// Extract just the user part from the request URI (e.g., "sip:16196000@10.0.0.1" → "16196000").
|
||||||
|
// extract_uri is for header values with angle brackets, not bare request URIs.
|
||||||
let dialed_number = invite
|
let dialed_number = invite
|
||||||
.request_uri()
|
.request_uri()
|
||||||
.and_then(|uri| SipMessage::extract_uri(uri))
|
.map(|uri| {
|
||||||
.unwrap_or(invite.request_uri().unwrap_or(""))
|
let stripped = uri
|
||||||
.to_string();
|
.strip_prefix("sip:")
|
||||||
|
.or_else(|| uri.strip_prefix("sips:"))
|
||||||
|
.unwrap_or(uri);
|
||||||
|
stripped.split('@').next().unwrap_or(stripped).to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let provider_dest: SocketAddr = match provider_config.outbound_proxy.to_socket_addr() {
|
let provider_dest: SocketAddr = match provider_config.outbound_proxy.to_socket_addr() {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
@@ -926,6 +1048,7 @@ impl CallManager {
|
|||||||
sip_leg: None,
|
sip_leg: None,
|
||||||
sip_call_id: Some(device_sip_call_id.clone()),
|
sip_call_id: Some(device_sip_call_id.clone()),
|
||||||
webrtc_session_id: None,
|
webrtc_session_id: None,
|
||||||
|
public_ip: None,
|
||||||
rtp_socket: Some(device_rtp.socket.clone()),
|
rtp_socket: Some(device_rtp.socket.clone()),
|
||||||
rtp_port: device_rtp.port,
|
rtp_port: device_rtp.port,
|
||||||
remote_media: device_media,
|
remote_media: device_media,
|
||||||
@@ -972,6 +1095,7 @@ impl CallManager {
|
|||||||
webrtc_session_id: None,
|
webrtc_session_id: None,
|
||||||
rtp_socket: Some(provider_rtp.socket.clone()),
|
rtp_socket: Some(provider_rtp.socket.clone()),
|
||||||
rtp_port: provider_rtp.port,
|
rtp_port: provider_rtp.port,
|
||||||
|
public_ip: public_ip.map(|s| s.to_string()),
|
||||||
remote_media: None,
|
remote_media: None,
|
||||||
signaling_addr: Some(provider_dest),
|
signaling_addr: Some(provider_dest),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
@@ -983,6 +1107,14 @@ impl CallManager {
|
|||||||
.insert(provider_sip_call_id, (call_id.clone(), provider_leg_id));
|
.insert(provider_sip_call_id, (call_id.clone(), provider_leg_id));
|
||||||
|
|
||||||
self.calls.insert(call_id.clone(), call);
|
self.calls.insert(call_id.clone(), call);
|
||||||
|
|
||||||
|
// Emit leg_added for both initial legs (device + provider).
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Some(call_id)
|
Some(call_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,7 +1134,7 @@ impl CallManager {
|
|||||||
public_ip: Option<&str>,
|
public_ip: Option<&str>,
|
||||||
registered_aor: &str,
|
registered_aor: &str,
|
||||||
) -> Option<String> {
|
) -> 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_ip = &config.proxy.lan_ip;
|
||||||
let lan_port = config.proxy.lan_port;
|
let lan_port = config.proxy.lan_port;
|
||||||
|
|
||||||
@@ -1039,6 +1171,7 @@ impl CallManager {
|
|||||||
webrtc_session_id: None,
|
webrtc_session_id: None,
|
||||||
rtp_socket: Some(rtp_alloc.socket.clone()),
|
rtp_socket: Some(rtp_alloc.socket.clone()),
|
||||||
rtp_port: rtp_alloc.port,
|
rtp_port: rtp_alloc.port,
|
||||||
|
public_ip: public_ip.map(|s| s.to_string()),
|
||||||
remote_media: None,
|
remote_media: None,
|
||||||
signaling_addr: Some(provider_dest),
|
signaling_addr: Some(provider_dest),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
@@ -1050,17 +1183,11 @@ impl CallManager {
|
|||||||
let call = self.calls.get_mut(call_id).unwrap();
|
let call = self.calls.get_mut(call_id).unwrap();
|
||||||
call.legs.insert(leg_id.clone(), leg_info);
|
call.legs.insert(leg_id.clone(), leg_info);
|
||||||
|
|
||||||
emit_event(
|
if let Some(call) = self.calls.get(call_id) {
|
||||||
&self.out_tx,
|
if let Some(leg) = call.legs.get(&leg_id) {
|
||||||
"leg_added",
|
emit_leg_added_event(&self.out_tx, call_id, leg);
|
||||||
serde_json::json!({
|
}
|
||||||
"call_id": call_id,
|
}
|
||||||
"leg_id": leg_id,
|
|
||||||
"kind": "sip-provider",
|
|
||||||
"state": "inviting",
|
|
||||||
"number": number,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Some(leg_id)
|
Some(leg_id)
|
||||||
}
|
}
|
||||||
@@ -1076,7 +1203,7 @@ impl CallManager {
|
|||||||
socket: &UdpSocket,
|
socket: &UdpSocket,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let device_addr = registrar.get_device_contact(device_id)?;
|
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_ip = &config.proxy.lan_ip;
|
||||||
let lan_port = config.proxy.lan_port;
|
let lan_port = config.proxy.lan_port;
|
||||||
|
|
||||||
@@ -1115,6 +1242,7 @@ impl CallManager {
|
|||||||
webrtc_session_id: None,
|
webrtc_session_id: None,
|
||||||
rtp_socket: Some(rtp_alloc.socket.clone()),
|
rtp_socket: Some(rtp_alloc.socket.clone()),
|
||||||
rtp_port: rtp_alloc.port,
|
rtp_port: rtp_alloc.port,
|
||||||
|
public_ip: None,
|
||||||
remote_media: None,
|
remote_media: None,
|
||||||
signaling_addr: Some(device_addr),
|
signaling_addr: Some(device_addr),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
@@ -1126,17 +1254,11 @@ impl CallManager {
|
|||||||
let call = self.calls.get_mut(call_id).unwrap();
|
let call = self.calls.get_mut(call_id).unwrap();
|
||||||
call.legs.insert(leg_id.clone(), leg_info);
|
call.legs.insert(leg_id.clone(), leg_info);
|
||||||
|
|
||||||
emit_event(
|
if let Some(call) = self.calls.get(call_id) {
|
||||||
&self.out_tx,
|
if let Some(leg) = call.legs.get(&leg_id) {
|
||||||
"leg_added",
|
emit_leg_added_event(&self.out_tx, call_id, leg);
|
||||||
serde_json::json!({
|
}
|
||||||
"call_id": call_id,
|
}
|
||||||
"leg_id": leg_id,
|
|
||||||
"kind": "sip-device",
|
|
||||||
"state": "inviting",
|
|
||||||
"device_id": device_id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Some(leg_id)
|
Some(leg_id)
|
||||||
}
|
}
|
||||||
@@ -1223,6 +1345,13 @@ impl CallManager {
|
|||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Emit leg_removed for source call.
|
||||||
|
emit_event(
|
||||||
|
&self.out_tx,
|
||||||
|
"leg_removed",
|
||||||
|
serde_json::json!({ "call_id": source_call_id, "leg_id": leg_id }),
|
||||||
|
);
|
||||||
|
|
||||||
// Update SIP index to point to the target call.
|
// Update SIP index to point to the target call.
|
||||||
if let Some(sip_cid) = &leg_info.sip_call_id {
|
if let Some(sip_cid) = &leg_info.sip_call_id {
|
||||||
self.sip_index.insert(
|
self.sip_index.insert(
|
||||||
@@ -1255,15 +1384,12 @@ impl CallManager {
|
|||||||
let target_call = self.calls.get_mut(target_call_id).unwrap();
|
let target_call = self.calls.get_mut(target_call_id).unwrap();
|
||||||
target_call.legs.insert(leg_id.to_string(), leg_info);
|
target_call.legs.insert(leg_id.to_string(), leg_info);
|
||||||
|
|
||||||
emit_event(
|
// Emit leg_added for target call.
|
||||||
&self.out_tx,
|
if let Some(target) = self.calls.get(target_call_id) {
|
||||||
"leg_transferred",
|
if let Some(leg) = target.legs.get(leg_id) {
|
||||||
serde_json::json!({
|
emit_leg_added_event(&self.out_tx, target_call_id, leg);
|
||||||
"leg_id": leg_id,
|
}
|
||||||
"source_call_id": source_call_id,
|
}
|
||||||
"target_call_id": target_call_id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if source call has too few legs remaining.
|
// Check if source call has too few legs remaining.
|
||||||
let source_call = self.calls.get(source_call_id).unwrap();
|
let source_call = self.calls.get(source_call_id).unwrap();
|
||||||
@@ -1366,6 +1492,11 @@ impl CallManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
leg.state = LegState::Terminated;
|
leg.state = LegState::Terminated;
|
||||||
|
emit_event(
|
||||||
|
&self.out_tx,
|
||||||
|
"leg_state_changed",
|
||||||
|
serde_json::json!({ "call_id": call_id, "leg_id": leg.id, "state": "terminated" }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_event(
|
emit_event(
|
||||||
@@ -1472,6 +1603,7 @@ impl CallManager {
|
|||||||
webrtc_session_id: None,
|
webrtc_session_id: None,
|
||||||
rtp_socket: Some(rtp_alloc.socket.clone()),
|
rtp_socket: Some(rtp_alloc.socket.clone()),
|
||||||
rtp_port: rtp_alloc.port,
|
rtp_port: rtp_alloc.port,
|
||||||
|
public_ip: public_ip.map(|s| s.to_string()),
|
||||||
remote_media: Some(provider_media),
|
remote_media: Some(provider_media),
|
||||||
signaling_addr: Some(from_addr),
|
signaling_addr: Some(from_addr),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
@@ -1484,6 +1616,13 @@ impl CallManager {
|
|||||||
);
|
);
|
||||||
self.calls.insert(call_id.to_string(), call);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build recording path.
|
// Build recording path.
|
||||||
let timestamp = std::time::SystemTime::now()
|
let timestamp = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ impl Endpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Provider quirks for codec/protocol workarounds.
|
/// Provider quirks for codec/protocol workarounds.
|
||||||
|
//
|
||||||
|
// Deserialized from provider config for TS parity. Early-media silence
|
||||||
|
// injection and related workarounds are not yet ported to the Rust engine,
|
||||||
|
// so every field is populated by serde but not yet consumed.
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct Quirks {
|
pub struct Quirks {
|
||||||
#[serde(rename = "earlyMediaSilence")]
|
#[serde(rename = "earlyMediaSilence")]
|
||||||
@@ -44,6 +49,9 @@ pub struct Quirks {
|
|||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct ProviderConfig {
|
pub struct ProviderConfig {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
// UI label — populated by serde for parity with the TS config, not
|
||||||
|
// consumed at runtime.
|
||||||
|
#[allow(dead_code)]
|
||||||
#[serde(rename = "displayName")]
|
#[serde(rename = "displayName")]
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub domain: String,
|
pub domain: String,
|
||||||
@@ -54,6 +62,8 @@ pub struct ProviderConfig {
|
|||||||
#[serde(rename = "registerIntervalSec")]
|
#[serde(rename = "registerIntervalSec")]
|
||||||
pub register_interval_sec: u32,
|
pub register_interval_sec: u32,
|
||||||
pub codecs: Vec<u8>,
|
pub codecs: Vec<u8>,
|
||||||
|
// Workaround knobs populated by serde but not yet acted upon — see Quirks.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub quirks: Quirks,
|
pub quirks: Quirks,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +94,10 @@ pub struct RouteMatch {
|
|||||||
|
|
||||||
/// Route action.
|
/// Route action.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
// Several fields (voicemail_box, ivr_menu_id, no_answer_timeout) are read
|
||||||
|
// by resolve_inbound_route but not yet honored downstream — see the
|
||||||
|
// multi-target TODO in CallManager::create_inbound_call.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct RouteAction {
|
pub struct RouteAction {
|
||||||
pub targets: Option<Vec<String>>,
|
pub targets: Option<Vec<String>>,
|
||||||
#[serde(rename = "ringBrowsers")]
|
#[serde(rename = "ringBrowsers")]
|
||||||
@@ -106,7 +120,11 @@ pub struct RouteAction {
|
|||||||
/// A routing rule.
|
/// A routing rule.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct Route {
|
pub struct Route {
|
||||||
|
// `id` and `name` are UI identifiers, populated by serde but not
|
||||||
|
// consumed by the resolvers.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub priority: i32,
|
pub priority: i32,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -192,10 +210,18 @@ pub fn matches_pattern(pattern: Option<&str>, value: &str) -> bool {
|
|||||||
/// Result of resolving an outbound route.
|
/// Result of resolving an outbound route.
|
||||||
pub struct OutboundRouteResult {
|
pub struct OutboundRouteResult {
|
||||||
pub provider: ProviderConfig,
|
pub provider: ProviderConfig,
|
||||||
|
// TODO: prefix rewriting is unfinished — this is computed but the
|
||||||
|
// caller ignores it and uses the raw dialed number.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub transformed_number: String,
|
pub transformed_number: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of resolving an inbound route.
|
/// Result of resolving an inbound route.
|
||||||
|
//
|
||||||
|
// `device_ids` and `ring_browsers` are consumed by create_inbound_call.
|
||||||
|
// The remaining fields (voicemail_box, ivr_menu_id, no_answer_timeout)
|
||||||
|
// are resolved but not yet acted upon — see the multi-target TODO.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct InboundRouteResult {
|
pub struct InboundRouteResult {
|
||||||
pub device_ids: Vec<String>,
|
pub device_ids: Vec<String>,
|
||||||
pub ring_browsers: bool,
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
188
rust/crates/proxy-engine/src/jitter_buffer.rs
Normal file
188
rust/crates/proxy-engine/src/jitter_buffer.rs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
//! Per-leg adaptive jitter buffer for the audio mixer.
|
||||||
|
//!
|
||||||
|
//! Sits between inbound RTP packet reception and the mixer's decode step.
|
||||||
|
//! Reorders packets by sequence number and delivers exactly one frame per
|
||||||
|
//! 20ms mixer tick, smoothing out network jitter. When a packet is missing,
|
||||||
|
//! the mixer can invoke codec PLC to conceal the gap.
|
||||||
|
|
||||||
|
use crate::mixer::RtpPacket;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// Per-leg jitter buffer. Collects RTP packets keyed by sequence number,
|
||||||
|
/// delivers one frame per 20ms tick in sequence order.
|
||||||
|
///
|
||||||
|
/// Adaptive target depth: starts at 3 frames (60ms), adjusts between
|
||||||
|
/// 2–6 frames based on observed jitter.
|
||||||
|
pub struct JitterBuffer {
|
||||||
|
/// Packets waiting for playout, keyed by seq number.
|
||||||
|
buffer: BTreeMap<u16, RtpPacket>,
|
||||||
|
/// Next expected sequence number for playout.
|
||||||
|
next_seq: Option<u16>,
|
||||||
|
/// Target buffer depth in frames (adaptive).
|
||||||
|
target_depth: u32,
|
||||||
|
/// Current fill level high-water mark (for adaptation).
|
||||||
|
max_fill_seen: u32,
|
||||||
|
/// Ticks since last adaptation adjustment.
|
||||||
|
adapt_counter: u32,
|
||||||
|
/// Consecutive ticks where buffer was empty (for ramp-up).
|
||||||
|
empty_streak: u32,
|
||||||
|
/// Consecutive ticks where buffer had excess (for ramp-down).
|
||||||
|
excess_streak: u32,
|
||||||
|
/// Whether we've started playout (initial fill complete).
|
||||||
|
playing: bool,
|
||||||
|
/// Number of frames consumed since start (for stats).
|
||||||
|
frames_consumed: u64,
|
||||||
|
/// Number of frames lost (gap in sequence).
|
||||||
|
frames_lost: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the mixer gets back each tick.
|
||||||
|
pub enum JitterResult {
|
||||||
|
/// A packet is available for decoding.
|
||||||
|
Packet(RtpPacket),
|
||||||
|
/// Packet was expected but missing — invoke PLC.
|
||||||
|
Missing,
|
||||||
|
/// Buffer is in initial fill phase — output silence.
|
||||||
|
Filling,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JitterBuffer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
buffer: BTreeMap::new(),
|
||||||
|
next_seq: None,
|
||||||
|
target_depth: 3, // 60ms initial target
|
||||||
|
max_fill_seen: 0,
|
||||||
|
adapt_counter: 0,
|
||||||
|
empty_streak: 0,
|
||||||
|
excess_streak: 0,
|
||||||
|
playing: false,
|
||||||
|
frames_consumed: 0,
|
||||||
|
frames_lost: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a received RTP packet into the buffer.
|
||||||
|
pub fn push(&mut self, pkt: RtpPacket) {
|
||||||
|
// Ignore duplicates.
|
||||||
|
if self.buffer.contains_key(&pkt.seq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect large forward seq jump (hold/resume, SSRC change).
|
||||||
|
if let Some(next) = self.next_seq {
|
||||||
|
let jump = pkt.seq.wrapping_sub(next);
|
||||||
|
if jump > 1000 && jump < 0x8000 {
|
||||||
|
// Massive forward jump — reset buffer.
|
||||||
|
self.reset();
|
||||||
|
self.next_seq = Some(pkt.seq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.next_seq.is_none() {
|
||||||
|
self.next_seq = Some(pkt.seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.buffer.insert(pkt.seq, pkt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume one frame for the current 20ms tick.
|
||||||
|
/// Called once per mixer tick per leg.
|
||||||
|
pub fn consume(&mut self) -> JitterResult {
|
||||||
|
// Track fill level for adaptation.
|
||||||
|
let fill = self.buffer.len() as u32;
|
||||||
|
if fill > self.max_fill_seen {
|
||||||
|
self.max_fill_seen = fill;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fill phase: wait until we have target_depth packets.
|
||||||
|
if !self.playing {
|
||||||
|
if fill >= self.target_depth {
|
||||||
|
self.playing = true;
|
||||||
|
} else {
|
||||||
|
return JitterResult::Filling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = match self.next_seq {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return JitterResult::Filling,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Advance next_seq (wrapping u16).
|
||||||
|
self.next_seq = Some(seq.wrapping_add(1));
|
||||||
|
|
||||||
|
// Try to pull the expected sequence number.
|
||||||
|
if let Some(pkt) = self.buffer.remove(&seq) {
|
||||||
|
self.frames_consumed += 1;
|
||||||
|
self.empty_streak = 0;
|
||||||
|
|
||||||
|
// Adaptive: if buffer is consistently deep, we can tighten.
|
||||||
|
if fill > self.target_depth + 2 {
|
||||||
|
self.excess_streak += 1;
|
||||||
|
} else {
|
||||||
|
self.excess_streak = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
JitterResult::Packet(pkt)
|
||||||
|
} else {
|
||||||
|
// Packet missing — PLC needed.
|
||||||
|
self.frames_lost += 1;
|
||||||
|
self.empty_streak += 1;
|
||||||
|
self.excess_streak = 0;
|
||||||
|
|
||||||
|
JitterResult::Missing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run adaptation logic. Call every tick; internally gates to ~1s intervals.
|
||||||
|
pub fn adapt(&mut self) {
|
||||||
|
self.adapt_counter += 1;
|
||||||
|
if self.adapt_counter < 50 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.adapt_counter = 0;
|
||||||
|
|
||||||
|
// If we had many empty ticks, increase depth.
|
||||||
|
if self.empty_streak > 3 && self.target_depth < 6 {
|
||||||
|
self.target_depth += 1;
|
||||||
|
}
|
||||||
|
// If buffer consistently overfull, decrease depth.
|
||||||
|
else if self.excess_streak > 25 && self.target_depth > 2 {
|
||||||
|
self.target_depth -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.max_fill_seen = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discard packets that are too old (seq far behind next_seq).
|
||||||
|
/// Prevents unbounded memory growth from reordered/late packets.
|
||||||
|
pub fn prune_stale(&mut self) {
|
||||||
|
if let Some(next) = self.next_seq {
|
||||||
|
// Remove anything more than 100 frames behind playout point.
|
||||||
|
// Use wrapping arithmetic: if (next - seq) > 100, it's stale.
|
||||||
|
let stale: Vec<u16> = self
|
||||||
|
.buffer
|
||||||
|
.keys()
|
||||||
|
.filter(|&&seq| {
|
||||||
|
let age = next.wrapping_sub(seq);
|
||||||
|
age > 100 && age < 0x8000 // < 0x8000 means it's actually behind, not ahead
|
||||||
|
})
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
for seq in stale {
|
||||||
|
self.buffer.remove(&seq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the buffer (e.g., after re-INVITE / hold-resume).
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.buffer.clear();
|
||||||
|
self.next_seq = None;
|
||||||
|
self.playing = false;
|
||||||
|
self.empty_streak = 0;
|
||||||
|
self.excess_streak = 0;
|
||||||
|
self.adapt_counter = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,8 @@ pub fn create_leg_channels() -> LegChannels {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn the inbound I/O task for a SIP leg.
|
/// Spawn the inbound I/O task for a SIP leg.
|
||||||
/// Reads RTP from the socket, strips the 12-byte header, sends payload to the mixer.
|
/// Reads RTP from the socket, parses the variable-length header (RFC 3550),
|
||||||
|
/// and sends the payload to the mixer.
|
||||||
/// Returns the JoinHandle (exits when the inbound_tx channel is dropped).
|
/// Returns the JoinHandle (exits when the inbound_tx channel is dropped).
|
||||||
pub fn spawn_sip_inbound(
|
pub fn spawn_sip_inbound(
|
||||||
rtp_socket: Arc<UdpSocket>,
|
rtp_socket: Arc<UdpSocket>,
|
||||||
@@ -51,12 +52,29 @@ pub fn spawn_sip_inbound(
|
|||||||
}
|
}
|
||||||
let pt = buf[1] & 0x7F;
|
let pt = buf[1] & 0x7F;
|
||||||
let marker = (buf[1] & 0x80) != 0;
|
let marker = (buf[1] & 0x80) != 0;
|
||||||
|
let seq = u16::from_be_bytes([buf[2], buf[3]]);
|
||||||
let timestamp = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]);
|
let timestamp = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]);
|
||||||
let payload = buf[12..n].to_vec();
|
|
||||||
|
// RFC 3550: header length = 12 + (CC * 4) + optional extension.
|
||||||
|
let cc = (buf[0] & 0x0F) as usize;
|
||||||
|
let has_extension = (buf[0] & 0x10) != 0;
|
||||||
|
let mut offset = 12 + cc * 4;
|
||||||
|
if has_extension {
|
||||||
|
if offset + 4 > n {
|
||||||
|
continue; // Malformed: extension header truncated.
|
||||||
|
}
|
||||||
|
let ext_len = u16::from_be_bytes([buf[offset + 2], buf[offset + 3]]) as usize;
|
||||||
|
offset += 4 + ext_len * 4;
|
||||||
|
}
|
||||||
|
if offset >= n {
|
||||||
|
continue; // No payload after header.
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = buf[offset..n].to_vec();
|
||||||
if payload.is_empty() {
|
if payload.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if inbound_tx.send(RtpPacket { payload, payload_type: pt, marker, timestamp }).await.is_err() {
|
if inbound_tx.send(RtpPacket { payload, payload_type: pt, marker, seq, timestamp }).await.is_err() {
|
||||||
break; // Channel closed — leg removed.
|
break; // Channel closed — leg removed.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ mod audio_player;
|
|||||||
mod call;
|
mod call;
|
||||||
mod call_manager;
|
mod call_manager;
|
||||||
mod config;
|
mod config;
|
||||||
mod dtmf;
|
|
||||||
mod ipc;
|
mod ipc;
|
||||||
|
mod jitter_buffer;
|
||||||
mod leg_io;
|
mod leg_io;
|
||||||
mod mixer;
|
mod mixer;
|
||||||
mod provider;
|
mod provider;
|
||||||
@@ -139,7 +139,6 @@ async fn handle_command(
|
|||||||
"configure" => handle_configure(engine, out_tx, &cmd).await,
|
"configure" => handle_configure(engine, out_tx, &cmd).await,
|
||||||
"hangup" => handle_hangup(engine, out_tx, &cmd).await,
|
"hangup" => handle_hangup(engine, out_tx, &cmd).await,
|
||||||
"make_call" => handle_make_call(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,
|
"add_leg" => handle_add_leg(engine, out_tx, &cmd).await,
|
||||||
"remove_leg" => handle_remove_leg(engine, out_tx, &cmd).await,
|
"remove_leg" => handle_remove_leg(engine, out_tx, &cmd).await,
|
||||||
// WebRTC commands — lock webrtc only (no engine contention).
|
// WebRTC commands — lock webrtc only (no engine contention).
|
||||||
@@ -329,7 +328,7 @@ async fn handle_sip_packet(
|
|||||||
..
|
..
|
||||||
} = *eng;
|
} = *eng;
|
||||||
let rtp_pool = rtp_pool.as_mut().unwrap();
|
let rtp_pool = rtp_pool.as_mut().unwrap();
|
||||||
let call_id = call_mgr
|
let inbound = call_mgr
|
||||||
.create_inbound_call(
|
.create_inbound_call(
|
||||||
&msg,
|
&msg,
|
||||||
from_addr,
|
from_addr,
|
||||||
@@ -343,7 +342,7 @@ async fn handle_sip_packet(
|
|||||||
)
|
)
|
||||||
.await;
|
.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).
|
// Emit event so TypeScript knows about the call (for dashboard, IVR routing, etc).
|
||||||
let from_header = msg.get_header("From").unwrap_or("");
|
let from_header = msg.get_header("From").unwrap_or("");
|
||||||
let from_uri = SipMessage::extract_uri(from_header).unwrap_or("Unknown");
|
let from_uri = SipMessage::extract_uri(from_header).unwrap_or("Unknown");
|
||||||
@@ -356,10 +355,11 @@ async fn handle_sip_packet(
|
|||||||
&eng.out_tx,
|
&eng.out_tx,
|
||||||
"incoming_call",
|
"incoming_call",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"call_id": call_id,
|
"call_id": inbound.call_id,
|
||||||
"from_uri": from_uri,
|
"from_uri": from_uri,
|
||||||
"to_number": called_number,
|
"to_number": called_number,
|
||||||
"provider_id": provider_id,
|
"provider_id": provider_id,
|
||||||
|
"ring_browsers": inbound.ring_browsers,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -382,7 +382,7 @@ async fn handle_sip_packet(
|
|||||||
let route_result = config_ref.resolve_outbound_route(
|
let route_result = config_ref.resolve_outbound_route(
|
||||||
&dialed_number,
|
&dialed_number,
|
||||||
device_id.as_deref(),
|
device_id.as_deref(),
|
||||||
&|pid: &str| {
|
&|_pid: &str| {
|
||||||
// Can't call async here — use a sync check.
|
// Can't call async here — use a sync check.
|
||||||
// For now, assume all configured providers are available.
|
// For now, assume all configured providers are available.
|
||||||
true
|
true
|
||||||
@@ -453,13 +453,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.
|
/// 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) {
|
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()) {
|
let number = match cmd.params.get("number").and_then(|v| v.as_str()) {
|
||||||
@@ -664,6 +657,7 @@ async fn handle_webrtc_link(
|
|||||||
webrtc_session_id: Some(session_id.clone()),
|
webrtc_session_id: Some(session_id.clone()),
|
||||||
rtp_socket: None,
|
rtp_socket: None,
|
||||||
rtp_port: 0,
|
rtp_port: 0,
|
||||||
|
public_ip: None,
|
||||||
remote_media: None,
|
remote_media: None,
|
||||||
signaling_addr: None,
|
signaling_addr: None,
|
||||||
metadata: std::collections::HashMap::new(),
|
metadata: std::collections::HashMap::new(),
|
||||||
@@ -677,6 +671,10 @@ async fn handle_webrtc_link(
|
|||||||
"leg_id": session_id,
|
"leg_id": session_id,
|
||||||
"kind": "webrtc",
|
"kind": "webrtc",
|
||||||
"state": "connected",
|
"state": "connected",
|
||||||
|
"codec": "Opus",
|
||||||
|
"rtpPort": 0,
|
||||||
|
"remoteMedia": null,
|
||||||
|
"metadata": {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
respond_ok(out_tx, &cmd.id, serde_json::json!({
|
respond_ok(out_tx, &cmd.id, serde_json::json!({
|
||||||
@@ -1111,6 +1109,7 @@ async fn handle_add_tool_leg(
|
|||||||
webrtc_session_id: None,
|
webrtc_session_id: None,
|
||||||
rtp_socket: None,
|
rtp_socket: None,
|
||||||
rtp_port: 0,
|
rtp_port: 0,
|
||||||
|
public_ip: None,
|
||||||
remote_media: None,
|
remote_media: None,
|
||||||
signaling_addr: None,
|
signaling_addr: None,
|
||||||
metadata,
|
metadata,
|
||||||
@@ -1125,8 +1124,11 @@ async fn handle_add_tool_leg(
|
|||||||
"call_id": call_id,
|
"call_id": call_id,
|
||||||
"leg_id": tool_leg_id,
|
"leg_id": tool_leg_id,
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"tool_type": tool_type_str,
|
|
||||||
"state": "connected",
|
"state": "connected",
|
||||||
|
"codec": null,
|
||||||
|
"rtpPort": 0,
|
||||||
|
"remoteMedia": null,
|
||||||
|
"metadata": { "tool_type": tool_type_str },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,26 +3,32 @@
|
|||||||
//! Each Call spawns one mixer task. Legs communicate with the mixer via
|
//! Each Call spawns one mixer task. Legs communicate with the mixer via
|
||||||
//! tokio mpsc channels — no shared mutable state, no lock contention.
|
//! tokio mpsc channels — no shared mutable state, no lock contention.
|
||||||
//!
|
//!
|
||||||
|
//! Internal bus format: 48kHz f32 PCM (960 samples per 20ms frame).
|
||||||
|
//! All encoding/decoding happens at leg boundaries. Per-leg inbound denoising at 48kHz.
|
||||||
|
//!
|
||||||
//! The mixer runs a 20ms tick loop:
|
//! The mixer runs a 20ms tick loop:
|
||||||
//! 1. Drain inbound channels, decode to PCM, resample to 16kHz
|
//! 1. Drain inbound channels, decode to f32, resample to 48kHz, denoise per-leg
|
||||||
//! 2. Compute total mix (sum of all **participant** legs' PCM as i32)
|
//! 2. Compute total mix (sum of all **participant** legs' f32 PCM as f64)
|
||||||
//! 3. For each participant leg: mix-minus = total - own, resample to leg codec rate, encode, send
|
//! 3. For each participant leg: mix-minus = total - own, resample to leg codec rate, encode, send
|
||||||
//! 4. For each isolated leg: play prompt frame or silence, check DTMF
|
//! 4. For each isolated leg: play prompt frame or silence, check DTMF
|
||||||
//! 5. For each tool leg: send per-source unmerged audio batch
|
//! 5. For each tool leg: send per-source unmerged audio batch
|
||||||
//! 6. Forward DTMF between participant legs only
|
//! 6. Forward DTMF between participant legs only
|
||||||
|
|
||||||
use crate::ipc::{emit_event, OutTx};
|
use crate::ipc::{emit_event, OutTx};
|
||||||
|
use crate::jitter_buffer::{JitterBuffer, JitterResult};
|
||||||
use crate::rtp::{build_rtp_header, rtp_clock_increment};
|
use crate::rtp::{build_rtp_header, rtp_clock_increment};
|
||||||
use codec_lib::{codec_sample_rate, TranscodeState};
|
use codec_lib::{codec_sample_rate, new_denoiser, TranscodeState};
|
||||||
|
use nnnoiseless::DenoiseState;
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tokio::time::{self, Duration, MissedTickBehavior};
|
use tokio::time::{self, Duration, MissedTickBehavior};
|
||||||
|
|
||||||
/// Mixing sample rate — 16kHz. G.722 is native, G.711 needs 2× upsample, Opus needs 3× downsample.
|
/// Mixing sample rate — 48kHz. Opus is native, G.722 needs 3× upsample, G.711 needs 6× upsample.
|
||||||
const MIX_RATE: u32 = 16000;
|
/// All processing (denoising, mixing) happens at this rate in f32 for maximum quality.
|
||||||
|
const MIX_RATE: u32 = 48000;
|
||||||
/// Samples per 20ms frame at the mixing rate.
|
/// Samples per 20ms frame at the mixing rate.
|
||||||
const MIX_FRAME_SIZE: usize = 320; // 16000 * 0.020
|
const MIX_FRAME_SIZE: usize = 960; // 48000 * 0.020
|
||||||
|
|
||||||
/// A raw RTP payload received from a leg (no RTP header).
|
/// A raw RTP payload received from a leg (no RTP header).
|
||||||
pub struct RtpPacket {
|
pub struct RtpPacket {
|
||||||
@@ -30,7 +36,13 @@ pub struct RtpPacket {
|
|||||||
pub payload_type: u8,
|
pub payload_type: u8,
|
||||||
/// RTP marker bit (first packet of a DTMF event, etc.).
|
/// RTP marker bit (first packet of a DTMF event, etc.).
|
||||||
pub marker: bool,
|
pub marker: bool,
|
||||||
|
/// RTP sequence number for reordering.
|
||||||
|
pub seq: u16,
|
||||||
/// RTP timestamp from the original packet header.
|
/// 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,
|
pub timestamp: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,8 +59,8 @@ enum LegRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct IsolationState {
|
struct IsolationState {
|
||||||
/// PCM frames at MIX_RATE (320 samples each) queued for playback.
|
/// PCM frames at MIX_RATE (960 samples each, 48kHz f32) queued for playback.
|
||||||
prompt_frames: VecDeque<Vec<i16>>,
|
prompt_frames: VecDeque<Vec<f32>>,
|
||||||
/// Digits that complete the interaction (e.g., ['1', '2']).
|
/// Digits that complete the interaction (e.g., ['1', '2']).
|
||||||
expected_digits: Vec<char>,
|
expected_digits: Vec<char>,
|
||||||
/// Ticks remaining before timeout (decremented each tick after prompt ends).
|
/// Ticks remaining before timeout (decremented each tick after prompt ends).
|
||||||
@@ -88,8 +100,8 @@ pub struct ToolAudioBatch {
|
|||||||
/// One participant's 20ms audio frame.
|
/// One participant's 20ms audio frame.
|
||||||
pub struct ToolAudioSource {
|
pub struct ToolAudioSource {
|
||||||
pub leg_id: String,
|
pub leg_id: String,
|
||||||
/// PCM at 16kHz, MIX_FRAME_SIZE (320) samples.
|
/// PCM at 48kHz f32, MIX_FRAME_SIZE (960) samples.
|
||||||
pub pcm_16k: Vec<i16>,
|
pub pcm_48k: Vec<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal storage for a tool leg inside the mixer.
|
/// Internal storage for a tool leg inside the mixer.
|
||||||
@@ -122,14 +134,12 @@ pub enum MixerCommand {
|
|||||||
/// DTMF from the leg is checked against expected_digits.
|
/// DTMF from the leg is checked against expected_digits.
|
||||||
StartInteraction {
|
StartInteraction {
|
||||||
leg_id: String,
|
leg_id: String,
|
||||||
/// PCM frames at MIX_RATE (16kHz), each 320 samples.
|
/// PCM frames at MIX_RATE (48kHz f32), each 960 samples.
|
||||||
prompt_pcm_frames: Vec<Vec<i16>>,
|
prompt_pcm_frames: Vec<Vec<f32>>,
|
||||||
expected_digits: Vec<char>,
|
expected_digits: Vec<char>,
|
||||||
timeout_ms: u32,
|
timeout_ms: u32,
|
||||||
result_tx: oneshot::Sender<InteractionResult>,
|
result_tx: oneshot::Sender<InteractionResult>,
|
||||||
},
|
},
|
||||||
/// Cancel an in-progress interaction (e.g., leg being removed).
|
|
||||||
CancelInteraction { leg_id: String },
|
|
||||||
|
|
||||||
/// Add a tool leg that receives per-source unmerged audio.
|
/// Add a tool leg that receives per-source unmerged audio.
|
||||||
AddToolLeg {
|
AddToolLeg {
|
||||||
@@ -149,12 +159,16 @@ pub enum MixerCommand {
|
|||||||
struct MixerLegSlot {
|
struct MixerLegSlot {
|
||||||
codec_pt: u8,
|
codec_pt: u8,
|
||||||
transcoder: TranscodeState,
|
transcoder: TranscodeState,
|
||||||
|
/// Per-leg inbound denoiser (48kHz, 480-sample frames).
|
||||||
|
denoiser: Box<DenoiseState<'static>>,
|
||||||
inbound_rx: mpsc::Receiver<RtpPacket>,
|
inbound_rx: mpsc::Receiver<RtpPacket>,
|
||||||
outbound_tx: mpsc::Sender<Vec<u8>>,
|
outbound_tx: mpsc::Sender<Vec<u8>>,
|
||||||
/// Last decoded PCM frame at MIX_RATE (320 samples). Used for mix-minus.
|
/// Last decoded+denoised PCM frame at MIX_RATE (960 samples, 48kHz f32).
|
||||||
last_pcm_frame: Vec<i16>,
|
last_pcm_frame: Vec<f32>,
|
||||||
/// Number of consecutive ticks with no inbound packet.
|
/// Number of consecutive ticks with no inbound packet.
|
||||||
silent_ticks: u32,
|
silent_ticks: u32,
|
||||||
|
/// Per-leg jitter buffer for packet reordering and timing.
|
||||||
|
jitter: JitterBuffer,
|
||||||
// RTP output state.
|
// RTP output state.
|
||||||
rtp_seq: u16,
|
rtp_seq: u16,
|
||||||
rtp_ts: u32,
|
rtp_ts: u32,
|
||||||
@@ -220,14 +234,16 @@ async fn mixer_loop(
|
|||||||
MixerLegSlot {
|
MixerLegSlot {
|
||||||
codec_pt,
|
codec_pt,
|
||||||
transcoder,
|
transcoder,
|
||||||
|
denoiser: new_denoiser(),
|
||||||
inbound_rx,
|
inbound_rx,
|
||||||
outbound_tx,
|
outbound_tx,
|
||||||
last_pcm_frame: vec![0i16; MIX_FRAME_SIZE],
|
last_pcm_frame: vec![0.0f32; MIX_FRAME_SIZE],
|
||||||
silent_ticks: 0,
|
silent_ticks: 0,
|
||||||
rtp_seq: 0,
|
rtp_seq: 0,
|
||||||
rtp_ts: 0,
|
rtp_ts: 0,
|
||||||
rtp_ssrc: rand::random(),
|
rtp_ssrc: rand::random(),
|
||||||
role: LegRole::Participant,
|
role: LegRole::Participant,
|
||||||
|
jitter: JitterBuffer::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -281,16 +297,6 @@ async fn mixer_loop(
|
|||||||
let _ = result_tx.send(InteractionResult::Cancelled);
|
let _ = result_tx.send(InteractionResult::Cancelled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(MixerCommand::CancelInteraction { leg_id }) => {
|
|
||||||
if let Some(slot) = legs.get_mut(&leg_id) {
|
|
||||||
if let LegRole::Isolated(ref mut state) = slot.role {
|
|
||||||
if let Some(tx) = state.result_tx.take() {
|
|
||||||
let _ = tx.send(InteractionResult::Cancelled);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slot.role = LegRole::Participant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(MixerCommand::AddToolLeg {
|
Ok(MixerCommand::AddToolLeg {
|
||||||
leg_id,
|
leg_id,
|
||||||
tool_type,
|
tool_type,
|
||||||
@@ -311,70 +317,103 @@ async fn mixer_loop(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Drain inbound packets, decode to 16kHz PCM. ─────────
|
// ── 2. Drain inbound packets, decode to 48kHz f32 PCM. ────
|
||||||
// DTMF (PT 101) packets are collected separately.
|
// DTMF (PT 101) packets are collected separately.
|
||||||
|
// Audio packets are sorted by sequence number and decoded
|
||||||
|
// in order to maintain codec state (critical for G.722 ADPCM).
|
||||||
let leg_ids: Vec<String> = legs.keys().cloned().collect();
|
let leg_ids: Vec<String> = legs.keys().cloned().collect();
|
||||||
let mut dtmf_forward: Vec<(String, RtpPacket)> = Vec::new();
|
let mut dtmf_forward: Vec<(String, RtpPacket)> = Vec::new();
|
||||||
|
|
||||||
for lid in &leg_ids {
|
for lid in &leg_ids {
|
||||||
let slot = legs.get_mut(lid).unwrap();
|
let slot = legs.get_mut(lid).unwrap();
|
||||||
|
|
||||||
// Drain channel — collect DTMF packets separately, keep latest audio.
|
// Step 2a: Drain all pending packets into the jitter buffer.
|
||||||
let mut latest_audio: Option<RtpPacket> = None;
|
let mut got_audio = false;
|
||||||
loop {
|
loop {
|
||||||
match slot.inbound_rx.try_recv() {
|
match slot.inbound_rx.try_recv() {
|
||||||
Ok(pkt) => {
|
Ok(pkt) => {
|
||||||
if pkt.payload_type == 101 {
|
if pkt.payload_type == 101 {
|
||||||
// DTMF telephone-event: collect for processing.
|
|
||||||
dtmf_forward.push((lid.clone(), pkt));
|
dtmf_forward.push((lid.clone(), pkt));
|
||||||
} else {
|
} else {
|
||||||
latest_audio = Some(pkt);
|
got_audio = true;
|
||||||
|
slot.jitter.push(pkt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(pkt) = latest_audio {
|
// Step 2b: Consume exactly one frame from the jitter buffer.
|
||||||
slot.silent_ticks = 0;
|
match slot.jitter.consume() {
|
||||||
match slot.transcoder.decode_to_pcm(&pkt.payload, pkt.payload_type) {
|
JitterResult::Packet(pkt) => {
|
||||||
Ok((pcm, rate)) => {
|
match slot.transcoder.decode_to_f32(&pkt.payload, pkt.payload_type) {
|
||||||
// Resample to mixing rate if needed.
|
Ok((pcm, rate)) => {
|
||||||
let pcm_mix = if rate == MIX_RATE {
|
let pcm_48k = if rate == MIX_RATE {
|
||||||
pcm
|
pcm
|
||||||
} else {
|
} else {
|
||||||
slot.transcoder
|
slot.transcoder
|
||||||
.resample(&pcm, rate, MIX_RATE)
|
.resample_f32(&pcm, rate, MIX_RATE)
|
||||||
.unwrap_or_else(|_| vec![0i16; MIX_FRAME_SIZE])
|
.unwrap_or_else(|_| vec![0.0f32; MIX_FRAME_SIZE])
|
||||||
};
|
};
|
||||||
// Pad or truncate to exactly MIX_FRAME_SIZE.
|
let processed = if slot.codec_pt != codec_lib::PT_OPUS {
|
||||||
let mut frame = pcm_mix;
|
TranscodeState::denoise_f32(&mut slot.denoiser, &pcm_48k)
|
||||||
frame.resize(MIX_FRAME_SIZE, 0);
|
} else {
|
||||||
slot.last_pcm_frame = frame;
|
pcm_48k
|
||||||
}
|
};
|
||||||
Err(_) => {
|
let mut frame = processed;
|
||||||
// Decode failed — use silence.
|
frame.resize(MIX_FRAME_SIZE, 0.0);
|
||||||
slot.last_pcm_frame = vec![0i16; MIX_FRAME_SIZE];
|
slot.last_pcm_frame = frame;
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if dtmf_forward.iter().any(|(src, _)| src == lid) {
|
JitterResult::Missing => {
|
||||||
// Got DTMF but no audio — don't bump silent_ticks (DTMF counts as activity).
|
// Invoke Opus PLC or fade for non-Opus codecs.
|
||||||
|
if slot.codec_pt == codec_lib::PT_OPUS {
|
||||||
|
match slot.transcoder.opus_plc(MIX_FRAME_SIZE) {
|
||||||
|
Ok(pcm) => {
|
||||||
|
slot.last_pcm_frame = pcm;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
for s in slot.last_pcm_frame.iter_mut() {
|
||||||
|
*s *= 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-Opus: fade last frame toward silence.
|
||||||
|
for s in slot.last_pcm_frame.iter_mut() {
|
||||||
|
*s *= 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JitterResult::Filling => {
|
||||||
|
slot.last_pcm_frame = vec![0.0f32; MIX_FRAME_SIZE];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run jitter adaptation + prune stale packets.
|
||||||
|
slot.jitter.adapt();
|
||||||
|
slot.jitter.prune_stale();
|
||||||
|
|
||||||
|
// Silent ticks: based on actual network reception, not jitter buffer state.
|
||||||
|
if got_audio || dtmf_forward.iter().any(|(src, _)| src == lid) {
|
||||||
slot.silent_ticks = 0;
|
slot.silent_ticks = 0;
|
||||||
} else {
|
} else {
|
||||||
slot.silent_ticks += 1;
|
slot.silent_ticks += 1;
|
||||||
// After 150 ticks (3 seconds) of silence, zero out to avoid stale audio.
|
}
|
||||||
if slot.silent_ticks > 150 {
|
if slot.silent_ticks > 150 {
|
||||||
slot.last_pcm_frame = vec![0i16; MIX_FRAME_SIZE];
|
slot.last_pcm_frame = vec![0.0f32; MIX_FRAME_SIZE];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Compute total mix from PARTICIPANT legs only. ────────
|
// ── 3. Compute total mix from PARTICIPANT legs only. ────────
|
||||||
let mut total_mix = vec![0i32; MIX_FRAME_SIZE];
|
// Accumulate as f64 to prevent precision loss when summing f32.
|
||||||
|
let mut total_mix = vec![0.0f64; MIX_FRAME_SIZE];
|
||||||
for slot in legs.values() {
|
for slot in legs.values() {
|
||||||
if matches!(slot.role, LegRole::Participant) {
|
if matches!(slot.role, LegRole::Participant) {
|
||||||
for (i, &s) in slot.last_pcm_frame.iter().enumerate().take(MIX_FRAME_SIZE) {
|
for (i, &s) in slot.last_pcm_frame.iter().enumerate().take(MIX_FRAME_SIZE) {
|
||||||
total_mix[i] += s as i32;
|
total_mix[i] += s as f64;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,27 +426,27 @@ async fn mixer_loop(
|
|||||||
for (lid, slot) in legs.iter_mut() {
|
for (lid, slot) in legs.iter_mut() {
|
||||||
match &mut slot.role {
|
match &mut slot.role {
|
||||||
LegRole::Participant => {
|
LegRole::Participant => {
|
||||||
// Mix-minus: total minus this leg's own contribution.
|
// Mix-minus: total minus this leg's own contribution, clamped to [-1.0, 1.0].
|
||||||
let mut mix_minus = Vec::with_capacity(MIX_FRAME_SIZE);
|
let mut mix_minus = Vec::with_capacity(MIX_FRAME_SIZE);
|
||||||
for i in 0..MIX_FRAME_SIZE {
|
for i in 0..MIX_FRAME_SIZE {
|
||||||
let sample = (total_mix[i] - slot.last_pcm_frame[i] as i32)
|
let sample =
|
||||||
.clamp(-32768, 32767) as i16;
|
(total_mix[i] - slot.last_pcm_frame[i] as f64) as f32;
|
||||||
mix_minus.push(sample);
|
mix_minus.push(sample.clamp(-1.0, 1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resample from 16kHz to the leg's codec native rate.
|
// Resample from 48kHz to the leg's codec native rate.
|
||||||
let target_rate = codec_sample_rate(slot.codec_pt);
|
let target_rate = codec_sample_rate(slot.codec_pt);
|
||||||
let resampled = if target_rate == MIX_RATE {
|
let resampled = if target_rate == MIX_RATE {
|
||||||
mix_minus
|
mix_minus
|
||||||
} else {
|
} else {
|
||||||
slot.transcoder
|
slot.transcoder
|
||||||
.resample(&mix_minus, MIX_RATE, target_rate)
|
.resample_f32(&mix_minus, MIX_RATE, target_rate)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Encode to the leg's codec.
|
// Encode to the leg's codec (f32 → i16 → codec inside encode_from_f32).
|
||||||
let encoded =
|
let encoded =
|
||||||
match slot.transcoder.encode_from_pcm(&resampled, slot.codec_pt) {
|
match slot.transcoder.encode_from_f32(&resampled, slot.codec_pt) {
|
||||||
Ok(e) if !e.is_empty() => e,
|
Ok(e) if !e.is_empty() => e,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
@@ -456,21 +495,21 @@ async fn mixer_loop(
|
|||||||
frame
|
frame
|
||||||
} else {
|
} else {
|
||||||
state.prompt_done = true;
|
state.prompt_done = true;
|
||||||
vec![0i16; MIX_FRAME_SIZE]
|
vec![0.0f32; MIX_FRAME_SIZE]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Encode prompt frame to the leg's codec (reuses existing encode path).
|
// Encode prompt frame to the leg's codec.
|
||||||
let target_rate = codec_sample_rate(slot.codec_pt);
|
let target_rate = codec_sample_rate(slot.codec_pt);
|
||||||
let resampled = if target_rate == MIX_RATE {
|
let resampled = if target_rate == MIX_RATE {
|
||||||
pcm_frame
|
pcm_frame
|
||||||
} else {
|
} else {
|
||||||
slot.transcoder
|
slot.transcoder
|
||||||
.resample(&pcm_frame, MIX_RATE, target_rate)
|
.resample_f32(&pcm_frame, MIX_RATE, target_rate)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Ok(encoded) =
|
if let Ok(encoded) =
|
||||||
slot.transcoder.encode_from_pcm(&resampled, slot.codec_pt)
|
slot.transcoder.encode_from_f32(&resampled, slot.codec_pt)
|
||||||
{
|
{
|
||||||
if !encoded.is_empty() {
|
if !encoded.is_empty() {
|
||||||
let header = build_rtp_header(
|
let header = build_rtp_header(
|
||||||
@@ -523,7 +562,7 @@ async fn mixer_loop(
|
|||||||
.filter(|(_, s)| matches!(s.role, LegRole::Participant))
|
.filter(|(_, s)| matches!(s.role, LegRole::Participant))
|
||||||
.map(|(lid, s)| ToolAudioSource {
|
.map(|(lid, s)| ToolAudioSource {
|
||||||
leg_id: lid.clone(),
|
leg_id: lid.clone(),
|
||||||
pcm_16k: s.last_pcm_frame.clone(),
|
pcm_48k: s.last_pcm_frame.clone(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -533,7 +572,7 @@ async fn mixer_loop(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|s| ToolAudioSource {
|
.map(|s| ToolAudioSource {
|
||||||
leg_id: s.leg_id.clone(),
|
leg_id: s.leg_id.clone(),
|
||||||
pcm_16k: s.pcm_16k.clone(),
|
pcm_48k: s.pcm_48k.clone(),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -331,17 +331,6 @@ impl ProviderManager {
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a provider is currently registered.
|
|
||||||
pub async fn is_registered(&self, provider_id: &str) -> bool {
|
|
||||||
for ps_arc in &self.providers {
|
|
||||||
let ps = ps_arc.lock().await;
|
|
||||||
if ps.config.id == provider_id {
|
|
||||||
return ps.is_registered;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registration loop for a single provider.
|
/// Registration loop for a single provider.
|
||||||
|
|||||||
@@ -178,5 +178,8 @@ impl Recorder {
|
|||||||
pub struct RecordingResult {
|
pub struct RecordingResult {
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
pub duration_ms: u64,
|
pub duration_ms: u64,
|
||||||
|
// Running-sample total kept for parity with the TS recorder; not yet
|
||||||
|
// surfaced through any event or dashboard field.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub total_samples: u64,
|
pub total_samples: u64,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,19 @@ const MAX_EXPIRES: u32 = 300;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RegisteredDevice {
|
pub struct RegisteredDevice {
|
||||||
pub device_id: String,
|
pub device_id: String,
|
||||||
|
// These fields are populated at REGISTER time for logging/debugging but are
|
||||||
|
// not read back — device identity flows via the `device_registered` push
|
||||||
|
// event, not via struct queries. Kept behind allow(dead_code) because
|
||||||
|
// removing them would churn handle_register for no runtime benefit.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
pub extension: String,
|
pub extension: String,
|
||||||
pub contact_addr: SocketAddr,
|
pub contact_addr: SocketAddr,
|
||||||
|
#[allow(dead_code)]
|
||||||
pub registered_at: Instant,
|
pub registered_at: Instant,
|
||||||
pub expires_at: Instant,
|
pub expires_at: Instant,
|
||||||
|
#[allow(dead_code)]
|
||||||
pub aor: String,
|
pub aor: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,11 +142,6 @@ impl Registrar {
|
|||||||
Some(entry.contact_addr)
|
Some(entry.contact_addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a source address belongs to a known device.
|
|
||||||
pub fn is_known_device_address(&self, addr: &str) -> bool {
|
|
||||||
self.devices.iter().any(|d| d.expected_address == addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find a registered device by its source IP address.
|
/// Find a registered device by its source IP address.
|
||||||
pub fn find_by_address(&self, addr: &SocketAddr) -> Option<&RegisteredDevice> {
|
pub fn find_by_address(&self, addr: &SocketAddr) -> Option<&RegisteredDevice> {
|
||||||
let ip = addr.ip().to_string();
|
let ip = addr.ip().to_string();
|
||||||
@@ -146,26 +149,4 @@ impl Registrar {
|
|||||||
e.contact_addr.ip().to_string() == ip && Instant::now() <= e.expires_at
|
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.
|
//! Manages a pool of even-numbered UDP ports for RTP media. `allocate()`
|
||||||
//! Each port gets a bound tokio UdpSocket. Supports:
|
//! hands back an `Arc<UdpSocket>` to the caller (stored on the owning
|
||||||
//! - Direct forwarding (SIP-to-SIP, no transcoding)
|
//! `LegInfo`), while the pool itself keeps only a `Weak<UdpSocket>`. When
|
||||||
//! - Transcoding forwarding (via codec-lib, e.g. G.722 ↔ Opus)
|
//! the call terminates and `LegInfo` is dropped, the strong refcount
|
||||||
//! - Silence generation
|
//! reaches zero, the socket is closed, and `allocate()` prunes the dead
|
||||||
//! - NAT priming
|
//! weak ref the next time it scans that slot — so the port automatically
|
||||||
|
//! becomes available for reuse without any explicit `release()` plumbing.
|
||||||
//!
|
//!
|
||||||
//! Ported from ts/call/rtp-port-pool.ts + sip-leg.ts RTP handling.
|
//! This fixes the previous leak where the pool held `Arc<UdpSocket>` and
|
||||||
|
//! `release()` was never called, eventually exhausting the port range and
|
||||||
|
//! causing "503 Service Unavailable" on new calls.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
use std::sync::{Arc, Weak};
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
/// A single RTP port allocation.
|
/// A single RTP port allocation.
|
||||||
@@ -24,7 +26,7 @@ pub struct RtpAllocation {
|
|||||||
pub struct RtpPortPool {
|
pub struct RtpPortPool {
|
||||||
min: u16,
|
min: u16,
|
||||||
max: u16,
|
max: u16,
|
||||||
allocated: HashMap<u16, Arc<UdpSocket>>,
|
allocated: HashMap<u16, Weak<UdpSocket>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RtpPortPool {
|
impl RtpPortPool {
|
||||||
@@ -41,11 +43,19 @@ impl RtpPortPool {
|
|||||||
pub async fn allocate(&mut self) -> Option<RtpAllocation> {
|
pub async fn allocate(&mut self) -> Option<RtpAllocation> {
|
||||||
let mut port = self.min;
|
let mut port = self.min;
|
||||||
while port < self.max {
|
while port < self.max {
|
||||||
|
// Prune a dead weak ref at this slot: if the last strong Arc
|
||||||
|
// (held by the owning LegInfo) was dropped when the call ended,
|
||||||
|
// the socket is already closed and the slot is free again.
|
||||||
|
if let Some(weak) = self.allocated.get(&port) {
|
||||||
|
if weak.strong_count() == 0 {
|
||||||
|
self.allocated.remove(&port);
|
||||||
|
}
|
||||||
|
}
|
||||||
if !self.allocated.contains_key(&port) {
|
if !self.allocated.contains_key(&port) {
|
||||||
match UdpSocket::bind(format!("0.0.0.0:{port}")).await {
|
match UdpSocket::bind(format!("0.0.0.0:{port}")).await {
|
||||||
Ok(sock) => {
|
Ok(sock) => {
|
||||||
let sock = Arc::new(sock);
|
let sock = Arc::new(sock);
|
||||||
self.allocated.insert(port, sock.clone());
|
self.allocated.insert(port, Arc::downgrade(&sock));
|
||||||
return Some(RtpAllocation { port, socket: sock });
|
return Some(RtpAllocation { port, socket: sock });
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -57,83 +67,6 @@ impl RtpPortPool {
|
|||||||
}
|
}
|
||||||
None // Pool exhausted.
|
None // Pool exhausted.
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release a port back to the pool.
|
|
||||||
pub fn release(&mut self, port: u16) {
|
|
||||||
self.allocated.remove(&port);
|
|
||||||
// Socket is dropped when the last Arc reference goes away.
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn size(&self) -> usize {
|
|
||||||
self.allocated.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn capacity(&self) -> usize {
|
|
||||||
((self.max - self.min) / 2) as usize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An active RTP relay between two endpoints.
|
|
||||||
/// Receives on `local_socket` and forwards to `remote_addr`.
|
|
||||||
pub struct RtpRelay {
|
|
||||||
pub local_port: u16,
|
|
||||||
pub local_socket: Arc<UdpSocket>,
|
|
||||||
pub remote_addr: Option<SocketAddr>,
|
|
||||||
/// If set, transcode packets using this codec session before forwarding.
|
|
||||||
pub transcode: Option<TranscodeConfig>,
|
|
||||||
/// Packets received counter.
|
|
||||||
pub pkt_received: u64,
|
|
||||||
/// Packets sent counter.
|
|
||||||
pub pkt_sent: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TranscodeConfig {
|
|
||||||
pub from_pt: u8,
|
|
||||||
pub to_pt: u8,
|
|
||||||
pub session_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RtpRelay {
|
|
||||||
pub fn new(port: u16, socket: Arc<UdpSocket>) -> Self {
|
|
||||||
Self {
|
|
||||||
local_port: port,
|
|
||||||
local_socket: socket,
|
|
||||||
remote_addr: None,
|
|
||||||
transcode: None,
|
|
||||||
pkt_received: 0,
|
|
||||||
pkt_sent: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_remote(&mut self, addr: SocketAddr) {
|
|
||||||
self.remote_addr = Some(addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a 1-byte NAT priming packet to open a pinhole.
|
|
||||||
pub async fn prime_nat(socket: &UdpSocket, remote: SocketAddr) {
|
|
||||||
let _ = socket.send_to(&[0u8], remote).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build an RTP silence frame for PCMU (payload type 0).
|
|
||||||
pub fn silence_frame_pcmu() -> Vec<u8> {
|
|
||||||
// 12-byte RTP header + 160 bytes of µ-law silence (0xFF)
|
|
||||||
let mut frame = vec![0u8; 172];
|
|
||||||
frame[0] = 0x80; // V=2
|
|
||||||
frame[1] = 0; // PT=0 (PCMU)
|
|
||||||
// seq, timestamp, ssrc left as 0 — caller should set these
|
|
||||||
frame[12..].fill(0xFF); // µ-law silence
|
|
||||||
frame
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build an RTP silence frame for G.722 (payload type 9).
|
|
||||||
pub fn silence_frame_g722() -> Vec<u8> {
|
|
||||||
// 12-byte RTP header + 160 bytes of G.722 silence
|
|
||||||
let mut frame = vec![0u8; 172];
|
|
||||||
frame[0] = 0x80; // V=2
|
|
||||||
frame[1] = 9; // PT=9 (G.722)
|
|
||||||
// G.722 silence: all zeros is valid silence
|
|
||||||
frame
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build an RTP header with the given parameters.
|
/// Build an RTP header with the given parameters.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use sip_proto::helpers::{
|
|||||||
};
|
};
|
||||||
use sip_proto::message::{RequestOptions, SipMessage};
|
use sip_proto::message::{RequestOptions, SipMessage};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
/// State of a SIP leg.
|
/// State of a SIP leg.
|
||||||
@@ -40,6 +39,9 @@ pub struct SipLegConfig {
|
|||||||
/// SIP target endpoint (provider outbound proxy or device address).
|
/// SIP target endpoint (provider outbound proxy or device address).
|
||||||
pub sip_target: SocketAddr,
|
pub sip_target: SocketAddr,
|
||||||
/// Provider credentials (for 407 auth).
|
/// Provider credentials (for 407 auth).
|
||||||
|
// username is carried for parity with the provider config but digest auth
|
||||||
|
// rebuilds the username from the registered AOR, so this slot is never read.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
pub registered_aor: Option<String>,
|
pub registered_aor: Option<String>,
|
||||||
@@ -51,6 +53,10 @@ pub struct SipLegConfig {
|
|||||||
|
|
||||||
/// A SIP leg with full dialog management.
|
/// A SIP leg with full dialog management.
|
||||||
pub struct SipLeg {
|
pub struct SipLeg {
|
||||||
|
// Leg identity is tracked via the enclosing LegInfo's key in the call's
|
||||||
|
// leg map; SipLeg itself never reads this field back. Kept to preserve
|
||||||
|
// the (id, config) constructor shape used by the call manager.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub state: LegState,
|
pub state: LegState,
|
||||||
pub config: SipLegConfig,
|
pub config: SipLegConfig,
|
||||||
@@ -411,11 +417,6 @@ impl SipLeg {
|
|||||||
dialog.terminate();
|
dialog.terminate();
|
||||||
Some(msg.serialize())
|
Some(msg.serialize())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the SIP Call-ID for routing.
|
|
||||||
pub fn sip_call_id(&self) -> Option<&str> {
|
|
||||||
self.dialog.as_ref().map(|d| d.call_id.as_str())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Actions produced by the SipLeg message handler.
|
/// Actions produced by the SipLeg message handler.
|
||||||
|
|||||||
@@ -27,22 +27,6 @@ impl SipTransport {
|
|||||||
self.socket.clone()
|
self.socket.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a raw SIP message to a destination.
|
|
||||||
pub async fn send_to(&self, data: &[u8], dest: SocketAddr) -> Result<usize, String> {
|
|
||||||
self.socket
|
|
||||||
.send_to(data, dest)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("send to {dest}: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a raw SIP message to an address:port pair.
|
|
||||||
pub async fn send_to_addr(&self, data: &[u8], addr: &str, port: u16) -> Result<usize, String> {
|
|
||||||
let dest: SocketAddr = format!("{addr}:{port}")
|
|
||||||
.parse()
|
|
||||||
.map_err(|e| format!("bad address {addr}:{port}: {e}"))?;
|
|
||||||
self.send_to(data, dest).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn the UDP receive loop. Calls the handler for every received packet.
|
/// Spawn the UDP receive loop. Calls the handler for every received packet.
|
||||||
pub fn spawn_receiver<F>(
|
pub fn spawn_receiver<F>(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Tool legs are observer legs that receive individual audio streams from each
|
//! Tool legs are observer legs that receive individual audio streams from each
|
||||||
//! participant in a call. The mixer pipes `ToolAudioBatch` every 20ms containing
|
//! participant in a call. The mixer pipes `ToolAudioBatch` every 20ms containing
|
||||||
//! each participant's decoded PCM@16kHz tagged with source leg ID.
|
//! each participant's decoded PCM@48kHz f32 tagged with source leg ID.
|
||||||
//!
|
//!
|
||||||
//! Consumers:
|
//! Consumers:
|
||||||
//! - **Recording**: writes per-source WAV files for speaker-separated recording.
|
//! - **Recording**: writes per-source WAV files for speaker-separated recording.
|
||||||
@@ -37,20 +37,25 @@ pub fn spawn_recording_tool(
|
|||||||
|
|
||||||
while let Some(batch) = rx.recv().await {
|
while let Some(batch) = rx.recv().await {
|
||||||
for source in &batch.sources {
|
for source in &batch.sources {
|
||||||
// Skip silence-only frames (all zeros = no audio activity).
|
// Skip silence-only frames (near-zero = no audio activity).
|
||||||
let has_audio = source.pcm_16k.iter().any(|&s| s != 0);
|
let has_audio = source.pcm_48k.iter().any(|&s| s.abs() > 1e-6);
|
||||||
if !has_audio && !recorders.contains_key(&source.leg_id) {
|
if !has_audio && !recorders.contains_key(&source.leg_id) {
|
||||||
continue; // Don't create a file for silence-only sources.
|
continue; // Don't create a file for silence-only sources.
|
||||||
}
|
}
|
||||||
|
|
||||||
let recorder = recorders.entry(source.leg_id.clone()).or_insert_with(|| {
|
let recorder = recorders.entry(source.leg_id.clone()).or_insert_with(|| {
|
||||||
let path = format!("{}/{}-{}.wav", base_dir, call_id, source.leg_id);
|
let path = format!("{}/{}-{}.wav", base_dir, call_id, source.leg_id);
|
||||||
Recorder::new_pcm(&path, 16000, None).unwrap_or_else(|e| {
|
Recorder::new_pcm(&path, 48000, None).unwrap_or_else(|e| {
|
||||||
panic!("failed to create recorder for {}: {e}", source.leg_id);
|
panic!("failed to create recorder for {}: {e}", source.leg_id);
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if !recorder.write_pcm(&source.pcm_16k) {
|
// Convert f32 [-1.0, 1.0] to i16 for WAV writing.
|
||||||
|
let pcm_i16: Vec<i16> = source.pcm_48k
|
||||||
|
.iter()
|
||||||
|
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
|
||||||
|
.collect();
|
||||||
|
if !recorder.write_pcm(&pcm_i16) {
|
||||||
// Max duration reached — stop recording this source.
|
// Max duration reached — stop recording this source.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -88,7 +93,7 @@ pub fn spawn_recording_tool(
|
|||||||
|
|
||||||
/// Spawn a transcription tool leg.
|
/// Spawn a transcription tool leg.
|
||||||
///
|
///
|
||||||
/// The plumbing is fully real: it receives per-source unmerged PCM@16kHz from
|
/// The plumbing is fully real: it receives per-source unmerged PCM@48kHz f32 from
|
||||||
/// the mixer every 20ms. The consumer is a stub that accumulates audio and
|
/// the mixer every 20ms. The consumer is a stub that accumulates audio and
|
||||||
/// reports metadata on close. Future: will stream to a Whisper HTTP endpoint.
|
/// reports metadata on close. Future: will stream to a Whisper HTTP endpoint.
|
||||||
pub fn spawn_transcription_tool(
|
pub fn spawn_transcription_tool(
|
||||||
@@ -105,7 +110,7 @@ pub fn spawn_transcription_tool(
|
|||||||
while let Some(batch) = rx.recv().await {
|
while let Some(batch) = rx.recv().await {
|
||||||
for source in &batch.sources {
|
for source in &batch.sources {
|
||||||
*source_samples.entry(source.leg_id.clone()).or_insert(0) +=
|
*source_samples.entry(source.leg_id.clone()).or_insert(0) +=
|
||||||
source.pcm_16k.len() as u64;
|
source.pcm_48k.len() as u64;
|
||||||
|
|
||||||
// TODO: Future — accumulate chunks and stream to Whisper endpoint.
|
// TODO: Future — accumulate chunks and stream to Whisper endpoint.
|
||||||
// For now, the audio is received and counted but not processed.
|
// For now, the audio is received and counted but not processed.
|
||||||
@@ -118,7 +123,7 @@ pub fn spawn_transcription_tool(
|
|||||||
.map(|(leg_id, samples)| {
|
.map(|(leg_id, samples)| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"source_leg_id": leg_id,
|
"source_leg_id": leg_id,
|
||||||
"duration_ms": (samples * 1000) / 16000,
|
"duration_ms": (samples * 1000) / 48000,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -290,8 +290,9 @@ async fn browser_to_mixer_loop(
|
|||||||
.send(RtpPacket {
|
.send(RtpPacket {
|
||||||
payload: payload.to_vec(),
|
payload: payload.to_vec(),
|
||||||
payload_type: PT_OPUS,
|
payload_type: PT_OPUS,
|
||||||
marker: false,
|
marker: rtp_packet.header.marker,
|
||||||
timestamp: 0,
|
seq: rtp_packet.header.sequence_number,
|
||||||
|
timestamp: rtp_packet.header.timestamp,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,10 +197,11 @@ pub fn compute_digest_auth(
|
|||||||
|
|
||||||
use crate::Endpoint;
|
use crate::Endpoint;
|
||||||
|
|
||||||
/// Parse the audio media port and connection address from an SDP body.
|
/// Parse the audio media port, connection address, and preferred codec from an SDP body.
|
||||||
pub fn parse_sdp_endpoint(sdp: &str) -> Option<Endpoint> {
|
pub fn parse_sdp_endpoint(sdp: &str) -> Option<Endpoint> {
|
||||||
let mut addr: Option<&str> = None;
|
let mut addr: Option<&str> = None;
|
||||||
let mut port: Option<u16> = None;
|
let mut port: Option<u16> = None;
|
||||||
|
let mut codec_pt: Option<u8> = None;
|
||||||
|
|
||||||
let normalized = sdp.replace("\r\n", "\n");
|
let normalized = sdp.replace("\r\n", "\n");
|
||||||
for raw in normalized.split('\n') {
|
for raw in normalized.split('\n') {
|
||||||
@@ -208,10 +209,16 @@ pub fn parse_sdp_endpoint(sdp: &str) -> Option<Endpoint> {
|
|||||||
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
|
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
|
||||||
addr = Some(rest.trim());
|
addr = Some(rest.trim());
|
||||||
} else if let Some(rest) = line.strip_prefix("m=audio ") {
|
} else if let Some(rest) = line.strip_prefix("m=audio ") {
|
||||||
|
// m=audio <port> RTP/AVP <pt1> [<pt2> ...]
|
||||||
let parts: Vec<&str> = rest.split_whitespace().collect();
|
let parts: Vec<&str> = rest.split_whitespace().collect();
|
||||||
if !parts.is_empty() {
|
if !parts.is_empty() {
|
||||||
port = parts[0].parse().ok();
|
port = parts[0].parse().ok();
|
||||||
}
|
}
|
||||||
|
// parts[1] is "RTP/AVP" or similar, parts[2..] are payload types.
|
||||||
|
// The first PT is the preferred codec.
|
||||||
|
if parts.len() > 2 {
|
||||||
|
codec_pt = parts[2].parse::<u8>().ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +226,7 @@ pub fn parse_sdp_endpoint(sdp: &str) -> Option<Endpoint> {
|
|||||||
(Some(a), Some(p)) => Some(Endpoint {
|
(Some(a), Some(p)) => Some(Endpoint {
|
||||||
address: a.to_string(),
|
address: a.to_string(),
|
||||||
port: p,
|
port: p,
|
||||||
|
codec_pt,
|
||||||
}),
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ pub mod dialog;
|
|||||||
pub mod helpers;
|
pub mod helpers;
|
||||||
pub mod rewrite;
|
pub mod rewrite;
|
||||||
|
|
||||||
/// Network endpoint (address + port).
|
/// Network endpoint (address + port + optional negotiated codec).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Endpoint {
|
pub struct Endpoint {
|
||||||
pub address: String,
|
pub address: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
/// First payload type from the SDP `m=audio` line (the preferred codec).
|
||||||
|
pub codec_pt: Option<u8>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let original = match (orig_addr, orig_port) {
|
let original = match (orig_addr, orig_port) {
|
||||||
(Some(a), Some(p)) => Some(Endpoint { address: a, port: p }),
|
(Some(a), Some(p)) => Some(Endpoint { address: a, port: p, codec_pt: None }),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: 'siprouter',
|
name: 'siprouter',
|
||||||
version: '1.16.0',
|
version: '1.20.3',
|
||||||
description: 'undefined'
|
description: 'undefined'
|
||||||
}
|
}
|
||||||
|
|||||||
26
ts/config.ts
26
ts/config.ts
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import type { IVoiceboxConfig } from './voicebox.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared types (previously in ts/sip/types.ts, now inlined)
|
// Shared types (previously in ts/sip/types.ts, now inlined)
|
||||||
@@ -160,24 +161,13 @@ export interface IContact {
|
|||||||
// Voicebox configuration
|
// Voicebox configuration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface IVoiceboxConfig {
|
// Canonical definition lives in voicebox.ts (imported at the top of this
|
||||||
/** Unique ID — typically matches device ID or extension. */
|
// file) — re-exported here so consumers can import everything from a
|
||||||
id: string;
|
// single config module without pulling in the voicebox implementation.
|
||||||
/** Whether this voicebox is active. */
|
// This used to be a duplicated interface and caused
|
||||||
enabled: boolean;
|
// "number | undefined is not assignable to number" type errors when
|
||||||
/** Custom TTS greeting text. */
|
// passing config.voiceboxes into VoiceboxManager.init().
|
||||||
greetingText?: string;
|
export type { IVoiceboxConfig };
|
||||||
/** TTS voice ID (default 'af_bella'). */
|
|
||||||
greetingVoice?: string;
|
|
||||||
/** Path to uploaded WAV greeting (overrides TTS). */
|
|
||||||
greetingWavPath?: string;
|
|
||||||
/** Seconds to wait before routing to voicemail (default 25). */
|
|
||||||
noAnswerTimeoutSec?: number;
|
|
||||||
/** Maximum recording duration in seconds (default 120). */
|
|
||||||
maxRecordingSec?: number;
|
|
||||||
/** Maximum stored messages per box (default 50). */
|
|
||||||
maxMessages?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// IVR configuration
|
// IVR configuration
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ type TProxyCommands = {
|
|||||||
params: { call_id: string };
|
params: { call_id: string };
|
||||||
result: { file_path: string; duration_ms: number };
|
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: {
|
add_device_leg: {
|
||||||
params: { call_id: string; device_id: string };
|
params: { call_id: string; device_id: string };
|
||||||
result: { leg_id: string };
|
result: { leg_id: string };
|
||||||
@@ -83,6 +91,34 @@ type TProxyCommands = {
|
|||||||
params: { model: string; voices: string; voice: string; text: string; output: string };
|
params: { model: string; voices: string; voice: string; text: string; output: string };
|
||||||
result: { output: string };
|
result: { output: string };
|
||||||
};
|
};
|
||||||
|
// WebRTC signaling — bridged from the browser via the TS control plane.
|
||||||
|
webrtc_offer: {
|
||||||
|
params: { session_id: string; sdp: string };
|
||||||
|
result: { sdp: string };
|
||||||
|
};
|
||||||
|
webrtc_ice: {
|
||||||
|
params: {
|
||||||
|
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;
|
from_uri: string;
|
||||||
to_number: string;
|
to_number: string;
|
||||||
provider_id: 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 {
|
export interface IOutboundCallEvent {
|
||||||
@@ -134,8 +175,22 @@ let logFn: ((msg: string) => void) | undefined;
|
|||||||
|
|
||||||
function buildLocalPaths(): string[] {
|
function buildLocalPaths(): string[] {
|
||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
|
// Map Node's process.arch to tsrust's friendly target name.
|
||||||
|
// tsrust writes multi-target binaries as <bin>_<os>_<arch>,
|
||||||
|
// e.g. proxy-engine_linux_amd64 / proxy-engine_linux_arm64.
|
||||||
|
const archSuffix =
|
||||||
|
process.arch === 'arm64' ? 'linux_arm64' :
|
||||||
|
process.arch === 'x64' ? 'linux_amd64' :
|
||||||
|
null;
|
||||||
|
const multiTarget = archSuffix
|
||||||
|
? [path.join(root, 'dist_rust', `proxy-engine_${archSuffix}`)]
|
||||||
|
: [];
|
||||||
return [
|
return [
|
||||||
|
// 1. Multi-target output matching the running host arch (Docker image, CI, multi-target dev).
|
||||||
|
...multiTarget,
|
||||||
|
// 2. Single-target (unsuffixed) output — legacy/fallback when tsrust runs without targets.
|
||||||
path.join(root, 'dist_rust', 'proxy-engine'),
|
path.join(root, 'dist_rust', 'proxy-engine'),
|
||||||
|
// 3. Direct cargo builds for dev iteration.
|
||||||
path.join(root, 'rust', 'target', 'release', 'proxy-engine'),
|
path.join(root, 'rust', 'target', 'release', 'proxy-engine'),
|
||||||
path.join(root, 'rust', 'target', 'debug', 'proxy-engine'),
|
path.join(root, 'rust', 'target', 'debug', 'proxy-engine'),
|
||||||
];
|
];
|
||||||
@@ -503,7 +558,7 @@ export async function sendProxyCommand<K extends keyof TProxyCommands>(
|
|||||||
params: TProxyCommands[K]['params'],
|
params: TProxyCommands[K]['params'],
|
||||||
): Promise<TProxyCommands[K]['result']> {
|
): Promise<TProxyCommands[K]['result']> {
|
||||||
if (!bridge || !initialized) throw new Error('proxy engine not initialized');
|
if (!bridge || !initialized) throw new Error('proxy engine not initialized');
|
||||||
return bridge.sendCommand(method as string, params as any) as any;
|
return bridge.sendCommand(method, params) as Promise<TProxyCommands[K]['result']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shut down the proxy engine. */
|
/** Shut down the proxy engine. */
|
||||||
|
|||||||
@@ -273,15 +273,23 @@ async function startProxyEngine(): Promise<void> {
|
|||||||
legs: new Map(),
|
legs: new Map(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify browsers of incoming call.
|
// Notify browsers of the incoming call, but only if the matched inbound
|
||||||
const browserIds = getAllBrowserDeviceIds();
|
// route asked for it. `ring_browsers !== false` preserves today's
|
||||||
for (const bid of browserIds) {
|
// ring-by-default behavior for any Rust release that predates this
|
||||||
sendToBrowserDevice(bid, {
|
// field or for the fallback "no route matched" case (where Rust still
|
||||||
type: 'webrtc-incoming',
|
// sends `true`). Note: this is an informational toast — browsers do
|
||||||
callId: data.call_id,
|
// NOT race the SIP device to answer. First-to-answer-wins requires
|
||||||
from: data.from_uri,
|
// a multi-leg fork which is not yet implemented.
|
||||||
deviceId: bid,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -425,9 +433,9 @@ async function startProxyEngine(): Promise<void> {
|
|||||||
id: data.leg_id,
|
id: data.leg_id,
|
||||||
type: data.kind,
|
type: data.kind,
|
||||||
state: data.state,
|
state: data.state,
|
||||||
codec: null,
|
codec: data.codec ?? null,
|
||||||
rtpPort: null,
|
rtpPort: data.rtpPort ?? null,
|
||||||
remoteMedia: null,
|
remoteMedia: data.remoteMedia ?? null,
|
||||||
metadata: data.metadata || {},
|
metadata: data.metadata || {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -493,7 +501,7 @@ async function startProxyEngine(): Promise<void> {
|
|||||||
onProxyEvent('recording_done', (data: any) => {
|
onProxyEvent('recording_done', (data: any) => {
|
||||||
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) caller=${data.caller_number}`);
|
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) caller=${data.caller_number}`);
|
||||||
// Save voicemail metadata via VoiceboxManager.
|
// Save voicemail metadata via VoiceboxManager.
|
||||||
voiceboxManager.addMessage?.('default', {
|
voiceboxManager.addMessage('default', {
|
||||||
callerNumber: data.caller_number || 'Unknown',
|
callerNumber: data.caller_number || 'Unknown',
|
||||||
callerName: null,
|
callerName: null,
|
||||||
fileName: data.file_path,
|
fileName: data.file_path,
|
||||||
|
|||||||
@@ -29,12 +29,14 @@ export interface IVoiceboxConfig {
|
|||||||
greetingVoice?: string;
|
greetingVoice?: string;
|
||||||
/** Path to uploaded WAV greeting (overrides TTS). */
|
/** Path to uploaded WAV greeting (overrides TTS). */
|
||||||
greetingWavPath?: string;
|
greetingWavPath?: string;
|
||||||
/** Seconds to wait before routing to voicemail (default 25). */
|
/** Seconds to wait before routing to voicemail. Defaults to 25 when
|
||||||
noAnswerTimeoutSec: number;
|
* absent — both the config loader and `VoiceboxManager.init` apply
|
||||||
/** Maximum recording duration in seconds (default 120). */
|
* the default via `??=`. */
|
||||||
maxRecordingSec: number;
|
noAnswerTimeoutSec?: number;
|
||||||
/** Maximum stored messages per box (default 50). */
|
/** Maximum recording duration in seconds. Defaults to 120. */
|
||||||
maxMessages: number;
|
maxRecordingSec?: number;
|
||||||
|
/** Maximum stored messages per box. Defaults to 50. */
|
||||||
|
maxMessages?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVoicemailMessage {
|
export interface IVoicemailMessage {
|
||||||
@@ -148,6 +150,35 @@ export class VoiceboxManager {
|
|||||||
// Message CRUD
|
// Message CRUD
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience wrapper around `saveMessage` — used by the `recording_done`
|
||||||
|
* event handler, which has a raw recording path + caller info and needs
|
||||||
|
* to persist metadata. Generates `id`, sets `timestamp = now`, defaults
|
||||||
|
* `heard = false`, and normalizes `fileName` to a basename (the WAV is
|
||||||
|
* expected to already live in the box's directory).
|
||||||
|
*/
|
||||||
|
addMessage(
|
||||||
|
boxId: string,
|
||||||
|
info: {
|
||||||
|
callerNumber: string;
|
||||||
|
callerName?: string | null;
|
||||||
|
fileName: string;
|
||||||
|
durationMs: number;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
const msg: IVoicemailMessage = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
boxId,
|
||||||
|
callerNumber: info.callerNumber,
|
||||||
|
callerName: info.callerName ?? undefined,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
durationMs: info.durationMs,
|
||||||
|
fileName: path.basename(info.fileName),
|
||||||
|
heard: false,
|
||||||
|
};
|
||||||
|
this.saveMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save a new voicemail message.
|
* Save a new voicemail message.
|
||||||
* The WAV file should already exist at the expected path.
|
* The WAV file should already exist at the expected path.
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: 'siprouter',
|
name: 'siprouter',
|
||||||
version: '1.16.0',
|
version: '1.20.3',
|
||||||
description: 'undefined'
|
description: 'undefined'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const VIEW_TABS = [
|
|||||||
{ name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
|
{ name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
|
||||||
{ name: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes },
|
{ name: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes },
|
||||||
{ name: 'Voicemail', iconName: 'lucide:voicemail', element: SipproxyViewVoicemail },
|
{ name: 'Voicemail', iconName: 'lucide:voicemail', element: SipproxyViewVoicemail },
|
||||||
{ name: 'IVR', iconName: 'lucide:list-tree', element: SipproxyViewIvr },
|
{ name: 'IVR', iconName: 'lucide:ListTree', element: SipproxyViewIvr },
|
||||||
{ name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
|
{ name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
|
||||||
{ name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
|
{ name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
|
||||||
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },
|
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ export class SipproxyViewCalls extends DeesElement {
|
|||||||
menuOptions: [
|
menuOptions: [
|
||||||
{
|
{
|
||||||
name: 'Transfer',
|
name: 'Transfer',
|
||||||
iconName: 'lucide:arrow-right-left',
|
iconName: 'lucide:ArrowRightLeft',
|
||||||
action: async (modalRef: any) => {
|
action: async (modalRef: any) => {
|
||||||
if (!targetCallId || !targetLegId) {
|
if (!targetCallId || !targetLegId) {
|
||||||
deesCatalog.DeesToast.error('Please select both a target call and a leg');
|
deesCatalog.DeesToast.error('Please select both a target call and a leg');
|
||||||
@@ -620,7 +620,7 @@ export class SipproxyViewCalls extends DeesElement {
|
|||||||
title: 'Inbound',
|
title: 'Inbound',
|
||||||
value: inboundCount,
|
value: inboundCount,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:phone-incoming',
|
icon: 'lucide:PhoneIncoming',
|
||||||
description: 'Incoming calls',
|
description: 'Incoming calls',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -628,7 +628,7 @@ export class SipproxyViewCalls extends DeesElement {
|
|||||||
title: 'Outbound',
|
title: 'Outbound',
|
||||||
value: outboundCount,
|
value: outboundCount,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:phone-outgoing',
|
icon: 'lucide:PhoneOutgoing',
|
||||||
description: 'Outgoing calls',
|
description: 'Outgoing calls',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export class SipproxyViewIvr extends DeesElement {
|
|||||||
title: 'Total Menus',
|
title: 'Total Menus',
|
||||||
value: ivr.menus.length,
|
value: ivr.menus.length,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:list-tree',
|
icon: 'lucide:ListTree',
|
||||||
description: 'IVR menu definitions',
|
description: 'IVR menu definitions',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -148,7 +148,7 @@ export class SipproxyViewIvr extends DeesElement {
|
|||||||
title: 'Entry Menu',
|
title: 'Entry Menu',
|
||||||
value: entryMenu?.name || '(none)',
|
value: entryMenu?.name || '(none)',
|
||||||
type: 'text' as any,
|
type: 'text' as any,
|
||||||
icon: 'lucide:door-open',
|
icon: 'lucide:DoorOpen',
|
||||||
description: entryMenu ? `ID: ${entryMenu.id}` : 'No entry menu set',
|
description: entryMenu ? `ID: ${entryMenu.id}` : 'No entry menu set',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -156,7 +156,7 @@ export class SipproxyViewIvr extends DeesElement {
|
|||||||
title: 'Status',
|
title: 'Status',
|
||||||
value: ivr.enabled ? 'Enabled' : 'Disabled',
|
value: ivr.enabled ? 'Enabled' : 'Disabled',
|
||||||
type: 'text' as any,
|
type: 'text' as any,
|
||||||
icon: ivr.enabled ? 'lucide:check-circle' : 'lucide:x-circle',
|
icon: ivr.enabled ? 'lucide:CheckCircle' : 'lucide:XCircle',
|
||||||
color: ivr.enabled ? 'hsl(142.1 76.2% 36.3%)' : 'hsl(0 84.2% 60.2%)',
|
color: ivr.enabled ? 'hsl(142.1 76.2% 36.3%)' : 'hsl(0 84.2% 60.2%)',
|
||||||
description: ivr.enabled ? 'IVR is active' : 'IVR is inactive',
|
description: ivr.enabled ? 'IVR is active' : 'IVR is inactive',
|
||||||
},
|
},
|
||||||
@@ -228,7 +228,7 @@ export class SipproxyViewIvr extends DeesElement {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Set as Entry',
|
name: 'Set as Entry',
|
||||||
iconName: 'lucide:door-open' as any,
|
iconName: 'lucide:DoorOpen' as any,
|
||||||
type: ['inRow'] as any,
|
type: ['inRow'] as any,
|
||||||
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
||||||
await this.setEntryMenu(item.id);
|
await this.setEntryMenu(item.id);
|
||||||
@@ -236,7 +236,7 @@ export class SipproxyViewIvr extends DeesElement {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash-2' as any,
|
iconName: 'lucide:Trash2' as any,
|
||||||
type: ['inRow'] as any,
|
type: ['inRow'] as any,
|
||||||
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
||||||
await this.confirmDeleteMenu(item);
|
await this.confirmDeleteMenu(item);
|
||||||
@@ -295,7 +295,7 @@ export class SipproxyViewIvr extends DeesElement {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash-2',
|
iconName: 'lucide:Trash2',
|
||||||
action: async (modalRef: any) => {
|
action: async (modalRef: any) => {
|
||||||
const ivr = this.getIvrConfig();
|
const ivr = this.getIvrConfig();
|
||||||
const menus = ivr.menus.filter((m) => m.id !== menu.id);
|
const menus = ivr.menus.filter((m) => m.id !== menu.id);
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export class SipproxyViewOverview extends DeesElement {
|
|||||||
title: 'Inbound Calls',
|
title: 'Inbound Calls',
|
||||||
value: inboundCalls,
|
value: inboundCalls,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:phone-incoming',
|
icon: 'lucide:PhoneIncoming',
|
||||||
description: 'Currently active',
|
description: 'Currently active',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -115,7 +115,7 @@ export class SipproxyViewOverview extends DeesElement {
|
|||||||
title: 'Outbound Calls',
|
title: 'Outbound Calls',
|
||||||
value: outboundCalls,
|
value: outboundCalls,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:phone-outgoing',
|
icon: 'lucide:PhoneOutgoing',
|
||||||
description: 'Currently active',
|
description: 'Currently active',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export class SipproxyViewProviders extends DeesElement {
|
|||||||
title: 'Registered',
|
title: 'Registered',
|
||||||
value: registered,
|
value: registered,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:check-circle',
|
icon: 'lucide:CheckCircle',
|
||||||
color: 'hsl(142.1 76.2% 36.3%)',
|
color: 'hsl(142.1 76.2% 36.3%)',
|
||||||
description: 'Active registrations',
|
description: 'Active registrations',
|
||||||
},
|
},
|
||||||
@@ -95,7 +95,7 @@ export class SipproxyViewProviders extends DeesElement {
|
|||||||
title: 'Unregistered',
|
title: 'Unregistered',
|
||||||
value: unregistered,
|
value: unregistered,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:alert-circle',
|
icon: 'lucide:AlertCircle',
|
||||||
color: unregistered > 0 ? 'hsl(0 84.2% 60.2%)' : undefined,
|
color: unregistered > 0 ? 'hsl(0 84.2% 60.2%)' : undefined,
|
||||||
description: unregistered > 0 ? 'Needs attention' : 'All healthy',
|
description: unregistered > 0 ? 'Needs attention' : 'All healthy',
|
||||||
},
|
},
|
||||||
@@ -153,7 +153,7 @@ export class SipproxyViewProviders extends DeesElement {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash-2',
|
iconName: 'lucide:Trash2',
|
||||||
type: ['inRow'] as any,
|
type: ['inRow'] as any,
|
||||||
actionFunc: async (actionData: any) => {
|
actionFunc: async (actionData: any) => {
|
||||||
await this.confirmDelete(actionData.item);
|
await this.confirmDelete(actionData.item);
|
||||||
@@ -579,7 +579,7 @@ export class SipproxyViewProviders extends DeesElement {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash-2',
|
iconName: 'lucide:Trash2',
|
||||||
action: async (modalRef: any) => {
|
action: async (modalRef: any) => {
|
||||||
try {
|
try {
|
||||||
const result = await appState.apiSaveConfig({
|
const result = await appState.apiSaveConfig({
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ export class SipproxyViewVoicemail extends DeesElement {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash-2',
|
iconName: 'lucide:Trash2',
|
||||||
action: async (modalRef: any) => {
|
action: async (modalRef: any) => {
|
||||||
try {
|
try {
|
||||||
await fetch(
|
await fetch(
|
||||||
@@ -281,7 +281,7 @@ export class SipproxyViewVoicemail extends DeesElement {
|
|||||||
title: 'Unheard Messages',
|
title: 'Unheard Messages',
|
||||||
value: unheard,
|
value: unheard,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:bell-ring',
|
icon: 'lucide:BellRing',
|
||||||
color: unheard > 0 ? 'hsl(0 84.2% 60.2%)' : 'hsl(142.1 76.2% 36.3%)',
|
color: unheard > 0 ? 'hsl(0 84.2% 60.2%)' : 'hsl(142.1 76.2% 36.3%)',
|
||||||
description: unheard > 0 ? 'Needs attention' : 'All caught up',
|
description: unheard > 0 ? 'Needs attention' : 'All caught up',
|
||||||
},
|
},
|
||||||
@@ -372,7 +372,7 @@ export class SipproxyViewVoicemail extends DeesElement {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash-2',
|
iconName: 'lucide:Trash2',
|
||||||
type: ['inRow'] as any,
|
type: ['inRow'] as any,
|
||||||
actionFunc: async (actionData: any) => {
|
actionFunc: async (actionData: any) => {
|
||||||
await this.deleteMessage(actionData.item as IVoicemailMessage);
|
await this.deleteMessage(actionData.item as IVoicemailMessage);
|
||||||
|
|||||||
Reference in New Issue
Block a user