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:
2026-10-23 23:47:45 +00:00
co-authored by Claude Sonnet 5.5
parent c18d79ba5a
commit c01032a2cc
35 changed files with 2278 additions and 0 deletions
@@ -0,0 +1,24 @@
namespace Nonemm.Recorder.Tests;
public class ChannelSourceTests
{
[Theory]
[InlineData("Microphone", false, 0, 1, "Microphone")]
[InlineData("Line In", false, 0, 2, "Line In L")]
[InlineData("Line In", false, 1, 2, "Line In R")]
[InlineData("Interface", false, 2, 8, "Interface 3")]
[InlineData("Speakers", true, 1, 2, "Speakers (output) R")]
public void The_label_names_the_device_and_the_channel(string device, bool isOutput, int channel, int count, string label)
{
Assert.Equal(label, new ChannelSource(device, isOutput, channel, count).Label);
}
[Fact]
public void An_input_and_an_output_with_the_same_name_are_different_channels()
{
ChannelSource input = new("USB", false, 0, 2);
ChannelSource output = new("USB", true, 0, 2);
Assert.False(input.IsSameChannel(output));
}
}
@@ -0,0 +1,47 @@
namespace Nonemm.Recorder.Tests;
/// A sound system with no hardware: the test lists devices and pushes samples.
internal sealed class FakeBackend : AudioBackend
{
private readonly Dictionary<string, CaptureHandler> handlers = [];
public List<AudioDeviceInfo> Devices { get; } = [];
public int PlaybackOpened { get; private set; }
public IReadOnlyList<AudioDeviceInfo> ListDevices() => [.. Devices];
public IDisposable OpenCapture(AudioDeviceInfo device, CaptureHandler handler)
{
handlers[device.Name] = handler;
return new Closer(() => handlers.Remove(device.Name));
}
public IDisposable OpenPlayback(PlaybackHandler handler)
{
PlaybackOpened++;
return new Closer(() => { });
}
public bool IsOpen(string name) => handlers.ContainsKey(name);
/// Delivers `frames` of interleaved samples, `value` in each channel `channel`.
public void Push(string name, int channels, int frames, int channel, float value)
{
float[] samples = new float[frames * channels];
for (int frame = 0; frame < frames; frame++)
{
samples[frame * channels + channel] = value;
}
handlers[name](samples);
}
public void Dispose()
{
}
private sealed class Closer(Action close) : IDisposable
{
public void Dispose() => close();
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Nonemm.Recorder\Nonemm.Recorder.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,92 @@
using Concentus;
namespace Nonemm.Recorder.Tests;
/// Reads back what OpusFileWriter wrote: checks every page checksum and
/// decodes the audio with Concentus, so the tests see what a player would.
internal sealed class OggOpusReader
{
public int Channels { get; private set; }
public int MappingFamily { get; private set; }
public int PreSkip { get; private set; }
public long FinalGranule { get; private set; }
public bool HasEndMark { get; private set; }
/// Decoded samples, one array for each channel, with the pre-skip removed.
public float[][] Pcm { get; private set; } = [];
public static OggOpusReader Read(string path)
{
byte[] file = File.ReadAllBytes(path);
OggOpusReader result = new();
List<byte[]> packets = [];
int position = 0;
while (position < file.Length)
{
Assert.Equal("OggS", System.Text.Encoding.ASCII.GetString(file, position, 4));
int segments = file[position + 26];
int size = 0;
for (int i = 0; i < segments; i++)
{
size += file[position + 27 + i];
}
int total = 27 + segments + size;
uint stored = BitConverter.ToUInt32(file, position + 22);
byte[] page = file[position..(position + total)];
Array.Clear(page, 22, 4);
Assert.Equal(Crc(page), stored);
result.FinalGranule = BitConverter.ToInt64(file, position + 6);
result.HasEndMark = (file[position + 5] & 0x04) != 0;
packets.Add(file[(position + 27 + segments)..(position + total)]);
position += total;
}
byte[] head = packets[0];
result.Channels = head[9];
result.PreSkip = BitConverter.ToUInt16(head, 10);
result.MappingFamily = head[18];
byte[] mapping = result.MappingFamily == 0 ? [0] : head[21..(21 + result.Channels)];
int streams = result.MappingFamily == 0 ? 1 : head[19];
int coupled = result.MappingFamily == 0 ? 0 : head[20];
IOpusMultiStreamDecoder decoder = OpusCodecFactory.CreateMultiStreamDecoder(
RecorderEngine.SampleRate, result.Channels, streams, coupled, mapping);
List<float>[] decoded = [.. Enumerable.Range(0, result.Channels).Select(_ => new List<float>())];
foreach (byte[] packet in packets.Skip(2))
{
float[] frame = new float[OpusFileWriter.FrameSamples * result.Channels];
int got = decoder.DecodeMultistream(packet, frame, OpusFileWriter.FrameSamples, false);
for (int sample = 0; sample < got; sample++)
{
for (int channel = 0; channel < result.Channels; channel++)
{
decoded[channel].Add(frame[sample * result.Channels + channel]);
}
}
}
result.Pcm = [.. decoded.Select(list => list.Skip(result.PreSkip).ToArray())];
return result;
}
private static uint Crc(byte[] data)
{
uint crc = 0;
foreach (byte b in data)
{
crc ^= (uint)b << 24;
for (int bit = 0; bit < 8; bit++)
{
crc = (crc & 0x80000000) != 0 ? (crc << 1) ^ 0x04C11DB7 : crc << 1;
}
}
return crc;
}
public static float Rms(float[] samples, int from, int count)
{
double sum = 0;
for (int i = from; i < from + count; i++)
{
sum += samples[i] * samples[i];
}
return (float)Math.Sqrt(sum / count);
}
}
@@ -0,0 +1,65 @@
namespace Nonemm.Recorder.Tests;
public class OpusFileWriterTests : IDisposable
{
private readonly string path = Path.Combine(Path.GetTempPath(), $"recorder-{Guid.NewGuid():N}.opus");
public void Dispose() => File.Delete(path);
private void Write(int channels, int frames, Func<int, int, float> sample)
{
using OpusFileWriter writer = new(File.Create(path), channels, 32);
for (int frame = 0; frame < frames; frame++)
{
float[] interleaved = new float[OpusFileWriter.FrameSamples * channels];
for (int i = 0; i < OpusFileWriter.FrameSamples; i++)
{
for (int channel = 0; channel < channels; channel++)
{
interleaved[i * channels + channel] = sample(channel, frame * OpusFileWriter.FrameSamples + i);
}
}
writer.WriteFrame(interleaved);
}
}
private static float Tone(int hertz, int index) =>
0.5f * (float)Math.Sin(2 * Math.PI * hertz * index / RecorderEngine.SampleRate);
[Fact]
public void Each_channel_comes_back_on_its_own_channel()
{
Write(3, 10, (channel, index) => channel == 1 ? 0 : Tone(channel == 0 ? 440 : 1000, index));
OggOpusReader file = OggOpusReader.Read(path);
Assert.Equal(3, file.Channels);
Assert.Equal(255, file.MappingFamily);
Assert.True(OggOpusReader.Rms(file.Pcm[0], 4800, 9600) > 0.25f);
Assert.True(OggOpusReader.Rms(file.Pcm[1], 4800, 9600) < 0.01f);
Assert.True(OggOpusReader.Rms(file.Pcm[2], 4800, 9600) > 0.25f);
}
[Fact]
public void The_end_granule_is_the_length_of_the_audio_plus_the_pre_skip()
{
Write(2, 10, (_, index) => Tone(440, index));
OggOpusReader file = OggOpusReader.Read(path);
Assert.True(file.HasEndMark);
Assert.Equal(10L * OpusFileWriter.FrameSamples + file.PreSkip, file.FinalGranule);
}
[Fact]
public void One_channel_is_written_as_a_plain_mono_file()
{
Write(1, 5, (_, index) => Tone(440, index));
OggOpusReader file = OggOpusReader.Read(path);
Assert.Equal(1, file.Channels);
Assert.Equal(0, file.MappingFamily);
Assert.True(OggOpusReader.Rms(file.Pcm[0], 4800, 9600) > 0.25f);
}
}
@@ -0,0 +1,51 @@
namespace Nonemm.Recorder.Tests;
public class RecorderConfigStoreTests : IDisposable
{
private readonly string folder = Path.Combine(Path.GetTempPath(), $"recorder-{Guid.NewGuid():N}");
public void Dispose()
{
if (Directory.Exists(folder))
{
Directory.Delete(folder, true);
}
}
[Fact]
public void A_missing_file_gives_32_kbps_and_no_channels()
{
RecorderConfig config = new RecorderConfigStore(Path.Combine(folder, "none.json")).Load();
Assert.Equal(32, config.BitrateKbps);
Assert.Empty(config.Channels);
}
[Fact]
public void Channels_that_were_saved_come_back_in_order()
{
RecorderConfigStore store = new(Path.Combine(folder, "config.json"));
store.Save(new RecorderConfig
{
BitrateKbps = 48,
Channels = [new("Line In", false, 1, 2), new("Speakers", true, 0, 2)],
});
RecorderConfig loaded = store.Load();
Assert.Equal(48, loaded.BitrateKbps);
Assert.Equal(["Line In R", "Speakers (output) L"], loaded.Channels.Select(channel => channel.Label));
}
[Fact]
public void A_file_that_is_not_json_throws_with_its_path()
{
Directory.CreateDirectory(folder);
string path = Path.Combine(folder, "config.json");
File.WriteAllText(path, "not json");
InvalidDataException error = Assert.Throws<InvalidDataException>(() => new RecorderConfigStore(path).Load());
Assert.Contains(path, error.Message);
}
}
@@ -0,0 +1,140 @@
namespace Nonemm.Recorder.Tests;
public class RecorderEngineTests : IDisposable
{
private readonly string path = Path.Combine(Path.GetTempPath(), $"recorder-{Guid.NewGuid():N}.opus");
private readonly FakeBackend backend = new();
private readonly RecorderEngine engine;
public RecorderEngineTests()
{
engine = new RecorderEngine(backend);
}
public void Dispose()
{
engine.Dispose();
File.Delete(path);
}
[Fact]
public void A_channel_of_a_missing_device_stays_in_the_list_as_unavailable()
{
RecorderChannel channel = engine.AddChannel(new ChannelSource("Gone", false, 0, 1));
Assert.False(channel.IsAvailable);
Assert.Single(engine.Channels);
Assert.Equal("not connected", channel.Problem);
}
[Fact]
public void A_device_that_appears_is_opened_and_its_channel_becomes_available()
{
RecorderChannel channel = engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
engine.Refresh();
Assert.True(channel.IsAvailable);
Assert.True(backend.IsOpen("Mic"));
}
[Fact]
public void A_device_that_is_unplugged_is_closed_and_its_channel_is_kept()
{
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
RecorderChannel channel = engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
backend.Devices.Clear();
engine.Refresh();
Assert.False(channel.IsAvailable);
Assert.False(backend.IsOpen("Mic"));
Assert.Single(engine.Channels);
}
[Fact]
public void A_channel_past_the_end_of_the_device_is_unavailable()
{
backend.Devices.Add(new AudioDeviceInfo("Line In", false, 2));
RecorderChannel channel = engine.AddChannel(new ChannelSource("Line In", false, 5, 8));
Assert.False(channel.IsAvailable);
}
[Fact]
public void The_two_sides_of_a_stereo_device_are_two_channels()
{
backend.Devices.Add(new AudioDeviceInfo("Line In", false, 2));
RecorderChannel left = engine.AddChannel(new ChannelSource("Line In", false, 0, 2));
RecorderChannel right = engine.AddChannel(new ChannelSource("Line In", false, 1, 2));
backend.Push("Line In", 2, 480, channel: 1, value: 0.5f);
Assert.Equal(0f, left.TakePeak());
Assert.Equal(0.5f, right.TakePeak());
}
[Fact]
public void An_unavailable_channel_is_recorded_as_silence_and_picks_up_when_it_appears()
{
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
engine.AddChannel(new ChannelSource("Radio", false, 0, 1));
engine.StartRecording(path, 32);
Feed(seconds: 1, "Mic");
backend.Devices.Add(new AudioDeviceInfo("Radio", false, 1));
engine.Refresh();
Feed(seconds: 1, "Mic", "Radio");
engine.StopRecording();
OggOpusReader file = OggOpusReader.Read(path);
Assert.Equal(2, file.Channels);
int second = RecorderEngine.SampleRate;
Assert.True(OggOpusReader.Rms(file.Pcm[0], second / 4, second / 2) > 0.2f);
Assert.True(OggOpusReader.Rms(file.Pcm[1], second / 4, second / 2) < 0.01f);
Assert.True(OggOpusReader.Rms(file.Pcm[1], second + second / 4, second / 2) > 0.2f);
}
[Fact]
public void The_channels_cannot_change_during_a_recording()
{
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
engine.StartRecording(path, 32);
Assert.Throws<InvalidOperationException>(() => engine.AddChannel(new ChannelSource("Mic", false, 0, 1)));
}
[Fact]
public void Monitoring_opens_the_playback_device_once_and_closes_it_with_the_last_channel()
{
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
RecorderChannel first = engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
RecorderChannel second = engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
engine.SetMonitored(first, true);
engine.SetMonitored(second, true);
engine.SetMonitored(first, false);
Assert.Equal(1, backend.PlaybackOpened);
engine.SetMonitored(second, false);
engine.SetMonitored(first, true);
Assert.Equal(2, backend.PlaybackOpened);
}
// a tone in real time, 10 ms at a time, as a device delivers it
private void Feed(int seconds, params string[] names)
{
for (int step = 0; step < seconds * 100; step++)
{
foreach (string name in names)
{
backend.Push(name, 1, 480, channel: 0, value: step % 2 == 0 ? 0.4f : -0.4f);
}
Thread.Sleep(10);
}
}
}
@@ -0,0 +1,46 @@
namespace Nonemm.Recorder.Tests;
public class SampleRingTests
{
[Fact]
public void Samples_come_out_in_the_order_they_went_in_across_the_wrap()
{
SampleRing ring = new(5);
ring.Write([1, 2, 3, 4]);
Span<float> first = stackalloc float[3];
ring.Read(first);
ring.Write([5, 6, 7]);
Span<float> all = stackalloc float[5];
int got = ring.Read(all);
Assert.Equal(4, got);
Assert.Equal([4f, 5f, 6f, 7f], all[..4].ToArray());
}
[Fact]
public void A_full_ring_drops_the_oldest_samples()
{
SampleRing ring = new(4);
ring.Write([1, 2, 3]);
ring.Write([4, 5, 6]);
Span<float> all = stackalloc float[4];
ring.Read(all);
Assert.Equal([3f, 4f, 5f, 6f], all.ToArray());
}
[Fact]
public void Trim_keeps_the_newest_samples()
{
SampleRing ring = new(8);
ring.Write([1, 2, 3, 4, 5]);
ring.TrimTo(2);
Span<float> all = stackalloc float[2];
ring.Read(all);
Assert.Equal([4f, 5f], all.ToArray());
}
}