Skip to content
Open
Show file tree
Hide file tree
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
29 changes: 19 additions & 10 deletions Runtime/Scripts/MicrophoneSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
56 changes: 52 additions & 4 deletions Runtime/Scripts/RtcAudioSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// Raised after the native audio source has been recreated at a new format (see
/// <see cref="Reconfigure"/>). The source's <see cref="Handle"/> changes, so any track
/// bound to the previous handle must be recreated against the new one.
/// </summary>
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
Expand Down Expand Up @@ -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<NewAudioSourceRequest>();
var newAudioSource = request.request;
newAudioSource.Type = AudioSourceType.AudioSourceNative;
Expand All @@ -111,11 +129,41 @@ protected RtcAudioSource(RtcAudioSourceType audioSourceType, uint sampleRate, ui
Utils.Debug($"{DebugTag} created handle={Handle.DangerousGetHandle()} expectedRate={_expectedSampleRate} expectedChannels={_expectedChannels} sourceType={_sourceType}");
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Must be called while capture is paused (no <see cref="AudioRead"/> callbacks in flight),
/// because it disposes and replaces <see cref="Handle"/>. Raises <see cref="FormatChanged"/>
/// on success so the owner can re-bind any track to the new handle.
/// </remarks>
/// <returns>True if the source was recreated; false if the format was unchanged or invalid.</returns>
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}");

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.

Without this change, we can have:

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)


// 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;
Expand Down
41 changes: 41 additions & 0 deletions Samples~/Meet/Assets/Runtime/MeetManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -562,6 +601,8 @@ private static void DisposeSource<T>(ref T source) where T : class, System.IDisp

private void CleanUpAllTracks()
{
if (_localRtcAudioSource != null)
_localRtcAudioSource.FormatChanged -= OnLocalMicrophoneFormatChanged;
DisposeSource(ref _localRtcAudioSource);
DisposeSource(ref _localRtcVideoSource);

Expand Down
Loading