TASK 8 meter scaling: +10 dB input amplification in AudioLevelMeter.ToDisplay so the meters use the full bar — speech peaks (~0.2 RMS, -14 dBFS) read ~0.93 (red zone) and normal speech (~0.05, -26 dBFS) ~0.73 (yellow) at maxed volume, instead of hovering at the red edge; ≤0.001 linear still reads zero so the meter never idles on background noise; one knob shared by the mic bar and the game bar (× MicVolume gain-on-noise untouched); tests updated + new ToDisplay_Pushes_Speech_Peaks_Into_Red_At_Maxed_Volume — 171 tests passing, 0 warnings

This commit is contained in:
2026-08-14 08:43:06 -07:00
parent d90b5ded0d
commit 0b71b030e4
7 changed files with 53 additions and 12 deletions
+8
View File
@@ -8,6 +8,14 @@
## Session state (last updated: 2026-08-13) ## Session state (last updated: 2026-08-13)
- **Branch:** `main`, in sync with `origin/main`. - **Branch:** `main`, in sync with `origin/main`.
- **TASK 8 (new, after TASK 7 shipped):** `AudioLevelMeter.ToDisplay` gained **+10 dB input
amplification** so the meters use the full bar — speech peaks (~0.2 RMS) now read ~0.93 (red)
and normal speech (~0.05) ~0.73 (yellow) at maxed volume; ≤0.001 linear still reads 0 (idle
never shows noise). One knob shared by the mic bar and game bar; `× MicVolume` untouched.
Tests updated + new `ToDisplay_Pushes_Speech_Peaks_Into_Red_At_Maxed_Volume`. **Uncommitted**
(pending user review): `Services/Audio/AudioLevelMeter.cs`, `ytLive.Tests/AudioMixerTests.cs`,
`ai.md`, `Services/index.md`, `ViewModels/index.md`, `TASKS.md`, `HANDOFF.md`. Meter tests:
7/7 passing, build 0 warnings.
- **This session (TASK 7 — UI polish batch, gramps's 6-point review):** - **This session (TASK 7 — UI polish batch, gramps's 6-point review):**
1. **Scenes list cleaned:** the per-row edit/trash/visibility icons and the inline 1. **Scenes list cleaned:** the per-row edit/trash/visibility icons and the inline
rename TextBox are gone. Scenes are pure selection rows; `IsHidden` stays rename TextBox are gone. Scenes are pure selection rows; `IsHidden` stays
+7 -4
View File
@@ -15,15 +15,18 @@ public sealed class AudioLevelMeter
/// <summary> /// <summary>
/// Maps a linear level (0..1) onto the meter's display scale: -60 dBFS..0 dBFS /// Maps a linear level (0..1) onto the meter's display scale: -60 dBFS..0 dBFS
/// spread linearly across 0..1. Linear RMS of real speech or game audio is /// spread linearly across 0..1, with +10 dB of input amplification. Linear RMS
/// ~0.01..0.1 (-40..-20 dBFS), which leaves a flat (linear) meter looking /// of real speech or game audio is ~0.01..0.1 (-40..-20 dBFS), which leaves a
/// dead; the log scale makes typical levels occupy the bar. /// flat (linear) meter looking dead; the log scale makes typical levels occupy
/// the bar and the amplification pushes real speech peaks into the red zone
/// (0.8+) at maxed volume instead of hovering at its edge. Inputs at or below
/// -60 dBFS (0.001 linear) read as zero — the meter never idles on background noise.
/// </summary> /// </summary>
public static float ToDisplay(float linear) public static float ToDisplay(float linear)
{ {
if (linear <= 0.001f) if (linear <= 0.001f)
return 0f; return 0f;
var db = 20f * MathF.Log10(linear); var db = 20f * MathF.Log10(linear) + 10f;
return Math.Clamp(1f + db / 60f, 0f, 1f); return Math.Clamp(1f + db / 60f, 0f, 1f);
} }
+1 -1
View File
@@ -49,7 +49,7 @@ External-facing logic: YouTube API, persistence. See
| `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it | | `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it |
| `Audio/WasapiMicAudioSource.cs` | Mic capture: NAudio `WasapiCapture` with the device resolved by `FriendlyName` matching `MicSourceName` (DisplayName only), default capture endpoint fallback; name provider `Func<string?>` re-read at each `Start` so a device picked mid-session takes effect immediately (the mixer restarts the mic on pick) | | `Audio/WasapiMicAudioSource.cs` | Mic capture: NAudio `WasapiCapture` with the device resolved by `FriendlyName` matching `MicSourceName` (DisplayName only), default capture endpoint fallback; name provider `Func<string?>` re-read at each `Start` so a device picked mid-session takes effect immediately (the mixer restarts the mic on pick) |
| `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter``MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed) | | `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter``MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed) |
| `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` and `GameAudioLevel`. `ToDisplay(float)` maps the raw linear RMS onto the meter's display scale (60..0 dBFS spread across 0..1) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead | | `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` and `GameAudioLevel`. `ToDisplay(float)` maps the raw linear RMS onto the meter's display scale (60..0 dBFS spread across 0..1, with **+10 dB input amplification** so real speech peaks hit the red zone at maxed volume) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead; ≤0.001 linear reads as zero (never idles on background noise) |
| `Audio/WaveToFloat.cs` | Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, `WaveFormatExtensible` IEEE-float subformat GUID, trailing partial samples ignored | | `Audio/WaveToFloat.cs` | Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, `WaveFormatExtensible` IEEE-float subformat GUID, trailing partial samples ignored |
| `IGameAudioDetector.cs` | **TASK 4 game audio bar seam**: `IsGameAudioActive` + `IsGameAudioActiveChanged` + `Poll()` — detects "a working game with sound is up in the preview" | | `IGameAudioDetector.cs` | **TASK 4 game audio bar seam**: `IsGameAudioActive` + `IsGameAudioActiveChanged` + `Poll()` — detects "a working game with sound is up in the preview" |
| `GameAudioHysteresis.cs` | Pure show/hide state machine for the game bar: SHOW = full-screen app holds sound ~500ms; HIDE = the app leaves fullscreen ~1s. **Silence never hides an active bar** — only the game leaving the preview does (creator's rule). No timers; `Update(isFullScreen, hasSound, now)` | | `GameAudioHysteresis.cs` | Pure show/hide state machine for the game bar: SHOW = full-screen app holds sound ~500ms; HIDE = the app leaves fullscreen ~1s. **Silence never hides an active bar** — only the game leaving the preview does (creator's rule). No timers; `Update(isFullScreen, hasSound, now)` |
+16
View File
@@ -623,6 +623,22 @@ the validator → persisted), compositor bar overlay (top/bottom + above-flash),
--- ---
## TASK 8 — Meter scaling amplification (voice meter uses the full bar)
**Goal:** the mic (and game) meters feel light — speech peaks should peg into the red at maxed volume.
### Status: ✅ Done
1. ✅ `AudioLevelMeter.ToDisplay` now adds **+10 dB of input amplification** before the 60..0 dBFS → 0..1 log mapping (was raw dB): speech peaks (~0.2 RMS, 14 dBFS) read ~0.93 → red zone; normal speech (~0.05, 26 dBFS) ~0.73 → yellow; background noise ≤0.001 linear (60 dBFS) still reads 0 (the meter never idles on it)
2. ✅ Unit tests updated to the new mapping + new `ToDisplay_Pushes_Speech_Peaks_Into_Red_At_Maxed_Volume` (0.2 → 0.92..0.95, 0.05 → 0.72..0.75)
### Design decisions
1. **Amplify at the mapping, not in the VM** — one knob (`ToDisplay`), shared by the mic bar and the game bar; the `× MicVolume` gain-on-noise behavior is untouched (raising the slider still moves ambient noise up the bar).
2. **+10 dB, not more** — louder boosts push quiet speech into the lower half and make the noise floor visible; +10 puts speech peaks solidly in red while idle stays at zero.
---
## Backlog (future versions) ## Backlog (future versions)
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization) 1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
+1 -1
View File
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -383,8 +383,11 @@ devices, no timers).
no longer touch the mixer). Mic samples feed a pure **`AudioLevelMeter`** (RMS with 0.2 exponential no longer touch the mixer). Mic samples feed a pure **`AudioLevelMeter`** (RMS with 0.2 exponential
smoothing) → `MicLevelChanged` → marshalled to the UI thread → `AudioLevel`; loopback samples feed a smoothing) → `MicLevelChanged` → marshalled to the UI thread → `AudioLevel`; loopback samples feed a
second meter → `LoopbackLevelChanged` → the game bar's `GameAudioLevel`. The raw linear level is second meter → `LoopbackLevelChanged` → the game bar's `GameAudioLevel`. The raw linear level is
mapped to the meter's display scale by `AudioLevelMeter.ToDisplay` (60..0 dBFS → 0..1): real speech/ mapped to the meter's display scale by `AudioLevelMeter.ToDisplay` (60..0 dBFS → 0..1 with **+10 dB
game RMS is ~0.01..0.1 linear, which would leave a flat scale looking dead. **Mic connection state input amplification**): real speech/game RMS is ~0.01..0.1 linear, which would leave a flat scale
looking dead; the amplification (added 2026-08-14) pushes real speech peaks (~0.2 RMS, 14 dBFS) to
~0.93 at maxed volume so the meter uses the whole bar proportionately instead of hovering at the red
edge. **Mic connection state
surfaces as events:** `MicConnected` (source `Started`), `MicFailed` (source `Failed`), and surfaces as events:** `MicConnected` (source `Started`), `MicFailed` (source `Failed`), and
`RestartMic()` re-resolves + restarts just the mic (loopback keeps running). Failures log via `RestartMic()` re-resolves + restarts just the mic (loopback keeps running). Failures log via
`AppLog`; a mic failure zeroes the meter, a loopback failure never kills the mic. Meter `Push` is `AppLog`; a mic failure zeroes the meter, a loopback failure never kills the mic. Meter `Push` is
+15 -4
View File
@@ -331,11 +331,22 @@ public class AudioLevelMeterTests
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0f)); Assert.Equal(0f, AudioLevelMeter.ToDisplay(0f));
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0.001f), 2); Assert.Equal(0f, AudioLevelMeter.ToDisplay(0.001f), 2);
// -60 dBFS floor → 0, 0 dBFS → 1, and a decade (~-20 dB) is 1/3 up the bar. // -60 dBFS floor → 0, 0 dBFS → 1, and +10 dB amplification: a decade
// (~-20 dB) is 2/3 up the bar.
Assert.Equal(1f, AudioLevelMeter.ToDisplay(1f), 3); Assert.Equal(1f, AudioLevelMeter.ToDisplay(1f), 3);
Assert.Equal(0.5f, AudioLevelMeter.ToDisplay(0.0316f), 2); Assert.InRange(AudioLevelMeter.ToDisplay(0.0316f), 0.66f, 0.67f);
Assert.InRange(AudioLevelMeter.ToDisplay(0.1f), 0.66f, 0.67f); Assert.InRange(AudioLevelMeter.ToDisplay(0.1f), 0.83f, 0.84f);
Assert.InRange(AudioLevelMeter.ToDisplay(0.01f), 0.32f, 0.34f); Assert.InRange(AudioLevelMeter.ToDisplay(0.01f), 0.49f, 0.51f);
}
[Fact]
public void ToDisplay_Pushes_Speech_Peaks_Into_Red_At_Maxed_Volume()
{
// ~0.2 linear RMS (-14 dBFS) is a loud speech peak; at maxed volume it
// must land in the red zone (0.8+) and normal speech (~0.05, -26 dBFS)
// in the yellow — the meter uses the whole bar proportionately.
Assert.InRange(AudioLevelMeter.ToDisplay(0.2f), 0.92f, 0.95f);
Assert.InRange(AudioLevelMeter.ToDisplay(0.05f), 0.72f, 0.75f);
} }
[Fact] [Fact]