diff options
| author | Roxie Linden <roxie@lindenlab.com> | 2026-06-30 11:48:27 -0700 |
|---|---|---|
| committer | Roxie Linden <roxie@lindenlab.com> | 2026-07-07 10:15:11 -0700 |
| commit | 4124f969ee7bbd4e0734ef0d118c60125d7bb358 (patch) | |
| tree | e26780486a49936788ce005049a93aa238b92ad7 | |
| parent | 95120bc676fc68793da2d4c06542cf7cb3272d00 (diff) | |
Keep capture device running for the whole voice session
Mute now zeroes captured gain and disables the sender tracks instead of
stopping the capture device, so unmuting no longer cold-starts the AEC
(no hiss) and Bluetooth devices no longer drop/restart as they switch
between mono and stereo.
Capture is gated on voice being enabled rather than on mute: it starts
when voice is enabled and runs across calls and mute/unmute, and is
released when voice is disabled (setVoiceEnabled). Playout stays gated on
there being a connection to render. As a result the OS "mic in use"
indicator is on for the length of the session and only clears when voice
is disabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| -rw-r--r-- | indra/llwebrtc/llwebrtc.cpp | 292 | ||||
| -rw-r--r-- | indra/llwebrtc/llwebrtc.h | 8 | ||||
| -rw-r--r-- | indra/llwebrtc/llwebrtc_impl.h | 27 | ||||
| -rw-r--r-- | indra/newview/llvoicewebrtc.cpp | 8 |
4 files changed, 163 insertions, 172 deletions
diff --git a/indra/llwebrtc/llwebrtc.cpp b/indra/llwebrtc/llwebrtc.cpp index ab455c9645..6c809f2743 100644 --- a/indra/llwebrtc/llwebrtc.cpp +++ b/indra/llwebrtc/llwebrtc.cpp @@ -49,12 +49,6 @@ static int16_t PLAYOUT_DEVICE_DEFAULT = 0; static int16_t RECORD_DEVICE_DEFAULT = 0; #endif -// How long to keep the capture device running after a mute before stopping it. -// Keeping capture alive across brief mute/unmute cycles avoids cold-starting -// the AEC (heard as a short hiss on unmute); once the mute has been held this -// long we stop recording so the OS "mic in use" indicator clears. -static const int MUTE_STOP_RECORDING_DELAY_MS = 30000; - // // LLWebRTCAudioTransport implementation @@ -268,24 +262,19 @@ void LLWebRTCAudioDeviceModule::SetTuning(bool tuning, bool mute) tuning_ = tuning; if (tuning) { - int32_t hr = inner_->InitMicrophone(); - hr = inner_->InitRecording(); - hr = inner_->StartRecording(); - hr = inner_->StopPlayout(); - } - else - { - if (mute) - { - inner_->StopRecording(); - } - else - { - inner_->InitRecording(); - inner_->StartRecording(); - } - inner_->StartPlayout(); + // 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. + 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. } // @@ -297,6 +286,7 @@ LLWebRTCImpl::LLWebRTCImpl(LLWebRTCLogCallback* logCallback) : mLogSink(new LLWebRTCLogSink(logCallback)), mPeerCustomProcessor(nullptr), mMute(true), + mVoiceEnabled(false), mTuningMode(false), mDevicesDeploying(0), mGain(0.0f), @@ -431,7 +421,7 @@ void LLWebRTCImpl::terminate() { if (mDeviceModule) { - mDeviceModule->Terminate(); + mDeviceModule->ForceTerminate(); } mDeviceModule = nullptr; }); @@ -538,23 +528,25 @@ void LLWebRTCImpl::unsetDevicesObserver(LLWebRTCDevicesObserver *observer) } } -// must be run in the worker thread. Selects the user's chosen capture/playout -// devices and (re)initializes and starts them. Does NOT touch per-connection -// tracks -- callers that also need mute/track state re-applied use -// workerDeployDevices(). -void LLWebRTCImpl::workerStartDevices() +// 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). +void LLWebRTCImpl::workerStartRecording() { - if (!mDeviceModule) + // 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()) { return; } int16_t recordingDevice = RECORD_DEVICE_DEFAULT; - int16_t recording_device_start = 0; - if (mRecordingDevice != "Default") { - for (int16_t i = recording_device_start; i < mRecordingDeviceList.size(); i++) + for (int16_t i = 0; i < mRecordingDeviceList.size(); i++) { if (mRecordingDeviceList[i].mID == mRecordingDevice) { @@ -570,8 +562,6 @@ void LLWebRTCImpl::workerStartDevices() } } - mDeviceModule->StopPlayout(); - mDeviceModule->ForceStopRecording(); #if WEBRTC_WIN if (recordingDevice < 0) { @@ -586,25 +576,32 @@ void LLWebRTCImpl::workerStartDevices() #endif mDeviceModule->InitMicrophone(); mDeviceModule->SetStereoRecording(false); - mBuiltinNS = mDeviceModule->BuiltInNSIsAvailable(); - mBuiltinAEC = mDeviceModule->BuiltInAECIsAvailable(); - mBuiltinAGC = mDeviceModule->BuiltInAGCIsAvailable(); // A newly-selected capture device may default its hardware AEC/AGC/NS on; // disable before InitRecording so the recording stream is configured to // use only WebRTC's software APM. workerDisableBuiltInAudioProcessing(); mDeviceModule->InitRecording(); + mDeviceModule->ForceStartRecording(); +} - if ((!mMute && mPeerConnections.size()) || mTuningMode) +// must be run in the worker thread. Selects the configured playout device and +// starts playout. Playout only runs while there's a connection to render +// (running the output device with no engine data is heard as a buzz), so this +// is a no-op when there are no connections or when already playing. Device +// changes go through workerDeployDevices(), which stops playout first. +void LLWebRTCImpl::workerStartPlayout() +{ + // Only run playout while voice is enabled and there's a connection to + // render (running the output device otherwise is heard as a buzz). + if (!mDeviceModule || !mVoiceEnabled || mTuningMode || mDeviceModule->Playing() || mPeerConnections.empty()) { - mDeviceModule->ForceStartRecording(); + return; } int16_t playoutDevice = PLAYOUT_DEVICE_DEFAULT; - int16_t playout_device_start = 0; if (mPlayoutDevice != "Default") { - for (int16_t i = playout_device_start; i < mPlayoutDeviceList.size(); i++) + for (int16_t i = 0; i < mPlayoutDeviceList.size(); i++) { if (mPlayoutDeviceList[i].mID == mPlayoutDevice) { @@ -635,22 +632,14 @@ void LLWebRTCImpl::workerStartDevices() mDeviceModule->InitSpeaker(); mDeviceModule->SetStereoPlayout(true); mDeviceModule->InitPlayout(); - - // Only run playout when there's actually something to render. Starting - // playout with no peer connection leaves the output device spinning with - // no engine data, which is heard as a buzz until a connection is made. - // (Recording is gated on the same condition above.) - if (!mTuningMode && !mPeerConnections.empty()) - { - mDeviceModule->StartPlayout(); - } + mDeviceModule->StartPlayout(); } -// must be run in the worker thread. Selects/starts the devices (via -// workerStartDevices) and then re-applies per-connection mute/track state. -// Use this for device changes and tuning; for simply bringing devices up when -// a connection is established (without disturbing the connection's own -// mute/track management) call workerStartDevices() directly. +// must be run in the worker thread. Used for device changes and tuning: forces +// a clean re-select of both devices, then re-applies per-connection mute/track +// state. To merely bring playout up when a connection is established (without +// disturbing the connection's own mute/track management) call +// workerOpenPlayout() directly -- see startPlayout(). void LLWebRTCImpl::workerDeployDevices() { if (!mDeviceModule) @@ -658,7 +647,13 @@ void LLWebRTCImpl::workerDeployDevices() return; } - workerStartDevices(); + // Stop first so the start helpers (which no-op when already running) will + // re-select the now-current device. + mDeviceModule->StopPlayout(); + mDeviceModule->ForceStopRecording(); + + workerStartRecording(); + workerStartPlayout(); mSignalingThread->PostTask( [this] @@ -701,6 +696,35 @@ void LLWebRTCImpl::setRenderDevice(const std::string &id) } } +void LLWebRTCImpl::setVoiceEnabled(bool enable) +{ + mVoiceEnabled = enable; + mWorkerThread->PostTask( + [this, enable]() + { + if (!mDeviceModule) + { + return; + } + 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. + mDeviceModule->Init(); + workerDeployDevices(); + } + else + { + // Voice off: release both devices so the OS mic/speaker aren't + // held open. + mDeviceModule->ForceStopRecording(); + mDeviceModule->StopPlayout(); + mDeviceModule->ForceTerminate(); + } + }); +} + // updateDevices needs to happen on the worker thread. void LLWebRTCImpl::updateDevices() { @@ -749,6 +773,8 @@ void LLWebRTCImpl::updateDevices() { observer->OnDevicesChanged(mPlayoutDeviceList, mRecordingDeviceList); } + + deployDevices(); } void LLWebRTCImpl::OnDevicesUpdated() @@ -771,6 +797,13 @@ void LLWebRTCImpl::setTuningMode(bool enable) [this] { mDeviceModule->SetTuning(mTuningMode, mMute); + if (!mTuningMode) + { + // Restore playout after tuning, gated on there being a + // connection to render (so the output device isn't left + // spinning with no engine data). + workerStartPlayout(); + } mSignalingThread->PostTask( [this] { @@ -842,48 +875,16 @@ 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 + // 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. if (mPeerCustomProcessor) { mPeerCustomProcessor->setGain(mMute ? 0.0f : mGain); } - - // Sequence counter to prevent race conditions from rapid requests to mute/unmute - static std::atomic<uint32_t> mute_sequence(0); - uint32_t current_sequence = ++mute_sequence; - - if (mMute) - { - // Keep capturing for a while after muting so quick mute/unmute cycles - // don't cold-start the AEC (and any OS capture effect such as Windows - // Voice Clarity), which is heard as a short hiss on unmute. Once the - // mute has been held this long, stop recording so the OS "mic in use" - // indicator clears. If the user unmutes or toggles before this fires, - // the sequence check turns it into a no-op and capture keeps running. - mWorkerThread->PostDelayedTask( - [this, current_sequence] - { - if (mDeviceModule && (current_sequence == mute_sequence.load())) - { - mDeviceModule->ForceStopRecording(); - } - }, - webrtc::TimeDelta::Millis(MUTE_STOP_RECORDING_DELAY_MS)); - } - else - { - mWorkerThread->PostTask( - [this, current_sequence] - { - if (mDeviceModule && (current_sequence == mute_sequence.load())) - { - // No-op if capture is still running (the common case, when - // unmuting within the stop delay -> no AEC cold start); - // restarts capture if a sustained mute had stopped it. - mDeviceModule->InitRecording(); - mDeviceModule->ForceStartRecording(); - } - }); - } } // @@ -900,12 +901,12 @@ LLWebRTCPeerConnectionInterface *LLWebRTCImpl::newPeerConnection() } mPeerConnections.emplace_back(peerConnection); - // The capture/playout devices are 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. The devices are (re)started from - // OnConnectionChange(kConnected) instead, once audio is actually - // established (see startAudioDevices()). + // 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. peerConnection->enableSenderTracks(false); peerConnection->resetMute(); @@ -923,79 +924,42 @@ void LLWebRTCImpl::freePeerConnection(LLWebRTCPeerConnectionInterface* peer_conn if (mPeerConnections.empty()) { intSetMute(true); - // Last connection gone: stop capture immediately rather than - // waiting out the mute stop-delay, so the mic isn't held open after - // the call, and stop playout so the output device isn't left - // spinning with no engine data. + // 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 + // 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 + // only sticks once the connection -- and its stream -- is gone. mWorkerThread->PostTask( [this]() { if (mDeviceModule) { - mDeviceModule->ForceStopRecording(); mDeviceModule->StopPlayout(); + if (!mVoiceEnabled) + { + mDeviceModule->ForceStopRecording(); + } } }); } } } -void LLWebRTCImpl::startAudioDevices() +void LLWebRTCImpl::startPlayout() { - // Called when a connection's audio is established. This is the - // authoritative point that brings the devices back (with the user's - // selected devices applied) after all connections dropped (teleport, voice - // restart) or for the first call of a session. It matters because the - // WebRTC engine no-ops Start/StopRecording on our ADM wrapper -- only our - // explicit Force* calls actually drive capture -- so when the devices were - // stopped, nothing else will restart them. - // - // It's guarded on Playing()/Recording() so a second connection establishing - // won't glitch an already-running stream, and doing this at "connected" - // rather than at connection creation avoids running the output device with - // no decoded audio during the handshake. + // 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. mWorkerThread->PostTask( [this]() { - if (!mDeviceModule || mTuningMode) - { - return; - } - - if (!mDeviceModule->Playing()) - { - // First established connection for this call: select and start - // the user's *chosen* capture/playout devices - // (SetRecordingDevice/SetPlayoutDevice). Just calling - // InitPlayout/InitRecording here would bring the devices up on - // whatever the ADM currently has selected -- the system default - // after a cold start -- which is why a p2p call (or a call after - // teleport/voice-restart) could come up on the wrong device. - // - // We call workerStartDevices() rather than the full - // deployDevices() on purpose: deployDevices() also re-applies - // per-connection mute/track state, which races with the - // viewer's own mute setup for the freshly-establishing - // connection and can leave the sender track disabled (recording - // runs but nothing transmits after teleport). - workerStartDevices(); - } - - // Authoritatively (re)start capture whenever we're connected and not - // device-muted. This runs unconditionally -- NOT just in an else - // branch -- because workerStartDevices() above stops recording while - // re-selecting the device and only restarts it behind a gate; if - // that gate doesn't line up (or capture was stopped on a prior - // disconnect, e.g. teleport), this is what reliably brings the mic - // back. No-op if capture is already running. - if (!mMute && !mPeerConnections.empty() && !mDeviceModule->Recording()) - { - if (!mDeviceModule->RecordingIsInitialized()) - { - mDeviceModule->InitRecording(); - } - mDeviceModule->ForceStartRecording(); - } + workerStartPlayout(); }); } @@ -1456,12 +1420,12 @@ void LLWebRTCPeerConnectionImpl::OnConnectionChange(webrtc::PeerConnectionInterf { case webrtc::PeerConnectionInterface::PeerConnectionState::kConnected: { - // Audio is established now -- (re)start the capture and playout - // devices. Doing this here rather than at connection creation - // avoids running the output device during the handshake (heard as a - // buzz), and reliably restores the devices after a full teardown - // (teleport / voice restart). - mWebRTCImpl->startAudioDevices(); + // 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 + // during the handshake (heard as a buzz). + mWebRTCImpl->startPlayout(); mPendingJobs++; webrtc::scoped_refptr<LLWebRTCPeerConnectionImpl> self(this); mWebRTCImpl->PostWorkerTask([self]() diff --git a/indra/llwebrtc/llwebrtc.h b/indra/llwebrtc/llwebrtc.h index e76e708f0c..821400cfe8 100644 --- a/indra/llwebrtc/llwebrtc.h +++ b/indra/llwebrtc/llwebrtc.h @@ -153,6 +153,14 @@ class LLWebRTCDeviceInterface virtual void setCaptureDevice(const std::string& id) = 0; virtual void setRenderDevice(const std::string& id) = 0; + // 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. + virtual void setVoiceEnabled(bool enable) = 0; + // Device observers for device change callbacks. virtual void setDevicesObserver(LLWebRTCDevicesObserver *observer) = 0; virtual void unsetDevicesObserver(LLWebRTCDevicesObserver *observer) = 0; diff --git a/indra/llwebrtc/llwebrtc_impl.h b/indra/llwebrtc/llwebrtc_impl.h index cfb0d10c29..28d25b8d51 100644 --- a/indra/llwebrtc/llwebrtc_impl.h +++ b/indra/llwebrtc/llwebrtc_impl.h @@ -180,7 +180,7 @@ private: class LLWebRTCAudioDeviceModule : public webrtc::AudioDeviceModule { public: - explicit LLWebRTCAudioDeviceModule(webrtc::scoped_refptr<webrtc::AudioDeviceModule> inner) : inner_(std::move(inner)), tuning_(false) + explicit LLWebRTCAudioDeviceModule(webrtc::scoped_refptr<webrtc::AudioDeviceModule> inner) : inner_(inner), tuning_(false) { RTC_CHECK(inner_); } @@ -197,9 +197,15 @@ public: } int32_t Init() override { return inner_->Init(); } - int32_t Terminate() override { return inner_->Terminate(); } + int32_t Terminate() override { + // libwebrtc attempts to terminate the adm when peer connections go to zero, but we don't want that, + // now that we're keeping the adm active throughout the session. + return 0; + } bool Initialized() const override { return inner_->Initialized(); } + int32_t ForceTerminate() { return inner_->Terminate(); } + // --- Device enumeration/selection (forward) --- int16_t PlayoutDevices() override { return inner_->PlayoutDevices(); } int16_t RecordingDevices() override { return inner_->RecordingDevices(); } @@ -422,6 +428,8 @@ class LLWebRTCImpl : public LLWebRTCDeviceInterface, public webrtc::AudioDeviceO void setCaptureDevice(const std::string& id) override; void setRenderDevice(const std::string& id) override; + void setVoiceEnabled(bool enable) override; + void setTuningMode(bool enable) override; float getTuningAudioLevel() override; float getPeerConnectionAudioLevel() override; @@ -499,16 +507,17 @@ class LLWebRTCImpl : public LLWebRTCDeviceInterface, public webrtc::AudioDeviceO LLWebRTCPeerConnectionInterface* newPeerConnection(); void freePeerConnection(LLWebRTCPeerConnectionInterface* peer_connection); - // (Re)start the capture and playout devices once a connection's audio is - // established. This is the authoritative point for bringing the devices - // back after all connections dropped (teleport, voice restart). Idempotent - // and safe to call from any thread (work is posted to the worker thread). - void startAudioDevices(); + // 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 + // any thread (work is posted to the worker thread). + void startPlayout(); protected: const webrtc::Environment mEnv; - void workerStartDevices(); + void workerStartRecording(); + void workerStartPlayout(); void workerDeployDevices(); // We always rely on WebRTC's internal (software APM) audio processing, so // any platform/hardware AEC/AGC/NS must be kept disabled. @@ -547,6 +556,8 @@ class LLWebRTCImpl : public LLWebRTCDeviceInterface, public webrtc::AudioDeviceO LLWebRTCVoiceDeviceList mPlayoutDeviceList; bool mMute; + // Whether voice is enabled; gates whether the capture/playout devices run. + bool mVoiceEnabled; float mGain; LLCustomProcessorStatePtr mPeerCustomProcessor; diff --git a/indra/newview/llvoicewebrtc.cpp b/indra/newview/llvoicewebrtc.cpp index ecf963039f..126d22924b 100644 --- a/indra/newview/llvoicewebrtc.cpp +++ b/indra/newview/llvoicewebrtc.cpp @@ -1711,6 +1711,14 @@ void LLWebRTCVoiceClient::setVoiceEnabled(bool enabled) mVoiceEnabled = enabled; LLVoiceClientStatusObserver::EStatusType status; + // Gate the audio devices on voice being enabled: the capture mic and + // playout speaker only run while voice is on, and the mic isn't held + // open when voice is off. + if (mWebRTCDeviceInterface) + { + mWebRTCDeviceInterface->setVoiceEnabled(enabled); + } + if (enabled) { LL_DEBUGS("Voice") << "enabling" << LL_ENDL; |
