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
+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": {}
}
}