From c01032a2ccae42cc0be1afd7b0007850174c0cac Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 23 Oct 2026 23:47:45 +0000 Subject: [PATCH] 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 --- Nonemm.slnx | 3 + README.md | 5 + docs/recorder.md | 74 ++++ src/Nonemm.Recorder.App/App.axaml | 8 + src/Nonemm.Recorder.App/App.axaml.cs | 19 ++ src/Nonemm.Recorder.App/ChannelRow.cs | 119 +++++++ src/Nonemm.Recorder.App/MainWindow.axaml | 30 ++ src/Nonemm.Recorder.App/MainWindow.axaml.cs | 234 +++++++++++++ .../Nonemm.Recorder.App.csproj | 20 ++ src/Nonemm.Recorder.App/Program.cs | 16 + src/Nonemm.Recorder.App/SourceChoice.cs | 14 + src/Nonemm.Recorder/AlsaQuiet.cs | 31 ++ src/Nonemm.Recorder/AudioBackend.cs | 22 ++ src/Nonemm.Recorder/AudioDeviceInfo.cs | 10 + src/Nonemm.Recorder/ChannelSource.cs | 28 ++ src/Nonemm.Recorder/MonitorComponent.cs | 39 +++ src/Nonemm.Recorder/Nonemm.Recorder.csproj | 14 + src/Nonemm.Recorder/OggPageWriter.cs | 68 ++++ src/Nonemm.Recorder/OpenDevice.cs | 55 +++ src/Nonemm.Recorder/OpusFileWriter.cs | 107 ++++++ src/Nonemm.Recorder/RecorderChannel.cs | 55 +++ src/Nonemm.Recorder/RecorderConfig.cs | 13 + src/Nonemm.Recorder/RecorderConfigStore.cs | 50 +++ src/Nonemm.Recorder/RecorderEngine.cs | 318 ++++++++++++++++++ src/Nonemm.Recorder/RecordingSession.cs | 186 ++++++++++ src/Nonemm.Recorder/SampleRing.cs | 88 +++++ src/Nonemm.Recorder/SoundFlowBackend.cs | 162 +++++++++ .../ChannelSourceTests.cs | 24 ++ tests/Nonemm.Recorder.Tests/FakeBackend.cs | 47 +++ .../Nonemm.Recorder.Tests.csproj | 25 ++ tests/Nonemm.Recorder.Tests/OggOpusReader.cs | 92 +++++ .../OpusFileWriterTests.cs | 65 ++++ .../RecorderConfigStoreTests.cs | 51 +++ .../RecorderEngineTests.cs | 140 ++++++++ .../Nonemm.Recorder.Tests/SampleRingTests.cs | 46 +++ 35 files changed, 2278 insertions(+) create mode 100644 docs/recorder.md create mode 100644 src/Nonemm.Recorder.App/App.axaml create mode 100644 src/Nonemm.Recorder.App/App.axaml.cs create mode 100644 src/Nonemm.Recorder.App/ChannelRow.cs create mode 100644 src/Nonemm.Recorder.App/MainWindow.axaml create mode 100644 src/Nonemm.Recorder.App/MainWindow.axaml.cs create mode 100644 src/Nonemm.Recorder.App/Nonemm.Recorder.App.csproj create mode 100644 src/Nonemm.Recorder.App/Program.cs create mode 100644 src/Nonemm.Recorder.App/SourceChoice.cs create mode 100644 src/Nonemm.Recorder/AlsaQuiet.cs create mode 100644 src/Nonemm.Recorder/AudioBackend.cs create mode 100644 src/Nonemm.Recorder/AudioDeviceInfo.cs create mode 100644 src/Nonemm.Recorder/ChannelSource.cs create mode 100644 src/Nonemm.Recorder/MonitorComponent.cs create mode 100644 src/Nonemm.Recorder/Nonemm.Recorder.csproj create mode 100644 src/Nonemm.Recorder/OggPageWriter.cs create mode 100644 src/Nonemm.Recorder/OpenDevice.cs create mode 100644 src/Nonemm.Recorder/OpusFileWriter.cs create mode 100644 src/Nonemm.Recorder/RecorderChannel.cs create mode 100644 src/Nonemm.Recorder/RecorderConfig.cs create mode 100644 src/Nonemm.Recorder/RecorderConfigStore.cs create mode 100644 src/Nonemm.Recorder/RecorderEngine.cs create mode 100644 src/Nonemm.Recorder/RecordingSession.cs create mode 100644 src/Nonemm.Recorder/SampleRing.cs create mode 100644 src/Nonemm.Recorder/SoundFlowBackend.cs create mode 100644 tests/Nonemm.Recorder.Tests/ChannelSourceTests.cs create mode 100644 tests/Nonemm.Recorder.Tests/FakeBackend.cs create mode 100644 tests/Nonemm.Recorder.Tests/Nonemm.Recorder.Tests.csproj create mode 100644 tests/Nonemm.Recorder.Tests/OggOpusReader.cs create mode 100644 tests/Nonemm.Recorder.Tests/OpusFileWriterTests.cs create mode 100644 tests/Nonemm.Recorder.Tests/RecorderConfigStoreTests.cs create mode 100644 tests/Nonemm.Recorder.Tests/RecorderEngineTests.cs create mode 100644 tests/Nonemm.Recorder.Tests/SampleRingTests.cs 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 @@ + + + +