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
@@ -135,19 +135,29 @@ internal sealed class AudioRecorder : IDisposable
|
|||||||
/// <summary>Constructs the recorder, opens the output file, and starts the writer
|
/// <summary>Constructs the recorder, opens the output file, and starts the writer
|
||||||
/// thread. If anything fails the constructor throws and no cleanup is needed (no
|
/// thread. If anything fails the constructor throws and no cleanup is needed (no
|
||||||
/// file has been opened yet).</summary>
|
/// file has been opened yet).</summary>
|
||||||
public AudioRecorder(RecordingSettings settings, Action<string>? onDiagnostic, Action<string, long>? onFinished)
|
public AudioRecorder(RecordingSettings settings, Action<string>? onDiagnostic, Action<string, long>? onFinished, string? explicitPath = null)
|
||||||
{
|
{
|
||||||
this.settings = settings.Clone();
|
this.settings = settings.Clone();
|
||||||
this.onDiagnostic = onDiagnostic;
|
this.onDiagnostic = onDiagnostic;
|
||||||
this.onFinished = onFinished;
|
this.onFinished = onFinished;
|
||||||
|
|
||||||
var folder = settings.ResolvedFolder();
|
if (explicitPath is not null)
|
||||||
if (string.IsNullOrWhiteSpace(folder)) folder = RecordingSettings.DefaultFolder();
|
{
|
||||||
Directory.CreateDirectory(folder);
|
// The RecordingController drives the folder/file naming (date folders, per-peer split
|
||||||
|
// tracks). Honour the path it hands us, creating the containing folder.
|
||||||
|
resolvedPath = explicitPath;
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(explicitPath) ?? RecordingSettings.DefaultFolder());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var folder = settings.ResolvedFolder();
|
||||||
|
if (string.IsNullOrWhiteSpace(folder)) folder = RecordingSettings.DefaultFolder();
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
var ext = ExtensionFor(settings.FileFormat);
|
var ext = ExtensionFor(settings.FileFormat);
|
||||||
var stamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
|
var stamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
|
||||||
resolvedPath = Path.Combine(folder, $"RemSound-{stamp}.{ext}");
|
resolvedPath = Path.Combine(folder, $"RemSound-{stamp}.{ext}");
|
||||||
|
}
|
||||||
|
|
||||||
// Writer creation happens on the constructor thread so any open errors are surfaced
|
// Writer creation happens on the constructor thread so any open errors are surfaced
|
||||||
// synchronously to the caller.
|
// synchronously to the caller.
|
||||||
@@ -565,7 +575,7 @@ internal sealed class AudioRecorder : IDisposable
|
|||||||
private float[] recvDirectionScratch = new float[DrainChunkFrames * MixChannels];
|
private float[] recvDirectionScratch = new float[DrainChunkFrames * MixChannels];
|
||||||
private float[] monoScratch = new float[DrainChunkFrames];
|
private float[] monoScratch = new float[DrainChunkFrames];
|
||||||
|
|
||||||
private static string ExtensionFor(RecordingFileFormat format) => format switch
|
public static string ExtensionFor(RecordingFileFormat format) => format switch
|
||||||
{
|
{
|
||||||
RecordingFileFormat.Wav => "wav",
|
RecordingFileFormat.Wav => "wav",
|
||||||
RecordingFileFormat.Mp3 => "mp3",
|
RecordingFileFormat.Mp3 => "mp3",
|
||||||
|
|||||||
@@ -823,6 +823,11 @@ public sealed class MainForm : Form
|
|||||||
settings,
|
settings,
|
||||||
msg => logFile.Event($"recorder: {msg}"));
|
msg => logFile.Event($"recorder: {msg}"));
|
||||||
recordingController.RecordingStateChanged += UpdateStartStopRecordingMenuLabel;
|
recordingController.RecordingStateChanged += UpdateStartStopRecordingMenuLabel;
|
||||||
|
// Supply the connected peers when a split recording starts, so it can make one track per peer.
|
||||||
|
recordingController.ConnectedPeersProvider = () =>
|
||||||
|
selectedPeerEndpoints
|
||||||
|
.Select(kv => (kv.Value.Address, selectedPeerLabels.GetValueOrDefault(kv.Key, kv.Value.Address.ToString())))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
// --- Set accessibility names ---
|
// --- Set accessibility names ---
|
||||||
// For these four controls the keyboard shortcut is included explicitly in both the
|
// For these four controls the keyboard shortcut is included explicitly in both the
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Net;
|
||||||
using RemSound.Core;
|
using RemSound.Core;
|
||||||
using RemSound.Receiver;
|
using RemSound.Receiver;
|
||||||
using RemSound.Sender;
|
using RemSound.Sender;
|
||||||
@@ -22,6 +23,18 @@ 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
|
||||||
|
// 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<string, AudioRecorder>? peerRecorders;
|
||||||
|
private AudioRecorder? meRecorder;
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
/// <summary>Supplies the currently-connected peers (address + display name) at the moment a split
|
||||||
|
/// recording starts, so one track file is created per peer. Set by MainForm.</summary>
|
||||||
|
public Func<IReadOnlyList<(IPAddress Address, string Name)>>? ConnectedPeersProvider { get; set; }
|
||||||
|
|
||||||
public RecordingController(AudioSender sender, AudioReceiver receiver, RemSoundSettingsStore settings, Action<string> diagnostic)
|
public RecordingController(AudioSender sender, AudioReceiver receiver, RemSoundSettingsStore settings, Action<string> diagnostic)
|
||||||
{
|
{
|
||||||
@@ -31,7 +44,7 @@ internal sealed class RecordingController
|
|||||||
this.diagnostic = diagnostic;
|
this.diagnostic = diagnostic;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsRecording => active is not null;
|
public bool IsRecording => active is not null || meRecorder is not null || peerRecorders 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
|
||||||
@@ -49,15 +62,18 @@ internal sealed class RecordingController
|
|||||||
/// while recording, but the guard is here for safety).</summary>
|
/// while recording, but the guard is here for safety).</summary>
|
||||||
public void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
if (active is not null) return;
|
if (IsRecording) return;
|
||||||
var s = settings.LoadRecordingSettings();
|
var s = settings.LoadRecordingSettings();
|
||||||
|
var now = DateTime.Now;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
active = new AudioRecorder(s, diagnostic, OnRecorderFinished);
|
if (s.SplitTracks) StartMultiTrack(s, now);
|
||||||
|
else StartSingleTrack(s, now);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
diagnostic($"recording: failed to start: {ex.GetType().Name}: {ex.Message}");
|
diagnostic($"recording: failed to start: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
CleanUpAfterFailedStart();
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
$"Could not start recording:\n\n{ex.Message}",
|
$"Could not start recording:\n\n{ex.Message}",
|
||||||
"RemSound — recording",
|
"RemSound — recording",
|
||||||
@@ -65,13 +81,7 @@ internal sealed class RecordingController
|
|||||||
MessageBoxIcon.Warning);
|
MessageBoxIcon.Warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire taps. Each tap is independent — the recorder's source-mode filter decides
|
|
||||||
// whether to actually write the samples.
|
|
||||||
sender.OnSentSamples = active.WriteSent;
|
|
||||||
receiver.OnReceivedSamples = active.WriteReceived;
|
|
||||||
RecordingStartedUtc = DateTime.UtcNow;
|
RecordingStartedUtc = DateTime.UtcNow;
|
||||||
diagnostic($"recording: started → {active.FilePath} (source={s.Source}, format={s.FileFormat}, channels={s.ChannelMode})");
|
|
||||||
RecordingStateChanged?.Invoke(true);
|
RecordingStateChanged?.Invoke(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,24 +90,27 @@ internal sealed class RecordingController
|
|||||||
/// so the user knows where the file landed.</summary>
|
/// so the user knows where the file landed.</summary>
|
||||||
public void Stop()
|
public void Stop()
|
||||||
{
|
{
|
||||||
var recorder = active;
|
if (!IsRecording) return;
|
||||||
if (recorder is null) return;
|
|
||||||
|
|
||||||
// Unhook taps FIRST so no more audio gets queued during the drain.
|
// Unhook every tap FIRST so no more audio is queued during the drain.
|
||||||
sender.OnSentSamples = null;
|
sender.OnSentSamples = null;
|
||||||
receiver.OnReceivedSamples = null;
|
receiver.OnReceivedSamples = null;
|
||||||
|
receiver.SetPeerRecordTap(null, false);
|
||||||
|
receiver.OnRecordBlockComplete = null;
|
||||||
|
|
||||||
|
var single = active;
|
||||||
|
var me = meRecorder;
|
||||||
|
var peers = peerRecorders;
|
||||||
active = null;
|
active = null;
|
||||||
|
meRecorder = null;
|
||||||
|
peerRecorders = null;
|
||||||
|
rawMixLen = 0;
|
||||||
RecordingStartedUtc = null;
|
RecordingStartedUtc = null;
|
||||||
try
|
|
||||||
{
|
StopRecorder(single);
|
||||||
recorder.Stop();
|
StopRecorder(me);
|
||||||
recorder.Dispose();
|
if (peers is not null) foreach (var r in peers.Values) StopRecorder(r);
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
diagnostic($"recording: stop threw {ex.GetType().Name}: {ex.Message}");
|
|
||||||
}
|
|
||||||
RecordingStateChanged?.Invoke(false);
|
RecordingStateChanged?.Invoke(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +119,134 @@ internal sealed class RecordingController
|
|||||||
diagnostic($"recording: finished → {path} ({bytes:N0} bytes)");
|
diagnostic($"recording: finished → {path} ({bytes:N0} bytes)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void StartSingleTrack(RecordingSettings s, DateTime now)
|
||||||
|
{
|
||||||
|
var path = SingleTrackPath(s, now);
|
||||||
|
active = new AudioRecorder(s, diagnostic, OnRecorderFinished, path);
|
||||||
|
sender.OnSentSamples = active.WriteSent;
|
||||||
|
if (s.BypassShaping)
|
||||||
|
{
|
||||||
|
// Raw single file: the mixed receive tap is POST pan/EQ, so instead sum each peer's RAW
|
||||||
|
// block per render and flush it to the one recorder on the block boundary.
|
||||||
|
rawMixLen = 0;
|
||||||
|
receiver.SetPeerRecordTap(OnRawMixTap, raw: true);
|
||||||
|
receiver.OnRecordBlockComplete = FlushRawMix;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
receiver.OnReceivedSamples = active.WriteReceived; // the shaped mix — what you hear
|
||||||
|
}
|
||||||
|
diagnostic($"recording: started → {path} (source={s.Source}, format={s.FileFormat}, bypass={s.BypassShaping})");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartMultiTrack(RecordingSettings s, DateTime now)
|
||||||
|
{
|
||||||
|
var folder = MultiTrackFolder(s, now);
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
var ext = AudioRecorder.ExtensionFor(s.FileFormat);
|
||||||
|
var time = now.ToString("HH-mm-ss");
|
||||||
|
|
||||||
|
// 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<string, AudioRecorder>();
|
||||||
|
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var byAddr = new Dictionary<string, string>();
|
||||||
|
foreach (var (addr, name) in ConnectedPeersProvider?.Invoke() ?? []) byAddr[addr.ToString()] = name;
|
||||||
|
foreach (var (addrKey, name) in byAddr)
|
||||||
|
{
|
||||||
|
var baseName = Sanitize(string.IsNullOrWhiteSpace(name) ? addrKey : name);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
peerRecorders = recs;
|
||||||
|
receiver.SetPeerRecordTap(OnPeerRecordBlock, raw: s.BypassShaping);
|
||||||
|
|
||||||
|
// Your own track — your sent audio.
|
||||||
|
var mePath = Path.Combine(folder, $"{Sanitize(Environment.MachineName)} {time}.{ext}");
|
||||||
|
meRecorder = new AudioRecorder(WithSource(s, RecordingSource.SentOnly), diagnostic, OnRecorderFinished, mePath);
|
||||||
|
sender.OnSentSamples = meRecorder.WriteSent;
|
||||||
|
|
||||||
|
diagnostic($"recording: started multi-track → {folder} ({recs.Count} peer track(s), format={s.FileFormat}, bypass={s.BypassShaping})");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio thread. Route each peer's block to that peer's recorder. peerRecorders is fully built at
|
||||||
|
// Start and only replaced with null at Stop, so a plain volatile read is safe.
|
||||||
|
private void OnPeerRecordBlock(IPEndPoint peer, ReadOnlyMemory<float> block)
|
||||||
|
{
|
||||||
|
var recs = peerRecorders;
|
||||||
|
if (recs is not null && recs.TryGetValue(peer.Address.ToString(), out var rec))
|
||||||
|
rec.WriteReceived(block, RenderRoute.Mixed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio thread. Sum a peer's raw block into the per-render mix for a single-file bypass recording.
|
||||||
|
private void OnRawMixTap(IPEndPoint peer, ReadOnlyMemory<float> block)
|
||||||
|
{
|
||||||
|
var span = block.Span;
|
||||||
|
if (rawMixAccum.Length < span.Length) rawMixAccum = new float[span.Length];
|
||||||
|
for (int i = 0; i < span.Length; i++) rawMixAccum[i] += span[i];
|
||||||
|
rawMixLen = span.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio thread. Flush the summed raw mix for this render to the single recorder, then reset.
|
||||||
|
private void FlushRawMix()
|
||||||
|
{
|
||||||
|
var rec = active;
|
||||||
|
int n = rawMixLen;
|
||||||
|
if (rec is not null && n > 0) rec.WriteReceived(rawMixAccum.AsMemory(0, n), RenderRoute.Mixed);
|
||||||
|
if (n > 0) { Array.Clear(rawMixAccum, 0, n); rawMixLen = 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopRecorder(AudioRecorder? r)
|
||||||
|
{
|
||||||
|
if (r is null) return;
|
||||||
|
try { r.Stop(); r.Dispose(); }
|
||||||
|
catch (Exception ex) { diagnostic($"recording: stop threw {ex.GetType().Name}: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanUpAfterFailedStart()
|
||||||
|
{
|
||||||
|
sender.OnSentSamples = null;
|
||||||
|
receiver.OnReceivedSamples = null;
|
||||||
|
receiver.SetPeerRecordTap(null, false);
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BaseFolder(RecordingSettings s)
|
||||||
|
{
|
||||||
|
var f = s.ResolvedFolder();
|
||||||
|
return string.IsNullOrWhiteSpace(f) ? RecordingSettings.DefaultFolder() : f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DateFolder(RecordingSettings s, DateTime now) =>
|
||||||
|
Path.Combine(BaseFolder(s), now.ToString("yyyy-MM-dd"));
|
||||||
|
|
||||||
|
private static string SingleTrackPath(RecordingSettings s, DateTime now) =>
|
||||||
|
Path.Combine(DateFolder(s, now), $"{now:HH-mm-ss} RemSound recording.{AudioRecorder.ExtensionFor(s.FileFormat)}");
|
||||||
|
|
||||||
|
private static string MultiTrackFolder(RecordingSettings s, DateTime now) =>
|
||||||
|
Path.Combine(DateFolder(s, now), $"{now:HH-mm-ss} RemSound recording multi track");
|
||||||
|
|
||||||
|
private static RecordingSettings WithSource(RecordingSettings s, RecordingSource src)
|
||||||
|
{
|
||||||
|
var c = s.Clone();
|
||||||
|
c.Source = src;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Sanitize(string name)
|
||||||
|
{
|
||||||
|
var invalid = Path.GetInvalidFileNameChars();
|
||||||
|
var sb = new System.Text.StringBuilder(name.Length);
|
||||||
|
foreach (var ch in name) sb.Append(Array.IndexOf(invalid, ch) >= 0 ? '_' : ch);
|
||||||
|
var r = sb.ToString().Trim().TrimEnd('.').Trim();
|
||||||
|
return string.IsNullOrEmpty(r) ? "peer" : r;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Open the currently-configured recordings folder in Windows Explorer.
|
/// <summary>Open the currently-configured recordings folder in Windows Explorer.
|
||||||
/// Creates the folder if it doesn't yet exist (a fresh install hasn't recorded
|
/// Creates the folder if it doesn't yet exist (a fresh install hasn't recorded
|
||||||
/// anything, so the folder won't be there). Surfaces filesystem errors to the user
|
/// anything, so the folder won't be there). Surfaces filesystem errors to the user
|
||||||
|
|||||||
@@ -125,6 +125,19 @@ internal sealed class RecordingSettingsDialog : Form
|
|||||||
private Control? compressionColumn;
|
private Control? compressionColumn;
|
||||||
private TableLayoutPanel? grid;
|
private TableLayoutPanel? grid;
|
||||||
|
|
||||||
|
private readonly AccessibleCheckBox splitTracksBox = new()
|
||||||
|
{
|
||||||
|
Text = "Split recording into separate &tracks — one file per peer, plus your own (Alt+T)",
|
||||||
|
AccessibleName = "Split recording into separate tracks, one file per peer plus your own send",
|
||||||
|
AutoSize = true,
|
||||||
|
};
|
||||||
|
private readonly AccessibleCheckBox bypassShapingBox = new()
|
||||||
|
{
|
||||||
|
Text = "Bypass pan and EQ when recording — record the &raw audio (Alt+R)",
|
||||||
|
AccessibleName = "Bypass pan and EQ when recording, record the raw audio",
|
||||||
|
AutoSize = true,
|
||||||
|
};
|
||||||
|
|
||||||
private readonly Button okButton = new()
|
private readonly Button okButton = new()
|
||||||
{
|
{
|
||||||
Text = "&OK",
|
Text = "&OK",
|
||||||
@@ -153,6 +166,11 @@ internal sealed class RecordingSettingsDialog : Form
|
|||||||
public RecordingSettingsDialog(RecordingSettings current)
|
public RecordingSettingsDialog(RecordingSettings current)
|
||||||
{
|
{
|
||||||
working = current?.Clone() ?? new RecordingSettings();
|
working = current?.Clone() ?? new RecordingSettings();
|
||||||
|
|
||||||
|
splitTracksBox.Checked = working.SplitTracks;
|
||||||
|
splitTracksBox.CheckedChanged += (_, _) => working.SplitTracks = splitTracksBox.Checked;
|
||||||
|
bypassShapingBox.Checked = working.BypassShaping;
|
||||||
|
bypassShapingBox.CheckedChanged += (_, _) => working.BypassShaping = bypassShapingBox.Checked;
|
||||||
var initialSnapshot = working.Clone();
|
var initialSnapshot = working.Clone();
|
||||||
|
|
||||||
Text = "Recording settings";
|
Text = "Recording settings";
|
||||||
@@ -267,6 +285,20 @@ internal sealed class RecordingSettingsDialog : Form
|
|||||||
Controls.Add(grid);
|
Controls.Add(grid);
|
||||||
Controls.Add(buttonRow);
|
Controls.Add(buttonRow);
|
||||||
|
|
||||||
|
// The two per-recording toggles sit across the top of the dialog. Added last so docking
|
||||||
|
// resolves them to the top strip, above the option columns.
|
||||||
|
var optionsRow = new FlowLayoutPanel
|
||||||
|
{
|
||||||
|
Dock = DockStyle.Top,
|
||||||
|
FlowDirection = FlowDirection.TopDown,
|
||||||
|
AutoSize = true,
|
||||||
|
WrapContents = false,
|
||||||
|
Padding = new Padding(12, 12, 12, 0),
|
||||||
|
};
|
||||||
|
optionsRow.Controls.Add(splitTracksBox);
|
||||||
|
optionsRow.Controls.Add(bypassShapingBox);
|
||||||
|
Controls.Add(optionsRow);
|
||||||
|
|
||||||
AcceptButton = okButton;
|
AcceptButton = okButton;
|
||||||
CancelButton = cancelButton;
|
CancelButton = cancelButton;
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,16 @@ public sealed class RecordingSettings
|
|||||||
/// machine, the recorder falls back to the default and notes it in the diagnostics.</summary>
|
/// machine, the recorder falls back to the default and notes it in the diagnostics.</summary>
|
||||||
public string? Folder { get; set; }
|
public string? Folder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When true, a recording is written as a FOLDER of separate tracks instead of one mixed
|
||||||
|
/// file: one file per connected peer (only that peer's received audio) plus one file for your own
|
||||||
|
/// sent audio. Off by default. (Ed's multi-track request.)</summary>
|
||||||
|
public bool SplitTracks { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When true, per-peer pan and EQ are BYPASSED for the recording — it captures the raw,
|
||||||
|
/// unshaped audio even though you still hear the shaped version live. When false (default), your
|
||||||
|
/// pan/EQ are baked into the recording (and on a split recording, into each peer's own track).</summary>
|
||||||
|
public bool BypassShaping { get; set; }
|
||||||
|
|
||||||
public RecordingSettings Clone() => new()
|
public RecordingSettings Clone() => new()
|
||||||
{
|
{
|
||||||
Source = Source,
|
Source = Source,
|
||||||
@@ -111,6 +121,8 @@ public sealed class RecordingSettings
|
|||||||
FlacBitsPerSample = FlacBitsPerSample,
|
FlacBitsPerSample = FlacBitsPerSample,
|
||||||
FlacCompressionLevel = FlacCompressionLevel,
|
FlacCompressionLevel = FlacCompressionLevel,
|
||||||
Folder = Folder,
|
Folder = Folder,
|
||||||
|
SplitTracks = SplitTracks,
|
||||||
|
BypassShaping = BypassShaping,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Default folder path used when <see cref="Folder"/> is blank. Computed at
|
/// <summary>Default folder path used when <see cref="Folder"/> is blank. Computed at
|
||||||
|
|||||||
@@ -198,6 +198,20 @@ public sealed class AudioReceiver : IDisposable
|
|||||||
public void SetPeerDsp(IPAddress address, PeerDspChain? chain) =>
|
public void SetPeerDsp(IPAddress address, PeerDspChain? chain) =>
|
||||||
playoutEngine.SetPeerDsp(address, 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>
|
/// <summary>
|
||||||
/// Optional callback invoked when the engine produces fully-processed mixed received
|
/// Optional callback invoked when the engine produces fully-processed mixed received
|
||||||
/// audio (volume / mute / limiter all applied). Span is 48 kHz interleaved stereo
|
/// 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
|
// 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.
|
// concealment artifact. Guarded by sessionsLock. A null value means explicitly cleared.
|
||||||
private readonly Dictionary<IPAddress, PeerDspChain?> peerDspByAddress = new();
|
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 = [];
|
private SessionPlayout[] sessionsSnapshot = [];
|
||||||
// Per-route scratch. Each IWaveProvider surface (Mixed / WasapiLane / AsioLane) runs on
|
// Per-route scratch. Each IWaveProvider surface (Mixed / WasapiLane / AsioLane) runs on
|
||||||
// its own consumer thread in BothIndependent mode (WASAPI master producer + ASIO render
|
// 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);
|
public WaveFormat WaveFormat { get; } = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
|
||||||
|
|
||||||
/// <summary>Legacy property returning the Mixed route's target. Used by code paths that
|
/// <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
|
// 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.
|
// frame zero rather than only on the next SetPeerDsp call.
|
||||||
if (peerDspByAddress.TryGetValue(endpoint.Address, out var chain)) sp.SetDsp(chain);
|
if (peerDspByAddress.TryGetValue(endpoint.Address, out var chain)) sp.SetDsp(chain);
|
||||||
|
if (recordTap is not null) sp.SetRecordTap(recordTap, recordTapRaw);
|
||||||
sessions[key] = sp;
|
sessions[key] = sp;
|
||||||
sessionsSnapshot = sessions.Values.ToArray();
|
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
|
// BothIndependent; without the tag both ended up in one recorder ring, doubling the
|
||||||
// file's effective sample rate).
|
// file's effective sample rate).
|
||||||
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), route);
|
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), route);
|
||||||
|
OnRecordBlockComplete?.Invoke();
|
||||||
|
|
||||||
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
||||||
return count;
|
return count;
|
||||||
@@ -877,6 +899,7 @@ internal sealed class PlayoutEngine : IWaveProvider
|
|||||||
// wasapi-slot ring (canonical single-lane slot in classic modes), so this fires
|
// wasapi-slot ring (canonical single-lane slot in classic modes), so this fires
|
||||||
// exactly once per real-time second.
|
// exactly once per real-time second.
|
||||||
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), RenderRoute.Mixed);
|
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), RenderRoute.Mixed);
|
||||||
|
OnRecordBlockComplete?.Invoke();
|
||||||
|
|
||||||
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
||||||
return count;
|
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
|
// 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.
|
// atomically (volatile reference); the audio thread reads it once per block. See PeerDspChain.
|
||||||
private volatile PeerDspChain? dsp;
|
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
|
// 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.
|
// gets a different sequence — but we don't care about reproducibility, just character.
|
||||||
private readonly Random concealRng = new(Random.Shared.Next());
|
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>
|
/// next block. Called from the UI thread; the audio thread reads the reference lock-free.</summary>
|
||||||
public void SetDsp(PeerDspChain? value) => dsp = value;
|
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"/>
|
/// <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>
|
/// to prune long-idle sessions so the dictionary doesn't grow unboundedly.</summary>
|
||||||
public DateTime LastWriteUtc { get; private set; } = DateTime.UtcNow;
|
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.
|
// Read through the resampler and apply concealment on full underruns.
|
||||||
ReadThroughResampler(output, outFrames);
|
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
|
// 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
|
// 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.
|
// it adds no buffering latency. Null (unshaped peer) skips the whole stage.
|
||||||
var d = dsp;
|
var d = dsp;
|
||||||
d?.Process(output, outFrames);
|
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;
|
return outFrames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user