using Concentus; using Concentus.Enums; namespace Nonemm.Recorder; /// Writes an Ogg Opus file with one channel for each input. Each channel is a /// stream of its own in an Opus multistream (mapping family 255, so players /// keep the channels apart instead of mixing them as speakers). Frames are /// 60 ms and the encoder runs at complexity 9. public sealed class OpusFileWriter : IDisposable { public const int FrameSamples = RecorderEngine.SampleRate * 60 / 1000; private const int Complexity = 9; private readonly Stream output; private readonly OggPageWriter pages; private readonly IOpusMultiStreamEncoder encoder; private readonly int channels; private readonly byte[] packet; private readonly int preSkip; private long samplesWritten; private bool isCompleted; /// `bitrateKbps` is for each channel. public OpusFileWriter(Stream output, int channels, int bitrateKbps) { this.output = output; this.channels = channels; // the managed encoder, so a recording does not depend on libopus being installed OpusCodecFactory.AttemptToUseNativeLibrary = false; byte[] mapping = new byte[channels]; encoder = OpusCodecFactory.CreateMultiStreamEncoder(RecorderEngine.SampleRate, channels, 255, out _, out _, mapping, OpusApplication.OPUS_APPLICATION_AUDIO); encoder.Complexity = Complexity; encoder.Bitrate = bitrateKbps * 1000 * channels; encoder.ExpertFrameDuration = OpusFramesize.OPUS_FRAMESIZE_60_MS; preSkip = encoder.Lookahead; packet = new byte[1276 * channels]; pages = new OggPageWriter(output, (uint)Random.Shared.Next()); pages.WritePacket(Header(mapping), 0, isFirst: true, isLast: false); pages.WritePacket(Tags(), 0, isFirst: false, isLast: false); } /// `interleaved` holds `FrameSamples` frames of one sample for each channel. public void WriteFrame(ReadOnlySpan interleaved) { int length = encoder.EncodeMultistream(interleaved, FrameSamples, packet, packet.Length); samplesWritten += FrameSamples; pages.WritePacket(packet.AsSpan(0, length), samplesWritten, isFirst: false, isLast: false); } /// Ends the file. It encodes a last frame of silence so the encoder's /// delay does not cut the end of the recording off, and marks the real /// length so the player drops the silence. public void Complete() { if (isCompleted) { return; } isCompleted = true; long length = samplesWritten; int size = encoder.EncodeMultistream(new float[FrameSamples * channels], FrameSamples, packet, packet.Length); pages.WritePacket(packet.AsSpan(0, size), length + preSkip, isFirst: false, isLast: true); output.Flush(); } public void Dispose() { Complete(); output.Dispose(); } private byte[] Header(byte[] mapping) { bool isMono = channels == 1; using MemoryStream stream = new(); using BinaryWriter writer = new(stream); writer.Write("OpusHead"u8); writer.Write((byte)1); writer.Write((byte)channels); writer.Write((ushort)preSkip); writer.Write(RecorderEngine.SampleRate); writer.Write((short)0); writer.Write((byte)(isMono ? 0 : 255)); if (!isMono) { writer.Write((byte)channels); writer.Write((byte)0); writer.Write(mapping); } return stream.ToArray(); } private static byte[] Tags() { byte[] vendor = "nonemm-recorder"u8.ToArray(); using MemoryStream stream = new(); using BinaryWriter writer = new(stream); writer.Write("OpusTags"u8); writer.Write(vendor.Length); writer.Write(vendor); writer.Write(0); return stream.ToArray(); } }