Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c18f2f7ca1 | |||
| 3e2fee16c1 | |||
| 04e706715f | |||
| 980a1500f5 | |||
| 33b4ae5dd0 | |||
| d2c18a4ebb | |||
| 3c010a3b1b | |||
| 88768f0586 |
@@ -19,5 +19,19 @@
|
||||
"dockerregistry.lossless.digital": "serve.zone/siprouter"
|
||||
},
|
||||
"platforms": ["linux/amd64", "linux/arm64"]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"release": {
|
||||
"targets": {
|
||||
"git": {
|
||||
"enabled": true,
|
||||
"remote": "origin"
|
||||
},
|
||||
"docker": {
|
||||
"enabled": true,
|
||||
"engine": "tsdocker"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## Pending
|
||||
|
||||
|
||||
## 2026-05-21 - 1.27.0
|
||||
|
||||
### Features
|
||||
|
||||
- persist siprouter config and media through SmartData and SmartBucket (storage)
|
||||
- store runtime config, voicemail metadata, fax jobs, and fax inbox metadata in SmartData
|
||||
- store voicemail audio, custom greetings, and fax payloads in SmartBucket while keeping local cache paths for Rust media access
|
||||
- migrate legacy local voicemail and fax metadata/media into SmartData and SmartBucket on startup
|
||||
- enable gitzone Docker release publishing through the configured tsdocker target
|
||||
|
||||
## 2026-04-20 - 1.26.0 - feat(fax)
|
||||
add fax routing, job tracking, inbox management, and T.38/UDPTL media support
|
||||
|
||||
- adds outbound fax origination through the proxy engine with provider codec validation and a new send_fax command
|
||||
- introduces fax box configuration, inbox storage, and dashboard/API endpoints for listing, downloading, and deleting received fax messages
|
||||
- tracks fax lifecycle events and persisted fax jobs in the runtime layer
|
||||
- extends SIP SDP parsing and rewriting to support non-audio media, including T.38 over UDPTL
|
||||
- records leg media protocol details and bridge state to distinguish RTP, WebRTC, internal, and fax media paths
|
||||
|
||||
## 2026-04-14 - 1.25.2 - fix(proxy-engine)
|
||||
improve inbound SIP routing diagnostics and enrich leg media state reporting
|
||||
|
||||
- Extract inbound called numbers from DID-related SIP headers when the request URI contains a provider account username.
|
||||
- Emit detailed sip_unhandled diagnostics for inbound route misses, missing devices, and RTP allocation failures.
|
||||
- Include codec, RTP port, remote media, and metadata in leg state change events and preserve those fields in runtime status/history views.
|
||||
- Match hostname-based providers against resolved inbound source IPs to accept provider traffic sent from resolved addresses.
|
||||
- Invalidate cached TTS WAV metadata across engine restarts and vendor the kokoro-tts crate via a local patch.
|
||||
|
||||
## 2026-04-14 - 1.25.1 - fix(proxy-engine)
|
||||
respect explicit inbound route targets and store voicemail in the configured mailbox
|
||||
|
||||
|
||||
+23
-10
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "siprouter",
|
||||
"version": "1.25.1",
|
||||
"version": "1.27.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"bundle": "node node_modules/.pnpm/esbuild@0.27.7/node_modules/esbuild/bin/esbuild ts_web/index.ts --bundle --format=esm --outfile=dist_ts_web/bundle.js --platform=browser --target=es2022 --minify",
|
||||
"bundle": "esbuild ts_web/index.ts --bundle --format=esm --outfile=dist_ts_web/bundle.js --platform=browser --target=es2022 --minify",
|
||||
"buildRust": "tsrust",
|
||||
"build": "pnpm run buildRust && pnpm run bundle",
|
||||
"build:docker": "tsdocker build --verbose",
|
||||
@@ -13,18 +13,31 @@
|
||||
"restartBackground": "pnpm run buildRust && pnpm run bundle; test -f .server.pid && kill $(cat .server.pid) 2>/dev/null; sleep 1; rm -f sip_trace.log proxy.out && nohup tsx ts/sipproxy.ts > proxy.out 2>&1 & echo $! > .server.pid; sleep 2; cat proxy.out"
|
||||
},
|
||||
"dependencies": {
|
||||
"@design.estate/dees-catalog": "^3.77.0",
|
||||
"@design.estate/dees-catalog": "^3.81.0",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/smartrust": "^1.3.2",
|
||||
"@push.rocks/smartstate": "^2.3.0",
|
||||
"@push.rocks/smartbucket": "^4.6.1",
|
||||
"@push.rocks/smartdata": "^7.1.7",
|
||||
"@push.rocks/smartrust": "^1.4.0",
|
||||
"@push.rocks/smartstate": "^2.3.1",
|
||||
"tsx": "^4.21.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsdocker": "^2.2.4",
|
||||
"@git.zone/tsrust": "^1.3.2",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@types/ws": "^8.18.1"
|
||||
"@git.zone/tsbundle": "^2.10.1",
|
||||
"@git.zone/tsdocker": "^2.2.5",
|
||||
"@git.zone/tsrust": "^1.3.3",
|
||||
"@git.zone/tswatch": "^3.3.3",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"esbuild": "^0.27.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"ignoredBuiltDependencies": [
|
||||
"@design.estate/dees-catalog"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"mongodb-memory-server"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1685
-688
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
mongodb-memory-server: true
|
||||
ignoredBuiltDependencies:
|
||||
- '@design.estate/dees-catalog'
|
||||
@@ -1,140 +1,104 @@
|
||||
# @serve.zone/siprouter
|
||||
# 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, adaptive jitter buffering, ML noise suppression, neural TTS, voicemail, IVR menus, and a slick web dashboard.
|
||||
siprouter is a TypeScript control plane plus Rust media/data plane for SIP routing, SIP device registration, SIP trunk calls, browser WebRTC softphones, voicemail/fax storage, and a live operations dashboard. It is intentionally split so TypeScript owns configuration, REST/WebSocket APIs, and UI glue while the Rust `proxy-engine` owns SIP, RTP, WebRTC media, codecs, mixing, jitter handling, fax transport, and real-time call state.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
---
|
||||
## Current Capabilities
|
||||
|
||||
## 🔥 What It Does
|
||||
- SIP B2BUA behavior for SIP providers and LAN SIP devices, including dialog state, provider registration, digest auth retry, SDP negotiation, BYE/CANCEL handling, and routing decisions in Rust.
|
||||
- Browser WebRTC softphone signaling through the TypeScript dashboard, with WebRTC media sessions implemented in the Rust engine.
|
||||
- A call hub model: each call owns multiple legs and a 20 ms tick mix-minus mixer at a 48 kHz f32 internal bus.
|
||||
- Codec handling for Opus, G.722, PCMU, and PCMA through `codec-lib`, including per-leg transcoding, resampling, packet loss concealment, and jitter buffering.
|
||||
- Device registration push events from Rust into TypeScript, then into the dashboard status stream.
|
||||
- Route configuration for inbound and outbound calls with provider/device matching, number patterns, failover provider fields, browser notification flags, voicemail, fax, and IVR-related action fields.
|
||||
- Voicemail metadata and WAV storage through `VoiceboxManager` under `.nogit/voicemail/{boxId}/`.
|
||||
- Fax box and fax job metadata storage under `.nogit/fax/`, backed by Rust fax handling that includes audio fax and T.38-related code paths.
|
||||
- Web dashboard and REST API on the configured `webUiPort`, served over HTTPS if `.nogit/cert.pem` and `.nogit/key.pem` exist, otherwise HTTP.
|
||||
|
||||
siprouter sits between your SIP trunk providers and your endpoints — hardware phones, ATAs, browser softphones — and handles **everything** in between:
|
||||
## Important Accuracy Notes
|
||||
|
||||
- 📞 **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 Opus audio to the SIP network
|
||||
- 🎛️ **Multi-Provider Trunking** — Register with multiple SIP providers simultaneously (sipgate, easybell, etc.) with automatic failover
|
||||
- 🎧 **48kHz f32 Audio Engine** — High-fidelity internal audio bus at 48kHz/32-bit float with native Opus float encode/decode, FFT-based resampling, and per-leg ML noise suppression
|
||||
- 🔀 **N-Leg Mix-Minus Mixer** — Conference-grade mixing with dynamic leg add/remove, transfer, and per-source audio separation
|
||||
- 🎯 **Adaptive Jitter Buffer** — Per-leg jitter buffering with sequence-based reordering, adaptive depth (60–120ms), Opus PLC for lost packets, and hold/resume detection
|
||||
- 📧 **Voicemail** — Configurable voicemail boxes with TTS greetings, recording, and web playback
|
||||
- 🔢 **IVR Menus** — DTMF-navigable interactive voice response with nested menus, routing actions, and custom prompts
|
||||
- 🗣️ **Neural TTS** — Kokoro-powered greetings and IVR prompts with 25+ voice presets
|
||||
- 🎙️ **Call Recording** — Per-source separated WAV recording at 48kHz via tool legs
|
||||
- 🖥️ **Web Dashboard** — Real-time SPA with 9 views: live calls, browser phone, routing, voicemail, IVR, contacts, providers, and streaming logs
|
||||
- TypeScript does not handle raw SIP or RTP. It sends high-level commands to the Rust engine over `@push.rocks/smartrust` and receives high-level events back.
|
||||
- Browser WebRTC calls use a strict two-stage link flow. The browser first creates a standalone WebRTC session with `webrtc-offer`; only after `webrtc-accept`/linking can Rust attach that session to the call mixer.
|
||||
- Inbound route resolution is wired in Rust, but multi-target inbound forking is not implemented yet. Only the first registered target device from an inbound route is rung.
|
||||
- `ringBrowsers` currently controls browser notifications. It is not first-answer-wins call racing against SIP devices.
|
||||
- `voicemailBox`, `ivrMenuId`, and `noAnswerTimeout` are part of resolved inbound route data, but the project notes mark downstream honoring of those fields as not complete yet.
|
||||
- The `/api/transfer` HTTP endpoint currently returns `501 not yet implemented`.
|
||||
|
||||
---
|
||||
## Architecture
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Browser["🌐 Browser Softphone<br/>(WebRTC via WebSocket signaling)"]
|
||||
Devices["📞 SIP Devices<br/>(HT801, desk phones, ATAs)"]
|
||||
Trunks["☎️ SIP Trunk Providers<br/>(sipgate, easybell, …)"]
|
||||
|
||||
subgraph Router["siprouter"]
|
||||
direction TB
|
||||
subgraph TS["TypeScript Control Plane"]
|
||||
TSBits["Config · WebRTC Signaling<br/>REST API · Web Dashboard<br/>Voicebox Manager · TTS Cache"]
|
||||
end
|
||||
subgraph Rust["Rust proxy-engine (data plane)"]
|
||||
RustBits["SIP Stack · Dialog SM · Auth<br/>Call Manager · N-Leg Mixer<br/>48kHz f32 Bus · Jitter Buffer<br/>Codec Engine · RTP Port Pool<br/>WebRTC Engine · Kokoro TTS<br/>Voicemail · IVR · Recording"]
|
||||
end
|
||||
TS <-->|"JSON-over-stdio IPC"| Rust
|
||||
end
|
||||
|
||||
Browser <-->|"Opus / WebRTC"| TS
|
||||
Rust <-->|"SIP / RTP"| Devices
|
||||
Rust <-->|"SIP / RTP"| Trunks
|
||||
```text
|
||||
Browser dashboard and softphone
|
||||
|
|
||||
| HTTP + WebSocket signaling
|
||||
v
|
||||
TypeScript control plane
|
||||
ts/sipproxy.ts
|
||||
ts/frontend.ts
|
||||
ts/webrtcbridge.ts
|
||||
ts/config.ts
|
||||
ts/proxybridge.ts
|
||||
|
|
||||
| JSON-over-stdio via @push.rocks/smartrust
|
||||
v
|
||||
Rust proxy-engine
|
||||
SIP transport and dialog state
|
||||
Call manager and call hub
|
||||
RTP port pool and RTP I/O
|
||||
48 kHz f32 mix-minus mixer
|
||||
WebRTC sessions
|
||||
Fax, voicemail, TTS, recorder, tool legs
|
||||
|
|
||||
| SIP/RTP/UDPTL/WebRTC media
|
||||
v
|
||||
SIP providers, SIP devices, and browser clients
|
||||
```
|
||||
|
||||
### 🧠 Key Design Decisions
|
||||
## Key Files
|
||||
|
||||
- **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.
|
||||
| Path | Role |
|
||||
| --- | --- |
|
||||
| `ts/sipproxy.ts` | Process entry point. Loads config, starts web UI, starts Rust, wires event handlers, and handles shutdown. |
|
||||
| `ts/config.ts` | `.nogit/config.json` schema, defaults, and validation. |
|
||||
| `ts/proxybridge.ts` | Typed command bridge to the Rust `proxy-engine` binary. |
|
||||
| `ts/frontend.ts` | HTTP API, static dashboard serving, status WebSocket, and WebRTC message routing. |
|
||||
| `ts/webrtcbridge.ts` | Browser device registration and WebSocket-to-device mapping. |
|
||||
| `ts/voicebox.ts` | Voicemail box config, WAV metadata, unheard counts, and message CRUD. |
|
||||
| `ts/faxbox.ts` | Fax inbox metadata and TIFF file tracking. |
|
||||
| `ts/faxjobs.ts` | Outbound/inbound fax job state persistence. |
|
||||
| `rust/crates/proxy-engine/src/call_manager.rs` | Central call registry, SIP routing, B2BUA state, route resolution, fax metadata, and call orchestration. |
|
||||
| `rust/crates/proxy-engine/src/mixer.rs` | 20 ms mix-minus engine with 48 kHz f32 processing, codec boundaries, jitter, PLC, DTMF, and tool-leg audio. |
|
||||
| `rust/crates/proxy-engine/src/webrtc_engine.rs` | Browser WebRTC sessions. |
|
||||
| `rust/crates/proxy-engine/src/fax_engine.rs` | Fax transfer engine using `spandsp` and `udptl`. |
|
||||
| `rust/crates/sip-proto/` | Zero-dependency SIP data library for parsing, serializing, dialogs, SDP helpers, digest auth, and URI rewriting. |
|
||||
| `ts_web/` | Lit/dees-element dashboard views and WebRTC browser client state. |
|
||||
|
||||
### 📲 WebRTC Browser Call Flow
|
||||
## Configuration
|
||||
|
||||
Browser calls are set up in a strict three-step dance — the WebRTC leg cannot be attached at call-creation time because the browser's session ID is only known once the SDP offer arrives:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant TS as TypeScript (sipproxy.ts)
|
||||
participant R as Rust proxy-engine
|
||||
participant P as SIP Provider
|
||||
|
||||
B->>TS: POST /api/call
|
||||
TS->>R: make_call (pending call, no WebRTC leg yet)
|
||||
R-->>TS: call_created
|
||||
TS-->>B: webrtc-incoming (callId)
|
||||
|
||||
B->>TS: webrtc-offer (sessionId, SDP)
|
||||
TS->>R: handle_webrtc_offer
|
||||
R-->>TS: webrtc-answer (SDP)
|
||||
TS-->>B: webrtc-answer
|
||||
Note over R: Standalone WebRTC session<br/>(not yet attached to call)
|
||||
|
||||
B->>TS: webrtc_link (callId + sessionId)
|
||||
TS->>R: link session → call
|
||||
R->>R: wire WebRTC leg through mixer
|
||||
R->>P: SIP INVITE
|
||||
P-->>R: 200 OK + SDP
|
||||
R-->>TS: call_answered
|
||||
Note over B,P: Bidirectional Opus ↔ codec-transcoded<br/>audio flows through the mixer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** ≥ 20 with `tsx` globally available
|
||||
- **pnpm** for package management
|
||||
- **Rust** toolchain (for building the proxy engine)
|
||||
|
||||
### Install & Build
|
||||
|
||||
```bash
|
||||
# Clone and install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build the Rust proxy-engine binary
|
||||
pnpm run buildRust
|
||||
|
||||
# Bundle the web frontend
|
||||
pnpm run bundle
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Create `.nogit/config.json`:
|
||||
Create `.nogit/config.json` in the repository root before starting the service.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"proxy": {
|
||||
"lanIp": "192.168.1.100", // Your server's LAN IP
|
||||
"lanPort": 5070, // SIP signaling port
|
||||
"publicIpSeed": "stun.example.com", // STUN server for public IP discovery
|
||||
"rtpPortRange": { "min": 20000, "max": 20200 }, // RTP port pool (even ports)
|
||||
"webUiPort": 3060 // Dashboard + REST API port
|
||||
"lanIp": "192.168.1.100",
|
||||
"lanPort": 5070,
|
||||
"publicIpSeed": null,
|
||||
"rtpPortRange": { "min": 20000, "max": 20200 },
|
||||
"webUiPort": 3060
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"id": "my-trunk",
|
||||
"displayName": "My SIP Provider",
|
||||
"domain": "sip.provider.com",
|
||||
"outboundProxy": { "address": "sip.provider.com", "port": 5060 },
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
"codecs": [9, 0, 8, 101], // G.722, PCMU, PCMA, telephone-event
|
||||
"registerIntervalSec": 300
|
||||
"id": "main-trunk",
|
||||
"displayName": "Main SIP trunk",
|
||||
"domain": "sip.example.net",
|
||||
"outboundProxy": { "address": "sip.example.net", "port": 5060 },
|
||||
"username": "trunk-user",
|
||||
"password": "trunk-password",
|
||||
"registerIntervalSec": 300,
|
||||
"codecs": [9, 0, 8, 101],
|
||||
"quirks": { "earlyMediaSilence": false }
|
||||
}
|
||||
],
|
||||
"devices": [
|
||||
@@ -148,13 +112,21 @@ Create `.nogit/config.json`:
|
||||
"routing": {
|
||||
"routes": [
|
||||
{
|
||||
"id": "inbound-main-did",
|
||||
"name": "Main DID",
|
||||
"id": "outbound-default",
|
||||
"name": "Outbound via main trunk",
|
||||
"priority": 100,
|
||||
"enabled": true,
|
||||
"match": { "direction": "outbound" },
|
||||
"action": { "provider": "main-trunk" }
|
||||
},
|
||||
{
|
||||
"id": "inbound-main",
|
||||
"name": "Inbound main number",
|
||||
"priority": 200,
|
||||
"enabled": true,
|
||||
"match": {
|
||||
"direction": "inbound",
|
||||
"sourceProvider": "my-trunk",
|
||||
"sourceProvider": "main-trunk",
|
||||
"numberPattern": "+49421219694"
|
||||
},
|
||||
"action": {
|
||||
@@ -162,267 +134,111 @@ Create `.nogit/config.json`:
|
||||
"ringBrowsers": true,
|
||||
"voicemailBox": "main"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "inbound-support-did",
|
||||
"name": "Support DID",
|
||||
"priority": 190,
|
||||
"enabled": true,
|
||||
"match": {
|
||||
"direction": "inbound",
|
||||
"sourceProvider": "my-trunk",
|
||||
"numberPattern": "+49421219695"
|
||||
},
|
||||
"action": {
|
||||
"ivrMenuId": "support-menu"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "outbound-default",
|
||||
"name": "Route via trunk",
|
||||
"priority": 100,
|
||||
"enabled": true,
|
||||
"match": { "direction": "outbound" },
|
||||
"action": { "provider": "my-trunk" }
|
||||
}
|
||||
]
|
||||
},
|
||||
"contacts": [],
|
||||
"voiceboxes": [
|
||||
{
|
||||
"id": "main",
|
||||
"enabled": true,
|
||||
"greetingText": "Please leave a message after the beep.",
|
||||
"greetingText": "Please leave a message after the tone.",
|
||||
"greetingVoice": "af_bella",
|
||||
"noAnswerTimeoutSec": 25,
|
||||
"maxRecordingSec": 120,
|
||||
"maxMessages": 50
|
||||
}
|
||||
],
|
||||
"contacts": [
|
||||
{ "id": "1", "name": "Alice", "number": "+491234567890", "starred": true }
|
||||
]
|
||||
"faxboxes": [],
|
||||
"ivr": {
|
||||
"enabled": false,
|
||||
"entryMenuId": "main-menu",
|
||||
"menus": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Inbound number ownership is explicit: add one inbound route per DID (or DID prefix) and scope it with `sourceProvider` when a provider delivers multiple external numbers.
|
||||
## Persistent Files
|
||||
|
||||
### TTS Setup (Optional)
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `.nogit/config.json` | Main app config. |
|
||||
| `.nogit/cert.pem` and `.nogit/key.pem` | Optional HTTPS certificate for the dashboard. |
|
||||
| `.nogit/voicemail/{boxId}/` | Voicemail WAV files and `messages.json`. |
|
||||
| `.nogit/fax/inboxes/{boxId}/` | Fax inbox files and metadata. |
|
||||
| `.nogit/fax/jobs.json` | Fax job state. |
|
||||
| `.nogit/prompts/` | Cached prompt/TTS assets used by call flows. |
|
||||
| `sip_trace.log` | Runtime log written by `ts/sipproxy.ts`. |
|
||||
|
||||
For neural voicemail greetings and IVR prompts, download the Kokoro TTS model:
|
||||
|
||||
```bash
|
||||
mkdir -p .nogit/tts
|
||||
curl -L -o .nogit/tts/kokoro-v1.0.onnx \
|
||||
https://github.com/mzdk100/kokoro/releases/download/V1.0/kokoro-v1.0.onnx
|
||||
curl -L -o .nogit/tts/voices.bin \
|
||||
https://github.com/mzdk100/kokoro/releases/download/V1.0/voices.bin
|
||||
```
|
||||
|
||||
Without the model files, TTS prompts (IVR menus, voicemail greetings) are skipped — everything else works fine.
|
||||
|
||||
### Run
|
||||
## HTTP and WebSocket API
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /api/status` | Full status snapshot for providers, devices, calls, and dashboard state. |
|
||||
| `POST /api/call` | Originate an outbound call. |
|
||||
| `POST /api/hangup` | Hang up a call. |
|
||||
| `POST /api/fax` | Start an outbound fax. |
|
||||
| `GET /api/fax/jobs` | List fax jobs. |
|
||||
| `GET /api/fax/inboxes/:boxId` | List fax inbox messages. |
|
||||
| `GET /api/fax/inboxes/:boxId/:messageId/file` | Stream a fax TIFF. |
|
||||
| `DELETE /api/fax/inboxes/:boxId/:messageId` | Delete a fax message. |
|
||||
| `POST /api/call/:id/addleg` | Add a registered SIP device leg to an active call. |
|
||||
| `POST /api/call/:id/addexternal` | Add an external dial-out leg to an active call. |
|
||||
| `POST /api/call/:id/removeleg` | Remove a leg from a call. |
|
||||
| `POST /api/transfer` | Present but returns `501 not yet implemented`. |
|
||||
| `GET /api/config` | Read sanitized config. |
|
||||
| `POST /api/config` | Update config and trigger runtime reload where possible. |
|
||||
| `GET /api/voicemail/:boxId` | List voicemail messages. |
|
||||
| `GET /api/voicemail/:boxId/unheard` | Get unheard voicemail count. |
|
||||
| `GET /api/voicemail/:boxId/:messageId/audio` | Stream voicemail WAV audio. |
|
||||
| `POST /api/voicemail/:boxId/:messageId/heard` | Mark voicemail as heard. |
|
||||
| `DELETE /api/voicemail/:boxId/:messageId` | Delete voicemail metadata and WAV file. |
|
||||
| `WS /ws` | Status updates, logs, WebRTC signaling, and browser phone events. |
|
||||
|
||||
## Build and Run
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run buildRust
|
||||
pnpm run bundle
|
||||
pnpm start
|
||||
```
|
||||
|
||||
The SIP proxy starts on the configured port and the web dashboard is available at `https://<your-ip>:3060`.
|
||||
|
||||
### HTTPS (Optional)
|
||||
|
||||
Place `cert.pem` and `key.pem` in `.nogit/` for TLS on the dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Project Structure
|
||||
Full build:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Docker build scripts are also present:
|
||||
|
||||
```bash
|
||||
pnpm run build:docker
|
||||
pnpm run release:docker
|
||||
```
|
||||
|
||||
`pnpm run buildRust` uses `tsrust`. Per the project notes, do not replace that with a direct `cargo build` when validating the packaged Rust output. The configured build path cross-compiles the Rust engine for Linux amd64 and arm64 targets.
|
||||
|
||||
## Project Map
|
||||
|
||||
```text
|
||||
siprouter/
|
||||
├── ts/ # TypeScript control plane
|
||||
│ ├── sipproxy.ts # Main entry — bootstraps everything
|
||||
│ ├── config.ts # Config loader & validation
|
||||
│ ├── proxybridge.ts # Rust proxy-engine IPC bridge (smartrust)
|
||||
│ ├── frontend.ts # Web dashboard HTTP/WS server + REST API
|
||||
│ ├── webrtcbridge.ts # WebRTC signaling layer
|
||||
│ ├── registrar.ts # Browser softphone registration
|
||||
│ ├── voicebox.ts # Voicemail box management
|
||||
│ └── call/
|
||||
│ └── prompt-cache.ts # Named audio prompt WAV management
|
||||
│
|
||||
├── ts_web/ # Web frontend (Lit-based SPA)
|
||||
│ ├── elements/ # Web components (9 dashboard views)
|
||||
│ └── state/ # App state, WebRTC client, notifications
|
||||
│
|
||||
├── rust/ # Rust workspace (the data plane)
|
||||
├── ts_web/ # Browser dashboard
|
||||
├── rust/
|
||||
│ └── crates/
|
||||
│ ├── codec-lib/ # Audio codec library (Opus/G.722/PCMU/PCMA)
|
||||
│ ├── sip-proto/ # Zero-dependency SIP protocol library
|
||||
│ └── proxy-engine/ # Main binary — SIP engine + mixer + RTP
|
||||
│
|
||||
├── html/ # Static HTML shell
|
||||
├── .nogit/ # Secrets, config, TTS models (gitignored)
|
||||
└── dist_rust/ # Compiled Rust binary (gitignored)
|
||||
│ ├── codec-lib/ # Codec and transcoding helpers
|
||||
│ ├── proxy-engine/ # Rust SIP/RTP/WebRTC/fax engine
|
||||
│ └── sip-proto/ # SIP message/dialog/SDP library
|
||||
├── html/ # Dashboard HTML shell
|
||||
├── dist_rust/ # Built Rust binaries
|
||||
├── dist_ts_web/ # Bundled web UI
|
||||
└── .nogit/ # Local config, secrets, voicemail, fax, prompts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎧 Audio Engine (Rust)
|
||||
|
||||
The `proxy-engine` binary handles all real-time audio processing with a **48kHz f32 internal bus** — encoding and decoding happens only at leg boundaries.
|
||||
|
||||
### Supported Codecs
|
||||
|
||||
| Codec | PT | Native Rate | Use Case |
|
||||
|-------|:--:|:-----------:|----------|
|
||||
| **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 |
|
||||
| **PCMA** (G.711 A-law) | 8 | 8 kHz | Legacy SIP |
|
||||
|
||||
### Audio Pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Inbound["Inbound path (per leg)"]
|
||||
direction LR
|
||||
IN_RTP["Wire RTP"] --> IN_JB["Jitter Buffer"] --> IN_DEC["Decode"] --> IN_RS["Resample → 48 kHz"] --> IN_DN["Denoise (RNNoise)"] --> IN_BUS["Mix Bus"]
|
||||
end
|
||||
|
||||
subgraph Outbound["Outbound path (per leg)"]
|
||||
direction LR
|
||||
OUT_BUS["Mix Bus"] --> OUT_MM["Mix-Minus"] --> OUT_RS["Resample → codec rate"] --> OUT_ENC["Encode"] --> OUT_RTP["Wire RTP"]
|
||||
end
|
||||
```
|
||||
|
||||
- **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
|
||||
|
||||
Voicemail greetings and IVR prompts are synthesized using [Kokoro TTS](https://github.com/mzdk100/kokoro) — an 82M parameter neural model running via ONNX Runtime directly in the Rust process:
|
||||
|
||||
- **24 kHz, 16-bit mono** output
|
||||
- **25+ voice presets** — American/British, male/female (e.g., `af_bella`, `am_adam`, `bf_emma`, `bm_george`)
|
||||
- **~800ms** synthesis time for a 3-second phrase
|
||||
- Lazy-loaded on first use — no startup cost if TTS is unused
|
||||
|
||||
---
|
||||
|
||||
## 📧 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
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Web Dashboard & REST API
|
||||
|
||||
### Dashboard Views
|
||||
|
||||
| View | Description |
|
||||
|------|-------------|
|
||||
| 📊 **Overview** | Stats tiles — uptime, providers, devices, active calls |
|
||||
| 📞 **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 |
|
||||
| 🔀 **Routes** | Routing rule management — match/action model with priority |
|
||||
| 📧 **Voicemail** | Voicemail box management + message playback |
|
||||
| 🔢 **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
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/status` | GET | Full system status (providers, devices, calls, history) |
|
||||
| `/api/call` | POST | Originate a call |
|
||||
| `/api/hangup` | POST | Hang up a call |
|
||||
| `/api/call/:id/addleg` | POST | Add a device leg to an active call |
|
||||
| `/api/call/:id/addexternal` | POST | Add an external participant via provider |
|
||||
| `/api/call/:id/removeleg` | POST | Remove a leg from a call |
|
||||
| `/api/transfer` | POST | Transfer a call |
|
||||
| `/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
|
||||
|
||||
Connect to `/ws` for real-time push:
|
||||
|
||||
```jsonc
|
||||
{ "type": "status", "data": { ... } } // Full status snapshot (1s interval)
|
||||
{ "type": "log", "data": { "message": "..." } } // Log lines in real-time
|
||||
{ "type": "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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Ports
|
||||
|
||||
| Port | Protocol | Purpose |
|
||||
|------|----------|---------|
|
||||
| 5070 (configurable) | UDP | SIP signaling |
|
||||
| 20000–20200 (configurable) | UDP | RTP media (even ports, per-call allocation) |
|
||||
| 3060 (configurable) | TCP | Web dashboard + WebSocket + REST API |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
```bash
|
||||
# Start in dev mode
|
||||
pnpm start
|
||||
|
||||
# Build Rust proxy-engine
|
||||
pnpm run buildRust
|
||||
|
||||
# Bundle web frontend
|
||||
pnpm run bundle
|
||||
|
||||
# Build + bundle + restart background server
|
||||
pnpm run restartBackground
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -28,3 +28,6 @@ rustflags = ["-C", "link-arg=-L.cargo/crosslibs/aarch64"]
|
||||
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"
|
||||
PKG_CONFIG_ALLOW_CROSS = "1"
|
||||
PKG_CONFIG_SYSROOT_DIR_aarch64_unknown_linux_gnu = "/"
|
||||
PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu = "/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig"
|
||||
|
||||
Generated
+161
-25
@@ -165,7 +165,7 @@ dependencies = [
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
@@ -181,7 +181,7 @@ dependencies = [
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
@@ -327,6 +327,26 @@ dependencies = [
|
||||
"virtue",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -429,6 +449,15 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "0.1.10"
|
||||
@@ -498,6 +527,17 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "3.2.25"
|
||||
@@ -538,7 +578,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9f73004e928ed46c3e7fd7406d2b12c8674153295f08af084b49860276dc02"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1021,6 +1061,12 @@ dependencies = [
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.12.3"
|
||||
@@ -1367,6 +1413,12 @@ dependencies = [
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.12.1"
|
||||
@@ -1668,7 +1720,7 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"rtcp",
|
||||
"rtp",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"waitgroup",
|
||||
"webrtc-srtp",
|
||||
@@ -1681,6 +1733,15 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -1733,8 +1794,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "kokoro-tts"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68e5d46e20a28fa5fd313d9ffcf4bbcf41570e64841d3944c832eef6b98d208b"
|
||||
dependencies = [
|
||||
"bincode 2.0.1",
|
||||
"cc",
|
||||
@@ -1794,6 +1853,16 @@ dependencies = [
|
||||
"rle-decode-fast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.4",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2388,7 +2457,9 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sip-proto",
|
||||
"spandsp",
|
||||
"tokio",
|
||||
"udptl",
|
||||
"webrtc",
|
||||
]
|
||||
|
||||
@@ -2586,7 +2657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6423493804221c276d27f3cc383cd5cbe1a1f10f210909fd4951b579b01293cd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"webrtc-util",
|
||||
]
|
||||
|
||||
@@ -2596,7 +2667,7 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5248489db464de29835170cd1f6e19933146b0016789effc59cb53d9f13844"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2608,7 +2679,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"webrtc-util",
|
||||
]
|
||||
|
||||
@@ -2619,7 +2690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bb90df8268abfe08452ef2dae9e867a54edfdaa71b3127ef47d8b031f77ac73"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2746,7 +2817,7 @@ checksum = "4d22a5ef407871893fd72b4562ee15e4742269b173959db4b8df6f538c414e13"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"substring",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -2959,6 +3030,28 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spandsp"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5f076b6e56f1a1062d6950dcd1c6c1df281ae2828db271929c50c191ec8c79e"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"spandsp-sys",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spandsp-sys"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c05ab99051230293dded61ba3cd32f06eb15b437a8135be21f560f72bab713db"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.5.2"
|
||||
@@ -3006,7 +3099,7 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"ring",
|
||||
"subtle",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"url",
|
||||
"webrtc-util",
|
||||
@@ -3106,7 +3199,16 @@ version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3120,6 +3222,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -3196,9 +3309,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -3232,7 +3357,7 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"ring",
|
||||
"stun",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
]
|
||||
@@ -3243,6 +3368,17 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "udptl"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b255ad0ff36582a8a453c42a2bcc16c72d00f0ab16a14a4a7aeacb55ccb2a351"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -3534,7 +3670,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"smol_str",
|
||||
"stun",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"tokio",
|
||||
"turn",
|
||||
@@ -3559,7 +3695,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"derive_builder",
|
||||
"log",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"webrtc-sctp",
|
||||
"webrtc-util",
|
||||
@@ -3597,7 +3733,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"signature",
|
||||
"subtle",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"webpki",
|
||||
"webrtc-util",
|
||||
@@ -3619,7 +3755,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"stun",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"turn",
|
||||
"url",
|
||||
@@ -3637,7 +3773,7 @@ checksum = "f08dfd7a6e3987e255c4dbe710dde5d94d0f0574f8a21afa95d171376c143106"
|
||||
dependencies = [
|
||||
"log",
|
||||
"socket2 0.4.10",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
]
|
||||
@@ -3652,7 +3788,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"rand 0.8.5",
|
||||
"rtp",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3667,7 +3803,7 @@ dependencies = [
|
||||
"crc",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
]
|
||||
@@ -3690,7 +3826,7 @@ dependencies = [
|
||||
"rtp",
|
||||
"sha1",
|
||||
"subtle",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
]
|
||||
@@ -3711,7 +3847,7 @@ dependencies = [
|
||||
"log",
|
||||
"nix",
|
||||
"rand 0.8.5",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"winapi",
|
||||
]
|
||||
@@ -3882,7 +4018,7 @@ dependencies = [
|
||||
"nom",
|
||||
"oid-registry 0.4.0",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
@@ -3901,7 +4037,7 @@ dependencies = [
|
||||
"oid-registry 0.6.1",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
|
||||
@@ -9,3 +9,6 @@ resolver = "2"
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
|
||||
[patch.crates-io]
|
||||
kokoro-tts = { path = "vendor/kokoro-tts" }
|
||||
|
||||
@@ -19,6 +19,8 @@ regex-lite = "0.1"
|
||||
webrtc = "0.8"
|
||||
rand = "0.8"
|
||||
hound = "3.5"
|
||||
spandsp = "0.1.5"
|
||||
udptl = "0.1.0"
|
||||
kokoro-tts = { version = "0.3", default-features = false, features = ["use-cmudict"] }
|
||||
ort = { version = "=2.0.0-rc.11", default-features = false, features = [
|
||||
"std", "download-binaries", "copy-dylibs", "ndarray",
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
pub type LegId = String;
|
||||
@@ -114,6 +114,13 @@ pub struct LegInfo {
|
||||
pub kind: LegKind,
|
||||
pub state: LegState,
|
||||
pub codec_pt: u8,
|
||||
/// Media transport currently negotiated for this leg.
|
||||
///
|
||||
/// `rtp` covers classic SIP audio media, `t38-udptl` covers T.38 fax,
|
||||
/// `webrtc` is used for browser legs, and `internal` for proxy-local media/tool paths.
|
||||
pub media_protocol: &'static str,
|
||||
/// Whether this leg is currently wired into an active media bridge.
|
||||
pub media_io_active: bool,
|
||||
|
||||
/// For SIP legs: the SIP dialog manager (handles 407 auth, BYE, etc).
|
||||
pub sip_leg: Option<SipLeg>,
|
||||
@@ -146,6 +153,15 @@ pub struct LegInfo {
|
||||
pub metadata: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PendingDialogBridge {
|
||||
pub source_leg_id: LegId,
|
||||
pub target_leg_id: LegId,
|
||||
pub source_request: SipMessage,
|
||||
pub target_request: SipMessage,
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
/// A multiparty call with N legs and a central mixer.
|
||||
pub struct Call {
|
||||
// Duplicated from the HashMap key in CallManager. Kept for future
|
||||
@@ -169,12 +185,21 @@ pub struct Call {
|
||||
/// Used to construct proper 180/200/error responses back to the device.
|
||||
pub device_invite: Option<SipMessage>,
|
||||
|
||||
/// Pending in-dialog B2BUA transaction bridged across two different SIP dialogs.
|
||||
pub pending_dialog_bridge: Option<PendingDialogBridge>,
|
||||
|
||||
/// All legs in this call, keyed by leg ID.
|
||||
pub legs: HashMap<LegId, LegInfo>,
|
||||
|
||||
/// Channel to send commands to the mixer task.
|
||||
pub mixer_cmd_tx: mpsc::Sender<MixerCommand>,
|
||||
|
||||
/// Active passthrough media bridge mode, if any.
|
||||
pub media_bridge_mode: Option<String>,
|
||||
|
||||
/// Cancellation handles for non-mixer passthrough media tasks.
|
||||
media_bridge_cancel_txs: Vec<watch::Sender<bool>>,
|
||||
|
||||
/// Handle to the mixer task (aborted on call teardown).
|
||||
mixer_task: Option<JoinHandle<()>>,
|
||||
}
|
||||
@@ -196,8 +221,11 @@ impl Call {
|
||||
callee_number: None,
|
||||
provider_id,
|
||||
device_invite: None,
|
||||
pending_dialog_bridge: None,
|
||||
legs: HashMap::new(),
|
||||
mixer_cmd_tx,
|
||||
media_bridge_mode: None,
|
||||
media_bridge_cancel_txs: Vec::new(),
|
||||
mixer_task: Some(mixer_task),
|
||||
}
|
||||
}
|
||||
@@ -235,8 +263,31 @@ impl Call {
|
||||
self.created_at.elapsed().as_secs()
|
||||
}
|
||||
|
||||
pub fn clear_media_bridge(&mut self) {
|
||||
for cancel_tx in self.media_bridge_cancel_txs.drain(..) {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
self.media_bridge_mode = None;
|
||||
}
|
||||
|
||||
pub fn install_media_bridge(
|
||||
&mut self,
|
||||
mode: &str,
|
||||
cancel_txs: Vec<watch::Sender<bool>>,
|
||||
) {
|
||||
self.clear_media_bridge();
|
||||
self.media_bridge_mode = Some(mode.to_string());
|
||||
self.media_bridge_cancel_txs = cancel_txs;
|
||||
}
|
||||
|
||||
pub fn note_mixer_bridge(&mut self, mode: &str) {
|
||||
self.clear_media_bridge();
|
||||
self.media_bridge_mode = Some(mode.to_string());
|
||||
}
|
||||
|
||||
/// Shut down the mixer and abort its task.
|
||||
pub async fn shutdown_mixer(&mut self) {
|
||||
self.clear_media_bridge();
|
||||
let _ = self.mixer_cmd_tx.send(MixerCommand::Shutdown).await;
|
||||
if let Some(handle) = self.mixer_task.take() {
|
||||
handle.abort();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,6 +105,8 @@ pub struct RouteAction {
|
||||
pub ring_browsers: Option<bool>,
|
||||
#[serde(rename = "voicemailBox")]
|
||||
pub voicemail_box: Option<String>,
|
||||
#[serde(rename = "faxBox")]
|
||||
pub fax_box: Option<String>,
|
||||
#[serde(rename = "ivrMenuId")]
|
||||
pub ivr_menu_id: Option<String>,
|
||||
#[serde(rename = "noAnswerTimeout")]
|
||||
@@ -161,6 +163,8 @@ pub struct AppConfig {
|
||||
pub devices: Vec<DeviceConfig>,
|
||||
pub routing: RoutingConfig,
|
||||
#[serde(default)]
|
||||
pub faxboxes: Vec<FaxBoxConfig>,
|
||||
#[serde(default)]
|
||||
pub voiceboxes: Vec<VoiceboxConfig>,
|
||||
#[serde(default)]
|
||||
pub ivr: Option<IvrConfig>,
|
||||
@@ -191,6 +195,16 @@ pub struct VoiceboxConfig {
|
||||
pub max_recording_sec: Option<u32>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FaxBoxConfig {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "maxMessages")]
|
||||
pub max_messages: Option<u32>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IVR config
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -273,6 +287,38 @@ pub fn normalize_routing_identity(value: &str) -> String {
|
||||
digits
|
||||
}
|
||||
|
||||
fn looks_like_phone_identity(value: &str) -> bool {
|
||||
let digits = value.chars().filter(|c| c.is_ascii_digit()).count();
|
||||
digits >= 6 && value.chars().all(|c| c.is_ascii_digit() || c == '+')
|
||||
}
|
||||
|
||||
/// Pick the best inbound called-number identity from common SIP headers.
|
||||
///
|
||||
/// Some providers deliver the DID in `To` / `P-Called-Party-ID` while the
|
||||
/// request URI contains an account username. Prefer a phone-like identity when
|
||||
/// present; otherwise fall back to the request URI user part.
|
||||
pub fn extract_inbound_called_number(msg: &SipMessage) -> String {
|
||||
let request_uri = normalize_routing_identity(msg.request_uri().unwrap_or(""));
|
||||
if looks_like_phone_identity(&request_uri) {
|
||||
return request_uri;
|
||||
}
|
||||
|
||||
for header_name in [
|
||||
"P-Called-Party-ID",
|
||||
"X-Called-Party-ID",
|
||||
"Diversion",
|
||||
"History-Info",
|
||||
"To",
|
||||
] {
|
||||
let candidate = normalize_routing_identity(msg.get_header(header_name).unwrap_or(""));
|
||||
if looks_like_phone_identity(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
request_uri
|
||||
}
|
||||
|
||||
fn parse_numeric_range_value(value: &str) -> Option<(bool, &str)> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -383,6 +429,7 @@ pub struct InboundRouteResult {
|
||||
pub ring_all_devices: bool,
|
||||
pub ring_browsers: bool,
|
||||
pub voicemail_box: Option<String>,
|
||||
pub fax_box: Option<String>,
|
||||
pub ivr_menu_id: Option<String>,
|
||||
pub no_answer_timeout: Option<u32>,
|
||||
}
|
||||
@@ -493,6 +540,7 @@ impl AppConfig {
|
||||
ring_all_devices: explicit_targets.is_none(),
|
||||
ring_browsers: route.action.ring_browsers.unwrap_or(false),
|
||||
voicemail_box: route.action.voicemail_box.clone(),
|
||||
fax_box: route.action.fax_box.clone(),
|
||||
ivr_menu_id: route.action.ivr_menu_id.clone(),
|
||||
no_answer_timeout: route.action.no_answer_timeout,
|
||||
});
|
||||
@@ -542,6 +590,7 @@ mod tests {
|
||||
extension: "100".to_string(),
|
||||
}],
|
||||
routing: RoutingConfig { routes },
|
||||
faxboxes: vec![],
|
||||
voiceboxes: vec![],
|
||||
ivr: None,
|
||||
}
|
||||
@@ -588,6 +637,7 @@ mod tests {
|
||||
targets: Some(vec!["desk".to_string()]),
|
||||
ring_browsers: Some(true),
|
||||
voicemail_box: None,
|
||||
fax_box: None,
|
||||
ivr_menu_id: None,
|
||||
no_answer_timeout: None,
|
||||
provider: None,
|
||||
@@ -612,6 +662,7 @@ mod tests {
|
||||
targets: None,
|
||||
ring_browsers: Some(false),
|
||||
voicemail_box: Some("support-box".to_string()),
|
||||
fax_box: None,
|
||||
ivr_menu_id: None,
|
||||
no_answer_timeout: Some(20),
|
||||
provider: None,
|
||||
@@ -636,6 +687,20 @@ mod tests {
|
||||
assert!(!support.ring_browsers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_inbound_called_number_prefers_did_headers_over_username_ruri() {
|
||||
let raw = b"INVITE sip:2830573e1@proxy.example SIP/2.0\r\nTo: <sip:+4942116767548@proxy.example>\r\nFrom: <sip:+491701234567@provider.example>;tag=abc\r\nCall-ID: test-1\r\nCSeq: 1 INVITE\r\nContent-Length: 0\r\n\r\n";
|
||||
let msg = SipMessage::parse(raw).expect("invite should parse");
|
||||
assert_eq!(extract_inbound_called_number(&msg), "+4942116767548");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_inbound_called_number_keeps_phone_ruri_when_already_present() {
|
||||
let raw = b"INVITE sip:042116767548@proxy.example SIP/2.0\r\nTo: <sip:2830573e1@proxy.example>\r\nFrom: <sip:+491701234567@provider.example>;tag=abc\r\nCall-ID: test-2\r\nCSeq: 1 INVITE\r\nContent-Length: 0\r\n\r\n";
|
||||
let msg = SipMessage::parse(raw).expect("invite should parse");
|
||||
assert_eq!(extract_inbound_called_number(&msg), "042116767548");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_pattern_supports_numeric_ranges() {
|
||||
assert!(matches_pattern(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ use crate::mixer::RtpPacket;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
/// Channel pair for connecting a leg to the mixer.
|
||||
pub struct LegChannels {
|
||||
@@ -109,3 +109,56 @@ pub fn spawn_sip_outbound(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn a raw UDP inbound task for non-RTP passthrough media such as T.38 UDPTL.
|
||||
pub fn spawn_raw_udp_inbound(
|
||||
media_socket: Arc<UdpSocket>,
|
||||
inbound_tx: mpsc::Sender<Vec<u8>>,
|
||||
mut cancel_rx: watch::Receiver<bool>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 2048];
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel_rx.changed() => break,
|
||||
recv = media_socket.recv_from(&mut buf) => {
|
||||
match recv {
|
||||
Ok((n, _from)) => {
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
if inbound_tx.send(buf[..n].to_vec()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn a raw UDP outbound task for non-RTP passthrough media such as T.38 UDPTL.
|
||||
pub fn spawn_raw_udp_outbound(
|
||||
media_socket: Arc<UdpSocket>,
|
||||
remote_media: SocketAddr,
|
||||
mut outbound_rx: mpsc::Receiver<Vec<u8>>,
|
||||
mut cancel_rx: watch::Receiver<bool>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel_rx.changed() => break,
|
||||
pkt = outbound_rx.recv() => {
|
||||
match pkt {
|
||||
Some(packet) => {
|
||||
let _ = media_socket.send_to(&packet, remote_media).await;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ mod audio_player;
|
||||
mod call;
|
||||
mod call_manager;
|
||||
mod config;
|
||||
#[allow(dead_code)]
|
||||
mod fax_engine;
|
||||
mod ipc;
|
||||
mod jitter_buffer;
|
||||
mod leg_io;
|
||||
@@ -25,7 +27,7 @@ mod voicemail;
|
||||
mod webrtc_engine;
|
||||
|
||||
use crate::call_manager::CallManager;
|
||||
use crate::config::{normalize_routing_identity, AppConfig};
|
||||
use crate::config::{extract_inbound_called_number, normalize_routing_identity, AppConfig};
|
||||
use crate::ipc::{emit_event, respond_err, respond_ok, Command, OutTx};
|
||||
use crate::provider::ProviderManager;
|
||||
use crate::registrar::Registrar;
|
||||
@@ -139,6 +141,7 @@ async fn handle_command(
|
||||
"configure" => handle_configure(engine, out_tx, &cmd).await,
|
||||
"hangup" => handle_hangup(engine, out_tx, &cmd).await,
|
||||
"make_call" => handle_make_call(engine, out_tx, &cmd).await,
|
||||
"send_fax" => handle_send_fax(engine, out_tx, &cmd).await,
|
||||
"add_leg" => handle_add_leg(engine, out_tx, &cmd).await,
|
||||
"remove_leg" => handle_remove_leg(engine, out_tx, &cmd).await,
|
||||
// WebRTC commands — lock webrtc only (no engine contention).
|
||||
@@ -346,7 +349,7 @@ async fn handle_sip_packet(
|
||||
// Emit event so TypeScript knows about the call (for dashboard, IVR routing, etc).
|
||||
let from_header = msg.get_header("From").unwrap_or("");
|
||||
let from_uri = normalize_routing_identity(from_header);
|
||||
let called_number = normalize_routing_identity(msg.request_uri().unwrap_or(""));
|
||||
let called_number = extract_inbound_called_number(&msg);
|
||||
|
||||
emit_event(
|
||||
&eng.out_tx,
|
||||
@@ -369,6 +372,20 @@ async fn handle_sip_packet(
|
||||
let dialed_number = normalize_routing_identity(msg.request_uri().unwrap_or(""));
|
||||
|
||||
let device = eng.registrar.find_by_address(&from_addr);
|
||||
if device.is_none() {
|
||||
emit_event(
|
||||
&eng.out_tx,
|
||||
"sip_unhandled",
|
||||
serde_json::json!({
|
||||
"method_or_status": "INVITE",
|
||||
"call_id": msg.call_id(),
|
||||
"from_addr": from_addr.ip().to_string(),
|
||||
"from_port": from_addr.port(),
|
||||
"is_from_provider": false,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let device_id = device.map(|d| d.device_id.clone());
|
||||
|
||||
// Find provider via routing rules.
|
||||
@@ -562,6 +579,162 @@ async fn handle_make_call(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd:
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `send_fax` — place an outbound server-side fax call via SpanDSP over G.711 audio.
|
||||
async fn handle_send_fax(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
|
||||
let number = match cmd.params.get("number").and_then(|v| v.as_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => {
|
||||
respond_err(out_tx, &cmd.id, "missing number");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let file_path = match cmd.params.get("file_path").and_then(|v| v.as_str()) {
|
||||
Some(path) if std::path::Path::new(path).exists() => path.to_string(),
|
||||
Some(_) => {
|
||||
respond_err(out_tx, &cmd.id, "fax file does not exist");
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
respond_err(out_tx, &cmd.id, "missing file_path");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let provider_id = cmd.params.get("provider_id").and_then(|v| v.as_str());
|
||||
|
||||
let mut eng = engine.lock().await;
|
||||
let config_ref = match &eng.config {
|
||||
Some(c) => c.clone(),
|
||||
None => {
|
||||
respond_err(out_tx, &cmd.id, "not configured");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let provider_config = if let Some(pid) = provider_id {
|
||||
config_ref.providers.iter().find(|p| p.id == pid).cloned()
|
||||
} else {
|
||||
let route = config_ref.resolve_outbound_route(&number, None, &|_| true);
|
||||
route.map(|r| r.provider)
|
||||
};
|
||||
|
||||
let mut provider_config = match provider_config {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
respond_err(out_tx, &cmd.id, "no provider available");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let fax_codec = if provider_config.codecs.contains(&codec_lib::PT_PCMU) {
|
||||
codec_lib::PT_PCMU
|
||||
} else if provider_config.codecs.contains(&codec_lib::PT_PCMA) {
|
||||
codec_lib::PT_PCMA
|
||||
} else {
|
||||
respond_err(
|
||||
out_tx,
|
||||
&cmd.id,
|
||||
&format!(
|
||||
"provider {} does not advertise PCMU/PCMA, which outbound fax currently requires",
|
||||
provider_config.id
|
||||
),
|
||||
);
|
||||
return;
|
||||
};
|
||||
provider_config.codecs = vec![fax_codec];
|
||||
|
||||
let (public_ip, registered_aor) = if let Some(ps_arc) = eng
|
||||
.provider_mgr
|
||||
.find_by_address(
|
||||
&provider_config
|
||||
.outbound_proxy
|
||||
.to_socket_addr()
|
||||
.unwrap_or_else(|| "0.0.0.0:0".parse().unwrap()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let ps = ps_arc.lock().await;
|
||||
(ps.public_ip.clone(), ps.registered_aor.clone())
|
||||
} else {
|
||||
(
|
||||
None,
|
||||
format!(
|
||||
"sip:{}@{}",
|
||||
provider_config.username, provider_config.domain
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
let socket = match &eng.transport {
|
||||
Some(t) => t.socket(),
|
||||
None => {
|
||||
respond_err(out_tx, &cmd.id, "not initialized");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ProxyEngine {
|
||||
ref mut call_mgr,
|
||||
ref mut rtp_pool,
|
||||
..
|
||||
} = *eng;
|
||||
let rtp_pool = rtp_pool.as_mut().unwrap();
|
||||
|
||||
let call_id = call_mgr
|
||||
.make_outbound_call(
|
||||
&number,
|
||||
&provider_config,
|
||||
&config_ref,
|
||||
rtp_pool,
|
||||
&socket,
|
||||
public_ip.as_deref(),
|
||||
®istered_aor,
|
||||
)
|
||||
.await;
|
||||
|
||||
let call_id = match call_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
respond_err(
|
||||
out_tx,
|
||||
&cmd.id,
|
||||
"fax origination failed — provider not registered or no ports available",
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(call) = call_mgr.calls.get_mut(&call_id) {
|
||||
let provider_leg_id = format!("{call_id}-prov");
|
||||
if let Some(leg) = call.legs.get_mut(&provider_leg_id) {
|
||||
leg.codec_pt = fax_codec;
|
||||
leg.metadata
|
||||
.insert("fax_mode".to_string(), serde_json::json!("outbound-audio"));
|
||||
leg.metadata
|
||||
.insert("fax_file_path".to_string(), serde_json::json!(file_path));
|
||||
}
|
||||
}
|
||||
|
||||
emit_event(
|
||||
out_tx,
|
||||
"outbound_call_started",
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"number": number,
|
||||
"provider_id": provider_config.id,
|
||||
"ring_browsers": false,
|
||||
}),
|
||||
);
|
||||
|
||||
respond_ok(
|
||||
out_tx,
|
||||
&cmd.id,
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"codec": if fax_codec == codec_lib::PT_PCMU { "PCMU" } else { "PCMA" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle the `hangup` command.
|
||||
async fn handle_hangup(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
|
||||
let call_id = match cmd.params.get("call_id").and_then(|v| v.as_str()) {
|
||||
@@ -724,6 +897,8 @@ async fn handle_webrtc_link(
|
||||
kind: crate::call::LegKind::WebRtc,
|
||||
state: crate::call::LegState::Connected,
|
||||
codec_pt: codec_lib::PT_OPUS,
|
||||
media_protocol: "webrtc",
|
||||
media_io_active: true,
|
||||
sip_leg: None,
|
||||
sip_call_id: None,
|
||||
webrtc_session_id: Some(session_id.clone()),
|
||||
@@ -748,6 +923,7 @@ async fn handle_webrtc_link(
|
||||
"state": "connected",
|
||||
"codec": "Opus",
|
||||
"rtpPort": 0,
|
||||
"mediaProtocol": "webrtc",
|
||||
"remoteMedia": null,
|
||||
"metadata": {},
|
||||
}),
|
||||
@@ -1448,6 +1624,8 @@ async fn handle_add_tool_leg(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cm
|
||||
kind: crate::call::LegKind::Tool,
|
||||
state: crate::call::LegState::Connected,
|
||||
codec_pt: 0,
|
||||
media_protocol: "internal",
|
||||
media_io_active: true,
|
||||
sip_leg: None,
|
||||
sip_call_id: None,
|
||||
webrtc_session_id: None,
|
||||
@@ -1471,6 +1649,7 @@ async fn handle_add_tool_leg(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cm
|
||||
"state": "connected",
|
||||
"codec": null,
|
||||
"rtpPort": 0,
|
||||
"mediaProtocol": "internal",
|
||||
"remoteMedia": null,
|
||||
"metadata": { "tool_type": tool_type_str },
|
||||
}),
|
||||
|
||||
@@ -313,6 +313,23 @@ impl ProviderManager {
|
||||
if ps.config.outbound_proxy.address == addr.ip().to_string() {
|
||||
return Some(ps_arc.clone());
|
||||
}
|
||||
|
||||
// Hostname-based providers (e.g. sipgate.de) often deliver inbound
|
||||
// INVITEs from resolved IPs rather than the literal configured host.
|
||||
// Resolve the proxy host and accept any matching IP/port variant.
|
||||
use std::net::ToSocketAddrs;
|
||||
if let Ok(resolved) = format!(
|
||||
"{}:{}",
|
||||
ps.config.outbound_proxy.address, ps.config.outbound_proxy.port
|
||||
)
|
||||
.to_socket_addrs()
|
||||
{
|
||||
for resolved_addr in resolved {
|
||||
if resolved_addr == *addr || resolved_addr.ip() == addr.ip() {
|
||||
return Some(ps_arc.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -108,6 +108,24 @@ impl SipLeg {
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
self.send_invite_with_sdp(from_uri, to_uri, sip_call_id, socket, sdp)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn send_invite_with_sdp(
|
||||
&mut self,
|
||||
from_uri: &str,
|
||||
to_uri: &str,
|
||||
sip_call_id: &str,
|
||||
socket: &UdpSocket,
|
||||
sdp: String,
|
||||
) {
|
||||
let ip = self
|
||||
.config
|
||||
.public_ip
|
||||
.as_deref()
|
||||
.unwrap_or(&self.config.lan_ip);
|
||||
|
||||
let invite = SipMessage::create_request(
|
||||
"INVITE",
|
||||
to_uri,
|
||||
@@ -401,6 +419,10 @@ impl SipLeg {
|
||||
return SipLegAction::Send(ok.serialize());
|
||||
}
|
||||
|
||||
if method == "INVITE" || method == "UPDATE" {
|
||||
return SipLegAction::InDialogRequest(method.to_string());
|
||||
}
|
||||
|
||||
SipLegAction::None
|
||||
}
|
||||
|
||||
@@ -436,6 +458,9 @@ pub enum SipLegAction {
|
||||
StateChange(LegState),
|
||||
/// Connected — send this ACK.
|
||||
ConnectedWithAck(Vec<u8>),
|
||||
/// Provider sent an in-dialog request (re-INVITE / UPDATE) that needs
|
||||
/// call-manager-specific handling.
|
||||
InDialogRequest(String),
|
||||
/// Terminated with a reason.
|
||||
Terminated(String),
|
||||
/// Send 200 OK and terminate.
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::audio_player::pcm_to_mix_frames;
|
||||
use kokoro_tts::{KokoroTts, Voice};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
pub const DEFAULT_MODEL_PATH: &str = ".nogit/tts/kokoro-v1.0.onnx";
|
||||
@@ -47,6 +48,10 @@ pub struct TtsEngine {
|
||||
/// Path that was used to load the current model (for cache invalidation).
|
||||
loaded_model_path: String,
|
||||
loaded_voices_path: String,
|
||||
/// On-disk TTS WAVs are cacheable only within a single engine lifetime.
|
||||
/// Every restart gets a new generation token, so prior process outputs are
|
||||
/// treated as stale and regenerated on first use.
|
||||
cache_generation: String,
|
||||
}
|
||||
|
||||
impl TtsEngine {
|
||||
@@ -55,6 +60,10 @@ impl TtsEngine {
|
||||
tts: None,
|
||||
loaded_model_path: String::new(),
|
||||
loaded_voices_path: String::new(),
|
||||
cache_generation: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos().to_string())
|
||||
.unwrap_or_else(|_| "0".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +237,7 @@ impl TtsEngine {
|
||||
return false;
|
||||
}
|
||||
match std::fs::read_to_string(&meta_path) {
|
||||
Ok(contents) => contents == Self::cache_key(text, voice),
|
||||
Ok(contents) => contents == self.cache_key(text, voice),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
@@ -236,12 +245,12 @@ impl TtsEngine {
|
||||
/// Write the sidecar `.meta` file next to the WAV.
|
||||
fn write_cache_meta(&self, output_path: &str, text: &str, voice: &str) {
|
||||
let meta_path = format!("{output_path}.meta");
|
||||
let _ = std::fs::write(&meta_path, Self::cache_key(text, voice));
|
||||
let _ = std::fs::write(&meta_path, self.cache_key(text, voice));
|
||||
}
|
||||
|
||||
/// Build the cache key from text + voice.
|
||||
fn cache_key(text: &str, voice: &str) -> String {
|
||||
format!("{}\0{}", text, voice)
|
||||
/// Build the cache key from process generation + text + voice.
|
||||
fn cache_key(&self, text: &str, voice: &str) -> String {
|
||||
format!("{}\0{}\0{}", self.cache_generation, text, voice)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
use md5::{Digest, Md5};
|
||||
use rand::Rng;
|
||||
|
||||
use crate::{Endpoint, SdpMediaKind};
|
||||
|
||||
// ---- ID generators ---------------------------------------------------------
|
||||
|
||||
/// Generate a random SIP Call-ID (32 hex chars).
|
||||
@@ -55,6 +57,9 @@ pub struct SdpOptions<'a> {
|
||||
pub ip: &'a str,
|
||||
pub port: u16,
|
||||
pub payload_types: &'a [u8],
|
||||
pub media_kind: SdpMediaKind,
|
||||
pub transport: &'a str,
|
||||
pub media_formats: &'a [&'a str],
|
||||
pub session_id: Option<&'a str>,
|
||||
pub session_name: Option<&'a str>,
|
||||
pub direction: Option<&'a str>,
|
||||
@@ -67,6 +72,9 @@ impl<'a> Default for SdpOptions<'a> {
|
||||
ip: "0.0.0.0",
|
||||
port: 0,
|
||||
payload_types: &[9, 0, 8, 101],
|
||||
media_kind: SdpMediaKind::Audio,
|
||||
transport: "RTP/AVP",
|
||||
media_formats: &[],
|
||||
session_id: None,
|
||||
session_name: None,
|
||||
direction: None,
|
||||
@@ -83,7 +91,14 @@ pub fn build_sdp(opts: &SdpOptions) -> String {
|
||||
.unwrap_or_else(|| format!("{}", rand::thread_rng().gen_range(0..1_000_000_000u64)));
|
||||
let session_name = opts.session_name.unwrap_or("-");
|
||||
let direction = opts.direction.unwrap_or("sendrecv");
|
||||
let pts: Vec<String> = opts.payload_types.iter().map(|pt| pt.to_string()).collect();
|
||||
let media_formats: Vec<String> = if !opts.media_formats.is_empty() {
|
||||
opts.media_formats
|
||||
.iter()
|
||||
.map(|fmt| fmt.to_string())
|
||||
.collect()
|
||||
} else {
|
||||
opts.payload_types.iter().map(|pt| pt.to_string()).collect()
|
||||
};
|
||||
|
||||
let mut lines = vec![
|
||||
"v=0".to_string(),
|
||||
@@ -91,9 +106,16 @@ pub fn build_sdp(opts: &SdpOptions) -> String {
|
||||
format!("s={session_name}"),
|
||||
format!("c=IN IP4 {}", opts.ip),
|
||||
"t=0 0".to_string(),
|
||||
format!("m=audio {} RTP/AVP {}", opts.port, pts.join(" ")),
|
||||
format!(
|
||||
"m={} {} {} {}",
|
||||
opts.media_kind.as_sdp_token(),
|
||||
opts.port,
|
||||
opts.transport,
|
||||
media_formats.join(" ")
|
||||
),
|
||||
];
|
||||
|
||||
if opts.media_kind == SdpMediaKind::Audio {
|
||||
for &pt in opts.payload_types {
|
||||
let name = codec_name(pt);
|
||||
if name != "unknown" {
|
||||
@@ -103,6 +125,7 @@ pub fn build_sdp(opts: &SdpOptions) -> String {
|
||||
lines.push("a=fmtp:101 0-16".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(format!("a={direction}"));
|
||||
for attr in opts.attributes {
|
||||
@@ -199,38 +222,62 @@ pub fn compute_digest_auth(
|
||||
|
||||
// ---- SDP parser ------------------------------------------------------------
|
||||
|
||||
use crate::Endpoint;
|
||||
|
||||
/// Parse the audio media port, connection address, and preferred codec from an SDP body.
|
||||
/// Parse the preferred media endpoint from an SDP body.
|
||||
///
|
||||
/// Audio `m=` lines are preferred when present so existing RTP call flows keep
|
||||
/// their current behavior. If no audio section exists, the first media section
|
||||
/// is returned, which allows T.38-only SDP offers/answers to be represented.
|
||||
pub fn parse_sdp_endpoint(sdp: &str) -> Option<Endpoint> {
|
||||
let mut addr: Option<&str> = None;
|
||||
let mut port: Option<u16> = None;
|
||||
let mut codec_pt: Option<u8> = None;
|
||||
let mut preferred: Option<(SdpMediaKind, u16, Option<u8>, String)> = None;
|
||||
let mut fallback: Option<(SdpMediaKind, u16, Option<u8>, String)> = None;
|
||||
|
||||
let normalized = sdp.replace("\r\n", "\n");
|
||||
for raw in normalized.split('\n') {
|
||||
let line = raw.trim();
|
||||
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
|
||||
addr = Some(rest.trim());
|
||||
} 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();
|
||||
if !parts.is_empty() {
|
||||
port = parts[0].parse().ok();
|
||||
} else if let Some(rest) = line.strip_prefix("m=") {
|
||||
// m=<media> <port> <transport> <fmt1> [<fmt2> ...]
|
||||
let mut media_and_rest = rest.splitn(2, ' ');
|
||||
let media = media_and_rest.next().unwrap_or("");
|
||||
let remainder = media_and_rest.next().unwrap_or("");
|
||||
let media_kind = SdpMediaKind::from_sdp_token(media);
|
||||
if media_kind == SdpMediaKind::Unknown {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = remainder.split_whitespace().collect();
|
||||
if !parts.is_empty() {
|
||||
if let Ok(port) = parts[0].parse() {
|
||||
let transport = parts.get(1).copied().unwrap_or("").to_string();
|
||||
let codec_pt = if media_kind == SdpMediaKind::Audio && parts.len() > 2 {
|
||||
parts[2].parse::<u8>().ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let candidate = (media_kind, port, codec_pt, transport);
|
||||
if fallback.is_none() {
|
||||
fallback = Some(candidate.clone());
|
||||
}
|
||||
if media_kind == SdpMediaKind::Audio {
|
||||
preferred = Some(candidate);
|
||||
} else if preferred.is_none() {
|
||||
preferred = Some(candidate);
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (addr, port) {
|
||||
(Some(a), Some(p)) => Some(Endpoint {
|
||||
match (addr, preferred.or(fallback)) {
|
||||
(Some(a), Some((media_kind, port, codec_pt, transport))) => Some(Endpoint {
|
||||
address: a.to_string(),
|
||||
port: p,
|
||||
port,
|
||||
codec_pt,
|
||||
media_kind,
|
||||
transport,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
@@ -327,6 +374,40 @@ mod tests {
|
||||
let ep = parse_sdp_endpoint(sdp).unwrap();
|
||||
assert_eq!(ep.address, "10.0.0.1");
|
||||
assert_eq!(ep.port, 5060);
|
||||
assert_eq!(ep.media_kind, SdpMediaKind::Audio);
|
||||
assert_eq!(ep.transport, "RTP/AVP");
|
||||
assert!(ep.is_audio_rtp());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_t38_sdp_endpoint() {
|
||||
let sdp = concat!(
|
||||
"v=0\r\n",
|
||||
"c=IN IP4 203.0.113.9\r\n",
|
||||
"m=image 4000 udptl t38\r\n",
|
||||
"a=T38FaxVersion:0\r\n",
|
||||
);
|
||||
let ep = parse_sdp_endpoint(sdp).unwrap();
|
||||
assert_eq!(ep.address, "203.0.113.9");
|
||||
assert_eq!(ep.port, 4000);
|
||||
assert_eq!(ep.media_kind, SdpMediaKind::Image);
|
||||
assert_eq!(ep.transport, "udptl");
|
||||
assert!(ep.is_t38_udptl());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_t38_sdp() {
|
||||
let sdp = build_sdp(&SdpOptions {
|
||||
ip: "192.168.1.1",
|
||||
port: 4000,
|
||||
media_kind: SdpMediaKind::Image,
|
||||
transport: "udptl",
|
||||
media_formats: &["t38"],
|
||||
attributes: &["T38FaxVersion:0"],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(sdp.contains("m=image 4000 udptl t38"));
|
||||
assert!(sdp.contains("a=T38FaxVersion:0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -16,4 +16,47 @@ pub struct Endpoint {
|
||||
pub port: u16,
|
||||
/// First payload type from the SDP `m=audio` line (the preferred codec).
|
||||
pub codec_pt: Option<u8>,
|
||||
/// SDP media kind from the `m=` line.
|
||||
pub media_kind: SdpMediaKind,
|
||||
/// SDP transport token from the `m=` line (e.g. `RTP/AVP`, `udptl`).
|
||||
pub transport: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SdpMediaKind {
|
||||
Audio,
|
||||
Image,
|
||||
Application,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl SdpMediaKind {
|
||||
pub fn as_sdp_token(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Audio => "audio",
|
||||
Self::Image => "image",
|
||||
Self::Application => "application",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_sdp_token(token: &str) -> Self {
|
||||
match token.to_ascii_lowercase().as_str() {
|
||||
"audio" => Self::Audio,
|
||||
"image" => Self::Image,
|
||||
"application" => Self::Application,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Endpoint {
|
||||
pub fn is_audio_rtp(&self) -> bool {
|
||||
self.media_kind == SdpMediaKind::Audio
|
||||
&& self.transport.to_ascii_uppercase().starts_with("RTP/")
|
||||
}
|
||||
|
||||
pub fn is_t38_udptl(&self) -> bool {
|
||||
self.media_kind == SdpMediaKind::Image && self.transport.eq_ignore_ascii_case("udptl")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Ported from ts/sip/rewrite.ts.
|
||||
|
||||
use crate::Endpoint;
|
||||
use crate::{Endpoint, SdpMediaKind};
|
||||
|
||||
/// Replaces the host:port in every `sip:` / `sips:` URI found in `value`.
|
||||
pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String {
|
||||
@@ -57,12 +57,12 @@ pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
/// Rewrites the connection address (`c=`) and audio media port (`m=audio`)
|
||||
/// in an SDP body. Returns the rewritten body together with the original
|
||||
/// endpoint that was replaced (if any).
|
||||
/// Rewrites the connection address (`c=`) and first supported media port
|
||||
/// (`m=audio`, `m=image`, `m=application`) in an SDP body. Returns the
|
||||
/// rewritten body together with the original endpoint that was replaced (if any).
|
||||
pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>) {
|
||||
let mut orig_addr: Option<String> = None;
|
||||
let mut orig_port: Option<u16> = None;
|
||||
let mut orig_media: Option<(SdpMediaKind, u16, String)> = None;
|
||||
|
||||
let lines: Vec<String> = body
|
||||
.replace("\r\n", "\n")
|
||||
@@ -71,10 +71,25 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>
|
||||
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
|
||||
orig_addr = Some(rest.trim().to_string());
|
||||
format!("c=IN IP4 {ip}")
|
||||
} else if line.starts_with("m=audio ") {
|
||||
} else if line.starts_with("m=audio ")
|
||||
|| line.starts_with("m=image ")
|
||||
|| line.starts_with("m=application ")
|
||||
{
|
||||
let parts: Vec<&str> = line.split(' ').collect();
|
||||
if parts.len() >= 2 {
|
||||
orig_port = parts[1].parse().ok();
|
||||
let media_kind = parts[0]
|
||||
.strip_prefix("m=")
|
||||
.map(SdpMediaKind::from_sdp_token)
|
||||
.unwrap_or(SdpMediaKind::Unknown);
|
||||
if orig_media.is_none() {
|
||||
orig_media = parts[1].parse().ok().map(|orig_port| {
|
||||
(
|
||||
media_kind,
|
||||
orig_port,
|
||||
parts.get(2).copied().unwrap_or("").to_string(),
|
||||
)
|
||||
});
|
||||
}
|
||||
let mut rebuilt = parts[0].to_string();
|
||||
rebuilt.push(' ');
|
||||
rebuilt.push_str(&port.to_string());
|
||||
@@ -91,11 +106,13 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>
|
||||
})
|
||||
.collect();
|
||||
|
||||
let original = match (orig_addr, orig_port) {
|
||||
(Some(a), Some(p)) => Some(Endpoint {
|
||||
let original = match (orig_addr, orig_media) {
|
||||
(Some(a), Some((media_kind, p, transport))) => Some(Endpoint {
|
||||
address: a,
|
||||
port: p,
|
||||
codec_pt: None,
|
||||
media_kind,
|
||||
transport,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
@@ -130,5 +147,19 @@ mod tests {
|
||||
let ep = orig.unwrap();
|
||||
assert_eq!(ep.address, "10.0.0.1");
|
||||
assert_eq!(ep.port, 5060);
|
||||
assert_eq!(ep.transport, "RTP/AVP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_t38_sdp() {
|
||||
let sdp = "v=0\r\nc=IN IP4 10.0.0.1\r\nm=image 5060 udptl t38\r\na=T38FaxVersion:0\r\n";
|
||||
let (rewritten, orig) = rewrite_sdp(sdp, "192.168.1.1", 4000);
|
||||
assert!(rewritten.contains("c=IN IP4 192.168.1.1"));
|
||||
assert!(rewritten.contains("m=image 4000 udptl t38"));
|
||||
let ep = orig.unwrap();
|
||||
assert_eq!(ep.address, "10.0.0.1");
|
||||
assert_eq!(ep.port, 5060);
|
||||
assert_eq!(ep.media_kind, SdpMediaKind::Image);
|
||||
assert_eq!(ep.transport, "udptl");
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"v":1}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "dfa3eda5e8c3f23f8b4c5d504acaebd6e7a45020",
|
||||
"dirty": true
|
||||
},
|
||||
"path_in_vcs": ""
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Ubuntu 专属依赖安装
|
||||
- name: Setup Ubuntu dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt install libasound2-dev
|
||||
|
||||
# 构建项目
|
||||
- name: Build
|
||||
run: cargo build -vv
|
||||
|
||||
# 运行测试
|
||||
- name: Run tests
|
||||
run: cargo test --workspace -vv
|
||||
@@ -0,0 +1,5 @@
|
||||
*.bin
|
||||
*.onnx
|
||||
Cargo.lock
|
||||
/target
|
||||
.idea
|
||||
Vendored
+116
@@ -0,0 +1,116 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2024"
|
||||
name = "kokoro-tts"
|
||||
version = "0.3.2"
|
||||
build = "build.rs"
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "用于Rust的轻量级AI离线语音合成器(Kokoro TTS),可轻松交叉编译到移动端"
|
||||
readme = "README.md"
|
||||
keywords = [
|
||||
"TTS",
|
||||
"Offline",
|
||||
"Lite",
|
||||
"AI",
|
||||
"Synthesizer",
|
||||
]
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/mzdk100/kokoro.git"
|
||||
|
||||
[features]
|
||||
use-cmudict = ["cmudict-fast"]
|
||||
|
||||
[lib]
|
||||
name = "kokoro_tts"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[example]]
|
||||
name = "synth_directly_v10"
|
||||
path = "examples/synth_directly_v10.rs"
|
||||
|
||||
[[example]]
|
||||
name = "synth_directly_v11"
|
||||
path = "examples/synth_directly_v11.rs"
|
||||
|
||||
[[example]]
|
||||
name = "synth_stream"
|
||||
path = "examples/synth_stream.rs"
|
||||
|
||||
[dependencies.bincode]
|
||||
version = "2.0"
|
||||
|
||||
[dependencies.chinese-number]
|
||||
version = "0.7.8"
|
||||
features = [
|
||||
"number-to-chinese",
|
||||
"chinese-to-number",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
[dependencies.cmudict-fast]
|
||||
version = "0.8.0"
|
||||
optional = true
|
||||
|
||||
[dependencies.futures]
|
||||
version = "0.3.31"
|
||||
|
||||
[dependencies.jieba-rs]
|
||||
version = "0.8.1"
|
||||
|
||||
[dependencies.log]
|
||||
version = "0.4.29"
|
||||
|
||||
[dependencies.ndarray]
|
||||
version = "0.17.2"
|
||||
|
||||
[dependencies.ort]
|
||||
version = "2.0.0-rc.11"
|
||||
|
||||
[dependencies.pin-project]
|
||||
version = "1.1.10"
|
||||
|
||||
[dependencies.pinyin]
|
||||
version = "0.11.0"
|
||||
|
||||
[dependencies.rand]
|
||||
version = "0.10.0-rc.7"
|
||||
|
||||
[dependencies.regex]
|
||||
version = "1.12.2"
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.49.0"
|
||||
features = [
|
||||
"fs",
|
||||
"rt-multi-thread",
|
||||
"time",
|
||||
"sync",
|
||||
]
|
||||
|
||||
[dev-dependencies.anyhow]
|
||||
version = "1.0.100"
|
||||
|
||||
[dev-dependencies.tokio]
|
||||
version = "1.49.0"
|
||||
features = ["macros"]
|
||||
|
||||
[dev-dependencies.voxudio]
|
||||
version = "0.5.7"
|
||||
features = ["device"]
|
||||
|
||||
[build-dependencies.cc]
|
||||
version = "1.2.53"
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "kokoro-tts"
|
||||
description = "用于Rust的轻量级AI离线语音合成器(Kokoro TTS),可轻松交叉编译到移动端"
|
||||
version = "0.3.2"
|
||||
edition = "2024"
|
||||
keywords = ["TTS", "Offline", "Lite", "AI", "Synthesizer"]
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/mzdk100/kokoro.git"
|
||||
readme = "README.md"
|
||||
|
||||
[features]
|
||||
use-cmudict = ["cmudict-fast"]
|
||||
|
||||
[dependencies]
|
||||
bincode = "2.0"
|
||||
chinese-number = { version = "0.7.8",default-features = false,features = ["number-to-chinese", "chinese-to-number"] }
|
||||
cmudict-fast = { version = "0.8.0", optional = true }
|
||||
futures = "0.3.31"
|
||||
jieba-rs = "0.8.1"
|
||||
log = "0.4.29"
|
||||
ndarray = "0.17.2"
|
||||
ort = "2.0.0-rc.11"
|
||||
pin-project = "1.1.10"
|
||||
pinyin = "0.11.0"
|
||||
rand="0.10.0-rc.7"
|
||||
regex = "1.12.2"
|
||||
tokio = { version = "1.49.0",features = ["fs", "rt-multi-thread","time", "sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1.0.100"
|
||||
tokio = {version = "1.49.0",features = ["macros"]}
|
||||
voxudio = { version = "0.5.7",features = ["device"] }
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1.2.53"
|
||||
Vendored
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Vendored
+59
@@ -0,0 +1,59 @@
|
||||
# Kokoro TTS的rust推理实现
|
||||
|
||||
[Kokoro](https://github.com/hexgrad/kokoro)
|
||||
|
||||
> **Kokoro**是具有8200万参数的开放式TTS型号。
|
||||
> 尽管具有轻巧的体系结构,但它的质量与大型型号相当,同时更快,更具成本效益。使用Apache许可的权重,可以将Kokoro部署从生产环境到个人项目的任何地方。
|
||||
|
||||
|
||||
## 概述
|
||||
|
||||
本项目包含幾个示例脚本,展示了如何使用Kokoro库进行语音合成。这些示例展示了如何直接合成语音和通过流式合成来处理更长的文本。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Rust编程语言
|
||||
- Tokio异步运行时
|
||||
- Rodio音频处理和播放的库(可选)
|
||||
- 下载模型资源,在這裡可以找到[1.0模型](https://github.com/mzdk100/kokoro/releases/tag/V1.0)和[1.1模型](https://github.com/mzdk100/kokoro/releases/tag/V1.1)
|
||||
|
||||
## 特点
|
||||
- 跨平台,可以轻松在Windows、Mac OS上构建,也可以轻松交叉编译到安卓和iOS。
|
||||
- 离线推理,不依赖网络。
|
||||
- 足够轻量级,有不同尺寸的模型可以选择(最小的模型仅88M)。
|
||||
- 发音人多样化,跨越多国语言。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 运行示例,克隆或下载本项目到本地。在项目根目录下运行:
|
||||
```shell
|
||||
cargo run --example synth_directly_v10
|
||||
cargo run --example synth_directly_v11
|
||||
```
|
||||
2. 集成到自己的项目中:
|
||||
```shell
|
||||
cargo add kokoro-tts
|
||||
```
|
||||
3. Linux依赖项
|
||||
```shell
|
||||
sudo apt install libasound2-dev
|
||||
```
|
||||
参考[examples](examples)文件夹中的示例代码进行开发。
|
||||
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用Apache-2.0许可证。请查看项目中的LICENSE文件了解更多信息。
|
||||
|
||||
## 注意
|
||||
|
||||
- 请确保在运行示例之前已经正确加载了模型和语音数据。
|
||||
- 示例中的语音合成参数(如语音名称、文本内容、速度等)仅作为示例,实际使用时请根据需要进行调整。
|
||||
|
||||
## 贡献
|
||||
|
||||
如果您有任何改进意见或想要贡献代码,请随时提交Pull Request或创建Issue。
|
||||
|
||||
## 免责声明
|
||||
|
||||
本项目中的示例代码仅用于演示目的。在使用本项目中的代码时,请确保遵守相关法律法规和社会主义核心价值观。开发者不对因使用本项目中的代码而导致的任何后果负责。
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
const SRC: &str = "src/transcription/en_ipa.c";
|
||||
cc::Build::new().file(SRC).compile("es");
|
||||
println!("cargo:rerun-if-changed={}", SRC);
|
||||
}
|
||||
+135010
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
+411980
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
use {
|
||||
kokoro_tts::{KokoroTts, Voice},
|
||||
voxudio::AudioPlayer,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let tts = KokoroTts::new("kokoro-v1.0.int8.onnx", "voices.bin").await?;
|
||||
let (audio, took) = tts
|
||||
.synth(
|
||||
"Hello, world!你好,我们是一群追逐梦想的人。我正在使用qq。",
|
||||
Voice::ZfXiaoxiao(1.2),
|
||||
)
|
||||
.await?;
|
||||
println!("Synth took: {:?}", took);
|
||||
let mut player = AudioPlayer::new()?;
|
||||
player.play()?;
|
||||
player.write::<24000>(&audio, 1).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use {
|
||||
kokoro_tts::{KokoroTts, Voice},
|
||||
voxudio::AudioPlayer,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let tts = KokoroTts::new("kokoro-v1.1-zh.onnx", "voices-v1.1-zh.bin").await?;
|
||||
let (audio, took) = tts
|
||||
.synth(
|
||||
"Hello, world!你好,我们是一群追逐梦想的人。我正在使用qq。",
|
||||
Voice::Zm045(1),
|
||||
)
|
||||
.await?;
|
||||
println!("Synth took: {:?}", took);
|
||||
let mut player = AudioPlayer::new()?;
|
||||
player.play()?;
|
||||
player.write::<24000>(&audio, 1).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
use {
|
||||
futures::StreamExt,
|
||||
kokoro_tts::{KokoroTts, Voice},
|
||||
voxudio::AudioPlayer,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let tts = KokoroTts::new("kokoro-v1.1-zh.onnx", "voices-v1.1-zh.bin").await?;
|
||||
let (mut sink, mut stream) = tts.stream(Voice::Zm098(1));
|
||||
sink.synth("hello world.").await?;
|
||||
sink.synth("你好,我们是一群追逐梦想的人。").await?;
|
||||
sink.set_voice(Voice::Zf032(2));
|
||||
sink.synth("我正在使用qq。").await?;
|
||||
sink.set_voice(Voice::Zf090(3));
|
||||
sink.synth("今天天气如何?").await?;
|
||||
sink.set_voice(Voice::Zm045(1));
|
||||
sink.synth("你在使用Rust编程语言吗?").await?;
|
||||
sink.set_voice(Voice::Zf039(1));
|
||||
sink.synth(
|
||||
"你轻轻地走过那
|
||||
在风雨花丛中
|
||||
每一点一滴带走
|
||||
是我醒来的梦
|
||||
是在那天空上
|
||||
最美丽的云朵
|
||||
在那彩虹 最温柔的风",
|
||||
)
|
||||
.await?;
|
||||
sink.set_voice(Voice::Zf088(1));
|
||||
sink.synth(
|
||||
"你静静看着我们
|
||||
最不舍的面容
|
||||
像流星划过夜空
|
||||
转瞬即逝的梦
|
||||
是最深情的脸 在这一瞬间
|
||||
在遥远天边
|
||||
",
|
||||
)
|
||||
.await?;
|
||||
drop(sink);
|
||||
|
||||
let mut player = AudioPlayer::new()?;
|
||||
player.play()?;
|
||||
while let Some((audio, took)) = stream.next().await {
|
||||
player.write::<24000>(&audio, 1).await?;
|
||||
println!("Synth took: {:?}", took);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Vendored
+514
@@ -0,0 +1,514 @@
|
||||
import re
|
||||
from typing import List, Optional, Tuple
|
||||
from jieba import posseg, cut_for_search
|
||||
from pypinyin import lazy_pinyin, load_phrases_dict, Style
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class MToken:
|
||||
tag: str
|
||||
whitespace: str
|
||||
phonemes: Optional[str] = None
|
||||
|
||||
ZH_MAP = {"b":"ㄅ","p":"ㄆ","m":"ㄇ","f":"ㄈ","d":"ㄉ","t":"ㄊ","n":"ㄋ","l":"ㄌ","g":"ㄍ","k":"ㄎ","h":"ㄏ","j":"ㄐ","q":"ㄑ","x":"ㄒ","zh":"ㄓ","ch":"ㄔ","sh":"ㄕ","r":"ㄖ","z":"ㄗ","c":"ㄘ","s":"ㄙ","a":"ㄚ","o":"ㄛ","e":"ㄜ","ie":"ㄝ","ai":"ㄞ","ei":"ㄟ","ao":"ㄠ","ou":"ㄡ","an":"ㄢ","en":"ㄣ","ang":"ㄤ","eng":"ㄥ","er":"ㄦ","i":"ㄧ","u":"ㄨ","v":"ㄩ","ii":"ㄭ","iii":"十","ve":"月","ia":"压","ian":"言","iang":"阳","iao":"要","in":"阴","ing":"应","iong":"用","iou":"又","ong":"中","ua":"穵","uai":"外","uan":"万","uang":"王","uei":"为","uen":"文","ueng":"瓮","uo":"我","van":"元","vn":"云"}
|
||||
for p in ';:,.!?/—…"()“” 12345R':
|
||||
assert p not in ZH_MAP, p
|
||||
ZH_MAP[p] = p
|
||||
|
||||
unk = '❓'
|
||||
punc = frozenset(';:,.!?—…"()“”')
|
||||
phrases_dict = {
|
||||
'开户行': [['ka1i'], ['hu4'], ['hang2']],
|
||||
'发卡行': [['fa4'], ['ka3'], ['hang2']],
|
||||
'放款行': [['fa4ng'], ['kua3n'], ['hang2']],
|
||||
'茧行': [['jia3n'], ['hang2']],
|
||||
'行号': [['hang2'], ['ha4o']],
|
||||
'各地': [['ge4'], ['di4']],
|
||||
'借还款': [['jie4'], ['hua2n'], ['kua3n']],
|
||||
'时间为': [['shi2'], ['jia1n'], ['we2i']],
|
||||
'为准': [['we2i'], ['zhu3n']],
|
||||
'色差': [['se4'], ['cha1']],
|
||||
'嗲': [['dia3']],
|
||||
'呗': [['bei5']],
|
||||
'不': [['bu4']],
|
||||
'咗': [['zuo5']],
|
||||
'嘞': [['lei5']],
|
||||
'掺和': [['chan1'], ['huo5']]
|
||||
}
|
||||
must_erhua = {
|
||||
"小院儿", "胡同儿", "范儿", "老汉儿", "撒欢儿", "寻老礼儿", "妥妥儿", "媳妇儿"
|
||||
}
|
||||
must_not_neural_tone_words = {
|
||||
'男子', '女子', '分子', '原子', '量子', '莲子', '石子', '瓜子', '电子', '人人', '虎虎',
|
||||
'幺幺', '干嘛', '学子', '哈哈', '数数', '袅袅', '局地', '以下', '娃哈哈', '花花草草', '留得',
|
||||
'耕地', '想想', '熙熙', '攘攘', '卵子', '死死', '冉冉', '恳恳', '佼佼', '吵吵', '打打',
|
||||
'考考', '整整', '莘莘', '落地', '算子', '家家户户', '青青'
|
||||
}
|
||||
must_neural_tone_words = {
|
||||
'麻烦', '麻利', '鸳鸯', '高粱', '骨头', '骆驼', '马虎', '首饰', '馒头', '馄饨', '风筝',
|
||||
'难为', '队伍', '阔气', '闺女', '门道', '锄头', '铺盖', '铃铛', '铁匠', '钥匙', '里脊',
|
||||
'里头', '部分', '那么', '道士', '造化', '迷糊', '连累', '这么', '这个', '运气', '过去',
|
||||
'软和', '转悠', '踏实', '跳蚤', '跟头', '趔趄', '财主', '豆腐', '讲究', '记性', '记号',
|
||||
'认识', '规矩', '见识', '裁缝', '补丁', '衣裳', '衣服', '衙门', '街坊', '行李', '行当',
|
||||
'蛤蟆', '蘑菇', '薄荷', '葫芦', '葡萄', '萝卜', '荸荠', '苗条', '苗头', '苍蝇', '芝麻',
|
||||
'舒服', '舒坦', '舌头', '自在', '膏药', '脾气', '脑袋', '脊梁', '能耐', '胳膊', '胭脂',
|
||||
'胡萝', '胡琴', '胡同', '聪明', '耽误', '耽搁', '耷拉', '耳朵', '老爷', '老实', '老婆',
|
||||
'戏弄', '将军', '翻腾', '罗嗦', '罐头', '编辑', '结实', '红火', '累赘', '糨糊', '糊涂',
|
||||
'精神', '粮食', '簸箕', '篱笆', '算计', '算盘', '答应', '笤帚', '笑语', '笑话', '窟窿',
|
||||
'窝囊', '窗户', '稳当', '稀罕', '称呼', '秧歌', '秀气', '秀才', '福气', '祖宗', '砚台',
|
||||
'码头', '石榴', '石头', '石匠', '知识', '眼睛', '眯缝', '眨巴', '眉毛', '相声', '盘算',
|
||||
'白净', '痢疾', '痛快', '疟疾', '疙瘩', '疏忽', '畜生', '生意', '甘蔗', '琵琶', '琢磨',
|
||||
'琉璃', '玻璃', '玫瑰', '玄乎', '狐狸', '状元', '特务', '牲口', '牙碜', '牌楼', '爽快',
|
||||
'爱人', '热闹', '烧饼', '烟筒', '烂糊', '点心', '炊帚', '灯笼', '火候', '漂亮', '滑溜',
|
||||
'溜达', '温和', '清楚', '消息', '浪头', '活泼', '比方', '正经', '欺负', '模糊', '槟榔',
|
||||
'棺材', '棒槌', '棉花', '核桃', '栅栏', '柴火', '架势', '枕头', '枇杷', '机灵', '本事',
|
||||
'木头', '木匠', '朋友', '月饼', '月亮', '暖和', '明白', '时候', '新鲜', '故事', '收拾',
|
||||
'收成', '提防', '挖苦', '挑剔', '指甲', '指头', '拾掇', '拳头', '拨弄', '招牌', '招呼',
|
||||
'抬举', '护士', '折腾', '扫帚', '打量', '打算', '打扮', '打听', '打发', '扎实', '扁担',
|
||||
'戒指', '懒得', '意识', '意思', '悟性', '怪物', '思量', '怎么', '念头', '念叨', '别人',
|
||||
'快活', '忙活', '志气', '心思', '得罪', '张罗', '弟兄', '开通', '应酬', '庄稼', '干事',
|
||||
'帮手', '帐篷', '希罕', '师父', '师傅', '巴结', '巴掌', '差事', '工夫', '岁数', '屁股',
|
||||
'尾巴', '少爷', '小气', '小伙', '将就', '对头', '对付', '寡妇', '家伙', '客气', '实在',
|
||||
'官司', '学问', '字号', '嫁妆', '媳妇', '媒人', '婆家', '娘家', '委屈', '姑娘', '姐夫',
|
||||
'妯娌', '妥当', '妖精', '奴才', '女婿', '头发', '太阳', '大爷', '大方', '大意', '大夫',
|
||||
'多少', '多么', '外甥', '壮实', '地道', '地方', '在乎', '困难', '嘴巴', '嘱咐', '嘟囔',
|
||||
'嘀咕', '喜欢', '喇嘛', '喇叭', '商量', '唾沫', '哑巴', '哈欠', '哆嗦', '咳嗽', '和尚',
|
||||
'告诉', '告示', '含糊', '吓唬', '后头', '名字', '名堂', '合同', '吆喝', '叫唤', '口袋',
|
||||
'厚道', '厉害', '千斤', '包袱', '包涵', '匀称', '勤快', '动静', '动弹', '功夫', '力气',
|
||||
'前头', '刺猬', '刺激', '别扭', '利落', '利索', '利害', '分析', '出息', '凑合', '凉快',
|
||||
'冷战', '冤枉', '冒失', '养活', '关系', '先生', '兄弟', '便宜', '使唤', '佩服', '作坊',
|
||||
'体面', '位置', '似的', '伙计', '休息', '什么', '人家', '亲戚', '亲家', '交情', '云彩',
|
||||
'事情', '买卖', '主意', '丫头', '丧气', '两口', '东西', '东家', '世故', '不由', '下水',
|
||||
'下巴', '上头', '上司', '丈夫', '丈人', '一辈', '那个', '菩萨', '父亲', '母亲', '咕噜',
|
||||
'邋遢', '费用', '冤家', '甜头', '介绍', '荒唐', '大人', '泥鳅', '幸福', '熟悉', '计划',
|
||||
'扑腾', '蜡烛', '姥爷', '照顾', '喉咙', '吉他', '弄堂', '蚂蚱', '凤凰', '拖沓', '寒碜',
|
||||
'糟蹋', '倒腾', '报复', '逻辑', '盘缠', '喽啰', '牢骚', '咖喱', '扫把', '惦记'
|
||||
}
|
||||
not_erhua = {
|
||||
"虐儿", "为儿", "护儿", "瞒儿", "救儿", "替儿", "有儿", "一儿", "我儿", "俺儿", "妻儿",
|
||||
"拐儿", "聋儿", "乞儿", "患儿", "幼儿", "孤儿", "婴儿", "婴幼儿", "连体儿", "脑瘫儿",
|
||||
"流浪儿", "体弱儿", "混血儿", "蜜雪儿", "舫儿", "祖儿", "美儿", "应采儿", "可儿", "侄儿",
|
||||
"孙儿", "侄孙儿", "女儿", "男儿", "红孩儿", "花儿", "虫儿", "马儿", "鸟儿", "猪儿", "猫儿",
|
||||
"狗儿", "少儿"
|
||||
}
|
||||
BU = '不'
|
||||
YI = '一'
|
||||
X_ENG = frozenset(['x', 'eng'])
|
||||
|
||||
# g2p
|
||||
load_phrases_dict(phrases_dict)
|
||||
|
||||
def get_initials_finals(word: str) -> Tuple[List[str], List[str]]:
|
||||
"""
|
||||
Get word initial and final by pypinyin or g2pM
|
||||
"""
|
||||
initials = []
|
||||
finals = []
|
||||
orig_initials = lazy_pinyin(word, neutral_tone_with_five=True, style=Style.INITIALS)
|
||||
orig_finals = lazy_pinyin(word, neutral_tone_with_five=True, style=Style.FINALS_TONE3)
|
||||
print(orig_initials, orig_finals)
|
||||
# after pypinyin==0.44.0, '嗯' need to be n2, cause the initial and final consonants cannot be empty at the same time
|
||||
en_index = [index for index, c in enumerate(word) if c == "嗯"]
|
||||
for i in en_index:
|
||||
orig_finals[i] = "n2"
|
||||
|
||||
for c, v in zip(orig_initials, orig_finals):
|
||||
if re.match(r'i\d', v):
|
||||
if c in ['z', 'c', 's']:
|
||||
# zi, ci, si
|
||||
v = re.sub('i', 'ii', v)
|
||||
elif c in ['zh', 'ch', 'sh', 'r']:
|
||||
# zhi, chi, shi
|
||||
v = re.sub('i', 'iii', v)
|
||||
initials.append(c)
|
||||
finals.append(v)
|
||||
|
||||
return initials, finals
|
||||
|
||||
def merge_erhua(initials: List[str], finals: List[str], word: str, pos: str) -> Tuple[List[str], List[str]]:
|
||||
"""
|
||||
Do erhub.
|
||||
"""
|
||||
# fix er1
|
||||
for i, phn in enumerate(finals):
|
||||
if i == len(finals) - 1 and word[i] == "儿" and phn == 'er1':
|
||||
finals[i] = 'er2'
|
||||
|
||||
# 发音
|
||||
if word not in must_erhua and (word in not_erhua or pos in {"a", "j", "nr"}):
|
||||
return initials, finals
|
||||
|
||||
# "……" 等情况直接返回
|
||||
if len(finals) != len(word):
|
||||
return initials, finals
|
||||
|
||||
assert len(finals) == len(word)
|
||||
|
||||
# 不发音
|
||||
new_initials = []
|
||||
new_finals = []
|
||||
for i, phn in enumerate(finals):
|
||||
if i == len(finals) - 1 and word[i] == "儿" and phn in {"er2", "er5"} and word[-2:] not in not_erhua and new_finals:
|
||||
new_finals[-1] = new_finals[-1][:-1] + "R" + new_finals[-1][-1]
|
||||
else:
|
||||
new_initials.append(initials[i])
|
||||
new_finals.append(phn)
|
||||
|
||||
return new_initials, new_finals
|
||||
|
||||
# merge "不" and the word behind it
|
||||
# if don't merge, "不" sometimes appears alone according to jieba, which may occur sandhi error
|
||||
def merge_bu(seg: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
|
||||
new_seg = []
|
||||
for i, (word, pos) in enumerate(seg):
|
||||
if pos not in X_ENG:
|
||||
last_word = None
|
||||
if i > 0:
|
||||
last_word, _ = seg[i - 1]
|
||||
if last_word == BU:
|
||||
word = last_word + word
|
||||
next_pos = None
|
||||
if i + 1 < len(seg):
|
||||
_, next_pos = seg[i + 1]
|
||||
if word != BU or next_pos is None or next_pos in X_ENG:
|
||||
new_seg.append((word, pos))
|
||||
return new_seg
|
||||
|
||||
# function 1: merge "一" and reduplication words in it's left and right, e.g. "听","一","听" ->"听一听"
|
||||
# function 2: merge single "一" and the word behind it
|
||||
# if don't merge, "一" sometimes appears alone according to jieba, which may occur sandhi error
|
||||
# e.g.
|
||||
# input seg: [('听', 'v'), ('一', 'm'), ('听', 'v')]
|
||||
# output seg: [['听一听', 'v']]
|
||||
def merge_yi(seg: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
|
||||
new_seg = []
|
||||
skip_next = False
|
||||
# function 1
|
||||
for i, (word, pos) in enumerate(seg):
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if i - 1 >= 0 and word == YI and i + 1 < len(seg) and seg[i - 1][0] == seg[i + 1][0] and seg[i - 1][1] == "v" and seg[i + 1][1] not in X_ENG:
|
||||
new_seg[-1] = (new_seg[-1][0] + YI + seg[i + 1][0], new_seg[-1][1])
|
||||
skip_next = True
|
||||
else:
|
||||
new_seg.append((word, pos))
|
||||
seg = new_seg
|
||||
new_seg = []
|
||||
# function 2
|
||||
for i, (word, pos) in enumerate(seg):
|
||||
if new_seg and new_seg[-1][0] == YI and pos not in X_ENG:
|
||||
new_seg[-1] = (new_seg[-1][0] + word, new_seg[-1][1])
|
||||
else:
|
||||
new_seg.append((word, pos))
|
||||
return new_seg
|
||||
|
||||
def merge_reduplication(seg: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
|
||||
new_seg = []
|
||||
for i, (word, pos) in enumerate(seg):
|
||||
if new_seg and word == new_seg[-1][0] and pos not in X_ENG:
|
||||
new_seg[-1][0] = new_seg[-1][0] + seg[i][0]
|
||||
else:
|
||||
new_seg.append([word, pos])
|
||||
return new_seg
|
||||
|
||||
def is_reduplication(word: str) -> bool:
|
||||
return len(word) == 2 and word[0] == word[1]
|
||||
|
||||
# the first and the second words are all_tone_three
|
||||
def merge_continuous_three_tones(seg: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
|
||||
new_seg = []
|
||||
sub_finals_list = []
|
||||
for (word, pos) in seg:
|
||||
if pos in X_ENG:
|
||||
sub_finals_list.append(['0'])
|
||||
continue
|
||||
orig_finals = lazy_pinyin(word, neutral_tone_with_five=True, style=Style.FINALS_TONE3)
|
||||
# after pypinyin==0.44.0, '嗯' need to be n2, cause the initial and final consonants cannot be empty at the same time
|
||||
en_index = [index for index, c in enumerate(word) if c == "嗯"]
|
||||
for i in en_index:
|
||||
orig_finals[i] = "n2"
|
||||
sub_finals_list.append(orig_finals)
|
||||
|
||||
assert len(sub_finals_list) == len(seg)
|
||||
merge_last = [False] * len(seg)
|
||||
for i, (word, pos) in enumerate(seg):
|
||||
if pos not in X_ENG and i - 1 >= 0 and all_tone_three(sub_finals_list[i - 1]) and all_tone_three(sub_finals_list[i]) and not merge_last[i - 1]:
|
||||
# if the last word is reduplication, not merge, because reduplication need to be _neural_sandhi
|
||||
if not is_reduplication(seg[i - 1][0]) and len(seg[i - 1][0]) + len(seg[i][0]) <= 3:
|
||||
new_seg[-1][0] = new_seg[-1][0] + seg[i][0]
|
||||
merge_last[i] = True
|
||||
else:
|
||||
new_seg.append([word, pos])
|
||||
else:
|
||||
new_seg.append([word, pos])
|
||||
|
||||
return new_seg
|
||||
|
||||
# the last char of first word and the first char of second word is tone_three
|
||||
def merge_continuous_three_tones_2(seg: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
|
||||
new_seg = []
|
||||
sub_finals_list = []
|
||||
for (word, pos) in seg:
|
||||
if pos in X_ENG:
|
||||
sub_finals_list.append(['0'])
|
||||
continue
|
||||
orig_finals = lazy_pinyin(
|
||||
word, neutral_tone_with_five=True, style=Style.FINALS_TONE3)
|
||||
# after pypinyin==0.44.0, '嗯' need to be n2, cause the initial and final consonants cannot be empty at the same time
|
||||
en_index = [index for index, c in enumerate(word) if c == "嗯"]
|
||||
for i in en_index:
|
||||
orig_finals[i] = "n2"
|
||||
sub_finals_list.append(orig_finals)
|
||||
assert len(sub_finals_list) == len(seg)
|
||||
merge_last = [False] * len(seg)
|
||||
for i, (word, pos) in enumerate(seg):
|
||||
if pos not in X_ENG and i - 1 >= 0 and sub_finals_list[i - 1][-1][-1] == "3" and sub_finals_list[i][0][-1] == "3" and not merge_last[i - 1]:
|
||||
# if the last word is reduplication, not merge, because reduplication need to be _neural_sandhi
|
||||
if not is_reduplication(seg[i - 1][0]) and len(seg[i - 1][0]) + len(seg[i][0]) <= 3:
|
||||
new_seg[-1][0] = new_seg[-1][0] + seg[i][0]
|
||||
merge_last[i] = True
|
||||
else:
|
||||
new_seg.append([word, pos])
|
||||
else:
|
||||
new_seg.append([word, pos])
|
||||
return new_seg
|
||||
|
||||
def merge_er(seg: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
|
||||
new_seg = []
|
||||
for i, (word, pos) in enumerate(seg):
|
||||
if i - 1 >= 0 and word == "儿" and new_seg[-1][1] not in X_ENG:
|
||||
new_seg[-1][0] = new_seg[-1][0] + seg[i][0]
|
||||
else:
|
||||
new_seg.append([word, pos])
|
||||
return new_seg
|
||||
|
||||
def pre_merge_for_modify(seg: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
seg: [(word, pos), ...]
|
||||
"""
|
||||
seg = merge_bu(seg)
|
||||
seg = merge_yi(seg)
|
||||
seg = merge_reduplication(seg)
|
||||
seg = merge_continuous_three_tones(seg)
|
||||
seg = merge_continuous_three_tones_2(seg)
|
||||
return merge_er(seg)
|
||||
|
||||
def bu_sandhi(word: str, finals: List[str]) -> List[str]:
|
||||
# e.g. 看不懂
|
||||
if len(word) == 3 and word[1] == BU:
|
||||
finals[1] = finals[1][:-1] + "5"
|
||||
else:
|
||||
for i, char in enumerate(word):
|
||||
# "不" before tone4 should be bu2, e.g. 不怕
|
||||
if char == BU and i + 1 < len(word) and finals[i + 1][-1] == "4":
|
||||
finals[i] = finals[i][:-1] + "2"
|
||||
return finals
|
||||
|
||||
def yi_sandhi(word: str, finals: List[str]) -> List[str]:
|
||||
# "一" in number sequences, e.g. 一零零, 二一零
|
||||
if word.find(YI) != -1 and all(
|
||||
[item.isnumeric() for item in word if item != YI]):
|
||||
return finals
|
||||
# "一" between reduplication words shold be yi5, e.g. 看一看
|
||||
elif len(word) == 3 and word[1] == YI and word[0] == word[-1]:
|
||||
finals[1] = finals[1][:-1] + "5"
|
||||
# when "一" is ordinal word, it should be yi1
|
||||
elif word.startswith("第一"):
|
||||
finals[1] = finals[1][:-1] + "1"
|
||||
else:
|
||||
for i, char in enumerate(word):
|
||||
if char == YI and i + 1 < len(word):
|
||||
# "一" before tone4 should be yi2, e.g. 一段
|
||||
if finals[i + 1][-1] in {'4', '5'}:
|
||||
finals[i] = finals[i][:-1] + "2"
|
||||
# "一" before non-tone4 should be yi4, e.g. 一天
|
||||
else:
|
||||
# "一" 后面如果是标点,还读一声
|
||||
if word[i + 1] not in punc:
|
||||
finals[i] = finals[i][:-1] + "4"
|
||||
return finals
|
||||
|
||||
def split_word(word: str) -> List[str]:
|
||||
word_list = cut_for_search(word)
|
||||
word_list = sorted(word_list, key=lambda i: len(i), reverse=False)
|
||||
first_subword = word_list[0]
|
||||
first_begin_idx = word.find(first_subword)
|
||||
if first_begin_idx == 0:
|
||||
second_subword = word[len(first_subword):]
|
||||
new_word_list = [first_subword, second_subword]
|
||||
else:
|
||||
second_subword = word[:-len(first_subword)]
|
||||
new_word_list = [second_subword, first_subword]
|
||||
return new_word_list
|
||||
|
||||
# the meaning of jieba pos tag: https://blog.csdn.net/weixin_44174352/article/details/113731041
|
||||
# e.g.
|
||||
# word: "家里"
|
||||
# pos: "s"
|
||||
# finals: ['ia1', 'i3']
|
||||
def neural_sandhi(word: str, pos: str, finals: List[str]) -> List[str]:
|
||||
if word in must_not_neural_tone_words:
|
||||
return finals
|
||||
# reduplication words for n. and v. e.g. 奶奶, 试试, 旺旺
|
||||
for j, item in enumerate(word):
|
||||
if j - 1 >= 0 and item == word[j - 1] and pos[0] in {"n", "v", "a"}:
|
||||
finals[j] = finals[j][:-1] + "5"
|
||||
ge_idx = word.find("个")
|
||||
if len(word) >= 1 and word[-1] in "吧呢啊呐噻嘛吖嗨呐哦哒滴哩哟喽啰耶喔诶":
|
||||
finals[-1] = finals[-1][:-1] + "5"
|
||||
elif len(word) >= 1 and word[-1] in "的地得":
|
||||
finals[-1] = finals[-1][:-1] + "5"
|
||||
# e.g. 走了, 看着, 去过
|
||||
elif len(word) == 1 and word in "了着过" and pos in {"ul", "uz", "ug"}:
|
||||
finals[-1] = finals[-1][:-1] + "5"
|
||||
elif len(word) > 1 and word[-1] in "们子" and pos in {"r", "n"}:
|
||||
finals[-1] = finals[-1][:-1] + "5"
|
||||
# e.g. 桌上, 地下
|
||||
elif len(word) > 1 and word[-1] in "上下" and pos in {"s", "l", "f"}:
|
||||
finals[-1] = finals[-1][:-1] + "5"
|
||||
# e.g. 上来, 下去
|
||||
elif len(word) > 1 and word[-1] in "来去" and word[-2] in "上下进出回过起开":
|
||||
finals[-1] = finals[-1][:-1] + "5"
|
||||
# 个做量词
|
||||
elif (ge_idx >= 1 and (word[ge_idx - 1].isnumeric() or word[ge_idx - 1] in "几有两半多各整每做是")) or word == '个':
|
||||
finals[ge_idx] = finals[ge_idx][:-1] + "5"
|
||||
else:
|
||||
if word in must_neural_tone_words or word[-2:] in must_neural_tone_words:
|
||||
finals[-1] = finals[-1][:-1] + "5"
|
||||
|
||||
word_list = split_word(word)
|
||||
finals_list = [finals[:len(word_list[0])], finals[len(word_list[0]):]]
|
||||
for i, word in enumerate(word_list):
|
||||
# conventional neural in Chinese
|
||||
if word in must_neural_tone_words or word[-2:] in must_neural_tone_words:
|
||||
finals_list[i][-1] = finals_list[i][-1][:-1] + "5"
|
||||
finals = sum(finals_list, [])
|
||||
return finals
|
||||
|
||||
def all_tone_three(finals: List[str]) -> bool:
|
||||
return all(x[-1] == "3" for x in finals)
|
||||
|
||||
def three_sandhi(word: str, finals: List[str]) -> List[str]:
|
||||
if len(word) == 2 and all_tone_three(finals):
|
||||
finals[0] = finals[0][:-1] + "2"
|
||||
elif len(word) == 3:
|
||||
word_list = split_word(word)
|
||||
if all_tone_three(finals):
|
||||
# disyllabic + monosyllabic, e.g. 蒙古/包
|
||||
if len(word_list[0]) == 2:
|
||||
finals[0] = finals[0][:-1] + "2"
|
||||
finals[1] = finals[1][:-1] + "2"
|
||||
# monosyllabic + disyllabic, e.g. 纸/老虎
|
||||
elif len(word_list[0]) == 1:
|
||||
finals[1] = finals[1][:-1] + "2"
|
||||
else:
|
||||
finals_list = [finals[:len(word_list[0])], finals[len(word_list[0]):]]
|
||||
if len(finals_list) == 2:
|
||||
for i, sub in enumerate(finals_list):
|
||||
# e.g. 所有/人
|
||||
if all_tone_three(sub) and len(sub) == 2:
|
||||
finals_list[i][0] = finals_list[i][0][:-1] + "2"
|
||||
# e.g. 好/喜欢
|
||||
elif i == 1 and not all_tone_three(sub) and finals_list[i][0][-1] == "3" and finals_list[0][-1][-1] == "3":
|
||||
finals_list[0][-1] = finals_list[0][-1][:-1] + "2"
|
||||
finals = sum(finals_list, [])
|
||||
# split idiom into two words who's length is 2
|
||||
elif len(word) == 4:
|
||||
finals_list = [finals[:2], finals[2:]]
|
||||
finals = []
|
||||
for sub in finals_list:
|
||||
if all_tone_three(sub):
|
||||
sub[0] = sub[0][:-1] + "2"
|
||||
finals += sub
|
||||
|
||||
return finals
|
||||
|
||||
def modified_tone(word: str, pos: str, finals: List[str]) -> List[str]:
|
||||
"""
|
||||
word: 分词
|
||||
pos: 词性
|
||||
finals: 带调韵母, [final1, ..., finaln]
|
||||
"""
|
||||
finals = bu_sandhi(word, finals)
|
||||
finals = yi_sandhi(word, finals)
|
||||
finals = neural_sandhi(word, pos, finals)
|
||||
return three_sandhi(word, finals)
|
||||
|
||||
def g2p(text: str, with_erhua: bool = True) -> str:
|
||||
"""
|
||||
Return: string of phonemes.
|
||||
'ㄋㄧ2ㄏㄠ3/ㄕ十4ㄐㄝ4'
|
||||
"""
|
||||
tokens = []
|
||||
seg_cut = posseg.lcut(text)
|
||||
# fix wordseg bad case for sandhi
|
||||
seg_cut = pre_merge_for_modify(seg_cut)
|
||||
|
||||
# 为了多音词获得更好的效果,这里采用整句预测
|
||||
initials = []
|
||||
finals = []
|
||||
# pypinyin, g2pM
|
||||
for word, pos in seg_cut:
|
||||
if pos == 'x' and '\u4E00' <= min(word) and max(word) <= '\u9FFF':
|
||||
pos = 'X'
|
||||
elif pos != 'x' and word in punc:
|
||||
pos = 'x'
|
||||
tk = MToken(tag=pos, whitespace='')
|
||||
if pos in X_ENG:
|
||||
if not word.isspace():
|
||||
if pos == 'x' and word in punc:
|
||||
tk.phonemes = word
|
||||
tokens.append(tk)
|
||||
elif tokens:
|
||||
tokens[-1].whitespace += word
|
||||
continue
|
||||
elif tokens and tokens[-1].tag not in X_ENG and not tokens[-1].whitespace:
|
||||
tokens[-1].whitespace = '/'
|
||||
|
||||
# g2p
|
||||
sub_initials, sub_finals = get_initials_finals(word)
|
||||
# tone sandhi
|
||||
sub_finals = modified_tone(word, pos, sub_finals)
|
||||
# er hua
|
||||
if with_erhua:
|
||||
sub_initials, sub_finals = merge_erhua(sub_initials, sub_finals, word, pos)
|
||||
|
||||
initials.append(sub_initials)
|
||||
finals.append(sub_finals)
|
||||
# assert len(sub_initials) == len(sub_finals) == len(word)
|
||||
|
||||
# sum(iterable[, start])
|
||||
# initials = sum(initials, [])
|
||||
# finals = sum(finals, [])
|
||||
|
||||
phones = []
|
||||
for c, v in zip(sub_initials, sub_finals):
|
||||
# NOTE: post process for pypinyin outputs
|
||||
# we discriminate i, ii and iii
|
||||
if c:
|
||||
phones.append(c)
|
||||
# replace punctuation by ` `
|
||||
# if c and c in punc:
|
||||
# phones.append(c)
|
||||
if v and (v not in punc or v != c):# and v not in rhy_phns:
|
||||
phones.append(v)
|
||||
phones = '_'.join(phones).replace('_eR', '_er').replace('R', '_R')
|
||||
phones = re.sub(r'(?=\d)', '_', phones).split('_')
|
||||
print(phones)
|
||||
tk.phonemes = ''.join(ZH_MAP.get(p, unk) for p in phones)
|
||||
tokens.append(tk)
|
||||
|
||||
return ''.join((unk if tk.phonemes is None else tk.phonemes) + tk.whitespace for tk in tokens)
|
||||
|
||||
print(g2p('时间为。Hello, world!你好,我们是一群追逐梦想的人。我正在使用qq。忽略卢驴'))
|
||||
seg = posseg.lcut('不好看', True)
|
||||
print(seg, merge_bu(seg))
|
||||
seg = merge_bu(posseg.lcut('听一听一个', True))
|
||||
print(seg, merge_yi(seg))
|
||||
seg = merge_bu(posseg.lcut('谢谢谢谢', True))
|
||||
print(seg, merge_reduplication(seg))
|
||||
seg = merge_bu(posseg.lcut('小美好', True))
|
||||
print(seg, merge_continuous_three_tones(seg))
|
||||
seg = merge_bu(posseg.lcut('风景好', True))
|
||||
print(seg, merge_continuous_three_tones_2(seg))
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
set PATH=%PATH%;D:\msys64\mingw64\bin
|
||||
cargo run --example synth_directly_v11
|
||||
pause
|
||||
Vendored
+80
@@ -0,0 +1,80 @@
|
||||
use crate::G2PError;
|
||||
use bincode::error::DecodeError;
|
||||
use ndarray::ShapeError;
|
||||
use ort::Error as OrtError;
|
||||
use std::{
|
||||
error::Error,
|
||||
fmt::{Debug, Display, Formatter, Result as FmtResult},
|
||||
io::Error as IoError,
|
||||
time::SystemTimeError,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum KokoroError {
|
||||
Decode(DecodeError),
|
||||
G2P(G2PError),
|
||||
Io(IoError),
|
||||
ModelReleased,
|
||||
Ort(OrtError),
|
||||
Send(String),
|
||||
Shape(ShapeError),
|
||||
SystemTime(SystemTimeError),
|
||||
VoiceNotFound(String),
|
||||
VoiceVersionInvalid(String),
|
||||
}
|
||||
|
||||
impl Display for KokoroError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
write!(f, "KokoroError: ")?;
|
||||
match self {
|
||||
Self::Decode(e) => Display::fmt(e, f),
|
||||
Self::G2P(e) => Display::fmt(e, f),
|
||||
Self::Io(e) => Display::fmt(e, f),
|
||||
Self::Ort(e) => Display::fmt(e, f),
|
||||
Self::ModelReleased => write!(f, "ModelReleased"),
|
||||
Self::Send(e) => Display::fmt(e, f),
|
||||
Self::Shape(e) => Display::fmt(e, f),
|
||||
Self::SystemTime(e) => Display::fmt(e, f),
|
||||
Self::VoiceNotFound(name) => write!(f, "VoiceNotFound({})", name),
|
||||
Self::VoiceVersionInvalid(msg) => write!(f, "VoiceVersionInvalid({})", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for KokoroError {}
|
||||
|
||||
impl From<IoError> for KokoroError {
|
||||
fn from(value: IoError) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DecodeError> for KokoroError {
|
||||
fn from(value: DecodeError) -> Self {
|
||||
Self::Decode(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OrtError> for KokoroError {
|
||||
fn from(value: OrtError) -> Self {
|
||||
Self::Ort(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<G2PError> for KokoroError {
|
||||
fn from(value: G2PError) -> Self {
|
||||
Self::G2P(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ShapeError> for KokoroError {
|
||||
fn from(value: ShapeError) -> Self {
|
||||
Self::Shape(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SystemTimeError> for KokoroError {
|
||||
fn from(value: SystemTimeError) -> Self {
|
||||
Self::SystemTime(value)
|
||||
}
|
||||
}
|
||||
Vendored
+321
@@ -0,0 +1,321 @@
|
||||
/// 文本到国际音标的转换
|
||||
mod v10;
|
||||
mod v11;
|
||||
|
||||
use super::PinyinError;
|
||||
use chinese_number::{ChineseCase, ChineseCountMethod, ChineseVariant, NumberToChinese};
|
||||
#[cfg(feature = "use-cmudict")]
|
||||
use cmudict_fast::{Cmudict, Error as CmudictError};
|
||||
use pinyin::ToPinyin;
|
||||
use regex::{Captures, Error as RegexError, Regex};
|
||||
use std::{
|
||||
error::Error,
|
||||
fmt::{Display, Formatter, Result as FmtResult},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum G2PError {
|
||||
#[cfg(feature = "use-cmudict")]
|
||||
CmudictError(CmudictError),
|
||||
EnptyData,
|
||||
#[cfg(not(feature = "use-cmudict"))]
|
||||
Nul(std::ffi::NulError),
|
||||
Pinyin(PinyinError),
|
||||
Regex(RegexError),
|
||||
#[cfg(not(feature = "use-cmudict"))]
|
||||
Utf8(std::str::Utf8Error),
|
||||
}
|
||||
|
||||
impl Display for G2PError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
write!(f, "G2PError: ")?;
|
||||
match self {
|
||||
#[cfg(feature = "use-cmudict")]
|
||||
Self::CmudictError(e) => Display::fmt(e, f),
|
||||
Self::EnptyData => Display::fmt("EmptyData", f),
|
||||
#[cfg(not(feature = "use-cmudict"))]
|
||||
Self::Nul(e) => Display::fmt(e, f),
|
||||
Self::Pinyin(e) => Display::fmt(e, f),
|
||||
Self::Regex(e) => Display::fmt(e, f),
|
||||
#[cfg(not(feature = "use-cmudict"))]
|
||||
Self::Utf8(e) => Display::fmt(e, f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for G2PError {}
|
||||
|
||||
impl From<PinyinError> for G2PError {
|
||||
fn from(value: PinyinError) -> Self {
|
||||
Self::Pinyin(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RegexError> for G2PError {
|
||||
fn from(value: RegexError) -> Self {
|
||||
Self::Regex(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "use-cmudict")]
|
||||
impl From<CmudictError> for G2PError {
|
||||
fn from(value: CmudictError) -> Self {
|
||||
Self::CmudictError(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "use-cmudict"))]
|
||||
impl From<std::ffi::NulError> for G2PError {
|
||||
fn from(value: std::ffi::NulError) -> Self {
|
||||
Self::Nul(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "use-cmudict"))]
|
||||
impl From<std::str::Utf8Error> for G2PError {
|
||||
fn from(value: std::str::Utf8Error) -> Self {
|
||||
Self::Utf8(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn word2ipa_zh(word: &str) -> Result<String, G2PError> {
|
||||
let iter = word.chars().map(|i| match i.to_pinyin() {
|
||||
None => Ok(i.to_string()),
|
||||
Some(p) => v10::py2ipa(p.with_tone_num_end()),
|
||||
});
|
||||
|
||||
let mut result = String::new();
|
||||
for i in iter {
|
||||
result.push_str(&i?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(feature = "use-cmudict")]
|
||||
fn word2ipa_en(word: &str) -> Result<String, G2PError> {
|
||||
use super::{arpa_to_ipa, letters_to_ipa};
|
||||
use std::{
|
||||
io::{Error as IoError, ErrorKind},
|
||||
str::FromStr,
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
fn get_cmudict<'a>() -> Result<&'a Cmudict, CmudictError> {
|
||||
static CMUDICT: LazyLock<Result<Cmudict, CmudictError>> =
|
||||
LazyLock::new(|| Cmudict::from_str(include_str!("../dict/cmudict.dict")));
|
||||
CMUDICT.as_ref().map_err(|i| match i {
|
||||
CmudictError::IoErr(e) => CmudictError::IoErr(IoError::new(ErrorKind::Other, e)),
|
||||
CmudictError::InvalidLine(e) => CmudictError::InvalidLine(*e),
|
||||
CmudictError::RuleParseError(e) => CmudictError::RuleParseError(e.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
if word.chars().count() < 4 && word.chars().all(|c| c.is_ascii_uppercase()) {
|
||||
return Ok(letters_to_ipa(word));
|
||||
}
|
||||
|
||||
let dict = get_cmudict()?;
|
||||
let upper = word.to_ascii_uppercase();
|
||||
let lower = word.to_ascii_lowercase();
|
||||
let Some(rules) = dict
|
||||
.get(word)
|
||||
.or_else(|| dict.get(&upper))
|
||||
.or_else(|| dict.get(&lower))
|
||||
else {
|
||||
return Ok(letters_to_ipa(word));
|
||||
};
|
||||
if rules.is_empty() {
|
||||
return Ok(word.to_owned());
|
||||
}
|
||||
let i = rand::random_range(0..rules.len());
|
||||
let result = rules[i]
|
||||
.pronunciation()
|
||||
.iter()
|
||||
.map(|i| arpa_to_ipa(&i.to_string()).unwrap_or_default())
|
||||
.collect::<String>();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "use-cmudict"))]
|
||||
fn word2ipa_en(word: &str) -> Result<String, G2PError> {
|
||||
use super::letters_to_ipa;
|
||||
use std::{
|
||||
ffi::{CStr, CString, c_char},
|
||||
sync::Once,
|
||||
};
|
||||
|
||||
if word.chars().count() < 4 && word.chars().all(|c| c.is_ascii_uppercase()) {
|
||||
return Ok(letters_to_ipa(word));
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn TextToPhonemes(text: *const c_char) -> *const ::std::os::raw::c_char;
|
||||
fn Initialize(data_dictlist: *const c_char);
|
||||
}
|
||||
|
||||
unsafe {
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
static DATA: &[u8] = include_bytes!("../dict/espeak.dict");
|
||||
Initialize(DATA.as_ptr() as _);
|
||||
});
|
||||
|
||||
let word = CString::new(word.to_lowercase())?.into_raw() as *const c_char;
|
||||
let res = TextToPhonemes(word);
|
||||
Ok(CStr::from_ptr(res).to_str()?.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn to_half_shape(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len() * 2); // 预分配合理空间
|
||||
let chars = text.chars().peekable();
|
||||
|
||||
for c in chars {
|
||||
match c {
|
||||
// 处理需要后看的情况
|
||||
'«' | '《' => result.push('“'),
|
||||
'»' | '》' => result.push('”'),
|
||||
'(' => result.push('('),
|
||||
')' => result.push(')'),
|
||||
// 简单替换规则
|
||||
'、' | ',' => result.push(','),
|
||||
'。' => result.push('.'),
|
||||
'!' => result.push('!'),
|
||||
':' => result.push(':'),
|
||||
';' => result.push(';'),
|
||||
'?' => result.push('?'),
|
||||
// 默认字符
|
||||
_ => result.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
// 清理多余空格并返回
|
||||
result
|
||||
}
|
||||
|
||||
fn num_repr(text: &str) -> Result<String, G2PError> {
|
||||
let regex = Regex::new(r#"\d+(\.\d+)?"#)?;
|
||||
Ok(regex
|
||||
.replace(text, |caps: &Captures| {
|
||||
let text = &caps[0];
|
||||
if let Ok(num) = text.parse::<f64>() {
|
||||
num.to_chinese(
|
||||
ChineseVariant::Traditional,
|
||||
ChineseCase::Lower,
|
||||
ChineseCountMethod::Low,
|
||||
)
|
||||
.map_or(text.to_owned(), |i| i)
|
||||
} else if let Ok(num) = text.parse::<i64>() {
|
||||
num.to_chinese(
|
||||
ChineseVariant::Traditional,
|
||||
ChineseCase::Lower,
|
||||
ChineseCountMethod::Low,
|
||||
)
|
||||
.map_or(text.to_owned(), |i| i)
|
||||
} else {
|
||||
text.to_owned()
|
||||
}
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub fn g2p(text: &str, use_v11: bool) -> Result<String, G2PError> {
|
||||
let text = num_repr(text)?;
|
||||
let sentence_pattern = Regex::new(
|
||||
r#"([\u4E00-\u9FFF]+)|([,。:·?、!《》()【】〖〗〔〕“”‘’〈〉…— ]+)|([\u0000-\u00FF]+)+"#,
|
||||
)?;
|
||||
let en_word_pattern = Regex::new("\\w+|\\W+")?;
|
||||
let jieba = jieba_rs::Jieba::new();
|
||||
let mut result = String::new();
|
||||
for i in sentence_pattern.captures_iter(&text) {
|
||||
match (i.get(1), i.get(2), i.get(3)) {
|
||||
(Some(text), _, _) => {
|
||||
let text = to_half_shape(text.as_str());
|
||||
if use_v11 {
|
||||
if !result.is_empty() && !result.ends_with(' ') {
|
||||
result.push(' ');
|
||||
}
|
||||
result.push_str(&v11::g2p(&text, true));
|
||||
result.push(' ');
|
||||
} else {
|
||||
for i in jieba.cut(&text, true) {
|
||||
result.push_str(&word2ipa_zh(i)?);
|
||||
result.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
(_, Some(text), _) => {
|
||||
let text = to_half_shape(text.as_str());
|
||||
result = result.trim_end().to_string();
|
||||
result.push_str(&text);
|
||||
result.push(' ');
|
||||
}
|
||||
(_, _, Some(text)) => {
|
||||
for i in en_word_pattern.captures_iter(text.as_str()) {
|
||||
let c = (i[0]).chars().next().unwrap_or_default();
|
||||
if c == '\''
|
||||
|| c == '_'
|
||||
|| c == '-'
|
||||
|| c.is_ascii_lowercase()
|
||||
|| c.is_ascii_uppercase()
|
||||
{
|
||||
let i = &i[0];
|
||||
if result.trim_end().ends_with(['.', ',', '!', '?'])
|
||||
&& !result.ends_with(' ')
|
||||
{
|
||||
result.push(' ');
|
||||
}
|
||||
result.push_str(&word2ipa_en(i)?);
|
||||
} else if c == ' ' && result.ends_with(' ') {
|
||||
result.push_str((i[0]).trim_start());
|
||||
} else {
|
||||
result.push_str(&i[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
}
|
||||
|
||||
Ok(result.trim().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(not(feature = "use-cmudict"))]
|
||||
#[test]
|
||||
fn test_word2ipa_en() -> Result<(), super::G2PError> {
|
||||
use super::word2ipa_en;
|
||||
|
||||
// println!("{:?}", espeak_rs::text_to_phonemes("days", "en", None, true, false));
|
||||
assert_eq!("kjˌuːkjˈuː", word2ipa_en("qq")?);
|
||||
assert_eq!("həlˈəʊ", word2ipa_en("hello")?);
|
||||
assert_eq!("wˈɜːld", word2ipa_en("world")?);
|
||||
assert_eq!("ˈapəl", word2ipa_en("apple")?);
|
||||
assert_eq!("tʃˈɪldɹɛn", word2ipa_en("children")?);
|
||||
assert_eq!("ˈaʊə", word2ipa_en("hour")?);
|
||||
assert_eq!("dˈeɪz", word2ipa_en("days")?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "use-cmudict")]
|
||||
#[test]
|
||||
fn test_word2ipa_en_is_case_insensitive_for_dictionary_words() -> Result<(), super::G2PError> {
|
||||
use super::word2ipa_en;
|
||||
|
||||
assert_eq!(word2ipa_en("Welcome")?, word2ipa_en("welcome")?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_g2p() -> Result<(), super::G2PError> {
|
||||
use super::g2p;
|
||||
|
||||
assert_eq!("ni↓xau↓ ʂɻ↘ʨje↘", g2p("你好世界", false)?);
|
||||
assert_eq!("ㄋㄧ2ㄏㄠ3/ㄕ十4ㄐㄝ4", g2p("你好世界", true)?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
use crate::{G2PError, pinyin_to_ipa};
|
||||
|
||||
fn retone(p: &str) -> String {
|
||||
let chars: Vec<char> = p.chars().collect();
|
||||
let mut result = String::with_capacity(p.len());
|
||||
let mut i = 0;
|
||||
|
||||
while i < chars.len() {
|
||||
match () {
|
||||
// 三声调优先处理
|
||||
_ if i + 2 < chars.len()
|
||||
&& chars[i] == '˧'
|
||||
&& chars[i + 1] == '˩'
|
||||
&& chars[i + 2] == '˧' =>
|
||||
{
|
||||
result.push('↓');
|
||||
i += 3;
|
||||
}
|
||||
// 二声调
|
||||
_ if i + 1 < chars.len() && chars[i] == '˧' && chars[i + 1] == '˥' => {
|
||||
result.push('↗');
|
||||
i += 2;
|
||||
}
|
||||
// 四声调
|
||||
_ if i + 1 < chars.len() && chars[i] == '˥' && chars[i + 1] == '˩' => {
|
||||
result.push('↘');
|
||||
i += 2;
|
||||
}
|
||||
// 一声调
|
||||
_ if chars[i] == '˥' => {
|
||||
result.push('→');
|
||||
i += 1;
|
||||
}
|
||||
// 组合字符替换(ɻ̩ 和 ɱ̩)
|
||||
_ if !(i + 1 >= chars.len() || chars[i+1] != '\u{0329}' || chars[i] != '\u{027B}' && chars[i] != '\u{0271}') =>
|
||||
{
|
||||
result.push('ɨ');
|
||||
i += 2;
|
||||
}
|
||||
// 默认情况
|
||||
_ => {
|
||||
result.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
!result.contains('\u{0329}'),
|
||||
"Unexpected combining mark in: {}",
|
||||
result
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn py2ipa(py: &str) -> Result<String, G2PError> {
|
||||
pinyin_to_ipa(py)?
|
||||
.first()
|
||||
.map_or(Err(G2PError::EnptyData), |i| {
|
||||
Ok(i.iter().map(|i| retone(i)).collect::<String>())
|
||||
})
|
||||
}
|
||||
+1263
File diff suppressed because it is too large
Load Diff
Vendored
+83
@@ -0,0 +1,83 @@
|
||||
mod error;
|
||||
mod g2p;
|
||||
mod stream;
|
||||
mod synthesizer;
|
||||
mod tokenizer;
|
||||
mod transcription;
|
||||
mod voice;
|
||||
|
||||
use {
|
||||
bincode::{config::standard, decode_from_slice},
|
||||
ort::{execution_providers::CUDAExecutionProvider, session::Session},
|
||||
std::{collections::HashMap, path::Path, sync::Arc, time::Duration},
|
||||
tokio::{fs::read, sync::Mutex},
|
||||
};
|
||||
pub use {error::*, g2p::*, stream::*, tokenizer::*, transcription::*, voice::*};
|
||||
|
||||
pub struct KokoroTts {
|
||||
model: Arc<Mutex<Session>>,
|
||||
voices: Arc<HashMap<String, Vec<Vec<Vec<f32>>>>>,
|
||||
}
|
||||
|
||||
impl KokoroTts {
|
||||
pub async fn new<P: AsRef<Path>>(model_path: P, voices_path: P) -> Result<Self, KokoroError> {
|
||||
let voices = read(voices_path).await?;
|
||||
let (voices, _) = decode_from_slice(&voices, standard())?;
|
||||
|
||||
let model = Session::builder()?
|
||||
.with_execution_providers([CUDAExecutionProvider::default().build()])?
|
||||
.commit_from_file(model_path)?;
|
||||
Ok(Self {
|
||||
model: Arc::new(model.into()),
|
||||
voices,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn new_from_bytes<B>(model: B, voices: B) -> Result<Self, KokoroError>
|
||||
where
|
||||
B: AsRef<[u8]>,
|
||||
{
|
||||
let (voices, _) = decode_from_slice(voices.as_ref(), standard())?;
|
||||
|
||||
let model = Session::builder()?
|
||||
.with_execution_providers([CUDAExecutionProvider::default().build()])?
|
||||
.commit_from_memory(model.as_ref())?;
|
||||
Ok(Self {
|
||||
model: Arc::new(model.into()),
|
||||
voices,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn synth<S>(&self, text: S, voice: Voice) -> Result<(Vec<f32>, Duration), KokoroError>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let name = voice.get_name();
|
||||
let pack = self
|
||||
.voices
|
||||
.get(name)
|
||||
.ok_or(KokoroError::VoiceNotFound(name.to_owned()))?;
|
||||
synthesizer::synth(Arc::downgrade(&self.model), text, pack, voice).await
|
||||
}
|
||||
|
||||
pub fn stream<S>(&self, voice: Voice) -> (SynthSink<S>, SynthStream)
|
||||
where
|
||||
S: AsRef<str> + Send + 'static,
|
||||
{
|
||||
let voices = Arc::downgrade(&self.voices);
|
||||
let model = Arc::downgrade(&self.model);
|
||||
|
||||
start_synth_session(voice, move |text, voice| {
|
||||
let voices = voices.clone();
|
||||
let model = model.clone();
|
||||
async move {
|
||||
let name = voice.get_name();
|
||||
let voices = voices.upgrade().ok_or(KokoroError::ModelReleased)?;
|
||||
let pack = voices
|
||||
.get(name)
|
||||
.ok_or(KokoroError::VoiceNotFound(name.to_owned()))?;
|
||||
synthesizer::synth(model, text, pack, voice).await
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Vendored
+157
@@ -0,0 +1,157 @@
|
||||
use {
|
||||
crate::{KokoroError, Voice},
|
||||
futures::{Sink, SinkExt, Stream},
|
||||
pin_project::pin_project,
|
||||
std::{
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
time::Duration,
|
||||
},
|
||||
tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel},
|
||||
};
|
||||
|
||||
struct Request<S> {
|
||||
voice: Voice,
|
||||
text: S,
|
||||
}
|
||||
|
||||
struct Response {
|
||||
data: Vec<f32>,
|
||||
took: Duration,
|
||||
}
|
||||
|
||||
/// 语音合成流
|
||||
///
|
||||
/// 该结构体用于通过流式合成来处理更长的文本。它实现了`Stream` trait,可以用于异步迭代合成后的音频数据。
|
||||
#[pin_project]
|
||||
pub struct SynthStream {
|
||||
#[pin]
|
||||
rx: UnboundedReceiver<Response>,
|
||||
}
|
||||
|
||||
impl Stream for SynthStream {
|
||||
type Item = (Vec<f32>, Duration);
|
||||
|
||||
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
Pin::new(&mut self.project().rx)
|
||||
.poll_recv(cx)
|
||||
.map(|i| i.map(|Response { data, took }| (data, took)))
|
||||
}
|
||||
}
|
||||
|
||||
/// 语音合成发送端
|
||||
///
|
||||
/// 该结构体用于发送语音合成请求。它实现了`Sink` trait,可以用于异步发送合成请求。
|
||||
#[pin_project]
|
||||
pub struct SynthSink<S> {
|
||||
tx: UnboundedSender<Request<S>>,
|
||||
voice: Voice,
|
||||
}
|
||||
|
||||
impl<S> SynthSink<S> {
|
||||
/// 设置语音名称
|
||||
///
|
||||
/// 该方法用于设置要合成的语音名称。
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `voice_name` - 语音名称,用于选择要合成的语音。
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```rust
|
||||
/// use kokoro_tts::{KokoroTts, Voice};
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let Ok(tts) = KokoroTts::new("../kokoro-v1.0.int8.onnx", "../voices.bin").await else {
|
||||
/// return;
|
||||
/// };
|
||||
/// // speed: 1.0
|
||||
/// let (mut sink, _) = tts.stream::<&str>(Voice::ZfXiaoxiao(1.0));
|
||||
/// // speed: 1.8
|
||||
/// sink.set_voice(Voice::ZmYunxi(1.8));
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
pub fn set_voice(&mut self, voice: Voice) {
|
||||
self.voice = voice
|
||||
}
|
||||
|
||||
/// 发送合成请求
|
||||
///
|
||||
/// 该方法用于发送语音合成请求。
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `text` - 要合成的文本内容。
|
||||
///
|
||||
/// # 返回值
|
||||
///
|
||||
/// 如果发送成功,将返回`Ok(())`;如果发送失败,将返回一个`KokoroError`类型的错误。
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```rust
|
||||
/// use kokoro_tts::{KokoroTts, Voice};
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let Ok(tts) = KokoroTts::new("../kokoro-v1.1-zh.onnx", "../voices-v1.1-zh.bin").await else {
|
||||
/// return;
|
||||
/// };
|
||||
/// let (mut sink, _) =tts.stream(Voice::Zf003(2));
|
||||
/// let _ = sink.synth("hello world.").await;
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
pub async fn synth(&mut self, text: S) -> Result<(), KokoroError> {
|
||||
self.send((self.voice, text)).await
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Sink<(Voice, S)> for SynthSink<S> {
|
||||
type Error = KokoroError;
|
||||
|
||||
fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn start_send(self: Pin<&mut Self>, (voice, text): (Voice, S)) -> Result<(), Self::Error> {
|
||||
self.tx
|
||||
.send(Request { voice, text })
|
||||
.map_err(|e| KokoroError::Send(e.to_string()))
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn start_synth_session<F, R, S>(
|
||||
voice: Voice,
|
||||
synth_request_callback: F,
|
||||
) -> (SynthSink<S>, SynthStream)
|
||||
where
|
||||
F: Fn(S, Voice) -> R + Send + 'static,
|
||||
R: Future<Output = Result<(Vec<f32>, Duration), KokoroError>> + Send,
|
||||
S: AsRef<str> + Send + 'static,
|
||||
{
|
||||
let (tx, mut rx) = unbounded_channel::<Request<S>>();
|
||||
let (tx2, rx2) = unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
while let Some(req) = rx.recv().await {
|
||||
let (data, took) = synth_request_callback(req.text, req.voice).await?;
|
||||
tx2.send(Response { data, took })
|
||||
.map_err(|e| KokoroError::Send(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok::<_, KokoroError>(())
|
||||
});
|
||||
|
||||
(SynthSink { tx, voice }, SynthStream { rx: rx2 })
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
use {
|
||||
crate::{KokoroError, Voice, g2p, get_token_ids},
|
||||
ndarray::Array,
|
||||
ort::{
|
||||
inputs,
|
||||
session::{RunOptions, Session},
|
||||
value::TensorRef,
|
||||
},
|
||||
std::{
|
||||
cmp::min,
|
||||
sync::Weak,
|
||||
time::{Duration, SystemTime},
|
||||
},
|
||||
tokio::sync::Mutex,
|
||||
};
|
||||
|
||||
async fn synth_v10<P, S>(
|
||||
model: Weak<Mutex<Session>>,
|
||||
phonemes: S,
|
||||
pack: P,
|
||||
speed: f32,
|
||||
) -> Result<(Vec<f32>, Duration), KokoroError>
|
||||
where
|
||||
P: AsRef<Vec<Vec<Vec<f32>>>>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let model = model.upgrade().ok_or(KokoroError::ModelReleased)?;
|
||||
let phonemes = get_token_ids(phonemes.as_ref(), false);
|
||||
let phonemes = Array::from_shape_vec((1, phonemes.len()), phonemes)?;
|
||||
let ref_s = pack.as_ref()[phonemes.len() - 1]
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let style = Array::from_shape_vec((1, ref_s.len()), ref_s)?;
|
||||
let speed = Array::from_vec(vec![speed]);
|
||||
let options = RunOptions::new()?;
|
||||
let mut model = model.lock().await;
|
||||
let t = SystemTime::now();
|
||||
let kokoro_output = model
|
||||
.run_async(
|
||||
inputs![
|
||||
"tokens" => TensorRef::from_array_view(&phonemes)?,
|
||||
"style" => TensorRef::from_array_view(&style)?,
|
||||
"speed" => TensorRef::from_array_view(&speed)?,
|
||||
],
|
||||
&options,
|
||||
)?
|
||||
.await?;
|
||||
let elapsed = t.elapsed()?;
|
||||
let (_, audio) = kokoro_output["audio"].try_extract_tensor::<f32>()?;
|
||||
|
||||
Ok((audio.to_owned(), elapsed))
|
||||
}
|
||||
|
||||
async fn synth_v11<P, S>(
|
||||
model: Weak<Mutex<Session>>,
|
||||
phonemes: S,
|
||||
pack: P,
|
||||
speed: i32,
|
||||
) -> Result<(Vec<f32>, Duration), KokoroError>
|
||||
where
|
||||
P: AsRef<Vec<Vec<Vec<f32>>>>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let model = model.upgrade().ok_or(KokoroError::ModelReleased)?;
|
||||
let mut phonemes = get_token_ids(phonemes.as_ref(), true);
|
||||
|
||||
let mut ret = Vec::new();
|
||||
let mut elapsed = Duration::ZERO;
|
||||
while let p = phonemes.drain(..min(pack.as_ref().len(), phonemes.len()))
|
||||
&& p.len() != 0
|
||||
{
|
||||
let phonemes = Array::from_shape_vec((1, p.len()), p.collect())?;
|
||||
let ref_s = pack.as_ref()[phonemes.len() - 1]
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or(vec![0.; 256]);
|
||||
|
||||
let style = Array::from_shape_vec((1, ref_s.len()), ref_s)?;
|
||||
let speed = Array::from_vec(vec![speed]);
|
||||
let options = RunOptions::new()?;
|
||||
let mut model = model.lock().await;
|
||||
let t = SystemTime::now();
|
||||
let kokoro_output = model
|
||||
.run_async(
|
||||
inputs![
|
||||
"input_ids" => TensorRef::from_array_view(&phonemes)?,
|
||||
"style" => TensorRef::from_array_view(&style)?,
|
||||
"speed" => TensorRef::from_array_view(&speed)?,
|
||||
],
|
||||
&options,
|
||||
)?
|
||||
.await?;
|
||||
elapsed = t.elapsed()?;
|
||||
let (_, audio) = kokoro_output["waveform"].try_extract_tensor::<f32>()?;
|
||||
let (_, _duration) = kokoro_output["duration"].try_extract_tensor::<i64>()?;
|
||||
// let _ = dbg!(duration.len());
|
||||
ret.extend_from_slice(audio);
|
||||
}
|
||||
|
||||
Ok((ret, elapsed))
|
||||
}
|
||||
|
||||
pub(super) async fn synth<P, S>(
|
||||
model: Weak<Mutex<Session>>,
|
||||
text: S,
|
||||
pack: P,
|
||||
voice: Voice,
|
||||
) -> Result<(Vec<f32>, Duration), KokoroError>
|
||||
where
|
||||
P: AsRef<Vec<Vec<Vec<f32>>>>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let phonemes = g2p(text.as_ref(), voice.is_v11_supported())?;
|
||||
// #[cfg(debug_assertions)]
|
||||
// println!("{}", phonemes);
|
||||
match voice {
|
||||
v if v.is_v11_supported() => synth_v11(model, phonemes, pack, v.get_speed_v11()?).await,
|
||||
v if v.is_v10_supported() => synth_v10(model, phonemes, pack, v.get_speed_v10()?).await,
|
||||
v => Err(KokoroError::VoiceVersionInvalid(v.get_name().to_owned())),
|
||||
}
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
use {
|
||||
log::warn,
|
||||
std::{collections::HashMap, sync::LazyLock},
|
||||
};
|
||||
static VOCAB_V10: LazyLock<HashMap<char, u8>> = LazyLock::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
|
||||
map.insert(';', 1);
|
||||
map.insert(':', 2);
|
||||
map.insert(',', 3);
|
||||
map.insert('.', 4);
|
||||
map.insert('!', 5);
|
||||
map.insert('?', 6);
|
||||
map.insert('—', 9);
|
||||
map.insert('…', 10);
|
||||
map.insert('"', 11);
|
||||
map.insert('(', 12);
|
||||
map.insert(')', 13);
|
||||
map.insert('“', 14);
|
||||
map.insert('”', 15);
|
||||
map.insert(' ', 16);
|
||||
map.insert('\u{0303}', 17); // Unicode escape for combining tilde
|
||||
map.insert('ʣ', 18);
|
||||
map.insert('ʥ', 19);
|
||||
map.insert('ʦ', 20);
|
||||
map.insert('ʨ', 21);
|
||||
map.insert('ᵝ', 22);
|
||||
map.insert('\u{AB67}', 23); // Unicode escape
|
||||
map.insert('A', 24);
|
||||
map.insert('I', 25);
|
||||
map.insert('O', 31);
|
||||
map.insert('Q', 33);
|
||||
map.insert('S', 35);
|
||||
map.insert('T', 36);
|
||||
map.insert('W', 39);
|
||||
map.insert('Y', 41);
|
||||
map.insert('ᵊ', 42);
|
||||
map.insert('a', 43);
|
||||
map.insert('b', 44);
|
||||
map.insert('c', 45);
|
||||
map.insert('d', 46);
|
||||
map.insert('e', 47);
|
||||
map.insert('f', 48);
|
||||
map.insert('h', 50);
|
||||
map.insert('i', 51);
|
||||
map.insert('j', 52);
|
||||
map.insert('k', 53);
|
||||
map.insert('l', 54);
|
||||
map.insert('m', 55);
|
||||
map.insert('n', 56);
|
||||
map.insert('o', 57);
|
||||
map.insert('p', 58);
|
||||
map.insert('q', 59);
|
||||
map.insert('r', 60);
|
||||
map.insert('s', 61);
|
||||
map.insert('t', 62);
|
||||
map.insert('u', 63);
|
||||
map.insert('v', 64);
|
||||
map.insert('w', 65);
|
||||
map.insert('x', 66);
|
||||
map.insert('y', 67);
|
||||
map.insert('z', 68);
|
||||
map.insert('ɑ', 69);
|
||||
map.insert('ɐ', 70);
|
||||
map.insert('ɒ', 71);
|
||||
map.insert('æ', 72);
|
||||
map.insert('β', 75);
|
||||
map.insert('ɔ', 76);
|
||||
map.insert('ɕ', 77);
|
||||
map.insert('ç', 78);
|
||||
map.insert('ɖ', 80);
|
||||
map.insert('ð', 81);
|
||||
map.insert('ʤ', 82);
|
||||
map.insert('ə', 83);
|
||||
map.insert('ɚ', 85);
|
||||
map.insert('ɛ', 86);
|
||||
map.insert('ɜ', 87);
|
||||
map.insert('ɟ', 90);
|
||||
map.insert('ɡ', 92);
|
||||
map.insert('ɥ', 99);
|
||||
map.insert('ɨ', 101);
|
||||
map.insert('ɪ', 102);
|
||||
map.insert('ʝ', 103);
|
||||
map.insert('ɯ', 110);
|
||||
map.insert('ɰ', 111);
|
||||
map.insert('ŋ', 112);
|
||||
map.insert('ɳ', 113);
|
||||
map.insert('ɲ', 114);
|
||||
map.insert('ɴ', 115);
|
||||
map.insert('ø', 116);
|
||||
map.insert('ɸ', 118);
|
||||
map.insert('θ', 119);
|
||||
map.insert('œ', 120);
|
||||
map.insert('ɹ', 123);
|
||||
map.insert('ɾ', 125);
|
||||
map.insert('ɻ', 126);
|
||||
map.insert('ʁ', 128);
|
||||
map.insert('ɽ', 129);
|
||||
map.insert('ʂ', 130);
|
||||
map.insert('ʃ', 131);
|
||||
map.insert('ʈ', 132);
|
||||
map.insert('ʧ', 133);
|
||||
map.insert('ʊ', 135);
|
||||
map.insert('ʋ', 136);
|
||||
map.insert('ʌ', 138);
|
||||
map.insert('ɣ', 139);
|
||||
map.insert('ɤ', 140);
|
||||
map.insert('χ', 142);
|
||||
map.insert('ʎ', 143);
|
||||
map.insert('ʒ', 147);
|
||||
map.insert('ʔ', 148);
|
||||
map.insert('ˈ', 156);
|
||||
map.insert('ˌ', 157);
|
||||
map.insert('ː', 158);
|
||||
map.insert('ʰ', 162);
|
||||
map.insert('ʲ', 164);
|
||||
map.insert('↓', 169);
|
||||
map.insert('→', 171);
|
||||
map.insert('↗', 172);
|
||||
map.insert('↘', 173);
|
||||
map.insert('ᵻ', 177);
|
||||
map
|
||||
});
|
||||
|
||||
static VOCAB_V11: LazyLock<HashMap<char, u8>> = LazyLock::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
|
||||
map.insert(';', 1);
|
||||
map.insert(':', 2);
|
||||
map.insert(',', 3);
|
||||
map.insert('.', 4);
|
||||
map.insert('!', 5);
|
||||
map.insert('?', 6);
|
||||
map.insert('/', 7);
|
||||
map.insert('—', 9);
|
||||
map.insert('…', 10);
|
||||
map.insert('"', 11);
|
||||
map.insert('(', 12);
|
||||
map.insert(')', 13);
|
||||
map.insert('“', 14);
|
||||
map.insert('”', 15);
|
||||
map.insert(' ', 16);
|
||||
map.insert('\u{0303}', 17); // Unicode escape for combining tilde
|
||||
map.insert('ʣ', 18);
|
||||
map.insert('ʥ', 19);
|
||||
map.insert('ʦ', 20);
|
||||
map.insert('ʨ', 21);
|
||||
map.insert('ᵝ', 22);
|
||||
map.insert('ㄓ', 23);
|
||||
map.insert('A', 24);
|
||||
map.insert('I', 25);
|
||||
map.insert('ㄅ', 30);
|
||||
map.insert('O', 31);
|
||||
map.insert('ㄆ', 32);
|
||||
map.insert('Q', 33);
|
||||
map.insert('R', 34);
|
||||
map.insert('S', 35);
|
||||
map.insert('T', 36);
|
||||
map.insert('ㄇ', 37);
|
||||
map.insert('ㄈ', 38);
|
||||
map.insert('W', 39);
|
||||
map.insert('ㄉ', 40);
|
||||
map.insert('Y', 41);
|
||||
map.insert('ᵊ', 42);
|
||||
map.insert('a', 43);
|
||||
map.insert('b', 44);
|
||||
map.insert('c', 45);
|
||||
map.insert('d', 46);
|
||||
map.insert('e', 47);
|
||||
map.insert('f', 48);
|
||||
map.insert('ㄊ', 49);
|
||||
map.insert('h', 50);
|
||||
map.insert('i', 51);
|
||||
map.insert('j', 52);
|
||||
map.insert('k', 53);
|
||||
map.insert('l', 54);
|
||||
map.insert('m', 55);
|
||||
map.insert('n', 56);
|
||||
map.insert('o', 57);
|
||||
map.insert('p', 58);
|
||||
map.insert('q', 59);
|
||||
map.insert('r', 60);
|
||||
map.insert('s', 61);
|
||||
map.insert('t', 62);
|
||||
map.insert('u', 63);
|
||||
map.insert('v', 64);
|
||||
map.insert('w', 65);
|
||||
map.insert('x', 66);
|
||||
map.insert('y', 67);
|
||||
map.insert('z', 68);
|
||||
map.insert('ɑ', 69);
|
||||
map.insert('ɐ', 70);
|
||||
map.insert('ɒ', 71);
|
||||
map.insert('æ', 72);
|
||||
map.insert('ㄋ', 73);
|
||||
map.insert('ㄌ', 74);
|
||||
map.insert('β', 75);
|
||||
map.insert('ɔ', 76);
|
||||
map.insert('ɕ', 77);
|
||||
map.insert('ç', 78);
|
||||
map.insert('ㄍ', 79);
|
||||
map.insert('ɖ', 80);
|
||||
map.insert('ð', 81);
|
||||
map.insert('ʤ', 82);
|
||||
map.insert('ə', 83);
|
||||
map.insert('ㄎ', 84);
|
||||
map.insert('ㄦ', 85);
|
||||
map.insert('ɛ', 86);
|
||||
map.insert('ɜ', 87);
|
||||
map.insert('ㄏ', 88);
|
||||
map.insert('ㄐ', 89);
|
||||
map.insert('ɟ', 90);
|
||||
map.insert('ㄑ', 91);
|
||||
map.insert('ɡ', 92);
|
||||
map.insert('ㄒ', 93);
|
||||
map.insert('ㄔ', 94);
|
||||
map.insert('ㄕ', 95);
|
||||
map.insert('ㄗ', 96);
|
||||
map.insert('ㄘ', 97);
|
||||
map.insert('ㄙ', 98);
|
||||
map.insert('月', 99);
|
||||
map.insert('ㄚ', 100);
|
||||
map.insert('ɨ', 101);
|
||||
map.insert('ɪ', 102);
|
||||
map.insert('ʝ', 103);
|
||||
map.insert('ㄛ', 104);
|
||||
map.insert('ㄝ', 105);
|
||||
map.insert('ㄞ', 106);
|
||||
map.insert('ㄟ', 107);
|
||||
map.insert('ㄠ', 108);
|
||||
map.insert('ㄡ', 109);
|
||||
map.insert('ɯ', 110);
|
||||
map.insert('ɰ', 111);
|
||||
map.insert('ŋ', 112);
|
||||
map.insert('ɳ', 113);
|
||||
map.insert('ɲ', 114);
|
||||
map.insert('ɴ', 115);
|
||||
map.insert('ø', 116);
|
||||
map.insert('ㄢ', 117);
|
||||
map.insert('ɸ', 118);
|
||||
map.insert('θ', 119);
|
||||
map.insert('œ', 120);
|
||||
map.insert('ㄣ', 121);
|
||||
map.insert('ㄤ', 122);
|
||||
map.insert('ɹ', 123);
|
||||
map.insert('ㄥ', 124);
|
||||
map.insert('ɾ', 125);
|
||||
map.insert('ㄖ', 126);
|
||||
map.insert('ㄧ', 127);
|
||||
map.insert('ʁ', 128);
|
||||
map.insert('ɽ', 129);
|
||||
map.insert('ʂ', 130);
|
||||
map.insert('ʃ', 131);
|
||||
map.insert('ʈ', 132);
|
||||
map.insert('ʧ', 133);
|
||||
map.insert('ㄨ', 134);
|
||||
map.insert('ʊ', 135);
|
||||
map.insert('ʋ', 136);
|
||||
map.insert('ㄩ', 137);
|
||||
map.insert('ʌ', 138);
|
||||
map.insert('ɣ', 139);
|
||||
map.insert('ㄜ', 140);
|
||||
map.insert('ㄭ', 141);
|
||||
map.insert('χ', 142);
|
||||
map.insert('ʎ', 143);
|
||||
map.insert('十', 144);
|
||||
map.insert('压', 145);
|
||||
map.insert('言', 146);
|
||||
map.insert('ʒ', 147);
|
||||
map.insert('ʔ', 148);
|
||||
map.insert('阳', 149);
|
||||
map.insert('要', 150);
|
||||
map.insert('阴', 151);
|
||||
map.insert('应', 152);
|
||||
map.insert('用', 153);
|
||||
map.insert('又', 154);
|
||||
map.insert('中', 155);
|
||||
map.insert('ˈ', 156);
|
||||
map.insert('ˌ', 157);
|
||||
map.insert('ː', 158);
|
||||
map.insert('穵', 159);
|
||||
map.insert('外', 160);
|
||||
map.insert('万', 161);
|
||||
map.insert('ʰ', 162);
|
||||
map.insert('王', 163);
|
||||
map.insert('ʲ', 164);
|
||||
map.insert('为', 165);
|
||||
map.insert('文', 166);
|
||||
map.insert('瓮', 167);
|
||||
map.insert('我', 168);
|
||||
map.insert('3', 169);
|
||||
map.insert('5', 170);
|
||||
map.insert('1', 171);
|
||||
map.insert('2', 172);
|
||||
map.insert('4', 173);
|
||||
map.insert('元', 175);
|
||||
map.insert('云', 176);
|
||||
map.insert('ᵻ', 177);
|
||||
map
|
||||
});
|
||||
|
||||
pub fn get_token_ids(phonemes: &str, v11: bool) -> Vec<i64> {
|
||||
let mut tokens = Vec::with_capacity(phonemes.len() + 2);
|
||||
tokens.push(0);
|
||||
|
||||
for i in phonemes.chars() {
|
||||
let v = if v11 {
|
||||
VOCAB_V11.get(&i).copied()
|
||||
} else {
|
||||
VOCAB_V10.get(&i).copied()
|
||||
};
|
||||
match v {
|
||||
Some(t) => {
|
||||
tokens.push(t as _);
|
||||
}
|
||||
_ => {
|
||||
warn!("Unknown phone {}, skipped.", i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokens.push(0);
|
||||
tokens
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
mod en;
|
||||
mod zh;
|
||||
|
||||
pub use {en::*, zh::*};
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
use regex::Regex;
|
||||
use std::{collections::HashMap, sync::LazyLock};
|
||||
|
||||
static LETTERS_IPA_MAP: LazyLock<HashMap<char, &'static str>> = LazyLock::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
map.insert('a', "ɐ");
|
||||
map.insert('b', "bˈi");
|
||||
map.insert('c', "sˈi");
|
||||
map.insert('d', "dˈi");
|
||||
map.insert('e', "ˈi");
|
||||
map.insert('f', "ˈɛf");
|
||||
map.insert('g', "ʤˈi");
|
||||
map.insert('h', "ˈAʧ");
|
||||
map.insert('i', "ˈI");
|
||||
map.insert('j', "ʤˈA");
|
||||
map.insert('k', "kˈA");
|
||||
map.insert('l', "ˈɛl");
|
||||
map.insert('m', "ˈɛm");
|
||||
map.insert('n', "ˈɛn");
|
||||
map.insert('o', "ˈO");
|
||||
map.insert('p', "pˈi");
|
||||
map.insert('q', "kjˈu");
|
||||
map.insert('r', "ˈɑɹ");
|
||||
map.insert('s', "ˈɛs");
|
||||
map.insert('t', "tˈi");
|
||||
map.insert('u', "jˈu");
|
||||
map.insert('v', "vˈi");
|
||||
map.insert('w', "dˈʌbᵊlju");
|
||||
map.insert('x', "ˈɛks");
|
||||
map.insert('y', "wˈI");
|
||||
map.insert('z', "zˈi");
|
||||
map.insert('A', "ˈA");
|
||||
map.insert('B', "bˈi");
|
||||
map.insert('C', "sˈi");
|
||||
map.insert('D', "dˈi");
|
||||
map.insert('E', "ˈi");
|
||||
map.insert('F', "ˈɛf");
|
||||
map.insert('G', "ʤˈi");
|
||||
map.insert('H', "ˈAʧ");
|
||||
map.insert('I', "ˈI");
|
||||
map.insert('J', "ʤˈA");
|
||||
map.insert('K', "kˈA");
|
||||
map.insert('L', "ˈɛl");
|
||||
map.insert('M', "ˈɛm");
|
||||
map.insert('N', "ˈɛn");
|
||||
map.insert('O', "ˈO");
|
||||
map.insert('P', "pˈi");
|
||||
map.insert('Q', "kjˈu");
|
||||
map.insert('R', "ˈɑɹ");
|
||||
map.insert('S', "ˈɛs");
|
||||
map.insert('T', "tˈi");
|
||||
map.insert('U', "jˈu");
|
||||
map.insert('V', "vˈi");
|
||||
map.insert('W', "dˈʌbᵊlju");
|
||||
map.insert('X', "ˈɛks");
|
||||
map.insert('Y', "wˈI");
|
||||
map.insert('Z', "zˈi");
|
||||
map
|
||||
});
|
||||
static ARPA_IPA_MAP: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("AA", "ɑ");
|
||||
map.insert("AE", "æ");
|
||||
map.insert("AH", "ə");
|
||||
map.insert("AO", "ɔ");
|
||||
map.insert("AW", "aʊ");
|
||||
map.insert("AY", "aɪ");
|
||||
map.insert("B", "b");
|
||||
map.insert("CH", "tʃ");
|
||||
map.insert("D", "d");
|
||||
map.insert("DH", "ð");
|
||||
map.insert("EH", "ɛ");
|
||||
map.insert("ER", "ɝ");
|
||||
map.insert("EY", "eɪ");
|
||||
map.insert("F", "f");
|
||||
map.insert("G", "ɡ");
|
||||
map.insert("HH", "h");
|
||||
map.insert("IH", "ɪ");
|
||||
map.insert("IY", "i");
|
||||
map.insert("JH", "dʒ");
|
||||
map.insert("K", "k");
|
||||
map.insert("L", "l");
|
||||
map.insert("M", "m");
|
||||
map.insert("N", "n");
|
||||
map.insert("NG", "ŋ");
|
||||
map.insert("OW", "oʊ");
|
||||
map.insert("OY", "ɔɪ");
|
||||
map.insert("P", "p");
|
||||
map.insert("R", "ɹ");
|
||||
map.insert("S", "s");
|
||||
map.insert("SH", "ʃ");
|
||||
map.insert("T", "t");
|
||||
map.insert("TH", "θ");
|
||||
map.insert("UH", "ʊ");
|
||||
map.insert("UW", "u");
|
||||
map.insert("V", "v");
|
||||
map.insert("W", "w");
|
||||
map.insert("Y", "j");
|
||||
map.insert("Z", "z");
|
||||
map.insert("ZH", "ʒ");
|
||||
map.insert("SIL", "");
|
||||
map
|
||||
});
|
||||
|
||||
/// 支持2025新增符号(如:吸气音ʘ)
|
||||
const SPECIAL_CASES: [(&str, &str); 3] = [("CLICK!", "ʘ"), ("TSK!", "ǀ"), ("TUT!", "ǁ")];
|
||||
|
||||
pub fn arpa_to_ipa(arpa: &str) -> Result<String, regex::Error> {
|
||||
let re = Regex::new(r"([A-Z!]+)(\d*)")?;
|
||||
|
||||
let Some(caps) = re.captures(arpa) else {
|
||||
return Ok(Default::default());
|
||||
};
|
||||
|
||||
// 处理特殊符号(2025新增)
|
||||
if let Some(sc) = SPECIAL_CASES.iter().find(|&&(s, _)| s == &caps[1]) {
|
||||
return Ok(sc.1.to_string());
|
||||
}
|
||||
|
||||
// 获取IPA映射
|
||||
let phoneme = ARPA_IPA_MAP
|
||||
.get(&caps[1])
|
||||
.map_or_else(|| letters_to_ipa(arpa), |i| i.to_string());
|
||||
|
||||
let mut result = String::with_capacity(arpa.len() * 2);
|
||||
// 添加重音标记(支持三级重音)
|
||||
result.push(match &caps[2] {
|
||||
"1" => 'ˈ',
|
||||
"2" => 'ˌ',
|
||||
"3" => '˧', // 2025新增中级重音
|
||||
_ => '\0',
|
||||
});
|
||||
|
||||
result.push_str(&phoneme);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn letters_to_ipa(letters: &str) -> String {
|
||||
let mut res = String::with_capacity(letters.len());
|
||||
for i in letters.chars() {
|
||||
if let Some(p) = LETTERS_IPA_MAP.get(&i) {
|
||||
res.push_str(p);
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
+3597
File diff suppressed because it is too large
Load Diff
+364
@@ -0,0 +1,364 @@
|
||||
/// 汉语拼音到国际音标的转换
|
||||
/// 参考了python的misaki库的zh.py。
|
||||
use std::{collections::HashMap, error::Error, fmt, sync::LazyLock};
|
||||
|
||||
const VALID_FINALS: [&str; 37] = [
|
||||
"i", "u", "ü", "a", "ia", "ua", "o", "uo", "e", "ie", "üe", "ai", "uai", "ei", "uei", "ao",
|
||||
"iao", "ou", "iou", "an", "ian", "uan", "üan", "en", "in", "uen", "ün", "ang", "iang", "uang",
|
||||
"eng", "ing", "ueng", "ong", "iong", "er", "ê",
|
||||
];
|
||||
const INITIALS: [&str; 21] = [
|
||||
"zh", "ch", "sh", "b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "n", "p", "q", "r", "s",
|
||||
"t", "x", "z",
|
||||
];
|
||||
|
||||
// 错误类型定义
|
||||
#[derive(Debug)]
|
||||
pub enum PinyinError {
|
||||
FinalNotFound(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for PinyinError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
PinyinError::FinalNotFound(tip) => write!(f, "Final not found: {}", tip),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for PinyinError {}
|
||||
|
||||
static INITIAL_MAPPING: LazyLock<HashMap<&'static str, Vec<Vec<&'static str>>>> =
|
||||
LazyLock::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
|
||||
map.insert("b", vec![vec!["p"]]);
|
||||
map.insert("c", vec![vec!["ʦʰ"]]);
|
||||
map.insert("ch", vec![vec!["ꭧʰ"]]);
|
||||
map.insert("d", vec![vec!["t"]]);
|
||||
map.insert("f", vec![vec!["f"]]);
|
||||
map.insert("g", vec![vec!["k"]]);
|
||||
map.insert("h", vec![vec!["x"], vec!["h"]]);
|
||||
map.insert("j", vec![vec!["ʨ"]]);
|
||||
map.insert("k", vec![vec!["kʰ"]]);
|
||||
map.insert("l", vec![vec!["l"]]);
|
||||
map.insert("m", vec![vec!["m"]]);
|
||||
map.insert("n", vec![vec!["n"]]);
|
||||
map.insert("p", vec![vec!["pʰ"]]);
|
||||
map.insert("q", vec![vec!["ʨʰ"]]);
|
||||
map.insert("r", vec![vec!["ɻ"], vec!["ʐ"]]);
|
||||
map.insert("s", vec![vec!["s"]]);
|
||||
map.insert("sh", vec![vec!["ʂ"]]);
|
||||
map.insert("t", vec![vec!["tʰ"]]);
|
||||
map.insert("x", vec![vec!["ɕ"]]);
|
||||
map.insert("z", vec![vec!["ʦ"]]);
|
||||
map.insert("zh", vec![vec!["ꭧ"]]);
|
||||
map
|
||||
});
|
||||
|
||||
static SYLLABIC_CONSONANT_MAPPINGS: LazyLock<HashMap<&'static str, Vec<Vec<&'static str>>>> =
|
||||
LazyLock::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("hm", vec![vec!["h", "m0"]]);
|
||||
map.insert("hng", vec![vec!["h", "ŋ0"]]);
|
||||
map.insert("m", vec![vec!["m0"]]);
|
||||
map.insert("n", vec![vec!["n0"]]);
|
||||
map.insert("ng", vec![vec!["ŋ0"]]);
|
||||
map
|
||||
});
|
||||
|
||||
static INTERJECTION_MAPPINGS: LazyLock<HashMap<&'static str, Vec<Vec<&'static str>>>> =
|
||||
LazyLock::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("io", vec![vec!["j", "ɔ0"]]);
|
||||
map.insert("ê", vec![vec!["ɛ0"]]);
|
||||
map.insert("er", vec![vec!["ɚ0"], vec!["aɚ̯0"]]);
|
||||
map.insert("o", vec![vec!["ɔ0"]]);
|
||||
map
|
||||
});
|
||||
|
||||
/// Duanmu (2000, p. 37) and Lin (2007, p. 68f)
|
||||
/// Diphtongs from Duanmu (2007, p. 40): au, əu, əi, ai
|
||||
/// Diphthongs from Lin (2007, p. 68f): au̯, ou̯, ei̯, ai̯
|
||||
static FINAL_MAPPING: LazyLock<HashMap<&'static str, Vec<Vec<&'static str>>>> =
|
||||
LazyLock::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("a", vec![vec!["a0"]]);
|
||||
map.insert("ai", vec![vec!["ai0"]]);
|
||||
map.insert("an", vec![vec!["a0", "n"]]);
|
||||
map.insert("ang", vec![vec!["a0", "ŋ"]]);
|
||||
map.insert("ao", vec![vec!["au0"]]);
|
||||
map.insert("e", vec![vec!["ɤ0"]]);
|
||||
map.insert("ei", vec![vec!["ei0"]]);
|
||||
map.insert("en", vec![vec!["ə0", "n"]]);
|
||||
map.insert("eng", vec![vec!["ə0", "ŋ"]]);
|
||||
map.insert("i", vec![vec!["i0"]]);
|
||||
map.insert("ia", vec![vec!["j", "a0"]]);
|
||||
map.insert("ian", vec![vec!["j", "ɛ0", "n"]]);
|
||||
map.insert("iang", vec![vec!["j", "a0", "ŋ"]]);
|
||||
map.insert("iao", vec![vec!["j", "au0"]]);
|
||||
map.insert("ie", vec![vec!["j", "e0"]]);
|
||||
map.insert("in", vec![vec!["i0", "n"]]);
|
||||
map.insert("iou", vec![vec!["j", "ou0"]]);
|
||||
map.insert("ing", vec![vec!["i0", "ŋ"]]);
|
||||
map.insert("iong", vec![vec!["j", "ʊ0", "ŋ"]]);
|
||||
map.insert("ong", vec![vec!["ʊ0", "ŋ"]]);
|
||||
map.insert("ou", vec![vec!["ou0"]]);
|
||||
map.insert("u", vec![vec!["u0"]]);
|
||||
map.insert("uei", vec![vec!["w", "ei0"]]);
|
||||
map.insert("ua", vec![vec!["w", "a0"]]);
|
||||
map.insert("uai", vec![vec!["w", "ai0"]]);
|
||||
map.insert("uan", vec![vec!["w", "a0", "n"]]);
|
||||
map.insert("uen", vec![vec!["w", "ə0", "n"]]);
|
||||
map.insert("uang", vec![vec!["w", "a0", "ŋ"]]);
|
||||
map.insert("ueng", vec![vec!["w", "ə0", "ŋ"]]);
|
||||
map.insert("ui", vec![vec!["w", "ei0"]]);
|
||||
map.insert("un", vec![vec!["w", "ə0", "n"]]);
|
||||
map.insert("uo", vec![vec!["w", "o0"]]);
|
||||
map.insert("o", vec![vec!["w", "o0"]]); // 注意:这里'o'的映射可能与预期不符,根据注释可能需要特殊处理
|
||||
map.insert("ü", vec![vec!["y0"]]);
|
||||
map.insert("üe", vec![vec!["ɥ", "e0"]]);
|
||||
map.insert("üan", vec![vec!["ɥ", "ɛ0", "n"]]);
|
||||
map.insert("ün", vec![vec!["y0", "n"]]);
|
||||
map
|
||||
});
|
||||
|
||||
static FINAL_MAPPING_AFTER_ZH_CH_SH_R: LazyLock<HashMap<&'static str, Vec<Vec<&'static str>>>> =
|
||||
LazyLock::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("i", vec![vec!["ɻ0"], vec!["ʐ0"]]);
|
||||
map
|
||||
});
|
||||
|
||||
static FINAL_MAPPING_AFTER_Z_C_S: LazyLock<HashMap<&'static str, Vec<Vec<&'static str>>>> =
|
||||
LazyLock::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("i", vec![vec!["ɹ0"], vec!["z0"]]);
|
||||
map
|
||||
});
|
||||
|
||||
static TONE_MAPPING: LazyLock<HashMap<u8, &'static str>> = LazyLock::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(1u8, "˥");
|
||||
map.insert(2u8, "˧˥");
|
||||
map.insert(3u8, "˧˩˧");
|
||||
map.insert(4u8, "˥˩");
|
||||
map.insert(5u8, "");
|
||||
map
|
||||
});
|
||||
|
||||
pub(crate) fn split_tone(pinyin: &str) -> (&str, u8) {
|
||||
if let Some(t) = pinyin
|
||||
.chars()
|
||||
.last()
|
||||
.and_then(|c| c.to_digit(10).map(|n| n as u8))
|
||||
{
|
||||
return (&pinyin[..pinyin.len() - 1], t);
|
||||
}
|
||||
(pinyin, 5)
|
||||
}
|
||||
|
||||
/// uen 转换,还原原始的韵母
|
||||
/// iou,uei,uen前面加声母的时候,写成iu,ui,un。
|
||||
/// 例如niu(牛),gui(归),lun(论)。
|
||||
fn convert_uen(s: &str) -> String {
|
||||
match s.strip_suffix('n') {
|
||||
Some(stem) if stem.ends_with(['u', 'ū', 'ú', 'ǔ', 'ù']) => {
|
||||
format!("{}en", stem)
|
||||
}
|
||||
_ => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ü 转换,还原原始的韵母
|
||||
/// ü行的韵母跟声母j,q,x拼的时候,写成ju(居),qu(区),xu(虚), ü上两点也省略;
|
||||
/// 但是跟声母n,l拼的时候,仍然写成nü(女),lü(吕)
|
||||
fn convert_uv(pinyin: &str) -> String {
|
||||
let chars = pinyin.chars().collect::<Vec<_>>();
|
||||
|
||||
match chars.as_slice() {
|
||||
[
|
||||
c @ ('j' | 'q' | 'x'),
|
||||
tone @ ('u' | 'ū' | 'ú' | 'ǔ' | 'ù'),
|
||||
rest @ ..,
|
||||
] => {
|
||||
let new_tone = match tone {
|
||||
'u' => 'ü',
|
||||
'ū' => 'ǖ',
|
||||
'ú' => 'ǘ',
|
||||
'ǔ' => 'ǚ',
|
||||
'ù' => 'ǜ',
|
||||
_ => unreachable!(),
|
||||
};
|
||||
format!("{}{}{}", c, new_tone, rest.iter().collect::<String>())
|
||||
}
|
||||
_ => pinyin.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// iou 转换,还原原始的韵母
|
||||
/// iou,uei,uen前面加声母的时候,写成iu,ui,un。
|
||||
/// 例如niu(牛),gui(归),lun(论)。
|
||||
fn convert_iou(pinyin: &str) -> String {
|
||||
let chars = pinyin.chars().collect::<Vec<_>>();
|
||||
|
||||
match chars.as_slice() {
|
||||
// 处理 iu 系列
|
||||
[.., 'i', u @ ('u' | 'ū' | 'ú' | 'ǔ' | 'ù')] => {
|
||||
format!("{}o{}", &pinyin[..pinyin.len() - 1], u)
|
||||
}
|
||||
|
||||
// 其他情况保持原样
|
||||
_ => pinyin.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// uei 转换,还原原始的韵母
|
||||
/// iou,uei,uen前面加声母的时候,写成iu,ui,un。
|
||||
/// 例如niu(牛),gui(归),lun(论)。
|
||||
fn convert_uei(pinyin: &str) -> String {
|
||||
let chars = pinyin.chars().collect::<Vec<_>>();
|
||||
|
||||
match chars.as_slice() {
|
||||
// 处理 ui 系列
|
||||
[.., 'u', i @ ('i' | 'ī' | 'í' | 'ǐ' | 'ì')] => {
|
||||
format!("{}e{}", &pinyin[..pinyin.len() - 1], i)
|
||||
}
|
||||
|
||||
// 其他情况保持原样
|
||||
_ => pinyin.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 零声母转换,还原原始的韵母
|
||||
/// i行的韵母,前面没有声母的时候,写成yi(衣),ya(呀),ye(耶),yao(腰),you(忧),yan(烟),yin(因),yang(央),ying(英),yong(雍)。
|
||||
/// u行的韵母,前面没有声母的时候,写成wu(乌),wa(蛙),wo(窝),wai(歪),wei(威),wan(弯),wen(温),wang(汪),weng(翁)。
|
||||
/// ü行的韵母,前面没有声母的时候,写成yu(迂),yue(约),yuan(冤),yun(晕);ü上两点省略。"""
|
||||
pub(crate) fn convert_zero_consonant(pinyin: &str) -> String {
|
||||
let mut buffer = String::with_capacity(pinyin.len() + 2);
|
||||
let chars: Vec<char> = pinyin.chars().collect();
|
||||
|
||||
match chars.as_slice() {
|
||||
// 处理Y系转换
|
||||
['y', 'u', rest @ ..] => {
|
||||
buffer.push('ü');
|
||||
buffer.extend(rest.iter());
|
||||
}
|
||||
['y', u @ ('ū' | 'ú' | 'ǔ' | 'ù'), rest @ ..] => {
|
||||
buffer.push(match u {
|
||||
'ū' => 'ǖ', // ü 第一声
|
||||
'ú' => 'ǘ', // ü 第二声
|
||||
'ǔ' => 'ǚ', // ü 第三声
|
||||
'ù' => 'ǜ', // ü 第四声
|
||||
_ => unreachable!(),
|
||||
});
|
||||
buffer.extend(rest.iter());
|
||||
}
|
||||
['y', i @ ('i' | 'ī' | 'í' | 'ǐ' | 'ì'), rest @ ..] => {
|
||||
buffer.push(*i);
|
||||
buffer.extend(rest.iter());
|
||||
}
|
||||
['y', rest @ ..] => {
|
||||
buffer.push('i');
|
||||
buffer.extend(rest);
|
||||
}
|
||||
|
||||
// 处理W系转换
|
||||
['w', u @ ('u' | 'ū' | 'ú' | 'ǔ' | 'ù'), rest @ ..] => {
|
||||
buffer.push(*u);
|
||||
buffer.extend(rest.iter());
|
||||
}
|
||||
['w', rest @ ..] => {
|
||||
buffer.push('u');
|
||||
buffer.extend(rest);
|
||||
}
|
||||
|
||||
// 无需转换的情况
|
||||
_ => return pinyin.to_string(),
|
||||
}
|
||||
|
||||
// 有效性验证
|
||||
if VALID_FINALS.contains(&buffer.as_str()) {
|
||||
buffer
|
||||
} else {
|
||||
pinyin.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn split_initial(pinyin: &str) -> (&'static str, &str) {
|
||||
for &initial in &INITIALS {
|
||||
if let Some(stripped) = pinyin.strip_prefix(initial) {
|
||||
return (initial, stripped);
|
||||
}
|
||||
}
|
||||
("", pinyin)
|
||||
}
|
||||
|
||||
fn apply_tone(variants: &[Vec<&str>], tone: u8) -> Vec<Vec<String>> {
|
||||
let tone_str = TONE_MAPPING.get(&tone).unwrap_or(&"");
|
||||
variants
|
||||
.iter()
|
||||
.map(|v| v.iter().map(|s| s.replace("0", tone_str)).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn pinyin_to_ipa(pinyin: &str) -> Result<Vec<Vec<String>>, PinyinError> {
|
||||
let (pinyin, tone) = split_tone(pinyin);
|
||||
let pinyin = convert_zero_consonant(pinyin);
|
||||
let pinyin = convert_uv(&pinyin);
|
||||
let pinyin = convert_iou(&pinyin);
|
||||
let pinyin = convert_uei(&pinyin);
|
||||
let pinyin = convert_uen(&pinyin);
|
||||
|
||||
// 处理特殊成音节辅音和感叹词
|
||||
if let Some(ipa) = SYLLABIC_CONSONANT_MAPPINGS.get(pinyin.as_str()) {
|
||||
return Ok(apply_tone(ipa, tone)
|
||||
.into_iter()
|
||||
.map(|i| i.into_iter().collect())
|
||||
.collect());
|
||||
}
|
||||
if let Some(ipa) = INTERJECTION_MAPPINGS.get(pinyin.as_str()) {
|
||||
return Ok(apply_tone(ipa, tone)
|
||||
.into_iter()
|
||||
.map(|i| i.into_iter().collect())
|
||||
.collect());
|
||||
}
|
||||
|
||||
// 分解声母韵母
|
||||
let (initial_part, final_part) = split_initial(pinyin.as_str());
|
||||
|
||||
// 获取韵母IPA
|
||||
let final_ipa = match initial_part {
|
||||
"zh" | "ch" | "sh" | "r" if FINAL_MAPPING_AFTER_ZH_CH_SH_R.contains_key(final_part) => {
|
||||
FINAL_MAPPING_AFTER_ZH_CH_SH_R.get(final_part)
|
||||
}
|
||||
"z" | "c" | "s" if FINAL_MAPPING_AFTER_Z_C_S.contains_key(final_part) => {
|
||||
FINAL_MAPPING_AFTER_Z_C_S.get(final_part)
|
||||
}
|
||||
_ => FINAL_MAPPING.get(final_part),
|
||||
}
|
||||
.ok_or(PinyinError::FinalNotFound(final_part.to_owned()))?;
|
||||
|
||||
// 组合所有可能
|
||||
let mut result = Vec::<Vec<String>>::new();
|
||||
let initials = INITIAL_MAPPING
|
||||
.get(initial_part)
|
||||
.map_or(vec![vec![Default::default()]], |i| {
|
||||
i.iter()
|
||||
.map(|i| i.iter().map(|i| i.to_string()).collect())
|
||||
.collect()
|
||||
});
|
||||
|
||||
for i in initials.into_iter() {
|
||||
for j in apply_tone(final_ipa, tone).into_iter() {
|
||||
result.push(
|
||||
i.iter()
|
||||
.chain(j.iter())
|
||||
.map(|i| i.to_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
Vendored
+673
@@ -0,0 +1,673 @@
|
||||
use crate::KokoroError;
|
||||
|
||||
//noinspection SpellCheckingInspection
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Voice {
|
||||
// v1.0
|
||||
ZmYunyang(f32),
|
||||
ZfXiaoni(f32),
|
||||
AfJessica(f32),
|
||||
BfLily(f32),
|
||||
ZfXiaobei(f32),
|
||||
ZmYunxia(f32),
|
||||
AfHeart(f32),
|
||||
BfEmma(f32),
|
||||
AmPuck(f32),
|
||||
BfAlice(f32),
|
||||
HfAlpha(f32),
|
||||
BfIsabella(f32),
|
||||
AfNova(f32),
|
||||
AmFenrir(f32),
|
||||
EmAlex(f32),
|
||||
ImNicola(f32),
|
||||
PmAlex(f32),
|
||||
AfAlloy(f32),
|
||||
ZmYunxi(f32),
|
||||
AfSarah(f32),
|
||||
JfNezumi(f32),
|
||||
BmDaniel(f32),
|
||||
JfTebukuro(f32),
|
||||
JfAlpha(f32),
|
||||
JmKumo(f32),
|
||||
EmSanta(f32),
|
||||
AmLiam(f32),
|
||||
AmSanta(f32),
|
||||
AmEric(f32),
|
||||
BmFable(f32),
|
||||
AfBella(f32),
|
||||
BmLewis(f32),
|
||||
PfDora(f32),
|
||||
AfNicole(f32),
|
||||
BmGeorge(f32),
|
||||
AmOnyx(f32),
|
||||
HmPsi(f32),
|
||||
HfBeta(f32),
|
||||
HmOmega(f32),
|
||||
ZfXiaoxiao(f32),
|
||||
FfSiwis(f32),
|
||||
EfDora(f32),
|
||||
AfAoede(f32),
|
||||
AmEcho(f32),
|
||||
AmMichael(f32),
|
||||
AfKore(f32),
|
||||
ZfXiaoyi(f32),
|
||||
JfGongitsune(f32),
|
||||
AmAdam(f32),
|
||||
IfSara(f32),
|
||||
AfSky(f32),
|
||||
PmSanta(f32),
|
||||
AfRiver(f32),
|
||||
ZmYunjian(f32),
|
||||
|
||||
// v1.1
|
||||
Zm029(i32),
|
||||
Zf048(i32),
|
||||
Zf008(i32),
|
||||
Zm014(i32),
|
||||
Zf003(i32),
|
||||
Zf047(i32),
|
||||
Zm080(i32),
|
||||
Zf094(i32),
|
||||
Zf046(i32),
|
||||
Zm054(i32),
|
||||
Zf001(i32),
|
||||
Zm062(i32),
|
||||
BfVale(i32),
|
||||
Zf044(i32),
|
||||
Zf005(i32),
|
||||
Zf028(i32),
|
||||
Zf059(i32),
|
||||
Zm030(i32),
|
||||
Zf074(i32),
|
||||
Zm009(i32),
|
||||
Zf004(i32),
|
||||
Zf021(i32),
|
||||
Zm095(i32),
|
||||
Zm041(i32),
|
||||
Zf087(i32),
|
||||
Zf039(i32),
|
||||
Zm031(i32),
|
||||
Zf007(i32),
|
||||
Zf038(i32),
|
||||
Zf092(i32),
|
||||
Zm056(i32),
|
||||
Zf099(i32),
|
||||
Zm010(i32),
|
||||
Zm069(i32),
|
||||
Zm016(i32),
|
||||
Zm068(i32),
|
||||
Zf083(i32),
|
||||
Zf093(i32),
|
||||
Zf006(i32),
|
||||
Zf026(i32),
|
||||
Zm053(i32),
|
||||
Zm064(i32),
|
||||
AfSol(i32),
|
||||
Zf042(i32),
|
||||
Zf084(i32),
|
||||
Zf073(i32),
|
||||
Zf067(i32),
|
||||
Zm025(i32),
|
||||
Zm020(i32),
|
||||
Zm050(i32),
|
||||
Zf070(i32),
|
||||
Zf002(i32),
|
||||
Zf032(i32),
|
||||
Zm091(i32),
|
||||
Zm066(i32),
|
||||
Zm089(i32),
|
||||
Zm034(i32),
|
||||
Zm100(i32),
|
||||
Zf086(i32),
|
||||
Zf040(i32),
|
||||
Zm011(i32),
|
||||
Zm098(i32),
|
||||
Zm015(i32),
|
||||
Zf051(i32),
|
||||
Zm065(i32),
|
||||
Zf076(i32),
|
||||
Zf036(i32),
|
||||
Zm033(i32),
|
||||
Zf018(i32),
|
||||
Zf017(i32),
|
||||
Zf049(i32),
|
||||
AfMaple(i32),
|
||||
Zm082(i32),
|
||||
Zm057(i32),
|
||||
Zf079(i32),
|
||||
Zf022(i32),
|
||||
Zm063(i32),
|
||||
Zf060(i32),
|
||||
Zf019(i32),
|
||||
Zm097(i32),
|
||||
Zm096(i32),
|
||||
Zf023(i32),
|
||||
Zf027(i32),
|
||||
Zf085(i32),
|
||||
Zf077(i32),
|
||||
Zm035(i32),
|
||||
Zf088(i32),
|
||||
Zf024(i32),
|
||||
Zf072(i32),
|
||||
Zm055(i32),
|
||||
Zm052(i32),
|
||||
Zf071(i32),
|
||||
Zm061(i32),
|
||||
Zf078(i32),
|
||||
Zm013(i32),
|
||||
Zm081(i32),
|
||||
Zm037(i32),
|
||||
Zf090(i32),
|
||||
Zf043(i32),
|
||||
Zm058(i32),
|
||||
Zm012(i32),
|
||||
Zm045(i32),
|
||||
Zf075(i32),
|
||||
}
|
||||
|
||||
impl Voice {
|
||||
//noinspection SpellCheckingInspection
|
||||
pub(super) fn get_name(&self) -> &str {
|
||||
match self {
|
||||
Self::ZmYunyang(_) => "zm_yunyang",
|
||||
Self::ZfXiaoni(_) => "zf_xiaoni",
|
||||
Self::AfJessica(_) => "af_jessica",
|
||||
Self::BfLily(_) => "bf_lily",
|
||||
Self::ZfXiaobei(_) => "zf_xiaobei",
|
||||
Self::ZmYunxia(_) => "zm_yunxia",
|
||||
Self::AfHeart(_) => "af_heart",
|
||||
Self::BfEmma(_) => "bf_emma",
|
||||
Self::AmPuck(_) => "am_puck",
|
||||
Self::BfAlice(_) => "bf_alice",
|
||||
Self::HfAlpha(_) => "hf_alpha",
|
||||
Self::BfIsabella(_) => "bf_isabella",
|
||||
Self::AfNova(_) => "af_nova",
|
||||
Self::AmFenrir(_) => "am_fenrir",
|
||||
Self::EmAlex(_) => "em_alex",
|
||||
Self::ImNicola(_) => "im_nicola",
|
||||
Self::PmAlex(_) => "pm_alex",
|
||||
Self::AfAlloy(_) => "af_alloy",
|
||||
Self::ZmYunxi(_) => "zm_yunxi",
|
||||
Self::AfSarah(_) => "af_sarah",
|
||||
Self::JfNezumi(_) => "jf_nezumi",
|
||||
Self::BmDaniel(_) => "bm_daniel",
|
||||
Self::JfTebukuro(_) => "jf_tebukuro",
|
||||
Self::JfAlpha(_) => "jf_alpha",
|
||||
Self::JmKumo(_) => "jm_kumo",
|
||||
Self::EmSanta(_) => "em_santa",
|
||||
Self::AmLiam(_) => "am_liam",
|
||||
Self::AmSanta(_) => "am_santa",
|
||||
Self::AmEric(_) => "am_eric",
|
||||
Self::BmFable(_) => "bm_fable",
|
||||
Self::AfBella(_) => "af_bella",
|
||||
Self::BmLewis(_) => "bm_lewis",
|
||||
Self::PfDora(_) => "pf_dora",
|
||||
Self::AfNicole(_) => "af_nicole",
|
||||
Self::BmGeorge(_) => "bm_george",
|
||||
Self::AmOnyx(_) => "am_onyx",
|
||||
Self::HmPsi(_) => "hm_psi",
|
||||
Self::HfBeta(_) => "hf_beta",
|
||||
Self::HmOmega(_) => "hm_omega",
|
||||
Self::ZfXiaoxiao(_) => "zf_xiaoxiao",
|
||||
Self::FfSiwis(_) => "ff_siwis",
|
||||
Self::EfDora(_) => "ef_dora",
|
||||
Self::AfAoede(_) => "af_aoede",
|
||||
Self::AmEcho(_) => "am_echo",
|
||||
Self::AmMichael(_) => "am_michael",
|
||||
Self::AfKore(_) => "af_kore",
|
||||
Self::ZfXiaoyi(_) => "zf_xiaoyi",
|
||||
Self::JfGongitsune(_) => "jf_gongitsune",
|
||||
Self::AmAdam(_) => "am_adam",
|
||||
Self::IfSara(_) => "if_sara",
|
||||
Self::AfSky(_) => "af_sky",
|
||||
Self::PmSanta(_) => "pm_santa",
|
||||
Self::AfRiver(_) => "af_river",
|
||||
Self::ZmYunjian(_) => "zm_yunjian",
|
||||
Self::Zm029(_) => "zm_029",
|
||||
Self::Zf048(_) => "zf_048",
|
||||
Self::Zf008(_) => "zf_008",
|
||||
Self::Zm014(_) => "zm_014",
|
||||
Self::Zf003(_) => "zf_003",
|
||||
Self::Zf047(_) => "zf_047",
|
||||
Self::Zm080(_) => "zm_080",
|
||||
Self::Zf094(_) => "zf_094",
|
||||
Self::Zf046(_) => "zf_046",
|
||||
Self::Zm054(_) => "zm_054",
|
||||
Self::Zf001(_) => "zf_001",
|
||||
Self::Zm062(_) => "zm_062",
|
||||
Self::BfVale(_) => "bf_vale",
|
||||
Self::Zf044(_) => "zf_044",
|
||||
Self::Zf005(_) => "zf_005",
|
||||
Self::Zf028(_) => "zf_028",
|
||||
Self::Zf059(_) => "zf_059",
|
||||
Self::Zm030(_) => "zm_030",
|
||||
Self::Zf074(_) => "zf_074",
|
||||
Self::Zm009(_) => "zm_009",
|
||||
Self::Zf004(_) => "zf_004",
|
||||
Self::Zf021(_) => "zf_021",
|
||||
Self::Zm095(_) => "zm_095",
|
||||
Self::Zm041(_) => "zm_041",
|
||||
Self::Zf087(_) => "zf_087",
|
||||
Self::Zf039(_) => "zf_039",
|
||||
Self::Zm031(_) => "zm_031",
|
||||
Self::Zf007(_) => "zf_007",
|
||||
Self::Zf038(_) => "zf_038",
|
||||
Self::Zf092(_) => "zf_092",
|
||||
Self::Zm056(_) => "zm_056",
|
||||
Self::Zf099(_) => "zf_099",
|
||||
Self::Zm010(_) => "zm_010",
|
||||
Self::Zm069(_) => "zm_069",
|
||||
Self::Zm016(_) => "zm_016",
|
||||
Self::Zm068(_) => "zm_068",
|
||||
Self::Zf083(_) => "zf_083",
|
||||
Self::Zf093(_) => "zf_093",
|
||||
Self::Zf006(_) => "zf_006",
|
||||
Self::Zf026(_) => "zf_026",
|
||||
Self::Zm053(_) => "zm_053",
|
||||
Self::Zm064(_) => "zm_064",
|
||||
Self::AfSol(_) => "af_sol",
|
||||
Self::Zf042(_) => "zf_042",
|
||||
Self::Zf084(_) => "zf_084",
|
||||
Self::Zf073(_) => "zf_073",
|
||||
Self::Zf067(_) => "zf_067",
|
||||
Self::Zm025(_) => "zm_025",
|
||||
Self::Zm020(_) => "zm_020",
|
||||
Self::Zm050(_) => "zm_050",
|
||||
Self::Zf070(_) => "zf_070",
|
||||
Self::Zf002(_) => "zf_002",
|
||||
Self::Zf032(_) => "zf_032",
|
||||
Self::Zm091(_) => "zm_091",
|
||||
Self::Zm066(_) => "zm_066",
|
||||
Self::Zm089(_) => "zm_089",
|
||||
Self::Zm034(_) => "zm_034",
|
||||
Self::Zm100(_) => "zm_100",
|
||||
Self::Zf086(_) => "zf_086",
|
||||
Self::Zf040(_) => "zf_040",
|
||||
Self::Zm011(_) => "zm_011",
|
||||
Self::Zm098(_) => "zm_098",
|
||||
Self::Zm015(_) => "zm_015",
|
||||
Self::Zf051(_) => "zf_051",
|
||||
Self::Zm065(_) => "zm_065",
|
||||
Self::Zf076(_) => "zf_076",
|
||||
Self::Zf036(_) => "zf_036",
|
||||
Self::Zm033(_) => "zm_033",
|
||||
Self::Zf018(_) => "zf_018",
|
||||
Self::Zf017(_) => "zf_017",
|
||||
Self::Zf049(_) => "zf_049",
|
||||
Self::AfMaple(_) => "af_maple",
|
||||
Self::Zm082(_) => "zm_082",
|
||||
Self::Zm057(_) => "zm_057",
|
||||
Self::Zf079(_) => "zf_079",
|
||||
Self::Zf022(_) => "zf_022",
|
||||
Self::Zm063(_) => "zm_063",
|
||||
Self::Zf060(_) => "zf_060",
|
||||
Self::Zf019(_) => "zf_019",
|
||||
Self::Zm097(_) => "zm_097",
|
||||
Self::Zm096(_) => "zm_096",
|
||||
Self::Zf023(_) => "zf_023",
|
||||
Self::Zf027(_) => "zf_027",
|
||||
Self::Zf085(_) => "zf_085",
|
||||
Self::Zf077(_) => "zf_077",
|
||||
Self::Zm035(_) => "zm_035",
|
||||
Self::Zf088(_) => "zf_088",
|
||||
Self::Zf024(_) => "zf_024",
|
||||
Self::Zf072(_) => "zf_072",
|
||||
Self::Zm055(_) => "zm_055",
|
||||
Self::Zm052(_) => "zm_052",
|
||||
Self::Zf071(_) => "zf_071",
|
||||
Self::Zm061(_) => "zm_061",
|
||||
Self::Zf078(_) => "zf_078",
|
||||
Self::Zm013(_) => "zm_013",
|
||||
Self::Zm081(_) => "zm_081",
|
||||
Self::Zm037(_) => "zm_037",
|
||||
Self::Zf090(_) => "zf_090",
|
||||
Self::Zf043(_) => "zf_043",
|
||||
Self::Zm058(_) => "zm_058",
|
||||
Self::Zm012(_) => "zm_012",
|
||||
Self::Zm045(_) => "zm_045",
|
||||
Self::Zf075(_) => "zf_075",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_v10_supported(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::ZmYunyang(_)
|
||||
| Self::ZfXiaoni(_)
|
||||
| Self::AfJessica(_)
|
||||
| Self::BfLily(_)
|
||||
| Self::ZfXiaobei(_)
|
||||
| Self::ZmYunxia(_)
|
||||
| Self::AfHeart(_)
|
||||
| Self::BfEmma(_)
|
||||
| Self::AmPuck(_)
|
||||
| Self::BfAlice(_)
|
||||
| Self::HfAlpha(_)
|
||||
| Self::BfIsabella(_)
|
||||
| Self::AfNova(_)
|
||||
| Self::AmFenrir(_)
|
||||
| Self::EmAlex(_)
|
||||
| Self::ImNicola(_)
|
||||
| Self::PmAlex(_)
|
||||
| Self::AfAlloy(_)
|
||||
| Self::ZmYunxi(_)
|
||||
| Self::AfSarah(_)
|
||||
| Self::JfNezumi(_)
|
||||
| Self::BmDaniel(_)
|
||||
| Self::JfTebukuro(_)
|
||||
| Self::JfAlpha(_)
|
||||
| Self::JmKumo(_)
|
||||
| Self::EmSanta(_)
|
||||
| Self::AmLiam(_)
|
||||
| Self::AmSanta(_)
|
||||
| Self::AmEric(_)
|
||||
| Self::BmFable(_)
|
||||
| Self::AfBella(_)
|
||||
| Self::BmLewis(_)
|
||||
| Self::PfDora(_)
|
||||
| Self::AfNicole(_)
|
||||
| Self::BmGeorge(_)
|
||||
| Self::AmOnyx(_)
|
||||
| Self::HmPsi(_)
|
||||
| Self::HfBeta(_)
|
||||
| Self::HmOmega(_)
|
||||
| Self::ZfXiaoxiao(_)
|
||||
| Self::FfSiwis(_)
|
||||
| Self::EfDora(_)
|
||||
| Self::AfAoede(_)
|
||||
| Self::AmEcho(_)
|
||||
| Self::AmMichael(_)
|
||||
| Self::AfKore(_)
|
||||
| Self::ZfXiaoyi(_)
|
||||
| Self::JfGongitsune(_)
|
||||
| Self::AmAdam(_)
|
||||
| Self::IfSara(_)
|
||||
| Self::AfSky(_)
|
||||
| Self::PmSanta(_)
|
||||
| Self::AfRiver(_)
|
||||
| Self::ZmYunjian(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn is_v11_supported(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Zm029(_)
|
||||
| Self::Zf048(_)
|
||||
| Self::Zf008(_)
|
||||
| Self::Zm014(_)
|
||||
| Self::Zf003(_)
|
||||
| Self::Zf047(_)
|
||||
| Self::Zm080(_)
|
||||
| Self::Zf094(_)
|
||||
| Self::Zf046(_)
|
||||
| Self::Zm054(_)
|
||||
| Self::Zf001(_)
|
||||
| Self::Zm062(_)
|
||||
| Self::BfVale(_)
|
||||
| Self::Zf044(_)
|
||||
| Self::Zf005(_)
|
||||
| Self::Zf028(_)
|
||||
| Self::Zf059(_)
|
||||
| Self::Zm030(_)
|
||||
| Self::Zf074(_)
|
||||
| Self::Zm009(_)
|
||||
| Self::Zf004(_)
|
||||
| Self::Zf021(_)
|
||||
| Self::Zm095(_)
|
||||
| Self::Zm041(_)
|
||||
| Self::Zf087(_)
|
||||
| Self::Zf039(_)
|
||||
| Self::Zm031(_)
|
||||
| Self::Zf007(_)
|
||||
| Self::Zf038(_)
|
||||
| Self::Zf092(_)
|
||||
| Self::Zm056(_)
|
||||
| Self::Zf099(_)
|
||||
| Self::Zm010(_)
|
||||
| Self::Zm069(_)
|
||||
| Self::Zm016(_)
|
||||
| Self::Zm068(_)
|
||||
| Self::Zf083(_)
|
||||
| Self::Zf093(_)
|
||||
| Self::Zf006(_)
|
||||
| Self::Zf026(_)
|
||||
| Self::Zm053(_)
|
||||
| Self::Zm064(_)
|
||||
| Self::AfSol(_)
|
||||
| Self::Zf042(_)
|
||||
| Self::Zf084(_)
|
||||
| Self::Zf073(_)
|
||||
| Self::Zf067(_)
|
||||
| Self::Zm025(_)
|
||||
| Self::Zm020(_)
|
||||
| Self::Zm050(_)
|
||||
| Self::Zf070(_)
|
||||
| Self::Zf002(_)
|
||||
| Self::Zf032(_)
|
||||
| Self::Zm091(_)
|
||||
| Self::Zm066(_)
|
||||
| Self::Zm089(_)
|
||||
| Self::Zm034(_)
|
||||
| Self::Zm100(_)
|
||||
| Self::Zf086(_)
|
||||
| Self::Zf040(_)
|
||||
| Self::Zm011(_)
|
||||
| Self::Zm098(_)
|
||||
| Self::Zm015(_)
|
||||
| Self::Zf051(_)
|
||||
| Self::Zm065(_)
|
||||
| Self::Zf076(_)
|
||||
| Self::Zf036(_)
|
||||
| Self::Zm033(_)
|
||||
| Self::Zf018(_)
|
||||
| Self::Zf017(_)
|
||||
| Self::Zf049(_)
|
||||
| Self::AfMaple(_)
|
||||
| Self::Zm082(_)
|
||||
| Self::Zm057(_)
|
||||
| Self::Zf079(_)
|
||||
| Self::Zf022(_)
|
||||
| Self::Zm063(_)
|
||||
| Self::Zf060(_)
|
||||
| Self::Zf019(_)
|
||||
| Self::Zm097(_)
|
||||
| Self::Zm096(_)
|
||||
| Self::Zf023(_)
|
||||
| Self::Zf027(_)
|
||||
| Self::Zf085(_)
|
||||
| Self::Zf077(_)
|
||||
| Self::Zm035(_)
|
||||
| Self::Zf088(_)
|
||||
| Self::Zf024(_)
|
||||
| Self::Zf072(_)
|
||||
| Self::Zm055(_)
|
||||
| Self::Zm052(_)
|
||||
| Self::Zf071(_)
|
||||
| Self::Zm061(_)
|
||||
| Self::Zf078(_)
|
||||
| Self::Zm013(_)
|
||||
| Self::Zm081(_)
|
||||
| Self::Zm037(_)
|
||||
| Self::Zf090(_)
|
||||
| Self::Zf043(_)
|
||||
| Self::Zm058(_)
|
||||
| Self::Zm012(_)
|
||||
| Self::Zm045(_)
|
||||
| Self::Zf075(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn get_speed_v10(&self) -> Result<f32, KokoroError> {
|
||||
match self {
|
||||
Self::ZmYunyang(v)
|
||||
| Self::ZfXiaoni(v)
|
||||
| Self::AfJessica(v)
|
||||
| Self::BfLily(v)
|
||||
| Self::ZfXiaobei(v)
|
||||
| Self::ZmYunxia(v)
|
||||
| Self::AfHeart(v)
|
||||
| Self::BfEmma(v)
|
||||
| Self::AmPuck(v)
|
||||
| Self::BfAlice(v)
|
||||
| Self::HfAlpha(v)
|
||||
| Self::BfIsabella(v)
|
||||
| Self::AfNova(v)
|
||||
| Self::AmFenrir(v)
|
||||
| Self::EmAlex(v)
|
||||
| Self::ImNicola(v)
|
||||
| Self::PmAlex(v)
|
||||
| Self::AfAlloy(v)
|
||||
| Self::ZmYunxi(v)
|
||||
| Self::AfSarah(v)
|
||||
| Self::JfNezumi(v)
|
||||
| Self::BmDaniel(v)
|
||||
| Self::JfTebukuro(v)
|
||||
| Self::JfAlpha(v)
|
||||
| Self::JmKumo(v)
|
||||
| Self::EmSanta(v)
|
||||
| Self::AmLiam(v)
|
||||
| Self::AmSanta(v)
|
||||
| Self::AmEric(v)
|
||||
| Self::BmFable(v)
|
||||
| Self::AfBella(v)
|
||||
| Self::BmLewis(v)
|
||||
| Self::PfDora(v)
|
||||
| Self::AfNicole(v)
|
||||
| Self::BmGeorge(v)
|
||||
| Self::AmOnyx(v)
|
||||
| Self::HmPsi(v)
|
||||
| Self::HfBeta(v)
|
||||
| Self::HmOmega(v)
|
||||
| Self::ZfXiaoxiao(v)
|
||||
| Self::FfSiwis(v)
|
||||
| Self::EfDora(v)
|
||||
| Self::AfAoede(v)
|
||||
| Self::AmEcho(v)
|
||||
| Self::AmMichael(v)
|
||||
| Self::AfKore(v)
|
||||
| Self::ZfXiaoyi(v)
|
||||
| Self::JfGongitsune(v)
|
||||
| Self::AmAdam(v)
|
||||
| Self::IfSara(v)
|
||||
| Self::AfSky(v)
|
||||
| Self::PmSanta(v)
|
||||
| Self::AfRiver(v)
|
||||
| Self::ZmYunjian(v) => Ok(*v),
|
||||
_ => Err(KokoroError::VoiceVersionInvalid(
|
||||
"Expect version 1.0".to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get_speed_v11(&self) -> Result<i32, KokoroError> {
|
||||
match self {
|
||||
Self::Zm029(v)
|
||||
| Self::Zf048(v)
|
||||
| Self::Zf008(v)
|
||||
| Self::Zm014(v)
|
||||
| Self::Zf003(v)
|
||||
| Self::Zf047(v)
|
||||
| Self::Zm080(v)
|
||||
| Self::Zf094(v)
|
||||
| Self::Zf046(v)
|
||||
| Self::Zm054(v)
|
||||
| Self::Zf001(v)
|
||||
| Self::Zm062(v)
|
||||
| Self::BfVale(v)
|
||||
| Self::Zf044(v)
|
||||
| Self::Zf005(v)
|
||||
| Self::Zf028(v)
|
||||
| Self::Zf059(v)
|
||||
| Self::Zm030(v)
|
||||
| Self::Zf074(v)
|
||||
| Self::Zm009(v)
|
||||
| Self::Zf004(v)
|
||||
| Self::Zf021(v)
|
||||
| Self::Zm095(v)
|
||||
| Self::Zm041(v)
|
||||
| Self::Zf087(v)
|
||||
| Self::Zf039(v)
|
||||
| Self::Zm031(v)
|
||||
| Self::Zf007(v)
|
||||
| Self::Zf038(v)
|
||||
| Self::Zf092(v)
|
||||
| Self::Zm056(v)
|
||||
| Self::Zf099(v)
|
||||
| Self::Zm010(v)
|
||||
| Self::Zm069(v)
|
||||
| Self::Zm016(v)
|
||||
| Self::Zm068(v)
|
||||
| Self::Zf083(v)
|
||||
| Self::Zf093(v)
|
||||
| Self::Zf006(v)
|
||||
| Self::Zf026(v)
|
||||
| Self::Zm053(v)
|
||||
| Self::Zm064(v)
|
||||
| Self::AfSol(v)
|
||||
| Self::Zf042(v)
|
||||
| Self::Zf084(v)
|
||||
| Self::Zf073(v)
|
||||
| Self::Zf067(v)
|
||||
| Self::Zm025(v)
|
||||
| Self::Zm020(v)
|
||||
| Self::Zm050(v)
|
||||
| Self::Zf070(v)
|
||||
| Self::Zf002(v)
|
||||
| Self::Zf032(v)
|
||||
| Self::Zm091(v)
|
||||
| Self::Zm066(v)
|
||||
| Self::Zm089(v)
|
||||
| Self::Zm034(v)
|
||||
| Self::Zm100(v)
|
||||
| Self::Zf086(v)
|
||||
| Self::Zf040(v)
|
||||
| Self::Zm011(v)
|
||||
| Self::Zm098(v)
|
||||
| Self::Zm015(v)
|
||||
| Self::Zf051(v)
|
||||
| Self::Zm065(v)
|
||||
| Self::Zf076(v)
|
||||
| Self::Zf036(v)
|
||||
| Self::Zm033(v)
|
||||
| Self::Zf018(v)
|
||||
| Self::Zf017(v)
|
||||
| Self::Zf049(v)
|
||||
| Self::AfMaple(v)
|
||||
| Self::Zm082(v)
|
||||
| Self::Zm057(v)
|
||||
| Self::Zf079(v)
|
||||
| Self::Zf022(v)
|
||||
| Self::Zm063(v)
|
||||
| Self::Zf060(v)
|
||||
| Self::Zf019(v)
|
||||
| Self::Zm097(v)
|
||||
| Self::Zm096(v)
|
||||
| Self::Zf023(v)
|
||||
| Self::Zf027(v)
|
||||
| Self::Zf085(v)
|
||||
| Self::Zf077(v)
|
||||
| Self::Zm035(v)
|
||||
| Self::Zf088(v)
|
||||
| Self::Zf024(v)
|
||||
| Self::Zf072(v)
|
||||
| Self::Zm055(v)
|
||||
| Self::Zm052(v)
|
||||
| Self::Zf071(v)
|
||||
| Self::Zm061(v)
|
||||
| Self::Zf078(v)
|
||||
| Self::Zm013(v)
|
||||
| Self::Zm081(v)
|
||||
| Self::Zm037(v)
|
||||
| Self::Zf090(v)
|
||||
| Self::Zf043(v)
|
||||
| Self::Zm058(v)
|
||||
| Self::Zm012(v)
|
||||
| Self::Zm045(v)
|
||||
| Self::Zf075(v) => Ok(*v),
|
||||
_ => Err(KokoroError::VoiceVersionInvalid(
|
||||
"Expect version 1.1".to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.25.1',
|
||||
version: '1.27.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
+126
-17
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* Application configuration — loaded from .nogit/config.json.
|
||||
* Application configuration models and normalization helpers.
|
||||
*
|
||||
* All network addresses, credentials, provider settings, device definitions,
|
||||
* and routing rules come from this single config file. No hardcoded values
|
||||
* in source.
|
||||
* and routing rules are persisted through SmartData.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { IFaxBoxConfig } from './faxbox.ts';
|
||||
import type { IVoiceboxConfig } from './voicebox.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -113,6 +111,9 @@ export interface ISipRouteAction {
|
||||
/** Voicemail fallback for matched inbound routes. */
|
||||
voicemailBox?: string;
|
||||
|
||||
/** Fax inbox target for matched inbound routes. */
|
||||
faxBox?: string;
|
||||
|
||||
/** Route to an IVR menu by menu ID (skip ringing devices). */
|
||||
ivrMenuId?: string;
|
||||
|
||||
@@ -189,6 +190,7 @@ export interface IContact {
|
||||
// "number | undefined is not assignable to number" type errors when
|
||||
// passing config.voiceboxes into VoiceboxManager.init().
|
||||
export type { IVoiceboxConfig };
|
||||
export type { IFaxBoxConfig };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IVR configuration
|
||||
@@ -255,26 +257,32 @@ export interface IAppConfig {
|
||||
incomingNumbers?: IIncomingNumberConfig[];
|
||||
routing: IRoutingConfig;
|
||||
contacts: IContact[];
|
||||
faxboxes?: IFaxBoxConfig[];
|
||||
voiceboxes?: IVoiceboxConfig[];
|
||||
ivr?: IIvrConfig;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loader
|
||||
// Defaults and normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
|
||||
|
||||
export function loadConfig(): IAppConfig {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
} catch {
|
||||
throw new Error(`config not found at ${CONFIG_PATH} — create .nogit/config.json`);
|
||||
function requiredInitialEnv(keyArg: string): string {
|
||||
const value = process.env[keyArg];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required initial config environment variable: ${keyArg}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const cfg = JSON.parse(raw) as IAppConfig;
|
||||
function numberFromEnv(keyArg: string, fallbackArg: number): number {
|
||||
const value = process.env[keyArg];
|
||||
if (!value) return fallbackArg;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallbackArg;
|
||||
}
|
||||
|
||||
export function normalizeConfig(cfg: IAppConfig): IAppConfig {
|
||||
try {
|
||||
// Basic validation.
|
||||
if (!cfg.proxy) throw new Error('config: missing "proxy" section');
|
||||
if (!cfg.proxy.lanIp) throw new Error('config: missing proxy.lanIp');
|
||||
@@ -323,7 +331,12 @@ export function loadConfig(): IAppConfig {
|
||||
c.starred ??= false;
|
||||
}
|
||||
|
||||
// Voicebox defaults.
|
||||
cfg.faxboxes ??= [];
|
||||
for (const fb of cfg.faxboxes) {
|
||||
fb.enabled ??= true;
|
||||
fb.maxMessages ??= 50;
|
||||
}
|
||||
|
||||
cfg.voiceboxes ??= [];
|
||||
for (const vb of cfg.voiceboxes) {
|
||||
vb.enabled ??= true;
|
||||
@@ -333,7 +346,6 @@ export function loadConfig(): IAppConfig {
|
||||
vb.greetingVoice ??= 'af_bella';
|
||||
}
|
||||
|
||||
// IVR defaults.
|
||||
if (cfg.ivr) {
|
||||
cfg.ivr.enabled ??= false;
|
||||
cfg.ivr.menus ??= [];
|
||||
@@ -345,6 +357,103 @@ export function loadConfig(): IAppConfig {
|
||||
}
|
||||
|
||||
return cfg;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function createInitialConfigFromEnv(): IAppConfig {
|
||||
return normalizeConfig({
|
||||
proxy: {
|
||||
lanIp: requiredInitialEnv('SIPROUTER_LAN_IP'),
|
||||
lanPort: numberFromEnv('SIPROUTER_LAN_PORT', 5070),
|
||||
publicIpSeed: process.env.SIPROUTER_PUBLIC_IP || null,
|
||||
rtpPortRange: {
|
||||
min: numberFromEnv('SIPROUTER_RTP_PORT_MIN', 20000),
|
||||
max: numberFromEnv('SIPROUTER_RTP_PORT_MAX', 20200),
|
||||
},
|
||||
webUiPort: numberFromEnv('SIPROUTER_WEB_UI_PORT', 3060),
|
||||
},
|
||||
providers: [],
|
||||
devices: [
|
||||
{
|
||||
id: process.env.SIPROUTER_INITIAL_DEVICE_ID || 'desk-phone',
|
||||
displayName: process.env.SIPROUTER_INITIAL_DEVICE_DISPLAY_NAME || 'Desk Phone',
|
||||
expectedAddress: requiredInitialEnv('SIPROUTER_INITIAL_DEVICE_ADDRESS'),
|
||||
extension: process.env.SIPROUTER_INITIAL_DEVICE_EXTENSION || '100',
|
||||
},
|
||||
],
|
||||
incomingNumbers: [],
|
||||
routing: { routes: [] },
|
||||
contacts: [],
|
||||
faxboxes: [],
|
||||
voiceboxes: [],
|
||||
ivr: {
|
||||
enabled: false,
|
||||
entryMenuId: 'main-menu',
|
||||
menus: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function maskConfig(configArg: IAppConfig): IAppConfig {
|
||||
return {
|
||||
...configArg,
|
||||
providers: configArg.providers?.map((providerArg) => ({
|
||||
...providerArg,
|
||||
password: providerArg.password ? '••••••' : providerArg.password,
|
||||
})) || [],
|
||||
};
|
||||
}
|
||||
|
||||
export function applyConfigUpdates(configArg: IAppConfig, updatesArg: any): IAppConfig {
|
||||
const cfg = JSON.parse(JSON.stringify(configArg)) as IAppConfig;
|
||||
|
||||
if (updatesArg.providers) {
|
||||
for (const up of updatesArg.providers) {
|
||||
const existing = cfg.providers?.find((p: any) => p.id === up.id);
|
||||
if (existing) {
|
||||
if (up.displayName !== undefined) existing.displayName = up.displayName;
|
||||
if (up.password && up.password !== '••••••') existing.password = up.password;
|
||||
if (up.domain !== undefined) existing.domain = up.domain;
|
||||
if (up.outboundProxy !== undefined) existing.outboundProxy = up.outboundProxy;
|
||||
if (up.username !== undefined) existing.username = up.username;
|
||||
if (up.registerIntervalSec !== undefined) existing.registerIntervalSec = up.registerIntervalSec;
|
||||
if (up.codecs !== undefined) existing.codecs = up.codecs;
|
||||
if (up.quirks !== undefined) existing.quirks = up.quirks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updatesArg.addProvider) {
|
||||
cfg.providers ??= [];
|
||||
cfg.providers.push(updatesArg.addProvider);
|
||||
}
|
||||
|
||||
if (updatesArg.removeProvider) {
|
||||
cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updatesArg.removeProvider);
|
||||
if (cfg.routing?.routes) {
|
||||
cfg.routing.routes = cfg.routing.routes.filter((r: any) =>
|
||||
r.match?.sourceProvider !== updatesArg.removeProvider &&
|
||||
r.action?.provider !== updatesArg.removeProvider
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatesArg.devices) {
|
||||
for (const ud of updatesArg.devices) {
|
||||
const existing = cfg.devices?.find((d: any) => d.id === ud.id);
|
||||
if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName;
|
||||
}
|
||||
}
|
||||
if (updatesArg.incomingNumbers !== undefined) cfg.incomingNumbers = updatesArg.incomingNumbers;
|
||||
if (updatesArg.routing?.routes) cfg.routing.routes = updatesArg.routing.routes;
|
||||
if (updatesArg.contacts !== undefined) cfg.contacts = updatesArg.contacts;
|
||||
if (updatesArg.faxboxes !== undefined) cfg.faxboxes = updatesArg.faxboxes;
|
||||
if (updatesArg.voiceboxes !== undefined) cfg.voiceboxes = updatesArg.voiceboxes;
|
||||
if (updatesArg.ivr !== undefined) cfg.ivr = updatesArg.ivr;
|
||||
|
||||
return normalizeConfig(cfg);
|
||||
}
|
||||
|
||||
// Route resolution, pattern matching, and provider/device lookup
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import fs from 'node:fs';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { SiprouterStorage } from './storage.ts';
|
||||
|
||||
export interface IFaxBoxConfig {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
maxMessages?: number;
|
||||
}
|
||||
|
||||
export interface IFaxMessage {
|
||||
id: string;
|
||||
boxId: string;
|
||||
callerNumber?: string;
|
||||
timestamp: number;
|
||||
fileName: string;
|
||||
objectKey?: string;
|
||||
completionCode?: number | null;
|
||||
completionLabel?: string | null;
|
||||
pageCount?: number;
|
||||
bitRate?: number;
|
||||
}
|
||||
|
||||
export class FaxBoxManager {
|
||||
private boxes = new Map<string, IFaxBoxConfig>();
|
||||
private messagesByBox = new Map<string, IFaxMessage[]>();
|
||||
private readonly basePath: string;
|
||||
private readonly log: (msg: string) => void;
|
||||
private readonly storage: SiprouterStorage;
|
||||
|
||||
constructor(log: (msg: string) => void, storageArg: SiprouterStorage) {
|
||||
this.basePath = path.join(process.cwd(), '.nogit', 'fax', 'inboxes');
|
||||
this.log = log;
|
||||
this.storage = storageArg;
|
||||
}
|
||||
|
||||
async init(faxBoxConfigs: IFaxBoxConfig[]): Promise<void> {
|
||||
this.boxes.clear();
|
||||
|
||||
for (const cfg of faxBoxConfigs) {
|
||||
cfg.enabled ??= true;
|
||||
cfg.maxMessages ??= 50;
|
||||
this.boxes.set(cfg.id, cfg);
|
||||
this.messagesByBox.set(cfg.id, await this.loadMessages(cfg.id));
|
||||
}
|
||||
|
||||
await fsPromises.mkdir(this.basePath, { recursive: true });
|
||||
this.log(`[faxbox] initialized ${this.boxes.size} fax box(es)`);
|
||||
}
|
||||
|
||||
getBox(boxId: string): IFaxBoxConfig | null {
|
||||
return this.boxes.get(boxId) ?? null;
|
||||
}
|
||||
|
||||
getBoxDir(boxId: string): string {
|
||||
return path.join(this.basePath, boxId);
|
||||
}
|
||||
|
||||
async prepareOutboundFaxFile(filePathArg: string): Promise<string> {
|
||||
const localPath = path.isAbsolute(filePathArg) ? filePathArg : path.join(process.cwd(), filePathArg);
|
||||
await fsPromises.access(localPath);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
async addMessage(
|
||||
boxId: string,
|
||||
info: {
|
||||
callerNumber?: string;
|
||||
fileName: string;
|
||||
completionCode?: number | null;
|
||||
completionLabel?: string | null;
|
||||
pageCount?: number;
|
||||
bitRate?: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
const id = crypto.randomUUID();
|
||||
const localPath = path.isAbsolute(info.fileName) ? info.fileName : path.join(process.cwd(), info.fileName);
|
||||
const objectKey = await this.storage.putFileObject(`fax/inboxes/${boxId}/${id}.tif`, localPath);
|
||||
|
||||
const msg: IFaxMessage = {
|
||||
id,
|
||||
boxId,
|
||||
callerNumber: info.callerNumber,
|
||||
timestamp: Date.now(),
|
||||
fileName: path.basename(localPath),
|
||||
objectKey,
|
||||
completionCode: info.completionCode ?? null,
|
||||
completionLabel: info.completionLabel ?? null,
|
||||
pageCount: info.pageCount,
|
||||
bitRate: info.bitRate,
|
||||
};
|
||||
|
||||
const messages = this.getMessages(boxId);
|
||||
messages.unshift(msg);
|
||||
await this.enforceLimit(boxId, messages);
|
||||
await this.writeMessages(boxId, messages);
|
||||
await fsPromises.rm(localPath, { force: true }).catch(() => {});
|
||||
this.log(`[faxbox] saved fax ${msg.id} in box "${msg.boxId}" (${msg.fileName})`);
|
||||
}
|
||||
|
||||
getMessages(boxId: string): IFaxMessage[] {
|
||||
return [...(this.messagesByBox.get(boxId) || [])];
|
||||
}
|
||||
|
||||
getMessage(boxId: string, messageId: string): IFaxMessage | null {
|
||||
const messages = this.messagesByBox.get(boxId) || [];
|
||||
return messages.find((m) => m.id === messageId) ?? null;
|
||||
}
|
||||
|
||||
async getMessageFilePath(boxId: string, messageId: string): Promise<string | null> {
|
||||
const msg = this.getMessage(boxId, messageId);
|
||||
if (!msg) return null;
|
||||
if (msg.objectKey) {
|
||||
return await this.storage.getObjectAsCachedFile(msg.objectKey, msg.fileName);
|
||||
}
|
||||
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
|
||||
return fs.existsSync(filePath) ? filePath : null;
|
||||
}
|
||||
|
||||
async deleteMessage(boxId: string, messageId: string): Promise<boolean> {
|
||||
const messages = this.messagesByBox.get(boxId) || [];
|
||||
const idx = messages.findIndex((m) => m.id === messageId);
|
||||
if (idx === -1) return false;
|
||||
|
||||
const msg = messages[idx];
|
||||
await this.storage.removeObject(msg.objectKey);
|
||||
if (!msg.objectKey) {
|
||||
await fsPromises.rm(path.join(this.getBoxDir(boxId), msg.fileName), { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
messages.splice(idx, 1);
|
||||
await this.writeMessages(boxId, messages);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async enforceLimit(boxId: string, messages: IFaxMessage[]): Promise<void> {
|
||||
const box = this.boxes.get(boxId);
|
||||
const maxMessages = box?.maxMessages ?? 50;
|
||||
while (messages.length > maxMessages) {
|
||||
const old = messages.pop()!;
|
||||
await this.storage.removeObject(old.objectKey);
|
||||
if (!old.objectKey) {
|
||||
await fsPromises.rm(path.join(this.getBoxDir(boxId), old.fileName), { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadMessages(boxId: string): Promise<IFaxMessage[]> {
|
||||
const storedMessages = await this.storage.getFaxMessages(boxId);
|
||||
if (storedMessages.length) return await this.ensureMessageObjects(boxId, storedMessages);
|
||||
|
||||
const filePath = path.join(this.getBoxDir(boxId), 'messages.json');
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
const raw = await fsPromises.readFile(filePath, 'utf8');
|
||||
const legacyMessages = await this.ensureMessageObjects(boxId, JSON.parse(raw) as IFaxMessage[]);
|
||||
await this.storage.writeFaxMessages(boxId, legacyMessages);
|
||||
return legacyMessages;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureMessageObjects(boxId: string, messages: IFaxMessage[]): Promise<IFaxMessage[]> {
|
||||
let changed = false;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.id) {
|
||||
msg.id = crypto.randomUUID();
|
||||
changed = true;
|
||||
}
|
||||
if (msg.objectKey) continue;
|
||||
|
||||
const localPath = path.isAbsolute(msg.fileName) ? msg.fileName : path.join(this.getBoxDir(boxId), msg.fileName);
|
||||
if (!fs.existsSync(localPath)) continue;
|
||||
|
||||
const extension = path.extname(localPath) || '.tif';
|
||||
msg.objectKey = await this.storage.putFileObject(`fax/inboxes/${boxId}/${msg.id}${extension}`, localPath);
|
||||
msg.fileName = path.basename(localPath);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await this.storage.writeFaxMessages(boxId, messages);
|
||||
this.log(`[faxbox] migrated legacy messages for box "${boxId}" to smartbucket`);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
private async writeMessages(boxId: string, messages: IFaxMessage[]): Promise<void> {
|
||||
this.messagesByBox.set(boxId, [...messages]);
|
||||
await this.storage.writeFaxMessages(boxId, messages);
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { SiprouterStorage } from './storage.ts';
|
||||
import type {
|
||||
IFaxCompletedEvent,
|
||||
IFaxFailedEvent,
|
||||
IFaxStartedEvent,
|
||||
} from './shared/proxy-events.ts';
|
||||
|
||||
export interface IFaxJob {
|
||||
id: string;
|
||||
callId: string;
|
||||
number?: string;
|
||||
providerId?: string;
|
||||
direction: 'outbound' | 'inbound';
|
||||
status: 'dialing' | 'started' | 'completed' | 'failed';
|
||||
transport?: 'audio' | 't38';
|
||||
filePath?: string;
|
||||
objectKey?: string;
|
||||
codec?: string;
|
||||
remoteMedia?: string;
|
||||
success?: boolean;
|
||||
completionCode?: number | null;
|
||||
completionLabel?: string | null;
|
||||
error?: string;
|
||||
stats?: IFaxCompletedEvent['stats'];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export class FaxJobManager {
|
||||
private jobs: IFaxJob[] = [];
|
||||
private readonly log: (msg: string) => void;
|
||||
private readonly storage: SiprouterStorage;
|
||||
|
||||
constructor(log: (msg: string) => void, storageArg: SiprouterStorage) {
|
||||
this.log = log;
|
||||
this.storage = storageArg;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.jobs = await this.storage.getFaxJobs();
|
||||
}
|
||||
|
||||
async noteDialing(callId: string, number: string, providerId: string): Promise<void> {
|
||||
const jobs = this.jobs;
|
||||
const now = Date.now();
|
||||
const existing = jobs.find((job) => job.callId === callId);
|
||||
if (existing) {
|
||||
existing.number = number;
|
||||
existing.providerId = providerId;
|
||||
existing.updatedAt = now;
|
||||
} else {
|
||||
jobs.unshift({
|
||||
id: callId,
|
||||
callId,
|
||||
number,
|
||||
providerId,
|
||||
direction: 'outbound',
|
||||
status: 'dialing',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
await this.writeJobs();
|
||||
}
|
||||
|
||||
async noteStarted(event: IFaxStartedEvent): Promise<void> {
|
||||
const now = Date.now();
|
||||
const job = this.getOrCreateJob(event.call_id, event.direction, now);
|
||||
job.status = 'started';
|
||||
job.transport = event.transport;
|
||||
job.filePath = event.file_path;
|
||||
await this.ensureOutboundFileObject(job, event.file_path);
|
||||
job.codec = event.codec;
|
||||
job.remoteMedia = event.remote_media;
|
||||
job.updatedAt = now;
|
||||
await this.writeJobs();
|
||||
}
|
||||
|
||||
async noteCompleted(event: IFaxCompletedEvent): Promise<void> {
|
||||
const now = Date.now();
|
||||
const job = this.getOrCreateJob(event.call_id, event.direction, now);
|
||||
job.status = 'completed';
|
||||
job.transport = event.transport;
|
||||
job.filePath = event.file_path;
|
||||
await this.ensureOutboundFileObject(job, event.file_path);
|
||||
job.codec = event.codec;
|
||||
job.success = event.success;
|
||||
job.completionCode = event.completion_code ?? null;
|
||||
job.completionLabel = event.completion_label ?? null;
|
||||
job.stats = event.stats;
|
||||
job.updatedAt = now;
|
||||
await this.writeJobs();
|
||||
}
|
||||
|
||||
async noteFailed(event: IFaxFailedEvent): Promise<void> {
|
||||
const now = Date.now();
|
||||
const job = this.getOrCreateJob(event.call_id, event.direction, now);
|
||||
job.status = 'failed';
|
||||
job.transport = event.transport;
|
||||
job.filePath = event.file_path;
|
||||
await this.ensureOutboundFileObject(job, event.file_path);
|
||||
job.error = event.error;
|
||||
job.success = false;
|
||||
job.updatedAt = now;
|
||||
await this.writeJobs();
|
||||
}
|
||||
|
||||
getJobs(): IFaxJob[] {
|
||||
return [...this.jobs];
|
||||
}
|
||||
|
||||
private getOrCreateJob(
|
||||
callId: string,
|
||||
direction: 'outbound' | 'inbound',
|
||||
now: number,
|
||||
): IFaxJob {
|
||||
let job = this.jobs.find((entry) => entry.callId === callId);
|
||||
if (!job) {
|
||||
job = {
|
||||
id: callId,
|
||||
callId,
|
||||
direction,
|
||||
status: 'dialing',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.jobs.unshift(job);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
private async ensureOutboundFileObject(jobArg: IFaxJob, filePathArg: string | undefined): Promise<void> {
|
||||
if (jobArg.direction !== 'outbound' || jobArg.objectKey || !filePathArg) return;
|
||||
|
||||
const localPath = path.isAbsolute(filePathArg) ? filePathArg : path.join(process.cwd(), filePathArg);
|
||||
if (!fs.existsSync(localPath)) return;
|
||||
|
||||
const extension = path.extname(localPath) || '.tif';
|
||||
jobArg.objectKey = await this.storage.putFileObject(`fax/outbound/${jobArg.callId}${extension}`, localPath);
|
||||
}
|
||||
|
||||
private async writeJobs(): Promise<void> {
|
||||
await this.storage.writeFaxJobs(this.jobs);
|
||||
this.log(`[fax] persisted ${this.jobs.length} job(s)`);
|
||||
}
|
||||
}
|
||||
+88
-75
@@ -11,17 +11,21 @@ import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { maskConfig, type IAppConfig } from './config.ts';
|
||||
import type { FaxBoxManager } from './faxbox.ts';
|
||||
import type { FaxJobManager } from './faxjobs.ts';
|
||||
import { handleWebRtcSignaling } from './webrtcbridge.ts';
|
||||
import type { VoiceboxManager } from './voicebox.ts';
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
|
||||
|
||||
interface IHandleRequestContext {
|
||||
getStatus: () => unknown;
|
||||
getConfig: () => IAppConfig;
|
||||
updateConfig: (updatesArg: any) => Promise<IAppConfig>;
|
||||
log: (msg: string) => void;
|
||||
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null;
|
||||
onStartCall: (number: string, deviceId?: string, providerId?: string) => Promise<{ id: string } | null>;
|
||||
onHangupCall: (callId: string) => boolean;
|
||||
onConfigSaved?: () => void | Promise<void>;
|
||||
faxBoxManager?: FaxBoxManager;
|
||||
faxJobManager?: FaxJobManager;
|
||||
voiceboxManager?: VoiceboxManager;
|
||||
}
|
||||
|
||||
@@ -108,7 +112,7 @@ async function handleRequest(
|
||||
res: http.ServerResponse,
|
||||
context: IHandleRequestContext,
|
||||
): Promise<void> {
|
||||
const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager } = context;
|
||||
const { getStatus, getConfig, updateConfig, log, onStartCall, onHangupCall, faxBoxManager, faxJobManager, voiceboxManager } = context;
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
const method = req.method || 'GET';
|
||||
|
||||
@@ -125,7 +129,7 @@ async function handleRequest(
|
||||
if (!number || typeof number !== 'string') {
|
||||
return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
|
||||
}
|
||||
const call = onStartCall(number, body?.deviceId, body?.providerId);
|
||||
const call = await onStartCall(number, body?.deviceId, body?.providerId);
|
||||
if (call) return sendJson(res, { ok: true, callId: call.id });
|
||||
return sendJson(res, { ok: false, error: 'call origination failed — provider not registered or no ports available' }, 503);
|
||||
} catch (e: any) {
|
||||
@@ -147,6 +151,68 @@ async function handleRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// API: send outbound fax.
|
||||
if (url.pathname === '/api/fax' && method === 'POST') {
|
||||
try {
|
||||
const body = await readJsonBody(req);
|
||||
const number = body?.number;
|
||||
let filePath = body?.filePath;
|
||||
if (!number || typeof number !== 'string') {
|
||||
return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
|
||||
}
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
return sendJson(res, { ok: false, error: 'missing "filePath" field' }, 400);
|
||||
}
|
||||
if (faxBoxManager) {
|
||||
filePath = await faxBoxManager.prepareOutboundFaxFile(filePath);
|
||||
}
|
||||
const { sendFax } = await import('./proxybridge.ts');
|
||||
const callId = await sendFax(number, filePath, body?.providerId);
|
||||
if (callId) {
|
||||
log(`[dashboard] fax started: ${callId} -> ${number} file=${filePath}`);
|
||||
return sendJson(res, { ok: true, callId });
|
||||
}
|
||||
return sendJson(res, { ok: false, error: 'fax origination failed' }, 503);
|
||||
} catch (e: any) {
|
||||
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// API: fax jobs.
|
||||
if (url.pathname === '/api/fax/jobs' && method === 'GET' && faxJobManager) {
|
||||
return sendJson(res, { ok: true, jobs: faxJobManager.getJobs() });
|
||||
}
|
||||
|
||||
// API: fax inbox - list messages.
|
||||
const faxListMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)$/);
|
||||
if (faxListMatch && method === 'GET' && faxBoxManager) {
|
||||
const boxId = faxListMatch[1];
|
||||
return sendJson(res, { ok: true, messages: faxBoxManager.getMessages(boxId) });
|
||||
}
|
||||
|
||||
// API: fax inbox - stream TIFF.
|
||||
const faxFileMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)\/file$/);
|
||||
if (faxFileMatch && method === 'GET' && faxBoxManager) {
|
||||
const [, boxId, msgId] = faxFileMatch;
|
||||
const filePath = await faxBoxManager.getMessageFilePath(boxId, msgId);
|
||||
if (!filePath) return sendJson(res, { ok: false, error: 'not found' }, 404);
|
||||
const stat = fs.statSync(filePath);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/tiff',
|
||||
'Content-Length': stat.size.toString(),
|
||||
'Accept-Ranges': 'bytes',
|
||||
});
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
// API: fax inbox - delete message.
|
||||
const faxDeleteMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)$/);
|
||||
if (faxDeleteMatch && method === 'DELETE' && faxBoxManager) {
|
||||
const [, boxId, msgId] = faxDeleteMatch;
|
||||
return sendJson(res, { ok: await faxBoxManager.deleteMessage(boxId, msgId) });
|
||||
}
|
||||
|
||||
// API: add a SIP device to a call (mid-call INVITE to desk phone).
|
||||
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addleg') && method === 'POST') {
|
||||
try {
|
||||
@@ -209,10 +275,7 @@ async function handleRequest(
|
||||
// API: get config (sans passwords).
|
||||
if (url.pathname === '/api/config' && method === 'GET') {
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const cfg = JSON.parse(raw);
|
||||
const safe = { ...cfg, providers: cfg.providers?.map((p: any) => ({ ...p, password: '••••••' })) };
|
||||
return sendJson(res, safe);
|
||||
return sendJson(res, maskConfig(getConfig()));
|
||||
} catch (e: any) {
|
||||
return sendJson(res, { ok: false, error: e.message }, 500);
|
||||
}
|
||||
@@ -222,64 +285,9 @@ async function handleRequest(
|
||||
if (url.pathname === '/api/config' && method === 'POST') {
|
||||
try {
|
||||
const updates = await readJsonBody(req);
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const cfg = JSON.parse(raw);
|
||||
|
||||
// Update existing providers.
|
||||
if (updates.providers) {
|
||||
for (const up of updates.providers) {
|
||||
const existing = cfg.providers?.find((p: any) => p.id === up.id);
|
||||
if (existing) {
|
||||
if (up.displayName !== undefined) existing.displayName = up.displayName;
|
||||
if (up.password && up.password !== '••••••') existing.password = up.password;
|
||||
if (up.domain !== undefined) existing.domain = up.domain;
|
||||
if (up.outboundProxy !== undefined) existing.outboundProxy = up.outboundProxy;
|
||||
if (up.username !== undefined) existing.username = up.username;
|
||||
if (up.registerIntervalSec !== undefined) existing.registerIntervalSec = up.registerIntervalSec;
|
||||
if (up.codecs !== undefined) existing.codecs = up.codecs;
|
||||
if (up.quirks !== undefined) existing.quirks = up.quirks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a new provider.
|
||||
if (updates.addProvider) {
|
||||
cfg.providers ??= [];
|
||||
cfg.providers.push(updates.addProvider);
|
||||
}
|
||||
|
||||
// Remove a provider.
|
||||
if (updates.removeProvider) {
|
||||
cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updates.removeProvider);
|
||||
// Clean up routing references — remove routes that reference this provider.
|
||||
if (cfg.routing?.routes) {
|
||||
cfg.routing.routes = cfg.routing.routes.filter((r: any) =>
|
||||
r.match?.sourceProvider !== updates.removeProvider &&
|
||||
r.action?.provider !== updates.removeProvider
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.devices) {
|
||||
for (const ud of updates.devices) {
|
||||
const existing = cfg.devices?.find((d: any) => d.id === ud.id);
|
||||
if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName;
|
||||
}
|
||||
}
|
||||
if (updates.incomingNumbers !== undefined) cfg.incomingNumbers = updates.incomingNumbers;
|
||||
if (updates.routing) {
|
||||
if (updates.routing.routes) {
|
||||
cfg.routing.routes = updates.routing.routes;
|
||||
}
|
||||
}
|
||||
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
|
||||
if (updates.voiceboxes !== undefined) cfg.voiceboxes = updates.voiceboxes;
|
||||
if (updates.ivr !== undefined) cfg.ivr = updates.ivr;
|
||||
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
||||
log('[config] updated config.json');
|
||||
await onConfigSaved?.();
|
||||
return sendJson(res, { ok: true });
|
||||
const config = await updateConfig(updates);
|
||||
log('[config] updated smartdata config');
|
||||
return sendJson(res, { ok: true, config: maskConfig(config) });
|
||||
} catch (e: any) {
|
||||
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||
}
|
||||
@@ -303,7 +311,7 @@ async function handleRequest(
|
||||
const vmAudioMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/audio$/);
|
||||
if (vmAudioMatch && method === 'GET' && voiceboxManager) {
|
||||
const [, boxId, msgId] = vmAudioMatch;
|
||||
const audioPath = voiceboxManager.getMessageAudioPath(boxId, msgId);
|
||||
const audioPath = await voiceboxManager.getMessageAudioPath(boxId, msgId);
|
||||
if (!audioPath) return sendJson(res, { ok: false, error: 'not found' }, 404);
|
||||
const stat = fs.statSync(audioPath);
|
||||
res.writeHead(200, {
|
||||
@@ -319,14 +327,14 @@ async function handleRequest(
|
||||
const vmHeardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/heard$/);
|
||||
if (vmHeardMatch && method === 'POST' && voiceboxManager) {
|
||||
const [, boxId, msgId] = vmHeardMatch;
|
||||
return sendJson(res, { ok: voiceboxManager.markHeard(boxId, msgId) });
|
||||
return sendJson(res, { ok: await voiceboxManager.markHeard(boxId, msgId) });
|
||||
}
|
||||
|
||||
// API: voicemail - delete message.
|
||||
const vmDeleteMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)$/);
|
||||
if (vmDeleteMatch && method === 'DELETE' && voiceboxManager) {
|
||||
const [, boxId, msgId] = vmDeleteMatch;
|
||||
return sendJson(res, { ok: voiceboxManager.deleteMessage(boxId, msgId) });
|
||||
return sendJson(res, { ok: await voiceboxManager.deleteMessage(boxId, msgId) });
|
||||
}
|
||||
|
||||
// Static files.
|
||||
@@ -364,10 +372,13 @@ export function initWebUi(
|
||||
const {
|
||||
port,
|
||||
getStatus,
|
||||
getConfig,
|
||||
updateConfig,
|
||||
log,
|
||||
onStartCall,
|
||||
onHangupCall,
|
||||
onConfigSaved,
|
||||
faxBoxManager,
|
||||
faxJobManager,
|
||||
voiceboxManager,
|
||||
onWebRtcOffer,
|
||||
onWebRtcIce,
|
||||
@@ -387,12 +398,12 @@ export function initWebUi(
|
||||
const cert = fs.readFileSync(certPath, 'utf8');
|
||||
const key = fs.readFileSync(keyPath, 'utf8');
|
||||
server = https.createServer({ cert, key }, (req, res) =>
|
||||
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
||||
handleRequest(req, res, { getStatus, getConfig, updateConfig, log, onStartCall, onHangupCall, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
||||
);
|
||||
useTls = true;
|
||||
} catch {
|
||||
server = http.createServer((req, res) =>
|
||||
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
||||
handleRequest(req, res, { getStatus, getConfig, updateConfig, log, onStartCall, onHangupCall, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -429,7 +440,9 @@ export function initWebUi(
|
||||
}
|
||||
} else if (msg.type?.startsWith('webrtc-')) {
|
||||
msg._remoteIp = remoteIp;
|
||||
handleWebRtcSignaling(socket, msg);
|
||||
if (msg.type) {
|
||||
handleWebRtcSignaling(socket, msg as IWebRtcSocketMessage & { type: string });
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import * as smartbucket from '@push.rocks/smartbucket';
|
||||
import * as smartdata from '@push.rocks/smartdata';
|
||||
|
||||
export { smartbucket, smartdata };
|
||||
@@ -17,6 +17,9 @@ export type {
|
||||
ICallEndedEvent,
|
||||
ICallRingingEvent,
|
||||
IDeviceRegisteredEvent,
|
||||
IFaxCompletedEvent,
|
||||
IFaxFailedEvent,
|
||||
IFaxStartedEvent,
|
||||
IIncomingCallEvent,
|
||||
ILegAddedEvent,
|
||||
ILegRemovedEvent,
|
||||
@@ -52,6 +55,10 @@ type TProxyCommands = {
|
||||
params: { number: string; device_id?: string; provider_id?: string };
|
||||
result: { call_id: string };
|
||||
};
|
||||
send_fax: {
|
||||
params: { number: string; file_path: string; provider_id?: string };
|
||||
result: { call_id: string; codec: 'PCMU' | 'PCMA' };
|
||||
};
|
||||
add_leg: {
|
||||
params: { call_id: string; number: string; provider_id?: string };
|
||||
result: { leg_id: string };
|
||||
@@ -262,6 +269,21 @@ export async function makeCall(number: string, deviceId?: string, providerId?: s
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendFax(number: string, filePath: string, providerId?: string): Promise<string | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await sendProxyCommand('send_fax', {
|
||||
number,
|
||||
file_path: filePath,
|
||||
provider_id: providerId,
|
||||
});
|
||||
return result.call_id || null;
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] send_fax error: ${errorMessage(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a hangup command.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { onProxyEvent } from '../proxybridge.ts';
|
||||
import { hangupCall, onProxyEvent } from '../proxybridge.ts';
|
||||
import type { FaxBoxManager } from '../faxbox.ts';
|
||||
import type { FaxJobManager } from '../faxjobs.ts';
|
||||
import type { VoiceboxManager } from '../voicebox.ts';
|
||||
import type { StatusStore } from './status-store.ts';
|
||||
import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts';
|
||||
@@ -6,6 +8,8 @@ import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts'
|
||||
export interface IRegisterProxyEventHandlersOptions {
|
||||
log: (msg: string) => void;
|
||||
statusStore: StatusStore;
|
||||
faxBoxManager: FaxBoxManager;
|
||||
faxJobManager: FaxJobManager;
|
||||
voiceboxManager: VoiceboxManager;
|
||||
webRtcLinks: WebRtcLinkManager;
|
||||
getBrowserDeviceIds: () => string[];
|
||||
@@ -19,6 +23,8 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
const {
|
||||
log,
|
||||
statusStore,
|
||||
faxBoxManager,
|
||||
faxJobManager,
|
||||
voiceboxManager,
|
||||
webRtcLinks,
|
||||
getBrowserDeviceIds,
|
||||
@@ -28,6 +34,28 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
onCloseWebRtcSession,
|
||||
} = options;
|
||||
|
||||
const legMediaDetails = (data: {
|
||||
codec?: string | null;
|
||||
mediaProtocol?: string | null;
|
||||
remoteMedia?: string | null;
|
||||
rtpPort?: number | null;
|
||||
}): string => {
|
||||
const parts: string[] = [];
|
||||
if (data.codec) {
|
||||
parts.push(`codec=${data.codec}`);
|
||||
}
|
||||
if (data.mediaProtocol) {
|
||||
parts.push(`media=${data.mediaProtocol}`);
|
||||
}
|
||||
if (data.remoteMedia) {
|
||||
parts.push(`remote=${data.remoteMedia}`);
|
||||
}
|
||||
if (data.rtpPort !== undefined && data.rtpPort !== null) {
|
||||
parts.push(`rtp=${data.rtpPort}`);
|
||||
}
|
||||
return parts.length ? ` ${parts.join(' ')}` : '';
|
||||
};
|
||||
|
||||
onProxyEvent('provider_registered', (data) => {
|
||||
const previous = statusStore.noteProviderRegistered(data);
|
||||
if (previous) {
|
||||
@@ -73,6 +101,15 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
log(`[call] outbound started: ${data.call_id} -> ${data.number} via ${data.provider_id}`);
|
||||
statusStore.noteOutboundCallStarted(data);
|
||||
|
||||
if (data.ring_browsers === false) {
|
||||
void faxJobManager.noteDialing(data.call_id, data.number, data.provider_id)
|
||||
.catch((error) => log(`[fax] persist dialing failed: ${error instanceof Error ? error.message : String(error)}`));
|
||||
}
|
||||
|
||||
if (data.ring_browsers === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const deviceId of getBrowserDeviceIds()) {
|
||||
sendToBrowserDevice(deviceId, {
|
||||
type: 'webrtc-incoming',
|
||||
@@ -92,6 +129,10 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
log(`[call] ${data.call_id} connected`);
|
||||
}
|
||||
|
||||
if (data.media_protocol && data.media_protocol !== 'rtp') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.provider_media_addr || !data.provider_media_port) {
|
||||
return;
|
||||
}
|
||||
@@ -128,7 +169,9 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
});
|
||||
|
||||
onProxyEvent('leg_added', (data) => {
|
||||
log(`[leg] added: call=${data.call_id} leg=${data.leg_id} kind=${data.kind} state=${data.state}`);
|
||||
log(
|
||||
`[leg] added: call=${data.call_id} leg=${data.leg_id} kind=${data.kind} state=${data.state}${legMediaDetails(data)}`,
|
||||
);
|
||||
statusStore.noteLegAdded(data);
|
||||
});
|
||||
|
||||
@@ -138,7 +181,9 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
});
|
||||
|
||||
onProxyEvent('leg_state_changed', (data) => {
|
||||
log(`[leg] state: call=${data.call_id} leg=${data.leg_id} -> ${data.state}`);
|
||||
log(
|
||||
`[leg] state: call=${data.call_id} leg=${data.leg_id} -> ${data.state}${legMediaDetails(data)}`,
|
||||
);
|
||||
statusStore.noteLegStateChanged(data);
|
||||
});
|
||||
|
||||
@@ -174,15 +219,48 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
onProxyEvent('recording_done', (data) => {
|
||||
const boxId = data.voicebox_id || 'default';
|
||||
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) box=${boxId} caller=${data.caller_number}`);
|
||||
voiceboxManager.addMessage(boxId, {
|
||||
void voiceboxManager.addMessage(boxId, {
|
||||
callerNumber: data.caller_number || 'Unknown',
|
||||
callerName: null,
|
||||
fileName: data.file_path,
|
||||
durationMs: data.duration_ms,
|
||||
});
|
||||
}).catch((error) => log(`[voicemail] persist failed: ${error instanceof Error ? error.message : String(error)}`));
|
||||
});
|
||||
|
||||
onProxyEvent('voicemail_error', (data) => {
|
||||
log(`[voicemail] error: ${data.error} call=${data.call_id}`);
|
||||
});
|
||||
|
||||
onProxyEvent('fax_started', (data) => {
|
||||
void faxJobManager.noteStarted(data).catch((error) => log(`[fax] persist start failed: ${error instanceof Error ? error.message : String(error)}`));
|
||||
log(`[fax] started: call=${data.call_id} leg=${data.leg_id} ${data.direction}/${data.transport} codec=${data.codec || '?'} file=${data.file_path}`);
|
||||
});
|
||||
|
||||
onProxyEvent('fax_completed', (data) => {
|
||||
void faxJobManager.noteCompleted(data).catch((error) => log(`[fax] persist completion failed: ${error instanceof Error ? error.message : String(error)}`));
|
||||
log(
|
||||
`[fax] completed: call=${data.call_id} leg=${data.leg_id} success=${data.success} pagesTx=${data.stats.pages_tx} bitrate=${data.stats.bit_rate} completion=${data.completion_label || data.completion_code || 'unknown'}`,
|
||||
);
|
||||
if (data.direction === 'inbound' && data.success && data.fax_box_id) {
|
||||
void faxBoxManager.addMessage(data.fax_box_id, {
|
||||
callerNumber: data.caller_number,
|
||||
fileName: data.file_path,
|
||||
completionCode: data.completion_code,
|
||||
completionLabel: data.completion_label,
|
||||
pageCount: data.stats.pages_rx || data.stats.pages_tx,
|
||||
bitRate: data.stats.bit_rate,
|
||||
}).catch((error) => log(`[fax] persist inbox failed: ${error instanceof Error ? error.message : String(error)}`));
|
||||
}
|
||||
if (data.direction === 'outbound' || data.fax_box_id) {
|
||||
void hangupCall(data.call_id);
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('fax_failed', (data) => {
|
||||
void faxJobManager.noteFailed(data).catch((error) => log(`[fax] persist failure failed: ${error instanceof Error ? error.message : String(error)}`));
|
||||
log(`[fax] failed: call=${data.call_id} leg=${data.leg_id} error=${data.error}`);
|
||||
if (data.direction === 'outbound' || data.fax_box_id) {
|
||||
void hangupCall(data.call_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+82
-67
@@ -88,16 +88,12 @@ export class StatusStore {
|
||||
}
|
||||
|
||||
noteDashboardCallStarted(callId: string, number: string, providerId?: string): void {
|
||||
this.activeCalls.set(callId, {
|
||||
id: callId,
|
||||
direction: 'outbound',
|
||||
callerNumber: null,
|
||||
calleeNumber: number,
|
||||
providerUsed: providerId || null,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
const call = this.getOrCreateCall(callId, 'outbound');
|
||||
call.direction = 'outbound';
|
||||
call.callerNumber = null;
|
||||
call.calleeNumber = number;
|
||||
call.providerUsed = providerId || null;
|
||||
call.state = 'setting-up';
|
||||
}
|
||||
|
||||
noteProviderRegistered(data: IProviderRegisteredEvent): { wasRegistered: boolean } | null {
|
||||
@@ -126,57 +122,40 @@ export class StatusStore {
|
||||
}
|
||||
|
||||
noteIncomingCall(data: IIncomingCallEvent): void {
|
||||
this.activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'inbound',
|
||||
callerNumber: data.from_uri,
|
||||
calleeNumber: data.to_number,
|
||||
providerUsed: data.provider_id,
|
||||
state: 'ringing',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
noteOutboundDeviceCall(data: IOutboundCallEvent): void {
|
||||
this.activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'outbound',
|
||||
callerNumber: data.from_device,
|
||||
calleeNumber: data.to_number,
|
||||
providerUsed: null,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
noteOutboundCallStarted(data: IOutboundCallStartedEvent): void {
|
||||
this.activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'outbound',
|
||||
callerNumber: null,
|
||||
calleeNumber: data.number,
|
||||
providerUsed: data.provider_id,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
noteCallRinging(data: ICallRingingEvent): void {
|
||||
const call = this.activeCalls.get(data.call_id);
|
||||
if (call) {
|
||||
const call = this.getOrCreateCall(data.call_id, 'inbound');
|
||||
call.direction = 'inbound';
|
||||
call.callerNumber = data.from_uri;
|
||||
call.calleeNumber = data.to_number;
|
||||
call.providerUsed = data.provider_id;
|
||||
if (call.state === 'setting-up') {
|
||||
call.state = 'ringing';
|
||||
}
|
||||
}
|
||||
|
||||
noteCallAnswered(data: ICallAnsweredEvent): boolean {
|
||||
const call = this.activeCalls.get(data.call_id);
|
||||
if (!call) {
|
||||
return false;
|
||||
noteOutboundDeviceCall(data: IOutboundCallEvent): void {
|
||||
const call = this.getOrCreateCall(data.call_id, 'outbound');
|
||||
call.direction = 'outbound';
|
||||
call.callerNumber = data.from_device;
|
||||
call.calleeNumber = data.to_number;
|
||||
call.providerUsed = null;
|
||||
}
|
||||
|
||||
noteOutboundCallStarted(data: IOutboundCallStartedEvent): void {
|
||||
const call = this.getOrCreateCall(data.call_id, 'outbound');
|
||||
call.direction = 'outbound';
|
||||
call.callerNumber = call.callerNumber ?? null;
|
||||
call.calleeNumber = data.number;
|
||||
call.providerUsed = data.provider_id;
|
||||
}
|
||||
|
||||
noteCallRinging(data: ICallRingingEvent): void {
|
||||
const call = this.getOrCreateCall(data.call_id);
|
||||
call.state = 'ringing';
|
||||
}
|
||||
|
||||
noteCallAnswered(data: ICallAnsweredEvent): boolean {
|
||||
const call = this.getOrCreateCall(data.call_id);
|
||||
|
||||
call.state = 'connected';
|
||||
|
||||
if (data.provider_media_addr && data.provider_media_port) {
|
||||
@@ -186,7 +165,12 @@ export class StatusStore {
|
||||
}
|
||||
|
||||
leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`;
|
||||
if (data.sip_pt !== undefined) {
|
||||
if (data.media_protocol) {
|
||||
leg.mediaProtocol = data.media_protocol;
|
||||
}
|
||||
if (data.media_protocol === 't38-udptl') {
|
||||
leg.codec = 'T.38';
|
||||
} else if (data.sip_pt !== undefined) {
|
||||
leg.codec = CODEC_NAMES[data.sip_pt] || `PT${data.sip_pt}`;
|
||||
}
|
||||
break;
|
||||
@@ -213,6 +197,11 @@ export class StatusStore {
|
||||
legs: [...call.legs.values()].map((leg) => ({
|
||||
id: leg.id,
|
||||
type: leg.type,
|
||||
state: leg.state,
|
||||
codec: leg.codec,
|
||||
rtpPort: leg.rtpPort,
|
||||
mediaProtocol: leg.mediaProtocol,
|
||||
remoteMedia: leg.remoteMedia,
|
||||
metadata: leg.metadata || {},
|
||||
})),
|
||||
});
|
||||
@@ -226,10 +215,7 @@ export class StatusStore {
|
||||
}
|
||||
|
||||
noteLegAdded(data: ILegAddedEvent): void {
|
||||
const call = this.activeCalls.get(data.call_id);
|
||||
if (!call) {
|
||||
return;
|
||||
}
|
||||
const call = this.getOrCreateCall(data.call_id);
|
||||
|
||||
call.legs.set(data.leg_id, {
|
||||
id: data.leg_id,
|
||||
@@ -237,6 +223,7 @@ export class StatusStore {
|
||||
state: data.state,
|
||||
codec: data.codec ?? null,
|
||||
rtpPort: data.rtpPort ?? null,
|
||||
mediaProtocol: data.mediaProtocol ?? null,
|
||||
remoteMedia: data.remoteMedia ?? null,
|
||||
metadata: data.metadata || {},
|
||||
});
|
||||
@@ -247,14 +234,23 @@ export class StatusStore {
|
||||
}
|
||||
|
||||
noteLegStateChanged(data: ILegStateChangedEvent): void {
|
||||
const call = this.activeCalls.get(data.call_id);
|
||||
if (!call) {
|
||||
return;
|
||||
}
|
||||
const call = this.getOrCreateCall(data.call_id);
|
||||
|
||||
const existingLeg = call.legs.get(data.leg_id);
|
||||
if (existingLeg) {
|
||||
existingLeg.state = data.state;
|
||||
if (data.codec !== undefined) {
|
||||
existingLeg.codec = data.codec;
|
||||
}
|
||||
if (data.rtpPort !== undefined) {
|
||||
existingLeg.rtpPort = data.rtpPort;
|
||||
}
|
||||
if (data.mediaProtocol !== undefined) {
|
||||
existingLeg.mediaProtocol = data.mediaProtocol;
|
||||
}
|
||||
if (data.remoteMedia !== undefined) {
|
||||
existingLeg.remoteMedia = data.remoteMedia;
|
||||
}
|
||||
if (data.metadata) {
|
||||
existingLeg.metadata = data.metadata;
|
||||
}
|
||||
@@ -265,9 +261,10 @@ export class StatusStore {
|
||||
id: data.leg_id,
|
||||
type: this.inferLegType(data.leg_id),
|
||||
state: data.state,
|
||||
codec: null,
|
||||
rtpPort: null,
|
||||
remoteMedia: null,
|
||||
codec: data.codec ?? null,
|
||||
rtpPort: data.rtpPort ?? null,
|
||||
mediaProtocol: data.mediaProtocol ?? null,
|
||||
remoteMedia: data.remoteMedia ?? null,
|
||||
metadata: data.metadata || {},
|
||||
});
|
||||
}
|
||||
@@ -310,4 +307,22 @@ export class StatusStore {
|
||||
}
|
||||
return 'webrtc';
|
||||
}
|
||||
|
||||
private getOrCreateCall(callId: string, direction: 'inbound' | 'outbound' = 'inbound'): IActiveCall {
|
||||
let call = this.activeCalls.get(callId);
|
||||
if (!call) {
|
||||
call = {
|
||||
id: callId,
|
||||
direction,
|
||||
callerNumber: null,
|
||||
calleeNumber: null,
|
||||
providerUsed: null,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
};
|
||||
this.activeCalls.set(callId, call);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface IOutboundCallStartedEvent {
|
||||
call_id: string;
|
||||
number: string;
|
||||
provider_id: string;
|
||||
ring_browsers?: boolean;
|
||||
}
|
||||
|
||||
export interface ICallRingingEvent {
|
||||
@@ -28,6 +29,7 @@ export interface ICallAnsweredEvent {
|
||||
call_id: string;
|
||||
provider_media_addr?: string;
|
||||
provider_media_port?: number;
|
||||
media_protocol?: string;
|
||||
sip_pt?: number;
|
||||
}
|
||||
|
||||
@@ -67,6 +69,7 @@ export interface ILegAddedEvent {
|
||||
state: string;
|
||||
codec?: string | null;
|
||||
rtpPort?: number | null;
|
||||
mediaProtocol?: string | null;
|
||||
remoteMedia?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -80,6 +83,10 @@ export interface ILegStateChangedEvent {
|
||||
call_id: string;
|
||||
leg_id: string;
|
||||
state: string;
|
||||
codec?: string | null;
|
||||
rtpPort?: number | null;
|
||||
mediaProtocol?: string | null;
|
||||
remoteMedia?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -125,6 +132,56 @@ export interface IVoicemailErrorEvent {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface IFaxStartedEvent {
|
||||
call_id: string;
|
||||
leg_id: string;
|
||||
direction: 'outbound' | 'inbound';
|
||||
transport: 'audio' | 't38';
|
||||
file_path: string;
|
||||
fax_box_id?: string;
|
||||
caller_number?: string;
|
||||
codec?: string;
|
||||
remote_media?: string;
|
||||
}
|
||||
|
||||
export interface IFaxCompletedEvent {
|
||||
call_id: string;
|
||||
leg_id: string;
|
||||
direction: 'outbound' | 'inbound';
|
||||
transport: 'audio' | 't38';
|
||||
file_path: string;
|
||||
fax_box_id?: string;
|
||||
caller_number?: string;
|
||||
codec?: string;
|
||||
success: boolean;
|
||||
completion_code?: number | null;
|
||||
completion_label?: string | null;
|
||||
stats: {
|
||||
bit_rate: number;
|
||||
error_correcting_mode: boolean;
|
||||
pages_tx: number;
|
||||
pages_rx: number;
|
||||
image_size: number;
|
||||
bad_rows: number;
|
||||
longest_bad_row_run: number;
|
||||
ecm_retries: number;
|
||||
current_status: number;
|
||||
rtp_events: number;
|
||||
rtn_events: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IFaxFailedEvent {
|
||||
call_id: string;
|
||||
leg_id: string;
|
||||
direction: 'outbound' | 'inbound';
|
||||
transport: 'audio' | 't38';
|
||||
file_path: string;
|
||||
fax_box_id?: string;
|
||||
caller_number?: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type TProxyEventMap = {
|
||||
provider_registered: IProviderRegisteredEvent;
|
||||
device_registered: IDeviceRegisteredEvent;
|
||||
@@ -145,4 +202,7 @@ export type TProxyEventMap = {
|
||||
voicemail_started: IVoicemailStartedEvent;
|
||||
recording_done: IRecordingDoneEvent;
|
||||
voicemail_error: IVoicemailErrorEvent;
|
||||
fax_started: IFaxStartedEvent;
|
||||
fax_completed: IFaxCompletedEvent;
|
||||
fax_failed: IFaxFailedEvent;
|
||||
};
|
||||
|
||||
+6
-1
@@ -26,6 +26,7 @@ export interface IActiveLeg {
|
||||
state: string;
|
||||
codec: string | null;
|
||||
rtpPort: number | null;
|
||||
mediaProtocol: string | null;
|
||||
remoteMedia: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
@@ -43,7 +44,11 @@ export interface IActiveCall {
|
||||
|
||||
export interface IHistoryLeg {
|
||||
id: string;
|
||||
type: string;
|
||||
type: TLegType;
|
||||
state: string;
|
||||
codec: string | null;
|
||||
rtpPort: number | null;
|
||||
remoteMedia: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
+60
-22
@@ -8,7 +8,9 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadConfig, type IAppConfig } from './config.ts';
|
||||
import { applyConfigUpdates, type IAppConfig } from './config.ts';
|
||||
import { FaxBoxManager } from './faxbox.ts';
|
||||
import { FaxJobManager } from './faxjobs.ts';
|
||||
import { broadcastWs, initWebUi } from './frontend.ts';
|
||||
import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts';
|
||||
import { VoiceboxManager } from './voicebox.ts';
|
||||
@@ -25,20 +27,21 @@ import {
|
||||
} from './proxybridge.ts';
|
||||
import { registerProxyEventHandlers } from './runtime/proxy-events.ts';
|
||||
import { StatusStore } from './runtime/status-store.ts';
|
||||
import { SiprouterStorage } from './storage.ts';
|
||||
import { WebRtcLinkManager, type IProviderMediaInfo } from './runtime/webrtc-linking.ts';
|
||||
|
||||
let appConfig: IAppConfig = loadConfig();
|
||||
let appConfig: IAppConfig;
|
||||
|
||||
const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
|
||||
const startTime = Date.now();
|
||||
const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const statusStore = new StatusStore(appConfig);
|
||||
const webRtcLinks = new WebRtcLinkManager();
|
||||
const voiceboxManager = new VoiceboxManager(log);
|
||||
|
||||
voiceboxManager.init(appConfig.voiceboxes ?? []);
|
||||
initWebRtcSignaling({ log });
|
||||
const storage = new SiprouterStorage(log);
|
||||
let statusStore: StatusStore;
|
||||
let webRtcLinks: WebRtcLinkManager;
|
||||
let faxBoxManager: FaxBoxManager;
|
||||
let faxJobManager: FaxJobManager;
|
||||
let voiceboxManager: VoiceboxManager;
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
@@ -61,6 +64,7 @@ function buildProxyConfig(config: IAppConfig): Record<string, unknown> {
|
||||
providers: config.providers,
|
||||
devices: config.devices,
|
||||
routing: config.routing,
|
||||
faxboxes: config.faxboxes ?? [],
|
||||
voiceboxes: config.voiceboxes ?? [],
|
||||
ivr: config.ivr,
|
||||
};
|
||||
@@ -89,11 +93,12 @@ async function configureRuntime(config: IAppConfig): Promise<boolean> {
|
||||
async function reloadConfig(): Promise<void> {
|
||||
try {
|
||||
const previousConfig = appConfig;
|
||||
const nextConfig = loadConfig();
|
||||
const nextConfig = await storage.getAppConfig();
|
||||
|
||||
appConfig = nextConfig;
|
||||
statusStore.updateConfig(nextConfig);
|
||||
voiceboxManager.init(nextConfig.voiceboxes ?? []);
|
||||
await faxBoxManager.init(nextConfig.faxboxes ?? []);
|
||||
await voiceboxManager.init(nextConfig.voiceboxes ?? []);
|
||||
|
||||
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
|
||||
log('[config] proxy.lanPort changed; restart required for SIP socket rebinding');
|
||||
@@ -113,6 +118,13 @@ async function reloadConfig(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateConfig(updatesArg: any): Promise<IAppConfig> {
|
||||
const nextConfig = applyConfigUpdates(appConfig, updatesArg);
|
||||
await storage.writeAppConfig(nextConfig);
|
||||
await reloadConfig();
|
||||
return appConfig;
|
||||
}
|
||||
|
||||
async function startProxyEngine(): Promise<void> {
|
||||
const started = await initProxyEngine(log);
|
||||
if (!started) {
|
||||
@@ -123,6 +135,8 @@ async function startProxyEngine(): Promise<void> {
|
||||
registerProxyEventHandlers({
|
||||
log,
|
||||
statusStore,
|
||||
faxBoxManager,
|
||||
faxJobManager,
|
||||
voiceboxManager,
|
||||
webRtcLinks,
|
||||
getBrowserDeviceIds: getAllBrowserDeviceIds,
|
||||
@@ -145,28 +159,44 @@ async function startProxyEngine(): Promise<void> {
|
||||
log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await storage.init();
|
||||
appConfig = await storage.getAppConfig();
|
||||
|
||||
statusStore = new StatusStore(appConfig);
|
||||
webRtcLinks = new WebRtcLinkManager();
|
||||
faxBoxManager = new FaxBoxManager(log, storage);
|
||||
faxJobManager = new FaxJobManager(log, storage);
|
||||
voiceboxManager = new VoiceboxManager(log, storage);
|
||||
|
||||
await faxBoxManager.init(appConfig.faxboxes ?? []);
|
||||
await faxJobManager.init();
|
||||
await voiceboxManager.init(appConfig.voiceboxes ?? []);
|
||||
initWebRtcSignaling({ log });
|
||||
|
||||
initWebUi({
|
||||
port: appConfig.proxy.webUiPort,
|
||||
getStatus,
|
||||
getConfig: () => appConfig,
|
||||
updateConfig,
|
||||
log,
|
||||
onStartCall: (number, deviceId, providerId) => {
|
||||
onStartCall: async (number, deviceId, providerId) => {
|
||||
log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`);
|
||||
void makeCall(number, deviceId, providerId).then((callId) => {
|
||||
if (callId) {
|
||||
const callId = await makeCall(number, deviceId, providerId);
|
||||
if (!callId) {
|
||||
log(`[dashboard] call failed for ${number}`);
|
||||
return null;
|
||||
}
|
||||
log(`[dashboard] call started: ${callId}`);
|
||||
statusStore.noteDashboardCallStarted(callId, number, providerId);
|
||||
} else {
|
||||
log(`[dashboard] call failed for ${number}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { id: `pending-${Date.now()}` };
|
||||
return { id: callId };
|
||||
},
|
||||
onHangupCall: (callId) => {
|
||||
void hangupCall(callId);
|
||||
return true;
|
||||
},
|
||||
onConfigSaved: reloadConfig,
|
||||
faxBoxManager,
|
||||
faxJobManager,
|
||||
voiceboxManager,
|
||||
onWebRtcOffer: async (sessionId, sdp, ws) => {
|
||||
log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`);
|
||||
@@ -187,7 +217,7 @@ initWebUi({
|
||||
log('[webrtc] ERROR: no answer SDP from Rust');
|
||||
},
|
||||
onWebRtcIce: async (sessionId, candidate) => {
|
||||
await webrtcIce(sessionId, candidate);
|
||||
await webrtcIce(sessionId, candidate as Parameters<typeof webrtcIce>[1]);
|
||||
},
|
||||
onWebRtcClose: async (sessionId) => {
|
||||
webRtcLinks.removeSession(sessionId);
|
||||
@@ -206,16 +236,24 @@ initWebUi({
|
||||
},
|
||||
});
|
||||
|
||||
void startProxyEngine();
|
||||
await startProxyEngine();
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
log(`[FATAL] ${errorMessage(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
log('SIGINT, exiting');
|
||||
shutdownProxyEngine();
|
||||
void storage.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
log('SIGTERM, exiting');
|
||||
shutdownProxyEngine();
|
||||
void storage.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
import fs from 'node:fs';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import * as plugins from './plugins.ts';
|
||||
import {
|
||||
createInitialConfigFromEnv,
|
||||
normalizeConfig,
|
||||
type IAppConfig,
|
||||
} from './config.ts';
|
||||
import type { IFaxMessage } from './faxbox.ts';
|
||||
import type { IFaxJob } from './faxjobs.ts';
|
||||
import type { IVoicemailMessage } from './voicebox.ts';
|
||||
|
||||
interface ISiprouterDataStore {
|
||||
appConfig: IAppConfig;
|
||||
faxJobs: IFaxJob[];
|
||||
faxMessagesByBox: Record<string, IFaxMessage[]>;
|
||||
voicemailMessagesByBox: Record<string, IVoicemailMessage[]>;
|
||||
}
|
||||
|
||||
type TLogFunction = (messageArg: string) => void;
|
||||
|
||||
const legacyConfigPath = path.join(process.cwd(), '.nogit', 'config.json');
|
||||
|
||||
function requiredEnv(keysArg: string[]): string {
|
||||
for (const key of keysArg) {
|
||||
const value = process.env[key];
|
||||
if (value) return value;
|
||||
}
|
||||
throw new Error(`Missing required environment variable: ${keysArg.join(' or ')}`);
|
||||
}
|
||||
|
||||
function optionalNumber(valueArg: string | undefined, fallbackArg?: number): number | undefined {
|
||||
if (!valueArg) return fallbackArg;
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? parsed : fallbackArg;
|
||||
}
|
||||
|
||||
function optionalBoolean(valueArg: string | undefined, fallbackArg?: boolean): boolean | undefined {
|
||||
if (valueArg === undefined) return fallbackArg;
|
||||
return !['0', 'false', 'no', 'off'].includes(valueArg.toLowerCase());
|
||||
}
|
||||
|
||||
function normalizeObjectKey(keyArg: string): string {
|
||||
const normalizedKey = keyArg.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/');
|
||||
if (normalizedKey.split('/').includes('..')) {
|
||||
throw new Error(`Invalid object key: ${keyArg}`);
|
||||
}
|
||||
return normalizedKey;
|
||||
}
|
||||
|
||||
export class SiprouterStorage {
|
||||
private db!: InstanceType<typeof plugins.smartdata.SmartdataDb>;
|
||||
private store!: any;
|
||||
private bucket!: any;
|
||||
private readonly cacheDir = path.join(process.cwd(), '.nogit', 'cache');
|
||||
private readonly log: TLogFunction;
|
||||
|
||||
constructor(logArg: TLogFunction) {
|
||||
this.log = logArg;
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
this.db = new plugins.smartdata.SmartdataDb(this.getMongoDescriptor() as any);
|
||||
await this.db.init();
|
||||
this.store = await this.db.createEasyStore('siprouter-data');
|
||||
|
||||
const smartBucket = new plugins.smartbucket.SmartBucket(this.getS3Descriptor() as any);
|
||||
const bucketName = requiredEnv(['SIPROUTER_S3_BUCKET', 'S3_BUCKET']);
|
||||
this.bucket = await smartBucket.bucketExists(bucketName)
|
||||
? await smartBucket.getBucketByName(bucketName)
|
||||
: await smartBucket.createBucket(bucketName);
|
||||
|
||||
await fsPromises.mkdir(this.cacheDir, { recursive: true });
|
||||
this.log('[storage] smartdata and smartbucket initialized');
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
if (this.db) {
|
||||
await this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
public async getAppConfig(): Promise<IAppConfig> {
|
||||
const storedConfig = await this.readKey('appConfig');
|
||||
if (storedConfig) {
|
||||
return normalizeConfig(storedConfig);
|
||||
}
|
||||
|
||||
const legacyConfig = await this.readLegacyConfig();
|
||||
const initialConfig = legacyConfig || createInitialConfigFromEnv();
|
||||
await this.writeAppConfig(initialConfig);
|
||||
this.log(legacyConfig ? '[storage] imported legacy .nogit/config.json into smartdata' : '[storage] created initial smartdata config');
|
||||
return initialConfig;
|
||||
}
|
||||
|
||||
public async writeAppConfig(configArg: IAppConfig): Promise<void> {
|
||||
await this.writeKey('appConfig', normalizeConfig(configArg));
|
||||
}
|
||||
|
||||
public async getFaxJobs(): Promise<IFaxJob[]> {
|
||||
return (await this.readKey('faxJobs')) || [];
|
||||
}
|
||||
|
||||
public async writeFaxJobs(jobsArg: IFaxJob[]): Promise<void> {
|
||||
await this.writeKey('faxJobs', jobsArg);
|
||||
}
|
||||
|
||||
public async getVoicemailMessages(boxIdArg: string): Promise<IVoicemailMessage[]> {
|
||||
const allMessages = (await this.readKey('voicemailMessagesByBox')) || {};
|
||||
return allMessages[boxIdArg] || [];
|
||||
}
|
||||
|
||||
public async writeVoicemailMessages(boxIdArg: string, messagesArg: IVoicemailMessage[]): Promise<void> {
|
||||
const allMessages = (await this.readKey('voicemailMessagesByBox')) || {};
|
||||
allMessages[boxIdArg] = messagesArg;
|
||||
await this.writeKey('voicemailMessagesByBox', allMessages);
|
||||
}
|
||||
|
||||
public async getFaxMessages(boxIdArg: string): Promise<IFaxMessage[]> {
|
||||
const allMessages = (await this.readKey('faxMessagesByBox')) || {};
|
||||
return allMessages[boxIdArg] || [];
|
||||
}
|
||||
|
||||
public async writeFaxMessages(boxIdArg: string, messagesArg: IFaxMessage[]): Promise<void> {
|
||||
const allMessages = (await this.readKey('faxMessagesByBox')) || {};
|
||||
allMessages[boxIdArg] = messagesArg;
|
||||
await this.writeKey('faxMessagesByBox', allMessages);
|
||||
}
|
||||
|
||||
public async putFileObject(objectKeyArg: string, filePathArg: string): Promise<string> {
|
||||
const objectKey = normalizeObjectKey(objectKeyArg);
|
||||
const contents = await fsPromises.readFile(filePathArg);
|
||||
await this.bucket.fastPut({ path: objectKey, contents, overwrite: true });
|
||||
await this.removeCachedObject(objectKey);
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
public async putBufferObject(objectKeyArg: string, bufferArg: Buffer): Promise<string> {
|
||||
const objectKey = normalizeObjectKey(objectKeyArg);
|
||||
await this.bucket.fastPut({ path: objectKey, contents: bufferArg, overwrite: true });
|
||||
await this.removeCachedObject(objectKey);
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
public async getObjectAsCachedFile(objectKeyArg: string, fileNameArg?: string): Promise<string | null> {
|
||||
const objectKey = normalizeObjectKey(objectKeyArg);
|
||||
const cachePath = this.getCachePath(objectKey);
|
||||
try {
|
||||
if (fs.existsSync(cachePath)) {
|
||||
return cachePath;
|
||||
}
|
||||
const contents = await this.bucket.fastGet({ path: objectKey });
|
||||
await fsPromises.mkdir(path.dirname(cachePath), { recursive: true });
|
||||
await fsPromises.writeFile(cachePath, contents);
|
||||
return cachePath;
|
||||
} catch {
|
||||
if (fileNameArg) {
|
||||
const fallbackPath = path.join(this.cacheDir, path.basename(fileNameArg));
|
||||
return fs.existsSync(fallbackPath) ? fallbackPath : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async removeObject(objectKeyArg: string | undefined): Promise<void> {
|
||||
if (!objectKeyArg) return;
|
||||
const objectKey = normalizeObjectKey(objectKeyArg);
|
||||
try {
|
||||
await this.bucket.fastRemove({ path: objectKey });
|
||||
} catch {
|
||||
// Missing objects are harmless during metadata cleanup.
|
||||
}
|
||||
await this.removeCachedObject(objectKey);
|
||||
}
|
||||
|
||||
private getCachePath(objectKeyArg: string): string {
|
||||
return path.join(this.cacheDir, normalizeObjectKey(objectKeyArg));
|
||||
}
|
||||
|
||||
private async removeCachedObject(objectKeyArg: string): Promise<void> {
|
||||
await fsPromises.rm(this.getCachePath(objectKeyArg), { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
private async readLegacyConfig(): Promise<IAppConfig | null> {
|
||||
try {
|
||||
const raw = await fsPromises.readFile(legacyConfigPath, 'utf8');
|
||||
return normalizeConfig(JSON.parse(raw) as IAppConfig);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async readKey<TKey extends keyof ISiprouterDataStore>(keyArg: TKey): Promise<ISiprouterDataStore[TKey] | undefined> {
|
||||
try {
|
||||
return await this.store.readKey(keyArg) as ISiprouterDataStore[TKey] | undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeKey<TKey extends keyof ISiprouterDataStore>(
|
||||
keyArg: TKey,
|
||||
valueArg: ISiprouterDataStore[TKey],
|
||||
): Promise<void> {
|
||||
await this.store.writeKey(keyArg, valueArg);
|
||||
}
|
||||
|
||||
private getMongoDescriptor(): Record<string, string> {
|
||||
const mongoDbUrl = requiredEnv([
|
||||
'SIPROUTER_MONGODB_URL',
|
||||
'MONGODB_URI',
|
||||
'MONGODB_URL',
|
||||
]);
|
||||
const descriptor: Record<string, string> = {
|
||||
mongoDbUrl,
|
||||
mongoDbName: process.env.SIPROUTER_MONGODB_NAME || process.env.MONGODB_DATABASE || process.env.MONGODB_NAME || 'siprouter',
|
||||
};
|
||||
|
||||
const mongoDbUser = process.env.SIPROUTER_MONGODB_USER || process.env.MONGODB_USERNAME || process.env.MONGODB_USER;
|
||||
const mongoDbPass = process.env.SIPROUTER_MONGODB_PASS || process.env.MONGODB_PASSWORD || process.env.MONGODB_PASS;
|
||||
if (mongoDbUser) descriptor.mongoDbUser = mongoDbUser;
|
||||
if (mongoDbPass) descriptor.mongoDbPass = mongoDbPass;
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
private getS3Descriptor(): Record<string, string | number | boolean> {
|
||||
const rawEndpoint = requiredEnv(['SIPROUTER_S3_ENDPOINT', 'S3_ENDPOINT', 'AWS_ENDPOINT_URL']);
|
||||
let endpoint = rawEndpoint;
|
||||
let port = optionalNumber(process.env.SIPROUTER_S3_PORT || process.env.S3_PORT);
|
||||
let useSsl = optionalBoolean(process.env.SIPROUTER_S3_USESSL || process.env.S3_USESSL || process.env.S3_USE_SSL);
|
||||
|
||||
if (/^https?:\/\//.test(rawEndpoint)) {
|
||||
const url = new URL(rawEndpoint);
|
||||
endpoint = url.hostname;
|
||||
port = url.port ? Number(url.port) : port;
|
||||
useSsl = url.protocol === 'https:';
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
accessKey: requiredEnv(['SIPROUTER_S3_ACCESS_KEY', 'S3_ACCESS_KEY', 'AWS_ACCESS_KEY_ID']),
|
||||
accessSecret: requiredEnv(['SIPROUTER_S3_SECRET_KEY', 'S3_SECRET_KEY', 'AWS_SECRET_ACCESS_KEY']),
|
||||
region: process.env.SIPROUTER_S3_REGION || process.env.S3_REGION || process.env.AWS_REGION || 'us-east-1',
|
||||
...(port ? { port } : {}),
|
||||
...(useSsl !== undefined ? { useSsl } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
+104
-166
@@ -1,22 +1,12 @@
|
||||
/**
|
||||
* VoiceboxManager — manages voicemail boxes, message storage, and MWI.
|
||||
*
|
||||
* Each voicebox corresponds to a device/extension. Messages are stored
|
||||
* as WAV files with JSON metadata in .nogit/voicemail/{boxId}/.
|
||||
*
|
||||
* Supports:
|
||||
* - Per-box configurable TTS greetings (text + voice) or uploaded WAV
|
||||
* - Message CRUD: save, list, mark heard, delete
|
||||
* - Unheard count for MWI (Message Waiting Indicator)
|
||||
* - Storage limit (max messages per box)
|
||||
* VoiceboxManager — manages voicemail boxes, message metadata, and audio objects.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
import type { SiprouterStorage } from './storage.ts';
|
||||
|
||||
export interface IVoiceboxConfig {
|
||||
/** Unique ID — typically matches device ID or extension. */
|
||||
@@ -27,11 +17,9 @@ export interface IVoiceboxConfig {
|
||||
greetingText?: string;
|
||||
/** Kokoro TTS voice ID for the greeting (default 'af_bella'). */
|
||||
greetingVoice?: string;
|
||||
/** Path to uploaded WAV greeting (overrides TTS). */
|
||||
/** Path to cached uploaded WAV greeting (overrides TTS). */
|
||||
greetingWavPath?: string;
|
||||
/** Seconds to wait before routing to voicemail. Defaults to 25 when
|
||||
* absent — both the config loader and `VoiceboxManager.init` apply
|
||||
* the default via `??=`. */
|
||||
/** Seconds to wait before routing to voicemail. */
|
||||
noAnswerTimeoutSec?: number;
|
||||
/** Maximum recording duration in seconds. Defaults to 120. */
|
||||
maxRecordingSec?: number;
|
||||
@@ -52,112 +40,80 @@ export interface IVoicemailMessage {
|
||||
timestamp: number;
|
||||
/** Duration in milliseconds. */
|
||||
durationMs: number;
|
||||
/** Relative path to the WAV file (within the box directory). */
|
||||
/** Display file name. */
|
||||
fileName: string;
|
||||
/** SmartBucket object key for the WAV payload. */
|
||||
objectKey?: string;
|
||||
/** Whether the message has been listened to. */
|
||||
heard: boolean;
|
||||
}
|
||||
|
||||
// Default greeting text when no custom text is configured.
|
||||
const DEFAULT_GREETING = 'The person you are trying to reach is not available. Please leave a message after the tone.';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VoiceboxManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class VoiceboxManager {
|
||||
private boxes = new Map<string, IVoiceboxConfig>();
|
||||
private basePath: string;
|
||||
private log: (msg: string) => void;
|
||||
private messagesByBox = new Map<string, IVoicemailMessage[]>();
|
||||
private readonly basePath: string;
|
||||
private readonly log: (msg: string) => void;
|
||||
private readonly storage: SiprouterStorage;
|
||||
|
||||
constructor(log: (msg: string) => void) {
|
||||
constructor(log: (msg: string) => void, storageArg: SiprouterStorage) {
|
||||
this.basePath = path.join(process.cwd(), '.nogit', 'voicemail');
|
||||
this.log = log;
|
||||
this.storage = storageArg;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load voicebox configurations from the app config.
|
||||
*/
|
||||
init(voiceboxConfigs: IVoiceboxConfig[]): void {
|
||||
async init(voiceboxConfigs: IVoiceboxConfig[]): Promise<void> {
|
||||
this.boxes.clear();
|
||||
|
||||
for (const cfg of voiceboxConfigs) {
|
||||
// Apply defaults.
|
||||
cfg.noAnswerTimeoutSec ??= 25;
|
||||
cfg.maxRecordingSec ??= 120;
|
||||
cfg.maxMessages ??= 50;
|
||||
cfg.greetingVoice ??= 'af_bella';
|
||||
|
||||
this.boxes.set(cfg.id, cfg);
|
||||
this.messagesByBox.set(cfg.id, await this.loadMessages(cfg.id));
|
||||
}
|
||||
|
||||
// Ensure base directory exists.
|
||||
fs.mkdirSync(this.basePath, { recursive: true });
|
||||
|
||||
await fsPromises.mkdir(this.basePath, { recursive: true });
|
||||
this.log(`[voicebox] initialized ${this.boxes.size} voicebox(es)`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Box management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Get config for a specific voicebox. */
|
||||
getBox(boxId: string): IVoiceboxConfig | null {
|
||||
return this.boxes.get(boxId) ?? null;
|
||||
}
|
||||
|
||||
/** Get all configured voicebox IDs. */
|
||||
getBoxIds(): string[] {
|
||||
return [...this.boxes.keys()];
|
||||
}
|
||||
|
||||
/** Get the greeting text for a voicebox. */
|
||||
getGreetingText(boxId: string): string {
|
||||
const box = this.boxes.get(boxId);
|
||||
return box?.greetingText || DEFAULT_GREETING;
|
||||
}
|
||||
|
||||
/** Get the greeting voice for a voicebox. */
|
||||
getGreetingVoice(boxId: string): string {
|
||||
const box = this.boxes.get(boxId);
|
||||
return box?.greetingVoice || 'af_bella';
|
||||
}
|
||||
|
||||
/** Check if a voicebox has a custom WAV greeting. */
|
||||
hasCustomGreetingWav(boxId: string): boolean {
|
||||
const box = this.boxes.get(boxId);
|
||||
if (!box?.greetingWavPath) return false;
|
||||
return fs.existsSync(box.greetingWavPath);
|
||||
}
|
||||
|
||||
/** Get the greeting WAV path (custom or null). */
|
||||
getCustomGreetingWavPath(boxId: string): string | null {
|
||||
const box = this.boxes.get(boxId);
|
||||
if (!box?.greetingWavPath) return null;
|
||||
return fs.existsSync(box.greetingWavPath) ? box.greetingWavPath : null;
|
||||
}
|
||||
|
||||
/** Get the directory path for a voicebox. */
|
||||
getBoxDir(boxId: string): string {
|
||||
return path.join(this.basePath, boxId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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(
|
||||
async addMessage(
|
||||
boxId: string,
|
||||
info: {
|
||||
callerNumber: string;
|
||||
@@ -165,124 +121,87 @@ export class VoiceboxManager {
|
||||
fileName: string;
|
||||
durationMs: number;
|
||||
},
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const id = crypto.randomUUID();
|
||||
const localPath = path.isAbsolute(info.fileName) ? info.fileName : path.join(process.cwd(), info.fileName);
|
||||
const objectKey = await this.storage.putFileObject(`voicemail/${boxId}/${id}.wav`, localPath);
|
||||
|
||||
const msg: IVoicemailMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
id,
|
||||
boxId,
|
||||
callerNumber: info.callerNumber,
|
||||
callerName: info.callerName ?? undefined,
|
||||
timestamp: Date.now(),
|
||||
durationMs: info.durationMs,
|
||||
fileName: path.basename(info.fileName),
|
||||
fileName: path.basename(localPath),
|
||||
objectKey,
|
||||
heard: false,
|
||||
};
|
||||
this.saveMessage(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new voicemail message.
|
||||
* The WAV file should already exist at the expected path.
|
||||
*/
|
||||
saveMessage(msg: IVoicemailMessage): void {
|
||||
const boxDir = this.getBoxDir(msg.boxId);
|
||||
fs.mkdirSync(boxDir, { recursive: true });
|
||||
const messages = this.getMessages(boxId);
|
||||
messages.unshift(msg);
|
||||
await this.enforceLimit(boxId, messages);
|
||||
await this.writeMessages(boxId, messages);
|
||||
|
||||
const messages = this.loadMessages(msg.boxId);
|
||||
messages.unshift(msg); // newest first
|
||||
|
||||
// Enforce max messages — delete oldest.
|
||||
const box = this.boxes.get(msg.boxId);
|
||||
const maxMessages = box?.maxMessages ?? 50;
|
||||
while (messages.length > maxMessages) {
|
||||
const old = messages.pop()!;
|
||||
const oldPath = path.join(boxDir, old.fileName);
|
||||
try {
|
||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
this.writeMessages(msg.boxId, messages);
|
||||
await fsPromises.rm(localPath, { force: true }).catch(() => {});
|
||||
this.log(`[voicebox] saved message ${msg.id} in box "${msg.boxId}" (${msg.durationMs}ms from ${msg.callerNumber})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List messages for a voicebox (newest first).
|
||||
*/
|
||||
getMessages(boxId: string): IVoicemailMessage[] {
|
||||
return this.loadMessages(boxId);
|
||||
return [...(this.messagesByBox.get(boxId) || [])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single message by ID.
|
||||
*/
|
||||
getMessage(boxId: string, messageId: string): IVoicemailMessage | null {
|
||||
const messages = this.loadMessages(boxId);
|
||||
const messages = this.messagesByBox.get(boxId) || [];
|
||||
return messages.find((m) => m.id === messageId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a message as heard.
|
||||
*/
|
||||
markHeard(boxId: string, messageId: string): boolean {
|
||||
const messages = this.loadMessages(boxId);
|
||||
async markHeard(boxId: string, messageId: string): Promise<boolean> {
|
||||
const messages = this.messagesByBox.get(boxId) || [];
|
||||
const msg = messages.find((m) => m.id === messageId);
|
||||
if (!msg) return false;
|
||||
|
||||
msg.heard = true;
|
||||
this.writeMessages(boxId, messages);
|
||||
await this.writeMessages(boxId, messages);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message (both metadata and WAV file).
|
||||
*/
|
||||
deleteMessage(boxId: string, messageId: string): boolean {
|
||||
const messages = this.loadMessages(boxId);
|
||||
async deleteMessage(boxId: string, messageId: string): Promise<boolean> {
|
||||
const messages = this.messagesByBox.get(boxId) || [];
|
||||
const idx = messages.findIndex((m) => m.id === messageId);
|
||||
if (idx === -1) return false;
|
||||
|
||||
const msg = messages[idx];
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
const wavPath = path.join(boxDir, msg.fileName);
|
||||
await this.storage.removeObject(msg.objectKey);
|
||||
if (!msg.objectKey) {
|
||||
await fsPromises.rm(path.join(this.getBoxDir(boxId), msg.fileName), { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
// Delete WAV file.
|
||||
try {
|
||||
if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath);
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// Remove from list and save.
|
||||
messages.splice(idx, 1);
|
||||
this.writeMessages(boxId, messages);
|
||||
await this.writeMessages(boxId, messages);
|
||||
this.log(`[voicebox] deleted message ${messageId} from box "${boxId}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full file path for a message's WAV file.
|
||||
*/
|
||||
getMessageAudioPath(boxId: string, messageId: string): string | null {
|
||||
async getMessageAudioPath(boxId: string, messageId: string): Promise<string | null> {
|
||||
const msg = this.getMessage(boxId, messageId);
|
||||
if (!msg) return null;
|
||||
if (msg.objectKey) {
|
||||
return await this.storage.getObjectAsCachedFile(msg.objectKey, msg.fileName);
|
||||
}
|
||||
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
|
||||
return fs.existsSync(filePath) ? filePath : null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Counts
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Get count of unheard messages for a voicebox. */
|
||||
getUnheardCount(boxId: string): number {
|
||||
const messages = this.loadMessages(boxId);
|
||||
const messages = this.messagesByBox.get(boxId) || [];
|
||||
return messages.filter((m) => !m.heard).length;
|
||||
}
|
||||
|
||||
/** Get total message count for a voicebox. */
|
||||
getTotalCount(boxId: string): number {
|
||||
return this.loadMessages(boxId).length;
|
||||
return (this.messagesByBox.get(boxId) || []).length;
|
||||
}
|
||||
|
||||
/** Get unheard counts for all voiceboxes. */
|
||||
getAllUnheardCounts(): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const boxId of this.boxes.keys()) {
|
||||
@@ -291,55 +210,74 @@ export class VoiceboxManager {
|
||||
return counts;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Greeting management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Save a custom greeting WAV file for a voicebox.
|
||||
*/
|
||||
saveCustomGreeting(boxId: string, wavData: Buffer): string {
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
fs.mkdirSync(boxDir, { recursive: true });
|
||||
const greetingPath = path.join(boxDir, 'greeting.wav');
|
||||
fs.writeFileSync(greetingPath, wavData);
|
||||
async saveCustomGreeting(boxId: string, wavData: Buffer): Promise<string> {
|
||||
const objectKey = await this.storage.putBufferObject(`voicemail/${boxId}/greeting.wav`, wavData);
|
||||
const greetingPath = await this.storage.getObjectAsCachedFile(objectKey, `voicemail-${boxId}-greeting.wav`);
|
||||
this.log(`[voicebox] saved custom greeting for box "${boxId}"`);
|
||||
return greetingPath;
|
||||
return greetingPath || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the custom greeting for a voicebox (falls back to TTS).
|
||||
*/
|
||||
deleteCustomGreeting(boxId: string): void {
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
const greetingPath = path.join(boxDir, 'greeting.wav');
|
||||
try {
|
||||
if (fs.existsSync(greetingPath)) fs.unlinkSync(greetingPath);
|
||||
} catch { /* best effort */ }
|
||||
async deleteCustomGreeting(boxId: string): Promise<void> {
|
||||
await this.storage.removeObject(`voicemail/${boxId}/greeting.wav`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal: JSON persistence
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private messagesPath(boxId: string): string {
|
||||
return path.join(this.getBoxDir(boxId), 'messages.json');
|
||||
private async enforceLimit(boxId: string, messages: IVoicemailMessage[]): Promise<void> {
|
||||
const box = this.boxes.get(boxId);
|
||||
const maxMessages = box?.maxMessages ?? 50;
|
||||
while (messages.length > maxMessages) {
|
||||
const old = messages.pop()!;
|
||||
await this.storage.removeObject(old.objectKey);
|
||||
if (!old.objectKey) {
|
||||
await fsPromises.rm(path.join(this.getBoxDir(boxId), old.fileName), { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private loadMessages(boxId: string): IVoicemailMessage[] {
|
||||
const filePath = this.messagesPath(boxId);
|
||||
private async loadMessages(boxId: string): Promise<IVoicemailMessage[]> {
|
||||
const storedMessages = await this.storage.getVoicemailMessages(boxId);
|
||||
if (storedMessages.length) return await this.ensureMessageObjects(boxId, storedMessages);
|
||||
|
||||
const filePath = path.join(this.getBoxDir(boxId), 'messages.json');
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(raw) as IVoicemailMessage[];
|
||||
const raw = await fsPromises.readFile(filePath, 'utf8');
|
||||
const legacyMessages = await this.ensureMessageObjects(boxId, JSON.parse(raw) as IVoicemailMessage[]);
|
||||
await this.storage.writeVoicemailMessages(boxId, legacyMessages);
|
||||
return legacyMessages;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private writeMessages(boxId: string, messages: IVoicemailMessage[]): void {
|
||||
const boxDir = this.getBoxDir(boxId);
|
||||
fs.mkdirSync(boxDir, { recursive: true });
|
||||
fs.writeFileSync(this.messagesPath(boxId), JSON.stringify(messages, null, 2), 'utf8');
|
||||
private async ensureMessageObjects(boxId: string, messages: IVoicemailMessage[]): Promise<IVoicemailMessage[]> {
|
||||
let changed = false;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.id) {
|
||||
msg.id = crypto.randomUUID();
|
||||
changed = true;
|
||||
}
|
||||
if (msg.objectKey) continue;
|
||||
|
||||
const localPath = path.isAbsolute(msg.fileName) ? msg.fileName : path.join(this.getBoxDir(boxId), msg.fileName);
|
||||
if (!fs.existsSync(localPath)) continue;
|
||||
|
||||
const extension = path.extname(localPath) || '.wav';
|
||||
msg.objectKey = await this.storage.putFileObject(`voicemail/${boxId}/${msg.id}${extension}`, localPath);
|
||||
msg.fileName = path.basename(localPath);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await this.storage.writeVoicemailMessages(boxId, messages);
|
||||
this.log(`[voicebox] migrated legacy messages for box "${boxId}" to smartbucket`);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
private async writeMessages(boxId: string, messages: IVoicemailMessage[]): Promise<void> {
|
||||
this.messagesByBox.set(boxId, [...messages]);
|
||||
await this.storage.writeVoicemailMessages(boxId, messages);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.25.1',
|
||||
version: '1.27.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -32,8 +32,32 @@ const LEG_TYPE_LABELS: Record<string, string> = {
|
||||
'sip-device': 'SIP Device',
|
||||
'sip-provider': 'SIP Provider',
|
||||
'webrtc': 'WebRTC',
|
||||
'tool': 'Tool',
|
||||
};
|
||||
|
||||
function renderHistoryLegs(legs: ICallHistoryEntry['legs']): TemplateResult {
|
||||
if (!legs.length) {
|
||||
return html`<span style="color:#64748b">-</span>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px;font-size:.72rem;line-height:1.35;">
|
||||
${legs.map(
|
||||
(leg) => html`
|
||||
<div>
|
||||
<span class="badge" style="${legTypeBadgeStyle(leg.type)}">${LEG_TYPE_LABELS[leg.type] || leg.type}</span>
|
||||
<span style="margin-left:6px;font-family:'JetBrains Mono',monospace;">${leg.codec || '--'}</span>
|
||||
<span style="margin-left:6px;color:#94a3b8;">${STATE_LABELS[leg.state] || leg.state}</span>
|
||||
${leg.remoteMedia
|
||||
? html`<span style="display:block;color:#64748b;font-family:'JetBrains Mono',monospace;">${leg.remoteMedia}</span>`
|
||||
: ''}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function directionIcon(dir: string): string {
|
||||
if (dir === 'inbound') return '\u2199';
|
||||
if (dir === 'outbound') return '\u2197';
|
||||
@@ -151,36 +175,240 @@ export class SipproxyViewCalls extends DeesElement {
|
||||
|
||||
.call-body {
|
||||
padding: 12px 16px 16px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.legs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 12px;
|
||||
.call-overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(240px, 0.9fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.legs-table th {
|
||||
text-align: left;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
font-size: 0.65rem;
|
||||
.call-route-card,
|
||||
.call-facts-card,
|
||||
.legs-section {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(51, 65, 85, 0.75);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.92) 0%, rgba(8, 15, 31, 0.88) 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.call-route-card,
|
||||
.call-facts-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--dees-color-border-default, #334155);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.legs-table td {
|
||||
padding: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
border-bottom: 1px solid var(--dees-color-border-subtle, rgba(51, 65, 85, 0.5));
|
||||
vertical-align: middle;
|
||||
.route-line {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.legs-table tr:last-child td {
|
||||
.route-party {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(71, 85, 105, 0.45);
|
||||
}
|
||||
|
||||
.route-party.align-end {
|
||||
text-align: right;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.route-party-label {
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.route-party-value {
|
||||
min-width: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.route-arrow {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
color: #93c5fd;
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
.call-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.subtle-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
color: #cbd5e1;
|
||||
background: rgba(30, 41, 59, 0.9);
|
||||
border: 1px solid rgba(71, 85, 105, 0.45);
|
||||
}
|
||||
|
||||
.call-facts-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fact-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid rgba(51, 65, 85, 0.55);
|
||||
}
|
||||
|
||||
.fact-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.fact-label {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.fact-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.76rem;
|
||||
text-align: right;
|
||||
color: #e2e8f0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.legs-section {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.legs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.legs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.leg-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(71, 85, 105, 0.4);
|
||||
}
|
||||
|
||||
.leg-card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.leg-card-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.leg-card-id {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.64rem;
|
||||
color: #64748b;
|
||||
word-break: break-all;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.leg-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 12px;
|
||||
}
|
||||
|
||||
.leg-fact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.leg-fact-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.leg-fact-label {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.leg-fact-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.76rem;
|
||||
color: #e2e8f0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.leg-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.no-legs {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed rgba(71, 85, 105, 0.55);
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
@@ -223,11 +451,39 @@ export class SipproxyViewCalls extends DeesElement {
|
||||
.empty-state-icon { font-size: 2.5rem; margin-bottom: 12px; opacity: 0.4; }
|
||||
.empty-state-text { font-size: 0.9rem; font-weight: 500; }
|
||||
.empty-state-sub { font-size: 0.75rem; margin-top: 4px; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.call-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.route-line {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.route-arrow {
|
||||
justify-self: center;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.route-party.align-end {
|
||||
text-align: left;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.leg-card-top {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.leg-card-id {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
async connectedCallback(): Promise<void> {
|
||||
await super.connectedCallback();
|
||||
this.rxSubscriptions.push({
|
||||
unsubscribe: appState.subscribe((s) => {
|
||||
this.appData = s;
|
||||
@@ -490,6 +746,11 @@ export class SipproxyViewCalls extends DeesElement {
|
||||
renderer: (val: number) =>
|
||||
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${fmtDuration(val)}</span>`,
|
||||
},
|
||||
{
|
||||
key: 'legs',
|
||||
header: 'Legs',
|
||||
renderer: (val: ICallHistoryEntry['legs']) => renderHistoryLegs(val),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -521,63 +782,110 @@ export class SipproxyViewCalls extends DeesElement {
|
||||
</div>
|
||||
<div class="call-id">${call.id}</div>
|
||||
<div class="call-body">
|
||||
<div class="call-overview">
|
||||
<div class="call-route-card">
|
||||
<div class="section-kicker">Call Route</div>
|
||||
<div class="route-line">
|
||||
<div class="route-party">
|
||||
<div class="route-party-label">From</div>
|
||||
<div class="route-party-value">${call.callerNumber || 'Unknown caller'}</div>
|
||||
</div>
|
||||
<div class="route-arrow">${directionIcon(call.direction)}</div>
|
||||
<div class="route-party align-end">
|
||||
<div class="route-party-label">To</div>
|
||||
<div class="route-party-value">${call.calleeNumber || 'System'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="call-tags">
|
||||
<span class="subtle-badge">${call.legs.length} ${call.legs.length === 1 ? 'leg' : 'legs'}</span>
|
||||
<span class="subtle-badge">${call.providerUsed || 'system handled'}</span>
|
||||
<span class="subtle-badge">started ${fmtTime(call.startedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="call-facts-card">
|
||||
<div class="section-kicker">Session</div>
|
||||
<div class="fact-row">
|
||||
<span class="fact-label">State</span>
|
||||
<span class="fact-value">${STATE_LABELS[call.state] || call.state}</span>
|
||||
</div>
|
||||
<div class="fact-row">
|
||||
<span class="fact-label">Direction</span>
|
||||
<span class="fact-value">${call.direction}</span>
|
||||
</div>
|
||||
<div class="fact-row">
|
||||
<span class="fact-label">Duration</span>
|
||||
<span class="fact-value">${fmtDuration(call.duration)}</span>
|
||||
</div>
|
||||
<div class="fact-row">
|
||||
<span class="fact-label">Provider</span>
|
||||
<span class="fact-value">${call.providerUsed || '--'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legs-section">
|
||||
<div class="legs-header">
|
||||
<div class="section-kicker">Active Legs</div>
|
||||
<span class="subtle-badge">${call.legs.length}</span>
|
||||
</div>
|
||||
|
||||
${call.legs.length
|
||||
? html`
|
||||
<table class="legs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>State</th>
|
||||
<th>Remote</th>
|
||||
<th>Port</th>
|
||||
<th>Codec</th>
|
||||
<th>Pkts In</th>
|
||||
<th>Pkts Out</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div class="legs-grid">
|
||||
${call.legs.map(
|
||||
(leg) => html`
|
||||
<tr>
|
||||
<td>
|
||||
<div class="leg-card">
|
||||
<div class="leg-card-top">
|
||||
<div class="leg-card-badges">
|
||||
<span class="badge" style="${legTypeBadgeStyle(leg.type)}">
|
||||
${LEG_TYPE_LABELS[leg.type] || leg.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" style="${stateBadgeStyle(leg.state)}">
|
||||
${leg.state}
|
||||
${STATE_LABELS[leg.state] || leg.state}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
${leg.remoteMedia
|
||||
? `${leg.remoteMedia.address}:${leg.remoteMedia.port}`
|
||||
: '--'}
|
||||
</td>
|
||||
<td>${leg.rtpPort ?? '--'}</td>
|
||||
<td>
|
||||
${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''}
|
||||
</td>
|
||||
<td>${leg.pktReceived}</td>
|
||||
<td>${leg.pktSent}</td>
|
||||
<td>
|
||||
</div>
|
||||
<div class="leg-card-id">${leg.id}</div>
|
||||
</div>
|
||||
|
||||
<div class="leg-facts">
|
||||
<div class="leg-fact">
|
||||
<span class="leg-fact-label">Codec</span>
|
||||
<span class="leg-fact-value">${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''}</span>
|
||||
</div>
|
||||
<div class="leg-fact">
|
||||
<span class="leg-fact-label">RTP Port</span>
|
||||
<span class="leg-fact-value">${leg.rtpPort ?? '--'}</span>
|
||||
</div>
|
||||
<div class="leg-fact leg-fact-wide">
|
||||
<span class="leg-fact-label">Remote Media</span>
|
||||
<span class="leg-fact-value">${leg.remoteMedia || '--'}</span>
|
||||
</div>
|
||||
<div class="leg-fact">
|
||||
<span class="leg-fact-label">Packets In</span>
|
||||
<span class="leg-fact-value">${leg.pktReceived}</span>
|
||||
</div>
|
||||
<div class="leg-fact">
|
||||
<span class="leg-fact-label">Packets Out</span>
|
||||
<span class="leg-fact-value">${leg.pktSent}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="leg-actions">
|
||||
<button
|
||||
class="btn btn-remove"
|
||||
@click=${() => this.handleRemoveLeg(call, leg)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
: html`<div style="color:#64748b;font-size:.75rem;font-style:italic;margin-bottom:8px;">
|
||||
No legs
|
||||
</div>`}
|
||||
: html`<div class="no-legs">No legs reported yet. SIP/system legs should appear here as soon as the call is wired.</div>`}
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-primary" @click=${() => this.handleAddParticipant(call)}>
|
||||
|
||||
@@ -18,6 +18,12 @@ interface IVoicemailMessage {
|
||||
heard: boolean;
|
||||
}
|
||||
|
||||
interface IVoiceboxRow {
|
||||
id: string;
|
||||
unheardCount: number;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -61,19 +67,6 @@ export class SipproxyViewVoicemail extends DeesElement {
|
||||
.view-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.box-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.box-selector label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.audio-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -135,10 +128,11 @@ export class SipproxyViewVoicemail extends DeesElement {
|
||||
const cfg = await appState.apiGetConfig();
|
||||
const boxes: { id: string }[] = cfg.voiceboxes || [];
|
||||
this.voiceboxIds = boxes.map((b) => b.id);
|
||||
if (this.voiceboxIds.length > 0 && !this.selectedBoxId) {
|
||||
this.selectedBoxId = this.voiceboxIds[0];
|
||||
const nextSelectedBoxId = this.voiceboxIds.includes(this.selectedBoxId)
|
||||
? this.selectedBoxId
|
||||
: (this.voiceboxIds[0] || '');
|
||||
this.selectedBoxId = nextSelectedBoxId;
|
||||
await this.loadMessages();
|
||||
}
|
||||
} catch {
|
||||
// Config unavailable.
|
||||
}
|
||||
@@ -161,11 +155,22 @@ export class SipproxyViewVoicemail extends DeesElement {
|
||||
}
|
||||
|
||||
private async selectBox(boxId: string) {
|
||||
if (boxId === this.selectedBoxId) {
|
||||
return;
|
||||
}
|
||||
this.selectedBoxId = boxId;
|
||||
this.stopAudio();
|
||||
await this.loadMessages();
|
||||
}
|
||||
|
||||
private getVoiceboxRows(): IVoiceboxRow[] {
|
||||
return this.voiceboxIds.map((id) => ({
|
||||
id,
|
||||
unheardCount: this.appData.voicemailCounts[id] || 0,
|
||||
selected: id === this.selectedBoxId,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- audio playback ------------------------------------------------------
|
||||
|
||||
private playMessage(msg: IVoicemailMessage) {
|
||||
@@ -341,6 +346,43 @@ export class SipproxyViewVoicemail extends DeesElement {
|
||||
];
|
||||
}
|
||||
|
||||
private getVoiceboxColumns() {
|
||||
return [
|
||||
{
|
||||
key: 'id',
|
||||
header: 'Voicebox',
|
||||
sortable: true,
|
||||
renderer: (val: string, row: IVoiceboxRow) => html`
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<span style="font-family:'JetBrains Mono',monospace;font-size:.85rem;">${val}</span>
|
||||
${row.selected ? html`
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:#1e3a5f;color:#60a5fa">Viewing</span>
|
||||
` : ''}
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
{
|
||||
key: 'unheardCount',
|
||||
header: 'Unheard',
|
||||
sortable: true,
|
||||
renderer: (val: number) => {
|
||||
const hasUnheard = val > 0;
|
||||
return html`
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.75rem;font-weight:600;background:${hasUnheard ? '#422006' : '#1f2937'};color:${hasUnheard ? '#f59e0b' : '#94a3b8'}">${val}</span>
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'selected',
|
||||
header: 'Status',
|
||||
value: (row: IVoiceboxRow) => (row.selected ? 'Open' : 'Available'),
|
||||
renderer: (val: string, row: IVoiceboxRow) => html`
|
||||
<span style="color:${row.selected ? '#60a5fa' : '#94a3b8'};font-size:.8rem;">${val}</span>
|
||||
`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- table actions -------------------------------------------------------
|
||||
|
||||
private getDataActions() {
|
||||
@@ -390,21 +432,43 @@ export class SipproxyViewVoicemail extends DeesElement {
|
||||
];
|
||||
}
|
||||
|
||||
private getVoiceboxActions() {
|
||||
return [
|
||||
{
|
||||
name: 'View Messages',
|
||||
iconName: 'lucide:folder-open',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IVoiceboxRow }) => {
|
||||
await this.selectBox(item.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Refresh Boxes',
|
||||
iconName: 'lucide:refreshCw',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.loadVoiceboxes();
|
||||
deesCatalog.DeesToast.success('Voiceboxes refreshed');
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- render --------------------------------------------------------------
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
${this.voiceboxIds.length > 1 ? html`
|
||||
<div class="box-selector">
|
||||
<label>Voicebox</label>
|
||||
<dees-input-dropdown
|
||||
.key=${'voicebox'}
|
||||
.selectedOption=${{ option: this.selectedBoxId, key: this.selectedBoxId }}
|
||||
.options=${this.voiceboxIds.map((id) => ({ option: id, key: id }))}
|
||||
@selectedOption=${(e: CustomEvent) => { this.selectBox(e.detail.key); }}
|
||||
></dees-input-dropdown>
|
||||
<div class="view-section">
|
||||
<dees-table
|
||||
heading1="Voiceboxes"
|
||||
heading2="${this.voiceboxIds.length} configured"
|
||||
dataName="voiceboxes"
|
||||
.data=${this.getVoiceboxRows()}
|
||||
.rowKey=${'id'}
|
||||
.columns=${this.getVoiceboxColumns()}
|
||||
.dataActions=${this.getVoiceboxActions()}
|
||||
></dees-table>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="view-section">
|
||||
<dees-statsgrid
|
||||
|
||||
Reference in New Issue
Block a user