fix(audio): _delayedMix never initialized — one NRE line behind the 'known failure', the class hang, AND the log flood; loopback gain now honest

The creator-declared known failure (Mix_HonorsProviderGains) and AudioPipelineTests'
standalone hang shared ONE root cause, born in TASK 22: AudioMixer.FillAndMix
dereferenced _delayedMix (declared float[]? , never assigned) as delayed.Length —
every live-mix tick NRE'd before the pipe write, so NO audio ever reached the wire
(tests starved -> hung/fail; app -> swallowed catch logged only ex.Message, 10ms
flood). Now: ctor-allocated + null-check, catch logs WITH stack via AppLog and is
throttled 5s, and a cancelled token breaks out before logging.

Bonus contract fix: AudioGainProvider.LoopbackGain returned unity on a disproven
premise ('loopback scales with endpoint volume'). The creator's tonight observation —
volume at 20%, meter pegged — proves the WASAPI tap is pre-endpoint-volume, so the
mixer must multiply by GameAudioVolume for stream honesty (this is what ai.md always
specified; the 'failing' test encoded the same and was RIGHT).

AudioPipelineTests: 25/25 in 26ms, standalone, no hang. Known-failure count: ZERO.
ai.md tests paragraph rewritten (no more suite-total claims, both ex-'knowns'
explained); TASK 22 regression recorded; MyMistakes: the known-failure-label rules.
This commit is contained in:
2026-09-01 21:20:45 -07:00
parent 5a1a3c566a
commit 5ead064d54
5 changed files with 61 additions and 20 deletions
+8 -4
View File
@@ -28,8 +28,12 @@ public sealed class AudioGainProvider
/// <summary>Mic gain for the live mix (0 = muted).</summary>
public double MicGain() => _micMuted() ? 0 : _micVolume();
/// <summary>Loopback (desktop/game) gain for the live mix (0 = muted, 1 = unity).
/// Unity because the slider now drives system volume via PushSystemVolume,
/// so the WASAPI loopback signal is already scaled by the user's volume choice.</summary>
public double LoopbackGain() => _gameMuted() ? 0 : 1.0;
/// <summary>Loopback (desktop/game) gain for the live mix (0 = muted).
/// Honesty rule (2026-09-01): WASAPI loopback capture does NOT shrink with the
/// endpoint volume — the creator dropped system volume to 20% and the captured
/// level stayed pegged — so the mixer must multiply by GameAudioVolume itself
/// for the stream to follow the knob (the old unity assumption left the stream
/// at full desktop volume while the headphones went quiet; ai.md always
/// specified loopbackGain = GameAudioVolume — the code had drifted).</summary>
public double LoopbackGain() => _gameMuted() ? 0 : _gameVolume();
}
+17 -3
View File
@@ -1,3 +1,4 @@
using ytLive.Helpers;
using ytLive.Services;
namespace ytLive.Services.Audio;
@@ -48,6 +49,7 @@ public sealed class AudioMixer : IDisposable
private float[]? _loopbackChunk;
private float[]? _mixBuffer;
private float[]? _delayedMix;
private DateTime _lastLoopErrorLogged = DateTime.MinValue;
public AudioMixer(
IAudioSource mic,
@@ -78,6 +80,7 @@ public sealed class AudioMixer : IDisposable
_micChunk = new float[framesPerTick];
_loopbackChunk = new float[framesPerTick * 2];
_mixBuffer = new float[framesPerTick * 2];
_delayedMix = new float[framesPerTick * 2];
_mic.Started += OnMicStarted;
_mic.SampleReady += OnMicSample;
@@ -280,7 +283,18 @@ public sealed class AudioMixer : IDisposable
}
catch (Exception ex)
{
_log?.Invoke($"Audio live loop error: {ex.Message}");
// Throttled + never on a dead session: the 2026-09-01 first-launch flood
// was one logged NRE every 10ms tick (see _delayedMix fix). Log at most
// once per 5s, and stop looping if the pipe writer is gone for good.
if (cancellationToken.IsCancellationRequested)
break;
var now = DateTime.UtcNow;
if (now - _lastLoopErrorLogged >= TimeSpan.FromSeconds(5))
{
_lastLoopErrorLogged = now;
AppLog.Write(ex, "Audio live loop error");
_log?.Invoke($"Audio live loop error: {ex.Message}");
}
}
var delay = nextTick - DateTime.UtcNow;
@@ -343,8 +357,8 @@ public sealed class AudioMixer : IDisposable
_masterLimiter.Process(mix);
_syncDelay.Configure(_syncOffsetMs?.Invoke() ?? 0);
var delayed = _delayedMix!;
if (delayed.Length < mix.Length)
var delayed = _delayedMix;
if (delayed is null || delayed.Length < mix.Length)
delayed = _delayedMix = new float[mix.Length];
_syncDelay.Process(mix, delayed);
Array.Copy(delayed, mix, mix.Length);