From eba13ac2c23762e8633b041b086646a48a211109 Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Sat, 7 Feb 2026 14:21:59 +0100 Subject: [PATCH] [feat](trx-server): add audio capture and TCP streaming Add AudioConfig to server configuration with support for RX capture and TX playback via cpal and Opus encoding. Run a dedicated TCP listener (default port 4533) that sends StreamInfo on connect, streams RX Opus frames to clients, and receives TX frames back. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Stanislaw Grams --- Cargo.lock | 623 ++++++++++++++++++++++++++++++++++- src/trx-server/Cargo.toml | 3 + src/trx-server/src/audio.rs | 307 +++++++++++++++++ src/trx-server/src/config.rs | 49 +++ src/trx-server/src/main.rs | 34 +- 5 files changed, 1009 insertions(+), 7 deletions(-) create mode 100644 src/trx-server/src/audio.rs diff --git a/Cargo.lock b/Cargo.lock index 04465b2..b24e86b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,20 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-ws" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a1fb4f9f2794b0aadaf2ba5f14a6f034c7e86957b458c506a8cb75953f2d99" +dependencies = [ + "actix-codec", + "actix-http", + "actix-web", + "bytestring", + "futures-core", + "tokio", +] + [[package]] name = "adler2" version = "2.0.1" @@ -225,6 +239,28 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.10.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "anstream" version = "0.6.21" @@ -275,12 +311,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "audiopus_sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" +dependencies = [ + "cmake", + "log", + "pkg-config", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -332,6 +403,12 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + [[package]] name = "bytes" version = "1.11.0" @@ -359,6 +436,21 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +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", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -371,6 +463,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.53" @@ -411,12 +514,31 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -450,6 +572,49 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -478,6 +643,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "deranged" version = "0.5.5" @@ -573,6 +744,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -747,6 +924,12 @@ dependencies = [ "wasip2", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "h2" version = "0.3.27" @@ -929,12 +1112,43 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + [[package]] name = "jobserver" version = "0.1.34" @@ -945,6 +1159,16 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -1042,6 +1266,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1077,6 +1307,35 @@ dependencies = [ "winapi", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + [[package]] name = "nix" version = "0.26.4" @@ -1100,6 +1359,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1115,6 +1384,48 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "objc2" version = "0.6.3" @@ -1267,6 +1578,29 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1285,6 +1619,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "opus" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +dependencies = [ + "audiopus_sys", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1356,6 +1699,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -1426,7 +1778,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.17", ] [[package]] @@ -1464,6 +1816,12 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1473,12 +1831,27 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1680,13 +2053,33 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1825,8 +2218,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -1838,6 +2231,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -1847,11 +2249,32 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", "winnow", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + [[package]] name = "toml_write" version = "0.1.2" @@ -1943,6 +2366,7 @@ dependencies = [ name = "trx-client" version = "0.1.0" dependencies = [ + "bytes", "clap", "dirs", "libloading", @@ -1996,6 +2420,7 @@ name = "trx-frontend-http" version = "0.1.0" dependencies = [ "actix-web", + "actix-ws", "bytes", "futures-util", "serde", @@ -2032,9 +2457,12 @@ dependencies = [ name = "trx-server" version = "0.1.0" dependencies = [ + "bytes", "clap", + "cpal", "dirs", "libloading", + "opus", "serde", "serde_json", "tokio", @@ -2058,7 +2486,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c01d12e3a56a4432a8b436f293c25f4808bdf9e9f9f98f9260bba1f1bc5a1f26" dependencies = [ - "thiserror", + "thiserror 2.0.17", ] [[package]] @@ -2109,6 +2537,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2124,6 +2562,75 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2140,18 +2647,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2179,6 +2733,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2212,6 +2781,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2224,6 +2799,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2236,6 +2817,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2260,6 +2847,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2272,6 +2865,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2284,6 +2883,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2296,6 +2901,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/src/trx-server/Cargo.toml b/src/trx-server/Cargo.toml index 179a6ca..1811609 100644 --- a/src/trx-server/Cargo.toml +++ b/src/trx-server/Cargo.toml @@ -18,5 +18,8 @@ tracing-subscriber = { workspace = true } clap = { workspace = true, features = ["derive"] } dirs = "6" libloading = "0.8" +bytes = "1" +cpal = "0.15" +opus = "0.3" trx-backend = { path = "trx-backend" } trx-core = { path = "../trx-core" } diff --git a/src/trx-server/src/audio.rs b/src/trx-server/src/audio.rs new file mode 100644 index 0000000..01bbfd5 --- /dev/null +++ b/src/trx-server/src/audio.rs @@ -0,0 +1,307 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! Audio capture, playback, and TCP streaming for trx-server. + +use std::net::SocketAddr; + +use bytes::Bytes; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{broadcast, mpsc}; +use tracing::{error, info, warn}; + +use trx_core::audio::{ + read_audio_msg, write_audio_msg, AudioStreamInfo, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, + AUDIO_MSG_TX_FRAME, +}; + +use crate::config::AudioConfig; + +/// Spawn the audio capture thread. +/// +/// Opens the configured input device via cpal, accumulates PCM samples into +/// frames of `frame_duration_ms` length, encodes each frame with Opus, and +/// broadcasts the resulting packets. +pub fn spawn_audio_capture( + cfg: &AudioConfig, + tx: broadcast::Sender, +) -> std::thread::JoinHandle<()> { + let sample_rate = cfg.sample_rate; + let channels = cfg.channels as u16; + let frame_duration_ms = cfg.frame_duration_ms; + let bitrate_bps = cfg.bitrate_bps; + let device_name = cfg.device.clone(); + + std::thread::spawn(move || { + if let Err(e) = + run_capture(sample_rate, channels, frame_duration_ms, bitrate_bps, device_name, tx) + { + error!("Audio capture thread error: {}", e); + } + }) +} + +fn run_capture( + sample_rate: u32, + channels: u16, + frame_duration_ms: u16, + bitrate_bps: u32, + device_name: Option, + tx: broadcast::Sender, +) -> Result<(), Box> { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let host = cpal::default_host(); + let device = if let Some(ref name) = device_name { + host.input_devices()? + .find(|d| d.name().map(|n| n == *name).unwrap_or(false)) + .ok_or_else(|| format!("audio input device '{}' not found", name))? + } else { + host.default_input_device() + .ok_or("no default audio input device")? + }; + + info!( + "Audio capture: using device '{}'", + device.name().unwrap_or_else(|_| "unknown".into()) + ); + + let config = cpal::StreamConfig { + channels, + sample_rate: cpal::SampleRate(sample_rate), + buffer_size: cpal::BufferSize::Default, + }; + + let frame_samples = (sample_rate as usize * frame_duration_ms as usize / 1000) * channels as usize; + + let opus_channels = match channels { + 1 => opus::Channels::Mono, + 2 => opus::Channels::Stereo, + _ => return Err(format!("unsupported channel count: {}", channels).into()), + }; + + let mut encoder = opus::Encoder::new(sample_rate, opus_channels, opus::Application::Audio)?; + encoder.set_bitrate(opus::Bitrate::Bits(bitrate_bps as i32))?; + + let (sample_tx, sample_rx) = std::sync::mpsc::sync_channel::>(64); + + let stream = device.build_input_stream( + &config, + move |data: &[f32], _: &cpal::InputCallbackInfo| { + let _ = sample_tx.try_send(data.to_vec()); + }, + move |err| { + error!("Audio input stream error: {}", err); + }, + None, + )?; + + stream.play()?; + info!("Audio capture: started ({}Hz, {} ch, {}ms frames)", sample_rate, channels, frame_duration_ms); + + let mut pcm_buf: Vec = Vec::with_capacity(frame_samples * 2); + let mut opus_buf = vec![0u8; 4096]; + + loop { + match sample_rx.recv() { + Ok(samples) => { + pcm_buf.extend_from_slice(&samples); + while pcm_buf.len() >= frame_samples { + let frame: Vec = pcm_buf.drain(..frame_samples).collect(); + match encoder.encode_float(&frame, &mut opus_buf) { + Ok(len) => { + let packet = Bytes::copy_from_slice(&opus_buf[..len]); + let _ = tx.send(packet); + } + Err(e) => { + warn!("Opus encode error: {}", e); + } + } + } + } + Err(_) => break, + } + } + + Ok(()) +} + +/// Spawn the audio playback task. +/// +/// Receives Opus packets, decodes them, and plays through cpal output. +pub fn spawn_audio_playback( + cfg: &AudioConfig, + rx: mpsc::Receiver, +) -> std::thread::JoinHandle<()> { + let sample_rate = cfg.sample_rate; + let channels = cfg.channels as u16; + let frame_duration_ms = cfg.frame_duration_ms; + let device_name = cfg.device.clone(); + + std::thread::spawn(move || { + if let Err(e) = run_playback(sample_rate, channels, frame_duration_ms, device_name, rx) { + error!("Audio playback thread error: {}", e); + } + }) +} + +fn run_playback( + sample_rate: u32, + channels: u16, + frame_duration_ms: u16, + device_name: Option, + mut rx: mpsc::Receiver, +) -> Result<(), Box> { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let host = cpal::default_host(); + let device = if let Some(ref name) = device_name { + host.output_devices()? + .find(|d| d.name().map(|n| n == *name).unwrap_or(false)) + .ok_or_else(|| format!("audio output device '{}' not found", name))? + } else { + host.default_output_device() + .ok_or("no default audio output device")? + }; + + info!( + "Audio playback: using device '{}'", + device.name().unwrap_or_else(|_| "unknown".into()) + ); + + let config = cpal::StreamConfig { + channels, + sample_rate: cpal::SampleRate(sample_rate), + buffer_size: cpal::BufferSize::Default, + }; + + let frame_samples = (sample_rate as usize * frame_duration_ms as usize / 1000) * channels as usize; + + let opus_channels = match channels { + 1 => opus::Channels::Mono, + 2 => opus::Channels::Stereo, + _ => return Err(format!("unsupported channel count: {}", channels).into()), + }; + + let mut decoder = opus::Decoder::new(sample_rate, opus_channels)?; + + let ring = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::::with_capacity(frame_samples * 8))); + let ring_writer = ring.clone(); + + let stream = device.build_output_stream( + &config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + let mut ring = ring.lock().unwrap(); + for sample in data.iter_mut() { + *sample = ring.pop_front().unwrap_or(0.0); + } + }, + move |err| { + error!("Audio output stream error: {}", err); + }, + None, + )?; + + stream.play()?; + info!("Audio playback: started"); + + let rt = tokio::runtime::Handle::current(); + let mut pcm_buf = vec![0f32; frame_samples]; + + rt.block_on(async { + while let Some(packet) = rx.recv().await { + match decoder.decode_float(&packet, &mut pcm_buf, false) { + Ok(decoded) => { + let mut ring = ring_writer.lock().unwrap(); + ring.extend(&pcm_buf[..decoded * channels as usize]); + } + Err(e) => { + warn!("Opus decode error: {}", e); + } + } + } + }); + + Ok(()) +} + +/// Run the audio TCP listener, accepting client connections. +pub async fn run_audio_listener( + addr: SocketAddr, + rx_audio: broadcast::Sender, + tx_audio: mpsc::Sender, + stream_info: AudioStreamInfo, +) -> std::io::Result<()> { + let listener = TcpListener::bind(addr).await?; + info!("Audio listener on {}", addr); + + loop { + let (socket, peer) = listener.accept().await?; + info!("Audio client connected: {}", peer); + + let rx_audio = rx_audio.clone(); + let tx_audio = tx_audio.clone(); + let info = stream_info.clone(); + + tokio::spawn(async move { + if let Err(e) = handle_audio_client(socket, peer, rx_audio, tx_audio, info).await { + warn!("Audio client {} error: {:?}", peer, e); + } + info!("Audio client {} disconnected", peer); + }); + } +} + +async fn handle_audio_client( + socket: TcpStream, + peer: SocketAddr, + rx_audio: broadcast::Sender, + tx_audio: mpsc::Sender, + stream_info: AudioStreamInfo, +) -> std::io::Result<()> { + let (reader, writer) = socket.into_split(); + let mut reader = tokio::io::BufReader::new(reader); + let mut writer = tokio::io::BufWriter::new(writer); + + // Send stream info + let info_json = serde_json::to_vec(&stream_info) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + write_audio_msg(&mut writer, AUDIO_MSG_STREAM_INFO, &info_json).await?; + + // Spawn RX forwarding task + let mut rx_sub = rx_audio.subscribe(); + let mut writer_for_rx = writer; + let rx_handle = tokio::spawn(async move { + loop { + match rx_sub.recv().await { + Ok(packet) => { + if let Err(e) = write_audio_msg(&mut writer_for_rx, AUDIO_MSG_RX_FRAME, &packet).await { + warn!("Audio RX write to {} failed: {}", peer, e); + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!("Audio RX: {} dropped {} frames", peer, n); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); + + // Read TX frames from client + loop { + match read_audio_msg(&mut reader).await { + Ok((AUDIO_MSG_TX_FRAME, payload)) => { + let _ = tx_audio.send(Bytes::from(payload)).await; + } + Ok((msg_type, _)) => { + warn!("Audio: unexpected message type {} from {}", msg_type, peer); + } + Err(_) => break, + } + } + + rx_handle.abort(); + Ok(()) +} diff --git a/src/trx-server/src/config.rs b/src/trx-server/src/config.rs index f5dd3be..0ed2bcf 100644 --- a/src/trx-server/src/config.rs +++ b/src/trx-server/src/config.rs @@ -29,6 +29,8 @@ pub struct ServerConfig { pub behavior: BehaviorConfig, /// TCP listener configuration pub listen: ListenConfig, + /// Audio streaming configuration + pub audio: AudioConfig, } /// General application settings. @@ -141,6 +143,49 @@ pub struct AuthConfig { pub tokens: Vec, } +/// Audio streaming configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AudioConfig { + /// Whether audio streaming is enabled + pub enabled: bool, + /// IP address to listen on for audio connections + pub listen: IpAddr, + /// TCP port for audio connections + pub port: u16, + /// Whether RX audio capture is enabled + pub rx_enabled: bool, + /// Whether TX audio playback is enabled + pub tx_enabled: bool, + /// Audio input device name (None = system default) + pub device: Option, + /// Sample rate in Hz + pub sample_rate: u32, + /// Number of audio channels + pub channels: u8, + /// Opus frame duration in milliseconds + pub frame_duration_ms: u16, + /// Opus bitrate in bits per second + pub bitrate_bps: u32, +} + +impl Default for AudioConfig { + fn default() -> Self { + Self { + enabled: false, + listen: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + port: 4533, + rx_enabled: true, + tx_enabled: true, + device: None, + sample_rate: 48000, + channels: 1, + frame_duration_ms: 20, + bitrate_bps: 24000, + } + } +} + impl ServerConfig { /// Load configuration from a specific file path. pub fn load_from_file(path: &Path) -> Result { @@ -205,6 +250,7 @@ impl ServerConfig { }, behavior: BehaviorConfig::default(), listen: ListenConfig::default(), + audio: AudioConfig::default(), }; toml::to_string_pretty(&example).unwrap_or_default() @@ -259,6 +305,9 @@ mod tests { assert!(config.listen.enabled); assert_eq!(config.listen.port, 4532); assert!(config.listen.auth.tokens.is_empty()); + assert!(!config.audio.enabled); + assert_eq!(config.audio.port, 4533); + assert_eq!(config.audio.sample_rate, 48000); } #[test] diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 8b1360a..8c68ccf 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: BSD-2-Clause +mod audio; mod config; mod error; mod listener; @@ -13,11 +14,14 @@ use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::time::Duration; +use bytes::Bytes; use clap::{Parser, ValueEnum}; use tokio::signal; -use tokio::sync::{mpsc, watch}; +use tokio::sync::{broadcast, mpsc, watch}; use tracing::{error, info}; +use trx_core::audio::AudioStreamInfo; + use trx_backend::{is_backend_registered, register_builtin_backends, registered_backends, RigAccess}; use trx_core::radio::freq::Freq; use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff}; @@ -304,6 +308,34 @@ async fn main() -> DynResult<()> { }); } + if cfg.audio.enabled { + let audio_listen = SocketAddr::from((cfg.audio.listen, cfg.audio.port)); + let stream_info = AudioStreamInfo { + sample_rate: cfg.audio.sample_rate, + channels: cfg.audio.channels, + frame_duration_ms: cfg.audio.frame_duration_ms, + }; + + let (rx_audio_tx, _) = broadcast::channel::(256); + let (tx_audio_tx, tx_audio_rx) = mpsc::channel::(64); + + if cfg.audio.rx_enabled { + let _capture_thread = audio::spawn_audio_capture(&cfg.audio, rx_audio_tx.clone()); + } + if cfg.audio.tx_enabled { + let _playback_thread = audio::spawn_audio_playback(&cfg.audio, tx_audio_rx); + } + + tokio::spawn(async move { + if let Err(e) = + audio::run_audio_listener(audio_listen, rx_audio_tx, tx_audio_tx, stream_info) + .await + { + error!("Audio listener error: {:?}", e); + } + }); + } + let _tx = tx; signal::ctrl_c().await?;