Skip to content

Commit 817b1a6

Browse files
MaxHeimbrockclaude
andcommitted
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) <noreply@anthropic.com>
1 parent d19145d commit 817b1a6

3 files changed

Lines changed: 112 additions & 14 deletions

File tree

Runtime/Scripts/MicrophoneSource.cs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -238,24 +238,27 @@ private void OnAudioConfigurationChanged(bool deviceWasChanged)
238238
if (!_started)
239239
return;
240240

241-
// The native source's rate is fixed at construction and RtcAudioSource drops frames
242-
// whose rate doesn't match it. If the device change moved Unity's DSP output rate,
243-
// restarting capture alone won't recover audio — warn so the silence is diagnosable.
244-
// Full recovery (recreating the native source at the new rate) is handled separately.
245-
var outputSampleRate = (uint)AudioSettings.outputSampleRate;
246-
if (outputSampleRate != _expectedSampleRate)
241+
// The native source rejects frames whose rate/channels don't match how it was
242+
// created. If the device change moved Unity's output format, the source must be
243+
// recreated at the new format (and its track re-bound) — otherwise restarting capture
244+
// alone won't recover audio. RtcAudioSource.Reconfigure handles the recreation; we
245+
// run it inside the restart while capture is paused.
246+
var (newRate, newChannels) = ResolveDeviceFormat();
247+
bool formatChanged = newRate != _expectedSampleRate || newChannels != _expectedChannels;
248+
249+
if (formatChanged)
247250
{
248-
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.");
251+
Utils.Debug($"MicrophoneSource: DSP format changed to {newRate}/{newChannels}, recreating native source and restarting capture");
252+
MonoBehaviourContext.RunCoroutine(RestartMicrophone(newRate, newChannels));
249253
}
250-
251-
if (deviceWasChanged)
254+
else if (deviceWasChanged)
252255
{
253256
Utils.Debug("MicrophoneSource: audio device changed, restarting capture on the current default device");
254257
MonoBehaviourContext.RunCoroutine(RestartMicrophone());
255258
}
256259
}
257260

