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