diff --git a/Runtime/Scripts/BasicAudioSource.cs b/Runtime/Scripts/BasicAudioSource.cs index 3b63680b..ebe1a713 100644 --- a/Runtime/Scripts/BasicAudioSource.cs +++ b/Runtime/Scripts/BasicAudioSource.cs @@ -19,9 +19,12 @@ 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 and + /// adjusted automatically to match the captured audio. + /// + 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/Participant.cs b/Runtime/Scripts/Participant.cs index 4da028c0..ee23c58a 100644 --- a/Runtime/Scripts/Participant.cs +++ b/Runtime/Scripts/Participant.cs @@ -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(); diff --git a/Runtime/Scripts/RtcAudioSource.cs b/Runtime/Scripts/RtcAudioSource.cs index a9af8a0a..ac178c32 100644 --- a/Runtime/Scripts/RtcAudioSource.cs +++ b/Runtime/Scripts/RtcAudioSource.cs @@ -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 @@ -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(); 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(); newAudioSource.Options.EchoCancellation = true; @@ -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(); } /// @@ -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(); diff --git a/Runtime/Scripts/Track.cs b/Runtime/Scripts/Track.cs index 419c25df..3e442322 100644 --- a/Runtime/Scripts/Track.cs +++ b/Runtime/Scripts/Track.cs @@ -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; @@ -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; @@ -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; } @@ -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(); var createTrack = request.request; @@ -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); } } diff --git a/Samples~/Meet/Assets/Runtime/MeetManager.cs b/Samples~/Meet/Assets/Runtime/MeetManager.cs index 225c7a0c..6bc31f0e 100644 --- a/Samples~/Meet/Assets/Runtime/MeetManager.cs +++ b/Samples~/Meet/Assets/Runtime/MeetManager.cs @@ -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); 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;