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 channels = []; private readonly Dictionary<(string Name, bool IsOutput), OpenDevice> open = []; private readonly Timer timer; private volatile RecorderChannel[] snapshot = []; private IReadOnlyList 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 Channels => snapshot; public IReadOnlyList 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 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 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 mono) { Span 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 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); } } }