Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 73 additions & 7 deletions Runtime/Scripts/MicrophoneSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,20 @@ namespace LiveKit
sealed public class MicrophoneSource : RtcAudioSource
{
private readonly GameObject _sourceObject;

// The device requested by the caller. Empty/null means "follow the OS default".
private readonly string _deviceName;

// The device the microphone is actually recording from right now. This can differ from
// _deviceName when the preferred device is unavailable and we fall back to the OS default,
// so all Microphone.* calls (IsRecording/GetPosition/End) must use this name.
private string _activeDeviceName;

public override event Action<float[], int, int> AudioRead;

private bool _disposed = false;
private bool _started = false;
private bool _restarting = false;

/// <summary>
/// Creates a new microphone source for the given device.
Expand Down Expand Up @@ -54,6 +62,10 @@ public override void Start()
throw new InvalidOperationException("Microphone access not authorized");

MonoBehaviourContext.OnApplicationPauseEvent += OnApplicationPause;
// Restart capture when the system audio device changes (e.g. a Bluetooth headset is
// unplugged). Unity rebuilds its audio graph on a device change, which both detaches
// the AudioProbe tap and leaves Microphone.Start bound to a now-gone device.
AudioSettings.OnAudioConfigurationChanged += OnAudioConfigurationChanged;
MonoBehaviourContext.RunCoroutine(StartMicrophone());

_started = true;
Expand All @@ -75,11 +87,16 @@ private IEnumerator StartMicrophone()
yield break;
}

// Resolve which device to record from. Falls back to the OS default when the
// preferred device is gone, so an unplugged headset transparently hands off to the
// built-in microphone.
_activeDeviceName = ResolveCaptureDevice();

AudioClip clip = null;
try
{
clip = Microphone.Start(
_deviceName,
_activeDeviceName,
loop: true,
lengthSec: 1,
frequency: (int)_expectedSampleRate
Expand Down Expand Up @@ -123,20 +140,20 @@ private IEnumerator StartMicrophone()
// Wait for microphone to actually start producing data with a timeout
const float timeout = 2f;
float elapsed = 0f;
while (Microphone.GetPosition(_deviceName) <= 0 && elapsed < timeout)
while (Microphone.GetPosition(_activeDeviceName) <= 0 && elapsed < timeout)
{
yield return new WaitForSeconds(0.05f);
elapsed += 0.05f;
}

if (Microphone.GetPosition(_deviceName) <= 0)
if (Microphone.GetPosition(_activeDeviceName) <= 0)
{
Utils.Error($"MicrophoneSource: Microphone did not start producing data after {timeout}s");
yield break;
}

source.Play();
Utils.Debug($"MicrophoneSource device='{_deviceName}' started successfully");
Utils.Debug($"MicrophoneSource device='{_activeDeviceName ?? "<default>"}' started successfully");
}

/// <summary>
Expand All @@ -147,13 +164,14 @@ public override void Stop()
base.Stop();
MonoBehaviourContext.RunCoroutine(StopMicrophone());
MonoBehaviourContext.OnApplicationPauseEvent -= OnApplicationPause;
AudioSettings.OnAudioConfigurationChanged -= OnAudioConfigurationChanged;
_started = false;
}

private IEnumerator StopMicrophone()
{
if (Microphone.IsRecording(_deviceName))
Microphone.End(_deviceName);
if (Microphone.IsRecording(_activeDeviceName))
Microphone.End(_activeDeviceName);

// Check if GameObject is still valid before trying to access components
if (_sourceObject != null)
Expand All @@ -170,7 +188,7 @@ private IEnumerator StopMicrophone()
UnityEngine.Object.Destroy(source);
}

Utils.Debug($"MicrophoneSource device='{_deviceName}' stopped");
Utils.Debug($"MicrophoneSource device='{_activeDeviceName ?? "<default>"}' stopped");
yield return null;
}

Expand All @@ -197,8 +215,54 @@ private void OnApplicationPause(bool pause)
}
}

// Picks the device name to pass to Microphone.Start. An empty preferred name, or a
// preferred device that is no longer connected, resolves to null so Unity records from
// the current OS default device.
private string ResolveCaptureDevice()
{
if (string.IsNullOrEmpty(_deviceName))
return null;

if (Array.IndexOf(Microphone.devices, _deviceName) >= 0)
return _deviceName;

Utils.Debug($"MicrophoneSource: preferred device '{_deviceName}' is no longer available, falling back to the OS default");
return null;
}

// Fires on the main thread when Unity's audio configuration changes, including when the
// system capture/playback device changes (e.g. unplugging a Bluetooth headset). Mirrors
// AudioStream.OnAudioConfigurationChanged on the playback side.
private void OnAudioConfigurationChanged(bool deviceWasChanged)
{
if (!_started)
return;

// The native source's rate is fixed at construction and RtcAudioSource drops frames
// whose rate doesn't match it. If the device change moved Unity's DSP output rate,
// restarting capture alone won't recover audio — warn so the silence is diagnosable.
// Full recovery (recreating the native source at the new rate) is handled separately.
var outputSampleRate = (uint)AudioSettings.outputSampleRate;
if (outputSampleRate != _expectedSampleRate)
{
Utils.Warning($"MicrophoneSource: audio device change moved the DSP output rate to {outputSampleRate}Hz, but the native source is fixed at {_expectedSampleRate}Hz. Captured frames will be dropped until the track is recreated at the new rate.");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Triggered on Unity 6 editor on mac starting with MDR-1000x, disabling Bluetooth to switch to built in mic, then connecting to MDR-1000x again:

LiveKit: MicrophoneSource: audio device change moved the DSP output rate to 44100Hz, but the native source is fixed at 48000Hz. Captured frames will be dropped until the track is recreated at the new rate.
UnityEngine.Logger:LogWarning (string,object)
LiveKit.Internal.Utils:Warning (object) (at /Users/maxheimbrock/dev/unity/client-sdk-unity/Runtime/Scripts/Internal/Utils.cs:41)
LiveKit.MicrophoneSource:OnAudioConfigurationChanged (bool) (at /Users/maxheimbrock/dev/unity/client-sdk-unity/Runtime/Scripts/MicrophoneSource.cs:248)
UnityEngine.AudioSettings:InvokeOnAudioConfigurationChanged (bool) (at /Users/bokken/build/output/unity/unity/Modules/Audio/Public/ScriptBindings/Audio.bindings.cs:413)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

if (deviceWasChanged)
{
Utils.Debug("MicrophoneSource: audio device changed, restarting capture on the current default device");
MonoBehaviourContext.RunCoroutine(RestartMicrophone());
}
}

private IEnumerator RestartMicrophone()
{
// The device-change event can fire several times around a single hardware swap;
// ignore re-entrant restarts so overlapping Stop/Start coroutines don't race.
if (_restarting)
yield break;
_restarting = true;

yield return StopMicrophone();

// Wait for iOS audio session to be ready before attempting to restart.
Expand All @@ -207,6 +271,8 @@ private IEnumerator RestartMicrophone()
yield return WaitForMicrophoneReady();

yield return StartMicrophone();

_restarting = false;
}

private IEnumerator WaitForMicrophoneReady()
Expand Down
Loading