Files
voice-cat/tests/VoiceCat.Tests/AudioEngineTests.cs
T
Talon f3ac779bf4
Build and test / test (macos-latest) (push) Waiting to run
Build and test / test (ubuntu-24.04) (push) Waiting to run
Build and test / test (windows-latest) (push) Waiting to run
Build and test / apple-client (push) Waiting to run
Survive changing networks and deepen the receive buffer
Media died silently whenever a client's source address changed. The relay bound a
peer's endpoint once and refused to move it, and the client stopped offering its
binding token after the first bind, so a Wi-Fi/cellular handover stranded the
session in both directions. Add an authenticated Rebind media frame: the binding
token travels in the clear for peer lookup only, and the AEAD tag over header and
token plus the peer's existing replay window are what authorize the move, so a
captured rebind cannot be replayed to redirect someone else's downlink. The client
rebuilds its UDP socket instead of retrying on one still pinned to a vanished
interface.

Nothing judged the control connection live: pings were sent and pongs ignored, so a
blackholed TCP path went unnoticed for minutes while the UI showed a live session.
Treat any server traffic as liveness and fail the connection when it stops, which
drives the existing reconnect.

The receive jitter buffer had lost its depth floor, so a channel without FEC or
DRED played out with no buffer at all and ordinary reordering became concealment.
Restore a one-frame floor, observe every arrival rather than only accepted ones —
a shallow buffer was rejecting the late arrivals that should have deepened it —
and allow playout to hold a frame so depth can follow a degrading link. A stalled
consumer now sheds the oldest queued packet instead of refusing the live talkspurt.

Add a deterministic network-impairment simulation covering bursty loss, jitter,
reordering, duplication, outages and a stalled consumer, a handover test against a
real relay, a replay test for the rebind path, and a blackholed control connection
driven through a freezable TCP proxy.
2026-09-24 19:15:16 +02:00

530 lines
26 KiB
C#

