From efa9435da752c8e2df8f8dcfef80f35bb67a4b22 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:39:09 +0100 Subject: [PATCH] Recording: sum each peer's streams per track (record how it sounds) Follow-up to the multi-track feature (Ed): the split path wrote each per-peer tap straight to that peer's file, so a peer sending on more than one lane stacked their streams sequentially instead of summing. Playback and the single-file path already sum; now the per-peer path does too. Each PeerTrack accumulates that peer's block(s) per render and flushes the sum once on the block boundary (OnRecordBlockComplete = FlushPeerTracks). "Sum to listen, sum to record." Held for next release. Co-Authored-By: Claude Opus 4.8 --- src/RemSound.App/RecordingController.cs | 58 ++++++++++++++++++------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/src/RemSound.App/RecordingController.cs b/src/RemSound.App/RecordingController.cs index ceaf23e..8d9719d 100644 --- a/src/RemSound.App/RecordingController.cs +++ b/src/RemSound.App/RecordingController.cs @@ -23,11 +23,19 @@ 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; + // Multi-track (split) state — one track 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. Each PeerTrack sums that peer's stream(s) per render and + // flushes once, so a peer sending on more than one lane is SUMMED (recorded as it sounds), not stacked. + private volatile Dictionary? peerTracks; private AudioRecorder? meRecorder; + + private sealed class PeerTrack(AudioRecorder recorder) + { + public AudioRecorder Recorder { get; } = recorder; + public float[] Accum = new float[4096]; + public int Len; + } // 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; @@ -44,7 +52,7 @@ internal sealed class RecordingController this.diagnostic = diagnostic; } - public bool IsRecording => active is not null || meRecorder is not null || peerRecorders is not null; + public bool IsRecording => active is not null || meRecorder is not null || peerTracks is not null; /// UTC clock at which the current recording started, or null when nothing is /// recording. Captured by and cleared by . Used @@ -100,16 +108,16 @@ internal sealed class RecordingController var single = active; var me = meRecorder; - var peers = peerRecorders; + var tracks = peerTracks; active = null; meRecorder = null; - peerRecorders = null; + peerTracks = null; rawMixLen = 0; RecordingStartedUtc = null; StopRecorder(single); StopRecorder(me); - if (peers is not null) foreach (var r in peers.Values) StopRecorder(r); + if (tracks is not null) foreach (var t in tracks.Values) StopRecorder(t.Recorder); RecordingStateChanged?.Invoke(false); } @@ -148,7 +156,7 @@ internal sealed class RecordingController // 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 recs = new Dictionary(); var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); var byAddr = new Dictionary(); foreach (var (addr, name) in ConnectedPeersProvider?.Invoke() ?? []) byAddr[addr.ToString()] = name; @@ -158,10 +166,11 @@ internal sealed class RecordingController 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); + recs[addrKey] = new PeerTrack(new AudioRecorder(WithSource(s, RecordingSource.ReceivedOnly), diagnostic, OnRecorderFinished, path)); } - peerRecorders = recs; + peerTracks = recs; receiver.SetPeerRecordTap(OnPeerRecordBlock, raw: s.BypassShaping); + receiver.OnRecordBlockComplete = FlushPeerTracks; // Your own track — your sent audio. var mePath = Path.Combine(folder, $"{Sanitize(Environment.MachineName)} {time}.{ext}"); @@ -175,9 +184,28 @@ internal sealed class RecordingController // 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); + var tracks = peerTracks; + if (tracks is null || !tracks.TryGetValue(peer.Address.ToString(), out var t)) return; + // SUM this peer's block into their per-render buffer, so a peer sending on more than one lane is + // combined (recorded as it sounds) rather than the lanes stacking one after another. + var span = block.Span; + if (t.Accum.Length < span.Length) t.Accum = new float[span.Length]; + for (int i = 0; i < span.Length; i++) t.Accum[i] += span[i]; + t.Len = span.Length; + } + + // Audio thread. Once per render, flush each peer's summed block to their file and reset. + private void FlushPeerTracks() + { + var tracks = peerTracks; + if (tracks is null) return; + foreach (var t in tracks.Values) + { + if (t.Len <= 0) continue; + t.Recorder.WriteReceived(t.Accum.AsMemory(0, t.Len), RenderRoute.Mixed); + Array.Clear(t.Accum, 0, t.Len); + t.Len = 0; + } } // Audio thread. Sum a peer's raw block into the per-render mix for a single-file bypass recording. @@ -213,7 +241,7 @@ internal sealed class RecordingController 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; } + if (peerTracks is not null) { foreach (var t in peerTracks.Values) StopRecorder(t.Recorder); peerTracks = null; } } private static string BaseFolder(RecordingSettings s)