2026-09-16 16:48:06 +02:00
|
|
|
using VoiceCat.Audio;
|
|
|
|
|
using VoiceCat.Codec;
|
|
|
|
|
using VoiceCat.Protocol;
|
|
|
|
|
using Voicecat.V1;
|
|
|
|
|
|
|
|
|
|
namespace VoiceCat.Tests;
|
|
|
|
|
|
|
|
|
|
public class AudioEngineTests
|
|
|
|
|
{
|
2026-09-21 14:14:15 +02:00
|
|
|
[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("socket.Send(packet.AsSpan(0, size)", source);
|
|
|
|
|
Assert.DoesNotContain("Task.Delay(5", source);
|
|
|
|
|
Assert.DoesNotContain("SendAsync(packet.AsMemory", source);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 16:25:30 +02:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 14:14:15 +02:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 16:25:30 +02:00
|
|
|
[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);
|
2026-09-21 14:14:15 +02:00
|
|
|
LocalAudioDiagnostics diagnostic = engine.GetLocalDiagnostics(1);
|
|
|
|
|
Assert.Equal(3, diagnostic.Cycles); Assert.Equal(1, diagnostic.StarvedCycles); Assert.Equal(2, diagnostic.EncodedPackets);
|
2026-09-20 16:25:30 +02:00
|
|
|
for (int i = 0; i < 10; i++) engine.ProcessCycle();
|
|
|
|
|
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
|
|
|
|
|
Assert.True((sent[^1].Flags & VoiceFrameFlags.Marker) != 0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 16:54:49 +02:00
|
|
|
[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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 16:25:30 +02:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 16:48:06 +02:00
|
|
|
[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 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, 10);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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);
|
|
|
|
|
}
|
2026-09-20 16:25:30 +02:00
|
|
|
|
2026-09-21 14:14:15 +02:00
|
|
|
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.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 16:25:30 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-09-16 16:48:06 +02:00
|
|
|
}
|