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
31 changes: 31 additions & 0 deletions Runtime/Scripts/AudioStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ public sealed class AudioStream : IDisposable
private const int CrossfadeFrames = 128; // ~2.7ms @ 48kHz
private int _skipCooldown = 0;

// --- Temporary receive diagnostics (Info level, emitted ~every 2s) ---
// Reveals whether choppiness is a buffer-starvation problem (underruns/low fill) versus a
// clean stream, and what rate/channels we are actually playing/requesting.
private long _diagWindowStartTicks;
private int _diagCallbacks;
private int _diagUnderruns;
private int _diagFramesReceived;

/// <summary>
/// Creates a new audio stream from a remote audio track, attaching it to the
/// given <see cref="AudioSource"/> in the scene.
Expand Down Expand Up @@ -147,6 +155,8 @@ private void OnAudioRead(float[] data, int channels, int sampleRate)

lock (_lock)
{
MaybeLogReceiveDiagnostics(channels, sampleRate);

// Single gate covering first-create and runtime format changes (e.g. after a
// system audio device switch). When the FFI stream is missing or what we asked
// Rust for no longer matches what Unity is delivering, post a (re)create to the
Expand Down Expand Up @@ -214,6 +224,7 @@ static float S16ToFloat(short v)
if (valuesAvailableToRead < data.Length)
{
_isPrimed = false;
_diagUnderruns++;
Utils.Debug($"AudioStream underrun detected, re-priming (got {valuesAvailableToRead} samples but want to read {data.Length})");

// Output silence immediately instead of playing partial/choppy samples.
Expand Down Expand Up @@ -370,6 +381,7 @@ private void OnAudioStreamEvent(AudioStreamEvent e)
var data = new ReadOnlySpan<byte>(frame.Data.ToPointer(), frame.Length);
_buffer.Write(data);
}
_diagFramesReceived++;
}
}

Expand Down Expand Up @@ -427,6 +439,25 @@ private void Dispose(bool disposing)
Dispose(false);
}

// Temporary diagnostic: ~every 2s logs buffer fill, underrun count, callback count and
// frames received so we can tell starvation (choppy) from a clean stream. Called under _lock.
private void MaybeLogReceiveDiagnostics(int channels, int sampleRate)
{
_diagCallbacks++;
var now = System.Diagnostics.Stopwatch.GetTimestamp();
if (_diagWindowStartTicks == 0) _diagWindowStartTicks = now;
var elapsed = (now - _diagWindowStartTicks) / (double)System.Diagnostics.Stopwatch.Frequency;
if (elapsed < 2.0) return;

float fill = _buffer != null ? _buffer.AvailableReadInPercent() : 0f;
Utils.Info($"AudioStream#{_trackHandleId} diag: out={sampleRate}Hz/{channels}ch ffi={_ffiSampleRate}Hz/{_ffiNumChannels}ch " +
$"bufferFill={fill * 100f:F0}% callbacks={_diagCallbacks} underruns={_diagUnderruns} framesRecv={_diagFramesReceived} over={elapsed:F1}s");
_diagWindowStartTicks = now;
_diagCallbacks = 0;
_diagUnderruns = 0;
_diagFramesReceived = 0;
}

// For testing and debugging
internal float GetBufferFill()
{
Expand Down
6 changes: 4 additions & 2 deletions Runtime/Scripts/BasicAudioSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ 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.
/// </remarks>
public BasicAudioSource(AudioSource source, RtcAudioSourceType sourceType = RtcAudioSourceType.AudioSourceCustom) : base(sourceType)
{
_source = source;
}
Expand Down
29 changes: 27 additions & 2 deletions 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 Expand Up @@ -59,6 +59,28 @@ public override void Start()
_started = true;
}

// Opens the microphone at the engine's output sample rate when the device supports it, so
// the captured clip and the AudioSource that plays it back run at the same rate. A mismatch
// makes the looping clip drift against the playback read position and produces choppy audio.
// Falls back to DefaultMicrophoneSampleRate when the output rate is unknown, and clamps to
// the device's supported range when it reports one.
private static int ResolveMicrophoneSampleRate(string deviceName)
{
int target = AudioSettings.outputSampleRate;
if (target <= 0)
target = (int)DefaultMicrophoneSampleRate;

Microphone.GetDeviceCaps(deviceName, out int minFreq, out int maxFreq);
// Unity reports (0, 0) when the device imposes no specific sample-rate range.
if (minFreq == 0 && maxFreq == 0)
return target;

var result = Mathf.Clamp(target, minFreq, maxFreq);
Utils.Info($"ResolveMicrophoneSampleRate: {result}");

return result;
}

private IEnumerator StartMicrophone()
{
// Validate that the GameObject is still valid before starting
Expand All @@ -76,13 +98,14 @@ private IEnumerator StartMicrophone()
}

