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
7 changes: 5 additions & 2 deletions Runtime/Scripts/BasicAudioSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ sealed public class BasicAudioSource : RtcAudioSource
/// Creates a new basic audio source for the given <see cref="AudioSource"/> in the scene.
/// </summary>
/// <param name="source">The <see cref="AudioSource"/> to capture from.</param>
/// <param name="channels">The number of channels to capture.</param>
/// <param name="sourceType">The type of audio source.</param>
public BasicAudioSource(AudioSource source, int channels = 2, RtcAudioSourceType sourceType = RtcAudioSourceType.AudioSourceCustom) : base(channels, sourceType)
/// <remarks>
/// The sample rate and channel count are taken from Unity's audio configuration and
/// adjusted automatically to match the captured audio.
/// </remarks>
public BasicAudioSource(AudioSource source, RtcAudioSourceType sourceType = RtcAudioSourceType.AudioSourceCustom) : base(sourceType)
{
_source = source;
}
Expand Down
2 changes: 1 addition & 1 deletion Runtime/Scripts/MicrophoneSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ sealed public class MicrophoneSource : RtcAudioSource
/// get the list of available devices.</param>
/// <param name="sourceObject">The GameObject to attach the AudioSource to. The object must be kept in the scene
/// for the duration of the source's lifetime.</param>
public MicrophoneSource(string deviceName, GameObject sourceObject) : base(2, RtcAudioSourceType.AudioSourceMicrophone)
public MicrophoneSource(string deviceName, GameObject sourceObject) : base(RtcAudioSourceType.AudioSourceMicrophone)
{
_deviceName = deviceName;
_sourceObject = sourceObject;
Expand Down
5 changes: 5 additions & 0 deletions Runtime/Scripts/Participant.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ public PublishTrackInstruction PublishTrack(ILocalTrack localTrack, TrackPublish
if (!Room.TryGetTarget(out var room))
throw new Exception("room is invalid");

// Remember the publish target so an audio track can transparently republish itself if
// its source recreates its native handle (e.g. on a sample-rate change).
if (localTrack is LocalAudioTrack audioTrack)
audioTrack.RememberPublishTarget(this, options);

var track = (Track)localTrack;

using var request = FFIBridge.Instance.NewRequest<PublishTrackRequest>();
Expand Down
165 changes: 151 additions & 14 deletions Runtime/Scripts/RtcAudioSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,26 @@ private sealed class PendingAudioFrame
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 FfiHandle Handle;
// Format of the live native source. Written only on the main thread (constructor and
// recreation); read on the audio thread. The writers publish their changes through the
// volatile _handleReady flag (set last in CreateNativeSource).
private volatile uint _liveSampleRate;
private volatile uint _liveChannels;
private volatile bool _handleReady;

// Coalesces recreation requests raised from the audio thread and marshaled to the main thread.
private readonly object _recreateLock = new object();
private bool _recreateScheduled;
private uint _desiredSampleRate;
private uint _desiredChannels;

// Raised on the main thread after the native source is recreated at runtime (not on the
// initial creation). LocalAudioTrack subscribes to rebuild and republish its FFI track,
// since a track is bound to a specific native source handle at creation time.
internal event Action NativeSourceChanged;

internal FfiHandle Handle { get; private set; }
protected AudioSourceInfo _info;

// CaptureAudioFrame is asynchronous: the native side can continue reading from the PCM
Expand All @@ -83,20 +99,43 @@ 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
// reads the device's output configuration up front and then corrects itself from the first
// captured frame (see OnAudioRead).
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;

uint initialRate;
uint initialChannels;
if (sampleRate > 0 && channels > 0)
{
initialRate = sampleRate;
initialChannels = channels;
}
else
{
(initialRate, initialChannels) = ResolveDeviceFormat();
}

CreateNativeSource(initialRate, initialChannels);
Utils.Debug($"{DebugTag} created handle={Handle.DangerousGetHandle()} rate={_liveSampleRate} channels={_liveChannels} sourceType={_sourceType}");
}

// Builds (or rebuilds) the underlying native audio source. Main thread only.
private void CreateNativeSource(uint sampleRate, uint channels)
{
using var request = FFIBridge.Instance.NewRequest<NewAudioSourceRequest>();
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 = channels;
newAudioSource.SampleRate = sampleRate;

newAudioSource.Options = request.TempResource<AudioSourceOptions>();
newAudioSource.Options.EchoCancellation = true;
Expand All @@ -106,7 +145,99 @@ protected RtcAudioSource(int channels = 2, RtcAudioSourceType audioSourceType =
FfiResponse res = response;
_info = res.NewAudioSource.Source.Info;
Handle = FfiHandle.FromOwnedHandle(res.NewAudioSource.Source.Handle);
Utils.Debug($"{DebugTag} created handle={Handle.DangerousGetHandle()} expectedRate={_expectedSampleRate} expectedChannels={_expectedChannels} sourceType={_sourceType}");

_liveSampleRate = sampleRate;
_liveChannels = channels;
_handleReady = true; // volatile release: publishes Handle/_info/_live* to the audio thread
}

// 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. Both values are corrected from the first captured frame regardless,
// so this only needs to provide a reasonable starting point; it 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;
}
}

