feat: promote linux native audio path
This commit is contained in:
Generated
+266
-55
@@ -77,7 +77,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
|
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alsa-sys",
|
"alsa-sys",
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -121,6 +121,16 @@ version = "0.1.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotate-snippets"
|
||||||
|
version = "0.11.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstyle"
|
name = "anstyle"
|
||||||
version = "1.0.14"
|
version = "1.0.14"
|
||||||
@@ -260,18 +270,31 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bindgen"
|
||||||
|
version = "0.72.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||||
|
dependencies = [
|
||||||
|
"annotate-snippets",
|
||||||
|
"bitflags",
|
||||||
|
"cexpr",
|
||||||
|
"clang-sys",
|
||||||
|
"itertools 0.10.5",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"regex",
|
||||||
|
"rustc-hash 2.1.2",
|
||||||
|
"shlex",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-vec"
|
name = "bit-vec"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitflags"
|
|
||||||
version = "1.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
@@ -367,6 +390,25 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cexpr"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||||
|
dependencies = [
|
||||||
|
"nom 7.1.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-expr"
|
||||||
|
version = "0.20.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368"
|
||||||
|
dependencies = [
|
||||||
|
"smallvec",
|
||||||
|
"target-lexicon",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -427,13 +469,15 @@ dependencies = [
|
|||||||
"dispatch2",
|
"dispatch2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
|
"libpulse-binding",
|
||||||
|
"libpulse-simple-binding",
|
||||||
"ndarray",
|
"ndarray",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"oboe",
|
"oboe",
|
||||||
"ort",
|
"ort",
|
||||||
|
"pipewire",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rustfft",
|
"rustfft",
|
||||||
"sdl2",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sonora",
|
"sonora",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -493,6 +537,7 @@ name = "chanora_protocol"
|
|||||||
version = "0.2.0-beta.1"
|
version = "0.2.0-beta.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64",
|
||||||
"chanora_resolver",
|
"chanora_resolver",
|
||||||
"futures",
|
"futures",
|
||||||
"reqwest 0.13.3",
|
"reqwest 0.13.3",
|
||||||
@@ -579,6 +624,17 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clang-sys"
|
||||||
|
version = "1.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||||
|
dependencies = [
|
||||||
|
"glob",
|
||||||
|
"libc",
|
||||||
|
"libloading 0.8.9",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.1"
|
version = "4.6.1"
|
||||||
@@ -658,6 +714,12 @@ version = "0.9.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie-factory"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -690,7 +752,7 @@ version = "0.14.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
|
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2-audio-toolbox",
|
"objc2-audio-toolbox",
|
||||||
"objc2-core-audio",
|
"objc2-core-audio",
|
||||||
@@ -1055,7 +1117,7 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"block2",
|
"block2",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -1514,6 +1576,12 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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.13.0"
|
version = "0.13.0"
|
||||||
@@ -2135,6 +2203,16 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libloading"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libloading"
|
name = "libloading"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2145,6 +2223,81 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libpulse-binding"
|
||||||
|
version = "2.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"libc",
|
||||||
|
"libpulse-sys",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libpulse-simple-binding"
|
||||||
|
version = "2.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7bebef0381c8e3e4b23cc24aaf36fab37472bece128de96f6a111efa464cfef"
|
||||||
|
dependencies = [
|
||||||
|
"libpulse-binding",
|
||||||
|
"libpulse-simple-sys",
|
||||||
|
"libpulse-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libpulse-simple-sys"
|
||||||
|
version = "1.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3bd96888fe37ad270d16abf5e82cccca1424871cf6afa2861824d2a52758eebc"
|
||||||
|
dependencies = [
|
||||||
|
"libpulse-sys",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libpulse-sys"
|
||||||
|
version = "1.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"pkg-config",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libspa"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2909f3be29d674e7f10604aff18d1bbe1bb03c4cd61c8a8ba19c0b1d162f7d4e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cc",
|
||||||
|
"cookie-factory",
|
||||||
|
"libc",
|
||||||
|
"libspa-sys",
|
||||||
|
"nom 8.0.0",
|
||||||
|
"rustix",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libspa-sys"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69ad52764fca54818486f3cf75afec844d1f1a1568c24dcee25d41b1ab007dda"
|
||||||
|
dependencies = [
|
||||||
|
"bindgen",
|
||||||
|
"cc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libsqlite3-sys"
|
name = "libsqlite3-sys"
|
||||||
version = "0.30.1"
|
version = "0.30.1"
|
||||||
@@ -2162,7 +2315,7 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
|
checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2345,7 +2498,7 @@ version = "0.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"jni-sys 0.3.1",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
@@ -2378,6 +2531,15 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -2488,7 +2650,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-audio",
|
"objc2-core-audio",
|
||||||
@@ -2526,7 +2688,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2536,7 +2698,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"block2",
|
"block2",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2555,7 +2717,7 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"block2",
|
"block2",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -2624,7 +2786,7 @@ version = "0.10.80"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2677,7 +2839,7 @@ version = "2.0.0-rc.12"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
|
checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libloading",
|
"libloading 0.9.0",
|
||||||
"ndarray",
|
"ndarray",
|
||||||
"ort-sys",
|
"ort-sys",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
@@ -2769,6 +2931,31 @@ 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 = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pipewire"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8585aba8a52ad74ccc633b8e293c1dc4277976bd5d510b925533f34fd6685f38"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"libc",
|
||||||
|
"libspa",
|
||||||
|
"libspa-sys",
|
||||||
|
"pipewire-sys",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pipewire-sys"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2089f245b548723e60325773c27f586b7a2372c79ea941b246cd0d654706adc"
|
||||||
|
dependencies = [
|
||||||
|
"bindgen",
|
||||||
|
"libspa-sys",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkcs8"
|
name = "pkcs8"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -3116,7 +3303,7 @@ version = "0.5.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3283,7 +3470,7 @@ version = "0.32.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"fallible-iterator",
|
"fallible-iterator",
|
||||||
"fallible-streaming-iterator",
|
"fallible-streaming-iterator",
|
||||||
"hashlink",
|
"hashlink",
|
||||||
@@ -3338,7 +3525,7 @@ version = "1.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
@@ -3457,29 +3644,6 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sdl2"
|
|
||||||
version = "0.37.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3b498da7d14d1ad6c839729bd4ad6fc11d90a57583605f3b4df2cd709a9cd380"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 1.3.2",
|
|
||||||
"lazy_static",
|
|
||||||
"libc",
|
|
||||||
"sdl2-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sdl2-sys"
|
|
||||||
version = "0.37.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "951deab27af08ed9c6068b7b0d05a93c91f0a8eb16b6b816a5e73452a43521d3"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"version-compare",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sec1"
|
name = "sec1"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -3500,7 +3664,7 @@ version = "2.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -3513,7 +3677,7 @@ version = "3.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -3868,7 +4032,7 @@ version = "0.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"system-configuration-sys",
|
"system-configuration-sys",
|
||||||
]
|
]
|
||||||
@@ -3883,13 +4047,26 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-deps"
|
||||||
|
version = "7.0.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-expr",
|
||||||
|
"heck",
|
||||||
|
"pkg-config",
|
||||||
|
"toml",
|
||||||
|
"version-compare",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "t4rust-derive"
|
name = "t4rust-derive"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e411692d6e0c8ce07f65a8210e95dcfb56cc4600940456592a49a3579ef6396"
|
checksum = "8e411692d6e0c8ce07f65a8210e95dcfb56cc4600940456592a49a3579ef6396"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
@@ -3901,6 +4078,12 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "target-lexicon"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -4188,7 +4371,7 @@ version = "0.6.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -4384,7 +4567,7 @@ version = "0.1.0"
|
|||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"num-derive",
|
"num-derive",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"omnom",
|
"omnom",
|
||||||
@@ -4412,7 +4595,7 @@ version = "0.1.0"
|
|||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"curve25519-dalek-ng",
|
"curve25519-dalek-ng",
|
||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
@@ -4455,6 +4638,12 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -4521,9 +4710,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.1.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29"
|
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
@@ -4657,7 +4846,7 @@ version = "0.244.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -4716,6 +4905,22 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -4725,6 +4930,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.54.0"
|
version = "0.54.0"
|
||||||
@@ -5170,7 +5381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitflags 2.11.1",
|
"bitflags",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -352,7 +352,22 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
|
|||||||
).firstOrNull { File(it).resolve("cargo").exists() }
|
).firstOrNull { File(it).resolve("cargo").exists() }
|
||||||
val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
|
val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
|
||||||
val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo"
|
val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo"
|
||||||
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: "stable-aarch64-apple-darwin"
|
val hostArch = System.getProperty("os.arch").lowercase()
|
||||||
|
val defaultRustupToolchain = when {
|
||||||
|
org.gradle.internal.os.OperatingSystem.current().isMacOsX && hostArch.contains("aarch64") ->
|
||||||
|
"stable-aarch64-apple-darwin"
|
||||||
|
org.gradle.internal.os.OperatingSystem.current().isMacOsX ->
|
||||||
|
"stable-x86_64-apple-darwin"
|
||||||
|
org.gradle.internal.os.OperatingSystem.current().isWindows && hostArch.contains("aarch64") ->
|
||||||
|
"stable-aarch64-pc-windows-msvc"
|
||||||
|
org.gradle.internal.os.OperatingSystem.current().isWindows ->
|
||||||
|
"stable-x86_64-pc-windows-msvc"
|
||||||
|
hostArch.contains("aarch64") || hostArch.contains("arm64") ->
|
||||||
|
"stable-aarch64-unknown-linux-gnu"
|
||||||
|
else ->
|
||||||
|
"stable-x86_64-unknown-linux-gnu"
|
||||||
|
}
|
||||||
|
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: defaultRustupToolchain
|
||||||
|
|
||||||
// SDD-118 item 13 (corrected): per-ABI Exec sub-tasks. Each runs an
|
// SDD-118 item 13 (corrected): per-ABI Exec sub-tasks. Each runs an
|
||||||
// isolated `cargo ndk -t <abi> ... -- build ...` so ANDROID_ABI is set
|
// isolated `cargo ndk -t <abi> ... -- build ...` so ANDROID_ABI is set
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
|
android.newDsl=false
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ List<PlatformCapability> _windowsCapabilities() => [
|
|||||||
List<PlatformCapability> _linuxCapabilities() => [
|
List<PlatformCapability> _linuxCapabilities() => [
|
||||||
const PlatformCapability(
|
const PlatformCapability(
|
||||||
feature: 'Voice capture',
|
feature: 'Voice capture',
|
||||||
description: 'PulseAudio/ALSA via cpal.',
|
description: 'PipeWire voice I/O with PulseAudio fallback.',
|
||||||
tier: CapabilityTier.supported,
|
tier: CapabilityTier.supported,
|
||||||
),
|
),
|
||||||
const PlatformCapability(
|
const PlatformCapability(
|
||||||
|
|||||||
@@ -226,6 +226,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
AndroidPermissionsService();
|
AndroidPermissionsService();
|
||||||
final IosPermissionsService _iosPermissions = IosPermissionsService();
|
final IosPermissionsService _iosPermissions = IosPermissionsService();
|
||||||
final UiPreferencesService _uiPreferences = const UiPreferencesService();
|
final UiPreferencesService _uiPreferences = const UiPreferencesService();
|
||||||
|
Map<String, ClientPlaybackPreference> _clientPlaybackPrefs = const {};
|
||||||
|
final Map<BigInt, double> _appliedClientPlaybackVolumes = {};
|
||||||
|
String _clientPlaybackPrefsHost = '';
|
||||||
late final BackIntentService _backIntentService;
|
late final BackIntentService _backIntentService;
|
||||||
int _modalRouteDepth = 0;
|
int _modalRouteDepth = 0;
|
||||||
|
|
||||||
@@ -314,6 +317,105 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureClientPlaybackPreferencesLoadedForCurrentHost() async {
|
||||||
|
final host = _hostCtl.text.trim().toLowerCase();
|
||||||
|
if (host.isEmpty) {
|
||||||
|
_clientPlaybackPrefsHost = '';
|
||||||
|
_clientPlaybackPrefs = const {};
|
||||||
|
_appliedClientPlaybackVolumes.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_clientPlaybackPrefsHost == host) return;
|
||||||
|
_appliedClientPlaybackVolumes.clear();
|
||||||
|
try {
|
||||||
|
_clientPlaybackPrefs = await _uiPreferences
|
||||||
|
.loadClientPlaybackPreferencesForServer(host);
|
||||||
|
_clientPlaybackPrefsHost = host;
|
||||||
|
} catch (_) {
|
||||||
|
_clientPlaybackPrefsHost = host;
|
||||||
|
_clientPlaybackPrefs = const {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyClientPlaybackPreferencesToSnapshot(
|
||||||
|
rust.BridgeSnapshot snap,
|
||||||
|
) async {
|
||||||
|
await _ensureClientPlaybackPreferencesLoadedForCurrentHost();
|
||||||
|
if (_clientPlaybackPrefs.isEmpty) return;
|
||||||
|
for (final client in snap.clients) {
|
||||||
|
if (client.id == snap.ownClientId ||
|
||||||
|
client.isServerQuery ||
|
||||||
|
client.uid.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final pref = _clientPlaybackPrefs[client.uid];
|
||||||
|
if (pref == null) continue;
|
||||||
|
if (_appliedClientPlaybackVolumes[client.id] == pref.appliedVolume) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await rust.setClientVolume(
|
||||||
|
clientId: client.id,
|
||||||
|
volume: pref.appliedVolume,
|
||||||
|
);
|
||||||
|
_appliedClientPlaybackVolumes[client.id] = pref.appliedVolume;
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort: queues may not exist yet for silent users.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setClientPlaybackPreference(
|
||||||
|
rust.BridgeClient client,
|
||||||
|
ClientPlaybackPreference preference,
|
||||||
|
) async {
|
||||||
|
if (client.id == _snapshot?.ownClientId ||
|
||||||
|
client.isServerQuery ||
|
||||||
|
client.uid.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final host = _hostCtl.text.trim().toLowerCase();
|
||||||
|
if (host.isEmpty) return;
|
||||||
|
|
||||||
|
await _ensureClientPlaybackPreferencesLoadedForCurrentHost();
|
||||||
|
final nextPrefs = Map<String, ClientPlaybackPreference>.from(
|
||||||
|
_clientPlaybackPrefs,
|
||||||
|
);
|
||||||
|
if (preference.volume == 1.0 && !preference.muted) {
|
||||||
|
nextPrefs.remove(client.uid);
|
||||||
|
} else {
|
||||||
|
nextPrefs[client.uid] = preference;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _clientPlaybackPrefs = nextPrefs);
|
||||||
|
} else {
|
||||||
|
_clientPlaybackPrefs = nextPrefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _uiPreferences.saveClientPlaybackPreference(
|
||||||
|
serverHost: host,
|
||||||
|
userUid: client.uid,
|
||||||
|
volume: preference.volume,
|
||||||
|
muted: preference.muted,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
_recordUiDiagnostic('save client playback preference', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rust.setClientVolume(
|
||||||
|
clientId: client.id,
|
||||||
|
volume: preference.appliedVolume,
|
||||||
|
);
|
||||||
|
_appliedClientPlaybackVolumes[client.id] = preference.appliedVolume;
|
||||||
|
} catch (error) {
|
||||||
|
_recordUiDiagnostic('set client volume', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _requestRecordAudioOnStartup() async {
|
Future<void> _requestRecordAudioOnStartup() async {
|
||||||
try {
|
try {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
@@ -1482,6 +1584,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
void _applySnapshot(rust.BridgeSnapshot snap) {
|
void _applySnapshot(rust.BridgeSnapshot snap) {
|
||||||
_snapshot = snap;
|
_snapshot = snap;
|
||||||
|
unawaited(_applyClientPlaybackPreferencesToSnapshot(snap));
|
||||||
final own = ownClientSnapshotState(snap);
|
final own = ownClientSnapshotState(snap);
|
||||||
if (own == null) return;
|
if (own == null) return;
|
||||||
_inputMuted = own.inputMuted;
|
_inputMuted = own.inputMuted;
|
||||||
@@ -2013,6 +2116,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
onJoinChannel: (ch) => _onJoinChannel(ch),
|
onJoinChannel: (ch) => _onJoinChannel(ch),
|
||||||
onJoinChannelWithPassword: (ch) =>
|
onJoinChannelWithPassword: (ch) =>
|
||||||
_onJoinChannel(ch, askForPassword: true),
|
_onJoinChannel(ch, askForPassword: true),
|
||||||
|
clientPlaybackPreferences: _clientPlaybackPrefs,
|
||||||
|
onClientPlaybackPreferenceChanged:
|
||||||
|
_setClientPlaybackPreference,
|
||||||
onTs3ServerLink: _onTs3ServerLink,
|
onTs3ServerLink: _onTs3ServerLink,
|
||||||
);
|
);
|
||||||
final ownClientState = _ownClientState;
|
final ownClientState = _ownClientState;
|
||||||
|
|||||||
@@ -18,17 +18,19 @@ Future<void>? _storageInitFuture;
|
|||||||
Future<void>? _vadBootstrapFuture;
|
Future<void>? _vadBootstrapFuture;
|
||||||
StorageDirectoryProvider _storageDirectoryProvider =
|
StorageDirectoryProvider _storageDirectoryProvider =
|
||||||
getApplicationSupportDirectory;
|
getApplicationSupportDirectory;
|
||||||
|
StorageDirectoryProvider _vadModelDirectoryProvider =
|
||||||
|
getApplicationSupportDirectory;
|
||||||
StorageInitializer _storageInitializer = _defaultStorageInitializer;
|
StorageInitializer _storageInitializer = _defaultStorageInitializer;
|
||||||
|
|
||||||
Future<void> _defaultStorageInitializer(String dir) {
|
Future<void> _defaultStorageInitializer(String dir) {
|
||||||
return rust.initStorage(dir: dir);
|
return rust.initStorage(dir: dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<File> _copyBundledAssetToDocuments({
|
Future<File> _copyBundledAssetToManagedDirectory({
|
||||||
required String assetPath,
|
required String assetPath,
|
||||||
required String fileName,
|
required String fileName,
|
||||||
}) async {
|
}) async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await _vadModelDirectoryProvider();
|
||||||
final file = File('${dir.path}/$fileName');
|
final file = File('${dir.path}/$fileName');
|
||||||
final data = await rootBundle.load(assetPath);
|
final data = await rootBundle.load(assetPath);
|
||||||
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||||
@@ -59,7 +61,7 @@ Future<void> configureBundledVadModels() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _configureBundledVadModelsImpl() async {
|
Future<void> _configureBundledVadModelsImpl() async {
|
||||||
final silero = await _copyBundledAssetToDocuments(
|
final silero = await _copyBundledAssetToManagedDirectory(
|
||||||
assetPath: _sileroVadAsset,
|
assetPath: _sileroVadAsset,
|
||||||
fileName: 'silero_vad.onnx',
|
fileName: 'silero_vad.onnx',
|
||||||
);
|
);
|
||||||
@@ -124,12 +126,15 @@ Future<void> _wireStorageImpl() async {
|
|||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void debugResetStorageBootstrap({
|
void debugResetStorageBootstrap({
|
||||||
StorageDirectoryProvider? storageDirectoryProvider,
|
StorageDirectoryProvider? storageDirectoryProvider,
|
||||||
|
StorageDirectoryProvider? vadModelDirectoryProvider,
|
||||||
StorageInitializer? storageInitializer,
|
StorageInitializer? storageInitializer,
|
||||||
}) {
|
}) {
|
||||||
_storageInitFuture = null;
|
_storageInitFuture = null;
|
||||||
_vadBootstrapFuture = null;
|
_vadBootstrapFuture = null;
|
||||||
_storageDirectoryProvider =
|
_storageDirectoryProvider =
|
||||||
storageDirectoryProvider ?? getApplicationSupportDirectory;
|
storageDirectoryProvider ?? getApplicationSupportDirectory;
|
||||||
|
_vadModelDirectoryProvider =
|
||||||
|
vadModelDirectoryProvider ?? getApplicationSupportDirectory;
|
||||||
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
|
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,8 +117,20 @@ Future<StartupDependencyCheckResult> checkStartupDependencies() async {
|
|||||||
final executableDir = File(_resolvedExecutableProvider()).parent.path;
|
final executableDir = File(_resolvedExecutableProvider()).parent.path;
|
||||||
final issues = <StartupDependencyIssue>[];
|
final issues = <StartupDependencyIssue>[];
|
||||||
|
|
||||||
if (!_hasAnyLoadableLibrary(const ['libSDL2.so', 'libSDL2-2.0.so.0'])) {
|
if (!_hasAnyLoadableLibrary(const [
|
||||||
issues.add(_buildSdlIssue(distro));
|
'libpipewire-0.3.so.0',
|
||||||
|
'libpipewire-0.3.so',
|
||||||
|
])) {
|
||||||
|
issues.add(_buildPipeWireIssue(distro));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_hasAnyLoadableLibrary(const [
|
||||||
|
'libpulse.so.0',
|
||||||
|
'libpulse.so',
|
||||||
|
'libpulse-simple.so.0',
|
||||||
|
'libpulse-simple.so',
|
||||||
|
])) {
|
||||||
|
issues.add(_buildPulseAudioIssue(distro));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await _hasOnnxRuntime(executableDir: executableDir)) {
|
if (!await _hasOnnxRuntime(executableDir: executableDir)) {
|
||||||
@@ -272,33 +284,76 @@ _LinuxArch _detectLinuxArch() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
StartupDependencyIssue _buildSdlIssue(_LinuxDistro distro) {
|
StartupDependencyIssue _buildPipeWireIssue(_LinuxDistro distro) {
|
||||||
final List<StartupInstallHint> hints = switch (distro) {
|
final List<StartupInstallHint> hints = switch (distro) {
|
||||||
_LinuxDistro.debian => const <StartupInstallHint>[
|
_LinuxDistro.debian => const <StartupInstallHint>[
|
||||||
StartupInstallHint(
|
StartupInstallHint(
|
||||||
label: 'Debian / Ubuntu',
|
label: 'Debian / Ubuntu',
|
||||||
command: 'sudo apt install libsdl2-2.0-0',
|
command: 'sudo apt install libpipewire-0.3-0',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
_LinuxDistro.fedora => const <StartupInstallHint>[
|
_LinuxDistro.fedora => const <StartupInstallHint>[
|
||||||
StartupInstallHint(label: 'Fedora', command: 'sudo dnf install SDL2'),
|
StartupInstallHint(
|
||||||
|
label: 'Fedora',
|
||||||
|
command: 'sudo dnf install pipewire-libs',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
_LinuxDistro.arch => const <StartupInstallHint>[
|
_LinuxDistro.arch => const <StartupInstallHint>[
|
||||||
StartupInstallHint(label: 'Arch Linux', command: 'sudo pacman -S sdl2'),
|
StartupInstallHint(
|
||||||
|
label: 'Arch Linux',
|
||||||
|
command: 'sudo pacman -S pipewire',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
_LinuxDistro.other => const <StartupInstallHint>[],
|
_LinuxDistro.other => const <StartupInstallHint>[],
|
||||||
};
|
};
|
||||||
|
|
||||||
return StartupDependencyIssue(
|
return StartupDependencyIssue(
|
||||||
id: 'linux-sdl2-runtime',
|
id: 'linux-pipewire-runtime',
|
||||||
title: 'SDL2 runtime is missing',
|
title: 'PipeWire runtime is missing',
|
||||||
summary:
|
summary:
|
||||||
'Chanora uses SDL2 for Linux audio playback. Without it, voice output will not work.',
|
'Chanora uses PipeWire as the primary Linux voice backend. Without it, Chanora will try the PulseAudio fallback.',
|
||||||
details: const [
|
details: const [
|
||||||
'Install the SDL2 runtime package for your distribution.',
|
'Install the PipeWire runtime package for your distribution.',
|
||||||
'After installing it, restart Chanora and tap Recheck.',
|
'After installing it, restart Chanora and tap Recheck.',
|
||||||
],
|
],
|
||||||
severity: StartupDependencySeverity.required,
|
severity: StartupDependencySeverity.recommended,
|
||||||
|
installHints: hints,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
StartupDependencyIssue _buildPulseAudioIssue(_LinuxDistro distro) {
|
||||||
|
final List<StartupInstallHint> hints = switch (distro) {
|
||||||
|
_LinuxDistro.debian => const <StartupInstallHint>[
|
||||||
|
StartupInstallHint(
|
||||||
|
label: 'Debian / Ubuntu',
|
||||||
|
command: 'sudo apt install libpulse0',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
_LinuxDistro.fedora => const <StartupInstallHint>[
|
||||||
|
StartupInstallHint(
|
||||||
|
label: 'Fedora',
|
||||||
|
command: 'sudo dnf install pulseaudio-libs',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
_LinuxDistro.arch => const <StartupInstallHint>[
|
||||||
|
StartupInstallHint(
|
||||||
|
label: 'Arch Linux',
|
||||||
|
command: 'sudo pacman -S libpulse',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
_LinuxDistro.other => const <StartupInstallHint>[],
|
||||||
|
};
|
||||||
|
|
||||||
|
return StartupDependencyIssue(
|
||||||
|
id: 'linux-pulseaudio-runtime',
|
||||||
|
title: 'PulseAudio runtime is missing',
|
||||||
|
summary:
|
||||||
|
'Chanora uses PulseAudio as the Linux fallback voice backend when PipeWire is unavailable.',
|
||||||
|
details: const [
|
||||||
|
'Install the PulseAudio client library package for your distribution.',
|
||||||
|
'After installing it, restart Chanora and tap Recheck.',
|
||||||
|
],
|
||||||
|
severity: StartupDependencySeverity.recommended,
|
||||||
installHints: hints,
|
installHints: hints,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class UiSettings {
|
class UiSettings {
|
||||||
@@ -7,10 +9,45 @@ class UiSettings {
|
|||||||
final String nickname;
|
final String nickname;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ClientPlaybackPreference {
|
||||||
|
const ClientPlaybackPreference({this.volume = 1.0, this.muted = false});
|
||||||
|
|
||||||
|
final double volume;
|
||||||
|
final bool muted;
|
||||||
|
|
||||||
|
double get appliedVolume => muted ? 0.0 : volume;
|
||||||
|
|
||||||
|
ClientPlaybackPreference copyWith({double? volume, bool? muted}) {
|
||||||
|
return ClientPlaybackPreference(
|
||||||
|
volume: _clampVolume(volume ?? this.volume),
|
||||||
|
muted: muted ?? this.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> toJson() => {
|
||||||
|
'volume': _clampVolume(volume),
|
||||||
|
'muted': muted,
|
||||||
|
};
|
||||||
|
|
||||||
|
static ClientPlaybackPreference fromJson(Map<String, dynamic> json) {
|
||||||
|
final volume = json['volume'];
|
||||||
|
return ClientPlaybackPreference(
|
||||||
|
volume: _clampVolume(volume is num ? volume.toDouble() : 1.0),
|
||||||
|
muted: json['muted'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _clampVolume(double volume) {
|
||||||
|
if (!volume.isFinite) return 1.0;
|
||||||
|
return volume.clamp(0.0, 4.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class UiPreferencesService {
|
class UiPreferencesService {
|
||||||
static const _hostKey = 'ui.host';
|
static const _hostKey = 'ui.host';
|
||||||
static const _nicknameKey = 'ui.nickname';
|
static const _nicknameKey = 'ui.nickname';
|
||||||
static const _permissionsExplainedKey = 'perms_explained';
|
static const _permissionsExplainedKey = 'perms_explained';
|
||||||
|
static const _clientPlaybackPrefsKey = 'audio.client_playback_prefs';
|
||||||
|
|
||||||
const UiPreferencesService();
|
const UiPreferencesService();
|
||||||
|
|
||||||
@@ -37,4 +74,80 @@ class UiPreferencesService {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_permissionsExplainedKey, true);
|
await prefs.setBool(_permissionsExplainedKey, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, ClientPlaybackPreference>>
|
||||||
|
loadClientPlaybackPreferencesForServer(String serverHost) async {
|
||||||
|
final normalizedHost = _normalizeServerHost(serverHost);
|
||||||
|
if (normalizedHost.isEmpty) return const {};
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_clientPlaybackPrefsKey);
|
||||||
|
if (raw == null || raw.isEmpty) return const {};
|
||||||
|
|
||||||
|
final decoded = _decodeClientPlaybackPreferences(raw);
|
||||||
|
|
||||||
|
final result = <String, ClientPlaybackPreference>{};
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final key = entry.key;
|
||||||
|
final value = entry.value;
|
||||||
|
final (host, uid) = _splitCompositeKey(key);
|
||||||
|
if (host != normalizedHost ||
|
||||||
|
uid.isEmpty ||
|
||||||
|
value is! Map<String, dynamic>) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[uid] = ClientPlaybackPreference.fromJson(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveClientPlaybackPreference({
|
||||||
|
required String serverHost,
|
||||||
|
required String userUid,
|
||||||
|
double? volume,
|
||||||
|
bool? muted,
|
||||||
|
}) async {
|
||||||
|
final normalizedHost = _normalizeServerHost(serverHost);
|
||||||
|
final normalizedUid = userUid.trim();
|
||||||
|
if (normalizedHost.isEmpty || normalizedUid.isEmpty) return;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final current = await loadClientPlaybackPreferencesForServer(serverHost);
|
||||||
|
final previous = current[normalizedUid] ?? const ClientPlaybackPreference();
|
||||||
|
final updated = previous.copyWith(volume: volume, muted: muted);
|
||||||
|
|
||||||
|
final raw = prefs.getString(_clientPlaybackPrefsKey);
|
||||||
|
final decoded = _decodeClientPlaybackPreferences(raw);
|
||||||
|
final compositeKey = _compositeKey(normalizedHost, normalizedUid);
|
||||||
|
|
||||||
|
if (updated.volume == 1.0 && !updated.muted) {
|
||||||
|
decoded.remove(compositeKey);
|
||||||
|
} else {
|
||||||
|
decoded[compositeKey] = updated.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
await prefs.setString(_clientPlaybackPrefsKey, jsonEncode(decoded));
|
||||||
|
}
|
||||||
|
|
||||||
|
String _compositeKey(String normalizedHost, String normalizedUid) {
|
||||||
|
return '$normalizedHost|$normalizedUid';
|
||||||
|
}
|
||||||
|
|
||||||
|
(String, String) _splitCompositeKey(String key) {
|
||||||
|
final index = key.indexOf('|');
|
||||||
|
if (index == -1) return ('', '');
|
||||||
|
return (key.substring(0, index), key.substring(index + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _normalizeServerHost(String host) => host.trim().toLowerCase();
|
||||||
|
|
||||||
|
Map<String, dynamic> _decodeClientPlaybackPreferences(String? raw) {
|
||||||
|
if (raw == null || raw.isEmpty) return <String, dynamic>{};
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
return decoded is Map<String, dynamic> ? decoded : <String, dynamic>{};
|
||||||
|
} catch (_) {
|
||||||
|
return <String, dynamic>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -795,6 +795,9 @@ class BridgeClient {
|
|||||||
/// Stable client id.
|
/// Stable client id.
|
||||||
final BigInt id;
|
final BigInt id;
|
||||||
|
|
||||||
|
/// Stable TeamSpeak unique identifier.
|
||||||
|
final String uid;
|
||||||
|
|
||||||
/// Channel id the client is currently in.
|
/// Channel id the client is currently in.
|
||||||
final BigInt channel;
|
final BigInt channel;
|
||||||
|
|
||||||
@@ -821,6 +824,7 @@ class BridgeClient {
|
|||||||
|
|
||||||
const BridgeClient({
|
const BridgeClient({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
required this.uid,
|
||||||
required this.channel,
|
required this.channel,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.inputMuted,
|
required this.inputMuted,
|
||||||
@@ -834,6 +838,7 @@ class BridgeClient {
|
|||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
id.hashCode ^
|
id.hashCode ^
|
||||||
|
uid.hashCode ^
|
||||||
channel.hashCode ^
|
channel.hashCode ^
|
||||||
name.hashCode ^
|
name.hashCode ^
|
||||||
inputMuted.hashCode ^
|
inputMuted.hashCode ^
|
||||||
@@ -849,6 +854,7 @@ class BridgeClient {
|
|||||||
other is BridgeClient &&
|
other is BridgeClient &&
|
||||||
runtimeType == other.runtimeType &&
|
runtimeType == other.runtimeType &&
|
||||||
id == other.id &&
|
id == other.id &&
|
||||||
|
uid == other.uid &&
|
||||||
channel == other.channel &&
|
channel == other.channel &&
|
||||||
name == other.name &&
|
name == other.name &&
|
||||||
inputMuted == other.inputMuted &&
|
inputMuted == other.inputMuted &&
|
||||||
|
|||||||
@@ -1751,18 +1751,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
BridgeClient dco_decode_bridge_client(dynamic raw) {
|
BridgeClient dco_decode_bridge_client(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
final arr = raw as List<dynamic>;
|
final arr = raw as List<dynamic>;
|
||||||
if (arr.length != 9)
|
if (arr.length != 10)
|
||||||
throw Exception('unexpected arr length: expect 9 but see ${arr.length}');
|
throw Exception('unexpected arr length: expect 10 but see ${arr.length}');
|
||||||
return BridgeClient(
|
return BridgeClient(
|
||||||
id: dco_decode_u_64(arr[0]),
|
id: dco_decode_u_64(arr[0]),
|
||||||
channel: dco_decode_u_64(arr[1]),
|
uid: dco_decode_String(arr[1]),
|
||||||
name: dco_decode_String(arr[2]),
|
channel: dco_decode_u_64(arr[2]),
|
||||||
inputMuted: dco_decode_bool(arr[3]),
|
name: dco_decode_String(arr[3]),
|
||||||
outputMuted: dco_decode_bool(arr[4]),
|
inputMuted: dco_decode_bool(arr[4]),
|
||||||
isSpeaking: dco_decode_bool(arr[5]),
|
outputMuted: dco_decode_bool(arr[5]),
|
||||||
isServerQuery: dco_decode_bool(arr[6]),
|
isSpeaking: dco_decode_bool(arr[6]),
|
||||||
talkPower: dco_decode_i_32(arr[7]),
|
isServerQuery: dco_decode_bool(arr[7]),
|
||||||
talkPowerGranted: dco_decode_bool(arr[8]),
|
talkPower: dco_decode_i_32(arr[8]),
|
||||||
|
talkPowerGranted: dco_decode_bool(arr[9]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2352,6 +2353,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer) {
|
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer) {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
var var_id = sse_decode_u_64(deserializer);
|
var var_id = sse_decode_u_64(deserializer);
|
||||||
|
var var_uid = sse_decode_String(deserializer);
|
||||||
var var_channel = sse_decode_u_64(deserializer);
|
var var_channel = sse_decode_u_64(deserializer);
|
||||||
var var_name = sse_decode_String(deserializer);
|
var var_name = sse_decode_String(deserializer);
|
||||||
var var_inputMuted = sse_decode_bool(deserializer);
|
var var_inputMuted = sse_decode_bool(deserializer);
|
||||||
@@ -2362,6 +2364,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
var var_talkPowerGranted = sse_decode_bool(deserializer);
|
var var_talkPowerGranted = sse_decode_bool(deserializer);
|
||||||
return BridgeClient(
|
return BridgeClient(
|
||||||
id: var_id,
|
id: var_id,
|
||||||
|
uid: var_uid,
|
||||||
channel: var_channel,
|
channel: var_channel,
|
||||||
name: var_name,
|
name: var_name,
|
||||||
inputMuted: var_inputMuted,
|
inputMuted: var_inputMuted,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
@@ -6,10 +8,17 @@ import '../services/channel_spacer.dart';
|
|||||||
import '../services/link_trust_service.dart';
|
import '../services/link_trust_service.dart';
|
||||||
import '../services/snapshot_state_mapper.dart';
|
import '../services/snapshot_state_mapper.dart';
|
||||||
import '../services/ts3_server_link.dart';
|
import '../services/ts3_server_link.dart';
|
||||||
|
import '../services/ui_preferences_service.dart';
|
||||||
import '../src/rust/api.dart' as rust;
|
import '../src/rust/api.dart' as rust;
|
||||||
import 'bbcode_text.dart';
|
import 'bbcode_text.dart';
|
||||||
import 'talk_power_warning.dart';
|
import 'talk_power_warning.dart';
|
||||||
|
|
||||||
|
typedef ClientPlaybackPreferenceChanged =
|
||||||
|
Future<void> Function(
|
||||||
|
rust.BridgeClient client,
|
||||||
|
ClientPlaybackPreference preference,
|
||||||
|
);
|
||||||
|
|
||||||
/// Connected-server snapshot with welcome text, channels, and clients.
|
/// Connected-server snapshot with welcome text, channels, and clients.
|
||||||
class SnapshotView extends StatefulWidget {
|
class SnapshotView extends StatefulWidget {
|
||||||
/// Construct a snapshot view.
|
/// Construct a snapshot view.
|
||||||
@@ -25,6 +34,8 @@ class SnapshotView extends StatefulWidget {
|
|||||||
required this.canJoinVoiceChannel,
|
required this.canJoinVoiceChannel,
|
||||||
required this.onJoinChannel,
|
required this.onJoinChannel,
|
||||||
required this.onJoinChannelWithPassword,
|
required this.onJoinChannelWithPassword,
|
||||||
|
this.clientPlaybackPreferences = const {},
|
||||||
|
this.onClientPlaybackPreferenceChanged,
|
||||||
this.onTs3ServerLink,
|
this.onTs3ServerLink,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,6 +69,12 @@ class SnapshotView extends StatefulWidget {
|
|||||||
/// Join a password-protected channel.
|
/// Join a password-protected channel.
|
||||||
final ValueChanged<rust.BridgeChannel> onJoinChannelWithPassword;
|
final ValueChanged<rust.BridgeChannel> onJoinChannelWithPassword;
|
||||||
|
|
||||||
|
/// Persisted per-client playback preferences keyed by TeamSpeak UID.
|
||||||
|
final Map<String, ClientPlaybackPreference> clientPlaybackPreferences;
|
||||||
|
|
||||||
|
/// Apply an updated per-client playback preference.
|
||||||
|
final ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged;
|
||||||
|
|
||||||
/// Handle TeamSpeak server links embedded in server-provided text.
|
/// Handle TeamSpeak server links embedded in server-provided text.
|
||||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||||
|
|
||||||
@@ -293,21 +310,41 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
final canAdjustPlayback = _canAdjustClientPlayback(client);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
left: channelIndent + _userRowStartIndent,
|
left: channelIndent + _userRowStartIndent,
|
||||||
right: 8,
|
right: 8,
|
||||||
),
|
),
|
||||||
child: AnimatedContainer(
|
child: GestureDetector(
|
||||||
duration: const Duration(milliseconds: 120),
|
behavior: HitTestBehavior.opaque,
|
||||||
curve: Curves.easeOut,
|
onLongPress: canAdjustPlayback
|
||||||
decoration: decoration,
|
? () =>
|
||||||
child: ListTile(
|
unawaited(_showClientPlaybackSheet(client, withHaptic: true))
|
||||||
dense: true,
|
: null,
|
||||||
visualDensity: VisualDensity.compact,
|
onSecondaryTap: canAdjustPlayback
|
||||||
leading: status.icon,
|
? () =>
|
||||||
title: Text(client.name, style: nameStyle),
|
unawaited(_showClientPlaybackSheet(client, withHaptic: true))
|
||||||
|
: null,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
decoration: decoration,
|
||||||
|
child: ListTile(
|
||||||
|
dense: true,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
leading: status.icon,
|
||||||
|
title: Text(client.name, style: nameStyle),
|
||||||
|
trailing: canAdjustPlayback
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.more_horiz),
|
||||||
|
tooltip: 'Playback options',
|
||||||
|
onPressed: () =>
|
||||||
|
unawaited(_showClientPlaybackSheet(client)),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -352,6 +389,100 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _canAdjustClientPlayback(rust.BridgeClient client) {
|
||||||
|
return widget.onClientPlaybackPreferenceChanged != null &&
|
||||||
|
client.id != widget.snapshot.ownClientId &&
|
||||||
|
!client.isServerQuery &&
|
||||||
|
client.uid.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientPlaybackPreference _clientPlaybackPreference(rust.BridgeClient client) {
|
||||||
|
return widget.clientPlaybackPreferences[client.uid] ??
|
||||||
|
const ClientPlaybackPreference();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showClientPlaybackSheet(
|
||||||
|
rust.BridgeClient client, {
|
||||||
|
bool withHaptic = false,
|
||||||
|
}) async {
|
||||||
|
if (!_canAdjustClientPlayback(client)) return;
|
||||||
|
|
||||||
|
final onChanged = widget.onClientPlaybackPreferenceChanged;
|
||||||
|
if (onChanged == null) return;
|
||||||
|
|
||||||
|
if (withHaptic) {
|
||||||
|
unawaited(HapticFeedback.selectionClick().catchError((_) {}));
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
var preference = _clientPlaybackPreference(client);
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setModalState) {
|
||||||
|
void updatePreference(ClientPlaybackPreference next) {
|
||||||
|
setModalState(() => preference = next);
|
||||||
|
unawaited(onChanged(client, next));
|
||||||
|
}
|
||||||
|
|
||||||
|
final volumePercent = (preference.volume * 100).round();
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
client.name,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Per-user playback',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
key: const Key('client-playback-mute-tile'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Mute playback'),
|
||||||
|
subtitle: Text(
|
||||||
|
preference.muted
|
||||||
|
? 'Audio from this user is muted on this server.'
|
||||||
|
: 'Audio from this user plays at $volumePercent%.',
|
||||||
|
),
|
||||||
|
value: preference.muted,
|
||||||
|
onChanged: (muted) =>
|
||||||
|
updatePreference(preference.copyWith(muted: muted)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Volume $volumePercent%',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
key: const Key('client-playback-volume-slider'),
|
||||||
|
min: 0.0,
|
||||||
|
max: 4.0,
|
||||||
|
divisions: 40,
|
||||||
|
label: '$volumePercent%',
|
||||||
|
value: preference.volume,
|
||||||
|
onChanged: (value) =>
|
||||||
|
updatePreference(preference.copyWith(volume: value)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
|
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
|
||||||
ThemeData theme,
|
ThemeData theme,
|
||||||
rust.BridgeClient client,
|
rust.BridgeClient client,
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ set(BINARY_NAME "chanora_flutter")
|
|||||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||||
set(APPLICATION_ID "app.chanora.chanora_flutter")
|
set(APPLICATION_ID "app.chanora.chanora_flutter")
|
||||||
|
|
||||||
|
# Repository root for Rust bridge builds / bundle staging.
|
||||||
|
set(CHANORA_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../..")
|
||||||
|
|
||||||
|
# Optional path to a Linux ONNX Runtime shared library for Silero VAD.
|
||||||
|
# When set, the bundle carries a local `lib/libonnxruntime.so` sidecar and the
|
||||||
|
# runner exports `ORT_DYLIB_PATH` to that file before Flutter starts.
|
||||||
|
set(CHANORA_ONNXRUNTIME_SHARED_LIB "$ENV{CHANORA_ONNXRUNTIME_SHARED_LIB}" CACHE FILEPATH
|
||||||
|
"Absolute path to libonnxruntime.so for Linux bundle packaging")
|
||||||
|
|
||||||
|
# Optional path to a prebuilt Rust bridge shared library. When unset, the Linux
|
||||||
|
# bundle build runs `cargo build -p chanora_bridge` for the host profile and
|
||||||
|
# stages the resulting `libchanora_bridge.so` automatically.
|
||||||
|
set(CHANORA_BRIDGE_SHARED_LIB "$ENV{CHANORA_BRIDGE_SHARED_LIB}" CACHE FILEPATH
|
||||||
|
"Absolute path to libchanora_bridge.so for Linux bundle packaging")
|
||||||
|
|
||||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||||
# versions of CMake.
|
# versions of CMake.
|
||||||
cmake_policy(SET CMP0063 NEW)
|
cmake_policy(SET CMP0063 NEW)
|
||||||
@@ -53,6 +68,7 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
|
|||||||
# System-level dependencies.
|
# System-level dependencies.
|
||||||
find_package(PkgConfig REQUIRED)
|
find_package(PkgConfig REQUIRED)
|
||||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||||
|
find_program(CARGO_EXECUTABLE cargo REQUIRED)
|
||||||
|
|
||||||
# Application build; see runner/CMakeLists.txt.
|
# Application build; see runner/CMakeLists.txt.
|
||||||
add_subdirectory("runner")
|
add_subdirectory("runner")
|
||||||
@@ -60,6 +76,27 @@ add_subdirectory("runner")
|
|||||||
# Run the Flutter tool portions of the build. This must not be removed.
|
# Run the Flutter tool portions of the build. This must not be removed.
|
||||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||||
|
|
||||||
|
if(CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||||
|
set(CHANORA_BRIDGE_PROFILE_DIR "debug")
|
||||||
|
set(CHANORA_BRIDGE_CARGO_ARGS build --package chanora_bridge)
|
||||||
|
else()
|
||||||
|
set(CHANORA_BRIDGE_PROFILE_DIR "release")
|
||||||
|
set(CHANORA_BRIDGE_CARGO_ARGS build --release --package chanora_bridge)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(CHANORA_BRIDGE_SHARED_LIB)
|
||||||
|
set(CHANORA_BRIDGE_STAGED_LIB "${CHANORA_BRIDGE_SHARED_LIB}")
|
||||||
|
else()
|
||||||
|
set(CHANORA_BRIDGE_STAGED_LIB
|
||||||
|
"${CHANORA_REPO_ROOT}/target/${CHANORA_BRIDGE_PROFILE_DIR}/libchanora_bridge.so")
|
||||||
|
add_custom_target(chanora_bridge_bundle
|
||||||
|
COMMAND "${CARGO_EXECUTABLE}" ${CHANORA_BRIDGE_CARGO_ARGS}
|
||||||
|
WORKING_DIRECTORY "${CHANORA_REPO_ROOT}"
|
||||||
|
COMMENT "Building chanora_bridge for Linux bundle"
|
||||||
|
VERBATIM)
|
||||||
|
add_dependencies(${BINARY_NAME} chanora_bridge_bundle)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Only the install-generated bundle's copy of the executable will launch
|
# Only the install-generated bundle's copy of the executable will launch
|
||||||
# correctly, since the resources must in the right relative locations. To avoid
|
# correctly, since the resources must in the right relative locations. To avoid
|
||||||
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
||||||
@@ -100,6 +137,29 @@ install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}
|
|||||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
COMPONENT Runtime)
|
COMPONENT Runtime)
|
||||||
|
|
||||||
|
if(EXISTS "${CHANORA_BRIDGE_STAGED_LIB}")
|
||||||
|
install(FILES "${CHANORA_BRIDGE_STAGED_LIB}"
|
||||||
|
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
else()
|
||||||
|
message(WARNING
|
||||||
|
"Chanora bridge shared library was not found for Linux bundling: "
|
||||||
|
"${CHANORA_BRIDGE_STAGED_LIB}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(CHANORA_ONNXRUNTIME_SHARED_LIB)
|
||||||
|
if(EXISTS "${CHANORA_ONNXRUNTIME_SHARED_LIB}")
|
||||||
|
install(FILES "${CHANORA_ONNXRUNTIME_SHARED_LIB}"
|
||||||
|
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
|
RENAME "libonnxruntime.so"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
else()
|
||||||
|
message(WARNING
|
||||||
|
"CHANORA_ONNXRUNTIME_SHARED_LIB was set but the file was not found: "
|
||||||
|
"${CHANORA_ONNXRUNTIME_SHARED_LIB}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
||||||
install(FILES "${bundled_library}"
|
install(FILES "${bundled_library}"
|
||||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
|
|||||||
@@ -14,6 +14,27 @@ struct _MyApplication {
|
|||||||
|
|
||||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||||
|
|
||||||
|
static void configure_onnxruntime_dylib_path() {
|
||||||
|
const gchar* existing = g_getenv("ORT_DYLIB_PATH");
|
||||||
|
if (existing != nullptr && *existing != '\0') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_autofree gchar* exe_path = g_file_read_link("/proc/self/exe", nullptr);
|
||||||
|
if (exe_path == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_autofree gchar* exe_dir = g_path_get_dirname(exe_path);
|
||||||
|
g_autofree gchar* ort_path =
|
||||||
|
g_build_filename(exe_dir, "lib", "libonnxruntime.so", nullptr);
|
||||||
|
if (!g_file_test(ort_path, G_FILE_TEST_IS_REGULAR)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_setenv("ORT_DYLIB_PATH", ort_path, TRUE);
|
||||||
|
}
|
||||||
|
|
||||||
// Called when first Flutter frame received.
|
// Called when first Flutter frame received.
|
||||||
static void first_frame_cb(MyApplication* self, FlView* view) {
|
static void first_frame_cb(MyApplication* self, FlView* view) {
|
||||||
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
||||||
@@ -110,6 +131,7 @@ static void my_application_startup(GApplication* application) {
|
|||||||
// MyApplication* self = MY_APPLICATION(object);
|
// MyApplication* self = MY_APPLICATION(object);
|
||||||
|
|
||||||
// Perform any actions required at application startup.
|
// Perform any actions required at application startup.
|
||||||
|
configure_onnxruntime_dylib_path();
|
||||||
|
|
||||||
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
|
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ void main() {
|
|||||||
rust.BridgeClient client({
|
rust.BridgeClient client({
|
||||||
BigInt? id,
|
BigInt? id,
|
||||||
BigInt? channelId,
|
BigInt? channelId,
|
||||||
|
String uid = '',
|
||||||
int talkPower = 0,
|
int talkPower = 0,
|
||||||
bool talkPowerGranted = false,
|
bool talkPowerGranted = false,
|
||||||
bool inputMuted = false,
|
bool inputMuted = false,
|
||||||
@@ -25,6 +26,7 @@ void main() {
|
|||||||
}) {
|
}) {
|
||||||
return rust.BridgeClient(
|
return rust.BridgeClient(
|
||||||
id: id ?? BigInt.from(7),
|
id: id ?? BigInt.from(7),
|
||||||
|
uid: uid,
|
||||||
channel: channelId ?? BigInt.one,
|
channel: channelId ?? BigInt.one,
|
||||||
name: 'Me',
|
name: 'Me',
|
||||||
inputMuted: inputMuted,
|
inputMuted: inputMuted,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ void main() {
|
|||||||
expect(result.issues, isEmpty);
|
expect(result.issues, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('missing SDL2 is reported as required on Debian-like systems', () async {
|
test('missing Linux audio runtimes are reported as recommended', () async {
|
||||||
debugResetStartupDependencyCheck(
|
debugResetStartupDependencyCheck(
|
||||||
platformIsLinux: () => true,
|
platformIsLinux: () => true,
|
||||||
libraryProbe: (candidate) => false,
|
libraryProbe: (candidate) => false,
|
||||||
@@ -31,11 +31,19 @@ void main() {
|
|||||||
final result = await checkStartupDependencies();
|
final result = await checkStartupDependencies();
|
||||||
|
|
||||||
expect(result.hasIssues, isTrue);
|
expect(result.hasIssues, isTrue);
|
||||||
final sdl = result.issues.firstWhere(
|
final pipewire = result.issues.firstWhere(
|
||||||
(issue) => issue.id == 'linux-sdl2-runtime',
|
(issue) => issue.id == 'linux-pipewire-runtime',
|
||||||
);
|
);
|
||||||
expect(sdl.isRequired, isTrue);
|
final pulse = result.issues.firstWhere(
|
||||||
expect(sdl.installHints.single.command, 'sudo apt install libsdl2-2.0-0');
|
(issue) => issue.id == 'linux-pulseaudio-runtime',
|
||||||
|
);
|
||||||
|
expect(pipewire.isRequired, isFalse);
|
||||||
|
expect(pulse.isRequired, isFalse);
|
||||||
|
expect(
|
||||||
|
pipewire.installHints.single.command,
|
||||||
|
'sudo apt install libpipewire-0.3-0',
|
||||||
|
);
|
||||||
|
expect(pulse.installHints.single.command, 'sudo apt install libpulse0');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('distro detection parses quoted ID_LIKE lists', () async {
|
test('distro detection parses quoted ID_LIKE lists', () async {
|
||||||
@@ -51,18 +59,22 @@ void main() {
|
|||||||
final result = await checkStartupDependencies();
|
final result = await checkStartupDependencies();
|
||||||
|
|
||||||
expect(result.platformLabel, 'Fedora');
|
expect(result.platformLabel, 'Fedora');
|
||||||
final sdl = result.issues.singleWhere(
|
final pipewire = result.issues.singleWhere(
|
||||||
(issue) => issue.id == 'linux-sdl2-runtime',
|
(issue) => issue.id == 'linux-pipewire-runtime',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
pipewire.installHints.single.command,
|
||||||
|
'sudo dnf install pipewire-libs',
|
||||||
);
|
);
|
||||||
expect(sdl.installHints.single.command, 'sudo dnf install SDL2');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'missing ONNX runtime is reported as recommended when SDL2 is present',
|
'missing ONNX runtime is reported as recommended when Linux audio runtimes are present',
|
||||||
() async {
|
() async {
|
||||||
debugResetStartupDependencyCheck(
|
debugResetStartupDependencyCheck(
|
||||||
platformIsLinux: () => true,
|
platformIsLinux: () => true,
|
||||||
libraryProbe: (candidate) => candidate.contains('SDL2'),
|
libraryProbe: (candidate) =>
|
||||||
|
candidate.contains('pipewire') || candidate.contains('pulse'),
|
||||||
fileExists: (_) async => false,
|
fileExists: (_) async => false,
|
||||||
osReleaseProvider: () async => 'ID=fedora\n',
|
osReleaseProvider: () async => 'ID=fedora\n',
|
||||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||||
@@ -106,7 +118,8 @@ void main() {
|
|||||||
test('bundle-local ONNX runtime clears the recommendation', () async {
|
test('bundle-local ONNX runtime clears the recommendation', () async {
|
||||||
debugResetStartupDependencyCheck(
|
debugResetStartupDependencyCheck(
|
||||||
platformIsLinux: () => true,
|
platformIsLinux: () => true,
|
||||||
libraryProbe: (candidate) => candidate.contains('SDL2'),
|
libraryProbe: (candidate) =>
|
||||||
|
candidate.contains('pipewire') || candidate.contains('pulse'),
|
||||||
fileExists: (path) async => path == '/opt/chanora/lib/libonnxruntime.so',
|
fileExists: (path) async => path == '/opt/chanora/lib/libonnxruntime.so',
|
||||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||||
currentDirectoryProvider: () => '/tmp',
|
currentDirectoryProvider: () => '/tmp',
|
||||||
@@ -130,11 +143,11 @@ void main() {
|
|||||||
platformLabel: 'Fedora',
|
platformLabel: 'Fedora',
|
||||||
issues: [
|
issues: [
|
||||||
StartupDependencyIssue(
|
StartupDependencyIssue(
|
||||||
id: 'linux-sdl2-runtime',
|
id: 'linux-pipewire-runtime',
|
||||||
title: 'SDL2 runtime is missing',
|
title: 'PipeWire runtime is missing',
|
||||||
summary: 'Audio playback needs SDL2.',
|
summary: 'Primary Linux audio needs PipeWire.',
|
||||||
details: ['Install SDL2 and recheck.'],
|
details: ['Install PipeWire and recheck.'],
|
||||||
severity: StartupDependencySeverity.required,
|
severity: StartupDependencySeverity.recommended,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -146,7 +159,9 @@ void main() {
|
|||||||
expect(text, contains('platform="Fedora"'));
|
expect(text, contains('platform="Fedora"'));
|
||||||
expect(
|
expect(
|
||||||
text,
|
text,
|
||||||
contains('issues=[linux-sdl2-runtime:required:SDL2 runtime is missing]'),
|
contains(
|
||||||
|
'issues=[linux-pipewire-runtime:recommended:PipeWire runtime is missing]',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,79 @@ void main() {
|
|||||||
|
|
||||||
expect(await service.hasExplainedPermissions(), isTrue);
|
expect(await service.hasExplainedPermissions(), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stores per-user playback preferences per server host', () async {
|
||||||
|
await service.saveClientPlaybackPreference(
|
||||||
|
serverHost: 'Example.COM',
|
||||||
|
userUid: 'user-a',
|
||||||
|
volume: 0.5,
|
||||||
|
muted: true,
|
||||||
|
);
|
||||||
|
await service.saveClientPlaybackPreference(
|
||||||
|
serverHost: 'other.example',
|
||||||
|
userUid: 'user-a',
|
||||||
|
volume: 2.0,
|
||||||
|
muted: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final examplePrefs = await service.loadClientPlaybackPreferencesForServer(
|
||||||
|
'example.com',
|
||||||
|
);
|
||||||
|
final otherPrefs = await service.loadClientPlaybackPreferencesForServer(
|
||||||
|
'other.example',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(examplePrefs['user-a']?.volume, 0.5);
|
||||||
|
expect(examplePrefs['user-a']?.muted, isTrue);
|
||||||
|
expect(otherPrefs['user-a']?.volume, 2.0);
|
||||||
|
expect(otherPrefs['user-a']?.muted, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'drops default playback preferences instead of persisting them',
|
||||||
|
() async {
|
||||||
|
await service.saveClientPlaybackPreference(
|
||||||
|
serverHost: 'example.com',
|
||||||
|
userUid: 'user-a',
|
||||||
|
volume: 0.2,
|
||||||
|
muted: true,
|
||||||
|
);
|
||||||
|
await service.saveClientPlaybackPreference(
|
||||||
|
serverHost: 'example.com',
|
||||||
|
userUid: 'user-a',
|
||||||
|
volume: 1.0,
|
||||||
|
muted: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final prefs = await service.loadClientPlaybackPreferencesForServer(
|
||||||
|
'example.com',
|
||||||
|
);
|
||||||
|
expect(prefs, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('ignores corrupt playback preference JSON', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'audio.client_playback_prefs': '{not-json',
|
||||||
|
});
|
||||||
|
|
||||||
|
final prefs = await service.loadClientPlaybackPreferencesForServer(
|
||||||
|
'example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prefs, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps invalid playback volumes to unity', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'audio.client_playback_prefs':
|
||||||
|
'{"example.com|user-a":{"volume":"invalid","muted":false}}',
|
||||||
|
});
|
||||||
|
|
||||||
|
final prefs = await service.loadClientPlaybackPreferencesForServer(
|
||||||
|
'example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prefs['user-a']?.volume, 1.0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ void main() {
|
|||||||
required BigInt id,
|
required BigInt id,
|
||||||
required String name,
|
required String name,
|
||||||
required BigInt channelId,
|
required BigInt channelId,
|
||||||
|
String uid = '',
|
||||||
bool isServerQuery = false,
|
bool isServerQuery = false,
|
||||||
}) {
|
}) {
|
||||||
return rust.BridgeClient(
|
return rust.BridgeClient(
|
||||||
id: id,
|
id: id,
|
||||||
|
uid: uid,
|
||||||
channel: channelId,
|
channel: channelId,
|
||||||
name: name,
|
name: name,
|
||||||
inputMuted: false,
|
inputMuted: false,
|
||||||
@@ -496,7 +498,15 @@ void main() {
|
|||||||
final privateTop = tester.getTopLeft(find.text('Alpha').first).dy;
|
final privateTop = tester.getTopLeft(find.text('Alpha').first).dy;
|
||||||
final serverTileSize = tester.getSize(
|
final serverTileSize = tester.getSize(
|
||||||
find
|
find
|
||||||
.ancestor(of: find.text('Server').first, matching: find.byType(Ink))
|
.ancestor(
|
||||||
|
of: find.byIcon(Icons.dns_outlined),
|
||||||
|
matching: find.byWidgetPredicate(
|
||||||
|
(widget) =>
|
||||||
|
widget is SizedBox &&
|
||||||
|
widget.width == 92 &&
|
||||||
|
widget.height == 92,
|
||||||
|
),
|
||||||
|
)
|
||||||
.first,
|
.first,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -506,6 +516,95 @@ void main() {
|
|||||||
expect(serverTileSize.height, 92);
|
expect(serverTileSize.height, 92);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('chat sidebar uses MD3-style selected container colors', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final theme = ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: theme,
|
||||||
|
home: ChatPage(
|
||||||
|
messages: const [],
|
||||||
|
snapshot: snapshot(channels: const [], clients: const []),
|
||||||
|
initialTarget: const rust.BridgeMessageTarget.server(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final serverIndicator = find.ancestor(
|
||||||
|
of: find.byIcon(Icons.dns_outlined),
|
||||||
|
matching: find.byType(AnimatedContainer),
|
||||||
|
);
|
||||||
|
final channelIndicator = find.ancestor(
|
||||||
|
of: find.byIcon(Icons.tag),
|
||||||
|
matching: find.byType(AnimatedContainer),
|
||||||
|
);
|
||||||
|
|
||||||
|
final serverDecoration =
|
||||||
|
tester.widget<AnimatedContainer>(serverIndicator).decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
final channelDecoration =
|
||||||
|
tester.widget<AnimatedContainer>(channelIndicator).decoration
|
||||||
|
as BoxDecoration;
|
||||||
|
final serverLabel = tester.widget<Text>(find.text('Server').last);
|
||||||
|
final channelLabel = tester.widget<Text>(find.text('Channel'));
|
||||||
|
|
||||||
|
expect(tester.getSize(serverIndicator), const Size(76, 76));
|
||||||
|
expect(tester.getSize(channelIndicator), const Size(76, 76));
|
||||||
|
expect(serverDecoration.color, theme.colorScheme.primaryContainer);
|
||||||
|
expect(channelDecoration.color, Colors.transparent);
|
||||||
|
expect(serverLabel.style?.color, theme.colorScheme.onPrimaryContainer);
|
||||||
|
expect(channelLabel.style?.color, theme.colorScheme.onSurfaceVariant);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('long private chat labels stay within fixed rail indicator', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
const longName = 'Very Long Private Chat Name That Should Not Stretch';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: ChatPage(
|
||||||
|
messages: [
|
||||||
|
ChatEntry(
|
||||||
|
senderId: BigInt.from(2),
|
||||||
|
senderName: longName,
|
||||||
|
message: 'Private',
|
||||||
|
target: rust.BridgeMessageTarget.client(BigInt.from(2)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
snapshot: snapshot(
|
||||||
|
channels: const [],
|
||||||
|
clients: [
|
||||||
|
client(
|
||||||
|
id: BigInt.from(2),
|
||||||
|
name: longName,
|
||||||
|
channelId: BigInt.zero,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
initialTarget: rust.BridgeMessageTarget.client(BigInt.from(2)),
|
||||||
|
initialClientName: longName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final selectedIndicator = find.ancestor(
|
||||||
|
of: find.text(longName).first,
|
||||||
|
matching: find.byType(AnimatedContainer),
|
||||||
|
);
|
||||||
|
final privateLabel = tester.widget<Text>(find.text(longName).first);
|
||||||
|
|
||||||
|
expect(tester.getSize(selectedIndicator), const Size(76, 76));
|
||||||
|
expect(privateLabel.maxLines, 2);
|
||||||
|
expect(privateLabel.overflow, TextOverflow.ellipsis);
|
||||||
|
expect(privateLabel.textAlign, TextAlign.center);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'plus opens user picker and close removes selected private chat',
|
'plus opens user picker and close removes selected private chat',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:chanora_flutter/services/ui_preferences_service.dart';
|
||||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
import 'package:chanora_flutter/widgets/snapshot_view.dart';
|
import 'package:chanora_flutter/widgets/snapshot_view.dart';
|
||||||
|
|
||||||
@@ -28,12 +30,14 @@ void main() {
|
|||||||
required int id,
|
required int id,
|
||||||
required int channelId,
|
required int channelId,
|
||||||
required String name,
|
required String name,
|
||||||
|
String uid = '',
|
||||||
bool speaking = false,
|
bool speaking = false,
|
||||||
int talkPower = 0,
|
int talkPower = 0,
|
||||||
bool talkPowerGranted = false,
|
bool talkPowerGranted = false,
|
||||||
}) {
|
}) {
|
||||||
return rust.BridgeClient(
|
return rust.BridgeClient(
|
||||||
id: BigInt.from(id),
|
id: BigInt.from(id),
|
||||||
|
uid: uid,
|
||||||
channel: BigInt.from(channelId),
|
channel: BigInt.from(channelId),
|
||||||
name: name,
|
name: name,
|
||||||
inputMuted: false,
|
inputMuted: false,
|
||||||
@@ -51,6 +55,8 @@ void main() {
|
|||||||
BigInt? ownClientId,
|
BigInt? ownClientId,
|
||||||
BigInt? currentVoiceChannelId,
|
BigInt? currentVoiceChannelId,
|
||||||
rust.BridgeAudioStats? audioStats,
|
rust.BridgeAudioStats? audioStats,
|
||||||
|
Map<String, ClientPlaybackPreference> clientPlaybackPreferences = const {},
|
||||||
|
ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged,
|
||||||
}) {
|
}) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
@@ -75,6 +81,8 @@ void main() {
|
|||||||
canJoinVoiceChannel: true,
|
canJoinVoiceChannel: true,
|
||||||
onJoinChannel: (_) {},
|
onJoinChannel: (_) {},
|
||||||
onJoinChannelWithPassword: (_) {},
|
onJoinChannelWithPassword: (_) {},
|
||||||
|
clientPlaybackPreferences: clientPlaybackPreferences,
|
||||||
|
onClientPlaybackPreferenceChanged: onClientPlaybackPreferenceChanged,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -262,6 +270,122 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets('remote client playback options button opens playback sheet', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
snapshotHarness(
|
||||||
|
channels: [channel(id: 1, name: 'Default Channel')],
|
||||||
|
clients: [
|
||||||
|
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||||
|
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||||
|
],
|
||||||
|
ownClientId: BigInt.from(100),
|
||||||
|
clientPlaybackPreferences: const {
|
||||||
|
'user-b': ClientPlaybackPreference(volume: 0.5, muted: false),
|
||||||
|
},
|
||||||
|
onClientPlaybackPreferenceChanged: (_, _) async {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byIcon(Icons.more_horiz));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Per-user playback'), findsOneWidget);
|
||||||
|
expect(find.text('Mute playback'), findsOneWidget);
|
||||||
|
expect(find.text('Volume 50%'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('client-playback-volume-slider')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('remote client long press opens playback sheet', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
snapshotHarness(
|
||||||
|
channels: [channel(id: 1, name: 'Default Channel')],
|
||||||
|
clients: [
|
||||||
|
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||||
|
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||||
|
],
|
||||||
|
ownClientId: BigInt.from(100),
|
||||||
|
onClientPlaybackPreferenceChanged: (_, _) async {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Per-user playback'), findsOneWidget);
|
||||||
|
expect(find.text('Mute playback'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('remote client secondary click opens playback sheet', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
snapshotHarness(
|
||||||
|
channels: [channel(id: 1, name: 'Default Channel')],
|
||||||
|
clients: [
|
||||||
|
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||||
|
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||||
|
],
|
||||||
|
ownClientId: BigInt.from(100),
|
||||||
|
onClientPlaybackPreferenceChanged: (_, _) async {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(
|
||||||
|
find.widgetWithText(ListTile, 'Bob'),
|
||||||
|
buttons: kSecondaryButton,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Per-user playback'), findsOneWidget);
|
||||||
|
expect(find.text('Mute playback'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('client playback sheet forwards mute and volume changes', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final changes = <ClientPlaybackPreference>[];
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
snapshotHarness(
|
||||||
|
channels: [channel(id: 1, name: 'Default Channel')],
|
||||||
|
clients: [
|
||||||
|
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||||
|
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||||
|
],
|
||||||
|
ownClientId: BigInt.from(100),
|
||||||
|
onClientPlaybackPreferenceChanged: (_, preference) async {
|
||||||
|
changes.add(preference);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byIcon(Icons.more_horiz));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Mute playback'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(changes, isNotEmpty);
|
||||||
|
expect(changes.last.muted, isTrue);
|
||||||
|
|
||||||
|
await tester.drag(
|
||||||
|
find.byKey(const Key('client-playback-volume-slider')),
|
||||||
|
const Offset(160, 0),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(changes.last.volume, greaterThan(1.0));
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('spacer channels render as layout rows and keep channel taps', (
|
testWidgets('spacer channels render as layout rows and keep channel taps', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ void main() {
|
|||||||
platformLabel: 'Fedora',
|
platformLabel: 'Fedora',
|
||||||
issues: const [
|
issues: const [
|
||||||
StartupDependencyIssue(
|
StartupDependencyIssue(
|
||||||
id: 'linux-sdl2-runtime',
|
id: 'linux-pipewire-runtime',
|
||||||
title: 'SDL2 runtime is missing',
|
title: 'PipeWire runtime is missing',
|
||||||
summary: 'Audio playback needs SDL2.',
|
summary: 'Primary Linux audio needs PipeWire.',
|
||||||
details: ['Install SDL2 and recheck.'],
|
details: ['Install PipeWire and recheck.'],
|
||||||
severity: StartupDependencySeverity.required,
|
severity: StartupDependencySeverity.recommended,
|
||||||
installHints: [
|
installHints: [
|
||||||
StartupInstallHint(
|
StartupInstallHint(
|
||||||
label: 'Release downloads',
|
label: 'Release downloads',
|
||||||
@@ -29,7 +29,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
StartupInstallHint(
|
StartupInstallHint(
|
||||||
label: 'Fedora',
|
label: 'Fedora',
|
||||||
command: 'sudo dnf install SDL2',
|
command: 'sudo dnf install pipewire-libs',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -52,14 +52,14 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Finish Linux setup'), findsOneWidget);
|
expect(find.text('Finish Linux setup'), findsOneWidget);
|
||||||
expect(find.text('SDL2 runtime is missing'), findsOneWidget);
|
expect(find.text('PipeWire runtime is missing'), findsOneWidget);
|
||||||
expect(find.text('Continue with limited mode'), findsOneWidget);
|
expect(find.text('Continue anyway'), findsOneWidget);
|
||||||
expect(find.text('Open'), findsOneWidget);
|
expect(find.text('Open'), findsOneWidget);
|
||||||
expect(find.text('Copy'), findsOneWidget);
|
expect(find.text('Copy'), findsOneWidget);
|
||||||
expect(loggedResults, [result]);
|
expect(loggedResults, [result]);
|
||||||
|
|
||||||
await tester.ensureVisible(find.text('Continue with limited mode'));
|
await tester.ensureVisible(find.text('Continue anyway'));
|
||||||
await tester.tap(find.text('Continue with limited mode'));
|
await tester.tap(find.text('Continue anyway'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('ready'), findsOneWidget);
|
expect(find.text('ready'), findsOneWidget);
|
||||||
|
|||||||
@@ -2411,6 +2411,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
clients: vec![ClientInfo {
|
clients: vec![ClientInfo {
|
||||||
id: chanora_protocol::ClientId(10),
|
id: chanora_protocol::ClientId(10),
|
||||||
|
uid: "uid".into(),
|
||||||
channel: chanora_protocol::ChannelId(1),
|
channel: chanora_protocol::ChannelId(1),
|
||||||
name: "u".into(),
|
name: "u".into(),
|
||||||
input_muted: false,
|
input_muted: false,
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
bench-harness = ["dep:criterion", "dep:dhat", "dep:serde_json"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chanora_protocol = { path = "../chanora_protocol" }
|
chanora_protocol = { path = "../chanora_protocol" }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
@@ -29,10 +33,13 @@ audiopus = "0.3.0-rc.0"
|
|||||||
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
|
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
|
||||||
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
||||||
rustfft = "6.2.0"
|
rustfft = "6.2.0"
|
||||||
|
criterion = { version = "0.5", optional = true }
|
||||||
|
dhat = { version = "0.3", optional = true }
|
||||||
|
serde_json = { version = "1", optional = true }
|
||||||
|
|
||||||
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
|
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
|
||||||
# Desktop audio I/O for Windows capture/playback and Linux capture.
|
# Desktop audio I/O for Windows capture/playback and Linux fallback metadata.
|
||||||
# Linux playback uses SDL2; Apple platforms use direct VoiceProcessingIO
|
# Linux voice I/O uses PipeWire first and PulseAudio as fallback; Apple platforms use direct VoiceProcessingIO
|
||||||
# AudioUnits via `coreaudio-rs` for the voice path.
|
# AudioUnits via `coreaudio-rs` for the voice path.
|
||||||
cpal = "0.17.3"
|
cpal = "0.17.3"
|
||||||
|
|
||||||
@@ -101,34 +108,33 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time", "test-util"
|
|||||||
# Cross-platform recording Layer for the SDD-090 / DEC-027 privacy
|
# Cross-platform recording Layer for the SDD-090 / DEC-027 privacy
|
||||||
# invariant integration test (`tests/ptt_privacy.rs`).
|
# invariant integration test (`tests/ptt_privacy.rs`).
|
||||||
tracing-subscriber = { version = "0.3", features = ["registry"] }
|
tracing-subscriber = { version = "0.3", features = ["registry"] }
|
||||||
# SDD-120 §3 — criterion bench harness (realtime_capture / opus_codec /
|
|
||||||
# resampler). `harness = false` per bench entry below disables the
|
|
||||||
# default libtest harness so criterion can install its own.
|
|
||||||
criterion = "0.5"
|
|
||||||
# SDD-120 §3 item 1 — dhat is used as the global allocator inside
|
|
||||||
# `benches/realtime_capture.rs` to count post-warmup heap allocations
|
|
||||||
# on the realtime capture path. Dev-dep only — does NOT affect
|
|
||||||
# production builds.
|
|
||||||
dhat = "0.3"
|
|
||||||
# SDD-120 §5 / §8 — JSON serialization for `emit_baseline` /
|
|
||||||
# `compare_baseline` binaries that consume criterion's per-bench
|
|
||||||
# `estimates.json` outputs and emit the SRS-217 baseline schema.
|
|
||||||
serde_json = "1"
|
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "realtime_capture"
|
name = "realtime_capture"
|
||||||
harness = false
|
harness = false
|
||||||
path = "benches/realtime_capture.rs"
|
path = "benches/realtime_capture.rs"
|
||||||
|
required-features = ["bench-harness"]
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "opus_codec"
|
name = "opus_codec"
|
||||||
harness = false
|
harness = false
|
||||||
path = "benches/opus_codec.rs"
|
path = "benches/opus_codec.rs"
|
||||||
|
required-features = ["bench-harness"]
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "resampler"
|
name = "resampler"
|
||||||
harness = false
|
harness = false
|
||||||
path = "benches/resampler.rs"
|
path = "benches/resampler.rs"
|
||||||
|
required-features = ["bench-harness"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "compare_baseline"
|
||||||
|
path = "examples/compare_baseline.rs"
|
||||||
|
required-features = ["bench-harness"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "emit_baseline"
|
||||||
|
path = "examples/emit_baseline.rs"
|
||||||
|
required-features = ["bench-harness"]
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
# GNOME-on-Wayland Global Push-to-Talk uses the freedesktop
|
# GNOME-on-Wayland Global Push-to-Talk uses the freedesktop
|
||||||
@@ -148,22 +154,8 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] }
|
|||||||
# options. The portal recommends fresh tokens to scope its own
|
# options. The portal recommends fresh tokens to scope its own
|
||||||
# object paths per call.
|
# object paths per call.
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
# SDL2 audio for Linux. Replaces the cpal playback path on Linux only;
|
# Linux-native voice I/O. PipeWire is primary on modern desktops;
|
||||||
# cpal stays in use for Linux capture and Windows capture/playback.
|
# PulseAudio is retained as fallback and for older environments.
|
||||||
# Apple platforms use direct VoiceProcessingIO AudioUnits. Rationale: the
|
pipewire = { version = "0.10", features = ["v0_3_44"] }
|
||||||
# cpal Linux backend opens raw ALSA `default`, which on most Arch
|
libpulse-binding = "2.30"
|
||||||
# / Fedora / Debian installs routes through `dmix` + `plug` with
|
libpulse-simple-binding = "2.29"
|
||||||
# nearest-neighbour resampling and very small period sizes — the
|
|
||||||
# combination produces audible crackling/popping. SDL2 on the same
|
|
||||||
# systems routes through PipeWire's PulseAudio compat bridge (or
|
|
||||||
# real PulseAudio), both of which carry a high-quality resampler
|
|
||||||
# and a sensible default period. The upstream tsclientlib audio
|
|
||||||
# example (`tsclientlib/examples/audio_utils/ts_to_audio.rs`) and
|
|
||||||
# the official Qint client both use SDL2 in exactly this shape;
|
|
||||||
# this dep brings Chanora in line with that pattern.
|
|
||||||
#
|
|
||||||
# `bundled` is OFF deliberately — we link against the system
|
|
||||||
# libSDL2.so. Arch ships `sdl2-compat`; Debian/Ubuntu ship
|
|
||||||
# `libsdl2-2.0-0`; Fedora ships `SDL2`. The chanora-flutter Linux
|
|
||||||
# build documentation lists this as a runtime dependency.
|
|
||||||
sdl2 = { version = "0.37", default-features = false }
|
|
||||||
|
|||||||
@@ -5,28 +5,35 @@
|
|||||||
//! and stopped before disconnect. It does not retry on device
|
//! and stopped before disconnect. It does not retry on device
|
||||||
//! change.
|
//! change.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait};
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
|
not(target_os = "linux"),
|
||||||
not(target_os = "ios"),
|
not(target_os = "ios"),
|
||||||
not(target_os = "macos"),
|
not(target_os = "macos"),
|
||||||
not(target_os = "android")
|
not(target_os = "android")
|
||||||
))]
|
))]
|
||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
|
not(target_os = "linux"),
|
||||||
not(target_os = "ios"),
|
not(target_os = "ios"),
|
||||||
not(target_os = "macos"),
|
not(target_os = "macos"),
|
||||||
not(target_os = "android")
|
not(target_os = "android")
|
||||||
))]
|
))]
|
||||||
use cpal::{SampleFormat, SizedSample};
|
use cpal::{SampleFormat, SizedSample};
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
|
not(target_os = "linux"),
|
||||||
not(target_os = "ios"),
|
not(target_os = "ios"),
|
||||||
not(target_os = "macos"),
|
not(target_os = "macos"),
|
||||||
not(target_os = "android")
|
not(target_os = "android")
|
||||||
))]
|
))]
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
|
not(target_os = "linux"),
|
||||||
not(target_os = "ios"),
|
not(target_os = "ios"),
|
||||||
not(target_os = "macos"),
|
not(target_os = "macos"),
|
||||||
not(target_os = "android")
|
not(target_os = "android")
|
||||||
@@ -103,6 +110,7 @@ pub struct AudioDeviceInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
|
not(target_os = "linux"),
|
||||||
not(target_os = "ios"),
|
not(target_os = "ios"),
|
||||||
not(target_os = "macos"),
|
not(target_os = "macos"),
|
||||||
not(target_os = "android")
|
not(target_os = "android")
|
||||||
@@ -112,6 +120,7 @@ fn desktop_device_id(device: &cpal::Device) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
|
not(target_os = "linux"),
|
||||||
not(target_os = "ios"),
|
not(target_os = "ios"),
|
||||||
not(target_os = "macos"),
|
not(target_os = "macos"),
|
||||||
not(target_os = "android")
|
not(target_os = "android")
|
||||||
@@ -123,6 +132,7 @@ fn short_device_id(id: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
|
not(target_os = "linux"),
|
||||||
not(target_os = "ios"),
|
not(target_os = "ios"),
|
||||||
not(target_os = "macos"),
|
not(target_os = "macos"),
|
||||||
not(target_os = "android")
|
not(target_os = "android")
|
||||||
@@ -178,29 +188,53 @@ pub fn list_audio_devices() -> AudioDeviceList {
|
|||||||
output_devices: Vec::new(),
|
output_devices: Vec::new(),
|
||||||
};
|
};
|
||||||
let host = cpal::default_host();
|
let host = cpal::default_host();
|
||||||
let default_in = host
|
#[cfg(target_os = "linux")]
|
||||||
.default_input_device()
|
{
|
||||||
.and_then(|device| desktop_device_id(&device));
|
let default_input_name = host.default_input_device().and_then(|device| {
|
||||||
let default_out = host
|
device
|
||||||
.default_output_device()
|
.description()
|
||||||
.and_then(|device| desktop_device_id(&device));
|
.ok()
|
||||||
if let Ok(devices) = host.input_devices() {
|
.map(|description| description.name().trim().to_owned())
|
||||||
for d in devices {
|
.filter(|name| !name.is_empty())
|
||||||
if let Some(mut device) = describe_device(&d) {
|
});
|
||||||
device.is_default = default_in
|
let default_output_name = host.default_output_device().and_then(|device| {
|
||||||
.as_ref()
|
device
|
||||||
.is_some_and(|default_id| default_id == &device.id);
|
.description()
|
||||||
list.input_devices.push(device);
|
.ok()
|
||||||
|
.map(|description| description.name().trim().to_owned())
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
});
|
||||||
|
list.input_devices =
|
||||||
|
crate::linux_pipewire_input::list_input_devices(default_input_name.as_deref());
|
||||||
|
list.output_devices =
|
||||||
|
crate::linux_pipewire_input::list_output_devices(default_output_name.as_deref());
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
let default_in = host
|
||||||
|
.default_input_device()
|
||||||
|
.and_then(|device| desktop_device_id(&device));
|
||||||
|
if let Ok(devices) = host.input_devices() {
|
||||||
|
for d in devices {
|
||||||
|
if let Some(mut device) = describe_device(&d) {
|
||||||
|
device.is_default = default_in
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|default_id| default_id == &device.id);
|
||||||
|
list.input_devices.push(device);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
let default_out = host
|
||||||
if let Ok(devices) = host.output_devices() {
|
.default_output_device()
|
||||||
for d in devices {
|
.and_then(|device| desktop_device_id(&device));
|
||||||
if let Some(mut device) = describe_device(&d) {
|
if let Ok(devices) = host.output_devices() {
|
||||||
device.is_default = default_out
|
for d in devices {
|
||||||
.as_ref()
|
if let Some(mut device) = describe_device(&d) {
|
||||||
.is_some_and(|default_id| default_id == &device.id);
|
device.is_default = default_out
|
||||||
list.output_devices.push(device);
|
.as_ref()
|
||||||
|
.is_some_and(|default_id| default_id == &device.id);
|
||||||
|
list.output_devices.push(device);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,31 +340,23 @@ pub struct AudioEngine {
|
|||||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||||
|
client_volume_overrides: Arc<Mutex<HashMap<SessionAudioId, f32>>>,
|
||||||
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
||||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
// Streams must be dropped to stop audio. Linux owns native
|
||||||
// Streams must be dropped to stop audio. Both are `!Send` because
|
// PipeWire/PulseAudio threads; Windows owns cpal streams. On iOS both
|
||||||
// cpal's Stream isn't Send on some backends; we keep them in an
|
// sides collapse into a single `IosVoiceUnit` (one VoiceProcessingIO
|
||||||
// Option wrapped by Mutex so stop() can move them out. On Linux
|
// AudioUnit hosts mic + speaker).
|
||||||
// the output side is `crate::sdl_output::SdlOutput` instead of a
|
#[cfg(target_os = "linux")]
|
||||||
// cpal Stream (see the SDD note inside `sdl_output.rs`); the
|
_input_stream: Mutex<Option<crate::linux_pipewire_input::LinuxInput>>,
|
||||||
// same unsafe Send/Sync impl below covers both. On iOS both
|
#[cfg(target_os = "windows")]
|
||||||
// sides collapse into a single `IosVoiceUnit` (one
|
|
||||||
// VoiceProcessingIO AudioUnit hosts mic + speaker) — cpal is
|
|
||||||
// unused on iOS for the reasons documented in
|
|
||||||
// `ios_voice_unit.rs`.
|
|
||||||
#[cfg(all(
|
|
||||||
not(target_os = "ios"),
|
|
||||||
not(target_os = "macos"),
|
|
||||||
not(target_os = "android")
|
|
||||||
))]
|
|
||||||
_input_stream: Mutex<Option<cpal::Stream>>,
|
_input_stream: Mutex<Option<cpal::Stream>>,
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
_output_stream: Mutex<Option<crate::sdl_output::SdlOutput>>,
|
_output_stream: Mutex<Option<crate::linux_pipewire_input::LinuxOutput>>,
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
not(target_os = "linux"),
|
not(target_os = "linux"),
|
||||||
not(target_os = "ios"),
|
not(target_os = "ios"),
|
||||||
@@ -457,6 +483,31 @@ fn open_ios_voice_backend(
|
|||||||
Ok(IosVoiceBackend::Vpio(unit))
|
Ok(IosVoiceBackend::Vpio(unit))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_saved_client_volume(
|
||||||
|
audio_handler: &mut AudioHandler<SessionAudioId>,
|
||||||
|
client_volume_overrides: &Mutex<HashMap<SessionAudioId, f32>>,
|
||||||
|
client_id: SessionAudioId,
|
||||||
|
) {
|
||||||
|
let override_volume = match client_volume_overrides.lock() {
|
||||||
|
Ok(overrides) => overrides.get(&client_id).copied(),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
client_id = client_id.0,
|
||||||
|
error = %error,
|
||||||
|
"client volume overrides lock poisoned — saved volume not applied"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(volume) = override_volume {
|
||||||
|
if let Some(queue) = audio_handler.get_mut_queues().get_mut(&client_id) {
|
||||||
|
queue.volume = volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl AudioEngine {
|
impl AudioEngine {
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
fn spawn_android_backend_event_task(
|
fn spawn_android_backend_event_task(
|
||||||
@@ -692,7 +743,7 @@ impl AudioEngine {
|
|||||||
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
|
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-Apple/non-Android implementation: cpal capture + (cpal | SDL2) output.
|
/// Non-Apple/non-Android implementation: native Linux voice I/O or cpal on Windows.
|
||||||
/// Kept as a separate function so the Apple and Android paths can
|
/// Kept as a separate function so the Apple and Android paths can
|
||||||
/// short-circuit at the top of `start_with_gate` without dragging a 200-line
|
/// short-circuit at the top of `start_with_gate` without dragging a 200-line
|
||||||
/// cfg-gated block. Body is the pre-iOS-port code, unchanged
|
/// cfg-gated block. Body is the pre-iOS-port code, unchanged
|
||||||
@@ -711,7 +762,16 @@ impl AudioEngine {
|
|||||||
"starting audio engine: cpal host selected"
|
"starting audio engine: cpal host selected"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fn device_name(device: &cpal::Device) -> Option<String> {
|
||||||
|
device
|
||||||
|
.description()
|
||||||
|
.ok()
|
||||||
|
.map(|description| description.name().trim().to_owned())
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper: find a device by stable id, falling back to default.
|
/// Helper: find a device by stable id, falling back to default.
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
fn find_device<DefaultFn, AllFn, Devices>(
|
fn find_device<DefaultFn, AllFn, Devices>(
|
||||||
host: &cpal::Host,
|
host: &cpal::Host,
|
||||||
default_fn: DefaultFn,
|
default_fn: DefaultFn,
|
||||||
@@ -735,29 +795,31 @@ impl AudioEngine {
|
|||||||
default_fn(host)
|
default_fn(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let in_dev = host.default_input_device();
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
let in_dev = find_device(
|
let in_dev = find_device(
|
||||||
&host,
|
&host,
|
||||||
cpal::Host::default_input_device,
|
cpal::Host::default_input_device,
|
||||||
cpal::Host::input_devices,
|
cpal::Host::input_devices,
|
||||||
cfg.input_device_id.as_deref(),
|
cfg.input_device_id.as_deref(),
|
||||||
)
|
);
|
||||||
.ok_or(AudioError::NoInputDevice)?;
|
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let out_dev = host.default_output_device();
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
let out_dev = find_device(
|
let out_dev = find_device(
|
||||||
&host,
|
&host,
|
||||||
cpal::Host::default_output_device,
|
cpal::Host::default_output_device,
|
||||||
cpal::Host::output_devices,
|
cpal::Host::output_devices,
|
||||||
cfg.output_device_id.as_deref(),
|
cfg.output_device_id.as_deref(),
|
||||||
)
|
|
||||||
.ok_or(AudioError::NoOutputDevice)?;
|
|
||||||
|
|
||||||
info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
in_device = %in_dev.description().map(|d| d.name().to_owned()).unwrap_or_default(),
|
|
||||||
out_device = %out_dev.description().map(|d| d.name().to_owned()).unwrap_or_default(),
|
|
||||||
"starting audio engine"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let in_dev = in_dev.ok_or(AudioError::NoInputDevice)?;
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let out_dev = out_dev.ok_or(AudioError::NoOutputDevice)?;
|
||||||
|
|
||||||
// Log the cpal-reported default configs *before* trying to
|
// Log the cpal-reported default configs *before* trying to
|
||||||
// open streams, so a downstream stream-build failure can
|
// open streams, so a downstream stream-build failure can
|
||||||
// be cross-referenced against what the platform reported
|
// be cross-referenced against what the platform reported
|
||||||
@@ -768,6 +830,29 @@ impl AudioEngine {
|
|||||||
// "Start audio button not work"). Promote what would
|
// "Start audio button not work"). Promote what would
|
||||||
// otherwise be silent or laconic errors into structured
|
// otherwise be silent or laconic errors into structured
|
||||||
// log records the user can paste back.
|
// log records the user can paste back.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Some(in_dev) = in_dev.as_ref() {
|
||||||
|
match in_dev.default_input_config() {
|
||||||
|
Ok(c) => info!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
channels = c.channels(),
|
||||||
|
sample_rate = c.sample_rate(),
|
||||||
|
sample_format = ?c.sample_format(),
|
||||||
|
"default_input_config reported"
|
||||||
|
),
|
||||||
|
Err(e) => warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
error = %e,
|
||||||
|
"default_input_config FAILED — capture will be disabled"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
"cpal default_input_device unavailable; Linux PipeWire capture will be tried directly"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
match in_dev.default_input_config() {
|
match in_dev.default_input_config() {
|
||||||
Ok(c) => info!(
|
Ok(c) => info!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
@@ -782,6 +867,24 @@ impl AudioEngine {
|
|||||||
"default_input_config FAILED — capture will be disabled"
|
"default_input_config FAILED — capture will be disabled"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Some(out_dev) = out_dev.as_ref() {
|
||||||
|
match out_dev.default_output_config() {
|
||||||
|
Ok(c) => info!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
channels = c.channels(),
|
||||||
|
sample_rate = c.sample_rate(),
|
||||||
|
sample_format = ?c.sample_format(),
|
||||||
|
"default_output_config reported"
|
||||||
|
),
|
||||||
|
Err(e) => warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
error = %e,
|
||||||
|
"default_output_config FAILED — output stream will fail to build"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
match out_dev.default_output_config() {
|
match out_dev.default_output_config() {
|
||||||
Ok(c) => info!(
|
Ok(c) => info!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
@@ -797,6 +900,7 @@ impl AudioEngine {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
let transmit_flag_for_capture = transmit_gate.flag_arc();
|
let transmit_flag_for_capture = transmit_gate.flag_arc();
|
||||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||||
let frames_received = Arc::new(AtomicU32::new(0));
|
let frames_received = Arc::new(AtomicU32::new(0));
|
||||||
@@ -804,6 +908,7 @@ impl AudioEngine {
|
|||||||
let output_muted = Arc::new(AtomicBool::new(false));
|
let output_muted = Arc::new(AtomicBool::new(false));
|
||||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||||
|
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
// ---------- Capture ----------
|
// ---------- Capture ----------
|
||||||
// Capture is best-effort. If the platform default input
|
// Capture is best-effort. If the platform default input
|
||||||
@@ -811,24 +916,52 @@ impl AudioEngine {
|
|||||||
// headless null sources or for users who deny the mic
|
// headless null sources or for users who deny the mic
|
||||||
// permission) we log and continue — playback alone is
|
// permission) we log and continue — playback alone is
|
||||||
// still useful. PTT becomes a no-op in that case.
|
// still useful. PTT becomes a no-op in that case.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let capture_result = match crate::linux_pipewire_input::start_capture(
|
||||||
|
cfg.input_device_id.as_deref(),
|
||||||
|
in_dev.as_ref().and_then(device_name).as_deref(),
|
||||||
|
voice_out_tx.clone(),
|
||||||
|
transmit_gate.clone(),
|
||||||
|
frames_sent.clone(),
|
||||||
|
cfg.mic_gain,
|
||||||
|
) {
|
||||||
|
Ok((stream, device_info)) => Ok((Some(stream), true, device_info.name)),
|
||||||
|
Err(linux_error) => {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
error = %linux_error,
|
||||||
|
"Linux native capture unavailable"
|
||||||
|
);
|
||||||
|
Err(linux_error)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
let capture_result = try_open_capture(
|
let capture_result = try_open_capture(
|
||||||
&in_dev,
|
&in_dev,
|
||||||
voice_out_tx,
|
voice_out_tx,
|
||||||
transmit_flag_for_capture,
|
transmit_flag_for_capture,
|
||||||
frames_sent.clone(),
|
frames_sent.clone(),
|
||||||
cfg.mic_gain,
|
cfg.mic_gain,
|
||||||
);
|
)
|
||||||
let (input_stream, capture_active) = match capture_result {
|
.map(|stream| {
|
||||||
Ok(s) => (Some(s), true),
|
(
|
||||||
|
Some(stream),
|
||||||
|
true,
|
||||||
|
device_name(&in_dev).unwrap_or_else(|| "cpal capture".to_string()),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let (input_stream, capture_active, capture_device_name) = match capture_result {
|
||||||
|
Ok((stream, active, device_name)) => (stream, active, device_name),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
error = %e,
|
error = %e,
|
||||||
"capture stream unavailable; continuing with playback only"
|
"capture stream unavailable; continuing with playback only"
|
||||||
);
|
);
|
||||||
(None, false)
|
(None, false, "<capture unavailable>".to_string())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
if let Some(s) = &input_stream {
|
if let Some(s) = &input_stream {
|
||||||
s.play()
|
s.play()
|
||||||
.map_err(|e| AudioError::Backend(format!("input play: {e}")))?;
|
.map_err(|e| AudioError::Backend(format!("input play: {e}")))?;
|
||||||
@@ -838,21 +971,18 @@ impl AudioEngine {
|
|||||||
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||||
Arc::new(Mutex::new(AudioHandler::new()));
|
Arc::new(Mutex::new(AudioHandler::new()));
|
||||||
|
|
||||||
// Linux uses SDL2 for output (Qint / upstream tsclientlib
|
// Linux uses native PipeWire/PulseAudio playback. PipeWire is primary;
|
||||||
// pattern). cpal's Linux backend opens raw ALSA which routes
|
// PulseAudio is fallback and can also be selected explicitly by id.
|
||||||
// through `dmix`+`plug` and produces audible crackling /
|
|
||||||
// popping on the 48 kHz → device-rate step. SDL2 on the same
|
|
||||||
// box routes through PipeWire's PA bridge (or PulseAudio)
|
|
||||||
// whose resampler is high-quality. We keep cpal on Windows
|
|
||||||
// and macOS — both have native backends (WASAPI / CoreAudio)
|
|
||||||
// without this problem. See `crates/chanora_audio/src/sdl_output.rs`
|
|
||||||
// for the full rationale.
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let output_stream = crate::sdl_output::SdlOutput::start(
|
let (output_stream, output_device_info) = crate::linux_pipewire_input::start_output(
|
||||||
|
cfg.output_device_id.as_deref(),
|
||||||
|
out_dev.as_ref().and_then(device_name).as_deref(),
|
||||||
audio_handler.clone(),
|
audio_handler.clone(),
|
||||||
output_gain.clone(),
|
output_gain.clone(),
|
||||||
output_muted.clone(),
|
output_muted.clone(),
|
||||||
)?;
|
)?;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let output_device_name = output_device_info.name;
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
let output_stream = {
|
let output_stream = {
|
||||||
@@ -927,10 +1057,20 @@ impl AudioEngine {
|
|||||||
.map_err(|e| AudioError::Backend(format!("output play: {e}")))?;
|
.map_err(|e| AudioError::Backend(format!("output play: {e}")))?;
|
||||||
stream
|
stream
|
||||||
};
|
};
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let output_device_name = device_name(&out_dev).unwrap_or_default();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
in_device = %capture_device_name,
|
||||||
|
out_device = %output_device_name,
|
||||||
|
"starting audio engine"
|
||||||
|
);
|
||||||
|
|
||||||
// ---------- Inbound forwarder ----------
|
// ---------- Inbound forwarder ----------
|
||||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||||
let handler_for_task = audio_handler.clone();
|
let handler_for_task = audio_handler.clone();
|
||||||
|
let client_volume_overrides_for_task = client_volume_overrides.clone();
|
||||||
let frames_received_for_task = frames_received.clone();
|
let frames_received_for_task = frames_received.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
@@ -947,6 +1087,11 @@ impl AudioEngine {
|
|||||||
if let Err(e) = h.handle_packet(id, v.packet) {
|
if let Err(e) = h.handle_packet(id, v.packet) {
|
||||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||||
} else {
|
} else {
|
||||||
|
apply_saved_client_volume(
|
||||||
|
&mut h,
|
||||||
|
&client_volume_overrides_for_task,
|
||||||
|
id,
|
||||||
|
);
|
||||||
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -966,6 +1111,10 @@ impl AudioEngine {
|
|||||||
audio_processing_config,
|
audio_processing_config,
|
||||||
audio_processing_stats,
|
audio_processing_stats,
|
||||||
audio_handler,
|
audio_handler,
|
||||||
|
client_volume_overrides,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
_input_stream: Mutex::new(input_stream),
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
_input_stream: Mutex::new(input_stream),
|
_input_stream: Mutex::new(input_stream),
|
||||||
_output_stream: Mutex::new(Some(output_stream)),
|
_output_stream: Mutex::new(Some(output_stream)),
|
||||||
shutdown_tx: Some(shutdown_tx),
|
shutdown_tx: Some(shutdown_tx),
|
||||||
@@ -991,6 +1140,7 @@ impl AudioEngine {
|
|||||||
let output_muted = Arc::new(AtomicBool::new(false));
|
let output_muted = Arc::new(AtomicBool::new(false));
|
||||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||||
|
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||||
Arc::new(Mutex::new(AudioHandler::new()));
|
Arc::new(Mutex::new(AudioHandler::new()));
|
||||||
@@ -1137,6 +1287,7 @@ impl AudioEngine {
|
|||||||
|
|
||||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||||
let handler_for_task = audio_handler.clone();
|
let handler_for_task = audio_handler.clone();
|
||||||
|
let client_volume_overrides_for_task = client_volume_overrides.clone();
|
||||||
let frames_received_for_task = frames_received.clone();
|
let frames_received_for_task = frames_received.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
@@ -1153,6 +1304,11 @@ impl AudioEngine {
|
|||||||
if let Err(e) = h.handle_packet(id, v.packet) {
|
if let Err(e) = h.handle_packet(id, v.packet) {
|
||||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||||
} else {
|
} else {
|
||||||
|
apply_saved_client_volume(
|
||||||
|
&mut h,
|
||||||
|
&client_volume_overrides_for_task,
|
||||||
|
id,
|
||||||
|
);
|
||||||
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1172,6 +1328,7 @@ impl AudioEngine {
|
|||||||
audio_processing_config,
|
audio_processing_config,
|
||||||
audio_processing_stats,
|
audio_processing_stats,
|
||||||
audio_handler,
|
audio_handler,
|
||||||
|
client_volume_overrides,
|
||||||
voice_out_tx,
|
voice_out_tx,
|
||||||
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
||||||
mic_gain: cfg.mic_gain,
|
mic_gain: cfg.mic_gain,
|
||||||
@@ -1224,6 +1381,7 @@ impl AudioEngine {
|
|||||||
let output_muted = Arc::new(AtomicBool::new(false));
|
let output_muted = Arc::new(AtomicBool::new(false));
|
||||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||||
|
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||||
Arc::new(Mutex::new(AudioHandler::new()));
|
Arc::new(Mutex::new(AudioHandler::new()));
|
||||||
@@ -1258,6 +1416,7 @@ impl AudioEngine {
|
|||||||
// waiting.
|
// waiting.
|
||||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||||
let handler_for_task = audio_handler.clone();
|
let handler_for_task = audio_handler.clone();
|
||||||
|
let client_volume_overrides_for_task = client_volume_overrides.clone();
|
||||||
let frames_received_for_task = frames_received.clone();
|
let frames_received_for_task = frames_received.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
@@ -1274,6 +1433,11 @@ impl AudioEngine {
|
|||||||
if let Err(e) = h.handle_packet(id, v.packet) {
|
if let Err(e) = h.handle_packet(id, v.packet) {
|
||||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||||
} else {
|
} else {
|
||||||
|
apply_saved_client_volume(
|
||||||
|
&mut h,
|
||||||
|
&client_volume_overrides_for_task,
|
||||||
|
id,
|
||||||
|
);
|
||||||
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1293,6 +1457,7 @@ impl AudioEngine {
|
|||||||
audio_processing_config,
|
audio_processing_config,
|
||||||
audio_processing_stats,
|
audio_processing_stats,
|
||||||
audio_handler,
|
audio_handler,
|
||||||
|
client_volume_overrides,
|
||||||
voice_out_tx,
|
voice_out_tx,
|
||||||
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
||||||
mic_gain: cfg.mic_gain,
|
mic_gain: cfg.mic_gain,
|
||||||
@@ -1312,12 +1477,8 @@ impl AudioEngine {
|
|||||||
// common contract is that dropping the wrapper stops
|
// common contract is that dropping the wrapper stops
|
||||||
// audio. iOS collapses input + output into one
|
// audio. iOS collapses input + output into one
|
||||||
// `IosVoiceUnit` (see `ios_voice_unit.rs`); every other
|
// `IosVoiceUnit` (see `ios_voice_unit.rs`); every other
|
||||||
// platform has separate cpal input + cpal/SDL output.
|
// platform has separate input + output stream owners.
|
||||||
#[cfg(all(
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
not(target_os = "ios"),
|
|
||||||
not(target_os = "macos"),
|
|
||||||
not(target_os = "android")
|
|
||||||
))]
|
|
||||||
{
|
{
|
||||||
let _ = self._input_stream.lock().unwrap().take();
|
let _ = self._input_stream.lock().unwrap().take();
|
||||||
let _ = self._output_stream.lock().unwrap().take();
|
let _ = self._output_stream.lock().unwrap().take();
|
||||||
@@ -1669,6 +1830,25 @@ impl AudioEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match self.client_volume_overrides.lock() {
|
||||||
|
Ok(mut overrides) => {
|
||||||
|
if (clamped - 1.0).abs() <= f32::EPSILON {
|
||||||
|
overrides.remove(&SessionAudioId(client_id));
|
||||||
|
} else {
|
||||||
|
overrides.insert(SessionAudioId(client_id), clamped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
client_id,
|
||||||
|
volume = clamped,
|
||||||
|
error = %e,
|
||||||
|
"set_client_volume: client volume overrides lock poisoned"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1680,7 +1860,7 @@ impl Drop for AudioEngine {
|
|||||||
|
|
||||||
// ---------- Capture pipeline ----------
|
// ---------- Capture pipeline ----------
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
#[cfg(target_os = "windows")]
|
||||||
fn try_open_capture(
|
fn try_open_capture(
|
||||||
in_dev: &cpal::Device,
|
in_dev: &cpal::Device,
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
@@ -1699,9 +1879,8 @@ fn try_open_capture(
|
|||||||
// jitter of WASAPI shared mode.
|
// jitter of WASAPI shared mode.
|
||||||
// * macOS / iOS: CoreAudio picks a HAL-friendly default;
|
// * macOS / iOS: CoreAudio picks a HAL-friendly default;
|
||||||
// iOS RemoteIO rejects arbitrary buffer-size requests.
|
// iOS RemoteIO rejects arbitrary buffer-size requests.
|
||||||
// * Linux: same SDL2-vs-cpal split as the output path; we
|
// * Linux does not reach this cpal capture path in production;
|
||||||
// still use cpal for capture but leave Default since
|
// it uses native PipeWire/PulseAudio capture instead.
|
||||||
// PipeWire's ALSA shim works well there.
|
|
||||||
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
|
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
@@ -1738,7 +1917,7 @@ fn try_open_capture(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
struct CaptureState {
|
pub(crate) struct CaptureState {
|
||||||
encoder: OpusEncoder,
|
encoder: OpusEncoder,
|
||||||
in_sample_rate: u32,
|
in_sample_rate: u32,
|
||||||
in_channels: usize,
|
in_channels: usize,
|
||||||
@@ -1777,7 +1956,7 @@ struct CaptureState {
|
|||||||
|
|
||||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
impl CaptureState {
|
impl CaptureState {
|
||||||
fn new(
|
pub(crate) fn new(
|
||||||
encoder: OpusEncoder,
|
encoder: OpusEncoder,
|
||||||
in_sample_rate: u32,
|
in_sample_rate: u32,
|
||||||
in_channels: usize,
|
in_channels: usize,
|
||||||
@@ -1810,7 +1989,7 @@ impl CaptureState {
|
|||||||
/// Consume an arbitrary-rate, multichannel cpal buffer; produce
|
/// Consume an arbitrary-rate, multichannel cpal buffer; produce
|
||||||
/// 48 kHz mono frames; encode and send when `transmit_active`
|
/// 48 kHz mono frames; encode and send when `transmit_active`
|
||||||
/// is true (PTT engaged).
|
/// is true (PTT engaged).
|
||||||
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
|
pub(crate) fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
|
||||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||||
// Drain accumulator while muted so we don't pop on PTT release.
|
// Drain accumulator while muted so we don't pop on PTT release.
|
||||||
self.pcm_accum.clear();
|
self.pcm_accum.clear();
|
||||||
@@ -1959,7 +2138,7 @@ impl CaptureState {
|
|||||||
|
|
||||||
/// Per-sample format conversion to f32 in the range [-1.0, 1.0].
|
/// Per-sample format conversion to f32 in the range [-1.0, 1.0].
|
||||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
trait ToF32 {
|
pub(crate) trait ToF32 {
|
||||||
fn to_f32_sample(self) -> f32;
|
fn to_f32_sample(self) -> f32;
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
@@ -1981,7 +2160,7 @@ impl ToF32 for u16 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
#[cfg(target_os = "windows")]
|
||||||
fn build_input_stream<T>(
|
fn build_input_stream<T>(
|
||||||
device: &cpal::Device,
|
device: &cpal::Device,
|
||||||
config: &cpal::StreamConfig,
|
config: &cpal::StreamConfig,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
//! stream-format requests on bus 0 (output) and bus 1 (input).
|
//! stream-format requests on bus 0 (output) and bus 1 (input).
|
||||||
//!
|
//!
|
||||||
//! This file replaces the cpal capture + playback streams on iOS and macOS.
|
//! This file replaces the cpal capture + playback streams on iOS and macOS.
|
||||||
//! Linux uses SDL2 for output (see `sdl_output.rs`) and cpal for capture;
|
//! Linux uses native PipeWire/PulseAudio voice I/O;
|
||||||
//! Windows uses cpal's WASAPI backend.
|
//! Windows uses cpal's WASAPI backend.
|
||||||
//!
|
//!
|
||||||
//! ## What VPIO gives us
|
//! ## What VPIO gives us
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
//! thread that owns the unit. We open the unit on the same thread
|
//! thread that owns the unit. We open the unit on the same thread
|
||||||
//! that calls `Self::start` (the tokio worker that runs
|
//! that calls `Self::start` (the tokio worker that runs
|
||||||
//! `chanora_core::ChanoraSession::start_audio`, the same pattern
|
//! `chanora_core::ChanoraSession::start_audio`, the same pattern
|
||||||
//! cpal + SDL use) and never move it. The outer `AudioEngine`
|
//! the desktop backends use) and never move it. The outer `AudioEngine`
|
||||||
//! already carries an `unsafe impl Send` to satisfy the same
|
//! already carries an `unsafe impl Send` to satisfy the same
|
||||||
//! constraint for cpal's `!Send` Stream type; that impl covers
|
//! constraint for cpal's `!Send` Stream type; that impl covers
|
||||||
//! VPIO too.
|
//! VPIO too.
|
||||||
@@ -589,16 +589,16 @@ impl IosVoiceUnit {
|
|||||||
/// captures samples, render callback fires when the hardware
|
/// captures samples, render callback fires when the hardware
|
||||||
/// needs samples to play.
|
/// needs samples to play.
|
||||||
///
|
///
|
||||||
/// Parameters mirror the capture + playback inputs the cpal
|
/// Parameters mirror the capture + playback inputs the desktop
|
||||||
/// and SDL backends accept so engine.rs can swap backends with
|
/// backends accept so engine.rs can swap backends with
|
||||||
/// a `cfg`.
|
/// a `cfg`.
|
||||||
///
|
///
|
||||||
/// * `handler` — shared AudioHandler the inbound forwarder
|
/// * `handler` — shared AudioHandler the inbound forwarder
|
||||||
/// feeds Opus packets into. The output render callback pulls
|
/// feeds Opus packets into. The output render callback pulls
|
||||||
/// decoded f32 frames from it (48 kHz stereo) and downmixes
|
/// decoded f32 frames from it (48 kHz stereo) and downmixes
|
||||||
/// to the i16 mono buffer VPIO expects.
|
/// to the i16 mono buffer VPIO expects.
|
||||||
/// * `output_gain` / `output_muted` — same atomics the cpal
|
/// * `output_gain` / `output_muted` — same atomics the desktop
|
||||||
/// and SDL output paths read on every callback so the master
|
/// output paths read on every callback so the master
|
||||||
/// volume + local-mute UI works identically across backends.
|
/// volume + local-mute UI works identically across backends.
|
||||||
/// * `voice_out_tx` — channel the capture pipeline sends
|
/// * `voice_out_tx` — channel the capture pipeline sends
|
||||||
/// encoded `OutPacket`s on.
|
/// encoded `OutPacket`s on.
|
||||||
@@ -734,7 +734,7 @@ impl IosVoiceUnit {
|
|||||||
// 1. Lock the AudioHandler, ask it to fill a scratch
|
// 1. Lock the AudioHandler, ask it to fill a scratch
|
||||||
// f32 stereo buffer (length = 2 * num_frames). The
|
// f32 stereo buffer (length = 2 * num_frames). The
|
||||||
// handler runs Opus decode + per-client jitter
|
// handler runs Opus decode + per-client jitter
|
||||||
// buffer + mix. Same primitive cpal + SDL output
|
// buffer + mix. Same primitive desktop output
|
||||||
// paths use; this is the platform-neutral playback
|
// paths use; this is the platform-neutral playback
|
||||||
// contract from `tsclientlib::audio::AudioHandler`.
|
// contract from `tsclientlib::audio::AudioHandler`.
|
||||||
// 2. Downmix to i16 mono with master gain. VPIO expects
|
// 2. Downmix to i16 mono with master gain. VPIO expects
|
||||||
@@ -748,12 +748,10 @@ impl IosVoiceUnit {
|
|||||||
// AudioHandler in step 1 so its jitter buffer
|
// AudioHandler in step 1 so its jitter buffer
|
||||||
// doesn't grow unbounded while muted. This is the
|
// doesn't grow unbounded while muted. This is the
|
||||||
// contract every other backend follows (matches
|
// contract every other backend follows (matches
|
||||||
// SdlOutput::callback and the cpal output stream).
|
// Linux PipeWire/PulseAudio and the cpal output stream).
|
||||||
//
|
//
|
||||||
// Build the playback pipeline (direct fill_buffer in
|
// Build the playback pipeline (direct fill_buffer in
|
||||||
// render callback; matches tsclientlib's reference SDL
|
// render callback; matches the desktop voice backends).
|
||||||
// example at
|
|
||||||
// tsclientlib/examples/audio_utils/ts_to_audio.rs).
|
|
||||||
//
|
//
|
||||||
// The earlier ring-buffer attempt (rc.8+73..+74) decoupled
|
// The earlier ring-buffer attempt (rc.8+73..+74) decoupled
|
||||||
// AudioHandler from the render callback via a 50 Hz
|
// AudioHandler from the render callback via a 50 Hz
|
||||||
@@ -766,14 +764,12 @@ impl IosVoiceUnit {
|
|||||||
// consumer pull being larger, the ring averaged out empty
|
// consumer pull being larger, the ring averaged out empty
|
||||||
// — fill_buffer was returning silence 65-84% of ticks
|
// — fill_buffer was returning silence 65-84% of ticks
|
||||||
// because we drained it too aggressively before packets
|
// because we drained it too aggressively before packets
|
||||||
// arrived. Linux/SDL's same pattern works fine because
|
// arrived. Direct desktop callbacks follow the same pattern.
|
||||||
// SDL calls fill_buffer at exactly the device callback
|
|
||||||
// rate.
|
|
||||||
//
|
//
|
||||||
// Revert to direct call: render callback locks
|
// Revert to direct call: render callback locks
|
||||||
// AudioHandler, asks for `num_frames` stereo frames, and
|
// AudioHandler, asks for `num_frames` stereo frames, and
|
||||||
// immediately downmixes to i16 mono into the output
|
// immediately downmixes to i16 mono into the output
|
||||||
// buffer. Same as Linux/SDL, just stereo-f32 -> mono-i16
|
// buffer. Same as desktop output, just stereo-f32 -> mono-i16
|
||||||
// converted at the boundary.
|
// converted at the boundary.
|
||||||
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(2048);
|
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(2048);
|
||||||
let handler_for_render = params.handler.clone();
|
let handler_for_render = params.handler.clone();
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ pub mod voice_activity;
|
|||||||
pub(crate) mod voice_render;
|
pub(crate) mod voice_render;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod sdl_output;
|
mod linux_pipewire_input;
|
||||||
|
|
||||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
mod ios_voice_unit;
|
mod ios_voice_unit;
|
||||||
|
|||||||
@@ -0,0 +1,961 @@
|
|||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{mpsc as std_mpsc, Arc, Mutex};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use libpulse_binding as pulse;
|
||||||
|
use libpulse_simple_binding as psimple;
|
||||||
|
use pipewire as pw;
|
||||||
|
use pulse::callbacks::ListResult;
|
||||||
|
use pulse::context::{self, Context};
|
||||||
|
use pulse::mainloop::threaded::Mainloop;
|
||||||
|
use pulse::proplist::Proplist;
|
||||||
|
use pulse::sample::{Format, Spec};
|
||||||
|
use pulse::stream::Direction;
|
||||||
|
use pw::spa::pod::Pod;
|
||||||
|
use pw::{properties::properties, spa};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
|
||||||
|
use crate::engine::{AudioDeviceInfo, CaptureState, SessionAudioId};
|
||||||
|
use crate::{AudioError, AudioTransmitGate};
|
||||||
|
use chanora_protocol::OutPacket;
|
||||||
|
|
||||||
|
const INPUT_ID_PREFIX: &str = "pw-input:";
|
||||||
|
const OUTPUT_ID_PREFIX: &str = "pw-output:";
|
||||||
|
const PULSE_INPUT_ID_PREFIX: &str = "pulse-input:";
|
||||||
|
const PULSE_OUTPUT_ID_PREFIX: &str = "pulse-output:";
|
||||||
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
|
const CAPTURE_CHANNELS: u32 = 1;
|
||||||
|
const PLAYBACK_CHANNELS: u32 = 2;
|
||||||
|
const PLAYBACK_FRAMES: usize = 960;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct LinuxDevice {
|
||||||
|
backend: LinuxBackend,
|
||||||
|
direction: LinuxDirection,
|
||||||
|
raw_id: String,
|
||||||
|
label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum LinuxBackend {
|
||||||
|
PipeWire,
|
||||||
|
PulseAudio,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum LinuxDirection {
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct PipeWireDevice {
|
||||||
|
node_id: u32,
|
||||||
|
label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxDevice {
|
||||||
|
fn to_audio_device_info(&self, default_label: Option<&str>) -> AudioDeviceInfo {
|
||||||
|
let is_default = default_label.is_some_and(|label| label == self.label);
|
||||||
|
let (backend, id) = match (self.backend, self.direction) {
|
||||||
|
(LinuxBackend::PipeWire, LinuxDirection::Input) => (
|
||||||
|
"PipeWire",
|
||||||
|
input_device_id(self.raw_id.parse::<u32>().unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(LinuxBackend::PipeWire, LinuxDirection::Output) => (
|
||||||
|
"PipeWire",
|
||||||
|
output_device_id(self.raw_id.parse::<u32>().unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(LinuxBackend::PulseAudio, LinuxDirection::Input) => {
|
||||||
|
("PulseAudio", pulse_input_device_id(&self.raw_id))
|
||||||
|
}
|
||||||
|
(LinuxBackend::PulseAudio, LinuxDirection::Output) => {
|
||||||
|
("PulseAudio", pulse_output_device_id(&self.raw_id))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
AudioDeviceInfo {
|
||||||
|
id,
|
||||||
|
name: self.label.clone(),
|
||||||
|
details: format!("{backend} · id={}", self.raw_id),
|
||||||
|
is_default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn input_device_id(node_id: u32) -> String {
|
||||||
|
format!("{INPUT_ID_PREFIX}{node_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn input_device_node_id_from_id(id: &str) -> Option<u32> {
|
||||||
|
id.strip_prefix(INPUT_ID_PREFIX)?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn output_device_id(node_id: u32) -> String {
|
||||||
|
format!("{OUTPUT_ID_PREFIX}{node_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn output_device_node_id_from_id(id: &str) -> Option<u32> {
|
||||||
|
id.strip_prefix(OUTPUT_ID_PREFIX)?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pulse_input_device_id(name: &str) -> String {
|
||||||
|
format!("{PULSE_INPUT_ID_PREFIX}{name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pulse_output_device_id(name: &str) -> String {
|
||||||
|
format!("{PULSE_OUTPUT_ID_PREFIX}{name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pulse_input_name_from_id(id: &str) -> Option<&str> {
|
||||||
|
id.strip_prefix(PULSE_INPUT_ID_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pulse_output_name_from_id(id: &str) -> Option<&str> {
|
||||||
|
id.strip_prefix(PULSE_OUTPUT_ID_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn list_input_devices(default_label: Option<&str>) -> Vec<AudioDeviceInfo> {
|
||||||
|
list_devices(LinuxDirection::Input)
|
||||||
|
.iter()
|
||||||
|
.map(|device| device.to_audio_device_info(default_label))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn list_output_devices(default_label: Option<&str>) -> Vec<AudioDeviceInfo> {
|
||||||
|
list_devices(LinuxDirection::Output)
|
||||||
|
.iter()
|
||||||
|
.map(|device| device.to_audio_device_info(default_label))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn start_capture(
|
||||||
|
prefer: Option<&str>,
|
||||||
|
default_label: Option<&str>,
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
transmit_gate: AudioTransmitGate,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
mic_gain: f32,
|
||||||
|
) -> Result<(LinuxInput, AudioDeviceInfo), AudioError> {
|
||||||
|
let devices = list_devices(LinuxDirection::Input);
|
||||||
|
let device = select_device(&devices, prefer, default_label).ok_or_else(|| {
|
||||||
|
AudioError::Backend("Linux audio did not expose a usable capture source".to_string())
|
||||||
|
})?;
|
||||||
|
let info = device.to_audio_device_info(default_label);
|
||||||
|
let encoder = crate::opus_voice::new_voip_encoder("pipewire capture")?;
|
||||||
|
let capture_state = Arc::new(Mutex::new(CaptureState::new(
|
||||||
|
encoder,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
CAPTURE_CHANNELS as usize,
|
||||||
|
mic_gain,
|
||||||
|
voice_out_tx,
|
||||||
|
transmit_gate.flag_arc(),
|
||||||
|
frames_sent,
|
||||||
|
)));
|
||||||
|
let stream = match device.backend {
|
||||||
|
LinuxBackend::PipeWire => LinuxInput::PipeWire(PipeWireInput::start(
|
||||||
|
PipeWireDevice {
|
||||||
|
node_id: device.raw_id.parse().map_err(|_| {
|
||||||
|
AudioError::Backend(format!("invalid PipeWire source id `{}`", device.raw_id))
|
||||||
|
})?,
|
||||||
|
label: device.label.clone(),
|
||||||
|
},
|
||||||
|
capture_state,
|
||||||
|
)?),
|
||||||
|
LinuxBackend::PulseAudio => LinuxInput::PulseAudio(PulseAudioInput::start(
|
||||||
|
device.raw_id.clone(),
|
||||||
|
capture_state,
|
||||||
|
)?),
|
||||||
|
};
|
||||||
|
Ok((stream, info))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn start_output(
|
||||||
|
prefer: Option<&str>,
|
||||||
|
default_label: Option<&str>,
|
||||||
|
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||||
|
output_gain: Arc<AtomicU32>,
|
||||||
|
output_muted: Arc<AtomicBool>,
|
||||||
|
) -> Result<(LinuxOutput, AudioDeviceInfo), AudioError> {
|
||||||
|
let devices = list_devices(LinuxDirection::Output);
|
||||||
|
let device = select_device(&devices, prefer, default_label).ok_or_else(|| {
|
||||||
|
AudioError::Backend("Linux audio did not expose a usable playback sink".to_string())
|
||||||
|
})?;
|
||||||
|
let info = device.to_audio_device_info(default_label);
|
||||||
|
let stream = match device.backend {
|
||||||
|
LinuxBackend::PipeWire => LinuxOutput::PipeWire(PipeWireOutput::start(
|
||||||
|
PipeWireDevice {
|
||||||
|
node_id: device.raw_id.parse().map_err(|_| {
|
||||||
|
AudioError::Backend(format!("invalid PipeWire sink id `{}`", device.raw_id))
|
||||||
|
})?,
|
||||||
|
label: device.label.clone(),
|
||||||
|
},
|
||||||
|
handler,
|
||||||
|
output_gain,
|
||||||
|
output_muted,
|
||||||
|
)?),
|
||||||
|
LinuxBackend::PulseAudio => LinuxOutput::PulseAudio(PulseAudioOutput::start(
|
||||||
|
device.raw_id.clone(),
|
||||||
|
handler,
|
||||||
|
output_gain,
|
||||||
|
output_muted,
|
||||||
|
)?),
|
||||||
|
};
|
||||||
|
Ok((stream, info))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_device(
|
||||||
|
devices: &[LinuxDevice],
|
||||||
|
prefer: Option<&str>,
|
||||||
|
default_label: Option<&str>,
|
||||||
|
) -> Option<LinuxDevice> {
|
||||||
|
if devices.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(prefer) = prefer {
|
||||||
|
if let Some(device) = devices
|
||||||
|
.iter()
|
||||||
|
.find(|device| device.to_audio_device_info(default_label).id == prefer)
|
||||||
|
{
|
||||||
|
return Some(device.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(default_label) = default_label {
|
||||||
|
if let Some(device) = devices.iter().find(|device| device.label == default_label) {
|
||||||
|
return Some(device.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
devices.first().cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_devices(direction: LinuxDirection) -> Vec<LinuxDevice> {
|
||||||
|
let mut devices = Vec::new();
|
||||||
|
match list_pipewire_nodes(direction) {
|
||||||
|
Ok(mut pipewire) => devices.append(&mut pipewire),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(target: "chanora_audio", error = %error, "PipeWire device enumeration failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match list_pulse_devices(direction) {
|
||||||
|
Ok(mut pulse) => devices.append(&mut pulse),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(target: "chanora_audio", error = %error, "PulseAudio device enumeration failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
devices
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_pipewire_nodes(direction: LinuxDirection) -> Result<Vec<LinuxDevice>, String> {
|
||||||
|
pw::init();
|
||||||
|
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
|
||||||
|
let context = pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
|
||||||
|
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
|
||||||
|
let registry = core.get_registry().map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let done = Rc::new(Cell::new(false));
|
||||||
|
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
|
||||||
|
let class_prefix = match direction {
|
||||||
|
LinuxDirection::Input => "Audio/Source",
|
||||||
|
LinuxDirection::Output => "Audio/Sink",
|
||||||
|
};
|
||||||
|
|
||||||
|
let devices_for_registry = devices.clone();
|
||||||
|
let _registry_listener = registry
|
||||||
|
.add_listener_local()
|
||||||
|
.global(move |global| {
|
||||||
|
if global.type_ != pw::types::ObjectType::Node {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(props) = global.props else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(media_class) = props.get(*pw::keys::MEDIA_CLASS) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !media_class.starts_with(class_prefix) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let label = props
|
||||||
|
.get(*pw::keys::NODE_DESCRIPTION)
|
||||||
|
.or_else(|| props.get(*pw::keys::NODE_NICK))
|
||||||
|
.or_else(|| props.get(*pw::keys::NODE_NAME))
|
||||||
|
.unwrap_or("Unnamed PipeWire node")
|
||||||
|
.to_string();
|
||||||
|
devices_for_registry.borrow_mut().push(LinuxDevice {
|
||||||
|
backend: LinuxBackend::PipeWire,
|
||||||
|
direction,
|
||||||
|
raw_id: global.id.to_string(),
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.register();
|
||||||
|
|
||||||
|
let pending = core.sync(0).map_err(|err| err.to_string())?;
|
||||||
|
let done_for_core = done.clone();
|
||||||
|
let loop_for_core = mainloop.clone();
|
||||||
|
let _core_listener = core
|
||||||
|
.add_listener_local()
|
||||||
|
.done(move |id, seq| {
|
||||||
|
if id == pw::core::PW_ID_CORE && seq == pending {
|
||||||
|
done_for_core.set(true);
|
||||||
|
loop_for_core.quit();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.error(move |_id, _seq, _res, message| {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
message,
|
||||||
|
"PipeWire core error during input enumeration"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.register();
|
||||||
|
|
||||||
|
while !done.get() {
|
||||||
|
mainloop.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut devices = devices.borrow().clone();
|
||||||
|
devices.sort_by(|left, right| {
|
||||||
|
left.label
|
||||||
|
.cmp(&right.label)
|
||||||
|
.then_with(|| left.raw_id.cmp(&right.raw_id))
|
||||||
|
});
|
||||||
|
Ok(devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_pulse_devices(direction: LinuxDirection) -> Result<Vec<LinuxDevice>, String> {
|
||||||
|
with_pulse_context(|context, mainloop| {
|
||||||
|
let done = Rc::new(Cell::new(false));
|
||||||
|
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
|
||||||
|
let done_for_cb = done.clone();
|
||||||
|
let devices_for_cb = devices.clone();
|
||||||
|
let mainloop_for_cb = mainloop.clone();
|
||||||
|
let introspector = context.borrow().introspect();
|
||||||
|
match direction {
|
||||||
|
LinuxDirection::Input => {
|
||||||
|
let _operation = introspector.get_source_info_list(move |result| match result {
|
||||||
|
ListResult::Item(info) => {
|
||||||
|
if info.monitor_of_sink.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(name) = info.name.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let label = info
|
||||||
|
.description
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| name.to_string());
|
||||||
|
devices_for_cb.borrow_mut().push(LinuxDevice {
|
||||||
|
backend: LinuxBackend::PulseAudio,
|
||||||
|
direction,
|
||||||
|
raw_id: name.to_string(),
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ListResult::End | ListResult::Error => {
|
||||||
|
done_for_cb.set(true);
|
||||||
|
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
while !done.get() {
|
||||||
|
mainloop.borrow_mut().wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LinuxDirection::Output => {
|
||||||
|
let _operation = introspector.get_sink_info_list(move |result| match result {
|
||||||
|
ListResult::Item(info) => {
|
||||||
|
let Some(name) = info.name.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let label = info
|
||||||
|
.description
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| name.to_string());
|
||||||
|
devices_for_cb.borrow_mut().push(LinuxDevice {
|
||||||
|
backend: LinuxBackend::PulseAudio,
|
||||||
|
direction,
|
||||||
|
raw_id: name.to_string(),
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ListResult::End | ListResult::Error => {
|
||||||
|
done_for_cb.set(true);
|
||||||
|
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
while !done.get() {
|
||||||
|
mainloop.borrow_mut().wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut devices = devices.borrow().clone();
|
||||||
|
devices.sort_by(|left, right| {
|
||||||
|
left.label
|
||||||
|
.cmp(&right.label)
|
||||||
|
.then_with(|| left.raw_id.cmp(&right.raw_id))
|
||||||
|
});
|
||||||
|
Ok(devices)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_pulse_context<T, F>(operation: F) -> Result<T, String>
|
||||||
|
where
|
||||||
|
F: FnOnce(&Rc<RefCell<Context>>, &Rc<RefCell<Mainloop>>) -> Result<T, String>,
|
||||||
|
{
|
||||||
|
let proplist = build_pulse_proplist()?;
|
||||||
|
let mainloop = Rc::new(RefCell::new(
|
||||||
|
Mainloop::new().ok_or("failed to create PulseAudio mainloop")?,
|
||||||
|
));
|
||||||
|
let context = Rc::new(RefCell::new(
|
||||||
|
Context::new_with_proplist(&*mainloop.borrow(), "chanora", &proplist)
|
||||||
|
.ok_or("failed to create PulseAudio context")?,
|
||||||
|
));
|
||||||
|
let mainloop_for_cb = mainloop.clone();
|
||||||
|
let context_for_cb = context.clone();
|
||||||
|
context
|
||||||
|
.borrow_mut()
|
||||||
|
.set_state_callback(Some(Box::new(move || {
|
||||||
|
let state = unsafe { (*context_for_cb.as_ptr()).get_state() };
|
||||||
|
match state {
|
||||||
|
context::State::Ready | context::State::Failed | context::State::Terminated => {
|
||||||
|
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
context
|
||||||
|
.borrow_mut()
|
||||||
|
.connect(None, context::FlagSet::NOFLAGS, None)
|
||||||
|
.map_err(pa_error)?;
|
||||||
|
mainloop.borrow_mut().lock();
|
||||||
|
mainloop.borrow_mut().start().map_err(pa_error)?;
|
||||||
|
loop {
|
||||||
|
match context.borrow().get_state() {
|
||||||
|
context::State::Ready => break,
|
||||||
|
context::State::Failed | context::State::Terminated => {
|
||||||
|
mainloop.borrow_mut().unlock();
|
||||||
|
mainloop.borrow_mut().stop();
|
||||||
|
return Err(format!(
|
||||||
|
"PulseAudio context state {:?}",
|
||||||
|
context.borrow().get_state()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => mainloop.borrow_mut().wait(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.borrow_mut().set_state_callback(None);
|
||||||
|
let result = operation(&context, &mainloop);
|
||||||
|
mainloop.borrow_mut().unlock();
|
||||||
|
mainloop.borrow_mut().stop();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_pulse_proplist() -> Result<Proplist, String> {
|
||||||
|
let mut proplist = Proplist::new().ok_or("failed to create PulseAudio proplist")?;
|
||||||
|
proplist
|
||||||
|
.set_str(pulse::proplist::properties::APPLICATION_NAME, "chanora")
|
||||||
|
.map_err(|_| "failed to set PulseAudio application name".to_string())?;
|
||||||
|
Ok(proplist)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pulse_capture_spec() -> Spec {
|
||||||
|
Spec {
|
||||||
|
format: Format::F32le,
|
||||||
|
channels: CAPTURE_CHANNELS as u8,
|
||||||
|
rate: SAMPLE_RATE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pulse_playback_spec() -> Spec {
|
||||||
|
Spec {
|
||||||
|
format: Format::F32le,
|
||||||
|
channels: PLAYBACK_CHANNELS as u8,
|
||||||
|
rate: SAMPLE_RATE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pa_error(err: pulse::error::PAErr) -> String {
|
||||||
|
err.to_string()
|
||||||
|
.unwrap_or_else(|| format!("PulseAudio error {err:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_startup(
|
||||||
|
ready_rx: std_mpsc::Receiver<Result<(), String>>,
|
||||||
|
handle: JoinHandle<Result<(), String>>,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<JoinHandle<Result<(), String>>, AudioError> {
|
||||||
|
match ready_rx.recv() {
|
||||||
|
Ok(Ok(())) => Ok(handle),
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
let _ = handle.join();
|
||||||
|
Err(AudioError::Backend(err))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let _ = handle.join();
|
||||||
|
Err(AudioError::Backend(format!(
|
||||||
|
"{label} startup failed: {err}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum LinuxInput {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
PipeWire(PipeWireInput),
|
||||||
|
#[allow(dead_code)]
|
||||||
|
PulseAudio(PulseAudioInput),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum LinuxOutput {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
PipeWire(PipeWireOutput),
|
||||||
|
#[allow(dead_code)]
|
||||||
|
PulseAudio(PulseAudioOutput),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PipeWireInput {
|
||||||
|
device: PipeWireDevice,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
handle: Option<JoinHandle<Result<(), String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PipeWireInput {
|
||||||
|
fn start(
|
||||||
|
device: PipeWireDevice,
|
||||||
|
capture_state: Arc<Mutex<CaptureState>>,
|
||||||
|
) -> Result<Self, AudioError> {
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
let stop_for_thread = stop.clone();
|
||||||
|
let device_for_thread = device.clone();
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
pw::init();
|
||||||
|
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
|
||||||
|
let context =
|
||||||
|
pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
|
||||||
|
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let mut props = properties! {
|
||||||
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
|
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||||||
|
*pw::keys::MEDIA_ROLE => "Communication",
|
||||||
|
};
|
||||||
|
let target = device_for_thread.node_id.to_string();
|
||||||
|
props.insert(*pw::keys::TARGET_OBJECT, target.as_str());
|
||||||
|
|
||||||
|
let stream = pw::stream::StreamBox::new(&core, "chanora-pipewire-capture", props)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let _listener = stream
|
||||||
|
.add_local_listener_with_user_data(capture_state)
|
||||||
|
.process(|stream, state| {
|
||||||
|
if let Some(mut buffer) = stream.dequeue_buffer() {
|
||||||
|
let datas = buffer.datas_mut();
|
||||||
|
if datas.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data_ref = &mut datas[0];
|
||||||
|
let byte_count = data_ref.chunk().size() as usize;
|
||||||
|
let Some(bytes) = data_ref.data() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let byte_count = byte_count.min(bytes.len());
|
||||||
|
if byte_count < 4 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut scratch = Vec::with_capacity(byte_count / 4);
|
||||||
|
for chunk in bytes[..byte_count].chunks_exact(4) {
|
||||||
|
scratch
|
||||||
|
.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||||
|
}
|
||||||
|
state.lock().unwrap().ingest(&scratch);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.register()
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let values = build_format_param()?;
|
||||||
|
let mut params =
|
||||||
|
[Pod::from_bytes(&values).ok_or("invalid PipeWire capture format pod")?];
|
||||||
|
|
||||||
|
stream
|
||||||
|
.connect(
|
||||||
|
spa::utils::Direction::Input,
|
||||||
|
None,
|
||||||
|
pw::stream::StreamFlags::AUTOCONNECT
|
||||||
|
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||||
|
| pw::stream::StreamFlags::RT_PROCESS,
|
||||||
|
&mut params,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let _ = ready_tx.send(Ok(()));
|
||||||
|
while !stop_for_thread.load(Ordering::SeqCst) {
|
||||||
|
let _ = mainloop
|
||||||
|
.loop_()
|
||||||
|
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(100)));
|
||||||
|
}
|
||||||
|
mainloop.quit();
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
let handle = wait_for_startup(ready_rx, handle, "PipeWire capture")?;
|
||||||
|
info!(target: "chanora_audio", device = %device.label, node_id = device.node_id, "PipeWire capture backend initialised");
|
||||||
|
Ok(Self {
|
||||||
|
device,
|
||||||
|
stop,
|
||||||
|
handle: Some(handle),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PipeWireOutput {
|
||||||
|
device: PipeWireDevice,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
handle: Option<JoinHandle<Result<(), String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PipeWireOutput {
|
||||||
|
fn start(
|
||||||
|
device: PipeWireDevice,
|
||||||
|
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||||
|
output_gain: Arc<AtomicU32>,
|
||||||
|
output_muted: Arc<AtomicBool>,
|
||||||
|
) -> Result<Self, AudioError> {
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
let stop_for_thread = stop.clone();
|
||||||
|
let device_for_thread = device.clone();
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
pw::init();
|
||||||
|
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
|
||||||
|
let context =
|
||||||
|
pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
|
||||||
|
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
|
||||||
|
let mut props = properties! {
|
||||||
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
|
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||||
|
*pw::keys::MEDIA_ROLE => "Communication",
|
||||||
|
};
|
||||||
|
let target = device_for_thread.node_id.to_string();
|
||||||
|
props.insert(*pw::keys::TARGET_OBJECT, target.as_str());
|
||||||
|
let stream = pw::stream::StreamBox::new(&core, "chanora-pipewire-playback", props)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let _listener = stream
|
||||||
|
.add_local_listener_with_user_data((handler, output_gain, output_muted))
|
||||||
|
.process(|stream, state| {
|
||||||
|
if let Some(mut buffer) = stream.dequeue_buffer() {
|
||||||
|
let datas = buffer.datas_mut();
|
||||||
|
if datas.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data_ref = &mut datas[0];
|
||||||
|
let byte_len = {
|
||||||
|
let Some(bytes) = data_ref.data() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let samples = bytes.len() / std::mem::size_of::<f32>();
|
||||||
|
let mut scratch = vec![0.0_f32; samples];
|
||||||
|
if let Ok(mut handler) = state.0.lock() {
|
||||||
|
handler.fill_buffer(&mut scratch);
|
||||||
|
}
|
||||||
|
apply_output_controls(&mut scratch, &state.1, &state.2);
|
||||||
|
for (chunk, sample) in
|
||||||
|
bytes.chunks_exact_mut(4).zip(scratch.into_iter())
|
||||||
|
{
|
||||||
|
chunk.copy_from_slice(&sample.to_le_bytes());
|
||||||
|
}
|
||||||
|
bytes.len()
|
||||||
|
};
|
||||||
|
let chunk = data_ref.chunk_mut();
|
||||||
|
*chunk.offset_mut() = 0;
|
||||||
|
*chunk.stride_mut() =
|
||||||
|
(PLAYBACK_CHANNELS * std::mem::size_of::<f32>() as u32) as i32;
|
||||||
|
*chunk.size_mut() = byte_len as u32;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.register()
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let values = build_playback_format_param()?;
|
||||||
|
let mut params =
|
||||||
|
[Pod::from_bytes(&values).ok_or("invalid PipeWire playback format pod")?];
|
||||||
|
stream
|
||||||
|
.connect(
|
||||||
|
spa::utils::Direction::Output,
|
||||||
|
None,
|
||||||
|
pw::stream::StreamFlags::AUTOCONNECT
|
||||||
|
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||||
|
| pw::stream::StreamFlags::RT_PROCESS,
|
||||||
|
&mut params,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let _ = ready_tx.send(Ok(()));
|
||||||
|
while !stop_for_thread.load(Ordering::SeqCst) {
|
||||||
|
let _ = mainloop
|
||||||
|
.loop_()
|
||||||
|
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(100)));
|
||||||
|
}
|
||||||
|
mainloop.quit();
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
let handle = wait_for_startup(ready_rx, handle, "PipeWire playback")?;
|
||||||
|
info!(target: "chanora_audio", device = %device.label, node_id = device.node_id, "PipeWire playback backend initialised");
|
||||||
|
Ok(Self {
|
||||||
|
device,
|
||||||
|
stop,
|
||||||
|
handle: Some(handle),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_output_controls(samples: &mut [f32], output_gain: &AtomicU32, output_muted: &AtomicBool) {
|
||||||
|
if output_muted.load(Ordering::Relaxed) {
|
||||||
|
samples.fill(0.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
||||||
|
if (gain - 1.0).abs() > f32::EPSILON {
|
||||||
|
for sample in samples.iter_mut() {
|
||||||
|
*sample *= gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PulseAudioInput {
|
||||||
|
device_name: String,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
handle: Option<JoinHandle<Result<(), String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PulseAudioInput {
|
||||||
|
fn start(
|
||||||
|
device_name: String,
|
||||||
|
capture_state: Arc<Mutex<CaptureState>>,
|
||||||
|
) -> Result<Self, AudioError> {
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
let stop_for_thread = stop.clone();
|
||||||
|
let device_for_thread = device_name.clone();
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let spec = pulse_capture_spec();
|
||||||
|
let stream = psimple::Simple::new(
|
||||||
|
None,
|
||||||
|
"chanora",
|
||||||
|
Direction::Record,
|
||||||
|
Some(device_for_thread.as_str()),
|
||||||
|
"chanora-record",
|
||||||
|
&spec,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(pa_error)?;
|
||||||
|
let _ = ready_tx.send(Ok(()));
|
||||||
|
let mut bytes =
|
||||||
|
vec![
|
||||||
|
0_u8;
|
||||||
|
PLAYBACK_FRAMES * CAPTURE_CHANNELS as usize * std::mem::size_of::<f32>()
|
||||||
|
];
|
||||||
|
while !stop_for_thread.load(Ordering::SeqCst) {
|
||||||
|
stream.read(&mut bytes).map_err(pa_error)?;
|
||||||
|
let mut scratch = Vec::with_capacity(bytes.len() / 4);
|
||||||
|
for chunk in bytes.chunks_exact(4) {
|
||||||
|
scratch.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||||
|
}
|
||||||
|
capture_state.lock().unwrap().ingest(&scratch);
|
||||||
|
}
|
||||||
|
stream.flush().map_err(pa_error)?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
let handle = wait_for_startup(ready_rx, handle, "PulseAudio capture")?;
|
||||||
|
info!(target: "chanora_audio", device = %device_name, "PulseAudio capture backend initialised");
|
||||||
|
Ok(Self {
|
||||||
|
device_name,
|
||||||
|
stop,
|
||||||
|
handle: Some(handle),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PulseAudioOutput {
|
||||||
|
device_name: String,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
handle: Option<JoinHandle<Result<(), String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PulseAudioOutput {
|
||||||
|
fn start(
|
||||||
|
device_name: String,
|
||||||
|
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||||
|
output_gain: Arc<AtomicU32>,
|
||||||
|
output_muted: Arc<AtomicBool>,
|
||||||
|
) -> Result<Self, AudioError> {
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
let stop_for_thread = stop.clone();
|
||||||
|
let device_for_thread = device_name.clone();
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let spec = pulse_playback_spec();
|
||||||
|
let stream = psimple::Simple::new(
|
||||||
|
None,
|
||||||
|
"chanora",
|
||||||
|
Direction::Playback,
|
||||||
|
Some(device_for_thread.as_str()),
|
||||||
|
"chanora-playback",
|
||||||
|
&spec,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(pa_error)?;
|
||||||
|
let _ = ready_tx.send(Ok(()));
|
||||||
|
let mut samples = vec![0.0_f32; PLAYBACK_FRAMES * PLAYBACK_CHANNELS as usize];
|
||||||
|
let mut bytes = vec![0_u8; samples.len() * std::mem::size_of::<f32>()];
|
||||||
|
while !stop_for_thread.load(Ordering::SeqCst) {
|
||||||
|
samples.fill(0.0);
|
||||||
|
if let Ok(mut handler) = handler.lock() {
|
||||||
|
handler.fill_buffer(&mut samples);
|
||||||
|
}
|
||||||
|
apply_output_controls(&mut samples, &output_gain, &output_muted);
|
||||||
|
for (chunk, sample) in bytes.chunks_exact_mut(4).zip(samples.iter().copied()) {
|
||||||
|
chunk.copy_from_slice(&sample.to_le_bytes());
|
||||||
|
}
|
||||||
|
stream.write(&bytes).map_err(pa_error)?;
|
||||||
|
}
|
||||||
|
stream.drain().map_err(pa_error)?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
let handle = wait_for_startup(ready_rx, handle, "PulseAudio playback")?;
|
||||||
|
info!(target: "chanora_audio", device = %device_name, "PulseAudio playback backend initialised");
|
||||||
|
Ok(Self {
|
||||||
|
device_name,
|
||||||
|
stop,
|
||||||
|
handle: Some(handle),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PipeWireInput {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
if let Err(_panic) = handle.join() {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
device = %self.device.label,
|
||||||
|
node_id = self.device.node_id,
|
||||||
|
"PipeWire capture thread panicked during shutdown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PipeWireOutput {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
if handle.join().is_err() {
|
||||||
|
warn!(target: "chanora_audio", device = %self.device.label, node_id = self.device.node_id, "PipeWire playback thread panicked during shutdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PulseAudioInput {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
if handle.join().is_err() {
|
||||||
|
warn!(target: "chanora_audio", device = %self.device_name, "PulseAudio capture thread panicked during shutdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PulseAudioOutput {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
if handle.join().is_err() {
|
||||||
|
warn!(target: "chanora_audio", device = %self.device_name, "PulseAudio playback thread panicked during shutdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_format_param() -> Result<Vec<u8>, String> {
|
||||||
|
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
|
||||||
|
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
|
||||||
|
audio_info.set_rate(SAMPLE_RATE);
|
||||||
|
audio_info.set_channels(CAPTURE_CHANNELS);
|
||||||
|
|
||||||
|
build_format_param_for_audio_info(audio_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_playback_format_param() -> Result<Vec<u8>, String> {
|
||||||
|
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
|
||||||
|
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
|
||||||
|
audio_info.set_rate(SAMPLE_RATE);
|
||||||
|
audio_info.set_channels(PLAYBACK_CHANNELS);
|
||||||
|
|
||||||
|
build_format_param_for_audio_info(audio_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_format_param_for_audio_info(
|
||||||
|
audio_info: spa::param::audio::AudioInfoRaw,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
let object = pw::spa::pod::Object {
|
||||||
|
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||||
|
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
|
||||||
|
properties: audio_info.into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
pw::spa::pod::serialize::PodSerializer::serialize(
|
||||||
|
std::io::Cursor::new(Vec::new()),
|
||||||
|
&pw::spa::pod::Value::Object(object),
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
.map(|serializer| serializer.0.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
input_device_id, input_device_node_id_from_id, output_device_id,
|
||||||
|
output_device_node_id_from_id, pulse_input_device_id, pulse_input_name_from_id,
|
||||||
|
pulse_output_device_id, pulse_output_name_from_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_device_id_round_trip() {
|
||||||
|
let id = input_device_id(43);
|
||||||
|
assert_eq!(input_device_node_id_from_id(&id), Some(43));
|
||||||
|
assert_eq!(input_device_node_id_from_id("legacy-id"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_device_id_round_trip() {
|
||||||
|
let id = output_device_id(44);
|
||||||
|
assert_eq!(output_device_node_id_from_id(&id), Some(44));
|
||||||
|
assert_eq!(output_device_node_id_from_id("legacy-id"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pulse_device_id_round_trip() {
|
||||||
|
let input = pulse_input_device_id("alsa_input.pci-0000_00_1f.3");
|
||||||
|
let output = pulse_output_device_id("alsa_output.pci-0000_00_1f.3");
|
||||||
|
assert_eq!(
|
||||||
|
pulse_input_name_from_id(&input),
|
||||||
|
Some("alsa_input.pci-0000_00_1f.3")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pulse_output_name_from_id(&output),
|
||||||
|
Some("alsa_output.pci-0000_00_1f.3")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
//! Linux output stream via SDL2 (Qint / upstream `tsclientlib`
|
|
||||||
//! `ts_to_audio` pattern).
|
|
||||||
//!
|
|
||||||
//! ## Why SDL2 and not cpal on Linux
|
|
||||||
//!
|
|
||||||
//! cpal's Linux backend opens raw ALSA's `default` PCM. On most
|
|
||||||
//! modern distributions (Arch with `pipewire-alsa`, Fedora 38+,
|
|
||||||
//! Debian/Ubuntu with PipeWire) that virtual device still goes
|
|
||||||
//! through ALSA's `dmix` + `plug` layers when bypassing the
|
|
||||||
//! PulseAudio/PipeWire client. The `plug` layer's default
|
|
||||||
//! resampler is **nearest-neighbour**, which causes pronounced
|
|
||||||
//! aliasing on the 48 kHz → 44.1 kHz step that the
|
|
||||||
//! `tsclientlib::AudioHandler` output requires. Users perceive
|
|
||||||
//! this as constant crackling and popping. cpal also picks a
|
|
||||||
//! small default period size (≈256 frames), which leaves no
|
|
||||||
//! headroom for kernel scheduler jitter and causes additional
|
|
||||||
//! xruns.
|
|
||||||
//!
|
|
||||||
//! SDL2 on the same systems opens the audio device through the
|
|
||||||
//! SDL audio driver — which prefers PulseAudio when available
|
|
||||||
//! and falls back to ALSA otherwise. On a PipeWire box the SDL
|
|
||||||
//! PulseAudio driver lands inside PipeWire's PulseAudio
|
|
||||||
//! compatibility layer, whose resampler is high quality. SDL
|
|
||||||
//! also defaults to a buffer ≈ samples-requested, so we get
|
|
||||||
//! exactly one Opus frame (20 ms) per callback.
|
|
||||||
//!
|
|
||||||
//! This file replaces the cpal output path on Linux only. The
|
|
||||||
//! capture path stays on cpal until we have a reason to swap it
|
|
||||||
//! (the user reports outbound is currently fine). Windows continues
|
|
||||||
//! to use cpal's WASAPI backend; Apple platforms use direct
|
|
||||||
//! VoiceProcessingIO AudioUnits for the voice path.
|
|
||||||
//!
|
|
||||||
//! ## Threading & lifecycle
|
|
||||||
//!
|
|
||||||
//! `sdl2::AudioDevice<CB>` is `!Send + !Sync` — the SDL audio
|
|
||||||
//! lock is associated with the calling thread. We open the device
|
|
||||||
//! on the same thread that calls `Self::start_with_gate` (the
|
|
||||||
//! tokio worker that runs `chanora_core::ChanoraSession::start_audio`)
|
|
||||||
//! and never move it. The outer `AudioEngine` already carries an
|
|
||||||
//! `unsafe impl Send` to bypass cpal's identical constraint; we
|
|
||||||
//! reuse that and stash the SDL device behind a `Mutex<Option<…>>`
|
|
||||||
//! the same way cpal does.
|
|
||||||
//!
|
|
||||||
//! Dropping the `SdlOutput` closes the SDL device cleanly and
|
|
||||||
//! drops the playback callback — that releases the
|
|
||||||
//! `Arc<Mutex<AudioHandler>>` clone the callback held.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use sdl2::audio::{AudioCallback, AudioDevice, AudioSpecDesired};
|
|
||||||
use sdl2::AudioSubsystem;
|
|
||||||
use tracing::{info, warn};
|
|
||||||
use tsclientlib::audio::AudioHandler;
|
|
||||||
|
|
||||||
use crate::engine::SessionAudioId;
|
|
||||||
use crate::AudioError;
|
|
||||||
|
|
||||||
/// 20 ms at 48 kHz mono == one Opus frame's worth of samples.
|
|
||||||
/// Stereo doubles the byte count but the frame-count stays the
|
|
||||||
/// same. Aligning the callback size to the Opus frame keeps the
|
|
||||||
/// jitter-buffer / playback handshake tight (no fractional frame
|
|
||||||
/// reads inside fill_buffer).
|
|
||||||
const FRAME_SAMPLES: u16 = 960;
|
|
||||||
|
|
||||||
/// SDL output device wrapper. Holds the live `AudioDevice` so its
|
|
||||||
/// callback keeps firing for the engine's lifetime, plus a
|
|
||||||
/// reference to the same `AudioHandler` the inbound forwarder
|
|
||||||
/// pushes into.
|
|
||||||
pub struct SdlOutput {
|
|
||||||
// Drop order: device first (stops the callback), then any
|
|
||||||
// remaining references release naturally. We keep `_subsystem`
|
|
||||||
// alive because dropping the AudioSubsystem before the device
|
|
||||||
// would invalidate SDL's internal state.
|
|
||||||
device: AudioDevice<TsPlaybackCallback>,
|
|
||||||
_subsystem: AudioSubsystem,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SdlOutput {
|
|
||||||
/// Open the default SDL playback device at the AudioHandler's
|
|
||||||
/// native format (48 kHz stereo f32) and start the device
|
|
||||||
/// playing immediately. The callback drains the AudioHandler
|
|
||||||
/// directly — no user-side resampling.
|
|
||||||
///
|
|
||||||
/// `output_gain` is read on every callback to apply the
|
|
||||||
/// master-volume slider; `output_muted` zeroes the output (but
|
|
||||||
/// still drains AudioHandler so its jitter buffer doesn't grow
|
|
||||||
/// unbounded while muted). These two atomics share the same
|
|
||||||
/// definitions the cpal path uses, so the same FFI surface
|
|
||||||
/// (`set_output_gain` / `set_output_muted`) drives both.
|
|
||||||
pub fn start(
|
|
||||||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
|
||||||
output_gain: Arc<AtomicU32>,
|
|
||||||
output_muted: Arc<AtomicBool>,
|
|
||||||
) -> Result<Self, AudioError> {
|
|
||||||
let sdl = sdl2::init().map_err(|e| AudioError::Backend(format!("sdl init: {e}")))?;
|
|
||||||
let subsystem = sdl
|
|
||||||
.audio()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("sdl audio subsystem: {e}")))?;
|
|
||||||
|
|
||||||
info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
driver = subsystem.current_audio_driver(),
|
|
||||||
"sdl audio subsystem initialised"
|
|
||||||
);
|
|
||||||
|
|
||||||
let desired = AudioSpecDesired {
|
|
||||||
freq: Some(48_000),
|
|
||||||
channels: Some(2),
|
|
||||||
samples: Some(FRAME_SAMPLES),
|
|
||||||
};
|
|
||||||
|
|
||||||
let device = AudioDevice::open_playback(&subsystem, None, &desired, |spec| {
|
|
||||||
info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
freq = spec.freq,
|
|
||||||
channels = spec.channels,
|
|
||||||
samples = spec.samples,
|
|
||||||
"sdl playback spec accepted"
|
|
||||||
);
|
|
||||||
TsPlaybackCallback {
|
|
||||||
handler,
|
|
||||||
output_gain,
|
|
||||||
output_muted,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map_err(|e| AudioError::Backend(format!("sdl open_playback: {e}")))?;
|
|
||||||
|
|
||||||
// Begin pumping audio frames out. SDL's device starts paused;
|
|
||||||
// resume() flips it into the playing state. The callback
|
|
||||||
// will fire repeatedly at ~50 Hz (every 20 ms) thereafter.
|
|
||||||
device.resume();
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
device,
|
|
||||||
_subsystem: subsystem,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for SdlOutput {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// AudioDevice::drop closes the device which stops the
|
|
||||||
// callback. We log so the chanora.log timeline matches
|
|
||||||
// engine shutdown.
|
|
||||||
warn!(target: "chanora_audio", "sdl playback device closing");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Playback callback invoked by SDL's audio thread. The shape
|
|
||||||
/// mirrors the upstream `tsclientlib` example's `SdlCallback`:
|
|
||||||
/// zero the output buffer (so silent regions emit silence rather
|
|
||||||
/// than stale memory), then ask the `AudioHandler` to fill in
|
|
||||||
/// whatever decoded frames it has buffered.
|
|
||||||
struct TsPlaybackCallback {
|
|
||||||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
|
||||||
output_gain: Arc<AtomicU32>,
|
|
||||||
output_muted: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioCallback for TsPlaybackCallback {
|
|
||||||
type Channel = f32;
|
|
||||||
|
|
||||||
fn callback(&mut self, buffer: &mut [f32]) {
|
|
||||||
// The buffer is interleaved stereo at 48 kHz from SDL.
|
|
||||||
// Length is FRAME_SAMPLES * 2 (= 1920 f32) per the spec
|
|
||||||
// we requested.
|
|
||||||
for sample in buffer.iter_mut() {
|
|
||||||
*sample = 0.0;
|
|
||||||
}
|
|
||||||
// Lock window kept as tight as the upstream example —
|
|
||||||
// fill_buffer does the actual Opus decode + jitter logic.
|
|
||||||
// Contention with the inbound forwarder is the same as
|
|
||||||
// in the cpal path, but the upstream design has shipped
|
|
||||||
// this way for years.
|
|
||||||
{
|
|
||||||
let mut data = self.handler.lock().unwrap();
|
|
||||||
let _removed_ids = data.fill_buffer(buffer);
|
|
||||||
// `_removed_ids` is the list of clients whose stream the
|
|
||||||
// handler just finished draining. We could publish that
|
|
||||||
// upward as a "stopped speaking" hint, but the existing
|
|
||||||
// BridgeEvent::VoiceState already covers that case via
|
|
||||||
// the bridge layer; ignoring matches upstream behaviour.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply local mute + master gain after fill so the jitter
|
|
||||||
// buffer still drains while muted (matching the cpal path's
|
|
||||||
// contract). `output_gain` is encoded as f32 bits inside an
|
|
||||||
// AtomicU32 — same encoding the cpal path uses.
|
|
||||||
if self.output_muted.load(Ordering::Relaxed) {
|
|
||||||
for sample in buffer.iter_mut() {
|
|
||||||
*sample = 0.0;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
|
||||||
if (gain - 1.0).abs() > f32::EPSILON {
|
|
||||||
for sample in buffer.iter_mut() {
|
|
||||||
*sample *= gain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,12 +33,16 @@
|
|||||||
//! ## Fallback
|
//! ## Fallback
|
||||||
//!
|
//!
|
||||||
//! `try_new` returns `None` when the model file is missing, the ONNX
|
//! `try_new` returns `None` when the model file is missing, the ONNX
|
||||||
//! Runtime is unavailable, or the platform is not iOS/macOS. The caller
|
//! Runtime is unavailable, or the platform cannot load ONNX Runtime. The caller
|
||||||
//! falls back to `WebRtcFallbackVad`.
|
//! falls back to `WebRtcFallbackVad`.
|
||||||
|
|
||||||
use super::{VadOutput, VoiceActivityDetector};
|
use super::{VadOutput, VoiceActivityDetector};
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use std::sync::OnceLock;
|
||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
/// 16 kHz frame size for Silero VAD v6 (32 ms).
|
/// 16 kHz frame size for Silero VAD v6 (32 ms).
|
||||||
@@ -88,7 +92,7 @@ impl SileroOnnxVad {
|
|||||||
/// Attempt to load the Silero v6 ONNX model from `model_path`.
|
/// Attempt to load the Silero v6 ONNX model from `model_path`.
|
||||||
///
|
///
|
||||||
/// Returns `None` when the model file is missing, the ONNX Runtime
|
/// Returns `None` when the model file is missing, the ONNX Runtime
|
||||||
/// is unavailable, or the platform does not support ONNX.
|
/// is unavailable, or the platform cannot initialize ONNX Runtime.
|
||||||
pub fn try_new(model_path: &str) -> Option<Self> {
|
pub fn try_new(model_path: &str) -> Option<Self> {
|
||||||
Self::try_new_onnx(model_path)
|
Self::try_new_onnx(model_path)
|
||||||
}
|
}
|
||||||
@@ -105,6 +109,17 @@ impl SileroOnnxVad {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Err(err) = ensure_linux_onnxruntime_loaded() {
|
||||||
|
error!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
path = model_path,
|
||||||
|
error = %err,
|
||||||
|
"SileroOnnxVad: ONNX Runtime is unavailable on Linux; falling back to WebRtcFallbackVad"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let session_result = std::panic::catch_unwind(|| {
|
let session_result = std::panic::catch_unwind(|| {
|
||||||
ort::session::Session::builder().and_then(|mut b| b.commit_from_file(model_path))
|
ort::session::Session::builder().and_then(|mut b| b.commit_from_file(model_path))
|
||||||
});
|
});
|
||||||
@@ -301,6 +316,111 @@ impl VoiceActivityDetector for SileroOnnxVad {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn ensure_linux_onnxruntime_loaded() -> Result<(), String> {
|
||||||
|
static ORT_INIT: OnceLock<Result<(), String>> = OnceLock::new();
|
||||||
|
|
||||||
|
ORT_INIT
|
||||||
|
.get_or_init(|| {
|
||||||
|
let dylib_path = linux_onnxruntime_dylib_path();
|
||||||
|
match dylib_path.as_ref() {
|
||||||
|
Some(path) => {
|
||||||
|
tracing::info!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
path = %path.display(),
|
||||||
|
"SileroOnnxVad: loading Linux ONNX Runtime from discovered shared library"
|
||||||
|
);
|
||||||
|
ort::init_from(path)
|
||||||
|
.map_err(|e| format!("init_from({}): {e}", path.display()))?
|
||||||
|
.with_name("chanora_audio")
|
||||||
|
.commit()
|
||||||
|
.then_some(())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!("commit(init_from {}): environment already initialized", path.display())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
"SileroOnnxVad: no explicit Linux ONNX Runtime shared library path found; trying loader default"
|
||||||
|
);
|
||||||
|
ort::init()
|
||||||
|
.with_name("chanora_audio")
|
||||||
|
.commit()
|
||||||
|
.then_some(())
|
||||||
|
.ok_or_else(|| "commit(init): environment already initialized".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_onnxruntime_dylib_path() -> Option<PathBuf> {
|
||||||
|
if let Some(path) = existing_env_file("ORT_DYLIB_PATH") {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(exe_dir) = exe.parent() {
|
||||||
|
candidates.push(exe_dir.join("lib").join("libonnxruntime.so"));
|
||||||
|
candidates.push(exe_dir.join("libonnxruntime.so"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(cwd) = std::env::current_dir() {
|
||||||
|
candidates.push(cwd.join("libonnxruntime.so"));
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.extend([
|
||||||
|
PathBuf::from("/usr/lib/libonnxruntime.so"),
|
||||||
|
PathBuf::from("/usr/lib64/libonnxruntime.so"),
|
||||||
|
PathBuf::from("/usr/local/lib/libonnxruntime.so"),
|
||||||
|
PathBuf::from("/lib/x86_64-linux-gnu/libonnxruntime.so"),
|
||||||
|
PathBuf::from("/usr/lib/x86_64-linux-gnu/libonnxruntime.so"),
|
||||||
|
PathBuf::from("/lib/aarch64-linux-gnu/libonnxruntime.so"),
|
||||||
|
PathBuf::from("/usr/lib/aarch64-linux-gnu/libonnxruntime.so"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
first_matching_dir_entry("/usr/lib", "libonnxruntime.so")
|
||||||
|
.or_else(|| first_matching_dir_entry("/usr/lib64", "libonnxruntime.so"))
|
||||||
|
.or_else(|| first_matching_dir_entry("/usr/local/lib", "libonnxruntime.so"))
|
||||||
|
.or_else(|| first_matching_dir_entry("/usr/lib/x86_64-linux-gnu", "libonnxruntime.so"))
|
||||||
|
.or_else(|| first_matching_dir_entry("/usr/lib/aarch64-linux-gnu", "libonnxruntime.so"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn existing_env_file(name: &str) -> Option<PathBuf> {
|
||||||
|
let path = std::env::var_os(name).map(PathBuf::from)?;
|
||||||
|
path.is_file().then_some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn first_matching_dir_entry(dir: &str, prefix: &str) -> Option<PathBuf> {
|
||||||
|
let mut matches: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||||
|
.ok()?
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| {
|
||||||
|
path.is_file()
|
||||||
|
&& path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.is_some_and(|name| name.starts_with(prefix))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
matches.sort();
|
||||||
|
matches.into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
// SAFETY: ONNX Runtime sessions are thread-safe for inference.
|
// SAFETY: ONNX Runtime sessions are thread-safe for inference.
|
||||||
// State arrays are owned by this struct and accessed only from
|
// State arrays are owned by this struct and accessed only from
|
||||||
// the single capture callback thread.
|
// the single capture callback thread.
|
||||||
@@ -401,6 +521,8 @@ impl Drop for SileroOnnxVadWorker {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
fn make_stub_vad() -> SileroOnnxVad {
|
fn make_stub_vad() -> SileroOnnxVad {
|
||||||
SileroOnnxVad {
|
SileroOnnxVad {
|
||||||
@@ -496,4 +618,17 @@ mod tests {
|
|||||||
&completed_frame[SILERO_FRAME_16K - SILERO_CONTEXT_16K..]
|
&completed_frame[SILERO_FRAME_16K - SILERO_CONTEXT_16K..]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn existing_env_file_ignores_missing_paths() {
|
||||||
|
let var_name = format!("CHANORA_TEST_ORT_{}", std::process::id());
|
||||||
|
std::env::remove_var(&var_name);
|
||||||
|
assert!(existing_env_file(&var_name).is_none());
|
||||||
|
|
||||||
|
let missing = PathBuf::from("/definitely/missing/libonnxruntime.so");
|
||||||
|
std::env::set_var(&var_name, &missing);
|
||||||
|
assert!(existing_env_file(&var_name).is_none());
|
||||||
|
std::env::remove_var(&var_name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,8 @@ pub struct BridgeChannel {
|
|||||||
pub struct BridgeClient {
|
pub struct BridgeClient {
|
||||||
/// Stable client id.
|
/// Stable client id.
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
|
/// Stable TeamSpeak unique identifier.
|
||||||
|
pub uid: String,
|
||||||
/// Channel id the client is currently in.
|
/// Channel id the client is currently in.
|
||||||
pub channel: u64,
|
pub channel: u64,
|
||||||
/// Nickname.
|
/// Nickname.
|
||||||
@@ -418,6 +420,7 @@ impl From<chanora_core::ServerSnapshot> for BridgeSnapshot {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|c| BridgeClient {
|
.map(|c| BridgeClient {
|
||||||
id: c.id.0,
|
id: c.id.0,
|
||||||
|
uid: c.uid,
|
||||||
channel: c.channel.0,
|
channel: c.channel.0,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
input_muted: c.input_muted,
|
input_muted: c.input_muted,
|
||||||
|
|||||||
@@ -1940,6 +1940,7 @@ impl SseDecode for crate::api::BridgeClient {
|
|||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
let mut var_id = <u64>::sse_decode(deserializer);
|
let mut var_id = <u64>::sse_decode(deserializer);
|
||||||
|
let mut var_uid = <String>::sse_decode(deserializer);
|
||||||
let mut var_channel = <u64>::sse_decode(deserializer);
|
let mut var_channel = <u64>::sse_decode(deserializer);
|
||||||
let mut var_name = <String>::sse_decode(deserializer);
|
let mut var_name = <String>::sse_decode(deserializer);
|
||||||
let mut var_inputMuted = <bool>::sse_decode(deserializer);
|
let mut var_inputMuted = <bool>::sse_decode(deserializer);
|
||||||
@@ -1950,6 +1951,7 @@ impl SseDecode for crate::api::BridgeClient {
|
|||||||
let mut var_talkPowerGranted = <bool>::sse_decode(deserializer);
|
let mut var_talkPowerGranted = <bool>::sse_decode(deserializer);
|
||||||
return crate::api::BridgeClient {
|
return crate::api::BridgeClient {
|
||||||
id: var_id,
|
id: var_id,
|
||||||
|
uid: var_uid,
|
||||||
channel: var_channel,
|
channel: var_channel,
|
||||||
name: var_name,
|
name: var_name,
|
||||||
input_muted: var_inputMuted,
|
input_muted: var_inputMuted,
|
||||||
@@ -2796,6 +2798,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeClient {
|
|||||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
[
|
[
|
||||||
self.id.into_into_dart().into_dart(),
|
self.id.into_into_dart().into_dart(),
|
||||||
|
self.uid.into_into_dart().into_dart(),
|
||||||
self.channel.into_into_dart().into_dart(),
|
self.channel.into_into_dart().into_dart(),
|
||||||
self.name.into_into_dart().into_dart(),
|
self.name.into_into_dart().into_dart(),
|
||||||
self.input_muted.into_into_dart().into_dart(),
|
self.input_muted.into_into_dart().into_dart(),
|
||||||
@@ -3434,6 +3437,7 @@ impl SseEncode for crate::api::BridgeClient {
|
|||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
<u64>::sse_encode(self.id, serializer);
|
<u64>::sse_encode(self.id, serializer);
|
||||||
|
<String>::sse_encode(self.uid, serializer);
|
||||||
<u64>::sse_encode(self.channel, serializer);
|
<u64>::sse_encode(self.channel, serializer);
|
||||||
<String>::sse_encode(self.name, serializer);
|
<String>::sse_encode(self.name, serializer);
|
||||||
<bool>::sse_encode(self.input_muted, serializer);
|
<bool>::sse_encode(self.input_muted, serializer);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ publish.workspace = true
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
base64 = "0.22"
|
||||||
chanora_resolver = { path = "../chanora_resolver" }
|
chanora_resolver = { path = "../chanora_resolver" }
|
||||||
|
|
||||||
# tsclientlib is git-only and not on crates.io. The "audio" feature
|
# tsclientlib is git-only and not on crates.io. The "audio" feature
|
||||||
@@ -31,12 +32,14 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
[target.'cfg(any(target_os = "ios", target_os = "macos", target_os = "windows"))'.dependencies]
|
||||||
# Desktop/iOS: native TLS maps to the platform TLS backend (Security.framework
|
# Apple + Windows keep the platform TLS backend (Security.framework /
|
||||||
# on Apple, SChannel on Windows, system OpenSSL on Linux/BSD).
|
# SChannel). iOS in particular avoids the rustls/aws-lc path here.
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(any(target_os = "android", target_os = "linux"))'.dependencies]
|
||||||
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
|
# Android + Linux avoid a system OpenSSL dependency and use rustls instead.
|
||||||
# native-tls for Apple targets where aws-lc/rustls is problematic for iOS.
|
# This keeps Linux CI / dev hosts buildable without `openssl-devel`, while
|
||||||
|
# preserving the native-tls path for Apple targets where rustls/aws-lc has
|
||||||
|
# been problematic.
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] }
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use base64::prelude::*;
|
||||||
use chanora_resolver::ChanoraResolver;
|
use chanora_resolver::ChanoraResolver;
|
||||||
use futures::prelude::*;
|
use futures::prelude::*;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -975,7 +976,9 @@ fn send_text_message(
|
|||||||
) -> Result<MessageHandle, ProtocolError> {
|
) -> Result<MessageHandle, ProtocolError> {
|
||||||
use tsproto_types::TextMessageTargetMode;
|
use tsproto_types::TextMessageTargetMode;
|
||||||
match target {
|
match target {
|
||||||
MessageTarget::Server => send_text_to_mode(con, message, TextMessageTargetMode::Server, "server"),
|
MessageTarget::Server => {
|
||||||
|
send_text_to_mode(con, message, TextMessageTargetMode::Server, "server")
|
||||||
|
}
|
||||||
MessageTarget::Channel => {
|
MessageTarget::Channel => {
|
||||||
// Fix: previously channel messages were sent via
|
// Fix: previously channel messages were sent via
|
||||||
// state.server.send_textmessage() which always uses
|
// state.server.send_textmessage() which always uses
|
||||||
@@ -1216,6 +1219,11 @@ fn build_snapshot(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|c| ClientInfo {
|
.map(|c| ClientInfo {
|
||||||
id: ClientId(c.id.0 as u64),
|
id: ClientId(c.id.0 as u64),
|
||||||
|
uid: c
|
||||||
|
.uid
|
||||||
|
.as_ref()
|
||||||
|
.map(|uid| BASE64_STANDARD.encode(&uid.0))
|
||||||
|
.unwrap_or_default(),
|
||||||
channel: ChannelId(c.channel.0),
|
channel: ChannelId(c.channel.0),
|
||||||
name: sanitize(&c.name),
|
name: sanitize(&c.name),
|
||||||
input_muted: c.input_muted,
|
input_muted: c.input_muted,
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ pub struct ServerActivity {
|
|||||||
pub struct ClientInfo {
|
pub struct ClientInfo {
|
||||||
/// Stable client id.
|
/// Stable client id.
|
||||||
pub id: ClientId,
|
pub id: ClientId,
|
||||||
|
/// Stable TeamSpeak unique identifier for cross-session preference keys.
|
||||||
|
pub uid: String,
|
||||||
/// Channel the client is currently in.
|
/// Channel the client is currently in.
|
||||||
pub channel: ChannelId,
|
pub channel: ChannelId,
|
||||||
/// Nickname, preserved verbatim per ADR-008.
|
/// Nickname, preserved verbatim per ADR-008.
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Chanora Linux build notes
|
||||||
|
|
||||||
|
This document covers the current Linux desktop build and packaging flow for
|
||||||
|
`apps/chanora_flutter`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Linux host with desktop Flutter support enabled
|
||||||
|
- Rust stable
|
||||||
|
- Flutter stable on `PATH`
|
||||||
|
- CMake 3.13+
|
||||||
|
- `pkg-config`
|
||||||
|
- GTK3 development headers
|
||||||
|
- Opus development headers that expose the `opus` pkg-config module used by
|
||||||
|
tsclientlib/audiopus for Linux audio encode/decode
|
||||||
|
- PipeWire development headers that expose the `libpipewire-0.3` and
|
||||||
|
`libspa-0.2` pkg-config modules used by the primary Linux voice backend
|
||||||
|
- PulseAudio development headers for the fallback Linux voice backend
|
||||||
|
- `clang` and `gcc`
|
||||||
|
- sibling checkout of `oboe-rs` at `../oboe-rs`
|
||||||
|
|
||||||
|
Install `opus-devel` on Fedora-style hosts or `libopus-dev` on Debian/Ubuntu.
|
||||||
|
The workspace does not vendor or patch Opus for Linux; the normal Linux build
|
||||||
|
links the system `libopus` through `pkg-config`.
|
||||||
|
|
||||||
|
For Linux audio specifically, the product uses PipeWire directly as the primary
|
||||||
|
backend and PulseAudio directly as the fallback. If you want a fast preflight,
|
||||||
|
these are the package checks that matter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkg-config --modversion gtk+-3.0
|
||||||
|
pkg-config --modversion opus
|
||||||
|
pkg-config --modversion libpipewire-0.3
|
||||||
|
pkg-config --modversion libspa-0.2
|
||||||
|
pkg-config --modversion libpulse
|
||||||
|
pkg-config --modversion libpulse-simple
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `libpipewire-0.3`, not `pipewire-0.3`. The latter probe will fail even on a
|
||||||
|
correctly provisioned host because it is not the module name exported by the
|
||||||
|
PipeWire development package.
|
||||||
|
|
||||||
|
## Optional: bundle ONNX Runtime
|
||||||
|
|
||||||
|
If you want the Linux bundle to include ONNX Runtime for Silero VAD, export:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CHANORA_ONNXRUNTIME_SHARED_LIB=/absolute/path/to/libonnxruntime.so
|
||||||
|
```
|
||||||
|
|
||||||
|
When set, the Flutter Linux bundle will include:
|
||||||
|
|
||||||
|
```text
|
||||||
|
build/linux/x64/<mode>/bundle/lib/libonnxruntime.so
|
||||||
|
```
|
||||||
|
|
||||||
|
and the Linux runner will set `ORT_DYLIB_PATH` to that bundled file at app
|
||||||
|
startup when the variable is not already present.
|
||||||
|
|
||||||
|
## Build helper
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/build-linux.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The Flutter Linux CMake bundle now owns staging `libchanora_bridge.so` into
|
||||||
|
`bundle/lib/`. A plain `flutter build linux` will build the host-profile Rust
|
||||||
|
bridge automatically; `tools/build-linux.sh` exports
|
||||||
|
`CHANORA_BRIDGE_SHARED_LIB` so release bundles use the already-built targeted
|
||||||
|
artifact instead of duplicating the copy logic in shell.
|
||||||
|
|
||||||
|
Useful variants:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/build-linux.sh --no-rust
|
||||||
|
./tools/build-linux.sh --regenerate-bindings
|
||||||
|
./tools/build-linux.sh --version v0.2.0-beta.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debian package helper
|
||||||
|
|
||||||
|
To build a `.deb` package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/build-linux-deb.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If `CHANORA_ONNXRUNTIME_SHARED_LIB` is set, the Debian helper now verifies that
|
||||||
|
`libonnxruntime.so` was actually bundled before packaging.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,12 @@ Authority: [`docs/architecture/proof-of-concept-plan.md`](../docs/architecture/p
|
|||||||
| `diagnostics-redaction-spike` | yes | **PASS** — see `diagnostics-redaction-spike/VERIFICATION.md` |
|
| `diagnostics-redaction-spike` | yes | **PASS** — see `diagnostics-redaction-spike/VERIFICATION.md` |
|
||||||
| `audio-capture-playback-spike` (desktop half) | yes | **PASS** — see `audio-capture-playback-spike/VERIFICATION.md` |
|
| `audio-capture-playback-spike` (desktop half) | yes | **PASS** — see `audio-capture-playback-spike/VERIFICATION.md` |
|
||||||
| `audio-capture-playback-android-spike` (mobile half) | yes | **PASS** — see `audio-capture-playback-android-spike/VERIFICATION.md` |
|
| `audio-capture-playback-android-spike` (mobile half) | yes | **PASS** — see `audio-capture-playback-android-spike/VERIFICATION.md` |
|
||||||
|
| `linux_voice_test` | no (extra Linux backend spike) | **PASS** — see `linux_voice_test/VERIFICATION.md` |
|
||||||
|
| `ts3-manual-research` | no (extra TS3 protocol research spike) | **PASS** — see `ts3-manual-research/VERIFICATION.md` |
|
||||||
|
| `tsclientlib-query-spike` | no (extra TS3 protocol research spike) | **PASS** — see `tsclientlib-query-spike/VERIFICATION.md` |
|
||||||
|
| `tsclientlib-channel-query-spike` | no (extra TS3 protocol research spike) | **PASS** — see `tsclientlib-channel-query-spike/VERIFICATION.md` |
|
||||||
|
| `tsclientlib-filetransfer-spike` | no (extra TS3 protocol research spike) | **PASS** — see `tsclientlib-filetransfer-spike/VERIFICATION.md` |
|
||||||
|
| `tsidentity-import-spike` | no (extra TS3 protocol research spike) | **PASS** — see `tsidentity-import-spike/VERIFICATION.md` |
|
||||||
|
|
||||||
## Naming note
|
## Naming note
|
||||||
|
|
||||||
|
|||||||
Generated
+1293
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "linux_voice_test"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ctrlc = "3.5"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
libpulse-binding = "2.30"
|
||||||
|
libpulse-simple-binding = "2.29"
|
||||||
|
pipewire = { version = "0.10", features = ["v0_3_44"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
cpal = "0.16"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# linux_voice_test
|
||||||
|
|
||||||
|
Purpose: prove a Linux-native voice loopback path that enumerates input/output
|
||||||
|
devices and can target PipeWire or PulseAudio directly instead of relying on
|
||||||
|
`cpal`'s generic ALSA path.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- enumerate Linux capture and playback devices through PipeWire or PulseAudio
|
||||||
|
- open a selected input and output device pair
|
||||||
|
- run local microphone-to-speaker loopback at 48 kHz mono f32
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
- Chanora product integration
|
||||||
|
- Opus encode/decode
|
||||||
|
- jitter buffer, per-user playback controls, or persistent settings
|
||||||
|
- packaging and service lifecycle
|
||||||
|
|
||||||
|
Current validation:
|
||||||
|
- `cargo check` succeeds on the Fedora-based host after isolating the PoC from
|
||||||
|
the workspace with a local `[workspace]` table
|
||||||
|
- `pkg-config --modversion libpipewire-0.3` and `pkg-config --modversion
|
||||||
|
libspa-0.2` both succeed on that host; `pipewire-0.3` is not the correct
|
||||||
|
module name
|
||||||
|
- `cargo run -- --list-devices` succeeds and lists real PipeWire devices on the
|
||||||
|
host
|
||||||
|
|
||||||
|
Reproduction:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd poc/linux_voice_test
|
||||||
|
cargo check
|
||||||
|
cargo run -- --list-devices
|
||||||
|
cargo run -- --backend pipewire --input 0 --output 0
|
||||||
|
```
|
||||||
|
|
||||||
|
See `VERIFICATION.md` for the exact commands and observed results captured in
|
||||||
|
this repository session.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# linux_voice_test verification
|
||||||
|
|
||||||
|
Date: 2026-05-25
|
||||||
|
Host: Fedora-based Linux sandbox host
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkg-config --modversion libpipewire-0.3
|
||||||
|
pkg-config --modversion libspa-0.2
|
||||||
|
cd poc/linux_voice_test
|
||||||
|
cargo check
|
||||||
|
cargo run -- --list-devices
|
||||||
|
```
|
||||||
|
|
||||||
|
## Observed results
|
||||||
|
|
||||||
|
`pkg-config` resolved the product Linux capture dependencies successfully:
|
||||||
|
|
||||||
|
```text
|
||||||
|
libpipewire-0.3 -> 1.6.5
|
||||||
|
libspa-0.2 -> 0.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `pipewire-0.3` is not the correct pkg-config module name for the Rust
|
||||||
|
PipeWire crates used here; the expected module is `libpipewire-0.3`.
|
||||||
|
|
||||||
|
`cargo check` completed successfully after adding an empty `[workspace]` table
|
||||||
|
to `poc/linux_voice_test/Cargo.toml` so the PoC can build independently from
|
||||||
|
the root workspace.
|
||||||
|
|
||||||
|
`cargo run -- --list-devices` completed successfully and printed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Backend: pipewire
|
||||||
|
Input devices:
|
||||||
|
[0] Brio 500 Analog Stereo [43]
|
||||||
|
[1] Built-in Audio Analog Stereo [34]
|
||||||
|
Backend: pipewire
|
||||||
|
Output devices:
|
||||||
|
[0] Built-in Audio Digital Stereo (IEC958) [46]
|
||||||
|
[1] Navi 21/23 HDMI/DP Audio Controller Digital Stereo (HDMI 4) [50]
|
||||||
|
```
|
||||||
|
|
||||||
|
## What this does validate
|
||||||
|
|
||||||
|
- PipeWire crate dependencies compile on this host
|
||||||
|
- the host exposes the exact `libpipewire-0.3` and `libspa-0.2` pkg-config
|
||||||
|
modules used by the product Linux capture backend
|
||||||
|
- PipeWire node enumeration works for both input and output
|
||||||
|
- the PoC can target real device node ids reported by the running desktop audio
|
||||||
|
graph
|
||||||
|
|
||||||
|
## What this does not validate
|
||||||
|
|
||||||
|
- PulseAudio fallback runtime on this host
|
||||||
|
- full microphone-to-speaker loopback quality over an extended session
|
||||||
|
- Chanora product audio-engine integration
|
||||||
|
- push-to-talk, VAD, Opus, jitter buffering, or per-user playback controls
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub const SAMPLE_RATE: u32 = 48_000;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub const CHANNELS: u32 = 1;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub const BUFFER_FRAMES: usize = 256;
|
||||||
|
pub const RING_CAPACITY_SAMPLES: usize = 48_000 * 2;
|
||||||
|
|
||||||
|
pub type SharedBuffer = Arc<Mutex<VecDeque<f32>>>;
|
||||||
|
|
||||||
|
pub fn make_shared_buffer() -> SharedBuffer {
|
||||||
|
Arc::new(Mutex::new(VecDeque::with_capacity(RING_CAPACITY_SAMPLES)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_sample(buffer: &SharedBuffer, sample: f32) {
|
||||||
|
let mut queue = buffer.lock().expect("audio buffer mutex poisoned");
|
||||||
|
if queue.len() >= RING_CAPACITY_SAMPLES {
|
||||||
|
let _ = queue.pop_front();
|
||||||
|
}
|
||||||
|
queue.push_back(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pop_sample(buffer: &SharedBuffer) -> f32 {
|
||||||
|
let mut queue = buffer.lock().expect("audio buffer mutex poisoned");
|
||||||
|
queue.pop_front().unwrap_or(0.0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
use crate::audio_buffer::{
|
||||||
|
make_shared_buffer, pop_sample, push_sample, SharedBuffer, CHANNELS, SAMPLE_RATE,
|
||||||
|
};
|
||||||
|
use crate::linux_shared::{
|
||||||
|
sort_devices, wait_for_startup, LinuxAudioBackend, LinuxDevice, LinuxLoopbackSession,
|
||||||
|
ThreadedLoopbackSession,
|
||||||
|
};
|
||||||
|
use pipewire as pw;
|
||||||
|
use pw::spa::pod::Pod;
|
||||||
|
use pw::{properties::properties, spa};
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
|
||||||
|
use std::sync::{mpsc, Arc};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub struct PipeWireBackend;
|
||||||
|
|
||||||
|
impl LinuxAudioBackend for PipeWireBackend {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"pipewire"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_input_devices(&self) -> Result<Vec<LinuxDevice>, String> {
|
||||||
|
list_nodes("Audio/Source")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_output_devices(&self) -> Result<Vec<LinuxDevice>, String> {
|
||||||
|
list_nodes("Audio/Sink")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_loopback(
|
||||||
|
&self,
|
||||||
|
input: &LinuxDevice,
|
||||||
|
output: &LinuxDevice,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) -> Result<Box<dyn LinuxLoopbackSession>, String> {
|
||||||
|
let input_id = input
|
||||||
|
.id
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| format!("invalid PipeWire input node id `{}`", input.id))?;
|
||||||
|
let output_id = output
|
||||||
|
.id
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| format!("invalid PipeWire output node id `{}`", output.id))?;
|
||||||
|
|
||||||
|
let buffer = make_shared_buffer();
|
||||||
|
let capture = spawn_capture(buffer.clone(), input_id, stop.clone())?;
|
||||||
|
let playback = match spawn_playback(buffer, output_id, stop.clone()) {
|
||||||
|
Ok(handle) => handle,
|
||||||
|
Err(err) => {
|
||||||
|
stop.store(true, AtomicOrdering::SeqCst);
|
||||||
|
let _ = capture.join();
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Box::new(ThreadedLoopbackSession {
|
||||||
|
capture,
|
||||||
|
playback,
|
||||||
|
stop,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_nodes(class_prefix: &str) -> Result<Vec<LinuxDevice>, String> {
|
||||||
|
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
|
||||||
|
let context = pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
|
||||||
|
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
|
||||||
|
let registry = core.get_registry().map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let done = Rc::new(Cell::new(false));
|
||||||
|
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
|
||||||
|
|
||||||
|
let devices_for_registry = devices.clone();
|
||||||
|
let class_prefix = class_prefix.to_string();
|
||||||
|
let class_prefix_for_registry = class_prefix.clone();
|
||||||
|
let _registry_listener = registry
|
||||||
|
.add_listener_local()
|
||||||
|
.global(move |global| {
|
||||||
|
if global.type_ != pw::types::ObjectType::Node {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(props) = global.props else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(media_class) = props.get(*pw::keys::MEDIA_CLASS) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !media_class.starts_with(class_prefix_for_registry.as_str()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = props
|
||||||
|
.get(*pw::keys::NODE_DESCRIPTION)
|
||||||
|
.or_else(|| props.get(*pw::keys::NODE_NICK))
|
||||||
|
.or_else(|| props.get(*pw::keys::NODE_NAME))
|
||||||
|
.unwrap_or("Unnamed PipeWire node")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
devices_for_registry.borrow_mut().push(LinuxDevice {
|
||||||
|
id: global.id.to_string(),
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.register();
|
||||||
|
|
||||||
|
let pending = core.sync(0).map_err(|err| err.to_string())?;
|
||||||
|
let done_for_core = done.clone();
|
||||||
|
let loop_for_core = mainloop.clone();
|
||||||
|
let _core_listener = core
|
||||||
|
.add_listener_local()
|
||||||
|
.done(move |id, seq| {
|
||||||
|
if id == pw::core::PW_ID_CORE && seq == pending {
|
||||||
|
done_for_core.set(true);
|
||||||
|
loop_for_core.quit();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.error(move |_id, _seq, _res, message| {
|
||||||
|
eprintln!("PipeWire core error: {message}");
|
||||||
|
})
|
||||||
|
.register();
|
||||||
|
|
||||||
|
while !done.get() {
|
||||||
|
mainloop.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut devices = devices.borrow().clone();
|
||||||
|
devices.sort_by(|left, right| sort_devices(left, right));
|
||||||
|
|
||||||
|
if devices.is_empty() {
|
||||||
|
return Err(format!("no PipeWire nodes found for {class_prefix}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_capture(
|
||||||
|
buffer: SharedBuffer,
|
||||||
|
source_id: u32,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) -> Result<JoinHandle<Result<(), String>>, String> {
|
||||||
|
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
|
||||||
|
let context =
|
||||||
|
pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
|
||||||
|
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let mut props = properties! {
|
||||||
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
|
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||||||
|
*pw::keys::MEDIA_ROLE => "Communication",
|
||||||
|
};
|
||||||
|
let target = source_id.to_string();
|
||||||
|
props.insert(*pw::keys::TARGET_OBJECT, target.as_str());
|
||||||
|
|
||||||
|
let stream = pw::stream::StreamBox::new(&core, "linux-voice-test-capture", props)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let _listener = stream
|
||||||
|
.add_local_listener_with_user_data(buffer)
|
||||||
|
.process(|stream, queue| {
|
||||||
|
if let Some(mut buffer) = stream.dequeue_buffer() {
|
||||||
|
let datas = buffer.datas_mut();
|
||||||
|
if datas.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data_ref = &mut datas[0];
|
||||||
|
let byte_count = data_ref.chunk().size() as usize;
|
||||||
|
let Some(bytes) = data_ref.data() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for chunk in bytes[..byte_count].chunks_exact(4) {
|
||||||
|
let sample = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
|
||||||
|
push_sample(queue, sample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.register()
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let values = build_format_param()?;
|
||||||
|
let mut params = [Pod::from_bytes(&values).ok_or("invalid PipeWire capture format pod")?];
|
||||||
|
|
||||||
|
stream
|
||||||
|
.connect(
|
||||||
|
spa::utils::Direction::Input,
|
||||||
|
None,
|
||||||
|
pw::stream::StreamFlags::AUTOCONNECT
|
||||||
|
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||||
|
| pw::stream::StreamFlags::RT_PROCESS,
|
||||||
|
&mut params,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let _ = ready_tx.send(Ok(()));
|
||||||
|
|
||||||
|
while !stop.load(AtomicOrdering::SeqCst) {
|
||||||
|
let _ = mainloop
|
||||||
|
.loop_()
|
||||||
|
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
mainloop.quit();
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
wait_for_startup(ready_rx, handle, "PipeWire capture")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_playback(
|
||||||
|
buffer: SharedBuffer,
|
||||||
|
sink_id: u32,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) -> Result<JoinHandle<Result<(), String>>, String> {
|
||||||
|
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
|
||||||
|
let context =
|
||||||
|
pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
|
||||||
|
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let mut props = properties! {
|
||||||
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
|
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||||
|
*pw::keys::MEDIA_ROLE => "Communication",
|
||||||
|
};
|
||||||
|
let target = sink_id.to_string();
|
||||||
|
props.insert(*pw::keys::TARGET_OBJECT, target.as_str());
|
||||||
|
|
||||||
|
let stream = pw::stream::StreamBox::new(&core, "linux-voice-test-playback", props)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let _listener = stream
|
||||||
|
.add_local_listener_with_user_data(buffer)
|
||||||
|
.process(|stream, queue| {
|
||||||
|
if let Some(mut buffer) = stream.dequeue_buffer() {
|
||||||
|
let datas = buffer.datas_mut();
|
||||||
|
if datas.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data_ref = &mut datas[0];
|
||||||
|
let byte_len = {
|
||||||
|
let Some(bytes) = data_ref.data() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for chunk in bytes.chunks_exact_mut(4) {
|
||||||
|
chunk.copy_from_slice(&pop_sample(queue).to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes.len()
|
||||||
|
};
|
||||||
|
let chunk = data_ref.chunk_mut();
|
||||||
|
*chunk.offset_mut() = 0;
|
||||||
|
*chunk.stride_mut() = 4;
|
||||||
|
*chunk.size_mut() = byte_len as u32;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.register()
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let values = build_format_param()?;
|
||||||
|
let mut params = [Pod::from_bytes(&values).ok_or("invalid PipeWire playback format pod")?];
|
||||||
|
|
||||||
|
stream
|
||||||
|
.connect(
|
||||||
|
spa::utils::Direction::Output,
|
||||||
|
None,
|
||||||
|
pw::stream::StreamFlags::AUTOCONNECT
|
||||||
|
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||||
|
| pw::stream::StreamFlags::RT_PROCESS,
|
||||||
|
&mut params,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let _ = ready_tx.send(Ok(()));
|
||||||
|
|
||||||
|
while !stop.load(AtomicOrdering::SeqCst) {
|
||||||
|
let _ = mainloop
|
||||||
|
.loop_()
|
||||||
|
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
mainloop.quit();
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
wait_for_startup(ready_rx, handle, "PipeWire playback")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_format_param() -> Result<Vec<u8>, String> {
|
||||||
|
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
|
||||||
|
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
|
||||||
|
audio_info.set_rate(SAMPLE_RATE);
|
||||||
|
audio_info.set_channels(CHANNELS);
|
||||||
|
|
||||||
|
let object = pw::spa::pod::Object {
|
||||||
|
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||||
|
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
|
||||||
|
properties: audio_info.into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
pw::spa::pod::serialize::PodSerializer::serialize(
|
||||||
|
std::io::Cursor::new(Vec::new()),
|
||||||
|
&pw::spa::pod::Value::Object(object),
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
.map(|serializer| serializer.0.into_inner())
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
use crate::audio_buffer::{
|
||||||
|
make_shared_buffer, pop_sample, push_sample, SharedBuffer, BUFFER_FRAMES, CHANNELS, SAMPLE_RATE,
|
||||||
|
};
|
||||||
|
use crate::linux_shared::{
|
||||||
|
sort_devices, wait_for_startup, LinuxAudioBackend, LinuxDevice, LinuxLoopbackSession,
|
||||||
|
ThreadedLoopbackSession,
|
||||||
|
};
|
||||||
|
use libpulse_binding as pulse;
|
||||||
|
use libpulse_simple_binding as psimple;
|
||||||
|
use pulse::callbacks::ListResult;
|
||||||
|
use pulse::context::{self, Context};
|
||||||
|
use pulse::mainloop::threaded::Mainloop;
|
||||||
|
use pulse::proplist::Proplist;
|
||||||
|
use pulse::sample::{Format, Spec};
|
||||||
|
use pulse::stream::Direction;
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
|
||||||
|
use std::sync::{mpsc, Arc};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
|
pub struct PulseAudioBackend;
|
||||||
|
|
||||||
|
impl LinuxAudioBackend for PulseAudioBackend {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"pulseaudio"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_input_devices(&self) -> Result<Vec<LinuxDevice>, String> {
|
||||||
|
with_pulse_context(|context, mainloop| {
|
||||||
|
let done = Rc::new(Cell::new(false));
|
||||||
|
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
|
||||||
|
|
||||||
|
let done_for_cb = done.clone();
|
||||||
|
let devices_for_cb = devices.clone();
|
||||||
|
let mainloop_for_cb = mainloop.clone();
|
||||||
|
let introspector = context.borrow().introspect();
|
||||||
|
let _operation = introspector.get_source_info_list(move |result| match result {
|
||||||
|
ListResult::Item(info) => {
|
||||||
|
if info.monitor_of_sink.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(name) = info.name.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let label = info
|
||||||
|
.description
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| name.to_string());
|
||||||
|
|
||||||
|
devices_for_cb.borrow_mut().push(LinuxDevice {
|
||||||
|
id: name.to_string(),
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ListResult::End | ListResult::Error => {
|
||||||
|
done_for_cb.set(true);
|
||||||
|
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
while !done.get() {
|
||||||
|
mainloop.borrow_mut().wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut devices = devices.borrow().clone();
|
||||||
|
devices.sort_by(|left, right| sort_devices(left, right));
|
||||||
|
|
||||||
|
if devices.is_empty() {
|
||||||
|
return Err("no PulseAudio sources found".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(devices)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_output_devices(&self) -> Result<Vec<LinuxDevice>, String> {
|
||||||
|
with_pulse_context(|context, mainloop| {
|
||||||
|
let done = Rc::new(Cell::new(false));
|
||||||
|
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
|
||||||
|
|
||||||
|
let done_for_cb = done.clone();
|
||||||
|
let devices_for_cb = devices.clone();
|
||||||
|
let mainloop_for_cb = mainloop.clone();
|
||||||
|
let introspector = context.borrow().introspect();
|
||||||
|
let _operation = introspector.get_sink_info_list(move |result| match result {
|
||||||
|
ListResult::Item(info) => {
|
||||||
|
let Some(name) = info.name.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let label = info
|
||||||
|
.description
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| name.to_string());
|
||||||
|
|
||||||
|
devices_for_cb.borrow_mut().push(LinuxDevice {
|
||||||
|
id: name.to_string(),
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ListResult::End | ListResult::Error => {
|
||||||
|
done_for_cb.set(true);
|
||||||
|
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
while !done.get() {
|
||||||
|
mainloop.borrow_mut().wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut devices = devices.borrow().clone();
|
||||||
|
devices.sort_by(|left, right| sort_devices(left, right));
|
||||||
|
|
||||||
|
if devices.is_empty() {
|
||||||
|
return Err("no PulseAudio sinks found".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(devices)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_loopback(
|
||||||
|
&self,
|
||||||
|
input: &LinuxDevice,
|
||||||
|
output: &LinuxDevice,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) -> Result<Box<dyn LinuxLoopbackSession>, String> {
|
||||||
|
let buffer = make_shared_buffer();
|
||||||
|
let capture = spawn_capture(buffer.clone(), input.id.clone(), stop.clone())?;
|
||||||
|
let playback = match spawn_playback(buffer, output.id.clone(), stop.clone()) {
|
||||||
|
Ok(handle) => handle,
|
||||||
|
Err(err) => {
|
||||||
|
stop.store(true, AtomicOrdering::SeqCst);
|
||||||
|
let _ = capture.join();
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Box::new(ThreadedLoopbackSession {
|
||||||
|
capture,
|
||||||
|
playback,
|
||||||
|
stop,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_pulse_context<T, F>(operation: F) -> Result<T, String>
|
||||||
|
where
|
||||||
|
F: FnOnce(&Rc<RefCell<Context>>, &Rc<RefCell<Mainloop>>) -> Result<T, String>,
|
||||||
|
{
|
||||||
|
let proplist = build_proplist()?;
|
||||||
|
let mainloop = Rc::new(RefCell::new(
|
||||||
|
Mainloop::new().ok_or("failed to create PulseAudio mainloop")?,
|
||||||
|
));
|
||||||
|
let context = Rc::new(RefCell::new(
|
||||||
|
Context::new_with_proplist(&*mainloop.borrow(), "linux-voice-test", &proplist)
|
||||||
|
.ok_or("failed to create PulseAudio context")?,
|
||||||
|
));
|
||||||
|
|
||||||
|
let mainloop_for_cb = mainloop.clone();
|
||||||
|
let context_for_cb = context.clone();
|
||||||
|
context
|
||||||
|
.borrow_mut()
|
||||||
|
.set_state_callback(Some(Box::new(move || {
|
||||||
|
let state = unsafe { (*context_for_cb.as_ptr()).get_state() };
|
||||||
|
match state {
|
||||||
|
context::State::Ready | context::State::Failed | context::State::Terminated => {
|
||||||
|
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
|
||||||
|
context
|
||||||
|
.borrow_mut()
|
||||||
|
.connect(None, context::FlagSet::NOFLAGS, None)
|
||||||
|
.map_err(pa_error)?;
|
||||||
|
|
||||||
|
mainloop.borrow_mut().lock();
|
||||||
|
mainloop.borrow_mut().start().map_err(pa_error)?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match context.borrow().get_state() {
|
||||||
|
context::State::Ready => break,
|
||||||
|
context::State::Failed | context::State::Terminated => {
|
||||||
|
mainloop.borrow_mut().unlock();
|
||||||
|
mainloop.borrow_mut().stop();
|
||||||
|
return Err(format!(
|
||||||
|
"PulseAudio context state {:?}",
|
||||||
|
context.borrow().get_state()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => mainloop.borrow_mut().wait(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.borrow_mut().set_state_callback(None);
|
||||||
|
|
||||||
|
let result = operation(&context, &mainloop);
|
||||||
|
mainloop.borrow_mut().unlock();
|
||||||
|
mainloop.borrow_mut().stop();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_capture(
|
||||||
|
buffer: SharedBuffer,
|
||||||
|
source_name: String,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) -> Result<JoinHandle<Result<(), String>>, String> {
|
||||||
|
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let spec = pulse_spec();
|
||||||
|
let stream = psimple::Simple::new(
|
||||||
|
None,
|
||||||
|
"linux-voice-test",
|
||||||
|
Direction::Record,
|
||||||
|
Some(source_name.as_str()),
|
||||||
|
"linux-voice-test-record",
|
||||||
|
&spec,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(pa_error)?;
|
||||||
|
|
||||||
|
let _ = ready_tx.send(Ok(()));
|
||||||
|
|
||||||
|
let mut bytes = vec![0_u8; BUFFER_FRAMES * std::mem::size_of::<f32>()];
|
||||||
|
while !stop.load(AtomicOrdering::SeqCst) {
|
||||||
|
stream.read(&mut bytes).map_err(pa_error)?;
|
||||||
|
for chunk in bytes.chunks_exact(4) {
|
||||||
|
let sample = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
|
||||||
|
push_sample(&buffer, sample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.flush().map_err(pa_error)?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
wait_for_startup(ready_rx, handle, "PulseAudio capture")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_playback(
|
||||||
|
buffer: SharedBuffer,
|
||||||
|
sink_name: String,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) -> Result<JoinHandle<Result<(), String>>, String> {
|
||||||
|
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let spec = pulse_spec();
|
||||||
|
let stream = psimple::Simple::new(
|
||||||
|
None,
|
||||||
|
"linux-voice-test",
|
||||||
|
Direction::Playback,
|
||||||
|
Some(sink_name.as_str()),
|
||||||
|
"linux-voice-test-playback",
|
||||||
|
&spec,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(pa_error)?;
|
||||||
|
|
||||||
|
let _ = ready_tx.send(Ok(()));
|
||||||
|
|
||||||
|
let mut bytes = vec![0_u8; BUFFER_FRAMES * std::mem::size_of::<f32>()];
|
||||||
|
while !stop.load(AtomicOrdering::SeqCst) {
|
||||||
|
for chunk in bytes.chunks_exact_mut(4) {
|
||||||
|
chunk.copy_from_slice(&pop_sample(&buffer).to_le_bytes());
|
||||||
|
}
|
||||||
|
stream.write(&bytes).map_err(pa_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.drain().map_err(pa_error)?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
wait_for_startup(ready_rx, handle, "PulseAudio playback")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_proplist() -> Result<Proplist, String> {
|
||||||
|
let mut proplist = Proplist::new().ok_or("failed to create PulseAudio proplist")?;
|
||||||
|
proplist
|
||||||
|
.set_str(
|
||||||
|
pulse::proplist::properties::APPLICATION_NAME,
|
||||||
|
"linux-voice-test",
|
||||||
|
)
|
||||||
|
.map_err(|_| "failed to set PulseAudio application name".to_string())?;
|
||||||
|
Ok(proplist)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pulse_spec() -> Spec {
|
||||||
|
let spec = Spec {
|
||||||
|
format: Format::F32le,
|
||||||
|
channels: CHANNELS as u8,
|
||||||
|
rate: SAMPLE_RATE,
|
||||||
|
};
|
||||||
|
debug_assert!(spec.is_valid());
|
||||||
|
spec
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pa_error(err: pulse::error::PAErr) -> String {
|
||||||
|
err.to_string()
|
||||||
|
.unwrap_or_else(|| format!("PulseAudio error {err:?}"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
use crate::linux_pipewire::PipeWireBackend;
|
||||||
|
use crate::linux_pulseaudio::PulseAudioBackend;
|
||||||
|
use std::env;
|
||||||
|
use std::fmt;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LinuxDevice {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for LinuxDevice {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{} [{}]", self.label, self.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait LinuxLoopbackSession {
|
||||||
|
fn wait(self: Box<Self>) -> Result<(), String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait LinuxAudioBackend {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
fn list_input_devices(&self) -> Result<Vec<LinuxDevice>, String>;
|
||||||
|
fn list_output_devices(&self) -> Result<Vec<LinuxDevice>, String>;
|
||||||
|
fn start_loopback(
|
||||||
|
&self,
|
||||||
|
input: &LinuxDevice,
|
||||||
|
output: &LinuxDevice,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) -> Result<Box<dyn LinuxLoopbackSession>, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum LinuxBackendKind {
|
||||||
|
Auto,
|
||||||
|
PipeWire,
|
||||||
|
PulseAudio,
|
||||||
|
}
|
||||||
|
|
||||||
|
const USAGE: &str = concat!(
|
||||||
|
"usage: cargo run -- [--backend auto|pipewire|pulseaudio] [--list-devices] ",
|
||||||
|
"[--input <index|id|label>] [--output <index|id|label>]\n\n",
|
||||||
|
"Examples:\n",
|
||||||
|
" cargo run -- --list-devices\n",
|
||||||
|
" cargo run -- --backend pipewire --list-devices\n",
|
||||||
|
" cargo run -- --input 0 --output 1\n",
|
||||||
|
" cargo run -- --backend pulseaudio --input alsa_input.pci-0000_00_1f.3.analog-stereo ",
|
||||||
|
"--output alsa_output.pci-0000_00_1f.3.analog-stereo"
|
||||||
|
);
|
||||||
|
|
||||||
|
pub fn run_from_env() -> Result<(), String> {
|
||||||
|
let args: Vec<String> = env::args().skip(1).collect();
|
||||||
|
let cli = parse_cli(&args)?;
|
||||||
|
let backend = create_backend(cli.backend)?;
|
||||||
|
let inputs = backend.list_input_devices()?;
|
||||||
|
let outputs = backend.list_output_devices()?;
|
||||||
|
|
||||||
|
if cli.list_devices {
|
||||||
|
print_devices(backend.name(), "Input devices:", &inputs);
|
||||||
|
print_devices(backend.name(), "Output devices:", &outputs);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = select_device("input", &inputs, cli.input.as_deref())?;
|
||||||
|
let output = select_device("output", &outputs, cli.output.as_deref())?;
|
||||||
|
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop_for_handler = stop.clone();
|
||||||
|
ctrlc::set_handler(move || {
|
||||||
|
stop_for_handler.store(true, Ordering::SeqCst);
|
||||||
|
})
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
println!("Backend: {}", backend.name());
|
||||||
|
println!("Input: {input}");
|
||||||
|
println!("Output: {output}");
|
||||||
|
println!("Streaming. Press Ctrl+C to stop.");
|
||||||
|
|
||||||
|
let session = backend.start_loopback(&input, &output, stop.clone())?;
|
||||||
|
|
||||||
|
while !stop.load(Ordering::SeqCst) {
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
session.wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LinuxCli {
|
||||||
|
backend: LinuxBackendKind,
|
||||||
|
list_devices: bool,
|
||||||
|
input: Option<String>,
|
||||||
|
output: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_cli(args: &[String]) -> Result<LinuxCli, String> {
|
||||||
|
let mut backend = LinuxBackendKind::Auto;
|
||||||
|
let mut list_devices = false;
|
||||||
|
let mut input = None;
|
||||||
|
let mut output = None;
|
||||||
|
let mut index = 0;
|
||||||
|
|
||||||
|
while index < args.len() {
|
||||||
|
match args[index].as_str() {
|
||||||
|
"--backend" => {
|
||||||
|
index += 1;
|
||||||
|
let value = args
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| format!("missing value for --backend\n{USAGE}"))?;
|
||||||
|
backend = match value.as_str() {
|
||||||
|
"auto" => LinuxBackendKind::Auto,
|
||||||
|
"pipewire" => LinuxBackendKind::PipeWire,
|
||||||
|
"pulseaudio" => LinuxBackendKind::PulseAudio,
|
||||||
|
_ => return Err(format!("unsupported backend `{value}`\n{USAGE}")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"--list-devices" => {
|
||||||
|
list_devices = true;
|
||||||
|
}
|
||||||
|
"--input" => {
|
||||||
|
index += 1;
|
||||||
|
input = Some(
|
||||||
|
args.get(index)
|
||||||
|
.ok_or_else(|| format!("missing value for --input\n{USAGE}"))?
|
||||||
|
.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--output" => {
|
||||||
|
index += 1;
|
||||||
|
output = Some(
|
||||||
|
args.get(index)
|
||||||
|
.ok_or_else(|| format!("missing value for --output\n{USAGE}"))?
|
||||||
|
.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--help" | "-h" => {
|
||||||
|
return Err(USAGE.to_string());
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(format!("unexpected argument `{other}`\n{USAGE}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(LinuxCli {
|
||||||
|
backend,
|
||||||
|
list_devices,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_backend(kind: LinuxBackendKind) -> Result<Box<dyn LinuxAudioBackend>, String> {
|
||||||
|
match kind {
|
||||||
|
LinuxBackendKind::Auto => {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
match create_backend(LinuxBackendKind::PipeWire) {
|
||||||
|
Ok(backend) => return Ok(backend),
|
||||||
|
Err(err) => errors.push(format!("pipewire: {err}")),
|
||||||
|
}
|
||||||
|
|
||||||
|
match create_backend(LinuxBackendKind::PulseAudio) {
|
||||||
|
Ok(backend) => return Ok(backend),
|
||||||
|
Err(err) => errors.push(format!("pulseaudio: {err}")),
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"failed to initialize a Linux audio backend\n{}",
|
||||||
|
errors.join("\n")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
LinuxBackendKind::PipeWire => {
|
||||||
|
pipewire::init();
|
||||||
|
Ok(Box::new(PipeWireBackend))
|
||||||
|
}
|
||||||
|
LinuxBackendKind::PulseAudio => Ok(Box::new(PulseAudioBackend)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_device(
|
||||||
|
kind: &str,
|
||||||
|
items: &[LinuxDevice],
|
||||||
|
selector: Option<&str>,
|
||||||
|
) -> Result<LinuxDevice, String> {
|
||||||
|
match selector {
|
||||||
|
Some(selector) => resolve_device_selector(kind, items, selector),
|
||||||
|
None => prompt_choice(&format!("{kind} devices:"), items),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_device_selector(
|
||||||
|
kind: &str,
|
||||||
|
items: &[LinuxDevice],
|
||||||
|
selector: &str,
|
||||||
|
) -> Result<LinuxDevice, String> {
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err(format!("no {kind} devices available"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(index) = selector.parse::<usize>() {
|
||||||
|
return items
|
||||||
|
.get(index)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("{kind} device index `{selector}` is out of range"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut matches = items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.id == selector || item.label == selector)
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
let Some(device) = matches.next() else {
|
||||||
|
return Err(format!(
|
||||||
|
"no {kind} device matched `{selector}`\nRun with `--list-devices` to inspect valid choices."
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches.next().is_some() {
|
||||||
|
return Err(format!(
|
||||||
|
"multiple {kind} devices matched `{selector}`\nUse the numeric index from `--list-devices` or the exact device id."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_devices(backend_name: &str, title: &str, items: &[LinuxDevice]) {
|
||||||
|
println!("Backend: {backend_name}");
|
||||||
|
println!("{title}");
|
||||||
|
for (index, item) in items.iter().enumerate() {
|
||||||
|
println!(" [{index}] {item}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_choice(title: &str, items: &[LinuxDevice]) -> Result<LinuxDevice, String> {
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err(format!("no items available for `{title}`"));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{title}");
|
||||||
|
for (index, item) in items.iter().enumerate() {
|
||||||
|
println!(" [{index}] {item}");
|
||||||
|
}
|
||||||
|
|
||||||
|
print!("Select number: ");
|
||||||
|
io::stdout().flush().map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin()
|
||||||
|
.read_line(&mut input)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let choice = input
|
||||||
|
.trim()
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| "invalid selection".to_string())?;
|
||||||
|
|
||||||
|
items
|
||||||
|
.get(choice)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "selection out of range".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ThreadedLoopbackSession {
|
||||||
|
pub capture: JoinHandle<Result<(), String>>,
|
||||||
|
pub playback: JoinHandle<Result<(), String>>,
|
||||||
|
pub stop: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxLoopbackSession for ThreadedLoopbackSession {
|
||||||
|
fn wait(self: Box<Self>) -> Result<(), String> {
|
||||||
|
let ThreadedLoopbackSession {
|
||||||
|
capture,
|
||||||
|
playback,
|
||||||
|
stop,
|
||||||
|
} = *self;
|
||||||
|
stop.store(true, Ordering::SeqCst);
|
||||||
|
join_backend_thread(capture)?;
|
||||||
|
join_backend_thread(playback)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wait_for_startup(
|
||||||
|
ready_rx: mpsc::Receiver<Result<(), String>>,
|
||||||
|
handle: JoinHandle<Result<(), String>>,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<JoinHandle<Result<(), String>>, String> {
|
||||||
|
match ready_rx.recv() {
|
||||||
|
Ok(Ok(())) => Ok(handle),
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
let _ = handle.join();
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let _ = handle.join();
|
||||||
|
Err(format!("{label} startup failed: {err}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join_backend_thread(handle: JoinHandle<Result<(), String>>) -> Result<(), String> {
|
||||||
|
match handle.join() {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => Err("backend thread panicked".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort_devices(left: &LinuxDevice, right: &LinuxDevice) -> std::cmp::Ordering {
|
||||||
|
left.label
|
||||||
|
.cmp(&right.label)
|
||||||
|
.then_with(|| left.id.cmp(&right.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn device(id: &str, label: &str) -> LinuxDevice {
|
||||||
|
LinuxDevice {
|
||||||
|
id: id.to_string(),
|
||||||
|
label: label.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_defaults_to_auto_backend() {
|
||||||
|
let cli = parse_cli(&[]).expect("parse_cli should accept empty args");
|
||||||
|
assert!(matches!(cli.backend, LinuxBackendKind::Auto));
|
||||||
|
assert!(!cli.list_devices);
|
||||||
|
assert_eq!(cli.input, None);
|
||||||
|
assert_eq!(cli.output, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_reads_non_interactive_selection() {
|
||||||
|
let cli = parse_cli(&[
|
||||||
|
"--backend".into(),
|
||||||
|
"pulseaudio".into(),
|
||||||
|
"--input".into(),
|
||||||
|
"1".into(),
|
||||||
|
"--output".into(),
|
||||||
|
"sink-id".into(),
|
||||||
|
"--list-devices".into(),
|
||||||
|
])
|
||||||
|
.expect("parse_cli should parse explicit flags");
|
||||||
|
|
||||||
|
assert!(matches!(cli.backend, LinuxBackendKind::PulseAudio));
|
||||||
|
assert!(cli.list_devices);
|
||||||
|
assert_eq!(cli.input.as_deref(), Some("1"));
|
||||||
|
assert_eq!(cli.output.as_deref(), Some("sink-id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_device_selector_supports_index_and_exact_id() {
|
||||||
|
let devices = vec![device("mic-id", "Mic"), device("sink-id", "Sink")];
|
||||||
|
|
||||||
|
let by_index = resolve_device_selector("input", &devices, "0")
|
||||||
|
.expect("selector should resolve by index");
|
||||||
|
assert_eq!(by_index.id, "mic-id");
|
||||||
|
|
||||||
|
let by_id = resolve_device_selector("output", &devices, "sink-id")
|
||||||
|
.expect("selector should resolve by id");
|
||||||
|
assert_eq!(by_id.label, "Sink");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_device_selector_rejects_ambiguous_labels() {
|
||||||
|
let devices = vec![device("mic-1", "Mic"), device("mic-2", "Mic")];
|
||||||
|
let err = resolve_device_selector("input", &devices, "Mic")
|
||||||
|
.expect_err("selector should reject ambiguous labels");
|
||||||
|
assert!(err.contains("multiple input devices matched `Mic`"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
mod audio_buffer;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux_pipewire;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux_pulseaudio;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux_shared;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows_cpal;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn main() -> Result<(), String> {
|
||||||
|
linux_shared::run_from_env()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn main() -> Result<(), String> {
|
||||||
|
windows_cpal::run()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
|
fn main() {
|
||||||
|
eprintln!("This POC currently supports Windows via cpal and Linux via PipeWire or PulseAudio.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
use crate::audio_buffer::{make_shared_buffer, pop_sample, push_sample};
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use cpal::{SampleFormat, Stream, StreamConfig};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub fn run() -> Result<(), String> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let input = host
|
||||||
|
.default_input_device()
|
||||||
|
.ok_or("no default input device available")?;
|
||||||
|
let output = host
|
||||||
|
.default_output_device()
|
||||||
|
.ok_or("no default output device available")?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Windows cpal input: {}",
|
||||||
|
input.name().map_err(|err| err.to_string())?
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Windows cpal output: {}",
|
||||||
|
output.name().map_err(|err| err.to_string())?
|
||||||
|
);
|
||||||
|
|
||||||
|
let input_config = input
|
||||||
|
.default_input_config()
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let output_config = output
|
||||||
|
.default_output_config()
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let config: StreamConfig = input_config.config();
|
||||||
|
let buffer = make_shared_buffer();
|
||||||
|
|
||||||
|
let input_stream = build_input_stream(
|
||||||
|
&input,
|
||||||
|
&config,
|
||||||
|
input_config.sample_format(),
|
||||||
|
buffer.clone(),
|
||||||
|
)?;
|
||||||
|
let output_stream = build_output_stream(
|
||||||
|
&output,
|
||||||
|
&config,
|
||||||
|
output_config.sample_format(),
|
||||||
|
buffer.clone(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop_for_handler = stop.clone();
|
||||||
|
ctrlc::set_handler(move || {
|
||||||
|
stop_for_handler.store(true, Ordering::SeqCst);
|
||||||
|
})
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
input_stream.play().map_err(|err| err.to_string())?;
|
||||||
|
output_stream.play().map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
println!("Streaming with cpal. Press Ctrl+C to stop.");
|
||||||
|
while !stop.load(Ordering::SeqCst) {
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(input_stream);
|
||||||
|
drop(output_stream);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_input_stream(
|
||||||
|
device: &cpal::Device,
|
||||||
|
config: &StreamConfig,
|
||||||
|
sample_format: SampleFormat,
|
||||||
|
buffer: crate::audio_buffer::SharedBuffer,
|
||||||
|
) -> Result<Stream, String> {
|
||||||
|
let err_fn = |err| eprintln!("cpal input stream error: {err}");
|
||||||
|
|
||||||
|
match sample_format {
|
||||||
|
SampleFormat::F32 => device
|
||||||
|
.build_input_stream(
|
||||||
|
config,
|
||||||
|
move |data: &[f32], _| {
|
||||||
|
for &sample in data {
|
||||||
|
push_sample(&buffer, sample);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string()),
|
||||||
|
SampleFormat::I16 => device
|
||||||
|
.build_input_stream(
|
||||||
|
config,
|
||||||
|
move |data: &[i16], _| {
|
||||||
|
for &sample in data {
|
||||||
|
push_sample(&buffer, sample as f32 / i16::MAX as f32);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string()),
|
||||||
|
SampleFormat::U16 => device
|
||||||
|
.build_input_stream(
|
||||||
|
config,
|
||||||
|
move |data: &[u16], _| {
|
||||||
|
for &sample in data {
|
||||||
|
let normalized = (sample as f32 / u16::MAX as f32) * 2.0 - 1.0;
|
||||||
|
push_sample(&buffer, normalized);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string()),
|
||||||
|
other => Err(format!("unsupported cpal input sample format: {other:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_output_stream(
|
||||||
|
device: &cpal::Device,
|
||||||
|
config: &StreamConfig,
|
||||||
|
sample_format: SampleFormat,
|
||||||
|
buffer: crate::audio_buffer::SharedBuffer,
|
||||||
|
) -> Result<Stream, String> {
|
||||||
|
let err_fn = |err| eprintln!("cpal output stream error: {err}");
|
||||||
|
|
||||||
|
match sample_format {
|
||||||
|
SampleFormat::F32 => device
|
||||||
|
.build_output_stream(
|
||||||
|
config,
|
||||||
|
move |data: &mut [f32], _| {
|
||||||
|
for sample in data {
|
||||||
|
*sample = pop_sample(&buffer);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string()),
|
||||||
|
SampleFormat::I16 => device
|
||||||
|
.build_output_stream(
|
||||||
|
config,
|
||||||
|
move |data: &mut [i16], _| {
|
||||||
|
for sample in data {
|
||||||
|
let value = pop_sample(&buffer).clamp(-1.0, 1.0);
|
||||||
|
*sample = (value * i16::MAX as f32) as i16;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string()),
|
||||||
|
SampleFormat::U16 => device
|
||||||
|
.build_output_stream(
|
||||||
|
config,
|
||||||
|
move |data: &mut [u16], _| {
|
||||||
|
for sample in data {
|
||||||
|
let value = pop_sample(&buffer).clamp(-1.0, 1.0);
|
||||||
|
*sample = (((value + 1.0) * 0.5) * u16::MAX as f32) as u16;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string()),
|
||||||
|
other => Err(format!("unsupported cpal output sample format: {other:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# TS3 Manual Research Spike
|
||||||
|
|
||||||
|
Chanora proof-of-concept. **Not product code.**
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Prove that `docs/ts3_development_manual_en_v6.md` was read and reduced into the
|
||||||
|
specific protocol guidance needed for the TeamSpeak research spikes in this
|
||||||
|
repository.
|
||||||
|
|
||||||
|
## What it covers
|
||||||
|
|
||||||
|
1. The file-transfer bootstrap flow: `ftinitdownload` / `ftinitupload` plus the
|
||||||
|
second TCP connection for raw bytes.
|
||||||
|
2. The client-variable surfaces relevant to user inspection: stable UID,
|
||||||
|
DBID, nickname, version, platform, avatar, and related profile fields.
|
||||||
|
3. Identity import/export and security-level concepts needed to parse and reuse
|
||||||
|
TeamSpeak identity exports.
|
||||||
|
4. The mapping from those manual sections to the executable PoCs under `poc/`.
|
||||||
|
|
||||||
|
## Manual sections used
|
||||||
|
|
||||||
|
- Section 9: variable parameters / client fields
|
||||||
|
- Section 12 and later references to `client_unique_identifier`
|
||||||
|
- Section 30: file transfer protocol
|
||||||
|
- Section 32: identity and security level
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
The verification record documents the extracted conclusions and which runnable
|
||||||
|
PoC proves each one.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
See `VERIFICATION.md`.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Verification record — `ts3-manual-research`
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
PASS. The TeamSpeak development manual at
|
||||||
|
`docs/ts3_development_manual_en_v6.md` was read and the relevant protocol
|
||||||
|
guidance was extracted into concrete conclusions that were then exercised by the
|
||||||
|
executable TeamSpeak PoCs in `poc/`.
|
||||||
|
|
||||||
|
## Source document
|
||||||
|
|
||||||
|
- `docs/ts3_development_manual_en_v6.md`
|
||||||
|
|
||||||
|
## Key extracted findings
|
||||||
|
|
||||||
|
### 1. File download path
|
||||||
|
|
||||||
|
The manual states that TeamSpeak file transfer is a two-phase flow:
|
||||||
|
|
||||||
|
1. send `ftinitdownload` on the control channel;
|
||||||
|
2. receive transfer token, host, and port;
|
||||||
|
3. open a second TCP connection and exchange raw bytes.
|
||||||
|
|
||||||
|
This directly informed `poc/tsclientlib-filetransfer-spike`.
|
||||||
|
|
||||||
|
### 2. User-profile variable surfaces
|
||||||
|
|
||||||
|
The manual identifies the client fields and lookup surfaces relevant to this
|
||||||
|
task:
|
||||||
|
|
||||||
|
- `client_unique_identifier`
|
||||||
|
- `client_database_id`
|
||||||
|
- `client_nickname`
|
||||||
|
- `client_version`
|
||||||
|
- `client_platform`
|
||||||
|
- avatar-related fields
|
||||||
|
|
||||||
|
This directly informed `poc/tsclientlib-query-spike`.
|
||||||
|
|
||||||
|
### 3. Identity import/export compatibility
|
||||||
|
|
||||||
|
The manual describes TeamSpeak identity material as keypair + counter/security
|
||||||
|
data and explicitly recommends keeping import/export compatible with the
|
||||||
|
official client format where possible.
|
||||||
|
|
||||||
|
This directly informed `poc/tsidentity-import-spike`.
|
||||||
|
|
||||||
|
## Cross-check against runnable PoCs
|
||||||
|
|
||||||
|
| Manual finding | Runnable PoC | Verification status |
|
||||||
|
|---|---|---|
|
||||||
|
| `ftinitdownload` bootstraps a second TCP transfer | `poc/tsclientlib-filetransfer-spike` | PASS |
|
||||||
|
| Client UID / DBID / profile / connection data can be surfaced from client protocol state | `poc/tsclientlib-query-spike` | PASS |
|
||||||
|
| Official-style identity export can be parsed, round-tripped, and reused for connect | `poc/tsidentity-import-spike` | PASS |
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The manual-reading requirement is satisfied with a concrete repo artifact and
|
||||||
|
its extracted protocol guidance is reflected in the corresponding executable
|
||||||
|
PoCs.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target/
|
||||||
+3453
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "tsclientlib-channel-query-spike"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
description = "Chanora PoC: inspect live TeamSpeak channel metadata over the full client protocol."
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
futures = "0.3"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
time = "0.3"
|
||||||
|
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["default-tls"] }
|
||||||
|
tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", package = "tsproto-packets" }
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# tsclientlib Channel Query Spike
|
||||||
|
|
||||||
|
Chanora proof-of-concept. **Not product code.**
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Prove what channel metadata can be observed from a normal TeamSpeak client
|
||||||
|
connection to `kr.teamspeak.app` without ServerQuery credentials.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
1. Connects to a TeamSpeak server with `tsclientlib`.
|
||||||
|
2. Subscribes to the server tree and pumps events so the live channel snapshot
|
||||||
|
is populated.
|
||||||
|
3. Prints the visible channel table.
|
||||||
|
4. Resolves a target channel by ID or exact name, or falls back to the current
|
||||||
|
channel.
|
||||||
|
5. Requests the channel description with `channelgetdescription`.
|
||||||
|
6. Prints channel metadata, flags, codec settings, capacity settings, child
|
||||||
|
channels, and current occupants from the live connection state.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --offline -- \
|
||||||
|
--address kr.teamspeak.app \
|
||||||
|
--nickname ChanoraPoC-ChannelInspector \
|
||||||
|
--target-channel-id 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful flags:
|
||||||
|
|
||||||
|
- `--target-channel-id <cid>`
|
||||||
|
- `--target-channel-name <name>`
|
||||||
|
- `--identity <identity-string>`
|
||||||
|
|
||||||
|
## Scope boundaries
|
||||||
|
|
||||||
|
- No ServerQuery login or administrative credentials.
|
||||||
|
- No attempt to bypass permission-gated data.
|
||||||
|
- No persistence of the generated identity.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
See `VERIFICATION.md`.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Verification record — `tsclientlib-channel-query-spike`
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
PASS. On 2026-05-25, the spike connected to `kr.teamspeak.app`, listed the
|
||||||
|
visible channels, resolved channel `1`, requested its description, and printed
|
||||||
|
live channel metadata plus current occupants from a normal TeamSpeak client
|
||||||
|
session.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Date | 2026-05-25 |
|
||||||
|
| Host OS | Linux (x86_64) |
|
||||||
|
| Build profile | dev |
|
||||||
|
| Target server | `kr.teamspeak.app` |
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --offline -- \
|
||||||
|
--address kr.teamspeak.app \
|
||||||
|
--nickname ChanoraPoC-ChannelInspector \
|
||||||
|
--target-channel-id 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## What was observed
|
||||||
|
|
||||||
|
- The visible channel table populated successfully from the live client state.
|
||||||
|
- Channel `1` resolved as `Default Channel`.
|
||||||
|
- `channelgetdescription` succeeded and populated the channel description in
|
||||||
|
`tsclientlib` state.
|
||||||
|
- Printed channel data for `Default Channel`:
|
||||||
|
- `cid=1`
|
||||||
|
- parent: `Root (0)`
|
||||||
|
- topic: `Default Channel`
|
||||||
|
- type: `Permanent`
|
||||||
|
- default channel: `Yes`
|
||||||
|
- password-protected: `No`
|
||||||
|
- codec: `OpusMusic`
|
||||||
|
- codec quality: `6`
|
||||||
|
- unencrypted: `No`
|
||||||
|
- max clients: `Unlimited`
|
||||||
|
- max family clients: `Unlimited`
|
||||||
|
- needed talk power: `-1`
|
||||||
|
- GUID: `76789d5c-8aac-4740-9d03-e66f09317153`
|
||||||
|
- icon id: `399174223`
|
||||||
|
- Channel description was visible and included bilingual contact text pointing
|
||||||
|
users to `me@edison.network`.
|
||||||
|
- Current occupancy for channel `1` during the run:
|
||||||
|
- `ChanoraPoC-ChannelInspector`
|
||||||
|
- `EdisonJwa`
|
||||||
|
- `observer`
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
This spike proves that a normal TeamSpeak client connection can inspect visible
|
||||||
|
channel metadata, request channel descriptions, and enumerate current occupants
|
||||||
|
without ServerQuery credentials.
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use clap::Parser;
|
||||||
|
use futures::prelude::*;
|
||||||
|
use time::Duration;
|
||||||
|
use tokio::time as tokio_time;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use tsclientlib::data;
|
||||||
|
use tsclientlib::{ChannelId, Connection, DisconnectOptions, Identity, MaxClients, OutCommandExt, StreamItem};
|
||||||
|
use tsproto_packets::packets::{Direction, Flags, OutCommand, PacketType};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "tsclientlib-channel-query-spike",
|
||||||
|
about = "Chanora PoC: inspect TeamSpeak channel information over the full client protocol."
|
||||||
|
)]
|
||||||
|
struct Args {
|
||||||
|
/// Server address (hostname[:port] or TSDNS name).
|
||||||
|
#[arg(short, long, default_value = "kr.teamspeak.app")]
|
||||||
|
address: String,
|
||||||
|
|
||||||
|
/// Nickname used on the server.
|
||||||
|
#[arg(short, long, default_value = "ChanoraPoC-ChannelInspector")]
|
||||||
|
nickname: String,
|
||||||
|
|
||||||
|
/// Optional TeamSpeak identity string. If omitted, a fresh identity is generated.
|
||||||
|
#[arg(long)]
|
||||||
|
identity: Option<String>,
|
||||||
|
|
||||||
|
/// Server password if required.
|
||||||
|
#[arg(long)]
|
||||||
|
password: Option<String>,
|
||||||
|
|
||||||
|
/// Inspect a specific channel ID.
|
||||||
|
#[arg(long)]
|
||||||
|
target_channel_id: Option<u64>,
|
||||||
|
|
||||||
|
/// Inspect a specific channel name.
|
||||||
|
#[arg(long)]
|
||||||
|
target_channel_name: Option<String>,
|
||||||
|
|
||||||
|
/// Print the visible channel table before detailed inspection.
|
||||||
|
#[arg(long, default_value_t = true)]
|
||||||
|
show_channels: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct TargetChannel {
|
||||||
|
id: ChannelId,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("info,tsproto=warn,tsclientlib=warn"));
|
||||||
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||||
|
|
||||||
|
info!(target: "channel-query-spike", address = %args.address, nickname = %args.nickname, "starting channel query spike");
|
||||||
|
|
||||||
|
let mut builder = Connection::build(args.address.clone()).name(args.nickname.clone());
|
||||||
|
let identity = match &args.identity {
|
||||||
|
Some(raw) => Identity::new_from_str(raw).context("parsing --identity")?,
|
||||||
|
None => Identity::create(),
|
||||||
|
};
|
||||||
|
builder = builder.identity(identity);
|
||||||
|
if let Some(password) = &args.password {
|
||||||
|
builder = builder.password(password.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut con = builder.connect().context("dialling server")?;
|
||||||
|
wait_for_initial_state(&mut con).await?;
|
||||||
|
if let Ok(state) = con.get_state() {
|
||||||
|
let _ = state.server.set_subscribed(true).send(&mut con);
|
||||||
|
}
|
||||||
|
pump_events(&mut con, std::time::Duration::from_secs(1)).await?;
|
||||||
|
|
||||||
|
let target = {
|
||||||
|
let state = con.get_state().context("reading connection state")?;
|
||||||
|
if args.show_channels {
|
||||||
|
print_channels(state);
|
||||||
|
}
|
||||||
|
resolve_target(&args, state)?
|
||||||
|
};
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("=== Detailed Target ===");
|
||||||
|
println!("Target channel id : {}", target.id.0);
|
||||||
|
println!("Target channel name : {}", target.name);
|
||||||
|
|
||||||
|
if let Err(error) = request_channel_description(&mut con, target.id).await {
|
||||||
|
warn!(target: "channel-query-spike", %error, channel_id = target.id.0, "channelgetdescription failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = con.get_state().context("reading updated channel state")?;
|
||||||
|
let channel = state
|
||||||
|
.channels
|
||||||
|
.get(&target.id)
|
||||||
|
.ok_or_else(|| anyhow!("target channel disappeared from state"))?;
|
||||||
|
|
||||||
|
print_channel_summary(state, channel)?;
|
||||||
|
|
||||||
|
con.disconnect(DisconnectOptions::new()).ok();
|
||||||
|
con.events().for_each(|_| futures::future::ready(())).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_initial_state(con: &mut Connection) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
let event = con
|
||||||
|
.events()
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow!("event stream ended before initial state"))??;
|
||||||
|
if matches!(event, StreamItem::BookEvents(_)) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pump_events(con: &mut Connection, budget: std::time::Duration) -> Result<()> {
|
||||||
|
let deadline = tokio_time::Instant::now() + budget;
|
||||||
|
loop {
|
||||||
|
let now = tokio_time::Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let remaining = deadline - now;
|
||||||
|
match tokio_time::timeout(remaining, con.events().next()).await {
|
||||||
|
Ok(Some(Ok(_))) => {}
|
||||||
|
Ok(Some(Err(error))) => return Err(error).context("pumping connection events"),
|
||||||
|
Ok(None) => bail!("event stream ended while pumping events"),
|
||||||
|
Err(_) => return Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_command(name: &str, args: &[(&str, String)]) -> OutCommand {
|
||||||
|
let mut command = OutCommand::new(Direction::C2S, Flags::empty(), PacketType::Command, name);
|
||||||
|
for (key, value) in args {
|
||||||
|
command.write_arg(key, value);
|
||||||
|
}
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_messages(con: &mut Connection, command: OutCommand) -> Result<()> {
|
||||||
|
let handle = command.send_with_result(con).context("sending command")?;
|
||||||
|
loop {
|
||||||
|
let item = con
|
||||||
|
.events()
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow!("event stream ended before command response"))??;
|
||||||
|
if let StreamItem::MessageResult(reply, status) = item {
|
||||||
|
if reply == handle {
|
||||||
|
status.map_err(|error| anyhow!("command failed: {error}"))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_channel_description(con: &mut Connection, channel_id: ChannelId) -> Result<()> {
|
||||||
|
request_messages(
|
||||||
|
con,
|
||||||
|
build_command("channelgetdescription", &[("cid", channel_id.0.to_string())]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
pump_events(con, std::time::Duration::from_millis(350)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_target(args: &Args, state: &data::Connection) -> Result<TargetChannel> {
|
||||||
|
if let Some(channel_id) = args.target_channel_id {
|
||||||
|
let id = ChannelId(channel_id);
|
||||||
|
let channel = state
|
||||||
|
.channels
|
||||||
|
.get(&id)
|
||||||
|
.ok_or_else(|| anyhow!("channel id {} not found in visible state", channel_id))?;
|
||||||
|
return Ok(TargetChannel {
|
||||||
|
id,
|
||||||
|
name: channel.name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = &args.target_channel_name {
|
||||||
|
let channel = state
|
||||||
|
.channels
|
||||||
|
.values()
|
||||||
|
.find(|channel| channel.name == *name)
|
||||||
|
.ok_or_else(|| anyhow!("channel name `{name}` not found in visible state"))?;
|
||||||
|
return Ok(TargetChannel {
|
||||||
|
id: channel.id,
|
||||||
|
name: channel.name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let own_client = state
|
||||||
|
.clients
|
||||||
|
.get(&state.own_client)
|
||||||
|
.ok_or_else(|| anyhow!("own client missing from state"))?;
|
||||||
|
let channel = state
|
||||||
|
.channels
|
||||||
|
.get(&own_client.channel)
|
||||||
|
.ok_or_else(|| anyhow!("own channel missing from state"))?;
|
||||||
|
Ok(TargetChannel {
|
||||||
|
id: channel.id,
|
||||||
|
name: channel.name.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_channels(state: &data::Connection) {
|
||||||
|
println!("=== Visible Channels ===");
|
||||||
|
let mut channels = state.channels.values().collect::<Vec<_>>();
|
||||||
|
channels.sort_by_key(|channel| channel.id.0);
|
||||||
|
for channel in channels {
|
||||||
|
let direct_clients = state
|
||||||
|
.clients
|
||||||
|
.values()
|
||||||
|
.filter(|client| client.channel == channel.id)
|
||||||
|
.count();
|
||||||
|
println!(
|
||||||
|
"- {} | cid={} parent={} type={} clients={} subscribed={} password={}",
|
||||||
|
channel.name,
|
||||||
|
channel.id.0,
|
||||||
|
channel.parent.0,
|
||||||
|
format_channel_type(channel),
|
||||||
|
direct_clients,
|
||||||
|
yes_no(channel.subscribed),
|
||||||
|
option_bool(channel.has_password),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_channel_summary(state: &data::Connection, channel: &data::Channel) -> Result<()> {
|
||||||
|
let parent_name = if channel.parent.0 == 0 {
|
||||||
|
"Root".to_string()
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
.channels
|
||||||
|
.get(&channel.parent)
|
||||||
|
.map(|parent| parent.name.clone())
|
||||||
|
.unwrap_or_else(|| format!("Unknown ({})", channel.parent.0))
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut direct_clients = state
|
||||||
|
.clients
|
||||||
|
.values()
|
||||||
|
.filter(|client| client.channel == channel.id)
|
||||||
|
.map(|client| format!("{} ({})", client.name, client.id.0))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
direct_clients.sort();
|
||||||
|
|
||||||
|
let mut child_channels = state
|
||||||
|
.channels
|
||||||
|
.values()
|
||||||
|
.filter(|candidate| candidate.parent == channel.id)
|
||||||
|
.map(|candidate| format!("{} ({})", candidate.name, candidate.id.0))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
child_channels.sort();
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("=== Channel Profile ===");
|
||||||
|
println!("Channel ID : {}", channel.id.0);
|
||||||
|
println!("Name : {}", channel.name);
|
||||||
|
println!("Parent : {} ({})", parent_name, channel.parent.0);
|
||||||
|
println!("Topic : {}", channel.topic.as_deref().unwrap_or(""));
|
||||||
|
println!(
|
||||||
|
"Description : {}",
|
||||||
|
channel
|
||||||
|
.optional_data
|
||||||
|
.as_ref()
|
||||||
|
.map(|data| data.description.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
);
|
||||||
|
println!("Type : {}", format_channel_type(channel));
|
||||||
|
println!("Order : {}", channel.order.0);
|
||||||
|
println!("Is Default : {}", option_bool(channel.is_default));
|
||||||
|
println!("Has Password : {}", option_bool(channel.has_password));
|
||||||
|
println!("Is Private : {}", option_bool(channel.is_private));
|
||||||
|
println!("Subscribed : {}", yes_no(channel.subscribed));
|
||||||
|
println!("Codec : {:?}", channel.codec);
|
||||||
|
println!(
|
||||||
|
"Codec Quality : {}",
|
||||||
|
channel
|
||||||
|
.codec_quality
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Latency Factor : {}",
|
||||||
|
channel
|
||||||
|
.codec_latency_factor
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
|
);
|
||||||
|
println!("Unencrypted : {}", option_bool(channel.is_unencrypted));
|
||||||
|
println!("Max Clients : {}", format_max_clients(channel.max_clients));
|
||||||
|
println!(
|
||||||
|
"Max Family Clients : {}",
|
||||||
|
format_max_clients(channel.max_family_clients)
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Delete Delay : {}",
|
||||||
|
channel
|
||||||
|
.delete_delay
|
||||||
|
.map(format_duration)
|
||||||
|
.unwrap_or_else(|| "None".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Needed Talk Power : {}",
|
||||||
|
channel
|
||||||
|
.needed_talk_power
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
|
);
|
||||||
|
println!("Forced Silence : {}", yes_no(channel.forced_silence));
|
||||||
|
println!(
|
||||||
|
"Phonetic Name : {}",
|
||||||
|
channel.phonetic_name.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"GUID : {}",
|
||||||
|
channel.guid.as_deref().unwrap_or("Unknown")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Storage Quota : {}",
|
||||||
|
channel
|
||||||
|
.storage_quota
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Icon ID : {}",
|
||||||
|
channel
|
||||||
|
.icon
|
||||||
|
.map(|value| value.0.to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
println!("=== Occupancy ===");
|
||||||
|
println!("Direct clients : {}", direct_clients.len());
|
||||||
|
println!(
|
||||||
|
"Client list : {}",
|
||||||
|
if direct_clients.is_empty() {
|
||||||
|
"None".to_string()
|
||||||
|
} else {
|
||||||
|
direct_clients.join(", ")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!("Child channels : {}", child_channels.len());
|
||||||
|
println!(
|
||||||
|
"Children list : {}",
|
||||||
|
if child_channels.is_empty() {
|
||||||
|
"None".to_string()
|
||||||
|
} else {
|
||||||
|
child_channels.join(", ")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_channel_type(channel: &data::Channel) -> &'static str {
|
||||||
|
match channel.channel_type {
|
||||||
|
tsclientlib::ChannelType::Temporary => "Temporary",
|
||||||
|
tsclientlib::ChannelType::SemiPermanent => "SemiPermanent",
|
||||||
|
tsclientlib::ChannelType::Permanent => "Permanent",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_max_clients(value: Option<MaxClients>) -> String {
|
||||||
|
match value {
|
||||||
|
Some(MaxClients::Unlimited) => "Unlimited".to_string(),
|
||||||
|
Some(MaxClients::Inherited) => "Inherited".to_string(),
|
||||||
|
Some(MaxClients::Limited(limit)) => limit.to_string(),
|
||||||
|
None => "Unknown".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn option_bool(value: Option<bool>) -> &'static str {
|
||||||
|
match value {
|
||||||
|
Some(true) => "Yes",
|
||||||
|
Some(false) => "No",
|
||||||
|
None => "Unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yes_no(value: bool) -> &'static str {
|
||||||
|
if value { "Yes" } else { "No" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_duration(duration: Duration) -> String {
|
||||||
|
let total_ms = duration.whole_milliseconds();
|
||||||
|
if total_ms < 1000 {
|
||||||
|
return format!("{total_ms} ms");
|
||||||
|
}
|
||||||
|
let total_seconds = duration.whole_seconds();
|
||||||
|
let seconds = total_seconds % 60;
|
||||||
|
let minutes = (total_seconds / 60) % 60;
|
||||||
|
let hours = total_seconds / 3600;
|
||||||
|
if hours > 0 {
|
||||||
|
format!("{hours}h {minutes}m {seconds}s")
|
||||||
|
} else if minutes > 0 {
|
||||||
|
format!("{minutes}m {seconds}s")
|
||||||
|
} else {
|
||||||
|
format!("{seconds}s")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/downloads/
|
||||||
|
/target/
|
||||||
+3453
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "tsclientlib-filetransfer-spike"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
description = "Chanora PoC: discover and download a TeamSpeak file over the file-transfer port."
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
futures = "0.3"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "fs", "io-util"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["default-tls"] }
|
||||||
|
tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", package = "tsproto-packets" }
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# tsclientlib Filetransfer Spike
|
||||||
|
|
||||||
|
Chanora proof-of-concept. **Not product code.**
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Prove that a normal TeamSpeak client session can download a file from the
|
||||||
|
TeamSpeak fileserver by:
|
||||||
|
|
||||||
|
1. discovering a candidate file path, and
|
||||||
|
2. completing the `ftinitdownload` + second TCP connection flow.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
1. Connects to `kr.teamspeak.app`.
|
||||||
|
2. Attempts `ftgetfilelist` on visible channels.
|
||||||
|
3. Falls back to server icon or client avatar paths if channel-file listing is
|
||||||
|
unavailable.
|
||||||
|
4. Uses `Connection::download_file` to perform the actual fileserver download.
|
||||||
|
5. Saves the file under `downloads/`.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --offline -- \
|
||||||
|
--address kr.teamspeak.app \
|
||||||
|
--nickname ChanoraPoC-Downloader
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional explicit target:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --offline -- \
|
||||||
|
--address kr.teamspeak.app \
|
||||||
|
--channel-id 0 \
|
||||||
|
--path /avatar_ebmjofhfgojajpappiikgponphiippbficbmfemf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
See `VERIFICATION.md`.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Verification record — `tsclientlib-filetransfer-spike`
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
PASS. The spike successfully downloaded a real file from
|
||||||
|
`kr.teamspeak.app` over the TeamSpeak fileserver path.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Date | 2026-05-25 |
|
||||||
|
| Host OS | Linux (x86_64) |
|
||||||
|
| Build profile | dev |
|
||||||
|
| Target server | `kr.teamspeak.app` |
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --offline -- \
|
||||||
|
--address kr.teamspeak.app \
|
||||||
|
--nickname ChanoraPoC-Downloader
|
||||||
|
```
|
||||||
|
|
||||||
|
## What was observed
|
||||||
|
|
||||||
|
- `ftgetfilelist` against visible channel IDs did not return typed file rows on
|
||||||
|
this server; each scan attempt failed with `ParameterNotFound`.
|
||||||
|
- The fallback logic selected `EdisonJwa`'s avatar path:
|
||||||
|
- channel id: `0`
|
||||||
|
- remote path: `/avatar_ebmjofhfgojajpappiikgponphiippbficbmfemf`
|
||||||
|
- `download_file` completed successfully.
|
||||||
|
- Reported file size: `129453`
|
||||||
|
- Copied bytes: `129453`
|
||||||
|
- Saved artifact:
|
||||||
|
- `downloads/avatar_ebmjofhfgojajpappiikgponphiippbficbmfemf`
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The practical TeamSpeak fileserver download path is proven:
|
||||||
|
|
||||||
|
1. determine a valid TeamSpeak remote path,
|
||||||
|
2. request the transfer bootstrap,
|
||||||
|
3. open the returned file-transfer TCP stream,
|
||||||
|
4. receive raw bytes and persist them locally.
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use clap::Parser;
|
||||||
|
use futures::prelude::*;
|
||||||
|
use tokio::io;
|
||||||
|
use tokio::time as tokio_time;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use tsclientlib::messages::s2c::{InFileListPart, InMessage};
|
||||||
|
use tsclientlib::{
|
||||||
|
ChannelId, Connection, DisconnectOptions, Identity, OutCommandExt, StreamItem,
|
||||||
|
};
|
||||||
|
use tsproto_packets::packets::{Direction, Flags, OutCommand, PacketType};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "tsclientlib-filetransfer-spike",
|
||||||
|
about = "Chanora PoC: list files and download one via the TeamSpeak fileserver."
|
||||||
|
)]
|
||||||
|
struct Args {
|
||||||
|
/// Server address (hostname[:port] or TSDNS name).
|
||||||
|
#[arg(short, long, default_value = "kr.teamspeak.app")]
|
||||||
|
address: String,
|
||||||
|
|
||||||
|
/// Nickname used on the server.
|
||||||
|
#[arg(short, long, default_value = "ChanoraPoC-Downloader")]
|
||||||
|
nickname: String,
|
||||||
|
|
||||||
|
/// Optional TeamSpeak identity string. If omitted, a fresh identity is generated.
|
||||||
|
#[arg(long)]
|
||||||
|
identity: Option<String>,
|
||||||
|
|
||||||
|
/// Server password if required.
|
||||||
|
#[arg(long)]
|
||||||
|
password: Option<String>,
|
||||||
|
|
||||||
|
/// Explicit channel id for the download target.
|
||||||
|
#[arg(long)]
|
||||||
|
channel_id: Option<u64>,
|
||||||
|
|
||||||
|
/// Explicit remote TeamSpeak path for the download target.
|
||||||
|
#[arg(long)]
|
||||||
|
path: Option<String>,
|
||||||
|
|
||||||
|
/// Output directory for downloaded files.
|
||||||
|
#[arg(long, default_value = "downloads")]
|
||||||
|
output_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct DownloadCandidate {
|
||||||
|
channel_id: ChannelId,
|
||||||
|
remote_path: String,
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("info,tsproto=warn,tsclientlib=warn"));
|
||||||
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||||
|
|
||||||
|
info!(target: "filetransfer-spike", address = %args.address, nickname = %args.nickname, "starting filetransfer spike");
|
||||||
|
|
||||||
|
let mut builder = Connection::build(args.address.clone()).name(args.nickname.clone());
|
||||||
|
let identity = match &args.identity {
|
||||||
|
Some(raw) => Identity::new_from_str(raw).context("parsing --identity")?,
|
||||||
|
None => Identity::create(),
|
||||||
|
};
|
||||||
|
builder = builder.identity(identity);
|
||||||
|
if let Some(password) = &args.password {
|
||||||
|
builder = builder.password(password.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut con = builder.connect().context("dialling server")?;
|
||||||
|
wait_for_initial_state(&mut con).await?;
|
||||||
|
if let Ok(state) = con.get_state() {
|
||||||
|
let _ = state.server.set_subscribed(true).send(&mut con);
|
||||||
|
}
|
||||||
|
pump_events(&mut con, std::time::Duration::from_secs(1)).await?;
|
||||||
|
|
||||||
|
let candidate = if let (Some(channel_id), Some(path)) = (args.channel_id, args.path.clone()) {
|
||||||
|
DownloadCandidate {
|
||||||
|
channel_id: ChannelId(channel_id),
|
||||||
|
remote_path: path,
|
||||||
|
reason: "explicit CLI target".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
discover_candidate(&mut con).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("=== Download Target ===");
|
||||||
|
println!("Reason : {}", candidate.reason);
|
||||||
|
println!("Channel ID : {}", candidate.channel_id.0);
|
||||||
|
println!("Remote path : {}", candidate.remote_path);
|
||||||
|
|
||||||
|
tokio::fs::create_dir_all(&args.output_dir)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("creating {}", args.output_dir.display()))?;
|
||||||
|
let output_path = args.output_dir.join(file_name_from_remote_path(&candidate.remote_path));
|
||||||
|
|
||||||
|
let download = con
|
||||||
|
.download_file(candidate.channel_id, &candidate.remote_path, None, None)
|
||||||
|
.context("requesting file download")?;
|
||||||
|
let mut result = await_download_result(&mut con, download).await?;
|
||||||
|
let mut output = tokio::fs::File::create(&output_path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("creating {}", output_path.display()))?;
|
||||||
|
let copied = io::copy(&mut result.stream, &mut output)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("writing {}", output_path.display()))?;
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("=== Download Result ===");
|
||||||
|
println!("Expected size : {}", result.size);
|
||||||
|
println!("Copied bytes : {}", copied);
|
||||||
|
println!("Saved to : {}", output_path.display());
|
||||||
|
|
||||||
|
con.disconnect(DisconnectOptions::new()).ok();
|
||||||
|
con.events().for_each(|_| futures::future::ready(())).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discover_candidate(con: &mut Connection) -> Result<DownloadCandidate> {
|
||||||
|
let channel_ids = {
|
||||||
|
let state = con.get_state().context("reading connection state")?;
|
||||||
|
let mut ids = state.channels.keys().copied().collect::<Vec<_>>();
|
||||||
|
ids.sort_by_key(|id| id.0);
|
||||||
|
ids
|
||||||
|
};
|
||||||
|
|
||||||
|
for channel_id in channel_ids {
|
||||||
|
match request_file_list(con, channel_id, "/").await {
|
||||||
|
Ok(files) => {
|
||||||
|
if let Some(file) = files.into_iter().find(|entry| entry.is_file) {
|
||||||
|
return Ok(DownloadCandidate {
|
||||||
|
channel_id: file.channel_id,
|
||||||
|
remote_path: format_path(&file.path, &file.name),
|
||||||
|
reason: format!("first file discovered via ftgetfilelist in channel {}", file.channel_id.0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!(target: "filetransfer-spike", %error, channel_id = channel_id.0, "ftgetfilelist failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = con.get_state().context("reading connection state after file scan")?;
|
||||||
|
if state.server.icon.0 != 0 {
|
||||||
|
return Ok(DownloadCandidate {
|
||||||
|
channel_id: ChannelId(0),
|
||||||
|
remote_path: format!("/icon_{}", state.server.icon.0),
|
||||||
|
reason: "server icon fallback".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for client in state.clients.values() {
|
||||||
|
if !client.avatar_hash.is_empty() {
|
||||||
|
if let Some(uid) = &client.uid {
|
||||||
|
return Ok(DownloadCandidate {
|
||||||
|
channel_id: ChannelId(0),
|
||||||
|
remote_path: format!("/avatar_{}", uid.as_ref().as_avatar()),
|
||||||
|
reason: format!("avatar fallback for client {}", client.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bail!("no downloadable candidate discovered from channel files, server icon, or client avatars");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_command(name: &str, args: &[(&str, String)], flags: &[&str]) -> OutCommand {
|
||||||
|
let mut command = OutCommand::new(Direction::C2S, Flags::empty(), PacketType::Command, name);
|
||||||
|
for flag in flags {
|
||||||
|
command.write_arg(flag, &"");
|
||||||
|
}
|
||||||
|
for (key, value) in args {
|
||||||
|
command.write_arg(key, value);
|
||||||
|
}
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_messages(con: &mut Connection, command: OutCommand) -> Result<Vec<InMessage>> {
|
||||||
|
let handle = command.send_with_result(con).context("sending command")?;
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
loop {
|
||||||
|
let item = con
|
||||||
|
.events()
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow!("event stream ended before command response"))??;
|
||||||
|
match item {
|
||||||
|
StreamItem::MessageEvent(message) => messages.push(message),
|
||||||
|
StreamItem::MessageResult(reply, status) if reply == handle => {
|
||||||
|
status.map_err(|error| anyhow!("command failed: {error}"))?;
|
||||||
|
return Ok(messages);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_file_list(con: &mut Connection, channel_id: ChannelId, path: &str) -> Result<Vec<InFileListPart>> {
|
||||||
|
let command = build_command(
|
||||||
|
"ftgetfilelist",
|
||||||
|
&[("cid", channel_id.0.to_string()), ("path", path.to_string())],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
let messages = request_messages(con, command).await?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for message in messages {
|
||||||
|
if let InMessage::FileList(list) = message {
|
||||||
|
rows.extend(list.iter().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn await_download_result(
|
||||||
|
con: &mut Connection,
|
||||||
|
handle: tsclientlib::FiletransferHandle,
|
||||||
|
) -> Result<tsclientlib::FileDownloadResult> {
|
||||||
|
loop {
|
||||||
|
let item = con
|
||||||
|
.events()
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow!("event stream ended before filetransfer result"))??;
|
||||||
|
match item {
|
||||||
|
StreamItem::FileDownload(reply, result) if reply == handle => return Ok(result),
|
||||||
|
StreamItem::FiletransferFailed(reply, error) if reply == handle => {
|
||||||
|
return Err(anyhow!("filetransfer failed: {error}"));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_initial_state(con: &mut Connection) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
let event = con
|
||||||
|
.events()
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow!("event stream ended before initial state"))??;
|
||||||
|
if matches!(event, StreamItem::BookEvents(_)) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pump_events(con: &mut Connection, budget: std::time::Duration) -> Result<()> {
|
||||||
|
let deadline = tokio_time::Instant::now() + budget;
|
||||||
|
loop {
|
||||||
|
let now = tokio_time::Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let remaining = deadline - now;
|
||||||
|
match tokio_time::timeout(remaining, con.events().next()).await {
|
||||||
|
Ok(Some(Ok(_))) => {}
|
||||||
|
Ok(Some(Err(error))) => return Err(error).context("pumping connection events"),
|
||||||
|
Ok(None) => bail!("event stream ended while pumping events"),
|
||||||
|
Err(_) => return Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_path(path: &str, name: &str) -> String {
|
||||||
|
if path == "/" {
|
||||||
|
format!("/{name}")
|
||||||
|
} else if path.ends_with('/') {
|
||||||
|
format!("{path}{name}")
|
||||||
|
} else {
|
||||||
|
format!("{path}/{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_name_from_remote_path(remote_path: &str) -> String {
|
||||||
|
let name = Path::new(remote_path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("download.bin");
|
||||||
|
let sanitized = name
|
||||||
|
.chars()
|
||||||
|
.map(|ch| if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' { ch } else { '_' })
|
||||||
|
.collect::<String>();
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
"download.bin".to_string()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target/
|
||||||
Generated
+3454
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "tsclientlib-query-spike"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
description = "Chanora PoC: query live TeamSpeak client profile and connection data over the full client protocol."
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
futures = "0.3"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
|
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["default-tls"] }
|
||||||
|
tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", package = "tsproto-packets" }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# tsclientlib Query Spike
|
||||||
|
|
||||||
|
Chanora proof-of-concept. **Not product code.**
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Prove what user/profile data can be observed from a normal TeamSpeak client
|
||||||
|
connection to `kr.teamspeak.app` without ServerQuery credentials.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
1. Connects to a TeamSpeak server with `tsclientlib`.
|
||||||
|
2. Subscribes to the server tree and pumps events so the live client/channel
|
||||||
|
snapshot is populated.
|
||||||
|
3. Prints the online client table.
|
||||||
|
4. Resolves a target client by nickname, UID, DBID, or CLID.
|
||||||
|
5. Requests richer client data with `clientgetvariables` and connection stats
|
||||||
|
with `getconnectioninfo`, then reads the populated values back out of the
|
||||||
|
live `tsclientlib` connection state.
|
||||||
|
6. Falls back gracefully when the server denies permission-gated lookups such
|
||||||
|
as `clientdbinfo`.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --offline -- \
|
||||||
|
--address kr.teamspeak.app \
|
||||||
|
--nickname ChanoraPoC-Inspector \
|
||||||
|
--target-nickname EdisonJwa
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful flags:
|
||||||
|
|
||||||
|
- `--target-nickname <name>`
|
||||||
|
- `--target-uid <uid>`
|
||||||
|
- `--target-dbid <dbid>`
|
||||||
|
- `--target-clid <clid>`
|
||||||
|
- `--identity <identity-string>`
|
||||||
|
|
||||||
|
## Scope boundaries
|
||||||
|
|
||||||
|
- No ServerQuery login or administrative credentials.
|
||||||
|
- No attempt to bypass permission-gated data.
|
||||||
|
- No persistence of the generated identity.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
See `VERIFICATION.md`.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Verification record — `tsclientlib-query-spike`
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
PASS. On 2026-05-25, the spike connected to `kr.teamspeak.app`, resolved the
|
||||||
|
live `EdisonJwa` session, and recovered the requested profile, group, country,
|
||||||
|
avatar, quota, and connection-stat fields from a normal TeamSpeak client
|
||||||
|
session.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Date | 2026-05-25 |
|
||||||
|
| Host OS | Linux (x86_64) |
|
||||||
|
| Build profile | dev |
|
||||||
|
| Target server | `kr.teamspeak.app` |
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --offline -- \
|
||||||
|
--address kr.teamspeak.app \
|
||||||
|
--nickname ChanoraPoC-Inspector \
|
||||||
|
--target-nickname EdisonJwa
|
||||||
|
```
|
||||||
|
|
||||||
|
## Observed output
|
||||||
|
|
||||||
|
- Live roster populated after subscription + event pumping.
|
||||||
|
- `EdisonJwa` was online and resolved as:
|
||||||
|
- `clid=18481`
|
||||||
|
- `dbid=2`
|
||||||
|
- `uid=QcnldW6Qnw/4im/t94j/FYIcVMU=`
|
||||||
|
- `country=KR`
|
||||||
|
- description: `test`
|
||||||
|
- avatar path: `/avatar_ebmjofhfgojajpappiikgponphiippbficbmfemf`
|
||||||
|
- `clientgetvariables` populated richer profile data:
|
||||||
|
- version: `3.6.2 [Build: 1695203293]`
|
||||||
|
- platform: `Windows`
|
||||||
|
- first connected: `2018-09-07T09:39:00Z`
|
||||||
|
- last connected: `2026-05-25T03:34:44Z`
|
||||||
|
- total connections: `3831`
|
||||||
|
- downloaded total: `102735429`
|
||||||
|
- uploaded total: `51249731`
|
||||||
|
- Group names resolved from live state after `servergrouplist` /
|
||||||
|
`channelgrouplist`:
|
||||||
|
- server groups: `Server Admin`, `Talkaholic`
|
||||||
|
- channel group: `Channel Admin`
|
||||||
|
- `getconnectioninfo` populated connection stats:
|
||||||
|
- connection time: `27m 29s`
|
||||||
|
- idle time: `182 ms`
|
||||||
|
- ping: `42 ms`
|
||||||
|
- client address: hidden by server permissions
|
||||||
|
- packet loss: `0.0000` in both directions
|
||||||
|
- packets transferred: populated
|
||||||
|
- bytes transferred: populated
|
||||||
|
- filetransfer bandwidth: `sent=0 recv=0`
|
||||||
|
- `clientdbinfo cldbid=2` still failed with
|
||||||
|
`PermissionsClientInsufficient`, which confirms that the richer results above
|
||||||
|
came from the full client session path rather than elevated query
|
||||||
|
permissions.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
This spike proves the following from a normal client connection:
|
||||||
|
|
||||||
|
- online roster discovery
|
||||||
|
- target user resolution by visible session data
|
||||||
|
- nickname / UID / DBID / country capture
|
||||||
|
- description, version, platform, first-connected, last-connected, and total
|
||||||
|
connections retrieval
|
||||||
|
- server-group and channel-group name resolution
|
||||||
|
- avatar path derivation
|
||||||
|
- live connection-time, idle-time, ping, loss, packet, byte, and quota
|
||||||
|
retrieval
|
||||||
|
|
||||||
|
## Remaining limits
|
||||||
|
|
||||||
|
- `clientdbinfo` remains permission-gated for this target on this server.
|
||||||
|
- `Client Address` is correctly reported as hidden because the live session did
|
||||||
|
not have permission to see it.
|
||||||
@@ -0,0 +1,776 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use base64::prelude::*;
|
||||||
|
use clap::Parser;
|
||||||
|
use futures::prelude::*;
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
use time::{Duration, OffsetDateTime};
|
||||||
|
use tokio::time as tokio_time;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use tsclientlib::data;
|
||||||
|
use tsclientlib::messages::s2c::{InClientDbIdFromUidPart, InClientDbInfoPart, InMessage};
|
||||||
|
use tsclientlib::{
|
||||||
|
ChannelGroupId, ClientDbId, ClientId, Connection, DisconnectOptions, Identity, OutCommandExt,
|
||||||
|
ServerGroupId, StreamItem, Uid,
|
||||||
|
};
|
||||||
|
use tsproto_packets::packets::{Direction, Flags, OutCommand, PacketType};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "tsclientlib-query-spike",
|
||||||
|
about = "Chanora PoC: inspect TeamSpeak client profile + connection info over the full client protocol."
|
||||||
|
)]
|
||||||
|
struct Args {
|
||||||
|
/// Server address (hostname[:port] or TSDNS name).
|
||||||
|
#[arg(short, long, default_value = "kr.teamspeak.app")]
|
||||||
|
address: String,
|
||||||
|
|
||||||
|
/// Nickname used on the server.
|
||||||
|
#[arg(short, long, default_value = "ChanoraPoC-Inspector")]
|
||||||
|
nickname: String,
|
||||||
|
|
||||||
|
/// Optional TeamSpeak identity string. If omitted, a fresh identity is generated.
|
||||||
|
#[arg(long)]
|
||||||
|
identity: Option<String>,
|
||||||
|
|
||||||
|
/// Server password if required.
|
||||||
|
#[arg(long)]
|
||||||
|
password: Option<String>,
|
||||||
|
|
||||||
|
/// Inspect a specific online/offline nickname.
|
||||||
|
#[arg(long)]
|
||||||
|
target_nickname: Option<String>,
|
||||||
|
|
||||||
|
/// Inspect a specific TeamSpeak UID.
|
||||||
|
#[arg(long)]
|
||||||
|
target_uid: Option<String>,
|
||||||
|
|
||||||
|
/// Inspect a specific TeamSpeak database id.
|
||||||
|
#[arg(long)]
|
||||||
|
target_dbid: Option<u64>,
|
||||||
|
|
||||||
|
/// Inspect a specific online client id.
|
||||||
|
#[arg(long)]
|
||||||
|
target_clid: Option<u16>,
|
||||||
|
|
||||||
|
/// Print the online client table before detailed inspection.
|
||||||
|
#[arg(long, default_value_t = true)]
|
||||||
|
show_online: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Target {
|
||||||
|
clid: Option<ClientId>,
|
||||||
|
dbid: Option<ClientDbId>,
|
||||||
|
uid_b64: Option<String>,
|
||||||
|
nickname: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct OnlineTargetSnapshot {
|
||||||
|
description: String,
|
||||||
|
country_code: String,
|
||||||
|
server_groups: Vec<ServerGroupId>,
|
||||||
|
channel_group: ChannelGroupId,
|
||||||
|
avatar_hash: String,
|
||||||
|
uid_b64: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("info,tsproto=warn,tsclientlib=warn"));
|
||||||
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||||
|
|
||||||
|
info!(target: "query-spike", address = %args.address, nickname = %args.nickname, "starting query spike");
|
||||||
|
|
||||||
|
let mut builder = Connection::build(args.address.clone()).name(args.nickname.clone());
|
||||||
|
let identity = match &args.identity {
|
||||||
|
Some(raw) => Identity::new_from_str(raw).context("parsing --identity")?,
|
||||||
|
None => Identity::create(),
|
||||||
|
};
|
||||||
|
builder = builder.identity(identity);
|
||||||
|
if let Some(password) = &args.password {
|
||||||
|
builder = builder.password(password.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut con = builder.connect().context("dialling server")?;
|
||||||
|
wait_for_initial_state(&mut con).await?;
|
||||||
|
if let Ok(state) = con.get_state() {
|
||||||
|
let _ = state.server.set_subscribed(true).send(&mut con);
|
||||||
|
}
|
||||||
|
pump_events(&mut con, std::time::Duration::from_secs(1)).await?;
|
||||||
|
|
||||||
|
let (mut target, online_snapshot) = {
|
||||||
|
let state = con.get_state().context("reading connection state")?;
|
||||||
|
if args.show_online {
|
||||||
|
print_online_clients(state);
|
||||||
|
}
|
||||||
|
let target = resolve_target(&args, state)?;
|
||||||
|
let snapshot = target
|
||||||
|
.clid
|
||||||
|
.and_then(|clid| state.clients.get(&clid))
|
||||||
|
.map(|client| OnlineTargetSnapshot {
|
||||||
|
description: client.description.clone(),
|
||||||
|
country_code: client.country_code.clone(),
|
||||||
|
server_groups: client.server_groups.iter().copied().collect(),
|
||||||
|
channel_group: client.channel_group,
|
||||||
|
avatar_hash: client.avatar_hash.clone(),
|
||||||
|
uid_b64: client.uid.as_ref().map(|uid| uid_to_b64(uid.as_ref())),
|
||||||
|
});
|
||||||
|
(target, snapshot)
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = request_server_group_list(&mut con).await;
|
||||||
|
let _ = request_channel_group_list(&mut con).await;
|
||||||
|
if target.dbid.is_none() {
|
||||||
|
if let Some(uid) = target.uid_b64.as_deref() {
|
||||||
|
if let Ok(lookup) = request_client_dbid_from_uid(&mut con, uid).await {
|
||||||
|
target.dbid = Some(lookup.client_db_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!("=== Detailed Target ===");
|
||||||
|
println!("Target nickname : {}", target.nickname.as_deref().unwrap_or("Unknown"));
|
||||||
|
println!("Target UID : {}", target.uid_b64.as_deref().unwrap_or("Unknown"));
|
||||||
|
println!(
|
||||||
|
"Target DBID : {}",
|
||||||
|
target
|
||||||
|
.dbid
|
||||||
|
.map(|id| id.0.to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Target CLID : {}",
|
||||||
|
target
|
||||||
|
.clid
|
||||||
|
.map(|id| id.0.to_string())
|
||||||
|
.unwrap_or_else(|| "Offline/Unknown".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(clid) = target.clid {
|
||||||
|
if let Err(error) = request_client_variables(&mut con, clid).await {
|
||||||
|
warn!(target: "query-spike", %error, clid = clid.0, "clientgetvariables failed");
|
||||||
|
}
|
||||||
|
if let Err(error) = request_client_connection_info(&mut con, clid).await {
|
||||||
|
warn!(target: "query-spike", %error, clid = clid.0, "getconnectioninfo failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let db_info = match target.dbid {
|
||||||
|
Some(dbid) => match request_client_db_info(&mut con, dbid).await {
|
||||||
|
Ok(info) => Some(info),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(target: "query-spike", %error, dbid = dbid.0, "clientdbinfo failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (optional_data, connection_data, server_group_names, channel_group_names) = {
|
||||||
|
let state = con.get_state().context("reading updated connection state")?;
|
||||||
|
let server_group_names = state
|
||||||
|
.server_groups
|
||||||
|
.iter()
|
||||||
|
.map(|(id, group)| (*id, group.name.clone()))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let channel_group_names = state
|
||||||
|
.channel_groups
|
||||||
|
.iter()
|
||||||
|
.map(|(id, group)| (*id, group.name.clone()))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
match target.clid.and_then(|clid| state.clients.get(&clid)) {
|
||||||
|
Some(client) => (
|
||||||
|
client.optional_data.clone(),
|
||||||
|
client.connection_data.clone(),
|
||||||
|
server_group_names,
|
||||||
|
channel_group_names,
|
||||||
|
),
|
||||||
|
None => (None, None, server_group_names, channel_group_names),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
print_detailed_summary(
|
||||||
|
target,
|
||||||
|
optional_data.as_ref(),
|
||||||
|
connection_data.as_ref(),
|
||||||
|
db_info.as_ref(),
|
||||||
|
online_snapshot.as_ref(),
|
||||||
|
&server_group_names,
|
||||||
|
&channel_group_names,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
con.disconnect(DisconnectOptions::new()).ok();
|
||||||
|
con.events().for_each(|_| futures::future::ready(())).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_initial_state(con: &mut Connection) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
let event = con
|
||||||
|
.events()
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow!("event stream ended before initial state"))??;
|
||||||
|
if matches!(event, StreamItem::BookEvents(_)) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pump_events(con: &mut Connection, budget: std::time::Duration) -> Result<()> {
|
||||||
|
let deadline = tokio_time::Instant::now() + budget;
|
||||||
|
loop {
|
||||||
|
let now = tokio_time::Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let remaining = deadline - now;
|
||||||
|
match tokio_time::timeout(remaining, con.events().next()).await {
|
||||||
|
Ok(Some(Ok(_))) => {}
|
||||||
|
Ok(Some(Err(error))) => return Err(error).context("pumping connection events"),
|
||||||
|
Ok(None) => bail!("event stream ended while pumping events"),
|
||||||
|
Err(_) => return Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_command(name: &str, args: &[(&str, String)], flags: &[&str]) -> OutCommand {
|
||||||
|
let mut command = OutCommand::new(Direction::C2S, Flags::empty(), PacketType::Command, name);
|
||||||
|
for flag in flags {
|
||||||
|
command.write_arg(flag, &"");
|
||||||
|
}
|
||||||
|
for (key, value) in args {
|
||||||
|
command.write_arg(key, value);
|
||||||
|
}
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_messages(con: &mut Connection, command: OutCommand) -> Result<Vec<InMessage>> {
|
||||||
|
let handle = command.send_with_result(con).context("sending command")?;
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
loop {
|
||||||
|
let item = con
|
||||||
|
.events()
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow!("event stream ended before command response"))??;
|
||||||
|
match item {
|
||||||
|
StreamItem::MessageEvent(message) => messages.push(message),
|
||||||
|
StreamItem::MessageResult(reply, status) if reply == handle => {
|
||||||
|
status.map_err(|error| anyhow!("command failed: {error}"))?;
|
||||||
|
return Ok(messages);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_messages_and_quiet(
|
||||||
|
con: &mut Connection,
|
||||||
|
command: OutCommand,
|
||||||
|
quiet_period: std::time::Duration,
|
||||||
|
) -> Result<Vec<InMessage>> {
|
||||||
|
let messages = request_messages(con, command).await?;
|
||||||
|
pump_events(con, quiet_period).await?;
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_client_variables(con: &mut Connection, clid: ClientId) -> Result<()> {
|
||||||
|
let command = build_command("clientgetvariables", &[("clid", clid.0.to_string())], &[]);
|
||||||
|
request_messages_and_quiet(con, command, std::time::Duration::from_millis(350)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_client_connection_info(con: &mut Connection, clid: ClientId) -> Result<()> {
|
||||||
|
let command = build_command("getconnectioninfo", &[("clid", clid.0.to_string())], &[]);
|
||||||
|
request_messages_and_quiet(con, command, std::time::Duration::from_millis(350)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_client_db_info(con: &mut Connection, dbid: ClientDbId) -> Result<InClientDbInfoPart> {
|
||||||
|
let command = build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]);
|
||||||
|
let messages = request_messages(con, command).await?;
|
||||||
|
for message in messages {
|
||||||
|
if let InMessage::ClientDbInfo(info) = message {
|
||||||
|
if let Some(row) = info.iter().next() {
|
||||||
|
return Ok(row.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("clientdbinfo returned no row");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_client_dbid_from_uid(con: &mut Connection, uid: &str) -> Result<InClientDbIdFromUidPart> {
|
||||||
|
let command = build_command("clientgetdbidfromuid", &[("cluid", uid.to_string())], &[]);
|
||||||
|
let messages = request_messages(con, command).await?;
|
||||||
|
for message in messages {
|
||||||
|
if let InMessage::ClientDbIdFromUid(info) = message {
|
||||||
|
if let Some(row) = info.iter().next() {
|
||||||
|
return Ok(row.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("clientgetdbidfromuid returned no row");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_server_group_list(con: &mut Connection) -> Result<()> {
|
||||||
|
request_messages_and_quiet(
|
||||||
|
con,
|
||||||
|
build_command("servergrouplist", &[], &[]),
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_channel_group_list(con: &mut Connection) -> Result<()> {
|
||||||
|
request_messages_and_quiet(
|
||||||
|
con,
|
||||||
|
build_command("channelgrouplist", &[], &[]),
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_target(args: &Args, state: &data::Connection) -> Result<Target> {
|
||||||
|
if let Some(clid) = args.target_clid {
|
||||||
|
if let Some(row) = state.clients.get(&ClientId(clid)) {
|
||||||
|
return Ok(Target {
|
||||||
|
clid: Some(row.id),
|
||||||
|
dbid: Some(row.database_id),
|
||||||
|
uid_b64: row.uid.as_ref().map(|uid| uid_to_b64(uid.as_ref())),
|
||||||
|
nickname: Some(row.name.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Ok(Target {
|
||||||
|
clid: Some(ClientId(clid)),
|
||||||
|
dbid: None,
|
||||||
|
uid_b64: None,
|
||||||
|
nickname: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(uid) = &args.target_uid {
|
||||||
|
if let Some(row) = state
|
||||||
|
.clients
|
||||||
|
.values()
|
||||||
|
.find(|row| row.uid.as_ref().is_some_and(|value| uid_to_b64(value.as_ref()) == *uid))
|
||||||
|
{
|
||||||
|
return Ok(Target {
|
||||||
|
clid: Some(row.id),
|
||||||
|
dbid: Some(row.database_id),
|
||||||
|
uid_b64: Some(uid.clone()),
|
||||||
|
nickname: Some(row.name.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Ok(Target {
|
||||||
|
clid: None,
|
||||||
|
dbid: args.target_dbid.map(ClientDbId),
|
||||||
|
uid_b64: Some(uid.clone()),
|
||||||
|
nickname: args.target_nickname.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(nickname) = &args.target_nickname {
|
||||||
|
if let Some(row) = state.clients.values().find(|row| row.name == *nickname) {
|
||||||
|
return Ok(Target {
|
||||||
|
clid: Some(row.id),
|
||||||
|
dbid: Some(row.database_id),
|
||||||
|
uid_b64: row.uid.as_ref().map(|uid| uid_to_b64(uid.as_ref())),
|
||||||
|
nickname: Some(row.name.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Ok(Target {
|
||||||
|
clid: None,
|
||||||
|
dbid: args.target_dbid.map(ClientDbId),
|
||||||
|
uid_b64: None,
|
||||||
|
nickname: Some(nickname.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(dbid) = args.target_dbid {
|
||||||
|
if let Some(row) = state.clients.values().find(|row| row.database_id.0 == dbid) {
|
||||||
|
return Ok(Target {
|
||||||
|
clid: Some(row.id),
|
||||||
|
dbid: Some(row.database_id),
|
||||||
|
uid_b64: row.uid.as_ref().map(|uid| uid_to_b64(uid.as_ref())),
|
||||||
|
nickname: Some(row.name.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Ok(Target {
|
||||||
|
clid: None,
|
||||||
|
dbid: Some(ClientDbId(dbid)),
|
||||||
|
uid_b64: args.target_uid.clone(),
|
||||||
|
nickname: args.target_nickname.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let own_client_id = state.own_client;
|
||||||
|
let own_client = state
|
||||||
|
.clients
|
||||||
|
.get(&own_client_id)
|
||||||
|
.ok_or_else(|| anyhow!("own client missing from state"))?;
|
||||||
|
Ok(Target {
|
||||||
|
clid: Some(own_client_id),
|
||||||
|
dbid: Some(own_client.database_id),
|
||||||
|
uid_b64: own_client.uid.as_ref().map(|uid| uid_to_b64(uid.as_ref())),
|
||||||
|
nickname: Some(own_client.name.clone()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_online_clients(state: &data::Connection) {
|
||||||
|
println!("=== Online Clients ===");
|
||||||
|
if !state.server.ips.is_empty() {
|
||||||
|
let ips = state
|
||||||
|
.server
|
||||||
|
.ips
|
||||||
|
.iter()
|
||||||
|
.map(std::string::ToString::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
println!("Server IPs: {ips}");
|
||||||
|
}
|
||||||
|
for row in state.clients.values() {
|
||||||
|
let uid = row
|
||||||
|
.uid
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| uid_to_b64(value.as_ref()))
|
||||||
|
.unwrap_or_else(|| "Hidden".to_string());
|
||||||
|
let country = if row.country_code.is_empty() {
|
||||||
|
"Unknown".to_string()
|
||||||
|
} else {
|
||||||
|
row.country_code.clone()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"- {} | clid={} dbid={} cid={} uid={} country={}",
|
||||||
|
row.name, row.id.0, row.database_id.0, row.channel.0, uid, country
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_detailed_summary(
|
||||||
|
target: Target,
|
||||||
|
client_info: Option<&data::OptionalClientData>,
|
||||||
|
connection_info: Option<&data::ConnectionClientData>,
|
||||||
|
db_info: Option<&InClientDbInfoPart>,
|
||||||
|
online_snapshot: Option<&OnlineTargetSnapshot>,
|
||||||
|
server_group_names: &HashMap<ServerGroupId, String>,
|
||||||
|
channel_group_names: &HashMap<ChannelGroupId, String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let nickname = db_info
|
||||||
|
.map(|info| info.name.clone())
|
||||||
|
.or(target.nickname)
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
let uid = db_info
|
||||||
|
.map(|info| uid_to_b64(info.uid.as_ref()))
|
||||||
|
.or(target.uid_b64)
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
let dbid = db_info
|
||||||
|
.map(|info| info.database_id.0)
|
||||||
|
.or(target.dbid.map(|value| value.0))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("=== Client Profile ===");
|
||||||
|
println!("Nickname : {} ({})", nickname, dbid);
|
||||||
|
println!("Unique ID : {}", uid);
|
||||||
|
println!("Database ID : {}", dbid);
|
||||||
|
println!(
|
||||||
|
"Description : {}",
|
||||||
|
db_info
|
||||||
|
.map(|info| info.description.as_str())
|
||||||
|
.or_else(|| online_snapshot.map(|info| info.description.as_str()))
|
||||||
|
.unwrap_or("")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Version : {}",
|
||||||
|
client_info.map(|info| info.version.as_str()).unwrap_or("Unknown")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Connections : {}",
|
||||||
|
client_info
|
||||||
|
.map(|info| info.connections_total)
|
||||||
|
.or_else(|| db_info.map(|info| info.connections_total))
|
||||||
|
.unwrap_or_default()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"First Connected : {}",
|
||||||
|
client_info
|
||||||
|
.map(|info| format_timestamp(info.created))
|
||||||
|
.or_else(|| db_info.map(|info| format_timestamp(info.created)))
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Last Connected : {}",
|
||||||
|
client_info
|
||||||
|
.map(|info| format_timestamp(info.last_connected))
|
||||||
|
.or_else(|| db_info.map(|info| format_timestamp(info.last_connected)))
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Online Since : {}",
|
||||||
|
connection_info
|
||||||
|
.and_then(|info| info.connected_time.map(format_duration))
|
||||||
|
.unwrap_or_else(|| "Offline".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Country : {}",
|
||||||
|
online_snapshot
|
||||||
|
.map(|info| info.country_code.as_str())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("Unknown")
|
||||||
|
);
|
||||||
|
if let Some(info) = client_info {
|
||||||
|
println!("Platform : {}", info.platform);
|
||||||
|
println!(
|
||||||
|
"Server Groups : {}",
|
||||||
|
online_snapshot
|
||||||
|
.map(|snapshot| format_server_groups(&snapshot.server_groups, &server_group_names))
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Channel Group : {}",
|
||||||
|
online_snapshot
|
||||||
|
.map(|snapshot| {
|
||||||
|
channel_group_names
|
||||||
|
.get(&snapshot.channel_group)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| format!("Unknown ({})", snapshot.channel_group.0))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Avatar Path : {}",
|
||||||
|
online_snapshot
|
||||||
|
.map(|snapshot| {
|
||||||
|
if snapshot.avatar_hash.is_empty() {
|
||||||
|
"None".to_string()
|
||||||
|
} else {
|
||||||
|
snapshot
|
||||||
|
.uid_b64
|
||||||
|
.as_ref()
|
||||||
|
.map(|uid| format!("/avatar_{}", uid_to_avatar_path(uid)))
|
||||||
|
.unwrap_or_else(|| "Hidden".to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
|
);
|
||||||
|
} else if let Some(info) = online_snapshot {
|
||||||
|
println!(
|
||||||
|
"Server Groups : {}",
|
||||||
|
format_server_groups(&info.server_groups, &server_group_names)
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Channel Group : {}",
|
||||||
|
channel_group_names
|
||||||
|
.get(&info.channel_group)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| format!("Unknown ({})", info.channel_group.0))
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Avatar Path : {}",
|
||||||
|
if info.avatar_hash.is_empty() {
|
||||||
|
"None".to_string()
|
||||||
|
} else {
|
||||||
|
info.uid_b64
|
||||||
|
.as_ref()
|
||||||
|
.map(|uid| format!("/avatar_{}", uid_to_avatar_path(uid)))
|
||||||
|
.unwrap_or_else(|| "Hidden".to_string())
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("Server Groups : Unknown");
|
||||||
|
println!("Channel Group : Unknown");
|
||||||
|
println!("Avatar Path : Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("=== Connection Info ===");
|
||||||
|
if let Some(connection) = connection_info {
|
||||||
|
println!(
|
||||||
|
"Connection Time : {}",
|
||||||
|
connection
|
||||||
|
.connected_time
|
||||||
|
.map(format_duration)
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
|
);
|
||||||
|
println!("Idle Time : {}", format_duration(connection.idle_time));
|
||||||
|
println!(
|
||||||
|
"Ping : {}",
|
||||||
|
connection
|
||||||
|
.ping
|
||||||
|
.map(format_duration)
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Client Address : {}",
|
||||||
|
connection
|
||||||
|
.client_address
|
||||||
|
.map(|address| address.to_string())
|
||||||
|
.unwrap_or_else(|| "Hidden".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Packet Loss C->S : total={} speech={} keepalive={} control={}",
|
||||||
|
format_loss(connection.client_to_server_packetloss_total),
|
||||||
|
format_loss(connection.client_to_server_packetloss_speech),
|
||||||
|
format_loss(connection.client_to_server_packetloss_keepalive),
|
||||||
|
format_loss(connection.client_to_server_packetloss_control),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Packet Loss S->C : total={} speech={} keepalive={} control={}",
|
||||||
|
connection
|
||||||
|
.server_to_client_packetloss_total
|
||||||
|
.map(format_loss)
|
||||||
|
.unwrap_or_else(|| "Hidden".to_string()),
|
||||||
|
connection
|
||||||
|
.server_to_client_packetloss_speech
|
||||||
|
.map(format_loss)
|
||||||
|
.unwrap_or_else(|| "Hidden".to_string()),
|
||||||
|
connection
|
||||||
|
.server_to_client_packetloss_keepalive
|
||||||
|
.map(format_loss)
|
||||||
|
.unwrap_or_else(|| "Hidden".to_string()),
|
||||||
|
connection
|
||||||
|
.server_to_client_packetloss_control
|
||||||
|
.map(format_loss)
|
||||||
|
.unwrap_or_else(|| "Hidden".to_string()),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Bandwidth Sent : 1s[speech={} keepalive={} control={}] 1m[speech={} keepalive={} control={}]",
|
||||||
|
option_u64(connection.bandwidth_sent_last_second_speech),
|
||||||
|
option_u64(connection.bandwidth_sent_last_second_keepalive),
|
||||||
|
option_u64(connection.bandwidth_sent_last_second_control),
|
||||||
|
option_u64(connection.bandwidth_sent_last_minute_speech),
|
||||||
|
option_u64(connection.bandwidth_sent_last_minute_keepalive),
|
||||||
|
option_u64(connection.bandwidth_sent_last_minute_control),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Bandwidth Received : 1s[speech={} keepalive={} control={}] 1m[speech={} keepalive={} control={}]",
|
||||||
|
option_u64(connection.bandwidth_received_last_second_speech),
|
||||||
|
option_u64(connection.bandwidth_received_last_second_keepalive),
|
||||||
|
option_u64(connection.bandwidth_received_last_second_control),
|
||||||
|
option_u64(connection.bandwidth_received_last_minute_speech),
|
||||||
|
option_u64(connection.bandwidth_received_last_minute_keepalive),
|
||||||
|
option_u64(connection.bandwidth_received_last_minute_control),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Filetransfer BW : sent={} recv={}",
|
||||||
|
option_u64(connection.filetransfer_bandwidth_sent),
|
||||||
|
option_u64(connection.filetransfer_bandwidth_received),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Packets Sent : speech={} keepalive={} control={}",
|
||||||
|
option_u64(connection.packets_sent_speech),
|
||||||
|
option_u64(connection.packets_sent_keepalive),
|
||||||
|
option_u64(connection.packets_sent_control),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Packets Received : speech={} keepalive={} control={}",
|
||||||
|
option_u64(connection.packets_received_speech),
|
||||||
|
option_u64(connection.packets_received_keepalive),
|
||||||
|
option_u64(connection.packets_received_control),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Bytes Sent : speech={} keepalive={} control={}",
|
||||||
|
option_u64(connection.bytes_sent_speech),
|
||||||
|
option_u64(connection.bytes_sent_keepalive),
|
||||||
|
option_u64(connection.bytes_sent_control),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Bytes Received : speech={} keepalive={} control={}",
|
||||||
|
option_u64(connection.bytes_received_speech),
|
||||||
|
option_u64(connection.bytes_received_keepalive),
|
||||||
|
option_u64(connection.bytes_received_control),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("Connection details unavailable (offline or permission-gated).");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("=== Transfer Quota ===");
|
||||||
|
if let Some(info) = client_info {
|
||||||
|
println!("Downloaded this month : {}", info.bytes_downloaded_month);
|
||||||
|
println!("Uploaded this month : {}", info.bytes_uploaded_month);
|
||||||
|
println!("Downloaded total : {}", info.bytes_downloaded_total);
|
||||||
|
println!("Uploaded total : {}", info.bytes_uploaded_total);
|
||||||
|
} else if let Some(info) = db_info {
|
||||||
|
println!("Downloaded this month : {}", info.bytes_downloaded_month);
|
||||||
|
println!("Uploaded this month : {}", info.bytes_uploaded_month);
|
||||||
|
println!("Downloaded total : {}", info.bytes_downloaded_total);
|
||||||
|
println!("Uploaded total : {}", info.bytes_uploaded_total);
|
||||||
|
} else {
|
||||||
|
println!("Quota details unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_server_groups(groups: &[ServerGroupId], names: &HashMap<ServerGroupId, String>) -> String {
|
||||||
|
let mut rendered = Vec::new();
|
||||||
|
for group in groups {
|
||||||
|
rendered.push(
|
||||||
|
names
|
||||||
|
.get(group)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| format!("Unknown ({})", group.0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rendered.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_timestamp(value: OffsetDateTime) -> String {
|
||||||
|
value
|
||||||
|
.format(&Rfc3339)
|
||||||
|
.unwrap_or_else(|_| value.unix_timestamp().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_duration(duration: Duration) -> String {
|
||||||
|
let total_ms = duration.whole_milliseconds();
|
||||||
|
if total_ms < 1000 {
|
||||||
|
return format!("{total_ms} ms");
|
||||||
|
}
|
||||||
|
let total_seconds = duration.whole_seconds();
|
||||||
|
let seconds = total_seconds % 60;
|
||||||
|
let minutes = (total_seconds / 60) % 60;
|
||||||
|
let hours = total_seconds / 3600;
|
||||||
|
if hours > 0 {
|
||||||
|
format!("{hours}h {minutes}m {seconds}s")
|
||||||
|
} else if minutes > 0 {
|
||||||
|
format!("{minutes}m {seconds}s")
|
||||||
|
} else {
|
||||||
|
format!("{seconds}s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_loss(value: f32) -> String {
|
||||||
|
format!("{value:.4}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn option_u64(value: Option<u64>) -> String {
|
||||||
|
value
|
||||||
|
.map(|number| number.to_string())
|
||||||
|
.unwrap_or_else(|| "Hidden".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uid_to_b64(uid: &Uid) -> String {
|
||||||
|
BASE64_STANDARD.encode(&uid.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uid_to_avatar_path(uid_b64: &str) -> String {
|
||||||
|
let decoded = BASE64_STANDARD.decode(uid_b64).unwrap_or_default();
|
||||||
|
let mut rendered = String::with_capacity(decoded.len() * 2);
|
||||||
|
for byte in decoded {
|
||||||
|
rendered.push((b'a' + (byte >> 4)) as char);
|
||||||
|
rendered.push((b'a' + (byte & 0x0f)) as char);
|
||||||
|
}
|
||||||
|
rendered
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/sample_identity.ini
|
||||||
|
/target/
|
||||||
Generated
+3452
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "tsidentity-import-spike"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
description = "Chanora PoC: import/export official TeamSpeak identity files and connect using the imported identity."
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
futures = "0.3"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["default-tls"] }
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# tsidentity Import Spike
|
||||||
|
|
||||||
|
Chanora proof-of-concept. **Not product code.**
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Prove TeamSpeak identity import/export compatibility with the official INI-style
|
||||||
|
format and verify that an imported identity can be used for a live connection.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
1. Parses an exported TeamSpeak identity file.
|
||||||
|
2. Computes the imported identity UID and security level.
|
||||||
|
3. Re-exports the identity in TeamSpeak's `counter + V + obfuscated-key` form.
|
||||||
|
4. Connects to `kr.teamspeak.app` using the imported identity and nickname.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --offline
|
||||||
|
|
||||||
|
cargo run --offline -- \
|
||||||
|
--identity-file /path/to/exported_identity.ini \
|
||||||
|
--address kr.teamspeak.app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important finding
|
||||||
|
|
||||||
|
The validation identity used during this spike round-tripped correctly, but its
|
||||||
|
UID `eaRkG62hRaLs+R9sOuOWK7xbnUY=` did **not** match the live `EdisonJwa` UID
|
||||||
|
`QcnldW6Qnw/4im/t94j/FYIcVMU=` observed on `kr.teamspeak.app`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
See `VERIFICATION.md`.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Verification record — `tsidentity-import-spike`
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
PASS. The sample TeamSpeak identity file imported successfully, exported back to
|
||||||
|
the exact same identity string, and was usable for a live connection to
|
||||||
|
`kr.teamspeak.app`.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Date | 2026-05-25 |
|
||||||
|
| Host OS | Linux (x86_64) |
|
||||||
|
| Build profile | test + dev |
|
||||||
|
| Target server | `kr.teamspeak.app` |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --offline
|
||||||
|
|
||||||
|
cargo run --offline -- \
|
||||||
|
--identity-file /path/to/exported_identity.ini \
|
||||||
|
--address kr.teamspeak.app
|
||||||
|
```
|
||||||
|
|
||||||
|
## What was observed
|
||||||
|
|
||||||
|
- Unit test passed:
|
||||||
|
- exported identity INI parsed successfully
|
||||||
|
- exported identity string matched the original exactly
|
||||||
|
- Imported sample identity properties:
|
||||||
|
- nickname: `Edison.Jwa`
|
||||||
|
- UID: `eaRkG62hRaLs+R9sOuOWK7xbnUY=`
|
||||||
|
- security level: `8`
|
||||||
|
- counter: `40`
|
||||||
|
- Live connect using the imported identity succeeded:
|
||||||
|
- connected nickname: `Edison.Jwa`
|
||||||
|
- connected UID: `eaRkG62hRaLs+R9sOuOWK7xbnUY=`
|
||||||
|
- connected DBID: `17225`
|
||||||
|
- connected CLID: `18506`
|
||||||
|
- connected CID: `1`
|
||||||
|
- country: `KR`
|
||||||
|
- server groups: `{ServerGroupId(8)}`
|
||||||
|
|
||||||
|
## Important finding
|
||||||
|
|
||||||
|
The provided sample identity is **not** the live `EdisonJwa` identity currently
|
||||||
|
online on `kr.teamspeak.app`.
|
||||||
|
|
||||||
|
- Sample imported identity UID: `eaRkG62hRaLs+R9sOuOWK7xbnUY=`
|
||||||
|
- Live `EdisonJwa` UID from the query spike: `QcnldW6Qnw/4im/t94j/FYIcVMU=`
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- the import/export implementation is working, and
|
||||||
|
- the sample INI belongs to a different TeamSpeak identity than the live
|
||||||
|
`EdisonJwa` account used in the example profile.
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use clap::Parser;
|
||||||
|
use futures::prelude::*;
|
||||||
|
use tokio::time as tokio_time;
|
||||||
|
use tracing::info;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use tsclientlib::{Connection, DisconnectOptions, Identity, OutCommandExt, StreamItem};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "tsidentity-import-spike",
|
||||||
|
about = "Chanora PoC: parse/export TeamSpeak identity files and connect with the imported identity."
|
||||||
|
)]
|
||||||
|
struct Args {
|
||||||
|
/// Path to an exported TeamSpeak identity INI file.
|
||||||
|
#[arg(long)]
|
||||||
|
identity_file: PathBuf,
|
||||||
|
|
||||||
|
/// Server address for the live connect verification.
|
||||||
|
#[arg(short, long, default_value = "kr.teamspeak.app")]
|
||||||
|
address: String,
|
||||||
|
|
||||||
|
/// Override the nickname from the identity file for the live connect verification.
|
||||||
|
#[arg(long)]
|
||||||
|
nickname: Option<String>,
|
||||||
|
|
||||||
|
/// Server password if required.
|
||||||
|
#[arg(long)]
|
||||||
|
password: Option<String>,
|
||||||
|
|
||||||
|
/// Skip the live connection step and only validate the import/export round trip.
|
||||||
|
#[arg(long)]
|
||||||
|
no_connect: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
struct IdentityRecord {
|
||||||
|
id: String,
|
||||||
|
identity: String,
|
||||||
|
nickname: String,
|
||||||
|
phonetic_nickname: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("info,tsproto=warn,tsclientlib=warn"));
|
||||||
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||||
|
|
||||||
|
let raw = fs::read_to_string(&args.identity_file)
|
||||||
|
.with_context(|| format!("reading {}", args.identity_file.display()))?;
|
||||||
|
let record = IdentityRecord::parse(&raw)?;
|
||||||
|
let identity = Identity::new_from_str(&record.identity).context("parsing TeamSpeak identity")?;
|
||||||
|
|
||||||
|
let computed_uid = identity.key().to_pub().get_uid();
|
||||||
|
let roundtrip_identity = export_identity_string(&identity);
|
||||||
|
let roundtrip_record = IdentityRecord {
|
||||||
|
id: record.id.clone(),
|
||||||
|
identity: roundtrip_identity.clone(),
|
||||||
|
nickname: record.nickname.clone(),
|
||||||
|
phonetic_nickname: record.phonetic_nickname.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("=== Identity Import ===");
|
||||||
|
println!("Identity file : {}", args.identity_file.display());
|
||||||
|
println!("Profile id : {}", record.id);
|
||||||
|
println!("Nickname : {}", record.nickname);
|
||||||
|
println!("UID : {}", computed_uid);
|
||||||
|
println!("Security level : {}", identity.level());
|
||||||
|
println!("Counter : {}", identity.counter());
|
||||||
|
println!("Roundtrip match : {}", if roundtrip_identity == record.identity { "yes" } else { "no" });
|
||||||
|
println!();
|
||||||
|
println!("=== Export Preview ===");
|
||||||
|
println!("{}", roundtrip_record.render());
|
||||||
|
|
||||||
|
if args.no_connect {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let connect_nickname = args.nickname.clone().unwrap_or_else(|| record.nickname.clone());
|
||||||
|
info!(
|
||||||
|
target: "identity-spike",
|
||||||
|
address = %args.address,
|
||||||
|
nickname = %connect_nickname,
|
||||||
|
uid = %computed_uid,
|
||||||
|
"connecting with imported identity"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut builder = Connection::build(args.address.clone())
|
||||||
|
.name(connect_nickname.clone())
|
||||||
|
.identity(identity);
|
||||||
|
if let Some(password) = &args.password {
|
||||||
|
builder = builder.password(password.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut con = builder.connect().context("dialling server with imported identity")?;
|
||||||
|
wait_for_initial_state(&mut con).await?;
|
||||||
|
if let Ok(state) = con.get_state() {
|
||||||
|
let _ = state.server.set_subscribed(true).send(&mut con);
|
||||||
|
}
|
||||||
|
pump_events(&mut con, std::time::Duration::from_secs(1)).await?;
|
||||||
|
|
||||||
|
let state = con.get_state().context("reading post-connect state")?;
|
||||||
|
let own = state
|
||||||
|
.clients
|
||||||
|
.get(&state.own_client)
|
||||||
|
.ok_or_else(|| anyhow!("own client missing after imported-identity connect"))?;
|
||||||
|
let own_uid = own
|
||||||
|
.uid
|
||||||
|
.as_ref()
|
||||||
|
.map(|uid| tsclientlib_uid_to_b64(uid.as_ref()))
|
||||||
|
.unwrap_or_else(|| "<missing>".to_string());
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("=== Live Connect Result ===");
|
||||||
|
println!("Connected nickname : {}", own.name);
|
||||||
|
println!("Connected UID : {}", own_uid);
|
||||||
|
println!("Connected DBID : {}", own.database_id.0);
|
||||||
|
println!("Connected CLID : {}", own.id.0);
|
||||||
|
println!("Connected CID : {}", own.channel.0);
|
||||||
|
println!(
|
||||||
|
"Country : {}",
|
||||||
|
if own.country_code.is_empty() {
|
||||||
|
"Unknown".to_string()
|
||||||
|
} else {
|
||||||
|
own.country_code.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!("Server groups : {:?}", own.server_groups);
|
||||||
|
println!("Avatar hash : {}", own.avatar_hash);
|
||||||
|
|
||||||
|
con.disconnect(DisconnectOptions::new()).ok();
|
||||||
|
con.events().for_each(|_| futures::future::ready(())).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityRecord {
|
||||||
|
fn parse(raw: &str) -> Result<Self> {
|
||||||
|
let mut section = None::<String>;
|
||||||
|
let mut map = std::collections::HashMap::new();
|
||||||
|
for line in raw.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||||
|
section = Some(trimmed[1..trimmed.len() - 1].to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if section.as_deref() != Some("Identity") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (key, value) = trimmed
|
||||||
|
.split_once('=')
|
||||||
|
.ok_or_else(|| anyhow!("invalid identity line: {trimmed}"))?;
|
||||||
|
map.insert(key.to_string(), unquote(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
id: required(&map, "id")?,
|
||||||
|
identity: required(&map, "identity")?,
|
||||||
|
nickname: required(&map, "nickname")?,
|
||||||
|
phonetic_nickname: map.get("phonetic_nickname").cloned().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"[Identity]\nid={}\nidentity=\"{}\"\nnickname={}\nphonetic_nickname={}\n",
|
||||||
|
self.id, self.identity, self.nickname, self.phonetic_nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required(map: &std::collections::HashMap<String, String>, key: &str) -> Result<String> {
|
||||||
|
map.get(key)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow!("missing required identity key `{key}`"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unquote(value: &str) -> String {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
|
||||||
|
trimmed[1..trimmed.len() - 1].to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_identity_string(identity: &Identity) -> String {
|
||||||
|
format!("{}V{}", identity.counter(), identity.key().to_ts_obfuscated())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_initial_state(con: &mut Connection) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
let event = con
|
||||||
|
.events()
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow!("event stream ended before initial state"))??;
|
||||||
|
if matches!(event, StreamItem::BookEvents(_)) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pump_events(con: &mut Connection, budget: std::time::Duration) -> Result<()> {
|
||||||
|
let deadline = tokio_time::Instant::now() + budget;
|
||||||
|
loop {
|
||||||
|
let now = tokio_time::Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let remaining = deadline - now;
|
||||||
|
match tokio_time::timeout(remaining, con.events().next()).await {
|
||||||
|
Ok(Some(Ok(_))) => {}
|
||||||
|
Ok(Some(Err(error))) => return Err(error).context("pumping connection events"),
|
||||||
|
Ok(None) => bail!("event stream ended while pumping events"),
|
||||||
|
Err(_) => return Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tsclientlib_uid_to_b64(uid: &tsclientlib::Uid) -> String {
|
||||||
|
use base64::prelude::*;
|
||||||
|
BASE64_STANDARD.encode(&uid.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_and_roundtrips_sample_identity() {
|
||||||
|
let raw = include_str!("../sample_identity.ini");
|
||||||
|
let record = IdentityRecord::parse(raw).expect("sample identity must parse");
|
||||||
|
let identity = Identity::new_from_str(&record.identity).expect("identity must parse");
|
||||||
|
let uid = identity.key().to_pub().get_uid();
|
||||||
|
assert_eq!(uid, "eaRkG62hRaLs+R9sOuOWK7xbnUY=");
|
||||||
|
assert_eq!(export_identity_string(&identity), record.identity);
|
||||||
|
assert_eq!(record.nickname, "Edison.Jwa");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/chanora-linux-deb.XXXXXX")"
|
|||||||
PKG_ROOT="$STAGE_DIR/package"
|
PKG_ROOT="$STAGE_DIR/package"
|
||||||
INSTALL_ROOT="$PKG_ROOT/opt/chanora"
|
INSTALL_ROOT="$PKG_ROOT/opt/chanora"
|
||||||
ICON_SRC="$APP_DIR/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png"
|
ICON_SRC="$APP_DIR/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png"
|
||||||
|
ONNXRUNTIME_SHARED_LIB="${CHANORA_ONNXRUNTIME_SHARED_LIB:-}"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -rf "$STAGE_DIR"
|
rm -rf "$STAGE_DIR"
|
||||||
@@ -67,6 +68,9 @@ main() {
|
|||||||
require_cmd dpkg-deb
|
require_cmd dpkg-deb
|
||||||
require_file "$PUBSPEC" "Flutter pubspec"
|
require_file "$PUBSPEC" "Flutter pubspec"
|
||||||
require_file "$ICON_SRC" "launcher icon"
|
require_file "$ICON_SRC" "launcher icon"
|
||||||
|
if [[ -n "$ONNXRUNTIME_SHARED_LIB" ]]; then
|
||||||
|
require_file "$ONNXRUNTIME_SHARED_LIB" "Linux ONNX Runtime shared library"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$OBOE_DIR/Cargo.toml" ]]; then
|
if [[ ! -f "$OBOE_DIR/Cargo.toml" ]]; then
|
||||||
cat >&2 <<EOF
|
cat >&2 <<EOF
|
||||||
@@ -106,6 +110,9 @@ EOF
|
|||||||
local bundle_dir="$APP_DIR/build/linux/$flutter_arch/release/bundle"
|
local bundle_dir="$APP_DIR/build/linux/$flutter_arch/release/bundle"
|
||||||
local app_binary="$bundle_dir/chanora_flutter"
|
local app_binary="$bundle_dir/chanora_flutter"
|
||||||
require_file "$app_binary" "Linux release bundle binary"
|
require_file "$app_binary" "Linux release bundle binary"
|
||||||
|
if [[ -n "$ONNXRUNTIME_SHARED_LIB" ]]; then
|
||||||
|
require_file "$bundle_dir/lib/libonnxruntime.so" "bundled Linux ONNX Runtime shared library"
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p \
|
mkdir -p \
|
||||||
"$PKG_ROOT/DEBIAN" \
|
"$PKG_ROOT/DEBIAN" \
|
||||||
@@ -123,7 +130,7 @@ Section: net
|
|||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: $arch
|
Architecture: $arch
|
||||||
Maintainer: Chanora Project Contributors
|
Maintainer: Chanora Project Contributors
|
||||||
Depends: libasound2, libgtk-3-0, libpulse0, libsdl2-2.0-0, libstdc++6
|
Depends: libasound2, libgtk-3-0, libpipewire-0.3-0, libpulse0, libstdc++6
|
||||||
Description: Chanora voice client for TeamSpeak-compatible servers
|
Description: Chanora voice client for TeamSpeak-compatible servers
|
||||||
Chanora is a Flutter + Rust voice client for TeamSpeak-compatible servers.
|
Chanora is a Flutter + Rust voice client for TeamSpeak-compatible servers.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
+70
-7
@@ -6,7 +6,9 @@
|
|||||||
# - Rust stable with x86_64-unknown-linux-gnu target
|
# - Rust stable with x86_64-unknown-linux-gnu target
|
||||||
# - Flutter 3.41.9+ stable on PATH
|
# - Flutter 3.41.9+ stable on PATH
|
||||||
# - CMake 3.13+, pkg-config, GTK3 dev headers, clang
|
# - CMake 3.13+, pkg-config, GTK3 dev headers, clang
|
||||||
# - System libopus dev package (libopus-dev / opus-devel)
|
# - System libopus development package (libopus-dev / opus-devel)
|
||||||
|
# - Optional: CHANORA_ONNXRUNTIME_SHARED_LIB=/abs/path/libonnxruntime.so
|
||||||
|
# to bundle ONNX Runtime into the Linux app under bundle/lib/
|
||||||
# - flutter_rust_bridge_codegen 2.12.0 (only if regenerating bindings)
|
# - flutter_rust_bridge_codegen 2.12.0 (only if regenerating bindings)
|
||||||
#
|
#
|
||||||
# Usage (from the repo root ~/chanora):
|
# Usage (from the repo root ~/chanora):
|
||||||
@@ -26,6 +28,7 @@ VERSION="v0.2.0-beta.1"
|
|||||||
SKIP_RUST=0
|
SKIP_RUST=0
|
||||||
REGEN=0
|
REGEN=0
|
||||||
TARGET="x86_64-unknown-linux-gnu"
|
TARGET="x86_64-unknown-linux-gnu"
|
||||||
|
ONNXRUNTIME_SHARED_LIB="${CHANORA_ONNXRUNTIME_SHARED_LIB:-}"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -48,6 +51,7 @@ echo "Repo root : $REPO_ROOT"
|
|||||||
echo "Version : $VERSION"
|
echo "Version : $VERSION"
|
||||||
echo "Target : $TARGET"
|
echo "Target : $TARGET"
|
||||||
echo "Build Rust: $([ "$SKIP_RUST" = 1 ] && echo skip || echo yes)"
|
echo "Build Rust: $([ "$SKIP_RUST" = 1 ] && echo skip || echo yes)"
|
||||||
|
echo "Bundle ORT: ${ONNXRUNTIME_SHARED_LIB:-<not set>}"
|
||||||
bar
|
bar
|
||||||
|
|
||||||
# ---------- 1. Verify toolchain ----------
|
# ---------- 1. Verify toolchain ----------
|
||||||
@@ -66,9 +70,54 @@ if ! pkg-config --exists gtk+-3.0 2>/dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verify libopus dev headers (needed by audiopus_sys to link)
|
# Verify libopus development files. tsclientlib's audio feature pulls in
|
||||||
|
# audiopus, which links libopus for Linux builds/tests.
|
||||||
if ! pkg-config --exists opus 2>/dev/null; then
|
if ! pkg-config --exists opus 2>/dev/null; then
|
||||||
echo " ERROR: opus dev headers not found. Install libopus-dev (Debian/Ubuntu) or opus-devel (Fedora)." >&2
|
echo " ERROR: opus pkg-config metadata not found." >&2
|
||||||
|
echo " Install the Opus development package (libopus-dev on" >&2
|
||||||
|
echo " Debian/Ubuntu or opus-devel on Fedora)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf " %-12s : %s\n" "opus" "$(pkg-config --modversion opus)"
|
||||||
|
|
||||||
|
# Verify the exact PipeWire pkg-config modules the primary Linux voice backend uses.
|
||||||
|
# `pipewire-0.3` is not the module name exposed by distro packages; the crates
|
||||||
|
# resolve `libpipewire-0.3` and `libspa-0.2`.
|
||||||
|
if ! pkg-config --exists libpipewire-0.3 2>/dev/null; then
|
||||||
|
echo " ERROR: libpipewire-0.3 pkg-config metadata not found." >&2
|
||||||
|
echo " Install PipeWire development headers (for example pipewire-devel" >&2
|
||||||
|
echo " on Fedora or libpipewire-0.3-dev on Debian/Ubuntu)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! pkg-config --exists libspa-0.2 2>/dev/null; then
|
||||||
|
echo " ERROR: libspa-0.2 pkg-config metadata not found." >&2
|
||||||
|
echo " Install the PipeWire SPA development headers packaged with" >&2
|
||||||
|
echo " your distro's PipeWire development package." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf " %-12s : %s\n" "libpipewire" "$(pkg-config --modversion libpipewire-0.3)"
|
||||||
|
printf " %-12s : %s\n" "libspa" "$(pkg-config --modversion libspa-0.2)"
|
||||||
|
|
||||||
|
# Verify PulseAudio development files used by the fallback Linux voice backend.
|
||||||
|
if ! pkg-config --exists libpulse 2>/dev/null; then
|
||||||
|
echo " ERROR: libpulse pkg-config metadata not found." >&2
|
||||||
|
echo " Install PulseAudio development headers (for example libpulse-dev" >&2
|
||||||
|
echo " on Debian/Ubuntu, pulseaudio-libs-devel on Fedora, or libpulse" >&2
|
||||||
|
echo " on Arch Linux)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! pkg-config --exists libpulse-simple 2>/dev/null; then
|
||||||
|
echo " ERROR: libpulse-simple pkg-config metadata not found." >&2
|
||||||
|
echo " Install PulseAudio simple API development headers." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf " %-12s : %s\n" "libpulse" "$(pkg-config --modversion libpulse)"
|
||||||
|
printf " %-12s : %s\n" "pulse-simple" "$(pkg-config --modversion libpulse-simple)"
|
||||||
|
|
||||||
|
# Verify the optional ONNX Runtime shared library when explicitly requested.
|
||||||
|
if [[ -n "$ONNXRUNTIME_SHARED_LIB" && ! -f "$ONNXRUNTIME_SHARED_LIB" ]]; then
|
||||||
|
echo " ERROR: CHANORA_ONNXRUNTIME_SHARED_LIB does not exist:" >&2
|
||||||
|
echo " $ONNXRUNTIME_SHARED_LIB" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -124,6 +173,7 @@ fi
|
|||||||
# ---------- 5. flutter build linux --release ----------
|
# ---------- 5. flutter build linux --release ----------
|
||||||
echo "[5/6] flutter build linux --release"
|
echo "[5/6] flutter build linux --release"
|
||||||
cd "$FLUTTER_APP"
|
cd "$FLUTTER_APP"
|
||||||
|
export CHANORA_BRIDGE_SHARED_LIB="$BRIDGE_SO"
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter build linux --release
|
flutter build linux --release
|
||||||
|
|
||||||
@@ -134,12 +184,25 @@ if [[ ! -f "$EXE" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Copy the bridge .so into the bundle's lib/ directory so dart:ffi finds it.
|
|
||||||
mkdir -p "$BUNDLE_DIR/lib"
|
|
||||||
cp "$BRIDGE_SO" "$BUNDLE_DIR/lib/"
|
|
||||||
echo " Bundle dir : $BUNDLE_DIR"
|
echo " Bundle dir : $BUNDLE_DIR"
|
||||||
echo " EXE : $EXE"
|
echo " EXE : $EXE"
|
||||||
echo " Bridge .so : $BUNDLE_DIR/lib/libchanora_bridge.so"
|
echo " Bridge .so : $BUNDLE_DIR/lib/libchanora_bridge.so"
|
||||||
|
if [[ ! -f "$BUNDLE_DIR/lib/libchanora_bridge.so" ]]; then
|
||||||
|
echo " ERROR: bridge shared library missing from Linux bundle:" >&2
|
||||||
|
echo " $BUNDLE_DIR/lib/libchanora_bridge.so" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$ONNXRUNTIME_SHARED_LIB" ]]; then
|
||||||
|
if [[ -f "$BUNDLE_DIR/lib/libonnxruntime.so" ]]; then
|
||||||
|
echo " ORT .so : $BUNDLE_DIR/lib/libonnxruntime.so"
|
||||||
|
else
|
||||||
|
echo " ERROR: ONNX Runtime bundle file missing after flutter build:" >&2
|
||||||
|
echo " $BUNDLE_DIR/lib/libonnxruntime.so" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " ORT .so : not bundled (set CHANORA_ONNXRUNTIME_SHARED_LIB to include it)"
|
||||||
|
fi
|
||||||
|
|
||||||
# ---------- 6. Package the bundle ----------
|
# ---------- 6. Package the bundle ----------
|
||||||
echo "[6/6] Package release tarball"
|
echo "[6/6] Package release tarball"
|
||||||
@@ -162,4 +225,4 @@ echo " target/$TARGET/release/libchanora_bridge.so"
|
|||||||
echo " Bundle dir:"
|
echo " Bundle dir:"
|
||||||
echo " $BUNDLE_DIR/"
|
echo " $BUNDLE_DIR/"
|
||||||
echo " Tarball:"
|
echo " Tarball:"
|
||||||
echo " $TAR_PATH"
|
echo " $TAR_PATH"
|
||||||
|
|||||||
Reference in New Issue
Block a user