diff --git a/Nonemm.slnx b/Nonemm.slnx
index c15af0d..18f5a38 100644
--- a/Nonemm.slnx
+++ b/Nonemm.slnx
@@ -10,6 +10,8 @@
+
+
@@ -26,6 +28,7 @@
+
diff --git a/README.md b/README.md
index 09d415b..36cbf90 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,11 @@ Everything that judges a station — the bar under the callsign box, a row in th
check window, a spot on the bandmap, a line in the log — is coloured by the same
scorer: red for a dupe, green for a new multiplier, blue for points.
+## Sound recorder
+
+`nonemm-recorder` is a small separate program that records channels of several
+audio devices into one Opus file. See [docs/recorder.md](docs/recorder.md).
+
## Windows
Build the Windows copy on any machine that has the .NET 10 SDK:
diff --git a/docs/recorder.md b/docs/recorder.md
new file mode 100644
index 0000000..7e369ba
--- /dev/null
+++ b/docs/recorder.md
@@ -0,0 +1,74 @@
+# Sound recorder
+
+`nonemm-recorder` is a separate program (`src/Nonemm.Recorder.App`). It records
+channels of several audio devices into one Opus file. The code that does the
+work is in `src/Nonemm.Recorder`, and the window is a thin layer over it.
+
+Run it with `./build.sh run --project src/Nonemm.Recorder.App`.
+
+## The file
+
+- Ogg Opus, one channel in the file for each row in the list, in list order.
+- Mapping family 255: each channel is its own mono stream, so a player does
+ not mix them as speakers. A file with one row is plain mono.
+- 48 kHz, 60 ms frames, complexity 9. The bitrate is for each channel and
+ defaults to 32 kbps, so a file of four rows is about 128 kbps.
+- A new file is named `recording-yyyyMMdd-HHmmss.opus` in the chosen folder.
+- Pause leaves no gap: the audio after Resume follows directly on the audio
+ before Pause.
+
+## Channels
+
+- Each row picks one channel of one device: `Line In L`, `Line In R`, or just
+ `Microphone` for a mono device. The list of choices is the same in every row.
+- Output devices are listed as `Speakers (output) L`. They record what that
+ device plays (see "Output devices").
+- A device that is not there keeps its row, in red, and writes silence. The
+ program lists the devices every 2 seconds. When the device returns, its
+ channel is recorded from that moment, also during a recording.
+- Rows cannot be added, removed or changed during a recording, as they would
+ change the channel count of the file.
+- The headphone button plays that channel, in mono, on the default playback
+ device. If it monitors an output device that is also the default one, it
+ feeds back.
+
+## Configuration
+
+One file, written when the window closes: `nonemm-recorder/config.json` in the
+user's application data folder (`%APPDATA%` on Windows, `~/.config` on Linux).
+It holds the rows, the bitrate and the folder. Rows of devices that were not
+connected stay in it.
+
+## Output devices
+
+| System | How an output is recorded | Tested |
+| --- | --- | --- |
+| Windows | WASAPI loopback of that playback device | no |
+| Linux | the PulseAudio or PipeWire "Monitor of ..." source | no |
+
+Neither was tested on real hardware: the machine this was written on has no
+sound device. The tests in `tests/Nonemm.Recorder.Tests` use a fake sound
+system and check the file by decoding it again.
+
+On Windows, SoundFlow only loopback-records the default output through its
+public API. `SoundFlowBackend.MarkLoopback` sets an internal setting by
+reflection to pick any output, so it is tied to SoundFlow 1.4.2.
+
+## Sound system
+
+On Linux the program asks for PulseAudio (PipeWire serves it) and falls back to
+ALSA only if that fails. The window shows which one is in use, and the reason
+in red when it is the fallback. ALSA lists sound cards and plugins, not the
+devices a PipeWire desktop shows, and has no "Monitor of ..." sources.
+
+SoundFlow 1.4.2 numbers its backend enum one higher than the miniaudio library
+it ships, so asking for `PulseAudio` gives ALSA and `Wasapi` gives DirectSound.
+`SoundFlowBackend.Start` lowers the value by one and checks the result. If a
+later SoundFlow fixes the numbering, that check fails with a message.
+
+## Timing
+
+The devices do not share a clock, so the file is cut by the system clock, not by
+how much each device delivered. The encoder runs 120 ms behind the clock. A
+device that delivers too little is padded with zeros. One that delivers too
+much loses its oldest samples once it is 240 ms ahead.
diff --git a/src/Nonemm.Recorder.App/App.axaml b/src/Nonemm.Recorder.App/App.axaml
new file mode 100644
index 0000000..2cdb247
--- /dev/null
+++ b/src/Nonemm.Recorder.App/App.axaml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/src/Nonemm.Recorder.App/App.axaml.cs b/src/Nonemm.Recorder.App/App.axaml.cs
new file mode 100644
index 0000000..e9eb033
--- /dev/null
+++ b/src/Nonemm.Recorder.App/App.axaml.cs
@@ -0,0 +1,19 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace Nonemm.Recorder.App;
+
+public partial class App : Application
+{
+ public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow(new RecorderConfigStore(RecorderConfigStore.DefaultPath()));
+ }
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/src/Nonemm.Recorder.App/ChannelRow.cs b/src/Nonemm.Recorder.App/ChannelRow.cs
new file mode 100644
index 0000000..274de07
--- /dev/null
+++ b/src/Nonemm.Recorder.App/ChannelRow.cs
@@ -0,0 +1,119 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace Nonemm.Recorder.App;
+
+/// One line of the channel list: the device and channel to record, a level
+/// meter, the monitor button and the remove button.
+internal sealed class ChannelRow : Grid
+{
+ // Material Design "headset" icon
+ private const string HeadphonesPath =
+ "M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z";
+
+ private readonly RecorderEngine engine;
+ private readonly ComboBox sources = new() { HorizontalAlignment = HorizontalAlignment.Stretch };
+ private readonly ProgressBar level = new() { Minimum = 0, Maximum = 1, Width = 90, Height = 8 };
+ private readonly ToggleButton monitor = new() { Width = 36 };
+ private readonly Button remove = new() { Content = "✕", Width = 36 };
+ private readonly Action report;
+ private bool isFilling;
+ private double shown;
+
+ public ChannelRow(RecorderEngine engine, RecorderChannel channel, Action onRemove, Action report)
+ {
+ this.engine = engine;
+ this.report = report;
+ Channel = channel;
+ ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto,Auto");
+ ColumnSpacing = 8;
+ Margin = new Thickness(0, 2);
+
+ sources.ItemTemplate = new FuncDataTemplate((choice, _) =>
+ new TextBlock { Text = choice?.Label, Foreground = choice?.Brush });
+ sources.SelectionChanged += (_, _) => OnSourcePicked();
+
+ monitor.Content = new PathIcon { Data = Geometry.Parse(HeadphonesPath), Width = 16, Height = 16 };
+ ToolTip.SetTip(monitor, "Monitor this channel on the playback device");
+ monitor.Click += (_, _) => OnMonitorClicked();
+
+ ToolTip.SetTip(remove, "Remove this channel");
+ remove.Click += (_, _) => onRemove(channel);
+
+ level.VerticalAlignment = VerticalAlignment.Center;
+ SetColumn(level, 1);
+ SetColumn(monitor, 2);
+ SetColumn(remove, 3);
+ Children.Add(sources);
+ Children.Add(level);
+ Children.Add(monitor);
+ Children.Add(remove);
+ FillSources();
+ }
+
+ public RecorderChannel Channel { get; }
+
+ /// Locks the choices that would change the file while it is being written.
+ public void SetRecording(bool isRecording)
+ {
+ sources.IsEnabled = !isRecording;
+ remove.IsEnabled = !isRecording;
+ }
+
+ /// Redraws the list of choices; the selection stays on the channel's own.
+ public void FillSources()
+ {
+ isFilling = true;
+ List choices = [];
+ foreach (AudioDeviceInfo device in engine.Devices.OrderBy(device => device.IsOutput))
+ {
+ choices.AddRange(device.Sources().Select(source => new SourceChoice(source, true)));
+ }
+ SourceChoice? current = choices.FirstOrDefault(choice => choice.Source.IsSameChannel(Channel.Source));
+ if (current is null)
+ {
+ current = new SourceChoice(Channel.Source, false);
+ choices.Insert(0, current);
+ }
+ sources.ItemsSource = choices;
+ sources.SelectedItem = current;
+ isFilling = false;
+ }
+
+ /// Moves the meter; called on a timer.
+ public void Tick()
+ {
+ double peak = Math.Sqrt(Math.Min(1.0, Channel.TakePeak()));
+ shown = Math.Max(peak, shown * 0.85);
+ level.Value = shown;
+ sources.Foreground = Channel.IsAvailable ? null : new SolidColorBrush(Color.Parse("#D93025"));
+ ToolTip.SetTip(sources, Channel.IsAvailable ? null : Channel.Problem);
+ }
+
+ private void OnSourcePicked()
+ {
+ if (isFilling || sources.SelectedItem is not SourceChoice choice
+ || choice.Source.IsSameChannel(Channel.Source))
+ {
+ return;
+ }
+ engine.SetSource(Channel, choice.Source);
+ }
+
+ private void OnMonitorClicked()
+ {
+ try
+ {
+ engine.SetMonitored(Channel, monitor.IsChecked == true);
+ }
+ catch (Exception cause)
+ {
+ monitor.IsChecked = false;
+ report($"Cannot play on the playback device: {cause.Message}");
+ }
+ }
+}
diff --git a/src/Nonemm.Recorder.App/MainWindow.axaml b/src/Nonemm.Recorder.App/MainWindow.axaml
new file mode 100644
index 0000000..d633eba
--- /dev/null
+++ b/src/Nonemm.Recorder.App/MainWindow.axaml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.Recorder.App/MainWindow.axaml.cs b/src/Nonemm.Recorder.App/MainWindow.axaml.cs
new file mode 100644
index 0000000..ea9a368
--- /dev/null
+++ b/src/Nonemm.Recorder.App/MainWindow.axaml.cs
@@ -0,0 +1,234 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+
+namespace Nonemm.Recorder.App;
+
+public partial class MainWindow : Window
+{
+ private const int TickMilliseconds = 50;
+
+ private readonly RecorderConfigStore store;
+ private readonly RecorderConfig config;
+ private readonly RecorderEngine? engine;
+ private readonly DispatcherTimer timer = new() { Interval = TimeSpan.FromMilliseconds(TickMilliseconds) };
+
+ public MainWindow()
+ : this(new RecorderConfigStore(RecorderConfigStore.DefaultPath()))
+ {
+ }
+
+ public MainWindow(RecorderConfigStore store)
+ {
+ InitializeComponent();
+ this.store = store;
+ try
+ {
+ config = store.Load();
+ }
+ catch (InvalidDataException problem)
+ {
+ config = new RecorderConfig();
+ Say($"{problem.Message}. Starting with an empty list.", isProblem: true);
+ }
+ BitrateBox.Value = config.BitrateKbps;
+ FolderBox.Text = config.OutputFolder ?? DefaultFolder();
+
+ SoundFlowBackend backend;
+ try
+ {
+ backend = new SoundFlowBackend();
+ engine = new RecorderEngine(backend);
+ }
+ catch (Exception cause)
+ {
+ Say($"The sound system could not start: {cause.Message}", isProblem: true);
+ RecordButton.Content = "● Record";
+ RecordButton.IsEnabled = false;
+ AddButton.IsEnabled = false;
+ PauseButton.IsEnabled = false;
+ return;
+ }
+ foreach (ChannelSource source in config.Channels)
+ {
+ AddRow(engine.AddChannel(source));
+ }
+ engine.Changed += () => Dispatcher.UIThread.Post(OnDevicesChanged);
+ engine.Start();
+ OnDevicesChanged();
+ ShowRecordState(isRecording: false);
+ Say(backend.Description, backend.IsFallback);
+
+ RecordButton.Click += (_, _) => ToggleRecording();
+ PauseButton.Click += (_, _) => TogglePause();
+ AddButton.Click += (_, _) => AddNext();
+ BrowseButton.Click += async (_, _) => await BrowseAsync();
+ timer.Tick += (_, _) => Tick();
+ timer.Start();
+ Closing += (_, _) => Shutdown();
+ }
+
+ private IEnumerable RowList => Rows.Children.OfType();
+
+ private static string DefaultFolder()
+ {
+ string music = Environment.GetFolderPath(Environment.SpecialFolder.MyMusic);
+ return music.Length > 0 ? music : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ }
+
+ private void AddRow(RecorderChannel channel)
+ {
+ Rows.Children.Add(new ChannelRow(engine!, channel, RemoveRow, text => Say(text, isProblem: true)));
+ }
+
+ private void RemoveRow(RecorderChannel channel)
+ {
+ ChannelRow row = RowList.First(candidate => candidate.Channel == channel);
+ try
+ {
+ engine!.RemoveChannel(channel);
+ }
+ catch (InvalidOperationException problem)
+ {
+ Say(problem.Message, isProblem: true);
+ return;
+ }
+ Rows.Children.Remove(row);
+ }
+
+ /// Adds a channel on the first channel no other row uses.
+ private void AddNext()
+ {
+ List all = [.. engine!.Devices.OrderBy(device => device.IsOutput).SelectMany(device => device.Sources())];
+ ChannelSource? next = all.FirstOrDefault(source => engine.Channels.All(channel => !channel.Source.IsSameChannel(source)))
+ ?? all.FirstOrDefault();
+ if (next is null)
+ {
+ Say("No audio devices found.", isProblem: true);
+ return;
+ }
+ AddRow(engine.AddChannel(next));
+ }
+
+ private void OnDevicesChanged()
+ {
+ foreach (ChannelRow row in RowList)
+ {
+ row.FillSources();
+ }
+ }
+
+ private void ToggleRecording()
+ {
+ if (engine!.Recording is not null)
+ {
+ string? path = engine.StopRecording();
+ ShowRecordState(isRecording: false);
+ Say(path is null ? "" : $"Saved {path}", isProblem: false);
+ return;
+ }
+ string folder = FolderBox.Text?.Trim() ?? "";
+ try
+ {
+ Directory.CreateDirectory(folder);
+ string path = Path.Combine(folder, $"recording-{DateTime.Now:yyyyMMdd-HHmmss}.opus");
+ engine.StartRecording(path, (int)(BitrateBox.Value ?? RecorderConfig.DefaultBitrateKbps));
+ Say($"Recording to {path}", isProblem: false);
+ }
+ catch (Exception cause) when (cause is IOException or UnauthorizedAccessException
+ or ArgumentException or InvalidOperationException)
+ {
+ Say($"Cannot start the recording: {cause.Message}", isProblem: true);
+ return;
+ }
+ ShowRecordState(isRecording: true);
+ }
+
+ private void TogglePause()
+ {
+ RecordingSession? session = engine!.Recording;
+ if (session is null)
+ {
+ return;
+ }
+ if (session.IsPaused)
+ {
+ session.Resume();
+ }
+ else
+ {
+ session.Pause();
+ }
+ PauseButton.Content = session.IsPaused ? "Resume" : "Pause";
+ }
+
+ private void ShowRecordState(bool isRecording)
+ {
+ RecordButton.Content = isRecording ? "■ Stop" : "● Record";
+ RecordButton.Foreground = isRecording ? null : new SolidColorBrush(Color.Parse("#D93025"));
+ PauseButton.IsEnabled = isRecording;
+ PauseButton.Content = "Pause";
+ AddButton.IsEnabled = !isRecording && engine is not null;
+ BitrateBox.IsEnabled = !isRecording;
+ FolderBox.IsEnabled = !isRecording;
+ BrowseButton.IsEnabled = !isRecording;
+ foreach (ChannelRow row in RowList)
+ {
+ row.SetRecording(isRecording);
+ }
+ }
+
+ private async Task BrowseAsync()
+ {
+ IReadOnlyList picked = await StorageProvider.OpenFolderPickerAsync(
+ new FolderPickerOpenOptions { Title = "Folder for the recordings" });
+ if (picked.Count > 0)
+ {
+ FolderBox.Text = picked[0].Path.LocalPath;
+ }
+ }
+
+ private void Tick()
+ {
+ foreach (ChannelRow row in RowList)
+ {
+ row.Tick();
+ }
+ RecordingSession? session = engine!.Recording;
+ if (session is null)
+ {
+ return;
+ }
+ TimeText.Text = session.Elapsed.ToString(@"hh\:mm\:ss");
+ if (session.Failure is { } failure)
+ {
+ engine.StopRecording();
+ ShowRecordState(isRecording: false);
+ Say($"The recording stopped: {failure.Message}", isProblem: true);
+ }
+ }
+
+ private void Shutdown()
+ {
+ timer.Stop();
+ config.BitrateKbps = (int)(BitrateBox.Value ?? RecorderConfig.DefaultBitrateKbps);
+ config.OutputFolder = FolderBox.Text;
+ config.Channels = [.. engine!.Channels.Select(channel => channel.Source)];
+ try
+ {
+ store.Save(config);
+ }
+ catch (Exception cause) when (cause is IOException or UnauthorizedAccessException)
+ {
+ Console.Error.WriteLine($"Cannot save the configuration: {cause.Message}");
+ }
+ engine.Dispose();
+ }
+
+ private void Say(string text, bool isProblem)
+ {
+ StatusText.Text = text;
+ StatusText.Foreground = isProblem ? new SolidColorBrush(Color.Parse("#D93025")) : null;
+ }
+}
diff --git a/src/Nonemm.Recorder.App/Nonemm.Recorder.App.csproj b/src/Nonemm.Recorder.App/Nonemm.Recorder.App.csproj
new file mode 100644
index 0000000..ac7ccdc
--- /dev/null
+++ b/src/Nonemm.Recorder.App/Nonemm.Recorder.App.csproj
@@ -0,0 +1,20 @@
+
+
+ WinExe
+ net10.0
+ enable
+ nonemm-recorder
+ Nonemm.Recorder.App
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.Recorder.App/Program.cs b/src/Nonemm.Recorder.App/Program.cs
new file mode 100644
index 0000000..dd68d60
--- /dev/null
+++ b/src/Nonemm.Recorder.App/Program.cs
@@ -0,0 +1,16 @@
+using Avalonia;
+
+namespace Nonemm.Recorder.App;
+
+internal static class Program
+{
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/src/Nonemm.Recorder.App/SourceChoice.cs b/src/Nonemm.Recorder.App/SourceChoice.cs
new file mode 100644
index 0000000..f6f27d5
--- /dev/null
+++ b/src/Nonemm.Recorder.App/SourceChoice.cs
@@ -0,0 +1,14 @@
+using Avalonia.Media;
+
+namespace Nonemm.Recorder.App;
+
+/// One entry in a channel's device list. A source whose device is not there is
+/// red.
+internal sealed record SourceChoice(ChannelSource Source, bool IsAvailable)
+{
+ private static readonly IBrush Missing = new SolidColorBrush(Color.Parse("#D93025"));
+
+ public string Label => IsAvailable ? Source.Label : $"{Source.Label} (not connected)";
+
+ public IBrush? Brush => IsAvailable ? null : Missing;
+}
diff --git a/src/Nonemm.Recorder/AlsaQuiet.cs b/src/Nonemm.Recorder/AlsaQuiet.cs
new file mode 100644
index 0000000..57c2ae1
--- /dev/null
+++ b/src/Nonemm.Recorder/AlsaQuiet.cs
@@ -0,0 +1,31 @@
+using System.Runtime.InteropServices;
+
+namespace Nonemm.Recorder;
+
+/// Stops libasound printing to the terminal. Opening the ALSA backend makes it
+/// try every plugin in /etc/asound.conf and print a line for each one that
+/// fails, which is most of them on a PipeWire system.
+internal static class AlsaQuiet
+{
+ // alsa-lib calls this with a printf format and its arguments; ignoring all
+ // of them is safe as the callee does not read them back
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate void ErrorHandler(nint file, int line, nint function, int error, nint format);
+
+ private static readonly ErrorHandler Ignore = (_, _, _, _, _) => { };
+
+ [DllImport("libasound.so.2")]
+ private static extern int snd_lib_error_set_handler(ErrorHandler? handler);
+
+ public static void Install()
+ {
+ try
+ {
+ snd_lib_error_set_handler(Ignore);
+ }
+ catch (DllNotFoundException)
+ {
+ // no libasound, so there is nothing to quiet
+ }
+ }
+}
diff --git a/src/Nonemm.Recorder/AudioBackend.cs b/src/Nonemm.Recorder/AudioBackend.cs
new file mode 100644
index 0000000..4a9c54d
--- /dev/null
+++ b/src/Nonemm.Recorder/AudioBackend.cs
@@ -0,0 +1,22 @@
+namespace Nonemm.Recorder;
+
+/// Receives what a capture device heard: 32-bit float samples at 48 kHz,
+/// interleaved, `AudioDeviceInfo.Channels` to a frame. It runs on the audio
+/// thread, so it must not block.
+public delegate void CaptureHandler(ReadOnlySpan interleaved);
+
+/// Fills `mono` with the next samples to play, at 48 kHz.
+public delegate void PlaybackHandler(Span mono);
+
+/// The sound system: lists devices, and opens them for capture or playback.
+/// The recorder takes it as an argument so tests can pass a fake.
+public interface AudioBackend : IDisposable
+{
+ IReadOnlyList ListDevices();
+
+ /// Throws when the device cannot be opened. Dispose the result to close it.
+ IDisposable OpenCapture(AudioDeviceInfo device, CaptureHandler handler);
+
+ /// Plays on the system's default playback device.
+ IDisposable OpenPlayback(PlaybackHandler handler);
+}
diff --git a/src/Nonemm.Recorder/AudioDeviceInfo.cs b/src/Nonemm.Recorder/AudioDeviceInfo.cs
new file mode 100644
index 0000000..1e8d83e
--- /dev/null
+++ b/src/Nonemm.Recorder/AudioDeviceInfo.cs
@@ -0,0 +1,10 @@
+namespace Nonemm.Recorder;
+
+/// A device the backend lists. `Channels` is the most it can give at once.
+public sealed record AudioDeviceInfo(string Name, bool IsOutput, int Channels)
+{
+ /// One entry for each channel the device has.
+ public IEnumerable Sources() =>
+ Enumerable.Range(0, Math.Max(1, Channels))
+ .Select(channel => new ChannelSource(Name, IsOutput, channel, Math.Max(1, Channels)));
+}
diff --git a/src/Nonemm.Recorder/ChannelSource.cs b/src/Nonemm.Recorder/ChannelSource.cs
new file mode 100644
index 0000000..90265c3
--- /dev/null
+++ b/src/Nonemm.Recorder/ChannelSource.cs
@@ -0,0 +1,28 @@
+namespace Nonemm.Recorder;
+
+/// One channel of one device, as the operator picked it. `Channel` counts from
+/// 0 and `ChannelCount` is how many channels the device had when it was picked,
+/// so a saved entry can be named without the device being there.
+public sealed record ChannelSource(string DeviceName, bool IsOutput, int Channel, int ChannelCount)
+{
+ public string Label
+ {
+ get
+ {
+ string device = IsOutput ? $"{DeviceName} (output)" : DeviceName;
+ return ChannelCount switch
+ {
+ <= 1 => device,
+ 2 => $"{device} {(Channel == 0 ? "L" : "R")}",
+ _ => $"{device} {Channel + 1}",
+ };
+ }
+ }
+
+ /// Two sources are the same channel when the device name, direction and
+ /// channel number match. The channel count does not take part, as a device
+ /// that comes back with another channel count is still that device.
+ public bool IsSameChannel(ChannelSource other) =>
+ IsOutput == other.IsOutput && Channel == other.Channel
+ && string.Equals(DeviceName, other.DeviceName, StringComparison.Ordinal);
+}
diff --git a/src/Nonemm.Recorder/MonitorComponent.cs b/src/Nonemm.Recorder/MonitorComponent.cs
new file mode 100644
index 0000000..0bab2fc
--- /dev/null
+++ b/src/Nonemm.Recorder/MonitorComponent.cs
@@ -0,0 +1,39 @@
+using SoundFlow.Abstracts;
+using SoundFlow.Structs;
+
+namespace Nonemm.Recorder;
+
+/// A source in the playback mixer that asks the recorder for mono samples and
+/// copies each one to every output channel.
+internal sealed class MonitorComponent : SoundComponent
+{
+ private readonly PlaybackHandler handler;
+ private float[] mono = new float[1024];
+
+ public MonitorComponent(AudioEngine engine, AudioFormat format, PlaybackHandler handler)
+ : base(engine, format)
+ {
+ this.handler = handler;
+ }
+
+ public override string Name { get; set; } = "Monitor";
+
+ protected override void GenerateAudio(Span buffer, int channels)
+ {
+ int frames = buffer.Length / channels;
+ if (mono.Length < frames)
+ {
+ mono = new float[frames];
+ }
+ Span samples = mono.AsSpan(0, frames);
+ samples.Clear();
+ handler(samples);
+ for (int frame = 0; frame < frames; frame++)
+ {
+ for (int channel = 0; channel < channels; channel++)
+ {
+ buffer[frame * channels + channel] = samples[frame];
+ }
+ }
+ }
+}
diff --git a/src/Nonemm.Recorder/Nonemm.Recorder.csproj b/src/Nonemm.Recorder/Nonemm.Recorder.csproj
new file mode 100644
index 0000000..6cdbc2e
--- /dev/null
+++ b/src/Nonemm.Recorder/Nonemm.Recorder.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.Recorder/OggPageWriter.cs b/src/Nonemm.Recorder/OggPageWriter.cs
new file mode 100644
index 0000000..32e06b3
--- /dev/null
+++ b/src/Nonemm.Recorder/OggPageWriter.cs
@@ -0,0 +1,68 @@
+namespace Nonemm.Recorder;
+
+/// Writes Ogg pages (RFC 3533) with one packet in each. A packet must fit one
+/// page, which caps it at 65025 bytes: 255 lacing values of 255 bytes.
+public sealed class OggPageWriter
+{
+ private const int MaxPacket = 255 * 255;
+
+ private static readonly uint[] CrcTable = BuildCrcTable();
+
+ private readonly Stream output;
+ private readonly uint serial;
+ private uint sequence;
+
+ public OggPageWriter(Stream output, uint serial)
+ {
+ this.output = output;
+ this.serial = serial;
+ }
+
+ /// `granule` is the position of the end of the packet in samples; -1 for a
+ /// page that has no packet ending on it.
+ public void WritePacket(ReadOnlySpan packet, long granule, bool isFirst, bool isLast)
+ {
+ if (packet.Length >= MaxPacket)
+ {
+ throw new InvalidOperationException(
+ $"an Ogg packet of {packet.Length} bytes does not fit in one page; lower the bitrate or the channel count");
+ }
+ int segments = packet.Length / 255 + 1;
+ byte[] page = new byte[27 + segments + packet.Length];
+ "OggS"u8.CopyTo(page);
+ page[5] = (byte)((isFirst ? 0x02 : 0) | (isLast ? 0x04 : 0));
+ BitConverter.TryWriteBytes(page.AsSpan(6), granule);
+ BitConverter.TryWriteBytes(page.AsSpan(14), serial);
+ BitConverter.TryWriteBytes(page.AsSpan(18), sequence++);
+ page[26] = (byte)segments;
+ for (int i = 0; i < segments - 1; i++)
+ {
+ page[27 + i] = 255;
+ }
+ page[27 + segments - 1] = (byte)(packet.Length % 255);
+ packet.CopyTo(page.AsSpan(27 + segments));
+
+ uint crc = 0;
+ foreach (byte b in page)
+ {
+ crc = (crc << 8) ^ CrcTable[(byte)(crc >> 24) ^ b];
+ }
+ BitConverter.TryWriteBytes(page.AsSpan(22), crc);
+ output.Write(page);
+ }
+
+ private static uint[] BuildCrcTable()
+ {
+ uint[] table = new uint[256];
+ for (uint i = 0; i < 256; i++)
+ {
+ uint value = i << 24;
+ for (int bit = 0; bit < 8; bit++)
+ {
+ value = (value & 0x80000000) != 0 ? (value << 1) ^ 0x04C11DB7 : value << 1;
+ }
+ table[i] = value;
+ }
+ return table;
+ }
+}
diff --git a/src/Nonemm.Recorder/OpenDevice.cs b/src/Nonemm.Recorder/OpenDevice.cs
new file mode 100644
index 0000000..a1eb87a
--- /dev/null
+++ b/src/Nonemm.Recorder/OpenDevice.cs
@@ -0,0 +1,55 @@
+namespace Nonemm.Recorder;
+
+/// A capture device in use: takes its interleaved samples apart and hands each
+/// one to the channel that records it.
+internal sealed class OpenDevice
+{
+ private readonly float[] scratch = new float[RecorderEngine.SampleRate];
+ private volatile RecorderChannel?[] byDeviceChannel;
+
+ public OpenDevice(AudioDeviceInfo info)
+ {
+ Info = info;
+ byDeviceChannel = new RecorderChannel?[Math.Max(1, info.Channels)];
+ }
+
+ public AudioDeviceInfo Info { get; }
+
+ public IDisposable Handle { get; set; } = null!; // set right after the device opens
+
+ /// Sets which channel gets which of the device's channels.
+ public void Route(IEnumerable users)
+ {
+ RecorderChannel?[] routing = new RecorderChannel?[byDeviceChannel.Length];
+ foreach (RecorderChannel user in users)
+ {
+ if (user.Source.Channel < routing.Length)
+ {
+ routing[user.Source.Channel] = user;
+ }
+ }
+ byDeviceChannel = routing;
+ }
+
+ public void OnSamples(ReadOnlySpan interleaved)
+ {
+ RecorderChannel?[] routing = byDeviceChannel;
+ int width = routing.Length;
+ int frames = Math.Min(interleaved.Length / width, scratch.Length);
+ for (int index = 0; index < width; index++)
+ {
+ if (routing[index] is not { } channel)
+ {
+ continue;
+ }
+ float level = 0;
+ for (int frame = 0; frame < frames; frame++)
+ {
+ float sample = interleaved[frame * width + index];
+ scratch[frame] = sample;
+ level = Math.Max(level, Math.Abs(sample));
+ }
+ channel.Add(scratch.AsSpan(0, frames), level);
+ }
+ }
+}
diff --git a/src/Nonemm.Recorder/OpusFileWriter.cs b/src/Nonemm.Recorder/OpusFileWriter.cs
new file mode 100644
index 0000000..1916e72
--- /dev/null
+++ b/src/Nonemm.Recorder/OpusFileWriter.cs
@@ -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 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();
+ }
+}
diff --git a/src/Nonemm.Recorder/RecorderChannel.cs b/src/Nonemm.Recorder/RecorderChannel.cs
new file mode 100644
index 0000000..9bfeeab
--- /dev/null
+++ b/src/Nonemm.Recorder/RecorderChannel.cs
@@ -0,0 +1,55 @@
+namespace Nonemm.Recorder;
+
+/// One channel in the file: where it comes from, whether that is there now,
+/// and the samples waiting to be recorded or played.
+public sealed class RecorderChannel
+{
+ /// A second of samples, which is far more than either reader lets pile up.
+ private const int RingSamples = RecorderEngine.SampleRate;
+
+ private readonly object peakGate = new();
+ private float peak;
+
+ internal RecorderChannel(ChannelSource source)
+ {
+ Source = source;
+ }
+
+ public ChannelSource Source { get; internal set; }
+
+ /// True while a device with this name and enough channels is open.
+ public bool IsAvailable { get; internal set; }
+
+ /// Why the device could not be opened, when it is listed but failed.
+ public string? Problem { get; internal set; }
+
+ public bool IsMonitored { get; set; }
+
+ internal SampleRing ForRecording { get; } = new(RingSamples);
+
+ internal SampleRing ForMonitor { get; } = new(RingSamples);
+
+ internal void Add(ReadOnlySpan samples, float level)
+ {
+ ForRecording.Write(samples);
+ if (IsMonitored)
+ {
+ ForMonitor.Write(samples);
+ }
+ lock (peakGate)
+ {
+ peak = Math.Max(peak, level);
+ }
+ }
+
+ /// The loudest sample since the last call, from 0 to 1 (more if it clips).
+ public float TakePeak()
+ {
+ lock (peakGate)
+ {
+ float taken = peak;
+ peak = 0;
+ return taken;
+ }
+ }
+}
diff --git a/src/Nonemm.Recorder/RecorderConfig.cs b/src/Nonemm.Recorder/RecorderConfig.cs
new file mode 100644
index 0000000..13ae67d
--- /dev/null
+++ b/src/Nonemm.Recorder/RecorderConfig.cs
@@ -0,0 +1,13 @@
+namespace Nonemm.Recorder;
+
+public sealed class RecorderConfig
+{
+ public const int DefaultBitrateKbps = 32;
+
+ /// Kilobits per second for each channel in the file.
+ public int BitrateKbps { get; set; } = DefaultBitrateKbps;
+
+ public string? OutputFolder { get; set; }
+
+ public List Channels { get; set; } = [];
+}
diff --git a/src/Nonemm.Recorder/RecorderConfigStore.cs b/src/Nonemm.Recorder/RecorderConfigStore.cs
new file mode 100644
index 0000000..a79b6e4
--- /dev/null
+++ b/src/Nonemm.Recorder/RecorderConfigStore.cs
@@ -0,0 +1,50 @@
+using System.Text.Json;
+
+namespace Nonemm.Recorder;
+
+/// Reads and writes the one configuration file.
+public sealed class RecorderConfigStore
+{
+ private static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
+
+ private readonly string path;
+
+ public RecorderConfigStore(string path)
+ {
+ this.path = path;
+ }
+
+ public static string DefaultPath()
+ {
+ string folder = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ return Path.Combine(folder, "nonemm-recorder", "config.json");
+ }
+
+ /// A missing file gives the default configuration. A file that cannot be
+ /// read throws, so a damaged file is not replaced without the operator
+ /// knowing.
+ public RecorderConfig Load()
+ {
+ if (!File.Exists(path))
+ {
+ return new RecorderConfig();
+ }
+ try
+ {
+ using FileStream stream = File.OpenRead(path);
+ return JsonSerializer.Deserialize(stream, Options) ?? new RecorderConfig();
+ }
+ catch (JsonException cause)
+ {
+ throw new InvalidDataException($"{path} is not a recorder configuration", cause);
+ }
+ }
+
+ public void Save(RecorderConfig config)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ string temporary = path + ".tmp";
+ File.WriteAllText(temporary, JsonSerializer.Serialize(config, Options));
+ File.Move(temporary, path, overwrite: true);
+ }
+}
diff --git a/src/Nonemm.Recorder/RecorderEngine.cs b/src/Nonemm.Recorder/RecorderEngine.cs
new file mode 100644
index 0000000..e4d1c27
--- /dev/null
+++ b/src/Nonemm.Recorder/RecorderEngine.cs
@@ -0,0 +1,318 @@
+namespace Nonemm.Recorder;
+
+/// The channels the operator picked, the devices behind them, what plays on
+/// the headphones, and the recording in progress.
+///
+/// A device is opened as soon as a channel needs it and stays open, so the
+/// meters work before anything is recorded. A device that is not listed leaves
+/// its channels unavailable: they stay in the list and record silence. A timer
+/// lists the devices again every `RefreshSeconds`, which opens a device that
+/// has come back, also in the middle of a recording.
+public sealed class RecorderEngine : IDisposable
+{
+ public const int SampleRate = 48000;
+
+ private const int RefreshSeconds = 2;
+
+ /// What is left in a monitor ring after a read is dropped beyond this, so
+ /// the headphones do not fall behind the room.
+ private const int MonitorBacklog = SampleRate / 5;
+
+ private readonly AudioBackend backend;
+ private readonly object gate = new();
+ private readonly List channels = [];
+ private readonly Dictionary<(string Name, bool IsOutput), OpenDevice> open = [];
+ private readonly Timer timer;
+ private volatile RecorderChannel[] snapshot = [];
+ private IReadOnlyList devices = [];
+ private IDisposable? playback;
+ private RecordingSession? session;
+
+ public RecorderEngine(AudioBackend backend)
+ {
+ this.backend = backend;
+ timer = new Timer(_ => Refresh(), null, Timeout.Infinite, Timeout.Infinite);
+ }
+
+ /// Raised on a timer thread after a refresh found a device added, removed
+ /// or opened.
+ public event Action? Changed;
+
+ public IReadOnlyList Channels => snapshot;
+
+ public IReadOnlyList Devices
+ {
+ get
+ {
+ lock (gate)
+ {
+ return devices;
+ }
+ }
+ }
+
+ public RecordingSession? Recording
+ {
+ get
+ {
+ lock (gate)
+ {
+ return session;
+ }
+ }
+ }
+
+ /// Lists the devices once and then every `RefreshSeconds`.
+ public void Start()
+ {
+ Refresh();
+ timer.Change(TimeSpan.FromSeconds(RefreshSeconds), TimeSpan.FromSeconds(RefreshSeconds));
+ }
+
+ public RecorderChannel AddChannel(ChannelSource source)
+ {
+ RecorderChannel channel = new(source);
+ lock (gate)
+ {
+ RequireNotRecording();
+ channels.Add(channel);
+ snapshot = [.. channels];
+ }
+ Refresh();
+ return channel;
+ }
+
+ public void RemoveChannel(RecorderChannel channel)
+ {
+ lock (gate)
+ {
+ RequireNotRecording();
+ channels.Remove(channel);
+ snapshot = [.. channels];
+ }
+ SetMonitored(channel, false);
+ Refresh();
+ }
+
+ public void SetSource(RecorderChannel channel, ChannelSource source)
+ {
+ lock (gate)
+ {
+ RequireNotRecording();
+ channel.Source = source;
+ }
+ Refresh();
+ }
+
+ /// Throws when no playback device can be opened.
+ public void SetMonitored(RecorderChannel channel, bool isMonitored)
+ {
+ lock (gate)
+ {
+ channel.IsMonitored = isMonitored;
+ if (!isMonitored)
+ {
+ channel.ForMonitor.Clear();
+ }
+ bool isNeeded = channels.Any(other => other.IsMonitored);
+ if (isNeeded && playback is null)
+ {
+ try
+ {
+ playback = backend.OpenPlayback(FillMonitor);
+ }
+ catch
+ {
+ channel.IsMonitored = false;
+ throw;
+ }
+ }
+ else if (!isNeeded && playback is not null)
+ {
+ playback.Dispose();
+ playback = null;
+ }
+ }
+ }
+
+ public void StartRecording(string path, int bitrateKbps)
+ {
+ lock (gate)
+ {
+ if (session is not null)
+ {
+ throw new InvalidOperationException("a recording is already running");
+ }
+ if (channels.Count == 0)
+ {
+ throw new InvalidOperationException("there are no channels to record");
+ }
+ session = new RecordingSession(path, channels, bitrateKbps);
+ }
+ }
+
+ /// Returns the path of the finished file, or null if nothing was recording.
+ public string? StopRecording()
+ {
+ RecordingSession? stopped;
+ lock (gate)
+ {
+ stopped = session;
+ session = null;
+ }
+ stopped?.Dispose();
+ return stopped?.Path;
+ }
+
+ /// Lists the devices and opens or closes the ones the channels need.
+ public void Refresh()
+ {
+ IReadOnlyList listed = backend.ListDevices();
+ bool hasChanged;
+ lock (gate)
+ {
+ hasChanged = !listed.SequenceEqual(devices);
+ devices = listed;
+ hasChanged |= Reconcile();
+ }
+ if (hasChanged)
+ {
+ Changed?.Invoke();
+ }
+ }
+
+ public void Dispose()
+ {
+ timer.Dispose();
+ StopRecording();
+ lock (gate)
+ {
+ playback?.Dispose();
+ playback = null;
+ foreach (OpenDevice device in open.Values)
+ {
+ device.Handle.Dispose();
+ }
+ open.Clear();
+ }
+ backend.Dispose();
+ }
+
+ // called with the gate held; returns whether a channel's state changed
+ private bool Reconcile()
+ {
+ bool hasChanged = false;
+ foreach (var group in channels.GroupBy(channel => (channel.Source.DeviceName, channel.Source.IsOutput)))
+ {
+ AudioDeviceInfo? device = devices.FirstOrDefault(listed =>
+ listed.Name == group.Key.DeviceName && listed.IsOutput == group.Key.IsOutput);
+ open.TryGetValue(group.Key, out OpenDevice? current);
+ if (device is null || (current is not null && current.Info != device))
+ {
+ if (current is not null)
+ {
+ Close(group.Key, current);
+ hasChanged = true;
+ }
+ }
+ if (device is not null && !open.ContainsKey(group.Key))
+ {
+ hasChanged |= TryOpen(group.Key, device, group);
+ }
+ if (open.TryGetValue(group.Key, out current))
+ {
+ current.Route(group);
+ }
+ foreach (RecorderChannel channel in group)
+ {
+ bool isAvailable = current is not null && channel.Source.Channel < current.Info.Channels;
+ hasChanged |= channel.IsAvailable != isAvailable;
+ channel.IsAvailable = isAvailable;
+ if (isAvailable)
+ {
+ channel.Problem = null;
+ }
+ else if (device is null)
+ {
+ channel.Problem = "not connected";
+ }
+ }
+ }
+ foreach (var key in open.Keys.Where(key => channels.All(channel =>
+ (channel.Source.DeviceName, channel.Source.IsOutput) != key)).ToList())
+ {
+ Close(key, open[key]);
+ }
+ return hasChanged;
+ }
+
+ // called with the gate held
+ private bool TryOpen((string Name, bool IsOutput) key, AudioDeviceInfo device, IEnumerable users)
+ {
+ OpenDevice opened = new(device);
+ try
+ {
+ opened.Handle = backend.OpenCapture(device, opened.OnSamples);
+ }
+ catch (Exception cause)
+ {
+ foreach (RecorderChannel channel in users)
+ {
+ channel.Problem = cause.Message;
+ }
+ return false;
+ }
+ open[key] = opened;
+ return true;
+ }
+
+ // called with the gate held
+ private void Close((string Name, bool IsOutput) key, OpenDevice device)
+ {
+ open.Remove(key);
+ device.Route([]);
+ try
+ {
+ device.Handle.Dispose();
+ }
+ catch (Exception cause) when (cause is ObjectDisposedException or InvalidOperationException)
+ {
+ // the device went away first, which is why it is being closed
+ }
+ }
+
+ private void RequireNotRecording()
+ {
+ if (session is not null)
+ {
+ throw new InvalidOperationException("the channels cannot change during a recording");
+ }
+ }
+
+ private void FillMonitor(Span mono)
+ {
+ Span chunk = stackalloc float[Math.Min(mono.Length, 4096)];
+ foreach (RecorderChannel channel in snapshot)
+ {
+ if (!channel.IsMonitored)
+ {
+ continue;
+ }
+ for (int done = 0; done < mono.Length;)
+ {
+ Span part = chunk[..Math.Min(chunk.Length, mono.Length - done)];
+ int got = channel.ForMonitor.Read(part);
+ for (int i = 0; i < got; i++)
+ {
+ mono[done + i] += part[i];
+ }
+ if (got < part.Length)
+ {
+ break;
+ }
+ done += got;
+ }
+ channel.ForMonitor.TrimTo(MonitorBacklog);
+ }
+ }
+}
diff --git a/src/Nonemm.Recorder/RecordingSession.cs b/src/Nonemm.Recorder/RecordingSession.cs
new file mode 100644
index 0000000..e38023b
--- /dev/null
+++ b/src/Nonemm.Recorder/RecordingSession.cs
@@ -0,0 +1,186 @@
+using System.Diagnostics;
+
+namespace Nonemm.Recorder;
+
+/// One recording: a file, and a thread that cuts 60 ms frames out of the
+/// channels' rings and encodes them. A channel with nothing in its ring gets
+/// silence, so the channels in the file stay in step whatever the devices do.
+///
+/// Frames are cut by the clock, not by how much a device delivered, as the
+/// devices do not share a clock. The thread stays `LatencyFrames` behind the
+/// clock so a device that delivers in bursts has its samples by the time they
+/// are cut.
+public sealed class RecordingSession : IDisposable
+{
+ private const int LatencyFrames = 2;
+
+ /// More than this behind means a device runs fast; the oldest is dropped.
+ private const int DriftFrames = 4;
+
+ private readonly RecorderChannel[] channels;
+ private readonly OpusFileWriter writer;
+ private readonly Thread thread;
+ private readonly Stopwatch segment = new();
+ private readonly object gate = new();
+ private long framesBeforeSegment;
+ private long framesWritten;
+ private bool isStopping;
+ private bool isPaused;
+ private Exception? failure;
+
+ public RecordingSession(string path, IReadOnlyList channels, int bitrateKbps)
+ {
+ this.channels = [.. channels];
+ writer = new OpusFileWriter(new FileStream(path, FileMode.Create, FileAccess.Write), this.channels.Length, bitrateKbps);
+ Path = path;
+ foreach (RecorderChannel channel in this.channels)
+ {
+ channel.ForRecording.Clear();
+ }
+ segment.Start();
+ thread = new Thread(Run) { Name = "recorder encoder", IsBackground = true };
+ thread.Start();
+ }
+
+ public string Path { get; }
+
+ public bool IsPaused
+ {
+ get
+ {
+ lock (gate)
+ {
+ return isPaused;
+ }
+ }
+ }
+
+ /// The length of the audio in the file so far.
+ public TimeSpan Elapsed
+ {
+ get
+ {
+ lock (gate)
+ {
+ return TimeSpan.FromMilliseconds(Math.Max(framesWritten, FramesDue(0)) * 60);
+ }
+ }
+ }
+
+ /// Set when the encoder thread failed, for example on a full disk.
+ public Exception? Failure
+ {
+ get
+ {
+ lock (gate)
+ {
+ return failure;
+ }
+ }
+ }
+
+ public void Pause()
+ {
+ lock (gate)
+ {
+ if (isPaused)
+ {
+ return;
+ }
+ framesBeforeSegment += segment.ElapsedMilliseconds / 60;
+ segment.Reset();
+ isPaused = true;
+ }
+ }
+
+ public void Resume()
+ {
+ lock (gate)
+ {
+ if (!isPaused)
+ {
+ return;
+ }
+ foreach (RecorderChannel channel in channels)
+ {
+ channel.ForRecording.Clear();
+ }
+ framesBeforeSegment = framesWritten;
+ segment.Restart();
+ isPaused = false;
+ }
+ }
+
+ /// Writes the frames that are left, ends the file and closes it.
+ public void Dispose()
+ {
+ lock (gate)
+ {
+ if (isStopping)
+ {
+ return;
+ }
+ isStopping = true;
+ }
+ thread.Join();
+ lock (gate)
+ {
+ while (failure is null && framesWritten < FramesDue(0))
+ {
+ WriteFrame();
+ }
+ }
+ writer.Dispose();
+ }
+
+ private long FramesDue(int latency) => framesBeforeSegment + segment.ElapsedMilliseconds / 60 - latency;
+
+ private void Run()
+ {
+ while (true)
+ {
+ lock (gate)
+ {
+ if (isStopping || failure is not null)
+ {
+ return;
+ }
+ if (!isPaused)
+ {
+ while (framesWritten < FramesDue(LatencyFrames) && failure is null)
+ {
+ WriteFrame();
+ }
+ }
+ }
+ Thread.Sleep(10);
+ }
+ }
+
+ // called with the gate held
+ private void WriteFrame()
+ {
+ float[] interleaved = new float[OpusFileWriter.FrameSamples * channels.Length];
+ float[] one = new float[OpusFileWriter.FrameSamples];
+ for (int index = 0; index < channels.Length; index++)
+ {
+ SampleRing ring = channels[index].ForRecording;
+ Array.Clear(one);
+ ring.Read(one);
+ ring.TrimTo(DriftFrames * OpusFileWriter.FrameSamples);
+ for (int sample = 0; sample < one.Length; sample++)
+ {
+ interleaved[sample * channels.Length + index] = one[sample];
+ }
+ }
+ try
+ {
+ writer.WriteFrame(interleaved);
+ framesWritten++;
+ }
+ catch (Exception cause) when (cause is IOException or InvalidOperationException)
+ {
+ failure = cause;
+ }
+ }
+}
diff --git a/src/Nonemm.Recorder/SampleRing.cs b/src/Nonemm.Recorder/SampleRing.cs
new file mode 100644
index 0000000..1c31457
--- /dev/null
+++ b/src/Nonemm.Recorder/SampleRing.cs
@@ -0,0 +1,88 @@
+namespace Nonemm.Recorder;
+
+/// A fixed-size queue of samples between the audio thread that fills it and
+/// the thread that drains it. When it is full, the oldest samples are dropped.
+public sealed class SampleRing
+{
+ private readonly float[] buffer;
+ private readonly object gate = new();
+ private int start;
+ private int count;
+
+ public SampleRing(int capacity)
+ {
+ buffer = new float[capacity];
+ }
+
+ public int Count
+ {
+ get
+ {
+ lock (gate)
+ {
+ return count;
+ }
+ }
+ }
+
+ public void Write(ReadOnlySpan samples)
+ {
+ lock (gate)
+ {
+ if (samples.Length > buffer.Length)
+ {
+ samples = samples[^buffer.Length..];
+ }
+ int overflow = count + samples.Length - buffer.Length;
+ if (overflow > 0)
+ {
+ start = (start + overflow) % buffer.Length;
+ count -= overflow;
+ }
+ int end = (start + count) % buffer.Length;
+ int first = Math.Min(samples.Length, buffer.Length - end);
+ samples[..first].CopyTo(buffer.AsSpan(end));
+ samples[first..].CopyTo(buffer);
+ count += samples.Length;
+ }
+ }
+
+ /// Fills `destination` with the oldest samples and returns how many it
+ /// got. The rest of `destination` is not touched.
+ public int Read(Span destination)
+ {
+ lock (gate)
+ {
+ int taken = Math.Min(destination.Length, count);
+ int first = Math.Min(taken, buffer.Length - start);
+ buffer.AsSpan(start, first).CopyTo(destination);
+ buffer.AsSpan(0, taken - first).CopyTo(destination[first..]);
+ start = (start + taken) % buffer.Length;
+ count -= taken;
+ return taken;
+ }
+ }
+
+ /// Drops the oldest samples until at most `keep` are left.
+ public void TrimTo(int keep)
+ {
+ lock (gate)
+ {
+ int drop = count - keep;
+ if (drop > 0)
+ {
+ start = (start + drop) % buffer.Length;
+ count = keep;
+ }
+ }
+ }
+
+ public void Clear()
+ {
+ lock (gate)
+ {
+ start = 0;
+ count = 0;
+ }
+ }
+}
diff --git a/src/Nonemm.Recorder/SoundFlowBackend.cs b/src/Nonemm.Recorder/SoundFlowBackend.cs
new file mode 100644
index 0000000..ae2c4d7
--- /dev/null
+++ b/src/Nonemm.Recorder/SoundFlowBackend.cs
@@ -0,0 +1,162 @@
+using SoundFlow.Abstracts;
+using SoundFlow.Abstracts.Devices;
+using SoundFlow.Backends.MiniAudio;
+using SoundFlow.Backends.MiniAudio.Devices;
+using SoundFlow.Backends.MiniAudio.Enums;
+using SoundFlow.Enums;
+using SoundFlow.Structs;
+
+namespace Nonemm.Recorder;
+
+/// Audio through miniaudio, by way of the SoundFlow package: WASAPI on
+/// Windows, PulseAudio (which PipeWire also serves) or ALSA on Linux.
+///
+/// An output device is recorded as its loopback. WASAPI does that on a
+/// playback device. PulseAudio lists the same thing as a capture source called
+/// "Monitor of ", which this class presents as an output named .
+public sealed class SoundFlowBackend : AudioBackend
+{
+ private const string MonitorPrefix = "Monitor of ";
+
+ private static readonly AudioFormat CaptureFormat = new()
+ {
+ Format = SampleFormat.F32,
+ Channels = 1,
+ SampleRate = RecorderEngine.SampleRate,
+ };
+
+ private readonly MiniAudioEngine engine;
+
+ public SoundFlowBackend()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ engine = Start(MiniAudioBackend.Wasapi);
+ Description = "Audio through WASAPI";
+ return;
+ }
+ AlsaQuiet.Install();
+ // PulseAudio first: PipeWire serves it. Asking for both at once lets
+ // miniaudio fall back to ALSA without a word, and ALSA on a PipeWire
+ // system lists a pile of devices with no names.
+ try
+ {
+ engine = Start(MiniAudioBackend.PulseAudio);
+ Description = "Audio through PulseAudio (or PipeWire)";
+ }
+ catch (InvalidOperationException cause)
+ {
+ engine = Start(MiniAudioBackend.Alsa);
+ Description = $"PulseAudio/PipeWire did not start ({cause.Message}). Using ALSA, which only lists the sound cards.";
+ IsFallback = true;
+ }
+ }
+
+ /// Which sound system is in use, and why when it is not the first choice.
+ public string Description { get; }
+
+ public bool IsFallback { get; }
+
+ public IReadOnlyList ListDevices()
+ {
+ engine.UpdateAudioDevicesInfo();
+ List devices = [];
+ foreach (DeviceInfo info in engine.CaptureDevices.Where(info => info.Name.Trim().Length > 0))
+ {
+ bool isMonitor = info.Name.StartsWith(MonitorPrefix, StringComparison.Ordinal);
+ string name = isMonitor ? info.Name[MonitorPrefix.Length..] : info.Name;
+ devices.Add(new AudioDeviceInfo(name, isMonitor, ChannelsOf(info)));
+ }
+ if (OperatingSystem.IsWindows())
+ {
+ foreach (DeviceInfo info in engine.PlaybackDevices.Where(info => info.Name.Trim().Length > 0))
+ {
+ devices.Add(new AudioDeviceInfo(info.Name, true, ChannelsOf(info)));
+ }
+ }
+ return devices;
+ }
+
+ public IDisposable OpenCapture(AudioDeviceInfo device, CaptureHandler handler)
+ {
+ (DeviceInfo info, bool isLoopback) = Find(device);
+ MiniAudioDeviceConfig config = new();
+ if (isLoopback)
+ {
+ MarkLoopback(config);
+ }
+ AudioFormat format = CaptureFormat with
+ {
+ Channels = device.Channels,
+ Layout = AudioFormat.GetLayoutFromChannels(device.Channels),
+ };
+ AudioCaptureDevice capture = engine.InitializeCaptureDevice(info, format, config);
+ capture.OnAudioProcessed += (samples, _) => handler(samples);
+ capture.Start();
+ return capture;
+ }
+
+ public IDisposable OpenPlayback(PlaybackHandler handler)
+ {
+ AudioFormat format = CaptureFormat with
+ {
+ Channels = 2,
+ Layout = AudioFormat.GetLayoutFromChannels(2),
+ };
+ AudioPlaybackDevice playback = engine.InitializePlaybackDevice(null, format, new MiniAudioDeviceConfig());
+ playback.MasterMixer.AddComponent(new MonitorComponent(engine, format, handler));
+ playback.Start();
+ return playback;
+ }
+
+ public void Dispose() => engine.Dispose();
+
+ private (DeviceInfo Info, bool IsLoopback) Find(AudioDeviceInfo device)
+ {
+ engine.UpdateAudioDevicesInfo();
+ if (device.IsOutput && OperatingSystem.IsWindows())
+ {
+ DeviceInfo output = engine.PlaybackDevices.First(info => info.Name == device.Name);
+ return (output, true);
+ }
+ string name = device.IsOutput ? MonitorPrefix + device.Name : device.Name;
+ return (engine.CaptureDevices.First(info => info.Name == name), false);
+ }
+
+ // SoundFlow 1.4.2 numbers its MiniAudioBackend enum one higher than the
+ // miniaudio it ships (the enum has Null = 0 in front; the library starts at
+ // Wasapi = 0). Asking for PulseAudio by name gets ALSA, and Wasapi gets
+ // DirectSound, so the value is lowered by one before it is passed on. The
+ // backend the engine reports back is shifted the same way, and is checked
+ // here so a fixed SoundFlow stops this with a message instead of opening
+ // the wrong backend.
+ private static MiniAudioEngine Start(MiniAudioBackend wanted)
+ {
+ MiniAudioEngine started = new([(MiniAudioBackend)((int)wanted - 1)]);
+ if ((int)started.ActiveBackend != (int)wanted - 1)
+ {
+ started.Dispose();
+ throw new NotSupportedException(
+ $"SoundFlow reports backend {started.ActiveBackend} when {wanted} was asked for; its backend numbering has changed");
+ }
+ return started;
+ }
+
+ // InitializeLoopbackDevice only records the default output. The setting it
+ // uses is internal to SoundFlow 1.4.2, and the device it opens is whatever
+ // device is passed in, so setting it here records any output device.
+ private static void MarkLoopback(MiniAudioDeviceConfig config)
+ {
+ System.Reflection.PropertyInfo property = typeof(DeviceSubConfig).GetProperty(
+ "IsLoopback", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic
+ | System.Reflection.BindingFlags.Public)
+ ?? throw new NotSupportedException("this SoundFlow version has no loopback setting");
+ property.SetValue(config.Capture, true);
+ }
+
+ private static int ChannelsOf(DeviceInfo info)
+ {
+ uint most = info.SupportedDataFormats?.Select(format => format.Channels).DefaultIfEmpty(0u).Max() ?? 0;
+ return most == 0 ? 2 : (int)most;
+ }
+}
diff --git a/tests/Nonemm.Recorder.Tests/ChannelSourceTests.cs b/tests/Nonemm.Recorder.Tests/ChannelSourceTests.cs
new file mode 100644
index 0000000..9a5479c
--- /dev/null
+++ b/tests/Nonemm.Recorder.Tests/ChannelSourceTests.cs
@@ -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));
+ }
+}
diff --git a/tests/Nonemm.Recorder.Tests/FakeBackend.cs b/tests/Nonemm.Recorder.Tests/FakeBackend.cs
new file mode 100644
index 0000000..493cbb7
--- /dev/null
+++ b/tests/Nonemm.Recorder.Tests/FakeBackend.cs
@@ -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 handlers = [];
+
+ public List Devices { get; } = [];
+
+ public int PlaybackOpened { get; private set; }
+
+ public IReadOnlyList 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();
+ }
+}
diff --git a/tests/Nonemm.Recorder.Tests/Nonemm.Recorder.Tests.csproj b/tests/Nonemm.Recorder.Tests/Nonemm.Recorder.Tests.csproj
new file mode 100644
index 0000000..ae5b71a
--- /dev/null
+++ b/tests/Nonemm.Recorder.Tests/Nonemm.Recorder.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Nonemm.Recorder.Tests/OggOpusReader.cs b/tests/Nonemm.Recorder.Tests/OggOpusReader.cs
new file mode 100644
index 0000000..cded6e3
--- /dev/null
+++ b/tests/Nonemm.Recorder.Tests/OggOpusReader.cs
@@ -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 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[] decoded = [.. Enumerable.Range(0, result.Channels).Select(_ => new List())];
+ 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);
+ }
+}
diff --git a/tests/Nonemm.Recorder.Tests/OpusFileWriterTests.cs b/tests/Nonemm.Recorder.Tests/OpusFileWriterTests.cs
new file mode 100644
index 0000000..be6cbba
--- /dev/null
+++ b/tests/Nonemm.Recorder.Tests/OpusFileWriterTests.cs
@@ -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 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);
+ }
+}
diff --git a/tests/Nonemm.Recorder.Tests/RecorderConfigStoreTests.cs b/tests/Nonemm.Recorder.Tests/RecorderConfigStoreTests.cs
new file mode 100644
index 0000000..f5a930c
--- /dev/null
+++ b/tests/Nonemm.Recorder.Tests/RecorderConfigStoreTests.cs
@@ -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(() => new RecorderConfigStore(path).Load());
+
+ Assert.Contains(path, error.Message);
+ }
+}
diff --git a/tests/Nonemm.Recorder.Tests/RecorderEngineTests.cs b/tests/Nonemm.Recorder.Tests/RecorderEngineTests.cs
new file mode 100644
index 0000000..19ca295
--- /dev/null
+++ b/tests/Nonemm.Recorder.Tests/RecorderEngineTests.cs
@@ -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(() => 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);
+ }
+ }
+}
diff --git a/tests/Nonemm.Recorder.Tests/SampleRingTests.cs b/tests/Nonemm.Recorder.Tests/SampleRingTests.cs
new file mode 100644
index 0000000..4c82938
--- /dev/null
+++ b/tests/Nonemm.Recorder.Tests/SampleRingTests.cs
@@ -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 first = stackalloc float[3];
+ ring.Read(first);
+ ring.Write([5, 6, 7]);
+
+ Span 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 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 all = stackalloc float[2];
+ ring.Read(all);
+
+ Assert.Equal([4f, 5f], all.ToArray());
+ }
+}