summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRoxie Linden <roxie@lindenlab.com>2026-06-30 11:48:27 -0700
committerRoxie Linden <roxie@lindenlab.com>2026-07-07 10:15:11 -0700
commit4124f969ee7bbd4e0734ef0d118c60125d7bb358 (patch)
treee26780486a49936788ce005049a93aa238b92ad7
parent95120bc676fc68793da2d4c06542cf7cb3272d00 (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.cpp292
-rw-r--r--indra/llwebrtc/llwebrtc.h8
-rw-r--r--indra/llwebrtc/llwebrtc_impl.h27
-rw-r--r--indra/newview/llvoicewebrtc.cpp8
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;