AudioClip clip = null;
var micFrequency = ResolveMicrophoneSampleRate(_deviceName);
try
{
clip = Microphone.Start(
_deviceName,
loop: true,
lengthSec: 1,
frequency: (int)DefaultMicrophoneSampleRate
frequency: micFrequency
);
}
catch (Exception e)
Expand All @@ -97,6 +120,8 @@ private IEnumerator StartMicrophone()
yield break;
}

Utils.Info($"MicrophoneSource device='{_deviceName}' opened at {micFrequency}Hz (output={AudioSettings.outputSampleRate}Hz)");

// Ensure no duplicate components exist before adding new ones.
// This is important during app resume on iOS where components might not be
// fully destroyed yet due to Unity's deferred Destroy().
Expand Down
115 changes: 106 additions & 9 deletions Runtime/Scripts/RtcAudioSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,20 +83,42 @@ private sealed class PendingAudioFrame
private volatile bool _disposed = false;
private int _audioReadCount = 0;

protected RtcAudioSource(int channels = 2, RtcAudioSourceType audioSourceType = RtcAudioSourceType.AudioSourceCustom)
// --- Temporary capture-rate diagnostics (Info level, emitted ~every 2s) ---
// Measures the effective sample rate from wall-clock time vs the rate we declared to the
// native source. A measured rate that differs from the declared rate means the format
// label on the frames is wrong (audio would sound fast/slow/choppy on the receiver).
private long _diagWindowStartTicks; // 0 = not started
private long _diagSamplesPerChannel;
private int _diagAcceptedFrames;
private int _diagDroppedFrames;

// 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<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 = _expectedChannels;
newAudioSource.SampleRate = _expectedSampleRate;

newAudioSource.Options = request.TempResource<AudioSourceOptions>();
newAudioSource.Options.EchoCancellation = true;
Expand All @@ -109,6 +131,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;
}
}

/// <summary>
/// Begin capturing audio samples from the underlying source.
/// </summary>
Expand Down Expand Up @@ -153,9 +218,19 @@ private void OnAudioRead(float[] data, int channels, int sampleRate)
return;
}

var willDrop = (uint)sampleRate != _expectedSampleRate || (uint)channels != _expectedChannels;
RecordCaptureDiagnostics(data.Length / channels, channels, sampleRate, willDrop);

// The native source rejects frames whose rate/channels differ from how it was
// configured (it does not resample). This should not happen now that the source is
// configured from the device, but if Unity reports an inconsistent format — or the
// output configuration changes at runtime — we drop the frame instead of sending a
// mismatch the native side would error on.
if ((uint)sampleRate != _expectedSampleRate || (uint)channels != _expectedChannels)
{
Utils.Warning($"{DebugTag} audio frame #{frameIndex} metadata mismatch actualRate={sampleRate} actualChannels={channels} expectedRate={_expectedSampleRate} expectedChannels={_expectedChannels} sourceType={_sourceType}");
if (frameIndex == 1 || frameIndex % 100 == 0)
Utils.Warning($"{DebugTag} dropping audio frame #{frameIndex}: format {sampleRate}/{channels} does not match source {_expectedSampleRate}/{_expectedChannels} (sourceType={_sourceType})");
return;
}

var pendingBeforeSend = PendingFrameCount();
Expand Down Expand Up @@ -342,6 +417,28 @@ private static double ElapsedMilliseconds(long startedTimestamp)
return (Stopwatch.GetTimestamp() - startedTimestamp) * 1000.0 / Stopwatch.Frequency;
}

// Temporary diagnostic: accumulates captured audio over wall-clock time and, ~every 2s,
// logs the effective sample rate vs the rate declared to the native source. Runs on the
// audio thread; the periodic Info log is cheap.
private void RecordCaptureDiagnostics(int samplesPerChannel, int channels, int sampleRate, bool dropped)
{
var now = Stopwatch.GetTimestamp();
if (_diagWindowStartTicks == 0) _diagWindowStartTicks = now;
_diagSamplesPerChannel += samplesPerChannel;
if (dropped) _diagDroppedFrames++; else _diagAcceptedFrames++;

var elapsed = (now - _diagWindowStartTicks) / (double)Stopwatch.Frequency;
if (elapsed < 2.0) return;

var measuredRate = _diagSamplesPerChannel / elapsed;
Utils.Info($"{DebugTag} capture diag: declared={_expectedSampleRate}Hz/{_expectedChannels}ch measuredRate={measuredRate:F0}Hz " +
$"lastFrame={samplesPerChannel}smp/{channels}ch/{sampleRate}Hz accepted={_diagAcceptedFrames} dropped={_diagDroppedFrames} over={elapsed:F1}s");
_diagWindowStartTicks = now;
_diagSamplesPerChannel = 0;
_diagAcceptedFrames = 0;
_diagDroppedFrames = 0;
}

private string DebugTag => $"RtcAudioSource#{_debugId}";
}
}
3 changes: 1 addition & 2 deletions Samples~/Meet/Assets/Runtime/MeetManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

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