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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user