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
+129
View File
@@ -0,0 +1,129 @@
namespace RemSound.Core;
/// <summary>
/// Per-stage sample-step "discontinuity detector" for diagnosing where in the audio pipeline
/// pops are being introduced. A single-sample step magnitude is the absolute difference
/// between consecutive samples of the same channel; a typical "click" in real audio shows
/// up as a step well above what naturally occurs in music or speech content.
///
/// Each probe holds the maximum step observed across all calls to <see cref="ScanStereo"/>
/// since the last <see cref="TakeMax"/>. The diag log polls TakeMax once per second to
/// emit the worst step at that pipeline stage. Comparing the max across stages — sender
/// pre-encode, receiver post-decode, receiver post-ring-read, receiver post-resampler,
/// final output — reveals which stage introduces the click.
///
/// Thread model: writes are lock-free CAS-update of a long-encoded float bit pattern (so
/// one probe can be hit from multiple threads if needed). Read-and-reset is also atomic.
/// Buffers are scanned cheaply — one subtract + abs + compare per sample — and the whole
/// scan is gated by <see cref="DiagnosticsGate.Enabled"/> so it pays nothing in production
/// when logging is off.
/// </summary>
public sealed class AudioStepProbe
{
private long maxStepBits;
// Remember the last sample on each channel so the next scan can compute the cross-buffer
// step. Without this we'd miss any discontinuity at the buffer boundary (the most
// suspicious place — that's where copies, format conversions and resampler hand-offs
// happen).
private float lastL;
private float lastR;
private bool hasLast;
/// <summary>Scan a single-channel slice of an interleaved multi-channel float buffer and
/// update the max step magnitude. <paramref name="channelCount"/> is the total number of
/// interleaved channels; <paramref name="channelIndex"/> picks which one to scan. Used by
/// the ASIO capture probe to look at raw driver-delivered samples on individual channels
/// before any mixing or clamping happens. Cheap; safe to call from any thread; no-op when
/// diagnostics are disabled.</summary>
public void ScanInterleavedChannel(ReadOnlySpan<float> interleavedFloats, int channelCount, int channelIndex)
{
if (!DiagnosticsGate.Enabled) return;
if (interleavedFloats.IsEmpty) return;
if (channelCount <= 0 || channelIndex < 0 || channelIndex >= channelCount) return;
var max = ReadMax();
// Use lastL as the cross-buffer carry for single-channel scans. (We don't need a
// separate "lastSingle" — every probe is consumed by exactly one caller at a time, so
// reusing the field is fine. The cross-buffer step is what matters for buffer-boundary
// glitches.)
var prev = lastL;
var seedFromPrev = hasLast;
var samples = interleavedFloats.Length / channelCount;
for (var i = 0; i < samples; i++)
{
var s = interleavedFloats[i * channelCount + channelIndex];
if (i == 0 && !seedFromPrev) prev = s;
var step = s - prev;
if (step < 0f) step = -step;
if (step > max) max = step;
prev = s;
}
lastL = prev;
hasLast = true;
WriteMaxIfGreater(max);
}
/// <summary>Scan an interleaved stereo float span and update the max step. Cheap; safe
/// to call from any thread. No-op if diagnostics are disabled.</summary>
public void ScanStereo(ReadOnlySpan<float> stereoFloats)
{
if (!DiagnosticsGate.Enabled) return;
if (stereoFloats.IsEmpty) return;
var max = ReadMax();
var prevL = lastL;
var prevR = lastR;
var seedFromPrev = hasLast;
// Pair walk. For samples after the first, compare to the previous sample of the
// same channel from THIS buffer. For the first pair, compare to the saved
// last-sample-from-the-previous-buffer if available.
for (var i = 0; i + 1 < stereoFloats.Length; i += 2)
{
var l = stereoFloats[i];
var r = stereoFloats[i + 1];
float stepL, stepR;
if (i == 0)
{
if (!seedFromPrev) { prevL = l; prevR = r; }
stepL = l - prevL;
stepR = r - prevR;
}
else
{
stepL = l - stereoFloats[i - 2];
stepR = r - stereoFloats[i - 1];
}
var absL = stepL < 0f ? -stepL : stepL;
var absR = stepR < 0f ? -stepR : stepR;
if (absL > max) max = absL;
if (absR > max) max = absR;
}
// Save the last sample of this buffer for the next scan.
var lastIdx = stereoFloats.Length - 2;
lastL = stereoFloats[lastIdx];
lastR = stereoFloats[lastIdx + 1];
hasLast = true;
WriteMaxIfGreater(max);
}
/// <summary>Atomic snapshot of the current max + reset to zero. Returns the value as
/// a float in the same units as the input (i.e. 0.5 = a 0.5-magnitude single-sample
/// step, which is a 6 dB jump and definitely audible).</summary>
public float TakeMax()
{
var bits = Interlocked.Exchange(ref maxStepBits, 0);
return BitConverter.Int32BitsToSingle((int)bits);
}
private float ReadMax() => BitConverter.Int32BitsToSingle((int)Volatile.Read(ref maxStepBits));
private void WriteMaxIfGreater(float candidate)
{
var candidateBits = (long)BitConverter.SingleToInt32Bits(candidate);
long current;
do
{
current = Volatile.Read(ref maxStepBits);
var currentValue = BitConverter.Int32BitsToSingle((int)current);
if (candidate <= currentValue) return;
} while (Interlocked.CompareExchange(ref maxStepBits, candidateBits, current) != current);
}
}
+25 -1
View File
@@ -62,8 +62,23 @@ public sealed class Profile
/// profile (not in AppConfig) because the right answer genuinely differs between
/// profiles.</summary>
public bool PriorityMode { get; set; }
/// <summary>True suppresses the connect/disconnect sound cues. Off by default.</summary>
/// <summary>Legacy combined "mute connect/disconnect sounds" toggle. True suppresses
/// both connect AND disconnect cues. Superseded 2026-05-15 by the four individual
/// <c>Enable*Cue</c> flags below — the new flags take precedence when set. This field
/// is preserved on the profile for backward compatibility with older builds that don't
/// know about the per-cue flags; on first load the per-cue flags inherit from this
/// (true → connect+disconnect cues disabled).</summary>
public bool MuteConnectionCues { get; set; }
/// <summary>Per-cue enable flags. Nullable so a missing entry in an older profile JSON
/// falls back to the legacy <see cref="MuteConnectionCues"/> migration path; once the
/// user touches the new UI we write a concrete <c>true</c>/<c>false</c> and the legacy
/// field stops mattering. Defaults to "play the sound" (true) for both cases — the
/// audio cues are part of the normal user feedback loop, not opt-in. 2026-05-15.</summary>
public bool? EnableConnectCue { get; set; }
public bool? EnableDisconnectCue { get; set; }
public bool? EnableRecordStartCue { get; set; }
public bool? EnableRecordStopCue { get; set; }
public int MaxLatencyMs { get; set; } = 80;
public int Smoothness { get; set; } = 3;
public bool ContinuousAutoTuneEnabled { get; set; }
@@ -95,6 +110,15 @@ public sealed class Profile
set => ConcealmentArtifactRaw = (int)value;
}
// === Recording ===
/// <summary>Recording source / format / attributes. The whole settings object is saved
/// per profile so different profiles can record different things (a "long session"
/// profile might record everything to MP3, a "monitoring" profile might not record at
/// all but keep the dialog defaults sensible). The recording isn't running until the
/// user explicitly triggers it via the Record menu; this just holds the configuration
/// the recorder picks up when it starts.</summary>
public RecordingSettings RecordingSettings { get; set; } = new();
// === Peers ===
public List<string> RememberedPeers { get; set; } = [];
/// <summary>Peer addresses (IP or host[:port]) the user had ticked in the connected
+129
View File
@@ -0,0 +1,129 @@
namespace RemSound.Core;
/// <summary>What audio gets captured by the recorder. Selected in the Recording settings
/// dialog and saved per profile. Defaults to <see cref="ReceivedOnly"/> which is the most
/// common "I want a copy of what my collaborator just played me" case.</summary>
public enum RecordingSource
{
/// <summary>Record only audio coming from connected peers (everything that would play
/// out of the receiver's render path).</summary>
ReceivedOnly = 0,
/// <summary>Record only audio captured locally (everything this machine is sending to
/// peers — your own mics / loopback / ASIO inputs).</summary>
SentOnly = 1,
/// <summary>Record the sum of received + sent audio, soft-mixed and limiter-protected
/// just like the playback path. Useful for capturing a complete two-way exchange in a
/// single file.</summary>
Both = 2,
}
/// <summary>Output container format the recorder writes to disk. The format dictates the
/// shape of <see cref="RecordingSettings.AudioAttributes"/> — uncompressed formats take
/// bit-depth, compressed formats take a bitrate, mono/stereo applies to all of them.</summary>
public enum RecordingFileFormat
{
/// <summary>RIFF WAVE, PCM. Lossless, large. Writer: in-process custom WAV writer with
/// periodic header re-patching so a mid-session crash leaves a playable file.</summary>
Wav = 0,
/// <summary>MPEG Layer III. Lossy, small. Writer: NAudio.Lame (LAME library).</summary>
Mp3 = 1,
/// <summary>Ogg container with Opus codec. Lossy, very small at sane bitrates.
/// Reuses the Concentus Opus encoder the wire path uses, wrapped in an Ogg container
/// via the Concentus.Oggfile NuGet.</summary>
Ogg = 2,
/// <summary>FLAC — lossless, typically ~50 % the size of equivalent WAV.
/// Writer: CUETools.Codecs.FLAKE — pure-managed FLAC encoder, no native DLL.</summary>
Flac = 3,
}
/// <summary>Channel layout for the recording — independent of the format. Stereo preserves
/// the L/R as captured; Mono downmixes to (L + R) / 2 with a 3 dB headroom safety knock so
/// fully-correlated content doesn't clip.</summary>
public enum RecordingChannelMode
{
Stereo = 0,
Mono = 1,
}
/// <summary>All the user-selectable knobs for a recording. Stored on <see cref="Profile"/>.
///
/// The <see cref="AudioAttributes"/> field is a flat int that means different things per
/// format — <see cref="WavBitsPerSample"/> for WAV, <see cref="Mp3BitrateKbps"/> for MP3
/// — kept as one slot rather than a separate field per format because (a) only one is
/// active at a time and (b) it keeps the profile JSON narrow. The format enum decides
/// which interpretation applies.
///
/// Path policy: <see cref="Folder"/> is stored verbatim. When empty, recordings go to
/// the default location (<c>&lt;exe&gt;\recordings\&lt;machine&gt;\</c>). When set, it
/// IS the folder — no per-machine subfolder is appended. Each recording session creates
/// a new file inside the folder, named with a UTC timestamp and the format extension.
/// </summary>
public sealed class RecordingSettings
{
public RecordingSource Source { get; set; } = RecordingSource.ReceivedOnly;
public RecordingFileFormat FileFormat { get; set; } = RecordingFileFormat.Wav;
public RecordingChannelMode ChannelMode { get; set; } = RecordingChannelMode.Stereo;
/// <summary>WAV bit depth. 16 / 24 / 32. 32 means IEEE float; 16 and 24 are signed PCM.
/// Defaults to 24 which matches RemSound's on-wire PCM bit depth — no extra quantisation
/// happens on the way to disk. Ignored when <see cref="FileFormat"/> isn't WAV.</summary>
public int WavBitsPerSample { get; set; } = 24;
/// <summary>MP3 CBR bitrate in kbps. Common values: 128, 192, 256, 320. 320 is the
/// LAME maximum and the default here — the recording feature is for archival of audio
/// you cared enough to send over the network, not for a podcast feed, so the bias is
/// toward "make the file slightly bigger for an audibly cleaner result". Ignored when
/// <see cref="FileFormat"/> isn't MP3.</summary>
public int Mp3BitrateKbps { get; set; } = 320;
/// <summary>OGG-Opus VBR target bitrate in kbps. Opus' practical sweet spot for music
/// is 96256 kbps; below 96 starts to introduce audible artefacts on dense material,
/// above 256 is diminishing returns. Default 192 — same compromise as the MP3 default
/// "noticeably-larger file for noticeably-cleaner result". Ignored when
/// <see cref="FileFormat"/> isn't Ogg.</summary>
public int OggOpusBitrateKbps { get; set; } = 192;
/// <summary>FLAC bit depth. 16 or 24 — FLAC is integer-PCM only, no 32-bit float, so
/// the WAV "32-bit float" option doesn't carry over. 24-bit matches the wire PCM bit
/// depth and is the default. Ignored when <see cref="FileFormat"/> isn't FLAC.</summary>
public int FlacBitsPerSample { get; set; } = 24;
/// <summary>FLAC compression level, 08. Higher = smaller file, more CPU during encode;
/// all levels are losslessly identical on decode. Reference encoder default is 5; we
/// match that — the encode is comfortably real-time at level 5 on any modern CPU.
/// Ignored when <see cref="FileFormat"/> isn't FLAC.</summary>
public int FlacCompressionLevel { get; set; } = 5;
/// <summary>Absolute path to the folder recordings get written into. Empty / null
/// means "use the default <c>&lt;exe&gt;\recordings\&lt;machine&gt;\</c>". Persisted
/// verbatim — if a saved profile points at a folder that doesn't exist on the loading
/// machine, the recorder falls back to the default and notes it in the diagnostics.</summary>
public string? Folder { get; set; }
public RecordingSettings Clone() => new()
{
Source = Source,
FileFormat = FileFormat,
ChannelMode = ChannelMode,
WavBitsPerSample = WavBitsPerSample,
Mp3BitrateKbps = Mp3BitrateKbps,
OggOpusBitrateKbps = OggOpusBitrateKbps,
FlacBitsPerSample = FlacBitsPerSample,
FlacCompressionLevel = FlacCompressionLevel,
Folder = Folder,
};
/// <summary>Default folder path used when <see cref="Folder"/> is blank. Computed at
/// call time (not cached) so a launch from a different exe directory picks up that
/// directory rather than the first-load one. The per-machine subfolder lets two
/// machines sharing a Dropbox-backed RemSound install keep their recordings tidily
/// separated by sender identity.</summary>
public static string DefaultFolder() =>
Path.Combine(AppContext.BaseDirectory, "recordings", Environment.MachineName);
/// <summary>Returns the resolved folder this profile would record into right now —
/// either the explicit <see cref="Folder"/> if set, or <see cref="DefaultFolder"/>.
/// Does not create the folder on disk.</summary>
public string ResolvedFolder() =>
string.IsNullOrWhiteSpace(Folder) ? DefaultFolder() : Folder!;
}
@@ -313,6 +313,79 @@ public sealed class RemSoundSettingsStore
Save(s);
}
// === Per-cue enable flags (2026-05-15) ===
// Each cue sound has its own enable toggle, surfaced in the Preferences dialog as a
// CheckedListBox. The legacy MuteConnectionCues above used to gate both connect AND
// disconnect — when the new flags are absent (null cache + null profile), the load
// helpers fall back to the legacy value as a migration step. Once the user touches
// any per-cue toggle, that flag's load returns the explicit value directly and the
// legacy field becomes irrelevant for that cue.
public bool LoadEnableConnectCue()
{
var s = Load();
if (s?.EnableConnectCue is bool v) return v;
// Legacy fallback: an older profile with MuteConnectionCues=true was muting both
// connect AND disconnect at once. Honour that intent on first load.
if (s?.MuteConnectionCues == true) return false;
return true;
}
public void SaveEnableConnectCue(bool value)
{
var s = Load() ?? new Settings();
s.EnableConnectCue = value;
Save(s);
}
public bool LoadEnableDisconnectCue()
{
var s = Load();
if (s?.EnableDisconnectCue is bool v) return v;
if (s?.MuteConnectionCues == true) return false;
return true;
}
public void SaveEnableDisconnectCue(bool value)
{
var s = Load() ?? new Settings();
s.EnableDisconnectCue = value;
Save(s);
}
public bool LoadEnableRecordStartCue() =>
Try(() => Load()?.EnableRecordStartCue) ?? true;
public void SaveEnableRecordStartCue(bool value)
{
var s = Load() ?? new Settings();
s.EnableRecordStartCue = value;
Save(s);
}
public bool LoadEnableRecordStopCue() =>
Try(() => Load()?.EnableRecordStopCue) ?? true;
public void SaveEnableRecordStopCue(bool value)
{
var s = Load() ?? new Settings();
s.EnableRecordStopCue = value;
Save(s);
}
/// <summary>The whole recording-settings bag for the current profile. Loaded as a
/// CLONE so callers can mutate the returned object without inadvertently writing
/// back to the cache. Save flushes the object atomically.</summary>
public RecordingSettings LoadRecordingSettings() =>
(Try(() => Load()?.RecordingSettings) ?? new RecordingSettings()).Clone();
public void SaveRecordingSettings(RecordingSettings value)
{
var s = Load() ?? new Settings();
s.RecordingSettings = value?.Clone() ?? new RecordingSettings();
Save(s);
}
/// <summary>How aggressively the receiver pulls the playout queue back to the user's
/// target latency under network jitter. 1 = stupid aggressive (~10 % playback rate change,
/// audible pitch shift on drift, sub-second recovery). 10 = perfectly smooth (gentle
@@ -418,6 +491,11 @@ public sealed class RemSoundSettingsStore
Smoothness = profile.Smoothness,
ConcealmentArtifact = (ConcealmentArtifact)profile.ConcealmentArtifactRaw,
MuteConnectionCues = profile.MuteConnectionCues,
EnableConnectCue = profile.EnableConnectCue,
EnableDisconnectCue = profile.EnableDisconnectCue,
EnableRecordStartCue = profile.EnableRecordStartCue,
EnableRecordStopCue = profile.EnableRecordStopCue,
RecordingSettings = profile.RecordingSettings?.Clone() ?? new RecordingSettings(),
};
}
@@ -459,6 +537,14 @@ public sealed class RemSoundSettingsStore
if (s.Smoothness is int sm) profile.Smoothness = sm;
if (s.ConcealmentArtifact is ConcealmentArtifact ca) profile.ConcealmentArtifactRaw = (int)ca;
if (s.MuteConnectionCues is bool mc) profile.MuteConnectionCues = mc;
// Per-cue enable flags — copy through verbatim (nullable on both sides, so an
// unset flag in the cache stays unset on the profile, letting the legacy
// MuteConnectionCues path govern that cue on the next load).
profile.EnableConnectCue = s.EnableConnectCue;
profile.EnableDisconnectCue = s.EnableDisconnectCue;
profile.EnableRecordStartCue = s.EnableRecordStartCue;
profile.EnableRecordStopCue = s.EnableRecordStopCue;
if (s.RecordingSettings is RecordingSettings rs) profile.RecordingSettings = rs.Clone();
}
private static HotkeySetting HotkeySettingFromRecord(HotkeyRecord r) => new()
@@ -513,6 +599,13 @@ public sealed class RemSoundSettingsStore
public int? Smoothness { get; set; }
public ConcealmentArtifact? ConcealmentArtifact { get; set; }
public bool? MuteConnectionCues { get; set; }
// Per-cue enable flags (2026-05-15). Nullable so an absent value in the loaded
// profile falls back to the legacy MuteConnectionCues migration path.
public bool? EnableConnectCue { get; set; }
public bool? EnableDisconnectCue { get; set; }
public bool? EnableRecordStartCue { get; set; }
public bool? EnableRecordStopCue { get; set; }
public RecordingSettings? RecordingSettings { get; set; }
}
private sealed class HotkeySetting