using System.Net;
using System.Net.Sockets;
using VoiceCat.Audio;
using VoiceCat.Codec;
using VoiceCat.Core;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
using VoiceCat.Transport;
using Voicecat.V1;
namespace VoiceCat.Tests;
public class AudioEngineTests
{
[Fact]
public void ManagedAudioWorkerUsesDeadlineSchedulingAtRealtimePriority()
{
string source = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Audio", "AudioEngine.cs"));
Assert.Contains("Priority = ThreadPriority.Highest", source);
Assert.Contains("WaitUntil(deadline);", source);
Assert.DoesNotContain("Thread.Sleep((int)Math.Ceiling(remaining))", source);
}
[Fact]
public void MediaSenderDoesNotDependOnCoalescedAsyncTimers()
{
string source = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Core", "ClientMediaTransport.cs"));
Assert.Contains("new Thread(Send)", source);
Assert.Contains("Volatile.Read(ref socket).Send(packet.AsSpan(0, size)", source);
Assert.DoesNotContain("Task.Delay(5", source);
Assert.DoesNotContain("SendAsync(packet.AsMemory", source);
}
[Fact]
public void MediaSenderWakesOnQueueSignalInsteadOfTimedPolling()
{
string source = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Core", "ClientMediaTransport.cs"));
Assert.Contains("sendReady.WaitOne(", source);
Assert.Contains("senderNapping", source);
Assert.DoesNotContain("Thread.Sleep(1);", source);
}
[Fact]
public async Task MediaSenderDeliversQueuedVoicePromptlyWithoutTimedPolling()
{
// A loopback "relay" answers the binding so the sender's own keepalive pass falls back to
// its five second schedule: only the queue signal can then deliver queued voice promptly,
// so a lost wake or a polling loop shows up here as a multi-second delay.
using var relay = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
relay.Bind(new IPEndPoint(IPAddress.Loopback, 0));
byte[] key = new byte[32]; Random.Shared.NextBytes(key);
using var crypto = new MediaSessionCrypto(new MediaEncryptor(key), new MediaDecryptor(key));
await using var transport = new ClientMediaTransport((IPEndPoint)relay.LocalEndPoint!, new byte[16], crypto, CancellationToken.None);
byte[] datagram = new byte[512];
using (var startup = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
{
SocketReceiveFromResult probe = await relay.ReceiveFromAsync(datagram, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), startup.Token);
Assert.Equal(VoiceFrameHeader.Size + 16, probe.ReceivedBytes);
byte[] keepalive = new byte[VoiceFrameHeader.Size];
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
await relay.SendToAsync(keepalive, SocketFlags.None, probe.RemoteEndPoint);
}
await transport.Bound.WaitAsync(TimeSpan.FromSeconds(5));
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
Assert.True(transport.TrySend(new(MediaFrameType.Voice, VoiceFrameFlags.None, 0, 7, 0, 0), new byte[80]));
await ReceiveVoiceAsync(relay, datagram, 1, TimeSpan.FromSeconds(2));
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2));
// A burst queued while the sender naps must drain in one wake, not one keepalive pass.
stopwatch.Restart();
for (uint i = 1; i <= 30; i++)
Assert.True(transport.TrySend(new(MediaFrameType.Voice, VoiceFrameFlags.None, 0, 7, 0, i * 960), new byte[80]));
await ReceiveVoiceAsync(relay, datagram, 30, TimeSpan.FromSeconds(2));
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2));
}
private static async Task ReceiveVoiceAsync(Socket relay, byte[] datagram, int count, TimeSpan timeout)
{
int voice = VoiceFrameHeader.Size + 80 + MediaEncryptor.TagSize;
using var deadline = new CancellationTokenSource(timeout);
while (count > 0)
{
SocketReceiveFromResult packet = await relay.ReceiveFromAsync(datagram, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), deadline.Token);
if (packet.ReceivedBytes == voice) count--;
}
}
[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 AdaptivePcmBufferPreservesPartialFrameAcrossCaptureCallbacks()
{
var buffer = new AdaptivePcmBuffer(1, 20);
short[] half = Enumerable.Range(0, 480).Select(value => (short)value).ToArray();
short[] full = new short[960];
Assert.True(buffer.TryWrite(half));
Assert.Equal(0, buffer.Read(full));
Assert.Equal(480, buffer.CountFrames);
Assert.True(buffer.TryWrite(half));
Assert.Equal(960, buffer.Read(full));
Assert.Equal(0, buffer.CountFrames);
Assert.Equal(half, full.AsSpan(0, 480).ToArray());
Assert.Equal(half, full.AsSpan(480, 480).ToArray());
}
[Theory]
[InlineData(20)]
[InlineData(40)]
[InlineData(60)]
public void AdaptivePcmBufferSustainsIosSizedCaptureCallbacks(int bufferMilliseconds)
{
var buffer = new AdaptivePcmBuffer(1, bufferMilliseconds);
short[] callback = new short[1_024], encoded = new short[960];
long producedFrames = 0;
int lateStarvation = 0, fullReads = 0;
// AVAudioEngine commonly delivers 1,024 frames every 21 1/3 ms while the codec owner
// requests 960 frames every 20 ms. Replay those independent clocks for two minutes.
for (long consumerFrame = 0; consumerFrame < 48_000L * 120; consumerFrame += 960)
{
while (producedFrames <= consumerFrame)
{
for (int i = 0; i < callback.Length; i++) callback[i] = (short)(producedFrames + i);
Assert.True(buffer.TryWrite(callback));
producedFrames += callback.Length;
}
int read = buffer.Read(encoded);
if (read == encoded.Length) fullReads++;
// A 20 ms target initially contains one 1,024-frame callback, so the codec's
// second deadline precedes the next callback by 1.33 ms. Re-priming once during
// that first second is expected; recurring starvation is the audible defect.
else if (consumerFrame >= 48_000) lateStarvation++;
}
Assert.Equal(0, lateStarvation);
Assert.True(fullReads > 5_000);
}
[Theory]
[InlineData(-1000)]
[InlineData(1000)]
public void AdaptivePcmBufferKeepsBurstingIosCaptureBoundedOverLongCalls(int partsPerMillion)
{
var buffer = new AdaptivePcmBuffer(1, 120, 65_536);
short[] callback = new short[4_800], encoded = new short[960];
long produced = 0;
int lateStarvation = 0;
for (long consumerFrame = 0; consumerFrame < 48_000L * 600; consumerFrame += 960)
{
while (produced / (1 + partsPerMillion / 1_000_000.0) <= consumerFrame)
{
Assert.True(buffer.TryWrite(callback));
produced += callback.Length;
}
if (buffer.Read(encoded) == 0 && consumerFrame >= 48_000) lateStarvation++;
Assert.InRange(buffer.CountFrames, 0, 20_000);
}
Assert.Equal(0, lateStarvation);
}
[Theory]
[InlineData(20)]
[InlineData(40)]
[InlineData(120)]
public void AdaptivePcmBufferResynchronizeDropsStallBacklogToTheTarget(int bufferMilliseconds)
{
var buffer = new AdaptivePcmBuffer(1, bufferMilliseconds, 65_536);
short[] chunk = new short[960], output = new short[960];
chunk.AsSpan().Fill(1234);
// A stalled consumer leaves every chunk queued: feed and drain rates are equal in steady
// state, so this backlog would otherwise sit at a fixed offset until overflow drops begin.
for (int i = 0; i < 50; i++) Assert.True(buffer.TryWrite(chunk));
Assert.Equal(50 * 960, buffer.CountFrames);
buffer.Resynchronize();
Assert.Equal(bufferMilliseconds * 48, buffer.CountFrames);
Assert.Equal(960, buffer.Read(output));
Assert.All(output, value => Assert.Equal(1234, value));
// At or below the target the resynchronization drops nothing.
buffer.Resynchronize();
Assert.Equal(bufferMilliseconds * 48 - 960, buffer.CountFrames);
}
[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);
LocalAudioDiagnostics diagnostic = engine.GetLocalDiagnostics(1);
Assert.Equal(3, diagnostic.Cycles); Assert.Equal(1, diagnostic.StarvedCycles); Assert.Equal(2, diagnostic.EncodedPackets);
for (int i = 0; i < 10; i++) engine.ProcessCycle();
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
Assert.True((sent[^1].Flags & VoiceFrameFlags.Marker) != 0);
}
[Fact]
public void AutomaticPacketLossUpdatesOnAudioOwnerAndManualStreamsIgnoreIt()
{
StreamInfo automatic = Stream();
automatic.Audio.PacketLossMode = PacketLossMode.PacketLossAutoBalanced;
using var engine = new AudioEngine((_, _, _, _) => true, false);
engine.AddLocalStream(automatic);
engine.SetExpectedPacketLoss(7);
Assert.Equal(20, engine.GetAppliedExpectedPacketLoss(1));
engine.ProcessCycle();
Assert.Equal(7, engine.GetAppliedExpectedPacketLoss(1));
engine.RemoveLocalStream(1);
engine.ProcessCycle();
engine.AddLocalStream(Stream());
engine.SetExpectedPacketLoss(4);
engine.ProcessCycle();
Assert.Equal(20, engine.GetAppliedExpectedPacketLoss(1));
}
[Theory]
[InlineData(5)] [InlineData(10)] [InlineData(20)] [InlineData(40)] [InlineData(60)]
public void RecoveryLookaheadTracksChannelFrameDuration(int frameMilliseconds)
{
// One frame of standing depth plus one of recovery lookahead. The depth floor keeps a
// quiet link from playing out with no buffer at all, where reordering alone conceals.
using var stream = new ReceiveStream(2, Stream(frameMilliseconds));
Assert.Equal(frameMilliseconds * 96, stream.TargetDepthSamples);
StreamInfo plain = Stream(frameMilliseconds); plain.Audio.Fec = false;
using var bare = new ReceiveStream(2, plain);
Assert.Equal(frameMilliseconds * 48, bare.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)]
public void LostFramesUseDredThenFecBeforeBoundedPlc(bool useDred)
{
using var receive = new ReceiveStream(2, Stream(dred: useDred));
using var encoder = new OpusEncoder(new() { DeepRedundancy = useDred, ForwardErrorCorrection = true, ExpectedPacketLossPercent = 30, Complexity = 10, Bitrate = 64000 });
byte[] packet = new byte[1275]; short[] tone = Tone(); int[] output = new int[1920];
for (uint i = 0; i < 40; i++)
{
CodecTests.FillTone(tone, 960, 1, 48000, (int)i);
int size = encoder.Encode(tone, packet);
if (i != 25 && i != 30 && i != 35) receive.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, size));
output.AsSpan().Clear(); receive.Mix(output, false, null);
}
if (useDred) Assert.True(receive.DredFrames > 0); else Assert.True(receive.FecFrames > 0);
for (int i = 0; i < 20; i++) { output.AsSpan().Clear(); receive.Mix(output, false, null); }
Assert.True(receive.ConcealedFrames > 0); Assert.All(output, sample => Assert.Equal(0, sample));
}
[Fact]
public void PcmRingDropsWholeFramesWhenFullAndPreservesOrderAcrossWraps()
{
var ring = new PcmRing(8); short[] output = new short[8];
for (int i = 0; i < 100; i++)
{
Assert.True(ring.TryWrite([1, 2, 3, 4, 5, 6])); Assert.False(ring.TryWrite([7, 8, 9]));
Assert.Equal(4, ring.Read(output.AsSpan(0, 4))); Assert.Equal(new short[] { 1, 2, 3, 4 }, output[..4]);
Assert.True(ring.TryWrite([7, 8])); Assert.Equal(4, ring.Read(output)); Assert.Equal(new short[] { 5, 6, 7, 8 }, output[..4]); Assert.Equal(0, ring.Count);
}
}
internal static StreamInfo Stream(int frame = 20, bool stereo = false, bool dred = false) => new()
{
StreamId = 1, Ssrc = 42, Kind = StreamKind.StreamMic,
Audio = new() { SampleRate = 48000, BitrateBps = 32000, FrameMs = (uint)frame, Complexity = 5,
Mode = stereo ? ChannelMode.ModeStereo : ChannelMode.ModeMono, Fec = true, ExpectedPacketLoss = 20, Dred = dred }
};
private static short[] Tone(int channels = 1)
{
var pcm = new short[960 * channels];
for (int i = 0; i < 960; i++) for (int c = 0; c < channels; c++) pcm[i * channels + c] = (short)(Math.Sin(i * 2 * Math.PI * (c == 0 ? 440 : 660) / 48000) * 8000);
return pcm;
}
[Theory]
[InlineData(5, false)] [InlineData(10, false)] [InlineData(20, false)] [InlineData(40, false)] [InlineData(60, false)] [InlineData(20, true)]
public void ReframedEncodedPcmIsDecodedAndMixedForMonoAndStereo(int frame, bool stereo)
{
StreamInfo stream = Stream(frame, stereo);
using var receive = new AudioEngine((_, _, _, _) => true, false);
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { stream } }], 1, 1);
using var send = new AudioEngine((ssrc, timestamp, payload, flags) => { receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload); return true; }, false);
send.InputMode = AudioInputMode.AlwaysOn; send.AddLocalStream(stream, stereo ? 2 : 1);
long energy = 0; int sinkChannels = 0;
receive.MixedPcm += pcm => { foreach (short sample in pcm) energy += Math.Abs((int)sample); };
receive.StreamPcm += (_, _, _, channels) => sinkChannels = channels;
short[] tone = Tone(stereo ? 2 : 1);
for (int i = 0; i < 30; i++) { Assert.True(send.FeedPcm(1, tone, stereo ? 2 : 1)); send.ProcessCycle(); receive.ProcessCycle(); }
Assert.True(energy > 100000); Assert.Equal(stereo ? 2 : 1, sinkChannels);
receive.SetRemotePlayback(2, 1, 1, true, false); energy = 0;
for (int i = 0; i < 5; i++) { send.FeedPcm(1, tone, stereo ? 2 : 1); send.ProcessCycle(); receive.ProcessCycle(); }
Assert.Equal(0, energy);
}
[Fact]
public void AudioCyclesAllocateZeroBytesWithEncodeDecodeStereoNoiseReductionAndMixing()
{
StreamInfo stream = Stream(20, true);
using var receive = new AudioEngine((_, _, _, _) => true, false);
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { stream } }], 1, 1);
receive.SetRemotePlayback(2, 1, 0.8f, false, true);
using var send = new AudioEngine((ssrc, timestamp, payload, flags) => { receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload); return true; }, false);
send.InputMode = AudioInputMode.AlwaysOn; send.InputNoiseReduction = true; send.AddLocalStream(stream, 2);
short[] tone = Tone(2);
for (int i = 0; i < 30; i++) { send.FeedPcm(1, tone, 2); send.ProcessCycle(); receive.ProcessCycle(); }
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100; i++) { send.FeedPcm(1, tone, 2); send.ProcessCycle(); receive.ProcessCycle(); }
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
}
[Fact]
public void DeviceClockedRunCyclePacesMixAndCaptureWithNoWorkerThread()
{
var sent = new List<(uint Timestamp, VoiceFrameFlags Flags)>(512);
StreamInfo stream = Stream();
using var receive = new AudioEngine((_, _, _, _) => true, false);
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { stream } }], 1, 1);
using var send = new AudioEngine((ssrc, timestamp, payload, flags) =>
{
sent.Add((timestamp, flags));
receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload);
return true;
}, false) { InputMode = AudioInputMode.AlwaysOn, DeviceBufferMilliseconds = 20 };
send.AddLocalStream(stream);
short[] tone = Tone(); long energy = 0;
receive.MixedPcm += pcm => { foreach (short sample in pcm) energy += Math.Abs((int)sample); };
// Each iteration is one 20 ms render demand quantum: feed the capture handoff, then pull
// both mixes, exactly as the iOS render callback drives its device-clocked engine.
for (int i = 0; i < 30; i++) { Assert.True(send.FeedPcm(1, tone, 1)); send.RunCycle(); receive.RunCycle(); }
Assert.Equal(30, send.GetLocalDiagnostics(1).Cycles);
Assert.Equal(0, send.GetLocalDiagnostics(1).StarvedCycles);
Assert.Equal(30, sent.Count);
Assert.True(energy > 100000);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100; i++) { send.FeedPcm(1, tone, 1); send.RunCycle(); receive.RunCycle(); }
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
}
[Fact]
public void RunCycleContainsFailuresInsteadOfThrowingIntoRealtimeCallbacks()
{
using var engine = new AudioEngine((_, _, _, _) => throw new InvalidOperationException("boom"), false)
{ InputMode = AudioInputMode.AlwaysOn, DeviceBufferMilliseconds = 20 };
engine.AddLocalStream(Stream());
Assert.True(engine.FeedPcm(1, Tone(), 1));
engine.RunCycle();
Assert.IsType<InvalidOperationException>(engine.Failure);
engine.RunCycle();
Assert.Equal(1, engine.GetLocalDiagnostics(1).Cycles);
}
[Fact]
public async Task DeviceClockedClientLeavesMixCadenceToTheAudioCallback()
{
string directory = Path.Combine(Path.GetTempPath(), $"voicecat-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
await using var paced = new VoiceCatClient(tofuStorePath: Path.Combine(directory, "paced.txt"));
await using var pulled = new VoiceCatClient(tofuStorePath: Path.Combine(directory, "pulled.txt"), deviceClockedAudio: true);
paced.Audio.AddLocalStream(Stream());
pulled.Audio.AddLocalStream(Stream());
await Task.Delay(250);
Assert.True(paced.Audio.GetLocalDiagnostics(1).Cycles > 0);
Assert.Equal(0, pulled.Audio.GetLocalDiagnostics(1).Cycles);
pulled.Audio.RunCycle();
Assert.Equal(1, pulled.Audio.GetLocalDiagnostics(1).Cycles);
}
finally
{
try { Directory.Delete(directory, true); }
catch (IOException) { } catch (UnauthorizedAccessException) { }
}
}
[Fact]
public void ManagedAudioWorkerResynchronizesProducerBacklogAfterStalls()
{
string source = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Audio", "AudioEngine.cs"));
string buffer = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Audio", "AdaptivePcmBuffer.cs"));
Assert.Contains("stream.Input.Resynchronize()", source);
Assert.Contains("public void Resynchronize()", buffer);
}
[Fact]
public void ResynchronizeInputsDropsStalledBacklogToTheBufferTarget()
{
using var send = new AudioEngine((_, _, _, _) => true, false);
send.DeviceBufferMilliseconds = 40; send.AddLocalStream(Stream());
short[] mono = Tone();
for (int i = 0; i < 40; i++) send.FeedPcm(1, mono, 1);
Assert.True(send.GetLocalDiagnostics(1).BufferedFrames > 1920 * 2);
send.ResynchronizeInputs();
Assert.Equal(1920, send.GetLocalDiagnostics(1).BufferedFrames);
send.ProcessCycle();
Assert.Equal(0, send.GetLocalDiagnostics(1).StarvedCycles);
}
[Fact]
public void RemotePlaybackSettingsAreIndependentForEachUserStream()
{
StreamInfo microphone = Stream();
StreamInfo screen = Stream(); screen.StreamId = 2; screen.Ssrc = 43; screen.Kind = StreamKind.StreamScreenAudio; screen.Label = "Screen audio";
using var receive = new AudioEngine((_, _, _, _) => true, false);
receive.SetRemoteStreams([new() { Id = 7, ChannelId = 1, Streams = { microphone, screen } }], 1, 1);
receive.SetRemotePlayback(7, microphone.StreamId, 0.5f, true, true);
receive.SetRemotePlayback(7, screen.StreamId, 1.75f, false, false);
Assert.Equal((0.5f, true, true), receive.GetRemotePlayback(7, microphone.StreamId));
Assert.Equal((1.75f, false, false), receive.GetRemotePlayback(7, screen.StreamId));
}
[Fact]
public void JitterBacklogIsBoundedAndPlcEventuallyBecomesSilence()
{
var info = Stream(); using var stream = new ReceiveStream(2, info); using var encoder = new OpusEncoder(new() { Bitrate = 32000 });
byte[] packet = new byte[1275]; int length = encoder.Encode(Tone(), packet); int[] output = new int[1920];
for (uint i = 0; i < 64; i++) stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, length));
stream.Mix(output, false, null); Assert.InRange(stream.Depth, 0, 6);
for (int i = 0; i < 20; i++) { output.AsSpan().Clear(); stream.Mix(output, false, null); }
Assert.All(output, value => Assert.Equal(0, value)); Assert.InRange(stream.ConcealedFrames, 1, 16);
}
[Fact]
public void PttResumeAndCaptureChannelChangesKeepTimestampProgressAndAudio()
{
var info = Stream(60, true); using var receive = new AudioEngine((_, _, _, _) => true, false);
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { info } }], 1, 1);
using var send = new AudioEngine((ssrc, timestamp, payload, flags) => { receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload); return true; }, false);
send.InputMode = AudioInputMode.PushToTalk; send.PushToTalk = true; send.AddLocalStream(info);
short[] mono = Tone(), stereo = Tone(2); long energy = 0;
receive.MixedPcm += pcm => { foreach (short value in pcm) energy += Math.Abs((int)value); };
for (int i = 0; i < 15; i++) { send.FeedPcm(1, mono, 1); send.ProcessCycle(); receive.ProcessCycle(); }
send.PushToTalk = false;
for (int i = 0; i < 16; i++) { send.FeedPcm(1, mono, 1); send.ProcessCycle(); receive.ProcessCycle(); }
send.SetCaptureChannels(1, 2); send.PushToTalk = true; energy = 0;
for (int i = 0; i < 15; i++) { send.FeedPcm(1, stereo, 2); send.ProcessCycle(); receive.ProcessCycle(); }
Assert.True(energy > 100000);
}
private static string FindRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VoiceCat.slnx")))
directory = directory.Parent;
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
}
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;
}
}