From a0a8331569a99f92accc47499d11854816433158 Mon Sep 17 00:00:00 2001 From: gramps Date: Sat, 15 Aug 2026 10:23:49 -0700 Subject: [PATCH] =?UTF-8?q?TASK=2024=20post-pause=20polish=20batch=20(crea?= =?UTF-8?q?tor's=208=20review=20issues,=202026-08-15):=20(1)=20desktop/gam?= =?UTF-8?q?e=20audio=20volume=20slider=20+=20(2)=20mute=20were=20no-ops=20?= =?UTF-8?q?=E2=80=94=20the=20AudioMixer's=20gain=20Func=20seams=20defaulte?= =?UTF-8?q?d=20to=20unity=20because=20the=20VM=20never=20passed=20them;=20?= =?UTF-8?q?new=20AudioGainProvider=20(Services/Audio/)=20wires=20MicMuted/?= =?UTF-8?q?MicVolume/GameMuted/GameAudioVolume=20into=20the=20mixer=20at?= =?UTF-8?q?=20construction,=20read=20live=20each=20mix=20tick,=20so=20slid?= =?UTF-8?q?ers=20and=20mutes=20are=20stream-honest;=20the=20game=20bar=20a?= =?UTF-8?q?lso=20scales=20TRAX=20locally=20via=20new=20MusicPlayer.LocalGa?= =?UTF-8?q?in=20(UpdateTraxLocalGain=20on=20every=20game=20volume/mute=20c?= =?UTF-8?q?hange=20+=20on=20load)=20so=20the=20creator=20HEARS=20the=20con?= =?UTF-8?q?trols=20work=20on=20the=20music=20in=20the=20headphones.=20(3)?= =?UTF-8?q?=20TRAX=20played=20once=20then=20stopped=20=E2=80=94=20OnPlayba?= =?UTF-8?q?ckStopped=20only=20looped=20on=20Position>=3DLength=20(unreliab?= =?UTF-8?q?le=20for=20MediaFoundationReader);=20now=20any=20clean=20stop?= =?UTF-8?q?=20rewinds=20+=20replays,=20errors/explicit=20stops=20surface?= =?UTF-8?q?=20via=20PlaybackEnded;=20unused=20using=20dropped.=20(4+6)=20T?= =?UTF-8?q?RAX=20moved=20right=20of=20Socials=20in=20the=20footer's=20left?= =?UTF-8?q?=20cluster=20(centered=20under=20scenes/sources);=20the=20mic?= =?UTF-8?q?=20cluster=20is=20now=20MIC=20+=20meter=20+=20mute=20+=20volume?= =?UTF-8?q?.=20(5)=20mic=20source=20persists=20=E2=80=94=20LayoutStore=20g?= =?UTF-8?q?ains=20a=20Settings=20key/value=20table=20(SaveMicSourceName/Lo?= =?UTF-8?q?adMicSourceName);=20the=20VM=20saves=20on=20pick=20and=20restor?= =?UTF-8?q?es=20before=20the=20mixer's=20first=20Start,=20so=20a=20restart?= =?UTF-8?q?=20reconnects=20the=20same=20vetted=20device=20(green)=20or=20r?= =?UTF-8?q?eports=20it=20missing=20(yellow).=20(7)=20Backdrop's=20missing?= =?UTF-8?q?=20trashcan=20shifted=20the=20edit/eye=20icons=20right=20?= =?UTF-8?q?=E2=80=94=20new=20HiddenBoolToVisibilityConverter=20keeps=20the?= =?UTF-8?q?=20trash=20column=20reserved=20(Hidden,=20not=20Collapsed)=20so?= =?UTF-8?q?=20every=20source=20row's=20icons=20stay=20in=20fixed=20columns?= =?UTF-8?q?.=20(8)=20a=201px=20hairline=20with=20top/bottom=20padding=20se?= =?UTF-8?q?parates=20scenes=20from=20sources=20in=20the=20left=20panel.=20?= =?UTF-8?q?Docs=20in=20the=20same=20commit:=20ai.md=20(gains=20honest=20+?= =?UTF-8?q?=20drive=20TRAX=20locally,=20TRAX=20loops,=20mic=20persistence,?= =?UTF-8?q?=20footer=20layout),=20TASKS.md=20TASK=2024,=20HANDOFF.md=20shi?= =?UTF-8?q?pped=20state,=20ViewModels/index.md.=20ONE=20integration=20test?= =?UTF-8?q?:=20AudioPipelineTests.Mix=5FHonorsProviderGains=5FAndGameMute?= =?UTF-8?q?=5FKillsTheLoopback=20(real=20mixer=20+=20provider=20gains=20th?= =?UTF-8?q?rough=20the=20pipe=20harness:=20scaled=20loopback=20audible,=20?= =?UTF-8?q?silence=20after=20mute).=20Build=200=20warnings,=20197=20tests?= =?UTF-8?q?=20passing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF.md | 85 +++++++++++++--------- Helpers/HiddenBoolToVisibilityConverter.cs | 20 +++++ MainWindow.xaml | 66 ++++++++++------- Services/Audio/AudioGainProvider.cs | 33 +++++++++ Services/Audio/MusicPlayer.cs | 32 ++++++-- Services/LayoutStore.cs | 30 ++++++++ TASKS.md | 46 +++++++++++- ViewModels/MainViewModel.cs | 31 +++++++- ViewModels/index.md | 2 +- ai.md | 60 +++++++++------ ytLive.Tests/AudioPipelineTests.cs | 57 +++++++++++++++ 11 files changed, 369 insertions(+), 93 deletions(-) create mode 100644 Helpers/HiddenBoolToVisibilityConverter.cs create mode 100644 Services/Audio/AudioGainProvider.cs 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();