diff --git a/HANDOFF.md b/HANDOFF.md
index b6df16d..8aad425 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -5,43 +5,62 @@
> a problem. Conventions: [`schema.md`](schema.md). Rewrite this file at session
> end, compaction, or any interruption.
-## Session state (last updated: 2026-08-14)
+## Session state (last updated: 2026-08-15)
-- **Branch:** `main`, tracking `origin/main` at **`f2f6401`** (pushed). Working tree was clean until the
- monetization docs changeset below.
-- **AUDIO MILESTONE (TASK 9) — SHIPPED + COMMITTED + PUSHED this session.** Real stream audio + voice
- filters + auto-duck + free TRAX background music. Build **0 warnings**, full suite **196 passing**
- (ONE integration test: `AudioPipelineTests.Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe`). Commits
- **`6d71ace`** (milestone) + **`f2f6401`** (secrets cleanup), force-pushed (`725f5a7...f2f6401`).
-- **Secrets scrubbed from git history.** The DO token + passwords recorded in `HANDOFF.md` were removed
- from the working file and **purged from all history** via `git filter-branch` over `3d92bd0..main`
- + `git gc --prune=now --aggressive` (scan of all refs = 0 hits). They still exist in chat — **keep
- treating as compromised**; rotate the DO API token. Old objects remain in llgit's store until server
- GC (unreachable, not fetchable). If a value is ever needed, `git reflog` is expired — it's gone.
-- **MONETIZATION LOCKED (2026-08-14, creator).** One paid line = **annual subscription**: early access
- **$49.99/yr** → **$99/yr list at GA**, **grandfather-while-subscribed**, lapse → list on renewal.
- Free tier unchanged (branding flash = the billboard + no Alerts; Alerts is the paid feature).
- **Billing NOT itch-locked** — itch.io has no native subscription billing; candidates Gumroad (native
- affiliates = tiebreaker) / Lemon Squeezy. Support = in-app bug-report → git issues, "when I get
- around to it" + emergency patches. Full policy in `ai.md` → Monetization; scoped plan + line-items
- in `TASKS.md` **TASK 23**.
-- **DEVELOPMENT PAUSED (creator's call 2026-08-14) — resuming streaming instead.** Channel revival =
- **pair-programming dev streams via OBS** (ytLlive can't go live yet: private-only + `_rtmpUrlProvider`
- null) + **The Division 2** gaming streams under the **"gramps/GOAT"** persona (67, gaming since 1981).
- 3D-printing content retired (printer hard down, novelty gone). **Dogfood switch** later: when TASK 5 +
- go-live capability land, stream *with* ytLlive — Division 2 (loud game + voice) is the demo for the
- ducking/voice-filter story. Early-access strategy + affiliate/ads are their own future line-item tasks.
-- **Docs changeset in flight (THIS changeset):** `ai.md` Monetization rewritten + stale paid-unlock seams
- fixed; `TASKS.md` TASK 23 added + TASK 21's itch.io URL mention corrected; this file rewritten. Commit
- + push as one change.
-- **Shipped, all pushed:** TASK 21 (creator-hub About, `3d92bd0`), TASK 8 (meter +10 dB), TASK 4 ship
- step 7 (one-click go-live + private-only), TASK 9 audio milestone (`6d71ace`), secrets cleanup
- (`f2f6401`).
-- **Resume point (when dev starts again):** **TASK 5** — reusable stream → `_rtmpUrlProvider` (the last
- blocker before go-live actually encodes + pushes). Then TASK 23 (billing + unlock + support).
+- **Branch:** `main`, tracking `origin/main`. Working tree: the **TASK 24 polish batch** below is
+ committed + pushed. Local branches `social-bar`/`webcam-validation` untouched (no secrets).
+- **TASK 24 — POST-PAUSE POLISH BATCH — SHIPPED + COMMITTED + PUSHED 2026-08-15.** All 8 creator
+ review issues fixed in one branch (details below). Build **0 warnings**, full suite **197 passing**
+ (ONE integration test for this branch: `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`).
+- **AUDIO MILESTONE (TASK 9) — SHIPPED + COMMITTED + PUSHED.** Real stream audio + voice filters +
+ auto-duck + free TRAX background music. Commits `6d71ace` (milestone) + `f2f6401` (secrets cleanup),
+ force-pushed (`725f5a7...f2f6401`).
+- **MONETIZATION LOCKED (2026-08-14, creator) — committed `86fcd86`.** One paid line = **annual
+ subscription**: early access **$49.99/yr** → **$99/yr list at GA**, **grandfather-while-subscribed**,
+ lapse → list on renewal. Free tier unchanged (branding flash = the billboard + no Alerts; Alerts is
+ the paid feature). **Billing NOT itch-locked**; candidates Gumroad (native affiliates =
+ tiebreaker) / Lemon Squeezy. Support = in-app bug-report → git issues. Full policy in `ai.md` →
+ Monetization; scoped plan in `TASKS.md` **TASK 23**.
+- **Secrets scrubbed from git history.** The DO token + passwords were purged via `git filter-branch`
+ + `git gc --prune=now --aggressive` (all-refs scan = 0 hits); values still exist in chat — keep
+ treating as **compromised**; rotate the DO API token.
+- **Shipped, all pushed:** TASK 24 polish batch, TASK 21 (creator-hub About, `3d92bd0`), TASK 8
+ (meter +10 dB), TASK 4 ship step 7 (one-click go-live + private-only), TASK 9 audio milestone
+ (`6d71ace`), secrets cleanup (`f2f6401`), monetization docs (`86fcd86`).
+- **Resume point (next branch):** **TASK 5** — reusable stream → `_rtmpUrlProvider` (the last blocker
+ before go-live actually encodes + pushes). Then TASK 23 (billing + unlock + support).
Optional, not queued: rewriting the healed entry's `ProfileUrl` to
`https://mastodon.llamachile.tube/@gramps` (user must say the word).
+## TASK 24 — the 8-issue polish batch — SHIPPED 2026-08-15 (what changed)
+
+The creator's review of the TASK 9 build, all fixed in one branch (full record in `TASKS.md` TASK 24).
+
+1. ✅ **Desktop/game audio volume slider had no effect** — the `AudioMixer` gain seams defaulted to
+ unity (the VM never passed them): the slider/mute were decorative, stream AND local. Fixed with a
+ new **`AudioGainProvider`** (`Services/Audio/`) wired into the mixer at construction, read live
+ each mix tick.
+2. ✅ **Desktop/game mute had no effect** — same wiring; `GameMuted` now zeroes the loopback on the
+ stream AND silences the music locally via new `MusicPlayer.LocalGain` (scaled by the game bar on
+ every volume/mute change + on TRAX load) — the creator hears the control work in the headphones.
+3. ✅ **TRAX played once then stopped** — `OnPlaybackStopped` only looped when `Position >= Length`
+ (unreliable for `MediaFoundationReader`); now any clean stop rewinds + replays. Dropped the unused
+ `using System.Runtime.InteropServices;` too.
+4. ✅ **TRAX/MIC buttons swapped** — footer mic cluster is now MIC + meter + mute + volume (TRAX is out
+ of the mic cluster entirely).
+5. ✅ **Mic source persists across restarts** — `LayoutStore` gained a `Settings` key/value table
+ (`SaveMicSourceName`/`LoadMicSourceName`); the VM saves on pick and restores before the mixer's
+ first `Start` → same already-vetted device reconnects green on restart, missing → yellow.
+6. ✅ **TRAX moved right of Socials** — both centered under the scenes/sources listboxes (footer line
+ 1, left cluster).
+7. ✅ **Backdrop icons shifted right** — new `HiddenBoolToVisibilityConverter` keeps the trash column
+ reserved (`Hidden`, not `Collapsed`) so edit/eye stay in fixed columns for every source row.
+8. ✅ **No separation between scenes and sources** — a 1px hairline with top/bottom padding now sits
+ between the two left-panel listboxes.
+
+**THE ONE integration test:** `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` — real mixer +
+`AudioGainProvider` gains through the pipe harness: scaled loopback audible at 0.5, silence after mute.
+
## TASK 9 audio milestone — SHIPPED 2026-08-14 (what changed)
The creator's feature review settled this as the single next branch ("all the audio issues done and
diff --git a/Helpers/HiddenBoolToVisibilityConverter.cs b/Helpers/HiddenBoolToVisibilityConverter.cs
new file mode 100644
index 0000000..daf6aa4
--- /dev/null
+++ b/Helpers/HiddenBoolToVisibilityConverter.cs
@@ -0,0 +1,20 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace ytLive.Helpers;
+
+///
+/// Maps bool → Visibility, collapsing nothing: true becomes Hidden (keeps
+/// its layout slot) rather than Collapsed (releases it). Used where a row
+/// must keep its column grid aligned — e.g. the Backdrop source's missing
+/// trashcan must not shift the edit/eye icons one slot to the right.
+///
+public class HiddenBoolToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ => value is true ? Visibility.Hidden : Visibility.Visible;
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ => value is Visibility.Visible;
+}
diff --git a/MainWindow.xaml b/MainWindow.xaml
index b23a3c5..667627e 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -33,6 +33,8 @@
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
+
@@ -148,6 +150,7 @@
+
@@ -205,8 +208,12 @@
+
+
+
-
+
-
+ Visibility="{Binding IsBackdrop, Converter={StaticResource HiddenBoolToVis}}">
@@ -322,7 +329,7 @@
-
@@ -750,8 +757,9 @@
preview while a full-screen game is producing sound or
TRAX music is playing (the game audio detector). Meter
+ mute + volume control live here; desktop audio itself
- is automatic WASAPI loopback, and since TASK 9 it rides
- into the live mix (game volume + mute are stream-honest). -->
+ is automatic WASAPI loopback. Volume + mute are
+ stream-honest AND scale the TRAX music in the
+ headphones, so the creator hears the control work. -->
-
+ Audio is KISS: desktop/game audio is automatic (WASAPI loopback);
+ the mic meter is the creator's only footer audio control (the
+ game audio bar lives INSIDE the preview window, overlaid at its
+ bottom edge while a full-screen game is up). -->
@@ -868,12 +876,14 @@
-
+
+
+
@@ -896,21 +921,6 @@
-
-
diff --git a/Services/Audio/AudioGainProvider.cs b/Services/Audio/AudioGainProvider.cs
new file mode 100644
index 0000000..5eb1b51
--- /dev/null
+++ b/Services/Audio/AudioGainProvider.cs
@@ -0,0 +1,33 @@
+namespace ytLive.Services.Audio;
+
+///
+/// Supplies the live-mix gain values (mic + loopback) as Func seams for
+/// , sourced from the VM's volume/mute state. Read live
+/// on every mix tick so slider drags and mutes take effect immediately on the
+/// stream. Mute and zero volume can never disagree: 0 always reads as muted.
+///
+public sealed class AudioGainProvider
+{
+ private readonly Func _micMuted;
+ private readonly Func _micVolume;
+ private readonly Func _gameMuted;
+ private readonly Func _gameVolume;
+
+ public AudioGainProvider(
+ Func micMuted,
+ Func micVolume,
+ Func gameMuted,
+ Func gameVolume)
+ {
+ _micMuted = micMuted;
+ _micVolume = micVolume;
+ _gameMuted = gameMuted;
+ _gameVolume = gameVolume;
+ }
+
+ /// Mic gain for the live mix (0 = muted).
+ public double MicGain() => _micMuted() ? 0 : _micVolume();
+
+ /// Loopback (desktop/game) gain for the live mix (0 = muted).
+ public double LoopbackGain() => _gameMuted() ? 0 : _gameVolume();
+}
diff --git a/Services/Audio/MusicPlayer.cs b/Services/Audio/MusicPlayer.cs
index a06b87b..fedf728 100644
--- a/Services/Audio/MusicPlayer.cs
+++ b/Services/Audio/MusicPlayer.cs
@@ -1,5 +1,4 @@
using System.IO;
-using System.Runtime.InteropServices;
using NAudio.Wave;
namespace ytLive.Services.Audio;
@@ -9,11 +8,14 @@ namespace ytLive.Services.Audio;
/// fixed quiet volume so it rides the WASAPI loopback into the mix — the same
/// physical path game audio takes, so music is ducked with the game, heard in
/// headphones, and bounced on the desktop audio meter. Loops on natural end.
+/// The desktop/game bar's gain and mute scale , so the
+/// creator hears the game-audio controls work on the music too.
///
public sealed class MusicPlayer : IDisposable
{
- /// The one and only music level — quiet bed, no slider (the
- /// creator's voice must stay on top; game/music loudness is the ducker's job).
+ /// The one and only music bed — quiet, no slider (the creator's
+ /// voice must stay on top; game/music loudness is the ducker's job). The
+ /// desktop audio bar scales this via .
public const float MusicVolume = 0.20f;
private MediaFoundationReader? _reader;
@@ -21,6 +23,7 @@ public sealed class MusicPlayer : IDisposable
private WaveOutEvent? _output;
private bool _disposed;
private bool _stopping;
+ private float _localGain = 1f;
/// Raised when playback ends or is stopped (not on natural-end loop).
public event Action? PlaybackEnded;
@@ -31,6 +34,19 @@ public sealed class MusicPlayer : IDisposable
public bool IsPlaying => _output?.PlaybackState == PlaybackState.Playing;
+ /// Local gain multiplier from the desktop/game audio bar (0 = mute,
+ /// 1 = full bed). Applied live on top of the fixed .
+ public float LocalGain
+ {
+ get => _localGain;
+ set
+ {
+ _localGain = Math.Clamp(value, 0f, 1f);
+ if (_volume != null)
+ _volume.Volume = MusicVolume * _localGain;
+ }
+ }
+
/// Loads a track and prepares playback (does not start it).
public void Load(string path)
{
@@ -40,7 +56,7 @@ public sealed class MusicPlayer : IDisposable
Stop();
TrackPath = path;
_reader = new MediaFoundationReader(path);
- _volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume };
+ _volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume * _localGain };
_output = new WaveOutEvent();
_output.PlaybackStopped += OnPlaybackStopped;
_output.Init(_volume);
@@ -95,9 +111,11 @@ public sealed class MusicPlayer : IDisposable
if (_disposed || _stopping || _output == null || _reader == null)
return;
- // Natural end → loop the track; anything else (an explicit stop or a
- // device failure) surfaces via PlaybackEnded so the UI can reset the dot.
- if (_reader.Position >= _reader.Length)
+ // Any clean stop is a natural end → loop the track (the Position/Length
+ // comparison is unreliable for MediaFoundationReader, so a track can
+ // otherwise play once and stop). Only an explicit stop or a device
+ // failure surfaces via PlaybackEnded so the UI can reset the dot.
+ if (e.Exception == null)
{
_reader.Position = 0;
_output.Play();
diff --git a/Services/LayoutStore.cs b/Services/LayoutStore.cs
index 796cf71..553b77d 100644
--- a/Services/LayoutStore.cs
+++ b/Services/LayoutStore.cs
@@ -135,6 +135,12 @@ public class LayoutStore : IDisposable
IsEnabled INTEGER NOT NULL DEFAULT 0
);
""",
+ """
+ CREATE TABLE IF NOT EXISTS Settings (
+ Key TEXT PRIMARY KEY,
+ Value TEXT NOT NULL
+ );
+ """,
};
foreach (var sql in statements)
{
@@ -585,6 +591,30 @@ public class LayoutStore : IDisposable
return scenes;
}
+ /// The last picked mic source name, or null if never chosen. Restored
+ /// at startup so the sound bar reconnects to the same, already-vetted device.
+ public string? LoadMicSourceName()
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'MicSourceName';";
+ var value = cmd.ExecuteScalar() as string;
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+
+ public void SaveMicSourceName(string? name)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM Settings WHERE Key = 'MicSourceName';";
+ cmd.ExecuteNonQuery();
+ if (string.IsNullOrWhiteSpace(name))
+ return;
+
+ using var insert = _connection.CreateCommand();
+ insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('MicSourceName', $name);";
+ insert.Parameters.AddWithValue("$name", name);
+ insert.ExecuteNonQuery();
+ }
+
public void Save(IEnumerable scenes, Webcam? webcam, SocialsConfig? socials, Music? music = null)
{
using var tx = _connection.BeginTransaction();
diff --git a/TASKS.md b/TASKS.md
index 1633a2e..25ab3a1 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -562,7 +562,7 @@ the validator → persisted), compositor bar overlay (top/bottom + above-flash),
**Goal:** Create/bind broadcasts, monitor YouTube-side stream health — the v3 way.
-### Status: ⏳ Not started — runs AFTER the TASK 9 audio milestone (creator's pick, 2026-08-14); `_currentBroadcastId` is already stashed from TASK 4 for its bind/transition work
+### Status: ⏳ Not started — runs AFTER the TASK 24 polish batch (creator's pick, 2026-08-15); `_currentBroadcastId` is already stashed from TASK 4 for its bind/transition work
1. ☐ Broadcast creation — title/description/privacy/scheduledStartTime via API, with the v3 flags above
2. ☐ Reusable stream — create once, cache + reuse; bind to broadcast
@@ -716,6 +716,50 @@ planning — those are separate line-item tasks. TASK 23 owns billing + unlock +
---
+## TASK 24 — Post-pause polish batch: the creator's 8 review issues (2026-08-15, shipped)
+
+**Status: ✅ SHIPPED 2026-08-15 — 197 tests passing, 0 warnings (ONE integration test:
+`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`).**
+
+The creator reviewed the TASK 9 build and filed 8 issues. All fixed in one branch on `main`:
+
+1. ✅ **Desktop/game audio volume slider had no effect** (headset OR stream) — the `AudioMixer`'s
+ `micGain`/`loopbackGain` seams defaulted to unity because the VM never passed them; the sliders
+ were decorative. Fixed: new `AudioGainProvider` (`Services/Audio/`) hands `MicMuted`/`MicVolume`/
+ `GameMuted`/`GameAudioVolume` to the mixer at construction, read live each mix tick.
+2. ✅ **Desktop/game mute button had no effect** — same root cause; `GameMuted` now zeroes the
+ loopback on the stream. Locally, `MusicPlayer.LocalGain` (new) is scaled by the game bar on every
+ volume/mute change + on TRAX load, so the creator **hears** the control work on the music (the
+ track rides the loopback channel, so it ducks locally exactly as it does on stream).
+3. ✅ **TRAX played once then stopped** — `MusicPlayer.OnPlaybackStopped` only looped when
+ `Position >= Length`, unreliable for `MediaFoundationReader`; now any clean stop
+ (`e.Exception == null`) rewinds + replays; only errors/explicit stops surface via `PlaybackEnded`.
+4. ✅ **TRAX/MIC buttons swapped** — the footer's mic cluster is now MIC + meter + mute + volume; TRAX
+ no longer sits between MIC and the (mic-specific) meter.
+5. ✅ **Mic source persists across restarts** — `LayoutStore` gained a `Settings` key/value table
+ (`SaveMicSourceName`/`LoadMicSourceName`); the VM saves on pick and restores before the mixer's
+ first `Start`, so a restart reconnects the same already-vetted device (green dot) or reports it
+ missing (yellow) instead of falling back to the default endpoint.
+6. ✅ **TRAX moved right of Socials** — both centered under the scenes/sources listboxes (footer line
+ 1, left cluster).
+7. ✅ **Backdrop's icons shifted right** — the trash button's `Collapsed` released its column; a new
+ `HiddenBoolToVisibilityConverter` keeps the slot reserved (`Hidden`), so edit/eye stay in their
+ fixed columns for every source row.
+8. ✅ **No separation between scenes and sources** — a 1px hairline with top/bottom padding now sits
+ between the two listboxes in the left panel.
+
+### Design decisions
+
+1. **The gain seams are the one source of truth** — `AudioGainProvider` is a thin seam (four Funcs)
+ so the mixer contract and the mute⇔zero-volume rule are testable without constructing the VM.
+2. **Stream-honest AND locally audible** — the game bar's controls now do two things: scale the
+ loopback on the stream and scale the music in the headphones. Native game audio is untouched
+ (system output, OBS-style non-monitored); an OBS-style monitor loop is out of scope.
+3. **Persist the DisplayName, not the device ID** — the FriendlyName match was already the mic's
+ identity end-to-end; `Settings` just makes it survive restarts.
+
+---
+
## Backlog (future versions)
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index dd9cdd5..8ee7a6b 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -58,6 +58,7 @@ public class MainViewModel : ViewModelBase
private readonly MusicPlayer _musicPlayer = new();
private Music? _music;
private bool _musicPlaying;
+ private AudioGainProvider? _gainProvider;
private StreamStatus _streamStatus = StreamStatus.Offline;
private StreamHealth _currentHealth = new();
private string _streamTitle = string.Empty;
@@ -450,6 +451,7 @@ public class MainViewModel : ViewModelBase
if (dialog.ShowDialog() == true && dialog.PickedDevice != null)
{
MicSourceName = dialog.PickedDevice.DisplayName;
+ _layoutStore.SaveMicSourceName(MicSourceName); // persist across restarts
_audioMixer.RestartMic(); // swap the live device immediately
}
}
@@ -560,6 +562,7 @@ public class MainViewModel : ViewModelBase
try
{
_musicPlayer.Load(_music.TrackPath);
+ UpdateTraxLocalGain();
_music.IsEnabled = true;
if (play)
_musicPlayer.Play();
@@ -591,6 +594,15 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(TraxToolTip));
}
+ /// Mirrors the desktop/game audio bar's volume and mute onto the
+ /// music so the creator hears the controls work in the headphones (the
+ /// track rides the loopback channel, so it must duck the same way locally).
+ /// Called whenever the game volume/mute change and on TRAX load.
+ private void UpdateTraxLocalGain()
+ {
+ _musicPlayer.LocalGain = GameMuted ? 0f : (float)GameAudioVolume;
+ }
+
/// Live desktop/game input level (0..1), fed by the mixer's loopback
/// capture. Read by the game meter, scaled by GameAudioVolume.
public double GameAudioLevel
@@ -680,6 +692,7 @@ public class MainViewModel : ViewModelBase
_gameVolumeBeforeMute = null;
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
+ UpdateTraxLocalGain();
}
}
}
@@ -1148,6 +1161,11 @@ public class MainViewModel : ViewModelBase
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath;
+ // Restore the last picked mic so the mixer's first capture attaches to
+ // the same, already-vetted device (green dot) or reports it missing
+ // (yellow) instead of silently falling back to the default.
+ MicSourceName = _layoutStore.LoadMicSourceName();
+
_cameraEnumerator = new MediaCaptureCameraEnumerator();
_cameraManager = new CameraManager(
_cameraEnumerator,
@@ -1158,10 +1176,21 @@ public class MainViewModel : ViewModelBase
_microphoneEnumerator = new WinRtMicrophoneEnumerator();
+ // The mixer consumes these Func seams live each mix tick, so the volume
+ // sliders and mute buttons are stream-honest (they also update the
+ // meters' display). Before this wiring the gains defaulted to unity and
+ // the controls were decorative.
+ _gainProvider = new AudioGainProvider(
+ () => MicMuted,
+ () => MicVolume,
+ () => GameMuted,
+ () => GameAudioVolume);
_audioMixer = new AudioMixer(
new WasapiMicAudioSource(() => MicSourceName),
new WasapiLoopbackAudioSource(),
- message => AppLog.Write(message));
+ message => AppLog.Write(message),
+ micGain: _gainProvider.MicGain,
+ loopbackGain: _gainProvider.LoopbackGain);
_audioMixer.MicLevelChanged += OnMicLevelChanged;
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
_audioMixer.MicConnected += OnMicConnected;
diff --git a/ViewModels/index.md b/ViewModels/index.md
index 3e71605..eb344d5 100644
--- a/ViewModels/index.md
+++ b/ViewModels/index.md
@@ -4,7 +4,7 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
-| `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **go-live (ship step 7): `BeginGoLive` accepts the dialog → sets `StreamVisibility` (always "Private") → fires `CreateBroadcastAsync` (stores `_currentBroadcastId` for TASK 5's bind/transition; failure → `StreamStatus.Error`) + `_framePump.StartAsync()`**; **End Livestream signs out** (`StopStream` clears session + token + `_currentBroadcastId` — crash-safe). **Connected account:** top-bar avatar/name (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`, cleared on End). **Element rows:** scenes are pure selection rows (no per-row icons); source rows carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`); duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3… — next free number derived from actual names, so deletions never collide; used by `AddSource` + `AddReusedImage`). **Audio (KISS — the mic is the creator's only audio control; capture SHIPPED, runs for the app's lifetime so the meters preview live):** the sound meter is a **READ-ONLY realtime level display**: fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)` (`MeterFillWidth`/`MeterBrush`, green→yellow→red, zone markers at 60%/80% — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS scale with +10 dB amplification so real speech/game levels occupy the bar and peaks hit red at maxed volume), where `AudioLevel` (live input, 0 with no input) is fed by the audio mixer once capture lands and `MicVolume` acts as a gain on ambient noise; while the slider is dragged the bar previews the slider position (`SetVolumeAdjusting`), returning to the live level on release (0 with no input — clicking the meter does nothing); `MicVolume` (default 0.8) + read-only `MicMuted`/`MicMuteText`/`ToggleMicMuteCommand` — **MicVolume drives MicMuted** (muted ⇔ volume 0): sliding to 0 flips the speaker to muted, sliding up from 0 clears it; muting stores the prior volume, unmuting restores it (default 0.8 if unknown) and flashes the meter to the restored position ~300ms (`BeginVolumeFlash`/`EndVolumeFlash`); `MicSourceName` = picked voice source, shown left-justified inside the meter bar (the fill runs at 75% opacity so the text + ruler markings show through); `OpenMicPickerCommand`/`PickMicrophone()` open the `MicPickerDialog` (a picked device takes effect immediately — `PickMicrophone` swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running); the **MIC label is a button** (`OpenMicPickerCommand`) with a **status dot** (`MicStatus`, `Models/MicStatus`: green = `MicConnected` via the source's `Started` event, yellow = `MicFailed` — in use/unplugged, red = no mic device at startup; `MicStatusBrush`/`MicStatusToolTip`); capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live (`BeginGoLive`/`StopStream` no longer touch the mixer) — zero devices = red dot + the mixer never starts. **Game audio bar** (desktop/game, **overlaid at the bottom of the preview window** — bottom-center chip, a mirror of the mic bar): `IsGameAudioBarVisible` (shown only while a full-screen game is producing sound — the VM polls `IGameAudioDetector` via the default `GameAudioDetector` every 250ms (`_gameAudioTimer`); the pure `GameAudioHysteresis` SHOWs after ~500ms of fullscreen+sound, HIDEs ~1s after leaving fullscreen, and **silence never hides an active bar**), `GameAudioLevel` (loopback meter via `LoopbackLevelChanged`, scaled by volume), `GameMuted`/`GameMuteText`/`ToggleGameMuteCommand`, `GameAudioVolume` (0..1 volume slider), `Begin/End/CancelGameVolumeFlash` (mirrors the mic bar's volume flash); the game speaker + slider share the mic bar's `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` code-behind pattern. **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`). **REC sign (2026-08-13):** top-center indicator always visible — `RecDotBrush` (offline `#555555`, live `#e94560`, live-private `#8f1f1f`), `RecTextBrush` (dim offline, white live), `RecDotOpacity` (0.55 offline, pulsing 1.0/0.35 live via `_recDotPulse` flipped on the live tick), `IsLivePrivate` (`IsLive && StreamVisibility == "Private"`) — notified from the `StreamStatus` + `StreamVisibility` setters; `LiveIndicatorVisible`/`LivePulseOpacity` removed. **Mic mute icon:** a second 16px clickable glyph (mic, red + slash when muted) between the meter and the speaker on the mic bar — same `ToggleMicMuteCommand`. **Health stats (TASK 4 ship step 6, 2026-08-13):** `OnFramePumpHealthUpdated` marshals `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration — raised on the stderr thread) onto the UI thread into `CurrentHealth` (bottom bar bindings); `ResetHealth(status)` zeroes dropped/duration on go-live/End so stats never linger |
+| `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **go-live (ship step 7): `BeginGoLive` accepts the dialog → sets `StreamVisibility` (always "Private") → fires `CreateBroadcastAsync` (stores `_currentBroadcastId` for TASK 5's bind/transition; failure → `StreamStatus.Error`) + `_framePump.StartAsync()`**; **End Livestream signs out** (`StopStream` clears session + token + `_currentBroadcastId` — crash-safe). **Connected account:** top-bar avatar/name (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`, cleared on End). **Element rows:** scenes are pure selection rows (no per-row icons); source rows carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; the Backdrop's trash slot stays **reserved** via `HiddenBoolToVisibilityConverter` so the edit/eye icons keep their fixed columns — TASK 24); duplicate resource names get a no-space incrementing suffix via shared `NextSourceName` (Image, Image2, Image3… — next free number derived from actual names, so deletions never collide; used by `AddSource` + `AddReusedImage`). **Audio (KISS — the mic is the creator's only audio control; capture SHIPPED, runs for the app's lifetime so the meters preview live):** the sound meter is a **READ-ONLY realtime level display**: fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)` (`MeterFillWidth`/`MeterBrush`, green→yellow→red, zone markers at 60%/80% — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS scale with +10 dB amplification so real speech/game levels occupy the bar and peaks hit red at maxed volume), where `AudioLevel` (live input, 0 with no input) is fed by the audio mixer once capture lands and `MicVolume` acts as a gain on ambient noise; while the slider is dragged the bar previews the slider position (`SetVolumeAdjusting`), returning to the live level on release (0 with no input — clicking the meter does nothing); `MicVolume` (default 0.8) + read-only `MicMuted`/`MicMuteText`/`ToggleMicMuteCommand` — **MicVolume drives MicMuted** (muted ⇔ volume 0): sliding to 0 flips the speaker to muted, sliding up from 0 clears it; muting stores the prior volume, unmuting restores it (default 0.8 if unknown) and flashes the meter to the restored position ~300ms (`BeginVolumeFlash`/`EndVolumeFlash`); `MicSourceName` = picked voice source, shown left-justified inside the meter bar (the fill runs at 75% opacity so the text + ruler markings show through); `OpenMicPickerCommand`/`PickMicrophone()` open the `MicPickerDialog` (a picked device takes effect immediately — `PickMicrophone` swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running); the **MIC label is a button** (`OpenMicPickerCommand`) with a **status dot** (`MicStatus`, `Models/MicStatus`: green = `MicConnected` via the source's `Started` event, yellow = `MicFailed` — in use/unplugged, red = no mic device at startup; `MicStatusBrush`/`MicStatusToolTip`); capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live (`BeginGoLive`/`StopStream` no longer touch the mixer) — zero devices = red dot + the mixer never starts. **Game audio bar** (desktop/game, **overlaid at the bottom of the preview window** — bottom-center chip, a mirror of the mic bar): `IsGameAudioBarVisible` (shown only while a full-screen game is producing sound — the VM polls `IGameAudioDetector` via the default `GameAudioDetector` every 250ms (`_gameAudioTimer`); the pure `GameAudioHysteresis` SHOWs after ~500ms of fullscreen+sound, HIDEs ~1s after leaving fullscreen, and **silence never hides an active bar**), `GameAudioLevel` (loopback meter via `LoopbackLevelChanged`, scaled by volume), `GameMuted`/`GameMuteText`/`ToggleGameMuteCommand`, `GameAudioVolume` (0..1 volume slider), `Begin/End/CancelGameVolumeFlash` (mirrors the mic bar's volume flash); the game speaker + slider share the mic bar's `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` code-behind pattern. **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`). **REC sign (2026-08-13):** top-center indicator always visible — `RecDotBrush` (offline `#555555`, live `#e94560`, live-private `#8f1f1f`), `RecTextBrush` (dim offline, white live), `RecDotOpacity` (0.55 offline, pulsing 1.0/0.35 live via `_recDotPulse` flipped on the live tick), `IsLivePrivate` (`IsLive && StreamVisibility == "Private"`) — notified from the `StreamStatus` + `StreamVisibility` setters; `LiveIndicatorVisible`/`LivePulseOpacity` removed. **Mic mute icon:** a second 16px clickable glyph (mic, red + slash when muted) between the meter and the speaker on the mic bar — same `ToggleMicMuteCommand`. **TASK 24 (2026-08-15):** the gain seams (`AudioGainProvider` → `micGain`/`loopbackGain`) reach the mixer at construction so volume sliders + mutes are stream-honest; the game bar also scales TRAX locally (`MusicPlayer.LocalGain`); `MicSourceName` persists via `LayoutStore`'s `Settings` table + restores before first `Start`; TRAX moved next to Socials in the footer's left cluster. **Health stats (TASK 4 ship step 6, 2026-08-13):** `OnFramePumpHealthUpdated` marshals `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration — raised on the stderr thread) onto the UI thread into `CurrentHealth` (bottom bar bindings); `ResetHealth(status)` zeroes dropped/duration on go-live/End so stats never linger |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
| **About hub (TASK 21, in `MainViewModel`)** | the About overlay is the in-app creator hub: `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public consts, live) + `PremiumUrl` (tabled billing seam — TASK 23 → the greyed "Unlock Premium" button lights up when filled); `OpenLicensesCommand` → `ShowLicenses()` loads the shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` and sets `IsLicensesOpen` (in-app scrolling panel, never the OS viewer); `BackToAbout_Click` in `MainWindow.xaml.cs` flips it off. |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
diff --git a/ai.md b/ai.md
index 16dfa6e..a7ec939 100644
--- a/ai.md
+++ b/ai.md
@@ -402,26 +402,39 @@ devices, no timers).
`mixInterval` (default 10 ms) `FillAndMix` reads a chunk from each ring buffer (silence-fills
underruns), computes the post-filter mic RMS → `AutoDucker` (threshold 0.02, duck ×0.25 / −12 dB,
attack 0.05, release 0.005, always on), applies the **honest gains** via `Func` seams
- (`micGain = MicVolume` so mute = 0; `loopbackGain = GameMuted ? 0 : GameAudioVolume`, × duck), mixes
- mono mic + stereo loopback into interleaved stereo (no clamp — gains are user-owned), and writes the
- floats to the pipe. `StopStream` → `StopLive()` (closes the pipe → ffmpeg audio EOF) **BEFORE**
- stopping the frame pump (video EOF) — the reverse order stalls on pipe backpressure. `FfmpegEncoder`
- itself is untouched; the VM owns the pipe lifecycle.
+ (`micGain = MicMuted ? 0 : MicVolume`; `loopbackGain = GameMuted ? 0 : GameAudioVolume`, × duck),
+ mixes mono mic + stereo loopback into interleaved stereo (no clamp — gains are user-owned), and
+ writes the floats to the pipe. **The gains are wired through `AudioGainProvider`**
+ (`Services/Audio/AudioGainProvider.cs`) — the VM hands its `MicGain`/`LoopbackGain` Funcs to the
+ mixer at construction, read live each tick, so the volume sliders and mute buttons are stream-honest.
+ (Before this wiring the seams defaulted to unity and the controls were decorative.) `StopStream` →
+ `StopLive()` (closes the pipe → ffmpeg audio EOF) **BEFORE** stopping the frame pump (video EOF) —
+ the reverse order stalls on pipe backpressure. `FfmpegEncoder` itself is untouched; the VM owns the
+ pipe lifecycle.
- **TRAX — free background music (TASK 9):** `MusicPlayer` = NAudio `MediaFoundationReader`
- (mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** (no slider) → `WaveOutEvent` on the
- default device, looping on natural end. It plays to the **desktop**, so the existing loopback captures
- it: the creator hears it in headphones, the sound-bar meter bounces, and the stream carries it through
- the loopback channel — **ducked with the game when the mic is hot**. No third mixer input. Footer
- **TRAX** button beside MIC: status dot (**red** no track / **yellow** loaded stopped / **green**
- playing) + "TRAX"; **left-click** toggles play/pause (opens the in-app picker via `OpenFileDialog`
- when no track is loaded); **right-click** always opens the picker (`TraxButton_PreviewMouseRightButtonUp`,
- `e.Handled = true`, code-behind pattern); tooltip shows the loaded/playing track name or "No track —
- right-click to choose background music". The picked track persists via **schema v9** single-row
- `Music` (`TrackPath`/`IsEnabled`) in `LayoutStore`. Known wrinkle (out of scope, future feature):
- YouTube mutes VODs carrying copyrighted music — "music on live, off VOD" is tabled.
+ (mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** bed (no slider) → `WaveOutEvent` on
+ the default device, **looping on any clean natural end** (`PlaybackStopped` with `e.Exception == null`
+ → rewind + replay; the earlier `Position >= Length` check was unreliable for `MediaFoundationReader`
+ and let tracks play once then stop). The desktop/game bar scales it locally via `LocalGain` (the VM's
+ `UpdateTraxLocalGain` runs on every `GameAudioVolume`/`GameMuted` change and on TRAX load), so the
+ creator **hears** the game volume slider and mute work on the music in the headphones — matching the
+ stream, where it rides the loopback channel ducked with the game. No third mixer input. Footer
+ **TRAX** button, relocated next to **Socials** in the footer's left cluster (centered under the
+ scenes/sources listboxes) so it no longer sits between MIC and the mic meter: status dot (**red** no
+ track / **yellow** loaded stopped / **green** playing) + "TRAX"; **left-click** toggles play/pause
+ (opens the in-app picker via `OpenFileDialog` when no track is loaded); **right-click** always opens
+ the picker (`TraxButton_PreviewMouseRightButtonUp`, `e.Handled = true`, code-behind pattern); tooltip
+ shows the loaded/playing track name or "No track — right-click to choose background music". The picked
+ track persists via **schema v9** single-row `Music` (`TrackPath`/`IsEnabled`) in `LayoutStore`. Known
+ wrinkle (out of scope, future feature): YouTube mutes VODs carrying copyrighted music — "music on
+ live, off VOD" is tabled.
- **Mic status dot (`Models/MicStatus.cs`)** on the footer's MIC button: green = `MicConnected`,
yellow = `MicFailed` (in use/unplugged), red = no mic device at startup (the mixer is never started,
- so loopback and the game bar can't run either — no capture devices at all).
+ so loopback and the game bar can't run either — no capture devices at all). **The picked mic persists:
+ `LayoutStore`'s `Settings` key/value table stores `MicSourceName` (saved on pick via
+ `SaveMicSourceName`), and the VM restores it at construction BEFORE the mixer's first `Start` — so a
+ restart reconnects the same already-vetted device (green) or reports it missing (yellow), instead of
+ silently falling back to the default endpoint.**
- **Game audio bar** (desktop/game, only while a full-screen game is up in the preview):
`IGameAudioDetector` seam (`Services/IGameAudioDetector.cs`), pure `GameAudioHysteresis` (SHOW after
~500ms of fullscreen + sound, HIDE after ~1s away from fullscreen, **silence never hides an active
@@ -429,15 +442,18 @@ devices, no timers).
(floor 0.5%). WPF-free — the VM owns a 250ms `DispatcherTimer` that polls it and flips
`IsGameAudioBarVisible` (`_gameAudioBarActive || IsMusicPlaying` — music bounces the bar even with no
game). The bar is **overlaid at the bottom of the preview window** (bottom-center, dark translucent
- chip, a mirror of the mic bar: meter + mute + volume slider). It's monitoring UI only — it lives in
- the XAML preview (`MainWindow.xaml`, the PreviewGrid) and never reaches the live output. Relabelled
+ chip, a mirror of the mic bar: meter + mute + volume slider). It's monitoring UI **in the preview
+ grid** — the meter is display-only, but its **volume slider + mute are stream-honest** (via
+ `AudioGainProvider.LoopbackGain`) **and drive TRAX locally** (`MusicPlayer.LocalGain`). Relabelled
**"Desktop Audio"** (TASK 9) since TRAX rides the same channel.
- **`WaveToFloat`** (pure, shared): WASAPI mix formats → interleaved float — IEEE float 32-bit direct,
PCM 16-bit normalized to -1..1, `WaveFormatExtensible` with the IEEE-float subformat GUID
(`NAudio.Dmo.AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT`), trailing partial samples ignored.
-- Build **0 warnings**; **196 passing** (DSP/ring-buffer/ducker/resampler/pipe + mixer/hysteresis/
- game-detector/meter-scale unit tests + the ONE integration test
- `AudioPipelineTests.Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe`).
+- Build **0 warnings**; **197 passing** (DSP/ring-buffer/ducker/resampler/pipe + mixer/hysteresis/
+ game-detector/meter-scale unit tests + the TASK 9 integration test
+ `AudioPipelineTests.Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe` + the polish-batch integration
+ test `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` proving the provider gains reach the
+ pipe and mute silences the loopback).
### Live frame pipeline (TASK 4 ship step 5 — shipped 2026-08-12, health stats 2026-08-13, plan in TASKS.md)
diff --git a/ytLive.Tests/AudioPipelineTests.cs b/ytLive.Tests/AudioPipelineTests.cs
index 8ab9dfb..22c781b 100644
--- a/ytLive.Tests/AudioPipelineTests.cs
+++ b/ytLive.Tests/AudioPipelineTests.cs
@@ -287,6 +287,63 @@ public class AudioPipelineTests
Assert.True(floats.Any(f => MathF.Abs(f) > 0.05f), "the pipe carried silence");
}
+ [Fact]
+ public async Task Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback()
+ {
+ // THE integration test for the polish batch: the AudioGainProvider's
+ // Func seams are exactly what the VM hands to the mixer, so this proves
+ // the desktop/game volume slider and mute button actually reach the live
+ // mix (before wiring they defaulted to unity and did nothing).
+ var pipeName = "ytllive_test_" + Guid.NewGuid().ToString("N");
+ var mic = new FakeSource();
+ var loopback = new FakeSource();
+
+ var gameVolume = 0.5;
+ var gameMuted = false;
+ var provider = new AudioGainProvider(
+ micMuted: () => false,
+ micVolume: () => 1.0,
+ gameMuted: () => gameMuted,
+ gameVolume: () => gameVolume);
+
+ using var mixer = new AudioMixer(
+ mic, loopback,
+ log: null,
+ micGain: provider.MicGain,
+ loopbackGain: provider.LoopbackGain,
+ mixInterval: TimeSpan.FromMilliseconds(5));
+
+ // Mic silent (so the ducker stays at unity) + a loud loopback bed that
+ // scales to 0.8 * 0.5 = 0.4 on the wire. Pre-fill the ring buffer so the
+ // first frames already carry real audio.
+ var loopChunk = new float[480];
+ Array.Fill(loopChunk, 0.8f);
+ for (var i = 0; i < 200; i++)
+ loopback.Emit(new AudioSample(loopChunk, 48000, 2));
+
+ using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.In, PipeOptions.Asynchronous);
+ var connected = client.ConnectAsync();
+ mixer.StartLive(pipeName);
+ await connected.WaitAsync(TimeSpan.FromSeconds(5));
+
+ // Phase 1: unmuted at 0.5 → scaled but clearly audible, bounded.
+ var bytes = await ReadFullyAsync(client, 240 * 2 * 4, TimeSpan.FromSeconds(5));
+ var floats = new float[bytes.Length / 4];
+ Buffer.BlockCopy(bytes, 0, floats, 0, bytes.Length);
+ Assert.True(floats.Any(f => MathF.Abs(f) > 0.1f), "scaled loopback should be audible");
+ Assert.All(floats, f => Assert.InRange(f, -0.55f, 0.55f));
+
+ // Phase 2: mute flips the provider's seam → the pipe must go silent.
+ gameMuted = true;
+ var mutedBytes = await ReadFullyAsync(client, 240 * 2 * 4, TimeSpan.FromSeconds(5));
+ mixer.StopLive();
+
+ Assert.True(mutedBytes.Length >= 240 * 2 * 4, "expected at least one full stereo frame after mute");
+ var mutedFloats = new float[mutedBytes.Length / 4];
+ Buffer.BlockCopy(mutedBytes, 0, mutedFloats, 0, mutedBytes.Length);
+ Assert.All(mutedFloats, f => Assert.Equal(0f, f, 6));
+ }
+
private static async Task ReadFullyAsync(NamedPipeClientStream client, int count, TimeSpan timeout)
{
var ms = new MemoryStream();