Port managed client audio and Windows application
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-16 16:48:06 +02:00
parent 5a226ba543
commit 82ad4c2811
56 changed files with 2304 additions and 250 deletions
@@ -0,0 +1,12 @@
namespace VoiceCat.Audio;
public sealed record AudioDeviceInfo(string Id, string Name, bool IsDefault);
public delegate void CapturePcmHandler(ReadOnlySpan<short> pcm, int channels);
public interface IAudioCapture : IDisposable { }
public interface IAudioPlayback : IDisposable { void Write(ReadOnlySpan<short> stereoPcm); }
public interface IAudioDeviceBackend
{
IReadOnlyList<AudioDeviceInfo> Enumerate(bool input);
IAudioCapture OpenCapture(string? deviceId, bool loopback, CapturePcmHandler pcm);
IAudioPlayback OpenPlayback(string? deviceId = null);
}
+170
View File
@@ -0,0 +1,170 @@
using System.Diagnostics;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Audio;
public sealed class AudioEngine : IDisposable
{
private readonly object gate = new();
private readonly EncodedVoiceSender sender;
private readonly int[] mixed = new int[1920];
private readonly short[] output = new short[1920];
private Routes routes = new([], [], 0);
private readonly List<(IDisposable Stream, long Epoch)> retired = [];
private long completedEpoch;
private readonly CancellationTokenSource stop = new();
private readonly Task maintenance;
private Thread? worker;
private int disposed;
public uint SampleClock { get; private set; }
public event MixedPcmHandler? MixedPcm;
public event PcmStreamHandler? StreamPcm;
public volatile float InputGain = 1, OutputGain = 1, VadThreshold = 0.02f;
public volatile bool InputNoiseReduction, MicMuted, Deafened, PushToTalk;
public volatile AudioInputMode InputMode = AudioInputMode.VoiceActivation;
public Exception? Failure { get; private set; }
public AudioEngine(EncodedVoiceSender sender, bool startWorker = true)
{
this.sender = sender;
maintenance = MaintainAsync();
if (startWorker)
{
worker = new Thread(Work) { IsBackground = true, Name = "VoiceCat managed audio" };
worker.Start();
}
}
public void AddLocalStream(StreamInfo info, int captureChannels = 1)
{
if (captureChannels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(captureChannels));
lock (gate)
{
ObjectDisposedException.ThrowIf(disposed != 0, this);
var stream = new LocalStream(info, captureChannels);
Routes previous = routes;
var locals = previous.Local.Where(s => s.Info.StreamId != info.StreamId).Append(stream).ToArray();
Publish(locals, previous.Remote);
}
}
public void RemoveLocalStream(uint streamId)
{
lock (gate) Publish(routes.Local.Where(s => s.Info.StreamId != streamId).ToArray(), routes.Remote);
}
public void SetCaptureChannels(uint streamId, int channels)
{
if (channels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(channels));
lock (gate)
{
var current = routes.Local.FirstOrDefault(s => s.Info.StreamId == streamId) ?? throw new ArgumentException("Stream not found.");
if (current.CaptureChannels != channels) Publish(routes.Local.Select(s => s == current ? new LocalStream(s.Info, channels) : s).ToArray(), routes.Remote);
}
}
public void SetRemoteStreams(IReadOnlyList<User> users, uint selfId, uint channelId)
{
lock (gate)
{
var next = new List<ReceiveStream>();
foreach (User user in users.Where(u => u.Id != selfId && u.ChannelId == channelId))
foreach (StreamInfo info in user.Streams)
{
var previous = routes.Remote.FirstOrDefault(s => s.UserId == user.Id && s.Info.Equals(info));
next.Add(previous ?? new ReceiveStream(user.Id, info));
}
Publish(routes.Local, next.ToArray());
}
}
public bool FeedPcm(uint streamId, ReadOnlySpan<short> pcm, int channels)
{
foreach (LocalStream stream in Volatile.Read(ref routes).Local)
if (stream.Info.StreamId == streamId) return stream.Feed(pcm, channels);
return false;
}
public void Receive(VoiceFrameHeader header, ReadOnlySpan<byte> packet)
{
foreach (ReceiveStream stream in Volatile.Read(ref routes).Remote) if (stream.Info.Ssrc == header.Ssrc) { stream.Enqueue(header, packet); return; }
}
public (float Level, bool Talking) GetLocalLevel(uint streamId)
{
foreach (LocalStream stream in Volatile.Read(ref routes).Local) if (stream.Info.StreamId == streamId) return (stream.Level, stream.Talking);
return default;
}
public void SetRemotePlayback(uint userId, uint streamId, float gain, bool muted, bool noiseReduction)
{
if (!float.IsFinite(gain) || gain < 0 || gain > 4) throw new ArgumentOutOfRangeException(nameof(gain));
foreach (var stream in Volatile.Read(ref routes).Remote)
if (stream.UserId == userId && stream.Info.StreamId == streamId) { stream.Gain = gain; stream.Muted = muted; stream.NoiseReduction = noiseReduction; return; }
}
public (float Gain, bool Muted, bool NoiseReduction)? GetRemotePlayback(uint userId, uint streamId)
{
foreach (var stream in Volatile.Read(ref routes).Remote) if (stream.UserId == userId && stream.Info.StreamId == streamId) return (stream.Gain, stream.Muted, stream.NoiseReduction);
return null;
}
private void Publish(LocalStream[] local, ReceiveStream[] remote)
{
Routes previous = routes; var next = new Routes(local, remote, previous.Epoch + 1);
foreach (var stream in previous.Local) if (!local.Contains(stream)) retired.Add((stream, next.Epoch));
foreach (var stream in previous.Remote) if (!remote.Contains(stream)) retired.Add((stream, next.Epoch));
Volatile.Write(ref routes, next);
}
// One audio owner calls this. No allocation, waiting, lock, registry mutation or disposal
// occurs inside a mix cycle. Callbacks receive borrowed spans and must follow that rule.
internal void ProcessCycle()
{
Routes current = Volatile.Read(ref routes);
mixed.AsSpan().Clear();
foreach (var stream in current.Local) stream.Process(this, sender);
foreach (var stream in current.Remote) stream.Mix(mixed, Deafened, StreamPcm);
float gain = Deafened ? 0 : OutputGain;
for (int i = 0; i < output.Length; i++) output[i] = (short)Math.Clamp((int)(mixed[i] * gain), short.MinValue, short.MaxValue);
MixedPcm?.Invoke(output);
SampleClock = unchecked(SampleClock + 960);
Volatile.Write(ref completedEpoch, current.Epoch);
}
private void Work()
{
long deadline = Stopwatch.GetTimestamp();
try
{
while (!stop.IsCancellationRequested)
{
ProcessCycle();
deadline += Stopwatch.Frequency / 50;
double remaining = (deadline - Stopwatch.GetTimestamp()) * 1000.0 / Stopwatch.Frequency;
if (remaining > 0) Thread.Sleep((int)Math.Ceiling(remaining));
else if (remaining < -100) deadline = Stopwatch.GetTimestamp();
}
}
catch (Exception exception) { Failure = exception; stop.Cancel(); }
}
private async Task MaintainAsync()
{
try
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(20));
while (await timer.WaitForNextTickAsync(stop.Token).ConfigureAwait(false))
lock (gate)
for (int i = retired.Count - 1; i >= 0; i--)
if (retired[i].Epoch <= Volatile.Read(ref completedEpoch)) { retired[i].Stream.Dispose(); retired.RemoveAt(i); }
}
catch (OperationCanceledException) when (stop.IsCancellationRequested) { }
}
public void Dispose()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
stop.Cancel(); worker?.Join(); maintenance.GetAwaiter().GetResult();
lock (gate)
{
foreach (var stream in routes.Local) stream.Dispose(); foreach (var stream in routes.Remote) stream.Dispose();
foreach (var stream in retired) stream.Stream.Dispose(); retired.Clear();
routes = new([], [], routes.Epoch + 1);
}
}
private sealed record Routes(LocalStream[] Local, ReceiveStream[] Remote, long Epoch);
}
+115
View File
@@ -0,0 +1,115 @@
using VoiceCat.Codec;
using VoiceCat.Dsp;
using VoiceCat.Protocol;
using Voicecat.V1;
using OpusApplication = Voicecat.V1.OpusApplication;
namespace VoiceCat.Audio;
public enum AudioInputMode { VoiceActivation, PushToTalk, AlwaysOn }
public delegate bool EncodedVoiceSender(uint ssrc, uint timestamp, ReadOnlySpan<byte> payload, VoiceFrameFlags flags);
public delegate void PcmStreamHandler(uint userId, uint streamId, ReadOnlySpan<short> pcm, int channels);
public delegate void MixedPcmHandler(ReadOnlySpan<short> stereoPcm);
internal sealed class LocalStream : IDisposable
{
internal readonly StreamInfo Info;
internal readonly int CaptureChannels;
internal readonly PcmRing Input = new(16384);
internal volatile float Level;
internal volatile bool Talking;
private readonly OpusEncoder encoder;
private readonly RnnoiseProcessor left, right;
private readonly EnergyVadProcessor vad = new();
private readonly short[] capture = new short[1920], wire = new short[5760 * 2], mono = new short[960];
private readonly byte[] packet = new byte[1275];
private readonly short[] converted = new short[16384];
private int feeding;
private int buffered;
private uint timestamp;
private bool wasTransmitting, marker;
internal bool Feed(ReadOnlySpan<short> pcm, int channels)
{
if (channels is not (1 or 2) || pcm.Length % channels != 0 || pcm.Length / channels * CaptureChannels > converted.Length || Interlocked.CompareExchange(ref feeding, 1, 0) != 0) return false;
try
{
if (channels == CaptureChannels) return Input.TryWrite(pcm);
int frames = pcm.Length / channels;
for (int i = 0; i < frames; i++)
if (CaptureChannels == 1) converted[i] = (short)(((int)pcm[i * 2] + pcm[i * 2 + 1]) / 2);
else { converted[2 * i] = pcm[i]; converted[2 * i + 1] = pcm[i]; }
return Input.TryWrite(converted.AsSpan(0, frames * CaptureChannels));
}
finally { Volatile.Write(ref feeding, 0); }
}
internal LocalStream(StreamInfo stream, int captureChannels)
{
Info = stream.Clone(); CaptureChannels = captureChannels;
encoder = new(new()
{
Channels = stream.Audio.Mode == ChannelMode.ModeStereo ? 2 : 1,
FrameDurationMilliseconds = checked((int)stream.Audio.FrameMs), MaximumBandwidthHz = checked((int)stream.Audio.SampleRate),
Bitrate = checked((int)stream.Audio.BitrateBps), Complexity = checked((int)stream.Audio.Complexity),
ExpectedPacketLossPercent = checked((int)stream.Audio.ExpectedPacketLoss), ForwardErrorCorrection = stream.Audio.Fec,
DiscontinuousTransmission = stream.Audio.Dtx, DeepRedundancy = stream.Audio.Dred,
Application = stream.Audio.Application switch { OpusApplication.OpusAudio => VoiceCat.Codec.OpusApplication.Audio, OpusApplication.OpusLowdelay => VoiceCat.Codec.OpusApplication.LowDelay, _ => VoiceCat.Codec.OpusApplication.Voip }
});
try { left = new(); } catch { encoder.Dispose(); throw; }
try { right = new(); } catch { left.Dispose(); encoder.Dispose(); throw; }
}
internal void Process(AudioEngine engine, EncodedVoiceSender sender)
{
var input = capture.AsSpan(0, 960 * CaptureChannels);
if (Input.Count < input.Length) { Level = 0; Talking = false; buffered = 0; wasTransmitting = false; return; }
while (Input.Count > input.Length * 6) Input.Read(input);
if (buffered == 0) timestamp = engine.SampleClock;
Input.Read(input);
bool mic = Info.Kind == StreamKind.StreamMic;
if (mic && engine.InputNoiseReduction)
{
if (CaptureChannels == 1) left.Process(input);
else
{
for (int i = 0; i < 960; i++) mono[i] = input[2 * i]; left.Process(mono);
for (int i = 0; i < 960; i++) input[2 * i] = mono[i];
for (int i = 0; i < 960; i++) mono[i] = input[2 * i + 1]; right.Process(mono);
for (int i = 0; i < 960; i++) input[2 * i + 1] = mono[i];
}
}
float gain = mic ? engine.InputGain : 1;
double energy = 0;
for (int i = 0; i < input.Length; i++) { input[i] = (short)Math.Clamp((int)(input[i] * gain), short.MinValue, short.MaxValue); energy += (double)input[i] * input[i]; }
Level = (float)(Math.Sqrt(energy / input.Length) / 32768);
vad.Threshold = engine.VadThreshold;
bool transmit = !mic || !engine.MicMuted && engine.InputMode switch
{
AudioInputMode.AlwaysOn => true, AudioInputMode.PushToTalk => engine.PushToTalk,
_ => vad.Process(input)
};
Talking = transmit && Level > 0.001f;
if (!transmit) { buffered = 0; wasTransmitting = false; return; }
if (!wasTransmitting) marker = true;
wasTransmitting = true;
int channels = encoder.Options.Channels;
for (int i = 0; i < 960; i++)
{
if (channels == 1) wire[buffered + i] = CaptureChannels == 1 ? input[i] : (short)(((int)input[2 * i] + input[2 * i + 1]) / 2);
else { wire[buffered + 2 * i] = input[i * CaptureChannels]; wire[buffered + 2 * i + 1] = input[i * CaptureChannels + CaptureChannels - 1]; }
}
buffered += 960 * channels;
int frame = encoder.Options.SamplesPerChannel * channels;
while (buffered >= frame)
{
int length = encoder.Encode(wire.AsSpan(0, frame), packet);
VoiceFrameFlags flags = (Info.Audio.Fec ? VoiceFrameFlags.FecPresent : VoiceFrameFlags.None) | (marker ? VoiceFrameFlags.Marker : VoiceFrameFlags.None);
sender(Info.Ssrc, timestamp, packet.AsSpan(0, length), flags); marker = false;
timestamp = unchecked(timestamp + (uint)encoder.Options.SamplesPerChannel);
buffered -= frame;
wire.AsSpan(frame, buffered).CopyTo(wire);
}
}
public void Dispose() { encoder.Dispose(); left.Dispose(); right.Dispose(); }
}
+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;
}
}
+163
View File
@@ -0,0 +1,163 @@
using VoiceCat.Codec;
using VoiceCat.Dsp;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Audio;
internal sealed class ReceiveStream : IDisposable
{
internal readonly uint UserId;
internal readonly StreamInfo Info;
internal volatile float Gain = 1;
internal volatile bool Muted, NoiseReduction;
private readonly OpusDecoder decoder;
private readonly OpusDeepRedundancy? dred;
private readonly RnnoiseProcessor left, right;
private readonly short[] pcm = new short[5760 * 2];
private readonly short[] mono = new short[960];
private readonly short[] block = new short[1920];
private readonly byte[][] packets = Enumerable.Range(0, 64).Select(_ => new byte[1275]).ToArray();
private readonly VoiceFrameHeader[] headers = new VoiceFrameHeader[64];
private readonly int[] lengths = new int[64];
private int read, written;
private readonly byte[][] jitter = Enumerable.Range(0, 6).Select(_ => new byte[1275]).ToArray();
private readonly uint[] timestamps = new uint[6];
private readonly int[] sizes = new int[6];
private int count, available, offset, missing, waiting;
private uint expected;
private bool started, hasTimestamp;
private bool hasMarker;
private uint lastMarker;
private readonly int channels, frameSamples, maximumDepth;
internal int Depth => count;
internal int ConcealedFrames { get; private set; }
internal int DredFrames { get; private set; }
internal int FecFrames { get; private set; }
internal ReceiveStream(uint userId, StreamInfo info)
{
if (info.Audio.FrameMs is not (5 or 10 or 20 or 40 or 60) || !Enum.IsDefined(info.Audio.Mode)) throw new ArgumentException("Unsupported remote audio configuration.", nameof(info));
UserId = userId; Info = info.Clone();
channels = info.Audio.Mode == ChannelMode.ModeStereo ? 2 : 1;
frameSamples = checked((int)info.Audio.FrameMs * 48);
maximumDepth = Math.Clamp(120 / (int)info.Audio.FrameMs, 2, 6);
decoder = new(48000, channels);
try { left = new(); } catch { decoder.Dispose(); throw; }
try { right = new(); } catch { left.Dispose(); decoder.Dispose(); throw; }
try { if (info.Audio.Dred) dred = new(); } catch { right.Dispose(); left.Dispose(); decoder.Dispose(); throw; }
}
// Only the network receive owner calls this; mixer alone consumes.
internal bool Enqueue(VoiceFrameHeader header, ReadOnlySpan<byte> payload)
{
int index = written;
if (payload.Length is < 1 or > 1275 || unchecked(index - Volatile.Read(ref read)) >= 64) return false;
int slot = index & 63; payload.CopyTo(packets[slot]); headers[slot] = header; lengths[slot] = payload.Length;
Volatile.Write(ref written, unchecked(index + 1)); return true;
}
private void Drain()
{
while (read != Volatile.Read(ref written))
{
int source = read & 63; uint timestamp = headers[source].Timestamp;
if (!hasTimestamp) { hasTimestamp = true; expected = timestamp; }
int delta = unchecked((int)(timestamp - expected));
if ((headers[source].Flags & VoiceFrameFlags.Marker) != 0 && (!hasMarker || unchecked((int)(timestamp - lastMarker)) > 0))
{
hasMarker = true; lastMarker = timestamp;
sizes.AsSpan().Clear(); count = available = offset = missing = waiting = 0;
expected = timestamp; started = false; delta = 0;
}
bool duplicate = false;
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == timestamp) duplicate = true;
if ((!started || delta >= 0) && delta % frameSamples == 0 && !duplicate)
{
if (count >= maximumDepth)
{
int oldest = Oldest(); sizes[oldest] = 0; count--;
}
int target = Array.IndexOf(sizes, 0);
timestamps[target] = timestamp; sizes[target] = lengths[source]; packets[source].AsSpan(0, lengths[source]).CopyTo(jitter[target]); count++;
int oldestRemaining = Oldest();
if (count >= maximumDepth && unchecked((int)(timestamps[oldestRemaining] - expected)) > 0) expected = timestamps[oldestRemaining];
}
Volatile.Write(ref read, unchecked(read + 1));
}
}
private int Oldest()
{
int oldest = -1;
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && (oldest < 0 || unchecked((int)(timestamps[i] - timestamps[oldest])) < 0)) oldest = i;
return oldest;
}
private void Decode()
{
available = frameSamples; offset = 0;
int found = -1;
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == expected) { found = i; break; }
bool decoded = false;
if (found >= 0)
{
decoded = decoder.TryDecode(jitter[found].AsSpan(0, sizes[found]), pcm, frameSamples, out int result) && result == frameSamples;
sizes[found] = 0; count--; missing = decoded ? 0 : missing + 1;
}
else
{
int next = Oldest(); missing++;
if (next >= 0 && unchecked((int)(timestamps[next] - expected)) == frameSamples)
{
var packet = jitter[next].AsSpan(0, sizes[next]);
if (dred?.TryRecover(decoder, packet, pcm, frameSamples) == true) { decoded = true; DredFrames++; }
else if (Info.Audio.Fec && decoder.TryDecode(packet, pcm, frameSamples, out int recovered, true) && recovered == frameSamples) { decoded = true; FecFrames++; }
}
}
int maximumConcealment = Math.Max(1, 200 / (int)Info.Audio.FrameMs);
if (!decoded && missing <= maximumConcealment)
{
decoded = decoder.TryDecode([], pcm, frameSamples, out _); ConcealedFrames++;
}
if (!decoded || missing > maximumConcealment) pcm.AsSpan(0, frameSamples * channels).Clear();
expected = unchecked(expected + (uint)frameSamples);
}
internal void Mix(Span<int> output, bool deafened, PcmStreamHandler? sink)
{
Drain();
if (!started)
{
waiting++;
if (!hasTimestamp || count < Math.Min(3, maximumDepth) && waiting < 3) return;
expected = timestamps[Oldest()]; started = true;
}
int copied = 0;
while (copied < 960)
{
if (available == 0) Decode();
int take = Math.Min(960 - copied, available);
var decoded = pcm.AsSpan(offset * channels, take * channels);
decoded.CopyTo(block.AsSpan(copied * channels));
available -= take; offset += take; copied += take;
}
var samples = block.AsSpan(0, 960 * channels);
if (NoiseReduction && Info.Kind == StreamKind.StreamMic)
{
if (channels == 1) left.Process(samples);
else
{
for (int i = 0; i < 960; i++) mono[i] = samples[2 * i]; left.Process(mono);
for (int i = 0; i < 960; i++) samples[2 * i] = mono[i];
for (int i = 0; i < 960; i++) mono[i] = samples[2 * i + 1]; right.Process(mono);
for (int i = 0; i < 960; i++) samples[2 * i + 1] = mono[i];
}
}
float gain = Muted || deafened ? 0 : Gain;
for (int i = 0; i < samples.Length; i++) samples[i] = (short)Math.Clamp((int)(samples[i] * gain), short.MinValue, short.MaxValue);
sink?.Invoke(UserId, Info.StreamId, samples, channels);
for (int i = 0; i < 960; i++) { output[i * 2] += samples[i * channels]; output[i * 2 + 1] += samples[i * channels + channels - 1]; }
}
public void Dispose() { decoder.Dispose(); dred?.Dispose(); left.Dispose(); right.Dispose(); }
}
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="../VoiceCat.Codec/VoiceCat.Codec.csproj" />
<ProjectReference Include="../VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
<ProjectReference Include="../VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<InternalsVisibleTo Include="VoiceCat.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,24 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
}
}
}
@@ -0,0 +1,31 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.7, )",
"resolved": "10.0.7",
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.codec": {
"type": "Project"
},
"voicecat.dsp": {
"type": "Project"
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
},
"net10.0/win-x64": {}
}
}