From 0bb9d0689e4582bcfbca36ea54494dabd0930516 Mon Sep 17 00:00:00 2001 From: Justin Scroggins Date: Mon, 2 Nov 2026 18:51:33 -0500 Subject: [PATCH] Give Tiny calls Discord-style mic controls, stronger noise modes, and voice quality presets. Adds a yellow/green/red input meter, mic volume, Off/Standard/High/Enhanced noise (RNNoise), and Data saver/Standard/High Opus presets so tinyserver mics are less touchy and clearer when users want them to be. Co-authored-by: Cursor --- Cargo.lock | 335 +++++++++++++++++- crates/chatd/src/media.rs | 20 +- crates/chatd/src/server.rs | 4 +- crates/mediad/Cargo.toml | 2 + crates/mediad/src/harness.rs | 2 +- crates/mediad/src/media.rs | 2 + crates/mediad/src/opus.rs | 8 +- crates/mediad/src/probe.rs | 1 + crates/mediad/src/process.rs | 150 ++++++-- crates/mediad/src/serve.rs | 4 + crates/mediad/src/session.rs | 12 +- crates/mediad/src/tiny.rs | 13 +- crates/protocol/src/call.rs | 131 ++++++- crates/protocol/src/settings.rs | 40 ++- crates/tinyclient/src/gui/call/devices.rs | 19 +- .../tinyclient/src/gui/call/phone/sheets.rs | 15 +- .../tinyclient/src/gui/settings/controls.rs | 13 +- crates/tinyclient/src/gui/settings/pages.rs | 66 +++- crates/tinyclient/src/gui/settings/rows.rs | 6 +- docs/product/09-settings.md | 6 +- 20 files changed, 760 insertions(+), 89 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc4c26ce..3c550576 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -311,6 +311,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "anymap3" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9" + [[package]] name = "approx" version = "0.5.1" @@ -350,6 +356,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + [[package]] name = "arrayref" version = "0.3.9" @@ -699,6 +711,17 @@ dependencies = [ "zbus", ] +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -1348,6 +1371,31 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" +dependencies = [ + "atty", + "bitflags 1.3.2", + "clap_lex", + "indexmap 1.9.3", + "once_cell", + "strsim 0.10.0", + "termcolor", + "textwrap", +] + +[[package]] +name = "clap_lex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +dependencies = [ + "os_str_bytes", +] + [[package]] name = "cmake" version = "0.1.58" @@ -1578,7 +1626,7 @@ dependencies = [ "cookie", "document-features", "idna", - "indexmap", + "indexmap 2.14.2", "log", "serde", "serde_derive", @@ -2055,7 +2103,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.119", ] @@ -2070,12 +2118,125 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dasp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7381b67da416b639690ac77c73b86a7b5e64a29e31d1f75fb3b1102301ef355a" +dependencies = [ + "dasp_envelope", + "dasp_frame", + "dasp_interpolate", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", + "dasp_signal", + "dasp_slice", + "dasp_window", +] + +[[package]] +name = "dasp_envelope" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ec617ce7016f101a87fe85ed44180839744265fae73bb4aa43e7ece1b7668b6" +dependencies = [ + "dasp_frame", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", +] + +[[package]] +name = "dasp_frame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" +dependencies = [ + "dasp_sample", +] + +[[package]] +name = "dasp_interpolate" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc975a6563bb7ca7ec0a6c784ead49983a21c24835b0bc96eea11ee407c7486" +dependencies = [ + "dasp_frame", + "dasp_ring_buffer", + "dasp_sample", +] + +[[package]] +name = "dasp_peak" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf88559d79c21f3d8523d91250c397f9a15b5fc72fbb3f87fdb0a37b79915bf" +dependencies = [ + "dasp_frame", + "dasp_sample", +] + +[[package]] +name = "dasp_ring_buffer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07d79e19b89618a543c4adec9c5a347fe378a19041699b3278e616e387511ea1" + +[[package]] +name = "dasp_rms" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c5dcb30b7e5014486e2822537ea2beae50b19722ffe2ed7549ab03774575aa" +dependencies = [ + "dasp_frame", + "dasp_ring_buffer", + "dasp_sample", +] + [[package]] name = "dasp_sample" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "dasp_signal" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa1ab7d01689c6ed4eae3d38fe1cea08cba761573fbd2d592528d55b421077e7" +dependencies = [ + "dasp_envelope", + "dasp_frame", + "dasp_interpolate", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", + "dasp_window", +] + +[[package]] +name = "dasp_slice" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e1c7335d58e7baedafa516cb361360ff38d6f4d3f9d9d5ee2a2fc8e27178fa1" +dependencies = [ + "dasp_frame", + "dasp_sample", +] + +[[package]] +name = "dasp_window" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99ded7b88821d2ce4e8b842c9f1c86ac911891ab89443cc1de750cae764c5076" +dependencies = [ + "dasp_sample", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -2526,6 +2687,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "easyfft" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "767e39eef2ad8a3b6f1d733be3ec70364d21d437d06d4f18ea76ce08df20b75f" +dependencies = [ + "array-init", + "generic_singleton", + "num-complex", + "realfft", + "rustfft", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -3310,6 +3484,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic_singleton" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab6e923c8e978e57cf63e2e200ca967d1d20f0ea2662b28f6d4e11c44aa6ab16" +dependencies = [ + "anymap3", + "parking_lot 0.12.5", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -3697,7 +3881,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a50a4b92bb986496158b619d9b0bc730dc5dcdb27f6cbc84359227e0b70894b" dependencies = [ "gpui_ce_util", - "indexmap", + "indexmap 2.14.2", "rustc-hash 2.1.3", ] @@ -4288,6 +4472,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -4405,6 +4595,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.5.3" @@ -4465,6 +4664,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "hpke-rs" version = "0.6.1" @@ -4874,6 +5079,16 @@ version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -5531,7 +5746,7 @@ dependencies = [ "hex", "hkdf", "hmac 0.12.1", - "indexmap", + "indexmap 2.14.2", "itertools", "libcrux-ml-kem", "libsignal-core", @@ -5877,6 +6092,7 @@ dependencies = [ "libc", "mio", "native-tls", + "nnnoiseless", "nokhwa", "objc2 0.6.5", "openh264", @@ -6053,7 +6269,7 @@ dependencies = [ "cfg_aliases", "codespan-reporting 0.11.1", "hexf-parse", - "indexmap", + "indexmap 2.14.2", "log", "rustc-hash 1.1.0", "strum 0.26.3", @@ -6077,7 +6293,7 @@ dependencies = [ "half", "hashbrown 0.16.1", "hexf-parse", - "indexmap", + "indexmap 2.14.2", "libm", "log", "num-traits", @@ -6162,6 +6378,22 @@ dependencies = [ "libc", ] +[[package]] +name = "nnnoiseless" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805d5964d1e7a0006a7fdced7dae75084d66d18b35f1dfe81bd76929b1f8da0c" +dependencies = [ + "anyhow", + "clap", + "dasp", + "dasp_interpolate", + "dasp_ring_buffer", + "easyfft", + "hound", + "once_cell", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -6356,6 +6588,7 @@ checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "bytemuck", "num-traits", + "serde", ] [[package]] @@ -6421,7 +6654,7 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi", + "hermit-abi 0.5.3", "libc", ] @@ -7379,6 +7612,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" + [[package]] name = "p256" version = "0.13.2" @@ -7672,7 +7911,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.2", ] [[package]] @@ -7844,7 +8083,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if 1.0.5", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.5.3", "pin-project-lite", "rustix 1.1.5", "windows-sys 0.61.2", @@ -8009,6 +8248,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -8237,7 +8485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.2", "log", "protobuf", "protobuf-support", @@ -8635,6 +8883,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "realfft" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "538895ccf2006b473aafbe8d72770e21038961ab67aae34f3c577485fc703a7d" +dependencies = [ + "rustfft", +] + [[package]] name = "reborrow" version = "0.5.5" @@ -8937,6 +9194,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -9075,7 +9346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "dyn-clone", - "indexmap", + "indexmap 2.14.2", "ref-cast", "schemars_derive", "serde", @@ -9307,7 +9578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "foldhash 0.2.0", - "indexmap", + "indexmap 2.14.2", "itoa", "memchr", "serde", @@ -9868,6 +10139,12 @@ dependencies = [ "time", ] +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strict-num" version = "0.1.1" @@ -9901,6 +10178,12 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -10457,6 +10740,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" + [[package]] name = "thiserror" version = "1.0.69" @@ -10742,7 +11031,7 @@ version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ - "indexmap", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.2", "toml_datetime 1.1.2+spec-1.1.0", @@ -10775,7 +11064,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap", + "indexmap 2.14.2", "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -10786,7 +11075,7 @@ version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" dependencies = [ - "indexmap", + "indexmap 2.14.2", "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -10797,7 +11086,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -10810,7 +11099,7 @@ version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ - "indexmap", + "indexmap 2.14.2", "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "winnow 1.0.4", @@ -10948,6 +11237,16 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -11784,7 +12083,7 @@ dependencies = [ "cfg_aliases", "document-features", "hashbrown 0.16.1", - "indexmap", + "indexmap 2.14.2", "log", "naga 29.0.4", "once_cell", diff --git a/crates/chatd/src/media.rs b/crates/chatd/src/media.rs index 35be3878..43893581 100644 --- a/crates/chatd/src/media.rs +++ b/crates/chatd/src/media.rs @@ -83,6 +83,8 @@ struct Live { /// The room requires push-to-talk (a Tiny room's `ptt_only`, or no voice activation /// there), whatever the user chose. push_to_talk: bool, + /// A Tiny room's bitrate ceiling in kb/s, when known. Quality presets are applied against it. + room_bitrate: Option, /// How mediad last said the call is protected, before whom the user verified is applied. secured: Option, } @@ -229,6 +231,7 @@ fn new_call(conversation: Key, state: CallState) -> Live { metering: false, me: String::new(), push_to_talk: false, + room_bitrate: None, secured: None, } } @@ -743,10 +746,10 @@ impl Calls { drop(tester); hub.changed(Change::Meter { level: None }); } - let (tuning, input, output, muted, mode, processing) = { + let (tuning, input, output, muted, mode, processing, quality) = { let settings = hub.settings(); let mode = input_mode(&settings, false); - (settings.tuning(), settings.device(INPUT), settings.device(OUTPUT), settings.on(JOIN_MUTED), mode, settings.processing()) + (settings.tuning(), settings.device(INPUT), settings.device(OUTPUT), settings.on(JOIN_MUTED), mode, settings.processing(), settings.voice_quality()) }; { let mut slot = crate::hub::lock(&self.slot); @@ -763,6 +766,7 @@ impl Calls { let _ = active.request(&MediaRequest::Tune(tuning)); let _ = active.request(&MediaRequest::Input(mode)); let _ = active.request(&MediaRequest::Processing(processing)); + let _ = active.request(&MediaRequest::Quality(quality)); // V9: everyone on this account whose volume for me is not the default. let gains = hub.settings().gains(&conversation.account()); for (user_id, percent, muted) in gains { @@ -793,6 +797,13 @@ impl Calls { let _ = active.request(&MediaRequest::Tune(tuning)); let _ = active.request(&MediaRequest::Input(input_mode(&settings, push_to_talk))); let _ = active.request(&MediaRequest::Processing(settings.processing())); + let quality = settings.voice_quality(); + let _ = active.request(&MediaRequest::Quality(quality)); + // Re-apply the room bitrate under the new quality cap when we know the room's. + let room_bitrate = crate::hub::lock(&active.live).room_bitrate; + if let Some(kbps) = room_bitrate { + let _ = active.request(&MediaRequest::Bitrate { kbps: quality.apply_room(kbps) }); + } } if let Some(tester) = crate::hub::lock(&self.tester).as_mut() { let _ = tester.send(&MediaRequest::Tune(tuning)); @@ -1254,10 +1265,13 @@ impl Calls { if !self.update(hub, conversation, |live| { changed = live.push_to_talk != push_to_talk; live.push_to_talk = push_to_talk; + live.room_bitrate = Some(kbps); }) { return; } - let _ = self.request(hub, &MediaRequest::Bitrate { kbps }); + let quality = hub.settings().voice_quality(); + let _ = self.request(hub, &MediaRequest::Bitrate { kbps: quality.apply_room(kbps) }); + let _ = self.request(hub, &MediaRequest::Quality(quality)); if changed { let mode = input_mode(&hub.settings(), push_to_talk); let _ = self.request(hub, &MediaRequest::Input(mode)); diff --git a/crates/chatd/src/server.rs b/crates/chatd/src/server.rs index 28ab6e53..1ddcb325 100644 --- a/crates/chatd/src/server.rs +++ b/crates/chatd/src/server.rs @@ -418,7 +418,9 @@ impl Chat { | settings::PTT_RELEASE_MS | settings::ECHO_CANCELLATION | settings::NOISE_SUPPRESSION - | settings::AUTO_GAIN => self.hub.calls.retune(&self.hub), + | settings::AUTO_GAIN + | settings::INPUT_VOLUME + | settings::VOICE_QUALITY => self.hub.calls.retune(&self.hub), settings::INPUT | settings::OUTPUT => self.hub.calls.redevice(&self.hub), _ => {} } diff --git a/crates/mediad/Cargo.toml b/crates/mediad/Cargo.toml index 74da164d..1b3d3a9e 100644 --- a/crates/mediad/Cargo.toml +++ b/crates/mediad/Cargo.toml @@ -46,6 +46,8 @@ symphonia = { version = "0.5", default-features = false, features = ["ogg", "mkv # (`webrtc-audio-processing`) needs meson and ninja to build, and none of the other pure-Rust # echo cancellers is WebRTC's. Pinned: a port moves fast, and a new version must be heard first. sonora = "=0.2.0" +# Neural noise suppression (RNNoise), pure Rust. Used for the Enhanced noise mode. +nnnoiseless = "0.5.2" # The watched picture is a JPEG on disk. The jpeg encoder is pure Rust. image = { version = "0.25.10", default-features = false, features = ["jpeg"] } diff --git a/crates/mediad/src/harness.rs b/crates/mediad/src/harness.rs index 05a85d19..fdc695d8 100644 --- a/crates/mediad/src/harness.rs +++ b/crates/mediad/src/harness.rs @@ -325,7 +325,7 @@ pub fn realtime(mic: (u32, usize), speaker: (u32, usize), seconds: f64) -> (crat crate::rt::tick_thread(); let mut processor = Processor::new(); - processor.set(Some(AudioProcessing { echo_cancellation: true, noise_suppression: true, auto_gain: true })); + processor.set(Some(AudioProcessing { echo_cancellation: true, auto_gain: true, noise: protocol::NoiseMode::Standard })); let (mut encoder, mut decoder) = (Encoder::voice().unwrap(), Decoder::new().unwrap()); let mut limiter = Limiter::default(); let mut warm = None; diff --git a/crates/mediad/src/media.rs b/crates/mediad/src/media.rs index b39fefcc..8c742e42 100644 --- a/crates/mediad/src/media.rs +++ b/crates/mediad/src/media.rs @@ -396,6 +396,8 @@ pub(crate) fn listen(controls: &Controls, audio: &mut Audio, processor: &mut Pro let captured = audio.capture(frame); processor.set(controls.processing()); if captured { + // Mic volume before DSP so AGC/NS see the level the user chose, and the meter matches. + crate::process::apply_input_gain(frame, controls.tuning().input); processor.capture(frame); } let heard = if captured { level(frame) } else { 0 }; diff --git a/crates/mediad/src/opus.rs b/crates/mediad/src/opus.rs index fe60a879..e9fdf0f9 100644 --- a/crates/mediad/src/opus.rs +++ b/crates/mediad/src/opus.rs @@ -107,7 +107,13 @@ impl Encoder { /// frames inside a talk spurt (the pauses between words) reach the far end as losses it has /// to make up. pub fn voice() -> Option { - Encoder::made(1, OPUS_APPLICATION_VOIP, VOICE_BITRATE, false) + Encoder::voice_quality(protocol::VoiceQuality::Standard) + } + + /// Like [`Encoder::voice`], but High quality uses Opus's Audio application for clearer speech. + pub fn voice_quality(quality: protocol::VoiceQuality) -> Option { + let application = if quality.prefer_audio_application() { OPUS_APPLICATION_AUDIO } else { OPUS_APPLICATION_VOIP }; + Encoder::made(1, application, VOICE_BITRATE, false) } fn made(channels: usize, application: i32, bitrate: i32, dtx: bool) -> Option { diff --git a/crates/mediad/src/probe.rs b/crates/mediad/src/probe.rs index 74e1f1bc..f86e1795 100644 --- a/crates/mediad/src/probe.rs +++ b/crates/mediad/src/probe.rs @@ -122,6 +122,7 @@ pub fn run() { }; let mut frame = [0i16; FRAME_SAMPLES]; if audio.capture(&mut frame) { + crate::process::apply_input_gain(&mut frame, controls.tuning().input); processor.capture(&mut frame); loudest = loudest.max(level(&frame)); if loopback { diff --git a/crates/mediad/src/process.rs b/crates/mediad/src/process.rs index 07b4ddf5..fd0423e8 100644 --- a/crates/mediad/src/process.rs +++ b/crates/mediad/src/process.rs @@ -1,6 +1,7 @@ //! The microphone's processing (V10 to V12): echo cancellation, noise suppression and automatic //! gain, through `sonora`, WebRTC's audio processing module ported to Rust (AEC3, its noise -//! suppressor and AGC2, with the high-pass filter in front). +//! suppressor and AGC2, with the high-pass filter in front). Enhanced noise uses RNNoise +//! (`nnnoiseless`) after AEC instead of sonora's Wiener NS. //! //! The echo canceller needs what the speaker plays: the call's mix goes in as the far end //! ([`Processor::render`]) every tick, cues included, and each microphone frame comes out with @@ -9,18 +10,23 @@ //! to mono going in and the result written to both channels coming out. //! //! Until chatd says what the user chose (`MediaRequest::Processing`), nothing is processed, and -//! with all three off the processor is not even built: the microphone is as captured. +//! with everything off the processor is not even built: the microphone is as captured. use crate::{CHANNELS, FRAME, FRAME_SAMPLES, RATE}; -use protocol::AudioProcessing; -use sonora::config::{AdaptiveDigital, EchoCanceller, GainController2, HighPassFilter, NoiseSuppression}; +use nnnoiseless::DenoiseState; +use protocol::{AudioProcessing, NoiseMode}; +use sonora::config::{ + AdaptiveDigital, EchoCanceller, GainController2, HighPassFilter, NoiseSuppression, NoiseSuppressionLevel, +}; use sonora::{Config, StreamConfig}; -/// Samples in one 10 ms block, the module's unit. +/// Samples in one 10 ms block, the module's unit — also RNNoise's frame. const BLOCK: usize = FRAME / 2; pub struct Processor { apm: Option, + /// RNNoise, only when Enhanced is chosen. + denoise: Option>>, /// What is in force, so a repeat of the same choice changes nothing. chosen: Option, } @@ -31,23 +37,40 @@ impl Default for Processor { } } -/// The module's settings for the user's three switches. +fn ns_level(mode: NoiseMode) -> Option { + let level = match mode { + NoiseMode::Off | NoiseMode::Enhanced => return None, + NoiseMode::Standard => NoiseSuppressionLevel::High, + NoiseMode::High => NoiseSuppressionLevel::VeryHigh, + }; + Some(NoiseSuppression { level, ..NoiseSuppression::default() }) +} + +/// The module's settings for the user's echo / noise / gain choices. fn config(chosen: AudioProcessing) -> Config { Config { high_pass_filter: Some(HighPassFilter::default()), echo_canceller: chosen.echo_cancellation.then(EchoCanceller::default), - noise_suppression: chosen.noise_suppression.then(NoiseSuppression::default), + noise_suppression: ns_level(chosen.noise), // Adaptive digital gain only: it brings a quiet microphone up to speech level and never // touches the system's input volume. - gain_controller2: chosen.auto_gain.then(|| GainController2 { adaptive_digital: Some(AdaptiveDigital::default()), ..GainController2::default() }), + gain_controller2: chosen.auto_gain.then(|| GainController2 { + adaptive_digital: Some(AdaptiveDigital::default()), + ..GainController2::default() + }), ..Config::default() } } +fn wants_apm(chosen: AudioProcessing) -> bool { + // Enhanced still wants the high-pass (and optional AEC/AGC) before RNNoise. + chosen.echo_cancellation || chosen.auto_gain || chosen.noise != NoiseMode::Off +} + impl Processor { /// Off: frames pass untouched. pub fn new() -> Processor { - Processor { apm: None, chosen: None } + Processor { apm: None, denoise: None, chosen: None } } /// Takes the user's choice. All off, or `None`, turns processing off. @@ -56,15 +79,20 @@ impl Processor { return; } self.chosen = chosen; - let wanted = chosen.filter(|c| c.echo_cancellation || c.noise_suppression || c.auto_gain); - self.apm = wanted.map(|chosen| { + let Some(chosen) = chosen else { + self.apm = None; + self.denoise = None; + return; + }; + self.apm = wants_apm(chosen).then(|| { let stream = StreamConfig::new(RATE, 1); sonora::AudioProcessing::builder().config(config(chosen)).capture_config(stream).render_config(stream).build() }); + self.denoise = (chosen.noise == NoiseMode::Enhanced).then(DenoiseState::new); } pub fn active(&self) -> bool { - self.apm.is_some() + self.apm.is_some() || self.denoise.is_some() } /// What the speaker is about to play: the echo canceller's reference. @@ -85,14 +113,32 @@ impl Processor { /// One microphone frame, processed in place. pub fn capture(&mut self, frame: &mut [i16; FRAME_SAMPLES]) { - let Some(apm) = &mut self.apm else { + if self.apm.is_none() && self.denoise.is_none() { return; - }; + } let mono = fold(frame); let mut done = [0f32; FRAME]; - for (block, out) in mono.as_chunks::().0.iter().zip(done.as_chunks_mut::().0) { - if apm.process_capture_f32(&[&block[..]], &mut [&mut out[..]]).is_err() { - out.copy_from_slice(block); + if let Some(apm) = &mut self.apm { + for (block, out) in mono.as_chunks::().0.iter().zip(done.as_chunks_mut::().0) { + if apm.process_capture_f32(&[&block[..]], &mut [&mut out[..]]).is_err() { + out.copy_from_slice(block); + } + } + } else { + done = mono; + } + if let Some(denoise) = &mut self.denoise { + // RNNoise wants ±32768-scale floats in 10 ms blocks. + let mut scaled = [0f32; FRAME]; + for (out, sample) in scaled.iter_mut().zip(done) { + *out = sample * 32768.; + } + let mut cleaned = [0f32; FRAME]; + for (block_in, block_out) in scaled.as_chunks::().0.iter().zip(cleaned.as_chunks_mut::().0) { + let _ = denoise.process_frame(block_out, block_in); + } + for (out, sample) in done.iter_mut().zip(cleaned) { + *out = sample / 32768.; } } for (pair, sample) in frame.as_chunks_mut::().0.iter_mut().zip(done) { @@ -106,7 +152,22 @@ impl Processor { } } -/// A stereo frame as mono floats. +/// Soft mic gain from Settings (0 to 100, 100 = unity), applied in place before processing. +pub fn apply_input_gain(frame: &mut [i16; FRAME_SAMPLES], percent: u8) { + if percent >= 100 { + return; + } + if percent == 0 { + frame.fill(0); + return; + } + let gain = f32::from(percent) / 100.; + for sample in frame.iter_mut() { + *sample = (f32::from(*sample) * gain).round().clamp(-32768., 32767.) as i16; + } +} + +/// A stereo frame as mono floats in −1…1. fn fold(frame: &[i16; FRAME_SAMPLES]) -> [f32; FRAME] { let mut mono = [0f32; FRAME]; for (out, pair) in mono.iter_mut().zip(frame.as_chunks::().0) { @@ -152,8 +213,8 @@ mod tests { frame } - fn on(echo_cancellation: bool, noise_suppression: bool, auto_gain: bool) -> Option { - Some(AudioProcessing { echo_cancellation, noise_suppression, auto_gain }) + fn on(echo_cancellation: bool, noise: NoiseMode, auto_gain: bool) -> Option { + Some(AudioProcessing { echo_cancellation, auto_gain, noise }) } /// The speaker's sound reaches the microphone 60 ms later at 40 %: what was played comes @@ -164,7 +225,7 @@ mod tests { let far = far_end(10); let delay = RATE as usize * 60 / 1000; let mut processor = Processor::new(); - processor.set(on(true, false, false)); + processor.set(on(true, NoiseMode::Off, false)); let (mut heard, mut sent) = (Vec::new(), Vec::new()); for (n, played) in far.as_chunks::().0.iter().enumerate() { processor.render(&stereo(played)); @@ -186,7 +247,7 @@ mod tests { fn speech_with_no_echo_goes_through() { let near = far_end(4); let mut processor = Processor::new(); - processor.set(on(true, false, false)); + processor.set(on(true, NoiseMode::Off, false)); let (mut heard, mut sent) = (Vec::new(), Vec::new()); for (n, said) in near.as_chunks::().0.iter().enumerate() { processor.render(&[0; FRAME_SAMPLES]); @@ -213,7 +274,7 @@ mod tests { .map(|s| s as i16) .collect(); let mut processor = Processor::new(); - processor.set(on(false, true, false)); + processor.set(on(false, NoiseMode::Standard, false)); let (mut heard, mut sent) = (Vec::new(), Vec::new()); for (n, noise) in hiss.as_chunks::().0.iter().enumerate() { let mut frame = stereo(noise); @@ -227,12 +288,39 @@ mod tests { assert!(reduced > 6., "the hiss is only {reduced:.1} dB quieter"); } + /// Enhanced (RNNoise) also brings steady hiss down. + #[test] + fn enhanced_noise_brings_steady_hiss_down() { + let mut seed: u32 = 7; + let hiss: Vec = (0..RATE as usize * 3) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + ((seed >> 8) as f32 / (1 << 24) as f32 * 2. - 1.) as f64 * 1500. + }) + .map(|s| s as i16) + .collect(); + let mut processor = Processor::new(); + processor.set(on(false, NoiseMode::Enhanced, false)); + let (mut heard, mut sent) = (Vec::new(), Vec::new()); + // RNNoise fades in; skip the first second so the measurement is steady state. + for (n, noise) in hiss.as_chunks::().0.iter().enumerate() { + let mut frame = stereo(noise); + processor.capture(&mut frame); + if n > 50 { + heard.extend_from_slice(noise); + sent.extend(frame.iter().step_by(CHANNELS)); + } + } + let reduced = db(rms(&heard) / rms(&sent).max(1.)); + assert!(reduced > 3., "enhanced hiss is only {reduced:.1} dB quieter"); + } + /// Automatic gain brings a quiet voice up. #[test] fn auto_gain_brings_a_quiet_voice_up() { let quiet: Vec = far_end(6).into_iter().map(|s| s / 20).collect(); let mut processor = Processor::new(); - processor.set(on(false, false, true)); + processor.set(on(false, NoiseMode::Off, true)); let (mut heard, mut sent) = (Vec::new(), Vec::new()); for (n, said) in quiet.as_chunks::().0.iter().enumerate() { let mut frame = stereo(said); @@ -249,7 +337,7 @@ mod tests { #[test] fn off_or_untold_leaves_the_microphone_as_captured() { let said = far_end(1); - for chosen in [None, on(false, false, false)] { + for chosen in [None, on(false, NoiseMode::Off, false)] { let mut processor = Processor::new(); processor.set(chosen); assert!(!processor.active()); @@ -260,4 +348,16 @@ mod tests { assert_eq!(frame, before); } } + + #[test] + fn input_gain_scales_and_zero_silences() { + let mut frame = [1000i16; FRAME_SAMPLES]; + apply_input_gain(&mut frame, 50); + assert_eq!(frame[0], 500); + apply_input_gain(&mut frame, 0); + assert_eq!(frame[0], 0); + let mut full = [1000i16; FRAME_SAMPLES]; + apply_input_gain(&mut full, 100); + assert_eq!(full[0], 1000); + } } diff --git a/crates/mediad/src/serve.rs b/crates/mediad/src/serve.rs index 85d81d6a..ca574205 100644 --- a/crates/mediad/src/serve.rs +++ b/crates/mediad/src/serve.rs @@ -299,6 +299,10 @@ impl Call { self.controls.set_bitrate(kbps); return MediaResponse::Done; } + MediaRequest::Quality(quality) => { + self.controls.set_quality(quality); + return MediaResponse::Done; + } // Measured once a second by the call's loop (`stats`). MediaRequest::Stats => { return match self.controls.stats() { diff --git a/crates/mediad/src/session.rs b/crates/mediad/src/session.rs index 0ec9d431..c963eac4 100644 --- a/crates/mediad/src/session.rs +++ b/crates/mediad/src/session.rs @@ -14,7 +14,7 @@ use crate::media::{self, FromMedia, MICROPHONE_DENIED}; use crate::permission::{microphone, Microphone}; use crate::rtp::{self, Cipher, Sender}; use crate::out; -use protocol::{AudioProcessing, CallFailure, CallStats, Cue, InputMode, MediaResponse, TinyPicture, TinySlot, Tuning, VoiceJoin}; +use protocol::{AudioProcessing, CallFailure, CallStats, Cue, InputMode, MediaResponse, TinyPicture, TinySlot, Tuning, VoiceJoin, VoiceQuality}; use serde_json::{json, Value}; use std::collections::{BTreeSet, HashMap}; use std::net::UdpSocket; @@ -100,6 +100,8 @@ pub struct Controls { ear: Mutex, /// Echo cancellation, noise suppression, automatic gain. `None` until chatd says: off. processing: Mutex>, + /// How clearly to encode (Tiny). Standard until chatd says. + quality: Mutex, /// What our voice is encoded at, in kb/s. 0: the call's own default. bitrate: AtomicU32, /// The connection over the last second, from the loop that carries the call. @@ -276,6 +278,14 @@ impl Controls { *self.processing.lock().unwrap() } + pub fn set_quality(&self, quality: VoiceQuality) { + *self.quality.lock().unwrap() = quality; + } + + pub fn quality(&self) -> VoiceQuality { + *self.quality.lock().unwrap() + } + pub fn set_bitrate(&self, kbps: u32) { self.bitrate.store(kbps, Ordering::Relaxed); } diff --git a/crates/mediad/src/tiny.rs b/crates/mediad/src/tiny.rs index bf318302..d98736be 100644 --- a/crates/mediad/src/tiny.rs +++ b/crates/mediad/src/tiny.rs @@ -470,6 +470,8 @@ struct Call { devices: Option, microphone_unasked: bool, encoder: Encoder, + /// Whether the encoder was made with Opus Audio (High quality) vs Voip. + encoder_audio_app: bool, gate: Gate, slots: HashMap, ticks: u64, @@ -1102,6 +1104,13 @@ impl Call { /// silence trail have gone, a muted microphone sends nothing. fn capture(&mut self, audio: &mut Audio) -> u8 { let controls = self.controls.clone(); + let quality = controls.quality(); + if quality.prefer_audio_application() != self.encoder_audio_app { + if let Some(encoder) = Encoder::voice_quality(quality) { + self.encoder = encoder; + self.encoder_audio_app = quality.prefer_audio_application(); + } + } self.encoder.set_bitrate(controls.bitrate()); let muted = controls.muted.load(Ordering::Relaxed); let mut frame = [0i16; FRAME_SAMPLES]; @@ -1297,7 +1306,8 @@ pub fn run(link: Link, user_id: String, controls: Arc) { // The tick is woken by the system timer: at 1 ms, not Windows' default 15.6 ms. let _timer = rt::FineTimer::hold(); rt::tick_thread(); - let (Some(encoder), Ok(poll)) = (Encoder::voice(), Poll::new()) else { + let quality = controls.quality(); + let (Some(encoder), Ok(poll)) = (Encoder::voice_quality(quality), Poll::new()) else { tracing::warn!("mediad: the Opus encoder or the poller could not be made"); out::fail(CallFailure::Engine, ENGINE); }; @@ -1319,6 +1329,7 @@ pub fn run(link: Link, user_id: String, controls: Arc) { devices: None, microphone_unasked, encoder, + encoder_audio_app: quality.prefer_audio_application(), gate: Gate::default(), slots: HashMap::new(), ticks: 0, diff --git a/crates/protocol/src/call.rs b/crates/protocol/src/call.rs index 7e61efec..a6cea8e4 100644 --- a/crates/protocol/src/call.rs +++ b/crates/protocol/src/call.rs @@ -422,6 +422,8 @@ pub enum MediaRequest { Processing(AudioProcessing), /// What our voice is encoded at, in kb/s (a Tiny room's bitrate, 16 to 128). Bitrate { kbps: u32 }, + /// How clearly to encode within that bitrate (Tiny calls). + Quality(VoiceQuality), /// How the connection is doing. Answered `Stats`, or `Refused` before media flows. Stats, /// Settings' microphone test plays the microphone back on the speaker, through the same @@ -454,17 +456,131 @@ impl Default for InputMode { } } -/// The microphone's processing. All on unless the user turned one off. +/// How noise is taken out of the microphone. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NoiseMode { + /// Nothing: fans, typing and traffic go through as captured. + Off, + /// WebRTC noise suppression at a strong everyday level. + #[default] + Standard, + /// WebRTC noise suppression at its strongest. + High, + /// RNNoise (neural): better on keyboards and steady noise than Standard/High. + Enhanced, +} + +impl NoiseMode { + pub fn as_str(self) -> &'static str { + match self { + NoiseMode::Off => "off", + NoiseMode::Standard => "standard", + NoiseMode::High => "high", + NoiseMode::Enhanced => "enhanced", + } + } + + pub fn parse(value: &str) -> Option { + Some(match value { + "off" => NoiseMode::Off, + // Older builds stored a switch: "on" meant WebRTC NS at its then-default. + "on" | "standard" => NoiseMode::Standard, + "high" => NoiseMode::High, + "enhanced" => NoiseMode::Enhanced, + _ => return None, + }) + } +} + +/// How clearly our voice is encoded, within a Tiny room's bitrate ceiling. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VoiceQuality { + /// Save bandwidth: about 32 kb/s. + DataSaver, + /// The room's bitrate as set (default). + #[default] + Standard, + /// Prefer the top of the room's range; clearer Opus Audio mode. + High, +} + +impl VoiceQuality { + pub fn as_str(self) -> &'static str { + match self { + VoiceQuality::DataSaver => "data_saver", + VoiceQuality::Standard => "standard", + VoiceQuality::High => "high", + } + } + + pub fn parse(value: &str) -> Option { + Some(match value { + "data_saver" => VoiceQuality::DataSaver, + "standard" => VoiceQuality::Standard, + "high" => VoiceQuality::High, + _ => return None, + }) + } + + /// The encode target in kb/s for a Tiny room whose ceiling is `room_kbps` (16 to 128). + /// Data saver caps at 32; High uses the room's full rate (admins set 96–128 for clarity). + pub fn apply_room(self, room_kbps: u32) -> u32 { + let room = room_kbps.clamp(16, 128); + match self { + VoiceQuality::DataSaver => room.min(32), + VoiceQuality::Standard | VoiceQuality::High => room, + } + } + + /// Whether the encoder should prefer Opus's Audio application (clearer than Voip). + pub fn prefer_audio_application(self) -> bool { + matches!(self, VoiceQuality::High) + } +} + +#[cfg(test)] +mod quality_tests { + use super::*; + + #[test] + fn voice_quality_caps_data_saver_and_keeps_room_for_high() { + assert_eq!(VoiceQuality::DataSaver.apply_room(128), 32); + assert_eq!(VoiceQuality::DataSaver.apply_room(24), 24); + assert_eq!(VoiceQuality::Standard.apply_room(64), 64); + assert_eq!(VoiceQuality::High.apply_room(128), 128); + assert!(VoiceQuality::High.prefer_audio_application()); + assert!(!VoiceQuality::Standard.prefer_audio_application()); + } + + #[test] + fn noise_mode_reads_legacy_on_off() { + assert_eq!(NoiseMode::parse("on"), Some(NoiseMode::Standard)); + assert_eq!(NoiseMode::parse("off"), Some(NoiseMode::Off)); + assert_eq!(NoiseMode::parse("enhanced"), Some(NoiseMode::Enhanced)); + } +} + +/// The microphone's processing. Echo and gain are on unless turned off; noise has modes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct AudioProcessing { pub echo_cancellation: bool, - pub noise_suppression: bool, pub auto_gain: bool, + #[serde(default)] + pub noise: NoiseMode, } impl Default for AudioProcessing { fn default() -> AudioProcessing { - AudioProcessing { echo_cancellation: true, noise_suppression: true, auto_gain: true } + AudioProcessing { echo_cancellation: true, auto_gain: true, noise: NoiseMode::Standard } + } +} + +impl AudioProcessing { + /// Whether any noise suppression runs. + pub fn noise_suppression(self) -> bool { + self.noise != NoiseMode::Off } } @@ -548,6 +664,9 @@ pub struct Tuning { pub gate: u8, /// The cues' volume. At 100 a cue peaks at -20 dBFS. pub cues: u8, + /// Our microphone's soft gain before processing. 100 is as captured; 0 is silence. + #[serde(default = "tuning_input_default")] + pub input: u8, /// Someone joins or leaves (S7). pub joins: bool, /// I mute, unmute, deafen or undeafen (S8). @@ -556,9 +675,13 @@ pub struct Tuning { pub connection: bool, } +fn tuning_input_default() -> u8 { + 100 +} + impl Default for Tuning { fn default() -> Tuning { - Tuning { voice: 100, gate: 25, cues: 50, joins: true, mine: true, connection: true } + Tuning { voice: 100, gate: 25, cues: 50, input: 100, joins: true, mine: true, connection: true } } } diff --git a/crates/protocol/src/settings.rs b/crates/protocol/src/settings.rs index 5bbbd348..dac4f7af 100644 --- a/crates/protocol/src/settings.rs +++ b/crates/protocol/src/settings.rs @@ -67,10 +67,16 @@ pub const SENSITIVITY: &str = "sensitivity"; pub const PUSH_TO_TALK: &str = "push_to_talk"; /// V8. How long the microphone stays open after push-to-talk is let go, in milliseconds. pub const PTT_RELEASE_MS: &str = "ptt_release_ms"; -/// V10, V11, V12. The microphone's processing. +/// V10, V11, V12. The microphone's processing. V11 is [`NOISE_CHOICES`]: off, standard, high, enhanced. pub const ECHO_CANCELLATION: &str = "echo_cancellation"; pub const NOISE_SUPPRESSION: &str = "noise_suppression"; pub const AUTO_GAIN: &str = "auto_gain"; +/// Mic soft gain before processing, 0 to 100. 100 is as captured. +pub const INPUT_VOLUME: &str = "input_volume"; +/// Opus encode quality within a Tiny room's bitrate: [`QUALITY_CHOICES`]. +pub const VOICE_QUALITY: &str = "voice_quality"; +pub const NOISE_CHOICES: [&str; 4] = ["off", "standard", "high", "enhanced"]; +pub const QUALITY_CHOICES: [&str; 3] = ["data_saver", "standard", "high"]; /// V8, V13, V14, V15. The shell's global keys, as [`crate::hotkey::Binding`] writes them: /// push-to-talk, push-to-talk with priority, toggle mute, toggle deafen. Absent is no key. /// Read by the shell; chatd only keeps them. @@ -202,12 +208,15 @@ fn kind(key: &str) -> Option { // outside the global scope. NOTIFY_ABOUT => Kind::Word(&ABOUT_CHOICES, "mentions"), SOUND => Kind::Word(&SOUND_CHOICES, "pebble"), - ECHO_CANCELLATION | NOISE_SUPPRESSION | AUTO_GAIN => Kind::Switch(true), + ECHO_CANCELLATION | AUTO_GAIN => Kind::Switch(true), + NOISE_SUPPRESSION => Kind::Word(&NOISE_CHOICES, "standard"), + VOICE_QUALITY => Kind::Word(&QUALITY_CHOICES, "standard"), JOIN_MUTED | PUSH_TO_TALK | REQUIRE_CALL_E2EE => Kind::Switch(false), MUTED_TALK => Kind::Switch(true), PTT_RELEASE_MS => Kind::Choice(&PTT_RELEASE_CHOICES, 200), CUE_VOLUME => Kind::Percent(50), VOICE_VOLUME => Kind::Percent(100), + INPUT_VOLUME => Kind::Percent(100), SENSITIVITY => Kind::Percent(25), INPUT | OUTPUT => Kind::Device, PTT_KEY | PRIORITY_KEY | MUTE_KEY | DEAFEN_KEY => Kind::Hotkey, @@ -283,6 +292,8 @@ pub fn valid(scope: &str, key: &str, value: &str) -> bool { Some(Kind::Word(words, _)) if matches!(key, MESSAGE_SOUND | MENTION_SOUND | RING_SOUND | SOUND) => { words.contains(&value) || value.strip_prefix("custom:").is_some_and(sound_id) || value == "same" } + // Older builds stored V11 as on/off; still readable so an upgrade keeps the choice. + Some(Kind::Word(words, _)) if key == NOISE_SUPPRESSION => words.contains(&value) || matches!(value, "on" | "off"), Some(Kind::Word(words, _)) => words.contains(&value), Some(Kind::Minute(_)) => value.parse::().is_ok_and(|n| n < 24 * 60), Some(Kind::Until) => value == "resume" || value.parse::().is_ok(), @@ -536,12 +547,23 @@ impl Values { voice: self.number(VOICE_VOLUME).min(100) as u8, gate: self.number(SENSITIVITY).min(100) as u8, cues: self.number(CUE_VOLUME).min(100) as u8, + input: self.number(INPUT_VOLUME).min(100) as u8, joins: sounds && self.on(CUE_JOINS), mine: sounds && self.on(CUE_MINE), connection: sounds && self.on(CUE_CONNECTION), } } + /// V11: off, standard (WebRTC), high (WebRTC strongest), or enhanced (RNNoise). + pub fn noise_mode(&self) -> crate::NoiseMode { + crate::NoiseMode::parse(self.word(GLOBAL, NOISE_SUPPRESSION)).unwrap_or_default() + } + + /// How clearly to encode on a Tiny call, within the room's bitrate. + pub fn voice_quality(&self) -> crate::VoiceQuality { + crate::VoiceQuality::parse(self.word(GLOBAL, VOICE_QUALITY)).unwrap_or_default() + } + /// How the microphone opens, as the user chose. A room that requires push-to-talk asks /// for [`Values::push_to_talk`] instead. pub fn input_mode(&self) -> crate::InputMode { @@ -560,8 +582,8 @@ impl Values { pub fn processing(&self) -> crate::AudioProcessing { crate::AudioProcessing { echo_cancellation: self.on(ECHO_CANCELLATION), - noise_suppression: self.on(NOISE_SUPPRESSION), auto_gain: self.on(AUTO_GAIN), + noise: self.noise_mode(), } } @@ -754,7 +776,17 @@ mod tests { values.set(GLOBAL, PTT_RELEASE_MS, Some("0".into())); values.set(GLOBAL, NOISE_SUPPRESSION, Some("off".into())); assert_eq!(values.input_mode(), crate::InputMode::PushToTalk { release_ms: 0 }); - assert!(!values.processing().noise_suppression && values.processing().echo_cancellation); + assert!(!values.processing().noise_suppression() && values.processing().echo_cancellation); + values.set(GLOBAL, NOISE_SUPPRESSION, Some("enhanced".into())); + assert_eq!(values.noise_mode(), crate::NoiseMode::Enhanced); + values.set(GLOBAL, NOISE_SUPPRESSION, Some("on".into())); + assert_eq!(values.noise_mode(), crate::NoiseMode::Standard); + assert_eq!(values.voice_quality(), crate::VoiceQuality::Standard); + values.set(GLOBAL, VOICE_QUALITY, Some("high".into())); + assert_eq!(values.voice_quality(), crate::VoiceQuality::High); + assert_eq!(values.tuning().input, 100); + values.set(GLOBAL, INPUT_VOLUME, Some("70".into())); + assert_eq!(values.tuning().input, 70); } #[test] diff --git a/crates/tinyclient/src/gui/call/devices.rs b/crates/tinyclient/src/gui/call/devices.rs index bc5117ed..ab70ab5d 100644 --- a/crates/tinyclient/src/gui/call/devices.rs +++ b/crates/tinyclient/src/gui/call/devices.rs @@ -259,13 +259,20 @@ impl Shell { } }; let lit = lit(level); + let gate = prefs::values().number(protocol::settings::SENSITIVITY) as u8; + let first_sent = (u16::from(gate.min(100)) * u16::from(SEGMENTS)).div_ceil(100) as u8; + let clip_from = (u16::from(95u8) * u16::from(SEGMENTS)).div_ceil(100) as u8; let segment = |ix: u8| { - div() - .flex_none() - .w(px(4.)) - .h(px(12.)) - .rounded(px(1.)) - .bg(if ix < lit { theme::positive() } else { theme::selected() }) + let color = if ix >= lit { + theme::selected() + } else if side == Side::Input && ix >= clip_from { + theme::danger() + } else if side == Side::Input && ix < first_sent { + theme::warning() + } else { + theme::positive() + }; + div().flex_none().w(px(4.)).h(px(12.)).rounded(px(1.)).bg(color) }; div() .h(px(26.)) diff --git a/crates/tinyclient/src/gui/call/phone/sheets.rs b/crates/tinyclient/src/gui/call/phone/sheets.rs index ec1a30cb..0da8d44a 100644 --- a/crates/tinyclient/src/gui/call/phone/sheets.rs +++ b/crates/tinyclient/src/gui/call/phone/sheets.rs @@ -105,7 +105,8 @@ impl Shell { /// Everything that is not in the bar. fn call_phone_more(&self, call: &Call, subject: &Subject, cx: &mut Context) -> Div { let values = crate::gui::app::prefs::values(); - let (push, noise, echo) = (values.on(PUSH_TO_TALK), values.on(NOISE_SUPPRESSION), values.on(ECHO_CANCELLATION)); + let noise_on = values.noise_mode() != protocol::NoiseMode::Off; + let (push, echo) = (values.on(PUSH_TO_TALK), values.on(ECHO_CANCELLATION)); let group = subject.kind != ConversationKind::Direct && in_call(call); let route = hooks::route(if sending(call, Content::Camera) { hooks::Route::Speaker } else { hooks::Route::Earpiece }); let mic = if call.input.is_empty() { "Microphone".to_owned() } else { call.input.clone() }; @@ -134,8 +135,16 @@ impl Shell { .on_click(cx.listener(move |this, _, _, cx| this.call_phone_switch(PUSH_TO_TALK, !push, cx))), ) .child( - row("call-phone-more-noise", "icons/audio-lines.svg", "Noise suppression", Some("Quiets fans, traffic and keyboards".into()), Some(switch(noise))) - .on_click(cx.listener(move |this, _, _, cx| this.call_phone_switch(NOISE_SUPPRESSION, !noise, cx))), + row("call-phone-more-noise", "icons/audio-lines.svg", "Noise suppression", Some("Quiets fans, traffic and keyboards".into()), Some(switch(noise_on))) + .on_click(cx.listener(move |this, _, _, cx| { + let value = Some(if noise_on { "off" } else { "standard" }.to_owned()); + let mut values = crate::gui::app::prefs::values(); + values.set(GLOBAL, NOISE_SUPPRESSION, value.clone()); + crate::gui::app::prefs::set_values(values); + crate::gui::settings::redraw(cx); + this.call_ask(Request::Set { scope: GLOBAL.to_owned(), key: NOISE_SUPPRESSION.to_owned(), value }, cx); + cx.notify(); + })), ) .child( row("call-phone-more-echo", "icons/volume-2.svg", "Echo cancellation", Some(if echo { "On: no headphones needed".into() } else { "Off: wear headphones".into() }), Some(switch(echo))) diff --git a/crates/tinyclient/src/gui/settings/controls.rs b/crates/tinyclient/src/gui/settings/controls.rs index adac012d..9fd9a1c6 100644 --- a/crates/tinyclient/src/gui/settings/controls.rs +++ b/crates/tinyclient/src/gui/settings/controls.rs @@ -286,19 +286,25 @@ pub fn lit(level: Option) -> usize { } } -/// The level meter: 20 segments, lit `text_faint` under the sensitivity line and `positive` -/// from it, so it shows what would be sent. The line is drawn whether or not a test runs. +/// Level at which the meter tip turns red (clipping / overload), on the same 0–100 scale. +pub const CLIP_LEVEL: u8 = 95; + +/// The level meter: 20 segments. Yellow under the sensitivity line (would not open), green +/// from it (would send), red tip near clip. The line is drawn whether or not a test runs. pub fn meter(level: Option, gate: u32) -> Div { let lit = lit(level); let line = position(gate, (0, 100), METER); let first_sent = (gate as usize * SEGMENTS).div_ceil(100); + let clip_from = (usize::from(CLIP_LEVEL) * SEGMENTS).div_ceil(100); let segment = |ix: usize| { let color: Hsla = if ix >= lit { switch_off().into_color() + } else if ix >= clip_from { + theme::danger().into_color() } else if ix >= first_sent { theme::positive().into_color() } else { - theme::text_faint().into_color() + theme::warning().into_color() }; div().flex_none().w(px(10.)).h(px(8.)).rounded(px(2.)).bg(color) }; @@ -345,5 +351,6 @@ mod tests { assert_eq!(lit(Some(1)), 1); assert_eq!(lit(Some(50)), 10); assert_eq!(lit(Some(100)), 20); + assert_eq!((usize::from(CLIP_LEVEL) * SEGMENTS).div_ceil(100), 19); } } diff --git a/crates/tinyclient/src/gui/settings/pages.rs b/crates/tinyclient/src/gui/settings/pages.rs index a8ed3a4b..dd4f2f18 100644 --- a/crates/tinyclient/src/gui/settings/pages.rs +++ b/crates/tinyclient/src/gui/settings/pages.rs @@ -16,15 +16,16 @@ use gpui::{canvas, div, prelude::*, px, AnyElement, ClipboardItem, Context, Div, use crate::gui::hotkeys::{self, Action, Reach}; use protocol::settings::{ self as keys, account_scope, DeviceChoice, Values, AUTO_GAIN, CACHE_MB, CACHE_MB_CHOICES, CUE_CONNECTION, CUE_JOINS, CUE_MINE, CUE_VOLUME, - ECHO_CANCELLATION, GLOBAL, INPUT, JOIN_MUTED, KEEP_DAYS, KEEP_DAYS_CHOICES, KEEP_RUNNING, NOISE_SUPPRESSION, OUTPUT, PTT_RELEASE_CHOICES, - PTT_RELEASE_MS, PUSH_TO_TALK, RECEIPTS, REQUIRE_CALL_E2EE, SENSITIVITY, SOUNDS, TYPING, VOICE_VOLUME, + ECHO_CANCELLATION, GLOBAL, INPUT, INPUT_VOLUME, JOIN_MUTED, KEEP_DAYS, KEEP_DAYS_CHOICES, KEEP_RUNNING, NOISE_CHOICES, NOISE_SUPPRESSION, OUTPUT, + PTT_RELEASE_CHOICES, PTT_RELEASE_MS, PUSH_TO_TALK, QUALITY_CHOICES, RECEIPTS, REQUIRE_CALL_E2EE, SENSITIVITY, SOUNDS, TYPING, VOICE_QUALITY, + VOICE_VOLUME, }; use protocol::{AccountStatus, Link, Network, Request, Response}; use std::rc::Rc; /// Choice sets whose pills do not fit beside their label. Short pairs stay pills. pub(super) fn menu_choice(id: &str) -> bool { - matches!(id, "S2" | "S3" | "S4" | "N2" | "N4" | "NL" | "N6" | "M5" | "P4" | "D2" | "V8") + matches!(id, "S2" | "S3" | "S4" | "N2" | "N4" | "NL" | "N6" | "M5" | "P4" | "D2" | "V8" | "V11" | "V21") } /// `--settings-choice` names one of these. @@ -42,6 +43,8 @@ pub(super) fn choice_id(name: &str) -> Option<&'static str> { "P4" => "P4", "D2" => "D2", "V8" => "V8", + "V11" => "V11", + "V21" => "V21", _ => return None, }) } @@ -61,6 +64,8 @@ fn choice_slot(id: &str) -> usize { "P4" => 34, "D2" => 42, "V8" => 48, + "V11" => 49, + "V21" => 50, _ => 0, } } @@ -96,6 +101,14 @@ fn choice_list(id: &str) -> Option<(Vec<(String, String)>, usize, usize)> { (pairs(&["250 MB", "500 MB", "1 GB", "2 GB"]), at, 1) } "V8" => (pairs(&["Voice activation", "Push to talk"]), usize::from(values.on(PUSH_TO_TALK)), 0), + "V11" => { + let at = NOISE_CHOICES.iter().position(|w| *w == values.word(GLOBAL, NOISE_SUPPRESSION)).unwrap_or(1); + (NOISE_CHOICES.iter().zip(["Off", "Standard", "High", "Enhanced"]).map(|(w, l)| ((*w).to_owned(), (*l).to_owned())).collect(), at, 1) + } + "V21" => { + let at = QUALITY_CHOICES.iter().position(|w| *w == values.word(GLOBAL, VOICE_QUALITY)).unwrap_or(1); + (QUALITY_CHOICES.iter().zip(["Data saver", "Standard", "High"]).map(|(w, l)| ((*w).to_owned(), (*l).to_owned())).collect(), at, 1) + } "N7" => (pairs(&["1 hour", "Until tomorrow", "Until I resume"]), 0, 0), _ => return None, }) @@ -155,6 +168,7 @@ pub fn slider(id: &str) -> Slider { // Narrower than the others: its row also carries the test sound's button. "S10" => Slider { range: (0, 100), default: 50, read: || prefs::values().number(CUE_VOLUME), stops: None, width: 160., words: percent }, "V5" => Slider { range: (0, 100), default: 100, read: || prefs::values().number(VOICE_VOLUME), stops: None, width: controls::TRACK, words: percent }, + "V20" => Slider { range: (0, 100), default: 100, read: || prefs::values().number(INPUT_VOLUME), stops: None, width: controls::TRACK, words: percent }, // Release delay: one stop for each of the six choices. "V14" => Slider { range: (0, 5), default: 2, read: release_stop, stops: Some(6), width: controls::TRACK, words: release_words }, _ => Slider { range: (0, 100), default: 25, read: || prefs::values().number(SENSITIVITY), stops: None, width: controls::METER, words: |v| v.to_string() }, @@ -167,6 +181,7 @@ pub fn store_slider(window: &mut SettingsWindow, id: &'static str, value: u32, c "S10" => CUE_VOLUME, "SV" => keys::ALERT_VOLUME, "V5" => VOICE_VOLUME, + "V20" => INPUT_VOLUME, "V14" => { let ms = PTT_RELEASE_CHOICES[(value as usize).min(PTT_RELEASE_CHOICES.len() - 1)]; let stored = (ms != Values::default_number(PTT_RELEASE_MS)).then(|| ms.to_string()); @@ -365,7 +380,7 @@ impl SettingsWindow { Section::Voice => { let values = prefs::values(); let push = values.on(PUSH_TO_TALK); - let mut input = vec!["V8", "V3", "V18"]; + let mut input = vec!["V8", "V3", "V20", "V21", "V18"]; let mode = if push { input.push("V14"); if values.hotkey(keys::PTT_KEY).is_none() { @@ -381,7 +396,7 @@ impl SettingsWindow { self.group(&input, None, window, cx).child(caption(mode)), self.group(&["V13", "V15", "V16", "V17"], Some("Keys"), window, cx).child(caption(keys_caption())), self.group(&["V10", "V11", "V12"], Some("Processing"), window, cx) - .child(caption("Echo cancellation takes out what your speakers play, so others don't hear themselves when you talk without headphones.")), + .child(caption("Echo cancellation takes out what your speakers play. Noise: Standard and High use WebRTC; Enhanced uses RNNoise for keyboards and steady noise.")), self.group(&["S7", "S8", "S9", "S10"], Some("Call sounds"), window, cx) .child(caption("Call sounds play on your call speaker, quieter than voices. Voice volume is in Audio & video.")), ] @@ -620,24 +635,37 @@ impl SettingsWindow { let second = Some("Plays your microphone back as others would hear it. Not during a call.".to_owned()); self.switch_row(id, self.loopback, false, second, |this, on, cx| this.set_loopback(on, cx), cx) } - "V10" | "V11" | "V12" => { + "V10" | "V12" => { let key = match id { "V10" => ECHO_CANCELLATION, - "V11" => NOISE_SUPPRESSION, _ => AUTO_GAIN, }; let on = values.on(key); let second = match (id, on) { ("V10", false) => Some("Wear headphones, or others hear themselves when you talk."), - ("V11", false) => Some("Fans, typing and traffic go through as they are."), - ("V12", false) => Some("Your voice is sent at the level your microphone gives."), + ("V12", false) => Some("Your voice is sent at the level your microphone gives. Use Mic volume to raise or lower it."), _ => None, }; self.switch_row(id, on, true, second.map(str::to_owned), move |this, on, cx| this.set_switch(id, key, on, true, cx), cx) } + "V11" => { + let at = NOISE_CHOICES.iter().position(|w| *w == values.word(GLOBAL, NOISE_SUPPRESSION)).unwrap_or(1); + let second = Some(match NOISE_CHOICES.get(at).copied().unwrap_or("standard") { + "off" => "Fans, typing and traffic go through as they are.".to_owned(), + "enhanced" => "RNNoise: stronger on keyboards and steady noise.".to_owned(), + "high" => "WebRTC at its strongest.".to_owned(), + _ => "WebRTC noise suppression for everyday background noise.".to_owned(), + }); + self.menu_choice_row(id, &["Off", "Standard", "High", "Enhanced"], at, 1, second, None, cx) + } + "V21" => { + let at = QUALITY_CHOICES.iter().position(|w| *w == values.word(GLOBAL, VOICE_QUALITY)).unwrap_or(1); + let second = Some("On Tiny calls only. High prefers a clearer Opus mode; Data saver caps around 32 kb/s. The room's bitrate is still the ceiling.".to_owned()); + self.menu_choice_row(id, &["Data saver", "Standard", "High"], at, 1, second, None, cx) + } "V13" | "V15" | "V16" | "V17" => return self.key_row(id, cx), "V6" => wide_row(label, None, self.slider_control(id, cx)), - "V5" => wide_row(label, None, self.slider_control(id, cx)), + "V5" | "V20" => wide_row(label, None, self.slider_control(id, cx)), "V4" => { let on = values.on(JOIN_MUTED); self.switch_row(id, on, false, None, move |this, on, cx| this.set_switch(id, JOIN_MUTED, on, false, cx), cx) @@ -959,6 +987,16 @@ impl SettingsWindow { self.set("D2", GLOBAL.into(), CACHE_MB, stored, cx); } "V8" => self.set_switch(id, PUSH_TO_TALK, ix == 1, false, cx), + "V11" => { + let word = NOISE_CHOICES[ix.min(NOISE_CHOICES.len() - 1)]; + let stored = (word != "standard").then(|| word.to_owned()); + self.set(id, GLOBAL.into(), NOISE_SUPPRESSION, stored, cx); + } + "V21" => { + let word = QUALITY_CHOICES[ix.min(QUALITY_CHOICES.len() - 1)]; + let stored = (word != "standard").then(|| word.to_owned()); + self.set(id, GLOBAL.into(), VOICE_QUALITY, stored, cx); + } "N7" => { let now = crate::view::now(); let until = match ix { @@ -1392,9 +1430,9 @@ impl SettingsWindow { })); let push = prefs::values().on(PUSH_TO_TALK); let second = match (testing, push) { - (true, false) => "Speak normally. Green is what others would hear.", - (true, true) => "Speak normally. In push to talk the key opens your microphone, not the line.", - (false, false) => "Below the line nothing is sent and your speaking ring stays off.", + (true, false) => "Yellow is below your sensitivity. Green is what others would hear. Red means you're clipping.", + (true, true) => "Speak normally. In push to talk the key opens your microphone, not the line. Red means you're clipping.", + (false, false) => "Yellow is below the line (nothing sent). Green would open the mic. Red is clipping.", (false, true) => "In push to talk the line does nothing; it is used when you go back to voice activation.", }; let label = |words: &'static str| div().flex_none().type_ui().text_color(theme::text()).child(words); @@ -1953,7 +1991,7 @@ mod tests { #[test] fn long_choice_sets_are_menus_and_the_button_stays_in_the_column() { - for id in ["S2", "S3", "S4", "N2", "N4", "NL", "N6", "M5", "P4", "D2", "V8"] { + for id in ["S2", "S3", "S4", "N2", "N4", "NL", "N6", "M5", "P4", "D2", "V8", "V11", "V21"] { assert!(menu_choice(id), "{id}"); } for id in ["A3", "M1", "M2", "N5", "N7"] { diff --git a/crates/tinyclient/src/gui/settings/rows.rs b/crates/tinyclient/src/gui/settings/rows.rs index 5b056d80..9efe3330 100644 --- a/crates/tinyclient/src/gui/settings/rows.rs +++ b/crates/tinyclient/src/gui/settings/rows.rs @@ -78,6 +78,8 @@ pub const ROWS: &[Row] = &[ row("V8", Voice, "", "Microphone opens by", &["Voice activation", "Push to talk"], &["ptt", "push to talk", "voice activity", "vad", "open mic"]), row("V3", Voice, "", "Input level", &[], &["test", "meter", "mic", "level"]), row("V6", Voice, "", "Sensitivity", &[], &["gate", "threshold", "noise", "voice activity", "vad"]), + row("V20", Voice, "", "Mic volume", &[], &["input", "gain", "loud", "quiet", "volume", "microphone"]), + row("V21", Voice, "", "Voice quality", &["Data saver", "Standard", "High"], &["codec", "bitrate", "opus", "quality", "clear"]), row("V18", Voice, "", "Hear yourself while testing", &[], &["loopback", "test", "listen", "monitor", "echo test"]), row("V19", Voice, "", "Tell me when I talk while muted", &[], &["muted", "talking", "warning", "mute", "speak"]), row("V14", Voice, "", "Release delay", &["0 ms", "100 ms", "200 ms", "300 ms", "500 ms", "1 s"], &["ptt", "push to talk", "delay", "hold", "cut off"]), @@ -86,7 +88,7 @@ pub const ROWS: &[Row] = &[ row("V16", Voice, "Keys", "Toggle mute", &[], &["mute", "hotkey", "shortcut", "global"]), row("V17", Voice, "Keys", "Toggle deafen", &[], &["deafen", "hotkey", "shortcut", "global"]), row("V10", Voice, "Processing", "Echo cancellation", &[], &["echo", "aec", "speakers", "feedback"]), - row("V11", Voice, "Processing", "Noise suppression", &[], &["noise", "background", "fan", "keyboard", "denoise"]), + row("V11", Voice, "Processing", "Noise suppression", &["Off", "Standard", "High", "Enhanced"], &["noise", "background", "fan", "keyboard", "denoise", "rnnoise"]), row("V12", Voice, "Processing", "Automatic gain", &[], &["agc", "gain", "loudness", "quiet", "volume"]), row("S7", Voice, "Call sounds", "Someone joins, leaves or I'm moved", &[], &["sound", "chime", "ding", "join", "leave", "move", "cue"]), row("S8", Voice, "Call sounds", "I mute, unmute or deafen", &[], &["sound", "ding", "tick", "cue", "deafen"]), @@ -210,7 +212,7 @@ pub fn kept_by_chatd(id: &str) -> bool { matches!( id, "N1" | "N2" | "ND" | "NG" | "NM" | "NT" | "N4" | "NL" | "N5" | "N6" | "N7" | "N8" | "N9" | "N10" | "N11" | "N13" | "S2" | "S3" | "S4" | "SV" | "S13" | "S14" - | "G2" | "S1" | "S7" | "S8" | "S9" | "S10" | "P1" | "P2" | "P4" | "PE" | "V1" | "V2" | "V3" | "V4" | "V5" | "V6" | "V8" | "V10" | "V11" | "V12" | "V13" | "V14" | "V15" | "V16" | "V17" | "V19" | "D1" | "D2" | "D3" + | "G2" | "S1" | "S7" | "S8" | "S9" | "S10" | "P1" | "P2" | "P4" | "PE" | "V1" | "V2" | "V3" | "V4" | "V5" | "V6" | "V8" | "V10" | "V11" | "V12" | "V13" | "V14" | "V15" | "V16" | "V17" | "V19" | "V20" | "V21" | "D1" | "D2" | "D3" ) } diff --git a/docs/product/09-settings.md b/docs/product/09-settings.md index 1ad73ec2..3509073b 100644 --- a/docs/product/09-settings.md +++ b/docs/product/09-settings.md @@ -240,10 +240,12 @@ no control. | # | Setting | Type | Default | Scope | Home | What it changes | Rel | | --- | --- | --- | --- | --- | --- | --- | --- | -| V3 | Input level | Test button and meter | | | | While on, the meter is live. Outside a call, mediad starts for it and exits after Stop. | in | +| V3 | Input level | Test button and meter | | | | While on, the meter is live (yellow below sensitivity, green would send, red near clip). Outside a call, mediad starts for it and exits after Stop. | in | | V6 | Microphone sensitivity | Slider over the live meter | | G | d | The voice gate threshold in mediad. | in | +| V20 | Mic volume | Slider | 100 % | G | d | Soft gain before processing. Separate from sensitivity and automatic gain. | in | +| V21 | Voice quality | Menu: Data saver / Standard / High | Standard | G | d | Tiny calls only. Caps encode bitrate (data saver ≈ 32 kb/s) within the room's ceiling; High uses Opus Audio mode. | in | | V8 | Voice activation or push to talk | Switch and key recorder | | G | d | In the window. | in | -| V10–V19 | Processing, keys, release delay | Switches and recorders | V10–V12 on | G | d | Echo cancellation, noise suppression, automatic gain (default on), key recorders (V13 and V15–V17: push-to-talk, priority, mute, deafen), and release delay. A downloaded noise-suppression model is not V11. | in | +| V10–V19 | Processing, keys, release delay | Switches, menus and recorders | V10/V12 on; V11 Standard | G | d | Echo cancellation, noise suppression (Off / Standard / High WebRTC, or Enhanced RNNoise), automatic gain, key recorders (V13 and V15–V17), and release delay. | in | | S7–S10 | Call cues and their volume | Switches and a slider | Cues on | G | d | Join, leave, mute, deafen, connection, and their volume. Cue semantics during a call are `06-calls.md`. | in | | V7 | Camera device | | | G | d | Not in the window. Search: "tinyclient has no camera yet." A Tiny call dock can toggle the camera. That toggle is `06-calls.md`. | not in 0.2.35 | | V9 | Per-person volume | | | C | | `CallGain` on a Tiny participant row, not a Settings row. | not in 0.2.35 |