feat(dock): master strip + MONO output (the accessibility switch)

Unit 9, the one that matters most for this creator.

MASTER STRIP (top, nearest the preview):
- one ladder fed by the MEASURED mix — `AudioMixer` now measures the buffer the encoder receives
  (`MasterLevel`, `MasterImbalanceDb`), so the master cannot show a source instead of the output
- `-12.4 dB` readout, and ONE L/R flag that appears above a 6 dB gap instead of a second ladder
- bitrate / FPS / dropped / duration from the existing health poll

MONO OUTPUT:
- `AudioFold.ToMono` averages each stereo pair into both channels, applied after the limiter and
  the sync delay, so the stream AND the recording get what was folded
- `MonoOutput` is persisted (`Audio.MonoOutput`) and restored at startup: an accessibility switch
  that resets each session is an accessibility failure
- one click in the strip, remembered forever

Guard: AudioFoldTests (hard-left reaches the right at half amplitude, hard-right — the inaudible
case — reaches the left, centred material keeps its level, mono buffers untouched) and
MasterStripTests (the strip binds the measured mix + the switch, the store round-trips it, and the
fold is the tested helper rather than new arithmetic). 33 audio tests green.
This commit is contained in:
2026-10-30 13:18:23 -07:00
parent a6f63f56f3
commit 5889d71db6
8 changed files with 236 additions and 0 deletions
+47
View File
@@ -48,6 +48,32 @@ public sealed class AudioMixer : IDisposable
/// <summary>The ducker's CURRENT gain (1 = not ducking, 0.25 = the −12 dB duck). Read by the
/// dock's badge: a duck indicator that is not the real value is decoration.</summary>
public float DuckGain => _ducker.CurrentGain;
/// <summary>Collapse the final mix to mono (creator ruling 2026-10-30: profoundly deaf on the
/// right, single-sided audio is inaudible). Applied AFTER the limiter and sync delay, so what
/// ships to the encoder — stream and recording alike — is what was folded.</summary>
public bool MonoOutput { get; set; }
/// <summary>Smoothed levels of the MIX (what the encoder receives), per channel.</summary>
public float MasterLeft { get; private set; }
public float MasterRight { get; private set; }
/// <summary>Largest channel level — one ladder for the master, honest about the pair.</summary>
public float MasterLevel => MathF.Max(MasterLeft, MasterRight);
/// <summary>Channel gap in dB; a silent side counts at the −60 dBFS floor, so one-sided reads
/// as the maximum imbalance rather than "nothing to compare".</summary>
public float MasterImbalanceDb
{
get
{
const float floor = 0.001f;
if (MasterLeft < floor && MasterRight < floor) return 0f;
var l = MathF.Max(MasterLeft, floor);
var r = MathF.Max(MasterRight, floor);
return MathF.Abs(20f * MathF.Log10(l / r));
}
}
private readonly MasterLimiter _masterLimiter = new();
private TinyResampler? _micResampler;
private TinyResampler? _loopbackResampler;
@@ -480,6 +506,27 @@ public sealed class AudioMixer : IDisposable
delayed = _delayedMix = new float[mix.Length];
_syncDelay.Process(mix, delayed);
Array.Copy(delayed, mix, mix.Length);
if (MonoOutput) Helpers.AudioFold.ToMono(mix.AsSpan(0, mix.Length), 2);
MeasureMaster(mix);
return (micRms, micCount, loopCount);
}
/// <summary>One pass over the finished mix: 1024 samples per channel, so the cost is noise
/// beside the mixer it sits behind.</summary>
private void MeasureMaster(float[] mix)
{
double left = 0, right = 0;
var frames = mix.Length / 2;
if (frames == 0) return;
for (var i = 0; i < frames; i++)
{
left += mix[i * 2] * mix[i * 2];
right += mix[i * 2 + 1] * mix[i * 2 + 1];
}
var l = (float)Math.Sqrt(left / frames);
var r = (float)Math.Sqrt(right / frames);
MasterLeft = MasterLeft * 0.8f + l * 0.2f;
MasterRight = MasterRight * 0.8f + r * 0.2f;
}
}
+7
View File
@@ -44,6 +44,13 @@ public partial class LayoutStore : IDisposable
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
}
/// <summary>Mono output (creator ruling 2026-10-30): folds the mix so single-sided audio is
/// audible on both sides. Restored at startup — an accessibility switch that resets is a
/// failure, not a default.</summary>
public bool LoadMonoOutput() => GetSetting("Audio.MonoOutput") == "1";
public void SaveMonoOutput(bool enabled) => UpsertSetting("Audio.MonoOutput", enabled ? "1" : "0");
/// <summary>The user's chosen record output folder (TASK 18), or null to use the
/// default <c>%APPDATA%\ytLlive\recordings</c>. Restored at startup.</summary>
public string? LoadRecordFolder()