Port managed client audio and Windows application
This commit is contained in:
@@ -5,8 +5,13 @@
|
||||
<Project Path="src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
|
||||
<Project Path="src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
|
||||
<Project Path="src/VoiceCat.Server/VoiceCat.Server.csproj" />
|
||||
<Project Path="src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
<Project Path="src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/VoiceCat.Tests/VoiceCat.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/clients/">
|
||||
<Project Path="../clients/windows/VoiceCat.Managed/VoiceCat.Managed.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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 IAudioDeviceBackend
|
||||
{
|
||||
IReadOnlyList<AudioDeviceInfo> Enumerate(bool input);
|
||||
IAudioCapture OpenCapture(string? deviceId, bool loopback, CapturePcmHandler pcm);
|
||||
IAudioPlayback OpenPlayback(string? deviceId = null);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Diagnostics;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Audio;
|
||||
|
||||
public sealed class AudioEngine : IDisposable
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly EncodedVoiceSender sender;
|
||||
private readonly int[] mixed = new int[1920];
|
||||
private readonly short[] output = new short[1920];
|
||||
private Routes routes = new([], [], 0);
|
||||
private readonly List<(IDisposable Stream, long Epoch)> retired = [];
|
||||
private long completedEpoch;
|
||||
private readonly CancellationTokenSource stop = new();
|
||||
private readonly Task maintenance;
|
||||
private Thread? worker;
|
||||
private int disposed;
|
||||
public uint SampleClock { get; private set; }
|
||||
public event MixedPcmHandler? MixedPcm;
|
||||
public event PcmStreamHandler? StreamPcm;
|
||||
public volatile float InputGain = 1, OutputGain = 1, VadThreshold = 0.02f;
|
||||
public volatile bool InputNoiseReduction, MicMuted, Deafened, PushToTalk;
|
||||
public volatile AudioInputMode InputMode = AudioInputMode.VoiceActivation;
|
||||
public Exception? Failure { get; private set; }
|
||||
|
||||
public AudioEngine(EncodedVoiceSender sender, bool startWorker = true)
|
||||
{
|
||||
this.sender = sender;
|
||||
maintenance = MaintainAsync();
|
||||
if (startWorker)
|
||||
{
|
||||
worker = new Thread(Work) { IsBackground = true, Name = "VoiceCat managed audio" };
|
||||
worker.Start();
|
||||
}
|
||||
}
|
||||
|
||||
public void AddLocalStream(StreamInfo info, int captureChannels = 1)
|
||||
{
|
||||
if (captureChannels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(captureChannels));
|
||||
lock (gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed != 0, this);
|
||||
var stream = new LocalStream(info, captureChannels);
|
||||
Routes previous = routes;
|
||||
var locals = previous.Local.Where(s => s.Info.StreamId != info.StreamId).Append(stream).ToArray();
|
||||
Publish(locals, previous.Remote);
|
||||
}
|
||||
}
|
||||
public void RemoveLocalStream(uint streamId)
|
||||
{
|
||||
lock (gate) Publish(routes.Local.Where(s => s.Info.StreamId != streamId).ToArray(), routes.Remote);
|
||||
}
|
||||
public void SetCaptureChannels(uint streamId, int channels)
|
||||
{
|
||||
if (channels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(channels));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetRemoteStreams(IReadOnlyList<User> users, uint selfId, uint channelId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var next = new List<ReceiveStream>();
|
||||
foreach (User user in users.Where(u => u.Id != selfId && u.ChannelId == channelId))
|
||||
foreach (StreamInfo info in user.Streams)
|
||||
{
|
||||
var previous = routes.Remote.FirstOrDefault(s => s.UserId == user.Id && s.Info.Equals(info));
|
||||
next.Add(previous ?? new ReceiveStream(user.Id, info));
|
||||
}
|
||||
Publish(routes.Local, next.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public bool FeedPcm(uint streamId, ReadOnlySpan<short> pcm, int channels)
|
||||
{
|
||||
foreach (LocalStream stream in Volatile.Read(ref routes).Local)
|
||||
if (stream.Info.StreamId == streamId) return stream.Feed(pcm, channels);
|
||||
return false;
|
||||
}
|
||||
public void Receive(VoiceFrameHeader header, ReadOnlySpan<byte> packet)
|
||||
{
|
||||
foreach (ReceiveStream stream in Volatile.Read(ref routes).Remote) if (stream.Info.Ssrc == header.Ssrc) { stream.Enqueue(header, packet); return; }
|
||||
}
|
||||
public (float Level, bool Talking) GetLocalLevel(uint streamId)
|
||||
{
|
||||
foreach (LocalStream stream in Volatile.Read(ref routes).Local) if (stream.Info.StreamId == streamId) return (stream.Level, stream.Talking);
|
||||
return default;
|
||||
}
|
||||
public void SetRemotePlayback(uint userId, uint streamId, float gain, bool muted, bool noiseReduction)
|
||||
{
|
||||
if (!float.IsFinite(gain) || gain < 0 || gain > 4) throw new ArgumentOutOfRangeException(nameof(gain));
|
||||
foreach (var stream in Volatile.Read(ref routes).Remote)
|
||||
if (stream.UserId == userId && stream.Info.StreamId == streamId) { stream.Gain = gain; stream.Muted = muted; stream.NoiseReduction = noiseReduction; return; }
|
||||
}
|
||||
public (float Gain, bool Muted, bool NoiseReduction)? GetRemotePlayback(uint userId, uint streamId)
|
||||
{
|
||||
foreach (var stream in Volatile.Read(ref routes).Remote) if (stream.UserId == userId && stream.Info.StreamId == streamId) return (stream.Gain, stream.Muted, stream.NoiseReduction);
|
||||
return null;
|
||||
}
|
||||
|
||||
private void Publish(LocalStream[] local, ReceiveStream[] remote)
|
||||
{
|
||||
Routes previous = routes; var next = new Routes(local, remote, previous.Epoch + 1);
|
||||
foreach (var stream in previous.Local) if (!local.Contains(stream)) retired.Add((stream, next.Epoch));
|
||||
foreach (var stream in previous.Remote) if (!remote.Contains(stream)) retired.Add((stream, next.Epoch));
|
||||
Volatile.Write(ref routes, next);
|
||||
}
|
||||
|
||||
// One audio owner calls this. No allocation, waiting, lock, registry mutation or disposal
|
||||
// occurs inside a mix cycle. Callbacks receive borrowed spans and must follow that rule.
|
||||
internal void ProcessCycle()
|
||||
{
|
||||
Routes current = Volatile.Read(ref routes);
|
||||
mixed.AsSpan().Clear();
|
||||
foreach (var stream in current.Local) stream.Process(this, sender);
|
||||
foreach (var stream in current.Remote) stream.Mix(mixed, Deafened, StreamPcm);
|
||||
float gain = Deafened ? 0 : OutputGain;
|
||||
for (int i = 0; i < output.Length; i++) output[i] = (short)Math.Clamp((int)(mixed[i] * gain), short.MinValue, short.MaxValue);
|
||||
MixedPcm?.Invoke(output);
|
||||
SampleClock = unchecked(SampleClock + 960);
|
||||
Volatile.Write(ref completedEpoch, current.Epoch);
|
||||
}
|
||||
|
||||
private void Work()
|
||||
{
|
||||
long deadline = Stopwatch.GetTimestamp();
|
||||
try
|
||||
{
|
||||
while (!stop.IsCancellationRequested)
|
||||
{
|
||||
ProcessCycle();
|
||||
deadline += Stopwatch.Frequency / 50;
|
||||
double remaining = (deadline - Stopwatch.GetTimestamp()) * 1000.0 / Stopwatch.Frequency;
|
||||
if (remaining > 0) Thread.Sleep((int)Math.Ceiling(remaining));
|
||||
else if (remaining < -100) deadline = Stopwatch.GetTimestamp();
|
||||
}
|
||||
}
|
||||
catch (Exception exception) { Failure = exception; stop.Cancel(); }
|
||||
}
|
||||
private async Task MaintainAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(20));
|
||||
while (await timer.WaitForNextTickAsync(stop.Token).ConfigureAwait(false))
|
||||
lock (gate)
|
||||
for (int i = retired.Count - 1; i >= 0; i--)
|
||||
if (retired[i].Epoch <= Volatile.Read(ref completedEpoch)) { retired[i].Stream.Dispose(); retired.RemoveAt(i); }
|
||||
}
|
||||
catch (OperationCanceledException) when (stop.IsCancellationRequested) { }
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
|
||||
stop.Cancel(); worker?.Join(); maintenance.GetAwaiter().GetResult();
|
||||
lock (gate)
|
||||
{
|
||||
foreach (var stream in routes.Local) stream.Dispose(); foreach (var stream in routes.Remote) stream.Dispose();
|
||||
foreach (var stream in retired) stream.Stream.Dispose(); retired.Clear();
|
||||
routes = new([], [], routes.Epoch + 1);
|
||||
}
|
||||
}
|
||||
private sealed record Routes(LocalStream[] Local, ReceiveStream[] Remote, long Epoch);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using VoiceCat.Codec;
|
||||
using VoiceCat.Dsp;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
using OpusApplication = Voicecat.V1.OpusApplication;
|
||||
|
||||
namespace VoiceCat.Audio;
|
||||
|
||||
public enum AudioInputMode { VoiceActivation, PushToTalk, AlwaysOn }
|
||||
public delegate bool EncodedVoiceSender(uint ssrc, uint timestamp, ReadOnlySpan<byte> payload, VoiceFrameFlags flags);
|
||||
public delegate void PcmStreamHandler(uint userId, uint streamId, ReadOnlySpan<short> pcm, int channels);
|
||||
public delegate void MixedPcmHandler(ReadOnlySpan<short> stereoPcm);
|
||||
|
||||
internal sealed class LocalStream : IDisposable
|
||||
{
|
||||
internal readonly StreamInfo Info;
|
||||
internal readonly int CaptureChannels;
|
||||
internal readonly PcmRing Input = new(16384);
|
||||
internal volatile float Level;
|
||||
internal volatile bool Talking;
|
||||
private readonly OpusEncoder encoder;
|
||||
private readonly RnnoiseProcessor left, right;
|
||||
private readonly EnergyVadProcessor vad = new();
|
||||
private readonly short[] capture = new short[1920], wire = new short[5760 * 2], mono = new short[960];
|
||||
private readonly byte[] packet = new byte[1275];
|
||||
private readonly short[] converted = new short[16384];
|
||||
private int feeding;
|
||||
private int buffered;
|
||||
private uint timestamp;
|
||||
private bool wasTransmitting, marker;
|
||||
|
||||
internal bool Feed(ReadOnlySpan<short> pcm, int channels)
|
||||
{
|
||||
if (channels is not (1 or 2) || pcm.Length % channels != 0 || pcm.Length / channels * CaptureChannels > converted.Length || Interlocked.CompareExchange(ref feeding, 1, 0) != 0) return false;
|
||||
try
|
||||
{
|
||||
if (channels == CaptureChannels) return Input.TryWrite(pcm);
|
||||
int frames = pcm.Length / channels;
|
||||
for (int i = 0; i < frames; i++)
|
||||
if (CaptureChannels == 1) converted[i] = (short)(((int)pcm[i * 2] + pcm[i * 2 + 1]) / 2);
|
||||
else { converted[2 * i] = pcm[i]; converted[2 * i + 1] = pcm[i]; }
|
||||
return Input.TryWrite(converted.AsSpan(0, frames * CaptureChannels));
|
||||
}
|
||||
finally { Volatile.Write(ref feeding, 0); }
|
||||
}
|
||||
|
||||
internal LocalStream(StreamInfo stream, int captureChannels)
|
||||
{
|
||||
Info = stream.Clone(); CaptureChannels = captureChannels;
|
||||
encoder = new(new()
|
||||
{
|
||||
Channels = stream.Audio.Mode == ChannelMode.ModeStereo ? 2 : 1,
|
||||
FrameDurationMilliseconds = checked((int)stream.Audio.FrameMs), MaximumBandwidthHz = checked((int)stream.Audio.SampleRate),
|
||||
Bitrate = checked((int)stream.Audio.BitrateBps), Complexity = checked((int)stream.Audio.Complexity),
|
||||
ExpectedPacketLossPercent = checked((int)stream.Audio.ExpectedPacketLoss), ForwardErrorCorrection = stream.Audio.Fec,
|
||||
DiscontinuousTransmission = stream.Audio.Dtx, DeepRedundancy = stream.Audio.Dred,
|
||||
Application = stream.Audio.Application switch { OpusApplication.OpusAudio => VoiceCat.Codec.OpusApplication.Audio, OpusApplication.OpusLowdelay => VoiceCat.Codec.OpusApplication.LowDelay, _ => VoiceCat.Codec.OpusApplication.Voip }
|
||||
});
|
||||
try { left = new(); } catch { encoder.Dispose(); throw; }
|
||||
try { right = new(); } catch { left.Dispose(); encoder.Dispose(); throw; }
|
||||
}
|
||||
|
||||
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 (buffered == 0) timestamp = engine.SampleClock;
|
||||
Input.Read(input);
|
||||
bool mic = Info.Kind == StreamKind.StreamMic;
|
||||
if (mic && engine.InputNoiseReduction)
|
||||
{
|
||||
if (CaptureChannels == 1) left.Process(input);
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < 960; i++) mono[i] = input[2 * i]; left.Process(mono);
|
||||
for (int i = 0; i < 960; i++) input[2 * i] = mono[i];
|
||||
for (int i = 0; i < 960; i++) mono[i] = input[2 * i + 1]; right.Process(mono);
|
||||
for (int i = 0; i < 960; i++) input[2 * i + 1] = mono[i];
|
||||
}
|
||||
}
|
||||
float gain = mic ? engine.InputGain : 1;
|
||||
double energy = 0;
|
||||
for (int i = 0; i < input.Length; i++) { input[i] = (short)Math.Clamp((int)(input[i] * gain), short.MinValue, short.MaxValue); energy += (double)input[i] * input[i]; }
|
||||
Level = (float)(Math.Sqrt(energy / input.Length) / 32768);
|
||||
vad.Threshold = engine.VadThreshold;
|
||||
bool transmit = !mic || !engine.MicMuted && engine.InputMode switch
|
||||
{
|
||||
AudioInputMode.AlwaysOn => true, AudioInputMode.PushToTalk => engine.PushToTalk,
|
||||
_ => vad.Process(input)
|
||||
};
|
||||
Talking = transmit && Level > 0.001f;
|
||||
if (!transmit) { buffered = 0; wasTransmitting = false; return; }
|
||||
if (!wasTransmitting) marker = true;
|
||||
wasTransmitting = true;
|
||||
int channels = encoder.Options.Channels;
|
||||
for (int i = 0; i < 960; i++)
|
||||
{
|
||||
if (channels == 1) wire[buffered + i] = CaptureChannels == 1 ? input[i] : (short)(((int)input[2 * i] + input[2 * i + 1]) / 2);
|
||||
else { wire[buffered + 2 * i] = input[i * CaptureChannels]; wire[buffered + 2 * i + 1] = input[i * CaptureChannels + CaptureChannels - 1]; }
|
||||
}
|
||||
buffered += 960 * channels;
|
||||
int frame = encoder.Options.SamplesPerChannel * channels;
|
||||
while (buffered >= frame)
|
||||
{
|
||||
int length = encoder.Encode(wire.AsSpan(0, frame), packet);
|
||||
VoiceFrameFlags flags = (Info.Audio.Fec ? VoiceFrameFlags.FecPresent : VoiceFrameFlags.None) | (marker ? VoiceFrameFlags.Marker : VoiceFrameFlags.None);
|
||||
sender(Info.Ssrc, timestamp, packet.AsSpan(0, length), flags); marker = false;
|
||||
timestamp = unchecked(timestamp + (uint)encoder.Options.SamplesPerChannel);
|
||||
buffered -= frame;
|
||||
wire.AsSpan(frame, buffered).CopyTo(wire);
|
||||
}
|
||||
}
|
||||
public void Dispose() { encoder.Dispose(); left.Dispose(); right.Dispose(); }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace VoiceCat.Audio;
|
||||
|
||||
// Single consumer, non-waiting producer gate. Whole writes either fit or drop, so
|
||||
// channels remain aligned and a capture thread never waits for a mixer/network owner.
|
||||
public sealed class PcmRing
|
||||
{
|
||||
private readonly short[] samples;
|
||||
private readonly int mask;
|
||||
private int read, written, producer;
|
||||
public PcmRing(int capacity = 32768)
|
||||
{
|
||||
if (capacity < 2 || (capacity & (capacity - 1)) != 0) throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
samples = new short[capacity]; mask = capacity - 1;
|
||||
}
|
||||
public int Count => unchecked(Volatile.Read(ref written) - Volatile.Read(ref read));
|
||||
public bool TryWrite(ReadOnlySpan<short> source)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref producer, 1, 0) != 0) return false;
|
||||
try
|
||||
{
|
||||
int index = written;
|
||||
if (source.Length > samples.Length - unchecked(index - Volatile.Read(ref read))) return false;
|
||||
for (int i = 0; i < source.Length; i++) samples[(index + i) & mask] = source[i];
|
||||
Volatile.Write(ref written, unchecked(index + source.Length)); return true;
|
||||
}
|
||||
finally { Volatile.Write(ref producer, 0); }
|
||||
}
|
||||
public int Read(Span<short> destination)
|
||||
{
|
||||
int index = read, count = Math.Min(destination.Length, unchecked(Volatile.Read(ref written) - index));
|
||||
for (int i = 0; i < count; i++) destination[i] = samples[(index + i) & mask];
|
||||
Volatile.Write(ref read, unchecked(index + count)); return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using VoiceCat.Codec;
|
||||
using VoiceCat.Dsp;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Audio;
|
||||
|
||||
internal sealed class ReceiveStream : IDisposable
|
||||
{
|
||||
internal readonly uint UserId;
|
||||
internal readonly StreamInfo Info;
|
||||
internal volatile float Gain = 1;
|
||||
internal volatile bool Muted, NoiseReduction;
|
||||
private readonly OpusDecoder decoder;
|
||||
private readonly OpusDeepRedundancy? dred;
|
||||
private readonly RnnoiseProcessor left, right;
|
||||
private readonly short[] pcm = new short[5760 * 2];
|
||||
private readonly short[] mono = new short[960];
|
||||
private readonly short[] block = new short[1920];
|
||||
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 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 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;
|
||||
internal int Depth => count;
|
||||
internal int ConcealedFrames { get; private set; }
|
||||
internal int DredFrames { get; private set; }
|
||||
internal int FecFrames { get; private set; }
|
||||
|
||||
internal ReceiveStream(uint userId, StreamInfo info)
|
||||
{
|
||||
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();
|
||||
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);
|
||||
decoder = new(48000, channels);
|
||||
try { left = new(); } catch { decoder.Dispose(); throw; }
|
||||
try { right = new(); } catch { left.Dispose(); decoder.Dispose(); throw; }
|
||||
try { if (info.Audio.Dred) dred = new(); } catch { right.Dispose(); left.Dispose(); decoder.Dispose(); throw; }
|
||||
}
|
||||
|
||||
// Only the network receive owner calls this; mixer alone consumes.
|
||||
internal bool Enqueue(VoiceFrameHeader header, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
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;
|
||||
Volatile.Write(ref written, unchecked(index + 1)); return true;
|
||||
}
|
||||
|
||||
private void Drain()
|
||||
{
|
||||
while (read != Volatile.Read(ref written))
|
||||
{
|
||||
int source = read & 63; uint timestamp = headers[source].Timestamp;
|
||||
if (!hasTimestamp) { hasTimestamp = true; expected = timestamp; }
|
||||
int delta = unchecked((int)(timestamp - expected));
|
||||
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;
|
||||
}
|
||||
bool duplicate = false;
|
||||
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == timestamp) duplicate = true;
|
||||
if ((!started || delta >= 0) && delta % frameSamples == 0 && !duplicate)
|
||||
{
|
||||
if (count >= maximumDepth)
|
||||
{
|
||||
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];
|
||||
}
|
||||
Volatile.Write(ref read, unchecked(read + 1));
|
||||
}
|
||||
}
|
||||
|
||||
private int Oldest()
|
||||
{
|
||||
int oldest = -1;
|
||||
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && (oldest < 0 || unchecked((int)(timestamps[i] - timestamps[oldest])) < 0)) oldest = i;
|
||||
return oldest;
|
||||
}
|
||||
|
||||
private void Decode()
|
||||
{
|
||||
available = frameSamples; offset = 0;
|
||||
int found = -1;
|
||||
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == expected) { found = i; break; }
|
||||
bool decoded = false;
|
||||
if (found >= 0)
|
||||
{
|
||||
decoded = decoder.TryDecode(jitter[found].AsSpan(0, sizes[found]), pcm, frameSamples, out int result) && result == frameSamples;
|
||||
sizes[found] = 0; count--; missing = decoded ? 0 : missing + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
int next = Oldest(); missing++;
|
||||
if (next >= 0 && unchecked((int)(timestamps[next] - expected)) == frameSamples)
|
||||
{
|
||||
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++; }
|
||||
}
|
||||
}
|
||||
int maximumConcealment = Math.Max(1, 200 / (int)Info.Audio.FrameMs);
|
||||
if (!decoded && missing <= maximumConcealment)
|
||||
{
|
||||
decoded = decoder.TryDecode([], pcm, frameSamples, out _); ConcealedFrames++;
|
||||
}
|
||||
if (!decoded || missing > maximumConcealment) pcm.AsSpan(0, frameSamples * channels).Clear();
|
||||
expected = unchecked(expected + (uint)frameSamples);
|
||||
}
|
||||
|
||||
internal void Mix(Span<int> output, bool deafened, PcmStreamHandler? sink)
|
||||
{
|
||||
Drain();
|
||||
if (!started)
|
||||
{
|
||||
waiting++;
|
||||
if (!hasTimestamp || count < Math.Min(3, maximumDepth) && waiting < 3) return;
|
||||
expected = timestamps[Oldest()]; started = true;
|
||||
}
|
||||
int copied = 0;
|
||||
while (copied < 960)
|
||||
{
|
||||
if (available == 0) Decode();
|
||||
int take = Math.Min(960 - copied, available);
|
||||
var decoded = pcm.AsSpan(offset * channels, take * channels);
|
||||
decoded.CopyTo(block.AsSpan(copied * channels));
|
||||
available -= take; offset += take; copied += take;
|
||||
}
|
||||
var samples = block.AsSpan(0, 960 * channels);
|
||||
if (NoiseReduction && Info.Kind == StreamKind.StreamMic)
|
||||
{
|
||||
if (channels == 1) left.Process(samples);
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < 960; i++) mono[i] = samples[2 * i]; left.Process(mono);
|
||||
for (int i = 0; i < 960; i++) samples[2 * i] = mono[i];
|
||||
for (int i = 0; i < 960; i++) mono[i] = samples[2 * i + 1]; right.Process(mono);
|
||||
for (int i = 0; i < 960; i++) samples[2 * i + 1] = mono[i];
|
||||
}
|
||||
}
|
||||
float gain = Muted || deafened ? 0 : Gain;
|
||||
for (int i = 0; i < samples.Length; i++) samples[i] = (short)Math.Clamp((int)(samples[i] * gain), short.MinValue, short.MaxValue);
|
||||
sink?.Invoke(UserId, Info.StreamId, samples, channels);
|
||||
for (int i = 0; i < 960; i++) { output[i * 2] += samples[i * channels]; output[i * 2 + 1] += samples[i * channels + channels - 1]; }
|
||||
}
|
||||
public void Dispose() { decoder.Dispose(); dred?.Dispose(); left.Dispose(); right.Dispose(); }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../VoiceCat.Codec/VoiceCat.Codec.csproj" />
|
||||
<ProjectReference Include="../VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
|
||||
<ProjectReference Include="../VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<InternalsVisibleTo Include="VoiceCat.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Google.Protobuf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.36.1",
|
||||
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
|
||||
},
|
||||
"voicecat.codec": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.dsp": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.protocol": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Google.Protobuf": "[3.36.1, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.7, )",
|
||||
"resolved": "10.0.7",
|
||||
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
|
||||
},
|
||||
"Google.Protobuf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.36.1",
|
||||
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
|
||||
},
|
||||
"voicecat.codec": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.dsp": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.protocol": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Google.Protobuf": "[3.36.1, )"
|
||||
}
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
@@ -41,5 +41,17 @@ public sealed class OpusDecoder : IDisposable
|
||||
return OpusException.Check(NativeMethods.Decode(handle, input, packet.Length, output, samplesPerChannel, recoverPreviousFrame ? 1 : 0));
|
||||
}
|
||||
|
||||
public unsafe bool TryDecode(ReadOnlySpan<byte> packet, Span<short> pcm, int samplesPerChannel, out int decodedSamples, bool recoverPreviousFrame = false)
|
||||
{
|
||||
ValidateOutput(pcm, samplesPerChannel);
|
||||
if (packet.Overlaps(MemoryMarshal.AsBytes(pcm))) throw new ArgumentException("Packet and PCM storage must not overlap.");
|
||||
fixed (byte* input = packet)
|
||||
fixed (short* output = pcm)
|
||||
{
|
||||
decodedSamples = NativeMethods.Decode(handle, input, packet.Length, output, samplesPerChannel, recoverPreviousFrame ? 1 : 0);
|
||||
return decodedSamples >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => handle.Dispose();
|
||||
}
|
||||
|
||||
@@ -40,11 +40,10 @@ public sealed class OpusDeepRedundancy : IDisposable
|
||||
fixed (byte* packet = nextPacket)
|
||||
fixed (short* output = pcm)
|
||||
{
|
||||
int parsed = OpusException.Check(NativeMethods.DredParse(decoder, dred, packet, nextPacket.Length,
|
||||
checked(samplesPerChannel + recoveryOffset), audioDecoder.SampleRate, out _));
|
||||
if (parsed == 0) return false;
|
||||
OpusException.Check(NativeMethods.DredDecode(audioDecoder.Handle, dred, recoveryOffset, output, samplesPerChannel));
|
||||
return true;
|
||||
int parsed = NativeMethods.DredParse(decoder, dred, packet, nextPacket.Length,
|
||||
checked(samplesPerChannel + recoveryOffset), audioDecoder.SampleRate, out _);
|
||||
if (parsed <= 0) return false;
|
||||
return NativeMethods.DredDecode(audioDecoder.Handle, dred, recoveryOffset, output, samplesPerChannel) >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed record OpusOptions
|
||||
{
|
||||
if (SampleRate is not (8000 or 12000 or 16000 or 24000 or 48000)) throw new ArgumentOutOfRangeException(nameof(SampleRate));
|
||||
if (Channels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(Channels));
|
||||
if (FrameDurationMilliseconds is not (10 or 20 or 40 or 60)) throw new ArgumentOutOfRangeException(nameof(FrameDurationMilliseconds));
|
||||
if (FrameDurationMilliseconds is not (5 or 10 or 20 or 40 or 60)) throw new ArgumentOutOfRangeException(nameof(FrameDurationMilliseconds));
|
||||
if (Application == OpusApplication.LowDelay && FrameDurationMilliseconds > 20) throw new ArgumentException("Low-delay Opus requires frames of at most 20 ms.");
|
||||
if (!Enum.IsDefined(Application)) throw new ArgumentOutOfRangeException(nameof(Application));
|
||||
if (Bitrate is < 500 or > 512000) throw new ArgumentOutOfRangeException(nameof(Bitrate));
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.7, )",
|
||||
"resolved": "10.0.7",
|
||||
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using VoiceCat.Protocol;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Transport;
|
||||
|
||||
namespace VoiceCat.Core;
|
||||
|
||||
public delegate void EncodedVoiceHandler(VoiceFrameHeader header, ReadOnlySpan<byte> payload);
|
||||
|
||||
internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
{
|
||||
private readonly Socket socket;
|
||||
private readonly MediaSessionCrypto crypto;
|
||||
private readonly CancellationTokenSource stop;
|
||||
private readonly byte[] binding = new byte[VoiceFrameHeader.Size + 16];
|
||||
private readonly byte[] keepalive = new byte[VoiceFrameHeader.Size];
|
||||
private readonly PacketQueue packets = new();
|
||||
private readonly Task sending;
|
||||
private readonly Task receiving;
|
||||
private readonly TaskCompletionSource bound = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
internal event EncodedVoiceHandler? Received;
|
||||
internal Task Bound => bound.Task;
|
||||
|
||||
internal ClientMediaTransport(IPEndPoint endpoint, ReadOnlySpan<byte> token, MediaSessionCrypto crypto, CancellationToken cancellationToken)
|
||||
{
|
||||
if (token.Length != 16) throw new IOException("Invalid UDP binding token.");
|
||||
this.crypto = crypto;
|
||||
stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
socket = new(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
|
||||
try { socket.Connect(endpoint); }
|
||||
catch { socket.Dispose(); stop.Dispose(); throw; }
|
||||
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
|
||||
token.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
|
||||
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||
receiving = ReceiveAsync();
|
||||
sending = SendAsync();
|
||||
}
|
||||
|
||||
internal bool TrySend(VoiceFrameHeader header, ReadOnlySpan<byte> payload) => packets.TryWrite(header, payload);
|
||||
|
||||
private async Task SendAsync()
|
||||
{
|
||||
byte[] plain = new byte[1275], packet = new byte[1275 + VoiceFrameHeader.Size + MediaEncryptor.TagSize];
|
||||
long nextKeepalive = 0;
|
||||
try
|
||||
{
|
||||
while (!stop.IsCancellationRequested)
|
||||
{
|
||||
if (Environment.TickCount64 >= nextKeepalive)
|
||||
{
|
||||
if (!bound.Task.IsCompleted) await socket.SendAsync(binding, SocketFlags.None, stop.Token).ConfigureAwait(false);
|
||||
await socket.SendAsync(keepalive, SocketFlags.None, stop.Token).ConfigureAwait(false);
|
||||
nextKeepalive = Environment.TickCount64 + (bound.Task.IsCompleted ? 5000 : 250);
|
||||
}
|
||||
while (packets.TryRead(plain, out VoiceFrameHeader header, out int length))
|
||||
{
|
||||
int size = crypto.Encryptor.Encrypt(header, plain.AsSpan(0, length), packet);
|
||||
await socket.SendAsync(packet.AsMemory(0, size), SocketFlags.None, stop.Token).ConfigureAwait(false);
|
||||
}
|
||||
await Task.Delay(5, stop.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException)
|
||||
{ if (!stop.IsCancellationRequested) bound.TrySetException(exception); }
|
||||
finally { stop.Cancel(); }
|
||||
}
|
||||
|
||||
private async Task ReceiveAsync()
|
||||
{
|
||||
byte[] packet = new byte[65535], plain = new byte[65535];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int length;
|
||||
try { length = await socket.ReceiveAsync(packet, SocketFlags.None, stop.Token).ConfigureAwait(false); }
|
||||
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionReset or SocketError.MessageSize) { continue; }
|
||||
if (!VoiceFrameHeader.TryRead(packet.AsSpan(0, length), out var candidate)) continue;
|
||||
if (candidate.Type == MediaFrameType.Keepalive && length == VoiceFrameHeader.Size) { bound.TrySetResult(); continue; }
|
||||
if (candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 ||
|
||||
!crypto.Decryptor.TryDecrypt(packet.AsSpan(0, length), plain, out var header, out int size)) continue;
|
||||
Received?.Invoke(header, plain.AsSpan(0, size));
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException)
|
||||
{ if (!stop.IsCancellationRequested) bound.TrySetException(exception); }
|
||||
finally { bound.TrySetCanceled(); stop.Cancel(); }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
stop.Cancel(); socket.Dispose();
|
||||
try { await Task.WhenAll(sending, receiving).ConfigureAwait(false); }
|
||||
finally { System.Security.Cryptography.CryptographicOperations.ZeroMemory(binding); stop.Dispose(); }
|
||||
}
|
||||
|
||||
// A bounded, allocation-free packet handoff. A contending producer drops instead of
|
||||
// waiting; the network owner alone consumes and encrypts. Audio never enters a Channel lock.
|
||||
private sealed class PacketQueue
|
||||
{
|
||||
private readonly byte[][] payloads = Enumerable.Range(0, 64).Select(_ => new byte[1275]).ToArray();
|
||||
private readonly VoiceFrameHeader[] headers = new VoiceFrameHeader[64];
|
||||
private readonly int[] lengths = new int[64];
|
||||
private int read, written, producer;
|
||||
internal bool TryWrite(VoiceFrameHeader header, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length is < 1 or > 1275 || Interlocked.CompareExchange(ref producer, 1, 0) != 0) return false;
|
||||
try
|
||||
{
|
||||
int index = written;
|
||||
if (unchecked(index - Volatile.Read(ref read)) >= 64) return false;
|
||||
int slot = index & 63;
|
||||
payload.CopyTo(payloads[slot]); headers[slot] = header; lengths[slot] = payload.Length;
|
||||
Volatile.Write(ref written, unchecked(index + 1)); return true;
|
||||
}
|
||||
finally { Volatile.Write(ref producer, 0); }
|
||||
}
|
||||
internal bool TryRead(Span<byte> payload, out VoiceFrameHeader header, out int length)
|
||||
{
|
||||
int index = read; header = default; length = 0;
|
||||
if (index == Volatile.Read(ref written)) return false;
|
||||
int slot = index & 63; header = headers[slot]; length = lengths[slot];
|
||||
payloads[slot].AsSpan(0, length).CopyTo(payload);
|
||||
Volatile.Write(ref read, unchecked(index + 1)); return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
<ProjectReference Include="../VoiceCat.Audio/VoiceCat.Audio.csproj" />
|
||||
<InternalsVisibleTo Include="VoiceCat.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,292 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Channels;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Transport;
|
||||
using VoiceCat.Protocol;
|
||||
using VoiceCat.Audio;
|
||||
using Voicecat.V1;
|
||||
using Channel = Voicecat.V1.Channel;
|
||||
|
||||
namespace VoiceCat.Core;
|
||||
|
||||
public enum ClientConnectionState { Disconnected, Connecting, VerifyingIdentity, Authenticating, Connected }
|
||||
public sealed record ServerIdentityChallenge(string Host, ushort Port, string CertificateFingerprint, TofuStatus Status);
|
||||
|
||||
public sealed partial class VoiceCatClient : IAsyncDisposable
|
||||
{
|
||||
private readonly string clientName;
|
||||
private readonly string clientVersion;
|
||||
private readonly TofuStore pins;
|
||||
private readonly SemaphoreSlim lifecycle = new(1);
|
||||
private readonly CancellationTokenSource disposed = new();
|
||||
private readonly object stateGate = new();
|
||||
private readonly ConcurrentDictionary<ulong, TaskCompletionSource<Envelope>> pending = new();
|
||||
private readonly System.Threading.Channels.Channel<Envelope> events = System.Threading.Channels.Channel.CreateBounded<Envelope>(128);
|
||||
private readonly Dictionary<uint, Channel> channels = [];
|
||||
private readonly Dictionary<uint, User> users = [];
|
||||
private readonly Dictionary<uint, StreamInfo> localStreams = [];
|
||||
public AudioEngine Audio { get; }
|
||||
public IReadOnlyList<StreamInfo> LocalStreams { get { lock (stateGate) return localStreams.Values.Select(s => s.Clone()).ToArray(); } }
|
||||
private TlsControlConnection? control;
|
||||
private MediaSessionCrypto? mediaCrypto;
|
||||
private ClientMediaTransport? media;
|
||||
private Task keepalive = Task.CompletedTask;
|
||||
public event EncodedVoiceHandler? VoiceReceived;
|
||||
private CancellationTokenSource? connectionLifetime;
|
||||
private Task reader = Task.CompletedTask;
|
||||
private long nextRequest;
|
||||
private AuthResult? authentication;
|
||||
private ServerHello? hello;
|
||||
private ClientConnectionState state;
|
||||
|
||||
public event Action<ClientConnectionState>? ConnectionStateChanged;
|
||||
public ClientConnectionState State { get { lock (stateGate) return state; } }
|
||||
public Task Completion => reader;
|
||||
public AuthResult? Authentication { get { lock (stateGate) return authentication?.Clone(); } }
|
||||
public ServerHello? ServerHello { get { lock (stateGate) return hello?.Clone(); } }
|
||||
public IReadOnlyList<Channel> Channels { get { lock (stateGate) return channels.Values.Select(c => c.Clone()).ToArray(); } }
|
||||
public IReadOnlyList<User> Users { get { lock (stateGate) return users.Values.Select(u => u.Clone()).ToArray(); } }
|
||||
public bool TryReadEvent(out Envelope? envelope) => events.Reader.TryRead(out envelope);
|
||||
public IAsyncEnumerable<Envelope> ReadEventsAsync(CancellationToken cancellationToken = default) => events.Reader.ReadAllAsync(cancellationToken);
|
||||
|
||||
public VoiceCatClient(string clientName = "VoiceCat .NET", string clientVersion = "0.1.0", string? tofuStorePath = null)
|
||||
{
|
||||
this.clientName = clientName;
|
||||
this.clientVersion = clientVersion;
|
||||
pins = new(tofuStorePath ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VoiceCat", "tofu.txt"));
|
||||
Audio = new(TrySendEncodedVoice);
|
||||
VoiceReceived += Audio.Receive;
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(string host, ushort port, Func<ServerIdentityChallenge, CancellationToken, ValueTask<bool>>? confirmIdentity = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(host);
|
||||
ArgumentOutOfRangeException.ThrowIfZero(port);
|
||||
await lifecycle.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
Socket? socket = null;
|
||||
bool started = false;
|
||||
try
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed.IsCancellationRequested, this);
|
||||
if (control is not null) throw new InvalidOperationException("Disconnect before reconnecting.");
|
||||
started = true;
|
||||
connectionLifetime = CancellationTokenSource.CreateLinkedTokenSource(disposed.Token);
|
||||
using var connecting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, connectionLifetime.Token);
|
||||
CancellationToken token = connecting.Token;
|
||||
SetState(ClientConnectionState.Connecting);
|
||||
socket = new(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
|
||||
await socket.ConnectAsync(host, port, token).ConfigureAwait(false);
|
||||
string? fingerprint = null;
|
||||
control = new(socket, TlsSession.CreateClient(value => { fingerprint = value; return true; }), connectionLifetime.Token);
|
||||
socket = null; // Transport owns it from here.
|
||||
mediaCrypto = await control.TakeMediaCryptoAsync(token).ConfigureAwait(false);
|
||||
string certificatePin = fingerprint ?? throw new IOException("TLS did not report a certificate fingerprint.");
|
||||
TofuStatus pinStatus = pins.Check(host, port, certificatePin);
|
||||
if (pinStatus != TofuStatus.Matched)
|
||||
{
|
||||
SetState(ClientConnectionState.VerifyingIdentity);
|
||||
if (confirmIdentity is null || !await confirmIdentity(new(host, port, certificatePin, pinStatus), token).ConfigureAwait(false))
|
||||
throw new System.Security.Authentication.AuthenticationException("Server identity was rejected.");
|
||||
pins.Pin(host, port, certificatePin);
|
||||
}
|
||||
reader = ReadAsync(control, connectionLifetime.Token);
|
||||
Envelope response = await RequestAsync(new() { ClientHello = new() { ProtoVersion = 2, ClientName = clientName, ClientVersion = clientVersion } }, token).ConfigureAwait(false);
|
||||
if (response.ServerHello?.ProtoVersion != 2) throw new IOException("Unsupported server protocol.");
|
||||
lock (stateGate) hello = response.ServerHello.Clone();
|
||||
keepalive = KeepaliveAsync(connectionLifetime.Token);
|
||||
SetState(ClientConnectionState.Authenticating);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket?.Dispose();
|
||||
if (started) await CloseAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally { lifecycle.Release(); }
|
||||
}
|
||||
|
||||
public Task<AuthResult> AuthenticateGuestAsync(string nickname, CancellationToken cancellationToken = default) =>
|
||||
AuthenticateAsync(new() { Guest = new() { Nickname = nickname } }, cancellationToken);
|
||||
public Task<AuthResult> AuthenticateUserAsync(string username, string password, CancellationToken cancellationToken = default) =>
|
||||
AuthenticateAsync(new() { Password = new() { Username = username, Password = password } }, cancellationToken);
|
||||
|
||||
private async Task<AuthResult> AuthenticateAsync(AuthRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (State != ClientConnectionState.Authenticating) throw new InvalidOperationException("Authentication requires a connected TLS session.");
|
||||
Envelope response = await RequestAsync(new() { AuthRequest = request }, cancellationToken).ConfigureAwait(false);
|
||||
AuthResult result = response.AuthResult ?? throw new IOException("Unexpected authentication response.");
|
||||
if (result.Ok)
|
||||
{
|
||||
var endpoint = (IPEndPoint)control!.RemoteEndPoint;
|
||||
IPAddress address = endpoint.Address.IsIPv4MappedToIPv6 ? endpoint.Address.MapToIPv4() : endpoint.Address;
|
||||
media = new(new(address, checked((int)ServerHello!.UdpPort)), result.UdpToken.Span, mediaCrypto!, connectionLifetime!.Token);
|
||||
media.Received += (header, packet) => VoiceReceived?.Invoke(header, packet);
|
||||
SetState(ClientConnectionState.Connected);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<VoiceSubscriptionResult> SubscribeVoiceAsync(bool subscribe = true, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (subscribe && media is not null) await media.Bound.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
|
||||
return (await RequestAsync(subscribe ? new() { SubscribeVoice = new() } : new() { UnsubscribeVoice = new() }, cancellationToken).ConfigureAwait(false)).VoiceSubscriptionResult;
|
||||
}
|
||||
|
||||
public bool TrySendEncodedVoice(uint ssrc, uint timestamp, ReadOnlySpan<byte> payload, VoiceFrameFlags flags = VoiceFrameFlags.None) =>
|
||||
media?.TrySend(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload) == true;
|
||||
|
||||
public async Task<StreamInfo> StartStreamAsync(StreamKind kind, string label = "", int captureChannels = 1, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (State != ClientConnectionState.Connected) throw new InvalidOperationException("Client is disconnected.");
|
||||
var response = (await RequestAsync(new() { StreamAnnounce = new() { Kind = kind, Label = label } }, cancellationToken).ConfigureAwait(false)).StreamAnnounceResult;
|
||||
if (!response.Ok) throw new InvalidOperationException(response.Error);
|
||||
var info = new StreamInfo { StreamId = response.StreamId, Ssrc = response.Ssrc, Kind = kind, Audio = response.EffectiveAudio.Clone(), Label = label };
|
||||
try
|
||||
{
|
||||
lock (stateGate) { if (State != ClientConnectionState.Connected) throw new InvalidOperationException("Client disconnected during stream negotiation."); Audio.AddLocalStream(info, captureChannels); localStreams[info.StreamId] = info; }
|
||||
return info.Clone();
|
||||
}
|
||||
catch { if (State == ClientConnectionState.Connected) Send(new() { StreamStop = new() { StreamId = info.StreamId } }); throw; }
|
||||
}
|
||||
|
||||
public void StopStream(uint streamId)
|
||||
{
|
||||
lock (stateGate) { localStreams.Remove(streamId); Audio.RemoveLocalStream(streamId); }
|
||||
Send(new() { StreamStop = new() { StreamId = streamId } });
|
||||
}
|
||||
|
||||
private async Task KeepaliveAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) Send(new() { Ping = new() { Nonce = checked((ulong)Environment.TickCount64) } });
|
||||
}
|
||||
catch (Exception exception) when (exception is OperationCanceledException or IOException or InvalidOperationException) { }
|
||||
}
|
||||
|
||||
public async Task<Envelope> RequestAsync(Envelope request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
TlsControlConnection connection = control ?? throw new InvalidOperationException("Client is disconnected.");
|
||||
var completion = new TaskCompletionSource<Envelope>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
ulong id = checked((ulong)Interlocked.Increment(ref nextRequest));
|
||||
Envelope outbound = request.Clone(); outbound.RequestId = id;
|
||||
if (!pending.TryAdd(id, completion)) throw new InvalidOperationException("Request ids exhausted.");
|
||||
try
|
||||
{
|
||||
if (!connection.TrySend(outbound)) throw new IOException("Control queue is full or closed.");
|
||||
return await completion.Task.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally { pending.TryRemove(id, out _); }
|
||||
}
|
||||
|
||||
public void Send(Envelope message)
|
||||
{
|
||||
TlsControlConnection connection = control ?? throw new InvalidOperationException("Client is disconnected.");
|
||||
if (!connection.TrySend(message.Clone())) throw new IOException("Control queue is full or closed.");
|
||||
}
|
||||
|
||||
private async Task ReadAsync(TlsControlConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
Exception? failure = null;
|
||||
try
|
||||
{
|
||||
await foreach (Envelope message in connection.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
Apply(message);
|
||||
if (message.RequestId != 0 && pending.TryRemove(message.RequestId, out var completion)) completion.TrySetResult(message.Clone());
|
||||
if (!events.Writer.TryWrite(message.Clone())) throw new IOException("Client event queue exhausted; consume events regularly.");
|
||||
if (message.Disconnect is not null) { connection.CompleteWrites(); break; }
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or OperationCanceledException or SocketException or ObjectDisposedException) { failure = exception; }
|
||||
finally
|
||||
{
|
||||
connectionLifetime?.Cancel();
|
||||
foreach (var operation in pending.Values) operation.TrySetException(failure ?? new IOException("Connection closed."));
|
||||
SetState(ClientConnectionState.Disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
private void Apply(Envelope message)
|
||||
{
|
||||
lock (stateGate)
|
||||
{
|
||||
if (message.AuthResult?.Ok == true) authentication = message.AuthResult.Clone();
|
||||
if (message.ServerState is not null)
|
||||
{
|
||||
channels.Clear(); users.Clear();
|
||||
foreach (var channel in message.ServerState.Channels) channels[channel.Id] = channel.Clone();
|
||||
foreach (var user in message.ServerState.Users) users[user.Id] = user.Clone();
|
||||
}
|
||||
if (message.ChannelEvent is not null)
|
||||
{
|
||||
if (message.ChannelEvent.Kind == ChannelEvent.Types.Kind.Deleted) channels.Remove(message.ChannelEvent.DeletedId);
|
||||
else if (message.ChannelEvent.Channel is not null) channels[message.ChannelEvent.Channel.Id] = message.ChannelEvent.Channel.Clone();
|
||||
}
|
||||
if (message.UserEvent is not null)
|
||||
{
|
||||
if (message.UserEvent.Kind == UserEvent.Types.Kind.Left) users.Remove(message.UserEvent.LeftId);
|
||||
else if (message.UserEvent.User is not null) users[message.UserEvent.User.Id] = message.UserEvent.User.Clone();
|
||||
}
|
||||
if (authentication is not null && (message.ServerState is not null || message.UserEvent is not null))
|
||||
{
|
||||
User self = users.GetValueOrDefault(authentication.Self.Id, authentication.Self);
|
||||
Audio.SetRemoteStreams(users.Values.ToArray(), self.Id, self.ChannelId);
|
||||
foreach (var id in localStreams.Keys.Where(id => !self.Streams.Any(s => s.StreamId == id)).ToArray())
|
||||
{ Audio.RemoveLocalStream(id); localStreams.Remove(id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SetState(ClientConnectionState value)
|
||||
{
|
||||
lock (stateGate) state = value;
|
||||
ConnectionStateChanged?.Invoke(value);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
connectionLifetime?.Cancel();
|
||||
await lifecycle.WaitAsync().ConfigureAwait(false);
|
||||
try { await CloseAsync().ConfigureAwait(false); }
|
||||
finally { lifecycle.Release(); }
|
||||
}
|
||||
|
||||
private async Task CloseAsync()
|
||||
{
|
||||
connectionLifetime?.Cancel();
|
||||
try { await reader.ConfigureAwait(false); }
|
||||
finally
|
||||
{
|
||||
try { await keepalive.ConfigureAwait(false); }
|
||||
catch (Exception exception) when (exception is IOException or OperationCanceledException or SocketException or ObjectDisposedException) { }
|
||||
try { if (media is not null) await media.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception exception) when (exception is IOException or OperationCanceledException or SocketException or ObjectDisposedException) { }
|
||||
try { if (control is not null) await control.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception exception) when (exception is IOException or OperationCanceledException or SocketException or ObjectDisposedException) { }
|
||||
control = null;
|
||||
media = null;
|
||||
mediaCrypto?.Dispose(); mediaCrypto = null;
|
||||
connectionLifetime?.Dispose(); connectionLifetime = null;
|
||||
lock (stateGate) { authentication = null; hello = null; channels.Clear(); users.Clear(); }
|
||||
lock (stateGate)
|
||||
{
|
||||
foreach (var id in localStreams.Keys) Audio.RemoveLocalStream(id);
|
||||
localStreams.Clear(); Audio.SetRemoteStreams([], 0, 0);
|
||||
}
|
||||
SetState(ClientConnectionState.Disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed.IsCancellationRequested) return;
|
||||
disposed.Cancel();
|
||||
await DisconnectAsync().ConfigureAwait(false);
|
||||
events.Writer.TryComplete();
|
||||
Audio.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"Google.Protobuf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.36.1",
|
||||
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
|
||||
},
|
||||
"voicecat.audio": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"VoiceCat.Codec": "[1.0.0, )",
|
||||
"VoiceCat.Dsp": "[1.0.0, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.codec": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "[2.6.2, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.dsp": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.protocol": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Google.Protobuf": "[3.36.1, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.7, )",
|
||||
"resolved": "10.0.7",
|
||||
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"Google.Protobuf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.36.1",
|
||||
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
|
||||
},
|
||||
"voicecat.audio": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"VoiceCat.Codec": "[1.0.0, )",
|
||||
"VoiceCat.Dsp": "[1.0.0, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.codec": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "[2.6.2, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.dsp": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.protocol": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Google.Protobuf": "[3.36.1, )"
|
||||
}
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using VoiceCat.Crypto;
|
||||
|
||||
namespace VoiceCat.Server.Transport;
|
||||
namespace VoiceCat.Transport;
|
||||
|
||||
internal sealed class MediaSessionCrypto(MediaEncryptor encryptor, MediaDecryptor decryptor) : IDisposable
|
||||
{
|
||||
+2
-1
@@ -7,7 +7,7 @@ using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Server.Transport;
|
||||
namespace VoiceCat.Transport;
|
||||
|
||||
internal sealed class TlsControlConnection : IAsyncDisposable
|
||||
{
|
||||
@@ -25,6 +25,7 @@ internal sealed class TlsControlConnection : IAsyncDisposable
|
||||
private MediaSessionCrypto? mediaCrypto;
|
||||
|
||||
public Task Completion { get; }
|
||||
internal System.Net.EndPoint RemoteEndPoint => socket.RemoteEndPoint!;
|
||||
public CancellationToken CancellationToken => lifetime.Token;
|
||||
|
||||
internal TlsControlConnection(Socket socket, TlsSession tls, CancellationToken cancellationToken, TimeSpan? handshakeTimeout = null)
|
||||
@@ -5,5 +5,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="VoiceCat.Tests" />
|
||||
<InternalsVisibleTo Include="VoiceCat.Server" />
|
||||
<InternalsVisibleTo Include="VoiceCat.Core" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.7, )",
|
||||
"resolved": "10.0.7",
|
||||
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using VoiceCat.Transport;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using VoiceCat.Transport;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using VoiceCat.Audio;
|
||||
using VoiceCat.Codec;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class AudioEngineTests
|
||||
{
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using VoiceCat.Core;
|
||||
using VoiceCat.Crypto;
|
||||
using Voicecat.V1;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
using static VoiceCat.Tests.MediaRelayTests;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class ManagedClientTests
|
||||
{
|
||||
private static VoiceCatClient NewClient(ServerFixture fixture, string name) => new(name, "test", Path.Combine(fixture.Directory, name + ".pins"));
|
||||
private static Task Connect(VoiceCatClient client, ServerFixture fixture) => client.ConnectAsync("127.0.0.1", (ushort)fixture.Server.EndPoint.Port, (_, _) => ValueTask.FromResult(true));
|
||||
private static async Task<Envelope> Event(VoiceCatClient client, Func<Envelope, bool> predicate)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await foreach (Envelope message in client.ReadEventsAsync(timeout.Token)) if (predicate(message)) return message;
|
||||
throw new IOException("Expected client event was not received.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManagedClientsAuthenticateChatAndCorrelateConcurrentRequests()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = NewClient(fixture, "Alice"); await using var bob = NewClient(fixture, "Bob");
|
||||
await Connect(alice, fixture); await Connect(bob, fixture);
|
||||
Assert.True((await alice.AuthenticateGuestAsync("Alice")).Ok); Assert.True((await bob.AuthenticateGuestAsync("Bob")).Ok);
|
||||
await Event(bob, e => e.ServerState is not null); await Event(alice, e => e.UserEvent?.User?.Nickname == "Bob");
|
||||
Assert.Equal(2, alice.Users.Count);
|
||||
var copy = alice.Users[0]; copy.Nickname = "Mutated"; Assert.DoesNotContain(alice.Users, u => u.Nickname == "Mutated");
|
||||
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 1, Body = "Managed conversation", ClientMsgId = "a1" } });
|
||||
Assert.Equal("Managed conversation", (await Event(bob, e => e.TextMessage is not null)).TextMessage.Body);
|
||||
var requests = Enumerable.Range(1, 20).Select(async i =>
|
||||
{
|
||||
Envelope response = await alice.RequestAsync(new() { Ping = new() { Nonce = (ulong)i } });
|
||||
Assert.Equal((ulong)i, response.Pong.Nonce); return response.RequestId;
|
||||
});
|
||||
Assert.Equal(20, (await Task.WhenAll(requests)).Distinct().Count());
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => Connect(alice, fixture));
|
||||
Assert.Equal(ClientConnectionState.Connected, alice.State);
|
||||
await alice.DisconnectAsync();
|
||||
await Event(bob, e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left);
|
||||
await Connect(alice, fixture); Assert.True((await alice.AuthenticateGuestAsync("Returned")).Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TofuRequiresApprovalPinsAcceptedCertificateAndRejectsChanges()
|
||||
{
|
||||
await using var first = new ServerFixture(); await using var second = new ServerFixture();
|
||||
await using var client = NewClient(first, "Tofu");
|
||||
await Assert.ThrowsAsync<System.Security.Authentication.AuthenticationException>(() => client.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port));
|
||||
await client.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port, (challenge, _) =>
|
||||
{ Assert.Equal(TofuStatus.FirstConnect, challenge.Status); return ValueTask.FromResult(true); });
|
||||
await client.DisconnectAsync();
|
||||
await client.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port); await client.DisconnectAsync();
|
||||
// Pin the other server's certificate to this endpoint, simulating a changed server certificate.
|
||||
using var credentials = ServerCredentials.LoadOrCreate(second.Directory, "VoiceCat Server");
|
||||
new TofuStore(Path.Combine(first.Directory, "Other.pins")).Pin("127.0.0.1", (ushort)first.Server.EndPoint.Port, credentials.CertificateFingerprint);
|
||||
await using var changed = new VoiceCatClient(tofuStorePath: Path.Combine(first.Directory, "Other.pins"));
|
||||
await Assert.ThrowsAsync<System.Security.Authentication.AuthenticationException>(() => changed.ConnectAsync("127.0.0.1", (ushort)first.Server.EndPoint.Port,
|
||||
(challenge, _) => { Assert.Equal(TofuStatus.Mismatch, challenge.Status); return ValueTask.FromResult(false); }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManagedClientSendsAndReceivesAuthenticatedEncodedVoice()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var managed = NewClient(fixture, "Managed"); await Connect(managed, fixture); await managed.AuthenticateGuestAsync("Managed");
|
||||
Assert.True((await managed.SubscribeVoiceAsync()).Ok);
|
||||
await using var peer = await VoicePeer.ConnectAsync(fixture, "Peer");
|
||||
var remote = await peer.AnnounceAsync(StreamKind.StreamMic);
|
||||
var local = (await managed.RequestAsync(new() { StreamAnnounce = new() { Kind = StreamKind.StreamMic } })).StreamAnnounceResult;
|
||||
Assert.True(local.Ok);
|
||||
var received = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
managed.VoiceReceived += (header, payload) => { Assert.Equal(remote.Ssrc, header.Ssrc); received.TrySetResult(payload.ToArray()); };
|
||||
await peer.SendAsync(peer.Seal(remote.Ssrc, [1, 2, 3]));
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, await received.Task.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
Assert.True(managed.TrySendEncodedVoice(local.Ssrc, 960, [4, 5, 6]));
|
||||
Assert.Equal(new byte[] { 4, 5, 6 }, (await peer.ReceiveVoiceAsync()).Payload);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using VoiceCat.Transport;
|
||||
using System.Net;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using VoiceCat.Transport;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using VoiceCat.Transport;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../clients/windows/VoiceCat.Managed/VoiceCat.Managed.csproj" />
|
||||
<ProjectReference Include="../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" PrivateAssets="all" />
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using VoiceCat.Interop;
|
||||
using VoiceCat.Server.Data;
|
||||
using Voicecat.V1;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
using Client = VoiceCat.Interop.VoiceCatClient;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class WindowsManagedClientTests
|
||||
{
|
||||
private static async Task Until(Client client, Func<bool> predicate)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
do { client.PumpEvents(); if (predicate()) return; await Task.Delay(10, timeout.Token); } while (true);
|
||||
}
|
||||
private static async Task Login(Client client, ServerFixture fixture, bool admin = false)
|
||||
{
|
||||
client.EventReceived += e => { if (e.Type == VcEventType.ServerIdentity) client.ConfirmServerIdentity(true); };
|
||||
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", (ushort)fixture.Server.EndPoint.Port));
|
||||
if (admin) client.AuthenticateUser("Admin", "secret"); else client.AuthenticateGuest("Guest");
|
||||
await Until(client, () => client.ListUsers().Count > 0);
|
||||
}
|
||||
[Fact]
|
||||
public async Task ShippedWindowsFacadeChatsExchangesPcmAndKeepsCaptureIdsAcrossChannelMoves()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
using (var accounts = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"))) await accounts.CreateAccountAsync("Admin", "secret", true);
|
||||
using var alice = new Client("Alice", "test", tofuStorePath: Path.Combine(fixture.Directory, "alice.pins"));
|
||||
using var bob = new Client("Bob", "test", tofuStorePath: Path.Combine(fixture.Directory, "bob.pins"));
|
||||
await Login(alice, fixture, true); await Login(bob, fixture);
|
||||
Assert.True(alice.GetPermissions().IsAdmin);
|
||||
string? body = null; bob.EventReceived += e => { if (e.Type == VcEventType.TextMessage) body = e.Text; };
|
||||
Assert.Equal(VcResult.Ok, alice.SendText(VcTextScope.Channel, 1, "Managed Windows chat"));
|
||||
await Until(bob, () => body is not null); Assert.Equal("Managed Windows chat", body);
|
||||
Assert.Equal(VcResult.Ok, alice.JoinVoice()); Assert.Equal(VcResult.Ok, bob.JoinVoice());
|
||||
alice.SetInputMode(VcInputMode.AlwaysOn); bob.SetInputMode(VcInputMode.AlwaysOn);
|
||||
var a = alice.StartStreamExternalFeed(VcStreamKind.Mic, "Mic"); var b = bob.StartStreamExternalFeed(VcStreamKind.Mic, "Mic");
|
||||
Assert.Equal(VcResult.Ok, a.Result); Assert.Equal(VcResult.Ok, b.Result);
|
||||
await Until(bob, () => bob.ManagedClient.Users.Any(u => u.Streams.Count > 0 && u.Id != bob.ManagedClient.Authentication!.Self.Id));
|
||||
long aliceEnergy = 0, bobEnergy = 0;
|
||||
alice.ManagedClient.Audio.MixedPcm += pcm => { long sum = 0; foreach (short sample in pcm) sum += Math.Abs((int)sample); Interlocked.Add(ref aliceEnergy, sum); };
|
||||
bob.ManagedClient.Audio.MixedPcm += pcm => { long sum = 0; foreach (short sample in pcm) sum += Math.Abs((int)sample); Interlocked.Add(ref bobEnergy, sum); };
|
||||
short[] tone = Enumerable.Range(0, 960).Select(i => (short)(8000 * Math.Sin(i * Math.PI * 880 / 48000))).ToArray();
|
||||
for (int i = 0; i < 40; i++) { alice.StreamFeedPcm(a.StreamId, tone, 960, 1); bob.StreamFeedPcm(b.StreamId, tone, 960, 1); alice.PumpEvents(); bob.PumpEvents(); await Task.Delay(20); }
|
||||
Assert.True(Interlocked.Read(ref aliceEnergy) > 100000); Assert.True(Interlocked.Read(ref bobEnergy) > 100000);
|
||||
uint oldId = alice.ManagedClient.LocalStreams.Single().StreamId;
|
||||
await alice.ManagedClient.RequestAsync(new() { CreateChannel = new() { Channel = new() { Name = "Stereo", ParentId = 1, Audio = AudioEngineTests.Stream(20, true).Audio } } });
|
||||
await Until(alice, () => alice.ListChannels().Any(c => c.Name == "Stereo"));
|
||||
uint channel = alice.ListChannels().Single(c => c.Name == "Stereo").Id;
|
||||
Assert.Equal(VcResult.Ok, alice.JoinChannel(channel));
|
||||
await Until(alice, () => alice.ManagedClient.LocalStreams.Any(s => s.StreamId != oldId));
|
||||
Assert.Equal(a.StreamId, Assert.Single(alice.ListUserStreams(alice.ManagedClient.Authentication!.Self.Id)).StreamId);
|
||||
Assert.True(alice.GetStreamAudioConfig(alice.ManagedClient.Authentication!.Self.Id, a.StreamId).Config!.Stereo);
|
||||
Assert.Equal(VcResult.Ok, alice.StreamFeedPcm(a.StreamId, tone, 960, 1));
|
||||
Assert.Equal(VcResult.Ok, alice.StopStream(a.StreamId));
|
||||
Assert.Empty(alice.ManagedClient.LocalStreams);
|
||||
}
|
||||
}
|
||||
@@ -146,9 +146,24 @@
|
||||
"xunit.extensibility.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"voicecat.audio": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"VoiceCat.Codec": "[1.0.0, )",
|
||||
"VoiceCat.Dsp": "[1.0.0, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.codec": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.core": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"VoiceCat.Audio": "[1.0.0, )",
|
||||
"VoiceCat.Crypto": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
@@ -159,6 +174,12 @@
|
||||
"voicecat.dsp": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.managed": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"VoiceCat.Core": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.protocol": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user