Bump to v1.2.0: recording, sound cues, drift compensation, diagnostics

New user-facing features:

* Recording. Dedicated Record menu (Alt+O — moved from Alt+R to
  avoid clashing with the Receive audio checkbox), Start/Stop on
  Ctrl+R, settings dialog, per-profile source / format / bit-depth /
  channel-mode / folder. Three source modes (received only, sent
  only, both). Files are crash-resilient — a process crash
  mid-recording leaves a playable file containing everything up to
  the last header refresh (~5 seconds).

* Four output formats, all functional:
    - WAV: 16/24-bit PCM or 32-bit float, custom writer with
      periodic RIFF re-patching.
    - MP3: LAME 128–320 kbps CBR (via NAudio.Lame).
    - OGG-Opus: 96–256 kbps VBR (via Concentus.Oggfile, reusing the
      Concentus encoder from the wire path).
    - FLAC: 16/24-bit lossless (via CUETools.Codecs.FLAKE — pure
      managed, no native DLL).

* Recording start/stop sound cues. record start.wav and
  record stop.wav play around the recording transition. Played via
  System.Media.SoundPlayer to the default Windows output, separate
  from the recording pipeline so a normal recording does not contain
  the cue.

* Per-cue Preferences. The old single "Mute connect/disconnect
  sounds" checkbox is replaced by a CheckedListBox: Connect /
  Disconnect / Recording start / Recording stop. Old profiles with
  the legacy MuteConnectionCues=true are honoured on first load via
  a migration path in the new Load* helpers.

* Receiver-side drift compensation switched from discrete
  single-frame splices to a continuous WdlResampler at a smoothed
  rate ratio. SessionPlayout.cs rewrite.

Diagnostics (only active with Enable logs ticked):

* Per-stage discontinuity probes — sender raw capture (per backend,
  PushModeWasapi + Asio both wired), sender pre-encode (now per
  lane in BothIndependent, fixing a cross-stream artefact), receiver
  post-decode, post-ring, post-resampler.

* Wire-level packet sequence tracking on each PCM stream — in-order
  / missed / reordered / duplicated counts in the diag log.

* Clipped-sample delta in the diag log.

* New AudioStepProbe in RemSound.Core with per-channel scan helper.

UI changes:

* Record menu uses Alt+O (Rec&ord). Inside the menu, item mnemonics
  unchanged (S / T / O / C).

* Auto-tune interval combo label is mode-aware: "Auto-tune latency
  interval" in classic modes, "Auto-tune interval — WASAPI and ASIO"
  in BothIndependent. The combo's Enabled state now follows EITHER
  lane's auto-tune checkbox (was only the WASAPI one — bug).

