From d8d5383e2b32f051cb5ffc2df2ed0baf36598a8c Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:52:19 +0200 Subject: [PATCH 1/4] Recover microphone capture when the device disappears mid-call When the active capture device vanishes (e.g. a Bluetooth headset disconnects), Unity's Microphone clip silently stops filling and capture went permanently dead. Detect and recover automatically: - Detection: the clip's position counter advances continuously while a device is alive (even in silence), so CaptureLoop treats a counter that hasn't moved for 1s - or IsRecording dropping to false - as device loss. - Recovery: end the dead device and retry until a device is available, preferring the original device if it reappears and falling back to the system default microphone otherwise. The normal start path re-measures the new device's rate and fragmentation, so recovering onto or off a misbehaving device (macOS Bluetooth HFP) works transparently. The published track is unaffected throughout: the native source's format is fixed (48kHz mono) and captured audio is resampled to it, so there is no republish or renegotiation - only a capture gap until a device is acquired. Also adds MicrophoneSource.SwitchDevice(deviceName) as the manual counterpart (same mechanism, app-initiated) and a DeviceName getter. Each CaptureLoop carries a generation token and is retired when a newer capture (restart, switch, or recovery) supersedes it, so rapid transitions cannot leave two loops reading different clips; recovery pauses while the app is backgrounded so it cannot fight the iOS pause/resume handling. Co-Authored-By: Claude Fable 5 --- Runtime/Scripts/MicrophoneSource.cs | 133 +++++++++++++++++++++++++--- 1 file changed, 121 insertions(+), 12 deletions(-) diff --git a/Runtime/Scripts/MicrophoneSource.cs b/Runtime/Scripts/MicrophoneSource.cs index b424e2a5..dc0775b4 100644 --- a/Runtime/Scripts/MicrophoneSource.cs +++ b/Runtime/Scripts/MicrophoneSource.cs @@ -32,18 +32,26 @@ sealed public class MicrophoneSource : RtcAudioSource // observed pathological device measures k=3.2; healthy devices measure ~1.0 with up to a // few percent of startup noise. Keep a wide margin between the two. private const double FragmentedKThreshold = 1.5; - private const float MaxBacklogSeconds = 0.2f; // drop backlog beyond this after a stall + private const float MaxBacklogSeconds = 0.2f; // drop backlog beyond this after a stall + private const float DeviceLostTimeoutSeconds = 1f; // no counter advance for this long = device gone + private const float RecoverRetrySeconds = 1f; - private readonly string _deviceName; + private string _deviceName; public override event Action AudioRead; private bool _disposed = false; private bool _started = false; private volatile bool _capturing = false; + private bool _switching = false; + private bool _paused = false; + private int _captureGeneration = 0; private StreamingResampler _resampler; + /// The microphone device currently being captured. + public string DeviceName => _deviceName; + /// /// Creates a new microphone source for the given device. /// @@ -100,12 +108,16 @@ private IEnumerator StartMicrophone() yield break; } + // Capture the device locally so a concurrent SwitchDevice can't mix two devices + // within one start sequence. + var device = _deviceName; + AudioClip clip = null; - int requestedRate = ResolveRequestedSampleRate(_deviceName); + int requestedRate = ResolveRequestedSampleRate(device); try { clip = Microphone.Start( - _deviceName, + device, loop: true, lengthSec: 2, frequency: requestedRate @@ -126,29 +138,104 @@ 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(device) <= 0 && elapsed < timeout) { yield return new WaitForSeconds(0.05f); elapsed += 0.05f; } - if (Microphone.GetPosition(_deviceName) <= 0) + if (Microphone.GetPosition(device) <= 0) { Utils.Error($"MicrophoneSource: Microphone did not start producing data after {timeout}s"); yield break; } - Utils.Info($"MicrophoneSource device='{_deviceName}' clip={clip.frequency}Hz/{clip.channels}ch samples={clip.samples} requested={requestedRate}Hz target={TargetSampleRate}Hz"); + Utils.Info($"MicrophoneSource device='{device}' clip={clip.frequency}Hz/{clip.channels}ch samples={clip.samples} requested={requestedRate}Hz target={TargetSampleRate}Hz"); _capturing = true; - MonoBehaviourContext.RunCoroutine(CaptureLoop(clip)); + MonoBehaviourContext.RunCoroutine(CaptureLoop(clip, device, ++_captureGeneration)); + } + + /// + /// Switches capture to a different microphone device while the published track keeps + /// working. The native source's format is fixed (48kHz mono) and captured audio is + /// resampled to it, so the track and its subscribers are unaffected; there is only a brief + /// capture gap (~0.5s) while the new device starts and its rate is measured. + /// + /// + /// Internal for now: device loss is handled automatically (see RecoverRoutine); this is + /// the manual primitive should a public device-picker API be needed later. + /// + /// The device to switch to. Use to + /// get the list of available devices. + internal void SwitchDevice(string deviceName) + { + if (_disposed) return; + if (deviceName == _deviceName) return; + if (_switching) + { + Utils.Warning("MicrophoneSource: device switch already in progress, ignoring"); + return; + } + + var previous = _deviceName; + _deviceName = deviceName; + + // Not capturing yet: the next Start() simply uses the new device. + if (!_started) return; + + _switching = true; + MonoBehaviourContext.RunCoroutine(SwitchRoutine(previous)); + } + + private IEnumerator SwitchRoutine(string previousDevice) + { + _capturing = false; + if (Microphone.IsRecording(previousDevice)) + Microphone.End(previousDevice); + + yield return StartMicrophone(); + _switching = false; + Utils.Info($"MicrophoneSource: switched capture to device '{_deviceName}'"); + } + + // Recovers capture after the active device disappeared mid-call (e.g. a Bluetooth headset + // disconnected). Retries until a device is available: the original device is preferred if + // it comes back, otherwise capture falls back to the system default microphone. The + // published track is unaffected throughout — the native source's fixed format never + // changes, there is simply a capture gap until a device is acquired. + private IEnumerator RecoverRoutine(string lostDevice, int generation) + { + if (Microphone.IsRecording(lostDevice)) + Microphone.End(lostDevice); + + while (_started && !_disposed && !_paused && generation == _captureGeneration) + { + var devices = Microphone.devices; + if (devices.Length > 0) + { + // Prefer the original device if it reappeared; otherwise use the system default. + _deviceName = Array.IndexOf(devices, lostDevice) >= 0 ? lostDevice : null; + + int generationBefore = _captureGeneration; + yield return StartMicrophone(); + if (_captureGeneration != generationBefore) + { + // A new CaptureLoop is running; recovery succeeded. + Utils.Info($"MicrophoneSource: recovered capture on device '{_deviceName ?? "(default)"}'"); + yield break; + } + } + yield return new WaitForSeconds(RecoverRetrySeconds); + } } // Reads new samples from the clip's ring buffer each frame and pushes them to the native // source via AudioRead. MicClipReader decides what to read (including reconstructing // fragmented buffers); this loop is the thin Unity shell around it. Runs on the main - // thread; the native source's queue absorbs the per-frame pacing jitter. - private IEnumerator CaptureLoop(AudioClip clip) + // thread; the native source's queue absorbs the per-frame pacing jitter. The generation + // token retires this loop when a newer capture (restart or device switch) supersedes it. + private IEnumerator CaptureLoop(AudioClip clip, string device, int generation) { int clipFrames = clip.samples; int channels = clip.channels; @@ -161,12 +248,32 @@ private IEnumerator CaptureLoop(AudioClip clip) bool announced = false; long reportedDrops = 0; - while (_capturing && !_disposed) + // Device-loss detection: the position counter advances continuously while a device is + // alive (even in silence), so a stalled counter or IsRecording dropping to false means + // the device disappeared (e.g. a Bluetooth headset disconnected mid-call). + int lastCounter = Microphone.GetPosition(device); + double lastAdvance = clock.Elapsed.TotalSeconds; + + while (_capturing && !_disposed && generation == _captureGeneration) { yield return null; + int counter = Microphone.GetPosition(device); + double now = clock.Elapsed.TotalSeconds; + if (counter != lastCounter) + { + lastCounter = counter; + lastAdvance = now; + } + else if (now - lastAdvance > DeviceLostTimeoutSeconds || !Microphone.IsRecording(device)) + { + Utils.Warning($"MicrophoneSource: device '{device}' stopped delivering audio; attempting recovery"); + MonoBehaviourContext.RunCoroutine(RecoverRoutine(device, generation)); + yield break; + } + ranges.Clear(); - reader.Update(Microphone.GetPosition(_deviceName), clock.Elapsed.TotalSeconds, ranges); + reader.Update(counter, now, ranges); if (!announced && reader.Ready) { @@ -244,6 +351,8 @@ private IEnumerator StopMicrophone() private void OnApplicationPause(bool pause) { + _paused = pause; + if (!_started) return; From 46481e9c23c01fc6aafad5d1cd7906b2b3c49a49 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:40:09 +0200 Subject: [PATCH 2/4] Reduce FMOD recovery noise by waiting for device removal (keep GetDeviceCaps) The previous attempt also dropped Microphone.GetDeviceCaps and requested the target rate (48kHz) directly, on the assumption it was only a hint. It is not: requesting 48kHz makes Unity open the Bluetooth HFP mic in a different mode (clip 48kHz/96000, fragments ~901 of 2880) whose geometry our reconstruction doesn't handle, breaking the audio. The caps-clamped request (16kHz) opens the device in its native mode with the verified-good 320-of-1024 fragmentation. Restore GetDeviceCaps and keep only the safe part of the noise reduction: RecoverRoutine now waits (polling Microphone.devices, which doesn't initialize a device) for the lost device to leave the list before calling back into the audio subsystem. That defers GetDeviceCaps/Start until after the teardown has settled, cutting the FMOD "Failed to get recording driver capabilities" lines without changing what rate is requested - so capture behavior is unchanged. Co-Authored-By: Claude Fable 5 --- Runtime/Scripts/MicrophoneSource.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Runtime/Scripts/MicrophoneSource.cs b/Runtime/Scripts/MicrophoneSource.cs index dc0775b4..3349df5b 100644 --- a/Runtime/Scripts/MicrophoneSource.cs +++ b/Runtime/Scripts/MicrophoneSource.cs @@ -35,6 +35,7 @@ sealed public class MicrophoneSource : RtcAudioSource private const float MaxBacklogSeconds = 0.2f; // drop backlog beyond this after a stall private const float DeviceLostTimeoutSeconds = 1f; // no counter advance for this long = device gone private const float RecoverRetrySeconds = 1f; + private const float DeviceRemovalTimeoutSeconds = 2f; // wait up to this for a lost device to leave the list private string _deviceName; @@ -209,6 +210,23 @@ private IEnumerator RecoverRoutine(string lostDevice, int generation) if (Microphone.IsRecording(lostDevice)) Microphone.End(lostDevice); + // Wait for the OS to finish removing the lost device before re-entering the audio + // subsystem. Touching Microphone (GetDeviceCaps / Start) mid-teardown makes FMOD log + // "Failed to get recording driver capabilities"; once the device drops out of the list + // the subsystem has settled. Reading the device list itself does not initialize a + // device, so it stays quiet. The retry loop below still covers slower replacements. + if (!string.IsNullOrEmpty(lostDevice)) + { + float waited = 0f; + while (waited < DeviceRemovalTimeoutSeconds + && Array.IndexOf(Microphone.devices, lostDevice) >= 0 + && _started && !_disposed && !_paused && generation == _captureGeneration) + { + yield return new WaitForSeconds(0.1f); + waited += 0.1f; + } + } + while (_started && !_disposed && !_paused && generation == _captureGeneration) { var devices = Microphone.devices; From 9872e66ee1fbdf746fdb8914be396a5b968baa9e Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:14:32 +0200 Subject: [PATCH 3/4] Silence FMOD error 80 noise during mic device fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the active mic disappears mid-call (e.g. a Bluetooth headset powers off), RecoverRoutine waited only for the lost device to leave the list, then immediately started the fallback. The OS hadn't yet made the replacement default's recording driver startable, so the first Microphone.Start tripped FMOD error 80 (FMOD_ERR_UNSUPPORTED) and returned null — spamming the console even though the 1s retry loop eventually recovered. - Add a short settle delay before recovery's first start attempt so the new driver is ready; the first attempt now usually succeeds, so even FMOD's own native (80) log line stops firing. - Downgrade transient start failures to debug level during recovery (quietFailure), since they are expected and retried. Co-Authored-By: Claude Fable 5 --- Runtime/Scripts/MicrophoneSource.cs | 30 ++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/Runtime/Scripts/MicrophoneSource.cs b/Runtime/Scripts/MicrophoneSource.cs index 3349df5b..580caf1d 100644 --- a/Runtime/Scripts/MicrophoneSource.cs +++ b/Runtime/Scripts/MicrophoneSource.cs @@ -36,6 +36,7 @@ sealed public class MicrophoneSource : RtcAudioSource private const float DeviceLostTimeoutSeconds = 1f; // no counter advance for this long = device gone private const float RecoverRetrySeconds = 1f; private const float DeviceRemovalTimeoutSeconds = 2f; // wait up to this for a lost device to leave the list + private const float RecoverSettleSeconds = 0.3f; // let the replacement device's driver come up before starting it private string _deviceName; @@ -100,7 +101,14 @@ public override void Start() _started = true; } - private IEnumerator StartMicrophone() + // quietFailure: during automatic recovery a failed start is expected (the replacement + // device's driver may not be startable yet) and is retried, so transient failures are + // logged at debug level instead of error to avoid spamming the console mid-handoff. Note: + // when Microphone.Start itself fails, Unity's native FMOD layer still logs its own error + // (e.g. "Starting microphone failed ... (80)"); that line originates inside the engine and + // cannot be suppressed from here — the settle delay before recovery's first attempt is what + // reduces how often it fires. + private IEnumerator StartMicrophone(bool quietFailure = false) { // Verify microphone is still authorized (could change during background) if (!Application.HasUserAuthorization(UserAuthorization.Microphone)) @@ -126,13 +134,13 @@ private IEnumerator StartMicrophone() } catch (Exception e) { - Utils.Error($"MicrophoneSource: Exception starting microphone: {e.Message}"); + LogStartIssue(quietFailure, $"MicrophoneSource: Exception starting microphone: {e.Message}"); yield break; } if (clip == null) { - Utils.Error("MicrophoneSource: Microphone.Start returned null, audio session may not be ready"); + LogStartIssue(quietFailure, "MicrophoneSource: Microphone.Start returned null, audio session may not be ready"); yield break; } @@ -147,7 +155,7 @@ private IEnumerator StartMicrophone() if (Microphone.GetPosition(device) <= 0) { - Utils.Error($"MicrophoneSource: Microphone did not start producing data after {timeout}s"); + LogStartIssue(quietFailure, $"MicrophoneSource: Microphone did not start producing data after {timeout}s"); yield break; } @@ -157,6 +165,12 @@ private IEnumerator StartMicrophone() MonoBehaviourContext.RunCoroutine(CaptureLoop(clip, device, ++_captureGeneration)); } + private static void LogStartIssue(bool quiet, string msg) + { + if (quiet) Utils.Debug(msg); + else Utils.Error(msg); + } + /// /// Switches capture to a different microphone device while the published track keeps /// working. The native source's format is fixed (48kHz mono) and captured audio is @@ -225,6 +239,12 @@ private IEnumerator RecoverRoutine(string lostDevice, int generation) yield return new WaitForSeconds(0.1f); waited += 0.1f; } + + // The lost device has left the list, but the OS is still promoting the replacement + // default and FMOD has yet to make its recording driver startable. Starting now is + // what trips FMOD error 80; a brief settle lets the new driver come up so the first + // attempt usually succeeds rather than failing and retrying. + yield return new WaitForSeconds(RecoverSettleSeconds); } while (_started && !_disposed && !_paused && generation == _captureGeneration) @@ -236,7 +256,7 @@ private IEnumerator RecoverRoutine(string lostDevice, int generation) _deviceName = Array.IndexOf(devices, lostDevice) >= 0 ? lostDevice : null; int generationBefore = _captureGeneration; - yield return StartMicrophone(); + yield return StartMicrophone(quietFailure: true); if (_captureGeneration != generationBefore) { // A new CaptureLoop is running; recovery succeeded. From b60ba34eaf27695d06e64b897c27dd70cd409c44 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:31:45 +0200 Subject: [PATCH 4/4] Dedupe mic sample-rate constant and trim accidental complexity The native source rate lived in two places: MicrophoneSource.TargetSampleRate mirrored RtcAudioSource.DefaultSampleRate. RtcAudioSource already stores the configured rate, so expose it and make it the single source of truth. - RtcAudioSource: add protected ExpectedSampleRate/ExpectedChannels. - MicrophoneSource: drop the TargetSampleRate const; construct the native source with the shared DefaultSampleRate (48kHz on all platforms, so behavior is unchanged) and read ExpectedSampleRate everywhere else. The resample target is now tied to what the native source was configured with. - Cache the WaitForSeconds yield instructions instead of allocating per yield. - Extract a shared PollUntil(condition, timeout) coroutine for the two duplicated poll-with-timeout loops (RecoverRoutine's removal loop keeps its extra guard conditions and is left as-is). Pure refactor; no functional change. Co-Authored-By: Claude Fable 5 --- Runtime/Scripts/MicrophoneSource.cs | 60 +++++++++++++++++------------ Runtime/Scripts/RtcAudioSource.cs | 7 ++++ 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/Runtime/Scripts/MicrophoneSource.cs b/Runtime/Scripts/MicrophoneSource.cs index 580caf1d..53a92b6b 100644 --- a/Runtime/Scripts/MicrophoneSource.cs +++ b/Runtime/Scripts/MicrophoneSource.cs @@ -23,8 +23,8 @@ sealed public class MicrophoneSource : RtcAudioSource // contiguous stream is reconstructed from it. // // The clip's data rate is clip.frequency (verified: fragments play at correct pitch), so - // captured samples are resampled from clip.frequency to the fixed native-source rate. - private const uint TargetSampleRate = 48000; + // captured samples are resampled from clip.frequency to the native source's configured rate + // (RtcAudioSource.ExpectedSampleRate, set from the constructor below). private const float PreRollSeconds = 0.3f; private const float SettleSeconds = 0.1f; // discard the counter's startup burst before measuring // Engaging fragmented mode discards (stride - valid) samples per stride, so a false @@ -37,6 +37,14 @@ sealed public class MicrophoneSource : RtcAudioSource private const float RecoverRetrySeconds = 1f; private const float DeviceRemovalTimeoutSeconds = 2f; // wait up to this for a lost device to leave the list private const float RecoverSettleSeconds = 0.3f; // let the replacement device's driver come up before starting it + private const float PollIntervalSeconds = 0.05f; // cadence for PollUntil readiness checks + + // Cached yield instructions; WaitForSeconds is immutable, so reusing one avoids a per-yield + // allocation (and silences the analyzer hint). + private static readonly WaitForSeconds WaitPoll = new(PollIntervalSeconds); + private static readonly WaitForSeconds WaitDeviceRemoval = new(0.1f); + private static readonly WaitForSeconds WaitRecoverSettle = new(RecoverSettleSeconds); + private static readonly WaitForSeconds WaitRecoverRetry = new(RecoverRetrySeconds); private string _deviceName; @@ -62,19 +70,19 @@ sealed public class MicrophoneSource : RtcAudioSource /// Unused; retained for compatibility. The microphone clip is read /// directly, so no scene GameObject/AudioSource is required. public MicrophoneSource(string deviceName, GameObject sourceObject) - : base(RtcAudioSourceType.AudioSourceMicrophone, TargetSampleRate, 1) + : base(RtcAudioSourceType.AudioSourceMicrophone, DefaultSampleRate, 1) { _deviceName = deviceName; } // The rate requested from Microphone.Start (a hint the platform may not honor), clamped to // the device's reported range. The authoritative data rate is clip.frequency afterwards. - private static int ResolveRequestedSampleRate(string deviceName) + private int ResolveRequestedSampleRate(string deviceName) { Microphone.GetDeviceCaps(deviceName, out int minFreq, out int maxFreq); if (minFreq == 0 && maxFreq == 0) - return (int)TargetSampleRate; - return Mathf.Clamp((int)TargetSampleRate, minFreq, maxFreq); + return (int)ExpectedSampleRate; + return Mathf.Clamp((int)ExpectedSampleRate, minFreq, maxFreq); } /// @@ -146,12 +154,7 @@ private IEnumerator StartMicrophone(bool quietFailure = false) // Wait for microphone to actually start producing data with a timeout const float timeout = 2f; - float elapsed = 0f; - while (Microphone.GetPosition(device) <= 0 && elapsed < timeout) - { - yield return new WaitForSeconds(0.05f); - elapsed += 0.05f; - } + yield return PollUntil(() => Microphone.GetPosition(device) > 0, timeout); if (Microphone.GetPosition(device) <= 0) { @@ -159,7 +162,7 @@ private IEnumerator StartMicrophone(bool quietFailure = false) yield break; } - Utils.Info($"MicrophoneSource device='{device}' clip={clip.frequency}Hz/{clip.channels}ch samples={clip.samples} requested={requestedRate}Hz target={TargetSampleRate}Hz"); + Utils.Info($"MicrophoneSource device='{device}' clip={clip.frequency}Hz/{clip.channels}ch samples={clip.samples} requested={requestedRate}Hz target={ExpectedSampleRate}Hz"); _capturing = true; MonoBehaviourContext.RunCoroutine(CaptureLoop(clip, device, ++_captureGeneration)); @@ -171,6 +174,18 @@ private static void LogStartIssue(bool quiet, string msg) else Utils.Error(msg); } + // Polls a condition every PollIntervalSeconds until it holds or the timeout elapses. + // Callers re-check the condition afterwards to distinguish success from timeout. + private IEnumerator PollUntil(Func done, float timeout) + { + float elapsed = 0f; + while (!done() && elapsed < timeout) + { + yield return WaitPoll; + elapsed += PollIntervalSeconds; + } + } + /// /// Switches capture to a different microphone device while the published track keeps /// working. The native source's format is fixed (48kHz mono) and captured audio is @@ -236,7 +251,7 @@ private IEnumerator RecoverRoutine(string lostDevice, int generation) && Array.IndexOf(Microphone.devices, lostDevice) >= 0 && _started && !_disposed && !_paused && generation == _captureGeneration) { - yield return new WaitForSeconds(0.1f); + yield return WaitDeviceRemoval; waited += 0.1f; } @@ -244,7 +259,7 @@ private IEnumerator RecoverRoutine(string lostDevice, int generation) // default and FMOD has yet to make its recording driver startable. Starting now is // what trips FMOD error 80; a brief settle lets the new driver come up so the first // attempt usually succeeds rather than failing and retrying. - yield return new WaitForSeconds(RecoverSettleSeconds); + yield return WaitRecoverSettle; } while (_started && !_disposed && !_paused && generation == _captureGeneration) @@ -264,7 +279,7 @@ private IEnumerator RecoverRoutine(string lostDevice, int generation) yield break; } } - yield return new WaitForSeconds(RecoverRetrySeconds); + yield return WaitRecoverRetry; } } @@ -280,7 +295,7 @@ private IEnumerator CaptureLoop(AudioClip clip, string device, int generation) int dataRate = clip.frequency > 0 ? clip.frequency : (int)DefaultMicrophoneSampleRate; var reader = new MicClipReader(clipFrames, dataRate, PreRollSeconds, FragmentedKThreshold, MaxBacklogSeconds, SettleSeconds); - _resampler = new StreamingResampler(dataRate, (int)TargetSampleRate); + _resampler = new StreamingResampler(dataRate, (int)ExpectedSampleRate); var ranges = new List(); var clock = System.Diagnostics.Stopwatch.StartNew(); bool announced = false; @@ -334,7 +349,7 @@ private IEnumerator CaptureLoop(AudioClip clip, string device, int generation) } // Reads a contiguous range, downmixes to mono, resamples clip.frequency -> - // TargetSampleRate (the resampler carries state across calls, so fragment junctions stay + // ExpectedSampleRate (the resampler carries state across calls, so fragment junctions stay // continuous), and fires AudioRead. private void ReadAndPush(AudioClip clip, int channels, int start, int count) { @@ -362,7 +377,7 @@ private void ReadAndPush(AudioClip clip, int channels, int start, int count) var output = _resampler.Process(mono, count); if (output.Length > 0) - AudioRead?.Invoke(output, 1, (int)TargetSampleRate); + AudioRead?.Invoke(output, 1, (int)ExpectedSampleRate); } /// @@ -424,15 +439,10 @@ private IEnumerator WaitForMicrophoneReady() // Wait for microphone devices to become available again after iOS audio session interruption. // This is more reliable than a fixed delay because we wait for actual system readiness. const float timeout = 2f; - float elapsed = 0f; // On iOS, Microphone.devices may be empty immediately after resume while // AVAudioSession is recovering from interruption. Wait until devices are available. - while (Microphone.devices.Length == 0 && elapsed < timeout) - { - yield return new WaitForSeconds(0.05f); - elapsed += 0.05f; - } + yield return PollUntil(() => Microphone.devices.Length > 0, timeout); if (Microphone.devices.Length == 0) { diff --git a/Runtime/Scripts/RtcAudioSource.cs b/Runtime/Scripts/RtcAudioSource.cs index 43f5c102..9876744b 100644 --- a/Runtime/Scripts/RtcAudioSource.cs +++ b/Runtime/Scripts/RtcAudioSource.cs @@ -63,6 +63,13 @@ private sealed class PendingAudioFrame private readonly uint _expectedSampleRate; private readonly uint _expectedChannels; + /// The sample rate the native source was configured with; the format captured + /// audio must match (subclasses resample to this). + protected uint ExpectedSampleRate => _expectedSampleRate; + + /// The channel count the native source was configured with. + protected uint ExpectedChannels => _expectedChannels; + internal readonly FfiHandle Handle; protected AudioSourceInfo _info;