diff --git a/src/RemSound.App/AudioRecorder.cs b/src/RemSound.App/AudioRecorder.cs index 02ebf26..63b851d 100644 --- a/src/RemSound.App/AudioRecorder.cs +++ b/src/RemSound.App/AudioRecorder.cs @@ -135,19 +135,29 @@ internal sealed class AudioRecorder : IDisposable /// Constructs the recorder, opens the output file, and starts the writer /// thread. If anything fails the constructor throws and no cleanup is needed (no /// file has been opened yet). - public AudioRecorder(RecordingSettings settings, Action? onDiagnostic, Action? onFinished) + public AudioRecorder(RecordingSettings settings, Action? onDiagnostic, Action? onFinished, string? explicitPath = null) { this.settings = settings.Clone(); this.onDiagnostic = onDiagnostic; this.onFinished = onFinished; - var folder = settings.ResolvedFolder(); - if (string.IsNullOrWhiteSpace(folder)) folder = RecordingSettings.DefaultFolder(); - Directory.CreateDirectory(folder); + if (explicitPath is not null) + { + // The RecordingController drives the folder/file naming (date folders, per-peer split + // tracks). Honour the path it hands us, creating the containing folder. + resolvedPath = explicitPath; + Directory.CreateDirectory(Path.GetDirectoryName(explicitPath) ?? RecordingSettings.DefaultFolder()); + } + else + { + var folder = settings.ResolvedFolder(); + if (string.IsNullOrWhiteSpace(folder)) folder = RecordingSettings.DefaultFolder(); + Directory.CreateDirectory(folder); - var ext = ExtensionFor(settings.FileFormat); - var stamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); - resolvedPath = Path.Combine(folder, $"RemSound-{stamp}.{ext}"); + var ext = ExtensionFor(settings.FileFormat); + var stamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); + resolvedPath = Path.Combine(folder, $"RemSound-{stamp}.{ext}"); + } // Writer creation happens on the constructor thread so any open errors are surfaced // synchronously to the caller. @@ -565,7 +575,7 @@ internal sealed class AudioRecorder : IDisposable private float[] recvDirectionScratch = new float[DrainChunkFrames * MixChannels]; private float[] monoScratch = new float[DrainChunkFrames]; - private static string ExtensionFor(RecordingFileFormat format) => format switch + public static string ExtensionFor(RecordingFileFormat format) => format switch { RecordingFileFormat.Wav => "wav", RecordingFileFormat.Mp3 => "mp3", diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 69a2c5b..96ce0aa 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -823,6 +823,11 @@ public sealed class MainForm : Form settings, msg => logFile.Event($"recorder: {msg}")); recordingController.RecordingStateChanged += UpdateStartStopRecordingMenuLabel; + // Supply the connected peers when a split recording starts, so it can make one track per peer. + recordingController.ConnectedPeersProvider = () => + selectedPeerEndpoints + .Select(kv => (kv.Value.Address, selectedPeerLabels.GetValueOrDefault(kv.Key, kv.Value.Address.ToString()))) + .ToList(); // --- Set accessibility names --- // For these four controls the keyboard shortcut is included explicitly in both the diff --git a/src/RemSound.App/RecordingController.cs b/src/RemSound.App/RecordingController.cs index 5ecaf63..ceaf23e 100644 --- a/src/RemSound.App/RecordingController.cs +++ b/src/RemSound.App/RecordingController.cs @@ -1,3 +1,4 @@ +using System.Net; using RemSound.Core; using RemSound.Receiver; using RemSound.Sender; @@ -22,6 +23,18 @@ internal sealed class RecordingController private readonly RemSoundSettingsStore settings; private readonly Action diagnostic; private AudioRecorder? active; + // Multi-track (split) state — one recorder per connected peer (keyed by address), plus one for your + // own send. Built entirely at Start and only replaced with null at Stop, so the audio-thread tap can + // read the dictionary with a plain volatile read. + private volatile Dictionary? peerRecorders; + private AudioRecorder? meRecorder; + // Single-file BYPASS state — sum each peer's RAW block per render, flush on the block boundary. + private float[] rawMixAccum = new float[4096]; + private int rawMixLen; + + /// Supplies the currently-connected peers (address + display name) at the moment a split + /// recording starts, so one track file is created per peer. Set by MainForm. + public Func>? ConnectedPeersProvider { get; set; } public RecordingController(AudioSender sender, AudioReceiver receiver, RemSoundSettingsStore settings, Action diagnostic) { @@ -31,7 +44,7 @@ internal sealed class RecordingController this.diagnostic = diagnostic; } - public bool IsRecording => active is not null; + public bool IsRecording => active is not null || meRecorder is not null || peerRecorders is not null; /// UTC clock at which the current recording started, or null when nothing is /// recording. Captured by and cleared by . Used @@ -49,15 +62,18 @@ internal sealed class RecordingController /// while recording, but the guard is here for safety). public void Start() { - if (active is not null) return; + if (IsRecording) return; var s = settings.LoadRecordingSettings(); + var now = DateTime.Now; try { - active = new AudioRecorder(s, diagnostic, OnRecorderFinished); + if (s.SplitTracks) StartMultiTrack(s, now); + else StartSingleTrack(s, now); } catch (Exception ex) { diagnostic($"recording: failed to start: {ex.GetType().Name}: {ex.Message}"); + CleanUpAfterFailedStart(); MessageBox.Show( $"Could not start recording:\n\n{ex.Message}", "RemSound — recording", @@ -65,13 +81,7 @@ internal sealed class RecordingController MessageBoxIcon.Warning); return; } - - // Wire taps. Each tap is independent — the recorder's source-mode filter decides - // whether to actually write the samples. - sender.OnSentSamples = active.WriteSent; - receiver.OnReceivedSamples = active.WriteReceived; RecordingStartedUtc = DateTime.UtcNow; - diagnostic($"recording: started → {active.FilePath} (source={s.Source}, format={s.FileFormat}, channels={s.ChannelMode})"); RecordingStateChanged?.Invoke(true); } @@ -80,24 +90,27 @@ internal sealed class RecordingController /// so the user knows where the file landed. public void Stop() { - var recorder = active; - if (recorder is null) return; + if (!IsRecording) return; - // Unhook taps FIRST so no more audio gets queued during the drain. + // Unhook every tap FIRST so no more audio is queued during the drain. sender.OnSentSamples = null; receiver.OnReceivedSamples = null; + receiver.SetPeerRecordTap(null, false); + receiver.OnRecordBlockComplete = null; + var single = active; + var me = meRecorder; + var peers = peerRecorders; active = null; + meRecorder = null; + peerRecorders = null; + rawMixLen = 0; RecordingStartedUtc = null; - try - { - recorder.Stop(); - recorder.Dispose(); - } - catch (Exception ex) - { - diagnostic($"recording: stop threw {ex.GetType().Name}: {ex.Message}"); - } + + StopRecorder(single); + StopRecorder(me); + if (peers is not null) foreach (var r in peers.Values) StopRecorder(r); + RecordingStateChanged?.Invoke(false); } @@ -106,6 +119,134 @@ internal sealed class RecordingController diagnostic($"recording: finished → {path} ({bytes:N0} bytes)"); } + private void StartSingleTrack(RecordingSettings s, DateTime now) + { + var path = SingleTrackPath(s, now); + active = new AudioRecorder(s, diagnostic, OnRecorderFinished, path); + sender.OnSentSamples = active.WriteSent; + if (s.BypassShaping) + { + // Raw single file: the mixed receive tap is POST pan/EQ, so instead sum each peer's RAW + // block per render and flush it to the one recorder on the block boundary. + rawMixLen = 0; + receiver.SetPeerRecordTap(OnRawMixTap, raw: true); + receiver.OnRecordBlockComplete = FlushRawMix; + } + else + { + receiver.OnReceivedSamples = active.WriteReceived; // the shaped mix — what you hear + } + diagnostic($"recording: started → {path} (source={s.Source}, format={s.FileFormat}, bypass={s.BypassShaping})"); + } + + private void StartMultiTrack(RecordingSettings s, DateTime now) + { + var folder = MultiTrackFolder(s, now); + Directory.CreateDirectory(folder); + var ext = AudioRecorder.ExtensionFor(s.FileFormat); + var time = now.ToString("HH-mm-ss"); + + // One track per connected peer (their received audio only), created up front on the UI thread + // so the audio-thread tap never has to open a file. Peers that join mid-recording aren't added. + var recs = new Dictionary(); + var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var byAddr = new Dictionary(); + foreach (var (addr, name) in ConnectedPeersProvider?.Invoke() ?? []) byAddr[addr.ToString()] = name; + foreach (var (addrKey, name) in byAddr) + { + var baseName = Sanitize(string.IsNullOrWhiteSpace(name) ? addrKey : name); + var fileName = $"{baseName} {time}"; + if (!usedNames.Add(fileName)) fileName = $"{baseName} ({addrKey}) {time}"; + var path = Path.Combine(folder, $"{fileName}.{ext}"); + recs[addrKey] = new AudioRecorder(WithSource(s, RecordingSource.ReceivedOnly), diagnostic, OnRecorderFinished, path); + } + peerRecorders = recs; + receiver.SetPeerRecordTap(OnPeerRecordBlock, raw: s.BypassShaping); + + // Your own track — your sent audio. + var mePath = Path.Combine(folder, $"{Sanitize(Environment.MachineName)} {time}.{ext}"); + meRecorder = new AudioRecorder(WithSource(s, RecordingSource.SentOnly), diagnostic, OnRecorderFinished, mePath); + sender.OnSentSamples = meRecorder.WriteSent; + + diagnostic($"recording: started multi-track → {folder} ({recs.Count} peer track(s), format={s.FileFormat}, bypass={s.BypassShaping})"); + } + + // Audio thread. Route each peer's block to that peer's recorder. peerRecorders is fully built at + // Start and only replaced with null at Stop, so a plain volatile read is safe. + private void OnPeerRecordBlock(IPEndPoint peer, ReadOnlyMemory block) + { + var recs = peerRecorders; + if (recs is not null && recs.TryGetValue(peer.Address.ToString(), out var rec)) + rec.WriteReceived(block, RenderRoute.Mixed); + } + + // Audio thread. Sum a peer's raw block into the per-render mix for a single-file bypass recording. + private void OnRawMixTap(IPEndPoint peer, ReadOnlyMemory block) + { + var span = block.Span; + if (rawMixAccum.Length < span.Length) rawMixAccum = new float[span.Length]; + for (int i = 0; i < span.Length; i++) rawMixAccum[i] += span[i]; + rawMixLen = span.Length; + } + + // Audio thread. Flush the summed raw mix for this render to the single recorder, then reset. + private void FlushRawMix() + { + var rec = active; + int n = rawMixLen; + if (rec is not null && n > 0) rec.WriteReceived(rawMixAccum.AsMemory(0, n), RenderRoute.Mixed); + if (n > 0) { Array.Clear(rawMixAccum, 0, n); rawMixLen = 0; } + } + + private void StopRecorder(AudioRecorder? r) + { + if (r is null) return; + try { r.Stop(); r.Dispose(); } + catch (Exception ex) { diagnostic($"recording: stop threw {ex.GetType().Name}: {ex.Message}"); } + } + + private void CleanUpAfterFailedStart() + { + sender.OnSentSamples = null; + receiver.OnReceivedSamples = null; + receiver.SetPeerRecordTap(null, false); + receiver.OnRecordBlockComplete = null; + StopRecorder(active); active = null; + StopRecorder(meRecorder); meRecorder = null; + if (peerRecorders is not null) { foreach (var r in peerRecorders.Values) StopRecorder(r); peerRecorders = null; } + } + + private static string BaseFolder(RecordingSettings s) + { + var f = s.ResolvedFolder(); + return string.IsNullOrWhiteSpace(f) ? RecordingSettings.DefaultFolder() : f; + } + + private static string DateFolder(RecordingSettings s, DateTime now) => + Path.Combine(BaseFolder(s), now.ToString("yyyy-MM-dd")); + + private static string SingleTrackPath(RecordingSettings s, DateTime now) => + Path.Combine(DateFolder(s, now), $"{now:HH-mm-ss} RemSound recording.{AudioRecorder.ExtensionFor(s.FileFormat)}"); + + private static string MultiTrackFolder(RecordingSettings s, DateTime now) => + Path.Combine(DateFolder(s, now), $"{now:HH-mm-ss} RemSound recording multi track"); + + private static RecordingSettings WithSource(RecordingSettings s, RecordingSource src) + { + var c = s.Clone(); + c.Source = src; + return c; + } + + private static string Sanitize(string name) + { + var invalid = Path.GetInvalidFileNameChars(); + var sb = new System.Text.StringBuilder(name.Length); + foreach (var ch in name) sb.Append(Array.IndexOf(invalid, ch) >= 0 ? '_' : ch); + var r = sb.ToString().Trim().TrimEnd('.').Trim(); + return string.IsNullOrEmpty(r) ? "peer" : r; + } + /// Open the currently-configured recordings folder in Windows Explorer. /// Creates the folder if it doesn't yet exist (a fresh install hasn't recorded /// anything, so the folder won't be there). Surfaces filesystem errors to the user diff --git a/src/RemSound.App/RecordingSettingsDialog.cs b/src/RemSound.App/RecordingSettingsDialog.cs index 5a570ef..f2692ec 100644 --- a/src/RemSound.App/RecordingSettingsDialog.cs +++ b/src/RemSound.App/RecordingSettingsDialog.cs @@ -125,6 +125,19 @@ internal sealed class RecordingSettingsDialog : Form private Control? compressionColumn; private TableLayoutPanel? grid; + private readonly AccessibleCheckBox splitTracksBox = new() + { + Text = "Split recording into separate &tracks — one file per peer, plus your own (Alt+T)", + AccessibleName = "Split recording into separate tracks, one file per peer plus your own send", + AutoSize = true, + }; + private readonly AccessibleCheckBox bypassShapingBox = new() + { + Text = "Bypass pan and EQ when recording — record the &raw audio (Alt+R)", + AccessibleName = "Bypass pan and EQ when recording, record the raw audio", + AutoSize = true, + }; + private readonly Button okButton = new() { Text = "&OK", @@ -153,6 +166,11 @@ internal sealed class RecordingSettingsDialog : Form public RecordingSettingsDialog(RecordingSettings current) { working = current?.Clone() ?? new RecordingSettings(); + + splitTracksBox.Checked = working.SplitTracks; + splitTracksBox.CheckedChanged += (_, _) => working.SplitTracks = splitTracksBox.Checked; + bypassShapingBox.Checked = working.BypassShaping; + bypassShapingBox.CheckedChanged += (_, _) => working.BypassShaping = bypassShapingBox.Checked; var initialSnapshot = working.Clone(); Text = "Recording settings"; @@ -267,6 +285,20 @@ internal sealed class RecordingSettingsDialog : Form Controls.Add(grid); Controls.Add(buttonRow); + // The two per-recording toggles sit across the top of the dialog. Added last so docking + // resolves them to the top strip, above the option columns. + var optionsRow = new FlowLayoutPanel + { + Dock = DockStyle.Top, + FlowDirection = FlowDirection.TopDown, + AutoSize = true, + WrapContents = false, + Padding = new Padding(12, 12, 12, 0), + }; + optionsRow.Controls.Add(splitTracksBox); + optionsRow.Controls.Add(bypassShapingBox); + Controls.Add(optionsRow); + AcceptButton = okButton; CancelButton = cancelButton; diff --git a/src/RemSound.Core/RecordingTypes.cs b/src/RemSound.Core/RecordingTypes.cs index 8ec1bfb..0d24fb3 100644 --- a/src/RemSound.Core/RecordingTypes.cs +++ b/src/RemSound.Core/RecordingTypes.cs @@ -100,6 +100,16 @@ public sealed class RecordingSettings /// machine, the recorder falls back to the default and notes it in the diagnostics. public string? Folder { get; set; } + /// When true, a recording is written as a FOLDER of separate tracks instead of one mixed + /// file: one file per connected peer (only that peer's received audio) plus one file for your own + /// sent audio. Off by default. (Ed's multi-track request.) + public bool SplitTracks { get; set; } + + /// When true, per-peer pan and EQ are BYPASSED for the recording — it captures the raw, + /// unshaped audio even though you still hear the shaped version live. When false (default), your + /// pan/EQ are baked into the recording (and on a split recording, into each peer's own track). + public bool BypassShaping { get; set; } + public RecordingSettings Clone() => new() { Source = Source, @@ -111,6 +121,8 @@ public sealed class RecordingSettings FlacBitsPerSample = FlacBitsPerSample, FlacCompressionLevel = FlacCompressionLevel, Folder = Folder, + SplitTracks = SplitTracks, + BypassShaping = BypassShaping, }; /// Default folder path used when is blank. Computed at diff --git a/src/RemSound.Receiver/AudioReceiver.cs b/src/RemSound.Receiver/AudioReceiver.cs index 6ac819b..f428d09 100644 --- a/src/RemSound.Receiver/AudioReceiver.cs +++ b/src/RemSound.Receiver/AudioReceiver.cs @@ -198,6 +198,20 @@ public sealed class AudioReceiver : IDisposable public void SetPeerDsp(IPAddress address, PeerDspChain? chain) => playoutEngine.SetPeerDsp(address, chain); + /// Sets (or clears with null) the split-recording tap on every current and future session, + /// so the recorder receives each peer's block separately. = before pan/EQ + /// (bypass), else after. Called from the UI thread. + public void SetPeerRecordTap(Action>? tap, bool raw) => + playoutEngine.SetRecordTap(tap, raw); + + /// Fired once per rendered block, right after — the block + /// boundary a single-file bypass recording uses to flush its per-peer sum. + public Action? OnRecordBlockComplete + { + get => playoutEngine.OnRecordBlockComplete; + set => playoutEngine.OnRecordBlockComplete = value; + } + /// /// Optional callback invoked when the engine produces fully-processed mixed received /// audio (volume / mute / limiter all applied). Span is 48 kHz interleaved stereo diff --git a/src/RemSound.Receiver/PlayoutEngine.cs b/src/RemSound.Receiver/PlayoutEngine.cs index c0ab1ff..e670500 100644 --- a/src/RemSound.Receiver/PlayoutEngine.cs +++ b/src/RemSound.Receiver/PlayoutEngine.cs @@ -49,6 +49,10 @@ internal sealed class PlayoutEngine : IWaveProvider // peer's shaping from frame zero — the same "applies to future sessions too" idea as the // concealment artifact. Guarded by sessionsLock. A null value means explicitly cleared. private readonly Dictionary peerDspByAddress = new(); + // Split-recording tap: one callback shared by every session (each passes its own Endpoint), plus + // whether to tap raw or shaped. Set on all current sessions and inherited by new ones, like the DSP. + private Action>? recordTap; + private bool recordTapRaw; private SessionPlayout[] sessionsSnapshot = []; // Per-route scratch. Each IWaveProvider surface (Mixed / WasapiLane / AsioLane) runs on // its own consumer thread in BothIndependent mode (WASAPI master producer + ASIO render @@ -153,6 +157,22 @@ internal sealed class PlayoutEngine : IWaveProvider } } + /// Sets (or clears with null) the split-recording tap on every current and future session. + /// selects the pre-pan/EQ (bypass) or post (shaped) block. + public void SetRecordTap(Action>? tap, bool raw) + { + lock (sessionsLock) + { + recordTap = tap; + recordTapRaw = raw; + foreach (var s in sessions.Values) s.SetRecordTap(tap, raw); + } + } + + /// Fired once per rendered block, right after the mixed received tap — the block boundary a + /// single-file "bypass" recording uses to flush its per-peer sum. + public Action? OnRecordBlockComplete { get; set; } + public WaveFormat WaveFormat { get; } = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels); /// Legacy property returning the Mixed route's target. Used by code paths that @@ -326,6 +346,7 @@ internal sealed class PlayoutEngine : IWaveProvider // Inherit this peer's pan+EQ too, so a mid-stream / reconnect session is shaped from // frame zero rather than only on the next SetPeerDsp call. if (peerDspByAddress.TryGetValue(endpoint.Address, out var chain)) sp.SetDsp(chain); + if (recordTap is not null) sp.SetRecordTap(recordTap, recordTapRaw); sessions[key] = sp; sessionsSnapshot = sessions.Values.ToArray(); } @@ -801,6 +822,7 @@ internal sealed class PlayoutEngine : IWaveProvider // BothIndependent; without the tag both ended up in one recorder ring, doubling the // file's effective sample rate). DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), route); + OnRecordBlockComplete?.Invoke(); Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float)); return count; @@ -877,6 +899,7 @@ internal sealed class PlayoutEngine : IWaveProvider // wasapi-slot ring (canonical single-lane slot in classic modes), so this fires // exactly once per real-time second. DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), RenderRoute.Mixed); + OnRecordBlockComplete?.Invoke(); Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float)); return count; diff --git a/src/RemSound.Receiver/SessionPlayout.cs b/src/RemSound.Receiver/SessionPlayout.cs index 1b42ef5..8820976 100644 --- a/src/RemSound.Receiver/SessionPlayout.cs +++ b/src/RemSound.Receiver/SessionPlayout.cs @@ -101,6 +101,13 @@ internal sealed class SessionPlayout : IDisposable // Per-peer pan + EQ, or null when this peer isn't shaped. Built on the UI thread and swapped in // atomically (volatile reference); the audio thread reads it once per block. See PeerDspChain. private volatile PeerDspChain? dsp; + // Split-recording tap: when set, each block this session produces is handed to the recorder as this + // one peer's audio — RAW (before pan/EQ) when recordRaw, else SHAPED (after). Set/cleared on the UI + // thread; read once per block on the audio thread. recordScratch is a reusable copy so the tap never + // allocates and never hands out the live output span. + private volatile Action>? recordTap; + private volatile bool recordRaw; + private float[] recordScratch = new float[2048]; // Per-session RNG for noise concealment. Seeded from process-level Shared so each session // gets a different sequence — but we don't care about reproducibility, just character. private readonly Random concealRng = new(Random.Shared.Next()); @@ -338,6 +345,22 @@ internal sealed class SessionPlayout : IDisposable /// next block. Called from the UI thread; the audio thread reads the reference lock-free. public void SetDsp(PeerDspChain? value) => dsp = value; + /// Sets (or clears with null) this session's split-recording tap. = + /// deliver the block before pan/EQ (bypass); otherwise after. Takes effect on the next block. + public void SetRecordTap(Action>? tap, bool raw) + { + recordRaw = raw; + recordTap = tap; + } + + private void EmitRecordTap(Action> tap, Span block, int frames) + { + int n = frames * 2; + if (recordScratch.Length < n) recordScratch = new float[n]; + block[..n].CopyTo(recordScratch); + tap(Endpoint, recordScratch.AsMemory(0, n)); + } + /// UTC time of the most recent successful audio write. Used by /// to prune long-idle sessions so the dictionary doesn't grow unboundedly. public DateTime LastWriteUtc { get; private set; } = DateTime.UtcNow; @@ -628,11 +651,16 @@ internal sealed class SessionPlayout : IDisposable // Read through the resampler and apply concealment on full underruns. ReadThroughResampler(output, outFrames); + // Split-recording tap, RAW variant — grab this peer's block BEFORE pan/EQ (bypass recording). + var tap = recordTap; + if (tap is not null && recordRaw) EmitRecordTap(tap, output, outFrames); // Per-peer pan + EQ: this peer's block is fully decoded and isolated here, immediately before // PlayoutEngine sums it into the mix — so shaping is per-peer and pre-mix, and being per-sample // it adds no buffering latency. Null (unshaped peer) skips the whole stage. var d = dsp; d?.Process(output, outFrames); + // Split-recording tap, SHAPED variant — after pan/EQ (the default, "record what you hear"). + if (tap is not null && !recordRaw) EmitRecordTap(tap, output, outFrames); return outFrames; }