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:
co-authored by
Claude Opus 4.7
parent
a0fe8070ed
commit
c59e413c1f
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user