fix(voice): clamp transmit when speakers muted

This commit is contained in:
Edison Jwa
2026-05-20 16:37:48 +09:00
parent 9a2c882ab0
commit 171baf6e41
2 changed files with 33 additions and 10 deletions
+8 -1
View File
@@ -718,8 +718,14 @@ class _BetaHomeState extends State<_BetaHome> {
final next = !_outputMuted; final next = !_outputMuted;
try { try {
await rust.setOutputMuted(muted: next); await rust.setOutputMuted(muted: next);
await rust.setHardMute(
muted: next || _inputMuted || _hardMuteByPermission,
);
if (!mounted) return; if (!mounted) return;
setState(() => _outputMuted = next); setState(() {
_outputMuted = next;
_hardMute = next || _inputMuted || _hardMuteByPermission;
});
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _error = e.toString()); setState(() => _error = e.toString());
@@ -887,6 +893,7 @@ class _BetaHomeState extends State<_BetaHome> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_inputMuted = next; _inputMuted = next;
_hardMute = next;
_hardMuteByPermission = false; _hardMuteByPermission = false;
}); });
} catch (e) { } catch (e) {
+25 -9
View File
@@ -300,6 +300,13 @@ struct ConnectedState {
/// for that channel. Kept in memory only; durable secret storage is /// for that channel. Kept in memory only; durable secret storage is
/// a separate storage-schema decision. /// a separate storage-schema decision.
channel_passwords: HashMap<u64, String>, channel_passwords: HashMap<u64, String>,
/// Local microphone mute state used to resolve the single
/// transmit hard-mute clamp from independent UI mute buttons.
local_input_muted: bool,
/// Local speaker mute state. Product rule for P0: speaker muted
/// also means mic disabled, so this participates in the local
/// transmit hard-mute clamp without sending server-side deafen.
local_output_muted: bool,
} }
fn normalize_channel_password(password: Option<String>) -> Option<String> { fn normalize_channel_password(password: Option<String>) -> Option<String> {
@@ -610,6 +617,8 @@ impl ChanoraSession {
sup_inner, sup_inner,
join_state, join_state,
channel_passwords: HashMap::new(), channel_passwords: HashMap::new(),
local_input_muted: false,
local_output_muted: false,
}); });
// TS3 servers auto-place a newly-connected client into the // TS3 servers auto-place a newly-connected client into the
@@ -996,15 +1005,23 @@ impl ChanoraSession {
input: Option<bool>, input: Option<bool>,
output: Option<bool>, output: Option<bool>,
) -> Result<(), CoreError> { ) -> Result<(), CoreError> {
let guard = self.inner.lock().await; let mut guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?; let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
// Server-side output mute/deafen makes TeamSpeak/tsclientlib // Server-side output mute/deafen makes TeamSpeak/tsclientlib
// consider the client unable to send audio. That is correct // consider the client unable to send audio. That is correct
// for a server-visible "deafened" state, but our P0 speaker // for a server-visible "deafened" state, but our P0 speaker
// button is a local playback mute. Keep output mute local-only // button is a local playback mute. Keep output mute off the
// so Android can still transmit while the user silences remote // server-side output/deafen flag, but fold it into the mic
// playback on this device. // disabled state below because P0 product semantics are:
state.protocol.set_muted(input, None).await?; // speaker disabled also means microphone disabled.
if let Some(muted) = input {
state.local_input_muted = muted;
}
if let Some(muted) = output {
state.local_output_muted = muted;
}
let mic_disabled = state.local_input_muted || state.local_output_muted;
state.protocol.set_muted(Some(mic_disabled), None).await?;
if let Some(muted) = output { if let Some(muted) = output {
if let Some(audio) = state.audio.as_ref() { if let Some(audio) = state.audio.as_ref() {
audio.set_output_muted(muted); audio.set_output_muted(muted);
@@ -1019,9 +1036,8 @@ impl ChanoraSession {
// flooded the log to 200 MB on the Korean test host. // flooded the log to 200 MB on the Korean test host.
// Clamp the transmit-mode selector's hard_mute input so // Clamp the transmit-mode selector's hard_mute input so
// the gate goes false too. // the gate goes false too.
if let Some(muted) = input { self.voice_selector
self.voice_selector.set_hard_mute(muted); .set_hard_mute(mic_disabled);
}
Ok(()) Ok(())
} }