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