From 61083e63033a2df4fefc8af812f203ada534b306 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:45:54 +0200 Subject: [PATCH 1/5] Configure native audio source from device, not hardcoded defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native (Rust) audio source was created with a hardcoded sample rate (48000) and channel count (2). Microphone frames flow through Unity's audio graph (AudioProbe) at the actual DSP output configuration, which often differs — e.g. with a Bluetooth headset. The Rust source does not resample; it rejects frames whose rate/channels don't match, causing the metadata-mismatch warning and capture failures. Read the source's sample rate and channel count from Unity's output configuration (AudioSettings.GetConfiguration) instead of hardcoded defaults, falling back to the defaults only when Unity can't report one. The base constructor now exposes a device-mode overload (type only) and an explicit overload (type, sampleRate, channels) for sources that generate a fixed format. MicrophoneSource and BasicAudioSource use device mode; BasicAudioSource drops its unused channels parameter. SineWaveAudioSource declares its exact format. If a frame's format still doesn't match (inconsistent Unity report or a runtime output change), drop it with a throttled warning instead of sending a mismatch the native side would error on. Also removes the redundant Microphone.Start in the Meet sample. Co-Authored-By: Claude Opus 4.8 (1M context) Remove logging changes --- Runtime/Scripts/BasicAudioSource.cs | 6 +- Runtime/Scripts/MicrophoneSource.cs | 2 +- Runtime/Scripts/RtcAudioSource.cs | 72 ++++++++++++++++++--- Samples~/Meet/Assets/Runtime/MeetManager.cs | 3 +- Tests/PlayMode/Utils/SineWaveAudioSource.cs | 2 +- 5 files changed, 71 insertions(+), 14 deletions(-) diff --git a/Runtime/Scripts/BasicAudioSource.cs b/Runtime/Scripts/BasicAudioSource.cs index 3b63680b..8193090d 100644 --- a/Runtime/Scripts/BasicAudioSource.cs +++ b/Runtime/Scripts/BasicAudioSource.cs @@ -19,9 +19,11 @@ sealed public class BasicAudioSource : RtcAudioSource /// Creates a new basic audio source for the given in the scene. /// /// The to capture from. - /// The number of channels to capture. /// The type of audio source. - public BasicAudioSource(AudioSource source, int channels = 2, RtcAudioSourceType sourceType = RtcAudioSourceType.AudioSourceCustom) : base(channels, sourceType) + /// + /// The sample rate and channel count are taken from Unity's audio configuration. + /// + public BasicAudioSource(AudioSource source, RtcAudioSourceType sourceType = RtcAudioSourceType.AudioSourceCustom) : base(sourceType) { _source = source; } diff --git a/Runtime/Scripts/MicrophoneSource.cs b/Runtime/Scripts/MicrophoneSource.cs index 904b8da7..a8775568 100644 --- a/Runtime/Scripts/MicrophoneSource.cs +++ b/Runtime/Scripts/MicrophoneSource.cs @@ -28,7 +28,7 @@ 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(2, RtcAudioSourceType.AudioSourceMicrophone) + public MicrophoneSource(string deviceName, GameObject sourceObject) : base(RtcAudioSourceType.AudioSourceMicrophone) { _deviceName = deviceName; _sourceObject = sourceObject; diff --git a/Runtime/Scripts/RtcAudioSource.cs b/Runtime/Scripts/RtcAudioSource.cs index a9af8a0a..7de3f61c 100644 --- a/Runtime/Scripts/RtcAudioSource.cs +++ b/Runtime/Scripts/RtcAudioSource.cs @@ -83,20 +83,33 @@ private sealed class PendingAudioFrame private volatile bool _disposed = false; private int _audioReadCount = 0; - protected RtcAudioSource(int channels = 2, RtcAudioSourceType audioSourceType = RtcAudioSourceType.AudioSourceCustom) + // Device-capture sources (microphone, AudioSource taps) don't know their format ahead of + // time — it is whatever Unity's audio graph delivers. They use this constructor, which + // configures the native source from Unity's current output configuration. + protected RtcAudioSource(RtcAudioSourceType audioSourceType) + : this(audioSourceType, 0, 0) { } + + // Sources that generate a fixed, known format (e.g. test signal generators) declare it + // directly. Passing 0 for either value falls back to the device configuration. + protected RtcAudioSource(RtcAudioSourceType audioSourceType, uint sampleRate, uint channels) { _sourceType = audioSourceType; - _expectedChannels = (uint)channels; + + if (sampleRate > 0 && channels > 0) + { + _expectedSampleRate = sampleRate; + _expectedChannels = channels; + } + else + { + (_expectedSampleRate, _expectedChannels) = ResolveDeviceFormat(); + } using var request = FFIBridge.Instance.NewRequest(); var newAudioSource = request.request; newAudioSource.Type = AudioSourceType.AudioSourceNative; - newAudioSource.NumChannels = (uint)channels; - newAudioSource.SampleRate = _sourceType == RtcAudioSourceType.AudioSourceMicrophone ? - DefaultMicrophoneSampleRate : DefaultSampleRate; - _expectedSampleRate = newAudioSource.SampleRate; - - Utils.Debug($"NewAudioSource: {newAudioSource.NumChannels} {newAudioSource.SampleRate}"); + newAudioSource.NumChannels = _expectedChannels; + newAudioSource.SampleRate = _expectedSampleRate; newAudioSource.Options = request.TempResource(); newAudioSource.Options.EchoCancellation = true; @@ -109,6 +122,49 @@ protected RtcAudioSource(int channels = 2, RtcAudioSourceType audioSourceType = Utils.Debug($"{DebugTag} created handle={Handle.DangerousGetHandle()} expectedRate={_expectedSampleRate} expectedChannels={_expectedChannels} sourceType={_sourceType}"); } + // Reads Unity's actual output audio configuration. The capture path delivers buffers at the + // DSP output rate/channel count (see AudioProbe), so this is the format the native source + // must match. Falls back to the platform defaults when Unity cannot report a configuration + // (e.g. batch mode without an audio device). + private (uint sampleRate, uint channels) ResolveDeviceFormat() + { + uint sampleRate = _sourceType == RtcAudioSourceType.AudioSourceMicrophone + ? DefaultMicrophoneSampleRate + : DefaultSampleRate; + uint channels = DefaultChannels; + + try + { + var config = UnityEngine.AudioSettings.GetConfiguration(); + if (config.sampleRate > 0) + sampleRate = (uint)config.sampleRate; + var configuredChannels = SpeakerModeChannels(config.speakerMode); + if (configuredChannels > 0) + channels = configuredChannels; + } + catch (Exception e) + { + Utils.Warning($"{DebugTag} could not read Unity audio configuration, using defaults: {e.Message}"); + } + + return (sampleRate, channels); + } + + private static uint SpeakerModeChannels(UnityEngine.AudioSpeakerMode mode) + { + switch (mode) + { + case UnityEngine.AudioSpeakerMode.Mono: return 1; + case UnityEngine.AudioSpeakerMode.Stereo: return 2; + case UnityEngine.AudioSpeakerMode.Quad: return 4; + case UnityEngine.AudioSpeakerMode.Surround: return 5; + case UnityEngine.AudioSpeakerMode.Mode5point1: return 6; + case UnityEngine.AudioSpeakerMode.Mode7point1: return 8; + case UnityEngine.AudioSpeakerMode.Prologic: return 2; + default: return 0; + } + } + /// /// Begin capturing audio samples from the underlying source. /// diff --git a/Samples~/Meet/Assets/Runtime/MeetManager.cs b/Samples~/Meet/Assets/Runtime/MeetManager.cs index 225c7a0c..97b2cb70 100644 --- a/Samples~/Meet/Assets/Runtime/MeetManager.cs +++ b/Samples~/Meet/Assets/Runtime/MeetManager.cs @@ -453,8 +453,7 @@ private IEnumerator PublishLocalMicrophone() { if (_audioObjects.ContainsKey(LocalAudioTrackName)) yield break; - Microphone.Start(null, true, 10, 44100); - + // MicrophoneSource starts the device itself, so we only need the device name here. var audioObject = new GameObject($"My Microphone: {Microphone.devices[0]}"); audioObject.transform.SetParent(_audioTrackParent); diff --git a/Tests/PlayMode/Utils/SineWaveAudioSource.cs b/Tests/PlayMode/Utils/SineWaveAudioSource.cs index 907e9ccc..2337615b 100644 --- a/Tests/PlayMode/Utils/SineWaveAudioSource.cs +++ b/Tests/PlayMode/Utils/SineWaveAudioSource.cs @@ -31,7 +31,7 @@ public SineWaveAudioSource( int sampleRate = 48000, double frequencyHz = 440.0, float amplitude = 0.1f) - : base(channels, RtcAudioSourceType.AudioSourceCustom) + : base(RtcAudioSourceType.AudioSourceCustom, (uint)sampleRate, (uint)channels) { _channels = channels; _sampleRate = sampleRate; From 710601c15b978199fe154a928e081c499143fa5e Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:14:01 +0200 Subject: [PATCH 2/5] Adds microphone start again for iOS, adds a debug log --- Runtime/Scripts/RtcAudioSource.cs | 2 ++ Samples~/Meet/Assets/Runtime/MeetManager.cs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Runtime/Scripts/RtcAudioSource.cs b/Runtime/Scripts/RtcAudioSource.cs index 7de3f61c..85cead4c 100644 --- a/Runtime/Scripts/RtcAudioSource.cs +++ b/Runtime/Scripts/RtcAudioSource.cs @@ -141,6 +141,8 @@ protected RtcAudioSource(RtcAudioSourceType audioSourceType, uint sampleRate, ui var configuredChannels = SpeakerModeChannels(config.speakerMode); if (configuredChannels > 0) channels = configuredChannels; + + Utils.Info($"Configured native audio source with sampleRate {sampleRate} and channels {channels}"); } catch (Exception e) { diff --git a/Samples~/Meet/Assets/Runtime/MeetManager.cs b/Samples~/Meet/Assets/Runtime/MeetManager.cs index 97b2cb70..179f697c 100644 --- a/Samples~/Meet/Assets/Runtime/MeetManager.cs +++ b/Samples~/Meet/Assets/Runtime/MeetManager.cs @@ -453,7 +453,9 @@ private IEnumerator PublishLocalMicrophone() { if (_audioObjects.ContainsKey(LocalAudioTrackName)) yield break; - // MicrophoneSource starts the device itself, so we only need the device name here. + // Start the microphone here for early iOS permission request + Microphone.Start(null, true, 10, 44100); + var audioObject = new GameObject($"My Microphone: {Microphone.devices[0]}"); audioObject.transform.SetParent(_audioTrackParent); From 2a262653db42452d2929f01bd22c810c6ca27193 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:33:28 +0200 Subject: [PATCH 3/5] Trust Unity audio selection, use sample rate detected in mic start --- Runtime/Scripts/MicrophoneSource.cs | 2 +- Runtime/Scripts/RtcAudioSource.cs | 39 ++++----------------- Samples~/Meet/Assets/Runtime/MeetManager.cs | 2 +- 3 files changed, 9 insertions(+), 34 deletions(-) diff --git a/Runtime/Scripts/MicrophoneSource.cs b/Runtime/Scripts/MicrophoneSource.cs index a8775568..34687f88 100644 --- a/Runtime/Scripts/MicrophoneSource.cs +++ b/Runtime/Scripts/MicrophoneSource.cs @@ -82,7 +82,7 @@ private IEnumerator StartMicrophone() _deviceName, loop: true, lengthSec: 1, - frequency: (int)DefaultMicrophoneSampleRate + frequency: (int)_expectedSampleRate ); } catch (Exception e) diff --git a/Runtime/Scripts/RtcAudioSource.cs b/Runtime/Scripts/RtcAudioSource.cs index 85cead4c..9a80b99e 100644 --- a/Runtime/Scripts/RtcAudioSource.cs +++ b/Runtime/Scripts/RtcAudioSource.cs @@ -46,22 +46,11 @@ private sealed class PendingAudioFrame /// public abstract event Action AudioRead; -#if UNITY_IOS && !UNITY_EDITOR - // iOS microphone sample rate is 24k - public static uint DefaultMicrophoneSampleRate = 24000; - - public static uint DefaultSampleRate = 48000; -#else - public static uint DefaultSampleRate = 48000; - public static uint DefaultMicrophoneSampleRate = DefaultSampleRate; -#endif - public static uint DefaultChannels = 2; - private readonly RtcAudioSourceType _sourceType; public RtcAudioSourceType SourceType => _sourceType; private readonly int _debugId = Interlocked.Increment(ref nextDebugId); - private readonly uint _expectedSampleRate; - private readonly uint _expectedChannels; + internal readonly uint _expectedSampleRate; + internal readonly uint _expectedChannels; internal readonly FfiHandle Handle; protected AudioSourceInfo _info; @@ -128,26 +117,12 @@ protected RtcAudioSource(RtcAudioSourceType audioSourceType, uint sampleRate, ui // (e.g. batch mode without an audio device). private (uint sampleRate, uint channels) ResolveDeviceFormat() { - uint sampleRate = _sourceType == RtcAudioSourceType.AudioSourceMicrophone - ? DefaultMicrophoneSampleRate - : DefaultSampleRate; - uint channels = DefaultChannels; + var config = UnityEngine.AudioSettings.GetConfiguration(); + var sampleRate = (uint)config.sampleRate; + var configuredChannels = SpeakerModeChannels(config.speakerMode); + var channels = configuredChannels; - try - { - var config = UnityEngine.AudioSettings.GetConfiguration(); - if (config.sampleRate > 0) - sampleRate = (uint)config.sampleRate; - var configuredChannels = SpeakerModeChannels(config.speakerMode); - if (configuredChannels > 0) - channels = configuredChannels; - - Utils.Info($"Configured native audio source with sampleRate {sampleRate} and channels {channels}"); - } - catch (Exception e) - { - Utils.Warning($"{DebugTag} could not read Unity audio configuration, using defaults: {e.Message}"); - } + Utils.Info($"Configured native audio source with sampleRate {sampleRate} and channels {channels}"); return (sampleRate, channels); } diff --git a/Samples~/Meet/Assets/Runtime/MeetManager.cs b/Samples~/Meet/Assets/Runtime/MeetManager.cs index 179f697c..c024b973 100644 --- a/Samples~/Meet/Assets/Runtime/MeetManager.cs +++ b/Samples~/Meet/Assets/Runtime/MeetManager.cs @@ -453,7 +453,7 @@ private IEnumerator PublishLocalMicrophone() { if (_audioObjects.ContainsKey(LocalAudioTrackName)) yield break; - // Start the microphone here for early iOS permission request + // Start the microphone here for early iOS permission request and android getting access to Microphone.devices Microphone.Start(null, true, 10, 44100); var audioObject = new GameObject($"My Microphone: {Microphone.devices[0]}"); From d19145d283f9ba6841ed250ba1e5d5b2f27d2576 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:00:15 +0200 Subject: [PATCH 4/5] Restart microphone capture on audio device change When a capture device disappears mid-call (e.g. unplugging a Bluetooth headset), the local microphone went silent and never recovered: MicrophoneSource stayed bound to the gone device name and never re-registered the AudioProbe tap that Unity detaches when it rebuilds its audio graph. Subscribe to AudioSettings.OnAudioConfigurationChanged (mirroring the playback-side AudioStream handler) and restart capture on a device change, resolving to the OS default device when the preferred device is no longer present. Track the active device separately from the preferred one so Microphone.IsRecording/GetPosition/End target the right device, and guard against overlapping restarts. The native source's rate is fixed at construction, so if the device change moves Unity's DSP output rate, frames are still dropped; warn clearly in that case. Full rate-change recovery follows separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- Runtime/Scripts/MicrophoneSource.cs | 80 ++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/Runtime/Scripts/MicrophoneSource.cs b/Runtime/Scripts/MicrophoneSource.cs index 34687f88..6b30900c 100644 --- a/Runtime/Scripts/MicrophoneSource.cs +++ b/Runtime/Scripts/MicrophoneSource.cs @@ -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 AudioRead; private bool _disposed = false; private bool _started = false; + private bool _restarting = false; /// /// Creates a new microphone source for the given device. @@ -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; @@ -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 @@ -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 ?? ""}' started successfully"); } /// @@ -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) @@ -170,7 +188,7 @@ private IEnumerator StopMicrophone() UnityEngine.Object.Destroy(source); } - Utils.Debug($"MicrophoneSource device='{_deviceName}' stopped"); + Utils.Debug($"MicrophoneSource device='{_activeDeviceName ?? ""}' stopped"); yield return null; } @@ -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."); + } + + 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. @@ -207,6 +271,8 @@ private IEnumerator RestartMicrophone() yield return WaitForMicrophoneReady(); yield return StartMicrophone(); + + _restarting = false; } private IEnumerator WaitForMicrophoneReady() From 817b1a63c554ba34e7161b26bb26916ca5804b94 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:04:58 +0200 Subject: [PATCH 5/5] Recreate audio source and republish track on sample-rate change Commit 1 restarts capture on a device change but the native source's rate stays fixed at construction, so a device whose rate differs from the original silently has every frame dropped. Add RtcAudioSource.Reconfigure(sampleRate, channels): it disposes the old native handle, rebuilds the source at the new format, and raises a new FormatChanged event. The native source stays alive via the track's reference until the track is dropped, so disposing the handle before the old track is unpublished is safe. The FFI exposes no in-place source reconfigure, so a fresh source (and re-bound track) is required. MicrophoneSource detects the format change via ResolveDeviceFormat in its config-changed handler and calls Reconfigure inside the restart while capture is paused (no AudioRead callbacks in flight). MeetManager subscribes to FormatChanged and republishes the local audio track against the source's new handle. Co-Authored-By: Claude Opus 4.8 (1M context) --- Runtime/Scripts/MicrophoneSource.cs | 29 +++++++---- Runtime/Scripts/RtcAudioSource.cs | 56 +++++++++++++++++++-- Samples~/Meet/Assets/Runtime/MeetManager.cs | 41 +++++++++++++++ 3 files changed, 112 insertions(+), 14 deletions(-) diff --git a/Runtime/Scripts/MicrophoneSource.cs b/Runtime/Scripts/MicrophoneSource.cs index 6b30900c..25626da4 100644 --- a/Runtime/Scripts/MicrophoneSource.cs +++ b/Runtime/Scripts/MicrophoneSource.cs @@ -238,24 +238,27 @@ 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) + // The native source rejects frames whose rate/channels don't match how it was + // created. If the device change moved Unity's output format, the source must be + // recreated at the new format (and its track re-bound) — otherwise restarting capture + // alone won't recover audio. RtcAudioSource.Reconfigure handles the recreation; we + // run it inside the restart while capture is paused. + var (newRate, newChannels) = ResolveDeviceFormat(); + bool formatChanged = newRate != _expectedSampleRate || newChannels != _expectedChannels; + + if (formatChanged) { - 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."); + Utils.Debug($"MicrophoneSource: DSP format changed to {newRate}/{newChannels}, recreating native source and restarting capture"); + MonoBehaviourContext.RunCoroutine(RestartMicrophone(newRate, newChannels)); } - - if (deviceWasChanged) + else if (deviceWasChanged) { Utils.Debug("MicrophoneSource: audio device changed, restarting capture on the current default device"); MonoBehaviourContext.RunCoroutine(RestartMicrophone()); } } - private IEnumerator RestartMicrophone() + private IEnumerator RestartMicrophone(uint reconfigureRate = 0, uint reconfigureChannels = 0) { // 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. @@ -265,6 +268,12 @@ private IEnumerator RestartMicrophone() yield return StopMicrophone(); + // With capture stopped (no AudioRead callbacks in flight), it's safe to recreate the + // native source at the new format. This raises FormatChanged so the owning track is + // re-bound to the new handle. + if (reconfigureRate > 0 && reconfigureChannels > 0) + Reconfigure(reconfigureRate, reconfigureChannels); + // Wait for iOS audio session to be ready before attempting to restart. // On iOS, after app resumes from background, the audio session needs time to // recover from interruption. Poll for readiness instead of using arbitrary delay. diff --git a/Runtime/Scripts/RtcAudioSource.cs b/Runtime/Scripts/RtcAudioSource.cs index 9a80b99e..f9e22b98 100644 --- a/Runtime/Scripts/RtcAudioSource.cs +++ b/Runtime/Scripts/RtcAudioSource.cs @@ -49,12 +49,22 @@ private sealed class PendingAudioFrame private readonly RtcAudioSourceType _sourceType; public RtcAudioSourceType SourceType => _sourceType; private readonly int _debugId = Interlocked.Increment(ref nextDebugId); - internal readonly uint _expectedSampleRate; - internal readonly uint _expectedChannels; - internal readonly FfiHandle Handle; + // The format the native source is configured for. Mutable because Reconfigure() can + // recreate the source at a new format when the audio device's rate/channels change. + internal uint _expectedSampleRate; + internal uint _expectedChannels; + + internal FfiHandle Handle; protected AudioSourceInfo _info; + /// + /// Raised after the native audio source has been recreated at a new format (see + /// ). The source's changes, so any track + /// bound to the previous handle must be recreated against the new one. + /// + public event Action FormatChanged; + // CaptureAudioFrame is asynchronous: the native side can continue reading from the PCM // pointer after request.Send() returns and encode it later on another queue. Because of // that, a single reusable NativeArray is unsafe here; the next AudioRead callback can @@ -94,6 +104,14 @@ protected RtcAudioSource(RtcAudioSourceType audioSourceType, uint sampleRate, ui (_expectedSampleRate, _expectedChannels) = ResolveDeviceFormat(); } + CreateNativeSource(); + } + + // Creates the native FFI audio source for the current _expectedSampleRate/_expectedChannels + // and stores its handle. Called once from the constructor and again from Reconfigure() when + // the format changes. + private void CreateNativeSource() + { using var request = FFIBridge.Instance.NewRequest(); var newAudioSource = request.request; newAudioSource.Type = AudioSourceType.AudioSourceNative; @@ -111,11 +129,41 @@ protected RtcAudioSource(RtcAudioSourceType audioSourceType, uint sampleRate, ui Utils.Debug($"{DebugTag} created handle={Handle.DangerousGetHandle()} expectedRate={_expectedSampleRate} expectedChannels={_expectedChannels} sourceType={_sourceType}"); } + /// + /// Recreates the native audio source at a new format. The Rust FFI source does not + /// resample and rejects frames whose rate/channels differ from how it was created, so when + /// the capture device moves Unity's output format we must build a fresh source. + /// + /// + /// Must be called while capture is paused (no callbacks in flight), + /// because it disposes and replaces . Raises + /// on success so the owner can re-bind any track to the new handle. + /// + /// True if the source was recreated; false if the format was unchanged or invalid. + public bool Reconfigure(uint sampleRate, uint channels) + { + if (_disposed) return false; + if (sampleRate == 0 || channels == 0) return false; + if (sampleRate == _expectedSampleRate && channels == _expectedChannels) return false; + + Utils.Debug($"{DebugTag} reconfigure {_expectedSampleRate}/{_expectedChannels} -> {sampleRate}/{channels}"); + + // The native source stays alive as long as a track references it, so disposing our + // handle here is safe even before the old track is unpublished. + Handle?.Dispose(); + _expectedSampleRate = sampleRate; + _expectedChannels = channels; + CreateNativeSource(); + + FormatChanged?.Invoke(); + return true; + } + // Reads Unity's actual output audio configuration. The capture path delivers buffers at the // DSP output rate/channel count (see AudioProbe), so this is the format the native source // must match. Falls back to the platform defaults when Unity cannot report a configuration // (e.g. batch mode without an audio device). - private (uint sampleRate, uint channels) ResolveDeviceFormat() + protected (uint sampleRate, uint channels) ResolveDeviceFormat() { var config = UnityEngine.AudioSettings.GetConfiguration(); var sampleRate = (uint)config.sampleRate; diff --git a/Samples~/Meet/Assets/Runtime/MeetManager.cs b/Samples~/Meet/Assets/Runtime/MeetManager.cs index c024b973..241e47aa 100644 --- a/Samples~/Meet/Assets/Runtime/MeetManager.cs +++ b/Samples~/Meet/Assets/Runtime/MeetManager.cs @@ -477,14 +477,53 @@ private IEnumerator PublishLocalMicrophone() _microphoneActive = true; _audioObjects[LocalAudioTrackName] = audioObject; _localRtcAudioSource = rtcSource; + // When the capture device changes to one with a different sample rate, the source + // recreates its native handle; re-bind the published track to the new handle. + rtcSource.FormatChanged += OnLocalMicrophoneFormatChanged; rtcSource.Start(); if (_participantTiles.TryGetValue(_localId, out var tile)) tile.SetMicMuted(false); } + // Raised (on the main thread) after the local microphone source recreated its native handle + // at a new format. The old track is bound to the now-disposed handle, so republish. + private void OnLocalMicrophoneFormatChanged() + { + StartCoroutine(RepublishLocalMicrophone()); + } + + private IEnumerator RepublishLocalMicrophone() + { + if (_localRtcAudioSource == null || _room == null) yield break; + + if (_localAudioTrack != null) + { + _room.LocalParticipant.UnpublishTrack(_localAudioTrack, false); + _localAudioTrack = null; + } + + _localAudioTrack = LocalAudioTrack.CreateAudioTrack(LocalAudioTrackName, _localRtcAudioSource, _room); + + var options = new TrackPublishOptions + { + AudioEncoding = new AudioEncoding { MaxBitrate = 64000 }, + Source = TrackSource.SourceMicrophone + }; + + var publish = _room.LocalParticipant.PublishTrack(_localAudioTrack, options); + yield return publish; + + if (publish.IsError) + Debug.LogError("Failed to republish local microphone after format change"); + else + Debug.Log("Republished local microphone track after audio format change"); + } + private void UnpublishLocalMicrophone() { + if (_localRtcAudioSource != null) + _localRtcAudioSource.FormatChanged -= OnLocalMicrophoneFormatChanged; DisposeSource(ref _localRtcAudioSource); if (_audioObjects.TryGetValue(LocalAudioTrackName, out var obj)) @@ -562,6 +601,8 @@ private static void DisposeSource(ref T source) where T : class, System.IDisp private void CleanUpAllTracks() { + if (_localRtcAudioSource != null) + _localRtcAudioSource.FormatChanged -= OnLocalMicrophoneFormatChanged; DisposeSource(ref _localRtcAudioSource); DisposeSource(ref _localRtcVideoSource);