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
+53 -1
View File
@@ -125,6 +125,37 @@ public sealed class AudioSender : IDisposable
public int TakeMaxEmitMs() => (int)(Interlocked.Exchange(ref maxEmitTicks, 0) * 1000 / Stopwatch.Frequency);
public int TakeMaxSendCallMs() => (int)(Interlocked.Exchange(ref maxSendCallTicks, 0) * 1000 / Stopwatch.Frequency);
// Pre-encode discontinuity probe — per-lane (each <see cref="SenderLane"/> owns its own).
// The aggregate accessor returns the max across both lanes since the last read; per-lane
// accessors expose them individually so BothIndependent mode can tell which lane is
// producing the artefact. Splitting the probe per-lane (2026-05-15) eliminates the
// cross-stream synthetic-step artefact that appeared when both lanes shared one probe and
// their interleaved callbacks fooled the cross-buffer step computation into recording a
// "step" between two unrelated audio streams.
public float TakeMaxSenderPreEncodeStep()
{
var a = defaultLane.TakeMaxPreEncodeStep();
var b = asioLane.TakeMaxPreEncodeStep();
return a > b ? a : b;
}
public float TakeMaxPreEncodeStepWasapiLane() => defaultLane.TakeMaxPreEncodeStep();
public float TakeMaxPreEncodeStepAsioLane() => asioLane.TakeMaxPreEncodeStep();
// Raw capture-side step probe — now lives inside each <see cref="ICaptureBackend"/>
// implementation so the ASIO path and the WASAPI path each measure their own buffers
// independently. The aggregate just asks the backend for the max since last read; in
// BothIndependent mode the composite backend forwards to both inners and returns the
// larger value.
public float TakeMaxSenderRawCaptureStep() => engine.TakeMaxRawCaptureStep();
// Snapshot the cumulative "hit the hard clamp" sample counter. The sender's mix path
// clamps any sample whose magnitude exceeds 1.0 (avoids producing samples the int24 path
// can't represent or that the resampler would treat as garbage). Per-second delta tells
// us whether the input signal is getting close enough to the rails that clipping is
// active — clipping itself produces no step, but a flat-topped sample plateau plus a
// following sharp drop can produce audible distortion that masquerades as a click.
public long ClippedSampleCount => engine.ClippedSampleCount;
// === inbound dispatch (relay-mode) ===
// The send socket is normally write-only, but in relay-mode the same socket is what
// catches return packets — the relay forwards traffic into our NAT pinhole, which lives on
@@ -144,6 +175,28 @@ public sealed class AudioSender : IDisposable
/// </summary>
public Action<byte[], int, IPEndPoint>? OnInboundPacket { get; set; }
/// <summary>
/// Optional callback invoked every time a SenderLane is about to encode a buffer of
/// captured float audio. The span is 48 kHz interleaved stereo float, lives on the
/// audio thread, and must be processed quickly or copied — the buffer is reused on
/// the very next callback. The recorder uses this tap to capture "what we sent" with
/// zero impact on the wire path (no allocation, no extra encoder pass). Null = no tap.
/// </summary>
public Action<ReadOnlyMemory<float>>? OnSentSamples { get; set; }
/// <summary>
/// Internal helper for <see cref="SenderLane"/> to invoke <see cref="OnSentSamples"/>
/// without paying a delegate-invocation cost when no tap is wired. Catches and drops
/// any exception from the user callback — a misbehaving recorder must not crash the
/// audio thread.
/// </summary>
internal void DispatchSentSamples(ReadOnlyMemory<float> samples)
{
var cb = OnSentSamples;
if (cb is null) return;
try { cb(samples); } catch { /* recorder failure isolated from audio path */ }
}
public AudioSender()
{
udp = new UdpClient(AddressFamily.InterNetwork);
@@ -357,7 +410,6 @@ public sealed class AudioSender : IDisposable
public int TakeMaxCaptureCallbackGapMs() => engine.TakeMaxCallbackGapMs();
public string? CaptureFormatDescription => engine.FirstCaptureFormatDescription;
public string? LastCaptureError => engine.FirstCaptureLastError;
public long ClippedSampleCount => engine.ClippedSampleCount;
public AudioTransportCodec Codec => codec;
public int OpusFrameMilliseconds => opusFrameMs;