// Called from the audio thread when an incoming frame's format does not match the live
// native source. Coalesces requests and marshals the rebuild to the main thread, because
// creating the native source and rebuilding the track touch FFI/Unity APIs that are not
// safe to call from the audio thread.
private void RequestNativeSource(uint sampleRate, uint channels)
{
lock (_recreateLock)
{
_desiredSampleRate = sampleRate;
_desiredChannels = channels;
if (_recreateScheduled) return;
_recreateScheduled = true;
}

var context = FfiClient.Instance._context;
if (context != null)
context.Post(_ => ApplyRecreate(), null);
else
ApplyRecreate();
}

private void ApplyRecreate()
{
uint sampleRate;
uint channels;
lock (_recreateLock)
{
_recreateScheduled = false;
sampleRate = _desiredSampleRate;
channels = _desiredChannels;
}

if (_disposed) return;
if (_handleReady && sampleRate == _liveSampleRate && channels == _liveChannels)
return; // configuration already settled on the desired format

Utils.Debug($"{DebugTag} recreating native source rate {_liveSampleRate}->{sampleRate} channels {_liveChannels}->{channels} sourceType={_sourceType}");

var previous = Handle;
_handleReady = false; // drop audio-thread frames until the new source is live
CreateNativeSource(sampleRate, channels);
NativeSourceChanged?.Invoke(); // let the track rebuild/republish onto the new handle
previous?.Dispose();
}

/// <summary>
Expand Down Expand Up @@ -153,9 +284,15 @@ private void OnAudioRead(float[] data, int channels, int sampleRate)
return;
}

if ((uint)sampleRate != _expectedSampleRate || (uint)channels != _expectedChannels)
// The native source rejects frames whose rate/channels differ from how it was
// configured (the Rust source does not resample). Unity's reported configuration is
// not always accurate and can change at runtime (e.g. when a Bluetooth headset
// connects), so trust the frame: if it does not match the live source, drop it and
// (re)create the native source to match.
if (!_handleReady || (uint)sampleRate != _liveSampleRate || (uint)channels != _liveChannels)
{
Utils.Warning($"{DebugTag} audio frame #{frameIndex} metadata mismatch actualRate={sampleRate} actualChannels={channels} expectedRate={_expectedSampleRate} expectedChannels={_expectedChannels} sourceType={_sourceType}");
RequestNativeSource((uint)sampleRate, (uint)channels);
return;
}

