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