Fix audio clock drift and adaptive jitter buffering
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
namespace VoiceCat.Audio;
|
||||
|
||||
// SPSC PCM handoff with a small occupancy-controlled sample-rate correction. Producers and
|
||||
// consumers remain non-waiting; the correction keeps independent hardware and managed clocks
|
||||
// from periodically reaching the hard underflow/overflow edges of a conventional ring.
|
||||
public sealed class AdaptivePcmBuffer
|
||||
{
|
||||
private const int SampleRate = 48_000;
|
||||
private const double MaximumCorrection = 0.005;
|
||||
private readonly short[] samples;
|
||||
private readonly int channels, frameMask;
|
||||
private int readFrame, writtenFrame, producer, targetFrames;
|
||||
private double phase;
|
||||
private bool primed;
|
||||
|
||||
public AdaptivePcmBuffer(int channels, int bufferMilliseconds = 40, int capacityFrames = 16_384)
|
||||
{
|
||||
if (channels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(channels));
|
||||
if (capacityFrames < 2 || (capacityFrames & (capacityFrames - 1)) != 0) throw new ArgumentOutOfRangeException(nameof(capacityFrames));
|
||||
this.channels = channels; samples = new short[checked(capacityFrames * channels)]; frameMask = capacityFrames - 1;
|
||||
BufferMilliseconds = bufferMilliseconds;
|
||||
}
|
||||
|
||||
public int Channels => channels;
|
||||
public int CountFrames => unchecked(Volatile.Read(ref writtenFrame) - Volatile.Read(ref readFrame));
|
||||
public int BufferMilliseconds
|
||||
{
|
||||
get => Volatile.Read(ref targetFrames) * 1000 / SampleRate;
|
||||
set
|
||||
{
|
||||
if (value is not (20 or 40 or 60)) throw new ArgumentOutOfRangeException(nameof(value));
|
||||
Volatile.Write(ref targetFrames, value * SampleRate / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryWrite(ReadOnlySpan<short> source)
|
||||
{
|
||||
if (source.Length == 0 || source.Length % channels != 0 || Interlocked.CompareExchange(ref producer, 1, 0) != 0) return false;
|
||||
try
|
||||
{
|
||||
int frames = source.Length / channels, index = writtenFrame;
|
||||
if (frames > frameMask + 1 - unchecked(index - Volatile.Read(ref readFrame))) return false;
|
||||
for (int frame = 0; frame < frames; frame++)
|
||||
{
|
||||
int target = ((index + frame) & frameMask) * channels;
|
||||
for (int channel = 0; channel < channels; channel++) samples[target + channel] = source[frame * channels + channel];
|
||||
}
|
||||
Volatile.Write(ref writtenFrame, unchecked(index + frames)); return true;
|
||||
}
|
||||
finally { Volatile.Write(ref producer, 0); }
|
||||
}
|
||||
|
||||
// Returns interleaved samples written. A zero return means the caller should treat the
|
||||
// already-cleared destination as silence. Once primed, short scheduling stalls re-prime
|
||||
// instead of repeatedly clicking at the ring edge.
|
||||
public int Read(Span<short> destination)
|
||||
{
|
||||
if (destination.Length % channels != 0) throw new ArgumentException("PCM must contain complete interleaved frames.", nameof(destination));
|
||||
int requestedFrames = destination.Length / channels;
|
||||
if (requestedFrames == 0) return 0;
|
||||
int available = CountFrames, target = Volatile.Read(ref targetFrames);
|
||||
if (!primed)
|
||||
{
|
||||
if (available < target) { destination.Clear(); return 0; }
|
||||
primed = true; phase = 0;
|
||||
}
|
||||
if (available <= 0) { primed = false; phase = 0; destination.Clear(); return 0; }
|
||||
|
||||
double correction = Math.Clamp((available - target) / (SampleRate * 2.0), -MaximumCorrection, MaximumCorrection);
|
||||
double step = 1.0 + correction;
|
||||
int produced = 0, read = readFrame;
|
||||
for (int frame = 0; frame < requestedFrames; frame++)
|
||||
{
|
||||
int baseOffset = (read & frameMask) * channels;
|
||||
int nextOffset = ((read + 1) & frameMask) * channels;
|
||||
int remaining = unchecked(Volatile.Read(ref writtenFrame) - read);
|
||||
if (remaining <= 0) break;
|
||||
double fraction = phase;
|
||||
for (int channel = 0; channel < channels; channel++)
|
||||
{
|
||||
int first = samples[baseOffset + channel];
|
||||
int second = remaining > 1 ? samples[nextOffset + channel] : first;
|
||||
destination[produced++] = (short)Math.Clamp((int)Math.Round(first + (second - first) * fraction), short.MinValue, short.MaxValue);
|
||||
}
|
||||
phase += step;
|
||||
int advance = (int)phase;
|
||||
if (advance > remaining) advance = remaining;
|
||||
read = unchecked(read + advance); phase -= advance;
|
||||
}
|
||||
Volatile.Write(ref readFrame, read);
|
||||
if (produced < destination.Length)
|
||||
{
|
||||
destination[produced..].Clear(); primed = false; phase = 0;
|
||||
}
|
||||
return produced;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@ namespace VoiceCat.Audio;
|
||||
public sealed record AudioDeviceInfo(string Id, string Name, bool IsDefault);
|
||||
public delegate void CapturePcmHandler(ReadOnlySpan<short> pcm, int channels);
|
||||
public interface IAudioCapture : IDisposable { }
|
||||
public interface IAudioPlayback : IDisposable { void Write(ReadOnlySpan<short> stereoPcm); }
|
||||
public interface IAudioPlayback : IDisposable
|
||||
{
|
||||
int BufferMilliseconds { get; set; }
|
||||
void Write(ReadOnlySpan<short> stereoPcm);
|
||||
}
|
||||
public interface IAudioDeviceBackend
|
||||
{
|
||||
IReadOnlyList<AudioDeviceInfo> Enumerate(bool input);
|
||||
|
||||
@@ -23,6 +23,17 @@ public sealed class AudioEngine : IDisposable
|
||||
public volatile float InputGain = 1, OutputGain = 1, VadThreshold = 0.02f;
|
||||
public volatile bool InputNoiseReduction, MicMuted, Deafened, PushToTalk;
|
||||
public volatile AudioInputMode InputMode = AudioInputMode.VoiceActivation;
|
||||
private int deviceBufferMilliseconds = 40;
|
||||
public int DeviceBufferMilliseconds
|
||||
{
|
||||
get => Volatile.Read(ref deviceBufferMilliseconds);
|
||||
set
|
||||
{
|
||||
if (value is not (20 or 40 or 60)) throw new ArgumentOutOfRangeException(nameof(value));
|
||||
Volatile.Write(ref deviceBufferMilliseconds, value);
|
||||
foreach (LocalStream stream in Volatile.Read(ref routes).Local) stream.BufferMilliseconds = value;
|
||||
}
|
||||
}
|
||||
public Exception? Failure { get; private set; }
|
||||
|
||||
public AudioEngine(EncodedVoiceSender sender, bool startWorker = true)
|
||||
@@ -42,7 +53,7 @@ public sealed class AudioEngine : IDisposable
|
||||
lock (gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed != 0, this);
|
||||
var stream = new LocalStream(info, captureChannels);
|
||||
var stream = new LocalStream(info, captureChannels, DeviceBufferMilliseconds);
|
||||
Routes previous = routes;
|
||||
var locals = previous.Local.Where(s => s.Info.StreamId != info.StreamId).Append(stream).ToArray();
|
||||
Publish(locals, previous.Remote);
|
||||
@@ -58,7 +69,7 @@ public sealed class AudioEngine : IDisposable
|
||||
lock (gate)
|
||||
{
|
||||
var current = routes.Local.FirstOrDefault(s => s.Info.StreamId == streamId) ?? throw new ArgumentException("Stream not found.");
|
||||
if (current.CaptureChannels != channels) Publish(routes.Local.Select(s => s == current ? new LocalStream(s.Info, channels) : s).ToArray(), routes.Remote);
|
||||
if (current.CaptureChannels != channels) Publish(routes.Local.Select(s => s == current ? new LocalStream(s.Info, channels, DeviceBufferMilliseconds) : s).ToArray(), routes.Remote);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ internal sealed class LocalStream : IDisposable
|
||||
{
|
||||
internal readonly StreamInfo Info;
|
||||
internal readonly int CaptureChannels;
|
||||
internal readonly PcmRing Input = new(16384);
|
||||
internal readonly AdaptivePcmBuffer Input;
|
||||
internal volatile float Level;
|
||||
internal volatile bool Talking;
|
||||
internal volatile float Gain = 1;
|
||||
@@ -27,6 +27,7 @@ internal sealed class LocalStream : IDisposable
|
||||
private readonly short[] converted = new short[16384];
|
||||
private int feeding;
|
||||
private int buffered;
|
||||
private int starvedSamples;
|
||||
private uint timestamp;
|
||||
private bool wasTransmitting, marker;
|
||||
|
||||
@@ -45,9 +46,12 @@ internal sealed class LocalStream : IDisposable
|
||||
finally { Volatile.Write(ref feeding, 0); }
|
||||
}
|
||||
|
||||
internal LocalStream(StreamInfo stream, int captureChannels)
|
||||
internal int BufferMilliseconds { get => Input.BufferMilliseconds; set => Input.BufferMilliseconds = value; }
|
||||
|
||||
internal LocalStream(StreamInfo stream, int captureChannels, int bufferMilliseconds = 40)
|
||||
{
|
||||
Info = stream.Clone(); CaptureChannels = captureChannels;
|
||||
Input = new(captureChannels, bufferMilliseconds);
|
||||
encoder = new(new()
|
||||
{
|
||||
Channels = stream.Audio.Mode == ChannelMode.ModeStereo ? 2 : 1,
|
||||
@@ -64,10 +68,14 @@ internal sealed class LocalStream : IDisposable
|
||||
internal void Process(AudioEngine engine, EncodedVoiceSender sender)
|
||||
{
|
||||
var input = capture.AsSpan(0, 960 * CaptureChannels);
|
||||
if (Input.Count < input.Length) { Level = 0; Talking = false; buffered = 0; wasTransmitting = false; return; }
|
||||
while (Input.Count > input.Length * 6) Input.Read(input);
|
||||
if (Input.Read(input) != input.Length)
|
||||
{
|
||||
Level = 0; Talking = false; starvedSamples += 960;
|
||||
if (starvedSamples >= 9600) { buffered = 0; wasTransmitting = false; }
|
||||
return;
|
||||
}
|
||||
starvedSamples = 0;
|
||||
if (buffered == 0) timestamp = engine.SampleClock;
|
||||
Input.Read(input);
|
||||
bool mic = Info.Kind == StreamKind.StreamMic;
|
||||
if (mic && engine.InputNoiseReduction)
|
||||
{
|
||||
|
||||
@@ -20,28 +20,36 @@ internal sealed class ReceiveStream : IDisposable
|
||||
private readonly byte[][] packets = Enumerable.Range(0, 64).Select(_ => new byte[1275]).ToArray();
|
||||
private readonly VoiceFrameHeader[] headers = new VoiceFrameHeader[64];
|
||||
private readonly int[] lengths = new int[64];
|
||||
private readonly long[] arrivals = new long[64];
|
||||
private int read, written;
|
||||
private readonly byte[][] jitter = Enumerable.Range(0, 6).Select(_ => new byte[1275]).ToArray();
|
||||
private readonly uint[] timestamps = new uint[6];
|
||||
private readonly int[] sizes = new int[6];
|
||||
private readonly byte[][] jitter;
|
||||
private readonly uint[] timestamps;
|
||||
private readonly int[] sizes;
|
||||
private int count, available, offset, missing, waiting;
|
||||
private uint expected;
|
||||
private bool started, hasTimestamp;
|
||||
private bool hasMarker;
|
||||
private uint lastMarker;
|
||||
private readonly int channels, frameSamples, maximumDepth;
|
||||
private long lastArrival;
|
||||
private uint lastArrivalTimestamp;
|
||||
private double jitterSamples;
|
||||
private readonly TimeProvider clock;
|
||||
internal int Depth => count;
|
||||
internal int TargetDepthSamples => TargetSamples();
|
||||
internal int ConcealedFrames { get; private set; }
|
||||
internal int DredFrames { get; private set; }
|
||||
internal int FecFrames { get; private set; }
|
||||
|
||||
internal ReceiveStream(uint userId, StreamInfo info)
|
||||
internal ReceiveStream(uint userId, StreamInfo info, TimeProvider? clock = null)
|
||||
{
|
||||
if (info.Audio.FrameMs is not (5 or 10 or 20 or 40 or 60) || !Enum.IsDefined(info.Audio.Mode)) throw new ArgumentException("Unsupported remote audio configuration.", nameof(info));
|
||||
UserId = userId; Info = info.Clone();
|
||||
UserId = userId; Info = info.Clone(); this.clock = clock ?? TimeProvider.System;
|
||||
channels = info.Audio.Mode == ChannelMode.ModeStereo ? 2 : 1;
|
||||
frameSamples = checked((int)info.Audio.FrameMs * 48);
|
||||
maximumDepth = Math.Clamp(120 / (int)info.Audio.FrameMs, 2, 6);
|
||||
maximumDepth = Math.Clamp(500 / (int)info.Audio.FrameMs + 2, 8, 104);
|
||||
jitter = Enumerable.Range(0, maximumDepth).Select(_ => new byte[1275]).ToArray();
|
||||
timestamps = new uint[maximumDepth]; sizes = new int[maximumDepth];
|
||||
decoder = new(48000, channels);
|
||||
try { left = new(); } catch { decoder.Dispose(); throw; }
|
||||
try { right = new(); } catch { left.Dispose(); decoder.Dispose(); throw; }
|
||||
@@ -53,7 +61,7 @@ internal sealed class ReceiveStream : IDisposable
|
||||
{
|
||||
int index = written;
|
||||
if (payload.Length is < 1 or > 1275 || unchecked(index - Volatile.Read(ref read)) >= 64) return false;
|
||||
int slot = index & 63; payload.CopyTo(packets[slot]); headers[slot] = header; lengths[slot] = payload.Length;
|
||||
int slot = index & 63; payload.CopyTo(packets[slot]); headers[slot] = header; lengths[slot] = payload.Length; arrivals[slot] = clock.GetTimestamp();
|
||||
Volatile.Write(ref written, unchecked(index + 1)); return true;
|
||||
}
|
||||
|
||||
@@ -67,8 +75,8 @@ internal sealed class ReceiveStream : IDisposable
|
||||
if ((headers[source].Flags & VoiceFrameFlags.Marker) != 0 && (!hasMarker || unchecked((int)(timestamp - lastMarker)) > 0))
|
||||
{
|
||||
hasMarker = true; lastMarker = timestamp;
|
||||
sizes.AsSpan().Clear(); count = available = offset = missing = waiting = 0;
|
||||
expected = timestamp; started = false; delta = 0;
|
||||
DropBefore(timestamp); available = offset = missing = waiting = 0;
|
||||
expected = timestamp; started = false; delta = 0; lastArrival = 0; jitterSamples = 0;
|
||||
}
|
||||
bool duplicate = false;
|
||||
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == timestamp) duplicate = true;
|
||||
@@ -79,14 +87,61 @@ internal sealed class ReceiveStream : IDisposable
|
||||
int oldest = Oldest(); sizes[oldest] = 0; count--;
|
||||
}
|
||||
int target = Array.IndexOf(sizes, 0);
|
||||
timestamps[target] = timestamp; sizes[target] = lengths[source]; packets[source].AsSpan(0, lengths[source]).CopyTo(jitter[target]); count++;
|
||||
int oldestRemaining = Oldest();
|
||||
if (count >= maximumDepth && unchecked((int)(timestamps[oldestRemaining] - expected)) > 0) expected = timestamps[oldestRemaining];
|
||||
timestamps[target] = timestamp; sizes[target] = lengths[source];
|
||||
packets[source].AsSpan(0, lengths[source]).CopyTo(jitter[target]); count++;
|
||||
ObserveArrival(timestamp, arrivals[source]);
|
||||
}
|
||||
Volatile.Write(ref read, unchecked(read + 1));
|
||||
}
|
||||
}
|
||||
|
||||
private void ObserveArrival(uint timestamp, long arrival)
|
||||
{
|
||||
if (lastArrival != 0)
|
||||
{
|
||||
int timestampDelta = unchecked((int)(timestamp - lastArrivalTimestamp));
|
||||
if (timestampDelta <= 0) return;
|
||||
if (timestampDelta > 0 && timestampDelta <= frameSamples * 10)
|
||||
{
|
||||
double arrivalDelta = clock.GetElapsedTime(lastArrival, arrival).TotalSeconds * 48_000;
|
||||
double deviation = Math.Abs(arrivalDelta - timestampDelta);
|
||||
jitterSamples += (deviation - jitterSamples) / 16.0;
|
||||
}
|
||||
}
|
||||
lastArrival = arrival; lastArrivalTimestamp = timestamp;
|
||||
}
|
||||
|
||||
private int TargetSamples()
|
||||
{
|
||||
int recovery = Info.Audio.Dred || Info.Audio.Fec ? frameSamples : 0;
|
||||
int variation = checked((int)Math.Ceiling(4 * jitterSamples / frameSamples)) * frameSamples;
|
||||
return Math.Min(5760, recovery + variation);
|
||||
}
|
||||
|
||||
private int Newest()
|
||||
{
|
||||
int newest = -1;
|
||||
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && (newest < 0 || unchecked((int)(timestamps[i] - timestamps[newest])) > 0)) newest = i;
|
||||
return newest;
|
||||
}
|
||||
|
||||
private void DropBefore(uint timestamp)
|
||||
{
|
||||
for (int i = 0; i < sizes.Length; i++)
|
||||
if (sizes[i] != 0 && unchecked((int)(timestamps[i] - timestamp)) < 0) { sizes[i] = 0; count--; }
|
||||
}
|
||||
|
||||
private void CatchUp()
|
||||
{
|
||||
if (!started || available != 0 || count == 0) return;
|
||||
int newest = Newest(); int target = TargetSamples();
|
||||
int lead = unchecked((int)(timestamps[newest] - expected));
|
||||
if (lead <= target + frameSamples) return;
|
||||
int keepBehind = target / frameSamples * frameSamples;
|
||||
expected = unchecked(timestamps[newest] - (uint)keepBehind);
|
||||
DropBefore(expected); missing = 0;
|
||||
}
|
||||
|
||||
private int Oldest()
|
||||
{
|
||||
int oldest = -1;
|
||||
@@ -108,11 +163,12 @@ internal sealed class ReceiveStream : IDisposable
|
||||
else
|
||||
{
|
||||
int next = Oldest(); missing++;
|
||||
if (next >= 0 && unchecked((int)(timestamps[next] - expected)) == frameSamples)
|
||||
int recoveryOffset = next < 0 ? 0 : unchecked((int)(timestamps[next] - expected));
|
||||
if (next >= 0 && recoveryOffset > 0 && recoveryOffset % frameSamples == 0)
|
||||
{
|
||||
var packet = jitter[next].AsSpan(0, sizes[next]);
|
||||
if (dred?.TryRecover(decoder, packet, pcm, frameSamples) == true) { decoded = true; DredFrames++; }
|
||||
else if (Info.Audio.Fec && decoder.TryDecode(packet, pcm, frameSamples, out int recovered, true) && recovered == frameSamples) { decoded = true; FecFrames++; }
|
||||
if (dred?.TryRecover(decoder, packet, pcm, frameSamples, recoveryOffset) == true) { decoded = true; DredFrames++; }
|
||||
else if (recoveryOffset == frameSamples && Info.Audio.Fec && decoder.TryDecode(packet, pcm, frameSamples, out int recovered, true) && recovered == frameSamples) { decoded = true; FecFrames++; }
|
||||
}
|
||||
}
|
||||
int maximumConcealment = Math.Max(1, 200 / (int)Info.Audio.FrameMs);
|
||||
@@ -127,11 +183,16 @@ internal sealed class ReceiveStream : IDisposable
|
||||
internal void Mix(Span<int> output, bool deafened, PcmStreamHandler? sink)
|
||||
{
|
||||
Drain();
|
||||
CatchUp();
|
||||
if (!started)
|
||||
{
|
||||
waiting++;
|
||||
if (!hasTimestamp || count < Math.Min(3, maximumDepth) && waiting < 3) return;
|
||||
expected = timestamps[Oldest()]; started = true;
|
||||
if (!hasTimestamp || count == 0) return;
|
||||
int oldest = Oldest(), newest = Newest();
|
||||
if (unchecked((int)(timestamps[newest] - timestamps[oldest])) < TargetSamples()) return;
|
||||
int keepBehind = TargetSamples() / frameSamples * frameSamples;
|
||||
expected = unchecked(timestamps[newest] - (uint)keepBehind);
|
||||
DropBefore(expected); started = true; waiting = 0;
|
||||
}
|
||||
int copied = 0;
|
||||
while (copied < 960)
|
||||
|
||||
@@ -7,6 +7,84 @@ namespace VoiceCat.Tests;
|
||||
|
||||
public class AudioEngineTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(-1000)]
|
||||
[InlineData(1000)]
|
||||
public void AdaptivePcmBufferAbsorbsIndependentClockDrift(int partsPerMillion)
|
||||
{
|
||||
var buffer = new AdaptivePcmBuffer(1, 40); short[] input = new short[962], output = new short[960];
|
||||
input.AsSpan().Fill(1234); Assert.True(buffer.TryWrite(input.AsSpan(0, 960)));
|
||||
Assert.True(buffer.TryWrite(input.AsSpan(0, 960)));
|
||||
double produced = 0;
|
||||
for (int cycle = 0; cycle < 10_000; cycle++)
|
||||
{
|
||||
produced += 960 * (1 + partsPerMillion / 1_000_000.0);
|
||||
int frames = (int)produced; produced -= frames;
|
||||
Assert.True(buffer.TryWrite(input.AsSpan(0, frames)));
|
||||
Assert.Equal(output.Length, buffer.Read(output));
|
||||
}
|
||||
Assert.InRange(buffer.CountFrames, 480, 3840);
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int i = 0; i < 100; i++) { buffer.TryWrite(input.AsSpan(0, 960)); buffer.Read(output); }
|
||||
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneCaptureMissDoesNotRestartTalkspurtButSustainedStarvationDoes()
|
||||
{
|
||||
var sent = new List<(uint Timestamp, VoiceFrameFlags Flags)>();
|
||||
using var engine = new AudioEngine((_, timestamp, _, flags) => { sent.Add((timestamp, flags)); return true; }, false)
|
||||
{ InputMode = AudioInputMode.AlwaysOn, DeviceBufferMilliseconds = 20 };
|
||||
engine.AddLocalStream(Stream()); short[] tone = Tone();
|
||||
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
|
||||
engine.ProcessCycle();
|
||||
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
|
||||
Assert.Equal(2, sent.Count); Assert.True((sent[0].Flags & VoiceFrameFlags.Marker) != 0); Assert.Equal(VoiceFrameFlags.None, sent[1].Flags & VoiceFrameFlags.Marker);
|
||||
for (int i = 0; i < 10; i++) engine.ProcessCycle();
|
||||
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
|
||||
Assert.True((sent[^1].Flags & VoiceFrameFlags.Marker) != 0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(5)] [InlineData(10)] [InlineData(20)] [InlineData(40)] [InlineData(60)]
|
||||
public void RecoveryLookaheadTracksChannelFrameDuration(int frameMilliseconds)
|
||||
{
|
||||
using var stream = new ReceiveStream(2, Stream(frameMilliseconds));
|
||||
Assert.Equal(frameMilliseconds * 48, stream.TargetDepthSamples);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JitterTargetAdaptsInSampleTimeAndRemainsCapped()
|
||||
{
|
||||
var clock = new ManualAudioClock(); StreamInfo info = Stream(); info.Audio.Fec = false;
|
||||
using var stream = new ReceiveStream(2, info, clock); using var encoder = new OpusEncoder(new() { Bitrate = 32000 });
|
||||
byte[] packet = new byte[1275]; int length = encoder.Encode(Tone(), packet);
|
||||
for (uint i = 0; i < 40; i++)
|
||||
{
|
||||
clock.Advance(i % 2 == 0 ? 5 : 35);
|
||||
stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, length));
|
||||
}
|
||||
int[] output = new int[1920]; stream.Mix(output, false, null);
|
||||
Assert.InRange(stream.TargetDepthSamples, 960, 5760);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DredUsesTimestampOffsetForConsecutiveMissingShortFrames()
|
||||
{
|
||||
using var probe = new OpusEncoder();
|
||||
if (!probe.SupportsDeepRedundancy) return;
|
||||
StreamInfo info = Stream(10, dred: true); using var stream = new ReceiveStream(2, info);
|
||||
using var encoder = new OpusEncoder(new() { FrameDurationMilliseconds = 10, DeepRedundancy = true, ExpectedPacketLossPercent = 30, Bitrate = 64000 });
|
||||
byte[] packet = new byte[1275]; short[] tone = new short[480]; int[] output = new int[1920];
|
||||
for (uint i = 0; i < 50; i++)
|
||||
{
|
||||
CodecTests.FillTone(tone, 480, 1, 48000, (int)i); int length = encoder.Encode(tone, packet);
|
||||
if (i is not (25 or 26)) stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 480), packet.AsSpan(0, length));
|
||||
if (i % 2 == 1) { output.AsSpan().Clear(); stream.Mix(output, false, null); }
|
||||
}
|
||||
Assert.True(stream.DredFrames >= 2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
@@ -114,4 +192,12 @@ public class AudioEngineTests
|
||||
for (int i = 0; i < 15; i++) { send.FeedPcm(1, stereo, 2); send.ProcessCycle(); receive.ProcessCycle(); }
|
||||
Assert.True(energy > 100000);
|
||||
}
|
||||
|
||||
private sealed class ManualAudioClock : TimeProvider
|
||||
{
|
||||
private long milliseconds;
|
||||
public override long TimestampFrequency => 1000;
|
||||
public override long GetTimestamp() => milliseconds;
|
||||
internal void Advance(int value) => milliseconds += value;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user