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
@@ -37,6 +37,11 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
|
||||
// open driver can keep running while routing changes between Mixed / AsioLane / no-op.
|
||||
// Volatile is sufficient for reference assignment on .NET (atomic, with memory barrier).
|
||||
private volatile Action<ReadOnlyMemory<float>> onMixedSamples;
|
||||
// Raw-capture step probe — measures discontinuities in the ASIO buffer exactly as the
|
||||
// driver delivered it, BEFORE our code sums the selected channel pairs or clamps to ±1.0.
|
||||
// Each capture backend owns its own probe so BothIndependent mode (ASIO and WASAPI both
|
||||
// capturing) can be diagnosed without the probes contaminating each other's state.
|
||||
private readonly AudioStepProbe rawCaptureStepProbe = new();
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
private readonly string driverName;
|
||||
public string DriverName => driverName;
|
||||
@@ -82,6 +87,8 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
|
||||
public void SetCallback(Action<ReadOnlyMemory<float>> callback) =>
|
||||
onMixedSamples = callback;
|
||||
|
||||
public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
|
||||
|
||||
public bool IsRunning => asio is not null;
|
||||
public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount);
|
||||
public long TotalCaptureBytes => Interlocked.Read(ref bytesCaptured);
|
||||
@@ -264,6 +271,23 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
|
||||
|
||||
if (pairs.Count == 0) return;
|
||||
|
||||
// Diagnostic raw-capture probe — scans the FIRST active channel pair's L channel in
|
||||
// the as-delivered-by-the-driver interleaved buffer. Fires BEFORE the mix/sum/clamp
|
||||
// below so the probe sees the driver's data verbatim. If this probe goes non-zero
|
||||
// on big steps while the post-mix probe also does, the discontinuity is upstream of
|
||||
// our code (driver, USB transport, audio hardware). If it stays clean while the
|
||||
// post-mix probe goes non-zero, something in the mix/clamp loop is creating the step.
|
||||
if (frames > 0 && recordChannelCount > 0)
|
||||
{
|
||||
var firstPair = pairs[0];
|
||||
var lCh = firstPair * 2;
|
||||
if (lCh < recordChannelCount)
|
||||
{
|
||||
rawCaptureStepProbe.ScanInterleavedChannel(
|
||||
new ReadOnlySpan<float>(interleavedScratch, 0, written), recordChannelCount, lCh);
|
||||
}
|
||||
}
|
||||
|
||||
for (var f = 0; f < frames; f++)
|
||||
{
|
||||
var srcBase = f * recordChannelCount;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -119,6 +119,16 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Max raw-capture step across both inner backends since the last call. Has to
|
||||
/// drain BOTH probes (so neither sits accumulating forever after we read one) and return
|
||||
/// the larger value.</summary>
|
||||
public float TakeMaxRawCaptureStep()
|
||||
{
|
||||
var w = wasapi?.TakeMaxRawCaptureStep() ?? 0f;
|
||||
var a = asio?.TakeMaxRawCaptureStep() ?? 0f;
|
||||
return w > a ? w : a;
|
||||
}
|
||||
|
||||
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
lock (gate)
|
||||
|
||||
@@ -45,6 +45,13 @@ internal interface ICaptureBackend : IDisposable
|
||||
/// support per-callback timing (e.g. trivial test backends) may return 0.</summary>
|
||||
int TakeMaxCallbackGapMs();
|
||||
|
||||
/// <summary>Worst single-sample step magnitude observed in the raw capture buffer since
|
||||
/// the last call; resets on read. Each backend owns its own probe instance so the
|
||||
/// cross-buffer step measurement doesn't get fooled by another backend's interleaved
|
||||
/// callbacks (which is what produced spurious 0.4-0.5 readings in BothIndependent mode
|
||||
/// before 2026-05-15). Backends that can't sensibly expose raw samples return 0.</summary>
|
||||
float TakeMaxRawCaptureStep();
|
||||
|
||||
void Start(IReadOnlyList<CaptureSourceSpec> specs);
|
||||
|
||||
/// <summary>Live-update of the active source set without stopping the mix loop. Adds/removes
|
||||
|
||||
@@ -111,6 +111,14 @@ internal sealed class MixingEngine : ICaptureBackend
|
||||
get { lock (gate) return active.Select(a => a.Source.Name).ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>Multi-source pull-mode WASAPI doesn't yet feed the raw-capture probe — each
|
||||
/// <see cref="CaptureSource"/> chain (BufferedWaveProvider → ToSampleProvider →
|
||||
/// resampler → stereo-mixdown) would need a per-source probe to be useful, and during
|
||||
/// the 2026-05-15 instrumentation push the user's tests have all been single-source on
|
||||
/// <see cref="PushModeWasapiBackend"/> instead. Stays at zero here; if a future
|
||||
/// multi-source WASAPI test needs the probe, add it per-source in CaptureSource.</summary>
|
||||
public float TakeMaxRawCaptureStep() => 0f;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the mix loop with the given initial source set. If already running, the existing
|
||||
/// loop is stopped first. After Start, <see cref="UpdateSources"/> can be called to add/remove
|
||||
|
||||
@@ -67,6 +67,14 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
|
||||
private long bytesCaptured;
|
||||
private long clippedSampleCount;
|
||||
|
||||
// Raw-capture step probe — scans the WASAPI source buffer as floats right after we
|
||||
// reinterpret the byte buffer, BEFORE resampling / stereo-mixdown / clamp. This is the
|
||||
// earliest float-form view of what the Windows audio engine handed us. Used together
|
||||
// with the per-lane pre-encode probe to localise where discontinuities enter on the
|
||||
// WASAPI path. Per-backend so BothIndependent doesn't cross-contaminate ASIO and WASAPI
|
||||
// probes' cross-buffer state.
|
||||
private readonly AudioStepProbe rawCaptureStepProbe = new();
|
||||
|
||||
// Resampling state — only allocated when source rate != MixSampleRate.
|
||||
private WdlResampler? resampler;
|
||||
private int sourceSampleRate;
|
||||
@@ -100,6 +108,8 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
|
||||
/// where Ed has been hunting jitter.</summary>
|
||||
public int TakeMaxCallbackGapMs() => 0;
|
||||
|
||||
public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
|
||||
|
||||
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
if (specs.Count == 0)
|
||||
@@ -247,6 +257,21 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
|
||||
Buffer.BlockCopy(e.Buffer, 0, sourceFloatScratch, 0, e.BytesRecorded);
|
||||
var sourceFrames = sourceFloatCount / sourceChannels;
|
||||
|
||||
// Raw-capture probe — scans the L channel of the source buffer in the form
|
||||
// Windows handed it to us, before our resample / mixdown / clamp. Channel layout
|
||||
// for WASAPI loopback is interleaved [L,R,...] for stereo or a single channel for
|
||||
// mono; the probe walks every Nth sample where N=sourceChannels. If this probe
|
||||
// shows steps that the per-lane pre-encode probe doesn't, our downstream
|
||||
// processing is masking real source-side issues. If both show the same steps,
|
||||
// the discontinuity arrived from Windows / the device driver.
|
||||
if (sourceFrames > 0 && sourceChannels > 0)
|
||||
{
|
||||
rawCaptureStepProbe.ScanInterleavedChannel(
|
||||
new ReadOnlySpan<float>(sourceFloatScratch, 0, sourceFloatCount),
|
||||
sourceChannels,
|
||||
0);
|
||||
}
|
||||
|
||||
// 2. Resample to MixSampleRate if needed. The resampler is pull-mode; we drive the
|
||||
// pull from our callback. Approximate output frames = input * outRate / inRate.
|
||||
float[] working;
|
||||
|
||||
@@ -52,6 +52,17 @@ internal sealed class SenderLane
|
||||
private OpusEncoderState opusEncoder;
|
||||
private int opusFrameStereoSamples;
|
||||
|
||||
// Per-lane pre-encode discontinuity probe. Moved here from AudioSender (2026-05-15) so
|
||||
// each lane has its OWN probe state and the cross-buffer step measurement (which carries
|
||||
// lastL/lastR across calls) only sees samples from one continuous audio stream. With the
|
||||
// earlier shared-probe design, BothIndependent mode mixed two unrelated streams' samples
|
||||
// into the same probe's cross-buffer carry, producing synthetic "steps" of arbitrary
|
||||
// magnitude every time the two lanes' callbacks interleaved — making the diag log unable
|
||||
// to tell a real capture glitch from instrumentation aliasing. Per-lane separation fixes
|
||||
// that without changing what the probe measures.
|
||||
private readonly AudioStepProbe preEncodeStepProbe = new();
|
||||
public float TakeMaxPreEncodeStep() => preEncodeStepProbe.TakeMax();
|
||||
|
||||
// Which render route this lane announces in its format packets. The receiver reads the
|
||||
// Lane byte on the wire and tags the matching SessionPlayout, which makes PlayoutEngine
|
||||
// route the lane's audio to the corresponding per-route IWaveProvider surface (lane
|
||||
@@ -144,6 +155,20 @@ internal sealed class SenderLane
|
||||
var emitStart = diag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
EnsureFormatPacketSent();
|
||||
|
||||
// Recording tap — the recorder gets the float audio about to be encoded. The lane
|
||||
// doesn't know whether the recorder is running; the dispatcher early-outs when no
|
||||
// callback is wired. Captured here (before encoding) so the recording is bit-clean
|
||||
// float, independent of which codec the wire is using.
|
||||
owner.DispatchSentSamples(stereoFloats);
|
||||
|
||||
// Discontinuity probe — what does the audio look like just before we encode it?
|
||||
// Compared to the receiver's per-stage probes, this tells us whether artefacts are
|
||||
// present at the sender side already (capture hardware glitch, mix-bus issue) or
|
||||
// introduced somewhere in the wire / decode / playout chain. Per-lane probe — see
|
||||
// <see cref="preEncodeStepProbe"/> field comment for why this isn't shared with the
|
||||
// other lane in BothIndependent.
|
||||
preEncodeStepProbe.ScanStereo(span);
|
||||
|
||||
switch (owner.Codec)
|
||||
{
|
||||
case AudioTransportCodec.Pcm:
|
||||
|
||||
Reference in New Issue
Block a user