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
+44
View File
@@ -214,6 +214,50 @@
<DockPanel VerticalAlignment="Center">
<TextBlock DockPanel.Dock="Left" Text="MASTER" FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
<!-- One ladder for the mix, plus the imbalance flag: two ladders would cost the
width that the telemetry needs, and the flag is the case that matters. -->
<controls:SegmentMeter DockPanel.Dock="Left" Width="120" Height="8" Margin="10,0,0,0"
Orientation="Horizontal" Segments="24"
Level="{Binding MasterMeterFraction}" VerticalAlignment="Center"/>
<TextBlock DockPanel.Dock="Left" Width="62" Margin="6,0,0,0" FontSize="10"
FontFamily="{StaticResource FontFamily.Mono}" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}"
Text="{Binding MasterLevelLabel}"/>
<Border DockPanel.Dock="Left" Margin="6,0,0,0" Padding="5,1" VerticalAlignment="Center"
CornerRadius="{StaticResource Radius.Sm}" Background="{StaticResource Brush.SurfaceContainerHigh}"
Visibility="{Binding MasterImbalance, Converter={StaticResource BoolToVis}}"
ToolTip="Left and right differ by more than 6 dB — one side may be inaudible.">
<TextBlock Text="L/R" FontSize="9" FontWeight="Bold" FontFamily="{StaticResource FontFamily.Mono}"
Foreground="{StaticResource Brush.Attention}"/>
</Border>
<!-- MONO: the accessibility switch, one click, remembered. -->
<ToggleButton DockPanel.Dock="Left" Margin="10,0,0,0" Height="20" Padding="8,0"
Style="{StaticResource SegmentToggle}" IsChecked="{Binding MonoOutput, Mode=TwoWay}"
ToolTip="Fold the output to mono — both ears hear everything">
<TextBlock Text="MONO" FontSize="10" FontWeight="Bold"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
</ToggleButton>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBlock FontSize="10" FontFamily="{StaticResource FontFamily.Mono}" Margin="0,0,12,0"
Foreground="{StaticResource Brush.OnSurfaceVariant}">
<Run Text="{Binding CurrentHealth.CurrentBitrate, StringFormat={}{0:0} kbps}"/>
</TextBlock>
<TextBlock FontSize="10" FontFamily="{StaticResource FontFamily.Mono}" Margin="0,0,12,0"
Foreground="{StaticResource Brush.OnSurfaceVariant}">
<Run Text="{Binding CurrentHealth.FPS, StringFormat={}{0:0} fps}"/>
</TextBlock>
<TextBlock FontSize="10" FontFamily="{StaticResource FontFamily.Mono}" Margin="0,0,12,0"
Foreground="{StaticResource Brush.OnSurfaceVariant}">
<Run Text="drop "/><Run Text="{Binding CurrentHealth.DroppedFrames}"/>
</TextBlock>
<TextBlock FontSize="10" FontFamily="{StaticResource FontFamily.Mono}"
Foreground="{StaticResource Brush.OnSurfaceVariant}"
Text="{Binding CurrentHealth.StreamDuration, StringFormat={}{0:hh\:mm\:ss}}"/>
</StackPanel>
</DockPanel>
</Border>
</Grid>
+27
View File
@@ -0,0 +1,27 @@
using System;
namespace ytLive.Helpers;
/// <summary>The accessible mono fold: average each stereo pair into both channels.
///
/// <para>Creator context (2026-10-30): profoundly deaf on the right. Hard-panned content is not
/// quieter to them, it is ABSENT, so a single-sided game or clip is unusable. Folding to mono puts
/// everything on both sides — the loudness cost is honest (a one-sided signal arrives at half
/// amplitude) and speech is unaffected because the mic is already centred.</para></summary>
public static class AudioFold
{
/// <summary>Folds an interleaved buffer in place. Mono buffers are left untouched: there is
/// nothing to combine, and rewriting them would be a silent no-op that hides a caller's bug.</summary>
public static void ToMono(Span<float> interleaved, int channels)
{
if (channels < 2) return;
var frames = interleaved.Length / channels;
for (var frame = 0; frame < frames; frame++)
{
var offset = frame * channels;
var mono = (interleaved[offset] + interleaved[offset + 1]) * 0.5f;
interleaved[offset] = mono;
interleaved[offset + 1] = mono;
}
}
}
+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()
+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");
+35
View File
@@ -0,0 +1,35 @@
using Xunit;
using ytLive.Helpers;
namespace ytLive.Tests;
/// <summary>The mono output exists because single-sided audio is inaudible to this creator, not
/// merely unbalanced. The fold has to move content to BOTH sides — a "fold" that keeps a channel
/// empty would leave the exact problem in place — and it must not touch mono buffers.</summary>
public class AudioFoldTests
{
[Fact]
public void Folding_to_mono_puts_a_one_sided_signal_on_both_sides()
{
// Hard LEFT content becomes audible on the right at half amplitude.
var left = new[] { 0.5f, 0f, 0.5f, 0f };
AudioFold.ToMono(left, 2);
Assert.Equal(new[] { 0.25f, 0.25f, 0.25f, 0.25f }, left);
// Hard RIGHT — the inaudible case — becomes audible on the left.
var right = new[] { 0f, 0.6f };
AudioFold.ToMono(right, 2);
Assert.Equal(0.3f, right[0], 4);
Assert.Equal(0.3f, right[1], 4);
// Centred material keeps its level (the mic case): no quietening.
var centred = new[] { 0.4f, 0.4f };
AudioFold.ToMono(centred, 2);
Assert.Equal(0.4f, centred[0], 4);
// Mono buffers are untouched — folding is about pairs.
var mono = new[] { 0.7f, 0.2f };
AudioFold.ToMono(mono, 1);
Assert.Equal(new[] { 0.7f, 0.2f }, mono);
}
}
+41
View File
@@ -0,0 +1,41 @@
using Xunit;
using ytLive.Helpers;
using ytLive.Services.Audio;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>The master strip is where the accessibility switch lives, so its wiring matters more
/// than its looks: MONO must reach the mixer, be remembered across sessions, and the strip's ladder
/// must show the MEASURED mix — with one L/R flag instead of two ladders.
/// </summary>
public class MasterStripTests
{
[Fact]
public void The_master_strip_shows_the_measured_mix_and_the_mono_switch_is_persisted()
{
var dock = SourceAssert.Text("Controls", "AudioDock.xaml");
Assert.Contains("IsChecked=\"{Binding MonoOutput, Mode=TwoWay}\"", dock);
Assert.Contains("Level=\"{Binding MasterMeterFraction}\"", dock);
Assert.Contains("Text=\"{Binding MasterLevelLabel}\"", dock);
Assert.Contains("Visibility=\"{Binding MasterImbalance, Converter={StaticResource BoolToVis}}\"", dock);
// One ladder on the master, not a pair.
Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(
dock[dock.IndexOf("MASTER", System.StringComparison.Ordinal)..], "SegmentMeter").Count);
// The switch reaches the mixer and the store.
Assert.NotNull(typeof(AudioMixer).GetProperty("MonoOutput"));
Assert.NotNull(typeof(MainViewModel).GetProperty("MonoOutput"));
var settings = SourceAssert.Text("Services", "LayoutStore.Settings.cs");
Assert.Contains("Audio.MonoOutput", settings);
Assert.Contains("LoadMonoOutput", settings);
// The mixer measures the finished mix, so the ladder cannot show a source instead of the output.
Assert.NotNull(typeof(AudioMixer).GetProperty("MasterLevel"));
Assert.NotNull(typeof(AudioMixer).GetProperty("MasterImbalanceDb"));
// And the fold is the tested helper, not new arithmetic in the mixer.
Assert.Contains("AudioFold.ToMono", SourceAssert.Text("Services", "Audio", "AudioMixer.cs"));
Assert.Equal(0.25f, GainDb.MonoFold(0.5f, 0f));
}
}