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:
co-authored by
Claude Opus 4.8
parent
de55230048
commit
efa9435da7
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user