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 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-06 10:39:09 +01:00
co-authored by Claude Opus 4.8
parent de55230048
commit efa9435da7
+43 -15
View File
@@ -23,11 +23,19 @@ internal sealed class RecordingController
private readonly RemSoundSettingsStore settings; private readonly RemSoundSettingsStore settings;
private readonly Action<string> diagnostic; private readonly Action<string> diagnostic;
private AudioRecorder? active; private AudioRecorder? active;
// Multi-track (split) state — one recorder per connected peer (keyed by address), plus one for your // Multi-track (split) state — one track per connected peer (keyed by address), plus one for your own
// own send. Built entirely at Start and only replaced with null at Stop, so the audio-thread tap can // send. Built entirely at Start and only replaced with null at Stop, so the audio-thread tap can read
// read the dictionary with a plain volatile read. // the dictionary with a plain volatile read. Each PeerTrack sums that peer's stream(s) per render and
private volatile Dictionary<string, AudioRecorder>? peerRecorders; // flushes once, so a peer sending on more than one lane is SUMMED (recorded as it sounds), not stacked.
private volatile Dictionary<string, PeerTrack>? peerTracks;
private AudioRecorder? meRecorder; 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. // Single-file BYPASS state — sum each peer's RAW block per render, flush on the block boundary.
private float[] rawMixAccum = new float[4096]; private float[] rawMixAccum = new float[4096];
private int rawMixLen; private int rawMixLen;
@@ -44,7 +52,7 @@ internal sealed class RecordingController
this.diagnostic = diagnostic; 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;
/// <summary>UTC clock at which the current recording started, or null when nothing is /// <summary>UTC clock at which the current recording started, or null when nothing is
/// recording. Captured by <see cref="Start"/> and cleared by <see cref="Stop"/>. Used /// recording. Captured by <see cref="Start"/> and cleared by <see cref="Stop"/>. Used
@@ -100,16 +108,16 @@ internal sealed class RecordingController
var single = active; var single = active;
var me = meRecorder; var me = meRecorder;
var peers = peerRecorders; var tracks = peerTracks;
active = null; active = null;
meRecorder = null; meRecorder = null;
peerRecorders = null; peerTracks = null;
rawMixLen = 0; rawMixLen = 0;
RecordingStartedUtc = null; RecordingStartedUtc = null;
StopRecorder(single); StopRecorder(single);
StopRecorder(me); 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); 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 // 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. // so the audio-thread tap never has to open a file. Peers that join mid-recording aren't added.
var recs = new Dictionary<string, AudioRecorder>(); var recs = new Dictionary<string, PeerTrack>();
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var byAddr = new Dictionary<string, string>(); var byAddr = new Dictionary<string, string>();
foreach (var (addr, name) in ConnectedPeersProvider?.Invoke() ?? []) byAddr[addr.ToString()] = name; foreach (var (addr, name) in ConnectedPeersProvider?.Invoke() ?? []) byAddr[addr.ToString()] = name;
@@ -158,10 +166,11 @@ internal sealed class RecordingController
var fileName = $"{baseName} {time}"; var fileName = $"{baseName} {time}";
if (!usedNames.Add(fileName)) fileName = $"{baseName} ({addrKey}) {time}"; if (!usedNames.Add(fileName)) fileName = $"{baseName} ({addrKey}) {time}";
var path = Path.Combine(folder, $"{fileName}.{ext}"); 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.SetPeerRecordTap(OnPeerRecordBlock, raw: s.BypassShaping);
receiver.OnRecordBlockComplete = FlushPeerTracks;
// Your own track — your sent audio. // Your own track — your sent audio.
var mePath = Path.Combine(folder, $"{Sanitize(Environment.MachineName)} {time}.{ext}"); 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. // Start and only replaced with null at Stop, so a plain volatile read is safe.
private void OnPeerRecordBlock(IPEndPoint peer, ReadOnlyMemory<float> block) private void OnPeerRecordBlock(IPEndPoint peer, ReadOnlyMemory<float> block)
{ {
var recs = peerRecorders; var tracks = peerTracks;
if (recs is not null && recs.TryGetValue(peer.Address.ToString(), out var rec)) if (tracks is null || !tracks.TryGetValue(peer.Address.ToString(), out var t)) return;
rec.WriteReceived(block, RenderRoute.Mixed); // 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. // 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; receiver.OnRecordBlockComplete = null;
StopRecorder(active); active = null; StopRecorder(active); active = null;
StopRecorder(meRecorder); meRecorder = 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) private static string BaseFolder(RecordingSettings s)