TASK 24 post-pause polish batch (creator's 8 review issues, 2026-08-15): (1) desktop/game audio volume slider + (2) mute were no-ops — the AudioMixer's gain Func seams defaulted to unity because the VM never passed them; new AudioGainProvider (Services/Audio/) wires MicMuted/MicVolume/GameMuted/GameAudioVolume into the mixer at construction, read live each mix tick, so sliders and mutes are stream-honest; the game bar also scales TRAX locally via new MusicPlayer.LocalGain (UpdateTraxLocalGain on every game volume/mute change + on load) so the creator HEARS the controls work on the music in the headphones. (3) TRAX played once then stopped — OnPlaybackStopped only looped on Position>=Length (unreliable for MediaFoundationReader); now any clean stop rewinds + replays, errors/explicit stops surface via PlaybackEnded; unused using dropped. (4+6) TRAX moved right of Socials in the footer's left cluster (centered under scenes/sources); the mic cluster is now MIC + meter + mute + volume. (5) mic source persists — LayoutStore gains 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 vetted device (green) or reports it missing (yellow). (7) Backdrop's missing trashcan shifted the edit/eye icons right — new HiddenBoolToVisibilityConverter keeps the trash column reserved (Hidden, not Collapsed) so every source row's icons stay in fixed columns. (8) a 1px hairline with top/bottom padding separates scenes from sources in the left panel. Docs in the same commit: ai.md (gains honest + drive TRAX locally, TRAX loops, mic persistence, footer layout), TASKS.md TASK 24, HANDOFF.md shipped state, ViewModels/index.md. ONE integration test: AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback (real mixer + provider gains through the pipe harness: scaled loopback audible, silence after mute). Build 0 warnings, 197 tests passing

This commit is contained in:
2026-08-15 10:23:49 -07:00
parent 86fcd868a8
commit a0a8331569
11 changed files with 369 additions and 93 deletions
+52 -33
View File
@@ -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
@@ -0,0 +1,20 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace ytLive.Helpers;
/// <summary>
/// Maps bool → Visibility, collapsing nothing: true becomes <c>Hidden</c> (keeps
/// its layout slot) rather than <c>Collapsed</c> (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.
/// </summary>
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;
}
+38 -28
View File
@@ -33,6 +33,8 @@
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
<Helpers:HiddenBoolToVisibilityConverter x:Key="HiddenBoolToVis"
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
<Helpers:EnumToBoolConverter x:Key="EnumToBool"
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
@@ -148,6 +150,7 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
@@ -205,8 +208,12 @@
</ListBox.ItemTemplate>
</Helpers:FocusPreservingListBox>
<!-- Visual separation between the two listboxes: a hairline
with breathing room on either side (the creator's fix). -->
<Rectangle Grid.Row="2" Height="1" Fill="#2c3a5c" Margin="0,10,0,2"/>
<!-- Sources (for active scene) -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,8,0,0">
<StackPanel Grid.Row="3" Orientation="Horizontal" Margin="0,8,0,0">
<TextBlock Text="SOURCES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
<Button ToolTip="Add Source" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
Click="AddSourceButton_Click">
@@ -226,7 +233,7 @@
</Button>
</StackPanel>
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="3" Background="Transparent" BorderThickness="0"
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="4" Background="Transparent" BorderThickness="0"
ItemsSource="{Binding ActiveScene.Elements}"
SelectedItem="{Binding SelectedElement, Mode=TwoWay}"
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
@@ -313,7 +320,7 @@
<Button Grid.Column="3" ToolTip="Remove Source"
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}"
Visibility="{Binding IsBackdrop, Converter={StaticResource InverseBoolToVis}}">
Visibility="{Binding IsBackdrop, Converter={StaticResource HiddenBoolToVis}}">
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button>
@@ -322,7 +329,7 @@
</ListBox.ItemTemplate>
</Helpers:FocusPreservingListBox>
<TextBlock Grid.Row="3" Text="No sources yet" Foreground="#555" FontSize="12"
<TextBlock Grid.Row="4" Text="No sources yet" Foreground="#555" FontSize="12"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
</Grid>
@@ -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. -->
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
Background="#D9000000" CornerRadius="6" Padding="12,8"
Margin="0,0,0,12"
@@ -855,12 +863,12 @@
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Line 1: Social controls on the LEFT (under scenes/sources),
<!-- Line 1: Social controls + TRAX on the LEFT (under scenes/sources),
sound meter + volume control CENTERED beneath the preview panel.
Audio is KISS: desktop/game audio is automatic (WASAPI loopback
at unity); 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). -->
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). -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/>
@@ -868,12 +876,14 @@
<ColumnDefinition Width="300"/>
</Grid.ColumnDefinitions>
<!-- Social controls: centered under the scenes/sources listboxes -->
<!-- Social controls + TRAX: centered under the scenes/sources
listboxes. TRAX rides the desktop/game audio channel, so
it lives in the left cluster, away from the mic meter. -->
<StackPanel Grid.Column="0" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Button Command="{Binding OpenSocialDialogCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="12,4"
FontSize="11" VerticalAlignment="Center"
FontSize="11" VerticalAlignment="Center" Margin="0,0,8,0"
ToolTip="Add or manage your social handles">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{Binding SocialBarDotBrush}"
@@ -881,6 +891,21 @@
<TextBlock Text="Socials"/>
</StackPanel>
</Button>
<!-- TRAX (TASK 9): free background music. Dot: green = playing,
yellow = loaded/stopped, red = no track. Left-click toggles
play/pause (opens the picker when no track); right-click
always opens the picker. -->
<Button Style="{StaticResource YtButtonSecondary}" Padding="12,4"
FontSize="11" VerticalAlignment="Center"
ToolTip="{Binding TraxToolTip}"
Click="TraxButton_Click"
PreviewMouseRightButtonUp="TraxButton_PreviewMouseRightButtonUp">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{Binding TraxDotBrush}"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="TRAX"/>
</StackPanel>
</Button>
</StackPanel>
<!-- Mic meter + volume: centered under the preview panel -->
@@ -896,21 +921,6 @@
<TextBlock Text="MIC"/>
</StackPanel>
</Button>
<!-- TRAX (TASK 9): free background music. Dot: green = playing,
yellow = loaded/stopped, red = no track. Left-click toggles
play/pause (opens the picker when no track); right-click
always opens the picker. -->
<Button Style="{StaticResource YtButtonSecondary}" Padding="12,4"
FontSize="11" VerticalAlignment="Center" Margin="0,0,8,0"
ToolTip="{Binding TraxToolTip}"
Click="TraxButton_Click"
PreviewMouseRightButtonUp="TraxButton_PreviewMouseRightButtonUp">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{Binding TraxDotBrush}"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="TRAX"/>
</StackPanel>
</Button>
<!-- Sound meter: muted track with a ruler scale, zone tints at
the yellow (60%) and red (80%) starts. READ-ONLY realtime
level display (fill = live level scaled by volume). -->
+33
View File
@@ -0,0 +1,33 @@
namespace ytLive.Services.Audio;
/// <summary>
/// Supplies the live-mix gain values (mic + loopback) as Func seams for
/// <see cref="AudioMixer"/>, 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.
/// </summary>
public sealed class AudioGainProvider
{
private readonly Func<bool> _micMuted;
private readonly Func<double> _micVolume;
private readonly Func<bool> _gameMuted;
private readonly Func<double> _gameVolume;
public AudioGainProvider(
Func<bool> micMuted,
Func<double> micVolume,
Func<bool> gameMuted,
Func<double> gameVolume)
{
_micMuted = micMuted;
_micVolume = micVolume;
_gameMuted = gameMuted;
_gameVolume = gameVolume;
}
/// <summary>Mic gain for the live mix (0 = muted).</summary>
public double MicGain() => _micMuted() ? 0 : _micVolume();
/// <summary>Loopback (desktop/game) gain for the live mix (0 = muted).</summary>
public double LoopbackGain() => _gameMuted() ? 0 : _gameVolume();
}
+25 -7
View File
@@ -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 <see cref="LocalGain"/>, so the
/// creator hears the game-audio controls work on the music too.
/// </summary>
public sealed class MusicPlayer : IDisposable
{
/// <summary>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).</summary>
/// <summary>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 <see cref="LocalGain"/>.</summary>
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;
/// <summary>Raised when playback ends or is stopped (not on natural-end loop).</summary>
public event Action? PlaybackEnded;
@@ -31,6 +34,19 @@ public sealed class MusicPlayer : IDisposable
public bool IsPlaying => _output?.PlaybackState == PlaybackState.Playing;
/// <summary>Local gain multiplier from the desktop/game audio bar (0 = mute,
/// 1 = full bed). Applied live on top of the fixed <see cref="MusicVolume"/>.</summary>
public float LocalGain
{
get => _localGain;
set
{
_localGain = Math.Clamp(value, 0f, 1f);
if (_volume != null)
_volume.Volume = MusicVolume * _localGain;
}
}
/// <summary>Loads a track and prepares playback (does not start it).</summary>
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();
+30
View File
@@ -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;
}
/// <summary>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.</summary>
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<Scene> scenes, Webcam? webcam, SocialsConfig? socials, Music? music = null)
{
using var tx = _connection.BeginTransaction();
+45 -1
View File
@@ -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)
+30 -1
View File
@@ -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));
}
/// <summary>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.</summary>
private void UpdateTraxLocalGain()
{
_musicPlayer.LocalGain = GameMuted ? 0f : (float)GameAudioVolume;
}
/// <summary>Live desktop/game input level (0..1), fed by the mixer's loopback
/// capture. Read by the game meter, scaled by GameAudioVolume.</summary>
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;
+1 -1
View File
File diff suppressed because one or more lines are too long
+38 -22
View File
@@ -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<double>` 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)
+57
View File
@@ -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<byte[]> ReadFullyAsync(NamedPipeClientStream client, int count, TimeSpan timeout)
{
var ms = new MemoryStream();