From bf7794f03568b0ab8d932a84a4432c391f29e24a Mon Sep 17 00:00:00 2001 From: Hecklezz Date: Tue, 13 Oct 2026 05:48:57 +1000 Subject: Implement option to stop mic capture when muted --- indra/llwebrtc/llwebrtc.cpp | 170 ++++++++++++++++----- indra/llwebrtc/llwebrtc.h | 10 +- indra/llwebrtc/llwebrtc_impl.h | 19 ++- indra/newview/app_settings/settings.xml | 11 ++ indra/newview/llvoicewebrtc.cpp | 31 +++- indra/newview/llvoicewebrtc.h | 1 + .../default/xui/en/panel_preferences_voice.xml | 11 ++ 7 files changed, 206 insertions(+), 47 deletions(-) diff --git a/indra/llwebrtc/llwebrtc.cpp b/indra/llwebrtc/llwebrtc.cpp index 87ed21291f..5ee89a7b82 100644 --- a/indra/llwebrtc/llwebrtc.cpp +++ b/indra/llwebrtc/llwebrtc.cpp @@ -52,6 +52,8 @@ static int16_t PLAYOUT_DEVICE_DEFAULT = 0; static int16_t RECORD_DEVICE_DEFAULT = 0; #endif +constexpr int32_t MIC_CAPTURE_STOP_DELAY_MS = 40; + // // LLWebRTCAudioTransport implementation @@ -265,19 +267,18 @@ void LLWebRTCAudioDeviceModule::SetTuning(bool tuning, bool mute) tuning_ = tuning; if (tuning) { - // Ensure capture is running (it's normally already running -- capture is - // session-long) so the mic-level meter works, and stop rendering the - // call while tuning. The recording calls are no-ops if capture is - // already active, so this won't cold-start it. + // Ensure capture is running so the mic-level meter works, and stop rendering + // the call while tuning. The recording calls are no-ops if capture is already + // active (it may not be, with "stop capture while muted" enabled). inner_->InitMicrophone(); inner_->InitRecording(); inner_->StartRecording(); inner_->StopPlayout(); } - // On exit, capture is deliberately left running (mute is handled by gain, - // not by stopping the device, so there's no AEC cold-start hiss). Playout - // is restored by the caller via workerOpenPlayout(), keeping it gated on - // there being a connection to render. + // On exit, capture is left running here; with "stop capture while muted" the + // caller (setTuningMode) stops it again if we're muted or have no connection. + // Playout is restored by the caller via workerOpenPlayout(), keeping it gated + // on there being a connection to render. } // @@ -638,16 +639,21 @@ void LLWebRTCImpl::unsetDevicesObserver(LLWebRTCDevicesObserver *observer) } // must be run in the worker thread. Selects the configured capture device and -// starts recording. Capture runs the whole time voice is enabled (it's never -// stopped for mute or between calls, so the AEC never cold-starts -- there's no -// hiss on unmute), so this is a no-op when already recording. Device changes -// go through workerDeployDevices(), which stops recording first to force a -// clean re-select; voice off goes through setVoiceEnabled(false). +// starts recording. By default capture runs the whole time voice is enabled +// (never stopped for mute or between calls, so the AEC never cold-starts -- no +// hiss on unmute), so this is a no-op when already recording. With "stop capture +// while muted" it's only started while unmuted and once a connection's audio is +// established. Device changes go through workerDeployDevices(), which stops +// recording first to force a clean re-select; voice off goes through setVoiceEnabled(false). void LLWebRTCImpl::workerStartRecording() { // Only run capture while voice is enabled, and never cold-start it when - // it's already running (that would cause the unmute hiss). - if (!mDeviceModule || !mVoiceEnabled || mDeviceModule->Recording()) + // it's already running (that would cause the unmute hiss). With "stop + // capture while muted", also hold it off while muted or with no connection's + // audio is established. + if (!mDeviceModule || !mVoiceEnabled || mDeviceModule->Recording() || + (mStopCaptureOnMute.load() && !mTuningMode && + (mMute || !mAudioEstablished))) { return; } @@ -689,7 +695,7 @@ void LLWebRTCImpl::workerStartRecording() // disable before InitRecording so the recording stream is configured to // use only WebRTC's software APM. workerDisableBuiltInAudioProcessing(); - mDeviceModule->InitRecording(); + mDeviceModule->ForceInitRecording(); mDeviceModule->ForceStartRecording(); } @@ -833,9 +839,9 @@ void LLWebRTCImpl::setVoiceEnabled(bool enable) } if (enable) { - // Voice on: start the capture device (it then stays running - // across calls and mute/unmute), and start playout if there's - // already a connection to render. + // Voice on: start the capture device (by default it then stays + // running across calls and mute/unmute), and start playout if + // there's already a connection to render. mDeviceModule->Init(); workerDeployDevices(); } @@ -850,6 +856,33 @@ void LLWebRTCImpl::setVoiceEnabled(bool enable) }); } +void LLWebRTCImpl::setStopCaptureOnMute(bool stop) +{ + if (mStopCaptureOnMute.exchange(stop) == stop) + { + return; + } + + mWorkerThread->PostTask([this, stop] + { + if (!mVoiceEnabled || !mDeviceModule || mTuningMode) + { + return; + } + if (stop) + { + if (mMute || !mAudioEstablished) + { + intSetMute(true, MIC_CAPTURE_STOP_DELAY_MS); + } + } + else + { + intSetMute(false, 0); + } + }); +} + // updateDevices needs to happen on the worker thread. void LLWebRTCImpl::updateDevices() { @@ -930,6 +963,11 @@ void LLWebRTCImpl::setTuningMode(bool enable) mDeviceModule->SetTuning(mTuningMode, mMute); if (!mTuningMode) { + if (mStopCaptureOnMute.load() && (mMute || !mAudioEstablished)) + { + intSetMute(true, MIC_CAPTURE_STOP_DELAY_MS); + } + // Restore playout after tuning, gated on there being a // connection to render (so the output device isn't left // spinning with no engine data). @@ -1007,15 +1045,54 @@ void LLWebRTCImpl::setMute(bool mute, int delay_ms) void LLWebRTCImpl::intSetMute(bool mute, int delay_ms) { // Mute by zeroing the captured (post-APM) gain; the sender track is also - // disabled per connection (see LLWebRTCPeerConnectionImpl::setMute). The - // capture device deliberately stays running for the whole session, so + // disabled per connection (see LLWebRTCPeerConnectionImpl::setMute). By + // default the capture device stays running for the whole session, so // muting/unmuting never stops or starts it -- that's what avoids the AEC - // cold-start hiss on unmute. Capture start/stop is tied to device - // selection (workerStartRecording) and shutdown, not to mute. + // cold-start hiss on unmute. With "stop capture while muted", it's stopped + // after the mute delay and restarted on unmute (below). if (mPeerCustomProcessor) { mPeerCustomProcessor->setGain(mMute ? 0.0f : mGain); } + + if (mute && !mStopCaptureOnMute.load()) + { + return; + } + + // Prevent a delayed stop from an earlier mute from stopping a newly restarted recording after a rapid mute/unmute. + static std::atomic mute_sequence(0); + uint32_t current_sequence = ++mute_sequence; + + auto setRecording = [this, current_sequence](bool start) + { + if (!mDeviceModule || !mVoiceEnabled || mTuningMode || + current_sequence != mute_sequence.load()) + { + return; + } + + if (start) + { + workerStartRecording(); + } + else if (mStopCaptureOnMute.load()) + { + mDeviceModule->ForceStopRecording(); + } + }; + + if (mute) + { + mWorkerThread->PostDelayedTask([setRecording] + { + setRecording(false); + }, webrtc::TimeDelta::Millis(delay_ms)); + } + else + { + mWorkerThread->PostTask([setRecording] { setRecording(true); }); + } } // @@ -1026,18 +1103,20 @@ LLWebRTCPeerConnectionInterface *LLWebRTCImpl::newPeerConnection() { webrtc::scoped_refptr peerConnection = webrtc::scoped_refptr(new webrtc::RefCountedObject(mEnv)); peerConnection->init(this); - if (mPeerConnections.empty()) + bool first_connection = mPeerConnections.empty(); + mPeerConnections.emplace_back(peerConnection); + if (first_connection) { intSetMute(mMute); } - mPeerConnections.emplace_back(peerConnection); // Playout is intentionally NOT started here. This runs when the connection // is created/connecting; starting the output device now leaves it spinning // with no decoded audio during the handshake, which is heard as a buzz. // Playout is started from OnConnectionChange(kConnected) instead, once audio - // is actually established (see startPlayout()). Capture follows - // voice-enabled state, so it's not touched here either. + // is actually established (see startPlayout()). Capture isn't started here + // either: with "stop capture while muted" it waits for audio to be + // established, and the first connection's intSetMute() above may only stop it. peerConnection->enableSenderTracks(false); peerConnection->resetMute(); @@ -1054,10 +1133,12 @@ void LLWebRTCImpl::freePeerConnection(LLWebRTCPeerConnectionInterface* peer_conn mPeerConnections.erase(it); if (mPeerConnections.empty()) { + mAudioEstablished = false; intSetMute(true); // Last connection gone: stop playout (there's nothing to render). - // Capture stays running while voice is enabled so it's ready -- with - // no cold-start hiss -- when the next call comes up. But if voice + // Capture stays running while voice is enabled so it's ready -- with no + // cold-start hiss -- when the next call comes up, unless "stop capture while + // muted" is on (intSetMute(true) above stops it). But if voice // has been disabled, stop capture now: setVoiceEnabled(false) tried // to, but the engine's send stream was still active then (and the // engine's own StopRecording is intentionally a no-op), so the stop @@ -1083,17 +1164,27 @@ void LLWebRTCImpl::freePeerConnection(LLWebRTCPeerConnectionInterface* peer_conn void LLWebRTCImpl::startPlayout() { - // Called when a connection's audio is established. Only playout is started - // here: it's gated on there being a connection to render, because running - // the output device with no engine data is heard as a buzz. Capture is - // NOT touched here -- it follows voice-enabled state (setVoiceEnabled), so - // it's already running if voice is on and must stay off if voice is off. - // Starting it here would also let a stray kConnected during voice-disable - // teardown re-open the mic. + // Called when a connection's audio is established. Playout is started here: + // it's gated on there being a connection to render, because running the + // output device with no engine data is heard as a buzz. + // Capture normally follows voice-enabled state (setVoiceEnabled), so it's + // already running if voice is on. The exception is "stop capture while + // muted", where it's held off until unmuted and audio is established. + // workerStartRecording() is gated on mVoiceEnabled, so this can't re-open + // the mic during voice-disable teardown. mWorkerThread->PostTask( [this]() { + if (mPeerConnections.empty()) + { + return; + } + mAudioEstablished = true; workerStartPlayout(); + if (mStopCaptureOnMute.load()) + { + workerStartRecording(); + } }); } @@ -1583,9 +1674,10 @@ void LLWebRTCPeerConnectionImpl::OnConnectionChange(webrtc::PeerConnectionInterf case webrtc::PeerConnectionInterface::PeerConnectionState::kConnected: { // Audio is established now -- start playout for this connection. - // (Capture follows voice-enabled state, so it's already running and - // isn't touched here.) Doing playout here rather than at connection - // creation avoids running the output device with no decoded audio + // (Capture normally follows voice-enabled state, so it's already running; + // startPlayout() starts it only for "stop capture while muted".) + // Doing playout here rather than at connection creation + // avoids running the output device with no decoded audio // during the handshake (heard as a buzz). mWebRTCImpl->startPlayout(); mPendingJobs++; diff --git a/indra/llwebrtc/llwebrtc.h b/indra/llwebrtc/llwebrtc.h index 821400cfe8..13078e39ea 100644 --- a/indra/llwebrtc/llwebrtc.h +++ b/indra/llwebrtc/llwebrtc.h @@ -156,10 +156,14 @@ class LLWebRTCDeviceInterface // Enable/disable the audio devices, set when voice is enabled/disabled. // The capture (microphone) and playout (speaker) devices only run while this // is enabled, so neither is held open when the user has voice off. While - // enabled, capture stays running across calls and mute/unmute so the AEC - // never cold-starts (no unmute hiss); playout still only runs when there's a - // connection to render. + // enabled, capture normally stays running across calls and mute/unmute so the + // AEC never cold-starts (no unmute hiss), unless setStopCaptureOnMute() is on; + // playout still only runs when there's a connection to render. virtual void setVoiceEnabled(bool enable) = 0; + // When true, capture is stopped while muted (or with no connection) and + // restarted on unmute, so Bluetooth headsets aren't held in call mode. + // May cause a brief hiss on unmute. + virtual void setStopCaptureOnMute(bool stop) = 0; // Device observers for device change callbacks. virtual void setDevicesObserver(LLWebRTCDevicesObserver *observer) = 0; diff --git a/indra/llwebrtc/llwebrtc_impl.h b/indra/llwebrtc/llwebrtc_impl.h index c980b28d6e..929ee813ed 100644 --- a/indra/llwebrtc/llwebrtc_impl.h +++ b/indra/llwebrtc/llwebrtc_impl.h @@ -243,7 +243,13 @@ public: int32_t StopPlayout() override { return inner_->StopPlayout(); } bool Playing() const override { return inner_->Playing(); } - int32_t InitRecording() override { return inner_->InitRecording(); } + int32_t InitRecording() override { + // Ignore webrtc.lib's InitRecording (sent when a send stream starts), as + // with StartRecording/StopRecording: it would leave capture initialised even + // while we hold capture off. The app initialises capture itself via ForceInitRecording. + return 0; + } + int32_t ForceInitRecording() { return inner_->InitRecording(); } bool RecordingIsInitialized() const override { return inner_->RecordingIsInitialized(); } int32_t StartRecording() override { // ignore start recording as webrtc.lib will @@ -442,6 +448,7 @@ class LLWebRTCImpl : public LLWebRTCDeviceInterface, public webrtc::AudioDeviceO void setRenderDevice(const std::string& id) override; void setVoiceEnabled(bool enable) override; + void setStopCaptureOnMute(bool stop) override; void setTuningMode(bool enable) override; float getTuningAudioLevel() override; @@ -521,8 +528,9 @@ class LLWebRTCImpl : public LLWebRTCDeviceInterface, public webrtc::AudioDeviceO void freePeerConnection(LLWebRTCPeerConnectionInterface* peer_connection); // Start playout once a connection's audio is established (playout is gated - // on there being a connection to render). Capture is not touched here -- - // it follows voice-enabled state, not connection state. Safe to call from + // on there being a connection to render). Capture normally follows + // voice-enabled state, not connection state; the exception is "stop capture + // while muted", where it's also started here if unmuted. Safe to call from // any thread (work is posted to the worker thread). void startPlayout(); @@ -572,6 +580,11 @@ class LLWebRTCImpl : public LLWebRTCDeviceInterface, public webrtc::AudioDeviceO // Whether voice is enabled; gates whether the capture/playout devices run. bool mVoiceEnabled; float mGain; + std::atomic mStopCaptureOnMute{ false }; + // True once any connection's audio has been established, until the last + // connection is freed (not a count of currently-connected peers). Used to + // hold capture off until a call is actually up. + std::atomic mAudioEstablished{ false }; LLCustomProcessorStatePtr mPeerCustomProcessor; diff --git a/indra/newview/app_settings/settings.xml b/indra/newview/app_settings/settings.xml index 3434ab1516..02ec142d02 100644 --- a/indra/newview/app_settings/settings.xml +++ b/indra/newview/app_settings/settings.xml @@ -16770,5 +16770,16 @@ Value 0 + StopMicCaptureWhenMuted + + Comment + Check to stop microphone capture while muted. May cause a hiss on unmute but prevents bluetooth devices always being in voice mode. + Persist + 1 + Type + Boolean + Value + 0 + diff --git a/indra/newview/llvoicewebrtc.cpp b/indra/newview/llvoicewebrtc.cpp index fb463fc94e..0593318d28 100644 --- a/indra/newview/llvoicewebrtc.cpp +++ b/indra/newview/llvoicewebrtc.cpp @@ -95,6 +95,7 @@ namespace { const uint32_t SET_HIDDEN_RESTORE_DELAY_MS = 200; // 200 ms to unmute again after hiding during teleport const uint32_t MUTE_FADE_DELAY_MS = 500; // 20ms fade followed by 480ms silence gets rid of the click just after unmuting. // This is because the buffers and processing is cleared by the silence. + // With StopMicCaptureWhenMuted, this is also the delay before capture is actually stopped. const F32 SPEAKING_AUDIO_LEVEL = 0.30; @@ -260,6 +261,11 @@ void LLWebRTCVoiceClient::cleanupSingleton() mAvatarNameCacheConnection.disconnect(); } + if (mStopMicCaptureOnMuteConnection.connected()) + { + mStopMicCaptureOnMuteConnection.disconnect(); + } + sShuttingDown = true; if (mSession) { @@ -309,6 +315,21 @@ void LLWebRTCVoiceClient::initWebRTC() }); } + // Connect StopMicCaptureWhenMuted control so the changes apply instantly + if (!mStopMicCaptureOnMuteConnection.connected()) + { + mStopMicCaptureOnMuteConnection = + gSavedSettings.getControl("StopMicCaptureWhenMuted")->getSignal()->connect( + [this](LLControlVariable*, const LLSD& new_val, const LLSD&) + { + if (mWebRTCDeviceInterface) + { + mWebRTCDeviceInterface->setStopCaptureOnMute(new_val.asBoolean()); + } + }); + } + mWebRTCDeviceInterface->setStopCaptureOnMute(gSavedSettings.getBOOL("StopMicCaptureWhenMuted")); + refreshDeviceLists(); } @@ -1736,14 +1757,20 @@ void LLWebRTCVoiceClient::setMuteMic(bool muted) mMuteMic = muted; - if (mIsInTuningMode) + if (mIsInTuningMode &&!gSavedSettings.getBOOL("StopMicCaptureWhenMuted")) { return; } if (mWebRTCDeviceInterface) { - mWebRTCDeviceInterface->setMute(muted, muted ? MUTE_FADE_DELAY_MS : 0); // delay for 40ms on mute to allow buffers to empty + // Delay on mute to let the fade and silence flush buffers (and, if enabled, before capture stops) + mWebRTCDeviceInterface->setMute(muted, muted ? MUTE_FADE_DELAY_MS : 0); + } + + if (mIsInTuningMode) + { + return; } // when you're hidden, your mic is always muted. diff --git a/indra/newview/llvoicewebrtc.h b/indra/newview/llvoicewebrtc.h index 76e172ff3d..b507644503 100644 --- a/indra/newview/llvoicewebrtc.h +++ b/indra/newview/llvoicewebrtc.h @@ -454,6 +454,7 @@ public: static void predAvatarNameResolution(const LLWebRTCVoiceClient::sessionStatePtr_t &session, LLUUID id, std::string name); boost::signals2::connection mAvatarNameCacheConnection; + boost::signals2::connection mStopMicCaptureOnMuteConnection; private: // init or restart the WebRTC device interface. diff --git a/indra/newview/skins/default/xui/en/panel_preferences_voice.xml b/indra/newview/skins/default/xui/en/panel_preferences_voice.xml index 6d8e39e03c..f6e521c68e 100644 --- a/indra/newview/skins/default/xui/en/panel_preferences_voice.xml +++ b/indra/newview/skins/default/xui/en/panel_preferences_voice.xml @@ -197,6 +197,17 @@ left="20" top_pad="9" width="200"/> +