Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33b4ae5dd0 | |||
| d2c18a4ebb | |||
| 3c010a3b1b | |||
| 88768f0586 |
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 2026-04-14 - 1.25.1 - fix(proxy-engine)
|
||||||
respect explicit inbound route targets and store voicemail in the configured mailbox
|
respect explicit inbound route targets and store voicemail in the configured mailbox
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "siprouter",
|
"name": "siprouter",
|
||||||
"version": "1.25.1",
|
"version": "1.26.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -28,3 +28,6 @@ rustflags = ["-C", "link-arg=-L.cargo/crosslibs/aarch64"]
|
|||||||
CC_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-gcc"
|
CC_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-gcc"
|
||||||
CXX_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-g++"
|
CXX_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-g++"
|
||||||
AR_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-ar"
|
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",
|
"nom",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rusticata-macros",
|
"rusticata-macros",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ dependencies = [
|
|||||||
"nom",
|
"nom",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rusticata-macros",
|
"rusticata-macros",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -327,6 +327,26 @@ dependencies = [
|
|||||||
"virtue",
|
"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]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -429,6 +449,15 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cexpr"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "0.1.10"
|
version = "0.1.10"
|
||||||
@@ -498,6 +527,17 @@ dependencies = [
|
|||||||
"inout",
|
"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]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "3.2.25"
|
version = "3.2.25"
|
||||||
@@ -538,7 +578,7 @@ version = "0.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2c9f73004e928ed46c3e7fd7406d2b12c8674153295f08af084b49860276dc02"
|
checksum = "2c9f73004e928ed46c3e7fd7406d2b12c8674153295f08af084b49860276dc02"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1021,6 +1061,12 @@ dependencies = [
|
|||||||
"signature",
|
"signature",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "elliptic-curve"
|
name = "elliptic-curve"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1367,6 +1413,12 @@ dependencies = [
|
|||||||
"polyval",
|
"polyval",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glob"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "group"
|
name = "group"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -1668,7 +1720,7 @@ dependencies = [
|
|||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"rtcp",
|
"rtcp",
|
||||||
"rtp",
|
"rtp",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"waitgroup",
|
"waitgroup",
|
||||||
"webrtc-srtp",
|
"webrtc-srtp",
|
||||||
@@ -1681,6 +1733,15 @@ version = "2.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -1733,8 +1794,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "kokoro-tts"
|
name = "kokoro-tts"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "68e5d46e20a28fa5fd313d9ffcf4bbcf41570e64841d3944c832eef6b98d208b"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bincode 2.0.1",
|
"bincode 2.0.1",
|
||||||
"cc",
|
"cc",
|
||||||
@@ -1794,6 +1853,16 @@ dependencies = [
|
|||||||
"rle-decode-fast",
|
"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]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -2388,7 +2457,9 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sip-proto",
|
"sip-proto",
|
||||||
|
"spandsp",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"udptl",
|
||||||
"webrtc",
|
"webrtc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2586,7 +2657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6423493804221c276d27f3cc383cd5cbe1a1f10f210909fd4951b579b01293cd"
|
checksum = "6423493804221c276d27f3cc383cd5cbe1a1f10f210909fd4951b579b01293cd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"webrtc-util",
|
"webrtc-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2596,7 +2667,7 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce5248489db464de29835170cd1f6e19933146b0016789effc59cb53d9f13844"
|
checksum = "ce5248489db464de29835170cd1f6e19933146b0016789effc59cb53d9f13844"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2608,7 +2679,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"webrtc-util",
|
"webrtc-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2619,7 +2690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7bb90df8268abfe08452ef2dae9e867a54edfdaa71b3127ef47d8b031f77ac73"
|
checksum = "7bb90df8268abfe08452ef2dae9e867a54edfdaa71b3127ef47d8b031f77ac73"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2746,7 +2817,7 @@ checksum = "4d22a5ef407871893fd72b4562ee15e4742269b173959db4b8df6f538c414e13"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"substring",
|
"substring",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2959,6 +3030,28 @@ dependencies = [
|
|||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -3006,7 +3099,7 @@ dependencies = [
|
|||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"ring",
|
"ring",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"webrtc-util",
|
"webrtc-util",
|
||||||
@@ -3106,7 +3199,16 @@ version = "1.0.69"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -3120,6 +3222,17 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.47"
|
version = "0.3.47"
|
||||||
@@ -3196,9 +3309,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"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]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.36"
|
version = "0.1.36"
|
||||||
@@ -3232,7 +3357,7 @@ dependencies = [
|
|||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"ring",
|
"ring",
|
||||||
"stun",
|
"stun",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"webrtc-util",
|
"webrtc-util",
|
||||||
]
|
]
|
||||||
@@ -3243,6 +3368,17 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
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]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -3534,7 +3670,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"smol_str",
|
"smol_str",
|
||||||
"stun",
|
"stun",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"turn",
|
"turn",
|
||||||
@@ -3559,7 +3695,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"derive_builder",
|
"derive_builder",
|
||||||
"log",
|
"log",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"webrtc-sctp",
|
"webrtc-sctp",
|
||||||
"webrtc-util",
|
"webrtc-util",
|
||||||
@@ -3597,7 +3733,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"signature",
|
"signature",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"webpki",
|
"webpki",
|
||||||
"webrtc-util",
|
"webrtc-util",
|
||||||
@@ -3619,7 +3755,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"stun",
|
"stun",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"turn",
|
"turn",
|
||||||
"url",
|
"url",
|
||||||
@@ -3637,7 +3773,7 @@ checksum = "f08dfd7a6e3987e255c4dbe710dde5d94d0f0574f8a21afa95d171376c143106"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"socket2 0.4.10",
|
"socket2 0.4.10",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"webrtc-util",
|
"webrtc-util",
|
||||||
]
|
]
|
||||||
@@ -3652,7 +3788,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"rtp",
|
"rtp",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3667,7 +3803,7 @@ dependencies = [
|
|||||||
"crc",
|
"crc",
|
||||||
"log",
|
"log",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"webrtc-util",
|
"webrtc-util",
|
||||||
]
|
]
|
||||||
@@ -3690,7 +3826,7 @@ dependencies = [
|
|||||||
"rtp",
|
"rtp",
|
||||||
"sha1",
|
"sha1",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"webrtc-util",
|
"webrtc-util",
|
||||||
]
|
]
|
||||||
@@ -3711,7 +3847,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"nix",
|
"nix",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
@@ -3882,7 +4018,7 @@ dependencies = [
|
|||||||
"nom",
|
"nom",
|
||||||
"oid-registry 0.4.0",
|
"oid-registry 0.4.0",
|
||||||
"rusticata-macros",
|
"rusticata-macros",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3901,7 +4037,7 @@ dependencies = [
|
|||||||
"oid-registry 0.6.1",
|
"oid-registry 0.6.1",
|
||||||
"ring",
|
"ring",
|
||||||
"rusticata-macros",
|
"rusticata-macros",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -9,3 +9,6 @@ resolver = "2"
|
|||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = true
|
lto = true
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
kokoro-tts = { path = "vendor/kokoro-tts" }
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ regex-lite = "0.1"
|
|||||||
webrtc = "0.8"
|
webrtc = "0.8"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
hound = "3.5"
|
hound = "3.5"
|
||||||
|
spandsp = "0.1.5"
|
||||||
|
udptl = "0.1.0"
|
||||||
kokoro-tts = { version = "0.3", default-features = false, features = ["use-cmudict"] }
|
kokoro-tts = { version = "0.3", default-features = false, features = ["use-cmudict"] }
|
||||||
ort = { version = "=2.0.0-rc.11", default-features = false, features = [
|
ort = { version = "=2.0.0-rc.11", default-features = false, features = [
|
||||||
"std", "download-binaries", "copy-dylibs", "ndarray",
|
"std", "download-binaries", "copy-dylibs", "ndarray",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::net::SocketAddr;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::{mpsc, watch};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
pub type LegId = String;
|
pub type LegId = String;
|
||||||
@@ -114,6 +114,13 @@ pub struct LegInfo {
|
|||||||
pub kind: LegKind,
|
pub kind: LegKind,
|
||||||
pub state: LegState,
|
pub state: LegState,
|
||||||
pub codec_pt: u8,
|
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).
|
/// For SIP legs: the SIP dialog manager (handles 407 auth, BYE, etc).
|
||||||
pub sip_leg: Option<SipLeg>,
|
pub sip_leg: Option<SipLeg>,
|
||||||
@@ -146,6 +153,15 @@ pub struct LegInfo {
|
|||||||
pub metadata: HashMap<String, serde_json::Value>,
|
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.
|
/// A multiparty call with N legs and a central mixer.
|
||||||
pub struct Call {
|
pub struct Call {
|
||||||
// Duplicated from the HashMap key in CallManager. Kept for future
|
// 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.
|
/// Used to construct proper 180/200/error responses back to the device.
|
||||||
pub device_invite: Option<SipMessage>,
|
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.
|
/// All legs in this call, keyed by leg ID.
|
||||||
pub legs: HashMap<LegId, LegInfo>,
|
pub legs: HashMap<LegId, LegInfo>,
|
||||||
|
|
||||||
/// Channel to send commands to the mixer task.
|
/// Channel to send commands to the mixer task.
|
||||||
pub mixer_cmd_tx: mpsc::Sender<MixerCommand>,
|
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).
|
/// Handle to the mixer task (aborted on call teardown).
|
||||||
mixer_task: Option<JoinHandle<()>>,
|
mixer_task: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
@@ -196,8 +221,11 @@ impl Call {
|
|||||||
callee_number: None,
|
callee_number: None,
|
||||||
provider_id,
|
provider_id,
|
||||||
device_invite: None,
|
device_invite: None,
|
||||||
|
pending_dialog_bridge: None,
|
||||||
legs: HashMap::new(),
|
legs: HashMap::new(),
|
||||||
mixer_cmd_tx,
|
mixer_cmd_tx,
|
||||||
|
media_bridge_mode: None,
|
||||||
|
media_bridge_cancel_txs: Vec::new(),
|
||||||
mixer_task: Some(mixer_task),
|
mixer_task: Some(mixer_task),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,8 +263,31 @@ impl Call {
|
|||||||
self.created_at.elapsed().as_secs()
|
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.
|
/// Shut down the mixer and abort its task.
|
||||||
pub async fn shutdown_mixer(&mut self) {
|
pub async fn shutdown_mixer(&mut self) {
|
||||||
|
self.clear_media_bridge();
|
||||||
let _ = self.mixer_cmd_tx.send(MixerCommand::Shutdown).await;
|
let _ = self.mixer_cmd_tx.send(MixerCommand::Shutdown).await;
|
||||||
if let Some(handle) = self.mixer_task.take() {
|
if let Some(handle) = self.mixer_task.take() {
|
||||||
handle.abort();
|
handle.abort();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -105,6 +105,8 @@ pub struct RouteAction {
|
|||||||
pub ring_browsers: Option<bool>,
|
pub ring_browsers: Option<bool>,
|
||||||
#[serde(rename = "voicemailBox")]
|
#[serde(rename = "voicemailBox")]
|
||||||
pub voicemail_box: Option<String>,
|
pub voicemail_box: Option<String>,
|
||||||
|
#[serde(rename = "faxBox")]
|
||||||
|
pub fax_box: Option<String>,
|
||||||
#[serde(rename = "ivrMenuId")]
|
#[serde(rename = "ivrMenuId")]
|
||||||
pub ivr_menu_id: Option<String>,
|
pub ivr_menu_id: Option<String>,
|
||||||
#[serde(rename = "noAnswerTimeout")]
|
#[serde(rename = "noAnswerTimeout")]
|
||||||
@@ -161,6 +163,8 @@ pub struct AppConfig {
|
|||||||
pub devices: Vec<DeviceConfig>,
|
pub devices: Vec<DeviceConfig>,
|
||||||
pub routing: RoutingConfig,
|
pub routing: RoutingConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub faxboxes: Vec<FaxBoxConfig>,
|
||||||
|
#[serde(default)]
|
||||||
pub voiceboxes: Vec<VoiceboxConfig>,
|
pub voiceboxes: Vec<VoiceboxConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ivr: Option<IvrConfig>,
|
pub ivr: Option<IvrConfig>,
|
||||||
@@ -191,6 +195,16 @@ pub struct VoiceboxConfig {
|
|||||||
pub max_recording_sec: Option<u32>,
|
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
|
// IVR config
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -273,6 +287,38 @@ pub fn normalize_routing_identity(value: &str) -> String {
|
|||||||
digits
|
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)> {
|
fn parse_numeric_range_value(value: &str) -> Option<(bool, &str)> {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -383,6 +429,7 @@ pub struct InboundRouteResult {
|
|||||||
pub ring_all_devices: bool,
|
pub ring_all_devices: bool,
|
||||||
pub ring_browsers: bool,
|
pub ring_browsers: bool,
|
||||||
pub voicemail_box: Option<String>,
|
pub voicemail_box: Option<String>,
|
||||||
|
pub fax_box: Option<String>,
|
||||||
pub ivr_menu_id: Option<String>,
|
pub ivr_menu_id: Option<String>,
|
||||||
pub no_answer_timeout: Option<u32>,
|
pub no_answer_timeout: Option<u32>,
|
||||||
}
|
}
|
||||||
@@ -493,6 +540,7 @@ impl AppConfig {
|
|||||||
ring_all_devices: explicit_targets.is_none(),
|
ring_all_devices: explicit_targets.is_none(),
|
||||||
ring_browsers: route.action.ring_browsers.unwrap_or(false),
|
ring_browsers: route.action.ring_browsers.unwrap_or(false),
|
||||||
voicemail_box: route.action.voicemail_box.clone(),
|
voicemail_box: route.action.voicemail_box.clone(),
|
||||||
|
fax_box: route.action.fax_box.clone(),
|
||||||
ivr_menu_id: route.action.ivr_menu_id.clone(),
|
ivr_menu_id: route.action.ivr_menu_id.clone(),
|
||||||
no_answer_timeout: route.action.no_answer_timeout,
|
no_answer_timeout: route.action.no_answer_timeout,
|
||||||
});
|
});
|
||||||
@@ -542,6 +590,7 @@ mod tests {
|
|||||||
extension: "100".to_string(),
|
extension: "100".to_string(),
|
||||||
}],
|
}],
|
||||||
routing: RoutingConfig { routes },
|
routing: RoutingConfig { routes },
|
||||||
|
faxboxes: vec![],
|
||||||
voiceboxes: vec![],
|
voiceboxes: vec![],
|
||||||
ivr: None,
|
ivr: None,
|
||||||
}
|
}
|
||||||
@@ -588,6 +637,7 @@ mod tests {
|
|||||||
targets: Some(vec!["desk".to_string()]),
|
targets: Some(vec!["desk".to_string()]),
|
||||||
ring_browsers: Some(true),
|
ring_browsers: Some(true),
|
||||||
voicemail_box: None,
|
voicemail_box: None,
|
||||||
|
fax_box: None,
|
||||||
ivr_menu_id: None,
|
ivr_menu_id: None,
|
||||||
no_answer_timeout: None,
|
no_answer_timeout: None,
|
||||||
provider: None,
|
provider: None,
|
||||||
@@ -612,6 +662,7 @@ mod tests {
|
|||||||
targets: None,
|
targets: None,
|
||||||
ring_browsers: Some(false),
|
ring_browsers: Some(false),
|
||||||
voicemail_box: Some("support-box".to_string()),
|
voicemail_box: Some("support-box".to_string()),
|
||||||
|
fax_box: None,
|
||||||
ivr_menu_id: None,
|
ivr_menu_id: None,
|
||||||
no_answer_timeout: Some(20),
|
no_answer_timeout: Some(20),
|
||||||
provider: None,
|
provider: None,
|
||||||
@@ -636,6 +687,20 @@ mod tests {
|
|||||||
assert!(!support.ring_browsers);
|
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]
|
#[test]
|
||||||
fn matches_pattern_supports_numeric_ranges() {
|
fn matches_pattern_supports_numeric_ranges() {
|
||||||
assert!(matches_pattern(
|
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::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::{mpsc, watch};
|
||||||
|
|
||||||
/// Channel pair for connecting a leg to the mixer.
|
/// Channel pair for connecting a leg to the mixer.
|
||||||
pub struct LegChannels {
|
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;
|
||||||
mod call_manager;
|
mod call_manager;
|
||||||
mod config;
|
mod config;
|
||||||
|
#[allow(dead_code)]
|
||||||
|
mod fax_engine;
|
||||||
mod ipc;
|
mod ipc;
|
||||||
mod jitter_buffer;
|
mod jitter_buffer;
|
||||||
mod leg_io;
|
mod leg_io;
|
||||||
@@ -25,7 +27,7 @@ mod voicemail;
|
|||||||
mod webrtc_engine;
|
mod webrtc_engine;
|
||||||
|
|
||||||
use crate::call_manager::CallManager;
|
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::ipc::{emit_event, respond_err, respond_ok, Command, OutTx};
|
||||||
use crate::provider::ProviderManager;
|
use crate::provider::ProviderManager;
|
||||||
use crate::registrar::Registrar;
|
use crate::registrar::Registrar;
|
||||||
@@ -139,6 +141,7 @@ async fn handle_command(
|
|||||||
"configure" => handle_configure(engine, out_tx, &cmd).await,
|
"configure" => handle_configure(engine, out_tx, &cmd).await,
|
||||||
"hangup" => handle_hangup(engine, out_tx, &cmd).await,
|
"hangup" => handle_hangup(engine, out_tx, &cmd).await,
|
||||||
"make_call" => handle_make_call(engine, out_tx, &cmd).await,
|
"make_call" => handle_make_call(engine, out_tx, &cmd).await,
|
||||||
|
"send_fax" => handle_send_fax(engine, out_tx, &cmd).await,
|
||||||
"add_leg" => handle_add_leg(engine, out_tx, &cmd).await,
|
"add_leg" => handle_add_leg(engine, out_tx, &cmd).await,
|
||||||
"remove_leg" => handle_remove_leg(engine, out_tx, &cmd).await,
|
"remove_leg" => handle_remove_leg(engine, out_tx, &cmd).await,
|
||||||
// WebRTC commands — lock webrtc only (no engine contention).
|
// WebRTC commands — lock webrtc only (no engine contention).
|
||||||
@@ -346,7 +349,7 @@ async fn handle_sip_packet(
|
|||||||
// Emit event so TypeScript knows about the call (for dashboard, IVR routing, etc).
|
// Emit event so TypeScript knows about the call (for dashboard, IVR routing, etc).
|
||||||
let from_header = msg.get_header("From").unwrap_or("");
|
let from_header = msg.get_header("From").unwrap_or("");
|
||||||
let from_uri = normalize_routing_identity(from_header);
|
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(
|
emit_event(
|
||||||
&eng.out_tx,
|
&eng.out_tx,
|
||||||
@@ -369,6 +372,20 @@ async fn handle_sip_packet(
|
|||||||
let dialed_number = normalize_routing_identity(msg.request_uri().unwrap_or(""));
|
let dialed_number = normalize_routing_identity(msg.request_uri().unwrap_or(""));
|
||||||
|
|
||||||
let device = eng.registrar.find_by_address(&from_addr);
|
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());
|
let device_id = device.map(|d| d.device_id.clone());
|
||||||
|
|
||||||
// Find provider via routing rules.
|
// 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.
|
/// Handle the `hangup` command.
|
||||||
async fn handle_hangup(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &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()) {
|
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,
|
kind: crate::call::LegKind::WebRtc,
|
||||||
state: crate::call::LegState::Connected,
|
state: crate::call::LegState::Connected,
|
||||||
codec_pt: codec_lib::PT_OPUS,
|
codec_pt: codec_lib::PT_OPUS,
|
||||||
|
media_protocol: "webrtc",
|
||||||
|
media_io_active: true,
|
||||||
sip_leg: None,
|
sip_leg: None,
|
||||||
sip_call_id: None,
|
sip_call_id: None,
|
||||||
webrtc_session_id: Some(session_id.clone()),
|
webrtc_session_id: Some(session_id.clone()),
|
||||||
@@ -748,6 +923,7 @@ async fn handle_webrtc_link(
|
|||||||
"state": "connected",
|
"state": "connected",
|
||||||
"codec": "Opus",
|
"codec": "Opus",
|
||||||
"rtpPort": 0,
|
"rtpPort": 0,
|
||||||
|
"mediaProtocol": "webrtc",
|
||||||
"remoteMedia": null,
|
"remoteMedia": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
}),
|
}),
|
||||||
@@ -1448,6 +1624,8 @@ async fn handle_add_tool_leg(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cm
|
|||||||
kind: crate::call::LegKind::Tool,
|
kind: crate::call::LegKind::Tool,
|
||||||
state: crate::call::LegState::Connected,
|
state: crate::call::LegState::Connected,
|
||||||
codec_pt: 0,
|
codec_pt: 0,
|
||||||
|
media_protocol: "internal",
|
||||||
|
media_io_active: true,
|
||||||
sip_leg: None,
|
sip_leg: None,
|
||||||
sip_call_id: None,
|
sip_call_id: None,
|
||||||
webrtc_session_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",
|
"state": "connected",
|
||||||
"codec": null,
|
"codec": null,
|
||||||
"rtpPort": 0,
|
"rtpPort": 0,
|
||||||
|
"mediaProtocol": "internal",
|
||||||
"remoteMedia": null,
|
"remoteMedia": null,
|
||||||
"metadata": { "tool_type": tool_type_str },
|
"metadata": { "tool_type": tool_type_str },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -313,6 +313,23 @@ impl ProviderManager {
|
|||||||
if ps.config.outbound_proxy.address == addr.ip().to_string() {
|
if ps.config.outbound_proxy.address == addr.ip().to_string() {
|
||||||
return Some(ps_arc.clone());
|
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
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,24 @@ impl SipLeg {
|
|||||||
..Default::default()
|
..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(
|
let invite = SipMessage::create_request(
|
||||||
"INVITE",
|
"INVITE",
|
||||||
to_uri,
|
to_uri,
|
||||||
@@ -401,6 +419,10 @@ impl SipLeg {
|
|||||||
return SipLegAction::Send(ok.serialize());
|
return SipLegAction::Send(ok.serialize());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if method == "INVITE" || method == "UPDATE" {
|
||||||
|
return SipLegAction::InDialogRequest(method.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
SipLegAction::None
|
SipLegAction::None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +458,9 @@ pub enum SipLegAction {
|
|||||||
StateChange(LegState),
|
StateChange(LegState),
|
||||||
/// Connected — send this ACK.
|
/// Connected — send this ACK.
|
||||||
ConnectedWithAck(Vec<u8>),
|
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 with a reason.
|
||||||
Terminated(String),
|
Terminated(String),
|
||||||
/// Send 200 OK and terminate.
|
/// Send 200 OK and terminate.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use crate::audio_player::pcm_to_mix_frames;
|
|||||||
use kokoro_tts::{KokoroTts, Voice};
|
use kokoro_tts::{KokoroTts, Voice};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{mpsc, watch};
|
||||||
|
|
||||||
pub const DEFAULT_MODEL_PATH: &str = ".nogit/tts/kokoro-v1.0.onnx";
|
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).
|
/// Path that was used to load the current model (for cache invalidation).
|
||||||
loaded_model_path: String,
|
loaded_model_path: String,
|
||||||
loaded_voices_path: String,
|
loaded_voices_path: String,
|
||||||
|
/// 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 {
|
impl TtsEngine {
|
||||||
@@ -55,6 +60,10 @@ impl TtsEngine {
|
|||||||
tts: None,
|
tts: None,
|
||||||
loaded_model_path: String::new(),
|
loaded_model_path: String::new(),
|
||||||
loaded_voices_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;
|
return false;
|
||||||
}
|
}
|
||||||
match std::fs::read_to_string(&meta_path) {
|
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,
|
Err(_) => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,12 +245,12 @@ impl TtsEngine {
|
|||||||
/// Write the sidecar `.meta` file next to the WAV.
|
/// Write the sidecar `.meta` file next to the WAV.
|
||||||
fn write_cache_meta(&self, output_path: &str, text: &str, voice: &str) {
|
fn write_cache_meta(&self, output_path: &str, text: &str, voice: &str) {
|
||||||
let meta_path = format!("{output_path}.meta");
|
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.
|
/// Build the cache key from process generation + text + voice.
|
||||||
fn cache_key(text: &str, voice: &str) -> String {
|
fn cache_key(&self, text: &str, voice: &str) -> String {
|
||||||
format!("{}\0{}", text, voice)
|
format!("{}\0{}\0{}", self.cache_generation, text, voice)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
use md5::{Digest, Md5};
|
use md5::{Digest, Md5};
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
|
||||||
|
use crate::{Endpoint, SdpMediaKind};
|
||||||
|
|
||||||
// ---- ID generators ---------------------------------------------------------
|
// ---- ID generators ---------------------------------------------------------
|
||||||
|
|
||||||
/// Generate a random SIP Call-ID (32 hex chars).
|
/// Generate a random SIP Call-ID (32 hex chars).
|
||||||
@@ -55,6 +57,9 @@ pub struct SdpOptions<'a> {
|
|||||||
pub ip: &'a str,
|
pub ip: &'a str,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub payload_types: &'a [u8],
|
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_id: Option<&'a str>,
|
||||||
pub session_name: Option<&'a str>,
|
pub session_name: Option<&'a str>,
|
||||||
pub direction: Option<&'a str>,
|
pub direction: Option<&'a str>,
|
||||||
@@ -67,6 +72,9 @@ impl<'a> Default for SdpOptions<'a> {
|
|||||||
ip: "0.0.0.0",
|
ip: "0.0.0.0",
|
||||||
port: 0,
|
port: 0,
|
||||||
payload_types: &[9, 0, 8, 101],
|
payload_types: &[9, 0, 8, 101],
|
||||||
|
media_kind: SdpMediaKind::Audio,
|
||||||
|
transport: "RTP/AVP",
|
||||||
|
media_formats: &[],
|
||||||
session_id: None,
|
session_id: None,
|
||||||
session_name: None,
|
session_name: None,
|
||||||
direction: 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)));
|
.unwrap_or_else(|| format!("{}", rand::thread_rng().gen_range(0..1_000_000_000u64)));
|
||||||
let session_name = opts.session_name.unwrap_or("-");
|
let session_name = opts.session_name.unwrap_or("-");
|
||||||
let direction = opts.direction.unwrap_or("sendrecv");
|
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![
|
let mut lines = vec![
|
||||||
"v=0".to_string(),
|
"v=0".to_string(),
|
||||||
@@ -91,9 +106,16 @@ pub fn build_sdp(opts: &SdpOptions) -> String {
|
|||||||
format!("s={session_name}"),
|
format!("s={session_name}"),
|
||||||
format!("c=IN IP4 {}", opts.ip),
|
format!("c=IN IP4 {}", opts.ip),
|
||||||
"t=0 0".to_string(),
|
"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 {
|
for &pt in opts.payload_types {
|
||||||
let name = codec_name(pt);
|
let name = codec_name(pt);
|
||||||
if name != "unknown" {
|
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("a=fmtp:101 0-16".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lines.push(format!("a={direction}"));
|
lines.push(format!("a={direction}"));
|
||||||
for attr in opts.attributes {
|
for attr in opts.attributes {
|
||||||
@@ -199,38 +222,62 @@ pub fn compute_digest_auth(
|
|||||||
|
|
||||||
// ---- SDP parser ------------------------------------------------------------
|
// ---- SDP parser ------------------------------------------------------------
|
||||||
|
|
||||||
use crate::Endpoint;
|
/// Parse the preferred media endpoint from an SDP body.
|
||||||
|
///
|
||||||
/// Parse the audio media port, connection address, and preferred codec 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> {
|
pub fn parse_sdp_endpoint(sdp: &str) -> Option<Endpoint> {
|
||||||
let mut addr: Option<&str> = None;
|
let mut addr: Option<&str> = None;
|
||||||
let mut port: Option<u16> = None;
|
let mut preferred: Option<(SdpMediaKind, u16, Option<u8>, String)> = None;
|
||||||
let mut codec_pt: Option<u8> = None;
|
let mut fallback: Option<(SdpMediaKind, u16, Option<u8>, String)> = None;
|
||||||
|
|
||||||
let normalized = sdp.replace("\r\n", "\n");
|
let normalized = sdp.replace("\r\n", "\n");
|
||||||
for raw in normalized.split('\n') {
|
for raw in normalized.split('\n') {
|
||||||
let line = raw.trim();
|
let line = raw.trim();
|
||||||
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
|
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
|
||||||
addr = Some(rest.trim());
|
addr = Some(rest.trim());
|
||||||
} else if let Some(rest) = line.strip_prefix("m=audio ") {
|
} else if let Some(rest) = line.strip_prefix("m=") {
|
||||||
// m=audio <port> RTP/AVP <pt1> [<pt2> ...]
|
// m=<media> <port> <transport> <fmt1> [<fmt2> ...]
|
||||||
let parts: Vec<&str> = rest.split_whitespace().collect();
|
let mut media_and_rest = rest.splitn(2, ' ');
|
||||||
if !parts.is_empty() {
|
let media = media_and_rest.next().unwrap_or("");
|
||||||
port = parts[0].parse().ok();
|
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) {
|
match (addr, preferred.or(fallback)) {
|
||||||
(Some(a), Some(p)) => Some(Endpoint {
|
(Some(a), Some((media_kind, port, codec_pt, transport))) => Some(Endpoint {
|
||||||
address: a.to_string(),
|
address: a.to_string(),
|
||||||
port: p,
|
port,
|
||||||
codec_pt,
|
codec_pt,
|
||||||
|
media_kind,
|
||||||
|
transport,
|
||||||
}),
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -327,6 +374,40 @@ mod tests {
|
|||||||
let ep = parse_sdp_endpoint(sdp).unwrap();
|
let ep = parse_sdp_endpoint(sdp).unwrap();
|
||||||
assert_eq!(ep.address, "10.0.0.1");
|
assert_eq!(ep.address, "10.0.0.1");
|
||||||
assert_eq!(ep.port, 5060);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -16,4 +16,47 @@ pub struct Endpoint {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// First payload type from the SDP `m=audio` line (the preferred codec).
|
/// First payload type from the SDP `m=audio` line (the preferred codec).
|
||||||
pub codec_pt: Option<u8>,
|
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.
|
//! 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`.
|
/// Replaces the host:port in every `sip:` / `sips:` URI found in `value`.
|
||||||
pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String {
|
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
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrites the connection address (`c=`) and audio media port (`m=audio`)
|
/// Rewrites the connection address (`c=`) and first supported media port
|
||||||
/// in an SDP body. Returns the rewritten body together with the original
|
/// (`m=audio`, `m=image`, `m=application`) in an SDP body. Returns the
|
||||||
/// endpoint that was replaced (if any).
|
/// 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>) {
|
pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>) {
|
||||||
let mut orig_addr: Option<String> = None;
|
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
|
let lines: Vec<String> = body
|
||||||
.replace("\r\n", "\n")
|
.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 ") {
|
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
|
||||||
orig_addr = Some(rest.trim().to_string());
|
orig_addr = Some(rest.trim().to_string());
|
||||||
format!("c=IN IP4 {ip}")
|
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();
|
let parts: Vec<&str> = line.split(' ').collect();
|
||||||
if parts.len() >= 2 {
|
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();
|
let mut rebuilt = parts[0].to_string();
|
||||||
rebuilt.push(' ');
|
rebuilt.push(' ');
|
||||||
rebuilt.push_str(&port.to_string());
|
rebuilt.push_str(&port.to_string());
|
||||||
@@ -91,11 +106,13 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let original = match (orig_addr, orig_port) {
|
let original = match (orig_addr, orig_media) {
|
||||||
(Some(a), Some(p)) => Some(Endpoint {
|
(Some(a), Some((media_kind, p, transport))) => Some(Endpoint {
|
||||||
address: a,
|
address: a,
|
||||||
port: p,
|
port: p,
|
||||||
codec_pt: None,
|
codec_pt: None,
|
||||||
|
media_kind,
|
||||||
|
transport,
|
||||||
}),
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
@@ -130,5 +147,19 @@ mod tests {
|
|||||||
let ep = orig.unwrap();
|
let ep = orig.unwrap();
|
||||||
assert_eq!(ep.address, "10.0.0.1");
|
assert_eq!(ep.address, "10.0.0.1");
|
||||||
assert_eq!(ep.port, 5060);
|
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 = {
|
export const commitinfo = {
|
||||||
name: 'siprouter',
|
name: 'siprouter',
|
||||||
version: '1.25.1',
|
version: '1.26.0',
|
||||||
description: 'undefined'
|
description: 'undefined'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import type { IFaxBoxConfig } from './faxbox.ts';
|
||||||
import type { IVoiceboxConfig } from './voicebox.js';
|
import type { IVoiceboxConfig } from './voicebox.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -113,6 +114,9 @@ export interface ISipRouteAction {
|
|||||||
/** Voicemail fallback for matched inbound routes. */
|
/** Voicemail fallback for matched inbound routes. */
|
||||||
voicemailBox?: string;
|
voicemailBox?: string;
|
||||||
|
|
||||||
|
/** Fax inbox target for matched inbound routes. */
|
||||||
|
faxBox?: string;
|
||||||
|
|
||||||
/** Route to an IVR menu by menu ID (skip ringing devices). */
|
/** Route to an IVR menu by menu ID (skip ringing devices). */
|
||||||
ivrMenuId?: string;
|
ivrMenuId?: string;
|
||||||
|
|
||||||
@@ -189,6 +193,7 @@ export interface IContact {
|
|||||||
// "number | undefined is not assignable to number" type errors when
|
// "number | undefined is not assignable to number" type errors when
|
||||||
// passing config.voiceboxes into VoiceboxManager.init().
|
// passing config.voiceboxes into VoiceboxManager.init().
|
||||||
export type { IVoiceboxConfig };
|
export type { IVoiceboxConfig };
|
||||||
|
export type { IFaxBoxConfig };
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// IVR configuration
|
// IVR configuration
|
||||||
@@ -255,6 +260,7 @@ export interface IAppConfig {
|
|||||||
incomingNumbers?: IIncomingNumberConfig[];
|
incomingNumbers?: IIncomingNumberConfig[];
|
||||||
routing: IRoutingConfig;
|
routing: IRoutingConfig;
|
||||||
contacts: IContact[];
|
contacts: IContact[];
|
||||||
|
faxboxes?: IFaxBoxConfig[];
|
||||||
voiceboxes?: IVoiceboxConfig[];
|
voiceboxes?: IVoiceboxConfig[];
|
||||||
ivr?: IIvrConfig;
|
ivr?: IIvrConfig;
|
||||||
}
|
}
|
||||||
@@ -323,6 +329,12 @@ export function loadConfig(): IAppConfig {
|
|||||||
c.starred ??= false;
|
c.starred ??= false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.faxboxes ??= [];
|
||||||
|
for (const fb of cfg.faxboxes) {
|
||||||
|
fb.enabled ??= true;
|
||||||
|
fb.maxMessages ??= 50;
|
||||||
|
}
|
||||||
|
|
||||||
// Voicebox defaults.
|
// Voicebox defaults.
|
||||||
cfg.voiceboxes ??= [];
|
cfg.voiceboxes ??= [];
|
||||||
for (const vb of cfg.voiceboxes) {
|
for (const vb of cfg.voiceboxes) {
|
||||||
|
|||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export interface IFaxBoxConfig {
|
||||||
|
id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
maxMessages?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFaxMessage {
|
||||||
|
id: string;
|
||||||
|
boxId: string;
|
||||||
|
callerNumber?: string;
|
||||||
|
timestamp: number;
|
||||||
|
fileName: string;
|
||||||
|
completionCode?: number | null;
|
||||||
|
completionLabel?: string | null;
|
||||||
|
pageCount?: number;
|
||||||
|
bitRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FaxBoxManager {
|
||||||
|
private boxes = new Map<string, IFaxBoxConfig>();
|
||||||
|
private readonly basePath: string;
|
||||||
|
private readonly log: (msg: string) => void;
|
||||||
|
|
||||||
|
constructor(log: (msg: string) => void) {
|
||||||
|
this.basePath = path.join(process.cwd(), '.nogit', 'fax', 'inboxes');
|
||||||
|
this.log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
init(faxBoxConfigs: IFaxBoxConfig[]): void {
|
||||||
|
this.boxes.clear();
|
||||||
|
|
||||||
|
for (const cfg of faxBoxConfigs) {
|
||||||
|
cfg.enabled ??= true;
|
||||||
|
cfg.maxMessages ??= 50;
|
||||||
|
this.boxes.set(cfg.id, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage(
|
||||||
|
boxId: string,
|
||||||
|
info: {
|
||||||
|
callerNumber?: string;
|
||||||
|
fileName: string;
|
||||||
|
completionCode?: number | null;
|
||||||
|
completionLabel?: string | null;
|
||||||
|
pageCount?: number;
|
||||||
|
bitRate?: number;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
const msg: IFaxMessage = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
boxId,
|
||||||
|
callerNumber: info.callerNumber,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
fileName: path.basename(info.fileName),
|
||||||
|
completionCode: info.completionCode ?? null,
|
||||||
|
completionLabel: info.completionLabel ?? null,
|
||||||
|
pageCount: info.pageCount,
|
||||||
|
bitRate: info.bitRate,
|
||||||
|
};
|
||||||
|
this.saveMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMessage(msg: IFaxMessage): void {
|
||||||
|
const boxDir = this.getBoxDir(msg.boxId);
|
||||||
|
fs.mkdirSync(boxDir, { recursive: true });
|
||||||
|
|
||||||
|
const messages = this.loadMessages(msg.boxId);
|
||||||
|
messages.unshift(msg);
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.writeMessages(msg.boxId, messages);
|
||||||
|
this.log(`[faxbox] saved fax ${msg.id} in box "${msg.boxId}" (${msg.fileName})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessages(boxId: string): IFaxMessage[] {
|
||||||
|
return this.loadMessages(boxId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessage(boxId: string, messageId: string): IFaxMessage | null {
|
||||||
|
return this.loadMessages(boxId).find((m) => m.id === messageId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessageFilePath(boxId: string, messageId: string): string | null {
|
||||||
|
const msg = this.getMessage(boxId, messageId);
|
||||||
|
if (!msg) return null;
|
||||||
|
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
|
||||||
|
return fs.existsSync(filePath) ? filePath : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteMessage(boxId: string, messageId: string): boolean {
|
||||||
|
const messages = this.loadMessages(boxId);
|
||||||
|
const idx = messages.findIndex((m) => m.id === messageId);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
|
||||||
|
const msg = messages[idx];
|
||||||
|
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
messages.splice(idx, 1);
|
||||||
|
this.writeMessages(boxId, messages);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private messagesPath(boxId: string): string {
|
||||||
|
return path.join(this.getBoxDir(boxId), 'messages.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadMessages(boxId: string): IFaxMessage[] {
|
||||||
|
const filePath = this.messagesPath(boxId);
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return [];
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as IFaxMessage[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeMessages(boxId: string, messages: IFaxMessage[]): void {
|
||||||
|
const boxDir = this.getBoxDir(boxId);
|
||||||
|
fs.mkdirSync(boxDir, { recursive: true });
|
||||||
|
fs.writeFileSync(this.messagesPath(boxId), JSON.stringify(messages, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 readonly basePath: string;
|
||||||
|
private readonly jobsPath: string;
|
||||||
|
private readonly log: (msg: string) => void;
|
||||||
|
|
||||||
|
constructor(log: (msg: string) => void) {
|
||||||
|
this.basePath = path.join(process.cwd(), '.nogit', 'fax');
|
||||||
|
this.jobsPath = path.join(this.basePath, 'jobs.json');
|
||||||
|
this.log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
fs.mkdirSync(this.basePath, { recursive: true });
|
||||||
|
if (!fs.existsSync(this.jobsPath)) {
|
||||||
|
this.writeJobs([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
noteDialing(callId: string, number: string, providerId: string): void {
|
||||||
|
const jobs = this.loadJobs();
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.writeJobs(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
noteStarted(event: IFaxStartedEvent): void {
|
||||||
|
const jobs = this.loadJobs();
|
||||||
|
const now = Date.now();
|
||||||
|
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now);
|
||||||
|
job.status = 'started';
|
||||||
|
job.transport = event.transport;
|
||||||
|
job.filePath = event.file_path;
|
||||||
|
job.codec = event.codec;
|
||||||
|
job.remoteMedia = event.remote_media;
|
||||||
|
job.updatedAt = now;
|
||||||
|
this.writeJobs(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
noteCompleted(event: IFaxCompletedEvent): void {
|
||||||
|
const jobs = this.loadJobs();
|
||||||
|
const now = Date.now();
|
||||||
|
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now);
|
||||||
|
job.status = 'completed';
|
||||||
|
job.transport = event.transport;
|
||||||
|
job.filePath = 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;
|
||||||
|
this.writeJobs(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
noteFailed(event: IFaxFailedEvent): void {
|
||||||
|
const jobs = this.loadJobs();
|
||||||
|
const now = Date.now();
|
||||||
|
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now);
|
||||||
|
job.status = 'failed';
|
||||||
|
job.transport = event.transport;
|
||||||
|
job.filePath = event.file_path;
|
||||||
|
job.error = event.error;
|
||||||
|
job.success = false;
|
||||||
|
job.updatedAt = now;
|
||||||
|
this.writeJobs(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
getJobs(): IFaxJob[] {
|
||||||
|
return this.loadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrCreateJob(
|
||||||
|
jobs: IFaxJob[],
|
||||||
|
callId: string,
|
||||||
|
direction: 'outbound' | 'inbound',
|
||||||
|
now: number,
|
||||||
|
): IFaxJob {
|
||||||
|
let job = jobs.find((entry) => entry.callId === callId);
|
||||||
|
if (!job) {
|
||||||
|
job = {
|
||||||
|
id: callId,
|
||||||
|
callId,
|
||||||
|
direction,
|
||||||
|
status: 'dialing',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
jobs.unshift(job);
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadJobs(): IFaxJob[] {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(this.jobsPath, 'utf8');
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
return Array.isArray(parsed) ? parsed as IFaxJob[] : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeJobs(jobs: IFaxJob[]): void {
|
||||||
|
fs.mkdirSync(this.basePath, { recursive: true });
|
||||||
|
fs.writeFileSync(this.jobsPath, JSON.stringify(jobs, null, 2));
|
||||||
|
this.log(`[fax] persisted ${jobs.length} job(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
-4
@@ -11,6 +11,8 @@ import path from 'node:path';
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import https from 'node:https';
|
import https from 'node:https';
|
||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import type { FaxBoxManager } from './faxbox.ts';
|
||||||
|
import type { FaxJobManager } from './faxjobs.ts';
|
||||||
import { handleWebRtcSignaling } from './webrtcbridge.ts';
|
import { handleWebRtcSignaling } from './webrtcbridge.ts';
|
||||||
import type { VoiceboxManager } from './voicebox.ts';
|
import type { VoiceboxManager } from './voicebox.ts';
|
||||||
|
|
||||||
@@ -22,6 +24,8 @@ interface IHandleRequestContext {
|
|||||||
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null;
|
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null;
|
||||||
onHangupCall: (callId: string) => boolean;
|
onHangupCall: (callId: string) => boolean;
|
||||||
onConfigSaved?: () => void | Promise<void>;
|
onConfigSaved?: () => void | Promise<void>;
|
||||||
|
faxBoxManager?: FaxBoxManager;
|
||||||
|
faxJobManager?: FaxJobManager;
|
||||||
voiceboxManager?: VoiceboxManager;
|
voiceboxManager?: VoiceboxManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +112,7 @@ async function handleRequest(
|
|||||||
res: http.ServerResponse,
|
res: http.ServerResponse,
|
||||||
context: IHandleRequestContext,
|
context: IHandleRequestContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager } = context;
|
const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager } = context;
|
||||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||||
const method = req.method || 'GET';
|
const method = req.method || 'GET';
|
||||||
|
|
||||||
@@ -147,6 +151,65 @@ async function handleRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API: send outbound fax.
|
||||||
|
if (url.pathname === '/api/fax' && method === 'POST') {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const number = body?.number;
|
||||||
|
const 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);
|
||||||
|
}
|
||||||
|
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 = 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: faxBoxManager.deleteMessage(boxId, msgId) });
|
||||||
|
}
|
||||||
|
|
||||||
// API: add a SIP device to a call (mid-call INVITE to desk phone).
|
// 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') {
|
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addleg') && method === 'POST') {
|
||||||
try {
|
try {
|
||||||
@@ -273,6 +336,7 @@ async function handleRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
|
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
|
||||||
|
if (updates.faxboxes !== undefined) cfg.faxboxes = updates.faxboxes;
|
||||||
if (updates.voiceboxes !== undefined) cfg.voiceboxes = updates.voiceboxes;
|
if (updates.voiceboxes !== undefined) cfg.voiceboxes = updates.voiceboxes;
|
||||||
if (updates.ivr !== undefined) cfg.ivr = updates.ivr;
|
if (updates.ivr !== undefined) cfg.ivr = updates.ivr;
|
||||||
|
|
||||||
@@ -368,6 +432,8 @@ export function initWebUi(
|
|||||||
onStartCall,
|
onStartCall,
|
||||||
onHangupCall,
|
onHangupCall,
|
||||||
onConfigSaved,
|
onConfigSaved,
|
||||||
|
faxBoxManager,
|
||||||
|
faxJobManager,
|
||||||
voiceboxManager,
|
voiceboxManager,
|
||||||
onWebRtcOffer,
|
onWebRtcOffer,
|
||||||
onWebRtcIce,
|
onWebRtcIce,
|
||||||
@@ -387,12 +453,12 @@ export function initWebUi(
|
|||||||
const cert = fs.readFileSync(certPath, 'utf8');
|
const cert = fs.readFileSync(certPath, 'utf8');
|
||||||
const key = fs.readFileSync(keyPath, 'utf8');
|
const key = fs.readFileSync(keyPath, 'utf8');
|
||||||
server = https.createServer({ cert, key }, (req, res) =>
|
server = https.createServer({ cert, key }, (req, res) =>
|
||||||
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
||||||
);
|
);
|
||||||
useTls = true;
|
useTls = true;
|
||||||
} catch {
|
} catch {
|
||||||
server = http.createServer((req, res) =>
|
server = http.createServer((req, res) =>
|
||||||
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +495,9 @@ export function initWebUi(
|
|||||||
}
|
}
|
||||||
} else if (msg.type?.startsWith('webrtc-')) {
|
} else if (msg.type?.startsWith('webrtc-')) {
|
||||||
msg._remoteIp = remoteIp;
|
msg._remoteIp = remoteIp;
|
||||||
handleWebRtcSignaling(socket, msg);
|
if (msg.type) {
|
||||||
|
handleWebRtcSignaling(socket, msg as IWebRtcSocketMessage & { type: string });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export type {
|
|||||||
ICallEndedEvent,
|
ICallEndedEvent,
|
||||||
ICallRingingEvent,
|
ICallRingingEvent,
|
||||||
IDeviceRegisteredEvent,
|
IDeviceRegisteredEvent,
|
||||||
|
IFaxCompletedEvent,
|
||||||
|
IFaxFailedEvent,
|
||||||
|
IFaxStartedEvent,
|
||||||
IIncomingCallEvent,
|
IIncomingCallEvent,
|
||||||
ILegAddedEvent,
|
ILegAddedEvent,
|
||||||
ILegRemovedEvent,
|
ILegRemovedEvent,
|
||||||
@@ -52,6 +55,10 @@ type TProxyCommands = {
|
|||||||
params: { number: string; device_id?: string; provider_id?: string };
|
params: { number: string; device_id?: string; provider_id?: string };
|
||||||
result: { call_id: string };
|
result: { call_id: string };
|
||||||
};
|
};
|
||||||
|
send_fax: {
|
||||||
|
params: { number: string; file_path: string; provider_id?: string };
|
||||||
|
result: { call_id: string; codec: 'PCMU' | 'PCMA' };
|
||||||
|
};
|
||||||
add_leg: {
|
add_leg: {
|
||||||
params: { call_id: string; number: string; provider_id?: string };
|
params: { call_id: string; number: string; provider_id?: string };
|
||||||
result: { leg_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.
|
* 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 { VoiceboxManager } from '../voicebox.ts';
|
||||||
import type { StatusStore } from './status-store.ts';
|
import type { StatusStore } from './status-store.ts';
|
||||||
import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts';
|
import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts';
|
||||||
@@ -6,6 +8,8 @@ import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts'
|
|||||||
export interface IRegisterProxyEventHandlersOptions {
|
export interface IRegisterProxyEventHandlersOptions {
|
||||||
log: (msg: string) => void;
|
log: (msg: string) => void;
|
||||||
statusStore: StatusStore;
|
statusStore: StatusStore;
|
||||||
|
faxBoxManager: FaxBoxManager;
|
||||||
|
faxJobManager: FaxJobManager;
|
||||||
voiceboxManager: VoiceboxManager;
|
voiceboxManager: VoiceboxManager;
|
||||||
webRtcLinks: WebRtcLinkManager;
|
webRtcLinks: WebRtcLinkManager;
|
||||||
getBrowserDeviceIds: () => string[];
|
getBrowserDeviceIds: () => string[];
|
||||||
@@ -19,6 +23,8 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
|||||||
const {
|
const {
|
||||||
log,
|
log,
|
||||||
statusStore,
|
statusStore,
|
||||||
|
faxBoxManager,
|
||||||
|
faxJobManager,
|
||||||
voiceboxManager,
|
voiceboxManager,
|
||||||
webRtcLinks,
|
webRtcLinks,
|
||||||
getBrowserDeviceIds,
|
getBrowserDeviceIds,
|
||||||
@@ -28,6 +34,28 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
|||||||
onCloseWebRtcSession,
|
onCloseWebRtcSession,
|
||||||
} = options;
|
} = 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) => {
|
onProxyEvent('provider_registered', (data) => {
|
||||||
const previous = statusStore.noteProviderRegistered(data);
|
const previous = statusStore.noteProviderRegistered(data);
|
||||||
if (previous) {
|
if (previous) {
|
||||||
@@ -73,6 +101,14 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
|||||||
log(`[call] outbound started: ${data.call_id} -> ${data.number} via ${data.provider_id}`);
|
log(`[call] outbound started: ${data.call_id} -> ${data.number} via ${data.provider_id}`);
|
||||||
statusStore.noteOutboundCallStarted(data);
|
statusStore.noteOutboundCallStarted(data);
|
||||||
|
|
||||||
|
if (data.ring_browsers === false) {
|
||||||
|
faxJobManager.noteDialing(data.call_id, data.number, data.provider_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.ring_browsers === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const deviceId of getBrowserDeviceIds()) {
|
for (const deviceId of getBrowserDeviceIds()) {
|
||||||
sendToBrowserDevice(deviceId, {
|
sendToBrowserDevice(deviceId, {
|
||||||
type: 'webrtc-incoming',
|
type: 'webrtc-incoming',
|
||||||
@@ -92,6 +128,10 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
|||||||
log(`[call] ${data.call_id} connected`);
|
log(`[call] ${data.call_id} connected`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.media_protocol && data.media_protocol !== 'rtp') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!data.provider_media_addr || !data.provider_media_port) {
|
if (!data.provider_media_addr || !data.provider_media_port) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -128,7 +168,9 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
|||||||
});
|
});
|
||||||
|
|
||||||
onProxyEvent('leg_added', (data) => {
|
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);
|
statusStore.noteLegAdded(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,7 +180,9 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
|||||||
});
|
});
|
||||||
|
|
||||||
onProxyEvent('leg_state_changed', (data) => {
|
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);
|
statusStore.noteLegStateChanged(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,4 +229,37 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
|||||||
onProxyEvent('voicemail_error', (data) => {
|
onProxyEvent('voicemail_error', (data) => {
|
||||||
log(`[voicemail] error: ${data.error} call=${data.call_id}`);
|
log(`[voicemail] error: ${data.error} call=${data.call_id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onProxyEvent('fax_started', (data) => {
|
||||||
|
faxJobManager.noteStarted(data);
|
||||||
|
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) => {
|
||||||
|
faxJobManager.noteCompleted(data);
|
||||||
|
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) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.direction === 'outbound' || data.fax_box_id) {
|
||||||
|
void hangupCall(data.call_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onProxyEvent('fax_failed', (data) => {
|
||||||
|
faxJobManager.noteFailed(data);
|
||||||
|
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 {
|
noteDashboardCallStarted(callId: string, number: string, providerId?: string): void {
|
||||||
this.activeCalls.set(callId, {
|
const call = this.getOrCreateCall(callId, 'outbound');
|
||||||
id: callId,
|
call.direction = 'outbound';
|
||||||
direction: 'outbound',
|
call.callerNumber = null;
|
||||||
callerNumber: null,
|
call.calleeNumber = number;
|
||||||
calleeNumber: number,
|
call.providerUsed = providerId || null;
|
||||||
providerUsed: providerId || null,
|
call.state = 'setting-up';
|
||||||
state: 'setting-up',
|
|
||||||
startedAt: Date.now(),
|
|
||||||
legs: new Map(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
noteProviderRegistered(data: IProviderRegisteredEvent): { wasRegistered: boolean } | null {
|
noteProviderRegistered(data: IProviderRegisteredEvent): { wasRegistered: boolean } | null {
|
||||||
@@ -126,57 +122,40 @@ export class StatusStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
noteIncomingCall(data: IIncomingCallEvent): void {
|
noteIncomingCall(data: IIncomingCallEvent): void {
|
||||||
this.activeCalls.set(data.call_id, {
|
const call = this.getOrCreateCall(data.call_id, 'inbound');
|
||||||
id: data.call_id,
|
call.direction = 'inbound';
|
||||||
direction: 'inbound',
|
call.callerNumber = data.from_uri;
|
||||||
callerNumber: data.from_uri,
|
call.calleeNumber = data.to_number;
|
||||||
calleeNumber: data.to_number,
|
call.providerUsed = data.provider_id;
|
||||||
providerUsed: data.provider_id,
|
if (call.state === 'setting-up') {
|
||||||
state: 'ringing',
|
|
||||||
startedAt: Date.now(),
|
|
||||||
legs: new Map(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
noteOutboundDeviceCall(data: IOutboundCallEvent): void {
|
|
||||||
this.activeCalls.set(data.call_id, {
|
|
||||||
id: data.call_id,
|
|
||||||
direction: 'outbound',
|
|
||||||
callerNumber: data.from_device,
|
|
||||||
calleeNumber: data.to_number,
|
|
||||||
providerUsed: null,
|
|
||||||
state: 'setting-up',
|
|
||||||
startedAt: Date.now(),
|
|
||||||
legs: new Map(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
noteOutboundCallStarted(data: IOutboundCallStartedEvent): void {
|
|
||||||
this.activeCalls.set(data.call_id, {
|
|
||||||
id: data.call_id,
|
|
||||||
direction: 'outbound',
|
|
||||||
callerNumber: null,
|
|
||||||
calleeNumber: data.number,
|
|
||||||
providerUsed: data.provider_id,
|
|
||||||
state: 'setting-up',
|
|
||||||
startedAt: Date.now(),
|
|
||||||
legs: new Map(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
noteCallRinging(data: ICallRingingEvent): void {
|
|
||||||
const call = this.activeCalls.get(data.call_id);
|
|
||||||
if (call) {
|
|
||||||
call.state = 'ringing';
|
call.state = 'ringing';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
noteCallAnswered(data: ICallAnsweredEvent): boolean {
|
noteOutboundDeviceCall(data: IOutboundCallEvent): void {
|
||||||
const call = this.activeCalls.get(data.call_id);
|
const call = this.getOrCreateCall(data.call_id, 'outbound');
|
||||||
if (!call) {
|
call.direction = 'outbound';
|
||||||
return false;
|
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';
|
call.state = 'connected';
|
||||||
|
|
||||||
if (data.provider_media_addr && data.provider_media_port) {
|
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}`;
|
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}`;
|
leg.codec = CODEC_NAMES[data.sip_pt] || `PT${data.sip_pt}`;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -213,6 +197,11 @@ export class StatusStore {
|
|||||||
legs: [...call.legs.values()].map((leg) => ({
|
legs: [...call.legs.values()].map((leg) => ({
|
||||||
id: leg.id,
|
id: leg.id,
|
||||||
type: leg.type,
|
type: leg.type,
|
||||||
|
state: leg.state,
|
||||||
|
codec: leg.codec,
|
||||||
|
rtpPort: leg.rtpPort,
|
||||||
|
mediaProtocol: leg.mediaProtocol,
|
||||||
|
remoteMedia: leg.remoteMedia,
|
||||||
metadata: leg.metadata || {},
|
metadata: leg.metadata || {},
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -226,10 +215,7 @@ export class StatusStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
noteLegAdded(data: ILegAddedEvent): void {
|
noteLegAdded(data: ILegAddedEvent): void {
|
||||||
const call = this.activeCalls.get(data.call_id);
|
const call = this.getOrCreateCall(data.call_id);
|
||||||
if (!call) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
call.legs.set(data.leg_id, {
|
call.legs.set(data.leg_id, {
|
||||||
id: data.leg_id,
|
id: data.leg_id,
|
||||||
@@ -237,6 +223,7 @@ export class StatusStore {
|
|||||||
state: data.state,
|
state: data.state,
|
||||||
codec: data.codec ?? null,
|
codec: data.codec ?? null,
|
||||||
rtpPort: data.rtpPort ?? null,
|
rtpPort: data.rtpPort ?? null,
|
||||||
|
mediaProtocol: data.mediaProtocol ?? null,
|
||||||
remoteMedia: data.remoteMedia ?? null,
|
remoteMedia: data.remoteMedia ?? null,
|
||||||
metadata: data.metadata || {},
|
metadata: data.metadata || {},
|
||||||
});
|
});
|
||||||
@@ -247,14 +234,23 @@ export class StatusStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
noteLegStateChanged(data: ILegStateChangedEvent): void {
|
noteLegStateChanged(data: ILegStateChangedEvent): void {
|
||||||
const call = this.activeCalls.get(data.call_id);
|
const call = this.getOrCreateCall(data.call_id);
|
||||||
if (!call) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingLeg = call.legs.get(data.leg_id);
|
const existingLeg = call.legs.get(data.leg_id);
|
||||||
if (existingLeg) {
|
if (existingLeg) {
|
||||||
existingLeg.state = data.state;
|
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) {
|
if (data.metadata) {
|
||||||
existingLeg.metadata = data.metadata;
|
existingLeg.metadata = data.metadata;
|
||||||
}
|
}
|
||||||
@@ -265,9 +261,10 @@ export class StatusStore {
|
|||||||
id: data.leg_id,
|
id: data.leg_id,
|
||||||
type: this.inferLegType(data.leg_id),
|
type: this.inferLegType(data.leg_id),
|
||||||
state: data.state,
|
state: data.state,
|
||||||
codec: null,
|
codec: data.codec ?? null,
|
||||||
rtpPort: null,
|
rtpPort: data.rtpPort ?? null,
|
||||||
remoteMedia: null,
|
mediaProtocol: data.mediaProtocol ?? null,
|
||||||
|
remoteMedia: data.remoteMedia ?? null,
|
||||||
metadata: data.metadata || {},
|
metadata: data.metadata || {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -310,4 +307,22 @@ export class StatusStore {
|
|||||||
}
|
}
|
||||||
return 'webrtc';
|
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;
|
call_id: string;
|
||||||
number: string;
|
number: string;
|
||||||
provider_id: string;
|
provider_id: string;
|
||||||
|
ring_browsers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICallRingingEvent {
|
export interface ICallRingingEvent {
|
||||||
@@ -28,6 +29,7 @@ export interface ICallAnsweredEvent {
|
|||||||
call_id: string;
|
call_id: string;
|
||||||
provider_media_addr?: string;
|
provider_media_addr?: string;
|
||||||
provider_media_port?: number;
|
provider_media_port?: number;
|
||||||
|
media_protocol?: string;
|
||||||
sip_pt?: number;
|
sip_pt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ export interface ILegAddedEvent {
|
|||||||
state: string;
|
state: string;
|
||||||
codec?: string | null;
|
codec?: string | null;
|
||||||
rtpPort?: number | null;
|
rtpPort?: number | null;
|
||||||
|
mediaProtocol?: string | null;
|
||||||
remoteMedia?: string | null;
|
remoteMedia?: string | null;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -80,6 +83,10 @@ export interface ILegStateChangedEvent {
|
|||||||
call_id: string;
|
call_id: string;
|
||||||
leg_id: string;
|
leg_id: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
codec?: string | null;
|
||||||
|
rtpPort?: number | null;
|
||||||
|
mediaProtocol?: string | null;
|
||||||
|
remoteMedia?: string | null;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +132,56 @@ export interface IVoicemailErrorEvent {
|
|||||||
error: string;
|
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 = {
|
export type TProxyEventMap = {
|
||||||
provider_registered: IProviderRegisteredEvent;
|
provider_registered: IProviderRegisteredEvent;
|
||||||
device_registered: IDeviceRegisteredEvent;
|
device_registered: IDeviceRegisteredEvent;
|
||||||
@@ -145,4 +202,7 @@ export type TProxyEventMap = {
|
|||||||
voicemail_started: IVoicemailStartedEvent;
|
voicemail_started: IVoicemailStartedEvent;
|
||||||
recording_done: IRecordingDoneEvent;
|
recording_done: IRecordingDoneEvent;
|
||||||
voicemail_error: IVoicemailErrorEvent;
|
voicemail_error: IVoicemailErrorEvent;
|
||||||
|
fax_started: IFaxStartedEvent;
|
||||||
|
fax_completed: IFaxCompletedEvent;
|
||||||
|
fax_failed: IFaxFailedEvent;
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-1
@@ -26,6 +26,7 @@ export interface IActiveLeg {
|
|||||||
state: string;
|
state: string;
|
||||||
codec: string | null;
|
codec: string | null;
|
||||||
rtpPort: number | null;
|
rtpPort: number | null;
|
||||||
|
mediaProtocol: string | null;
|
||||||
remoteMedia: string | null;
|
remoteMedia: string | null;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -43,7 +44,11 @@ export interface IActiveCall {
|
|||||||
|
|
||||||
export interface IHistoryLeg {
|
export interface IHistoryLeg {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: TLegType;
|
||||||
|
state: string;
|
||||||
|
codec: string | null;
|
||||||
|
rtpPort: number | null;
|
||||||
|
remoteMedia: string | null;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -9,6 +9,8 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { loadConfig, type IAppConfig } from './config.ts';
|
import { loadConfig, type IAppConfig } from './config.ts';
|
||||||
|
import { FaxBoxManager } from './faxbox.ts';
|
||||||
|
import { FaxJobManager } from './faxjobs.ts';
|
||||||
import { broadcastWs, initWebUi } from './frontend.ts';
|
import { broadcastWs, initWebUi } from './frontend.ts';
|
||||||
import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts';
|
import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts';
|
||||||
import { VoiceboxManager } from './voicebox.ts';
|
import { VoiceboxManager } from './voicebox.ts';
|
||||||
@@ -35,8 +37,12 @@ const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|||||||
|
|
||||||
const statusStore = new StatusStore(appConfig);
|
const statusStore = new StatusStore(appConfig);
|
||||||
const webRtcLinks = new WebRtcLinkManager();
|
const webRtcLinks = new WebRtcLinkManager();
|
||||||
|
const faxBoxManager = new FaxBoxManager(log);
|
||||||
|
const faxJobManager = new FaxJobManager(log);
|
||||||
const voiceboxManager = new VoiceboxManager(log);
|
const voiceboxManager = new VoiceboxManager(log);
|
||||||
|
|
||||||
|
faxBoxManager.init(appConfig.faxboxes ?? []);
|
||||||
|
faxJobManager.init();
|
||||||
voiceboxManager.init(appConfig.voiceboxes ?? []);
|
voiceboxManager.init(appConfig.voiceboxes ?? []);
|
||||||
initWebRtcSignaling({ log });
|
initWebRtcSignaling({ log });
|
||||||
|
|
||||||
@@ -61,6 +67,7 @@ function buildProxyConfig(config: IAppConfig): Record<string, unknown> {
|
|||||||
providers: config.providers,
|
providers: config.providers,
|
||||||
devices: config.devices,
|
devices: config.devices,
|
||||||
routing: config.routing,
|
routing: config.routing,
|
||||||
|
faxboxes: config.faxboxes ?? [],
|
||||||
voiceboxes: config.voiceboxes ?? [],
|
voiceboxes: config.voiceboxes ?? [],
|
||||||
ivr: config.ivr,
|
ivr: config.ivr,
|
||||||
};
|
};
|
||||||
@@ -93,6 +100,7 @@ async function reloadConfig(): Promise<void> {
|
|||||||
|
|
||||||
appConfig = nextConfig;
|
appConfig = nextConfig;
|
||||||
statusStore.updateConfig(nextConfig);
|
statusStore.updateConfig(nextConfig);
|
||||||
|
faxBoxManager.init(nextConfig.faxboxes ?? []);
|
||||||
voiceboxManager.init(nextConfig.voiceboxes ?? []);
|
voiceboxManager.init(nextConfig.voiceboxes ?? []);
|
||||||
|
|
||||||
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
|
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
|
||||||
@@ -123,6 +131,8 @@ async function startProxyEngine(): Promise<void> {
|
|||||||
registerProxyEventHandlers({
|
registerProxyEventHandlers({
|
||||||
log,
|
log,
|
||||||
statusStore,
|
statusStore,
|
||||||
|
faxBoxManager,
|
||||||
|
faxJobManager,
|
||||||
voiceboxManager,
|
voiceboxManager,
|
||||||
webRtcLinks,
|
webRtcLinks,
|
||||||
getBrowserDeviceIds: getAllBrowserDeviceIds,
|
getBrowserDeviceIds: getAllBrowserDeviceIds,
|
||||||
@@ -167,6 +177,8 @@ initWebUi({
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
onConfigSaved: reloadConfig,
|
onConfigSaved: reloadConfig,
|
||||||
|
faxBoxManager,
|
||||||
|
faxJobManager,
|
||||||
voiceboxManager,
|
voiceboxManager,
|
||||||
onWebRtcOffer: async (sessionId, sdp, ws) => {
|
onWebRtcOffer: async (sessionId, sdp, ws) => {
|
||||||
log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`);
|
log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`);
|
||||||
@@ -187,7 +199,7 @@ initWebUi({
|
|||||||
log('[webrtc] ERROR: no answer SDP from Rust');
|
log('[webrtc] ERROR: no answer SDP from Rust');
|
||||||
},
|
},
|
||||||
onWebRtcIce: async (sessionId, candidate) => {
|
onWebRtcIce: async (sessionId, candidate) => {
|
||||||
await webrtcIce(sessionId, candidate);
|
await webrtcIce(sessionId, candidate as Parameters<typeof webrtcIce>[1]);
|
||||||
},
|
},
|
||||||
onWebRtcClose: async (sessionId) => {
|
onWebRtcClose: async (sessionId) => {
|
||||||
webRtcLinks.removeSession(sessionId);
|
webRtcLinks.removeSession(sessionId);
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: 'siprouter',
|
name: 'siprouter',
|
||||||
version: '1.25.1',
|
version: '1.26.0',
|
||||||
description: 'undefined'
|
description: 'undefined'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,32 @@ const LEG_TYPE_LABELS: Record<string, string> = {
|
|||||||
'sip-device': 'SIP Device',
|
'sip-device': 'SIP Device',
|
||||||
'sip-provider': 'SIP Provider',
|
'sip-provider': 'SIP Provider',
|
||||||
'webrtc': 'WebRTC',
|
'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 {
|
function directionIcon(dir: string): string {
|
||||||
if (dir === 'inbound') return '\u2199';
|
if (dir === 'inbound') return '\u2199';
|
||||||
if (dir === 'outbound') return '\u2197';
|
if (dir === 'outbound') return '\u2197';
|
||||||
@@ -151,36 +175,240 @@ export class SipproxyViewCalls extends DeesElement {
|
|||||||
|
|
||||||
.call-body {
|
.call-body {
|
||||||
padding: 12px 16px 16px;
|
padding: 12px 16px 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legs-table {
|
.call-overview {
|
||||||
width: 100%;
|
display: grid;
|
||||||
border-collapse: collapse;
|
grid-template-columns: minmax(0, 1.6fr) minmax(240px, 0.9fr);
|
||||||
font-size: 0.75rem;
|
gap: 14px;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.legs-table th {
|
.call-route-card,
|
||||||
text-align: left;
|
.call-facts-card,
|
||||||
color: #64748b;
|
.legs-section {
|
||||||
font-weight: 500;
|
border-radius: 14px;
|
||||||
font-size: 0.65rem;
|
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;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
color: #64748b;
|
||||||
padding: 6px 8px;
|
|
||||||
border-bottom: 1px solid var(--dees-color-border-default, #334155);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.legs-table td {
|
.route-line {
|
||||||
padding: 8px;
|
display: grid;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
font-size: 0.7rem;
|
align-items: center;
|
||||||
border-bottom: 1px solid var(--dees-color-border-subtle, rgba(51, 65, 85, 0.5));
|
gap: 12px;
|
||||||
vertical-align: middle;
|
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;
|
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 {
|
.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-icon { font-size: 2.5rem; margin-bottom: 12px; opacity: 0.4; }
|
||||||
.empty-state-text { font-size: 0.9rem; font-weight: 500; }
|
.empty-state-text { font-size: 0.9rem; font-weight: 500; }
|
||||||
.empty-state-sub { font-size: 0.75rem; margin-top: 4px; }
|
.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() {
|
async connectedCallback(): Promise<void> {
|
||||||
super.connectedCallback();
|
await super.connectedCallback();
|
||||||
this.rxSubscriptions.push({
|
this.rxSubscriptions.push({
|
||||||
unsubscribe: appState.subscribe((s) => {
|
unsubscribe: appState.subscribe((s) => {
|
||||||
this.appData = s;
|
this.appData = s;
|
||||||
@@ -490,6 +746,11 @@ export class SipproxyViewCalls extends DeesElement {
|
|||||||
renderer: (val: number) =>
|
renderer: (val: number) =>
|
||||||
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${fmtDuration(val)}</span>`,
|
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>
|
||||||
<div class="call-id">${call.id}</div>
|
<div class="call-id">${call.id}</div>
|
||||||
<div class="call-body">
|
<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
|
${call.legs.length
|
||||||
? html`
|
? html`
|
||||||
<table class="legs-table">
|
<div class="legs-grid">
|
||||||
<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>
|
|
||||||
${call.legs.map(
|
${call.legs.map(
|
||||||
(leg) => html`
|
(leg) => html`
|
||||||
<tr>
|
<div class="leg-card">
|
||||||
<td>
|
<div class="leg-card-top">
|
||||||
|
<div class="leg-card-badges">
|
||||||
<span class="badge" style="${legTypeBadgeStyle(leg.type)}">
|
<span class="badge" style="${legTypeBadgeStyle(leg.type)}">
|
||||||
${LEG_TYPE_LABELS[leg.type] || leg.type}
|
${LEG_TYPE_LABELS[leg.type] || leg.type}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge" style="${stateBadgeStyle(leg.state)}">
|
<span class="badge" style="${stateBadgeStyle(leg.state)}">
|
||||||
${leg.state}
|
${STATE_LABELS[leg.state] || leg.state}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</div>
|
||||||
<td>
|
<div class="leg-card-id">${leg.id}</div>
|
||||||
${leg.remoteMedia
|
</div>
|
||||||
? `${leg.remoteMedia.address}:${leg.remoteMedia.port}`
|
|
||||||
: '--'}
|
<div class="leg-facts">
|
||||||
</td>
|
<div class="leg-fact">
|
||||||
<td>${leg.rtpPort ?? '--'}</td>
|
<span class="leg-fact-label">Codec</span>
|
||||||
<td>
|
<span class="leg-fact-value">${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''}</span>
|
||||||
${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''}
|
</div>
|
||||||
</td>
|
<div class="leg-fact">
|
||||||
<td>${leg.pktReceived}</td>
|
<span class="leg-fact-label">RTP Port</span>
|
||||||
<td>${leg.pktSent}</td>
|
<span class="leg-fact-value">${leg.rtpPort ?? '--'}</span>
|
||||||
<td>
|
</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
|
<button
|
||||||
class="btn btn-remove"
|
class="btn btn-remove"
|
||||||
@click=${() => this.handleRemoveLeg(call, leg)}
|
@click=${() => this.handleRemoveLeg(call, leg)}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
`
|
`
|
||||||
: html`<div style="color:#64748b;font-size:.75rem;font-style:italic;margin-bottom:8px;">
|
: html`<div class="no-legs">No legs reported yet. SIP/system legs should appear here as soon as the call is wired.</div>`}
|
||||||
No legs
|
</div>
|
||||||
</div>`}
|
|
||||||
|
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class="btn btn-primary" @click=${() => this.handleAddParticipant(call)}>
|
<button class="btn btn-primary" @click=${() => this.handleAddParticipant(call)}>
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ interface IVoicemailMessage {
|
|||||||
heard: boolean;
|
heard: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IVoiceboxRow {
|
||||||
|
id: string;
|
||||||
|
unheardCount: number;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -61,19 +67,6 @@ export class SipproxyViewVoicemail extends DeesElement {
|
|||||||
.view-section {
|
.view-section {
|
||||||
margin-bottom: 24px;
|
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 {
|
.audio-player {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -135,10 +128,11 @@ export class SipproxyViewVoicemail extends DeesElement {
|
|||||||
const cfg = await appState.apiGetConfig();
|
const cfg = await appState.apiGetConfig();
|
||||||
const boxes: { id: string }[] = cfg.voiceboxes || [];
|
const boxes: { id: string }[] = cfg.voiceboxes || [];
|
||||||
this.voiceboxIds = boxes.map((b) => b.id);
|
this.voiceboxIds = boxes.map((b) => b.id);
|
||||||
if (this.voiceboxIds.length > 0 && !this.selectedBoxId) {
|
const nextSelectedBoxId = this.voiceboxIds.includes(this.selectedBoxId)
|
||||||
this.selectedBoxId = this.voiceboxIds[0];
|
? this.selectedBoxId
|
||||||
|
: (this.voiceboxIds[0] || '');
|
||||||
|
this.selectedBoxId = nextSelectedBoxId;
|
||||||
await this.loadMessages();
|
await this.loadMessages();
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Config unavailable.
|
// Config unavailable.
|
||||||
}
|
}
|
||||||
@@ -161,11 +155,22 @@ export class SipproxyViewVoicemail extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async selectBox(boxId: string) {
|
private async selectBox(boxId: string) {
|
||||||
|
if (boxId === this.selectedBoxId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.selectedBoxId = boxId;
|
this.selectedBoxId = boxId;
|
||||||
this.stopAudio();
|
this.stopAudio();
|
||||||
await this.loadMessages();
|
await this.loadMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getVoiceboxRows(): IVoiceboxRow[] {
|
||||||
|
return this.voiceboxIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
unheardCount: this.appData.voicemailCounts[id] || 0,
|
||||||
|
selected: id === this.selectedBoxId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- audio playback ------------------------------------------------------
|
// ---- audio playback ------------------------------------------------------
|
||||||
|
|
||||||
private playMessage(msg: IVoicemailMessage) {
|
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 -------------------------------------------------------
|
// ---- table actions -------------------------------------------------------
|
||||||
|
|
||||||
private getDataActions() {
|
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 --------------------------------------------------------------
|
// ---- render --------------------------------------------------------------
|
||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
${this.voiceboxIds.length > 1 ? html`
|
<div class="view-section">
|
||||||
<div class="box-selector">
|
<dees-table
|
||||||
<label>Voicebox</label>
|
heading1="Voiceboxes"
|
||||||
<dees-input-dropdown
|
heading2="${this.voiceboxIds.length} configured"
|
||||||
.key=${'voicebox'}
|
dataName="voiceboxes"
|
||||||
.selectedOption=${{ option: this.selectedBoxId, key: this.selectedBoxId }}
|
.data=${this.getVoiceboxRows()}
|
||||||
.options=${this.voiceboxIds.map((id) => ({ option: id, key: id }))}
|
.rowKey=${'id'}
|
||||||
@selectedOption=${(e: CustomEvent) => { this.selectBox(e.detail.key); }}
|
.columns=${this.getVoiceboxColumns()}
|
||||||
></dees-input-dropdown>
|
.dataActions=${this.getVoiceboxActions()}
|
||||||
|
></dees-table>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
|
||||||
|
|
||||||
<div class="view-section">
|
<div class="view-section">
|
||||||
<dees-statsgrid
|
<dees-statsgrid
|
||||||
|
|||||||
Reference in New Issue
Block a user