258-
private IEnumerator RestartMicrophone()
261+
private IEnumerator RestartMicrophone(uint reconfigureRate = 0, uint reconfigureChannels = 0)
259262
{
260263
// The device-change event can fire several times around a single hardware swap;
261264
// ignore re-entrant restarts so overlapping Stop/Start coroutines don't race.
@@ -265,6 +268,12 @@ private IEnumerator RestartMicrophone()
265268

266269
yield return StopMicrophone();
267270

271+
// With capture stopped (no AudioRead callbacks in flight), it's safe to recreate the
272+
// native source at the new format. This raises FormatChanged so the owning track is
273+
// re-bound to the new handle.
274+
if (reconfigureRate > 0 && reconfigureChannels > 0)
275+
Reconfigure(reconfigureRate, reconfigureChannels);
276+
268277
// Wait for iOS audio session to be ready before attempting to restart.
269278
// On iOS, after app resumes from background, the audio session needs time to
270279
// recover from interruption. Poll for readiness instead of using arbitrary delay.

Runtime/Scripts/RtcAudioSource.cs

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,22 @@ private sealed class PendingAudioFrame
4949
private readonly RtcAudioSourceType _sourceType;
5050
public RtcAudioSourceType SourceType => _sourceType;
5151
private readonly int _debugId = Interlocked.Increment(ref nextDebugId);
52-
internal readonly uint _expectedSampleRate;
53-
internal readonly uint _expectedChannels;
5452

55-
internal readonly FfiHandle Handle;
53+
// The format the native source is configured for. Mutable because Reconfigure() can
54+
// recreate the source at a new format when the audio device's rate/channels change.
55+
internal uint _expectedSampleRate;
56+
internal uint _expectedChannels;
57+
58+
internal FfiHandle Handle;
5659
protected AudioSourceInfo _info;
5760

61+
/// <summary>
62+
/// Raised after the native audio source has been recreated at a new format (see
63+
/// <see cref="Reconfigure"/>). The source's <see cref="Handle"/> changes, so any track
64+
/// bound to the previous handle must be recreated against the new one.
65+
/// </summary>
66+
public event Action FormatChanged;
67+
5868
// CaptureAudioFrame is asynchronous: the native side can continue reading from the PCM
5969
// pointer after request.Send() returns and encode it later on another queue. Because of
6070
// that, a single reusable NativeArray is unsafe here; the next AudioRead callback can
@@ -94,6 +104,14 @@ protected RtcAudioSource(RtcAudioSourceType audioSourceType, uint sampleRate, ui
94104
(_expectedSampleRate, _expectedChannels) = ResolveDeviceFormat();
95105
}
96106

107+
CreateNativeSource();
108+
}
109+
110+
// Creates the native FFI audio source for the current _expectedSampleRate/_expectedChannels
111+
// and stores its handle. Called once from the constructor and again from Reconfigure() when
112+
// the format changes.
113+
private void CreateNativeSource()
114+
{
97115
using var request = FFIBridge.Instance.NewRequest<NewAudioSourceRequest>();
98116
var newAudioSource = request.request;
99117
newAudioSource.Type = AudioSourceType.AudioSourceNative;
@@ -111,11 +129,41 @@ protected RtcAudioSource(RtcAudioSourceType audioSourceType, uint sampleRate, ui
111129
Utils.Debug($"{DebugTag} created handle={Handle.DangerousGetHandle()} expectedRate={_expectedSampleRate} expectedChannels={_expectedChannels} sourceType={_sourceType}");
112130
}
113131

132+
/// <summary>
133+
/// Recreates the native audio source at a new format. The Rust FFI source does not
134+
/// resample and rejects frames whose rate/channels differ from how it was created, so when
135+
/// the capture device moves Unity's output format we must build a fresh source.
136+
/// </summary>
137+
/// <remarks>
138+
/// Must be called while capture is paused (no <see cref="AudioRead"/> callbacks in flight),
139+
/// because it disposes and replaces <see cref="Handle"/>. Raises <see cref="FormatChanged"/>
140+
/// on success so the owner can re-bind any track to the new handle.
141+
/// </remarks>
142+
/// <returns>True if the source was recreated; false if the format was unchanged or invalid.</returns>
143+
public bool Reconfigure(uint sampleRate, uint channels)
144+
{
145+
if (_disposed) return false;
146+
if (sampleRate == 0 || channels == 0) return false;
147+
if (sampleRate == _expectedSampleRate && channels == _expectedChannels) return false;
148+
149+
Utils.Debug($"{DebugTag} reconfigure {_expectedSampleRate}/{_expectedChannels} -> {sampleRate}/{channels}");
150+
151+
// The native source stays alive as long as a track references it, so disposing our
152+
// handle here is safe even before the old track is unpublished.
153+
Handle?.Dispose();
154+
_expectedSampleRate = sampleRate;
155+
_expectedChannels = channels;
156+
CreateNativeSource();
157+
158+
FormatChanged?.Invoke();
159+
return true;
160+
}
161+
114162
// Reads Unity's actual output audio configuration. The capture path delivers buffers at the
115163
// DSP output rate/channel count (see AudioProbe), so this is the format the native source
116164
// must match. Falls back to the platform defaults when Unity cannot report a configuration
117165
// (e.g. batch mode without an audio device).
118-
private (uint sampleRate, uint channels) ResolveDeviceFormat()
166+
protected (uint sampleRate, uint channels) ResolveDeviceFormat()
119167
{
120168
var config = UnityEngine.AudioSettings.GetConfiguration();
121169
var sampleRate = (uint)config.sampleRate;

Samples~/Meet/Assets/Runtime/MeetManager.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,14 +477,53 @@ private IEnumerator PublishLocalMicrophone()
477477
_microphoneActive = true;
478478
_audioObjects[LocalAudioTrackName] = audioObject;
479479
_localRtcAudioSource = rtcSource;
480+
// When the capture device changes to one with a different sample rate, the source
481+
// recreates its native handle; re-bind the published track to the new handle.
482+
rtcSource.FormatChanged += OnLocalMicrophoneFormatChanged;
480483
rtcSource.Start();
481484

482485
if (_participantTiles.TryGetValue(_localId, out var tile))
483486
tile.SetMicMuted(false);
484487
}
485488

489+
// Raised (on the main thread) after the local microphone source recreated its native handle
490+
// at a new format. The old track is bound to the now-disposed handle, so republish.
491+
private void OnLocalMicrophoneFormatChanged()
492+
{
493+
StartCoroutine(RepublishLocalMicrophone());
494+
}
495+
496+
private IEnumerator RepublishLocalMicrophone()
497+
{
498+
if (_localRtcAudioSource == null || _room == null) yield break;
499+
500+
if (_localAudioTrack != null)
501+
{
502+
_room.LocalParticipant.UnpublishTrack(_localAudioTrack, false);
503+
_localAudioTrack = null;
504+
}
505+
506+
_localAudioTrack = LocalAudioTrack.CreateAudioTrack(LocalAudioTrackName, _localRtcAudioSource, _room);
507+
508+
var options = new TrackPublishOptions
509+
{
510+
AudioEncoding = new AudioEncoding { MaxBitrate = 64000 },
511+
Source = TrackSource.SourceMicrophone
512+
};
513+
514+
var publish = _room.LocalParticipant.PublishTrack(_localAudioTrack, options);
515+
yield return publish;
516+
517+
if (publish.IsError)
518+
Debug.LogError("Failed to republish local microphone after format change");
519+
else
520+
Debug.Log("Republished local microphone track after audio format change");
521+
}
522+
486523
private void UnpublishLocalMicrophone()
487524
{
525+
if (_localRtcAudioSource != null)
526+
_localRtcAudioSource.FormatChanged -= OnLocalMicrophoneFormatChanged;
488527
DisposeSource(ref _localRtcAudioSource);
489528

490529
if (_audioObjects.TryGetValue(LocalAudioTrackName, out var obj))
@@ -562,6 +601,8 @@ private static void DisposeSource<T>(ref T source) where T : class, System.IDisp
562601

563602
private void CleanUpAllTracks()
564603
{
604+
if (_localRtcAudioSource != null)
605+
_localRtcAudioSource.FormatChanged -= OnLocalMicrophoneFormatChanged;
565606
DisposeSource(ref _localRtcAudioSource);
566607
DisposeSource(ref _localRtcVideoSource);
567608

0 commit comments

Comments
 (0)