diff --git a/Runtime/Scripts/Audio/MicrophoneSource.cs b/Runtime/Scripts/Audio/MicrophoneSource.cs
index 75bec1f0..31eecd7c 100644
--- a/Runtime/Scripts/Audio/MicrophoneSource.cs
+++ b/Runtime/Scripts/Audio/MicrophoneSource.cs
@@ -12,15 +12,56 @@ namespace LiveKit
///
/// Ensure microphone permissions are granted before calling .
///
+ ///
+ /// Strategy used by to move samples from Unity's microphone
+ /// ring buffer into the audio pipeline.
+ ///
+ public enum MicrophoneCaptureMode
+ {
+ ///
+ /// Play the microphone clip through an and capture it via
+ /// (OnAudioFilterRead). This is the default and matches the
+ /// SDK's historical behavior.
+ ///
+ AudioFilter,
+
+ ///
+ /// Read samples directly out of the microphone clip with
+ /// / on the main
+ /// thread, without creating an AudioSource or AudioProbe.
+ ///
+ /// couples two clocks: the microphone driver writes the clip
+ /// at the input device's rate while the AudioSource reads it at the output pipeline's
+ /// rate. On devices where those clocks drift (measured on Meta Quest, whose capture
+ /// pipeline runs at 24 kHz), the read position slides against the write position until
+ /// the filter consumes stale or not-yet-written regions of the ring, which surfaces as
+ /// intermittent silence and crackle. Direct polling uses the microphone's own write
+ /// position as the only clock, so drift cannot accumulate; this is the same capture
+ /// strategy used by Photon Voice and Mumble on the same hardware.
+ ///
+ DirectPolling,
+ }
+
sealed public class MicrophoneSource : RtcAudioSource
{
+ // DirectPolling: never emit more than this much backlog in one tick. The native audio
+ // source ingests 10 ms frames into a bounded (~1 s) queue at real time; after a main
+ // thread hitch (GC, scene load) Microphone.GetPosition can report hundreds of ms of
+ // backlog, and pushing it all at once overflows that queue. Real-time voice cannot use
+ // stale audio anyway, so the loop drops the oldest samples beyond this budget and
+ // resynchronizes to the newest.
+ private const int CatchUpBudgetMs = 300;
+
private readonly GameObject _sourceObject;
private readonly string _deviceName;
+ private readonly MicrophoneCaptureMode _captureMode;
public override event Action AudioRead;
private bool _disposed = false;
private bool _started = false;
+ // Invalidates an in-flight polling loop when the microphone is stopped or restarted.
+ private int _pollGeneration = 0;
///
/// Creates a new microphone source for the given device.
@@ -29,10 +70,28 @@ sealed public class MicrophoneSource : RtcAudioSource
/// get the list of available devices.
/// The GameObject to attach the AudioSource to. The object must be kept in the scene
/// for the duration of the source's lifetime.
- public MicrophoneSource(string deviceName, GameObject sourceObject) : base(RtcAudioSourceType.AudioSourceMicrophone)
+ public MicrophoneSource(string deviceName, GameObject sourceObject)
+ : this(deviceName, sourceObject, MicrophoneCaptureMode.AudioFilter)
+ {
+ }
+
+ ///
+ /// Creates a new microphone source for the given device using the given capture mode.
+ ///
+ /// The name of the device to capture from. Use to
+ /// get the list of available devices.
+ /// The GameObject to attach the AudioSource to (unused by
+ /// , which creates no components). The object must be kept
+ /// in the scene for the duration of the source's lifetime.
+ /// How samples are moved out of the microphone ring buffer. Use
+ /// on devices where the default filter-based capture
+ /// exhibits drift (e.g. Meta Quest).
+ public MicrophoneSource(string deviceName, GameObject sourceObject, MicrophoneCaptureMode captureMode)
+ : base(RtcAudioSourceType.AudioSourceMicrophone)
{
_deviceName = deviceName;
_sourceObject = sourceObject;
+ _captureMode = captureMode;
}
///
@@ -98,6 +157,28 @@ private IEnumerator StartMicrophone()
yield break;
}
+ if (_captureMode == MicrophoneCaptureMode.DirectPolling)
+ {
+ // Wait for the microphone to actually start producing data before polling.
+ const float pollTimeout = 2f;
+ float pollElapsed = 0f;
+ while (Microphone.GetPosition(_deviceName) <= 0 && pollElapsed < pollTimeout)
+ {
+ yield return new WaitForSeconds(0.05f);
+ pollElapsed += 0.05f;
+ }
+ if (Microphone.GetPosition(_deviceName) <= 0)
+ {
+ Utils.Error($"MicrophoneSource: Microphone did not start producing data after {pollTimeout}s");
+ yield break;
+ }
+
+ int generation = ++_pollGeneration;
+ MonoBehaviourContext.RunCoroutine(PollMicrophone(clip, generation));
+ Utils.Debug($"MicrophoneSource device='{_deviceName}' started successfully (direct polling)");
+ yield break;
+ }
+
// Ensure no duplicate components exist before adding new ones.
// This is important during app resume on iOS where components might not be
// fully destroyed yet due to Unity's deferred Destroy().
@@ -153,6 +234,8 @@ public override void Stop()
private IEnumerator StopMicrophone()
{
+ _pollGeneration++; // ends an in-flight DirectPolling loop
+
if (Microphone.IsRecording(_deviceName))
Microphone.End(_deviceName);
@@ -175,6 +258,133 @@ private IEnumerator StopMicrophone()
yield return null;
}
+ ///
+ /// DirectPolling capture: reads 10 ms frames straight out of the microphone clip using
+ /// the microphone's own write position as the only clock. Runs once per rendered frame;
+ /// exits when the source is stopped, restarted, or the device stops recording.
+ ///
+ private IEnumerator PollMicrophone(AudioClip clip, int generation)
+ {
+ int sampleRate = clip.frequency;
+ int clipChannels = clip.channels;
+ int expectedChannels = (int)_expectedChannels;
+ int clipLength = clip.samples; // samples per channel
+ int frameSize = sampleRate / 100; // 10 ms per channel
+ int maxCatchUpFrames = CatchUpBudgetMs / 10;
+
+ var readBuf = new float[frameSize * clipChannels];
+ var sendBuf = clipChannels == expectedChannels ? readBuf : new float[frameSize * expectedChannels];
+
+ int lastPos = Microphone.GetPosition(_deviceName);
+ if (lastPos < 0) lastPos = 0;
+
+ int droppedSinceWarn = 0;
+ float nextWarnTime = 0f;
+ bool channelMismatchWarned = false;
+
+ while (_started && generation == _pollGeneration && Microphone.IsRecording(_deviceName))
+ {
+ int micPos = Microphone.GetPosition(_deviceName);
+ // GetPosition can transiently return -1 on some Android device states; never
+ // feed a negative offset into the modulo below or into GetData.
+ if (micPos < 0)
+ {
+ yield return null;
+ continue;
+ }
+
+ int available = micPos - lastPos;
+ if (available < 0) available += clipLength; // ring wrap
+
+ // A stall longer than the ring can hold makes the modulo untrustworthy (the
+ // write position may have lapped us); resync to the newest data outright.
+ if (available >= clipLength - frameSize)
+ {
+ lastPos = micPos;
+ yield return null;
+ continue;
+ }
+
+ // Drop the oldest backlog beyond the catch-up budget so a main-thread hitch
+ // can never flood the native queue in a single tick.
+ int backlogFrames = available / frameSize;
+ if (backlogFrames > maxCatchUpFrames)
+ {
+ int dropFrames = backlogFrames - maxCatchUpFrames;
+ lastPos = (lastPos + dropFrames * frameSize) % clipLength;
+ droppedSinceWarn += dropFrames;
+ }
+
+ if (droppedSinceWarn > 0 && Time.unscaledTime >= nextWarnTime)
+ {
+ Utils.Warning($"MicrophoneSource: dropped {droppedSinceWarn} stale mic frame(s) to avoid flooding the capture queue (main-thread hitch?)");
+ droppedSinceWarn = 0;
+ nextWarnTime = Time.unscaledTime + 1f;
+ }
+
+ while (_started && generation == _pollGeneration && ((micPos - lastPos + clipLength) % clipLength) >= frameSize)
+ {
+ clip.GetData(readBuf, lastPos);
+ lastPos = (lastPos + frameSize) % clipLength;
+
+ if (clipChannels != expectedChannels)
+ {
+ if (clipChannels == 1)
+ {
+ // Mono clip, multi-channel source (the common Android case):
+ // duplicate each sample across the expected channels.
+ for (int i = 0; i < frameSize; i++)
+ for (int c = 0; c < expectedChannels; c++)
+ sendBuf[i * expectedChannels + c] = readBuf[i];
+ }
+ else if (expectedChannels == 1)
+ {
+ // Multi-channel clip, mono source: average.
+ for (int i = 0; i < frameSize; i++)
+ {
+ float sum = 0f;
+ for (int c = 0; c < clipChannels; c++)
+ sum += readBuf[i * clipChannels + c];
+ sendBuf[i] = sum / clipChannels;
+ }
+ }
+ else
+ {
+ // Unusual pairing: carry the first clip channel into every output
+ // channel rather than sending misinterleaved audio.
+ if (!channelMismatchWarned)
+ {
+ channelMismatchWarned = true;
+ Utils.Warning($"MicrophoneSource: clip has {clipChannels} channels but source expects {expectedChannels}; using first channel");
+ }
+ for (int i = 0; i < frameSize; i++)
+ for (int c = 0; c < expectedChannels; c++)
+ sendBuf[i * expectedChannels + c] = readBuf[i * clipChannels];
+ }
+ }
+
+ try
+ {
+ AudioRead?.Invoke(sendBuf, expectedChannels, sampleRate);
+ }
+ catch (Exception e)
+ {
+ // This loop is the sole frame producer: a throwing subscriber must not
+ // kill it, or the microphone goes permanently silent.
+ if (Time.unscaledTime >= nextWarnTime)
+ {
+ Utils.Warning($"MicrophoneSource: AudioRead subscriber threw: {e.Message}");
+ nextWarnTime = Time.unscaledTime + 1f;
+ }
+ }
+ }
+
+ yield return null;
+ }
+
+ Utils.Debug($"MicrophoneSource device='{_deviceName}' polling loop ended");
+ }
+
private void OnAudioRead(float[] data, int channels, int sampleRate)
{
AudioRead?.Invoke(data, channels, sampleRate);