Add a sound recorder that writes several devices to one Opus file
nonemm-recorder is a separate program (src/Nonemm.Recorder.App) over a library (src/Nonemm.Recorder). Each row in its list is one channel of one device, and each row becomes one channel in an Ogg Opus file: 60 ms frames, complexity 9, 32 kbps per channel by default. - A device that is not connected keeps its row, in red, and writes silence. It is recorded from the moment it returns, also during a recording. - Output devices are recorded as loopback: WASAPI on Windows, the "Monitor of" sources on PulseAudio and PipeWire. - A headphone button plays one channel, in mono, on the default device. - The rows, bitrate and folder are saved when the window closes. SoundFlow 1.4.2 numbers its backend enum one higher than the miniaudio it ships, so asking for PulseAudio gave ALSA and Wasapi gave DirectSound. SoundFlowBackend lowers the value by one and checks which backend started. Output devices and live capture are not tested on real hardware. The tests use a fake sound system and decode the written file. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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<float> 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user