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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user