var pendingBeforeSend = PendingFrameCount();
Expand Down
54 changes: 50 additions & 4 deletions Runtime/Scripts/Track.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public class Track : ITrack
// IsOwned is true if C# owns the handle
public bool IsOwned => Handle != null && !Handle.IsInvalid;

public readonly FfiHandle Handle;
public FfiHandle Handle { get; private set; }

FfiHandle ITrack.TrackHandle => Handle;

Expand All @@ -104,6 +104,17 @@ internal void UpdateInfo(TrackInfo info)
_info = info;
}

// Replaces the underlying FFI track handle. Used when a local track is rebuilt because its
// audio source recreated its native handle at a new sample rate/channel count. Disposes
// the previous handle.
internal void SwapHandle(OwnedTrack track)
{
var previous = Handle;
Handle = FfiHandle.FromOwnedHandle(track.Handle);
UpdateInfo(track.Info);
previous?.Dispose();
}

internal void UpdateMuted(bool muted)
{
_info.Muted = muted;
Expand All @@ -118,6 +129,9 @@ internal void DisposeHandles()
public sealed class LocalAudioTrack : Track, ILocalTrack, IAudioTrack
{
RtcAudioSource _source;
string _name;
LocalParticipant _participant;
TrackPublishOptions _publishOptions;

IRtcSource ILocalTrack.source { get => _source; }

Expand All @@ -126,6 +140,17 @@ internal LocalAudioTrack(OwnedTrack track, Room room, RtcAudioSource source) : b
}

public static LocalAudioTrack CreateAudioTrack(string name, RtcAudioSource source, Room room)
{
var track = new LocalAudioTrack(CreateFfiTrack(name, source), room, source);
track._name = name;
// The track is bound to a specific native source handle at creation time and cannot
// follow a new one in place. If the source recreates its native handle at runtime
// (e.g. on a sample-rate change), rebuild and republish the track onto the new handle.
source.NativeSourceChanged += track.OnNativeSourceChanged;
return track;
}

private static OwnedTrack CreateFfiTrack(string name, RtcAudioSource source)
{
using var request = FFIBridge.Instance.NewRequest<CreateAudioTrackRequest>();
var createTrack = request.request;
Expand All @@ -134,9 +159,30 @@ public static LocalAudioTrack CreateAudioTrack(string name, RtcAudioSource sourc

using var resp = request.Send();
FfiResponse res = resp;
var trackInfo = res.CreateAudioTrack.Track;
var track = new LocalAudioTrack(trackInfo, room, source);
return track;
return res.CreateAudioTrack.Track;
}

// Records the publish target so the track can republish itself after a source recreation.
internal void RememberPublishTarget(LocalParticipant participant, TrackPublishOptions options)
{
_participant = participant;
_publishOptions = options;
}

// Runs on the main thread after the source recreated its native handle. Rebuilds the FFI
// track onto the new source and, if the track was already published, republishes it.
private void OnNativeSourceChanged()
{
var wasPublished = _participant != null && !string.IsNullOrEmpty(Sid);

// Unpublish first (reads the current Sid) before swapping to the new handle.
if (wasPublished)
_participant.UnpublishTrack(this, false);

SwapHandle(CreateFfiTrack(_name, _source));

if (wasPublished)
_participant.PublishTrack(this, _publishOptions);
}
}

Expand Down
4 changes: 2 additions & 2 deletions Samples~/Meet/Assets/Runtime/MeetManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -453,8 +453,8 @@ private IEnumerator PublishLocalMicrophone()
{
if (_audioObjects.ContainsKey(LocalAudioTrackName)) yield break;

Microphone.Start(null, true, 10, 44100);

// MicrophoneSource starts the device itself (at the resolved sample rate), so we only
// need the device name here.
var audioObject = new GameObject($"My Microphone: {Microphone.devices[0]}");
audioObject.transform.SetParent(_audioTrackParent);

Expand Down
2 changes: 1 addition & 1 deletion Tests/PlayMode/Utils/SineWaveAudioSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading