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)); + } +}