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 @@ + + + +