diff --git a/Controls/AudioDock.xaml b/Controls/AudioDock.xaml
index 40ae1af..efabd9d 100644
--- a/Controls/AudioDock.xaml
+++ b/Controls/AudioDock.xaml
@@ -214,6 +214,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Helpers/AudioFold.cs b/Helpers/AudioFold.cs
new file mode 100644
index 0000000..0d3d920
--- /dev/null
+++ b/Helpers/AudioFold.cs
@@ -0,0 +1,27 @@
+using System;
+
+namespace ytLive.Helpers;
+
+/// The accessible mono fold: average each stereo pair into both channels.
+///
+/// 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.
+public static class AudioFold
+{
+ /// 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.
+ public static void ToMono(Span 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;
+ }
+ }
+}
diff --git a/Services/Audio/AudioMixer.cs b/Services/Audio/AudioMixer.cs
index da6b99d..51899e5 100644
--- a/Services/Audio/AudioMixer.cs
+++ b/Services/Audio/AudioMixer.cs
@@ -48,6 +48,32 @@ public sealed class AudioMixer : IDisposable
/// 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.
public float DuckGain => _ducker.CurrentGain;
+
+ /// 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.
+ public bool MonoOutput { get; set; }
+
+ /// Smoothed levels of the MIX (what the encoder receives), per channel.
+ public float MasterLeft { get; private set; }
+ public float MasterRight { get; private set; }
+
+ /// Largest channel level — one ladder for the master, honest about the pair.
+ public float MasterLevel => MathF.Max(MasterLeft, MasterRight);
+
+ /// 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".
+ 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);
}
+
+ /// One pass over the finished mix: 1024 samples per channel, so the cost is noise
+ /// beside the mixer it sits behind.
+ 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;
+ }
}
diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs
index 2def9cb..2448026 100644
--- a/Services/LayoutStore.Settings.cs
+++ b/Services/LayoutStore.Settings.cs
@@ -44,6 +44,13 @@ public partial class LayoutStore : IDisposable
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
}
+ /// 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.
+ public bool LoadMonoOutput() => GetSetting("Audio.MonoOutput") == "1";
+
+ public void SaveMonoOutput(bool enabled) => UpsertSetting("Audio.MonoOutput", enabled ? "1" : "0");
+
/// The user's chosen record output folder (TASK 18), or null to use the
/// default %APPDATA%\ytLlive\recordings. Restored at startup.
public string? LoadRecordFolder()
diff --git a/ViewModels/MainViewModel.Audio.cs b/ViewModels/MainViewModel.Audio.cs
index a892f7f..8763a37 100644
--- a/ViewModels/MainViewModel.Audio.cs
+++ b/ViewModels/MainViewModel.Audio.cs
@@ -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;
+
+ /// 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.
+ public bool MonoOutput
+ {
+ get => _monoOutput;
+ set
+ {
+ if (!SetProperty(ref _monoOutput, value)) return;
+ _audioMixer.MonoOutput = value;
+ _layoutStore.SaveMonoOutput(value);
+ }
+ }
+
+ /// Master ladder input: the measured mix, 0…1 on the same display scale.
+ public double MasterMeterFraction => AudioLevelMeter.ToDisplay(_audioMixer.MasterLevel);
+
+ public string MasterLevelLabel => Helpers.GainDb.Label(Helpers.GainDb.ToDb(_audioMixer.MasterLevel)) + " dB";
+
+ /// 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.
+ public bool MasterImbalance => _audioMixer.MasterImbalanceDb > 6f;
+
+ private void RaiseMasterReadouts()
+ {
+ OnPropertyChanged(nameof(MasterMeterFraction));
+ OnPropertyChanged(nameof(MasterLevelLabel));
+ OnPropertyChanged(nameof(MasterImbalance));
+ }
+
/// "0 ms (aligned)", "+50 ms", "−120 ms" — what the dock shows beside the slider.
public string SyncOffsetLabel => AudioSyncOffsetMs == 0
? "0 ms (aligned)"
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 984bde0..86b3303 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -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");
diff --git a/ytLive.Tests/AudioFoldTests.cs b/ytLive.Tests/AudioFoldTests.cs
new file mode 100644
index 0000000..b80c52f
--- /dev/null
+++ b/ytLive.Tests/AudioFoldTests.cs
@@ -0,0 +1,35 @@
+using Xunit;
+using ytLive.Helpers;
+
+namespace ytLive.Tests;
+
+/// 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.
+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);
+ }
+}
diff --git a/ytLive.Tests/MasterStripTests.cs b/ytLive.Tests/MasterStripTests.cs
new file mode 100644
index 0000000..470eb11
--- /dev/null
+++ b/ytLive.Tests/MasterStripTests.cs
@@ -0,0 +1,41 @@
+using Xunit;
+using ytLive.Helpers;
+using ytLive.Services.Audio;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+/// 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.
+///
+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));
+ }
+}