Add a sound recorder that writes several devices to one Opus file
nonemm-recorder is a separate program (src/Nonemm.Recorder.App) over a library (src/Nonemm.Recorder). Each row in its list is one channel of one device, and each row becomes one channel in an Ogg Opus file: 60 ms frames, complexity 9, 32 kbps per channel by default. - A device that is not connected keeps its row, in red, and writes silence. It is recorded from the moment it returns, also during a recording. - Output devices are recorded as loopback: WASAPI on Windows, the "Monitor of" sources on PulseAudio and PipeWire. - A headphone button plays one channel, in mono, on the default device. - The rows, bitrate and folder are saved when the window closes. SoundFlow 1.4.2 numbers its backend enum one higher than the miniaudio it ships, so asking for PulseAudio gave ALSA and Wasapi gave DirectSound. SoundFlowBackend lowers the value by one and checks which backend started. Output devices and live capture are not tested on real hardware. The tests use a fake sound system and decode the written file. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
<Project Path="src/Nonemm.Keying/Nonemm.Keying.csproj" />
|
||||
<Project Path="src/Nonemm.Digital/Nonemm.Digital.csproj" />
|
||||
<Project Path="src/Nonemm.Session/Nonemm.Session.csproj" />
|
||||
<Project Path="src/Nonemm.Recorder/Nonemm.Recorder.csproj" />
|
||||
<Project Path="src/Nonemm.Recorder.App/Nonemm.Recorder.App.csproj" />
|
||||
<Project Path="src/Nonemm.App/Nonemm.App.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tools/">
|
||||
@@ -26,6 +28,7 @@
|
||||
<Project Path="tests/Nonemm.Digital.Tests/Nonemm.Digital.Tests.csproj" />
|
||||
<Project Path="tests/Nonemm.Rig.Tests/Nonemm.Rig.Tests.csproj" />
|
||||
<Project Path="tests/Nonemm.Network.Tests/Nonemm.Network.Tests.csproj" />
|
||||
<Project Path="tests/Nonemm.Recorder.Tests/Nonemm.Recorder.Tests.csproj" />
|
||||
<Project Path="tests/Nonemm.App.Tests/Nonemm.App.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.Recorder.App.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<string> report;
|
||||
private bool isFilling;
|
||||
private double shown;
|
||||
|
||||
public ChannelRow(RecorderEngine engine, RecorderChannel channel, Action<RecorderChannel> onRemove, Action<string> 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<SourceChoice>((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<SourceChoice> 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.Recorder.App.MainWindow"
|
||||
Title="Nonemm Recorder" Width="620" Height="460" MinWidth="520" MinHeight="300">
|
||||
<DockPanel Margin="12">
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,10">
|
||||
<Button Name="RecordButton" MinWidth="90" />
|
||||
<Button Name="PauseButton" Content="Pause" MinWidth="90" IsEnabled="False" />
|
||||
<TextBlock Name="TimeText" Text="00:00:00" VerticalAlignment="Center" FontSize="18" Margin="8,0,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto" Margin="0,10,0,0" ColumnSpacing="8" RowSpacing="6">
|
||||
<Button Name="AddButton" Content="Add channel" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" HorizontalAlignment="Right">
|
||||
<TextBlock Text="Bitrate per channel" VerticalAlignment="Center" />
|
||||
<NumericUpDown Name="BitrateBox" Minimum="6" Maximum="256" Increment="2" Value="32" Width="150" FormatString="0 "kbps"" />
|
||||
</StackPanel>
|
||||
<Grid Grid.Row="1" Grid.ColumnSpan="2" ColumnDefinitions="Auto,*,Auto" ColumnSpacing="8">
|
||||
<TextBlock Text="Save in" VerticalAlignment="Center" />
|
||||
<TextBox Name="FolderBox" Grid.Column="1" />
|
||||
<Button Name="BrowseButton" Grid.Column="2" Content="Browse…" />
|
||||
</Grid>
|
||||
<TextBlock Name="StatusText" Grid.Row="2" Grid.ColumnSpan="2" TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel Name="Rows" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -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<ChannelRow> RowList => Rows.Children.OfType<ChannelRow>();
|
||||
|
||||
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<ChannelSource> 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<IStorageFolder> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>nonemm-recorder</AssemblyName>
|
||||
<RootNamespace>Nonemm.Recorder.App</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Nonemm.Recorder\Nonemm.Recorder.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<float> interleaved);
|
||||
|
||||
/// Fills `mono` with the next samples to play, at 48 kHz.
|
||||
public delegate void PlaybackHandler(Span<float> 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<AudioDeviceInfo> 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);
|
||||
}
|
||||
@@ -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<ChannelSource> Sources() =>
|
||||
Enumerable.Range(0, Math.Max(1, Channels))
|
||||
.Select(channel => new ChannelSource(Name, IsOutput, channel, Math.Max(1, Channels)));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<float> buffer, int channels)
|
||||
{
|
||||
int frames = buffer.Length / channels;
|
||||
if (mono.Length < frames)
|
||||
{
|
||||
mono = new float[frames];
|
||||
}
|
||||
Span<float> 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Concentus" Version="2.2.2" />
|
||||
<PackageReference Include="SoundFlow" Version="1.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<byte> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<RecorderChannel> 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<float> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Concentus;
|
||||
using Concentus.Enums;
|
||||
|
||||
namespace Nonemm.Recorder;
|
||||
|
||||
/// Writes an Ogg Opus file with one channel for each input. Each channel is a
|
||||
/// stream of its own in an Opus multistream (mapping family 255, so players
|
||||
/// keep the channels apart instead of mixing them as speakers). Frames are
|
||||
/// 60 ms and the encoder runs at complexity 9.
|
||||
public sealed class OpusFileWriter : IDisposable
|
||||
{
|
||||
public const int FrameSamples = RecorderEngine.SampleRate * 60 / 1000;
|
||||
|
||||
private const int Complexity = 9;
|
||||
|
||||
private readonly Stream output;
|
||||
private readonly OggPageWriter pages;
|
||||
private readonly IOpusMultiStreamEncoder encoder;
|
||||
private readonly int channels;
|
||||
private readonly byte[] packet;
|
||||
private readonly int preSkip;
|
||||
private long samplesWritten;
|
||||
private bool isCompleted;
|
||||
|
||||
/// `bitrateKbps` is for each channel.
|
||||
public OpusFileWriter(Stream output, int channels, int bitrateKbps)
|
||||
{
|
||||
this.output = output;
|
||||
this.channels = channels;
|
||||
// the managed encoder, so a recording does not depend on libopus being installed
|
||||
OpusCodecFactory.AttemptToUseNativeLibrary = false;
|
||||
byte[] mapping = new byte[channels];
|
||||
encoder = OpusCodecFactory.CreateMultiStreamEncoder(RecorderEngine.SampleRate, channels, 255,
|
||||
out _, out _, mapping, OpusApplication.OPUS_APPLICATION_AUDIO);
|
||||
encoder.Complexity = Complexity;
|
||||
encoder.Bitrate = bitrateKbps * 1000 * channels;
|
||||
encoder.ExpertFrameDuration = OpusFramesize.OPUS_FRAMESIZE_60_MS;
|
||||
preSkip = encoder.Lookahead;
|
||||
packet = new byte[1276 * channels];
|
||||
pages = new OggPageWriter(output, (uint)Random.Shared.Next());
|
||||
pages.WritePacket(Header(mapping), 0, isFirst: true, isLast: false);
|
||||
pages.WritePacket(Tags(), 0, isFirst: false, isLast: false);
|
||||
}
|
||||
|
||||
/// `interleaved` holds `FrameSamples` frames of one sample for each channel.
|
||||
public void WriteFrame(ReadOnlySpan<float> interleaved)
|
||||
{
|
||||
int length = encoder.EncodeMultistream(interleaved, FrameSamples, packet, packet.Length);
|
||||
samplesWritten += FrameSamples;
|
||||
pages.WritePacket(packet.AsSpan(0, length), samplesWritten, isFirst: false, isLast: false);
|
||||
}
|
||||
|
||||
/// Ends the file. It encodes a last frame of silence so the encoder's
|
||||
/// delay does not cut the end of the recording off, and marks the real
|
||||
/// length so the player drops the silence.
|
||||
public void Complete()
|
||||
{
|
||||
if (isCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
isCompleted = true;
|
||||
long length = samplesWritten;
|
||||
int size = encoder.EncodeMultistream(new float[FrameSamples * channels], FrameSamples, packet, packet.Length);
|
||||
pages.WritePacket(packet.AsSpan(0, size), length + preSkip, isFirst: false, isLast: true);
|
||||
output.Flush();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Complete();
|
||||
output.Dispose();
|
||||
}
|
||||
|
||||
private byte[] Header(byte[] mapping)
|
||||
{
|
||||
bool isMono = channels == 1;
|
||||
using MemoryStream stream = new();
|
||||
using BinaryWriter writer = new(stream);
|
||||
writer.Write("OpusHead"u8);
|
||||
writer.Write((byte)1);
|
||||
writer.Write((byte)channels);
|
||||
writer.Write((ushort)preSkip);
|
||||
writer.Write(RecorderEngine.SampleRate);
|
||||
writer.Write((short)0);
|
||||
writer.Write((byte)(isMono ? 0 : 255));
|
||||
if (!isMono)
|
||||
{
|
||||
writer.Write((byte)channels);
|
||||
writer.Write((byte)0);
|
||||
writer.Write(mapping);
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] Tags()
|
||||
{
|
||||
byte[] vendor = "nonemm-recorder"u8.ToArray();
|
||||
using MemoryStream stream = new();
|
||||
using BinaryWriter writer = new(stream);
|
||||
writer.Write("OpusTags"u8);
|
||||
writer.Write(vendor.Length);
|
||||
writer.Write(vendor);
|
||||
writer.Write(0);
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -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<float> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ChannelSource> Channels { get; set; } = [];
|
||||
}
|
||||
@@ -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<RecorderConfig>(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);
|
||||
}
|
||||
}
|
||||
@@ -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<RecorderChannel> channels = [];
|
||||
private readonly Dictionary<(string Name, bool IsOutput), OpenDevice> open = [];
|
||||
private readonly Timer timer;
|
||||
private volatile RecorderChannel[] snapshot = [];
|
||||
private IReadOnlyList<AudioDeviceInfo> 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<RecorderChannel> Channels => snapshot;
|
||||
|
||||
public IReadOnlyList<AudioDeviceInfo> 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<AudioDeviceInfo> 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<RecorderChannel> 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<float> mono)
|
||||
{
|
||||
Span<float> 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<float> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<RecorderChannel> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<float> 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<float> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <sink>", which this class presents as an output named <sink>.
|
||||
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<AudioDeviceInfo> ListDevices()
|
||||
{
|
||||
engine.UpdateAudioDevicesInfo();
|
||||
List<AudioDeviceInfo> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Nonemm.Recorder.Tests;
|
||||
|
||||
public class ChannelSourceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Microphone", false, 0, 1, "Microphone")]
|
||||
[InlineData("Line In", false, 0, 2, "Line In L")]
|
||||
[InlineData("Line In", false, 1, 2, "Line In R")]
|
||||
[InlineData("Interface", false, 2, 8, "Interface 3")]
|
||||
[InlineData("Speakers", true, 1, 2, "Speakers (output) R")]
|
||||
public void The_label_names_the_device_and_the_channel(string device, bool isOutput, int channel, int count, string label)
|
||||
{
|
||||
Assert.Equal(label, new ChannelSource(device, isOutput, channel, count).Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_input_and_an_output_with_the_same_name_are_different_channels()
|
||||
{
|
||||
ChannelSource input = new("USB", false, 0, 2);
|
||||
ChannelSource output = new("USB", true, 0, 2);
|
||||
|
||||
Assert.False(input.IsSameChannel(output));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Nonemm.Recorder.Tests;
|
||||
|
||||
/// A sound system with no hardware: the test lists devices and pushes samples.
|
||||
internal sealed class FakeBackend : AudioBackend
|
||||
{
|
||||
private readonly Dictionary<string, CaptureHandler> handlers = [];
|
||||
|
||||
public List<AudioDeviceInfo> Devices { get; } = [];
|
||||
|
||||
public int PlaybackOpened { get; private set; }
|
||||
|
||||
public IReadOnlyList<AudioDeviceInfo> ListDevices() => [.. Devices];
|
||||
|
||||
public IDisposable OpenCapture(AudioDeviceInfo device, CaptureHandler handler)
|
||||
{
|
||||
handlers[device.Name] = handler;
|
||||
return new Closer(() => handlers.Remove(device.Name));
|
||||
}
|
||||
|
||||
public IDisposable OpenPlayback(PlaybackHandler handler)
|
||||
{
|
||||
PlaybackOpened++;
|
||||
return new Closer(() => { });
|
||||
}
|
||||
|
||||
public bool IsOpen(string name) => handlers.ContainsKey(name);
|
||||
|
||||
/// Delivers `frames` of interleaved samples, `value` in each channel `channel`.
|
||||
public void Push(string name, int channels, int frames, int channel, float value)
|
||||
{
|
||||
float[] samples = new float[frames * channels];
|
||||
for (int frame = 0; frame < frames; frame++)
|
||||
{
|
||||
samples[frame * channels + channel] = value;
|
||||
}
|
||||
handlers[name](samples);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class Closer(Action close) : IDisposable
|
||||
{
|
||||
public void Dispose() => close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Nonemm.Recorder\Nonemm.Recorder.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,92 @@
|
||||
using Concentus;
|
||||
|
||||
namespace Nonemm.Recorder.Tests;
|
||||
|
||||
/// Reads back what OpusFileWriter wrote: checks every page checksum and
|
||||
/// decodes the audio with Concentus, so the tests see what a player would.
|
||||
internal sealed class OggOpusReader
|
||||
{
|
||||
public int Channels { get; private set; }
|
||||
public int MappingFamily { get; private set; }
|
||||
public int PreSkip { get; private set; }
|
||||
public long FinalGranule { get; private set; }
|
||||
public bool HasEndMark { get; private set; }
|
||||
|
||||
/// Decoded samples, one array for each channel, with the pre-skip removed.
|
||||
public float[][] Pcm { get; private set; } = [];
|
||||
|
||||
public static OggOpusReader Read(string path)
|
||||
{
|
||||
byte[] file = File.ReadAllBytes(path);
|
||||
OggOpusReader result = new();
|
||||
List<byte[]> packets = [];
|
||||
int position = 0;
|
||||
while (position < file.Length)
|
||||
{
|
||||
Assert.Equal("OggS", System.Text.Encoding.ASCII.GetString(file, position, 4));
|
||||
int segments = file[position + 26];
|
||||
int size = 0;
|
||||
for (int i = 0; i < segments; i++)
|
||||
{
|
||||
size += file[position + 27 + i];
|
||||
}
|
||||
int total = 27 + segments + size;
|
||||
uint stored = BitConverter.ToUInt32(file, position + 22);
|
||||
byte[] page = file[position..(position + total)];
|
||||
Array.Clear(page, 22, 4);
|
||||
Assert.Equal(Crc(page), stored);
|
||||
result.FinalGranule = BitConverter.ToInt64(file, position + 6);
|
||||
result.HasEndMark = (file[position + 5] & 0x04) != 0;
|
||||
packets.Add(file[(position + 27 + segments)..(position + total)]);
|
||||
position += total;
|
||||
}
|
||||
byte[] head = packets[0];
|
||||
result.Channels = head[9];
|
||||
result.PreSkip = BitConverter.ToUInt16(head, 10);
|
||||
result.MappingFamily = head[18];
|
||||
byte[] mapping = result.MappingFamily == 0 ? [0] : head[21..(21 + result.Channels)];
|
||||
int streams = result.MappingFamily == 0 ? 1 : head[19];
|
||||
int coupled = result.MappingFamily == 0 ? 0 : head[20];
|
||||
IOpusMultiStreamDecoder decoder = OpusCodecFactory.CreateMultiStreamDecoder(
|
||||
RecorderEngine.SampleRate, result.Channels, streams, coupled, mapping);
|
||||
List<float>[] decoded = [.. Enumerable.Range(0, result.Channels).Select(_ => new List<float>())];
|
||||
foreach (byte[] packet in packets.Skip(2))
|
||||
{
|
||||
float[] frame = new float[OpusFileWriter.FrameSamples * result.Channels];
|
||||
int got = decoder.DecodeMultistream(packet, frame, OpusFileWriter.FrameSamples, false);
|
||||
for (int sample = 0; sample < got; sample++)
|
||||
{
|
||||
for (int channel = 0; channel < result.Channels; channel++)
|
||||
{
|
||||
decoded[channel].Add(frame[sample * result.Channels + channel]);
|
||||
}
|
||||
}
|
||||
}
|
||||
result.Pcm = [.. decoded.Select(list => list.Skip(result.PreSkip).ToArray())];
|
||||
return result;
|
||||
}
|
||||
|
||||
private static uint Crc(byte[] data)
|
||||
{
|
||||
uint crc = 0;
|
||||
foreach (byte b in data)
|
||||
{
|
||||
crc ^= (uint)b << 24;
|
||||
for (int bit = 0; bit < 8; bit++)
|
||||
{
|
||||
crc = (crc & 0x80000000) != 0 ? (crc << 1) ^ 0x04C11DB7 : crc << 1;
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
public static float Rms(float[] samples, int from, int count)
|
||||
{
|
||||
double sum = 0;
|
||||
for (int i = from; i < from + count; i++)
|
||||
{
|
||||
sum += samples[i] * samples[i];
|
||||
}
|
||||
return (float)Math.Sqrt(sum / count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace Nonemm.Recorder.Tests;
|
||||
|
||||
public class OpusFileWriterTests : IDisposable
|
||||
{
|
||||
private readonly string path = Path.Combine(Path.GetTempPath(), $"recorder-{Guid.NewGuid():N}.opus");
|
||||
|
||||
public void Dispose() => File.Delete(path);
|
||||
|
||||
private void Write(int channels, int frames, Func<int, int, float> sample)
|
||||
{
|
||||
using OpusFileWriter writer = new(File.Create(path), channels, 32);
|
||||
for (int frame = 0; frame < frames; frame++)
|
||||
{
|
||||
float[] interleaved = new float[OpusFileWriter.FrameSamples * channels];
|
||||
for (int i = 0; i < OpusFileWriter.FrameSamples; i++)
|
||||
{
|
||||
for (int channel = 0; channel < channels; channel++)
|
||||
{
|
||||
interleaved[i * channels + channel] = sample(channel, frame * OpusFileWriter.FrameSamples + i);
|
||||
}
|
||||
}
|
||||
writer.WriteFrame(interleaved);
|
||||
}
|
||||
}
|
||||
|
||||
private static float Tone(int hertz, int index) =>
|
||||
0.5f * (float)Math.Sin(2 * Math.PI * hertz * index / RecorderEngine.SampleRate);
|
||||
|
||||
[Fact]
|
||||
public void Each_channel_comes_back_on_its_own_channel()
|
||||
{
|
||||
Write(3, 10, (channel, index) => channel == 1 ? 0 : Tone(channel == 0 ? 440 : 1000, index));
|
||||
|
||||
OggOpusReader file = OggOpusReader.Read(path);
|
||||
|
||||
Assert.Equal(3, file.Channels);
|
||||
Assert.Equal(255, file.MappingFamily);
|
||||
Assert.True(OggOpusReader.Rms(file.Pcm[0], 4800, 9600) > 0.25f);
|
||||
Assert.True(OggOpusReader.Rms(file.Pcm[1], 4800, 9600) < 0.01f);
|
||||
Assert.True(OggOpusReader.Rms(file.Pcm[2], 4800, 9600) > 0.25f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_end_granule_is_the_length_of_the_audio_plus_the_pre_skip()
|
||||
{
|
||||
Write(2, 10, (_, index) => Tone(440, index));
|
||||
|
||||
OggOpusReader file = OggOpusReader.Read(path);
|
||||
|
||||
Assert.True(file.HasEndMark);
|
||||
Assert.Equal(10L * OpusFileWriter.FrameSamples + file.PreSkip, file.FinalGranule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_channel_is_written_as_a_plain_mono_file()
|
||||
{
|
||||
Write(1, 5, (_, index) => Tone(440, index));
|
||||
|
||||
OggOpusReader file = OggOpusReader.Read(path);
|
||||
|
||||
Assert.Equal(1, file.Channels);
|
||||
Assert.Equal(0, file.MappingFamily);
|
||||
Assert.True(OggOpusReader.Rms(file.Pcm[0], 4800, 9600) > 0.25f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace Nonemm.Recorder.Tests;
|
||||
|
||||
public class RecorderConfigStoreTests : IDisposable
|
||||
{
|
||||
private readonly string folder = Path.Combine(Path.GetTempPath(), $"recorder-{Guid.NewGuid():N}");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(folder))
|
||||
{
|
||||
Directory.Delete(folder, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_file_gives_32_kbps_and_no_channels()
|
||||
{
|
||||
RecorderConfig config = new RecorderConfigStore(Path.Combine(folder, "none.json")).Load();
|
||||
|
||||
Assert.Equal(32, config.BitrateKbps);
|
||||
Assert.Empty(config.Channels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Channels_that_were_saved_come_back_in_order()
|
||||
{
|
||||
RecorderConfigStore store = new(Path.Combine(folder, "config.json"));
|
||||
store.Save(new RecorderConfig
|
||||
{
|
||||
BitrateKbps = 48,
|
||||
Channels = [new("Line In", false, 1, 2), new("Speakers", true, 0, 2)],
|
||||
});
|
||||
|
||||
RecorderConfig loaded = store.Load();
|
||||
|
||||
Assert.Equal(48, loaded.BitrateKbps);
|
||||
Assert.Equal(["Line In R", "Speakers (output) L"], loaded.Channels.Select(channel => channel.Label));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_file_that_is_not_json_throws_with_its_path()
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
string path = Path.Combine(folder, "config.json");
|
||||
File.WriteAllText(path, "not json");
|
||||
|
||||
InvalidDataException error = Assert.Throws<InvalidDataException>(() => new RecorderConfigStore(path).Load());
|
||||
|
||||
Assert.Contains(path, error.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
namespace Nonemm.Recorder.Tests;
|
||||
|
||||
public class RecorderEngineTests : IDisposable
|
||||
{
|
||||
private readonly string path = Path.Combine(Path.GetTempPath(), $"recorder-{Guid.NewGuid():N}.opus");
|
||||
private readonly FakeBackend backend = new();
|
||||
private readonly RecorderEngine engine;
|
||||
|
||||
public RecorderEngineTests()
|
||||
{
|
||||
engine = new RecorderEngine(backend);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
engine.Dispose();
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_channel_of_a_missing_device_stays_in_the_list_as_unavailable()
|
||||
{
|
||||
RecorderChannel channel = engine.AddChannel(new ChannelSource("Gone", false, 0, 1));
|
||||
|
||||
Assert.False(channel.IsAvailable);
|
||||
Assert.Single(engine.Channels);
|
||||
Assert.Equal("not connected", channel.Problem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_device_that_appears_is_opened_and_its_channel_becomes_available()
|
||||
{
|
||||
RecorderChannel channel = engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
|
||||
|
||||
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
|
||||
engine.Refresh();
|
||||
|
||||
Assert.True(channel.IsAvailable);
|
||||
Assert.True(backend.IsOpen("Mic"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_device_that_is_unplugged_is_closed_and_its_channel_is_kept()
|
||||
{
|
||||
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
|
||||
RecorderChannel channel = engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
|
||||
|
||||
backend.Devices.Clear();
|
||||
engine.Refresh();
|
||||
|
||||
Assert.False(channel.IsAvailable);
|
||||
Assert.False(backend.IsOpen("Mic"));
|
||||
Assert.Single(engine.Channels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_channel_past_the_end_of_the_device_is_unavailable()
|
||||
{
|
||||
backend.Devices.Add(new AudioDeviceInfo("Line In", false, 2));
|
||||
|
||||
RecorderChannel channel = engine.AddChannel(new ChannelSource("Line In", false, 5, 8));
|
||||
|
||||
Assert.False(channel.IsAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_two_sides_of_a_stereo_device_are_two_channels()
|
||||
{
|
||||
backend.Devices.Add(new AudioDeviceInfo("Line In", false, 2));
|
||||
RecorderChannel left = engine.AddChannel(new ChannelSource("Line In", false, 0, 2));
|
||||
RecorderChannel right = engine.AddChannel(new ChannelSource("Line In", false, 1, 2));
|
||||
|
||||
backend.Push("Line In", 2, 480, channel: 1, value: 0.5f);
|
||||
|
||||
Assert.Equal(0f, left.TakePeak());
|
||||
Assert.Equal(0.5f, right.TakePeak());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unavailable_channel_is_recorded_as_silence_and_picks_up_when_it_appears()
|
||||
{
|
||||
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
|
||||
engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
|
||||
engine.AddChannel(new ChannelSource("Radio", false, 0, 1));
|
||||
|
||||
engine.StartRecording(path, 32);
|
||||
Feed(seconds: 1, "Mic");
|
||||
backend.Devices.Add(new AudioDeviceInfo("Radio", false, 1));
|
||||
engine.Refresh();
|
||||
Feed(seconds: 1, "Mic", "Radio");
|
||||
engine.StopRecording();
|
||||
|
||||
OggOpusReader file = OggOpusReader.Read(path);
|
||||
Assert.Equal(2, file.Channels);
|
||||
int second = RecorderEngine.SampleRate;
|
||||
Assert.True(OggOpusReader.Rms(file.Pcm[0], second / 4, second / 2) > 0.2f);
|
||||
Assert.True(OggOpusReader.Rms(file.Pcm[1], second / 4, second / 2) < 0.01f);
|
||||
Assert.True(OggOpusReader.Rms(file.Pcm[1], second + second / 4, second / 2) > 0.2f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_channels_cannot_change_during_a_recording()
|
||||
{
|
||||
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
|
||||
engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
|
||||
engine.StartRecording(path, 32);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => engine.AddChannel(new ChannelSource("Mic", false, 0, 1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Monitoring_opens_the_playback_device_once_and_closes_it_with_the_last_channel()
|
||||
{
|
||||
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
|
||||
RecorderChannel first = engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
|
||||
RecorderChannel second = engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
|
||||
|
||||
engine.SetMonitored(first, true);
|
||||
engine.SetMonitored(second, true);
|
||||
engine.SetMonitored(first, false);
|
||||
|
||||
Assert.Equal(1, backend.PlaybackOpened);
|
||||
engine.SetMonitored(second, false);
|
||||
engine.SetMonitored(first, true);
|
||||
Assert.Equal(2, backend.PlaybackOpened);
|
||||
}
|
||||
|
||||
// a tone in real time, 10 ms at a time, as a device delivers it
|
||||
private void Feed(int seconds, params string[] names)
|
||||
{
|
||||
for (int step = 0; step < seconds * 100; step++)
|
||||
{
|
||||
foreach (string name in names)
|
||||
{
|
||||
backend.Push(name, 1, 480, channel: 0, value: step % 2 == 0 ? 0.4f : -0.4f);
|
||||
}
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace Nonemm.Recorder.Tests;
|
||||
|
||||
public class SampleRingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Samples_come_out_in_the_order_they_went_in_across_the_wrap()
|
||||
{
|
||||
SampleRing ring = new(5);
|
||||
ring.Write([1, 2, 3, 4]);
|
||||
Span<float> first = stackalloc float[3];
|
||||
ring.Read(first);
|
||||
ring.Write([5, 6, 7]);
|
||||
|
||||
Span<float> all = stackalloc float[5];
|
||||
int got = ring.Read(all);
|
||||
|
||||
Assert.Equal(4, got);
|
||||
Assert.Equal([4f, 5f, 6f, 7f], all[..4].ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_full_ring_drops_the_oldest_samples()
|
||||
{
|
||||
SampleRing ring = new(4);
|
||||
ring.Write([1, 2, 3]);
|
||||
ring.Write([4, 5, 6]);
|
||||
|
||||
Span<float> all = stackalloc float[4];
|
||||
ring.Read(all);
|
||||
|
||||
Assert.Equal([3f, 4f, 5f, 6f], all.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trim_keeps_the_newest_samples()
|
||||
{
|
||||
SampleRing ring = new(8);
|
||||
ring.Write([1, 2, 3, 4, 5]);
|
||||
ring.TrimTo(2);
|
||||
|
||||
Span<float> all = stackalloc float[2];
|
||||
ring.Read(all);
|
||||
|
||||
Assert.Equal([4f, 5f], all.ToArray());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user