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