Retire legacy implementations and flatten managed layout
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s

This commit is contained in:
2026-09-21 00:11:32 +02:00
parent dd811a0bb8
commit 08e6c5930a
422 changed files with 252 additions and 38242 deletions
+97
View File
@@ -0,0 +1,97 @@
namespace VoiceCat.Audio;
// SPSC PCM handoff with a small occupancy-controlled sample-rate correction. Producers and
// consumers remain non-waiting; the correction keeps independent hardware and managed clocks
// from periodically reaching the hard underflow/overflow edges of a conventional ring.
public sealed class AdaptivePcmBuffer
{
private const int SampleRate = 48_000;
private const double MaximumCorrection = 0.005;
private readonly short[] samples;
private readonly int channels, frameMask;
private int readFrame, writtenFrame, producer, targetFrames;
private double phase;
private bool primed;
public AdaptivePcmBuffer(int channels, int bufferMilliseconds = 40, int capacityFrames = 16_384)
{
if (channels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(channels));
if (capacityFrames < 2 || (capacityFrames & (capacityFrames - 1)) != 0) throw new ArgumentOutOfRangeException(nameof(capacityFrames));
this.channels = channels; samples = new short[checked(capacityFrames * channels)]; frameMask = capacityFrames - 1;
BufferMilliseconds = bufferMilliseconds;
}
public int Channels => channels;
public int CountFrames => unchecked(Volatile.Read(ref writtenFrame) - Volatile.Read(ref readFrame));
public int BufferMilliseconds
{
get => Volatile.Read(ref targetFrames) * 1000 / SampleRate;
set
{
if (value is not (20 or 40 or 60)) throw new ArgumentOutOfRangeException(nameof(value));
Volatile.Write(ref targetFrames, value * SampleRate / 1000);
}
}
public bool TryWrite(ReadOnlySpan<short> source)
{
if (source.Length == 0 || source.Length % channels != 0 || Interlocked.CompareExchange(ref producer, 1, 0) != 0) return false;
try
{
int frames = source.Length / channels, index = writtenFrame;
if (frames > frameMask + 1 - unchecked(index - Volatile.Read(ref readFrame))) return false;
for (int frame = 0; frame < frames; frame++)
{
int target = ((index + frame) & frameMask) * channels;
for (int channel = 0; channel < channels; channel++) samples[target + channel] = source[frame * channels + channel];
}
Volatile.Write(ref writtenFrame, unchecked(index + frames)); return true;
}
finally { Volatile.Write(ref producer, 0); }
}
// Returns interleaved samples written. A zero return means the caller should treat the
// already-cleared destination as silence. Once primed, short scheduling stalls re-prime
// instead of repeatedly clicking at the ring edge.
public int Read(Span<short> destination)
{
if (destination.Length % channels != 0) throw new ArgumentException("PCM must contain complete interleaved frames.", nameof(destination));
int requestedFrames = destination.Length / channels;
if (requestedFrames == 0) return 0;
int available = CountFrames, target = Volatile.Read(ref targetFrames);
if (!primed)
{
if (available < target) { destination.Clear(); return 0; }
primed = true; phase = 0;
}
if (available <= 0) { primed = false; phase = 0; destination.Clear(); return 0; }
double correction = Math.Clamp((available - target) / (SampleRate * 2.0), -MaximumCorrection, MaximumCorrection);
double step = 1.0 + correction;
int produced = 0, read = readFrame;
for (int frame = 0; frame < requestedFrames; frame++)
{
int baseOffset = (read & frameMask) * channels;
int nextOffset = ((read + 1) & frameMask) * channels;
int remaining = unchecked(Volatile.Read(ref writtenFrame) - read);
if (remaining <= 0) break;
double fraction = phase;
for (int channel = 0; channel < channels; channel++)
{
int first = samples[baseOffset + channel];
int second = remaining > 1 ? samples[nextOffset + channel] : first;
destination[produced++] = (short)Math.Clamp((int)Math.Round(first + (second - first) * fraction), short.MinValue, short.MaxValue);
}
phase += step;
int advance = (int)phase;
if (advance > remaining) advance = remaining;
read = unchecked(read + advance); phase -= advance;
}
Volatile.Write(ref readFrame, read);
if (produced < destination.Length)
{
destination[produced..].Clear(); primed = false; phase = 0;
}
return produced;
}
}
+16
View File
@@ -0,0 +1,16 @@
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
{
int BufferMilliseconds { get; set; }
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);
}
+187
View File
@@ -0,0 +1,187 @@
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;
private int deviceBufferMilliseconds = 40;
public int DeviceBufferMilliseconds
{
get => Volatile.Read(ref deviceBufferMilliseconds);
set
{
if (value is not (20 or 40 or 60)) throw new ArgumentOutOfRangeException(nameof(value));
Volatile.Write(ref deviceBufferMilliseconds, value);
foreach (LocalStream stream in Volatile.Read(ref routes).Local) stream.BufferMilliseconds = value;
}
}
public Exception? Failure { get; private set; }
public AudioEngine(EncodedVoiceSender sender, bool startWorker = true)
{
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, DeviceBufferMilliseconds);
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, DeviceBufferMilliseconds) : 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 SetLocalGain(uint streamId, float gain)
{
if (!float.IsFinite(gain) || gain < 0 || gain > 4) throw new ArgumentOutOfRangeException(nameof(gain));
foreach (LocalStream stream in Volatile.Read(ref routes).Local)
if (stream.Info.StreamId == streamId) { stream.Gain = gain; return; }
}
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);
}
+124
View File
@@ -0,0 +1,124 @@
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 AdaptivePcmBuffer Input;
internal volatile float Level;
internal volatile bool Talking;
internal volatile float Gain = 1;
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 int starvedSamples;
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 int BufferMilliseconds { get => Input.BufferMilliseconds; set => Input.BufferMilliseconds = value; }
internal LocalStream(StreamInfo stream, int captureChannels, int bufferMilliseconds = 40)
{
Info = stream.Clone(); CaptureChannels = captureChannels;
Input = new(captureChannels, bufferMilliseconds);
encoder = new(new()
{
Channels = stream.Audio.Mode == ChannelMode.ModeStereo ? 2 : 1,
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.Read(input) != input.Length)
{
Level = 0; Talking = false; starvedSamples += 960;
if (starvedSamples >= 9600) { buffered = 0; wasTransmitting = false; }
return;
}
starvedSamples = 0;
if (buffered == 0) timestamp = engine.SampleClock;
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 : Gain;
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(); }
}
+34
View File
@@ -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;
}
}
+224
View File
@@ -0,0 +1,224 @@
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 readonly long[] arrivals = new long[64];
private int read, written;
private readonly byte[][] jitter;
private readonly uint[] timestamps;
private readonly int[] sizes;
private int count, available, offset, missing, waiting;
private uint expected;
private bool started, hasTimestamp;
private bool hasMarker;
private uint lastMarker;
private readonly int channels, frameSamples, maximumDepth;
private long lastArrival;
private uint lastArrivalTimestamp;
private double jitterSamples;
private readonly TimeProvider clock;
internal int Depth => count;
internal int TargetDepthSamples => TargetSamples();
internal int ConcealedFrames { get; private set; }
internal int DredFrames { get; private set; }
internal int FecFrames { get; private set; }
internal ReceiveStream(uint userId, StreamInfo info, TimeProvider? clock = null)
{
if (info.Audio.FrameMs is not (5 or 10 or 20 or 40 or 60) || !Enum.IsDefined(info.Audio.Mode)) throw new ArgumentException("Unsupported remote audio configuration.", nameof(info));
UserId = userId; Info = info.Clone(); this.clock = clock ?? TimeProvider.System;
channels = info.Audio.Mode == ChannelMode.ModeStereo ? 2 : 1;
frameSamples = checked((int)info.Audio.FrameMs * 48);
maximumDepth = Math.Clamp(500 / (int)info.Audio.FrameMs + 2, 8, 104);
jitter = Enumerable.Range(0, maximumDepth).Select(_ => new byte[1275]).ToArray();
timestamps = new uint[maximumDepth]; sizes = new int[maximumDepth];
decoder = new(48000, channels);
try { left = new(); } catch { decoder.Dispose(); throw; }
try { right = new(); } catch { left.Dispose(); decoder.Dispose(); throw; }
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; arrivals[slot] = clock.GetTimestamp();
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;
DropBefore(timestamp); available = offset = missing = waiting = 0;
expected = timestamp; started = false; delta = 0; lastArrival = 0; jitterSamples = 0;
}
bool duplicate = false;
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == timestamp) duplicate = true;
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++;
ObserveArrival(timestamp, arrivals[source]);
}
Volatile.Write(ref read, unchecked(read + 1));
}
}
private void ObserveArrival(uint timestamp, long arrival)
{
if (lastArrival != 0)
{
int timestampDelta = unchecked((int)(timestamp - lastArrivalTimestamp));
if (timestampDelta <= 0) return;
if (timestampDelta > 0 && timestampDelta <= frameSamples * 10)
{
double arrivalDelta = clock.GetElapsedTime(lastArrival, arrival).TotalSeconds * 48_000;
double deviation = Math.Abs(arrivalDelta - timestampDelta);
jitterSamples += (deviation - jitterSamples) / 16.0;
}
}
lastArrival = arrival; lastArrivalTimestamp = timestamp;
}
private int TargetSamples()
{
int recovery = Info.Audio.Dred || Info.Audio.Fec ? frameSamples : 0;
int variation = checked((int)Math.Ceiling(4 * jitterSamples / frameSamples)) * frameSamples;
return Math.Min(5760, recovery + variation);
}
private int Newest()
{
int newest = -1;
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && (newest < 0 || unchecked((int)(timestamps[i] - timestamps[newest])) > 0)) newest = i;
return newest;
}
private void DropBefore(uint timestamp)
{
for (int i = 0; i < sizes.Length; i++)
if (sizes[i] != 0 && unchecked((int)(timestamps[i] - timestamp)) < 0) { sizes[i] = 0; count--; }
}
private void CatchUp()
{
if (!started || available != 0 || count == 0) return;
int newest = Newest(); int target = TargetSamples();
int lead = unchecked((int)(timestamps[newest] - expected));
if (lead <= target + frameSamples) return;
int keepBehind = target / frameSamples * frameSamples;
expected = unchecked(timestamps[newest] - (uint)keepBehind);
DropBefore(expected); missing = 0;
}
private int Oldest()
{
int oldest = -1;
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++;
int recoveryOffset = next < 0 ? 0 : unchecked((int)(timestamps[next] - expected));
if (next >= 0 && recoveryOffset > 0 && recoveryOffset % frameSamples == 0)
{
var packet = jitter[next].AsSpan(0, sizes[next]);
if (dred?.TryRecover(decoder, packet, pcm, frameSamples, recoveryOffset) == true) { decoded = true; DredFrames++; }
else if (recoveryOffset == frameSamples && Info.Audio.Fec && decoder.TryDecode(packet, pcm, frameSamples, out int recovered, true) && recovered == frameSamples) { decoded = true; FecFrames++; }
}
}
int maximumConcealment = Math.Max(1, 200 / (int)Info.Audio.FrameMs);
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();
CatchUp();
if (!started)
{
waiting++;
if (!hasTimestamp || count == 0) return;
int oldest = Oldest(), newest = Newest();
if (unchecked((int)(timestamps[newest] - timestamps[oldest])) < TargetSamples()) return;
int keepBehind = TargetSamples() / frameSamples * frameSamples;
expected = unchecked(timestamps[newest] - (uint)keepBehind);
DropBefore(expected); started = true; waiting = 0;
}
int copied = 0;
while (copied < 960)
{
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(); }
}
+8
View File
@@ -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>
+26
View File
@@ -0,0 +1,26 @@
{
"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, )"
}
}
},
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
+24
View File
@@ -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": {}
}
}
+139
View File
@@ -0,0 +1,139 @@
using System.Diagnostics;
using System.Text.Json;
using VoiceCat.Audio;
using VoiceCat.Core;
using Voicecat.V1;
return await CliCommand.RunAsync(args);
public static class CliCommand
{
public static async Task<int> RunAsync(string[] args)
{
try
{
var options = Options.Parse(args);
await using var client = new VoiceCatClient("VoiceCat.Cli", "0.1.0", options.Pins);
client.ConnectionStateChanged += state => Print("state", state.ToString());
long energy = 0;
client.Audio.MixedPcm += pcm => { long sum = 0; foreach (short sample in pcm) sum += Math.Abs((int)sample); Interlocked.Add(ref energy, sum); };
await client.ConnectAsync(options.Host, options.Port, (challenge, _) =>
{
Print("identity", challenge.CertificateFingerprint, new { status = challenge.Status.ToString() });
return ValueTask.FromResult(options.TrustFirst && challenge.Status == VoiceCat.Crypto.TofuStatus.FirstConnect);
});
AuthResult auth = await client.AuthenticateGuestAsync(options.Nickname);
if (!auth.Ok) throw new InvalidOperationException(auth.Error);
Print("authenticated", options.Nickname, new { userId = auth.Self.Id });
if (options.Channel != 1)
{
var joined = (await client.RequestAsync(new() { JoinChannel = new() { ChannelId = options.Channel } })).JoinChannelResult;
if (!joined.Ok) throw new InvalidOperationException(joined.Error);
}
using var stopped = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; stopped.Cancel(); };
Task events = ObserveAsync(client, options.ExpectText, stopped.Token);
if (options.Voice)
{
VoiceSubscriptionResult subscribed = await client.SubscribeVoiceAsync();
if (!subscribed.Ok) throw new InvalidOperationException(subscribed.Error);
await client.StartStreamAsync(StreamKind.StreamMic, "CLI tone");
client.Audio.InputMode = AudioInputMode.AlwaysOn;
}
Print("ready", options.Nickname);
if (options.Delay > TimeSpan.Zero) await Task.Delay(options.Delay, stopped.Token);
if (options.SendText is not null)
client.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = options.Channel, Body = options.SendText, ClientMsgId = Guid.NewGuid().ToString("N") } });
Task? tone = options.Voice ? SendToneAsync(client, options.ToneDuration, stopped.Token) : null;
if (options.ToneDuration > TimeSpan.Zero)
{
await tone!;
Print("complete", options.Nickname, new { voiceEnergy = Interlocked.Read(ref energy) });
stopped.Cancel();
}
else if (options.OneShot)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(stopped.Token);
deadline.CancelAfter(options.Timeout);
while ((!string.IsNullOrEmpty(options.ExpectText) && !SeenText) || (options.ExpectVoice && Interlocked.Read(ref energy) < 100000))
await Task.Delay(20, deadline.Token);
Print("complete", options.Nickname, new { voiceEnergy = Interlocked.Read(ref energy) });
if (options.Linger > TimeSpan.Zero) await Task.Delay(options.Linger, deadline.Token);
stopped.Cancel();
}
else await InteractiveAsync(client, stopped.Token);
if (tone is not null) try { await tone; } catch (OperationCanceledException) { }
try { await events; } catch (OperationCanceledException) { }
return 0;
}
catch (OperationCanceledException) { Console.Error.WriteLine("VoiceCat.Cli timed out or was cancelled."); return 2; }
catch (Exception exception) { Console.Error.WriteLine(exception.Message); return 1; }
}
private static volatile bool SeenText;
private static async Task ObserveAsync(VoiceCatClient client, string? expected, CancellationToken token)
{
await foreach (Envelope message in client.ReadEventsAsync(token))
{
if (message.TextMessage is { } text)
{
Print("text", text.Body, new { senderId = text.SenderId, channelId = text.TargetId });
if (expected is null || text.Body == expected) SeenText = true;
}
if (message.UserEvent is { } user) Print("user", user.Kind.ToString(), new { userId = user.User?.Id ?? user.LeftId });
}
}
private static async Task SendToneAsync(VoiceCatClient client, TimeSpan requestedDuration, CancellationToken token)
{
TimeSpan duration = requestedDuration > TimeSpan.Zero ? requestedDuration : TimeSpan.FromSeconds(3);
int frames = checked((int)Math.Ceiling(duration.TotalSeconds * 50));
const int leadFrames = 8;
long started = Stopwatch.GetTimestamp();
short[] pcm = new short[960];
for (int frame = 0; frame < frames && !token.IsCancellationRequested; frame++)
{
if (frame >= leadFrames)
{
long target = started + (frame - leadFrames) * Stopwatch.Frequency / 50;
while (true)
{
double remainingMilliseconds = (target - Stopwatch.GetTimestamp()) * 1000.0 / Stopwatch.Frequency;
if (remainingMilliseconds <= 1) break;
await Task.Delay(TimeSpan.FromMilliseconds(remainingMilliseconds - 0.5), token);
}
}
for (int i = 0; i < pcm.Length; i++) pcm[i] = (short)(Math.Sin((frame * 960 + i) * Math.PI * 880 / 48000) * 8000);
foreach (StreamInfo stream in client.LocalStreams)
while (!client.Audio.FeedPcm(stream.StreamId, pcm, 1)) await Task.Delay(1, token);
}
await Task.Delay(TimeSpan.FromMilliseconds(leadFrames * 20), token);
}
private static async Task InteractiveAsync(VoiceCatClient client, CancellationToken token)
{
while (!token.IsCancellationRequested && await Console.In.ReadLineAsync(token) is { } line)
{
if (line == "/quit") return;
if (line.StartsWith("/join ") && uint.TryParse(line[6..], out uint channel)) await client.RequestAsync(new() { JoinChannel = new() { ChannelId = channel } }, token);
else client.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = client.Authentication?.Self.ChannelId ?? 1, Body = line, ClientMsgId = Guid.NewGuid().ToString("N") } });
}
}
private static void Print(string type, string value, object? extra = null) => Console.WriteLine(JsonSerializer.Serialize(new { type, value, extra }));
private sealed record Options(string Host, ushort Port, string Nickname, string Pins, uint Channel, bool TrustFirst, bool Voice, bool ExpectVoice, string? SendText, string? ExpectText, TimeSpan Delay, TimeSpan Linger, TimeSpan Timeout, TimeSpan ToneDuration)
{
internal bool OneShot => SendText is not null || ExpectText is not null || ExpectVoice;
internal static Options Parse(string[] args)
{
string Value(string name, string fallback) { int i = Array.IndexOf(args, name); return i >= 0 && i + 1 < args.Length ? args[i + 1] : fallback; }
bool Has(string name) => args.Contains(name, StringComparer.OrdinalIgnoreCase);
if (Has("--help")) { Console.WriteLine("VoiceCat.Cli --host HOST --port PORT --nickname NAME [--trust-first] [--channel ID] [--voice] [--test-tone-seconds N] [--send-text TEXT] [--expect-text TEXT] [--expect-voice] [--start-delay-ms N]"); Environment.Exit(0); }
TimeSpan toneDuration = TimeSpan.FromSeconds(int.Parse(Value("--test-tone-seconds", "0")));
if (toneDuration < TimeSpan.Zero || toneDuration > TimeSpan.FromHours(1)) throw new ArgumentOutOfRangeException("--test-tone-seconds", "Tone duration must be between 0 and 3600 seconds.");
return new(Value("--host", "127.0.0.1"), ushort.Parse(Value("--port", "8384")), Value("--nickname", Environment.UserName),
Value("--pins", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VoiceCat", "cli-tofu.txt")), uint.Parse(Value("--channel", "1")), Has("--trust-first"), Has("--voice") || Has("--expect-voice") || toneDuration > TimeSpan.Zero, Has("--expect-voice"),
Array.IndexOf(args, "--send-text") is int send and >= 0 && send + 1 < args.Length ? args[send + 1] : null,
Array.IndexOf(args, "--expect-text") is int expect and >= 0 && expect + 1 < args.Length ? args[expect + 1] : null,
TimeSpan.FromMilliseconds(int.Parse(Value("--start-delay-ms", "0"))), TimeSpan.FromMilliseconds(int.Parse(Value("--linger-ms", "1000"))),
TimeSpan.FromSeconds(int.Parse(Value("--timeout-seconds", "15"))), toneDuration);
}
}
}
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../VoiceCat.Core/VoiceCat.Core.csproj" />
</ItemGroup>
</Project>
+51
View File
@@ -0,0 +1,51 @@
{
"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.core": {
"type": "Project",
"dependencies": {
"VoiceCat.Audio": "[1.0.0, )",
"VoiceCat.Crypto": "[1.0.0, )"
}
},
"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, )"
}
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
using Microsoft.Win32.SafeHandles;
namespace VoiceCat.Codec;
internal sealed class OpusEncoderHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public OpusEncoderHandle() : base(true) { }
internal OpusEncoderHandle(nint value) : this() => SetHandle(value);
protected override bool ReleaseHandle() { NativeMethods.EncoderDestroy(handle); return true; }
}
internal sealed class OpusDecoderHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public OpusDecoderHandle() : base(true) { }
internal OpusDecoderHandle(nint value) : this() => SetHandle(value);
protected override bool ReleaseHandle() { NativeMethods.DecoderDestroy(handle); return true; }
}
internal sealed class DredDecoderHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public DredDecoderHandle() : base(true) { }
internal DredDecoderHandle(nint value) : this() => SetHandle(value);
protected override bool ReleaseHandle() { NativeMethods.DredDecoderDestroy(handle); return true; }
}
internal sealed class DredHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public DredHandle() : base(true) { }
internal DredHandle(nint value) : this() => SetHandle(value);
protected override bool ReleaseHandle() { NativeMethods.DredDestroy(handle); return true; }
}
+49
View File
@@ -0,0 +1,49 @@
using System.Runtime.InteropServices;
using System.Reflection;
namespace VoiceCat.Codec;
internal static unsafe partial class NativeMethods
{
static NativeMethods()
{
if (OperatingSystem.IsIOS()) NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, ResolveIosStaticLibrary);
}
private static nint ResolveIosStaticLibrary(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) =>
libraryName == "voicecat_media" ? NativeLibrary.GetMainProgramHandle() : 0;
private const string Library = "voicecat_media";
[LibraryImport(Library, EntryPoint = "vcm_opus_version")]
internal static partial nint Version();
[LibraryImport(Library, EntryPoint = "vcm_opus_error")]
internal static partial nint Error(int error);
[LibraryImport(Library, EntryPoint = "vcm_encoder_create")]
internal static partial nint EncoderCreate(int rate, int channels, int application, out int error);
[LibraryImport(Library, EntryPoint = "vcm_encoder_destroy")]
internal static partial void EncoderDestroy(nint encoder);
[LibraryImport(Library, EntryPoint = "vcm_encoder_set")]
internal static partial int EncoderSet(OpusEncoderHandle encoder, int request, int value);
[LibraryImport(Library, EntryPoint = "vcm_encoder_get_dred")]
internal static partial int EncoderGetDred(OpusEncoderHandle encoder, out int duration);
[LibraryImport(Library, EntryPoint = "vcm_encode")]
internal static partial int Encode(OpusEncoderHandle encoder, short* pcm, int samples, byte* packet, int capacity);
[LibraryImport(Library, EntryPoint = "vcm_decoder_create")]
internal static partial nint DecoderCreate(int rate, int channels, out int error);
[LibraryImport(Library, EntryPoint = "vcm_decoder_destroy")]
internal static partial void DecoderDestroy(nint decoder);
[LibraryImport(Library, EntryPoint = "vcm_decode")]
internal static partial int Decode(OpusDecoderHandle decoder, byte* packet, int length, short* pcm, int samples, int fec);
[LibraryImport(Library, EntryPoint = "vcm_dred_decoder_create")]
internal static partial nint DredDecoderCreate(out int error);
[LibraryImport(Library, EntryPoint = "vcm_dred_decoder_destroy")]
internal static partial void DredDecoderDestroy(nint decoder);
[LibraryImport(Library, EntryPoint = "vcm_dred_create")]
internal static partial nint DredCreate(out int error);
[LibraryImport(Library, EntryPoint = "vcm_dred_destroy")]
internal static partial void DredDestroy(nint dred);
[LibraryImport(Library, EntryPoint = "vcm_dred_parse")]
internal static partial int DredParse(DredDecoderHandle decoder, DredHandle dred, byte* packet, int length, int samples, int rate, out int end);
[LibraryImport(Library, EntryPoint = "vcm_dred_decode")]
internal static partial int DredDecode(OpusDecoderHandle decoder, DredHandle dred, int offset, short* pcm, int samples);
}
+57
View File
@@ -0,0 +1,57 @@
using System.Runtime.InteropServices;
namespace VoiceCat.Codec;
public sealed class OpusDecoder : IDisposable
{
private readonly OpusDecoderHandle handle;
public int SampleRate { get; }
public int Channels { get; }
public OpusDecoder(int sampleRate = 48000, int channels = 1)
{
new OpusOptions { SampleRate = sampleRate, Channels = channels }.Validate();
SampleRate = sampleRate;
Channels = channels;
handle = new(NativeMethods.DecoderCreate(sampleRate, channels, out int error));
if (error < 0 || handle.IsInvalid)
{
handle.Dispose();
OpusException.Check(error);
throw new OutOfMemoryException();
}
}
internal OpusDecoderHandle Handle => handle;
internal void ValidateOutput(Span<short> pcm, int samplesPerChannel)
{
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
if (samplesPerChannel <= 0 || samplesPerChannel > SampleRate * 120 / 1000 || samplesPerChannel % (SampleRate / 400) != 0)
throw new ArgumentOutOfRangeException(nameof(samplesPerChannel));
if (pcm.Length < samplesPerChannel * Channels) throw new ArgumentException("PCM storage is too small.", nameof(pcm));
}
public unsafe int Decode(ReadOnlySpan<byte> packet, Span<short> pcm, int samplesPerChannel, 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)
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();
}
+51
View File
@@ -0,0 +1,51 @@
using System.Runtime.InteropServices;
namespace VoiceCat.Codec;
public sealed class OpusDeepRedundancy : IDisposable
{
private readonly DredDecoderHandle decoder;
private readonly DredHandle dred;
public OpusDeepRedundancy()
{
decoder = new(NativeMethods.DredDecoderCreate(out int error));
if (error < 0 || decoder.IsInvalid)
{
decoder.Dispose();
if (error == -5) throw new NotSupportedException("This libopus build does not include DRED.");
OpusException.Check(error);
throw new OutOfMemoryException();
}
dred = new(NativeMethods.DredCreate(out error));
if (error < 0 || dred.IsInvalid)
{
decoder.Dispose();
dred.Dispose();
if (error == -5) throw new NotSupportedException("This libopus build does not include DRED.");
OpusException.Check(error);
throw new OutOfMemoryException();
}
}
public unsafe bool TryRecover(OpusDecoder audioDecoder, ReadOnlySpan<byte> nextPacket, Span<short> pcm, int samplesPerChannel, int? offset = null)
{
ObjectDisposedException.ThrowIf(decoder.IsClosed, this);
ArgumentNullException.ThrowIfNull(audioDecoder);
audioDecoder.ValidateOutput(pcm, samplesPerChannel);
int recoveryOffset = offset ?? samplesPerChannel;
ArgumentOutOfRangeException.ThrowIfNegative(recoveryOffset);
if (nextPacket.IsEmpty) return false;
if (nextPacket.Overlaps(MemoryMarshal.AsBytes(pcm))) throw new ArgumentException("Packet and PCM storage must not overlap.");
fixed (byte* packet = nextPacket)
fixed (short* output = pcm)
{
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;
}
}
public void Dispose() { dred.Dispose(); decoder.Dispose(); }
}
+53
View File
@@ -0,0 +1,53 @@
using System.Runtime.InteropServices;
namespace VoiceCat.Codec;
public sealed class OpusEncoder : IDisposable
{
private readonly OpusEncoderHandle handle;
public OpusOptions Options { get; }
public bool SupportsDeepRedundancy { get; }
public static string Version => Marshal.PtrToStringUTF8(NativeMethods.Version())!;
public OpusEncoder(OpusOptions? options = null)
{
Options = options ?? new();
Options.Validate();
handle = new(NativeMethods.EncoderCreate(Options.SampleRate, Options.Channels, (int)Options.Application, out int error));
try
{
OpusException.Check(error);
if (handle.IsInvalid) throw new OutOfMemoryException();
Set(4002, Options.Bitrate);
Set(4004, Options.MaximumBandwidthHz switch { 0 => 1105, <= 8000 => 1101, <= 12000 => 1102, <= 16000 => 1103, <= 24000 => 1104, _ => 1105 });
Set(4010, Options.Complexity);
Set(4012, Options.ForwardErrorCorrection ? 1 : 0);
Set(4016, Options.DiscontinuousTransmission ? 1 : 0);
Set(4014, Options.ExpectedPacketLossPercent);
int support = NativeMethods.EncoderGetDred(handle, out _);
if (support != -5) OpusException.Check(support);
SupportsDeepRedundancy = support == 0 && Options.SampleRate >= 16000;
if (Options.DeepRedundancy && !SupportsDeepRedundancy)
throw new NotSupportedException("DRED encoding requires a DRED-enabled libopus build and a PCM rate of at least 16 kHz.");
if (SupportsDeepRedundancy)
// Opus 1.5.2 requires two redundancy chunks; 20 ms alone cannot produce DRED.
Set(4050, Options.DeepRedundancy ? Math.Max(3, (Options.FrameDurationMilliseconds + 9) / 10) : 0);
}
catch { handle.Dispose(); throw; }
}
private void Set(int request, int value) => OpusException.Check(NativeMethods.EncoderSet(handle, request, value));
public unsafe int Encode(ReadOnlySpan<short> pcm, Span<byte> packet)
{
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
if (pcm.Length != Options.SamplesPerChannel * Options.Channels) throw new ArgumentException("PCM must contain exactly one interleaved frame.", nameof(pcm));
if (packet.IsEmpty) throw new ArgumentException("Packet storage must not be empty.", nameof(packet));
if (MemoryMarshal.AsBytes(pcm).Overlaps(packet)) throw new ArgumentException("PCM and packet storage must not overlap.");
fixed (short* input = pcm)
fixed (byte* output = packet)
return OpusException.Check(NativeMethods.Encode(handle, input, Options.SamplesPerChannel, output, packet.Length));
}
public void Dispose() => handle.Dispose();
}
+10
View File
@@ -0,0 +1,10 @@
using System.Runtime.InteropServices;
namespace VoiceCat.Codec;
public sealed class OpusException : Exception
{
public int ErrorCode { get; }
internal OpusException(int error) : base(Marshal.PtrToStringUTF8(NativeMethods.Error(error))) => ErrorCode = error;
internal static int Check(int result) => result < 0 ? throw new OpusException(result) : result;
}
+32
View File
@@ -0,0 +1,32 @@
namespace VoiceCat.Codec;
public enum OpusApplication { Voip = 2048, Audio = 2049, LowDelay = 2051 }
public sealed record OpusOptions
{
public int SampleRate { get; init; } = 48000;
public int Channels { get; init; } = 1;
public int FrameDurationMilliseconds { get; init; } = 20;
public int Bitrate { get; init; } = 24000;
public int MaximumBandwidthHz { get; init; }
public int Complexity { get; init; } = 10;
public int ExpectedPacketLossPercent { get; init; }
public bool ForwardErrorCorrection { get; init; } = true;
public bool DiscontinuousTransmission { get; init; }
public bool DeepRedundancy { get; init; }
public OpusApplication Application { get; init; } = OpusApplication.Voip;
public int SamplesPerChannel => SampleRate / 1000 * FrameDurationMilliseconds;
internal void Validate()
{
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 (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));
if (Complexity is < 0 or > 10) throw new ArgumentOutOfRangeException(nameof(Complexity));
if (ExpectedPacketLossPercent is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(ExpectedPacketLossPercent));
ArgumentOutOfRangeException.ThrowIfNegative(MaximumBandwidthHz);
}
}
+5
View File
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
@@ -0,0 +1,8 @@
{
"version": 1,
"dependencies": {
"net10.0": {},
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"version": 1,
"dependencies": {
"net10.0": {}
}
}
@@ -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": {}
}
}
+71
View File
@@ -0,0 +1,71 @@
using Voicecat.V1;
namespace VoiceCat.Core;
public sealed partial class VoiceCatClient
{
public Permissions Permissions => Authentication?.Permissions?.Clone() ?? new Permissions();
public Task<GenericResult> KickUserAsync(uint userId, string reason = "", CancellationToken cancellationToken = default) =>
RequestGenericAsync(new() { Kick = new() { UserId = userId, Reason = reason } }, cancellationToken);
public Task<GenericResult> BanUserAsync(uint userId, string reason = "", ulong expiresUnixMs = 0, CancellationToken cancellationToken = default) =>
RequestGenericAsync(new() { Ban = new() { UserId = userId, Reason = reason, ExpiresUnixMs = expiresUnixMs } }, cancellationToken);
public Task<GenericResult> MoveUserAsync(uint userId, uint channelId, CancellationToken cancellationToken = default) =>
RequestGenericAsync(new() { MoveUser = new() { UserId = userId, ChannelId = channelId } }, cancellationToken);
public Task<GenericResult> SetServerMuteAsync(uint userId, bool muted, bool deafened, CancellationToken cancellationToken = default) =>
RequestGenericAsync(new() { ServerMute = new() { UserId = userId, Muted = muted, Deafened = deafened } }, cancellationToken);
public Task<GenericResult> SetPermissionsAsync(uint userId, Permissions permissions, CancellationToken cancellationToken = default) =>
RequestGenericAsync(new() { SetPermission = new() { UserId = userId, Permissions = permissions.Clone() } }, cancellationToken);
public Task<GenericResult> CreateChannelAsync(Channel channel, string password = "", CancellationToken cancellationToken = default) =>
RequestGenericAsync(new() { CreateChannel = new() { Channel = channel.Clone(), Password = password } }, cancellationToken);
public Task<GenericResult> EditChannelAsync(Channel channel, string password = "", CancellationToken cancellationToken = default) =>
RequestGenericAsync(new() { EditChannel = new() { Channel = channel.Clone(), Password = password } }, cancellationToken);
public Task<GenericResult> DeleteChannelAsync(uint channelId, CancellationToken cancellationToken = default) =>
RequestGenericAsync(new() { DeleteChannel = new() { ChannelId = channelId } }, cancellationToken);
public Task<GenericResult> CreateAccountAsync(string username, string password, CancellationToken cancellationToken = default) =>
RequestGenericAsync(new() { CreateAccount = new() { Username = username, Password = password } }, cancellationToken);
public Task<GenericResult> ResetPasswordAsync(string username, string password, CancellationToken cancellationToken = default) =>
RequestGenericAsync(new() { ResetPassword = new() { Username = username, NewPassword = password } }, cancellationToken);
public Task<GenericResult> DeleteAccountAsync(string username, CancellationToken cancellationToken = default) =>
RequestGenericAsync(new() { DeleteAccount = new() { Username = username } }, cancellationToken);
public async Task<IReadOnlyList<AccountEntry>> ListAccountsAsync(CancellationToken cancellationToken = default)
{
Envelope response = await RequestAsync(new() { ListAccounts = new() }, cancellationToken).ConfigureAwait(false);
if (response.ListAccountsResult is null) throw new IOException("Unexpected account-list response.");
return response.ListAccountsResult.Accounts.Select(account => account.Clone()).ToArray();
}
public void SetSelfAudioState(bool microphoneMuted, bool deafened)
{
Audio.MicMuted = microphoneMuted;
Audio.Deafened = deafened;
foreach (StreamInfo stream in LocalStreams)
{
(float _, bool talking) = Audio.GetLocalLevel(stream.StreamId);
Send(new() { StreamState = new() { StreamId = stream.StreamId, Muted = microphoneMuted, Talking = talking } });
}
}
public void PublishStreamState(uint streamId, bool talking)
{
if (!LocalStreams.Any(stream => stream.StreamId == streamId)) return;
Send(new() { StreamState = new() { StreamId = streamId, Muted = Audio.MicMuted, Talking = talking } });
}
private async Task<GenericResult> RequestGenericAsync(Envelope request, CancellationToken cancellationToken)
{
Envelope response = await RequestAsync(request, cancellationToken).ConfigureAwait(false);
return response.GenericResult?.Clone() ?? throw new IOException("Unexpected administration response.");
}
}
+128
View File
@@ -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;
}
}
}
+103
View File
@@ -0,0 +1,103 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace VoiceCat.Core;
public enum ServerAuthentication { Guest, Account }
public sealed record ServerProfile(Guid Id, string Host, ushort Port, ServerAuthentication Authentication, string? Username, string? Nickname,
[property: JsonIgnore] string? LegacyKeychainTag = null)
{
public static ServerProfile Create(string host, ushort port, ServerAuthentication authentication, string? username = null, string? nickname = null, Guid? id = null)
{
host = host.Trim(); username = Normalize(username); nickname = Normalize(nickname);
if (host.Length == 0) throw new ArgumentException("Server host is required.", nameof(host));
if (port == 0) throw new ArgumentOutOfRangeException(nameof(port));
if (authentication == ServerAuthentication.Account && username is null) throw new ArgumentException("Username is required for account authentication.", nameof(username));
return new(id.GetValueOrDefault(Guid.NewGuid()), host, port, authentication,
authentication == ServerAuthentication.Account ? username : null,
authentication == ServerAuthentication.Guest ? nickname : null);
}
[JsonIgnore]
public string DisplayName => Authentication == ServerAuthentication.Account
? $"{Username}@{Host}:{Port}"
: $"{Host}:{Port} (Guest{(Nickname is null ? "" : $": {Nickname}")})";
internal bool IsValid => Id != Guid.Empty && !string.IsNullOrWhiteSpace(Host) && Port != 0 &&
(Authentication == ServerAuthentication.Guest || !string.IsNullOrWhiteSpace(Username));
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed class ServerProfileStore(string path)
{
public IReadOnlyList<ServerProfile> Load()
{
try
{
if (!File.Exists(path)) return [];
byte[] contents = File.ReadAllBytes(path);
using JsonDocument document = JsonDocument.Parse(contents);
if (document.RootElement.ValueKind == JsonValueKind.Array && document.RootElement.EnumerateArray().Any(LooksLegacy))
return LoadLegacy(document.RootElement);
return (JsonSerializer.Deserialize(contents, ServerProfileJsonContext.Default.ServerProfileArray) ?? [])
.Where(profile => profile.IsValid).ToArray();
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { return []; }
}
public void Save(IEnumerable<ServerProfile> profiles)
{
ArgumentNullException.ThrowIfNull(profiles);
ServerProfile[] valid = profiles.Where(profile => profile is not null && profile.IsValid).ToArray();
string fullPath = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
PreserveLegacyBackup(fullPath);
string temporary = fullPath + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
File.WriteAllBytes(temporary, JsonSerializer.SerializeToUtf8Bytes(valid, ServerProfileJsonContext.Default.ServerProfileArray));
File.Move(temporary, fullPath, true);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
private static bool LooksLegacy(JsonElement item) => item.ValueKind == JsonValueKind.Object && item.TryGetProperty("authMode", out _);
private static IReadOnlyList<ServerProfile> LoadLegacy(JsonElement root)
{
var profiles = new List<ServerProfile>();
foreach (JsonElement item in root.EnumerateArray())
{
if (!item.TryGetProperty("id", out JsonElement idValue) || !Guid.TryParse(idValue.GetString(), out Guid id) ||
!item.TryGetProperty("host", out JsonElement hostValue) || !item.TryGetProperty("port", out JsonElement portValue) ||
!portValue.TryGetUInt16(out ushort port)) continue;
string? mode = item.TryGetProperty("authMode", out JsonElement modeValue) ? modeValue.GetString() : null;
string? username = item.TryGetProperty("savedUsername", out JsonElement usernameValue) ? usernameValue.GetString() : null;
string? nickname = item.TryGetProperty("nickname", out JsonElement nicknameValue) ? nicknameValue.GetString() : null;
string? keychainTag = item.TryGetProperty("keychainTag", out JsonElement tagValue) ? tagValue.GetString() : null;
ServerAuthentication authentication = mode == "password" ? ServerAuthentication.Account : ServerAuthentication.Guest;
try { profiles.Add(ServerProfile.Create(hostValue.GetString() ?? "", port, authentication, username, nickname, id) with { LegacyKeychainTag = keychainTag }); }
catch (ArgumentException) { }
}
return profiles;
}
private static void PreserveLegacyBackup(string fullPath)
{
if (!File.Exists(fullPath)) return;
try
{
using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(fullPath));
if (document.RootElement.ValueKind != JsonValueKind.Array || !document.RootElement.EnumerateArray().Any(LooksLegacy)) return;
string backup = fullPath + ".swift-backup.json";
if (!File.Exists(backup)) File.Copy(fullPath, backup);
}
catch (JsonException) { }
}
}
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true, UseStringEnumConverter = true)]
[JsonSerializable(typeof(ServerProfile[]))]
internal sealed partial class ServerProfileJsonContext : JsonSerializerContext;
+7
View File
@@ -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>
+294
View File
@@ -0,0 +1,294 @@
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 Exception? ConnectionFailure { get; private set; }
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;
ConnectionFailure = null;
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) { failure = exception; ConnectionFailure = 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();
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"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, )"
}
}
},
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
+44
View File
@@ -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": {}
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Parameters;
namespace VoiceCat.Crypto;
internal sealed class MediaCipher : IDisposable
{
private readonly byte[] key;
private readonly ChaCha20Poly1305? platformCipher;
private bool disposed;
public MediaCipher(ReadOnlySpan<byte> key, bool useManaged)
{
if (key.Length != 32) throw new ArgumentException("Media keys must contain 32 bytes.", nameof(key));
this.key = key.ToArray();
if (!useManaged && ChaCha20Poly1305.IsSupported) platformCipher = new(this.key);
}
public void Encrypt(ulong counter, ReadOnlySpan<byte> plaintext, ReadOnlySpan<byte> aad, Span<byte> output)
{
ObjectDisposedException.ThrowIf(disposed, this);
Span<byte> nonce = stackalloc byte[12];
nonce.Clear();
BinaryPrimitives.WriteUInt64BigEndian(nonce[4..], counter);
if (platformCipher is not null)
{
platformCipher.Encrypt(nonce, plaintext, output[..plaintext.Length], output.Slice(plaintext.Length, 16), aad);
return;
}
var cipher = new Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305();
cipher.Init(true, new AeadParameters(new KeyParameter(key), 128, nonce.ToArray(), aad.ToArray()));
int written = cipher.ProcessBytes(plaintext, output);
cipher.DoFinal(output[written..]);
}
public bool TryDecrypt(ulong counter, ReadOnlySpan<byte> sealedPayload, ReadOnlySpan<byte> aad, Span<byte> output)
{
ObjectDisposedException.ThrowIf(disposed, this);
Span<byte> nonce = stackalloc byte[12];
nonce.Clear();
BinaryPrimitives.WriteUInt64BigEndian(nonce[4..], counter);
int length = sealedPayload.Length - 16;
try
{
if (platformCipher is not null)
platformCipher.Decrypt(nonce, sealedPayload[..length], sealedPayload[length..], output[..length], aad);
else
{
var cipher = new Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305();
cipher.Init(false, new AeadParameters(new KeyParameter(key), 128, nonce.ToArray(), aad.ToArray()));
int written = cipher.ProcessBytes(sealedPayload, output);
cipher.DoFinal(output[written..]);
}
return true;
}
catch (Exception exception) when (exception is AuthenticationTagMismatchException or InvalidCipherTextException)
{
CryptographicOperations.ZeroMemory(output[..length]);
return false;
}
}
public void Dispose()
{
if (disposed) return;
disposed = true;
platformCipher?.Dispose();
CryptographicOperations.ZeroMemory(key);
}
}
+60
View File
@@ -0,0 +1,60 @@
using VoiceCat.Protocol;
namespace VoiceCat.Crypto;
public sealed class MediaDecryptor : IDisposable
{
private readonly MediaCipher cipher;
private ulong highestSequence;
private ulong replayWindow;
private bool initialized;
private bool disposed;
public MediaDecryptor(ReadOnlySpan<byte> key) : this(key, false) { }
internal MediaDecryptor(ReadOnlySpan<byte> key, bool useManaged) => cipher = new(key, useManaged);
public bool TryDecrypt(ReadOnlySpan<byte> packet, Span<byte> plaintext, out VoiceFrameHeader header, out int bytesWritten)
{
ObjectDisposedException.ThrowIf(disposed, this);
header = default;
bytesWritten = 0;
if (packet.Length < VoiceFrameHeader.Size + MediaEncryptor.TagSize) return false;
int length = packet.Length - VoiceFrameHeader.Size - MediaEncryptor.TagSize;
ArgumentOutOfRangeException.ThrowIfLessThan(plaintext.Length, length);
if (packet.Overlaps(plaintext)) throw new ArgumentException("Input and output must not overlap.", nameof(plaintext));
VoiceFrameHeader.TryRead(packet, out var candidate);
ulong sequence = candidate.Sequence;
if (initialized && sequence <= highestSequence)
{
ulong offset = highestSequence - sequence;
if (offset >= 64 || (replayWindow & (1UL << (int)offset)) != 0) return false;
}
if (!cipher.TryDecrypt(sequence, packet[VoiceFrameHeader.Size..], packet[..VoiceFrameHeader.Size], plaintext[..length])) return false;
// Only authenticated counters may move the replay window.
if (!initialized)
{
highestSequence = sequence;
replayWindow = 1;
initialized = true;
}
else if (sequence > highestSequence)
{
ulong shift = sequence - highestSequence;
replayWindow = (shift >= 64 ? 0 : replayWindow << (int)shift) | 1;
highestSequence = sequence;
}
else replayWindow |= 1UL << (int)(highestSequence - sequence);
header = candidate;
bytesWritten = length;
return true;
}
public void Dispose()
{
if (disposed) return;
disposed = true;
cipher.Dispose();
}
}
+40
View File
@@ -0,0 +1,40 @@
using VoiceCat.Protocol;
namespace VoiceCat.Crypto;
public sealed class MediaEncryptor : IDisposable
{
private readonly MediaCipher cipher;
private ulong nextSequence;
private bool disposed;
public const int TagSize = 16;
public MediaEncryptor(ReadOnlySpan<byte> key) : this(key, false) { }
internal MediaEncryptor(ReadOnlySpan<byte> key, bool useManaged, ulong initialSequence = 0)
{
cipher = new(key, useManaged);
nextSequence = initialSequence;
}
public int Encrypt(VoiceFrameHeader header, ReadOnlySpan<byte> plaintext, Span<byte> packet)
{
ObjectDisposedException.ThrowIf(disposed, this);
int size = checked(VoiceFrameHeader.Size + plaintext.Length + TagSize);
ArgumentOutOfRangeException.ThrowIfLessThan(packet.Length, size);
if (nextSequence == ulong.MaxValue) throw new InvalidOperationException("Media counter exhausted; establish a new session.");
if (plaintext.Overlaps(packet)) throw new ArgumentException("Input and output must not overlap.", nameof(packet));
header = header with { Sequence = nextSequence++ };
header.Write(packet);
cipher.Encrypt(header.Sequence, plaintext, packet[..VoiceFrameHeader.Size], packet.Slice(VoiceFrameHeader.Size, plaintext.Length + TagSize));
return size;
}
public void Dispose()
{
if (disposed) return;
disposed = true;
cipher.Dispose();
}
}
+77
View File
@@ -0,0 +1,77 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
namespace VoiceCat.Crypto;
public sealed class PasswordHasher
{
private static readonly UTF8Encoding Utf8 = new(false, true);
public const int MaximumPasswordBytes = 1024;
public string Hash(string password)
{
ArgumentException.ThrowIfNullOrEmpty(password);
byte[] salt = RandomNumberGenerator.GetBytes(16);
byte[] hash = Derive(password, salt, 65536, 2, 1);
try { return $"$argon2id$v=19$m=65536,t=2,p=1${Base64(salt)}${Base64(hash)}"; }
finally { CryptographicOperations.ZeroMemory(hash); }
}
public bool Verify(string password, string encodedHash)
{
ArgumentNullException.ThrowIfNull(password);
ArgumentNullException.ThrowIfNull(encodedHash);
if (encodedHash.Length > 256) return false;
try { if (Utf8.GetByteCount(password) > MaximumPasswordBytes) return false; }
catch (EncoderFallbackException) { return false; }
string[] fields = encodedHash.Split('$');
if (fields.Length != 6 || fields[0] != "" || fields[1] != "argon2id" || fields[2] != "v=19") return false;
string[] costs = fields[3].Split(',');
if (costs.Length != 3 || !Cost(costs[0], "m=", out int memory) || !Cost(costs[1], "t=", out int iterations) || !Cost(costs[2], "p=", out int parallelism)) return false;
if (memory is < 8 or > 131072 || iterations is < 1 or > 10 || parallelism is < 1 or > 4 || memory < 8 * parallelism) return false;
byte[] salt, expected;
try { salt = Decode(fields[4]); expected = Decode(fields[5]); }
catch (FormatException) { return false; }
if (salt.Length != 16 || expected.Length != 32) return false;
byte[] actual = Derive(password, salt, memory, iterations, parallelism);
try { return CryptographicOperations.FixedTimeEquals(actual, expected); }
finally { CryptographicOperations.ZeroMemory(actual); }
}
private static bool Cost(string value, string prefix, out int cost)
{
cost = 0;
return value.StartsWith(prefix, StringComparison.Ordinal) && int.TryParse(value.AsSpan(prefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out cost);
}
private static byte[] Derive(string password, byte[] salt, int memory, int iterations, int parallelism)
{
if (Utf8.GetByteCount(password) > MaximumPasswordBytes) throw new ArgumentException("Password exceeds 1024 UTF-8 bytes.", nameof(password));
byte[] bytes = Utf8.GetBytes(password);
byte[] output = new byte[32];
var parameters = new Argon2Parameters.Builder(Argon2Parameters.Argon2id)
.WithVersion(Argon2Parameters.Version13).WithMemoryAsKB(memory)
.WithIterations(iterations).WithParallelism(parallelism).WithSalt(salt).Build();
try
{
var generator = new Argon2BytesGenerator();
generator.Init(parameters);
generator.GenerateBytes(bytes, output);
return output;
}
catch { CryptographicOperations.ZeroMemory(output); throw; }
finally { CryptographicOperations.ZeroMemory(bytes); }
}
private static string Base64(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=');
private static byte[] Decode(string value)
{
if (value.Contains('=') || value.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not ('+' or '/'))) throw new FormatException();
byte[] bytes = Convert.FromBase64String(value.PadRight((value.Length + 3) / 4 * 4, '='));
if (Base64(bytes) != value) throw new FormatException();
return bytes;
}
}
+23
View File
@@ -0,0 +1,23 @@
namespace VoiceCat.Crypto;
internal static class PrivateFiles
{
public static void Write(string path, ReadOnlySpan<byte> data)
{
string destination = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
string temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
using (var stream = new FileStream(temporary, options))
{
stream.Write(data);
stream.Flush(flushToDisk: true);
}
File.Move(temporary, destination, overwrite: true);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
}
+75
View File
@@ -0,0 +1,75 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
namespace VoiceCat.Crypto;
public sealed class ServerCredentials : IDisposable
{
private readonly X509Certificate2 certificate;
private bool disposed;
private ServerCredentials(ServerIdentity identity, X509Certificate2 certificate)
{
Identity = identity;
this.certificate = certificate;
}
public ServerIdentity Identity { get; }
public string CertificateFingerprint => Convert.ToHexString(SHA256.HashData(certificate.RawData));
public static ServerCredentials LoadOrCreate(string directory, string serverName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
Directory.CreateDirectory(directory);
string identityPath = Path.Combine(directory, "identity.key");
string certificatePath = Path.Combine(directory, "server.crt");
string keyPath = Path.Combine(directory, "server.key");
bool hasIdentity = File.Exists(identityPath);
bool hasCertificate = File.Exists(certificatePath);
bool hasKey = File.Exists(keyPath);
if (hasIdentity && hasCertificate && hasKey)
{
var identity = ServerIdentity.Load(identityPath);
try { return new(identity, X509Certificate2.CreateFromPemFile(certificatePath, keyPath)); }
catch { identity.Dispose(); throw; }
}
if (hasIdentity || hasCertificate || hasKey)
throw new InvalidDataException("Server credentials are incomplete; restore the missing files before starting.");
var generated = ServerIdentity.Generate();
try
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var name = new X500DistinguishedNameBuilder();
name.AddCommonName(serverName);
var request = new CertificateRequest(name.Build(), key, HashAlgorithmName.SHA256);
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
var san = new SubjectAlternativeNameBuilder();
san.AddUri(new Uri("urn:voicecat:identity:ed25519:" + Convert.ToHexString(generated.PublicKey).ToLowerInvariant()));
request.CertificateExtensions.Add(san.Build());
using var created = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddYears(10));
string certificatePem = created.ExportCertificatePem();
string privateKeyPem = key.ExportPkcs8PrivateKeyPem();
generated.Save(identityPath);
PrivateFiles.Write(certificatePath, Encoding.UTF8.GetBytes(certificatePem));
PrivateFiles.Write(keyPath, Encoding.UTF8.GetBytes(privateKeyPem));
return new(generated, X509Certificate2.CreateFromPem(certificatePem, privateKeyPem));
}
catch { generated.Dispose(); throw; }
}
public TlsSession CreateTlsSession()
{
ObjectDisposedException.ThrowIf(disposed, this);
using var key = certificate.GetECDsaPrivateKey() ?? throw new InvalidDataException("Server TLS certificate requires an ECDSA key.");
return TlsSession.CreateServer(certificate.ExportCertificatePem(), key.ExportPkcs8PrivateKeyPem());
}
public void Dispose()
{
if (disposed) return;
disposed = true;
Identity.Dispose();
certificate.Dispose();
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Security.Cryptography;
using Org.BouncyCastle.Crypto.Parameters;
namespace VoiceCat.Crypto;
public sealed class ServerIdentity : IDisposable
{
private readonly byte[] seed;
private readonly byte[] publicKey;
private bool disposed;
private ServerIdentity(byte[] seed)
{
this.seed = seed;
publicKey = new Ed25519PrivateKeyParameters(seed, 0).GeneratePublicKey().GetEncoded();
}
public byte[] PublicKey => (byte[])publicKey.Clone();
public string Fingerprint => Convert.ToHexString(SHA256.HashData(publicKey));
public static ServerIdentity Generate() => new(RandomNumberGenerator.GetBytes(32));
public static ServerIdentity Load(string path)
{
byte[] data = File.ReadAllBytes(path);
try
{
if (data.Length != 96) throw new InvalidDataException("Server identity must contain 96 bytes.");
var identity = new ServerIdentity(data.AsSpan(32, 32).ToArray());
if (!CryptographicOperations.FixedTimeEquals(identity.publicKey, data.AsSpan(0, 32)) ||
!CryptographicOperations.FixedTimeEquals(identity.publicKey, data.AsSpan(64, 32)))
{
identity.Dispose();
throw new InvalidDataException("Server identity public key does not match its seed.");
}
return identity;
}
finally { CryptographicOperations.ZeroMemory(data); }
}
public void Save(string path)
{
ObjectDisposedException.ThrowIf(disposed, this);
byte[] data = new byte[96];
publicKey.CopyTo(data, 0);
seed.CopyTo(data, 32);
publicKey.CopyTo(data, 64);
try { PrivateFiles.Write(path, data); }
finally { CryptographicOperations.ZeroMemory(data); }
}
public void Dispose()
{
if (disposed) return;
disposed = true;
CryptographicOperations.ZeroMemory(seed);
}
}
+208
View File
@@ -0,0 +1,208 @@
using System.Security.Cryptography;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Tls;
using Org.BouncyCastle.Tls.Crypto;
using Org.BouncyCastle.Tls.Crypto.Impl.BC;
namespace VoiceCat.Crypto;
public sealed class TlsSession : IDisposable
{
private readonly TlsProtocol protocol;
private readonly bool isClient;
private readonly byte[] scratch = new byte[16384];
private byte[]? clientToServerKey;
private byte[]? serverToClientKey;
private bool disposed;
private TlsSession(TlsProtocol protocol, bool isClient)
{
this.protocol = protocol;
this.isClient = isClient;
}
public bool IsReady => !disposed && clientToServerKey is not null && serverToClientKey is not null && !protocol.IsClosed;
public string? PeerCertificateFingerprint { get; private set; }
public int PendingCiphertextBytes => protocol.GetAvailableOutputBytes();
public void Close()
{
ObjectDisposedException.ThrowIf(disposed, this);
protocol.Close();
}
public void CompleteInput()
{
ObjectDisposedException.ThrowIf(disposed, this);
protocol.CloseInput();
}
public static TlsSession CreateClient(Func<string, bool> acceptCertificate)
{
ArgumentNullException.ThrowIfNull(acceptCertificate);
var protocol = new TlsClientProtocol();
var session = new TlsSession(protocol, true);
protocol.Connect(new ClientPeer(session, acceptCertificate));
return session;
}
public static TlsSession CreateServer(string certificatePem, string privateKeyPem)
{
ArgumentException.ThrowIfNullOrWhiteSpace(certificatePem);
ArgumentException.ThrowIfNullOrWhiteSpace(privateKeyPem);
var protocol = new TlsServerProtocol();
var session = new TlsSession(protocol, false);
protocol.Accept(new ServerPeer(session, certificatePem, privateKeyPem));
return session;
}
public void ReceiveCiphertext(ReadOnlySpan<byte> input)
{
ObjectDisposedException.ThrowIf(disposed, this);
while (!input.IsEmpty)
{
int count = Math.Min(input.Length, scratch.Length);
input[..count].CopyTo(scratch);
protocol.OfferInput(scratch, 0, count);
input = input[count..];
}
}
public int DrainCiphertext(Span<byte> output)
{
ObjectDisposedException.ThrowIf(disposed, this);
int count = protocol.ReadOutput(scratch, 0, Math.Min(output.Length, scratch.Length));
scratch.AsSpan(0, count).CopyTo(output);
return count;
}
public int ReadPlaintext(Span<byte> output)
{
ObjectDisposedException.ThrowIf(disposed, this);
int count = protocol.ReadInput(scratch, 0, Math.Min(output.Length, scratch.Length));
scratch.AsSpan(0, count).CopyTo(output);
CryptographicOperations.ZeroMemory(scratch.AsSpan(0, count));
return count;
}
public void WritePlaintext(ReadOnlySpan<byte> input)
{
RequireReady();
protocol.WriteApplicationData(input);
}
public MediaEncryptor CreateMediaEncryptor()
{
byte[] key = ExportMediaKey(isClient ? (byte)0 : (byte)1);
try { return new(key); }
finally { CryptographicOperations.ZeroMemory(key); }
}
public MediaDecryptor CreateMediaDecryptor()
{
byte[] key = ExportMediaKey(isClient ? (byte)1 : (byte)0);
try { return new(key); }
finally { CryptographicOperations.ZeroMemory(key); }
}
internal byte[] ExportMediaKey(byte direction)
{
RequireReady();
ArgumentOutOfRangeException.ThrowIfGreaterThan(direction, (byte)1);
return (byte[])(direction == 0 ? clientToServerKey! : serverToClientKey!).Clone();
}
private void CompleteHandshake(TlsContext context)
{
// BouncyCastle destroys exporter secrets after this callback returns.
clientToServerKey = context.ExportKeyingMaterial("voicecat media v1", [0], 32);
serverToClientKey = context.ExportKeyingMaterial("voicecat media v1", [1], 32);
}
private void RequireReady()
{
ObjectDisposedException.ThrowIf(disposed, this);
if (!IsReady) throw new InvalidOperationException("TLS handshake has not completed or the session is closed.");
}
public void Dispose()
{
if (disposed) return;
disposed = true;
try { protocol.Close(); }
finally
{
if (clientToServerKey is not null) CryptographicOperations.ZeroMemory(clientToServerKey);
if (serverToClientKey is not null) CryptographicOperations.ZeroMemory(serverToClientKey);
CryptographicOperations.ZeroMemory(scratch);
}
}
private sealed class ClientPeer(TlsSession session, Func<string, bool> acceptCertificate)
: DefaultTlsClient(new BcTlsCrypto())
{
protected override ProtocolVersion[] GetSupportedVersions() => [ProtocolVersion.TLSv13];
protected override int[] GetSupportedCipherSuites() => CipherSuites;
public override TlsAuthentication GetAuthentication() => new Authentication(session, acceptCertificate);
public override void NotifyHandshakeComplete()
{
base.NotifyHandshakeComplete();
session.CompleteHandshake(m_context);
}
}
private sealed class Authentication(TlsSession session, Func<string, bool> acceptCertificate) : TlsAuthentication
{
public void NotifyServerCertificate(TlsServerCertificate serverCertificate)
{
var chain = serverCertificate.Certificate.GetCertificateList();
if (chain.Length == 0) throw new TlsFatalAlert(AlertDescription.bad_certificate);
string fingerprint = Convert.ToHexString(SHA256.HashData(chain[0].GetEncoded()));
session.PeerCertificateFingerprint = fingerprint;
if (!acceptCertificate(fingerprint)) throw new TlsFatalAlert(AlertDescription.bad_certificate);
}
public TlsCredentials? GetClientCredentials(Org.BouncyCastle.Tls.CertificateRequest certificateRequest) => null;
}
private sealed class ServerPeer : DefaultTlsServer
{
private readonly TlsSession session;
private readonly byte[] certificateDer;
private readonly AsymmetricKeyParameter privateKey;
public ServerPeer(TlsSession session, string certificatePem, string privateKeyPem) : base(new BcTlsCrypto())
{
this.session = session;
using var certificate = System.Security.Cryptography.X509Certificates.X509Certificate2.CreateFromPem(certificatePem);
certificateDer = certificate.RawData;
using var reader = new StringReader(privateKeyPem);
privateKey = (AsymmetricKeyParameter)new PemReader(reader).ReadObject();
if (privateKey is not Org.BouncyCastle.Crypto.Parameters.ECPrivateKeyParameters)
throw new ArgumentException("Server TLS credentials require an ECDSA key.", nameof(privateKeyPem));
}
protected override ProtocolVersion[] GetSupportedVersions() => [ProtocolVersion.TLSv13];
protected override int[] GetSupportedCipherSuites() => CipherSuites;
public override TlsCredentials GetCredentials()
{
var certificate = new Certificate([], [new CertificateEntry(Crypto.CreateCertificate(certificateDer), null)]);
return new BcDefaultTlsCredentialedSigner(new TlsCryptoParameters(m_context), (BcTlsCrypto)Crypto,
privateKey, certificate, new SignatureAndHashAlgorithm(Org.BouncyCastle.Tls.HashAlgorithm.sha256, SignatureAlgorithm.ecdsa));
}
public override void NotifyHandshakeComplete()
{
base.NotifyHandshakeComplete();
session.CompleteHandshake(m_context);
}
}
private static int[] CipherSuites =>
[
CipherSuite.TLS_AES_128_GCM_SHA256,
CipherSuite.TLS_AES_256_GCM_SHA384,
CipherSuite.TLS_CHACHA20_POLY1305_SHA256
];
}
+72
View File
@@ -0,0 +1,72 @@
using System.Text;
namespace VoiceCat.Crypto;
public enum TofuStatus { FirstConnect, Matched, Mismatch }
public sealed class TofuStore
{
private readonly string path;
private readonly Dictionary<string, string> pins = new(StringComparer.Ordinal);
public TofuStore(string path)
{
this.path = Path.GetFullPath(path);
if (!File.Exists(this.path)) return;
foreach (string line in File.ReadLines(this.path))
{
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#')) continue;
string[] parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) throw new InvalidDataException("Malformed TOFU pin entry.");
pins[parts[0]] = NormalizeFingerprint(parts[1]);
}
}
public TofuStatus Check(string host, ushort port, string fingerprint)
{
string key = Endpoint(host, port);
string normalized = NormalizeFingerprint(fingerprint);
return !pins.TryGetValue(key, out var pin) ? TofuStatus.FirstConnect :
pin == normalized ? TofuStatus.Matched : TofuStatus.Mismatch;
}
public void Pin(string host, ushort port, string fingerprint)
{
string key = Endpoint(host, port);
string value = NormalizeFingerprint(fingerprint);
var updated = new Dictionary<string, string>(pins, StringComparer.Ordinal) { [key] = value };
Save(updated);
pins[key] = value;
}
public void Remove(string host, ushort port)
{
string key = Endpoint(host, port);
var updated = new Dictionary<string, string>(pins, StringComparer.Ordinal);
updated.Remove(key);
Save(updated);
pins.Remove(key);
}
private void Save(Dictionary<string, string> updated)
{
string contents = string.Concat(updated.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key} {pair.Value}\n"));
PrivateFiles.Write(path, Encoding.UTF8.GetBytes(contents));
}
private static string Endpoint(string host, ushort port)
{
ArgumentException.ThrowIfNullOrWhiteSpace(host);
if (host.Any(char.IsWhiteSpace)) throw new ArgumentException("Host cannot contain whitespace.", nameof(host));
ArgumentOutOfRangeException.ThrowIfZero(port);
return $"{host}:{port}";
}
private static string NormalizeFingerprint(string fingerprint)
{
ArgumentNullException.ThrowIfNull(fingerprint);
if (fingerprint.Length != 64 || !fingerprint.All(Uri.IsHexDigit))
throw new InvalidDataException("TLS certificate fingerprints must contain 64 hexadecimal characters.");
return fingerprint.ToLowerInvariant();
}
}
@@ -0,0 +1,10 @@
using VoiceCat.Crypto;
namespace VoiceCat.Transport;
internal sealed class MediaSessionCrypto(MediaEncryptor encryptor, MediaDecryptor decryptor) : IDisposable
{
public MediaEncryptor Encryptor { get; } = encryptor;
public MediaDecryptor Decryptor { get; } = decryptor;
public void Dispose() { Encryptor.Dispose(); Decryptor.Dispose(); }
}
@@ -0,0 +1,186 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Net.Sockets;
using System.Threading.Channels;
using Google.Protobuf;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Transport;
internal sealed class TlsControlConnection : IAsyncDisposable
{
internal const int MaximumPayloadLength = 65536;
private readonly Socket socket;
private readonly TlsSession tls;
private readonly CancellationTokenSource lifetime;
private readonly Channel<byte[]> outgoing = System.Threading.Channels.Channel.CreateBounded<byte[]>(64);
private readonly Channel<Envelope> incoming = System.Threading.Channels.Channel.CreateBounded<Envelope>(32);
private readonly byte[] prefix = new byte[4];
private int prefixBytes;
private byte[]? payload;
private int payloadBytes;
private readonly TaskCompletionSource mediaReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
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)
{
this.socket = socket;
this.tls = tls;
lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
lifetime.CancelAfter(handshakeTimeout ?? TimeSpan.FromSeconds(15));
Completion = RunAsync();
}
public IAsyncEnumerable<Envelope> ReadAsync(CancellationToken cancellationToken) => incoming.Reader.ReadAllAsync(cancellationToken);
public bool TrySend(Envelope envelope)
{
if (envelope.CalculateSize() > MaximumPayloadLength) throw new InvalidDataException("Server control payload exceeds 64 KiB.");
var framed = new ArrayBufferWriter<byte>();
ControlFraming.WriteEnvelope(framed, envelope);
if (outgoing.Writer.TryWrite(framed.WrittenSpan.ToArray())) return true;
lifetime.Cancel();
return false;
}
public void CompleteWrites() => outgoing.Writer.TryComplete();
internal async Task<MediaSessionCrypto> TakeMediaCryptoAsync(CancellationToken cancellationToken)
{
await mediaReady.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
return Interlocked.Exchange(ref mediaCrypto, null) ?? throw new InvalidOperationException("Media crypto already has an owner.");
}
private async Task RunAsync()
{
byte[] ciphertext = new byte[16384];
byte[] plaintext = new byte[16384];
byte[] sendBuffer = new byte[16384];
CancellationToken cancellationToken = lifetime.Token;
Task<int>? receive = null;
Task<bool>? ready = null;
Exception? error = null;
try
{
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
receive = socket.ReceiveAsync(ciphertext, SocketFlags.None, cancellationToken).AsTask();
while (true)
{
if (tls.IsReady)
{
while (outgoing.Reader.TryRead(out byte[]? frame)) tls.WritePlaintext(frame);
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
ready ??= outgoing.Reader.WaitToReadAsync(cancellationToken).AsTask();
}
Task winner = ready is null ? receive : await Task.WhenAny(receive, ready).ConfigureAwait(false);
if (winner == receive)
{
int count = await receive.ConfigureAwait(false);
if (count == 0)
{
tls.CompleteInput();
if (prefixBytes != 0 || payload is not null) throw new InvalidDataException("Truncated control frame.");
break;
}
tls.ReceiveCiphertext(ciphertext.AsSpan(0, count));
if (tls.IsReady && !mediaReady.Task.IsCompleted)
{
var encryptor = tls.CreateMediaEncryptor();
try { mediaCrypto = new(encryptor, tls.CreateMediaDecryptor()); }
catch { encryptor.Dispose(); throw; }
mediaReady.SetResult();
lifetime.CancelAfter(Timeout.InfiniteTimeSpan);
}
while ((count = tls.ReadPlaintext(plaintext)) > 0) Parse(plaintext.AsSpan(0, count));
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
receive = socket.ReceiveAsync(ciphertext, SocketFlags.None, cancellationToken).AsTask();
}
else
{
bool hasOutgoing = await ready!.ConfigureAwait(false);
ready = null;
if (!hasOutgoing)
{
tls.Close();
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
break;
}
}
}
}
catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
{
if (!cancellationToken.IsCancellationRequested) error = exception;
}
finally
{
mediaReady.TrySetCanceled();
lifetime.Cancel();
socket.Dispose();
if (receive is not null)
{
try { await receive.ConfigureAwait(false); }
catch (Exception exception) when (exception is SocketException or OperationCanceledException or ObjectDisposedException) { }
}
tls.Dispose();
incoming.Writer.TryComplete(error);
outgoing.Writer.TryComplete(error);
}
}
private async Task FlushAsync(byte[] buffer, CancellationToken cancellationToken)
{
int count;
while ((count = tls.DrainCiphertext(buffer)) > 0)
{
int sent = 0;
while (sent < count)
{
int written = await socket.SendAsync(buffer.AsMemory(sent, count - sent), SocketFlags.None, cancellationToken).ConfigureAwait(false);
if (written == 0) throw new IOException("Socket closed during TLS send.");
sent += written;
}
}
}
private void Parse(ReadOnlySpan<byte> input)
{
while (!input.IsEmpty)
{
if (payload is null)
{
int count = Math.Min(4 - prefixBytes, input.Length);
input[..count].CopyTo(prefix.AsSpan(prefixBytes));
prefixBytes += count;
input = input[count..];
if (prefixBytes != 4) continue;
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
if (length > MaximumPayloadLength) throw new InvalidDataException("Server control payload exceeds 64 KiB.");
payload = new byte[length];
prefixBytes = 0;
}
int consumed = Math.Min(payload.Length - payloadBytes, input.Length);
input[..consumed].CopyTo(payload.AsSpan(payloadBytes));
payloadBytes += consumed;
input = input[consumed..];
if (payloadBytes != payload.Length) continue;
Envelope envelope = Envelope.Parser.ParseFrom(payload);
payload = null;
payloadBytes = 0;
if (!incoming.Writer.TryWrite(envelope)) throw new IOException("Control consumer exceeded its bounded queue.");
}
}
public async ValueTask DisposeAsync()
{
lifetime.Cancel();
try { await Completion.ConfigureAwait(false); }
finally { Interlocked.Exchange(ref mediaCrypto, null)?.Dispose(); lifetime.Dispose(); }
}
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="../VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="VoiceCat.Tests" />
<InternalsVisibleTo Include="VoiceCat.Server" />
<InternalsVisibleTo Include="VoiceCat.Core" />
</ItemGroup>
</Project>
@@ -0,0 +1,26 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"BouncyCastle.Cryptography": {
"type": "Direct",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"BouncyCastle.Cryptography": {
"type": "Direct",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
}
}
}
@@ -0,0 +1,31 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"BouncyCastle.Cryptography": {
"type": "Direct",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0/linux-x64": {}
}
}
@@ -0,0 +1,31 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"BouncyCastle.Cryptography": {
"type": "Direct",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0/win-x64": {}
}
}
+48
View File
@@ -0,0 +1,48 @@
namespace VoiceCat.Dsp;
public sealed class EnergyVadProcessor
{
private readonly TimeProvider timeProvider;
private long lastVoiceTimestamp;
private bool hasVoice;
private float threshold;
public float Threshold
{
get => Volatile.Read(ref threshold);
set
{
if (!float.IsFinite(value) || value is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(value));
Volatile.Write(ref threshold, value);
}
}
public TimeSpan HangTime { get; }
public EnergyVadProcessor(float threshold = 0.02f, TimeSpan? hangTime = null, TimeProvider? timeProvider = null)
{
Threshold = threshold;
HangTime = hangTime ?? TimeSpan.FromMilliseconds(300);
if (HangTime < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(hangTime));
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public bool Process(ReadOnlySpan<short> pcm)
{
long now = timeProvider.GetTimestamp();
if (!pcm.IsEmpty)
{
double sum = 0;
foreach (short sample in pcm)
{
double normalized = sample / 32768.0;
sum += normalized * normalized;
}
if (Math.Sqrt(sum / pcm.Length) >= Threshold)
{
lastVoiceTimestamp = now;
hasVoice = true;
}
}
return hasVoice && timeProvider.GetElapsedTime(lastVoiceTimestamp, now) < HangTime;
}
}
+63
View File
@@ -0,0 +1,63 @@
using System.Runtime.InteropServices;
using System.Reflection;
using Microsoft.Win32.SafeHandles;
namespace VoiceCat.Dsp;
public sealed unsafe partial class RnnoiseProcessor : IDisposable
{
private const string NativeLibrary = "voicecat_media";
public const int SampleRate = 48000;
public const int FrameSamples = 480;
private readonly RnnoiseHandle handle;
private readonly float[] input = new float[FrameSamples];
private readonly float[] output = new float[FrameSamples];
static RnnoiseProcessor()
{
if (OperatingSystem.IsIOS()) System.Runtime.InteropServices.NativeLibrary.SetDllImportResolver(typeof(RnnoiseProcessor).Assembly, ResolveIosStaticLibrary);
}
private static nint ResolveIosStaticLibrary(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) =>
libraryName == NativeLibrary ? System.Runtime.InteropServices.NativeLibrary.GetMainProgramHandle() : 0;
public RnnoiseProcessor()
{
handle = new(Create());
if (handle.IsInvalid) { handle.Dispose(); throw new OutOfMemoryException(); }
}
public void Process(Span<short> pcm, int sampleRate = SampleRate)
{
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
if (sampleRate != SampleRate) return;
if (pcm.Length % FrameSamples != 0) throw new ArgumentException("RNNoise requires complete 480-sample mono chunks.", nameof(pcm));
fixed (float* source = input)
fixed (float* destination = output)
{
for (int offset = 0; offset < pcm.Length; offset += FrameSamples)
{
for (int i = 0; i < FrameSamples; i++) input[i] = pcm[offset + i];
ProcessFrame(handle, destination, source);
for (int i = 0; i < FrameSamples; i++)
pcm[offset + i] = (short)Math.Clamp(MathF.Round(output[i], MidpointRounding.AwayFromZero), short.MinValue, short.MaxValue);
}
}
}
public void Dispose() => handle.Dispose();
[LibraryImport(NativeLibrary, EntryPoint = "vcm_rnnoise_create")]
private static partial nint Create();
[LibraryImport(NativeLibrary, EntryPoint = "vcm_rnnoise_destroy")]
private static partial void Destroy(nint state);
[LibraryImport(NativeLibrary, EntryPoint = "vcm_rnnoise_process")]
private static partial float ProcessFrame(RnnoiseHandle state, float* output, float* input);
private sealed class RnnoiseHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public RnnoiseHandle() : base(true) { }
internal RnnoiseHandle(nint value) : this() => SetHandle(value);
protected override bool ReleaseHandle() { Destroy(handle); return true; }
}
}
+5
View File
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
+8
View File
@@ -0,0 +1,8 @@
{
"version": 1,
"dependencies": {
"net10.0": {},
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"version": 1,
"dependencies": {
"net10.0": {}
}
}
@@ -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": {}
}
}
+95
View File
@@ -0,0 +1,95 @@
using System.Buffers;
using System.Buffers.Binary;
using System.IO.Pipelines;
using System.Runtime.CompilerServices;
using Google.Protobuf;
using Voicecat.V1;
namespace VoiceCat.Protocol;
public static class ControlFraming
{
public const int MaxPayloadLength = 16 * 1024 * 1024;
public static bool TryReadFrame(ref ReadOnlySequence<byte> input, out ReadOnlySequence<byte> payload)
{
payload = default;
if (input.Length < 4) return false;
Span<byte> prefix = stackalloc byte[4];
input.Slice(0, 4).CopyTo(prefix);
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
if (length > MaxPayloadLength) throw new InvalidDataException("Control frame exceeds 16 MiB.");
if (input.Length < 4L + length) return false;
payload = input.Slice(4, length);
input = input.Slice(4L + length);
return true;
}
public static void WriteFrame(IBufferWriter<byte> output, ReadOnlySpan<byte> payload)
{
ArgumentNullException.ThrowIfNull(output);
ArgumentOutOfRangeException.ThrowIfGreaterThan(payload.Length, MaxPayloadLength);
BinaryPrimitives.WriteUInt32BigEndian(output.GetSpan(4), (uint)payload.Length);
output.Advance(4);
output.Write(payload);
}
public static void WriteEnvelope(IBufferWriter<byte> output, Envelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentNullException.ThrowIfNull(output);
int length = envelope.CalculateSize();
ArgumentOutOfRangeException.ThrowIfGreaterThan(length, MaxPayloadLength);
BinaryPrimitives.WriteUInt32BigEndian(output.GetSpan(4), (uint)length);
output.Advance(4);
envelope.WriteTo(output);
}
public static async IAsyncEnumerable<Envelope> ReadEnvelopesAsync(
PipeReader reader, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(reader);
byte[] prefix = new byte[4];
while (true)
{
if (!await ReadExactlyAsync(reader, prefix, cancellationToken).ConfigureAwait(false)) yield break;
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
if (length > MaxPayloadLength) throw new InvalidDataException("Control frame exceeds 16 MiB.");
byte[] payload = length == 0 ? [] : new byte[length];
if (length != 0 && !await ReadExactlyAsync(reader, payload, cancellationToken).ConfigureAwait(false))
throw new InvalidDataException("Truncated control frame.");
yield return Envelope.Parser.ParseFrom(payload);
}
}
private static async ValueTask<bool> ReadExactlyAsync(PipeReader reader, Memory<byte> destination, CancellationToken cancellationToken)
{
int written = 0;
while (written < destination.Length)
{
ReadResult result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
var buffer = result.Buffer;
var consumed = buffer.Start;
try
{
if (result.IsCanceled) throw new OperationCanceledException(cancellationToken);
int count = (int)Math.Min(buffer.Length, destination.Length - written);
buffer.Slice(0, count).CopyTo(destination.Span[written..]);
consumed = buffer.GetPosition(count);
written += count;
if (written == destination.Length) return true;
if (result.IsCompleted)
{
if (written != 0) throw new InvalidDataException("Truncated control frame.");
return false;
}
}
finally
{
// Consume fragments so pipe backpressure cannot stall a large frame.
reader.AdvanceTo(consumed, consumed);
}
}
return true;
}
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Condition="'$(Protobuf_ProtocFullPath)' == '' and Exists('/opt/homebrew/bin/protoc')">
<!-- Grpc.Tools currently ships an x64-only macOS protoc. Prefer Homebrew's native
compiler on Apple Silicon hosts that do not have Rosetta installed. -->
<Protobuf_ProtocFullPath>/opt/homebrew/bin/protoc</Protobuf_ProtocFullPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.36.1" />
<PackageReference Include="Grpc.Tools" Version="2.83.0" PrivateAssets="all" />
<Protobuf Include="../../proto/voicecat.proto" GrpcServices="None" />
</ItemGroup>
</Project>
+49
View File
@@ -0,0 +1,49 @@
using System.Buffers.Binary;
namespace VoiceCat.Protocol;
public enum MediaFrameType : byte
{
Voice = 1,
Keepalive = 2,
UdpBinding = 3
}
[Flags]
public enum VoiceFrameFlags : byte
{
None = 0,
Marker = 1,
FecPresent = 2,
Dtx = 4,
Last = 8
}
public readonly record struct VoiceFrameHeader(
MediaFrameType Type, VoiceFrameFlags Flags, ushort Codec, uint Ssrc, ulong Sequence, uint Timestamp)
{
public const int Size = 20;
public void Write(Span<byte> destination)
{
ArgumentOutOfRangeException.ThrowIfLessThan(destination.Length, Size);
destination[0] = (byte)Type;
destination[1] = (byte)Flags;
BinaryPrimitives.WriteUInt16BigEndian(destination[2..], Codec);
BinaryPrimitives.WriteUInt32BigEndian(destination[4..], Ssrc);
BinaryPrimitives.WriteUInt64BigEndian(destination[8..], Sequence);
BinaryPrimitives.WriteUInt32BigEndian(destination[16..], Timestamp);
}
public static bool TryRead(ReadOnlySpan<byte> source, out VoiceFrameHeader header)
{
header = default;
if (source.Length < Size) return false;
header = new((MediaFrameType)source[0], (VoiceFrameFlags)source[1],
BinaryPrimitives.ReadUInt16BigEndian(source[2..]),
BinaryPrimitives.ReadUInt32BigEndian(source[4..]),
BinaryPrimitives.ReadUInt64BigEndian(source[8..]),
BinaryPrimitives.ReadUInt32BigEndian(source[16..]));
return true;
}
}
@@ -0,0 +1,21 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Google.Protobuf": {
"type": "Direct",
"requested": "[3.36.1, )",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"Grpc.Tools": {
"type": "Direct",
"requested": "[2.83.0, )",
"resolved": "2.83.0",
"contentHash": "vK2Go/83W0v2Nn7tTP9fGrX4IjmOa93s3M0SZeFimU1vIIr2wL9yNJlIyK21y85SGm3++JncB8IF751cjoLHuQ=="
}
},
"net10.0/ios-arm64": {},
"net10.0/iossimulator-arm64": {}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Google.Protobuf": {
"type": "Direct",
"requested": "[3.36.1, )",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"Grpc.Tools": {
"type": "Direct",
"requested": "[2.83.0, )",
"resolved": "2.83.0",
"contentHash": "vK2Go/83W0v2Nn7tTP9fGrX4IjmOa93s3M0SZeFimU1vIIr2wL9yNJlIyK21y85SGm3++JncB8IF751cjoLHuQ=="
}
}
}
}
@@ -0,0 +1,26 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Google.Protobuf": {
"type": "Direct",
"requested": "[3.36.1, )",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"Grpc.Tools": {
"type": "Direct",
"requested": "[2.83.0, )",
"resolved": "2.83.0",
"contentHash": "vK2Go/83W0v2Nn7tTP9fGrX4IjmOa93s3M0SZeFimU1vIIr2wL9yNJlIyK21y85SGm3++JncB8IF751cjoLHuQ=="
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
}
},
"net10.0/linux-x64": {}
}
}
@@ -0,0 +1,26 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Google.Protobuf": {
"type": "Direct",
"requested": "[3.36.1, )",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"Grpc.Tools": {
"type": "Direct",
"requested": "[2.83.0, )",
"resolved": "2.83.0",
"contentHash": "vK2Go/83W0v2Nn7tTP9fGrX4IjmOa93s3M0SZeFimU1vIIr2wL9yNJlIyK21y85SGm3++JncB8IF751cjoLHuQ=="
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
}
},
"net10.0/win-x64": {}
}
}
+101
View File
@@ -0,0 +1,101 @@
using System.Text;
using Google.Protobuf;
using Microsoft.Data.Sqlite;
using Voicecat.V1;
namespace VoiceCat.Server;
public sealed partial class VoiceServer
{
private void Moderate(Session actor, Envelope request)
{
lock (gate)
{
bool permitted = actor.Permissions.IsAdmin || request.BodyCase switch
{
Envelope.BodyOneofCase.Kick or Envelope.BodyOneofCase.ServerMute => actor.Permissions.CanKick,
Envelope.BodyOneofCase.Ban => actor.Permissions.CanBan,
Envelope.BodyOneofCase.MoveUser => actor.Permissions.CanMoveUsers,
// Granting arbitrary permissions (including admin) is reserved for administrators.
_ => false
};
if (!permitted) { SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; }
uint id = request.Kick?.UserId ?? request.Ban?.UserId ?? request.MoveUser?.UserId ?? request.ServerMute?.UserId ?? request.SetPermission.UserId;
Session? target = sessions.Values.FirstOrDefault(p => !p.Closing && p.User?.Id == id);
if (target is null) { SendResult(actor, request.RequestId, false, 3, "User not found."); return; }
string reason = request.Kick?.Reason ?? request.Ban?.Reason ?? "";
if (Encoding.UTF8.GetByteCount(reason) > 4096 || request.SetPermission is not null && request.SetPermission.Permissions is null)
{ SendResult(actor, request.RequestId, false, 3, "Invalid moderation request."); return; }
if (request.MoveUser is not null)
{
Channel? destination = channels.FirstOrDefault(c => c.Id == request.MoveUser.ChannelId);
if (destination is null || destination.MaxUsers != 0 && sessions.Values.Count(p => p.Id != target.Id && p.User?.ChannelId == destination.Id) >= destination.MaxUsers)
{ SendResult(actor, request.RequestId, false, 3, "Channel unavailable."); return; }
target.User!.ChannelId = destination.Id;
target.User.Streams.Clear();
PublishMedia();
BroadcastUser(target);
}
else if (request.ServerMute is not null)
{
target.User!.ServerMuted = request.ServerMute.Muted;
target.User.ServerDeafened = request.ServerMute.Deafened;
PublishMedia();
BroadcastUser(target);
}
else if (request.SetPermission is not null) target.Permissions = request.SetPermission.Permissions.Clone();
else
{
if (request.Ban is not null)
{
// Guest nicknames are not identities; ban their address instead of reserving a nickname.
accounts.Ban(target.User!.IsGuest ? "ip" : "username", target.User.IsGuest ? target.Address : target.User.Nickname, reason, request.Ban.ExpiresUnixMs);
}
target.DepartureReason = reason;
target.Closing = true;
PublishMedia();
Reject(target, reason.Length == 0 ? "Removed by moderator." : reason);
}
SendResult(actor, request.RequestId, true, 0, "");
}
}
private async Task AdministerAccountsAsync(Session actor, Envelope request)
{
lock (gate)
{
if (!actor.Permissions.IsAdmin && !actor.Permissions.CanAdminAccounts)
{ SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; }
}
// Authority is checked when accepting the operation; bounded password work runs off the control loop.
try
{
string? username = request.CreateAccount?.Username ?? request.ResetPassword?.Username ?? request.DeleteAccount?.Username;
if (username is not null && (string.IsNullOrWhiteSpace(username) || username.Length > 128)) throw new ArgumentException("Invalid username.");
bool ok = true;
switch (request.BodyCase)
{
case Envelope.BodyOneofCase.CreateAccount:
await accounts.CreateAccountAsync(username!, request.CreateAccount!.Password, cancellationToken: actor.Connection.CancellationToken).ConfigureAwait(false);
break;
case Envelope.BodyOneofCase.ResetPassword:
ok = await accounts.ResetPasswordAsync(username!, request.ResetPassword!.NewPassword, actor.Connection.CancellationToken).ConfigureAwait(false);
break;
case Envelope.BodyOneofCase.DeleteAccount: ok = accounts.DeleteAccount(username!); break;
case Envelope.BodyOneofCase.ListAccounts:
var response = new Envelope { RequestId = request.RequestId, ListAccountsResult = new() };
foreach (var account in accounts.ListAccounts())
{
response.ListAccountsResult.Accounts.Add(new AccountEntry { Username = account.Username, IsAdmin = account.IsAdmin,
CreatedAtUnixMs = checked((ulong)account.CreatedAt * 1000), LastLoginUnixMs = checked((ulong)account.LastLogin * 1000) });
if (response.CalculateSize() > 65536) { SendResult(actor, request.RequestId, false, 3, "Account list exceeds protocol frame limit."); return; }
}
actor.Connection.TrySend(response);
return;
}
SendResult(actor, request.RequestId, ok, ok ? 0U : 3U, ok ? "" : "Account not found.");
}
catch (ArgumentException) { SendResult(actor, request.RequestId, false, 3, "Invalid username or password."); }
catch (SqliteException exception) when (exception.SqliteErrorCode == 19) { SendResult(actor, request.RequestId, false, 3, "Account already exists or is invalid."); }
}
}
@@ -0,0 +1,57 @@
namespace VoiceCat.Server;
// Bounds password work before Argon2. Both source address and account share the limit
// across connections; failed attempts cannot bypass it by reconnecting.
internal sealed class AuthenticationLimiter(VoiceServerOptions options, TimeProvider clock)
{
private readonly object gate = new();
private readonly Dictionary<string, Bucket> buckets = new(StringComparer.Ordinal);
private const int MaximumKeys = 4096;
internal bool TryAcquire(string address, string username)
{
lock (gate)
{
long now = clock.GetTimestamp();
string[] keys = ["ip:" + address, "user:" + username];
if (buckets.Count > MaximumKeys - 2)
foreach (var key in buckets.Where(pair => clock.GetElapsedTime(pair.Value.Updated, now) > TimeSpan.FromMinutes(10)).Select(pair => pair.Key).ToArray()) buckets.Remove(key);
foreach (string key in keys)
{
if (!buckets.TryGetValue(key, out Bucket? bucket))
{
if (buckets.Count >= MaximumKeys) return false;
buckets.Add(key, bucket = new(options.AuthenticationBurst, now));
}
double elapsed = Math.Max(0, clock.GetElapsedTime(bucket.Updated, now).TotalSeconds);
bucket.Tokens = Math.Min(options.AuthenticationBurst, bucket.Tokens + elapsed / options.AuthenticationRefillInterval.TotalSeconds);
bucket.Updated = now;
if (bucket.Tokens < 1 || now < bucket.BlockedUntil) return false;
}
foreach (string key in keys) buckets[key].Tokens--;
return true;
}
}
internal void Record(string address, string username, bool success)
{
lock (gate)
{
foreach (string key in new[] { "ip:" + address, "user:" + username })
{
if (!buckets.TryGetValue(key, out Bucket? bucket)) continue;
bucket.Failures = success ? 0 : Math.Min(8, bucket.Failures + 1);
bucket.BlockedUntil = bucket.Failures < 3 ? 0 :
clock.GetTimestamp() + checked((long)(Math.Min(30, 1 << (bucket.Failures - 3)) * (double)clock.TimestampFrequency));
}
}
}
private sealed class Bucket(double tokens, long updated)
{
internal double Tokens = tokens;
internal long Updated = updated;
internal long BlockedUntil;
internal int Failures;
}
}
+83
View File
@@ -0,0 +1,83 @@
using System.Text;
using Microsoft.Data.Sqlite;
using Voicecat.V1;
namespace VoiceCat.Server;
public sealed partial class VoiceServer
{
private void ManageChannel(Session actor, Envelope request)
{
lock (gate)
{
bool create = request.CreateChannel is not null;
Channel? input = create ? request.CreateChannel!.Channel : request.EditChannel?.Channel;
bool permitted = actor.Permissions.IsAdmin || create && actor.Permissions.CanCreateTempChannel && input?.Type == ChannelType.ChannelTemporary;
if (!permitted) { SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; }
try
{
if (request.DeleteChannel is not null)
{
uint id = request.DeleteChannel.ChannelId;
if (id == 1 || !channels.Any(c => c.Id == id) || channels.Any(c => c.ParentId == id))
throw new ArgumentException("Cannot delete Lobby, a missing channel, or a channel with children.");
accounts.DeleteChannel(id);
channels.RemoveAll(c => c.Id == id);
foreach (Session peer in sessions.Values.Where(p => p.User?.ChannelId == id))
{
peer.User!.ChannelId = 1;
peer.User.Streams.Clear();
BroadcastUser(peer);
}
PublishMedia();
Broadcast(new() { ChannelEvent = new() { Kind = ChannelEvent.Types.Kind.Deleted, DeletedId = id } });
}
else
{
string password = create ? request.CreateChannel!.Password : request.EditChannel!.Password;
ValidateChannel(input, password, create);
Channel saved = accounts.SaveChannel(input!, password, create);
if (create) channels.Add(saved);
else channels[channels.FindIndex(c => c.Id == saved.Id)] = saved;
// Existing encoders negotiated the previous configuration. Stop their streams on edits.
if (!create)
{
foreach (Session peer in sessions.Values.Where(p => p.User?.ChannelId == saved.Id))
{
peer.User!.Streams.Clear();
BroadcastUser(peer);
}
PublishMedia();
}
Broadcast(new() { ChannelEvent = new() { Kind = create ? ChannelEvent.Types.Kind.Created : ChannelEvent.Types.Kind.Updated, Channel = saved.Clone() } });
}
SendResult(actor, request.RequestId, true, 0, "");
}
catch (ArgumentException exception) { SendResult(actor, request.RequestId, false, 3, exception.Message); }
catch (SqliteException exception) when (exception.SqliteErrorCode == 19) { SendResult(actor, request.RequestId, false, 3, "Channel name already exists or channel is invalid."); }
}
}
private void ValidateChannel(Channel? channel, string password, bool create)
{
var a = channel?.Audio;
if (channel is null || string.IsNullOrWhiteSpace(channel.Name) || Encoding.UTF8.GetByteCount(channel.Name) > 128 ||
Encoding.UTF8.GetByteCount(channel.Topic) > 4096 || Encoding.UTF8.GetByteCount(password) > 1024 || !Enum.IsDefined(channel.Type) ||
channel.MaxUsers > int.MaxValue || a is null || a.Codec != 0 || !Enum.IsDefined(a.Mode) || !Enum.IsDefined(a.Application) ||
a.SampleRate != 48000 || a.BitrateBps is < 500 or > 512000 || a.FrameMs is not (5 or 10 or 20 or 40 or 60) ||
a.Complexity > 10 || a.ExpectedPacketLoss > 100 || a.Dred ||
!create && !channels.Any(c => c.Id == channel.Id) || channel.ParentId != 0 && !channels.Any(c => c.Id == channel.ParentId))
throw new ArgumentException("Invalid channel or audio configuration (database v2 cannot persist DRED).");
if (channel.Id == 1 && !create && (password.Length != 0 || channel.ParentId != 0)) throw new ArgumentException("Lobby must remain an unprotected root channel.");
uint parent = channel.ParentId;
var visited = new HashSet<uint>();
while (parent != 0)
{
if (!visited.Add(parent) || !create && parent == channel.Id) throw new ArgumentException("Channel tree cannot contain cycles.");
parent = channels.First(c => c.Id == parent).ParentId;
}
}
private static void SendResult(Session actor, ulong id, bool ok, uint code, string message) =>
actor.Connection.TrySend(new() { RequestId = id, GenericResult = new() { Ok = ok, Code = code, Message = message } });
}
@@ -0,0 +1,50 @@
namespace VoiceCat.Server.Data;
public sealed partial class AccountStore
{
public async Task<bool> ResetPasswordAsync(string username, string password, CancellationToken cancellationToken = default)
{
string hash = await PasswordWorkAsync(() => hasher.Hash(password), cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
using var connection = Open();
using var command = connection.CreateCommand();
command.CommandText = "UPDATE accounts SET pw_hash=$hash WHERE username=$user";
command.Parameters.AddWithValue("$hash", hash);
command.Parameters.AddWithValue("$user", username);
return command.ExecuteNonQuery() == 1;
}
public bool DeleteAccount(string username)
{
using var connection = Open();
using var command = connection.CreateCommand();
command.CommandText = "DELETE FROM accounts WHERE username=$user";
command.Parameters.AddWithValue("$user", username);
return command.ExecuteNonQuery() == 1;
}
public IReadOnlyList<Account> ListAccounts()
{
using var connection = Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT id,username,is_admin,created_at,last_login FROM accounts ORDER BY username";
using var reader = command.ExecuteReader();
var result = new List<Account>();
while (reader.Read()) result.Add(new(reader.GetInt64(0), reader.GetString(1), reader.GetBoolean(2), reader.GetInt64(3), reader.GetInt64(4)));
return result;
}
internal void Ban(string type, string subject, string reason, ulong expiresUnixMs)
{
using var connection = Open();
using var command = connection.CreateCommand();
command.CommandText = "INSERT INTO bans (subject_type,subject,reason,expires_at,created_at) VALUES ($type,$subject,$reason,$expires,$created)";
command.Parameters.AddWithValue("$type", type);
command.Parameters.AddWithValue("$subject", subject);
command.Parameters.AddWithValue("$reason", reason);
// Native schema timestamps are seconds; round upwards to avoid expiring early.
command.Parameters.AddWithValue("$expires", checked((long)(expiresUnixMs / 1000 + (expiresUnixMs % 1000 == 0 ? 0UL : 1UL))));
command.Parameters.AddWithValue("$created", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
command.ExecuteNonQuery();
}
}
+159
View File
@@ -0,0 +1,159 @@
using System.Globalization;
using Microsoft.Data.Sqlite;
using VoiceCat.Crypto;
namespace VoiceCat.Server.Data;
public sealed record Account(long Id, string Username, bool IsAdmin, long CreatedAt, long LastLogin);
public sealed partial class AccountStore : IDisposable
{
static AccountStore() => SQLitePCL.Batteries_V2.Init();
private readonly string connectionString;
private readonly PasswordHasher hasher = new();
private readonly SemaphoreSlim passwordWorkers = new(2);
private bool disposed;
private const string DummyHash = "$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$Ki9tdSYqOtze3s3LAS6gv6I0buTIh2abdjWzY3GeLiE";
public AccountStore(string path)
{
connectionString = new SqliteConnectionStringBuilder { DataSource = Path.GetFullPath(path), Pooling = false, DefaultTimeout = 5 }.ToString();
using var connection = Open();
using var setup = connection.CreateCommand();
setup.CommandText = "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);";
setup.ExecuteNonQuery();
using var transaction = connection.BeginTransaction();
using var version = connection.CreateCommand();
version.Transaction = transaction;
version.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
object? stored = version.ExecuteScalar();
if (stored is not null && (!int.TryParse((string)stored, NumberStyles.None, CultureInfo.InvariantCulture, out int revision) || revision is < 1 or > 2))
throw new InvalidDataException("Unsupported server database schema version.");
using var resource = typeof(AccountStore).Assembly.GetManifestResourceStream("VoiceCat.Server.Data.schema.sql")!;
using var reader = new StreamReader(resource);
using var migrate = connection.CreateCommand();
migrate.Transaction = transaction;
migrate.CommandText = reader.ReadToEnd() + "INSERT INTO server_meta (key,value) VALUES ('schema_version','2') ON CONFLICT(key) DO UPDATE SET value='2';";
migrate.ExecuteNonQuery();
transaction.Commit();
}
private SqliteConnection Open()
{
ObjectDisposedException.ThrowIf(disposed, this);
var connection = new SqliteConnection(connectionString);
try { connection.Open(); return connection; }
catch { connection.Dispose(); throw; }
}
public async Task<Account> CreateAccountAsync(string username, string password, bool isAdmin = false, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(username);
if (username.Length > 128) throw new ArgumentException("Username exceeds 128 characters.", nameof(username));
string hash = await PasswordWorkAsync(() => hasher.Hash(password), cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
using var connection = Open();
using var command = connection.CreateCommand();
long created = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
command.CommandText = "INSERT INTO accounts (username,pw_hash,is_admin,created_at) VALUES ($user,$hash,$admin,$created) RETURNING id";
command.Parameters.AddWithValue("$user", username);
command.Parameters.AddWithValue("$hash", hash);
command.Parameters.AddWithValue("$admin", isAdmin ? 1 : 0);
command.Parameters.AddWithValue("$created", created);
return new((long)command.ExecuteScalar()!, username, isAdmin, created, 0);
}
public async Task<Account?> AuthenticateAsync(string username, string password, CancellationToken cancellationToken = default)
{
string? hash = null;
Account? account = null;
using (var connection = Open())
using (var command = connection.CreateCommand())
{
command.CommandText = "SELECT id,pw_hash,is_admin,created_at,last_login FROM accounts WHERE username=$user";
command.Parameters.AddWithValue("$user", username);
using var reader = command.ExecuteReader();
if (reader.Read())
{
hash = reader.GetString(1);
account = new(reader.GetInt64(0), username, reader.GetInt64(2) != 0, reader.GetInt64(3), reader.GetInt64(4));
}
}
bool verified = await PasswordWorkAsync(() => hasher.Verify(password, hash ?? DummyHash), cancellationToken).ConfigureAwait(false);
if (hash is null || !verified) return null;
cancellationToken.ThrowIfCancellationRequested();
using var updated = Open();
using var update = updated.CreateCommand();
long login = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
update.CommandText = "UPDATE accounts SET last_login=$login WHERE id=$id AND pw_hash=$hash";
update.Parameters.AddWithValue("$login", login);
update.Parameters.AddWithValue("$id", account!.Id);
update.Parameters.AddWithValue("$hash", hash);
return update.ExecuteNonQuery() == 1 ? account with { LastLogin = login } : null;
}
private async Task<T> PasswordWorkAsync<T>(Func<T> work, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
await passwordWorkers.WaitAsync(cancellationToken).ConfigureAwait(false);
try { return await Task.Run(work, cancellationToken).ConfigureAwait(false); }
finally { passwordWorkers.Release(); }
}
public void Dispose() => disposed = true;
public IReadOnlyList<Voicecat.V1.Channel> LoadChannels()
{
using var connection = Open();
using var transaction = connection.BeginTransaction();
using var seed = connection.CreateCommand();
seed.Transaction = transaction;
seed.CommandText = "SELECT COUNT(*) FROM channels";
bool empty = (long)seed.ExecuteScalar()! == 0;
seed.CommandText = """
INSERT INTO channels (id,name,max_users) VALUES (1,'Lobby',20);
INSERT INTO channels (id,name,audio_mode,audio_bitrate_bps,audio_application,audio_fec,audio_expected_packet_loss,audio_dtx,audio_complexity,sort_order)
VALUES (2,'Music Room',1,128000,1,0,0,0,8,1);
""";
if (empty) seed.ExecuteNonQuery();
transaction.Commit();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT id,parent_id,name,topic,password_hash,max_users,type,sort_order,
audio_codec,audio_mode,audio_sample_rate,audio_bitrate_bps,audio_frame_ms,
audio_application,audio_fec,audio_expected_packet_loss,audio_dtx,audio_complexity
FROM channels ORDER BY sort_order,id
""";
using var reader = command.ExecuteReader();
var channels = new List<Voicecat.V1.Channel>();
while (reader.Read())
{
channels.Add(new()
{
Id = checked((uint)reader.GetInt64(0)), ParentId = checked((uint)reader.GetInt64(1)),
Name = reader.GetString(2), Topic = reader.GetString(3), PasswordProtected = reader.GetString(4).Length != 0,
MaxUsers = checked((uint)reader.GetInt64(5)), Type = (Voicecat.V1.ChannelType)reader.GetInt32(6), Order = reader.GetInt32(7),
Audio = new()
{
Codec = checked((uint)reader.GetInt64(8)), Mode = (Voicecat.V1.ChannelMode)reader.GetInt32(9),
SampleRate = checked((uint)reader.GetInt64(10)), BitrateBps = checked((uint)reader.GetInt64(11)),
FrameMs = checked((uint)reader.GetInt64(12)), Application = (Voicecat.V1.OpusApplication)reader.GetInt32(13),
Fec = reader.GetInt32(14) != 0, ExpectedPacketLoss = checked((uint)reader.GetInt64(15)),
Dtx = reader.GetInt32(16) != 0, Complexity = checked((uint)reader.GetInt64(17))
}
});
}
return channels;
}
public bool IsBanned(string subjectType, string subject)
{
using var connection = Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT 1 FROM bans WHERE subject_type=$type AND subject=$subject AND (expires_at=0 OR expires_at>$now) LIMIT 1";
command.Parameters.AddWithValue("$type", subjectType);
command.Parameters.AddWithValue("$subject", subject);
command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
return command.ExecuteScalar() is not null;
}
}
+78
View File
@@ -0,0 +1,78 @@
using System.Security.Cryptography;
using System.Text;
using Org.BouncyCastle.Crypto.Digests;
using Voicecat.V1;
namespace VoiceCat.Server.Data;
public sealed partial class AccountStore
{
private static byte[] ChannelDigest(string password, byte[] salt)
{
var digest = new Blake2bDigest(salt, 32, null, null);
byte[] bytes = Encoding.UTF8.GetBytes(password);
byte[] hash = new byte[32];
try { digest.BlockUpdate(bytes, 0, bytes.Length); digest.DoFinal(hash, 0); return hash; }
finally { CryptographicOperations.ZeroMemory(bytes); }
}
public bool CheckChannelPassword(uint id, string password)
{
using var connection = Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT password_hash FROM channels WHERE id=$id";
command.Parameters.AddWithValue("$id", id);
if (command.ExecuteScalar() is not string stored) return false;
if (stored.Length == 0) return true;
if (stored.Length != 97 || stored[32] != ':') return false;
try
{
byte[] salt = Convert.FromHexString(stored[..32]);
return CryptographicOperations.FixedTimeEquals(ChannelDigest(password, salt), Convert.FromHexString(stored[33..]));
}
catch (FormatException) { return false; }
}
internal Channel SaveChannel(Channel channel, string password, bool create)
{
using var connection = Open();
using var command = connection.CreateCommand();
string hash = "";
if (password.Length != 0)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
hash = Convert.ToHexString(salt).ToLowerInvariant() + ":" + Convert.ToHexString(ChannelDigest(password, salt)).ToLowerInvariant();
}
string[] columns = ["parent_id", "name", "topic", "max_users", "type", "sort_order", "audio_codec", "audio_mode", "audio_sample_rate", "audio_bitrate_bps", "audio_frame_ms", "audio_application", "audio_fec", "audio_expected_packet_loss", "audio_dtx", "audio_complexity"];
var a = channel.Audio;
object[] values = [channel.ParentId, channel.Name, channel.Topic, channel.MaxUsers, (int)channel.Type, channel.Order, a.Codec, (int)a.Mode, a.SampleRate, a.BitrateBps, a.FrameMs, (int)a.Application, a.Fec, a.ExpectedPacketLoss, a.Dtx, a.Complexity];
for (int i = 0; i < columns.Length; i++) command.Parameters.AddWithValue("$" + columns[i], values[i]);
command.Parameters.AddWithValue("$hash", hash);
command.Parameters.AddWithValue("$id", channel.Id);
command.CommandText = create
? $"INSERT INTO channels ({string.Join(',', columns)},password_hash) VALUES ({string.Join(',', columns.Select(c => "$" + c))},$hash) RETURNING id"
: $"UPDATE channels SET {string.Join(',', columns.Select(c => c + "=$" + c))},password_hash=CASE WHEN $hash='' THEN password_hash ELSE $hash END WHERE id=$id RETURNING id";
var saved = channel.Clone();
saved.Id = checked((uint)(long)(command.ExecuteScalar() ?? throw new InvalidDataException("Channel not found.")));
saved.PasswordProtected = hash.Length != 0 || !create && CheckChannelPasswordPresent(saved.Id);
return saved;
}
private bool CheckChannelPasswordPresent(uint id)
{
using var connection = Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT length(password_hash)>0 FROM channels WHERE id=$id";
command.Parameters.AddWithValue("$id", id);
return (long)command.ExecuteScalar()! != 0;
}
internal void DeleteChannel(uint id)
{
using var connection = Open();
using var command = connection.CreateCommand();
command.CommandText = "DELETE FROM channels WHERE id=$id";
command.Parameters.AddWithValue("$id", id);
command.ExecuteNonQuery();
}
}
+38
View File
@@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
pw_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_login INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER NOT NULL DEFAULT 0,
name TEXT UNIQUE NOT NULL,
topic TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL DEFAULT '',
max_users INTEGER NOT NULL DEFAULT 0,
type INTEGER NOT NULL DEFAULT 0,
audio_codec INTEGER NOT NULL DEFAULT 0,
audio_mode INTEGER NOT NULL DEFAULT 0,
audio_sample_rate INTEGER NOT NULL DEFAULT 48000,
audio_bitrate_bps INTEGER NOT NULL DEFAULT 24000,
audio_frame_ms INTEGER NOT NULL DEFAULT 20,
audio_application INTEGER NOT NULL DEFAULT 0,
audio_fec INTEGER NOT NULL DEFAULT 1,
audio_expected_packet_loss INTEGER NOT NULL DEFAULT 10,
audio_dtx INTEGER NOT NULL DEFAULT 1,
audio_complexity INTEGER NOT NULL DEFAULT 5,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_type TEXT NOT NULL,
subject TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
expires_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_bans_subject ON bans(subject_type, subject);
+3
View File
@@ -0,0 +1,3 @@
using VoiceCat.Server;
return await ServerCommand.RunAsync(args, Console.Out, Console.Error);
+184
View File
@@ -0,0 +1,184 @@
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Text.Json;
using VoiceCat.Crypto;
using VoiceCat.Server.Data;
using VoiceCat.Transport;
namespace VoiceCat.Server;
internal sealed record ServerConfiguration(string Directory, string BindAddress, int Port, VoiceServerOptions Options);
internal static class ServerCommand
{
internal static ServerConfiguration Parse(string[] args, Func<string, string?> environment)
{
var values = new Dictionary<string, string>(StringComparer.Ordinal);
string[] names = ["data-dir", "bind", "port", "name", "allow-guests", "max-connections", "handshake-seconds", "idle-seconds", "reaper-seconds", "auth-burst", "auth-refill-seconds"];
string[] variables = ["DATA_DIR", "BIND_ADDRESS", "BIND_PORT", "SERVER_NAME", "ALLOW_GUESTS", "MAX_CONNECTIONS", "HANDSHAKE_TIMEOUT_SECONDS", "IDLE_TIMEOUT_SECONDS", "REAPER_INTERVAL_SECONDS", "AUTH_BURST", "AUTH_REFILL_SECONDS"];
for (int i = 0; i < names.Length; i++) if (environment("VOICECAT_" + variables[i]) is string value) values[names[i]] = value;
int positional = 0;
for (int i = 0; i < args.Length; i++)
{
string argument = args[i];
if (argument is "--print-config" or "--print-fingerprint" or "--admin") continue;
if (argument == "account") { i += i + 1 < args.Length && args[i + 1] == "list" ? 1 : 2; continue; }
if (!argument.StartsWith("--", StringComparison.Ordinal))
{
if (args.Contains("account")) throw new ArgumentException("Unexpected account argument; passwords are not command arguments.");
if (positional >= 2) throw new ArgumentException("Unexpected argument.");
values[positional++ == 0 ? "data-dir" : "port"] = argument;
continue;
}
string key = argument[2..];
if (!names.Contains(key) || ++i >= args.Length) throw new ArgumentException("Unknown option or missing value: " + argument);
values[key] = args[i];
}
string Get(string key, string fallback) => values.GetValueOrDefault(key, fallback);
int Number(string key, int fallback) => int.Parse(Get(key, fallback.ToString(CultureInfo.InvariantCulture)), CultureInfo.InvariantCulture);
var options = new VoiceServerOptions
{
Name = Get("name", "VoiceCat Server"), AllowGuests = bool.Parse(Get("allow-guests", "true")), MaximumConnections = Number("max-connections", 64),
HandshakeTimeout = TimeSpan.FromSeconds(Number("handshake-seconds", 15)), IdleTimeout = TimeSpan.FromSeconds(Number("idle-seconds", 45)),
ReaperInterval = TimeSpan.FromSeconds(Number("reaper-seconds", 15)), AuthenticationBurst = Number("auth-burst", 5),
AuthenticationRefillInterval = TimeSpan.FromSeconds(Number("auth-refill-seconds", 10))
};
options.Validate();
int port = Number("port", 8384);
if (port is < 0 or > 65535) throw new ArgumentException("Port must be between 0 and 65535.");
string bind = Get("bind", "0.0.0.0");
if (!IPAddress.TryParse(bind, out _)) throw new ArgumentException("Bind address must be an IPv4 or IPv6 literal.");
return new(Path.GetFullPath(Get("data-dir", "voicecat-data")), bind, port, options);
}
internal static async Task<int> RunAsync(string[] args, TextWriter output, TextWriter error, CancellationToken cancellationToken = default)
{
if (args.Contains("--help"))
{
await output.WriteLineAsync("VoiceCat TLS/UDP server\n--data-dir PATH --bind IP --port PORT --name NAME --allow-guests true|false\n--max-connections N --handshake-seconds N --idle-seconds N --reaper-seconds N\n--auth-burst N --auth-refill-seconds N --print-config --print-fingerprint\n--health-check HOST:PORT [--expect-fingerprint SHA256]\naccount add|reset|delete|list [USERNAME] [--admin]\nAccount passwords: hidden prompt, or VOICECAT_ADMIN_PASSWORD (never command arguments).\nDefaults: 0.0.0.0:8384 TCP+UDP, ./voicecat-data; VOICECAT_* environment overrides supported.");
return 0;
}
try
{
int healthIndex = Array.IndexOf(args, "--health-check");
if (healthIndex >= 0)
{
if (healthIndex + 1 >= args.Length) throw new ArgumentException("Health endpoint required.");
string? expected = null;
int fingerprintIndex = Array.IndexOf(args, "--expect-fingerprint");
if (fingerprintIndex >= 0) expected = fingerprintIndex + 1 < args.Length ? args[fingerprintIndex + 1] : throw new ArgumentException("Expected fingerprint required.");
return await HealthCheckAsync(args[healthIndex + 1], expected, output, cancellationToken).ConfigureAwait(false);
}
ServerConfiguration config = Parse(args, Environment.GetEnvironmentVariable);
if (args.Contains("--print-config")) { await output.WriteLineAsync(JsonSerializer.Serialize(config)); return 0; }
CreateDataDirectory(config.Directory);
int accountIndex = Array.IndexOf(args, "account");
if (accountIndex >= 0) return await AccountAsync(args, accountIndex, config, output, cancellationToken).ConfigureAwait(false);
if (args.Contains("--print-fingerprint"))
{
using var credentials = ServerCredentials.LoadOrCreate(config.Directory, config.Options.Name);
await output.WriteLineAsync(credentials.CertificateFingerprint);
return 0;
}
using var instanceLock = new FileStream(Path.Combine(config.Directory, ".server.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
using var stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
ConsoleCancelEventHandler cancel = (_, e) => { e.Cancel = true; stop.Cancel(); };
Console.CancelKeyPress += cancel;
using var terminate = OperatingSystem.IsWindows() ? null : PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { context.Cancel = true; stop.Cancel(); });
using var interrupt = OperatingSystem.IsWindows() ? null : PosixSignalRegistration.Create(PosixSignal.SIGINT, context => { context.Cancel = true; stop.Cancel(); });
VoiceServer? server = null;
try
{
server = new(config.Directory, new(IPAddress.Parse(config.BindAddress), config.Port), config.Options);
server.ConnectionFailed += exception => error.WriteLine(JsonSerializer.Serialize(new { @event = "connection_closed", type = exception.GetType().Name }));
using var credentials = ServerCredentials.LoadOrCreate(config.Directory, config.Options.Name);
await output.WriteLineAsync(JsonSerializer.Serialize(new { @event = "ready", address = server.EndPoint.Address.ToString(), port = server.EndPoint.Port, udp_port = server.MediaEndPoint.Port,
certificate_fingerprint = credentials.CertificateFingerprint, identity_fingerprint = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(credentials.Identity.PublicKey)) }));
Task stopped = Task.Delay(Timeout.Infinite, stop.Token);
if (await Task.WhenAny(stopped, server.Completion).ConfigureAwait(false) == server.Completion) await server.Completion.ConfigureAwait(false);
return 0;
}
finally
{
Console.CancelKeyPress -= cancel;
stop.Cancel();
if (server is not null) await server.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
}
catch (Exception exception) when (exception is ArgumentException or FormatException or OverflowException or IOException or InvalidOperationException or SocketException or AuthenticationException or CryptographicException or Microsoft.Data.Sqlite.SqliteException or TimeoutException || exception is OperationCanceledException && !cancellationToken.IsCancellationRequested)
{
await error.WriteLineAsync("VoiceCat command failed: " + exception.GetType().Name + ". Check configuration, data files and port availability.");
return 1;
}
}
private static async Task<int> HealthCheckAsync(string endpoint, string? expectedFingerprint, TextWriter output, CancellationToken cancellationToken)
{
int separator = endpoint.LastIndexOf(':');
if (separator < 1 || !ushort.TryParse(endpoint[(separator + 1)..], out ushort port)) throw new ArgumentException("Health endpoint must be HOST:PORT.");
string host = endpoint[..separator].Trim('[', ']');
byte[]? expected = null;
if (expectedFingerprint is not null)
{
try { expected = Convert.FromHexString(expectedFingerprint); } catch (FormatException) { throw new ArgumentException("Expected fingerprint must be hexadecimal."); }
if (expected.Length != 32) throw new ArgumentException("Expected fingerprint must be SHA-256.");
}
string? actual = null;
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); deadline.CancelAfter(TimeSpan.FromSeconds(5));
using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(host, port, deadline.Token).ConfigureAwait(false);
using var tls = TlsSession.CreateClient(fingerprint => { actual = fingerprint; return true; });
await using var connection = new TlsControlConnection(socket, tls, deadline.Token, TimeSpan.FromSeconds(5));
using MediaSessionCrypto crypto = await connection.TakeMediaCryptoAsync(deadline.Token).ConfigureAwait(false);
if (actual is null || expected is not null && !CryptographicOperations.FixedTimeEquals(Convert.FromHexString(actual), expected)) return 1;
await output.WriteLineAsync(JsonSerializer.Serialize(new { status = "healthy", certificate_fingerprint = actual }));
return 0;
}
private static void CreateDataDirectory(string directory)
{
if (OperatingSystem.IsWindows()) System.IO.Directory.CreateDirectory(directory);
else System.IO.Directory.CreateDirectory(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
private static async Task<int> AccountAsync(string[] args, int index, ServerConfiguration config, TextWriter output, CancellationToken cancellationToken)
{
if (index + 1 >= args.Length) throw new ArgumentException("Account operation required.");
string operation = args[index + 1];
using var store = new AccountStore(Path.Combine(config.Directory, "voicecat.db"));
if (operation == "list")
{
foreach (Account account in store.ListAccounts()) await output.WriteLineAsync(JsonSerializer.Serialize(new { account.Username, account.IsAdmin, account.CreatedAt, account.LastLogin }));
return 0;
}
if (index + 2 >= args.Length || args[index + 2].StartsWith("--", StringComparison.Ordinal)) throw new ArgumentException("Username required.");
string username = args[index + 2];
if (operation == "delete") return store.DeleteAccount(username) ? 0 : 1;
if (operation is not ("add" or "reset")) throw new ArgumentException("Unknown account operation.");
string password = Environment.GetEnvironmentVariable("VOICECAT_ADMIN_PASSWORD") ?? ReadPassword();
if (operation == "add") await store.CreateAccountAsync(username, password, args.Contains("--admin"), cancellationToken).ConfigureAwait(false);
else if (!await store.ResetPasswordAsync(username, password, cancellationToken).ConfigureAwait(false)) return 1;
await output.WriteLineAsync("Account updated.");
return 0;
}
private static string ReadPassword()
{
if (Console.IsInputRedirected) return Console.ReadLine() ?? throw new ArgumentException("Password input required.");
Console.Error.Write("Password: ");
var characters = new List<char>();
while (true)
{
ConsoleKeyInfo key = Console.ReadKey(true);
if (key.Key == ConsoleKey.Enter) break;
if (key.Key == ConsoleKey.Backspace) { if (characters.Count != 0) characters.RemoveAt(characters.Count - 1); }
else if (!char.IsControl(key.KeyChar) && characters.Count < 1024) characters.Add(key.KeyChar);
}
Console.Error.WriteLine();
return new string(characters.ToArray());
}
}
@@ -0,0 +1,50 @@
using System.Net;
using System.Security.Cryptography;
using VoiceCat.Protocol;
namespace VoiceCat.Server.Transport;
// One packet at a time. Each returned buffer must be sent before preparing the next recipient.
internal sealed class MediaFanout : IDisposable
{
private readonly byte[] plaintext = new byte[65535];
private readonly byte[] output = new byte[65535];
private MediaRoute[] routes = [];
private MediaRoute? source;
private VoiceFrameHeader header;
private int length;
private int index;
public bool TryStart(ReadOnlySpan<byte> packet, MediaRoute sender, MediaRoute[] recipients)
{
source = null;
if (!VoiceFrameHeader.TryRead(packet, out var candidate) || candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 ||
!sender.Subscribed || sender.Muted || !sender.Sources.Contains(candidate.Ssrc) ||
packet.Length <= VoiceFrameHeader.Size + 16 || packet.Length > output.Length) return false;
if (!sender.Peer.Crypto.Decryptor.TryDecrypt(packet, plaintext, out header, out length)) return false;
source = sender;
routes = recipients;
index = 0;
return true;
}
public bool TryNext(out ReadOnlyMemory<byte> packet, out SocketAddress? endpoint)
{
packet = default;
endpoint = null;
if (source is null) return false;
while (index < routes.Length)
{
MediaRoute recipient = routes[index++];
if (ReferenceEquals(recipient.Peer, source.Peer) || recipient.ChannelId != source.ChannelId ||
!recipient.Subscribed || recipient.Deafened || recipient.Peer.Endpoint is null) continue;
int size = recipient.Peer.Crypto.Encryptor.Encrypt(header, plaintext.AsSpan(0, length), output);
packet = output.AsMemory(0, size);
endpoint = recipient.Peer.Endpoint;
return true;
}
return false;
}
public void Dispose() => CryptographicOperations.ZeroMemory(plaintext);
}
+158
View File
@@ -0,0 +1,158 @@
using VoiceCat.Transport;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Threading.Channels;
using VoiceCat.Protocol;
namespace VoiceCat.Server.Transport;
internal sealed class MediaPeer(byte[] token, MediaSessionCrypto crypto, SessionActivity? activity = null)
{
public byte[] Token { get; } = token;
public MediaSessionCrypto Crypto { get; } = crypto;
public SessionActivity Activity { get; } = activity ?? new(TimeProvider.System);
// Only the UDP loop reads or changes the endpoint and binding state.
public SocketAddress? Endpoint { get; set; }
public void Dispose() { Crypto.Dispose(); CryptographicOperations.ZeroMemory(Token); }
}
internal sealed record MediaRoute(MediaPeer Peer, uint ChannelId, bool Subscribed, bool Muted, bool Deafened, uint[] Sources);
internal sealed class MediaRelay : IAsyncDisposable
{
private readonly Socket socket;
private readonly CancellationTokenSource shutdown = new();
private readonly ConcurrentQueue<MediaPeer> retired = new();
private readonly Channel<byte> changed = Channel.CreateBounded<byte>(1);
private MediaRoute[] routes = [];
private readonly byte[] input = new byte[65535];
private readonly MediaFanout fanout = new();
private readonly Task receiving;
internal Task Completion => receiving;
public IPEndPoint EndPoint { get; }
public event Action<Exception>? Failed;
public MediaRelay(IPEndPoint endpoint)
{
socket = new(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
try { socket.Bind(endpoint); EndPoint = (IPEndPoint)socket.LocalEndPoint!; }
catch { socket.Dispose(); shutdown.Dispose(); throw; }
receiving = ReceiveAsync();
}
// Publications are serialized by the server's session gate. Crypto ownership transfers here.
public void Publish(MediaRoute[] next)
{
MediaRoute[] previous = Volatile.Read(ref routes);
Volatile.Write(ref routes, next);
foreach (MediaRoute route in previous)
if (!next.Any(candidate => ReferenceEquals(candidate.Peer, route.Peer))) retired.Enqueue(route.Peer);
changed.Writer.TryWrite(0);
}
private void DrainRetired()
{
while (retired.TryDequeue(out MediaPeer? peer)) peer.Dispose();
}
private async Task ReceiveAsync()
{
var sender = new SocketAddress(socket.AddressFamily);
Task<int>? receive = null;
Task<bool>? update = null;
try
{
while (true)
{
receive ??= socket.ReceiveFromAsync(input, SocketFlags.None, sender, shutdown.Token).AsTask();
update ??= changed.Reader.WaitToReadAsync(shutdown.Token).AsTask();
await Task.WhenAny(receive, update).ConfigureAwait(false);
if (update.IsCompleted)
{
await update.ConfigureAwait(false);
while (changed.Reader.TryRead(out _)) { }
update = null;
DrainRetired();
}
if (!receive.IsCompleted) continue;
int length;
try { length = await receive.ConfigureAwait(false); }
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.MessageSize or SocketError.ConnectionReset) { continue; }
finally { receive = null; }
DrainRetired();
MediaRoute[] current = Volatile.Read(ref routes);
if (!VoiceFrameHeader.TryRead(input.AsSpan(0, length), out var header)) continue;
MediaRoute? source = null;
foreach (MediaRoute route in current)
if (route.Peer.Endpoint?.Equals(sender) == true) { source = route; break; }
if (header.Type == MediaFrameType.UdpBinding)
{
if (length != VoiceFrameHeader.Size + 16 || source is not null) continue;
foreach (MediaRoute route in current)
{
if (route.Peer.Endpoint is not null || !CryptographicOperations.FixedTimeEquals(route.Peer.Token, input.AsSpan(VoiceFrameHeader.Size, 16))) continue;
var bound = new SocketAddress(sender.Family, sender.Size);
for (int index = 0; index < sender.Size; index++) bound[index] = sender[index];
route.Peer.Endpoint = bound;
break;
}
continue;
}
if (source is null) continue;
if (header.Type == MediaFrameType.Keepalive)
{
if (length == VoiceFrameHeader.Size)
{
source.Peer.Activity.Touch();
await SendAsync(input.AsMemory(0, length), sender).ConfigureAwait(false);
}
continue;
}
if (!fanout.TryStart(input.AsSpan(0, length), source, current)) continue;
source.Peer.Activity.Touch();
while (fanout.TryNext(out ReadOnlyMemory<byte> packet, out SocketAddress? endpoint))
await SendAsync(packet, endpoint!).ConfigureAwait(false);
}
}
catch (Exception exception) when (shutdown.IsCancellationRequested && exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
catch (Exception exception) { Failed?.Invoke(exception); throw; }
finally
{
shutdown.Cancel();
socket.Dispose();
fanout.Dispose();
if (receive is not null)
{
try { await receive.ConfigureAwait(false); }
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
}
if (update is not null)
{
try { await update.ConfigureAwait(false); }
catch (OperationCanceledException) { }
}
}
}
private async ValueTask SendAsync(ReadOnlyMemory<byte> packet, SocketAddress endpoint)
{
try { await socket.SendToAsync(packet, SocketFlags.None, endpoint, shutdown.Token).ConfigureAwait(false); }
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionReset or SocketError.HostUnreachable or SocketError.NetworkUnreachable) { }
}
public async ValueTask DisposeAsync()
{
shutdown.Cancel();
socket.Dispose();
try { await receiving.ConfigureAwait(false); }
finally
{
DrainRetired();
foreach (MediaRoute route in Volatile.Read(ref routes)) route.Peer.Dispose();
shutdown.Dispose();
}
}
}
@@ -0,0 +1,18 @@
namespace VoiceCat.Server.Transport;
internal sealed class SessionActivity(TimeProvider clock)
{
private long lastSeen = clock.GetTimestamp();
public void Touch()
{
long now = clock.GetTimestamp();
long previous = Volatile.Read(ref lastSeen);
while (now > previous)
{
long observed = Interlocked.CompareExchange(ref lastSeen, now, previous);
if (observed == previous) return;
previous = observed;
}
}
public bool IsExpired(TimeSpan timeout) => clock.GetElapsedTime(Volatile.Read(ref lastSeen)) >= timeout;
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="10.0.5" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.2" />
<PackageReference Include="SourceGear.sqlite3" Version="3.50.4.2" />
<EmbeddedResource Include="Data/schema.sql" />
<InternalsVisibleTo Include="VoiceCat.Tests" />
</ItemGroup>
</Project>
+423
View File
@@ -0,0 +1,423 @@
using VoiceCat.Transport;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using Google.Protobuf;
using VoiceCat.Crypto;
using VoiceCat.Server.Data;
using VoiceCat.Server.Transport;
using Voicecat.V1;
namespace VoiceCat.Server;
public sealed partial class VoiceServer : IAsyncDisposable
{
private readonly Socket listener;
private readonly MediaRelay media;
private readonly ServerCredentials credentials;
private readonly AccountStore accounts;
private readonly List<Voicecat.V1.Channel> channels;
private readonly bool allowGuests;
private readonly string name;
private readonly VoiceServerOptions options;
private readonly TimeProvider clock;
private readonly AuthenticationLimiter authenticationLimiter;
private readonly CancellationTokenSource shutdown = new();
private readonly object gate = new();
private readonly Dictionary<ulong, Session> sessions = [];
private readonly List<Task> connections = [];
private ulong nextSession;
private uint nextUser;
private uint nextSsrc;
private readonly Task accepting;
private readonly Task reaping;
private int disposed;
public IPEndPoint EndPoint => (IPEndPoint)listener.LocalEndPoint!;
public IPEndPoint MediaEndPoint => media.EndPoint;
public event Action<Exception>? ConnectionFailed;
public Task Completion { get; }
public VoiceServer(string directory, IPEndPoint endpoint, bool allowGuests = true, string name = "VoiceCat Server")
: this(directory, endpoint, new VoiceServerOptions { AllowGuests = allowGuests, Name = name }) { }
public VoiceServer(string directory, IPEndPoint endpoint, VoiceServerOptions options, TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
this.options = options;
clock = timeProvider ?? TimeProvider.System;
authenticationLimiter = new(options, clock);
allowGuests = options.AllowGuests;
name = options.Name;
credentials = ServerCredentials.LoadOrCreate(directory, name);
try
{
accounts = new AccountStore(Path.Combine(directory, "voicecat.db"));
channels = accounts.LoadChannels().ToList();
listener = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
listener.Bind(endpoint);
listener.Listen(options.MaximumConnections);
media = new((IPEndPoint)listener.LocalEndPoint!);
media.Failed += exception => ConnectionFailed?.Invoke(exception);
}
catch
{
listener?.Dispose();
accounts?.Dispose();
credentials.Dispose();
shutdown.Dispose();
throw;
}
accepting = AcceptAsync();
reaping = ReapAsync();
Completion = MonitorAsync();
}
private async Task MonitorAsync()
{
Task first = await Task.WhenAny(options.IdleTimeout == TimeSpan.Zero
? [accepting, media.Completion] : new[] { accepting, reaping, media.Completion }).ConfigureAwait(false);
if (shutdown.IsCancellationRequested) return;
await first.ConfigureAwait(false);
throw new IOException("A server transport loop stopped unexpectedly.");
}
private async Task AcceptAsync()
{
try
{
while (!shutdown.IsCancellationRequested)
{
Socket socket = await listener.AcceptAsync(shutdown.Token).ConfigureAwait(false);
lock (gate)
{
if (sessions.Count >= options.MaximumConnections) { socket.Dispose(); continue; }
socket.NoDelay = true;
string address = ((IPEndPoint)socket.RemoteEndPoint!).Address.ToString();
var connection = new TlsControlConnection(socket, credentials.CreateTlsSession(), shutdown.Token, options.HandshakeTimeout);
var session = new Session(++nextSession, connection, address, new(clock));
sessions.Add(session.Id, session);
connections.RemoveAll(task => task.IsCompleted);
connections.Add(HandleAsync(session));
}
}
}
catch (Exception exception) when (shutdown.IsCancellationRequested && exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
}
private async Task HandleAsync(Session session)
{
try
{
await foreach (Envelope envelope in session.Connection.ReadAsync(shutdown.Token).ConfigureAwait(false))
{
if (session.Closing) break;
session.Activity.Touch();
if (envelope.Ping is not null)
{
session.Connection.TrySend(new() { RequestId = envelope.RequestId, Pong = new() { Nonce = envelope.Ping.Nonce } });
continue;
}
if (envelope.Disconnect is not null) { session.Connection.CompleteWrites(); break; }
if (!session.HelloReceived)
{
if (envelope.ClientHello?.ProtoVersion != 2 || accounts.IsBanned("ip", session.Address))
{
Reject(session, "Unsupported protocol version or banned address.");
break;
}
session.Media = new(RandomNumberGenerator.GetBytes(16), await session.Connection.TakeMediaCryptoAsync(shutdown.Token).ConfigureAwait(false), session.Activity);
var hello = new ServerHello { ProtoVersion = 2, ServerName = name, ServerVersion = "0.1.0-dotnet", UdpPort = checked((uint)media.EndPoint.Port), ServerIdentityFingerprint = ByteString.CopyFrom(SHA256.HashData(credentials.Identity.PublicKey)) };
if (allowGuests) hello.AuthMethods.Add("guest");
hello.AuthMethods.Add("password");
session.Connection.TrySend(new() { RequestId = envelope.RequestId, ServerHello = hello });
session.HelloReceived = true;
continue;
}
if (session.User is null)
{
if (envelope.AuthRequest is null) { Reject(session, "Authentication required."); break; }
await AuthenticateAsync(session, envelope.RequestId, envelope.AuthRequest).ConfigureAwait(false);
continue;
}
switch (envelope.BodyCase)
{
case Envelope.BodyOneofCase.TextMessage: RelayText(session, envelope.TextMessage); break;
case Envelope.BodyOneofCase.Subscribe: SendSnapshot(session); break;
case Envelope.BodyOneofCase.JoinChannel: Join(session, envelope.RequestId, envelope.JoinChannel.ChannelId, envelope.JoinChannel.Password); break;
case Envelope.BodyOneofCase.LeaveChannel: Join(session, envelope.RequestId, 1); break;
case Envelope.BodyOneofCase.CreateChannel:
case Envelope.BodyOneofCase.EditChannel:
case Envelope.BodyOneofCase.DeleteChannel: ManageChannel(session, envelope); break;
case Envelope.BodyOneofCase.Kick:
case Envelope.BodyOneofCase.Ban:
case Envelope.BodyOneofCase.MoveUser:
case Envelope.BodyOneofCase.ServerMute:
case Envelope.BodyOneofCase.SetPermission: Moderate(session, envelope); break;
case Envelope.BodyOneofCase.CreateAccount:
case Envelope.BodyOneofCase.ResetPassword:
case Envelope.BodyOneofCase.DeleteAccount:
case Envelope.BodyOneofCase.ListAccounts: await AdministerAccountsAsync(session, envelope).ConfigureAwait(false); break;
case Envelope.BodyOneofCase.SubscribeVoice: SubscribeVoice(session, envelope.RequestId, true); break;
case Envelope.BodyOneofCase.UnsubscribeVoice: SubscribeVoice(session, envelope.RequestId, false); break;
case Envelope.BodyOneofCase.StreamAnnounce: AnnounceStream(session, envelope.RequestId, envelope.StreamAnnounce); break;
case Envelope.BodyOneofCase.StreamStop: StopStream(session, envelope.StreamStop.StreamId); break;
case Envelope.BodyOneofCase.StreamState: UpdateStream(session, envelope.StreamState); break;
case Envelope.BodyOneofCase.UdpBinding:
if (!envelope.UdpBinding.Ack && CryptographicOperations.FixedTimeEquals(envelope.UdpBinding.UdpToken.Span, session.Media!.Token))
session.Connection.TrySend(new() { RequestId = envelope.RequestId, UdpBinding = new() { Ack = true } });
break;
default:
session.Connection.TrySend(new() { RequestId = envelope.RequestId, GenericResult = new() { Code = 1, Message = "Operation is not implemented by this server checkpoint." } });
break;
}
}
await session.Connection.Completion.ConfigureAwait(false);
}
catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
{
if (!shutdown.IsCancellationRequested && exception is not OperationCanceledException) ConnectionFailed?.Invoke(exception);
}
finally
{
lock (gate)
{
sessions.Remove(session.Id);
if (session.User is null) session.Media?.Dispose();
else PublishMedia();
if (session.User is not null) Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Left, LeftId = session.User.Id, Reason = session.DepartureReason } });
}
await session.Connection.DisposeAsync().ConfigureAwait(false);
}
}
private static void Reject(Session session, string reason)
{
session.Connection.TrySend(new() { Disconnect = new() { Code = 1, Reason = reason } });
session.Connection.CompleteWrites();
}
private async Task ReapAsync()
{
if (options.IdleTimeout == TimeSpan.Zero) return;
using var timer = new PeriodicTimer(options.ReaperInterval, clock);
try
{
while (await timer.WaitForNextTickAsync(shutdown.Token).ConfigureAwait(false))
{
lock (gate)
{
foreach (Session session in sessions.Values)
{
if (session.Closing || !session.Activity.IsExpired(options.IdleTimeout)) continue;
session.Closing = true;
Reject(session, "Receive idle timeout.");
}
}
}
}
catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { }
}
private async Task AuthenticateAsync(Session session, ulong requestId, AuthRequest request)
{
User? user = null;
bool admin = false;
if (request.Guest is not null && allowGuests && request.Guest.Nickname.Length <= 128)
user = new() { Nickname = request.Guest.Nickname.Length == 0 ? "Guest" : request.Guest.Nickname, IsGuest = true, ChannelId = 1 };
else if (request.Password is not null && request.Password.Username.Length <= 128 && request.Password.Password.Length <= 1024 &&
authenticationLimiter.TryAcquire(session.Address, request.Password.Username))
{
Account? account = accounts.IsBanned("username", request.Password.Username) ? null :
await accounts.AuthenticateAsync(request.Password.Username, request.Password.Password, session.Connection.CancellationToken).ConfigureAwait(false);
authenticationLimiter.Record(session.Address, request.Password.Username, account is not null);
if (account is not null) { user = new() { Nickname = account.Username, ChannelId = 1 }; admin = account.IsAdmin; }
}
shutdown.Token.ThrowIfCancellationRequested();
session.Connection.CancellationToken.ThrowIfCancellationRequested();
lock (gate)
{
if (session.Closing) return;
var lobby = channels.FirstOrDefault(channel => channel.Id == 1);
if (user is null || lobby is null || lobby.PasswordProtected || lobby.MaxUsers != 0 && sessions.Values.Count(peer => peer.User?.ChannelId == 1) >= lobby.MaxUsers)
{
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new() { Error = "Invalid credentials or lobby unavailable." } });
return;
}
user.Id = checked(++nextUser);
session.User = user;
session.Permissions = new() { IsAdmin = admin, CanAdminAccounts = admin, CanBan = admin, CanKick = admin, CanMoveUsers = admin, CanCreateTempChannel = admin };
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new()
{
Ok = true, SessionId = session.Id, Self = user.Clone(), UdpToken = ByteString.CopyFrom(session.Media!.Token),
Permissions = session.Permissions.Clone()
} });
PublishMedia();
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Joined, User = user.Clone() } }, session.Id);
SendSnapshot(session);
}
}
private void SendSnapshot(Session session)
{
lock (gate)
{
var snapshot = new ServerStateSnapshot();
snapshot.Channels.Add(channels.Select(channel => channel.Clone()));
snapshot.Users.Add(sessions.Values.Where(peer => peer.User is not null).Select(peer => peer.User!.Clone()));
session.Connection.TrySend(new() { ServerState = snapshot });
}
}
private void Join(Session session, ulong requestId, uint channelId, string password = "")
{
lock (gate)
{
var channel = channels.FirstOrDefault(candidate => candidate.Id == channelId);
if (channel is null || Encoding.UTF8.GetByteCount(password) > 1024 || !accounts.CheckChannelPassword(channelId, password) || channel.MaxUsers != 0 && sessions.Values.Count(peer => peer.Id != session.Id && peer.User?.ChannelId == channelId) >= channel.MaxUsers)
{
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = new() { Error = "Channel unavailable." } });
return;
}
if (session.User!.ChannelId != channelId) session.User.Streams.Clear();
session.User.ChannelId = channelId;
PublishMedia();
var result = new JoinChannelResult { Ok = true, ChannelId = channelId, Audio = channel.Audio.Clone() };
result.Members.Add(sessions.Values.Where(peer => peer.User?.ChannelId == channelId).Select(peer => peer.User!.Clone()));
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = result });
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Updated, User = session.User.Clone() } });
}
}
private void RelayText(Session sender, TextMessage message)
{
lock (gate)
{
bool permitted = Encoding.UTF8.GetByteCount(message.Body) <= 4096 && message.ClientMsgId.Length <= 128 &&
(message.Scope == TextScope.TextServer || message.Scope == TextScope.TextChannel && message.TargetId == sender.User!.ChannelId ||
message.Scope == TextScope.TextPrivate && sessions.Values.Any(peer => peer.User?.Id == message.TargetId));
if (permitted)
{
var relay = message.Clone();
relay.SenderId = sender.User!.Id;
relay.SentAtUnixMs = checked((ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
var envelope = new Envelope { TextMessage = relay };
foreach (Session recipient in sessions.Values.Where(peer => peer.User is not null))
if (message.Scope == TextScope.TextServer || message.Scope == TextScope.TextChannel && recipient.User!.ChannelId == message.TargetId ||
message.Scope == TextScope.TextPrivate && (recipient.User!.Id == message.TargetId || recipient.Id == sender.Id))
recipient.Connection.TrySend(envelope);
}
sender.Connection.TrySend(new() { TextMessageAck = new() { ClientMsgId = message.ClientMsgId, Ok = permitted } });
}
}
private void PublishMedia()
{
media.Publish(sessions.Values.Where(peer => peer.User is not null && !peer.Closing).Select(peer => new MediaRoute(
peer.Media!, peer.User!.ChannelId, peer.User.VoiceSubscribed, peer.User.ServerMuted, peer.User.SelfDeafened || peer.User.ServerDeafened,
peer.User.Streams.Select(stream => stream.Ssrc).ToArray())).ToArray());
}
private void BroadcastUser(Session session) => Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Updated, User = session.User!.Clone() } });
private void SubscribeVoice(Session session, ulong requestId, bool subscribed)
{
lock (gate)
{
session.User!.VoiceSubscribed = subscribed;
if (!subscribed) session.User.Streams.Clear();
PublishMedia();
session.Connection.TrySend(new() { RequestId = requestId, VoiceSubscriptionResult = new() { Ok = true, Subscribed = subscribed } });
BroadcastUser(session);
}
}
private void AnnounceStream(Session session, ulong requestId, StreamAnnounce request)
{
lock (gate)
{
if (!session.User!.VoiceSubscribed || !Enum.IsDefined(request.Kind) || request.Label.Length > 128 || session.User.Streams.Count >= 16 ||
nextSsrc == uint.MaxValue || session.NextStream == uint.MaxValue || request.RequestedAudio?.BitrateBps is > 0 and < 500)
{
session.Connection.TrySend(new() { RequestId = requestId, StreamAnnounceResult = new() { Error = "Voice subscription required, invalid stream, or stream limit reached." } });
return;
}
AudioConfig audio = channels.First(channel => channel.Id == session.User.ChannelId).Audio.Clone();
if (request.RequestedAudio?.BitrateBps > 0) audio.BitrateBps = Math.Min(audio.BitrateBps, request.RequestedAudio.BitrateBps);
var stream = new StreamInfo { StreamId = ++session.NextStream, Ssrc = ++nextSsrc, Kind = request.Kind, Label = request.Label, Audio = audio };
session.User.Streams.Add(stream);
PublishMedia();
session.Connection.TrySend(new() { RequestId = requestId, StreamAnnounceResult = new() { Ok = true, StreamId = stream.StreamId, Ssrc = stream.Ssrc, EffectiveAudio = audio.Clone() } });
BroadcastUser(session);
}
}
private void StopStream(Session session, uint streamId)
{
lock (gate)
{
StreamInfo? stream = session.User!.Streams.FirstOrDefault(candidate => candidate.StreamId == streamId);
if (stream is null) return;
session.User.Streams.Remove(stream);
PublishMedia();
BroadcastUser(session);
}
}
private void UpdateStream(Session session, StreamStateUpdate update)
{
lock (gate)
{
StreamInfo? stream = session.User!.Streams.FirstOrDefault(candidate => candidate.StreamId == update.StreamId);
if (stream is null) return;
Broadcast(new() { StreamState = new() { UserId = session.User.Id, StreamId = stream.StreamId, Muted = update.Muted, Talking = update.Talking } });
}
}
private void Broadcast(Envelope envelope, ulong excluded = 0)
{
foreach (Session recipient in sessions.Values.Where(peer => peer.Id != excluded && peer.User is not null)) recipient.Connection.TrySend(envelope);
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
shutdown.Cancel();
listener.Dispose();
try
{
await Task.WhenAll(accepting, reaping).ConfigureAwait(false);
}
finally
{
try
{
Task[] pending;
lock (gate) pending = connections.ToArray();
await Task.WhenAll(pending).ConfigureAwait(false);
}
finally
{
try { await media.DisposeAsync().ConfigureAwait(false); }
finally { accounts.Dispose(); credentials.Dispose(); shutdown.Dispose(); }
}
}
}
private sealed class Session(ulong id, TlsControlConnection connection, string address, SessionActivity activity)
{
public ulong Id { get; } = id;
public TlsControlConnection Connection { get; } = connection;
public string Address { get; } = address;
public SessionActivity Activity { get; } = activity;
public bool Closing { get; set; }
public string DepartureReason { get; set; } = "";
public bool HelloReceived { get; set; }
public User? User { get; set; }
public Permissions Permissions { get; set; } = new();
public MediaPeer? Media { get; set; }
public uint NextStream;
}
}
+25
View File
@@ -0,0 +1,25 @@
namespace VoiceCat.Server;
public sealed record VoiceServerOptions
{
public string Name { get; init; } = "VoiceCat Server";
public bool AllowGuests { get; init; } = true;
public int MaximumConnections { get; init; } = 64;
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(15);
public TimeSpan IdleTimeout { get; init; } = TimeSpan.FromSeconds(45);
public TimeSpan ReaperInterval { get; init; } = TimeSpan.FromSeconds(15);
public int AuthenticationBurst { get; init; } = 5;
public TimeSpan AuthenticationRefillInterval { get; init; } = TimeSpan.FromSeconds(10);
internal void Validate()
{
ArgumentException.ThrowIfNullOrWhiteSpace(Name);
ArgumentOutOfRangeException.ThrowIfLessThan(MaximumConnections, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(AuthenticationBurst, 1);
if (AuthenticationRefillInterval <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(AuthenticationRefillInterval));
if (HandshakeTimeout <= TimeSpan.Zero || HandshakeTimeout.TotalMilliseconds > uint.MaxValue - 1) throw new ArgumentOutOfRangeException(nameof(HandshakeTimeout));
if (IdleTimeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(IdleTimeout));
if (ReaperInterval < TimeSpan.Zero || ReaperInterval.TotalMilliseconds > uint.MaxValue - 1 || IdleTimeout > TimeSpan.Zero && ReaperInterval == TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(ReaperInterval));
}
}
+76
View File
@@ -0,0 +1,76 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.Data.Sqlite.Core": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"SourceGear.sqlite3": {
"type": "Direct",
"requested": "[3.50.4.2, )",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Direct",
"requested": "[3.0.2, )",
"resolved": "3.0.2",
"contentHash": "nzPPFpELY9U1scLvQpA1k1GIgR9ror83DCPmirT2/i5NCPdTBfhTDA6MZqFZonGDayye5mUQRQLOVyEiJNYr0g==",
"dependencies": {
"SQLitePCLRaw.config.e_sqlite3": "3.0.2",
"SourceGear.sqlite3": "3.50.4.2"
}
},
"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=="
},
"SQLitePCLRaw.config.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "QPHR1Axs8YCCapb0TnmT7PxY9DX3sg4I4T9HOSKeFBiT5l482mjrOIxuyt+xOCwEQ2Enq5h0tgDOXMnJi+i0sw==",
"dependencies": {
"SQLitePCLRaw.provider.e_sqlite3": "3.0.2"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "tnbRf0muOOSJK1RLCfyYK13jynFScgL4xMj7yC3oy8lrrGKXTKmOoWjfdV+cFfBRdppm4qST31hvp8ihgIgvMQ=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "RQIliDp47mQxGYNcBB6W+ezHbegkImrSZVTuWjQCSTTl3pQ37Q3rALkkkdTAMEmcIz71PEOCqNZMp7lXCnVqEQ==",
"dependencies": {
"SQLitePCLRaw.core": "3.0.2"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
}
}
}
@@ -0,0 +1,90 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.Data.Sqlite.Core": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
},
"SourceGear.sqlite3": {
"type": "Direct",
"requested": "[3.50.4.2, )",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Direct",
"requested": "[3.0.2, )",
"resolved": "3.0.2",
"contentHash": "nzPPFpELY9U1scLvQpA1k1GIgR9ror83DCPmirT2/i5NCPdTBfhTDA6MZqFZonGDayye5mUQRQLOVyEiJNYr0g==",
"dependencies": {
"SQLitePCLRaw.config.e_sqlite3": "3.0.2",
"SourceGear.sqlite3": "3.50.4.2"
}
},
"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=="
},
"SQLitePCLRaw.config.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "QPHR1Axs8YCCapb0TnmT7PxY9DX3sg4I4T9HOSKeFBiT5l482mjrOIxuyt+xOCwEQ2Enq5h0tgDOXMnJi+i0sw==",
"dependencies": {
"SQLitePCLRaw.provider.e_sqlite3": "3.0.2"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "tnbRf0muOOSJK1RLCfyYK13jynFScgL4xMj7yC3oy8lrrGKXTKmOoWjfdV+cFfBRdppm4qST31hvp8ihgIgvMQ=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "RQIliDp47mQxGYNcBB6W+ezHbegkImrSZVTuWjQCSTTl3pQ37Q3rALkkkdTAMEmcIz71PEOCqNZMp7lXCnVqEQ==",
"dependencies": {
"SQLitePCLRaw.core": "3.0.2"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0/linux-x64": {
"SourceGear.sqlite3": {
"type": "Direct",
"requested": "[3.50.4.2, )",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
}
}
}
}
@@ -0,0 +1,90 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.Data.Sqlite.Core": {
"type": "Direct",
"requested": "[10.0.5, )",
"resolved": "10.0.5",
"contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
},
"SourceGear.sqlite3": {
"type": "Direct",
"requested": "[3.50.4.2, )",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Direct",
"requested": "[3.0.2, )",
"resolved": "3.0.2",
"contentHash": "nzPPFpELY9U1scLvQpA1k1GIgR9ror83DCPmirT2/i5NCPdTBfhTDA6MZqFZonGDayye5mUQRQLOVyEiJNYr0g==",
"dependencies": {
"SQLitePCLRaw.config.e_sqlite3": "3.0.2",
"SourceGear.sqlite3": "3.50.4.2"
}
},
"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=="
},
"SQLitePCLRaw.config.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "QPHR1Axs8YCCapb0TnmT7PxY9DX3sg4I4T9HOSKeFBiT5l482mjrOIxuyt+xOCwEQ2Enq5h0tgDOXMnJi+i0sw==",
"dependencies": {
"SQLitePCLRaw.provider.e_sqlite3": "3.0.2"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "tnbRf0muOOSJK1RLCfyYK13jynFScgL4xMj7yC3oy8lrrGKXTKmOoWjfdV+cFfBRdppm4qST31hvp8ihgIgvMQ=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "RQIliDp47mQxGYNcBB6W+ezHbegkImrSZVTuWjQCSTTl3pQ37Q3rALkkkdTAMEmcIz71PEOCqNZMp7lXCnVqEQ==",
"dependencies": {
"SQLitePCLRaw.core": "3.0.2"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0/win-x64": {
"SourceGear.sqlite3": {
"type": "Direct",
"requested": "[3.50.4.2, )",
"resolved": "3.50.4.2",
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
}
}
}
}