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
+34
View File
@@ -55,6 +55,7 @@ public partial class MainViewModel
OnPropertyChanged(nameof(MeterBrush));
OnPropertyChanged(nameof(MicMeterFraction));
UpdateDuckState();
RaiseMasterReadouts();
OnPropertyChanged(nameof(MicMeterFraction));
OnPropertyChanged(nameof(MicVolumeDb));
OnPropertyChanged(nameof(MicVolumeDbLabel));
@@ -200,6 +201,39 @@ public partial class MainViewModel
}
}
// ─── Master strip ────────────────────────────────────────────────────────
private bool _monoOutput;
/// <summary>Collapse the output to mono. Persisted, because a creator who needs it needs it on
/// every session — an accessibility switch that resets is an accessibility failure.</summary>
public bool MonoOutput
{
get => _monoOutput;
set
{
if (!SetProperty(ref _monoOutput, value)) return;
_audioMixer.MonoOutput = value;
_layoutStore.SaveMonoOutput(value);
}
}
/// <summary>Master ladder input: the measured mix, 0…1 on the same display scale.</summary>
public double MasterMeterFraction => AudioLevelMeter.ToDisplay(_audioMixer.MasterLevel);
public string MasterLevelLabel => Helpers.GainDb.Label(Helpers.GainDb.ToDb(_audioMixer.MasterLevel)) + " dB";
/// <summary>True when the mix's channels differ by more than 6 dB — the one thing L/R is
/// genuinely good for, shown as one flag instead of two ladders.</summary>
public bool MasterImbalance => _audioMixer.MasterImbalanceDb > 6f;
private void RaiseMasterReadouts()
{
OnPropertyChanged(nameof(MasterMeterFraction));
OnPropertyChanged(nameof(MasterLevelLabel));
OnPropertyChanged(nameof(MasterImbalance));
}
/// <summary>"0 ms (aligned)", "+50 ms", "−120 ms" — what the dock shows beside the slider.</summary>
public string SyncOffsetLabel => AudioSyncOffsetMs == 0
? "0 ms (aligned)"
+1
View File
@@ -269,6 +269,7 @@ public partial class MainViewModel : ViewModelBase
// (yellow) instead of silently falling back to the default.
MicSourceName = _layoutStore.LoadMicSourceName();
AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
MonoOutput = _layoutStore.LoadMonoOutput(); // applied to the mixer by the setter
try
{
var s = _layoutStore.GetSetting("ZoomShape");