v4.0: full audio-cue system, cause-aware auto-tune, four-tab Preferences, install-side default sounds

Audio cues
- Cues for send/receive on-off, minimise/restore, checkbox tick/untick, and tab switch
- Soft keyboard clicks while typing, with a distinct passkey sound on password fields
- Per-cue "Choose sound" variant picker; "(none)" silences a cue; front-most missing-sound warning
- Send/receive cues take priority over the generic checkbox sound; programmatic ticks stay silent

Preferences
- Redesigned into four tabs (General, Audio cues, Startup behaviour, Update settings)
- Startup behaviour moved in from the Options menu
- NVDA now announces the dialog on open (focus a real named control, not the quiet tab control)

Auto-tune
- Cause-aware: tells device render-callback stalls (more buffer can't fix) apart from genuine
  network/buffer starvation, so it no longer pins latency high on chunky onboard cards
- Lowering the target eases the buffer down (glide) instead of trimming it, so no clicks while tuning

Sounds layout
- Shipped defaults moved out of the per-user folder into an install-side "default sounds" folder,
  so updates can refresh them; user customs are Browse-picked file paths and are left untouched
- Startup migration removes both legacy sound folders; verified from oldest (v1.0-v3.3) and v3.4 layouts

Quiet automated launches
- New --silent launch flag mutes all cue sounds and suppresses the startup dialogs (migration notice,
  update check, Realtek/mic/missing-sound warnings) so test launches never disturb the user
- run-tests / build-release / SelfTest repointed to the new "default sounds" layout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-13 11:57:19 +01:00
co-authored by Claude Opus 4.8
parent 0b7ad49021
commit a408d2b56e
61 changed files with 548 additions and 139 deletions
+13
View File
@@ -491,6 +491,19 @@ public sealed class AudioReceiver : IDisposable
public long Underruns => playoutEngine.AggregateUnderruns;
public long Drops => playoutEngine.AggregateDrops + Interlocked.Read(ref packetsDropped);
/// <summary>Cause-split of <see cref="Underruns"/> for the continuous auto-tune (2026-06-13).
/// <c>TuneBlockingUnderruns</c> are the short-reads that mean "the buffer is genuinely too thin"
/// (full-empty reads + reads taken while the ring was draining below target) — the auto-tune
/// gates its lowering on THESE rather than the legacy total, so a steady trickle of inaudible
/// device-gulp partials (a chunky output render callback asking for an oversized block on an
/// otherwise on-target ring) no longer pins the target high forever. <c>DeviceGulpUnderruns</c>
/// is that excluded device-structural remainder, surfaced for the diag log so the split can be
/// validated. Both are per-route for BothIndependent, mirroring <see cref="UnderrunsFor"/>.</summary>
public long TuneBlockingUnderruns => playoutEngine.AggregateTuneBlockingUnderruns;
public long TuneBlockingUnderrunsFor(RenderRoute route) => playoutEngine.AggregateTuneBlockingUnderrunsFor(route);
public long DeviceGulpUnderruns => playoutEngine.AggregateDeviceGulpUnderruns;
public long DeviceGulpUnderrunsFor(RenderRoute route) => playoutEngine.AggregateDeviceGulpUnderrunsFor(route);
/// <summary>Per-cause split of the legacy `Drops` rollup. Useful in the diag log to tell
/// "we deliberately trimmed the buffer to track the latency target" (TrimDropBytes) from
/// "we got malformed packets" (PacketsRejectedMalformed) from "ringbuffer overflowed and
+47
View File
@@ -366,6 +366,53 @@ internal sealed class PlayoutEngine : IWaveProvider
return total;
}
/// <summary>Cause-split underrun aggregators for the continuous auto-tune (2026-06-13).
/// <see cref="AggregateTuneBlockingUnderrunsFor"/> sums the short-reads the tuner should treat
/// as "need more buffer" (full-empty reads + reads taken while the ring was draining below
/// target). <see cref="AggregateDeviceGulpUnderrunsFor"/> sums the inaudible on-target partial
/// short-reads caused by a chunky output render callback, which more buffer can't fix and which
/// must NOT block the tuner from lowering. Together they partition <see cref="AggregateUnderrunsFor"/>.
/// Per-route, mirroring the underrun aggregator above.</summary>
public long AggregateTuneBlockingUnderrunsFor(RenderRoute route)
{
long total = 0;
foreach (var s in sessionsSnapshot)
{
if (s.Route == route) total += s.TuneBlockingUnderrunsTotal;
}
return total;
}
public long AggregateTuneBlockingUnderruns
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.TuneBlockingUnderrunsTotal;
return total;
}
}
public long AggregateDeviceGulpUnderrunsFor(RenderRoute route)
{
long total = 0;
foreach (var s in sessionsSnapshot)
{
if (s.Route == route) total += s.DeviceGulpUnderrunsTotal;
}
return total;
}
public long AggregateDeviceGulpUnderruns
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.DeviceGulpUnderrunsTotal;
return total;
}
}
/// <summary>True if at least one session is currently tagged for this route. Used by
/// the auto-tune to skip ticking a lane that has nobody to tune — without this gate
/// the ASIO auto-tune (for example) would react to the shared network-gap signal
+78 -1
View File
@@ -56,6 +56,19 @@ internal sealed class SessionPlayout : IDisposable
// independent of the byte amount).
private long trimFireCount;
// Reference latency the click-trim threshold is measured against, distinct from the live
// target. It snaps UP to the target instantly (raising never trims) but eases DOWN slowly when
// the target is lowered - e.g. the continuous auto-tune walking the latency down. Without this
// glide, each downward target step drops the trim threshold under the buffer's natural sawtooth
// and the trim shaves a chunk off the buffer on the spot: an audible click on every step (the
// "crackly while it tunes" Ed reported, worse at a 3-second tune interval, and on ASIO too since
// its tighter smoothness=1 trim is even more eager). Easing the ceiling down slower than the
// depth corrector drains the buffer lets the buffer slide down UNDER the threshold instead of
// being trimmed. Genuine bloat above the gliding ceiling still trims. In steady state it equals
// the target, so normal operation - including ASIO Tight mode's burst snap-back - is unchanged.
private double trimGlideTargetMs;
private long prevTrimGlideTicks;
// === Underrun concealment state ===
// When the playout ring buffer comes up short on a render-side read, AudioRingBuffer
// silence-fills the missing portion with hard zero. The transient from the last real
@@ -249,6 +262,33 @@ internal sealed class SessionPlayout : IDisposable
private long partialReadFiresTotal;
public long ConcealmentFiresTotal => Interlocked.Read(ref concealmentFiresTotal);
public long PartialReadFiresTotal => Interlocked.Read(ref partialReadFiresTotal);
// Cause-split of ring short-reads, for the continuous auto-tune (2026-06-13). The legacy
// UnderrunCount can't tell apart two very different events that both come up short on a read:
// * PRODUCER starvation — the ring is draining below target because decoded audio isn't
// arriving fast enough (late/lost packets, or a stalled receive/decode thread). MORE
// buffer genuinely helps; this is the "raise the target / don't lower" case.
// * DEVICE gulp — the output render callback came late/chunky and asked for an oversized
// block in one go (classic onboard-Realtek WASAPI ~21ms period stretching toward ~42ms),
// so a perfectly healthy on-target ring still can't fill that one gulp. These are partial
// short-reads that are INAUDIBLE (zero-padded sub-frame tail since the 2026-05-14 fix), and
// more buffer never cures the gulp pattern — it just adds permanent latency. This is the
// case that used to pin the auto-tune high forever (it skipped lowering on ANY underrun).
// We tell them apart by where the buffer is sitting at the instant of the short-read: a
// chronically-below-target buffer (filteredErrorFrames well negative) is producer starvation;
// an on-target buffer that momentarily came up short is a device gulp. The split is computed
// live on the render thread, independent of file logging, so it drives the live tuner even
// with logs off. The cumulative totals are also surfaced for the diag log so the classification
// can be validated against real captures.
private long tuneBlockingUnderrunsTotal;
private long deviceGulpUnderrunsTotal;
/// <summary>Short-reads the auto-tune should treat as "need more buffer" — full-empty reads
/// (audible) plus any short-read taken while the ring was chronically below target (producer
/// starvation). These block the auto-tune from lowering, exactly as the legacy total used to.</summary>
public long TuneBlockingUnderrunsTotal => Interlocked.Read(ref tuneBlockingUnderrunsTotal);
/// <summary>Inaudible partial short-reads taken while the ring was on target — the device-gulp
/// case more buffer can't fix. These must NOT block the auto-tune from lowering.</summary>
public long DeviceGulpUnderrunsTotal => Interlocked.Read(ref deviceGulpUnderrunsTotal);
/// <summary>Live state — the LP-filtered drift error in stereo frames. Positive = buffer
/// running above target on average (sender clock faster); negative = buffer below
/// target. Magnitude shows how off-target the buffer's average position is right now.</summary>
@@ -378,6 +418,8 @@ internal sealed class SessionPlayout : IDisposable
lastConcealSampleR = 0f;
filteredErrorFrames = 0;
prevDriftSampleTicks = 0;
trimGlideTargetMs = 0;
prevTrimGlideTicks = 0;
// Phase-4 drift resampler state. Reset counters and window state. Reset() on the
// resampler clears its internal filter delay line so a fresh session doesn't
// inherit phase from a prior one. SetRates back to 1:1 — we'll re-measure drift
@@ -520,8 +562,28 @@ internal sealed class SessionPlayout : IDisposable
};
if (knobExtraMs >= 0)
{
// Ease the trim's reference latency: snap UP to the target instantly (raising never
// trims), glide DOWN at ~1.5 ms/sec - slower than the depth corrector can drain the
// buffer - so a lowered target (the auto-tune walking latency down) lets the buffer
// slide down under the threshold rather than being shaved into a click on each step.
const double TrimGlideDownMsPerSec = 1.5;
var nowTrimTicks = Stopwatch.GetTimestamp();
if (trimGlideTargetMs <= 0 || targetLatencyMs >= trimGlideTargetMs)
{
trimGlideTargetMs = targetLatencyMs;
}
else if (prevTrimGlideTicks != 0)
{
var dtSec = (nowTrimTicks - prevTrimGlideTicks) / (double)Stopwatch.Frequency;
trimGlideTargetMs = Math.Max(targetLatencyMs, trimGlideTargetMs - TrimGlideDownMsPerSec * dtSec);
}
prevTrimGlideTicks = nowTrimTicks;
var trimMarginMs = floorMarginMs + knobExtraMs;
var trimThresholdBytes = MillisecondsToBytes(targetLatencyMs + trimMarginMs);
// Threshold uses the gliding ceiling so a freshly-lowered target doesn't trip the trim.
// The drop destination still uses the REAL target, so if genuine bloat does push the
// buffer above the gliding ceiling, it's cut back to where the latency actually wants it.
var trimThresholdBytes = MillisecondsToBytes((int)Math.Round(trimGlideTargetMs) + trimMarginMs);
if (playout.BufferedBytes > trimThresholdBytes)
{
var keepBytes = MillisecondsToBytes(Math.Max(targetLatencyMs + dropToCushionMs, 1));
@@ -701,6 +763,21 @@ internal sealed class SessionPlayout : IDisposable
// than zero; the zero-padded tail just produces silence at the resampler output
// for that fraction.
var artifact = (ConcealmentArtifact)concealmentArtifactRaw;
// Cause-split for the continuous auto-tune (see tuneBlockingUnderrunsTotal declaration).
// Any short-read is classified the instant it happens by where the buffer is sitting:
// a ring chronically below target (filteredErrorFrames well negative) is producer/network
// starvation that more buffer fixes; an on-target ring that came up short is an inaudible
// device gulp that more buffer can't fix. ~3ms of deficit is the boundary — past the
// on-target jitter the depth-corrector leaves behind, far short of a real producer stall.
if (framesGot < inputFramesNeeded)
{
const int TuneStarveMs = 3;
var producerBehind = filteredErrorFrames <= -(TuneStarveMs * MixSampleRate / 1000.0);
if (framesGot == 0 || producerBehind)
Interlocked.Increment(ref tuneBlockingUnderrunsTotal);
else
Interlocked.Increment(ref deviceGulpUnderrunsTotal);
}
if (framesGot == 0)
{
Interlocked.Increment(ref concealmentFiresTotal);