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 > a problem. Conventions: [`schema.md`](schema.md). Rewrite this file at session
> end, compaction, or any interruption. > 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 - **Branch:** `main`, tracking `origin/main`. Working tree: the **TASK 24 polish batch** below is
monetization docs changeset below. committed + pushed. Local branches `social-bar`/`webcam-validation` untouched (no secrets).
- **AUDIO MILESTONE (TASK 9) — SHIPPED + COMMITTED + PUSHED this session.** Real stream audio + voice - **TASK 24 — POST-PAUSE POLISH BATCH — SHIPPED + COMMITTED + PUSHED 2026-08-15.** All 8 creator
filters + auto-duck + free TRAX background music. Build **0 warnings**, full suite **196 passing** review issues fixed in one branch (details below). Build **0 warnings**, full suite **197 passing**
(ONE integration test: `AudioPipelineTests.Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe`). Commits (ONE integration test for this branch: `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`).
**`6d71ace`** (milestone) + **`f2f6401`** (secrets cleanup), force-pushed (`725f5a7...f2f6401`). - **AUDIO MILESTONE (TASK 9) — SHIPPED + COMMITTED + PUSHED.** Real stream audio + voice filters +
- **Secrets scrubbed from git history.** The DO token + passwords recorded in `HANDOFF.md` were removed auto-duck + free TRAX background music. Commits `6d71ace` (milestone) + `f2f6401` (secrets cleanup),
from the working file and **purged from all history** via `git filter-branch` over `3d92bd0..main` force-pushed (`725f5a7...f2f6401`).
+ `git gc --prune=now --aggressive` (scan of all refs = 0 hits). They still exist in chat — **keep - **MONETIZATION LOCKED (2026-08-14, creator) — committed `86fcd86`.** One paid line = **annual
treating as compromised**; rotate the DO API token. Old objects remain in llgit's store until server subscription**: early access **$49.99/yr** → **$99/yr list at GA**, **grandfather-while-subscribed**,
GC (unreachable, not fetchable). If a value is ever needed, `git reflog` is expired — it's gone. lapse → list on renewal. Free tier unchanged (branding flash = the billboard + no Alerts; Alerts is
- **MONETIZATION LOCKED (2026-08-14, creator).** One paid line = **annual subscription**: early access the paid feature). **Billing NOT itch-locked**; candidates Gumroad (native affiliates =
**$49.99/yr** → **$99/yr list at GA**, **grandfather-while-subscribed**, lapse → list on renewal. tiebreaker) / Lemon Squeezy. Support = in-app bug-report → git issues. Full policy in `ai.md`
Free tier unchanged (branding flash = the billboard + no Alerts; Alerts is the paid feature). Monetization; scoped plan in `TASKS.md` **TASK 23**.
**Billing NOT itch-locked** — itch.io has no native subscription billing; candidates Gumroad (native - **Secrets scrubbed from git history.** The DO token + passwords were purged via `git filter-branch`
affiliates = tiebreaker) / Lemon Squeezy. Support = in-app bug-report → git issues, "when I get + `git gc --prune=now --aggressive` (all-refs scan = 0 hits); values still exist in chat — keep
around to it" + emergency patches. Full policy in `ai.md` → Monetization; scoped plan + line-items treating as **compromised**; rotate the DO API token.
in `TASKS.md` **TASK 23**. - **Shipped, all pushed:** TASK 24 polish batch, TASK 21 (creator-hub About, `3d92bd0`), TASK 8
- **DEVELOPMENT PAUSED (creator's call 2026-08-14) — resuming streaming instead.** Channel revival = (meter +10 dB), TASK 4 ship step 7 (one-click go-live + private-only), TASK 9 audio milestone
**pair-programming dev streams via OBS** (ytLlive can't go live yet: private-only + `_rtmpUrlProvider` (`6d71ace`), secrets cleanup (`f2f6401`), monetization docs (`86fcd86`).
null) + **The Division 2** gaming streams under the **"gramps/GOAT"** persona (67, gaming since 1981). - **Resume point (next branch):** **TASK 5** — reusable stream → `_rtmpUrlProvider` (the last blocker
3D-printing content retired (printer hard down, novelty gone). **Dogfood switch** later: when TASK 5 + before go-live actually encodes + pushes). Then TASK 23 (billing + unlock + support).
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).
Optional, not queued: rewriting the healed entry's `ProfileUrl` to Optional, not queued: rewriting the healed entry's `ProfileUrl` to
`https://mastodon.llamachile.tube/@gramps` (user must say the word). `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) ## 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 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"/> xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis" <Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"
xmlns:Helpers="clr-namespace:ytLive.Helpers"/> xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
<Helpers:HiddenBoolToVisibilityConverter x:Key="HiddenBoolToVis"
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
<Helpers:EnumToBoolConverter x:Key="EnumToBool" <Helpers:EnumToBoolConverter x:Key="EnumToBool"
xmlns:Helpers="clr-namespace:ytLive.Helpers"/> xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
@@ -148,6 +150,7 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
@@ -205,8 +208,12 @@
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</Helpers:FocusPreservingListBox> </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) --> <!-- 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"/> <TextBlock Text="SOURCES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
<Button ToolTip="Add Source" Style="{StaticResource IconButton}" Margin="4,-4,0,0" <Button ToolTip="Add Source" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
Click="AddSourceButton_Click"> Click="AddSourceButton_Click">
@@ -226,7 +233,7 @@
</Button> </Button>
</StackPanel> </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}" ItemsSource="{Binding ActiveScene.Elements}"
SelectedItem="{Binding SelectedElement, Mode=TwoWay}" SelectedItem="{Binding SelectedElement, Mode=TwoWay}"
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown" PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
@@ -313,7 +320,7 @@
<Button Grid.Column="3" ToolTip="Remove Source" <Button Grid.Column="3" ToolTip="Remove Source"
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}" Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}" 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" <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"/> Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button> </Button>
@@ -322,7 +329,7 @@
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</Helpers:FocusPreservingListBox> </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" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/> Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
</Grid> </Grid>
@@ -750,8 +757,9 @@
preview while a full-screen game is producing sound or preview while a full-screen game is producing sound or
TRAX music is playing (the game audio detector). Meter TRAX music is playing (the game audio detector). Meter
+ mute + volume control live here; desktop audio itself + mute + volume control live here; desktop audio itself
is automatic WASAPI loopback, and since TASK 9 it rides is automatic WASAPI loopback. Volume + mute are
into the live mix (game volume + mute are stream-honest). --> stream-honest AND scale the TRAX music in the
headphones, so the creator hears the control work. -->
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom" <Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
Background="#D9000000" CornerRadius="6" Padding="12,8" Background="#D9000000" CornerRadius="6" Padding="12,8"
Margin="0,0,0,12" Margin="0,0,0,12"
@@ -855,12 +863,12 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </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. sound meter + volume control CENTERED beneath the preview panel.
Audio is KISS: desktop/game audio is automatic (WASAPI loopback Audio is KISS: desktop/game audio is automatic (WASAPI loopback);
at unity); the mic meter is the creator's only footer audio the mic meter is the creator's only footer audio control (the
control (the game audio bar lives INSIDE the preview window, game audio bar lives INSIDE the preview window, overlaid at its
overlaid at its bottom edge while a full-screen game is up). --> bottom edge while a full-screen game is up). -->
<Grid Grid.Row="0"> <Grid Grid.Row="0">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/> <ColumnDefinition Width="220"/>
@@ -868,12 +876,14 @@
<ColumnDefinition Width="300"/> <ColumnDefinition Width="300"/>
</Grid.ColumnDefinitions> </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" <StackPanel Grid.Column="0" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center"> HorizontalAlignment="Center" VerticalAlignment="Center">
<Button Command="{Binding OpenSocialDialogCommand}" <Button Command="{Binding OpenSocialDialogCommand}"
Style="{StaticResource YtButtonSecondary}" Padding="12,4" 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"> ToolTip="Add or manage your social handles">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{Binding SocialBarDotBrush}" <Ellipse Width="8" Height="8" Fill="{Binding SocialBarDotBrush}"
@@ -881,6 +891,21 @@
<TextBlock Text="Socials"/> <TextBlock Text="Socials"/>
</StackPanel> </StackPanel>
</Button> </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> </StackPanel>
<!-- Mic meter + volume: centered under the preview panel --> <!-- Mic meter + volume: centered under the preview panel -->
@@ -896,21 +921,6 @@
<TextBlock Text="MIC"/> <TextBlock Text="MIC"/>
</StackPanel> </StackPanel>
</Button> </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 <!-- Sound meter: muted track with a ruler scale, zone tints at
the yellow (60%) and red (80%) starts. READ-ONLY realtime the yellow (60%) and red (80%) starts. READ-ONLY realtime
level display (fill = live level scaled by volume). --> 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.IO;
using System.Runtime.InteropServices;
using NAudio.Wave; using NAudio.Wave;
namespace ytLive.Services.Audio; 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 /// 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 /// 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. /// 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> /// </summary>
public sealed class MusicPlayer : IDisposable public sealed class MusicPlayer : IDisposable
{ {
/// <summary>The one and only music level — quiet bed, no slider (the /// <summary>The one and only music bed — quiet, no slider (the creator's
/// creator's voice must stay on top; game/music loudness is the ducker's job).</summary> /// 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; public const float MusicVolume = 0.20f;
private MediaFoundationReader? _reader; private MediaFoundationReader? _reader;
@@ -21,6 +23,7 @@ public sealed class MusicPlayer : IDisposable
private WaveOutEvent? _output; private WaveOutEvent? _output;
private bool _disposed; private bool _disposed;
private bool _stopping; private bool _stopping;
private float _localGain = 1f;
/// <summary>Raised when playback ends or is stopped (not on natural-end loop).</summary> /// <summary>Raised when playback ends or is stopped (not on natural-end loop).</summary>
public event Action? PlaybackEnded; public event Action? PlaybackEnded;
@@ -31,6 +34,19 @@ public sealed class MusicPlayer : IDisposable
public bool IsPlaying => _output?.PlaybackState == PlaybackState.Playing; 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> /// <summary>Loads a track and prepares playback (does not start it).</summary>
public void Load(string path) public void Load(string path)
{ {
@@ -40,7 +56,7 @@ public sealed class MusicPlayer : IDisposable
Stop(); Stop();
TrackPath = path; TrackPath = path;
_reader = new MediaFoundationReader(path); _reader = new MediaFoundationReader(path);
_volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume }; _volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume * _localGain };
_output = new WaveOutEvent(); _output = new WaveOutEvent();
_output.PlaybackStopped += OnPlaybackStopped; _output.PlaybackStopped += OnPlaybackStopped;
_output.Init(_volume); _output.Init(_volume);
@@ -95,9 +111,11 @@ public sealed class MusicPlayer : IDisposable
if (_disposed || _stopping || _output == null || _reader == null) if (_disposed || _stopping || _output == null || _reader == null)
return; return;
// Natural end → loop the track; anything else (an explicit stop or a // Any clean stop is a natural end → loop the track (the Position/Length
// device failure) surfaces via PlaybackEnded so the UI can reset the dot. // comparison is unreliable for MediaFoundationReader, so a track can
if (_reader.Position >= _reader.Length) // 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; _reader.Position = 0;
_output.Play(); _output.Play();
+30
View File
@@ -135,6 +135,12 @@ public class LayoutStore : IDisposable
IsEnabled INTEGER NOT NULL DEFAULT 0 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) foreach (var sql in statements)
{ {
@@ -585,6 +591,30 @@ public class LayoutStore : IDisposable
return scenes; 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) public void Save(IEnumerable<Scene> scenes, Webcam? webcam, SocialsConfig? socials, Music? music = null)
{ {
using var tx = _connection.BeginTransaction(); 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. **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 1. ☐ Broadcast creation — title/description/privacy/scheduledStartTime via API, with the v3 flags above
2. ☐ Reusable stream — create once, cache + reuse; bind to broadcast 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) ## Backlog (future versions)
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization) 1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
+30 -1
View File
@@ -58,6 +58,7 @@ public class MainViewModel : ViewModelBase
private readonly MusicPlayer _musicPlayer = new(); private readonly MusicPlayer _musicPlayer = new();
private Music? _music; private Music? _music;
private bool _musicPlaying; private bool _musicPlaying;
private AudioGainProvider? _gainProvider;
private StreamStatus _streamStatus = StreamStatus.Offline; private StreamStatus _streamStatus = StreamStatus.Offline;
private StreamHealth _currentHealth = new(); private StreamHealth _currentHealth = new();
private string _streamTitle = string.Empty; private string _streamTitle = string.Empty;
@@ -450,6 +451,7 @@ public class MainViewModel : ViewModelBase
if (dialog.ShowDialog() == true && dialog.PickedDevice != null) if (dialog.ShowDialog() == true && dialog.PickedDevice != null)
{ {
MicSourceName = dialog.PickedDevice.DisplayName; MicSourceName = dialog.PickedDevice.DisplayName;
_layoutStore.SaveMicSourceName(MicSourceName); // persist across restarts
_audioMixer.RestartMic(); // swap the live device immediately _audioMixer.RestartMic(); // swap the live device immediately
} }
} }
@@ -560,6 +562,7 @@ public class MainViewModel : ViewModelBase
try try
{ {
_musicPlayer.Load(_music.TrackPath); _musicPlayer.Load(_music.TrackPath);
UpdateTraxLocalGain();
_music.IsEnabled = true; _music.IsEnabled = true;
if (play) if (play)
_musicPlayer.Play(); _musicPlayer.Play();
@@ -591,6 +594,15 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(TraxToolTip)); 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 /// <summary>Live desktop/game input level (0..1), fed by the mixer's loopback
/// capture. Read by the game meter, scaled by GameAudioVolume.</summary> /// capture. Read by the game meter, scaled by GameAudioVolume.</summary>
public double GameAudioLevel public double GameAudioLevel
@@ -680,6 +692,7 @@ public class MainViewModel : ViewModelBase
_gameVolumeBeforeMute = null; _gameVolumeBeforeMute = null;
OnPropertyChanged(nameof(GameMeterFillWidth)); OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush)); OnPropertyChanged(nameof(GameMeterBrush));
UpdateTraxLocalGain();
} }
} }
} }
@@ -1148,6 +1161,11 @@ public class MainViewModel : ViewModelBase
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath); _layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath; _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(); _cameraEnumerator = new MediaCaptureCameraEnumerator();
_cameraManager = new CameraManager( _cameraManager = new CameraManager(
_cameraEnumerator, _cameraEnumerator,
@@ -1158,10 +1176,21 @@ public class MainViewModel : ViewModelBase
_microphoneEnumerator = new WinRtMicrophoneEnumerator(); _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( _audioMixer = new AudioMixer(
new WasapiMicAudioSource(() => MicSourceName), new WasapiMicAudioSource(() => MicSourceName),
new WasapiLoopbackAudioSource(), new WasapiLoopbackAudioSource(),
message => AppLog.Write(message)); message => AppLog.Write(message),
micGain: _gainProvider.MicGain,
loopbackGain: _gainProvider.LoopbackGain);
_audioMixer.MicLevelChanged += OnMicLevelChanged; _audioMixer.MicLevelChanged += OnMicLevelChanged;
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged; _audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
_audioMixer.MicConnected += OnMicConnected; _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 `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, 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 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 (`micGain = MicMuted ? 0 : MicVolume`; `loopbackGain = GameMuted ? 0 : GameAudioVolume`, × duck),
mono mic + stereo loopback into interleaved stereo (no clamp — gains are user-owned), and writes the mixes mono mic + stereo loopback into interleaved stereo (no clamp — gains are user-owned), and
floats to the pipe. `StopStream` → `StopLive()` (closes the pipe → ffmpeg audio EOF) **BEFORE** writes the floats to the pipe. **The gains are wired through `AudioGainProvider`**
stopping the frame pump (video EOF) — the reverse order stalls on pipe backpressure. `FfmpegEncoder` (`Services/Audio/AudioGainProvider.cs`) — the VM hands its `MicGain`/`LoopbackGain` Funcs to the
itself is untouched; the VM owns the pipe lifecycle. 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` - **TRAX — free background music (TASK 9):** `MusicPlayer` = NAudio `MediaFoundationReader`
(mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** (no slider) → `WaveOutEvent` on the (mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** bed (no slider) → `WaveOutEvent` on
default device, looping on natural end. It plays to the **desktop**, so the existing loopback captures the default device, **looping on any clean natural end** (`PlaybackStopped` with `e.Exception == null`
it: the creator hears it in headphones, the sound-bar meter bounces, and the stream carries it through → rewind + replay; the earlier `Position >= Length` check was unreliable for `MediaFoundationReader`
the loopback channel — **ducked with the game when the mic is hot**. No third mixer input. Footer and let tracks play once then stop). The desktop/game bar scales it locally via `LocalGain` (the VM's
**TRAX** button beside MIC: status dot (**red** no track / **yellow** loaded stopped / **green** `UpdateTraxLocalGain` runs on every `GameAudioVolume`/`GameMuted` change and on TRAX load), so the
playing) + "TRAX"; **left-click** toggles play/pause (opens the in-app picker via `OpenFileDialog` creator **hears** the game volume slider and mute work on the music in the headphones — matching the
when no track is loaded); **right-click** always opens the picker (`TraxButton_PreviewMouseRightButtonUp`, stream, where it rides the loopback channel ducked with the game. No third mixer input. Footer
`e.Handled = true`, code-behind pattern); tooltip shows the loaded/playing track name or "No track — **TRAX** button, relocated next to **Socials** in the footer's left cluster (centered under the
right-click to choose background music". The picked track persists via **schema v9** single-row scenes/sources listboxes) so it no longer sits between MIC and the mic meter: status dot (**red** no
`Music` (`TrackPath`/`IsEnabled`) in `LayoutStore`. Known wrinkle (out of scope, future feature): track / **yellow** loaded stopped / **green** playing) + "TRAX"; **left-click** toggles play/pause
YouTube mutes VODs carrying copyrighted music — "music on live, off VOD" is tabled. (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`, - **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, 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): - **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 `IGameAudioDetector` seam (`Services/IGameAudioDetector.cs`), pure `GameAudioHysteresis` (SHOW after
~500ms of fullscreen + sound, HIDE after ~1s away from fullscreen, **silence never hides an active ~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 (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 `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 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 chip, a mirror of the mic bar: meter + mute + volume slider). It's monitoring UI **in the preview
the XAML preview (`MainWindow.xaml`, the PreviewGrid) and never reaches the live output. Relabelled 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. **"Desktop Audio"** (TASK 9) since TRAX rides the same channel.
- **`WaveToFloat`** (pure, shared): WASAPI mix formats → interleaved float — IEEE float 32-bit direct, - **`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 PCM 16-bit normalized to -1..1, `WaveFormatExtensible` with the IEEE-float subformat GUID
(`NAudio.Dmo.AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT`), trailing partial samples ignored. (`NAudio.Dmo.AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT`), trailing partial samples ignored.
- Build **0 warnings**; **196 passing** (DSP/ring-buffer/ducker/resampler/pipe + mixer/hysteresis/ - Build **0 warnings**; **197 passing** (DSP/ring-buffer/ducker/resampler/pipe + mixer/hysteresis/
game-detector/meter-scale unit tests + the ONE integration test game-detector/meter-scale unit tests + the TASK 9 integration test
`AudioPipelineTests.Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe`). `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) ### 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"); 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) private static async Task<byte[]> ReadFullyAsync(NamedPipeClientStream client, int count, TimeSpan timeout)
{ {
var ms = new MemoryStream(); var ms = new MemoryStream();