feat: promote linux native audio path

This commit is contained in:
Edison Jwa
2026-05-25 17:42:06 +09:00
parent c19de3a370
commit a2d686d9d0
73 changed files with 26156 additions and 467 deletions
Generated
+266 -55
View File
@@ -77,7 +77,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
dependencies = [
"alsa-sys",
"bitflags 2.11.1",
"bitflags",
"cfg-if",
"libc",
]
@@ -121,6 +121,16 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "anstyle"
version = "1.0.14"
@@ -260,18 +270,31 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "bit-vec"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -367,6 +390,25 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "cfg-if"
version = "1.0.4"
@@ -427,13 +469,15 @@ dependencies = [
"dispatch2",
"futures-util",
"jni 0.21.1",
"libpulse-binding",
"libpulse-simple-binding",
"ndarray",
"ndk-context",
"oboe",
"ort",
"pipewire",
"rand 0.8.6",
"rustfft",
"sdl2",
"serde_json",
"sonora",
"thiserror 2.0.18",
@@ -493,6 +537,7 @@ name = "chanora_protocol"
version = "0.2.0-beta.1"
dependencies = [
"async-trait",
"base64",
"chanora_resolver",
"futures",
"reqwest 0.13.3",
@@ -579,6 +624,17 @@ dependencies = [
"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]]
name = "clap"
version = "4.6.1"
@@ -658,6 +714,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cookie-factory"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -690,7 +752,7 @@ version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"libc",
"objc2-audio-toolbox",
"objc2-core-audio",
@@ -1055,7 +1117,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"block2",
"libc",
"objc2",
@@ -1514,6 +1576,12 @@ dependencies = [
"time",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "group"
version = "0.13.0"
@@ -2135,6 +2203,16 @@ dependencies = [
"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]]
name = "libloading"
version = "0.9.0"
@@ -2145,6 +2223,81 @@ dependencies = [
"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]]
name = "libsqlite3-sys"
version = "0.30.1"
@@ -2162,7 +2315,7 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"libc",
]
@@ -2345,7 +2498,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"jni-sys 0.3.1",
"log",
"ndk-sys",
@@ -2378,6 +2531,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2488,7 +2650,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"libc",
"objc2",
"objc2-core-audio",
@@ -2526,7 +2688,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"objc2",
]
@@ -2536,7 +2698,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"block2",
"dispatch2",
"libc",
@@ -2555,7 +2717,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"block2",
"libc",
"objc2",
@@ -2624,7 +2786,7 @@ version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"cfg-if",
"foreign-types",
"libc",
@@ -2677,7 +2839,7 @@ version = "2.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
dependencies = [
"libloading",
"libloading 0.9.0",
"ndarray",
"ort-sys",
"smallvec",
@@ -2769,6 +2931,31 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "pkcs8"
version = "0.10.2"
@@ -3116,7 +3303,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.11.1",
"bitflags",
]
[[package]]
@@ -3283,7 +3470,7 @@ version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@@ -3338,7 +3525,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"errno",
"libc",
"linux-raw-sys",
@@ -3457,29 +3644,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "sec1"
version = "0.7.3"
@@ -3500,7 +3664,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
@@ -3513,7 +3677,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -3868,7 +4032,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -3883,13 +4047,26 @@ dependencies = [
"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]]
name = "t4rust-derive"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e411692d6e0c8ce07f65a8210e95dcfb56cc4600940456592a49a3579ef6396"
dependencies = [
"nom",
"nom 7.1.3",
"proc-macro2",
"quote",
"syn",
@@ -3901,6 +4078,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "target-lexicon"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -4188,7 +4371,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"bytes",
"futures-util",
"http",
@@ -4384,7 +4567,7 @@ version = "0.1.0"
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
dependencies = [
"base64",
"bitflags 2.11.1",
"bitflags",
"num-derive",
"num-traits",
"omnom",
@@ -4412,7 +4595,7 @@ version = "0.1.0"
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
dependencies = [
"base64",
"bitflags 2.11.1",
"bitflags",
"curve25519-dalek-ng",
"elliptic-curve",
"generic-array",
@@ -4455,6 +4638,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
@@ -4521,9 +4710,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.1.1"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "version_check"
@@ -4657,7 +4846,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
@@ -4716,6 +4905,22 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "winapi-util"
version = "0.1.11"
@@ -4725,6 +4930,12 @@ dependencies = [
"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]]
name = "windows"
version = "0.54.0"
@@ -5170,7 +5381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.1",
"bitflags",
"indexmap",
"log",
"serde",
@@ -352,7 +352,22 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
).firstOrNull { File(it).resolve("cargo").exists() }
val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
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
// 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
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() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'PulseAudio/ALSA via cpal.',
description: 'PipeWire voice I/O with PulseAudio fallback.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
+106
View File
@@ -226,6 +226,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
AndroidPermissionsService();
final IosPermissionsService _iosPermissions = IosPermissionsService();
final UiPreferencesService _uiPreferences = const UiPreferencesService();
Map<String, ClientPlaybackPreference> _clientPlaybackPrefs = const {};
final Map<BigInt, double> _appliedClientPlaybackVolumes = {};
String _clientPlaybackPrefsHost = '';
late final BackIntentService _backIntentService;
int _modalRouteDepth = 0;
@@ -314,6 +317,105 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
} 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 {
try {
if (Platform.isAndroid) {
@@ -1482,6 +1584,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
void _applySnapshot(rust.BridgeSnapshot snap) {
_snapshot = snap;
unawaited(_applyClientPlaybackPreferencesToSnapshot(snap));
final own = ownClientSnapshotState(snap);
if (own == null) return;
_inputMuted = own.inputMuted;
@@ -2013,6 +2116,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
onJoinChannel: (ch) => _onJoinChannel(ch),
onJoinChannelWithPassword: (ch) =>
_onJoinChannel(ch, askForPassword: true),
clientPlaybackPreferences: _clientPlaybackPrefs,
onClientPlaybackPreferenceChanged:
_setClientPlaybackPreference,
onTs3ServerLink: _onTs3ServerLink,
);
final ownClientState = _ownClientState;
@@ -18,17 +18,19 @@ Future<void>? _storageInitFuture;
Future<void>? _vadBootstrapFuture;
StorageDirectoryProvider _storageDirectoryProvider =
getApplicationSupportDirectory;
StorageDirectoryProvider _vadModelDirectoryProvider =
getApplicationSupportDirectory;
StorageInitializer _storageInitializer = _defaultStorageInitializer;
Future<void> _defaultStorageInitializer(String dir) {
return rust.initStorage(dir: dir);
}
Future<File> _copyBundledAssetToDocuments({
Future<File> _copyBundledAssetToManagedDirectory({
required String assetPath,
required String fileName,
}) async {
final dir = await getApplicationDocumentsDirectory();
final dir = await _vadModelDirectoryProvider();
final file = File('${dir.path}/$fileName');
final data = await rootBundle.load(assetPath);
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
@@ -59,7 +61,7 @@ Future<void> configureBundledVadModels() async {
}
Future<void> _configureBundledVadModelsImpl() async {
final silero = await _copyBundledAssetToDocuments(
final silero = await _copyBundledAssetToManagedDirectory(
assetPath: _sileroVadAsset,
fileName: 'silero_vad.onnx',
);
@@ -124,12 +126,15 @@ Future<void> _wireStorageImpl() async {
@visibleForTesting
void debugResetStorageBootstrap({
StorageDirectoryProvider? storageDirectoryProvider,
StorageDirectoryProvider? vadModelDirectoryProvider,
StorageInitializer? storageInitializer,
}) {
_storageInitFuture = null;
_vadBootstrapFuture = null;
_storageDirectoryProvider =
storageDirectoryProvider ?? getApplicationSupportDirectory;
_vadModelDirectoryProvider =
vadModelDirectoryProvider ?? getApplicationSupportDirectory;
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
}
@@ -117,8 +117,20 @@ Future<StartupDependencyCheckResult> checkStartupDependencies() async {
final executableDir = File(_resolvedExecutableProvider()).parent.path;
final issues = <StartupDependencyIssue>[];
if (!_hasAnyLoadableLibrary(const ['libSDL2.so', 'libSDL2-2.0.so.0'])) {
issues.add(_buildSdlIssue(distro));
if (!_hasAnyLoadableLibrary(const [
'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)) {
@@ -272,33 +284,76 @@ _LinuxArch _detectLinuxArch() {
};
}
StartupDependencyIssue _buildSdlIssue(_LinuxDistro distro) {
StartupDependencyIssue _buildPipeWireIssue(_LinuxDistro distro) {
final List<StartupInstallHint> hints = switch (distro) {
_LinuxDistro.debian => const <StartupInstallHint>[
StartupInstallHint(
label: 'Debian / Ubuntu',
command: 'sudo apt install libsdl2-2.0-0',
command: 'sudo apt install libpipewire-0.3-0',
),
],
_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>[
StartupInstallHint(label: 'Arch Linux', command: 'sudo pacman -S sdl2'),
StartupInstallHint(
label: 'Arch Linux',
command: 'sudo pacman -S pipewire',
),
],
_LinuxDistro.other => const <StartupInstallHint>[],
};
return StartupDependencyIssue(
id: 'linux-sdl2-runtime',
title: 'SDL2 runtime is missing',
id: 'linux-pipewire-runtime',
title: 'PipeWire runtime is missing',
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 [
'Install the SDL2 runtime package for your distribution.',
'Install the PipeWire runtime package for your distribution.',
'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,
);
}
@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class UiSettings {
@@ -7,10 +9,45 @@ class UiSettings {
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 {
static const _hostKey = 'ui.host';
static const _nicknameKey = 'ui.nickname';
static const _permissionsExplainedKey = 'perms_explained';
static const _clientPlaybackPrefsKey = 'audio.client_playback_prefs';
const UiPreferencesService();
@@ -37,4 +74,80 @@ class UiPreferencesService {
final prefs = await SharedPreferences.getInstance();
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.
final BigInt id;
/// Stable TeamSpeak unique identifier.
final String uid;
/// Channel id the client is currently in.
final BigInt channel;
@@ -821,6 +824,7 @@ class BridgeClient {
const BridgeClient({
required this.id,
required this.uid,
required this.channel,
required this.name,
required this.inputMuted,
@@ -834,6 +838,7 @@ class BridgeClient {
@override
int get hashCode =>
id.hashCode ^
uid.hashCode ^
channel.hashCode ^
name.hashCode ^
inputMuted.hashCode ^
@@ -849,6 +854,7 @@ class BridgeClient {
other is BridgeClient &&
runtimeType == other.runtimeType &&
id == other.id &&
uid == other.uid &&
channel == other.channel &&
name == other.name &&
inputMuted == other.inputMuted &&
@@ -1751,18 +1751,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeClient dco_decode_bridge_client(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 9)
throw Exception('unexpected arr length: expect 9 but see ${arr.length}');
if (arr.length != 10)
throw Exception('unexpected arr length: expect 10 but see ${arr.length}');
return BridgeClient(
id: dco_decode_u_64(arr[0]),
channel: dco_decode_u_64(arr[1]),
name: dco_decode_String(arr[2]),
inputMuted: dco_decode_bool(arr[3]),
outputMuted: dco_decode_bool(arr[4]),
isSpeaking: dco_decode_bool(arr[5]),
isServerQuery: dco_decode_bool(arr[6]),
talkPower: dco_decode_i_32(arr[7]),
talkPowerGranted: dco_decode_bool(arr[8]),
uid: dco_decode_String(arr[1]),
channel: dco_decode_u_64(arr[2]),
name: dco_decode_String(arr[3]),
inputMuted: dco_decode_bool(arr[4]),
outputMuted: dco_decode_bool(arr[5]),
isSpeaking: dco_decode_bool(arr[6]),
isServerQuery: dco_decode_bool(arr[7]),
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) {
// Codec=Sse (Serialization based), see doc to use other codecs
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_name = sse_decode_String(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);
return BridgeClient(
id: var_id,
uid: var_uid,
channel: var_channel,
name: var_name,
inputMuted: var_inputMuted,
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -6,10 +8,17 @@ import '../services/channel_spacer.dart';
import '../services/link_trust_service.dart';
import '../services/snapshot_state_mapper.dart';
import '../services/ts3_server_link.dart';
import '../services/ui_preferences_service.dart';
import '../src/rust/api.dart' as rust;
import 'bbcode_text.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.
class SnapshotView extends StatefulWidget {
/// Construct a snapshot view.
@@ -25,6 +34,8 @@ class SnapshotView extends StatefulWidget {
required this.canJoinVoiceChannel,
required this.onJoinChannel,
required this.onJoinChannelWithPassword,
this.clientPlaybackPreferences = const {},
this.onClientPlaybackPreferenceChanged,
this.onTs3ServerLink,
});
@@ -58,6 +69,12 @@ class SnapshotView extends StatefulWidget {
/// Join a password-protected channel.
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.
final Ts3ServerLinkHandler? onTs3ServerLink;
@@ -293,21 +310,41 @@ class _SnapshotViewState extends State<SnapshotView> {
],
)
: null;
final canAdjustPlayback = _canAdjustClientPlayback(client);
return Padding(
padding: EdgeInsets.only(
left: channelIndent + _userRowStartIndent,
right: 8,
),
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),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: canAdjustPlayback
? () =>
unawaited(_showClientPlaybackSheet(client, withHaptic: true))
: null,
onSecondaryTap: canAdjustPlayback
? () =>
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(
ThemeData theme,
rust.BridgeClient client,
+60
View File
@@ -9,6 +9,21 @@ set(BINARY_NAME "chanora_flutter")
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
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
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
@@ -53,6 +68,7 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
find_program(CARGO_EXECUTABLE cargo REQUIRED)
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
@@ -60,6 +76,27 @@ add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
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
# 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
@@ -100,6 +137,29 @@ install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
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})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
@@ -14,6 +14,27 @@ struct _MyApplication {
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.
static void first_frame_cb(MyApplication* self, FlView* 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);
// Perform any actions required at application startup.
configure_onnxruntime_dylib_path();
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
}
@@ -18,6 +18,7 @@ void main() {
rust.BridgeClient client({
BigInt? id,
BigInt? channelId,
String uid = '',
int talkPower = 0,
bool talkPowerGranted = false,
bool inputMuted = false,
@@ -25,6 +26,7 @@ void main() {
}) {
return rust.BridgeClient(
id: id ?? BigInt.from(7),
uid: uid,
channel: channelId ?? BigInt.one,
name: 'Me',
inputMuted: inputMuted,
@@ -18,7 +18,7 @@ void main() {
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(
platformIsLinux: () => true,
libraryProbe: (candidate) => false,
@@ -31,11 +31,19 @@ void main() {
final result = await checkStartupDependencies();
expect(result.hasIssues, isTrue);
final sdl = result.issues.firstWhere(
(issue) => issue.id == 'linux-sdl2-runtime',
final pipewire = result.issues.firstWhere(
(issue) => issue.id == 'linux-pipewire-runtime',
);
expect(sdl.isRequired, isTrue);
expect(sdl.installHints.single.command, 'sudo apt install libsdl2-2.0-0');
final pulse = result.issues.firstWhere(
(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 {
@@ -51,18 +59,22 @@ void main() {
final result = await checkStartupDependencies();
expect(result.platformLabel, 'Fedora');
final sdl = result.issues.singleWhere(
(issue) => issue.id == 'linux-sdl2-runtime',
final pipewire = result.issues.singleWhere(
(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(
'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 {
debugResetStartupDependencyCheck(
platformIsLinux: () => true,
libraryProbe: (candidate) => candidate.contains('SDL2'),
libraryProbe: (candidate) =>
candidate.contains('pipewire') || candidate.contains('pulse'),
fileExists: (_) async => false,
osReleaseProvider: () async => 'ID=fedora\n',
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
@@ -106,7 +118,8 @@ void main() {
test('bundle-local ONNX runtime clears the recommendation', () async {
debugResetStartupDependencyCheck(
platformIsLinux: () => true,
libraryProbe: (candidate) => candidate.contains('SDL2'),
libraryProbe: (candidate) =>
candidate.contains('pipewire') || candidate.contains('pulse'),
fileExists: (path) async => path == '/opt/chanora/lib/libonnxruntime.so',
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
currentDirectoryProvider: () => '/tmp',
@@ -130,11 +143,11 @@ void main() {
platformLabel: 'Fedora',
issues: [
StartupDependencyIssue(
id: 'linux-sdl2-runtime',
title: 'SDL2 runtime is missing',
summary: 'Audio playback needs SDL2.',
details: ['Install SDL2 and recheck.'],
severity: StartupDependencySeverity.required,
id: 'linux-pipewire-runtime',
title: 'PipeWire runtime is missing',
summary: 'Primary Linux audio needs PipeWire.',
details: ['Install PipeWire and recheck.'],
severity: StartupDependencySeverity.recommended,
),
],
);
@@ -146,7 +159,9 @@ void main() {
expect(text, contains('platform="Fedora"'));
expect(
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);
});
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 String name,
required BigInt channelId,
String uid = '',
bool isServerQuery = false,
}) {
return rust.BridgeClient(
id: id,
uid: uid,
channel: channelId,
name: name,
inputMuted: false,
@@ -496,7 +498,15 @@ void main() {
final privateTop = tester.getTopLeft(find.text('Alpha').first).dy;
final serverTileSize = tester.getSize(
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,
);
@@ -506,6 +516,95 @@ void main() {
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(
'plus opens user picker and close removes selected private chat',
(tester) async {
@@ -1,7 +1,9 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/widgets/snapshot_view.dart';
@@ -28,12 +30,14 @@ void main() {
required int id,
required int channelId,
required String name,
String uid = '',
bool speaking = false,
int talkPower = 0,
bool talkPowerGranted = false,
}) {
return rust.BridgeClient(
id: BigInt.from(id),
uid: uid,
channel: BigInt.from(channelId),
name: name,
inputMuted: false,
@@ -51,6 +55,8 @@ void main() {
BigInt? ownClientId,
BigInt? currentVoiceChannelId,
rust.BridgeAudioStats? audioStats,
Map<String, ClientPlaybackPreference> clientPlaybackPreferences = const {},
ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
@@ -75,6 +81,8 @@ void main() {
canJoinVoiceChannel: true,
onJoinChannel: (_) {},
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', (
tester,
) async {
@@ -17,11 +17,11 @@ void main() {
platformLabel: 'Fedora',
issues: const [
StartupDependencyIssue(
id: 'linux-sdl2-runtime',
title: 'SDL2 runtime is missing',
summary: 'Audio playback needs SDL2.',
details: ['Install SDL2 and recheck.'],
severity: StartupDependencySeverity.required,
id: 'linux-pipewire-runtime',
title: 'PipeWire runtime is missing',
summary: 'Primary Linux audio needs PipeWire.',
details: ['Install PipeWire and recheck.'],
severity: StartupDependencySeverity.recommended,
installHints: [
StartupInstallHint(
label: 'Release downloads',
@@ -29,7 +29,7 @@ void main() {
),
StartupInstallHint(
label: 'Fedora',
command: 'sudo dnf install SDL2',
command: 'sudo dnf install pipewire-libs',
),
],
),
@@ -52,14 +52,14 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Finish Linux setup'), findsOneWidget);
expect(find.text('SDL2 runtime is missing'), findsOneWidget);
expect(find.text('Continue with limited mode'), findsOneWidget);
expect(find.text('PipeWire runtime is missing'), findsOneWidget);
expect(find.text('Continue anyway'), findsOneWidget);
expect(find.text('Open'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(loggedResults, [result]);
await tester.ensureVisible(find.text('Continue with limited mode'));
await tester.tap(find.text('Continue with limited mode'));
await tester.ensureVisible(find.text('Continue anyway'));
await tester.tap(find.text('Continue anyway'));
await tester.pumpAndSettle();
expect(find.text('ready'), findsOneWidget);
+1
View File
@@ -2411,6 +2411,7 @@ mod tests {
],
clients: vec![ClientInfo {
id: chanora_protocol::ClientId(10),
uid: "uid".into(),
channel: chanora_protocol::ChannelId(1),
name: "u".into(),
input_muted: false,
+27 -35
View File
@@ -9,6 +9,10 @@ license.workspace = true
repository.workspace = true
publish.workspace = true
[features]
default = []
bench-harness = ["dep:criterion", "dep:dhat", "dep:serde_json"]
[dependencies]
chanora_protocol = { path = "../chanora_protocol" }
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"] }
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
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]
# Desktop audio I/O for Windows capture/playback and Linux capture.
# Linux playback uses SDL2; Apple platforms use direct VoiceProcessingIO
# Desktop audio I/O for Windows capture/playback and Linux fallback metadata.
# Linux voice I/O uses PipeWire first and PulseAudio as fallback; Apple platforms use direct VoiceProcessingIO
# AudioUnits via `coreaudio-rs` for the voice path.
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
# invariant integration test (`tests/ptt_privacy.rs`).
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]]
name = "realtime_capture"
harness = false
path = "benches/realtime_capture.rs"
required-features = ["bench-harness"]
[[bench]]
name = "opus_codec"
harness = false
path = "benches/opus_codec.rs"
required-features = ["bench-harness"]
[[bench]]
name = "resampler"
harness = false
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]
# 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
# object paths per call.
rand = "0.8"
# SDL2 audio for Linux. Replaces the cpal playback path on Linux only;
# cpal stays in use for Linux capture and Windows capture/playback.
# Apple platforms use direct VoiceProcessingIO AudioUnits. Rationale: the
# cpal Linux backend opens raw ALSA `default`, which on most Arch
# / Fedora / Debian installs routes through `dmix` + `plug` with
# 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 }
# Linux-native voice I/O. PipeWire is primary on modern desktops;
# PulseAudio is retained as fallback and for older environments.
pipewire = { version = "0.10", features = ["v0_3_44"] }
libpulse-binding = "2.30"
libpulse-simple-binding = "2.29"
+257 -78
View File
@@ -5,28 +5,35 @@
//! and stopped before disconnect. It does not retry on device
//! change.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
#[cfg(target_os = "linux")]
use cpal::traits::{DeviceTrait, HostTrait};
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use cpal::{SampleFormat, SizedSample};
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use std::collections::hash_map::DefaultHasher;
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -103,6 +110,7 @@ pub struct AudioDeviceInfo {
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -112,6 +120,7 @@ fn desktop_device_id(device: &cpal::Device) -> Option<String> {
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -123,6 +132,7 @@ fn short_device_id(id: &str) -> String {
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -178,29 +188,53 @@ pub fn list_audio_devices() -> AudioDeviceList {
output_devices: Vec::new(),
};
let host = cpal::default_host();
let default_in = host
.default_input_device()
.and_then(|device| desktop_device_id(&device));
let default_out = host
.default_output_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);
#[cfg(target_os = "linux")]
{
let default_input_name = host.default_input_device().and_then(|device| {
device
.description()
.ok()
.map(|description| description.name().trim().to_owned())
.filter(|name| !name.is_empty())
});
let default_output_name = host.default_output_device().and_then(|device| {
device
.description()
.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);
}
}
}
}
if let Ok(devices) = host.output_devices() {
for d in devices {
if let Some(mut device) = describe_device(&d) {
device.is_default = default_out
.as_ref()
.is_some_and(|default_id| default_id == &device.id);
list.output_devices.push(device);
let default_out = host
.default_output_device()
.and_then(|device| desktop_device_id(&device));
if let Ok(devices) = host.output_devices() {
for d in devices {
if let Some(mut device) = describe_device(&d) {
device.is_default = default_out
.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_stats: Arc<crate::SharedAudioProcessingStats>,
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"))]
voice_out_tx: mpsc::Sender<OutPacket>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
mic_gain: f32,
#[cfg(any(target_os = "ios", target_os = "macos"))]
// Streams must be dropped to stop audio. Both are `!Send` because
// cpal's Stream isn't Send on some backends; we keep them in an
// Option wrapped by Mutex so stop() can move them out. On Linux
// the output side is `crate::sdl_output::SdlOutput` instead of a
// cpal Stream (see the SDD note inside `sdl_output.rs`); the
// same unsafe Send/Sync impl below covers both. On iOS both
// 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")
))]
// Streams must be dropped to stop audio. Linux owns native
// PipeWire/PulseAudio threads; Windows owns cpal streams. On iOS both
// sides collapse into a single `IosVoiceUnit` (one VoiceProcessingIO
// AudioUnit hosts mic + speaker).
#[cfg(target_os = "linux")]
_input_stream: Mutex<Option<crate::linux_pipewire_input::LinuxInput>>,
#[cfg(target_os = "windows")]
_input_stream: Mutex<Option<cpal::Stream>>,
#[cfg(target_os = "linux")]
_output_stream: Mutex<Option<crate::sdl_output::SdlOutput>>,
_output_stream: Mutex<Option<crate::linux_pipewire_input::LinuxOutput>>,
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
@@ -457,6 +483,31 @@ fn open_ios_voice_backend(
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 {
#[cfg(target_os = "android")]
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)
}
/// 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
/// 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
@@ -711,7 +762,16 @@ impl AudioEngine {
"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.
#[cfg(not(target_os = "linux"))]
fn find_device<DefaultFn, AllFn, Devices>(
host: &cpal::Host,
default_fn: DefaultFn,
@@ -735,29 +795,31 @@ impl AudioEngine {
default_fn(host)
}
#[cfg(target_os = "linux")]
let in_dev = host.default_input_device();
#[cfg(not(target_os = "linux"))]
let in_dev = find_device(
&host,
cpal::Host::default_input_device,
cpal::Host::input_devices,
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(
&host,
cpal::Host::default_output_device,
cpal::Host::output_devices,
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
// open streams, so a downstream stream-build failure can
// be cross-referenced against what the platform reported
@@ -768,6 +830,29 @@ impl AudioEngine {
// "Start audio button not work"). Promote what would
// otherwise be silent or laconic errors into structured
// 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() {
Ok(c) => info!(
target: "chanora_audio",
@@ -782,6 +867,24 @@ impl AudioEngine {
"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() {
Ok(c) => info!(
target: "chanora_audio",
@@ -797,6 +900,7 @@ impl AudioEngine {
),
}
#[cfg(target_os = "windows")]
let transmit_flag_for_capture = transmit_gate.flag_arc();
let frames_sent = 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 audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
// ---------- Capture ----------
// 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
// permission) we log and continue — playback alone is
// 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(
&in_dev,
voice_out_tx,
transmit_flag_for_capture,
frames_sent.clone(),
cfg.mic_gain,
);
let (input_stream, capture_active) = match capture_result {
Ok(s) => (Some(s), true),
)
.map(|stream| {
(
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) => {
warn!(
target: "chanora_audio",
error = %e,
"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 {
s.play()
.map_err(|e| AudioError::Backend(format!("input play: {e}")))?;
@@ -838,21 +971,18 @@ impl AudioEngine {
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new()));
// Linux uses SDL2 for output (Qint / upstream tsclientlib
// pattern). cpal's Linux backend opens raw ALSA which routes
// 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.
// Linux uses native PipeWire/PulseAudio playback. PipeWire is primary;
// PulseAudio is fallback and can also be selected explicitly by id.
#[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(),
output_gain.clone(),
output_muted.clone(),
)?;
#[cfg(target_os = "linux")]
let output_device_name = output_device_info.name;
#[cfg(not(target_os = "linux"))]
let output_stream = {
@@ -927,10 +1057,20 @@ impl AudioEngine {
.map_err(|e| AudioError::Backend(format!("output play: {e}")))?;
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 ----------
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
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();
tokio::spawn(async move {
loop {
@@ -947,6 +1087,11 @@ impl AudioEngine {
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
apply_saved_client_volume(
&mut h,
&client_volume_overrides_for_task,
id,
);
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
@@ -966,6 +1111,10 @@ impl AudioEngine {
audio_processing_config,
audio_processing_stats,
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),
_output_stream: Mutex::new(Some(output_stream)),
shutdown_tx: Some(shutdown_tx),
@@ -991,6 +1140,7 @@ impl AudioEngine {
let output_muted = Arc::new(AtomicBool::new(false));
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::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>>> =
Arc::new(Mutex::new(AudioHandler::new()));
@@ -1137,6 +1287,7 @@ impl AudioEngine {
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
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();
tokio::spawn(async move {
loop {
@@ -1153,6 +1304,11 @@ impl AudioEngine {
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
apply_saved_client_volume(
&mut h,
&client_volume_overrides_for_task,
id,
);
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
@@ -1172,6 +1328,7 @@ impl AudioEngine {
audio_processing_config,
audio_processing_stats,
audio_handler,
client_volume_overrides,
voice_out_tx,
voice_activity_selector: cfg.voice_activity_selector.clone(),
mic_gain: cfg.mic_gain,
@@ -1224,6 +1381,7 @@ impl AudioEngine {
let output_muted = Arc::new(AtomicBool::new(false));
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::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>>> =
Arc::new(Mutex::new(AudioHandler::new()));
@@ -1258,6 +1416,7 @@ impl AudioEngine {
// waiting.
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
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();
tokio::spawn(async move {
loop {
@@ -1274,6 +1433,11 @@ impl AudioEngine {
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
apply_saved_client_volume(
&mut h,
&client_volume_overrides_for_task,
id,
);
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
@@ -1293,6 +1457,7 @@ impl AudioEngine {
audio_processing_config,
audio_processing_stats,
audio_handler,
client_volume_overrides,
voice_out_tx,
voice_activity_selector: cfg.voice_activity_selector.clone(),
mic_gain: cfg.mic_gain,
@@ -1312,12 +1477,8 @@ impl AudioEngine {
// common contract is that dropping the wrapper stops
// audio. iOS collapses input + output into one
// `IosVoiceUnit` (see `ios_voice_unit.rs`); every other
// platform has separate cpal input + cpal/SDL output.
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
// platform has separate input + output stream owners.
#[cfg(any(target_os = "linux", target_os = "windows"))]
{
let _ = self._input_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 ----------
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
#[cfg(target_os = "windows")]
fn try_open_capture(
in_dev: &cpal::Device,
voice_out_tx: mpsc::Sender<OutPacket>,
@@ -1699,9 +1879,8 @@ fn try_open_capture(
// jitter of WASAPI shared mode.
// * macOS / iOS: CoreAudio picks a HAL-friendly default;
// iOS RemoteIO rejects arbitrary buffer-size requests.
// * Linux: same SDL2-vs-cpal split as the output path; we
// still use cpal for capture but leave Default since
// PipeWire's ALSA shim works well there.
// * Linux does not reach this cpal capture path in production;
// it uses native PipeWire/PulseAudio capture instead.
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
#[cfg(target_os = "windows")]
{
@@ -1738,7 +1917,7 @@ fn try_open_capture(
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
struct CaptureState {
pub(crate) struct CaptureState {
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
@@ -1777,7 +1956,7 @@ struct CaptureState {
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl CaptureState {
fn new(
pub(crate) fn new(
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
@@ -1810,7 +1989,7 @@ impl CaptureState {
/// Consume an arbitrary-rate, multichannel cpal buffer; produce
/// 48 kHz mono frames; encode and send when `transmit_active`
/// 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) {
// Drain accumulator while muted so we don't pop on PTT release.
self.pcm_accum.clear();
@@ -1959,7 +2138,7 @@ impl CaptureState {
/// 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")))]
trait ToF32 {
pub(crate) trait ToF32 {
fn to_f32_sample(self) -> f32;
}
#[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>(
device: &cpal::Device,
config: &cpal::StreamConfig,
+11 -15
View File
@@ -25,7 +25,7 @@
//! stream-format requests on bus 0 (output) and bus 1 (input).
//!
//! 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.
//!
//! ## What VPIO gives us
@@ -53,7 +53,7 @@
//! thread that owns the unit. We open the unit on the same thread
//! that calls `Self::start` (the tokio worker that runs
//! `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
//! constraint for cpal's `!Send` Stream type; that impl covers
//! VPIO too.
@@ -589,16 +589,16 @@ impl IosVoiceUnit {
/// captures samples, render callback fires when the hardware
/// needs samples to play.
///
/// Parameters mirror the capture + playback inputs the cpal
/// and SDL backends accept so engine.rs can swap backends with
/// Parameters mirror the capture + playback inputs the desktop
/// backends accept so engine.rs can swap backends with
/// a `cfg`.
///
/// * `handler` — shared AudioHandler the inbound forwarder
/// feeds Opus packets into. The output render callback pulls
/// decoded f32 frames from it (48 kHz stereo) and downmixes
/// to the i16 mono buffer VPIO expects.
/// * `output_gain` / `output_muted` — same atomics the cpal
/// and SDL output paths read on every callback so the master
/// * `output_gain` / `output_muted` — same atomics the desktop
/// output paths read on every callback so the master
/// volume + local-mute UI works identically across backends.
/// * `voice_out_tx` — channel the capture pipeline sends
/// encoded `OutPacket`s on.
@@ -734,7 +734,7 @@ impl IosVoiceUnit {
// 1. Lock the AudioHandler, ask it to fill a scratch
// f32 stereo buffer (length = 2 * num_frames). The
// 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
// contract from `tsclientlib::audio::AudioHandler`.
// 2. Downmix to i16 mono with master gain. VPIO expects
@@ -748,12 +748,10 @@ impl IosVoiceUnit {
// AudioHandler in step 1 so its jitter buffer
// doesn't grow unbounded while muted. This is the
// 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
// render callback; matches tsclientlib's reference SDL
// example at
// tsclientlib/examples/audio_utils/ts_to_audio.rs).
// render callback; matches the desktop voice backends).
//
// The earlier ring-buffer attempt (rc.8+73..+74) decoupled
// AudioHandler from the render callback via a 50 Hz
@@ -766,14 +764,12 @@ impl IosVoiceUnit {
// consumer pull being larger, the ring averaged out empty
// — fill_buffer was returning silence 65-84% of ticks
// because we drained it too aggressively before packets
// arrived. Linux/SDL's same pattern works fine because
// SDL calls fill_buffer at exactly the device callback
// rate.
// arrived. Direct desktop callbacks follow the same pattern.
//
// Revert to direct call: render callback locks
// AudioHandler, asks for `num_frames` stereo frames, and
// 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.
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(2048);
let handler_for_render = params.handler.clone();
+1 -1
View File
@@ -47,7 +47,7 @@ pub mod voice_activity;
pub(crate) mod voice_render;
#[cfg(target_os = "linux")]
mod sdl_output;
mod linux_pipewire_input;
#[cfg(any(target_os = "ios", target_os = "macos"))]
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")
);
}
}
-203
View File
@@ -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;
}
}
}
}
+137 -2
View File
@@ -33,12 +33,16 @@
//! ## Fallback
//!
//! `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`.
use super::{VadOutput, VoiceActivityDetector};
#[cfg(target_os = "linux")]
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
#[cfg(target_os = "linux")]
use std::sync::OnceLock;
use std::thread::JoinHandle;
/// 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`.
///
/// 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> {
Self::try_new_onnx(model_path)
}
@@ -105,6 +109,17 @@ impl SileroOnnxVad {
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(|| {
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.
// State arrays are owned by this struct and accessed only from
// the single capture callback thread.
@@ -401,6 +521,8 @@ impl Drop for SileroOnnxVadWorker {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
use std::path::PathBuf;
fn make_stub_vad() -> SileroOnnxVad {
SileroOnnxVad {
@@ -496,4 +618,17 @@ mod tests {
&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);
}
}
+3
View File
@@ -355,6 +355,8 @@ pub struct BridgeChannel {
pub struct BridgeClient {
/// Stable client id.
pub id: u64,
/// Stable TeamSpeak unique identifier.
pub uid: String,
/// Channel id the client is currently in.
pub channel: u64,
/// Nickname.
@@ -418,6 +420,7 @@ impl From<chanora_core::ServerSnapshot> for BridgeSnapshot {
.into_iter()
.map(|c| BridgeClient {
id: c.id.0,
uid: c.uid,
channel: c.channel.0,
name: c.name,
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
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
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_name = <String>::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);
return crate::api::BridgeClient {
id: var_id,
uid: var_uid,
channel: var_channel,
name: var_name,
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 {
[
self.id.into_into_dart().into_dart(),
self.uid.into_into_dart().into_dart(),
self.channel.into_into_dart().into_dart(),
self.name.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
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<u64>::sse_encode(self.id, serializer);
<String>::sse_encode(self.uid, serializer);
<u64>::sse_encode(self.channel, serializer);
<String>::sse_encode(self.name, serializer);
<bool>::sse_encode(self.input_muted, serializer);
+9 -6
View File
@@ -13,6 +13,7 @@ publish.workspace = true
thiserror.workspace = true
tracing.workspace = true
serde.workspace = true
base64 = "0.22"
chanora_resolver = { path = "../chanora_resolver" }
# 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"
async-trait = "0.1"
[target.'cfg(not(target_os = "android"))'.dependencies]
# Desktop/iOS: native TLS maps to the platform TLS backend (Security.framework
# on Apple, SChannel on Windows, system OpenSSL on Linux/BSD).
[target.'cfg(any(target_os = "ios", target_os = "macos", target_os = "windows"))'.dependencies]
# Apple + Windows keep the platform TLS backend (Security.framework /
# SChannel). iOS in particular avoids the rustls/aws-lc path here.
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
[target.'cfg(target_os = "android")'.dependencies]
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
# native-tls for Apple targets where aws-lc/rustls is problematic for iOS.
[target.'cfg(any(target_os = "android", target_os = "linux"))'.dependencies]
# Android + Linux avoid a system OpenSSL dependency and use rustls instead.
# 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"] }
+9 -1
View File
@@ -20,6 +20,7 @@
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use base64::prelude::*;
use chanora_resolver::ChanoraResolver;
use futures::prelude::*;
use std::collections::HashMap;
@@ -975,7 +976,9 @@ fn send_text_message(
) -> Result<MessageHandle, ProtocolError> {
use tsproto_types::TextMessageTargetMode;
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 => {
// Fix: previously channel messages were sent via
// state.server.send_textmessage() which always uses
@@ -1216,6 +1219,11 @@ fn build_snapshot(
.iter()
.map(|c| ClientInfo {
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),
name: sanitize(&c.name),
input_muted: c.input_muted,
+2
View File
@@ -68,6 +68,8 @@ pub struct ServerActivity {
pub struct ClientInfo {
/// Stable client id.
pub id: ClientId,
/// Stable TeamSpeak unique identifier for cross-session preference keys.
pub uid: String,
/// Channel the client is currently in.
pub channel: ChannelId,
/// Nickname, preserved verbatim per ADR-008.
+91
View File
@@ -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
+6
View File
@@ -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` |
| `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` |
| `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
+1293
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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"
+37
View File
@@ -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.
+59
View File
@@ -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
+29
View File
@@ -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)
}
+318
View File
@@ -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:?}"))
}
+384
View File
@@ -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`"));
}
}
+26
View File
@@ -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.");
}
+168
View File
@@ -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:?}")),
}
}
+35
View File
@@ -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`.
+60
View File
@@ -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/
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/
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
}
}
+1
View File
@@ -0,0 +1 @@
/target/
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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" }
+48
View File
@@ -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.
+776
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
/sample_identity.ini
/target/
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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"] }
+35
View File
@@ -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.
+249
View File
@@ -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");
}
}
+8 -1
View File
@@ -12,6 +12,7 @@ STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/chanora-linux-deb.XXXXXX")"
PKG_ROOT="$STAGE_DIR/package"
INSTALL_ROOT="$PKG_ROOT/opt/chanora"
ICON_SRC="$APP_DIR/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png"
ONNXRUNTIME_SHARED_LIB="${CHANORA_ONNXRUNTIME_SHARED_LIB:-}"
cleanup() {
rm -rf "$STAGE_DIR"
@@ -67,6 +68,9 @@ main() {
require_cmd dpkg-deb
require_file "$PUBSPEC" "Flutter pubspec"
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
cat >&2 <<EOF
@@ -106,6 +110,9 @@ EOF
local bundle_dir="$APP_DIR/build/linux/$flutter_arch/release/bundle"
local app_binary="$bundle_dir/chanora_flutter"
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 \
"$PKG_ROOT/DEBIAN" \
@@ -123,7 +130,7 @@ Section: net
Priority: optional
Architecture: $arch
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
Chanora is a Flutter + Rust voice client for TeamSpeak-compatible servers.
EOF
+70 -7
View File
@@ -6,7 +6,9 @@
# - Rust stable with x86_64-unknown-linux-gnu target
# - Flutter 3.41.9+ stable on PATH
# - 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)
#
# Usage (from the repo root ~/chanora):
@@ -26,6 +28,7 @@ VERSION="v0.2.0-beta.1"
SKIP_RUST=0
REGEN=0
TARGET="x86_64-unknown-linux-gnu"
ONNXRUNTIME_SHARED_LIB="${CHANORA_ONNXRUNTIME_SHARED_LIB:-}"
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -48,6 +51,7 @@ echo "Repo root : $REPO_ROOT"
echo "Version : $VERSION"
echo "Target : $TARGET"
echo "Build Rust: $([ "$SKIP_RUST" = 1 ] && echo skip || echo yes)"
echo "Bundle ORT: ${ONNXRUNTIME_SHARED_LIB:-<not set>}"
bar
# ---------- 1. Verify toolchain ----------
@@ -66,9 +70,54 @@ if ! pkg-config --exists gtk+-3.0 2>/dev/null; then
exit 1
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
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
fi
@@ -124,6 +173,7 @@ fi
# ---------- 5. flutter build linux --release ----------
echo "[5/6] flutter build linux --release"
cd "$FLUTTER_APP"
export CHANORA_BRIDGE_SHARED_LIB="$BRIDGE_SO"
flutter pub get
flutter build linux --release
@@ -134,12 +184,25 @@ if [[ ! -f "$EXE" ]]; then
exit 1
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 " EXE : $EXE"
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 ----------
echo "[6/6] Package release tarball"
@@ -162,4 +225,4 @@ echo " target/$TARGET/release/libchanora_bridge.so"
echo " Bundle dir:"
echo " $BUNDLE_DIR/"
echo " Tarball:"
echo " $TAR_PATH"
echo " $TAR_PATH"