Wire format and audio pipeline unchanged from v1.1 — v1.1 and v1.2
peers interoperate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-05-15 12:40:01 +01:00
co-authored by Claude Opus 4.7
parent a0fe8070ed
commit c59e413c1f
27 changed files with 3348 additions and 350 deletions
+432
View File
@@ -0,0 +1,432 @@
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Recording settings dialog. Three listboxes laid out left-to-right:
/// * Recording &source (Alt+S) — what audio gets captured
/// * File &format (Alt+F) — WAV / MP3 / Ogg / FLAC
/// * Audio &attributes (Alt+A) — bit depth or bitrate, plus channel mode
///
/// The attributes list repopulates whenever the file-format selection changes, so the user
/// always sees only the choices that make sense for the format. Selecting a WAV-only
/// attribute then switching the format to MP3 doesn't carry forward — the format-attributes
/// list resets to a sensible default for the new format.
///
/// Settings are written back to the profile only when the user presses OK. Cancel / Esc
/// discards. The dialog also exposes <see cref="ChangedAnything"/> so the caller can
/// MarkProfileDirty after a successful OK.
///
/// Reachable from the Record menu → "Recording settings...".
/// </summary>
internal sealed class RecordingSettingsDialog : Form
{
private readonly RecordingSettings working; // mutated as the user interacts
private readonly Label sourceLabel = new()
{
Text = "Recording &source (Alt+S):",
AutoSize = true,
Padding = new Padding(0, 0, 0, 4),
};
private readonly ListBox sourceList = new()
{
AccessibleName = "Recording source",
SelectionMode = SelectionMode.One,
IntegralHeight = false,
Height = 120,
};
private readonly Label formatLabel = new()
{
Text = "File &format (Alt+F):",
AutoSize = true,
Padding = new Padding(0, 0, 0, 4),
};
private readonly ListBox formatList = new()
{
AccessibleName = "File format",
SelectionMode = SelectionMode.One,
IntegralHeight = false,
Height = 120,
};
private readonly Label attributesLabel = new()
{
Text = "Audio format &attributes (Alt+A):",
AutoSize = true,
Padding = new Padding(0, 0, 0, 4),
};
private readonly ListBox attributesList = new()
{
AccessibleName = "Audio format attributes",
SelectionMode = SelectionMode.One,
IntegralHeight = false,
Height = 200,
};
private readonly Button okButton = new()
{
Text = "&OK",
AutoSize = true,
DialogResult = DialogResult.OK,
};
private readonly Button cancelButton = new()
{
Text = "&Cancel",
AutoSize = true,
DialogResult = DialogResult.Cancel,
};
/// <summary>True if the user pressed OK and any setting actually changed. The caller
/// uses this to mark the profile dirty.</summary>
public bool ChangedAnything { get; private set; }
/// <summary>The final settings (after OK). Equals the input settings if Cancel was
/// pressed — caller should ignore this on a non-OK DialogResult.</summary>
public RecordingSettings Result => working;
public RecordingSettingsDialog(RecordingSettings current)
{
working = current?.Clone() ?? new RecordingSettings();
var initialSnapshot = working.Clone();
Text = "Recording settings";
FormBorderStyle = FormBorderStyle.FixedDialog;
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
StartPosition = FormStartPosition.CenterParent;
KeyPreview = true;
ClientSize = new Size(700, 360);
PopulateSourceList();
PopulateFormatList();
PopulateAttributesList();
SelectFromSource(working.Source);
SelectFromFormat(working.FileFormat);
SelectFromAttributes(working);
sourceList.SelectedIndexChanged += (_, _) =>
{
if (sourceList.SelectedIndex < 0) return;
working.Source = (RecordingSource)sourceList.SelectedIndex;
};
formatList.SelectedIndexChanged += (_, _) =>
{
if (formatList.SelectedIndex < 0) return;
var newFormat = (RecordingFileFormat)formatList.SelectedIndex;
if (newFormat == working.FileFormat) return;
working.FileFormat = newFormat;
PopulateAttributesList();
SelectFromAttributes(working);
};
attributesList.SelectedIndexChanged += (_, _) =>
{
if (attributesList.SelectedIndex < 0) return;
ApplyAttributesSelection();
};
okButton.Click += (_, _) =>
{
ChangedAnything = !SettingsEqual(initialSnapshot, working);
};
// Three columns side by side, OK/Cancel row beneath.
var grid = new TableLayoutPanel
{
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 3,
RowCount = 2,
};
for (var i = 0; i < 3; i++) grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.3f));
grid.RowStyles.Add(new RowStyle(SizeType.AutoSize));
grid.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
var sourceColumn = MakeColumn(sourceLabel, sourceList);
var formatColumn = MakeColumn(formatLabel, formatList);
var attributesColumn = MakeColumn(attributesLabel, attributesList);
grid.Controls.Add(sourceColumn, 0, 0);
grid.SetRowSpan(sourceColumn, 2);
grid.Controls.Add(formatColumn, 1, 0);
grid.SetRowSpan(formatColumn, 2);
grid.Controls.Add(attributesColumn, 2, 0);
grid.SetRowSpan(attributesColumn, 2);
var buttonRow = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
FlowDirection = FlowDirection.RightToLeft,
AutoSize = true,
Padding = new Padding(0, 0, 12, 12),
};
buttonRow.Controls.Add(cancelButton);
buttonRow.Controls.Add(okButton);
// Order: dialog body first (grid), then buttons docked beneath.
Controls.Add(grid);
Controls.Add(buttonRow);
AcceptButton = okButton;
CancelButton = cancelButton;
// Tab order top-to-bottom of the visible flow: source, format, attributes, OK, Cancel.
sourceList.TabIndex = 0;
formatList.TabIndex = 1;
attributesList.TabIndex = 2;
okButton.TabIndex = 3;
cancelButton.TabIndex = 4;
}
private static Control MakeColumn(Label label, ListBox list)
{
var panel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
};
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
list.Dock = DockStyle.Fill;
panel.Controls.Add(label, 0, 0);
panel.Controls.Add(list, 0, 1);
return panel;
}
private void PopulateSourceList()
{
sourceList.BeginUpdate();
sourceList.Items.Clear();
// Order MUST match RecordingSource enum values 0/1/2.
sourceList.Items.Add("Record all received audio");
sourceList.Items.Add("Record all sent audio");
sourceList.Items.Add("Record both sent and received audio");
sourceList.EndUpdate();
}
private void PopulateFormatList()
{
formatList.BeginUpdate();
formatList.Items.Clear();
// Order MUST match RecordingFileFormat enum values 0..3.
formatList.Items.Add("WAV (uncompressed)");
formatList.Items.Add("MP3");
formatList.Items.Add("Ogg-Opus");
formatList.Items.Add("FLAC (lossless)");
formatList.EndUpdate();
}
// === Per-format attribute tables ===
// All four formats currently record at the engine's 48 kHz mix rate, so labels include
// "48 kHz" to make the sample rate explicit (it's not a choice — it's a statement of fact
// about what gets written, which removes a common surprise for users who expected to see
// a rate picker). Channel mode is part of every row because it determines file shape
// alongside the format-specific quality knob.
private static readonly (int Bits, RecordingChannelMode Mode, string Label)[] WavAttributes =
{
(16, RecordingChannelMode.Stereo, "16-bit PCM, 48 kHz, stereo"),
(16, RecordingChannelMode.Mono, "16-bit PCM, 48 kHz, mono"),
(24, RecordingChannelMode.Stereo, "24-bit PCM, 48 kHz, stereo"),
(24, RecordingChannelMode.Mono, "24-bit PCM, 48 kHz, mono"),
(32, RecordingChannelMode.Stereo, "32-bit float, 48 kHz, stereo"),
(32, RecordingChannelMode.Mono, "32-bit float, 48 kHz, mono"),
};
private static readonly (int Kbps, RecordingChannelMode Mode, string Label)[] Mp3Attributes =
{
(128, RecordingChannelMode.Stereo, "128 kbps, 48 kHz, stereo"),
(128, RecordingChannelMode.Mono, "128 kbps, 48 kHz, mono"),
(192, RecordingChannelMode.Stereo, "192 kbps, 48 kHz, stereo"),
(192, RecordingChannelMode.Mono, "192 kbps, 48 kHz, mono"),
(256, RecordingChannelMode.Stereo, "256 kbps, 48 kHz, stereo"),
(256, RecordingChannelMode.Mono, "256 kbps, 48 kHz, mono"),
(320, RecordingChannelMode.Stereo, "320 kbps, 48 kHz, stereo"),
(320, RecordingChannelMode.Mono, "320 kbps, 48 kHz, mono"),
};
// OGG-Opus is VBR — kbps numbers are the encoder's target average. Opus' music-quality
// sweet spot starts around 96 kbps; we expose 96 / 128 / 192 / 256 so users have a
// smaller-file option without it sounding obviously lossy on dense material.
private static readonly (int Kbps, RecordingChannelMode Mode, string Label)[] OggOpusAttributes =
{
(96, RecordingChannelMode.Stereo, "96 kbps, 48 kHz, stereo"),
(96, RecordingChannelMode.Mono, "96 kbps, 48 kHz, mono"),
(128, RecordingChannelMode.Stereo, "128 kbps, 48 kHz, stereo"),
(128, RecordingChannelMode.Mono, "128 kbps, 48 kHz, mono"),
(192, RecordingChannelMode.Stereo, "192 kbps, 48 kHz, stereo"),
(192, RecordingChannelMode.Mono, "192 kbps, 48 kHz, mono"),
(256, RecordingChannelMode.Stereo, "256 kbps, 48 kHz, stereo"),
(256, RecordingChannelMode.Mono, "256 kbps, 48 kHz, mono"),
};
// FLAC is lossless — quality knob is just bit depth (and silently, compression level,
// which we hard-fix at the reference encoder's default 5). 32-bit float isn't a FLAC
// option (FLAC stores integer PCM), so it's deliberately absent.
private static readonly (int Bits, RecordingChannelMode Mode, string Label)[] FlacAttributes =
{
(16, RecordingChannelMode.Stereo, "16-bit, 48 kHz, stereo"),
(16, RecordingChannelMode.Mono, "16-bit, 48 kHz, mono"),
(24, RecordingChannelMode.Stereo, "24-bit, 48 kHz, stereo"),
(24, RecordingChannelMode.Mono, "24-bit, 48 kHz, mono"),
};
private void PopulateAttributesList()
{
attributesList.BeginUpdate();
attributesList.Items.Clear();
switch (working.FileFormat)
{
case RecordingFileFormat.Wav:
foreach (var (_, _, label) in WavAttributes) attributesList.Items.Add(label);
break;
case RecordingFileFormat.Mp3:
foreach (var (_, _, label) in Mp3Attributes) attributesList.Items.Add(label);
break;
case RecordingFileFormat.Ogg:
foreach (var (_, _, label) in OggOpusAttributes) attributesList.Items.Add(label);
break;
case RecordingFileFormat.Flac:
foreach (var (_, _, label) in FlacAttributes) attributesList.Items.Add(label);
break;
default:
attributesList.Items.Add("Default settings");
break;
}
attributesList.EndUpdate();
}
private void SelectFromSource(RecordingSource src)
{
var idx = (int)src;
if (idx >= 0 && idx < sourceList.Items.Count) sourceList.SelectedIndex = idx;
}
private void SelectFromFormat(RecordingFileFormat fmt)
{
var idx = (int)fmt;
if (idx >= 0 && idx < formatList.Items.Count) formatList.SelectedIndex = idx;
}
private void SelectFromAttributes(RecordingSettings s)
{
switch (s.FileFormat)
{
case RecordingFileFormat.Wav:
for (var i = 0; i < WavAttributes.Length; i++)
{
var (bits, mode, _) = WavAttributes[i];
if (bits == s.WavBitsPerSample && mode == s.ChannelMode)
{
attributesList.SelectedIndex = i;
return;
}
}
attributesList.SelectedIndex = 2; // 24-bit stereo default
break;
case RecordingFileFormat.Mp3:
for (var i = 0; i < Mp3Attributes.Length; i++)
{
var (kbps, mode, _) = Mp3Attributes[i];
if (kbps == s.Mp3BitrateKbps && mode == s.ChannelMode)
{
attributesList.SelectedIndex = i;
return;
}
}
attributesList.SelectedIndex = 6; // 320 kbps stereo default
break;
case RecordingFileFormat.Ogg:
for (var i = 0; i < OggOpusAttributes.Length; i++)
{
var (kbps, mode, _) = OggOpusAttributes[i];
if (kbps == s.OggOpusBitrateKbps && mode == s.ChannelMode)
{
attributesList.SelectedIndex = i;
return;
}
}
attributesList.SelectedIndex = 4; // 192 kbps stereo default
break;
case RecordingFileFormat.Flac:
for (var i = 0; i < FlacAttributes.Length; i++)
{
var (bits, mode, _) = FlacAttributes[i];
if (bits == s.FlacBitsPerSample && mode == s.ChannelMode)
{
attributesList.SelectedIndex = i;
return;
}
}
attributesList.SelectedIndex = 2; // 24-bit stereo default
break;
default:
if (attributesList.Items.Count > 0) attributesList.SelectedIndex = 0;
break;
}
}
private void ApplyAttributesSelection()
{
var idx = attributesList.SelectedIndex;
if (idx < 0) return;
switch (working.FileFormat)
{
case RecordingFileFormat.Wav:
if (idx < WavAttributes.Length)
{
var (bits, mode, _) = WavAttributes[idx];
working.WavBitsPerSample = bits;
working.ChannelMode = mode;
}
break;
case RecordingFileFormat.Mp3:
if (idx < Mp3Attributes.Length)
{
var (kbps, mode, _) = Mp3Attributes[idx];
working.Mp3BitrateKbps = kbps;
working.ChannelMode = mode;
}
break;
case RecordingFileFormat.Ogg:
if (idx < OggOpusAttributes.Length)
{
var (kbps, mode, _) = OggOpusAttributes[idx];
working.OggOpusBitrateKbps = kbps;
working.ChannelMode = mode;
}
break;
case RecordingFileFormat.Flac:
if (idx < FlacAttributes.Length)
{
var (bits, mode, _) = FlacAttributes[idx];
working.FlacBitsPerSample = bits;
working.ChannelMode = mode;
}
break;
default:
break;
}
}
private static bool SettingsEqual(RecordingSettings a, RecordingSettings b) =>
a.Source == b.Source
&& a.FileFormat == b.FileFormat
&& a.ChannelMode == b.ChannelMode
&& a.WavBitsPerSample == b.WavBitsPerSample
&& a.Mp3BitrateKbps == b.Mp3BitrateKbps
&& a.OggOpusBitrateKbps == b.OggOpusBitrateKbps
&& a.FlacBitsPerSample == b.FlacBitsPerSample
&& a.FlacCompressionLevel == b.FlacCompressionLevel
&& string.Equals(a.Folder ?? string.Empty, b.Folder ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}