Multi-track + shaped/raw recording (per-peer split tracks) — held for next release

Recording Settings gains two per-profile toggles (Ed's request):
 * "Split recording into separate tracks" — each recording becomes a folder with one file per
   connected peer (their received audio only) plus one for your own send.
 * "Bypass pan and EQ when recording" — record the RAW audio (before pan/EQ) instead of the
   shaped audio you hear; applies to single and split.

Engine: a per-peer record tap in SessionPlayout hands each peer's block to the recorder RAW
(before pan/EQ) or SHAPED (after) per the bypass flag; PlayoutEngine propagates the tap to all
sessions (+ inherits on reconnect) and fires OnRecordBlockComplete each render; AudioReceiver
exposes SetPeerRecordTap / OnRecordBlockComplete. AudioRecorder now accepts an explicit path and
exposes ExtensionFor. RecordingController composes one AudioRecorder per track: multi-track =
a recorder per connected peer + a "me" recorder; single-track shaped = the existing mixed tap;
single-track raw (bypass) = sum each peer's raw block per render, flushed on the block boundary.

Naming (Ed's scheme, sortable): <recordings>/<yyyy-MM-dd>/ then, single-track, "<HH-mm-ss>
RemSound recording.<ext>"; multi-track, a folder "<HH-mm-ss> RemSound recording multi track/"
containing "<machine name> <HH-mm-ss>.<ext>" per peer (name or IP) and for your own send.

Known edge (noted): a peer using sender-side BothIndependent (two lanes) records both lanes to
one file in a split recording; single-track bypass sums per render in the Mixed path. Off by
default (both toggles unticked = today's behaviour). Builds clean; pending Ed's hands-on test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-06 10:05:00 +01:00
co-authored by Claude Opus 4.8
parent 703e6a022d
commit de55230048
8 changed files with 294 additions and 29 deletions
+14
View File
@@ -198,6 +198,20 @@ public sealed class AudioReceiver : IDisposable
public void SetPeerDsp(IPAddress address, PeerDspChain? chain) =>
playoutEngine.SetPeerDsp(address, chain);
/// <summary>Sets (or clears with null) the split-recording tap on every current and future session,
/// so the recorder receives each peer's block separately. <paramref name="raw"/> = before pan/EQ
/// (bypass), else after. Called from the UI thread.</summary>
public void SetPeerRecordTap(Action<IPEndPoint, ReadOnlyMemory<float>>? tap, bool raw) =>
playoutEngine.SetRecordTap(tap, raw);
/// <summary>Fired once per rendered block, right after <see cref="OnReceivedSamples"/> — the block
/// boundary a single-file bypass recording uses to flush its per-peer sum.</summary>
public Action? OnRecordBlockComplete
{
get => playoutEngine.OnRecordBlockComplete;
set => playoutEngine.OnRecordBlockComplete = value;
}
/// <summary>
/// Optional callback invoked when the engine produces fully-processed mixed received
/// audio (volume / mute / limiter all applied). Span is 48 kHz interleaved stereo
+23
View File
@@ -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<IPAddress, PeerDspChain?> 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<IPEndPoint, ReadOnlyMemory<float>>? 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
}
}
/// <summary>Sets (or clears with null) the split-recording tap on every current and future session.
/// <paramref name="raw"/> selects the pre-pan/EQ (bypass) or post (shaped) block.</summary>
public void SetRecordTap(Action<IPEndPoint, ReadOnlyMemory<float>>? tap, bool raw)
{
lock (sessionsLock)
{
recordTap = tap;
recordTapRaw = raw;
foreach (var s in sessions.Values) s.SetRecordTap(tap, raw);
}
}
/// <summary>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.</summary>
public Action? OnRecordBlockComplete { get; set; }
public WaveFormat WaveFormat { get; } = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
/// <summary>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;
+28
View File
@@ -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<IPEndPoint, ReadOnlyMemory<float>>? 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.</summary>
public void SetDsp(PeerDspChain? value) => dsp = value;
/// <summary>Sets (or clears with null) this session's split-recording tap. <paramref name="raw"/> =
/// deliver the block before pan/EQ (bypass); otherwise after. Takes effect on the next block.</summary>
public void SetRecordTap(Action<IPEndPoint, ReadOnlyMemory<float>>? tap, bool raw)
{
recordRaw = raw;
recordTap = tap;
}
private void EmitRecordTap(Action<IPEndPoint, ReadOnlyMemory<float>> tap, Span<float> 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));
}
/// <summary>UTC time of the most recent successful audio write. Used by <see cref="AudioReceiver"/>
/// to prune long-idle sessions so the dictionary doesn't grow unboundedly.</summary>
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;
}