Compare commits
50
Commits
b265d42fb4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69f08a478e | ||
|
|
ba60f9ab3d | ||
|
|
f4caea09ad | ||
|
|
70b5b01920 | ||
|
|
bd61e8df2a | ||
|
|
5bd750eaf5 | ||
|
|
1ce6b9c1b8 | ||
|
|
70fcf89f9e | ||
|
|
caf5d6130d | ||
|
|
6b4aa99863 | ||
|
|
b92569a8c7 | ||
|
|
838ba17eaf | ||
|
|
16abb1ddc1 | ||
|
|
4bc41c488c | ||
|
|
a5a10fb0b0 | ||
|
|
6c5cd2f66c | ||
|
|
a5dc2f494c | ||
|
|
d46e84562f | ||
|
|
aae6104666 | ||
|
|
cc75212997 | ||
|
|
1eb04597d1 | ||
|
|
c1b3868913 | ||
|
|
af6210082c | ||
|
|
1abc86358a | ||
|
|
514fb8480d | ||
|
|
bb3bc12784 | ||
|
|
b216cbc56f | ||
|
|
e654a16b60 | ||
|
|
5ae52808bb | ||
|
|
751d9b199c | ||
|
|
102cb96b28 | ||
|
|
47f409f0e4 | ||
|
|
7f89534309 | ||
|
|
f6fdab3bf2 | ||
|
|
ada8d2457d | ||
|
|
7acf044e4a | ||
|
|
b0f8263414 | ||
|
|
f2cab5ca0f | ||
|
|
93719a286d | ||
|
|
1708667602 | ||
|
|
700c7a8c23 | ||
|
|
37ef74eb8d | ||
|
|
1e3a4f7fe0 | ||
|
|
9c0c40c688 | ||
|
|
ff5c005bd9 | ||
|
|
2f5d0c1759 | ||
|
|
9621db29c9 | ||
|
|
24077e58cf | ||
|
|
2c5a3d8c11 | ||
|
|
4e5b4e3999 |
Binary file not shown.
|
Before Width: | Height: | Size: 383 KiB After Width: | Height: | Size: 2.5 MiB |
@@ -213,6 +213,31 @@
|
||||
<Button Content="+100" Width="34" Height="16" FontSize="9" Padding="0"
|
||||
Style="{StaticResource YtButtonSecondary}" Click="SyncPreset_Click" Tag="100"/>
|
||||
</StackPanel>
|
||||
<!-- Microphone-only delay: the voice waits for the camera's picture. The
|
||||
whole-mix slider above would drag desktop and alert audio with it. -->
|
||||
<Grid Margin="0,5,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
|
||||
<TextBlock Text="MIC" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="10" VerticalAlignment="Center"
|
||||
FontFamily="{StaticResource FontFamily.Mono}" TextAlignment="Right"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" Text="{Binding MicSyncOffsetLabel}"/>
|
||||
</Grid>
|
||||
<Slider Minimum="0" Maximum="500" SmallChange="5" LargeChange="25"
|
||||
TickFrequency="5" IsSnapToTickEnabled="True"
|
||||
Value="{Binding MicSyncOffsetMs, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,2,0,0" HorizontalAlignment="Right">
|
||||
<Button Content="off" Width="26" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
|
||||
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="0"/>
|
||||
<Button Content="175" Width="30" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
|
||||
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="175"/>
|
||||
<Button Content="350" Width="30" Height="16" FontSize="9" Padding="0"
|
||||
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="350"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -56,6 +56,13 @@ public partial class AudioDock : UserControl
|
||||
vm.SetSyncOffset(ms);
|
||||
}
|
||||
|
||||
private void MicSyncPreset_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not ViewModels.MainViewModel vm) return;
|
||||
if (sender is Button { Tag: string tag } && int.TryParse(tag, out var ms))
|
||||
vm.MicSyncOffsetMs = ms;
|
||||
}
|
||||
|
||||
private void TraxPlayPause_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick();
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:models="clr-namespace:ytLive.Models"
|
||||
xmlns:services="clr-namespace:ytLive.Services">
|
||||
xmlns:services="clr-namespace:ytLive.Services"
|
||||
xmlns:notification="clr-namespace:Notification.Core;assembly=Notification.Core">
|
||||
<!-- App Settings, as a RIGHT-EDGE PULL-OUT (creator ruling 2026-11-01): the gear tab opens
|
||||
this drawer immediately, in the same animated-width pattern as Stream Settings / YPP /
|
||||
Socials / TEST. It used to be a centred modal overlay, which then needed its own scroll
|
||||
@@ -25,6 +26,24 @@
|
||||
IsReadOnly="True" Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Text="Slide-out alerts" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
|
||||
<TextBlock Text="Position" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
|
||||
<ComboBox Style="{StaticResource YtComboBox}" Margin="10,0,0,8"
|
||||
SelectedValue="{Binding ToastPosition, Mode=TwoWay}" SelectedValuePath="Tag"
|
||||
ToolTip="Choose where notifications appear inside the app window.">
|
||||
<ComboBoxItem Content="Top left" Tag="{x:Static notification:NotificationPosition.TopLeft}"/>
|
||||
<ComboBoxItem Content="Top right" Tag="{x:Static notification:NotificationPosition.TopRight}"/>
|
||||
<ComboBoxItem Content="Center left" Tag="{x:Static notification:NotificationPosition.CenterLeft}"/>
|
||||
<ComboBoxItem Content="Center right" Tag="{x:Static notification:NotificationPosition.CenterRight}"/>
|
||||
<ComboBoxItem Content="Bottom left" Tag="{x:Static notification:NotificationPosition.BottomLeft}"/>
|
||||
<ComboBoxItem Content="Bottom right" Tag="{x:Static notification:NotificationPosition.BottomRight}"/>
|
||||
</ComboBox>
|
||||
<CheckBox Content="Keep alerts until dismissed" Style="{StaticResource PropertyPillCheck}"
|
||||
Margin="12,2,0,2" IsChecked="{Binding StickyAlertsUntilDismissed, Mode=TwoWay}"
|
||||
ToolTip="When on, every slide-out alert stays until you click it or close it."/>
|
||||
<TextBlock Text="Off: informational alerts use their normal timeouts; errors still stay until closed."
|
||||
Style="{StaticResource PropertyHint}" Margin="10,0,0,8"/>
|
||||
|
||||
<!-- ═══ BRAND ═══ -->
|
||||
<TextBlock Text="Brand logo" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
|
||||
<TextBlock Text="Image file (shown on every scene)" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
|
||||
@@ -124,6 +143,13 @@
|
||||
</DockPanel>
|
||||
|
||||
<!-- ═══ LAYOUT DEFAULTS (TASK 37) ═══ -->
|
||||
<TextBlock Text="Editing" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
|
||||
<CheckBox IsChecked="{Binding LockEditWhileLive, Mode=TwoWay}" Margin="10,0,0,2"
|
||||
Style="{StaticResource PropertyPillCheck}"
|
||||
Content="Lock editing while recording or live"/>
|
||||
<TextBlock Text="Off: layers can be moved or resized during a show. On: going live turns EDIT off and stopping turns it back on."
|
||||
Style="{StaticResource PropertyHint}" Margin="28,0,0,10"/>
|
||||
|
||||
<TextBlock Text="Layout defaults" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
|
||||
<TextBlock Text="Replaces every scene and layer with this build’s factory layout. Your selected webcam stays the same."
|
||||
Style="{StaticResource PropertyHint}" Margin="10,0,0,6"/>
|
||||
|
||||
+168
-58
@@ -53,26 +53,6 @@
|
||||
</TextBlock>
|
||||
</Button>
|
||||
|
||||
<!-- EDIT: explicit toggle. Neutral = off (chat), blue = on (layers/properties). The
|
||||
trigger used to set the SAME colour as the resting state, so the button never
|
||||
looked pressed. Right edge aligns with the layers panel below. -->
|
||||
<ToggleButton Grid.Column="1" IsChecked="{Binding EditingActive, Mode=TwoWay}" Content="EDIT"
|
||||
Cursor="Hand" Height="32" Padding="14,0" Margin="18,0,0,2"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
FontWeight="Bold" FontSize="12" Foreground="{StaticResource Brush.OnAccent}" BorderThickness="0">
|
||||
<ToggleButton.Template>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="EditBody" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="EditBody" Property="Background" Value="{StaticResource Brush.Tertiary}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</ToggleButton.Template>
|
||||
</ToggleButton>
|
||||
</Grid>
|
||||
|
||||
<!-- Session surface (top bar redesign): the bar is ONE surface rendered by
|
||||
@@ -81,44 +61,174 @@
|
||||
reality line + End so nothing mid-session can be re-armed. -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<!-- IDLE: the mode switch + the action(s) of the armed world. The three
|
||||
controls share one height (build 77 feedback) so they read as one
|
||||
family — segments host, Start/Go Live, and Test all sit at 32. -->
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Visibility="{Binding ShowIdleWorld, Converter={StaticResource BoolToVis}}">
|
||||
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="2" Height="32" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<ToggleButton Style="{StaticResource RecordPillToggle}"
|
||||
IsChecked="{Binding RecordPillOn}">
|
||||
<!-- the label follows the pill's own foreground: muted when armed-off,
|
||||
dark on red when armed (SegmentLabel pins its own colour) -->
|
||||
<TextBlock Text="REC" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"
|
||||
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
|
||||
</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource RecordPillToggle}"
|
||||
IsChecked="{Binding OnAirPillOn}">
|
||||
<TextBlock Text="ON-AIR" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"
|
||||
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Go face names the world: "Start Recording" vs "Go Live".
|
||||
(Signed-out ON-AIR Start routes to the sign-in — StartSession.) -->
|
||||
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
|
||||
Height="32" Margin="10,0,0,0" Command="{Binding PrimaryStartCommand}"
|
||||
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<!-- TASK 32·3: the drop ended this broadcast. YouTube cannot resume a completed one, so
|
||||
the only honest action is a new broadcast — one click, saved details, and the title
|
||||
says it is a resume so the channel shows what happened. -->
|
||||
<Button x:Name="BackOnAirButton" Content="{Binding BackOnAirLabel}"
|
||||
Style="{StaticResource YtButton}" Height="32" Margin="10,0,0,0"
|
||||
Background="{StaticResource Brush.Primary}"
|
||||
Command="{Binding BackOnAirCommand}"
|
||||
ToolTip="Start a new broadcast from your saved details — this begins a new video"
|
||||
Visibility="{Binding IsOffAirAfterDrop, Converter={StaticResource BoolToVis}}"/>
|
||||
<!-- No separate TEST button (creator ruling 2026-10-30): EDIT decides whether
|
||||
the ON-AIR start is the real livestream or the test one. -->
|
||||
</StackPanel>
|
||||
<!-- THE decision (creator ruling 2026-11-12): [ Build Scenes ][ Record / Go Live ].
|
||||
Build Scenes is the new edit mode — there is no separate EDIT button, so the
|
||||
question "which world am I in?" and "can I edit?" cannot disagree. Clicking the
|
||||
highlighted half re-enters it, which is how a creator gets back to the layers
|
||||
during a session. -->
|
||||
<!-- THREE FIXED SLOTS (creator, 2026-11-13): [Build Scenes] [YouTube] [Record]. The bar's
|
||||
shape NEVER changes — every transition happens inside one slot, so the neighbouring
|
||||
buttons cannot move. Each slot is its own little flow. -->
|
||||
<Border Background="{StaticResource Brush.SegmentTrack}" CornerRadius="12" Padding="4"
|
||||
VerticalAlignment="Center" Margin="0,0,12,0"
|
||||
KeyboardNavigation.TabNavigation="Cycle" KeyboardNavigation.DirectionalNavigation="Cycle">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- ══ SLOT 1: BUILD SCENES ══ -->
|
||||
<Button x:Name="BuildSegmentButton" Grid.Column="0" Style="{StaticResource SegmentHalf}"
|
||||
Tag="True" Command="{Binding SelectBuildCommand}"
|
||||
IsEnabled="{Binding IsBuildScenesEnabled}"
|
||||
ToolTip="{Binding BuildScenesTooltip}" FocusVisualStyle="{x:Null}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource SegmentIcon}" Tag="True" Data="M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z"/>
|
||||
<TextBlock Text="Build Scenes" FontSize="12" FontWeight="Bold" Margin="6,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- ══ SLOT 2: YOUTUBE ══ -->
|
||||
<Grid Grid.Column="1" Margin="4,0">
|
||||
<!-- State 0: idle -->
|
||||
<Button Style="{StaticResource SegmentHalf}" Tag="False"
|
||||
Command="{Binding OpenYouTubeCommand}" FocusVisualStyle="{x:Null}"
|
||||
Visibility="{Binding IsYouTubeIdle, Converter={StaticResource BoolToVis}}"
|
||||
ToolTip="Stream to YouTube">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource SegmentIcon}" Tag="False" Data="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
|
||||
<TextBlock Text="YouTube" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- State 1: choose privacy — grouped inside a bordered sub-pill -->
|
||||
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
|
||||
Visibility="{Binding IsYouTubeChoosingPrivacy, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding ChooseLiveCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Public livestream">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource OutputChipIcon}" Data="M8,5v14l11,-7z"/>
|
||||
<TextBlock Text="Live" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.SegmentTextActive}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding ChoosePrivateCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Private stream — nothing public is published">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource OutputChipIcon}" Data="M7,10V7a5,5 0 0,1 10,0v3h1a1,1 0 0,1 1,1v9a1,1 0 0,1 -1,1H6a1,1 0 0,1 -1,-1v-9a1,1 0 0,1 1,-1h1zM9,10h6V7a3,3 0 0,0 -6,0v3z"/>
|
||||
<TextBlock Text="Private" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.SegmentTextActive}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelYouTubeCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
|
||||
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- State 2: armed — amber border, because the next click goes on air -->
|
||||
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
|
||||
BorderBrush="{StaticResource Brush.Attention}" BorderThickness="1"
|
||||
Visibility="{Binding IsYouTubeArmed, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding StartYouTubeCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Start broadcasting now">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
|
||||
Fill="{StaticResource Brush.Attention}"/>
|
||||
<TextBlock Text="{Binding YouTubeArmLabel}" FontSize="12" FontWeight="Bold"
|
||||
Margin="5,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.SegmentTextActive}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelYouTubeCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
|
||||
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- State 3: streaming -->
|
||||
<Button Style="{StaticResource SegmentHalf}" Tag="True"
|
||||
Command="{Binding StopYouTubeCommand}" FocusVisualStyle="{x:Null}"
|
||||
Visibility="{Binding IsYouTubeStreaming, Converter={StaticResource BoolToVis}}"
|
||||
ToolTip="End the broadcast">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource SegmentIcon}" Tag="True" Data="M6,6h12v12H6z"/>
|
||||
<TextBlock Text="{Binding YouTubeStopLabel}" FontSize="12" FontWeight="Bold"
|
||||
Margin="6,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- ══ SLOT 3: RECORD ══ -->
|
||||
<Grid Grid.Column="2" Margin="4,0,0,0">
|
||||
<!-- State 0: idle -->
|
||||
<Button Style="{StaticResource SegmentHalf}" Tag="False"
|
||||
Command="{Binding OpenRecordCommand}" FocusVisualStyle="{x:Null}"
|
||||
Visibility="{Binding IsRecordIdle, Converter={StaticResource BoolToVis}}"
|
||||
ToolTip="Record to a local MP4">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource SegmentIcon}" Tag="False" Data="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20zM12,4a8,8 0 1,1 0,16a8,8 0 1,1 0,-16zM12,7.5a4.5,4.5 0 1,1 0,9a4.5,4.5 0 1,1 0,-9z"/>
|
||||
<TextBlock Text="Record" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- State 1: armed -->
|
||||
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
|
||||
BorderBrush="{StaticResource Brush.Attention}" BorderThickness="1"
|
||||
Visibility="{Binding IsRecordArmed, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding StartRecordingCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Start recording now">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
|
||||
Fill="{StaticResource Brush.Attention}"/>
|
||||
<TextBlock Text="Start Recording" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.SegmentTextActive}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelRecordCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
|
||||
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- State 2: recording — green pulse, clock, stop -->
|
||||
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
|
||||
Visibility="{Binding IsRecordRecording, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
|
||||
Fill="{StaticResource Brush.RecordLive}"
|
||||
Opacity="{Binding RecDotOpacity, Mode=OneWay}"/>
|
||||
<TextBlock Text="{Binding RecordElapsedLabel}" FontSize="11" FontWeight="Bold"
|
||||
FontFamily="Consolas" Margin="5,0,8,0" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.SegmentTextActive}"/>
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding StopRecordingCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Stop recording and save">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource OutputChipIcon}" Data="M6,6h12v12H6z"/>
|
||||
<TextBlock Text="Stop" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.SegmentTextActive}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
|
||||
<StackPanel Orientation="Horizontal"
|
||||
|
||||
@@ -19,6 +19,10 @@ public partial class TopBar : UserControl
|
||||
InitializeComponent();
|
||||
AvatarImage.Source = null;
|
||||
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
|
||||
|
||||
// Keyboard enters the bar on Build scenes, and Tab cycles between the two halves only
|
||||
// (creator, build 729) — Tab must not walk off into the rest of the window from here.
|
||||
Loaded += (_, _) => BuildSegmentButton.Focus();
|
||||
}
|
||||
|
||||
/// <summary>Right-click on the LlamaCasty wordmark opens the app menu (Report Bug / Feature
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, static TextOverlay preview image from the shared cached text raster, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
|
||||
| `SceneThumbnailStrip.xaml(.cs)` | Top row over the preview column: five fixed-size scene cards with scene name, hotkey, staged `PROGRAM` badge, orange staged/live border |
|
||||
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept; TextOverlay properties edit multiline content, font type/family, size, color via the shared palette, emphasis, and horizontal/vertical alignment with a static-text notice; sub-options are indented under their headings. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
|
||||
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; About includes the output-quality conversion copy and an explicit llamacasty.com link, while the licensing/legal panel remains in-app. App Settings groups recording, brand-logo, preview, transition, and staged/all-scene layout-default controls. `BackToAbout_Click` lives here |
|
||||
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; About includes the output-quality conversion copy and an explicit llamacasty.com link, while the licensing/legal panel remains in-app. App Settings groups recording, slide-out alert placement/stickiness, brand-logo, preview, transition, and staged/all-scene layout-default controls. `BackToAbout_Click` lives here |
|
||||
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
|
||||
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
|
||||
|
||||
|
||||
+30
-20
@@ -1,30 +1,40 @@
|
||||
# HANDOFF — 2026-11-11 (TASK 37 complete; queue advances to TASK 40)
|
||||
# HANDOFF — 2026-11-12 (bitrate + audio-clock fixes; sync number pending)
|
||||
|
||||
## State
|
||||
|
||||
- `main`, clean and pushed to `origin/main` at `1d35a74`.
|
||||
- **Done:** TASK 35 scene-linked audio, TASK 55 disk hygiene, TASK 65 replay clips, TASK 37 defaults/current split.
|
||||
- TASK 37: SQLite keeps an immutable default snapshot beside build-stamped current rows; App Settings can
|
||||
restore the staged scene or all scenes, offline only. Webcam identity survives both restore paths.
|
||||
- TASK 14·15 mic input gain was closed by creator test (−2.6 dB, works great); slices 16–18 remain.
|
||||
- Verification: **615 tests green, clean build with 0 warnings** (`FAST=1 ./scripts/verify.sh`).
|
||||
- Steam remains blocked on Valve identity/tax review; no app IDs or store page.
|
||||
- `main`; `b265d42` is the latest pushed commit. Everything after is local.
|
||||
- **Bitrate saga closed.** An @8 stream averaged 5.1 Mbps because the args were capped VBR, not CBR
|
||||
(measured: 9 kbps vs 1951 kbps at a 2000k target on a static frame). `700c7a8` adds `-rc cbr` for
|
||||
NVENC/AMF, nothing for QSV (infers CBR from bitrate == maxrate) or OpenH264 (no measurable gain).
|
||||
- Ladder `1708667` (12/10/6); floor `93719a2` (tier YouTube minimum); rate box `f2cab5c`; rate history +
|
||||
consent proposal `b0f8263`; pre-release 60 fps switch `1e3a4f7`.
|
||||
- **Audio drift closed** (`47f409f`, `102cb96`, `751d9b1`): the mix is driven by the monotonic clock,
|
||||
counts only audio whose write reached the pipe, and starts its clock at session open. Measured
|
||||
across three recordings: +2.1% long → 226 ms short → 137 ms short → **lengths now match (34 ms in
|
||||
13 s)**.
|
||||
- **Sync answer (measured, settled).** Mic delay ships at **0** and should stay off; with it off the audio
|
||||
path runs ~100 ms behind, so the **global** LIP-SYNC offset is **−130 ms**. Both offsets default to 0 —
|
||||
a correction belongs to one camera, never to the product (`AudioSyncSettingsTests` pins it).
|
||||
- **675 tests green, 0 warnings.** `scripts/avsync.py` measures any recording in seconds.
|
||||
|
||||
## Next action
|
||||
|
||||
- Queue: **40·A/C/D → Move → 9·5 → 14·16/17/18 → 66 → 67 → 68 → 36**.
|
||||
- Ask creator before TASK 68 about first-run ON-AIR default; before gold pass, settle the Release
|
||||
single-instance mutex behavior.
|
||||
- TASK 34 scheduling and TASK 50 recording publish are retired; YouTube owns those surfaces.
|
||||
- Creator: set the mic delay to 350, record ~15 s, run `./scripts/avsync.py "<clip>" --crop
|
||||
764x764+1032+159`. Target: `offset` inside ±30 ms and `lengths match`.
|
||||
- Then resume the queue: **40·A/C/D → Move → 9·5 → 14·16/17/18 → 66 → 67 → 68 → 69 → 70 → 36**.
|
||||
- Do not push app commits absent a checkpoint request.
|
||||
|
||||
## Landmines
|
||||
|
||||
- Preview frame-source layers need a `PreviewPane` DataTrigger; pin each new type with a visibility test.
|
||||
- Bundled-art healing compares bytes (`LayoutStore.AssetMatches`); a compiled version constant races its resource.
|
||||
- Pack URIs for bundled assets must be assembly-qualified (`/LlamaCasty;component/Assets/…`).
|
||||
- Media sessions are keyed by path + look; never acquire by raw path.
|
||||
- Audio pipe allows one server per name; DEBUG suffixes it, Release does not.
|
||||
- Any test constructing `MainWindow` must use a temporary `LayoutPathOverride` and clear it in `finally`.
|
||||
- Build with the Windows `dotnet.exe` host from WSL; read render numbers in Release.
|
||||
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
|
||||
- `Assets/starting-background.jpg` is modified in the working tree, **awaiting the creator's visual
|
||||
check** — do not commit it with another unit.
|
||||
- **Never read a field the constructor assigns later** (`RefreshRateProposal()` at ctor 354 reads
|
||||
`_framePump`, built at 468 — 8 failing tests, `e654a16`).
|
||||
- **Never pipe a long job through `tail`** — it buffers, so a live gate looks dead, and verify's phase-3
|
||||
build writes to `/dev/null`. Use `vstest --logger:"console;verbosity=normal"`; it names the failure.
|
||||
- A killed test run leaves `testhost.exe` holding `LlamaCasty.dll`; every later build then fails on the
|
||||
copy. `taskkill.exe /F /IM testhost.exe` before rebuilding.
|
||||
- `ChromaKeyRealClipTests`, `AudioPipelineTests.Mix_*`, `AppLogRotationTests` and
|
||||
`NotificationAreaIntegrationTests` flake under load; each passes alone.
|
||||
- Every rate change is a full encoder relaunch; rates go through `MainViewModel.EffectiveQuality`.
|
||||
- `MainWindow` tests need a temporary `LayoutPathOverride` cleared in `finally`; read render numbers in Release.
|
||||
|
||||
+42
-3
@@ -145,6 +145,45 @@
|
||||
ItemContainerStyle="{StaticResource YtQualityComboBoxItem}"
|
||||
ToolTip="{Binding ResolutionHelp}"
|
||||
IsEnabled="{Binding CanChangeQuality}"/>
|
||||
<!-- The creator's own rate: the tier sets the geometry, this sets the bits.
|
||||
Guidance lives in the tooltip (YouTube's numbers, from RateLadder). -->
|
||||
<Grid Margin="0,6,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="RatedMbpsBox" Grid.Column="0" Style="{StaticResource YtTextBoxCompact}"
|
||||
Text="{Binding RatedMbps, UpdateSourceTrigger=LostFocus, StringFormat={}{0:0.#}}"
|
||||
ToolTip="{Binding RateHint}"
|
||||
IsEnabled="{Binding CanChangeQuality}"/>
|
||||
<TextBlock Grid.Column="1" Text="Mbps" Margin="6,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"/>
|
||||
</Grid>
|
||||
<!-- Between sessions only, never mid-show, never applied without a click: what
|
||||
the last few sessions actually held (Models/RateHistory). -->
|
||||
<Border Margin="0,6,0,0" Padding="8,6" CornerRadius="4"
|
||||
Background="{StaticResource Brush.SurfaceContainerHigh}">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasRateProposal}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding RateProposalText}" TextWrapping="Wrap" FontSize="11"
|
||||
Foreground="{StaticResource Brush.OnSurface}"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||
<Button Content="Use it" Command="{Binding AcceptRateProposalCommand}"
|
||||
Style="{StaticResource YtButton}"/>
|
||||
<Button Content="Keep mine" Command="{Binding DismissRateProposalCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="6,0,0,0"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -170,10 +209,10 @@
|
||||
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
|
||||
<controls:OverlayHost x:Name="AboutOverlay" Grid.RowSpan="3"/>
|
||||
|
||||
<!-- ═══ TOASTS: non-blocking notifications (bottom-right, above footer) ═══ -->
|
||||
<!-- ═══ TOASTS: non-blocking notifications (user-positioned, bottom-right by default) ═══ -->
|
||||
<notifications:NotificationArea x:Name="ToastArea"
|
||||
Grid.RowSpan="4"
|
||||
Position="BottomRight" MaxItems="4"
|
||||
Margin="0,0,12,96"/>
|
||||
Position="{Binding ToastPosition}" MaxItems="4"
|
||||
Margin="{Binding ToastAreaMargin}"/>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
+39
-1
@@ -147,8 +147,28 @@ public partial class MainWindow : Window
|
||||
_topBar.RefreshAvatar();
|
||||
}
|
||||
|
||||
private void MainWindow_Closing(object? sender, CancelEventArgs e)
|
||||
private bool _shutdownComplete;
|
||||
|
||||
private async void MainWindow_Closing(object? sender, CancelEventArgs e)
|
||||
{
|
||||
if (_shutdownComplete)
|
||||
{
|
||||
SaveWindowGeometry();
|
||||
_viewModel.Shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// A session must END before anything is disposed (2026-11-12: closing mid-recording used to
|
||||
// pull the pump out from under itself). Hold the window open, stop properly, then close again.
|
||||
if (_viewModel.IsLive || _viewModel.IsRecording)
|
||||
{
|
||||
e.Cancel = true;
|
||||
await _viewModel.StopBeforeShutdownAsync();
|
||||
_shutdownComplete = true;
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
|
||||
SaveWindowGeometry();
|
||||
_viewModel.Shutdown();
|
||||
}
|
||||
@@ -181,6 +201,24 @@ public partial class MainWindow : Window
|
||||
{
|
||||
_viewModel.DismissZoomIfActive();
|
||||
}
|
||||
|
||||
// Esc leaves a sub-menu (creator ruling 2026-11-12): the Output world is a sub-menu, and its
|
||||
// back chevron is gone in favour of this. The bar says so under the switches.
|
||||
// Esc backs out of whichever slot is mid-flow (creator, 2026-11-13): the slots cancel in place
|
||||
// rather than moving the bar around.
|
||||
if (e.Key == System.Windows.Input.Key.Escape)
|
||||
{
|
||||
if (_viewModel.IsYouTubeChoosingPrivacy || _viewModel.IsYouTubeArmed)
|
||||
{
|
||||
_viewModel.CancelYouTubeCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (_viewModel.IsRecordArmed)
|
||||
{
|
||||
_viewModel.CancelRecordCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateTaskbarOverlay()
|
||||
|
||||
+14
-1
@@ -19,6 +19,19 @@ public static class OutputProfile
|
||||
/// <summary>Free ceiling: 30 frames per second.</summary>
|
||||
public const int FreeMaxFps = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-release switch. <c>false</c> means the frame-rate boundary is not enforced yet, so every
|
||||
/// shipped tier is selectable by any build — the app is still being tested at 60 fps in
|
||||
/// <b>Release</b>, where <c>DevBuild.IsDevelopment</c> is false and the developer override cannot
|
||||
/// help. Flip to <c>true</c> when the paid edition ships; the rule itself already exists and its
|
||||
/// tests do not change.
|
||||
///
|
||||
/// <para><b>Why <c>static readonly</c>:</b> a <c>const</c> folds the consuming <c>||</c> at compile
|
||||
/// time and leaves an unreachable operand, which is a warning. The JIT folds a readonly field
|
||||
/// just as well, and the build stays at zero warnings.</para>
|
||||
/// </summary>
|
||||
public static readonly bool EntitlementEnforced = false;
|
||||
|
||||
/// <summary>The default free tier and fallback when no shipped tier is available.</summary>
|
||||
public const string FreeFallbackName = "1080p30";
|
||||
|
||||
@@ -51,6 +64,6 @@ public static class OutputProfile
|
||||
|
||||
// No shipped tier fits the budget: build the floor explicitly rather than trusting
|
||||
// the table. A build whose tiers were all redefined must still emit a legal frame.
|
||||
return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 8.0, 1920, 1080);
|
||||
return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 10.0, 1920, 1080);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>One finished broadcast, as evidence about a tier's rate.</summary>
|
||||
/// <param name="Tier">The quality tier's display name, e.g. "1080p60".</param>
|
||||
/// <param name="Encoder">The probed encoder, e.g. "h264_nvenc" — history against one encoder is not
|
||||
/// evidence about another, so a GPU or driver change cannot import stale conclusions.</param>
|
||||
/// <param name="RatedMbps">The rate the session started at (the creator's or the tier default).</param>
|
||||
/// <param name="SettledMbps">The rate the session ended at after any step-down.</param>
|
||||
/// <param name="Steps">How many quality steps the session spent. Zero means the rate held.</param>
|
||||
public readonly record struct SessionRateResult(
|
||||
string Tier, string Encoder, double RatedMbps, double SettledMbps, int Steps)
|
||||
{
|
||||
public bool Stepped => Steps > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the app has learned about a connection, as pure policy — the only thing allowed to suggest a
|
||||
/// different starting rate, and it never applies one itself.
|
||||
///
|
||||
/// <para><b>High-water mark</b> is the highest rate that <i>finished</i> a session with zero steps, not
|
||||
/// the highest number ever seen: one easy session (a menu screen, a quiet game) must not set a target
|
||||
/// the machine cannot hold, which would make every later session look like a regression.</para>
|
||||
///
|
||||
/// <para><b>Asymmetric on purpose.</b> Raising needs a 10% improvement across five clean sessions, so
|
||||
/// fixing nothing is slow; lowering needs only repeated step-downs, because fixing damage should not
|
||||
/// wait. A creator may always override either way — see <c>MainViewModel.RatedMbps</c>.</para>
|
||||
/// </summary>
|
||||
public static class RateHistory
|
||||
{
|
||||
/// <summary>Sessions considered. Below this there is no evidence, only noise.</summary>
|
||||
public const int Window = 5;
|
||||
|
||||
/// <summary>How far above the current rate an upward proposal must sit to be worth asking.</summary>
|
||||
public const double RaiseThreshold = 0.10;
|
||||
|
||||
/// <summary>Step-downs in the window that make a downward proposal worth asking.</summary>
|
||||
public const int LowerAfterSteps = 3;
|
||||
|
||||
/// <summary>A suggested starting rate, and which way it moves.</summary>
|
||||
public readonly record struct Proposal(double Mbps, bool Raise);
|
||||
|
||||
/// <summary>
|
||||
/// The suggestion for one tier, or null when the evidence does not warrant one. <paramref name="encoder"/>
|
||||
/// null considers every encoder; callers pass the current one so a hardware change starts clean.
|
||||
/// </summary>
|
||||
public static Proposal? Suggest(
|
||||
IEnumerable<SessionRateResult> history, string tier, string? encoder, double currentMbps)
|
||||
{
|
||||
var window = history
|
||||
.Where(entry => entry.Tier == tier && (encoder is null || entry.Encoder == encoder))
|
||||
.TakeLast(Window)
|
||||
.ToList();
|
||||
|
||||
if (window.Count < Window) return null;
|
||||
|
||||
if (window.Count(entry => entry.Stepped) >= LowerAfterSteps)
|
||||
return new Proposal(Math.Round(Median(window.Select(entry => entry.SettledMbps)), 1), Raise: false);
|
||||
|
||||
var highWater = window.Max(entry => entry.Stepped ? 0 : entry.RatedMbps);
|
||||
return highWater >= currentMbps * (1 + RaiseThreshold)
|
||||
? new Proposal(Math.Round(highWater, 0), Raise: true)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static double Median(IEnumerable<double> values)
|
||||
{
|
||||
var sorted = values.OrderBy(value => value).ToArray();
|
||||
if (sorted.Length == 0) return 0;
|
||||
var middle = sorted.Length / 2;
|
||||
return sorted.Length % 2 == 1
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
}
|
||||
|
||||
// ─── Storage form: one line per session, unit-separated fields ───
|
||||
|
||||
private const char Field = '\u001F';
|
||||
|
||||
public static string Format(SessionRateResult entry)
|
||||
=> string.Join(Field, entry.Tier, entry.Encoder,
|
||||
entry.RatedMbps.ToString("0.#"), entry.SettledMbps.ToString("0.#"), entry.Steps);
|
||||
|
||||
public static SessionRateResult? Parse(string? line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) return null;
|
||||
var parts = line.Split(Field);
|
||||
if (parts.Length != 5 || !double.TryParse(parts[2], out var rated)
|
||||
|| !double.TryParse(parts[3], out var settled) || !int.TryParse(parts[4], out var steps))
|
||||
return null;
|
||||
return new SessionRateResult(parts[0], parts[1], rated, settled, steps);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>
|
||||
/// YouTube's published H.264 ingestion numbers for a tier, in Mbps — the floor a step-down may walk
|
||||
/// to and the recommended rate a creator aims at.
|
||||
///
|
||||
/// <para><b>Source:</b> support.google.com/youtube/answer/2853702 (checked 2026-11-12): 1080p@60
|
||||
/// 6 min / 17 recommended, 1080p@30 5/14, 720p@30 and @60 3/8. The same page specifies CBR, which is
|
||||
/// why these numbers are a target the encoder actually holds (<c>FfmpegArgs.AddRateControl</c>).</para>
|
||||
///
|
||||
/// <para><b>Vertical is not a separate row.</b> 1080×1920 has the same pixel count as 1920×1080, so a
|
||||
/// portrait tier takes the band of its landscape pair — the frame rate alone decides which row.</para>
|
||||
/// </summary>
|
||||
public static class RateLadder
|
||||
{
|
||||
/// <summary>Minimum and recommended rates for a tier.</summary>
|
||||
public readonly record struct Band(double Minimum, double Recommended);
|
||||
|
||||
public static Band For(int width, int height, int fps)
|
||||
{
|
||||
var is1080 = Math.Max(width, height) >= 1920;
|
||||
return (is1080, fps > 30) switch
|
||||
{
|
||||
(true, true) => new Band(6.0, 17.0),
|
||||
(true, false) => new Band(5.0, 14.0),
|
||||
(_, _) => new Band(3.0, 8.0),
|
||||
};
|
||||
}
|
||||
|
||||
public static Band For(QualityOption tier) => For(tier.Width, tier.Height, tier.Fps);
|
||||
}
|
||||
@@ -21,12 +21,17 @@ public enum SessionStartKind
|
||||
/// <para>Pure so the whole matrix is testable without a window.</para></summary>
|
||||
public static class SessionStartDecision
|
||||
{
|
||||
public static SessionStartKind For(bool recordArmed, bool onAirArmed, bool editing)
|
||||
/// <summary>
|
||||
/// Which kind of session the armed output starts. Since 2026-11-12 there is no inference from
|
||||
/// editing: the output stage offers "Private Test Stream" and "Review & Go Live" as separate
|
||||
/// buttons, so ON-AIR always means the real livestream and Test is something the creator asks for.
|
||||
/// </summary>
|
||||
public static SessionStartKind For(bool recordArmed, bool onAirArmed)
|
||||
{
|
||||
// Neither pill armed still starts something: local recording needs no account, and the
|
||||
// Neither output armed still starts something: local recording needs no account, and the
|
||||
// no-dead-ends rule forbids an inert primary button.
|
||||
if (recordArmed || !onAirArmed) return SessionStartKind.Record;
|
||||
return editing ? SessionStartKind.Test : SessionStartKind.Live;
|
||||
return SessionStartKind.Live;
|
||||
}
|
||||
|
||||
/// <summary>What pressing the button actually does, given whether YouTube is connected.
|
||||
|
||||
@@ -105,8 +105,11 @@ public sealed class StepDownPolicy
|
||||
|
||||
/// <summary>Explicit geometry: the tier the creator actually chose, which is what decides the
|
||||
/// step budget (a 1080 start gets three, a 720 start gets one).</summary>
|
||||
/// <param name="floorBitrateMbps">The lowest rate a step-down may reach — the tier's YouTube
|
||||
/// minimum (<see cref="RateLadder"/>). Never below the starting rate, so an unusual start cannot
|
||||
/// begin already under its floor. Omitted, the historical 3/4 Mbps floor applies.</param>
|
||||
public StepDownPolicy(int startWidth, int startHeight, int startFps, double startBitrateMbps,
|
||||
bool allowClimb = false)
|
||||
bool allowClimb = false, double? floorBitrateMbps = null)
|
||||
{
|
||||
_vertical = startHeight > startWidth;
|
||||
_allowClimb = allowClimb;
|
||||
@@ -114,7 +117,9 @@ public sealed class StepDownPolicy
|
||||
_ceilingWidth = startWidth;
|
||||
_ceilingHeight = startHeight;
|
||||
_ceilingBitrateMbps = startBitrateMbps;
|
||||
_floorBitrateMbps = _vertical ? 4.0 : 3.0;
|
||||
_floorBitrateMbps = Math.Min(
|
||||
floorBitrateMbps ?? (_vertical ? 4.0 : 3.0),
|
||||
startBitrateMbps);
|
||||
|
||||
Fps = startFps;
|
||||
Width = startWidth;
|
||||
|
||||
@@ -35,6 +35,37 @@ max 200.
|
||||
|
||||
## 🔬 Recipes registry
|
||||
|
||||
### A SETTING THAT "DIDN'T STICK" — CHECK THE DEBOUNCE FIRST (RECIPE, 2026-11-12)
|
||||
|
||||
**Symptom:** a value is set in the UI, behaves correctly for one session, and is gone after a
|
||||
restart. It looks like a wiring bug in the control; it is almost always `ScheduleSave()`.
|
||||
|
||||
**Cause:** `MainViewModel.ScheduleSave()` restarts a debounce timer that later calls `Save()`, which
|
||||
writes the whole layout. Anything changed in the last few seconds before the app closes is lost —
|
||||
and none of it is logged, so the next session reads a value the creator never chose.
|
||||
|
||||
**Quick fix:** for anything small and decisive (a sync offset, a toggle the creator just clicked),
|
||||
call the `_layoutStore.Save…()` method **in the setter itself**, before `ScheduleSave()`. Two rows
|
||||
cost nothing; a lost setting costs a whole reproduce-and-test cycle. Live example:
|
||||
`MainViewModel.Audio.cs` → `AudioSyncOffsetMs` / `MicSyncOffsetMs`.
|
||||
|
||||
**Check list when a remembered setting misbehaves:** grep the property for `ScheduleSave`, confirm a
|
||||
direct save exists, then reproduce with a *restart* (not just a new session) before debugging deeper.
|
||||
|
||||
**Bit us:** the lip-sync offset. `Audio.SyncOffsetMs` was set to −130, lost on close, and two clips
|
||||
were measured against a setting that was not in force — the numbers disagreed by ~65 ms and the
|
||||
disagreement was blamed on the audio path.
|
||||
|
||||
### A SYNC OFFSET IS NEVER A PRODUCT DEFAULT (2026-11-12)
|
||||
|
||||
**Mistake:** `MicSyncOffsetMs` shipped at **175 ms** because that suited one C920 on one desk. It
|
||||
shifted every creator's voice to correct one machine and hid whether a desync was the app's fault.
|
||||
|
||||
**Rule:** offsets ship at 0 and are opt-in. `AudioSyncSettingsTests` pins it. And the tool that
|
||||
measures them, `scripts/avsync.py`, varies **±35 ms between clips** on identical settings — it can
|
||||
answer "is this 100 ms out?" but never "is this 20 ms out?". Below ~50 ms the creator's judgement is
|
||||
the better instrument, so stop tuning rather than chasing the tool's noise.
|
||||
|
||||
### A LOOSE FILE IN `Assets/` IS NOT AN ASSET — THE CSProj LIST IS (RECIPE, 2026-11-04)
|
||||
|
||||
**Problem:** four untracked `Assets/llamacasty-*` exports sat in the tree for days, reported in
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// How many frames the mix must produce <i>now</i>, derived from the monotonic clock rather than from a
|
||||
/// tick count.
|
||||
///
|
||||
/// <para><b>Why this exists (2026-11-12):</b> the live loop used to write a fixed 480 frames per
|
||||
/// iteration and trust that every iteration was 10 ms. It is not: `Task.Delay` plus processing lands
|
||||
/// near 9.8 ms under a raised timer resolution, and 102 ticks per second where 100 are expected is
|
||||
/// 102% of an audio second per wall second. FFmpeg stamps audio by sample count, so the track comes out
|
||||
/// longer than the show and the sound slides later against a correct picture — measured on a 9.9 s
|
||||
/// recording: video 9.888 s (right), audio 10.112 s (+2.1%, 224 ms).</para>
|
||||
///
|
||||
/// <para>The video side never had this bug because the pump paces off a stopwatch. This is the audio
|
||||
/// equivalent: ask the clock what should exist, produce the difference, pad on underrun. Pure, so the
|
||||
/// drift case is a test rather than a listening test.</para>
|
||||
/// </summary>
|
||||
public static class AudioClock
|
||||
{
|
||||
/// <summary>
|
||||
/// Frames still owed at <paramref name="elapsedSeconds"/>: expected-minus-produced, clamped to
|
||||
/// 0..<paramref name="maxFrames"/>. Zero means "not due yet"; the cap keeps a stalled loop from
|
||||
/// dumping a huge burst in one write (the deficit stays owed and is paid off over the next ticks).
|
||||
/// </summary>
|
||||
public static int FramesDue(double elapsedSeconds, long framesProduced, int sampleRate, int maxFrames)
|
||||
{
|
||||
if (elapsedSeconds <= 0 || sampleRate <= 0 || maxFrames <= 0) return 0;
|
||||
var expected = (long)Math.Round(elapsedSeconds * sampleRate);
|
||||
var debt = expected - framesProduced;
|
||||
return (int)Math.Clamp(debt, 0L, maxFrames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance the produced count for frames that actually reached the encoder. A write that went
|
||||
/// nowhere (ffmpeg had not connected yet) must stay owed: counting it would shorten the track by
|
||||
/// that gap — measured 2026-11-12 on the first clock-driven recording, where 27 start-up drops made
|
||||
/// the audio 226 ms <i>short</i> after the same bug had made it 224 ms long.
|
||||
/// </summary>
|
||||
public static long Advance(long framesProduced, int frames, bool wrote)
|
||||
=> framesProduced + (wrote ? frames : 0);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Services;
|
||||
|
||||
@@ -34,6 +35,9 @@ public sealed class AudioMixer : IDisposable
|
||||
private readonly TimeSpan _mixInterval;
|
||||
private readonly Func<int>? _syncOffsetMs;
|
||||
private readonly AudioSyncDelay _syncDelay;
|
||||
private readonly AudioSyncDelay _micSyncDelay;
|
||||
private readonly Func<int>? _micSyncOffsetMs;
|
||||
private float[]? _delayedMic;
|
||||
private int _advanceSamplesRemaining;
|
||||
private bool _started;
|
||||
|
||||
@@ -97,7 +101,8 @@ public sealed class AudioMixer : IDisposable
|
||||
Func<double>? micGain = null,
|
||||
Func<double>? loopbackGain = null,
|
||||
Func<int>? syncOffsetMs = null,
|
||||
TimeSpan? mixInterval = null)
|
||||
TimeSpan? mixInterval = null,
|
||||
Func<int>? micSyncOffsetMs = null)
|
||||
{
|
||||
_mic = mic;
|
||||
_loopback = loopback;
|
||||
@@ -108,20 +113,28 @@ public sealed class AudioMixer : IDisposable
|
||||
_loopbackGain = loopbackGain;
|
||||
_syncOffsetMs = syncOffsetMs;
|
||||
_syncDelay = new AudioSyncDelay(OutputSampleRate);
|
||||
// The microphone alone, mono: a camera's frames reach the compositor later than the mic's
|
||||
// samples reach the pipe, so the voice has to wait for the picture. Loopback and alert audio
|
||||
// are not tied to a camera frame and must not move with it.
|
||||
_micSyncOffsetMs = micSyncOffsetMs;
|
||||
_micSyncDelay = new AudioSyncDelay(OutputSampleRate, channels: 1);
|
||||
_mixInterval = mixInterval ?? DefaultMixInterval;
|
||||
|
||||
var seconds = _mixInterval.TotalSeconds;
|
||||
var framesPerTick = Math.Max(1, (int)Math.Round(OutputSampleRate * seconds));
|
||||
// A tick normally owes ~10 ms; after a stall it may owe more, and the clock-based loop pays it
|
||||
// off in one write. Buffers therefore size to the largest chunk the planner can return.
|
||||
var maxChunkFrames = Math.Max(framesPerTick, OutputSampleRate / 5);
|
||||
_micBuffer = new AudioRingBuffer(OutputSampleRate * 2);
|
||||
_loopbackBuffer = new AudioRingBuffer(OutputSampleRate * 2 * 2);
|
||||
_alertBuffer = new AudioRingBuffer(OutputSampleRate * 2 * 8);
|
||||
_voiceChain = new VoiceFilterChain(OutputSampleRate);
|
||||
_ducker = new AutoDucker();
|
||||
_micChunk = new float[framesPerTick];
|
||||
_loopbackChunk = new float[framesPerTick * 2];
|
||||
_alertChunk = new float[framesPerTick * 2];
|
||||
_mixBuffer = new float[framesPerTick * 2];
|
||||
_delayedMix = new float[framesPerTick * 2];
|
||||
_micChunk = new float[maxChunkFrames];
|
||||
_loopbackChunk = new float[maxChunkFrames * 2];
|
||||
_alertChunk = new float[maxChunkFrames * 2];
|
||||
_mixBuffer = new float[maxChunkFrames * 2];
|
||||
_delayedMix = new float[maxChunkFrames * 2];
|
||||
|
||||
_mic.Started += OnMicStarted;
|
||||
_mic.SampleReady += OnMicSample;
|
||||
@@ -222,7 +235,9 @@ public sealed class AudioMixer : IDisposable
|
||||
_liveCts = cts;
|
||||
_pipe = new NamedPipeAudioWriter();
|
||||
_pipe.Start(pipeName);
|
||||
_log?.Invoke($"Audio live: started, pipe '{pipeName}'");
|
||||
// Prove in the log which mic delay is actually in force: the value lives in the layout DB and a
|
||||
// preset click is invisible in a recording.
|
||||
_log?.Invoke($"Audio live: started, pipe '{pipeName}', mic delay {_micSyncOffsetMs?.Invoke() ?? 0} ms");
|
||||
_ = Task.Run(() => LiveLoopAsync(_pipe, cts.Token));
|
||||
}
|
||||
|
||||
@@ -384,12 +399,44 @@ public sealed class AudioMixer : IDisposable
|
||||
var mixBuffer = _mixBuffer!;
|
||||
var micChunk = _micChunk!;
|
||||
var loopbackChunk = _loopbackChunk!;
|
||||
|
||||
// The audio clock is the monotonic clock, not the tick count. A fixed chunk per iteration turns
|
||||
// any scheduler error into a permanent A/V rate error (measured 2026-11-12: 2.1% fast, 224 ms
|
||||
// of drift in ten seconds); asking the clock how much is owed and producing exactly that keeps
|
||||
// the track the same length as the show.
|
||||
// The clock runs from the moment the session opens, NOT from the first successful write. Starting
|
||||
// it at connect made the track begin that much later than the picture: measured 2026-11-12, the
|
||||
// writes began 160 ms after ffmpeg started reading and the audio stream came out 137 ms shorter
|
||||
// than the video, so every word sat 160 ms late. Owed time is paid out of the capture ring when
|
||||
// the pipe connects, which is the audio that was missed — real audio, not padding.
|
||||
var clock = Stopwatch.StartNew();
|
||||
long framesProduced = 0;
|
||||
var maxChunkFrames = micChunk.Length;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var nextTick = DateTime.UtcNow + _mixInterval;
|
||||
try
|
||||
{
|
||||
var (_, micDrained, loopDrained) = FillAndMix(micChunk, loopbackChunk, mixBuffer);
|
||||
// Nowhere for audio to go until ffmpeg is reading: wait without producing, so the debt
|
||||
// stays owed and is paid from the ring the moment the pipe connects.
|
||||
if (!pipe.IsConnected)
|
||||
{
|
||||
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var frames = AudioClock.FramesDue(
|
||||
clock.Elapsed.TotalSeconds, framesProduced, OutputSampleRate, maxChunkFrames);
|
||||
if (frames <= 0)
|
||||
{
|
||||
// Nothing owed yet — sooner than the tick interval, so a short yield, not a spin.
|
||||
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var (_, micDrained, loopDrained) = FillAndMix(micChunk, loopbackChunk, mixBuffer, frames);
|
||||
var samples = frames * 2;
|
||||
|
||||
// Negative offset: skip the armed |N| ms of stream head. The pipe
|
||||
// writer tolerates a partial chunk (any float length is valid);
|
||||
@@ -398,17 +445,19 @@ public sealed class AudioMixer : IDisposable
|
||||
var writeFrom = 0;
|
||||
if (_advanceSamplesRemaining > 0)
|
||||
{
|
||||
var drop = Math.Min(_advanceSamplesRemaining, mixBuffer.Length);
|
||||
var drop = Math.Min(_advanceSamplesRemaining, samples);
|
||||
_advanceSamplesRemaining -= drop;
|
||||
writeFrom = drop;
|
||||
}
|
||||
|
||||
if (writeFrom < mixBuffer.Length)
|
||||
await pipe.WriteAsync(mixBuffer.AsMemory(writeFrom), cancellationToken).ConfigureAwait(false);
|
||||
var wrote = writeFrom >= samples
|
||||
|| await pipe.WriteAsync(mixBuffer.AsMemory(writeFrom, samples - writeFrom),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
framesProduced = AudioClock.Advance(framesProduced, frames, wrote);
|
||||
|
||||
_micDrainedTotal += micDrained;
|
||||
_loopDrainedTotal += loopDrained;
|
||||
for (var i = writeFrom; i < mixBuffer.Length; i++)
|
||||
for (var i = writeFrom; i < samples; i++)
|
||||
{
|
||||
var abs = Math.Abs(mixBuffer[i]);
|
||||
if (abs > _peakMix) _peakMix = abs;
|
||||
@@ -466,17 +515,18 @@ public sealed class AudioMixer : IDisposable
|
||||
/// Returns the pre-duck mic RMS and how many samples each buffer contributed
|
||||
/// (the 5s live-loop telemetry reads those to name whether capture or the
|
||||
/// pipe starved a silent recording).</summary>
|
||||
private (float MicRms, int MicDrained, int LoopDrained) FillAndMix(float[] micChunk, float[] loopbackChunk, float[] mix)
|
||||
private (float MicRms, int MicDrained, int LoopDrained) FillAndMix(
|
||||
float[] micChunk, float[] loopbackChunk, float[] mix, int frames)
|
||||
{
|
||||
var micCount = _micBuffer.Read(micChunk, micChunk.Length);
|
||||
var loopCount = _loopbackBuffer.Read(loopbackChunk, loopbackChunk.Length);
|
||||
var micCount = _micBuffer.Read(micChunk, frames);
|
||||
var loopCount = _loopbackBuffer.Read(loopbackChunk, frames * 2);
|
||||
var alertChunk = _alertChunk!;
|
||||
var alertCount = _alertBuffer.Read(alertChunk, alertChunk.Length);
|
||||
var alertCount = _alertBuffer.Read(alertChunk, frames * 2);
|
||||
|
||||
float micRms = 0;
|
||||
if (micCount == 0)
|
||||
{
|
||||
Array.Clear(micChunk, 0, micChunk.Length);
|
||||
Array.Clear(micChunk, 0, frames);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -484,56 +534,70 @@ public sealed class AudioMixer : IDisposable
|
||||
for (var i = 0; i < micCount; i++)
|
||||
sumSquares += micChunk[i] * micChunk[i];
|
||||
micRms = (float)Math.Sqrt(sumSquares / micCount);
|
||||
if (micCount < micChunk.Length)
|
||||
Array.Clear(micChunk, micCount, micChunk.Length - micCount);
|
||||
if (micCount < frames)
|
||||
Array.Clear(micChunk, micCount, frames - micCount);
|
||||
}
|
||||
|
||||
if (loopCount == 0)
|
||||
{
|
||||
Array.Clear(loopbackChunk, 0, loopbackChunk.Length);
|
||||
Array.Clear(loopbackChunk, 0, frames * 2);
|
||||
}
|
||||
else if (loopCount < loopbackChunk.Length)
|
||||
else if (loopCount < frames * 2)
|
||||
{
|
||||
Array.Clear(loopbackChunk, loopCount, loopbackChunk.Length - loopCount);
|
||||
Array.Clear(loopbackChunk, loopCount, frames * 2 - loopCount);
|
||||
}
|
||||
|
||||
if (alertCount == 0)
|
||||
{
|
||||
Array.Clear(alertChunk, 0, alertChunk.Length);
|
||||
Array.Clear(alertChunk, 0, frames * 2);
|
||||
}
|
||||
else if (alertCount < alertChunk.Length)
|
||||
else if (alertCount < frames * 2)
|
||||
{
|
||||
Array.Clear(alertChunk, alertCount, alertChunk.Length - alertCount);
|
||||
Array.Clear(alertChunk, alertCount, frames * 2 - alertCount);
|
||||
}
|
||||
|
||||
// Mic-only delay: applied to the source, before the mix, so the desktop and alert audio keep
|
||||
// their timing. Reconfigured every tick like the mix line above (same 2026-09-12 trap: a
|
||||
// reallocating Configure would discard the buffered voice every 10 ms).
|
||||
var micDelayMs = _micSyncOffsetMs?.Invoke() ?? 0;
|
||||
if (micDelayMs > 0)
|
||||
{
|
||||
_micSyncDelay.Configure(micDelayMs);
|
||||
var delayedMic = _delayedMic;
|
||||
if (delayedMic is null || delayedMic.Length < frames)
|
||||
delayedMic = _delayedMic = new float[frames];
|
||||
_micSyncDelay.Process(micChunk.AsSpan(0, frames), delayedMic.AsSpan(0, frames));
|
||||
Array.Copy(delayedMic, micChunk, frames);
|
||||
}
|
||||
|
||||
var duck = _ducker.Update(micRms);
|
||||
var micGain = (float)(_micGain?.Invoke() ?? 1.0);
|
||||
var loopGain = (float)(_loopbackGain?.Invoke() ?? 1.0) * duck;
|
||||
|
||||
var frames = micChunk.Length;
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
var m = micChunk[i] * micGain;
|
||||
mix[i * 2] = m + loopbackChunk[i * 2] * loopGain + alertChunk[i * 2];
|
||||
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain + alertChunk[i * 2 + 1];
|
||||
}
|
||||
_masterLimiter.Process(mix);
|
||||
var samples = frames * 2;
|
||||
_masterLimiter.Process(mix.AsSpan(0, samples));
|
||||
|
||||
_syncDelay.Configure(_syncOffsetMs?.Invoke() ?? 0);
|
||||
var delayed = _delayedMix;
|
||||
if (delayed is null || delayed.Length < mix.Length)
|
||||
delayed = _delayedMix = new float[mix.Length];
|
||||
_syncDelay.Process(mix, delayed);
|
||||
Array.Copy(delayed, mix, mix.Length);
|
||||
if (delayed is null || delayed.Length < samples)
|
||||
delayed = _delayedMix = new float[samples];
|
||||
_syncDelay.Process(mix.AsSpan(0, samples), delayed.AsSpan(0, samples));
|
||||
Array.Copy(delayed, mix, samples);
|
||||
|
||||
if (MonoOutput) Helpers.AudioFold.ToMono(mix.AsSpan(0, mix.Length), 2);
|
||||
MeasureMaster(mix);
|
||||
if (MonoOutput) Helpers.AudioFold.ToMono(mix.AsSpan(0, samples), 2);
|
||||
MeasureMaster(mix.AsSpan(0, samples));
|
||||
return (micRms, micCount, loopCount);
|
||||
}
|
||||
|
||||
/// <summary>One pass over the finished mix: 1024 samples per channel, so the cost is noise
|
||||
/// beside the mixer it sits behind.</summary>
|
||||
private void MeasureMaster(float[] mix)
|
||||
private void MeasureMaster(ReadOnlySpan<float> mix)
|
||||
{
|
||||
double left = 0, right = 0;
|
||||
var frames = mix.Length / 2;
|
||||
|
||||
@@ -3,8 +3,8 @@ using System;
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 22: a fixed end-to-end audio delay applied to the interleaved stereo
|
||||
/// mix in <see cref="AudioMixer"/>. This is OBS's documented fix for the
|
||||
/// TASK 22: a fixed end-to-end audio delay applied either to the interleaved
|
||||
/// stereo mix or to one mono source in <see cref="AudioMixer"/>. This is OBS's documented fix for the
|
||||
/// "audio is ahead of video" lip-sync case: delay the audio by a few
|
||||
/// milliseconds so it lands on the video. It is a pure delay line — input in,
|
||||
/// the same audio N ms later out — so it is trivially testable and carries no
|
||||
@@ -20,14 +20,19 @@ namespace ytLive.Services.Audio;
|
||||
public sealed class AudioSyncDelay
|
||||
{
|
||||
private readonly int _sampleRate;
|
||||
private readonly int _channels;
|
||||
|
||||
private float[] _buffer = Array.Empty<float>();
|
||||
private int _delaySamples;
|
||||
private int _writePos;
|
||||
|
||||
public AudioSyncDelay(int sampleRate)
|
||||
/// <param name="channels">Samples per frame in the chunks passed to <see cref="Process"/>: 2 for
|
||||
/// the interleaved stereo mix, 1 for a mono source such as the microphone. The offset is always
|
||||
/// specified in milliseconds, so the frame count — not the sample count — is what stays constant.</param>
|
||||
public AudioSyncDelay(int sampleRate, int channels = 2)
|
||||
{
|
||||
_sampleRate = Math.Max(1, sampleRate);
|
||||
_channels = Math.Clamp(channels, 1, 2);
|
||||
Configure(0);
|
||||
}
|
||||
|
||||
@@ -45,13 +50,13 @@ public sealed class AudioSyncDelay
|
||||
var delaySamples = Frames(delayMs);
|
||||
if (delaySamples == _delaySamples && _buffer.Length > 0) return;
|
||||
_delaySamples = delaySamples;
|
||||
_buffer = new float[(_delaySamples + _sampleRate) * 2 + 1024];
|
||||
_buffer = new float[(_delaySamples + _sampleRate) * _channels + 1024];
|
||||
_writePos = 0;
|
||||
}
|
||||
|
||||
/// <summary>Delays an interleaved stereo chunk by the configured offset.
|
||||
/// The returned length equals <paramref name="input"/>'s; the head is
|
||||
/// silence until the line fills.</summary>
|
||||
/// <summary>Delays one interleaved chunk (stereo or mono, per the constructor) by the configured
|
||||
/// offset. The returned length equals <paramref name="input"/>'s; the head is silence until the
|
||||
/// line fills.</summary>
|
||||
public void Process(ReadOnlySpan<float> input, Span<float> output)
|
||||
{
|
||||
if (output.Length < input.Length)
|
||||
@@ -87,8 +92,8 @@ public sealed class AudioSyncDelay
|
||||
private int Frames(int delayMs)
|
||||
{
|
||||
var clamped = Math.Clamp(delayMs, 0, 500);
|
||||
// Interleaved stereo: each frame is two samples (L, R), so the delay
|
||||
// in buffer positions is twice the frame count.
|
||||
return (int)Math.Round(_sampleRate / 1000.0 * clamped) * 2;
|
||||
// A frame is `_channels` samples, so the delay in buffer positions is
|
||||
// the frame count times the channel count.
|
||||
return (int)Math.Round(_sampleRate / 1000.0 * clamped) * _channels;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed class MasterLimiter
|
||||
/// <summary>Scales the frame by the current gain. Gain drops instantly to
|
||||
/// <c>ceiling/peak</c> when the frame exceeds the ceiling and recovers
|
||||
/// toward unity slowly.</summary>
|
||||
public void Process(float[] samples)
|
||||
public void Process(Span<float> samples)
|
||||
{
|
||||
var peak = 0f;
|
||||
foreach (var s in samples)
|
||||
|
||||
@@ -23,8 +23,10 @@ public interface IAudioPipeWriter : IDisposable
|
||||
/// connected to the pipe.</summary>
|
||||
long DroppedWrites { get; }
|
||||
|
||||
/// <summary>Writes interleaved float samples; dropped until the client connects.</summary>
|
||||
Task WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default);
|
||||
/// <summary>Writes interleaved float samples. Returns false when nothing was written because the
|
||||
/// client has not connected or the pipe is closed — the caller must not count that audio as
|
||||
/// produced, or a start-up gap silently shortens the track.</summary>
|
||||
Task<bool> WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Closes the server end — ffmpeg sees EOF and ends the audio input.</summary>
|
||||
void Stop();
|
||||
@@ -65,7 +67,7 @@ public sealed class NamedPipeAudioWriter : IAudioPipeWriter
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default)
|
||||
public async Task<bool> WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default)
|
||||
{
|
||||
NamedPipeServerStream? pipe;
|
||||
lock (_sync)
|
||||
@@ -76,11 +78,12 @@ public sealed class NamedPipeAudioWriter : IAudioPipeWriter
|
||||
{
|
||||
if (pipe?.IsConnected == false || pipe == null)
|
||||
DroppedWrites++;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var bytes = MemoryMarshal.AsBytes(samples.Span).ToArray();
|
||||
await pipe.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
|
||||
@@ -77,9 +77,29 @@ public static class FfmpegArgs
|
||||
args.AddRange(["-vf", $"scale={options.Width}:{options.Height}:flags=bilinear,setsar=1"]);
|
||||
}
|
||||
args.AddRange(["-c:v", videoEncoder]);
|
||||
AddRateControl(args, videoEncoder);
|
||||
args.AddRange(["-b:v", $"{options.BitrateKbps}k", "-maxrate", $"{options.BitrateKbps}k",
|
||||
"-bufsize", $"{options.BitrateKbps * 2}k"]);
|
||||
args.AddRange(["-g", gop.ToString(), "-keyint_min", gop.ToString(),
|
||||
"-sc_threshold", "0", "-bf", "0", "-pix_fmt", "yuv420p"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constant bitrate, spelled the one way each encoder understands.
|
||||
///
|
||||
/// <para><b>Why this is not a shared flag:</b> the <c>-b:v/-maxrate/-bufsize</c> triad is capped VBR,
|
||||
/// not CBR — measured 2026-11-12 on the bundled BtbN build, a static frame at <c>-b:v 2000k</c>
|
||||
/// produced <b>9 kbps</b> with NVENC's default rate control and <b>1951 kbps</b> with <c>-rc cbr</c>.
|
||||
/// That is the gap behind an @8 stream that averaged 5.1 Mbps.</para>
|
||||
///
|
||||
/// <para>Per encoder: NVENC and AMF take an explicit mode. QSV has no <c>-rc</c> and already selects
|
||||
/// CBR when <c>bitrate == maxrate</c> (<c>libavcodec/qsvenc.c</c>, <c>select_rc_mode</c>). OpenH264's
|
||||
/// <c>-rc_mode bitrate</c> made no measurable difference on this build, so it is deliberately not
|
||||
/// passed. YouTube's encoder spec requires CBR (support.google.com/youtube/answer/2853702).</para>
|
||||
/// </summary>
|
||||
private static void AddRateControl(List<string> args, string videoEncoder)
|
||||
{
|
||||
if (videoEncoder is "h264_nvenc" or "h264_amf")
|
||||
args.AddRange(["-rc", "cbr"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
private IEncoderProcess? _process;
|
||||
private EncoderOptions? _options;
|
||||
private StreamHealth _health = new() { Status = StreamStatus.Offline };
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? VideoEncoderName { get; private set; }
|
||||
private Task? _stderrLoop;
|
||||
private bool _stopRequested;
|
||||
|
||||
@@ -82,6 +85,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
|
||||
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
|
||||
var encoder = options.VideoEncoder ?? await ProbeEncoderAsync(ffmpegPath, cancellationToken).ConfigureAwait(false);
|
||||
VideoEncoderName = encoder;
|
||||
var args = FfmpegArgs.Build(options, encoder);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
|
||||
@@ -91,6 +91,10 @@ public sealed class FramePump : IDisposable
|
||||
|
||||
private readonly object _gate = new();
|
||||
private IFfmpegEncoder? _encoder;
|
||||
|
||||
/// <summary>The encoder this session is running, or null before the first start. The view model
|
||||
/// files each broadcast's rate history under it.</summary>
|
||||
public string? EncoderName => _encoder?.VideoEncoderName;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _pumpTask;
|
||||
private bool _started;
|
||||
@@ -248,17 +252,24 @@ public sealed class FramePump : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>TASK 33: relaunch the pump and encoder at a new tier, over the same URL and audio pipe.
|
||||
/// <summary>TASK 33: relaunch the pump and encoder at a new tier, over the same URL and named audio pipe.
|
||||
///
|
||||
/// <para>Full stop + start rather than an encoder swap, deliberately: <see cref="StopAsync"/> awaits
|
||||
/// the loop and flushes the encoder, so no in-flight submit can land on a disposed process. A bare
|
||||
/// swap races exactly that and would fault the loop — which ends the broadcast, the one thing a
|
||||
/// quality step-down must never do. The cost is a short freeze, already accepted by the ladder.</para>
|
||||
/// quality step-down must never do. The audio pipe must be closed before the pump stops and freshly
|
||||
/// opened before it starts again; otherwise ffmpeg waits on the still-open audio input, then the new
|
||||
/// process tries to attach to a broken server. The cost is a short freeze, already accepted by the ladder.</para>
|
||||
/// </summary>
|
||||
public async Task<bool> ChangeEncoderAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<bool> ChangeEncoderAsync(Action stopAudio, Action startAudio,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stopAudio);
|
||||
ArgumentNullException.ThrowIfNull(startAudio);
|
||||
stopAudio();
|
||||
await StopAsync(cancellationToken);
|
||||
if (cancellationToken.IsCancellationRequested) return false;
|
||||
startAudio();
|
||||
await StartAsync(cancellationToken);
|
||||
return IsRunning;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ public interface IFfmpegEncoder : IDisposable
|
||||
/// <summary>Fires when the subprocess dies unexpectedly (non-zero exit while live).</summary>
|
||||
event EventHandler<string>? ProcessFailed;
|
||||
|
||||
/// <summary>Which encoder the probe chose for this session ("h264_nvenc", "libopenh264", …). Null
|
||||
/// before the first start. The rate history is keyed by it: evidence about one encoder says nothing
|
||||
/// about another.</summary>
|
||||
string? VideoEncoderName { get; }
|
||||
|
||||
Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default);
|
||||
Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default);
|
||||
Task StopAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -1,11 +1,96 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Notification.Core;
|
||||
using ytLive.Services.Compositor;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
public NotificationPosition LoadNotificationPosition()
|
||||
{
|
||||
var value = GetSetting("Notifications.Position");
|
||||
return Enum.TryParse(value, true, out NotificationPosition position) && IsSupportedNotificationPosition(position)
|
||||
? position
|
||||
: NotificationPosition.BottomRight;
|
||||
}
|
||||
|
||||
public void SaveNotificationPosition(NotificationPosition position)
|
||||
{
|
||||
if (!IsSupportedNotificationPosition(position)) position = NotificationPosition.BottomRight;
|
||||
UpsertSetting("Notifications.Position", position.ToString());
|
||||
}
|
||||
|
||||
/// <summary>May editing be used while a session runs? Off by default: the creator's ruling
|
||||
/// (2026-11-12) is that moving a layer or resizing a webcam mid-show is theirs to do. Turning it on
|
||||
/// restores the old gun-safety — go-live flips EDIT off and stop flips it back.</summary>
|
||||
public bool LoadLockEditWhileLive() => GetSetting("App.LockEditWhileLive") == "1";
|
||||
|
||||
public void SaveLockEditWhileLive(bool locked)
|
||||
=> UpsertSetting("App.LockEditWhileLive", locked ? "1" : "0");
|
||||
|
||||
public bool LoadStickyAlertsUntilDismissed()
|
||||
=> GetSetting("Notifications.StickyUntilDismissed") == "1";
|
||||
|
||||
public void SaveStickyAlertsUntilDismissed(bool enabled)
|
||||
=> UpsertSetting("Notifications.StickyUntilDismissed", enabled ? "1" : "0");
|
||||
|
||||
/// <summary>The creator's own video rate for one tier, in Mbps, or null to use the shipped default.
|
||||
/// Per tier because each has its own ceiling; the rate is a target the encoder is asked to hold,
|
||||
/// so a stored number means the same thing on every run (CBR).</summary>
|
||||
public double? LoadRatedMbps(string tierDisplay)
|
||||
=> double.TryParse(GetSetting($"Video.Rate.{tierDisplay}"), out var mbps) && mbps > 0 ? mbps : null;
|
||||
|
||||
/// <summary>Finished sessions, oldest first — the evidence behind any rate suggestion. Capped so the
|
||||
/// row never grows without bound: 40 sessions is several weeks of streaming for anyone.</summary>
|
||||
public List<SessionRateResult> LoadRateHistory()
|
||||
{
|
||||
var raw = GetSetting("Video.RateHistory");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return new List<SessionRateResult>();
|
||||
return raw.Split('\n')
|
||||
.Select(RateHistory.Parse)
|
||||
.Where(entry => entry is not null)
|
||||
.Select(entry => entry!.Value)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public void AppendRateHistory(SessionRateResult entry)
|
||||
{
|
||||
var history = LoadRateHistory();
|
||||
history.Add(entry);
|
||||
if (history.Count > RateHistoryCap)
|
||||
history.RemoveRange(0, history.Count - RateHistoryCap);
|
||||
UpsertSetting("Video.RateHistory", string.Join('\n', history.Select(RateHistory.Format)));
|
||||
}
|
||||
|
||||
private const int RateHistoryCap = 40;
|
||||
|
||||
/// <summary>The proposed rate the creator already refused for this tier, or zero for none. Stored as the
|
||||
/// number rather than a flag, so a materially different suggestion may still be asked.</summary>
|
||||
public double LoadDismissedRateProposal(string tierDisplay)
|
||||
=> double.TryParse(GetSetting($"Video.RateProposalDismissed.{tierDisplay}"), out var mbps) ? mbps : 0;
|
||||
|
||||
public void SaveDismissedRateProposal(string tierDisplay, double mbps)
|
||||
=> UpsertSetting($"Video.RateProposalDismissed.{tierDisplay}", mbps.ToString("0.#"));
|
||||
|
||||
public void SaveRatedMbps(string tierDisplay, double? mbps)
|
||||
{
|
||||
if (mbps is not > 0)
|
||||
{
|
||||
using var delete = _connection.CreateCommand();
|
||||
delete.CommandText = "DELETE FROM Settings WHERE Key = $key;";
|
||||
delete.Parameters.AddWithValue("$key", $"Video.Rate.{tierDisplay}");
|
||||
delete.ExecuteNonQuery();
|
||||
return;
|
||||
}
|
||||
|
||||
UpsertSetting($"Video.Rate.{tierDisplay}", mbps.Value.ToString("0.#"));
|
||||
}
|
||||
|
||||
private static bool IsSupportedNotificationPosition(NotificationPosition position)
|
||||
=> position is NotificationPosition.TopLeft or NotificationPosition.TopRight
|
||||
or NotificationPosition.CenterLeft or NotificationPosition.CenterRight
|
||||
or NotificationPosition.BottomLeft or NotificationPosition.BottomRight;
|
||||
|
||||
/// <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()
|
||||
@@ -40,6 +125,26 @@ public partial class LayoutStore : IDisposable
|
||||
return int.TryParse(value, out var ms) ? Math.Clamp(ms, -500, 500) : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shipped default for the microphone-only delay: **zero**. A sync offset is a correction for one
|
||||
/// camera on one desk, never a product default — shipping a number would shift every creator's audio
|
||||
/// to fix one machine's camera, and would hide whether the fault is theirs or ours.
|
||||
/// </summary>
|
||||
public const int DefaultMicSyncOffsetMs = 0;
|
||||
|
||||
/// <summary>Microphone-only sync delay (0..500 ms), so the voice can wait for the camera's picture
|
||||
/// without moving desktop or alert audio. Absent means the shipped default.</summary>
|
||||
public int LoadMicSyncOffsetMs()
|
||||
{
|
||||
var value = GetSetting("Audio.MicSyncOffsetMs");
|
||||
return int.TryParse(value, out var ms)
|
||||
? Math.Clamp(ms, 0, 500)
|
||||
: DefaultMicSyncOffsetMs;
|
||||
}
|
||||
|
||||
public void SaveMicSyncOffsetMs(int ms)
|
||||
=> UpsertSetting("Audio.MicSyncOffsetMs", Math.Clamp(ms, 0, 500).ToString());
|
||||
|
||||
public void SaveAudioSyncOffsetMs(int ms)
|
||||
{
|
||||
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
|
||||
|
||||
@@ -8,9 +8,10 @@ using ytLive.Helpers;
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Toast notifications rendered inside the main window's <c>ToastArea</c>
|
||||
/// (bottom-right, above the footer). Info/success auto-dismiss after ~4s,
|
||||
/// warnings ~8s, errors stay until dismissed. Calls from any thread are
|
||||
/// Toast notifications rendered inside the main window's <c>ToastArea</c>, whose
|
||||
/// position is configurable in App Settings (bottom-right by default). Info/success auto-dismiss after ~4s,
|
||||
/// warnings ~8s, errors stay until dismissed. App Settings can make all alerts sticky and
|
||||
/// dismissible by clicking the card. Calls from any thread are
|
||||
/// marshalled to the captured dispatcher.
|
||||
/// </summary>
|
||||
public sealed class NotificationService : INotificationService
|
||||
@@ -28,35 +29,49 @@ public sealed class NotificationService : INotificationService
|
||||
|
||||
private readonly NotificationManager _manager = new();
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private readonly Func<bool> _stickyAlertsUntilDismissed;
|
||||
|
||||
public NotificationService()
|
||||
: this(Application.Current?.Dispatcher)
|
||||
: this(Application.Current?.Dispatcher, static () => false)
|
||||
{
|
||||
}
|
||||
|
||||
internal NotificationService(Dispatcher? dispatcher)
|
||||
: this(dispatcher, static () => false)
|
||||
{
|
||||
}
|
||||
|
||||
internal NotificationService(Dispatcher? dispatcher, Func<bool> stickyAlertsUntilDismissed)
|
||||
{
|
||||
_dispatcher = dispatcher ?? Dispatcher.CurrentDispatcher;
|
||||
_stickyAlertsUntilDismissed = stickyAlertsUntilDismissed;
|
||||
NotificationConstants.NotificationCornerRadius = new CornerRadius(8);
|
||||
NotificationConstants.KeepNotificationVisibleOnMouseOver = true;
|
||||
}
|
||||
|
||||
public void Show(string title, string message, AppNotificationSeverity severity)
|
||||
{
|
||||
var request = BuildRequest(title, message, severity);
|
||||
_dispatcher.Invoke(() => _manager.Show(request));
|
||||
_dispatcher.Invoke(() => _manager.Show(BuildRequestForCurrentSettings(title, message, severity)));
|
||||
}
|
||||
|
||||
internal static NotificationRequest BuildRequest(string title, string message, AppNotificationSeverity severity)
|
||||
internal NotificationRequest BuildRequestForCurrentSettings(string title, string message,
|
||||
AppNotificationSeverity severity)
|
||||
=> BuildRequest(title, message, severity, _stickyAlertsUntilDismissed());
|
||||
|
||||
internal static NotificationRequest BuildRequest(string title, string message,
|
||||
AppNotificationSeverity severity, bool stickyUntilDismissed = false)
|
||||
{
|
||||
var (backgroundHex, expirationSeconds) = Cards[severity];
|
||||
var builder = NotificationBuilder
|
||||
.Create(title, message)
|
||||
.InArea(AreaName)
|
||||
.WithBackground(NotificationColor.FromHex(backgroundHex))
|
||||
.WithForeground(NotificationColor.FromHex(ThemeBrush.Hex("Brush.OnSurface")));
|
||||
.WithForeground(NotificationColor.FromHex(ThemeBrush.Hex("Brush.OnSurface")))
|
||||
.CloseOnClick(stickyUntilDismissed);
|
||||
|
||||
if (severity == AppNotificationSeverity.Error)
|
||||
if (stickyUntilDismissed)
|
||||
builder.NeverExpires();
|
||||
else if (severity == AppNotificationSeverity.Error)
|
||||
builder.NeverExpires();
|
||||
else
|
||||
builder.ExpiresInSeconds(expirationSeconds);
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. **TASK 47 + scene ticker:** the `alertTicker: Func<Scene, VideoFrame?>?` seam selects the active scene's AlertBox ticker, threaded through `RenderScene`/`RenderFull` + **mixed into `BuildFullRenderSignature`**; standalone tickers are resolved as per-element dynamic sources. See [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Live frame pipeline". **2026-10-16:** owns a `CompositeTimings` collector, passes it through `RenderScene`/`RenderFull`/`GetBakedBase`/`CompositeLayers`, logs `FramePump parts (N renders/5s): …` (per-stage breakdown, slowest first) beside the stats line each 5s window, and dumps the scene shape — `FramePump scene dump:` header + one line per element (z-index, name, kind, type, visibility, rect, opacity, clip) — at pump start and on every scene change |
|
||||
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
|
||||
| `INotificationService.cs` | **Toast seam (TASK 24)**: `Show(title, message, AppNotificationSeverity)` + `AppNotificationSeverity { Info, Success, Warning, Error }` + `Info/Success/Warning/Error` extension methods — non-blocking user-facing messages; VMs/services raise through this so failures surface without MessageBox blocking the UI thread |
|
||||
| `NotificationService.cs` | The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library `NotificationManager`; routes to the in-window `NotificationArea` named `ToastArea` (MainWindow bottom-right above the footer); lifetimes via the pure `BuildRequest` mapping + `Cards` tint table — Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky (`NeverExpires()` = `TimeSpan.MaxValue`, never null); dark card tints matching the health-banner language; all Show calls marshal to the captured Dispatcher. See [`../ai.md`](../ai.md) "Toast notifications" |
|
||||
| `NotificationService.cs` | The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library `NotificationManager`; routes to the in-window `NotificationArea` named `ToastArea` (position configurable in App Settings; bottom-right default); Info/Success ~4s, Warning ~8s by default, Error sticky; the persisted App Settings switch makes all severities sticky and body-click dismissible. Dark card tints match the health-banner language; all Show calls marshal to the captured Dispatcher. See [`../ai.md`](../ai.md) "Toast notifications" |
|
||||
| `Audio/IAudioSource.cs` | **TASK 4 ship step 4 seam**: live capture source — `Start`/`Stop`/`Started`/`SampleReady(AudioSample)`/`Failed(Exception)`, `IDisposable`. Capture now runs for the app's lifetime (started at startup) so the footer meters preview live. The app consumes this seam; tests inject hermetic fakes |
|
||||
| `Audio/AudioSample.cs` | One captured chunk: interleaved PCM float (-1..1) + sample rate + channels |
|
||||
| `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it |
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
**Creator ruling 2026-11-09 — finish the queue before the new features, gold pass always last.**
|
||||
|
||||
`40·A/C/D → Move → 9·5 → 14 (remaining slices) → 66 → 67 → 68 → 36`
|
||||
`40·A/C/D → Move → 9·5 → 14 (remaining slices) → 66 → 67 → 68 → 69 → 70 → 36`
|
||||
|
||||
The rule behind the two retirements below: **do not rebuild the platform's own surface.** Scheduling,
|
||||
publishing, moderation and channel management are YouTube's; the app owns capture → scene → sound →
|
||||
@@ -30,6 +30,8 @@ output.
|
||||
| 66 | ☐ | **Timed ticker messages** — "every N minutes show this", from a creator-written list | [`task-66`](TASKS/task-66-timed-ticker.md) |
|
||||
| 67 | ☐ | **Per-event alert videos** — a clip per monetization event, falling back to the default | [`task-67`](TASKS/task-67-per-event-alert-videos.md) |
|
||||
| 68 | ☐ | **Editing while live** + first-run On-Air state matching the 1-2-3 screen | [`task-68`](TASKS/task-68-edit-while-live.md) |
|
||||
| 69 | ☐ | **Chat sample-preview toggle** — per-layer control to show/hide mock chat in the preview; real messages unaffected, never output | [`task-69`](TASKS/task-69-chat-preview-toggle.md) |
|
||||
| 70 | ☐ | **Window App layer** — capture one selected running app window as a visual layer over Live's game/display capture | [`task-70`](TASKS/task-70-window-app-layer.md) |
|
||||
| 36 | ☐ | **Gold pass** — the v1 gate: screens/layers audit (every control does something), native-Windows verification, branding-flash decision. Always the last unit | [`task-36`](TASKS/task-36-gold-pass.md) |
|
||||
| 34 | ❌ | **Scheduled streams — retired 2026-11-09.** YouTube owns scheduling *and* the reminder that makes it useful; the app neither creates nor displays scheduled broadcasts, and there is no value in a copy that can fall out of sync | [`decisions`](docs/ai/decisions.md) |
|
||||
| 50 | ❌ | **Publish recording — retired 2026-11-09.** YouTube Studio owns upload (resumable, quota-aware, and it explains rejections). In-app publishing would also widen the OAuth consent to whole-channel video management. The recording lands in the creator's folder; the app adds "Show in folder" instead | [`decisions`](docs/ai/decisions.md) |
|
||||
|
||||
@@ -26,6 +26,9 @@ until a frame source exists, so the compositor is ship step 1. The pipeline is
|
||||
1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only. **License posture (decided): GPL-free build** — NVENC (NVIDIA) / QSV (Intel) / AMF (AMD) + OpenH264 software fallback + built-in AAC; no libx264 (GPL contaminates a paid product). Output containers are identical either way (H.264+AAC in `.flv` for RTMP, `.mp4`/`.ts` for VOD) — the format is NOT the differentiator, the license and per-GPU quality are. **License guardrails (never violate — see `ai.md` → "Licensing — do not violate"):** only BtbN `lgpl`/`lgpl-shared` builds; never GPL (gyan.dev) or `nonfree` (fdk-aac); never static for distribution (LGPL §6 relink material); never link FFmpeg into the app; never drop `THIRD-PARTY-NOTICES.txt` from the app/About screen.
|
||||
2. **RTMP push** — **FFmpeg subprocess (decided)**: app feeds raw frames via stdin, parses stderr for health; one battle-tested binary does encode + FLV mux + push. **No self-heal (claim-check 2026-09-01): ffmpeg's reconnect flags are input-side; an RTMP *output* push does not reconnect itself — retry is app-side, owned by TASK 32.** **Binary distribution (decided): check-then-pull** — probe `where ffmpeg`/PATH at first go-live; if absent, download a **pinned** build (**BtbN LGPL-shared win64** zip, ~75 MB — gyan.dev's builds are GPLv3 and ship libx264, which violates the license posture; BtbN's LGPL variant drops x264/x265 while keeping NVENC/QSV/AMF + libopenh264 + native AAC) to `%APPDATA%\ytLlive\tools\ffmpeg.exe` (extract `ffmpeg.exe` **plus the `libav*.dll` family**) and cache it, offline-friendly. Behind an `IFfmpegLocator` seam so tests fake it (ship step 2, below). Push goes to the cached reusable stream's ingestion URL
|
||||
3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
|
||||
> **Superseded 2026-11-12:** the shipped ladder is 1080p60@12, 1080p30@10, 720p@6, vertical mirroring
|
||||
> its landscape pair, and the rate is CBR (`docs/ai/ui-surface.md`, `docs/ai/output.md`). The numbers
|
||||
> below are the original decision, recorded as history — the flat 8 was never derived from anything.
|
||||
1. 720p30 @ 6 Mbps
|
||||
2. 720p60 @ 6 Mbps
|
||||
3. 1080p30 @ 8 Mbps
|
||||
|
||||
@@ -37,6 +37,9 @@ that step relieves nothing. Fps is **not** a congestion lever: same bitrate, sam
|
||||
| 720p60 | **1** | 720p30 @ 4.8 Mbps |
|
||||
| Vertical 1080p60 | **1** | Vertical 1080p30 @ 6.4 Mbps |
|
||||
|
||||
> **Superseded 2026-11-12:** the bitrate floor is the tier's YouTube minimum — 6 Mbps at 1080p60, 5 at
|
||||
> 1080p30, 3 at 720p (`Models/RateLadder`, passed into `StepDownPolicy`) — and the ladder's defaults are
|
||||
> 12/10/6. The table above records the 8-Mbps-era examples, not current output.
|
||||
- Floors: 16:9 never below **720p30**; vertical never below **Vertical 1080p30**.
|
||||
- Budget exhausted → **hold**, keep streaming, one persistent warning naming the cause, manual
|
||||
step-up stays available. Never disconnect, never nag repeatedly.
|
||||
|
||||
@@ -50,6 +50,11 @@ separate scope-checked work unit with its own Good Dog integration test.
|
||||
|
||||
## Unit A — App Settings "Active Web Camera" (your ask 1)
|
||||
|
||||
> **Plan drift corrected 2026-11-12:** App Settings is no longer the `OverlayHost.xaml` StackPanel —
|
||||
> it is the right-edge drawer `Controls/SettingsDrawer.xaml`, opened by `IsSettingsDrawerOpen`
|
||||
> (creator ruling 2026-11-01). The camera group belongs under "Default Location for Recordings"
|
||||
> there. Everything else below still holds.
|
||||
|
||||
New group **under** Recordings in the App Settings overlay (`OverlayHost.xaml:39` settings
|
||||
StackPanel): current camera display + **Change…** + **Tune…**.
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# TASK 69 — Chat sample-preview toggle
|
||||
|
||||
**Status:** ☐ Queued (creator request 2026-11-11); scheduled after TASK 68 and before the gold pass.
|
||||
|
||||
## Request
|
||||
|
||||
Expose the autogenerated sample chatter as a per-ChatBox layer-control toggle, so the creator decides
|
||||
whether it appears while adjusting chat font/color and previewing the layout. Keep the existing behavior
|
||||
as the default: `Source.ChatPreviewEnabled` already defaults to `true`.
|
||||
|
||||
## Established behavior
|
||||
|
||||
- `ChatOverlayLayer.StartMockPreview` autogenerates sample messages for the ChatBox preview.
|
||||
- A real message stops the sample loop; after the real-message fade clears the buffer, the sample loop
|
||||
starts again—even during a live session.
|
||||
- Samples are preview-only: they are not inserted into the real `Messages` buffer, and the output
|
||||
renderer reads that real buffer. Viewers do not receive sample chatter.
|
||||
- `ChatPreviewEnabled` is currently read by the mock-preview starter but is not persisted.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The selected ChatBox layer control exposes **Show sample chat in preview**, on by default.
|
||||
- The choice persists across restart and applies to that ChatBox layer.
|
||||
- Off stops/removes generated samples. It does not hide real chat messages or change the live output.
|
||||
- When on, samples return in the preview when there are no real messages to show.
|
||||
- One Good Dog integration test covers the toggle and persistence; pin that sample chatter never enters
|
||||
the output message buffer.
|
||||
@@ -0,0 +1,45 @@
|
||||
# TASK 70 — Window App layer
|
||||
|
||||
**Status:** ☐ Queued (creator request 2026-11-11); scheduled after TASK 69 and before the gold pass.
|
||||
|
||||
## Request and locked limits
|
||||
|
||||
Add a **Window App** layer that captures a specific running app window and composites it above the
|
||||
Live scene's game/display Background. Example: show a terminal over a game. This is an ordinary
|
||||
foreground visual layer, not the existing TASK 38 **Capture Window…** action, which replaces the
|
||||
locked Background itself.
|
||||
|
||||
- Available in **Live only**.
|
||||
- At most **one Window App layer** may be active in the layout.
|
||||
- It cannot remove or mask the target from the underlying game/display capture. If the app is already
|
||||
visible in those captured pixels, it may appear twice; do not select the game/already-captured app as
|
||||
the overlay target.
|
||||
- Visual only; no independent per-app audio capture or routing.
|
||||
- Enumerate visible top-level windows of other processes. A process with several windows may offer
|
||||
several titled entries; this selects a window, not every window owned by the process.
|
||||
|
||||
## Capture boundaries
|
||||
|
||||
- Target HWNDs are session-scoped. Never resurrect a raw HWND after restart: Windows can recycle it to
|
||||
an unrelated window. The creator must select the window again for a new session.
|
||||
- If the target closes, minimizes, suspends rendering, or cannot be captured, its image may freeze,
|
||||
disappear, or go black; release the dead capture rather than leaving an apparently live layer.
|
||||
- Secure/protected content may be returned black or excluded by Windows. This is an OS restriction,
|
||||
not something LlamaCasty can bypass.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Add → Window App lists eligible windows and selection adds one Live-scene layer above the Background.
|
||||
- The source updates while its target window is running, independent of whether another window covers it.
|
||||
- The app refuses a second Window App layer and refuses adding this source outside Live.
|
||||
- Target closure/failure is surfaced and releases the capture; restarting never reuses a saved HWND.
|
||||
- One Good Dog integration test drives a fake capture source/enumerator. A real Windows visual check is
|
||||
required before commit.
|
||||
|
||||
## Reference scan
|
||||
|
||||
- Windows Graphics Capture exposes HWND-targeted items via
|
||||
[`IGraphicsCaptureItemInterop::CreateForWindow`](https://learn.microsoft.com/en-us/windows/win32/api/windows.graphics.capture.interop/nf-windows.graphics.capture.interop-igraphicscaptureiteminterop-createforwindow).
|
||||
- OBS's [Window Capture source](https://obsproject.com/kb/window-capture-sources) confirms the intended
|
||||
foreground-source behavior; Windows documents that protected windows may be black or omitted in
|
||||
[New Ways to do Screen Capture](https://blogs.windows.com/windowsdeveloper/2019/09/16/new-ways-to-do-screen-capture/).
|
||||
@@ -1002,6 +1002,131 @@
|
||||
<!-- REC / ON-AIR segment pills: an armed session control reads red, because red is what
|
||||
"live or recording" means (DESIGN.md). Kept separate from SegmentToggle so the Rect|Circle
|
||||
choice in the properties panel stays the interactive blue. -->
|
||||
<!-- An output chip: muted until it is the CHOSEN output, then red with white ink (creator, build
|
||||
729). Tag carries that chip's own selection flag, so one style serves every chip. -->
|
||||
<Style x:Key="OutputChip" TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource Brush.SegmentHitArea}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.SegmentTextInactive}"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Margin" Value="0,0,4,0"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Face" Background="{TemplateBinding Background}"
|
||||
CornerRadius="8" Padding="12,4">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
|
||||
<Setter Property="Background" Value="{StaticResource Brush.Primary}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnAccent}"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
</DataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsMouseOver, RelativeSource={RelativeSource Self}}" Value="True"/>
|
||||
<Condition Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="False"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Background" Value="{StaticResource Brush.SegmentHover}"/>
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- The chip glyph follows the chip: muted normally, white on the red selected face. -->
|
||||
<Style x:Key="OutputChipIcon" TargetType="Path">
|
||||
<Setter Property="Fill" Value="{StaticResource Brush.SegmentTextInactive}"/>
|
||||
<Setter Property="Width" Value="14"/>
|
||||
<Setter Property="Height" Value="14"/>
|
||||
<Setter Property="Stretch" Value="Uniform"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
|
||||
<Setter Property="Fill" Value="{StaticResource Brush.OnAccent}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- The segmented mode switcher's icon: muted when its segment is inactive, coral when active.
|
||||
Tag carries the segment's own flag so one style serves both halves (creator mock 2026-11-12). -->
|
||||
<Style x:Key="SegmentIcon" TargetType="Path">
|
||||
<Setter Property="Fill" Value="{StaticResource Brush.SegmentTextInactive}"/>
|
||||
<Setter Property="Width" Value="15"/>
|
||||
<Setter Property="Height" Value="15"/>
|
||||
<Setter Property="Stretch" Value="Uniform"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
|
||||
<Setter Property="Fill" Value="{StaticResource Brush.SegmentIconActive}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- One segment of the switcher: pill container is the host Border, this is a half of it. -->
|
||||
<Style x:Key="SegmentHalf" TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource Brush.SegmentHitArea}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.SegmentTextInactive}"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Face" Background="{TemplateBinding Background}"
|
||||
CornerRadius="8" Padding="14,6">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<!-- Active: lighter inner pill, off-white bold text. -->
|
||||
<DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
|
||||
<Setter Property="Background" Value="{StaticResource Brush.SegmentActive}"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.SegmentTextActive}"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
</DataTrigger>
|
||||
<!-- Hover tints ONLY the inactive half, so the active one never looks clickable-but-dim. -->
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsMouseOver, RelativeSource={RelativeSource Self}}" Value="True"/>
|
||||
<Condition Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="False"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Background" Value="{StaticResource Brush.SegmentHover}"/>
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- The top bar's world segment (creator ruling 2026-11-12): [ Build Scenes ][ Record / Go Live ].
|
||||
A Button, not a ToggleButton — the highlight follows the view model, so a click must never be
|
||||
what sets it. Each button passes its own world flag in Tag and one DataTrigger paints it. -->
|
||||
<Style x:Key="WorldSegmentButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Brush.OnAccent}"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Height" Value="28"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Face" Background="{TemplateBinding Background}"
|
||||
CornerRadius="3" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
|
||||
<Setter Property="Background" Value="{StaticResource Brush.Primary}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="RecordPillToggle" TargetType="ToggleButton" BasedOn="{StaticResource SegmentToggle}">
|
||||
<!-- SegmentToggle's template hard-codes a Transparent face and paints the checked state
|
||||
itself, so an armed pill needs its own template that honours Background. -->
|
||||
|
||||
@@ -35,6 +35,18 @@
|
||||
<SolidColorBrush x:Key="Brush.Tertiary" Color="#38bdf8"/>
|
||||
<SolidColorBrush x:Key="Brush.OnTertiary" Color="#0b1326"/>
|
||||
<SolidColorBrush x:Key="Brush.OnAccent" Color="#ffffff"/>
|
||||
<!-- Segmented mode switcher (creator mock, 2026-11-12) -->
|
||||
<SolidColorBrush x:Key="Brush.SegmentTrack" Color="#1E222B"/>
|
||||
<SolidColorBrush x:Key="Brush.SegmentActive" Color="#2E3440"/>
|
||||
<SolidColorBrush x:Key="Brush.SegmentHover" Color="#252A34"/>
|
||||
<SolidColorBrush x:Key="Brush.SegmentTextActive" Color="#E2E8F0"/>
|
||||
<SolidColorBrush x:Key="Brush.SegmentTextInactive" Color="#8892A4"/>
|
||||
<SolidColorBrush x:Key="Brush.SegmentIconActive" Color="#F26D6D"/>
|
||||
<!-- One-alpha black: transparent to the eye, opaque to hit-testing, so the whole segment is clickable. -->
|
||||
<SolidColorBrush x:Key="Brush.SegmentHitArea" Color="#01000000"/>
|
||||
<!-- The recording dot on the output pill (creator, build 730). -->
|
||||
<SolidColorBrush x:Key="Brush.RecordLive" Color="#2ECC71"/>
|
||||
|
||||
<SolidColorBrush x:Key="Brush.Primary" Color="#ff5451"/>
|
||||
<SolidColorBrush x:Key="Brush.PrimaryContainer" Color="#b91a24"/>
|
||||
<SolidColorBrush x:Key="Brush.Secondary" Color="#ffb95f"/>
|
||||
|
||||
@@ -50,7 +50,10 @@ public partial class MainViewModel
|
||||
private void RefreshSteamLicenceAlert()
|
||||
{
|
||||
var existing = _attentionAlerts.FirstOrDefault(alert => alert.Id == SteamLicenceAlertId);
|
||||
var hidden = IsAppOwned && _steamLicenceDismissed;
|
||||
// The row exists to explain what the frame-rate boundary costs when it applies. While the
|
||||
// pre-release switch is off nothing is limited, so announcing a limit would be a false claim.
|
||||
var hidden = !OutputProfile.EntitlementEnforced
|
||||
|| (IsAppOwned && _steamLicenceDismissed);
|
||||
|
||||
if (hidden)
|
||||
{
|
||||
|
||||
@@ -74,6 +74,7 @@ public partial class MainViewModel
|
||||
private bool _gameMuted;
|
||||
private double? _gameVolumeBeforeMute;
|
||||
private int _audioSyncOffsetMs;
|
||||
private int _micSyncOffsetMs = LayoutStore.DefaultMicSyncOffsetMs;
|
||||
private AudioGainProvider? _gainProvider;
|
||||
private readonly IMicrophoneEnumerator _microphoneEnumerator;
|
||||
private readonly AudioMixer _audioMixer;
|
||||
@@ -242,12 +243,38 @@ public partial class MainViewModel
|
||||
var clamped = Math.Clamp(value, -500, 500);
|
||||
if (!SetProperty(ref _audioSyncOffsetMs, clamped)) return;
|
||||
OnPropertyChanged(nameof(SyncOffsetLabel));
|
||||
// Written at once, not through the debounced layout save: a sync offset set seconds before
|
||||
// closing the app used to be lost, and losing it costs a whole record-and-measure cycle.
|
||||
if (_layoutStore is { } store) store.SaveAudioSyncOffsetMs(clamped);
|
||||
ScheduleSave();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Master strip ────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// Microphone-only sync delay in milliseconds (0..500, default 175). A camera's frames reach the
|
||||
/// compositor later than the mic's samples reach the audio pipe, so by default the voice runs
|
||||
/// AHEAD of the lips; delaying the mic alone closes the gap without touching desktop or alert audio,
|
||||
/// which have no camera to follow. The creator can dial it to taste in the audio dock.
|
||||
///
|
||||
/// <para>175 ms is the creator's own measurement for a C920 pipeline (they asked for +150..+200);
|
||||
/// it is a persisted setting, not a constant, because the right number is the machine's, not ours.</para>
|
||||
/// </summary>
|
||||
public int MicSyncOffsetMs
|
||||
{
|
||||
get => _micSyncOffsetMs;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, 0, 500);
|
||||
if (!SetProperty(ref _micSyncOffsetMs, clamped)) return;
|
||||
OnPropertyChanged(nameof(MicSyncOffsetLabel));
|
||||
if (_layoutStore is { } store) store.SaveMicSyncOffsetMs(clamped); // immediate, see above
|
||||
ScheduleSave();
|
||||
}
|
||||
}
|
||||
|
||||
public string MicSyncOffsetLabel => _micSyncOffsetMs == 0 ? "off" : $"+{_micSyncOffsetMs} ms";
|
||||
|
||||
// ─── Master strip ────────────────────────────────────────────────────────
|
||||
private bool _monoOutput;
|
||||
|
||||
/// <summary>Collapse the output to mono. Persisted, because a creator who needs it needs it on
|
||||
|
||||
@@ -99,7 +99,7 @@ public partial class MainViewModel
|
||||
return;
|
||||
}
|
||||
|
||||
var quality = Models.OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
|
||||
var quality = EffectiveQuality(Models.OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput));
|
||||
var bytes = ClipLibrary.RingBytesFor(_clipSeconds, (int)Math.Round(quality.Bitrate * 1000));
|
||||
if (_clipRing is null || _clipRingCapacity != bytes)
|
||||
{
|
||||
|
||||
@@ -38,7 +38,13 @@ public partial class MainViewModel
|
||||
private set => SetProperty(ref _entitlementErrorMessage, value);
|
||||
}
|
||||
|
||||
public bool HasProOutput => DeveloperMode || IsAppOwned;
|
||||
/// <summary>
|
||||
/// May this run emit 60 fps? True while the pre-release switch is off
|
||||
/// (<see cref="OutputProfile.EntitlementEnforced"/>), or for a development build's override, or
|
||||
/// when Steam says the paid app is owned. Everything that gates on the frame rate reads this one
|
||||
/// property: the tier setter, the dropdown's strikethrough, the locked-item style.
|
||||
/// </summary>
|
||||
public bool HasProOutput => !OutputProfile.EntitlementEnforced || DeveloperMode || IsAppOwned;
|
||||
|
||||
private static IEntitlement CreateEntitlement()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The creator's own video rate, per tier — the latitude the tier table deliberately does not take
|
||||
/// away. A creator who knows their upload can set any rate from the tier's YouTube minimum upward;
|
||||
/// nothing here invents a ceiling, because a wrong guess is what live step-down exists to catch.
|
||||
///
|
||||
/// <para><b>Why per tier:</b> a number that is sensible at 1080p60 is wasteful at 720p30, and the
|
||||
/// creator who tuned one tier has not tuned the other. Unset means "use the shipped default", which
|
||||
/// is what <see cref="EffectiveQuality"/> substitutes.</para>
|
||||
///
|
||||
/// <para><b>Why this matters now:</b> with CBR holding the target (<c>FfmpegArgs.AddRateControl</c>)
|
||||
/// the number the creator types is the number that goes out, so it is a real quality choice rather
|
||||
/// than an average to undershoot.</para>
|
||||
/// </summary>
|
||||
public partial class MainViewModel
|
||||
{
|
||||
private readonly Dictionary<string, double> _ratedMbpsByTier = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>The tier with the creator's rate substituted for the shipped default. Every consumer
|
||||
/// that builds encoder options or a step-down policy must use this, never the raw tier.</summary>
|
||||
public QualityOption EffectiveQuality(QualityOption tier)
|
||||
=> RatedFor(tier) is var rated && Math.Abs(rated - tier.Bitrate) > 0.001
|
||||
? tier with { Bitrate = rated }
|
||||
: tier;
|
||||
|
||||
private double RatedFor(QualityOption tier)
|
||||
{
|
||||
if (_ratedMbpsByTier.TryGetValue(tier.Display, out var cached)) return cached;
|
||||
|
||||
// The constructor resolves the default tier before the layout store exists, so the first read
|
||||
// must not cache: a cached default would shadow the creator's stored rate for that run.
|
||||
var stored = _layoutStore is null ? null : _layoutStore.LoadRatedMbps(tier.Display);
|
||||
var value = Math.Max(stored ?? tier.Bitrate, RateLadder.For(tier).Minimum);
|
||||
if (_layoutStore is not null) _ratedMbpsByTier[tier.Display] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>The rate the selected tier will stream at, in Mbps.</summary>
|
||||
public double RatedMbps
|
||||
{
|
||||
get => RatedFor(SelectedQuality);
|
||||
set
|
||||
{
|
||||
var tier = SelectedQuality;
|
||||
var band = RateLadder.For(tier);
|
||||
// Below the tier minimum YouTube will not hold the resolution; above ~100 is not a stream.
|
||||
var clamped = Math.Clamp(Math.Round(value, 1), band.Minimum, 100.0);
|
||||
if (Math.Abs(clamped - RatedFor(tier)) < 0.05)
|
||||
{
|
||||
OnPropertyChanged(nameof(RatedMbps));
|
||||
return;
|
||||
}
|
||||
|
||||
_ratedMbpsByTier[tier.Display] = clamped;
|
||||
_layoutStore.SaveRatedMbps(tier.Display, clamped);
|
||||
ApplyStreamQuality(tier);
|
||||
OnPropertyChanged(nameof(RatedMbps));
|
||||
OnPropertyChanged(nameof(RateHint));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What YouTube expects for this tier, shown next to the field.</summary>
|
||||
public string RateHint
|
||||
{
|
||||
get
|
||||
{
|
||||
var band = RateLadder.For(SelectedQuality);
|
||||
return $"YouTube recommends {band.Recommended:0.#} Mbps for {SelectedQuality.Display}; " +
|
||||
$"it will not take less than {band.Minimum:0.#}. Your connection must carry this " +
|
||||
"plus ~20% headroom.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Restores the shipped default for the selected tier.</summary>
|
||||
public void ResetRatedMbps()
|
||||
{
|
||||
var tier = SelectedQuality;
|
||||
if (!_ratedMbpsByTier.ContainsKey(tier.Display)) return;
|
||||
_ratedMbpsByTier.Remove(tier.Display);
|
||||
_layoutStore.SaveRatedMbps(tier.Display, null);
|
||||
_ratedMbpsByTier[tier.Display] = RatedFor(tier);
|
||||
ApplyStreamQuality(tier);
|
||||
OnPropertyChanged(nameof(RatedMbps));
|
||||
OnPropertyChanged(nameof(RateHint));
|
||||
}
|
||||
|
||||
// ─── Between-session suggestion (Models/RateHistory) ───
|
||||
//
|
||||
// Never applied on its own. The creator is asked once per materially different answer and may say
|
||||
// "keep mine"; that refusal is remembered against the proposed value, so the same question is not
|
||||
// asked again until the evidence actually moves.
|
||||
|
||||
private RateHistory.Proposal? _rateProposal;
|
||||
|
||||
public bool HasRateProposal => _rateProposal is not null;
|
||||
|
||||
public string? RateProposalText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_rateProposal is not { } proposal) return null;
|
||||
var tier = SelectedQuality.Display;
|
||||
return proposal.Raise
|
||||
? $"Your last {RateHistory.Window} sessions on {tier} held {proposal.Mbps:0.#} Mbps. " +
|
||||
$"Start the next stream there instead of {RatedMbps:0.#}?"
|
||||
: $"Your last {RateHistory.Window} sessions on {tier} had to drop quality. " +
|
||||
$"Start the next stream at {proposal.Mbps:0.#} Mbps instead of {RatedMbps:0.#}?";
|
||||
}
|
||||
}
|
||||
|
||||
public ICommand AcceptRateProposalCommand { get; private set; } = null!;
|
||||
public ICommand DismissRateProposalCommand { get; private set; } = null!;
|
||||
|
||||
private void InitializeRateProposalCommands()
|
||||
{
|
||||
AcceptRateProposalCommand = new RelayCommand(_ =>
|
||||
{
|
||||
if (_rateProposal is not { } proposal) return;
|
||||
RatedMbps = proposal.Mbps; // explicit consent is the only path that changes it
|
||||
ClearRateProposal();
|
||||
});
|
||||
DismissRateProposalCommand = new RelayCommand(_ =>
|
||||
{
|
||||
if (_rateProposal is { } proposal)
|
||||
_layoutStore.SaveDismissedRateProposal(SelectedQuality.Display, proposal.Mbps);
|
||||
ClearRateProposal();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Recomputes the suggestion for the selected tier. Called at startup and whenever the tier
|
||||
/// or the rate changes; silent unless the evidence warrants a change and the creator has not already
|
||||
/// refused this exact number.</summary>
|
||||
internal void RefreshRateProposal()
|
||||
{
|
||||
_rateProposal = null;
|
||||
if (_layoutStore is not null)
|
||||
{
|
||||
var tier = SelectedQuality;
|
||||
var history = _layoutStore.LoadRateHistory();
|
||||
// Before this run's first stream the encoder is unknown; the last session on this tier is
|
||||
// the honest guess, and evidence about a different encoder stays out either way.
|
||||
var lastForTier = history.LastOrDefault(entry => entry.Tier == tier.Display);
|
||||
// The pump is built later in the constructor than this runs, so before the first session
|
||||
// there is no encoder to ask; the last session on this tier is the honest guess, and
|
||||
// evidence about a different encoder stays out either way.
|
||||
var encoder = (_framePump is { } pump ? pump.EncoderName : null)
|
||||
?? (string.IsNullOrEmpty(lastForTier.Encoder) ? null : lastForTier.Encoder);
|
||||
|
||||
var suggestion = RateHistory.Suggest(history, tier.Display, encoder, RatedMbps);
|
||||
if (suggestion is { } candidate
|
||||
&& _layoutStore.LoadDismissedRateProposal(tier.Display) != candidate.Mbps)
|
||||
{
|
||||
_rateProposal = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasRateProposal));
|
||||
OnPropertyChanged(nameof(RateProposalText));
|
||||
}
|
||||
|
||||
private void ClearRateProposal()
|
||||
{
|
||||
_rateProposal = null;
|
||||
OnPropertyChanged(nameof(HasRateProposal));
|
||||
OnPropertyChanged(nameof(RateProposalText));
|
||||
}
|
||||
}
|
||||
@@ -77,11 +77,21 @@ public partial class MainViewModel : ViewModelBase
|
||||
// CREATOR RULING 2026-09-26: Cancel means DISCARD, not "save under the
|
||||
// default name". This used to fall through and File.Move anyway, so a
|
||||
// cancelled dialog silently kept the recording — Cancel was a lie.
|
||||
var dialog = new RenameRecordingDialog(autoStem);
|
||||
var creatorSaved = dialog.ShowDialog() == true;
|
||||
//
|
||||
// APP CLOSE (2026-11-13): a modal cannot open while the window is closing — WPF throws
|
||||
// "Cannot call ShowDialog while a Window is closing", which killed the app mid-shutdown.
|
||||
// So closing saves under the automatic name instead: never discard footage the creator
|
||||
// did not choose to discard, and never hold the window hostage to a dialog.
|
||||
var creatorSaved = true;
|
||||
string? chosenName = null;
|
||||
if (!_shuttingDown)
|
||||
{
|
||||
var dialog = new RenameRecordingDialog(autoStem);
|
||||
creatorSaved = dialog.ShowDialog() == true;
|
||||
chosenName = dialog.ResultFileName;
|
||||
}
|
||||
|
||||
var result = CompleteRecordingSave(
|
||||
startPath, dir, autoStem, dialog.ResultFileName, creatorSaved);
|
||||
var result = CompleteRecordingSave(startPath, dir, autoStem, chosenName, creatorSaved);
|
||||
switch (result.Outcome)
|
||||
{
|
||||
case RecordingSaveOutcome.Saved:
|
||||
|
||||
@@ -152,6 +152,13 @@ public partial class MainViewModel
|
||||
|
||||
private void OnEncoderFailed(object? sender, string message)
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
{
|
||||
dispatcher.BeginInvoke(new Action(() => OnEncoderFailed(sender, message)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsLive && !IsRecording) return;
|
||||
if (_recoveryCts != null) return; // one recovery at a time
|
||||
if (!IsLive) return; // a recording-only session has nothing to relive
|
||||
@@ -203,22 +210,7 @@ public partial class MainViewModel
|
||||
_ => BroadcastLiveness.Live,
|
||||
};
|
||||
},
|
||||
Report = text =>
|
||||
{
|
||||
var progress = text.StartsWith("Stream dropped", StringComparison.Ordinal)
|
||||
? ParseAttempt(text)
|
||||
: 0;
|
||||
if (progress > 0) ReconnectAttempt = progress;
|
||||
|
||||
var to = AppLog.SnapshotCurrentLaunch() is { Count: > 0 } entries
|
||||
? entries[^1].Sequence
|
||||
: logFrom;
|
||||
PushAttentionAlert(
|
||||
"stream-recovery",
|
||||
text,
|
||||
logFrom,
|
||||
Math.Max(logFrom, to));
|
||||
},
|
||||
Report = text => ReportRecoveryStatus(text, logFrom),
|
||||
Log = line => AppLog.Write(line, AppLogMessageType.Error, AppLogErrorClass.Class2),
|
||||
});
|
||||
|
||||
@@ -267,6 +259,26 @@ public partial class MainViewModel
|
||||
}
|
||||
}
|
||||
|
||||
internal void ReportRecoveryStatus(string text, long logFrom)
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
{
|
||||
dispatcher.Invoke(() => ReportRecoveryStatus(text, logFrom));
|
||||
return;
|
||||
}
|
||||
|
||||
var progress = text.StartsWith("Stream dropped", StringComparison.Ordinal)
|
||||
? ParseAttempt(text)
|
||||
: 0;
|
||||
if (progress > 0) ReconnectAttempt = progress;
|
||||
|
||||
var to = AppLog.SnapshotCurrentLaunch() is { Count: > 0 } entries
|
||||
? entries[^1].Sequence
|
||||
: logFrom;
|
||||
PushAttentionAlert("stream-recovery", text, logFrom, Math.Max(logFrom, to));
|
||||
}
|
||||
|
||||
private static int ParseAttempt(string text)
|
||||
{
|
||||
// "Stream dropped — reconnecting (attempt 2 of 4)"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Windows;
|
||||
using Notification.Core;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
@@ -10,6 +13,83 @@ namespace ytLive.ViewModels;
|
||||
public partial class MainViewModel
|
||||
{
|
||||
private bool _settingsDrawerOpen;
|
||||
private NotificationPosition _toastPosition = NotificationPosition.BottomRight;
|
||||
private bool _stickyAlertsUntilDismissed;
|
||||
private bool _lockEditWhileLive;
|
||||
private bool _editingForcedOff;
|
||||
|
||||
public bool StickyAlertsUntilDismissed
|
||||
{
|
||||
get => _stickyAlertsUntilDismissed;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _stickyAlertsUntilDismissed, value)) return;
|
||||
_layoutStore.SaveStickyAlertsUntilDismissed(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lock editing for the duration of a session. Off = edit while live (creator ruling
|
||||
/// 2026-11-12); on = go-live flips EDIT off and stop flips it back, as it always did.</summary>
|
||||
public bool LockEditWhileLive
|
||||
{
|
||||
get => _lockEditWhileLive;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _lockEditWhileLive, value)) return;
|
||||
_layoutStore.SaveLockEditWhileLive(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the edit lock when a session starts or stops. Split out so the rule is testable without
|
||||
/// starting a real broadcast.
|
||||
/// </summary>
|
||||
internal void ApplyEditLockForSession(bool starting)
|
||||
{
|
||||
if (!LockEditWhileLive)
|
||||
{
|
||||
// The creator may edit during the show; nothing is forced either way.
|
||||
_editingForcedOff = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (starting)
|
||||
{
|
||||
_editingForcedOff = true;
|
||||
EditingActive = false;
|
||||
}
|
||||
else if (_editingForcedOff)
|
||||
{
|
||||
_editingForcedOff = false;
|
||||
EditingActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
public NotificationPosition ToastPosition
|
||||
{
|
||||
get => _toastPosition;
|
||||
set
|
||||
{
|
||||
if (!IsSupportedToastPosition(value) || !SetProperty(ref _toastPosition, value)) return;
|
||||
_layoutStore.SaveNotificationPosition(value);
|
||||
OnPropertyChanged(nameof(ToastAreaMargin));
|
||||
}
|
||||
}
|
||||
|
||||
public Thickness ToastAreaMargin => ToastPosition switch
|
||||
{
|
||||
NotificationPosition.TopLeft => new Thickness(12, 12, 0, 0),
|
||||
NotificationPosition.TopRight => new Thickness(0, 12, 12, 0),
|
||||
NotificationPosition.CenterLeft => new Thickness(12, 0, 0, 0),
|
||||
NotificationPosition.CenterRight => new Thickness(0, 0, 12, 0),
|
||||
NotificationPosition.BottomLeft => new Thickness(12, 0, 0, 96),
|
||||
_ => new Thickness(0, 0, 12, 96),
|
||||
};
|
||||
|
||||
private static bool IsSupportedToastPosition(NotificationPosition position)
|
||||
=> position is NotificationPosition.TopLeft or NotificationPosition.TopRight
|
||||
or NotificationPosition.CenterLeft or NotificationPosition.CenterRight
|
||||
or NotificationPosition.BottomLeft or NotificationPosition.BottomRight;
|
||||
|
||||
public bool SettingsDrawerOpen
|
||||
{
|
||||
|
||||
@@ -19,14 +19,17 @@ public partial class MainViewModel
|
||||
private StepDownPlanner? _stepDownPlanner;
|
||||
private (int Width, int Height, int Fps, double BitrateMbps)? _encoderTierOverride;
|
||||
private bool _stepDownBusy;
|
||||
private double _sessionRatedMbps;
|
||||
|
||||
/// <summary>Starts the watch for this broadcast. Called once per session, before the pump starts.</summary>
|
||||
private void StartStepDownWatch()
|
||||
{
|
||||
var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
|
||||
var quality = EffectiveQuality(OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput));
|
||||
_encoderTierOverride = null;
|
||||
_sessionRatedMbps = quality.Bitrate;
|
||||
_stepDownPlanner = new StepDownPlanner(
|
||||
new StepDownPolicy(quality.Width, quality.Height, quality.Fps, quality.Bitrate),
|
||||
new StepDownPolicy(quality.Width, quality.Height, quality.Fps, quality.Bitrate,
|
||||
floorBitrateMbps: RateLadder.For(quality).Minimum),
|
||||
_sessionStartedAtUtc ?? DateTime.UtcNow);
|
||||
_framePump.StatsReported += OnPumpStatsReported;
|
||||
}
|
||||
@@ -36,12 +39,35 @@ public partial class MainViewModel
|
||||
private void StopStepDownWatch()
|
||||
{
|
||||
_framePump.StatsReported -= OnPumpStatsReported;
|
||||
RecordSessionRate();
|
||||
_stepDownPlanner = null;
|
||||
_encoderTierOverride = null;
|
||||
_stepDownBusy = false;
|
||||
_screenCaptureManager.ShedLoad(false); // never carry a shed cadence into the next show
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One finished session, filed as evidence for the rate-history policy (Models/RateHistory).
|
||||
/// Best effort: a store failure must never be the reason a stop fails.
|
||||
/// </summary>
|
||||
private void RecordSessionRate()
|
||||
{
|
||||
if (_stepDownPlanner is not { } planner) return;
|
||||
try
|
||||
{
|
||||
_layoutStore.AppendRateHistory(new SessionRateResult(
|
||||
SelectedQuality.Display,
|
||||
_framePump.EncoderName ?? "unknown",
|
||||
_sessionRatedMbps,
|
||||
planner.Current.BitrateMbps,
|
||||
planner.Policy.StepsUsed));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Rate history not recorded: {ex.Message}", AppLogMessageType.Warning, AppLogErrorClass.Class2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Runs on the PUMP thread, so it must never block: the restart it may trigger stops the
|
||||
/// very loop that raised this event. Decide here (pure and fast), act on the dispatcher.</summary>
|
||||
private void OnPumpStatsReported(object? sender, FramePumpStats stats)
|
||||
@@ -74,7 +100,9 @@ public partial class MainViewModel
|
||||
|
||||
_encoderTierOverride = landed;
|
||||
AppLog.Write($"Quality step-down: {decision.Why}");
|
||||
await _framePump.ChangeEncoderAsync();
|
||||
await _framePump.ChangeEncoderAsync(
|
||||
_audioMixer.StopLive,
|
||||
() => _audioMixer.StartLive(InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName)));
|
||||
|
||||
// Show what is actually going out. The dropdown keeps the creator's choice — the policy
|
||||
// does not get to rewrite their intent, only the encoder's output.
|
||||
|
||||
@@ -298,7 +298,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
// before that (2026-09-25 diagnosis), so chat resolution runs AFTER the pump
|
||||
// starts. Chat is non-fatal — if it fails, the stream continues without chat.
|
||||
LiveScene = StagedScene;
|
||||
EditingActive = false;
|
||||
ApplyEditLockForSession(starting: true);
|
||||
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
||||
|
||||
// Check promptly until YouTube confirms the broadcast is live. Health
|
||||
@@ -404,29 +404,49 @@ public partial class MainViewModel : ViewModelBase
|
||||
HealthIssueBanner = report.Text;
|
||||
}
|
||||
|
||||
private async void StopStream()
|
||||
/// <param name="afterFailure">True when the session is being torn down because something broke,
|
||||
/// not because the creator pressed Stop. A failure clears both outputs (there is no session and no
|
||||
/// choice worth keeping); a creator's Stop of a recording restores the Local MP4 pair.</param>
|
||||
private async void StopStream(bool afterFailure = false)
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
{
|
||||
_ = dispatcher.BeginInvoke(new Action(() => StopStream()));
|
||||
return;
|
||||
}
|
||||
|
||||
CancelStreamRecovery(); // a stop wins over a relaunch in flight
|
||||
StopStepDownWatch();
|
||||
StopClipRing();
|
||||
RemoveAttentionAlert("stream-recovery");
|
||||
var wasLive = IsLive;
|
||||
var wasRecording = IsRecording;
|
||||
var wasRecordingOnly = IsRecording && !IsLive && !afterFailure;
|
||||
|
||||
StreamStatus = StreamStatus.Offline;
|
||||
_sessionStartedAtUtc = null;
|
||||
IsRecording = false;
|
||||
LiveScene = null;
|
||||
EditingActive = true;
|
||||
ApplyEditLockForSession(starting: false);
|
||||
// TASK 41: a stopped test broadcast forgets its drawer session (idempotent)
|
||||
// and drops the TEST face — a test never outlives its run.
|
||||
TestSession.EndTestSilently();
|
||||
IsTestStream = false;
|
||||
// Stop ends EVERYTHING (creator ruling 2026-09-01): a lit pill with no session
|
||||
// behind it is a lie — the stuck REC pill after the failed 2026-09-01 recording
|
||||
// attempt was the report. Intent resets with reality; re-arming is one click.
|
||||
RecordPillOn = false;
|
||||
OnAirPillOn = false;
|
||||
// Stop ends the session (creator ruling 2026-09-01): a lit pill with no session behind it is a
|
||||
// lie. Since build 730 the pills are the chosen OUTPUT, not an armed intent, so a finished
|
||||
// local recording returns to its idle pair — Local MP4 chosen, Start Recording beside it
|
||||
// (creator's words) — while a finished stream clears both and asks again.
|
||||
if (wasRecordingOnly)
|
||||
{
|
||||
RecordPillOn = true;
|
||||
OnAirPillOn = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
RecordPillOn = false;
|
||||
OnAirPillOn = false;
|
||||
}
|
||||
WindowTitle = "LlamaCasty";
|
||||
// Deactivate zoom when stopping
|
||||
if (_zoomState.IsActive || _zoomState.IsMoving)
|
||||
@@ -497,7 +517,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
private EncoderOptions? BuildEncoderOptions()
|
||||
{
|
||||
var url = _rtmpUrlProvider();
|
||||
var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
|
||||
var quality = EffectiveQuality(OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput));
|
||||
// TASK 33: while a session is live, a step-down owns the emitted tier even though the dropdown
|
||||
// still shows what the creator picked. Their intent is not edited by the policy.
|
||||
if (_encoderTierOverride is { } landed)
|
||||
@@ -537,7 +557,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
var live = IsLive;
|
||||
_notifications.Error(live ? "The stream pipeline stopped" : "Recording stopped", message);
|
||||
StopStream();
|
||||
StopStream(afterFailure: true);
|
||||
}
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
@@ -636,6 +656,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
}
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
OnPropertyChanged(nameof(LiveElapsedText));
|
||||
OnPropertyChanged(nameof(RecordElapsedLabel));
|
||||
}
|
||||
|
||||
private void OnLiveTimerTick(object? sender, EventArgs e)
|
||||
@@ -643,8 +664,9 @@ public partial class MainViewModel : ViewModelBase
|
||||
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
|
||||
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
|
||||
_recordLength = _liveElapsed;
|
||||
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
|
||||
_recDotPulse = _recDotPulse > 0.5 ? 0.2 : 1.0;
|
||||
OnPropertyChanged(nameof(RecDotOpacity));
|
||||
OnPropertyChanged(nameof(RecordElapsedLabel));
|
||||
|
||||
// TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain.
|
||||
if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder
|
||||
@@ -668,7 +690,11 @@ public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
var title = BroadcastForm.Title;
|
||||
var (outcome, error) = await ClosePrivateBroadcastAsync(_youtubeStream, broadcastId,
|
||||
() => KeepBroadcastPromptOverride?.Invoke(title) ?? new KeepBroadcastDialog(title).ShowDialog() == true);
|
||||
// Closing the app cannot open a modal (WPF forbids ShowDialog while a window closes), so the
|
||||
// safe default is KEEP: the broadcast stays on YouTube as a private video for the creator to
|
||||
// deal with, instead of being deleted by a prompt nobody could answer.
|
||||
() => _shuttingDown
|
||||
|| (KeepBroadcastPromptOverride?.Invoke(title) ?? new KeepBroadcastDialog(title).ShowDialog() == true));
|
||||
switch (outcome)
|
||||
{
|
||||
case PrivateBroadcastOutcome.Kept:
|
||||
|
||||
@@ -41,7 +41,9 @@ public partial class MainViewModel : ViewModelBase
|
||||
// ─── IsRecording / IsLive are reality. ON-AIR needs a sign-in, and the
|
||||
// ─── pills are radio-exclusive (record-OR-live, creator ruling 2026-09-01):
|
||||
// ─── arming one clears the other.
|
||||
private bool _recordPillOn;
|
||||
// Local MP4 is the armed output at launch (creator ruling 2026-11-12: "pos = ON (default)").
|
||||
// Before that both were unarmed, so the output stage opened with nothing selected.
|
||||
private bool _recordPillOn = true;
|
||||
private bool _onAirPillOn;
|
||||
private bool _isRecording;
|
||||
|
||||
@@ -85,6 +87,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||
RaiseTopBarModes();
|
||||
RaiseSlotStates(); // IsLive drives ShowOutputStage
|
||||
UpdateLiveVisuals();
|
||||
}
|
||||
}
|
||||
@@ -115,6 +118,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
if (!SetProperty(ref _recordPillOn, value)) return;
|
||||
if (value && _onAirPillOn) OnAirPillOn = false; // record-OR-live — arming REC drops ON-AIR
|
||||
RaiseSlotStates();
|
||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||
OnPropertyChanged(nameof(PrimaryStartKind));
|
||||
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
|
||||
@@ -134,6 +138,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
// intent, not reality; the Start face turns this into a sign-in.
|
||||
if (!SetProperty(ref _onAirPillOn, value)) return;
|
||||
if (value && _recordPillOn) RecordPillOn = false; // record-OR-live — arming ON-AIR drops REC
|
||||
RaiseSlotStates();
|
||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||
OnPropertyChanged(nameof(PrimaryStartKind));
|
||||
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
|
||||
@@ -166,6 +171,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(EndButtonText));
|
||||
OnPropertyChanged(nameof(ShowTestButton));
|
||||
RaiseTopBarModes();
|
||||
RaiseSlotStates(); // Start Recording <-> Stop Recording, and the REC clock
|
||||
UpdateSessionTimer();
|
||||
}
|
||||
}
|
||||
@@ -189,7 +195,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
/// <summary>Creator ruling 2026-10-30: EDIT decides whether the ON-AIR start is the real
|
||||
/// livestream or the test one, which is why the separate TEST button is gone.</summary>
|
||||
public SessionStartKind PrimaryStartKind =>
|
||||
SessionStartDecision.For(RecordPillOn, OnAirPillOn, EditingActive);
|
||||
SessionStartDecision.For(RecordPillOn, OnAirPillOn);
|
||||
|
||||
public string PrimaryStartButtonLabel => SessionStartDecision.LabelFor(PrimaryStartKind);
|
||||
|
||||
@@ -227,7 +233,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
public string EndButtonText =>
|
||||
IsRecording && !IsLive ? "Stop Recording"
|
||||
: IsTestStream ? "End Test"
|
||||
: "End Stream";
|
||||
: "End Live Stream";
|
||||
|
||||
/// <summary>TASK 41 — Test is a child of Stream (creator ruling: "Test should only
|
||||
/// proc when we've selected stream"): a dry-run of the livestream, so it exists
|
||||
@@ -249,7 +255,9 @@ public partial class MainViewModel : ViewModelBase
|
||||
public bool ShowIdleWorld => IsOffline && !IsRecording;
|
||||
|
||||
/// <summary>Running — the reality line (dot + word + elapsed) + End replace the switch.</summary>
|
||||
public bool RunningVisible => IsLive || IsRecording;
|
||||
// Live only: a local recording's status lives in the output pill now (creator, build 730), so its
|
||||
// separate dot/elapsed/stop cluster would be a second, redundant set of the same facts.
|
||||
public bool RunningVisible => IsLive;
|
||||
|
||||
/// <summary>Record world: the switch sits on REC and Go says "Start Recording".
|
||||
/// No Test — YouTube's stream actions don't exist here. The account zone IS
|
||||
@@ -446,14 +454,22 @@ public partial class MainViewModel : ViewModelBase
|
||||
private const double MasterFrameWidth = 1920;
|
||||
private const double MasterFrameHeight = 1080;
|
||||
|
||||
/// <summary>
|
||||
/// The shipped ladder. The rate on each tier is the creator-facing target the encoder is asked to
|
||||
/// hold (CBR — see <c>FfmpegArgs.AddRateControl</c>), between YouTube's H.264 recommended value
|
||||
/// (17 Mbps at 1080p60, 14 at 1080p30, 8 at 720p) and the practitioner sweet spot most guides land
|
||||
/// on for YouTube. The old flat 8 Mbps was inherited from TASK 4 as a "mainstream ceiling" — an
|
||||
/// unmeasured round number that produced a 5.1 Mbps average. Vertical mirrors its landscape pair:
|
||||
/// 1080×1920 is the same pixel count as 1920×1080.
|
||||
/// </summary>
|
||||
public QualityOption[] QualityOptions { get; } =
|
||||
{
|
||||
new("1080p60", 60, 8.0, 1920, 1080),
|
||||
new("1080p30", 30, 8.0, 1920, 1080),
|
||||
new("1080p60", 60, 12.0, 1920, 1080),
|
||||
new("1080p30", 30, 10.0, 1920, 1080),
|
||||
new("720p60", 60, 6.0, 1280, 720),
|
||||
new("720p30", 30, 6.0, 1280, 720),
|
||||
new("Vertical 1080p30", 30, 8.0, 1080, 1920),
|
||||
new("Vertical 1080p60", 60, 8.0, 1080, 1920),
|
||||
new("Vertical 1080p30", 30, 10.0, 1080, 1920),
|
||||
new("Vertical 1080p60", 60, 12.0, 1080, 1920),
|
||||
};
|
||||
|
||||
public string ResolutionHelp { get; } =
|
||||
@@ -480,7 +496,12 @@ public partial class MainViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
if (SetProperty(ref _selectedQuality, value))
|
||||
{
|
||||
ApplyStreamQuality(value);
|
||||
OnPropertyChanged(nameof(RatedMbps));
|
||||
OnPropertyChanged(nameof(RateHint));
|
||||
RefreshRateProposal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,7 +518,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
private void ApplyStreamQuality(QualityOption quality)
|
||||
{
|
||||
var health = CurrentHealth;
|
||||
health.CurrentBitrate = quality.Bitrate;
|
||||
health.CurrentBitrate = EffectiveQuality(quality).Bitrate;
|
||||
health.FPS = quality.Fps;
|
||||
OnPropertyChanged(nameof(CurrentHealth));
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
/// <summary>Where the YouTube slot is in its own five-step flow.</summary>
|
||||
public enum YouTubeSlotState
|
||||
{
|
||||
/// <summary>[ ▶ YouTube ]</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>[ ▶ Live | 🔒 Private | ✕ ]</summary>
|
||||
ChoosePrivacy,
|
||||
|
||||
/// <summary>[ ● Start Live | ✕ ] or [ ● Start Private | ✕ ]</summary>
|
||||
Armed,
|
||||
|
||||
/// <summary>[ ■ Stop Live ] or [ ■ Stop Private ]</summary>
|
||||
Streaming,
|
||||
}
|
||||
|
||||
/// <summary>Where the Record slot is in its own three-step flow.</summary>
|
||||
public enum RecordSlotState
|
||||
{
|
||||
/// <summary>[ ◉ Record ]</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>[ ● Start Recording | ✕ ]</summary>
|
||||
Armed,
|
||||
|
||||
/// <summary>[ ■ Stop Recording ]</summary>
|
||||
Recording,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The top bar is THREE FIXED SLOTS (creator, 2026-11-13): Build Scenes, YouTube, Record. A slot never
|
||||
/// changes the bar's shape — every state transition happens INSIDE its own slot, and the neighbouring
|
||||
/// slots never move. Earlier attempts added completion states as siblings in the bar, which read as five
|
||||
/// ungrouped toolbar items and pushed Record around.
|
||||
/// </summary>
|
||||
public partial class MainViewModel
|
||||
{
|
||||
private YouTubeSlotState _youTubeStep = YouTubeSlotState.Idle;
|
||||
private RecordSlotState _recordStep = RecordSlotState.Idle;
|
||||
private bool _podcastLive; // true = Private chosen (the private-visibility test stream)
|
||||
|
||||
// ─── Build Scenes slot ───
|
||||
// Enabled only while BOTH other slots are idle: editing a scene under a live stream or a running
|
||||
// recording is what the lock is for, and the tooltip says so out loud.
|
||||
public bool IsBuildScenesEnabled =>
|
||||
_youTubeStep == YouTubeSlotState.Idle && _recordStep == RecordSlotState.Idle;
|
||||
|
||||
public string BuildScenesTooltip => IsBuildScenesEnabled
|
||||
? "Edit layers, scenes and webcam placement"
|
||||
: "Scene editing is locked while streaming or recording";
|
||||
|
||||
// ─── YouTube slot ───
|
||||
public YouTubeSlotState YouTubeStep => _youTubeStep;
|
||||
public bool IsYouTubeIdle => _youTubeStep == YouTubeSlotState.Idle;
|
||||
public bool IsYouTubeChoosingPrivacy => _youTubeStep == YouTubeSlotState.ChoosePrivacy;
|
||||
public bool IsYouTubeArmed => _youTubeStep == YouTubeSlotState.Armed;
|
||||
public bool IsYouTubeStreaming => _youTubeStep == YouTubeSlotState.Streaming;
|
||||
|
||||
public bool IsLiveChosen => !_podcastLive;
|
||||
public bool IsPrivateChosen => _podcastLive;
|
||||
public string YouTubeArmLabel => _podcastLive ? "Start Private" : "Start Live";
|
||||
public string YouTubeStopLabel => _podcastLive ? "Stop Private" : "Stop Live";
|
||||
|
||||
// ─── Record slot ───
|
||||
public RecordSlotState RecordStep => _recordStep;
|
||||
public bool IsRecordIdle => _recordStep == RecordSlotState.Idle;
|
||||
public bool IsRecordArmed => _recordStep == RecordSlotState.Armed;
|
||||
public bool IsRecordRecording => _recordStep == RecordSlotState.Recording;
|
||||
public string RecordElapsedLabel => $"REC: {LiveElapsedText}";
|
||||
|
||||
public ICommand SelectBuildCommand { get; private set; } = null!;
|
||||
public ICommand OpenYouTubeCommand { get; private set; } = null!;
|
||||
public ICommand ChooseLiveCommand { get; private set; } = null!;
|
||||
public ICommand ChoosePrivateCommand { get; private set; } = null!;
|
||||
public ICommand CancelYouTubeCommand { get; private set; } = null!;
|
||||
public ICommand StartYouTubeCommand { get; private set; } = null!;
|
||||
public ICommand StopYouTubeCommand { get; private set; } = null!;
|
||||
|
||||
public ICommand OpenRecordCommand { get; private set; } = null!;
|
||||
public ICommand StartRecordingCommand { get; private set; } = null!;
|
||||
public ICommand CancelRecordCommand { get; private set; } = null!;
|
||||
public ICommand StopRecordingCommand { get; private set; } = null!;
|
||||
|
||||
private void InitializeActivityCommands()
|
||||
{
|
||||
SelectBuildCommand = new RelayCommand(_ => RaiseSlotStates(), _ => IsBuildScenesEnabled);
|
||||
OpenYouTubeCommand = new RelayCommand(_ => SetYouTubeStep(YouTubeSlotState.ChoosePrivacy));
|
||||
ChooseLiveCommand = new RelayCommand(_ => { _podcastLive = false; SetYouTubeStep(YouTubeSlotState.Armed); });
|
||||
ChoosePrivateCommand = new RelayCommand(_ => { _podcastLive = true; SetYouTubeStep(YouTubeSlotState.Armed); });
|
||||
CancelYouTubeCommand = new RelayCommand(_ => SetYouTubeStep(YouTubeSlotState.Idle));
|
||||
|
||||
// Signed out still routes to sign-in rather than sitting dead (no-dead-ends rule).
|
||||
StartYouTubeCommand = new RelayCommand(_ =>
|
||||
{
|
||||
if (!IsConnected) { _ = SignInAsync(); return; }
|
||||
if (_podcastLive) BeginTestStream(); else StartSession();
|
||||
SetYouTubeStep(YouTubeSlotState.Streaming);
|
||||
});
|
||||
|
||||
StopYouTubeCommand = new RelayCommand(_ =>
|
||||
{
|
||||
StopStream();
|
||||
SetYouTubeStep(YouTubeSlotState.Idle);
|
||||
});
|
||||
|
||||
OpenRecordCommand = new RelayCommand(_ => SetRecordStep(RecordSlotState.Armed));
|
||||
StartRecordingCommand = new RelayCommand(_ =>
|
||||
{
|
||||
BeginRecordOnly();
|
||||
SetRecordStep(RecordSlotState.Recording);
|
||||
});
|
||||
CancelRecordCommand = new RelayCommand(_ => SetRecordStep(RecordSlotState.Idle));
|
||||
StopRecordingCommand = new RelayCommand(_ =>
|
||||
{
|
||||
StopStream();
|
||||
SetRecordStep(RecordSlotState.Idle);
|
||||
});
|
||||
}
|
||||
|
||||
private void SetYouTubeStep(YouTubeSlotState step)
|
||||
{
|
||||
_youTubeStep = step;
|
||||
RaiseSlotStates();
|
||||
}
|
||||
|
||||
private void SetRecordStep(RecordSlotState step)
|
||||
{
|
||||
_recordStep = step;
|
||||
RaiseSlotStates();
|
||||
}
|
||||
|
||||
/// <summary>One place that announces everything the three slots show, so no caller can forget a flag.
|
||||
/// </summary>
|
||||
internal void RaiseSlotStates()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsBuildScenesEnabled));
|
||||
OnPropertyChanged(nameof(BuildScenesTooltip));
|
||||
|
||||
OnPropertyChanged(nameof(YouTubeStep));
|
||||
OnPropertyChanged(nameof(IsYouTubeIdle));
|
||||
OnPropertyChanged(nameof(IsYouTubeChoosingPrivacy));
|
||||
OnPropertyChanged(nameof(IsYouTubeArmed));
|
||||
OnPropertyChanged(nameof(IsYouTubeStreaming));
|
||||
OnPropertyChanged(nameof(IsLiveChosen));
|
||||
OnPropertyChanged(nameof(IsPrivateChosen));
|
||||
OnPropertyChanged(nameof(YouTubeArmLabel));
|
||||
OnPropertyChanged(nameof(YouTubeStopLabel));
|
||||
|
||||
OnPropertyChanged(nameof(RecordStep));
|
||||
OnPropertyChanged(nameof(IsRecordIdle));
|
||||
OnPropertyChanged(nameof(IsRecordArmed));
|
||||
OnPropertyChanged(nameof(IsRecordRecording));
|
||||
OnPropertyChanged(nameof(RecordElapsedLabel));
|
||||
|
||||
// Build Scenes is the edit surface, so the lock has to reach editing too.
|
||||
EditingActive = IsBuildScenesEnabled;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public partial class MainViewModel
|
||||
TestSession = new TestSessionViewModel(
|
||||
_youtubeStream,
|
||||
_youtubeChat,
|
||||
onEndStream: StopStream);
|
||||
onEndStream: () => StopStream());
|
||||
|
||||
// One drawer at a time: the three pull-outs share the preview's right edge,
|
||||
// so opening one collapses the other two (all directions).
|
||||
|
||||
@@ -32,7 +32,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
// Toasts (TASK 24): non-blocking user-facing messages. Parameterless ctor
|
||||
// news up the real service; the VM is built from XAML so no DI seam exists.
|
||||
private readonly INotificationService _notifications = new NotificationService();
|
||||
private readonly INotificationService _notifications;
|
||||
|
||||
// Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
|
||||
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
|
||||
@@ -216,6 +216,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
public MainViewModel()
|
||||
{
|
||||
_notifications = new NotificationService(System.Windows.Application.Current?.Dispatcher,
|
||||
() => StickyAlertsUntilDismissed);
|
||||
AppLog.Write("MainViewModel ctor begin");
|
||||
InitializeCurrentLaunchLogs();
|
||||
IsAppOwned = _entitlement.IsAppOwned;
|
||||
@@ -340,11 +342,19 @@ public partial class MainViewModel : ViewModelBase
|
||||
OpenHotkeyConfigCommand = new RelayCommand(_ => OpenHotkeyConfig());
|
||||
ChangeAccountCommand = new RelayCommand(_ => _ = ChangeAccountAsync());
|
||||
LogoutCommand = new RelayCommand(_ => _ = SignOutYouTubeAsync());
|
||||
InitializeActivityCommands();
|
||||
|
||||
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
|
||||
_activeLayoutPath = _layoutStore.ActivePath;
|
||||
_toastPosition = _layoutStore.LoadNotificationPosition();
|
||||
_stickyAlertsUntilDismissed = _layoutStore.LoadStickyAlertsUntilDismissed();
|
||||
_lockEditWhileLive = _layoutStore.LoadLockEditWhileLive();
|
||||
_recordFolder = _layoutStore.LoadRecordFolder();
|
||||
|
||||
// What the last few sessions on this tier actually held, if it is worth asking about.
|
||||
InitializeRateProposalCommands();
|
||||
RefreshRateProposal();
|
||||
|
||||
// Load saved hotkey bindings (filled with defaults for any unbound actions).
|
||||
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
|
||||
|
||||
@@ -360,6 +370,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
// (yellow) instead of silently falling back to the default.
|
||||
MicSourceName = _layoutStore.LoadMicSourceName();
|
||||
AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
|
||||
MicSyncOffsetMs = _layoutStore.LoadMicSyncOffsetMs();
|
||||
_zoomFollowsBox = _layoutStore.LoadZoomFollowBox();
|
||||
_autoChromaScreen = _layoutStore.LoadAutoChromaScreen();
|
||||
_steamLicenceDismissed = _layoutStore.LoadSteamLicenceIndicatorDismissed();
|
||||
@@ -405,7 +416,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
message => AppLog.Write(message),
|
||||
micGain: _gainProvider.MicGain,
|
||||
loopbackGain: _gainProvider.LoopbackGain,
|
||||
syncOffsetMs: () => AudioSyncOffsetMs);
|
||||
syncOffsetMs: () => AudioSyncOffsetMs,
|
||||
micSyncOffsetMs: () => MicSyncOffsetMs);
|
||||
_audioMixer.MicLevelChanged += OnMicLevelChanged;
|
||||
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
|
||||
_audioMixer.MicConnected += OnMicConnected;
|
||||
@@ -604,6 +616,40 @@ public partial class MainViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Ends a running session before the app tears itself down, and says whether it is safe to close.
|
||||
///
|
||||
/// <para>Closing with a recording active used to dispose the frame pump and the audio mixer under a
|
||||
/// live loop: ffmpeg never flushed, the file was never renamed or kept, and the pump was disposed
|
||||
/// while it was still working. The window now holds itself open until the ordinary stop completes
|
||||
/// (bounded — a stuck encoder must not trap the creator in an unclosable app).</para>
|
||||
/// </summary>
|
||||
/// <summary>True while the app is closing. Dialogs cannot open then, so the stop path skips its
|
||||
/// prompts and takes the safe default (save under the automatic name, keep the broadcast).</summary>
|
||||
internal bool IsShuttingDown => _shuttingDown;
|
||||
private bool _shuttingDown;
|
||||
|
||||
internal async Task<bool> StopBeforeShutdownAsync()
|
||||
{
|
||||
if (!IsLive && !IsRecording) return true;
|
||||
|
||||
_shuttingDown = true;
|
||||
StopStream();
|
||||
for (var i = 0; i < 40 && (IsLive || IsRecording); i++) // ~2 s: long enough for a flush
|
||||
await Task.Delay(50).ConfigureAwait(true);
|
||||
|
||||
if (IsLive || IsRecording)
|
||||
{
|
||||
// The stop could not finish (no pump, a dead encoder, a test harness). A window that cannot
|
||||
// close is a worse failure than a session flag left set, so clear the UI state and go.
|
||||
AppLog.Write("Shutdown: session did not stop in time; closing anyway", AppLogMessageType.Warning);
|
||||
IsRecording = false;
|
||||
StreamStatus = StreamStatus.Offline;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Shutdown()
|
||||
{
|
||||
StopCurrentLaunchLogFeed();
|
||||
@@ -633,6 +679,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
_layoutStore.SaveTransitionSettings(
|
||||
SelectedTransitionType.ToString(), TransitionDurationMs);
|
||||
_layoutStore.SaveAudioSyncOffsetMs(AudioSyncOffsetMs);
|
||||
_layoutStore.SaveMicSyncOffsetMs(MicSyncOffsetMs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ areas' docs in their own rows keeps this index small to read.
|
||||
| `MainViewModel.Socials.cs` | Social bar (global resource, overlaid on every scene): `_socials`/`_socialBarFrame`/`_socialValidator` (`ISocialValidator`/`HttpSocialValidator`), `HasSocials`/`SocialBarVisible`/`SocialBarDotBrush`/`SocialBarGlowBrush`/`Socials`/`SocialBarTop`, `OpenSocialDialogCommand`/`OpenSocialDialog` (spawns `SocialsDialogViewModel` + window; saved copy replaces `_socials`), `NotifySocialsChanged`/`RenderSocialBarFrame` (`SocialBarRenderer` → immutable `_socialBarFrame` the frame pump reads), fediverse heal `HealFediverseSoftwareAsync` (static, test-facing) + `HealFediverseSoftwareInBackground`, bar edge toggle `SetSocialBarPosition`/`ToggleSocialBarPosition`. `BarOnBrush`/`BarOffBrush`/`CreateBrush` stay in core (shared tokens). |
|
||||
| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master; **TASK 57:** the tier change also resolves every scene's camera placement (the vertical tier brings the vertical placements in, 16:9 restores the desktop layout — see `MainViewModel.Webcam.cs`). **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. **TASK 57 (Unit 2):** the tier change also raises `CanToggleClipShape` (the circle option disappears in the vertical Chat layout). |
|
||||
| `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity), `ShowAbout` (returns to the About hub) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` in-app); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` + `PremiumUrl` (`https://llamacasty.com`, opens only when explicitly clicked) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. A locked quality selection opens About without changing the tier. Ctor command assignments stay in core. |
|
||||
| `MainViewModel.Settings.cs` | App Settings drawer state + global slide-out toast position (`ToastPosition`) and stickiness (`StickyAlertsUntilDismissed`), persisted in `LayoutStore.Settings`; `ToastAreaMargin` keeps the six supported positions aligned to the window edges/safe footer gap. |
|
||||
| `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real, then feed **both** component layers), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). **Alerts (TASK 43):** same facade for the native alert box — `_alertLayer` (`AlertOverlayLayer`), `RenderAlertBox`/`UpdateAlertBoxPreview`, `UpdateActiveAlertBoxPreviews`, `CanAddAlerts` (one alert zone per layout). Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `new AlertRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose` + `_alertLayer.Dispose`) stays in core. |
|
||||
| `MainViewModel.Logs.cs` | Non-edit left-panel tab state and bounded current-launch log collections; refreshes the process-local AppLog snapshot while Logs is selected, pins Class 1 before Class 2 until acknowledged, then returns entries to the LIFO timeline; stops its dispatcher timer on shutdown. |
|
||||
| `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 |
|
||||
|
||||
@@ -46,9 +46,12 @@ The product rule is frame rate, not pixel count or a resolution label:
|
||||
| Landscape | 1280×720 @30 · 1920×1080 @30 | all Free tiers + 1280×720 @60 · 1920×1080 @60 |
|
||||
| Vertical 9:16 | 1080×1920 @30 | all Free tiers + 1080×1920 @60 |
|
||||
|
||||
- **Implementation gap (2026-11-04):** `Models/OutputProfile.cs` still gates by pixel budget plus
|
||||
frame rate. Update that rule and its tests to use frame rate alone; this docs unit records the
|
||||
product decision but does not change code.
|
||||
- **Pre-release: the boundary is not enforced yet (2026-11-12).** `OutputProfile.EntitlementEnforced`
|
||||
is `false`, so every shipped tier — 60 fps included — is selectable by any build until the paid
|
||||
edition ships. It is needed because 60 fps can only be performance-tested in a **Release** build,
|
||||
where `DevBuild.IsDevelopment` is false and `DeveloperMode` cannot unlock anything. Flip the switch
|
||||
at launch: the rule, the resolver and their tests already implement the enforced state, and the
|
||||
`EntitlementTests` case asserts both states so it does not go red on the flip.
|
||||
|
||||
- **Records too.** The frame-rate rule governs the file the creator keeps as well as the stream;
|
||||
otherwise free 60-fps recording is a re-upload hole. **There is one ffmpeg process with two output blocks**
|
||||
|
||||
@@ -139,6 +139,15 @@ save/load round trips are exact: an element removed in the UI (`RemoveElement`
|
||||
close) does **not** come back after reload (`LayoutStorePersistenceTests` guards this).
|
||||
|
||||
|
||||
## A/V sync measurement
|
||||
|
||||
`scripts/avsync.py <recording> [--crop WxH+X+Y]` reports what no ear can: each stream's length,
|
||||
whether they drift (a length mismatch is a clock problem), and the residual offset between mouth
|
||||
motion and speech (a constant problem), with a per-3s window table and a correlation so a weak
|
||||
reading says so. It uses the bundled `ffmpeg.exe` and needs `numpy`. The creator is deaf, so this —
|
||||
not a listening test — is how sync gets judged: feed it the clip instead of guessing, and quote the
|
||||
numbers in the commit.
|
||||
|
||||
## Session performance evidence
|
||||
|
||||
`python3 scripts/session-stats.py` mines every `startup*.log` (primary, rotated, and per-instance)
|
||||
|
||||
+54
-2
@@ -91,6 +91,22 @@ mixer writes IEEE-float stereo to a Windows named pipe** (`-f f32le -ar 48000 -a
|
||||
anullsrc` silence in the TASK 8 audio milestone) + explicit `-map 0:v -map 1:a` + `-c:v <enc> -b:v K
|
||||
-maxrate K -bufsize 2K` + **`-g fps×4 -keyint_min fps×4 -sc_threshold 0 -bf 0 -pix_fmt yuv420p`**
|
||||
(≤4s keyframes, closed GOP, H.264 compliance) + `-c:a aac -ar 48000 -ac 2 -f flv <rtmpUrl>`.
|
||||
**Rate control is CBR and it is per-encoder** (`FfmpegArgs.AddRateControl`). The `-b:v/-maxrate/-bufsize`
|
||||
triad is **capped VBR, not CBR**: measured 2026-11-12 on the bundled BtbN build, a static frame at
|
||||
`-b:v 2000k` produced **9 kbps** under NVENC's default and **1951 kbps** with `-rc cbr` — the cause of an
|
||||
@8 stream that averaged 5.1 Mbps. NVENC and AMF are passed `-rc cbr`; **QSV has no `-rc`** and selects CBR
|
||||
itself when `bitrate == maxrate` (`libavcodec/qsvenc.c`, `select_rc_mode`), so the triad is its whole ask;
|
||||
OpenH264's `-rc_mode bitrate` changed nothing measurable here and is not passed. YouTube's encoder spec
|
||||
requires CBR. Never assume `-maxrate` equals CBR — it does not.
|
||||
|
||||
**The starting rate is learned, never imposed** (`Models/RateHistory`, `ViewModels/MainViewModel.Rate.cs`).
|
||||
Each finished broadcast is filed locally — tier, probed encoder, rated, settled, steps spent — and the
|
||||
only thing that may change a starting rate is a proposal the creator clicks. A **high-water mark** is the
|
||||
highest rate that *finished* a session with zero steps, never the highest number seen, so one easy session
|
||||
cannot set a target the machine cannot hold. Raising needs 10% above the current rate across five clean
|
||||
sessions of the same tier **and encoder** (history against NVENC is not evidence about QSV); lowering needs
|
||||
three step-downs in five, because fixing damage should not wait. "Keep mine" is remembered against that
|
||||
specific number, so the same question is not asked again until the evidence moves. Nothing is uploaded.
|
||||
**Record output (TASK 18):** `FfmpegArgs.Build` emits one self-contained block per output, each its own
|
||||
`-map 0:v -map 1:a` + codec tags (`AddVideoTags` helper). Stream block = `-f flv <rtmpUrl>`; record block =
|
||||
`-f mp4 <RecordPath>`. `EncoderOptions.StreamEnabled`/`RecordEnabled`/`RecordPath` gate each block, so
|
||||
@@ -248,8 +264,10 @@ devices, no timers).
|
||||
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. **Recordings are fragmented MP4 (TASK 32·4, 2026-11-05):** the record block carries
|
||||
the reverse order stalls on pipe backpressure. Encoder step-down follows the same order: close the
|
||||
audio pipe, flush/stop the pump, create a fresh server on the same name, then restart the pump so ffmpeg
|
||||
connects to a live server. `FfmpegEncoder` itself is untouched; the VM owns the pipe lifecycle.
|
||||
**Recordings are fragmented MP4 (TASK 32·4, 2026-11-05):** the record block carries
|
||||
`-movflags +frag_keyframe+empty_moov+default_base_moof`, so the index is written first and a playable
|
||||
fragment is flushed at every keyframe. A plain MP4 puts its index at the end, which is why a power cut
|
||||
or a killed process previously left the whole take unreadable. The FLV stream block never gets it.
|
||||
@@ -272,6 +290,40 @@ devices, no timers).
|
||||
(a one-time status dot next to it was removed 2026-09-23 per creator feedback — the slider +
|
||||
numeric tooltip carry the state). Changing the delay flushes the line (a live change clicks
|
||||
rather than smears).
|
||||
- **Microphone-only delay (2026-11-12):** a camera's frames reach the compositor later than the mic's
|
||||
samples reach the pipe, so the voice runs ahead of the lips. `AudioSyncDelay` now takes a channel
|
||||
count, and `FillAndMix` runs a second line (**mono**) over `micChunk` *before* the mix — loopback and
|
||||
alert audio are not tied to a camera frame and must not move with it. Seam: `Func<int> micSyncOffsetMs`
|
||||
(VM `MicSyncOffsetMs`, **0..500 ms**, persisted `Audio.MicSyncOffsetMs`, shipped default **0 / off** — a sync offset corrects one camera on
|
||||
one desk, so it is opt-in, never a product default).
|
||||
A **slider 0..500** (default 0) plus `off / 175 / 350` presets sit in the LIP-SYNC bay under the whole-mix
|
||||
slider — presets alone could not express the value a slow camera actually needs. `Audio live: started`
|
||||
logs the applied number, because the value lives in the layout DB and a preset click is invisible in a
|
||||
recording. Both lines are
|
||||
reconfigured every tick, so both carry the 2026-09-12 warning: a `Configure` that reallocates
|
||||
discards the buffered audio every 10 ms.
|
||||
- **Measured lip-sync, 2026-11-12 (the answer, until hardware changes).** With the mic delay off, the
|
||||
audio path itself runs about **100 ms behind** the picture — WASAPI capture plus ring buffering plus
|
||||
the 10 ms mix ticks — so the fix is the **global** offset at about **−130 ms**, which advances the mix.
|
||||
Delaying the mic cannot fix audio that is late; every millisecond added made it worse (350 ms read as
|
||||
clearly broken). Residual after −130 is inside one 30 fps frame (±30 ms, and the two measurement
|
||||
methods then disagree by more than the correction), so that is the floor — stop tuning there. The
|
||||
negative offset also shortens the audio track by its own value, which is the trim working, not drift.
|
||||
Judge this with `scripts/avsync.py` (clap pairs + mouth correlation), never by ear: the creator is deaf.
|
||||
- **The audio clock is the wall clock, not the tick count (2026-11-12).** `LiveLoopAsync` asks
|
||||
`Services/Audio/AudioClock.FramesDue(elapsed, produced, rate, maxChunk)` how many frames are owed and
|
||||
produces exactly that, instead of writing a fixed 480 frames per iteration. The fixed version turned a
|
||||
scheduler error into a permanent A/V rate error: iterations land near 9.8 ms rather than 10, which is
|
||||
102 ticks/s where 100 are expected — 2.1% more audio than wall time. Measured on a 9.9 s recording:
|
||||
**video 9.888 s (correct, 594 frames at 60 fps), audio 10.112 s**, so the sound slid ~200 ms behind
|
||||
the lips across ten seconds and no static delay could fix it. Buffers size to the largest chunk the
|
||||
planner may return (`OutputSampleRate / 5`), and a stalled loop pays its debt over the following ticks
|
||||
rather than dumping one huge write. Video never had this bug — the pump paces off a stopwatch.
|
||||
**`Advance` counts a frame only when its write reached the pipe**, and the clock runs from session
|
||||
open, not from the first successful write. Both come from measurement: 27 start-up writes were being
|
||||
counted while dropped (**226 ms short**), and starting the clock at connect instead made the track
|
||||
begin 160 ms after the picture (**137 ms short**, every word late). The owed time is paid out of the
|
||||
capture ring when the pipe connects — that is the audio that was missed, not padding.
|
||||
- **TRAX — free background music (TASK 8):** `MusicPlayer` = NAudio `MediaFoundationReader`
|
||||
(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`
|
||||
|
||||
+54
-4
@@ -144,7 +144,54 @@ Placement math itself is covered by `WindowPlacementTests` (pure function, no HW
|
||||
geometry fields are 44px: the measured width of `-1920` in the app's font at 11px (via WPF's
|
||||
`FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
|
||||
`ytLive.Tests/GeometryFieldWidthTests.cs`
|
||||
- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p30@8 and 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
|
||||
- Resolution tiers: 1080p60@12 (default) → 1080p30@10 → 720p60@6 → 720p30@6 → **Vertical 1080p30@10 and 1080p60@12 (9:16, 1080×1920)** — the `@` is the CBR target the encoder is asked to hold, between YouTube's H.264 recommendation and the practitioner sweet spot (its 14/17/8 recommendations are the max, the tier minimums 5/6/3 the step-down floor). Vertical mirrors its landscape pair: same pixel count, same rate. The old flat 8 Mbps was an inherited round number, not a measurement. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. Under the picker sits the creator's own **rate box** (Mbps, per tier, persisted as `Video.Rate.<tier>`): the tier chooses geometry, the box chooses bits, clamped at the tier's YouTube minimum and otherwise unbounded because live step-down is the thing that catches a wrong guess. Its tooltip carries YouTube's recommendation for the tier (`Models/RateLadder`). Every consumer — encoder options, step-down policy, clip buffer — reads `MainViewModel.EffectiveQuality`, never the raw tier. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
|
||||
- **The top bar is THREE FIXED SLOTS (creator ruling 2026-11-13): `[Build Scenes] [YouTube] [Record]`.**
|
||||
The bar's shape never changes — every transition happens INSIDE one slot, so neighbouring buttons cannot
|
||||
move. Attempts that added completion states as siblings (`Live`, `Private`, `Stop`) read as five
|
||||
ungrouped toolbar items and pushed Record around; that is the mistake this replaces.
|
||||
Slot flows (`MainViewModel.TopBar.cs`, `YouTubeSlotState` / `RecordSlotState`):
|
||||
- **YouTube:** Idle → ChoosePrivacy (`Live | Private | ✕`) → Armed (`Start Live|Start Private | ✕`, amber
|
||||
border because the next click goes on air) → Streaming (`Stop Live|Stop Private`).
|
||||
- **Record:** Idle → Armed (`Start Recording | ✕`) → Recording (`● REC hh:mm:ss` + `Stop`).
|
||||
- **Build Scenes** is enabled only while BOTH action slots are Idle; otherwise it is disabled at 40%
|
||||
opacity with the tooltip "Scene editing is locked while streaming or recording".
|
||||
Arming and starting stay separate presses: Start is the last "are you ready?" moment.
|
||||
- **Legacy note — superseded by the three-slot bar above:**
|
||||
Build Scenes ships highlighted and **is** edit mode — the standalone EDIT toggle is gone, so "which
|
||||
world am I in?" and "can I edit?" cannot disagree (`MainViewModel.TopBar.cs`, `BarWorld`). The segment
|
||||
leads the centre cluster and stays present in every state; clicking the highlighted half re-enters it,
|
||||
which is how a creator gets back to the layers during a session. `WorldSegmentButton`
|
||||
(`Themes/Controls.xaml`) is a Button, not a ToggleButton — the highlight follows the view model, and a
|
||||
click must never be what sets it. The segment exists **only** in the Build world: choosing Record / Go
|
||||
Live **replaces** it with the output line, and a left chevron (`Back to Build Scenes`) is present for
|
||||
the whole Output world, running included — the way back to the layers, and the reason the bar has no
|
||||
dead end.
|
||||
- **The output stage** (`ShowOutputStage`) is the idle cluster and lives only in the Record / Go Live
|
||||
world while nothing runs: `Output [▶ YouTube] [● Local MP4] One output at a time`, each switch carrying
|
||||
its glyph, with **Local MP4 armed at launch** (`RecordPillOn` starts true — it did not before). The two
|
||||
are one axis and the view model already drops one when the other arms, so exclusivity is policy, not
|
||||
layout. A running session hides the stage (its output cannot change) and the reality line plus the
|
||||
stop action take the cluster's place.
|
||||
- **The action follows the armed output** (creator ruling 2026-11-12). Local MP4 → `Start Recording`
|
||||
(`Stop Recording` while running). YouTube → `Private Test Stream` beside `Review & Go Live`, the latter
|
||||
carrying the primary face because it is the default ask. **Test is no longer inferred from EDIT** — the
|
||||
2026-10-30 rule where COLLECT EDIT decided test-vs-real is superseded, and `SessionStartDecision.For`
|
||||
takes only the armed output. Running: `End Live Stream` (or `End Test` for a test run, `Stop Recording`
|
||||
for a local one) via `EndButtonText`. A signed-out YouTube press starts the sign-in flow rather than
|
||||
sitting dead (no-dead-ends).
|
||||
- **Editing during a session is a setting (creator ruling 2026-11-12).** `App.LockEditWhileLive` is off
|
||||
by default: a show does not take EDIT away, so a creator may move a layer or resize a webcam while
|
||||
live. On restores the old gun-safety — go-live flips EDIT off, stop flips it back, and only because
|
||||
the lock took it away (`MainViewModel.ApplyEditLockForSession`, `EditWhileLiveTests`). Settings →
|
||||
Editing carries the checkbox.
|
||||
- **OPEN (creator, 2026-11-12): can a locked scene still be changed through a layer's visibility eye?**
|
||||
Reported as "the creator can still use the layer icon — visibility — if it was already enabled for
|
||||
that layer". Not reproduced from the obvious paths: with EDIT off the left panel swaps the layer list
|
||||
for chat (`LeftPanel.xaml` row 1), the eye button carries no lock guard of its own, the preview routes
|
||||
a left click to the zoom box and swallows Ctrl-click (`PreviewPane.xaml.cs`). So either visibility
|
||||
toggling is meant to stay live while the scene is locked (a deliberate exception to gun-safety), or a
|
||||
surface still exposes it and should be closed. Decide before touching layer locking; check the eye
|
||||
button in `LeftPanel.xaml` (grid column 3) and every `ToggleElementVisibilityCommand` binding first.
|
||||
- **Locked output tiers stay visible and clickable.** They are dimmed for non-owners. Choosing one keeps the free tier selected and opens About in-app; only the explicit llamacasty.com link opens a browser.
|
||||
- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug). **The log is SIZE-CAPPED (TASK 55, 2026-10-22):** at 5 MB `startup.log` rolls to `startup.1.log` (one rolled copy kept) — the policy lives in `Helpers/LogRotation.cs` as a pure function over a path, so `AppLog`'s 156-call-site surface never changes and the rule is testable on its own. Guard: `ytLive.Tests/AppLogRotationTests.cs`
|
||||
|
||||
@@ -159,7 +206,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB
|
||||
**Current layout (2026-10-26 redesign commits):** `MainWindow` has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer.
|
||||
|
||||
- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = a two-tab panel with **Live Chat** first/default and **Logs** second. Logs is a read-only view of up to 500 `AppLog` entries captured in this process; it does not load older `startup.log` contents or change file logging/rotation. Entries wrap and use the theme's type colors. Explicit Class 1/2 severity is independent of message type/source: Class 1 sorts above Class 2 in the pinned group; acknowledging unpins the entry but retains it in the chronological list. The staged scene is the preview/edit target; `LiveScene` is the program output.
|
||||
- **Settings is a right-edge drawer, not a modal (2026-11-01):** the gear rail tab opens `Controls/SettingsDrawer` in the same animated-width stack as Stream Settings / YPP / Socials / TEST (`SettingsDrawerOpen`, exclusive with the other four). Categories are section headings with their controls indented under them — Zoom holds *Zoom view shape* and *Zoom box follows*. TASK 37 adds **Restore staged scene** and **Restore all scenes** under Layout defaults; both are disabled while live or recording, and restoring a layout preserves the selected webcam. The app menu (Report Bug / Feature Request / About) hangs off a **right-click on the LlamaCasty wordmark**; left-click still opens About. The modal shell keeps only Bug / Feature / About, which is why it no longer needs a scroll viewer.
|
||||
- **Settings is a right-edge drawer, not a modal (2026-11-01):** the gear rail tab opens `Controls/SettingsDrawer` in the same animated-width stack as Stream Settings / YPP / Socials / TEST (`SettingsDrawerOpen`, exclusive with the other four). Categories are section headings with their controls indented under them — Zoom holds *Zoom view shape* and *Zoom box follows*. TASK 37 adds **Restore staged scene** and **Restore all scenes** under Layout defaults; both are disabled while live or recording, and restoring a layout preserves the selected webcam. App Settings controls slide-out toast placement (six left/right × top/center/bottom positions; bottom-right default) and has a sticky mode that keeps all notifications until clicked or closed. The app menu (Report Bug / Feature Request / About) hangs off a **right-click on the LlamaCasty wordmark**; left-click still opens About. The modal shell keeps only Bug / Feature / About, which is why it no longer needs a scroll viewer.
|
||||
- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its background uses the idle surface color when stopped. Normal ON-AIR stays `Brush.Primary` red while connecting and turns `Brush.Positive` green only after YouTube reports the broadcast `live`; stopping restores the idle color. TEST stays gold for the entire test session. Its **right edge aligns with the layers panel below** (`TopBar` column 0 is a fixed **352** = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The **settings gear moved to the preview rail** (2026-11-01): first tab, orange (`Brush.Attention`), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings.
|
||||
- **Preview gestures (2026-11-01):** *edit mode* — left selects/drags/resizes, right opens the context menu, **Ctrl+right toggles the zoom box**. *Preview mode* — **left toggles the zoom box**, right opens the context menu; **Ctrl+right does nothing at all** (no chord while live or recording — one hand, always). ⚠️ **Both the Down and the Up event must be handled for a Ctrl+right-click**: WPF's `PopupControlService` opens a context menu on right-button-**up**, so suppressing only the down event leaves the menu appearing under the gesture (found 2026-11-01 in edit mode). Menus exist on the webcam layer and the backdrop; every other layer right-clicks through to the backdrop menu. Left-dragging with the zoom box open moves it, in both modes. **Ctrl-down closes it**: pressing Ctrl while the box is open dismisses it, so the same hand that opened it can put it away (`MainWindow_PreviewKeyDown`; the box cannot cancel itself on creation because Ctrl is already down before it exists). **Follow mode** is a Settings pill, default **off = Anchor**: with Follow on, dragging re-anchors the capture to the box centre (`ZoomFollowsBox`, persisted as the `Zoom.FollowBox` setting). **The gesture activates the box at the cursor**, not merely dismisses it: `ActivateZoom` no longer refuses when idle (it used to return unless `IsLive || IsRecording`, and `ToggleZoom` only ever dismissed — so the gesture did nothing at all outside a session until 2026-11-01). The social bar consumes its own click (toggle top ⇄ bottom), and the preview's tunnelling handler steps aside for it — otherwise the bar is unclickable in preview mode and drags the layer beneath it in edit mode.
|
||||
- **Preview undo/redo (2026-11-01):** `Ctrl+Z` / `Ctrl+Shift+Z`, window-level bindings, live only while `EditingActive` — undo with the preview showing program output would rewrite a scene nobody is looking at. `Models/PreviewHistory` snapshots element **references** plus geometry, visibility, order and membership, so move / resize / reorder / add / remove / hide all undo in one step per gesture (`PushPreviewUndo` is called once at drag or resize start, never inside the move loop). Property edits (colour, opacity, border, font) are **not** captured yet — stated in the type's doc comment so nobody assumes otherwise. The stack clears on layout load. Guard: `ytLive.Tests/PreviewHistoryTests.cs`.
|
||||
@@ -204,13 +251,16 @@ extension methods; `MainViewModel` news up the real `NotificationService` at its
|
||||
MIT — notice #10 in `THIRD-PARTY-NOTICES.txt`). Facts that cost a hunt:
|
||||
- The `NotificationArea` XAML control registers itself with the manager on `Loaded`; routing matches
|
||||
the area's `Name` against the request's `AreaName` — our host is `x:Name="ToastArea"` in MainWindow's
|
||||
root grid (last child = topmost z-order, `Grid.RowSpan=4`, bottom-right above the footer via
|
||||
`Margin="0,0,12,96"`, outside the preview Viewbox). An unknown AreaName silently drops the toast.
|
||||
root grid (last child = topmost z-order, `Grid.RowSpan=4`, global App Settings chooses one of six
|
||||
left/right × top/center/bottom positions; bottom placements leave a 96px footer gap). An unknown
|
||||
AreaName silently drops the toast.
|
||||
- `NeverExpires()` sets `ExpirationTime = TimeSpan.MaxValue` (NOT null); `NotificationColor.ToHex()`
|
||||
returns `#AARRGGBB` (alpha prefix).
|
||||
- Lifetimes: Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky until dismissed (the pure mapping
|
||||
lives in `NotificationService.BuildRequest` + the `Cards` tint table: slate info `#3a3b52`, green
|
||||
success `#1d5c38`, amber warning `#b8860b`, dark-red error `#8f1f1f` — matching the health-banner language).
|
||||
- Global App Settings `StickyAlertsUntilDismissed` changes every severity to no-expiration + body-click-to-close;
|
||||
off preserves those defaults. The preference persists in the profile `Settings` table.
|
||||
- Calls marshal to the captured Dispatcher (`_dispatcher.Invoke`) — encoder/pump events fire off-thread.
|
||||
|
||||
Migrated MessageBoxes: both webcam-acquire warnings, image-read warning, sign-in unsuccessful/failed.
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure a recording's A/V sync without listening to it.
|
||||
|
||||
Prints the three numbers that matter, from the file itself:
|
||||
1. how long each stream actually is (a length mismatch is a clock problem),
|
||||
2. how far apart they drift per second (a rate problem),
|
||||
3. the residual offset between mouth motion and speech (a constant problem).
|
||||
|
||||
Usage: avsync.py <recording.mp4> [--ffmpeg <path>] [--crop WxH+X+Y]
|
||||
|
||||
Exit code is 0 when the file is measurable, 1 when it is not.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
DEFAULT_FFMPEG = r"C:\Users\gramp\AppData\Roaming\ytLlive\tools\ffmpeg.exe"
|
||||
|
||||
|
||||
def host_path(path: str) -> str:
|
||||
"""Accept a Windows path under WSL (and a native path everywhere else)."""
|
||||
if os.name != "nt" and len(path) > 2 and path[1] == ":":
|
||||
return f"/mnt/{path[0].lower()}/{path[2:].replace(chr(92), '/').lstrip('/')}"
|
||||
return path
|
||||
|
||||
|
||||
def run(ffmpeg: str, args: list[str]) -> bytes:
|
||||
proc = subprocess.run([host_path(ffmpeg), "-v", "error", *args], capture_output=True)
|
||||
if proc.returncode != 0:
|
||||
sys.stderr.write(proc.stderr.decode("utf-8", "replace"))
|
||||
raise SystemExit(1)
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def probe(ffprobe: str, path: str) -> dict:
|
||||
out = run(ffprobe, ["-show_entries",
|
||||
"stream=index,codec_type,duration",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "json", path])
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
def stream_durations(meta: dict) -> tuple[float, float]:
|
||||
video = audio = 0.0
|
||||
for s in meta.get("streams", []):
|
||||
d = float(s.get("duration") or 0)
|
||||
if s.get("codec_type") == "video" and d:
|
||||
video = max(video, d)
|
||||
elif s.get("codec_type") == "audio" and d:
|
||||
audio = max(audio, d)
|
||||
return video, audio
|
||||
|
||||
|
||||
def envelopes(ffmpeg: str, path: str, crop: str | None):
|
||||
audio_bytes = run(ffmpeg, ["-i", path, "-map", "0:a", "-ac", "1", "-ar", "48000",
|
||||
"-f", "s16le", "-"])
|
||||
audio = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float64) / 32768.0
|
||||
|
||||
filters = []
|
||||
if crop:
|
||||
# Accept the everyday "WxH+X+Y" geometry and translate it to the filter's w:h:x:y.
|
||||
spec = crop.replace("x", ":", 1).replace("+", ":")
|
||||
if ":" not in spec and crop.count(":") == 3:
|
||||
spec = crop
|
||||
filters.append(f"crop={spec}")
|
||||
filters += ["scale=64:64", "format=gray"]
|
||||
video_bytes = run(ffmpeg, ["-i", path, "-map", "0:v", "-vf", ",".join(filters),
|
||||
"-f", "rawvideo", "-"])
|
||||
frames = np.frombuffer(video_bytes, dtype=np.uint8).reshape(-1, 64, 64).astype(np.float64)
|
||||
motion = np.abs(np.diff(frames, axis=0)).mean(axis=(1, 2))
|
||||
motion = np.concatenate([[motion[0] if len(motion) else 0.0], motion])
|
||||
return audio, motion
|
||||
|
||||
|
||||
def offset_ms(audio: np.ndarray, motion: np.ndarray, fps: float, max_ms: float = 400.0):
|
||||
"""Cross-correlate mouth motion against speech energy on a 100 Hz grid.
|
||||
|
||||
Returns (lag_ms, correlation) where a POSITIVE lag means the sound arrives later than the
|
||||
mouth movement (audio delayed / video ahead).
|
||||
"""
|
||||
grid = 100.0
|
||||
duration = min(len(audio) / 48000.0, len(motion) / fps)
|
||||
t = np.arange(0, duration, 1.0 / grid)
|
||||
|
||||
def resample(x: np.ndarray, rate: float) -> np.ndarray:
|
||||
return x[np.clip((t * rate).astype(int), 0, len(x) - 1)]
|
||||
|
||||
win = max(1, int(48000 / grid))
|
||||
n = len(audio) // win
|
||||
audio_rms = np.sqrt((audio[: n * win].reshape(n, win) ** 2).mean(axis=1))
|
||||
audio_env = np.interp(t, np.arange(n) / grid, audio_rms)
|
||||
|
||||
def smooth(x: np.ndarray) -> np.ndarray:
|
||||
k = max(3, int(grid * 0.05) | 1)
|
||||
return np.convolve(x, np.ones(k) / k, mode="same")
|
||||
|
||||
def z(x: np.ndarray) -> np.ndarray:
|
||||
s = x.std()
|
||||
return (x - x.mean()) / (s if s > 1e-9 else 1.0)
|
||||
|
||||
m = z(smooth(resample(motion, fps)))
|
||||
a = z(smooth(audio_env))
|
||||
|
||||
limit = int(grid * max_ms / 1000)
|
||||
|
||||
def best_for(m_seg: np.ndarray, a_seg: np.ndarray) -> tuple[float, float]:
|
||||
best_corr, best_lag = -9.0, 0
|
||||
for k in range(-limit, limit + 1):
|
||||
a_part = a_seg[max(0, k): len(a_seg) + min(0, k)]
|
||||
m_part = m_seg[max(0, -k): len(m_seg) - max(0, k)]
|
||||
corr = float((m_part * a_part).mean())
|
||||
if corr > best_corr:
|
||||
best_corr, best_lag = corr, k
|
||||
return best_lag / grid * 1000.0, best_corr
|
||||
|
||||
# Whole-clip first; then in 3 s windows, because a periodic voice (a spoken alphabet, a
|
||||
# song) can lock the global search onto the wrong beat. Only windows that correlate
|
||||
# strongly are trusted, and their median is the answer.
|
||||
overall_lag, overall_corr = best_for(m, a)
|
||||
seg = int(3 * grid)
|
||||
windows = []
|
||||
for start in range(0, max(1, len(m) - seg), seg):
|
||||
lag, corr = best_for(m[start:start + seg], a[start:start + seg])
|
||||
windows.append((start / grid, lag, corr))
|
||||
strong = sorted(lag for _, lag, corr in windows if corr >= 0.35)
|
||||
if len(strong) >= 2:
|
||||
mid = len(strong) // 2
|
||||
robust = strong[mid] if len(strong) % 2 else (strong[mid - 1] + strong[mid]) / 2
|
||||
spread = strong[-1] - strong[0]
|
||||
if spread > 150:
|
||||
# Windows that disagree this much cannot be averaged into an answer. Say so.
|
||||
return float("nan"), max(corr for _, _, corr in windows), windows
|
||||
return robust, max(corr for _, _, corr in windows), windows
|
||||
if strong:
|
||||
return float("nan"), max(corr for _, _, corr in windows), windows
|
||||
return overall_lag, overall_corr, windows
|
||||
|
||||
|
||||
def clap_offset(audio: np.ndarray, motion: np.ndarray, fps: float) -> tuple[float, int]:
|
||||
"""Pair sharp audio transients with the nearest big visual change and take the median.
|
||||
|
||||
A clap is the only event in an ordinary recording with an unmistakable audio spike and an
|
||||
unmistakable visual spike a fixed distance apart, which is why it beats correlating a mouth
|
||||
against speech: no periodicity to lock onto wrongly. Returns (median_ms, pair_count) where a
|
||||
POSITIVE value means the sound arrived later than the picture.
|
||||
"""
|
||||
if fps <= 0 or len(motion) < 4:
|
||||
return float("nan"), 0
|
||||
|
||||
win = 240 # 5 ms amplitude windows
|
||||
n = len(audio) // win
|
||||
if n < 8:
|
||||
return float("nan"), 0
|
||||
amp = np.abs(audio[: n * win].reshape(n, win)).max(axis=1)
|
||||
times = np.arange(n) * win / 48000.0
|
||||
|
||||
threshold = np.percentile(amp, 99.0)
|
||||
events: list[float] = []
|
||||
last = -9.0
|
||||
for t in times[amp >= threshold]:
|
||||
if t - last > 0.6: # one event per clap, not one per sample of its decay
|
||||
events.append(float(t))
|
||||
last = t
|
||||
|
||||
deltas = []
|
||||
for t in events:
|
||||
lo, hi = int((t - 0.4) * fps), int((t + 0.4) * fps)
|
||||
if lo < 0 or hi >= len(motion):
|
||||
continue
|
||||
peak = (lo + int(np.argmax(motion[lo:hi]))) / fps
|
||||
deltas.append((t - peak) * 1000.0)
|
||||
|
||||
if len(deltas) < 3:
|
||||
return float("nan"), len(deltas)
|
||||
|
||||
# Keep the cluster: an arm entering the frame moves more than the clap itself and lands early.
|
||||
deltas.sort()
|
||||
mid = len(deltas) // 2
|
||||
median = deltas[mid] if len(deltas) % 2 else (deltas[mid - 1] + deltas[mid]) / 2
|
||||
tight = [d for d in deltas if abs(d - median) <= 60]
|
||||
if len(tight) < 3:
|
||||
return float("nan"), len(tight)
|
||||
return float(np.median(tight)), len(tight)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("recording")
|
||||
ap.add_argument("--ffmpeg", default=os.environ.get("YTLLIVE_FFMPEG", DEFAULT_FFMPEG))
|
||||
ap.add_argument("--crop", default=None,
|
||||
help="webcam region, e.g. 764x764+1032+159 (defaults to the whole frame)")
|
||||
ap.add_argument("--max-ms", type=float, default=400.0)
|
||||
args = ap.parse_args()
|
||||
|
||||
ffmpeg = args.ffmpeg
|
||||
ffprobe = ffmpeg.replace("ffmpeg.exe", "ffprobe.exe")
|
||||
if not os.path.exists(host_path(ffmpeg)):
|
||||
sys.stderr.write(f"ffmpeg not found: {ffmpeg}\n")
|
||||
return 1
|
||||
|
||||
video, audio = stream_durations(probe(ffprobe, args.recording))
|
||||
if not video or not audio:
|
||||
sys.stderr.write("could not read both stream durations\n")
|
||||
return 1
|
||||
|
||||
diff = audio - video
|
||||
pct = diff / video * 100.0
|
||||
|
||||
samples, motion = envelopes(ffmpeg, args.recording, args.crop)
|
||||
fps = len(motion) / (len(samples) / 48000.0) if len(motion) else 0.0
|
||||
lag, corr, windows = offset_ms(samples, motion, fps, args.max_ms) if fps > 0 else (0.0, 0.0, [])
|
||||
|
||||
print(f"video {video:8.3f} s")
|
||||
print(f"audio {audio:8.3f} s")
|
||||
print(f"delta {diff:+8.3f} s ({pct:+.2f}% of the video length)")
|
||||
if abs(diff) < 0.05:
|
||||
print(" lengths match")
|
||||
else:
|
||||
print(" LENGTH MISMATCH — a clock problem, UNLESS a negative lip-sync offset is set:")
|
||||
print(f" trimming the stream head by N ms makes the audio track N ms shorter by design")
|
||||
for start, wlag, wcorr in windows:
|
||||
mark = "*" if wcorr >= 0.35 else " "
|
||||
print(f" {start:5.1f}s {wlag:+7.0f} ms corr {wcorr:.2f} {mark}")
|
||||
|
||||
if lag != lag or corr < 0.35:
|
||||
clap, pairs = clap_offset(samples, motion, fps)
|
||||
if clap == clap:
|
||||
print(f"offset {clap:+8.0f} ms (clap pairs: {pairs})")
|
||||
direction = "sound LATER than the lips" if clap > 0 else "sound EARLIER than the lips"
|
||||
if abs(clap) >= 20:
|
||||
print(f" {direction} — adjust the mic delay by {-clap:+.0f} ms")
|
||||
else:
|
||||
print(" within a frame or two — leave it alone")
|
||||
return 0
|
||||
print("offset unmeasurable — too little agreement between mouth motion and speech")
|
||||
print(f" ({pairs} usable clap pairs) record 3 claps, hands in the webcam frame, 2 s apart")
|
||||
return 0
|
||||
|
||||
print(f"offset {lag:+8.0f} ms (correlation {corr:.2f})")
|
||||
if abs(lag) >= 20:
|
||||
direction = "sound LATER than the lips" if lag > 0 else "sound EARLIER than the lips"
|
||||
print(f" {direction} — adjust the mic delay by {-lag:+.0f} ms")
|
||||
else:
|
||||
print(" within a frame or two — leave it alone")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+30
-2
@@ -77,10 +77,38 @@ fi
|
||||
if [ "${PHYSICAL_TESTS:-0}" != "1" ]; then
|
||||
TEST_ARGS+=("--TestCaseFilter:$FILTER")
|
||||
fi
|
||||
RUN_RAW="$("$DOTNET" vstest "${TEST_ARGS[@]}" 2>&1)"
|
||||
# The test HOST sometimes finishes the suite and then never exits, spinning CPU with a multi-GB working
|
||||
# set (three separate sightings, 2026-11-12/13). The suite's own summary line proves the run completed, so
|
||||
# bound the wait instead of hanging the gate forever, and sweep the orphan afterwards.
|
||||
# Captured to a FILE, not command substitution: `timeout` kills the host with SIGTERM and a substituted
|
||||
# buffer is lost with it, which left the gate with no summary at all (the exact silent failure this is
|
||||
# meant to remove). A file keeps whatever the suite printed before the kill.
|
||||
RAW_FILE="$(mktemp)"
|
||||
# Per-test lines (not just the summary): when the host dies before summarising, the individual
|
||||
# results are still the evidence, and 679 "Passed" lines with zero "Failed" is a pass.
|
||||
timeout 300 "$DOTNET" vstest "${TEST_ARGS[@]}" --logger:"console;verbosity=normal" > "$RAW_FILE" 2>&1
|
||||
TEST_EXIT=$?
|
||||
RUN_RAW="$(cat "$RAW_FILE")"
|
||||
rm -f "$RAW_FILE"
|
||||
if [ "$TEST_EXIT" = "124" ]; then
|
||||
echo "⚠ test host did not exit after the suite; judging on what the suite printed"
|
||||
taskkill.exe /F /IM testhost.exe > /dev/null 2>&1 || true
|
||||
# No summary? Fall back to the per-test lines: zero failures among real results is a pass.
|
||||
if echo "$RUN_RAW" | grep -qiE "Passed!|Failed!"; then
|
||||
TEST_EXIT=0
|
||||
else
|
||||
PASSED_COUNT=$(echo "$RUN_RAW" | grep -cE "^ Passed ")
|
||||
FAILED_COUNT=$(echo "$RUN_RAW" | grep -cE "^ Failed ")
|
||||
echo " per-test lines: $PASSED_COUNT passed, $FAILED_COUNT failed"
|
||||
if [ "$FAILED_COUNT" = "0" ] && [ "$PASSED_COUNT" -gt 0 ]; then
|
||||
TEST_EXIT=0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
set -e
|
||||
echo "$RUN_RAW" | grep -iE "Passed!|Failed!" | tail -1
|
||||
# `pipefail` is on: a grep that finds no summary would fail the whole pipeline and kill the gate even
|
||||
# though the per-test fallback above already decided. Hence the explicit `|| true`.
|
||||
echo "$RUN_RAW" | grep -iE "Passed!|Failed!" | tail -1 || true
|
||||
|
||||
# Known pre-existing failures (see HANDOFF.md: do not chase). Anything beyond
|
||||
# these is a real regression and fails the gate.
|
||||
|
||||
@@ -141,6 +141,11 @@ public sealed class AboutHubTests
|
||||
[Fact]
|
||||
public void Free_user_gets_1080p30_and_cannot_select_or_change_to_60fps()
|
||||
{
|
||||
// Coverage of the boundary once it is enforced. While the pre-release switch is off
|
||||
// (OutputProfile.EntitlementEnforced == false) no build is limited, so there is nothing here
|
||||
// to assert; that state is covered by EntitlementTests.
|
||||
if (!OutputProfile.EntitlementEnforced) return;
|
||||
|
||||
_app.Run(() =>
|
||||
{
|
||||
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"llamacasty-upgrade-{Guid.NewGuid():N}.db");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
@@ -126,10 +127,63 @@ public sealed class AttentionAlertStackTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecoveryReportAndStop_FromWorkerThread_MarshalAlertChangesToDispatcher()
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-alerts-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
MainViewModel? vm = null;
|
||||
try
|
||||
{
|
||||
await _app.RunAsync(async () =>
|
||||
{
|
||||
vm = new MainViewModel();
|
||||
var dispatcher = System.Windows.Application.Current!.Dispatcher;
|
||||
var changedOnUi = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
vm.AttentionAlerts.CollectionChanged += (_, _) =>
|
||||
changedOnUi.TrySetResult(dispatcher.CheckAccess());
|
||||
|
||||
await Task.Run(() => vm.ReportRecoveryStatus(
|
||||
"Stream dropped — reconnecting (attempt 1 of 4)", 10));
|
||||
Assert.True(await changedOnUi.Task.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
|
||||
var stopped = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
vm.PropertyChanged += (_, args) =>
|
||||
{
|
||||
if (args.PropertyName == nameof(vm.StreamStatus) && vm.IsOffline)
|
||||
stopped.TrySetResult(dispatcher.CheckAccess());
|
||||
};
|
||||
vm.StreamStatus = ytLive.Models.StreamStatus.Streaming;
|
||||
|
||||
await Task.Run(() => vm.EndStreamCommand.Execute(null));
|
||||
Assert.True(await stopped.Task.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
Assert.DoesNotContain(vm.AttentionAlerts, alert => alert.Id == "stream-recovery");
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (vm != null)
|
||||
await _app.RunAsync(() =>
|
||||
{
|
||||
vm.Shutdown();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void WithVm(Action<MainViewModel> body) => WithVm((vm, _) => body(vm));
|
||||
|
||||
private void WithVm(Action<MainViewModel, string> body)
|
||||
{
|
||||
// Every case here hangs off the pinned licence row, and that row exists only while the
|
||||
// frame-rate boundary is enforced — pre-release nothing is limited, so it is deliberately
|
||||
// absent. The suite goes dark until launch; the switch-off state is covered by
|
||||
// EntitlementTests.
|
||||
if (!OutputProfile.EntitlementEnforced) return;
|
||||
|
||||
_app.Run(() =>
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-alerts-{Guid.NewGuid():N}.db");
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
using ytLive.Services.Audio;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The mix's sample count comes from the clock, not from a tick count — the fix for the 2026-11-12
|
||||
/// drift (video 9.888 s against audio 10.112 s on a 9.9 s recording: the audio ran 2.1% long and the
|
||||
/// sound slid ~200 ms behind the lips by the end of a ten-second clip).
|
||||
/// </summary>
|
||||
public class AudioClockTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_mix_follows_the_wall_clock_even_when_the_tick_runs_short()
|
||||
{
|
||||
const int rate = 48000;
|
||||
const int maxChunk = rate / 5;
|
||||
|
||||
// The measured scheduler: iterations land every 9.8 ms, not the requested 10 ms.
|
||||
var tickSeconds = 0.0098;
|
||||
|
||||
long produced = 0;
|
||||
long naive = 0;
|
||||
var ticks = 0;
|
||||
var elapsed = 0.0;
|
||||
while (elapsed < 10.0)
|
||||
{
|
||||
var frames = AudioClock.FramesDue(elapsed, produced, rate, maxChunk);
|
||||
produced += frames;
|
||||
naive += (int)Math.Round(rate * 0.010); // what the old "480 per tick" loop wrote
|
||||
ticks++;
|
||||
elapsed += tickSeconds;
|
||||
}
|
||||
|
||||
var expected = (long)Math.Round(elapsed * rate);
|
||||
|
||||
// The old loop drifted by the tick-rate error — about 2% of ten seconds — and kept drifting.
|
||||
Assert.True(naive - expected > rate * 0.01,
|
||||
$"the fixed-chunk loop should reproduce the drift (naive {naive} vs expected {expected})");
|
||||
|
||||
// Asking the clock keeps the produced count on the clock, within one chunk of payment.
|
||||
Assert.True(Math.Abs(produced - expected) <= maxChunk,
|
||||
$"clock-driven count must track the clock (produced {produced} vs expected {expected})");
|
||||
|
||||
// And a stalled loop pays its debt off gradually rather than dumping it in one write.
|
||||
Assert.Equal(maxChunk, AudioClock.FramesDue(5.0, 0, rate, maxChunk));
|
||||
Assert.Equal(0, AudioClock.FramesDue(5.0, rate * 5, rate, maxChunk));
|
||||
|
||||
// Audio that never reached the encoder stays owed. Counting it would shorten the track by the
|
||||
// start-up gap — the mirror image of the drift this clock exists to prevent (27 dropped start-up
|
||||
// writes made the first clock-driven recording 226 ms short).
|
||||
var produced2 = 0L;
|
||||
for (var i = 0; i < 27; i++)
|
||||
produced2 = AudioClock.Advance(produced2, 480, wrote: false);
|
||||
Assert.Equal(0, produced2);
|
||||
Assert.Equal(480, AudioClock.Advance(produced2, 480, wrote: true));
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,19 @@ public class AudioSyncDelayTests
|
||||
Assert.Equal(960, foundAt); // same 10ms-delay shift as the single-Configure test
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AMonoSourceIsDelayedInFrames_NotInDoubledSamples()
|
||||
{
|
||||
// 48 kHz, 175 ms = 8400 frames. For the microphone (mono) that is 8400 samples…
|
||||
var mic = new AudioSyncDelay(48000, channels: 1);
|
||||
Assert.Equal(8400, MarkerShift(mic, 9600, configureMs: 175));
|
||||
|
||||
// …while the same class in its stereo role, guarding the whole mix, counts frames in pairs:
|
||||
// the same 175 ms must still be 175 ms, not 87.5.
|
||||
var mix = new AudioSyncDelay(48000);
|
||||
Assert.Equal(16800, MarkerShift(mix, 19200, configureMs: 175));
|
||||
}
|
||||
|
||||
private static int MarkerShift(AudioSyncDelay delay, int chunkLen, int configureMs = -1)
|
||||
{
|
||||
if (configureMs >= 0)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Sync offsets ship at zero. A correction belongs to one camera on one desk, so a fresh profile must
|
||||
/// not arrive with audio already shifted — a non-zero default would move every creator's voice to fix
|
||||
/// one machine and would hide whether the fault is theirs or ours.
|
||||
/// </summary>
|
||||
// The view model below touches the static LayoutPathOverride, so it shares the layout collection.
|
||||
[Collection("MainViewModelLayoutPath")]
|
||||
public sealed class AudioSyncSettingsTests : IDisposable
|
||||
{
|
||||
private readonly string _db = Path.Combine(Path.GetTempPath(), $"ytLlive-syncsettings-{Guid.NewGuid():N}.db");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(_db); } catch (IOException) { /* best effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Both_offsets_start_at_zero_and_only_then_take_a_value()
|
||||
{
|
||||
using (var store = new LayoutStore(_db))
|
||||
{
|
||||
Assert.Equal(0, store.LoadMicSyncOffsetMs()); // opt-in, not pre-applied
|
||||
Assert.Equal(0, store.LoadAudioSyncOffsetMs());
|
||||
|
||||
// A mic delay cannot be negative — advancing is the global offset's job.
|
||||
store.SaveMicSyncOffsetMs(-120);
|
||||
Assert.Equal(0, store.LoadMicSyncOffsetMs());
|
||||
|
||||
store.SaveMicSyncOffsetMs(600); // beyond the slider's range
|
||||
Assert.Equal(500, store.LoadMicSyncOffsetMs());
|
||||
|
||||
store.SaveMicSyncOffsetMs(240);
|
||||
store.SaveAudioSyncOffsetMs(-130);
|
||||
}
|
||||
|
||||
using var reopened = new LayoutStore(_db);
|
||||
Assert.Equal(240, reopened.LoadMicSyncOffsetMs());
|
||||
Assert.Equal(-130, reopened.LoadAudioSyncOffsetMs());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A slider move is persisted as it happens. It used to ride the debounced layout save, so a value
|
||||
/// set seconds before closing the app was lost — which cost a record-and-measure cycle on 2026-11-12
|
||||
/// when a −130 ms offset vanished between two clips.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_slider_move_survives_without_any_debounce_flush()
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = _db;
|
||||
try
|
||||
{
|
||||
var vm = new MainViewModel();
|
||||
vm.AudioSyncOffsetMs = -130;
|
||||
vm.MicSyncOffsetMs = 240;
|
||||
|
||||
using var store = new LayoutStore(_db);
|
||||
Assert.Equal(-130, store.LoadAudioSyncOffsetMs());
|
||||
Assert.Equal(240, store.LoadMicSyncOffsetMs());
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -580,6 +580,8 @@ public sealed class BrandFlashOutputTests
|
||||
/// production encoder uses, so the assertion is about the bytes on the wire.</summary>
|
||||
private sealed class CapturingEncoder : IFfmpegEncoder
|
||||
{
|
||||
public string? VideoEncoderName => "h264_nvenc";
|
||||
|
||||
public readonly List<VideoFrame> Frames = new();
|
||||
public readonly TaskCompletionSource FirstFrame =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Editing during a session is the creator's call (ruling 2026-11-12: moving a layer or resizing a
|
||||
/// webcam mid-show is theirs to do). The setting decides whether go-live still flips EDIT off.
|
||||
/// </summary>
|
||||
[Collection("MainViewModelLayoutPath")]
|
||||
public class EditWhileLiveTests
|
||||
{
|
||||
private static MainViewModel NewVm(string db, out Action cleanup)
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = db;
|
||||
cleanup = () =>
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
try { File.Delete(db); } catch (IOException) { }
|
||||
};
|
||||
return new MainViewModel();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Editing_stays_available_during_a_session_unless_the_creator_locked_it()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), $"llamacasty-editlock-{Guid.NewGuid():N}.db");
|
||||
var vm = NewVm(db, out var cleanup);
|
||||
try
|
||||
{
|
||||
Assert.False(vm.LockEditWhileLive); // unlocked ships as the default
|
||||
Assert.True(vm.IsEditMode);
|
||||
|
||||
// Unlocked: a session neither takes EDIT away nor gives it back.
|
||||
vm.EditingActive = false;
|
||||
vm.ApplyEditLockForSession(starting: true);
|
||||
Assert.False(vm.EditingActive);
|
||||
vm.EditingActive = true;
|
||||
vm.ApplyEditLockForSession(starting: true);
|
||||
Assert.True(vm.EditingActive);
|
||||
|
||||
// Locked: go-live turns EDIT off…
|
||||
vm.LockEditWhileLive = true;
|
||||
vm.ApplyEditLockForSession(starting: true);
|
||||
Assert.False(vm.EditingActive);
|
||||
Assert.False(vm.IsEditMode);
|
||||
|
||||
// …and stopping restores it, but only because the lock took it away.
|
||||
vm.ApplyEditLockForSession(starting: false);
|
||||
Assert.True(vm.EditingActive);
|
||||
Assert.True(vm.IsEditMode);
|
||||
|
||||
// The choice survives a restart: a second view model on the same profile reads it back.
|
||||
var reloaded = new MainViewModel();
|
||||
Assert.True(reloaded.LockEditWhileLive);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public sealed class EncoderRestartTests
|
||||
var tier = (Width: 1920, Height: 1080, Fps: 60, Bitrate: 8.0);
|
||||
var started = new List<EncoderOptions>();
|
||||
var frames = new List<CapturingEncoder>();
|
||||
var order = new List<string>();
|
||||
|
||||
var scene = new Scene { Name = "Live" };
|
||||
scene.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true });
|
||||
@@ -49,7 +50,12 @@ public sealed class EncoderRestartTests
|
||||
encoderFactory: () =>
|
||||
{
|
||||
var encoder = new CapturingEncoder();
|
||||
encoder.Started += options => started.Add(options);
|
||||
encoder.Started += options =>
|
||||
{
|
||||
started.Add(options);
|
||||
order.Add($"encoder-start-{options.Fps}");
|
||||
};
|
||||
encoder.Stopped = () => order.Add("encoder-stop");
|
||||
frames.Add(encoder);
|
||||
return encoder;
|
||||
},
|
||||
@@ -63,7 +69,9 @@ public sealed class EncoderRestartTests
|
||||
// options func now reports the new tier — which the restart must re-read.
|
||||
tier = (1920, 1080, 30, 6.4);
|
||||
|
||||
Assert.True(await pump.ChangeEncoderAsync());
|
||||
Assert.True(await pump.ChangeEncoderAsync(
|
||||
stopAudio: () => order.Add("audio-stop"),
|
||||
startAudio: () => order.Add("audio-start")));
|
||||
Assert.True(await frames[1].FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5)), "restarted session never produced a frame");
|
||||
|
||||
Assert.Equal(2, started.Count);
|
||||
@@ -71,14 +79,18 @@ public sealed class EncoderRestartTests
|
||||
Assert.Equal(30, started[1].Fps);
|
||||
Assert.Equal(6400, started[1].BitrateKbps);
|
||||
Assert.NotSame(frames[0], frames[1]);
|
||||
Assert.Equal(new[] { "encoder-start-60", "audio-stop", "encoder-stop", "audio-start", "encoder-start-30" }, order);
|
||||
|
||||
await pump.StopAsync();
|
||||
}
|
||||
|
||||
private sealed class CapturingEncoder : IFfmpegEncoder
|
||||
{
|
||||
public string? VideoEncoderName => "h264_nvenc";
|
||||
|
||||
public readonly TaskCompletionSource<bool> FirstFrame = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public event Action<EncoderOptions>? Started;
|
||||
public Action? Stopped { get; set; }
|
||||
|
||||
public int DroppedFrames => 0;
|
||||
|
||||
@@ -97,7 +109,11 @@ public sealed class EncoderRestartTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Stopped?.Invoke();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services.Entitlement;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
@@ -87,7 +89,12 @@ public class EntitlementTests
|
||||
|
||||
vm.EntitlementOverride = new FakeEntitlement(false);
|
||||
Assert.False(vm.IsAppOwned);
|
||||
Assert.False(vm.HasProOutput);
|
||||
// Pro output is what the frame-rate boundary answers, and while the pre-release switch is
|
||||
// off that answer is yes regardless of ownership. The rail still moves IsAppOwned itself,
|
||||
// and the paid-limit row is absent because nothing is being limited.
|
||||
Assert.Equal(!OutputProfile.EntitlementEnforced, vm.HasProOutput);
|
||||
Assert.Equal(OutputProfile.EntitlementEnforced,
|
||||
vm.AttentionAlerts.Any(alert => alert.Id == "steam-licence"));
|
||||
|
||||
vm.EntitlementOverride = new FakeEntitlement(true);
|
||||
Assert.True(vm.IsAppOwned);
|
||||
@@ -99,4 +106,36 @@ public class EntitlementTests
|
||||
try { File.Delete(tempDb); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pre-release switch is the only thing that lets an <b>unowned</b> build reach 60 fps, which
|
||||
/// is what makes Release-build performance testing possible before the paid edition ships.
|
||||
///
|
||||
/// <para>Written against both states of <see cref="OutputProfile.EntitlementEnforced"/> so
|
||||
/// flipping it at launch does not leave a red test behind: with the switch off the unowned view
|
||||
/// model keeps 1080p60; with it on the tier setter refuses it and the free tier stands.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_unowned_build_reaches_60_fps_only_while_the_gate_is_unenforced()
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-entitlement-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
try
|
||||
{
|
||||
var vm = new MainViewModel { EntitlementOverride = new FakeEntitlement(false) };
|
||||
|
||||
vm.SelectedQuality = vm.QualityOptions.Single(tier => tier.Display == "1080p60");
|
||||
|
||||
Assert.False(vm.IsAppOwned);
|
||||
|
||||
// The tier the encoder would actually be built from (MainViewModel.Streaming.Operations).
|
||||
var landed = OutputProfile.Resolve(vm.SelectedQuality, vm.QualityOptions, vm.HasProOutput);
|
||||
Assert.Equal(OutputProfile.EntitlementEnforced ? 30 : 60, landed.Fps);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
try { File.Delete(tempDb); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +316,33 @@ public class FfmpegEncoderTests
|
||||
Assert.Equal("240", args[keyint + 1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Args_AskForConstantBitrateWhereTheEncoderNeedsTelling()
|
||||
{
|
||||
var options = new EncoderOptions { RtmpUrl = "rtmp://x/y", BitrateKbps = 12000 };
|
||||
|
||||
// Both hardware encoders own a rate-control switch; without it the target is an average to
|
||||
// undershoot (measured: 9 kbps against a 2000k target on a static frame).
|
||||
foreach (var encoder in new[] { "h264_nvenc", "h264_amf" })
|
||||
{
|
||||
var args = FfmpegArgs.Build(options, encoder).ToArray();
|
||||
var rc = Array.IndexOf(args, "-rc");
|
||||
Assert.True(rc >= 0, $"{encoder} must be told the rate-control mode");
|
||||
Assert.Equal("cbr", args[rc + 1]);
|
||||
}
|
||||
|
||||
// QSV has no -rc: libavcodec/qsvenc.c selects CBR when bitrate == maxrate, so the triad alone
|
||||
// is the request. Passing -rc there would be an unknown option.
|
||||
var qsv = FfmpegArgs.Build(options, "h264_qsv").ToArray();
|
||||
Assert.DoesNotContain("-rc", qsv);
|
||||
Assert.Equal(qsv[Array.IndexOf(qsv, "-b:v") + 1], qsv[Array.IndexOf(qsv, "-maxrate") + 1]);
|
||||
|
||||
// OpenH264 gains nothing measurable from -rc_mode bitrate; it stays out of the command line.
|
||||
var openh264 = FfmpegArgs.Build(options, "libopenh264").ToArray();
|
||||
Assert.DoesNotContain("-rc", openh264);
|
||||
Assert.DoesNotContain("-rc_mode", openh264);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Args_IncludeInputOutputAndCompliance()
|
||||
{
|
||||
|
||||
@@ -22,6 +22,8 @@ public class FramePumpTests
|
||||
{
|
||||
private sealed class FakeEncoder : IFfmpegEncoder
|
||||
{
|
||||
public string? VideoEncoderName => "h264_nvenc";
|
||||
|
||||
public readonly List<VideoFrame> Frames = new();
|
||||
public readonly List<byte[]> Backings = new();
|
||||
public int StartCount;
|
||||
@@ -133,6 +135,22 @@ public class FramePumpTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_pump_reports_which_encoder_the_session_runs()
|
||||
{
|
||||
// The rate history is keyed by encoder: evidence recorded against NVENC must not be applied to a
|
||||
// machine that later runs QSV. Before this the name went to a StreamHealth copy nobody read, so
|
||||
// every filed session said "unknown".
|
||||
var encoder = new FakeEncoder();
|
||||
using var pump = NewPump(encoder);
|
||||
|
||||
Assert.Null(pump.EncoderName); // nothing has started yet
|
||||
await pump.StartAsync();
|
||||
Assert.Equal("h264_nvenc", pump.EncoderName);
|
||||
|
||||
await pump.StopAsync();
|
||||
}
|
||||
|
||||
/// <summary>The ONE integration test for TASK 5: the real service (hermetic
|
||||
/// HTTP) returns the reusable stream's RTMP URL, the provider seam hands it
|
||||
/// to the pump, and the pump starts the encoder with that exact URL — the
|
||||
|
||||
@@ -21,12 +21,12 @@ public class OutputProfileTests
|
||||
/// step deliberately: this is the table the budget has to cover.</summary>
|
||||
private static readonly QualityOption[] Shipped =
|
||||
{
|
||||
new("1080p60", 60, 8.0, 1920, 1080),
|
||||
new("1080p30", 30, 8.0, 1920, 1080),
|
||||
new("1080p60", 60, 12.0, 1920, 1080),
|
||||
new("1080p30", 30, 10.0, 1920, 1080),
|
||||
new("720p60", 60, 6.0, 1280, 720),
|
||||
new("720p30", 30, 6.0, 1280, 720),
|
||||
new("Vertical 1080p30", 30, 8.0, 1080, 1920),
|
||||
new("Vertical 1080p60", 60, 8.0, 1080, 1920),
|
||||
new("Vertical 1080p30", 30, 10.0, 1080, 1920),
|
||||
new("Vertical 1080p60", 60, 12.0, 1080, 1920),
|
||||
};
|
||||
|
||||
private static QualityOption Tier(string name) => Shipped.Single(t => t.Display == name);
|
||||
@@ -40,7 +40,7 @@ public class OutputProfileTests
|
||||
Assert.Equal(1920, resolved.Width);
|
||||
Assert.Equal(1080, resolved.Height);
|
||||
Assert.Equal(30, resolved.Fps);
|
||||
Assert.Equal(8.0, resolved.Bitrate);
|
||||
Assert.Equal(10.0, resolved.Bitrate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -77,7 +77,7 @@ public class OutputProfileTests
|
||||
Assert.Equal(1920, resolved.Width);
|
||||
Assert.Equal(1080, resolved.Height);
|
||||
Assert.Equal(30, resolved.Fps);
|
||||
Assert.Equal(8.0, resolved.Bitrate);
|
||||
Assert.Equal(10.0, resolved.Bitrate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The rate the app suggests next session, from real sessions — and the two rules that keep it from
|
||||
/// becoming a nuisance: five sessions of evidence per tier and encoder, a 10% gap before asking for
|
||||
/// more, and a lower bar for backing off.
|
||||
/// </summary>
|
||||
public class RateHistoryTests
|
||||
{
|
||||
private static SessionRateResult Session(
|
||||
double rated, double settled = -1, int steps = 0, string tier = "1080p60", string encoder = "h264_nvenc")
|
||||
=> new(tier, encoder, rated, settled < 0 ? rated : settled, steps);
|
||||
|
||||
[Fact]
|
||||
public void Suggestions_need_five_sessions_of_the_same_tier_and_encoder_and_persist_with_them()
|
||||
{
|
||||
// Four sessions is not evidence.
|
||||
Assert.Null(RateHistory.Suggest(
|
||||
Enumerable.Range(0, 4).Select(_ => Session(15)), "1080p60", "h264_nvenc", 12));
|
||||
|
||||
// Five clean sessions holding 15 against a 12 target: worth asking, and the ask is 10% up.
|
||||
var raised = RateHistory.Suggest(
|
||||
Enumerable.Range(0, 5).Select(_ => Session(15)), "1080p60", "h264_nvenc", 12);
|
||||
Assert.NotNull(raised);
|
||||
Assert.True(raised!.Value.Raise);
|
||||
Assert.Equal(15, raised.Value.Mbps);
|
||||
|
||||
// The same evidence means nothing below the threshold (12 → 12.6 is under 10%).
|
||||
Assert.Null(RateHistory.Suggest(
|
||||
Enumerable.Range(0, 5).Select(_ => Session(13)), "1080p60", "h264_nvenc", 12));
|
||||
|
||||
// A different encoder is a different machine: no suggestion.
|
||||
Assert.Null(RateHistory.Suggest(
|
||||
Enumerable.Range(0, 5).Select(_ => Session(15)), "1080p60", "h264_qsv", 12));
|
||||
|
||||
// Three step-downs out of five asks to back off to the settled median, faster than it asks to
|
||||
// climb — fixing damage should not wait for a 10% gap.
|
||||
var lowered = RateHistory.Suggest(
|
||||
new[]
|
||||
{
|
||||
Session(12, 9.6, 1), Session(12, 8.0, 2), Session(12), Session(12, 7.2, 1), Session(12),
|
||||
},
|
||||
"1080p60", "h264_nvenc", 12);
|
||||
Assert.NotNull(lowered);
|
||||
Assert.False(lowered!.Value.Raise);
|
||||
Assert.Equal(9.6, lowered.Value.Mbps); // median of 9.6, 8.0, 12, 7.2, 12
|
||||
|
||||
// And the storage form survives a round trip through the settings row, which is where the
|
||||
// evidence actually lives between runs.
|
||||
var db = Path.Combine(Path.GetTempPath(), $"llamacasty-rate-history-{Guid.NewGuid():N}.db");
|
||||
using (var store = new LayoutStore(db))
|
||||
{
|
||||
foreach (var entry in Enumerable.Range(0, 5).Select(_ => Session(15)))
|
||||
store.AppendRateHistory(entry);
|
||||
|
||||
var reloaded = store.LoadRateHistory();
|
||||
Assert.Equal(5, reloaded.Count);
|
||||
Assert.Equal(Session(15), reloaded[0]);
|
||||
Assert.Equal(15, RateHistory.Suggest(reloaded, "1080p60", "h264_nvenc", 12)!.Value.Mbps);
|
||||
}
|
||||
try { File.Delete(db); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The creator's own video rate: per tier, clamped at the tier's YouTube minimum, persisted across
|
||||
/// runs, and substituted into every consumer that builds encoder options.
|
||||
///
|
||||
/// <para>The rule that must never break: whatever the creator typed is what the encoder is asked to
|
||||
/// hold, and no tier's minimum is quietly undercut.</para>
|
||||
/// </summary>
|
||||
[Collection("MainViewModelLayoutPath")]
|
||||
public class RatedBitrateTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_creator_rate_is_per_tier_clamped_to_the_minimum_and_restored()
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-rate-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
try
|
||||
{
|
||||
var vm = new MainViewModel();
|
||||
var tier60 = vm.QualityOptions.Single(tier => tier.Display == "1080p60");
|
||||
var tier30 = vm.QualityOptions.Single(tier => tier.Display == "1080p30");
|
||||
vm.SelectedQuality = tier60;
|
||||
|
||||
// Unset means the shipped default, not zero.
|
||||
Assert.Equal(tier60.Bitrate, vm.RatedMbps);
|
||||
|
||||
// A creator who knows their line sets their own number, and the encoder sees it.
|
||||
vm.RatedMbps = 15;
|
||||
Assert.Equal(15, vm.RatedMbps);
|
||||
Assert.Equal(15, vm.EffectiveQuality(tier60).Bitrate);
|
||||
Assert.Equal(60, vm.EffectiveQuality(tier60).Fps); // geometry stays the tier's
|
||||
|
||||
// Below YouTube's minimum for the tier is not a legal target.
|
||||
vm.RatedMbps = 2;
|
||||
Assert.Equal(RateLadder.For(tier60).Minimum, vm.RatedMbps);
|
||||
|
||||
// The other tier keeps its own value — one setting does not leak into the ladder.
|
||||
vm.SelectedQuality = tier30;
|
||||
Assert.Equal(tier30.Bitrate, vm.RatedMbps);
|
||||
vm.SelectedQuality = tier60;
|
||||
|
||||
// And it survives a restart: the same profile, a fresh view model.
|
||||
var reloaded = new MainViewModel();
|
||||
reloaded.SelectedQuality = reloaded.QualityOptions.Single(tier => tier.Display == "1080p60");
|
||||
Assert.Equal(RateLadder.For(tier60).Minimum, reloaded.RatedMbps);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
try { File.Delete(tempDb); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The suggestion the creator is asked about before a stream, and the refusal that stops it coming
|
||||
/// back: raising is proposed only from five clean sessions above the 10% gap, applied only on a
|
||||
/// click, and "keep mine" suppresses that exact number for the tier.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_suggested_rate_is_offered_between_sessions_and_only_a_click_applies_it()
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-rate-proposal-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
try
|
||||
{
|
||||
using (var seed = new LayoutStore(tempDb))
|
||||
{
|
||||
for (var i = 0; i < 5; i++)
|
||||
seed.AppendRateHistory(new SessionRateResult("1080p60", "h264_nvenc", 15.0, 15.0, 0));
|
||||
Assert.Equal(5, seed.LoadRateHistory().Count);
|
||||
}
|
||||
|
||||
var vm = new MainViewModel();
|
||||
vm.SelectedQuality = vm.QualityOptions.Single(tier => tier.Display == "1080p60");
|
||||
|
||||
var historyCount = new LayoutStore(tempDb).LoadRateHistory().Count;
|
||||
|
||||
// Offered, not applied: the rate is still the shipped default until the creator agrees.
|
||||
Assert.True(vm.HasRateProposal, $"history={historyCount}");
|
||||
Assert.Contains("15", vm.RateProposalText);
|
||||
Assert.Equal(12.0, vm.RatedMbps);
|
||||
|
||||
vm.AcceptRateProposalCommand.Execute(null);
|
||||
Assert.Equal(15.0, vm.RatedMbps);
|
||||
Assert.False(vm.HasRateProposal);
|
||||
|
||||
// Honest evidence now says 15 is what this line holds, so the same question stops being
|
||||
// asked: 15 is no longer 10% above the rate in use.
|
||||
var second = new MainViewModel();
|
||||
second.SelectedQuality = second.QualityOptions.Single(tier => tier.Display == "1080p60");
|
||||
Assert.False(second.HasRateProposal);
|
||||
|
||||
// Back to 12 by hand: the evidence qualifies again, and this time the creator says no.
|
||||
second.RatedMbps = 12;
|
||||
second.SelectedQuality = second.QualityOptions.Single(tier => tier.Display == "1080p30");
|
||||
second.SelectedQuality = second.QualityOptions.Single(tier => tier.Display == "1080p60");
|
||||
Assert.True(second.HasRateProposal);
|
||||
|
||||
second.DismissRateProposalCommand.Execute(null);
|
||||
Assert.False(second.HasRateProposal);
|
||||
Assert.Equal(12.0, second.RatedMbps);
|
||||
|
||||
// Refusal is remembered against that number, so a fresh run stays quiet.
|
||||
var third = new MainViewModel();
|
||||
third.SelectedQuality = third.QualityOptions.Single(tier => tier.Display == "1080p60");
|
||||
Assert.False(third.HasRateProposal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
try { File.Delete(tempDb); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,8 +64,10 @@ public sealed class RealAppHost : IDisposable
|
||||
failure = ex;
|
||||
}
|
||||
});
|
||||
// Rethrow preserving the ORIGINAL stack: `throw failure;` reset it to this line, which hid
|
||||
// which assertion actually failed inside the passed action.
|
||||
if (failure != null)
|
||||
throw failure;
|
||||
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw();
|
||||
}
|
||||
|
||||
/// <summary>Awaits <paramref name="action"/> on the App's STA thread and rethrows
|
||||
|
||||
@@ -3,27 +3,22 @@ using ytLive.Models;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>Creator ruling (2026-10-30): the top bar's primary button is context-sensitive and the
|
||||
/// separate TEST button is gone.
|
||||
/// <summary>Which session an armed output starts, and where a press routes.
|
||||
///
|
||||
/// <para>EDIT off + ON-AIR → the livestream message. EDIT on + ON-AIR → the test-livestream
|
||||
/// message. REC always records, because there is no test recording. The matrix is pure, so it is
|
||||
/// cheaper to pin it here than to drive a window.</para></summary>
|
||||
/// <para>Superseded 2026-11-12: the ON-AIR start no longer infers Test from EDIT. The output stage
|
||||
/// offers "Private Test Stream" and "Review & Go Live" as separate buttons, so ON-AIR always means the
|
||||
/// real livestream here. REC always records, because there is no test recording.</para></summary>
|
||||
public class SessionStartDecisionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Edit_decides_whether_the_on_air_start_is_live_or_a_test()
|
||||
public void The_armed_output_decides_the_session_kind_and_test_is_never_inferred()
|
||||
{
|
||||
// ON-AIR armed: EDIT decides.
|
||||
Assert.Equal(SessionStartKind.Live, SessionStartDecision.For(recordArmed: false, onAirArmed: true, editing: false));
|
||||
// ON-AIR armed: the real livestream, never a test.
|
||||
Assert.Equal(SessionStartKind.Live, SessionStartDecision.For(recordArmed: false, onAirArmed: true));
|
||||
Assert.Equal("Go Live", SessionStartDecision.LabelFor(SessionStartKind.Live));
|
||||
|
||||
Assert.Equal(SessionStartKind.Test, SessionStartDecision.For(recordArmed: false, onAirArmed: true, editing: true));
|
||||
Assert.Equal("Test Stream", SessionStartDecision.LabelFor(SessionStartKind.Test));
|
||||
|
||||
// REC armed: always a recording, whatever EDIT says (no test recording exists).
|
||||
Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(true, false, false));
|
||||
Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(true, false, true));
|
||||
// REC armed: always a recording (no test recording exists).
|
||||
Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(true, false));
|
||||
Assert.Equal("Start Recording", SessionStartDecision.LabelFor(SessionStartKind.Record));
|
||||
|
||||
// Signed out: an ON-AIR press (live OR test) routes to the sign-in flow, never a dead
|
||||
@@ -35,6 +30,6 @@ public class SessionStartDecisionTests
|
||||
Assert.Equal(SessionStartDecision.Route.Record, SessionStartDecision.RouteFor(SessionStartKind.Record, connected: false));
|
||||
|
||||
// Unarmed start records locally (the no-dead-ends rule).
|
||||
Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(false, false, false));
|
||||
Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(false, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,26 @@ public sealed class StepDownPolicyTests
|
||||
Assert.Equal(1920, policy.Width);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UploadStep_StopsAtTheTiersYoutubeMinimumWhenOneIsGiven()
|
||||
{
|
||||
// 1080p60: YouTube's floor is 6 Mbps, not the historical 3. From 8 the ladder reaches it in two
|
||||
// steps (8 → 6.4 → 6.0) and must then hold rather than cut into the minimum.
|
||||
var floor = RateLadder.For(1920, 1080, 60).Minimum;
|
||||
Assert.Equal(6.0, floor);
|
||||
|
||||
var policy = new StepDownPolicy(1920, 1080, 60, 8.0, floorBitrateMbps: floor);
|
||||
var t = 0.0;
|
||||
|
||||
for (var i = 0; i < 12; i++)
|
||||
{
|
||||
t += 40;
|
||||
policy.Observe(Upload(encoderDrops: 60), TimeSpan.FromSeconds(t)); // 20% loss
|
||||
}
|
||||
|
||||
Assert.Equal(6.0, policy.BitrateMbps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothKindsOfEvidenceInOneWindow_DoesNothing()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Notification.Core;
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
[Collection("RealApp")]
|
||||
public sealed class StickyNotificationIntegrationTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public StickyNotificationIntegrationTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void StickyMode_Persists_AndMakesEverySeverityClickDismissible() => _app.Run(() =>
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-sticky-toast-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
MainWindow? window = null;
|
||||
try
|
||||
{
|
||||
window = new MainWindow();
|
||||
window.Show();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
Assert.False(vm.StickyAlertsUntilDismissed, "sticky notifications should default off");
|
||||
vm.StickyAlertsUntilDismissed = true;
|
||||
|
||||
window.Close();
|
||||
window = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
window = new MainWindow();
|
||||
window.Show();
|
||||
var restored = (MainViewModel)window.DataContext;
|
||||
Assert.True(restored.StickyAlertsUntilDismissed);
|
||||
|
||||
var service = new NotificationService(System.Windows.Application.Current?.Dispatcher,
|
||||
() => restored.StickyAlertsUntilDismissed);
|
||||
var sticky = service.BuildRequestForCurrentSettings("Upload", "Recovered", AppNotificationSeverity.Info);
|
||||
Assert.Equal(TimeSpan.MaxValue, sticky.ExpirationTime);
|
||||
Assert.True(sticky.CloseOnClick);
|
||||
|
||||
restored.StickyAlertsUntilDismissed = false;
|
||||
var transient = service.BuildRequestForCurrentSettings("Upload", "Recovered", AppNotificationSeverity.Info);
|
||||
Assert.Equal(TimeSpan.FromSeconds(4), transient.ExpirationTime);
|
||||
Assert.False(transient.CloseOnClick, "disabling sticky mode restores timed, non-click-dismissable alerts");
|
||||
|
||||
var settings = SourceAssert.Text("Controls", "SettingsDrawer.xaml");
|
||||
Assert.Contains("Content=\"Keep alerts until dismissed\"", settings);
|
||||
Assert.Contains("Binding StickyAlertsUntilDismissed", settings);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window?.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Notification.Core;
|
||||
using Notification.Wpf.Controls;
|
||||
using Xunit;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
[Collection("RealApp")]
|
||||
public sealed class ToastPositionIntegrationTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public ToastPositionIntegrationTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void GlobalPosition_UpdatesToastArea_AndPersistsAcrossRestart() => _app.Run(() =>
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-toast-position-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
MainWindow? window = null;
|
||||
try
|
||||
{
|
||||
window = new MainWindow();
|
||||
window.Show();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
var area = Assert.IsType<NotificationArea>(window.FindName("ToastArea"));
|
||||
|
||||
Assert.Equal(NotificationPosition.BottomRight, vm.ToastPosition);
|
||||
Assert.Equal(NotificationPosition.BottomRight, area.Position);
|
||||
|
||||
vm.ToastPosition = NotificationPosition.CenterLeft;
|
||||
Assert.Equal(NotificationPosition.CenterLeft, area.Position);
|
||||
|
||||
window.Close();
|
||||
window = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
window = new MainWindow();
|
||||
window.Show();
|
||||
var restoredVm = (MainViewModel)window.DataContext;
|
||||
var restoredArea = Assert.IsType<NotificationArea>(window.FindName("ToastArea"));
|
||||
Assert.Equal(NotificationPosition.CenterLeft, restoredVm.ToastPosition);
|
||||
Assert.Equal(NotificationPosition.CenterLeft, restoredArea.Position);
|
||||
|
||||
var settings = SourceAssert.Text("Controls", "SettingsDrawer.xaml");
|
||||
Assert.Contains("Text=\"Slide-out alerts\"", settings);
|
||||
Assert.Contains("Content=\"Center left\"", settings);
|
||||
Assert.Contains("Content=\"Bottom right\"", settings);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window?.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -84,16 +84,19 @@ public sealed class TopBarModeTests
|
||||
Assert.False(vm.ShowTestButton);
|
||||
Assert.Equal("Start Recording", vm.PrimaryStartButtonLabel);
|
||||
|
||||
// Running retires the switch and worlds; the reality line pins the mode.
|
||||
vm.RecordPillOn = false;
|
||||
vm.OnAirPillOn = true;
|
||||
vm.IsRecording = true;
|
||||
Assert.False(vm.ShowIdleWorld);
|
||||
Assert.True(vm.RunningVisible);
|
||||
Assert.Equal("REC", vm.RunningText);
|
||||
Assert.Equal("#22c55e", vm.RunningDotBrush);
|
||||
vm.IsRecording = false;
|
||||
// A local recording lives in the Record activity (2026-11-13): the bar keeps its three
|
||||
// buttons and the Record one carries REC + the clock + Stop, so the separate reality line
|
||||
// stays away — it would be a second set of the same facts.
|
||||
vm.OpenRecordCommand.Execute(null);
|
||||
vm.StartRecordingCommand.Execute(null);
|
||||
Assert.True(vm.IsRecordRecording);
|
||||
Assert.False(vm.RunningVisible);
|
||||
Assert.StartsWith("REC: ", vm.RecordElapsedLabel);
|
||||
vm.StopRecordingCommand.Execute(null);
|
||||
|
||||
// A live stream is the YouTube slot's flow; the reality line pins the mode.
|
||||
vm.StreamStatus = StreamStatus.Streaming;
|
||||
Assert.True(vm.RunningVisible);
|
||||
Assert.Equal("LIVE", vm.RunningText);
|
||||
Assert.Equal("#ff5451", vm.RunningDotBrush);
|
||||
vm.IsTestStream = true;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The bar is three FIXED SLOTS (creator, 2026-11-13). Each slot owns its own flow and mutates only its
|
||||
/// own interior, so neighbouring buttons never move; and while either action slot is past Idle, Build
|
||||
/// Scenes is locked.
|
||||
/// </summary>
|
||||
[Collection("MainViewModelLayoutPath")]
|
||||
public class TopBarSlotTests
|
||||
{
|
||||
[Fact]
|
||||
public void Each_slot_runs_its_own_flow_in_place_and_build_locks_while_they_do()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), $"llamacasty-slots-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = db;
|
||||
try
|
||||
{
|
||||
var vm = new MainViewModel();
|
||||
|
||||
// All three idle: YouTube and Record are single buttons, Build is live.
|
||||
Assert.True(vm.IsYouTubeIdle);
|
||||
Assert.True(vm.IsRecordIdle);
|
||||
Assert.True(vm.IsBuildScenesEnabled);
|
||||
Assert.Contains("Edit layers", vm.BuildScenesTooltip);
|
||||
|
||||
// YouTube: idle -> choose privacy -> armed -> (start) streaming.
|
||||
vm.OpenYouTubeCommand.Execute(null);
|
||||
Assert.True(vm.IsYouTubeChoosingPrivacy);
|
||||
Assert.False(vm.IsBuildScenesEnabled);
|
||||
Assert.Contains("locked", vm.BuildScenesTooltip);
|
||||
|
||||
vm.ChoosePrivateCommand.Execute(null);
|
||||
Assert.True(vm.IsYouTubeArmed);
|
||||
Assert.Equal("Start Private", vm.YouTubeArmLabel);
|
||||
|
||||
vm.ChooseLiveCommand.Execute(null); // switching choice while armed is allowed
|
||||
Assert.Equal("Start Live", vm.YouTubeArmLabel);
|
||||
|
||||
// Cancel returns that slot to Idle — and Build unlocks because both slots are idle again.
|
||||
vm.CancelYouTubeCommand.Execute(null);
|
||||
Assert.True(vm.IsYouTubeIdle);
|
||||
Assert.True(vm.IsBuildScenesEnabled);
|
||||
|
||||
// Record: idle -> armed -> (start) recording, with the clock carrying REC.
|
||||
vm.OpenRecordCommand.Execute(null);
|
||||
Assert.True(vm.IsRecordArmed);
|
||||
Assert.False(vm.IsBuildScenesEnabled);
|
||||
|
||||
vm.StartRecordingCommand.Execute(null);
|
||||
Assert.True(vm.IsRecordRecording);
|
||||
Assert.StartsWith("REC: ", vm.RecordElapsedLabel);
|
||||
|
||||
vm.StopRecordingCommand.Execute(null);
|
||||
Assert.True(vm.IsRecordIdle);
|
||||
Assert.True(vm.IsBuildScenesEnabled);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
try { File.Delete(db); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,38 @@ public class UiShellTests
|
||||
Assert.Contains("Brush.Youtube", tokens);
|
||||
Assert.Equal(2, Count(preview, "Background=\"{StaticResource Brush.Youtube}\""));
|
||||
|
||||
// 2. EDIT: resting face differs from the armed face, and the label follows it.
|
||||
Assert.Contains("x:Name=\"EditBody\" Background=\"{StaticResource Brush.SurfaceContainerHigh}\"", topBar);
|
||||
Assert.Contains("<Setter TargetName=\"EditBody\" Property=\"Background\" Value=\"{StaticResource Brush.Tertiary}\"/>", topBar);
|
||||
// The label is white in BOTH states — only the fill changes. A muted resting colour is
|
||||
// what made it illegible on the dark bar (creator, 2026-10-30).
|
||||
Assert.Contains("FontSize=\"12\" Foreground=\"{StaticResource Brush.OnAccent}\"", topBar);
|
||||
// 2. The world segment replaced the EDIT toggle (creator ruling 2026-11-12): both halves are
|
||||
// present, each lit by its own world flag through the shared style.
|
||||
Assert.Contains("Text=\"Build Scenes\"", topBar);
|
||||
Assert.Contains("Text=\"YouTube\"", topBar);
|
||||
Assert.Contains("Text=\"Record\"", topBar);
|
||||
Assert.Contains("IsEnabled=\"{Binding IsBuildScenesEnabled}\"", topBar);
|
||||
Assert.Contains("Command=\"{Binding OpenYouTubeCommand}\"", topBar);
|
||||
Assert.DoesNotContain("x:Name=\"EditBody\"", topBar);
|
||||
|
||||
// 2b. The segment is Build-world only: choosing Record / Go Live REPLACES it with the output
|
||||
// line (creator ruling 2026-11-12), and a chevron is the way back.
|
||||
Assert.Contains("Data=\"M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z\"", topBar);
|
||||
// Segmented switcher (creator mock 2026-11-12): pill track, two equal halves, coral active icon.
|
||||
Assert.Contains("Brush.SegmentTrack", topBar);
|
||||
Assert.Contains("Style=\"{StaticResource SegmentHalf}\"", topBar);
|
||||
Assert.Contains("Style=\"{StaticResource SegmentIcon}\"", topBar);
|
||||
|
||||
|
||||
|
||||
Assert.DoesNotContain("ToolTip=\"Back to Build Scenes\"", topBar); // Esc replaced the chevron
|
||||
|
||||
// 2c. The output stage: two labelled switches and the exclusivity promise beside them.
|
||||
|
||||
Assert.Contains("Text=\"YouTube\"", topBar);
|
||||
Assert.Contains("Command=\"{Binding OpenYouTubeCommand}\"", topBar);
|
||||
Assert.Contains("Command=\"{Binding OpenRecordCommand}\"", topBar);
|
||||
|
||||
// 2d. The bar carries NO action button: the output line is the bar's whole middle
|
||||
// (creator ruling 2026-11-12 — the action is placed elsewhere, on instruction).
|
||||
Assert.DoesNotContain("Content=\"Start Recording\"", topBar);
|
||||
Assert.DoesNotContain("Content=\"Private Test Stream\"", topBar);
|
||||
Assert.DoesNotContain("Content=\"Review & Go Live\"", topBar);
|
||||
|
||||
// 3. Active scene = orange; live stays red.
|
||||
Assert.Contains("IsStaged}\" Value=\"True\">\n <Setter Property=\"BorderBrush\" Value=\"{StaticResource Brush.Attention}\"/>", strip);
|
||||
@@ -49,7 +75,7 @@ public class UiShellTests
|
||||
pill = pill[..pill.IndexOf("</Style>", System.StringComparison.Ordinal)];
|
||||
Assert.Contains("<Setter Property=\"Foreground\" Value=\"{StaticResource Brush.OnAccent}\"/>", pill);
|
||||
Assert.Contains("Brush.Primary", theme);
|
||||
Assert.Contains("Style=\"{StaticResource RecordPillToggle}\"", topBar);
|
||||
Assert.Contains("Style=\"{StaticResource OutputChip}\"", topBar);
|
||||
Assert.Contains("Background=\"{StaticResource Brush.Primary}\" Margin=\"14,0,0,0\"", topBar);
|
||||
Assert.DoesNotContain("Background=\"#333\"", topBar);
|
||||
|
||||
@@ -91,11 +117,12 @@ public class UiShellTests
|
||||
Assert.Contains("MouseLeftButtonUp=\"SettingsTab_Click\"", gear);
|
||||
Assert.DoesNotContain("ToolTip=\"Settings\"", topBar);
|
||||
|
||||
// EDIT stays in the left brand cluster, before the centre session cluster.
|
||||
var edit = topBar.IndexOf("EditingActive", System.StringComparison.Ordinal);
|
||||
// The world segment leads the centre cluster; the brand column carries no edit control.
|
||||
var centre = topBar.IndexOf("<StackPanel Grid.Column=\"1\"", System.StringComparison.Ordinal);
|
||||
Assert.True(edit > 0 && centre > edit,
|
||||
"the EDIT toggle must appear before the centre (session) cluster");
|
||||
var segment = topBar.IndexOf("Text=\"Build Scenes\"", System.StringComparison.Ordinal);
|
||||
Assert.True(centre > 0 && segment > centre,
|
||||
"the three-activity segment must lead the centre cluster");
|
||||
Assert.DoesNotContain("x:Name=\"EditBody\"", topBar);
|
||||
}
|
||||
|
||||
private static int Count(string haystack, string needle)
|
||||
|
||||
Reference in New Issue
Block a user