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
141 changes: 134 additions & 7 deletions Runtime/Scripts/MicrophoneSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,25 @@ 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;

// Diagnostics: counts AudioProbe buffers delivered, so a read-only health monitor can tell
// whether capture has stalled (e.g. after a Bluetooth route change) without restarting.
private volatile int _audioReadFrames = 0;
private bool _monitoring = false;

/// <summary>
/// Creates a new microphone source for the given device.
Expand Down Expand Up @@ -54,9 +67,21 @@ 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;

// DIAGNOSTIC (read-only): periodically log capture health so logcat shows whether
// buffers keep flowing and whether the config-changed event fires on a device change.
if (!_monitoring)
{
_monitoring = true;
MonoBehaviourContext.RunCoroutine(MonitorCaptureHealth());
}
}

private IEnumerator StartMicrophone()
Expand All @@ -75,11 +100,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 +153,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.Info($"MicrophoneSource device='{_activeDeviceName ?? "<default>"}' started successfully");
}

/// <summary>
Expand All @@ -147,13 +177,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,12 +201,13 @@ private IEnumerator StopMicrophone()
UnityEngine.Object.Destroy(source);
}

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

private void OnAudioRead(float[] data, int channels, int sampleRate)
{
_audioReadFrames++;
AudioRead?.Invoke(data, channels, sampleRate);
}

Expand All @@ -197,8 +229,65 @@ 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 audio device changes (e.g. connecting/disconnecting a Bluetooth headset). Mirrors
// AudioStream.OnAudioConfigurationChanged on the playback side.
private void OnAudioConfigurationChanged(bool deviceWasChanged)
{
// DIAGNOSTIC: confirms whether this event fires at all on a device change (the open
// question on Android, where a Bluetooth route change may not change the DSP config).
Utils.Info($"MicrophoneSource: OnAudioConfigurationChanged deviceWasChanged={deviceWasChanged} outputSampleRate={AudioSettings.outputSampleRate} started={_started}");

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.

}

// Unity rebuilds its audio graph on any configuration change — including an output
// route change (e.g. a Bluetooth headset disconnecting) where the input device itself
// doesn't change. On mobile the input is always the built-in mic regardless of the
// headset, so deviceWasChanged is false there even though the rebuild detaches the
// AudioProbe tap and stops capture. Always restart so the tap is re-registered;
// AudioStream does the same on the playback side and never gates on deviceWasChanged.
Utils.Debug("MicrophoneSource: audio configuration changed, restarting capture");
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)
{
Utils.Info("MicrophoneSource: restart requested but one is already in progress, ignoring");
yield break;
}
_restarting = true;
Utils.Info("MicrophoneSource: restart begin");

yield return StopMicrophone();

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

yield return StartMicrophone();

_restarting = false;
Utils.Info("MicrophoneSource: restart end");
}

// DIAGNOSTIC (read-only — never restarts): logs capture health every couple of seconds so
// logcat shows whether AudioProbe buffers keep flowing after a device change and whether
// Microphone still reports recording/advancing. Runs for the lifetime of the source.
private IEnumerator MonitorCaptureHealth()
{
int lastFrames = _audioReadFrames;
int lastPosition = -1;
while (_started && !_disposed)
{
yield return new WaitForSeconds(2f);

int frames = _audioReadFrames;
int delta = frames - lastFrames;
lastFrames = frames;

bool recording = false;
int position = -1;
try
{
recording = Microphone.IsRecording(_activeDeviceName);
position = Microphone.GetPosition(_activeDeviceName);
}
catch (Exception e)
{
Utils.Warning($"MicrophoneSource: health probe threw {e.Message}");
}

Utils.Info($"MicrophoneSource: health framesLast2s={delta} totalFrames={frames} isRecording={recording} position={position} prevPosition={lastPosition} device='{_activeDeviceName ?? "<default>"}' muted={Muted} restarting={_restarting}");
lastPosition = position;
}

_monitoring = false;
Utils.Info("MicrophoneSource: health monitor stopped");
}

private IEnumerator WaitForMicrophoneReady()
Expand Down
Loading