feat(alerts): sound byte under Built-in video, and alert audio that reaches the device

Creator request: a sound-byte browse + label under Built-in video, with an option to loop the
sound while the clip plays. The video itself is always one pass to completion.

- Source gains AlertAudioPath and AlertAudioLoop, persisted (column + migration + INSERT +
  SELECT + reader, the whole chain — that chain is where the blur crashed once).
- The sound byte plays through MediaPlayer on the default output device; MediaEnded re-seeks to
  zero while looping, and StopClip kills it so a tail cannot bleed into the next alert.
- The clip's OWN soundtrack moved off the mixer and onto the device as well
  (Services/Audio/AlertAudioOutput: one WaveOutEvent + buffered float provider).

Why the move: the creator asked that alert audio — the clip's track or the added sound byte —
appear in the desktop sound bar. Only audio that reaches the default device is picked up by the
WASAPI loopback that feeds both the meter and the stream. The old EnqueueAlertAudio route put the
sound in the stream but left the meter flat and the room silent.

Tests: the sound byte and its loop flag round-trip through SQLite, and a fresh alert layer starts
with no sound byte and no loop. Release 0 warnings.
This commit is contained in:
2026-11-01 14:13:57 -08:00
parent 46d12e5873
commit b00bf48b9d
11 changed files with 287 additions and 5 deletions
+21
View File
@@ -780,6 +780,27 @@
HorizontalAlignment="Left"/>
</DockPanel>
<!-- Sound byte (creator ruling 2026-11-01): plays WITH the alert clip, on
the default device, so it is audible and shows in the desktop meter.
The video itself always plays once to completion. -->
<TextBlock Text="Sound byte" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
FontWeight="SemiBold" Margin="0,6,0,2"/>
<DockPanel Margin="0,0,0,2">
<Button DockPanel.Dock="Right" Content="Browse…" Command="{Binding BrowseAlertAudioCommand}"
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
Padding="8,2" Margin="6,0,0,0" FontSize="11"/>
<Button DockPanel.Dock="Right" Content="✕" Command="{Binding ClearAlertAudioCommand}"
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
Padding="7,2" FontSize="11" ToolTip="Remove the sound byte"
Visibility="{Binding SelectedElement.HasAlertAudio, Converter={StaticResource BoolToVis}}"/>
<TextBox Text="{Binding SelectedElement.AlertAudioFileDisplay, Mode=OneWay}"
Style="{StaticResource YtTextBoxCompact}" IsReadOnly="True"
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
</DockPanel>
<CheckBox Content="Loop the sound until the video ends" Style="{StaticResource YtCheckBox}" Margin="0,2,0,6"
IsChecked="{Binding SelectedElement.AlertAudioLoop, Mode=TwoWay}"
IsEnabled="{Binding SelectedElement.HasAlertAudio}"/>
<Grid Margin="0,0,0,4"
Visibility="{Binding SelectedElement.AlertUseDefaultVideo, Converter={StaticResource InverseBoolToVis}}">
<Grid.ColumnDefinitions>
+22
View File
@@ -273,6 +273,28 @@ public class Source : SceneElement
set => Set(ref _alertVideoVolume, Math.Clamp(value, 0.0, 1.0));
}
// Sound byte that plays with the alert clip (creator ruling 2026-11-01). Referenced from disk
// like the clip; the video always plays once to completion, the sound may loop under it.
private string? _alertAudioPath;
public string? AlertAudioPath
{
get => _alertAudioPath;
set => Set(ref _alertAudioPath, value);
}
private bool _alertAudioLoop;
public bool AlertAudioLoop
{
get => _alertAudioLoop;
set => Set(ref _alertAudioLoop, value);
}
public string AlertAudioFileDisplay =>
string.IsNullOrWhiteSpace(_alertAudioPath) ? "No sound byte" : System.IO.Path.GetFileName(_alertAudioPath);
/// <summary>True once a file is chosen — gates the loop switch and the clear button.</summary>
public bool HasAlertAudio => !string.IsNullOrWhiteSpace(_alertAudioPath);
private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
public AlertDisplayMethod AlertDisplayMethod
{
+39 -1
View File
@@ -317,6 +317,40 @@ public sealed class AlertOverlayLayer : IDisposable
return Math.Clamp(_elapsed / FadeDurationSeconds, 0.0, 1.0);
}
// Sound byte (creator ruling 2026-11-01): a file the creator picks to play WITH the clip. It
// goes to the default device, not into the mixer, so it is audible and visible in the desktop
// sound bar. MediaPlayer handles the decode; the clip itself still stops playback.
private System.Windows.Media.MediaPlayer? _soundByte;
private void StartSoundByte(Source box)
{
StopSoundByte();
var path = box.AlertAudioPath;
if (string.IsNullOrWhiteSpace(path)) return;
try
{
var player = new System.Windows.Media.MediaPlayer { Volume = Math.Clamp(box.AlertVideoVolume, 0, 1) };
if (box.AlertAudioLoop) player.MediaEnded += (_, _) => { player.Position = TimeSpan.Zero; player.Play(); };
player.Open(new Uri(path, UriKind.Absolute));
player.Play();
_soundByte = player;
AppLog.Write($"Alert sound byte: '{path}' loop={box.AlertAudioLoop}");
}
catch (Exception ex)
{
AppLog.Write(ex, "Alert sound byte failed");
StopSoundByte();
}
}
private void StopSoundByte()
{
var player = _soundByte;
_soundByte = null;
if (player == null) return;
try { player.Stop(); player.Close(); } catch { /* already closed */ }
}
private void BeginClip()
{
StopClip();
@@ -332,7 +366,9 @@ public sealed class AlertOverlayLayer : IDisposable
_tickerMethod = box.AlertDisplayMethod;
}
if (box == null || _clipDecoderFactory == null) return;
if (box == null) return;
StartSoundByte(box);
if (_clipDecoderFactory == null) return;
var path = _clipPathResolver?.Invoke(box);
if (string.IsNullOrWhiteSpace(path)) return;
@@ -358,6 +394,7 @@ public sealed class AlertOverlayLayer : IDisposable
private void StopClip()
{
StopSoundByte();
var clip = _clip;
_clip = null;
_latestClipFrame = null;
@@ -533,6 +570,7 @@ public sealed class AlertOverlayLayer : IDisposable
public void Dispose()
{
StopSoundByte();
_ticker?.Stop();
_queue.Clear();
_current = null;
+83
View File
@@ -0,0 +1,83 @@
using System;
using NAudio.Wave;
using ytLive.Helpers;
namespace ytLive.Services.Audio;
/// <summary>
/// Plays alert audio on the **default output device**, which is what makes it visible in the
/// desktop sound bar (creator ruling 2026-11-01): the app mixes for the encoder, but only audio
/// that actually reaches the speakers is picked up by the WASAPI loopback that feeds both the
/// meter and the stream. Injecting straight into the mixer, as this used to do, produced sound
/// nobody could see and nobody could hear.
///
/// <para>One long-lived <see cref="WaveOutEvent"/> with a buffered float provider: the alert clip
/// submits PCM as it decodes, and a sound byte can be submitted through the same device, so they
/// mix with each other and with everything else on the machine.</para>
/// </summary>
public sealed class AlertAudioOutput : IDisposable
{
private readonly object _gate = new();
private WaveOutEvent? _device;
private BufferedWaveProvider? _buffer;
private int _sampleRate;
private int _channels;
/// <summary>Queue one decoded pulse of the alert clip. Never blocks the decoder: if the device
/// cannot keep up the buffer simply grows, and it is cleared when the alert ends.</summary>
public void Submit(AudioSample sample)
{
if (sample.Samples.Length == 0) return;
lock (_gate)
{
try
{
EnsureDevice(sample.SampleRate, sample.Channels);
if (_buffer == null) return;
var bytes = new byte[sample.Samples.Length * sizeof(float)];
Buffer.BlockCopy(sample.Samples, 0, bytes, 0, bytes.Length);
_buffer.AddSamples(bytes, 0, bytes.Length);
}
catch (Exception ex)
{
// No output device (a headless stream box, a locked-down session): the alert still
// renders, it is simply silent. Never let audio take the alert down.
AppLog.Write(ex, "Alert audio output failed");
Stop();
}
}
}
/// <summary>Stop and flush — called when the clip ends so a queued tail cannot bleed into the
/// next alert.</summary>
public void Stop()
{
lock (_gate)
{
try { _device?.Stop(); } catch { /* already stopped */ }
_buffer?.ClearBuffer();
_device?.Dispose();
_device = null;
_buffer = null;
}
}
private void EnsureDevice(int sampleRate, int channels)
{
if (_device != null && _sampleRate == sampleRate && _channels == channels) return;
_device?.Dispose();
_sampleRate = sampleRate;
_channels = channels;
_buffer = new BufferedWaveProvider(new WaveFormat(sampleRate, 32, channels))
{
BufferDuration = TimeSpan.FromSeconds(2),
DiscardOnBufferOverflow = true,
};
_device = new WaveOutEvent { DesiredLatency = 120 };
_device.Init(_buffer);
_device.Play();
}
public void Dispose() => Stop();
}
+3 -1
View File
@@ -111,7 +111,7 @@ public partial class LayoutStore : IDisposable
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
AlertDisplayMethod, IsBrandLogo, BlurAmount
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
@@ -165,6 +165,8 @@ public partial class LayoutStore : IDisposable
AlertDisplayMethod = reader.IsDBNull(36) ? AlertDisplayMethod.TickerScroll : (AlertDisplayMethod)reader.GetInt32(36),
IsBrandLogo = reader.GetInt32(reader.GetOrdinal("IsBrandLogo")) != 0,
BlurAmount = reader.IsDBNull(reader.GetOrdinal("BlurAmount")) ? 0.5 : reader.GetDouble(reader.GetOrdinal("BlurAmount")),
AlertAudioPath = reader.IsDBNull(reader.GetOrdinal("AlertAudioPath")) ? null : reader.GetString(reader.GetOrdinal("AlertAudioPath")),
AlertAudioLoop = reader.GetInt32(reader.GetOrdinal("AlertAudioLoop")) != 0,
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<(Source, int)>();
+14
View File
@@ -206,6 +206,20 @@ public partial class LayoutStore : IDisposable
blur.ExecuteNonQuery();
}
if (!columns.Contains("AlertAudioPath"))
{
using var audio = _connection.CreateCommand();
audio.CommandText = "ALTER TABLE Source ADD COLUMN AlertAudioPath TEXT NULL;";
audio.ExecuteNonQuery();
}
if (!columns.Contains("AlertAudioLoop"))
{
using var loop = _connection.CreateCommand();
loop.CommandText = "ALTER TABLE Source ADD COLUMN AlertAudioLoop INTEGER NOT NULL DEFAULT 0;";
loop.ExecuteNonQuery();
}
if (!columns.Contains("IsBrandLogo"))
{
using var cmd = _connection.CreateCommand();
+6 -2
View File
@@ -92,7 +92,7 @@ public partial class LayoutStore : IDisposable
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
AlertDisplayMethod, IsBrandLogo, BlurAmount)
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
@@ -100,7 +100,7 @@ public partial class LayoutStore : IDisposable
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
$alertDisplayMethod, $isBrandLogo, $blurAmount)
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -142,6 +142,8 @@ public partial class LayoutStore : IDisposable
var alertDisplayMethodP = cmd.Parameters.Add("$alertDisplayMethod", SqliteType.Integer);
var isBrandLogoP = cmd.Parameters.Add("$isBrandLogo", SqliteType.Integer);
cmd.Parameters.Add("$blurAmount", SqliteType.Real);
var alertAudioPathP = cmd.Parameters.Add("$alertAudioPath", SqliteType.Text);
var alertAudioLoopP = cmd.Parameters.Add("$alertAudioLoop", SqliteType.Integer);
// Source and WebcamSceneConfig share ONE z-order space per scene:
// each element gets its index within scene.Elements regardless of
@@ -192,6 +194,8 @@ public partial class LayoutStore : IDisposable
alertDisplayMethodP.Value = (int)source.AlertDisplayMethod;
isBrandLogoP.Value = source.IsBrandLogo ? 1 : 0;
cmd.Parameters["$blurAmount"].Value = source.BlurAmount;
alertAudioPathP.Value = (object?)source.AlertAudioPath ?? DBNull.Value;
alertAudioLoopP.Value = source.AlertAudioLoop ? 1 : 0;
cmd.ExecuteNonQuery();
}
}
+19
View File
@@ -187,6 +187,25 @@ public partial class MainViewModel : ViewModelBase
box.AlertUseDefaultVideo = false;
}
private void BrowseAlertAudio(Source? box)
{
if (box == null || box.Type != SourceType.AlertBox) return;
var dialog = new OpenFileDialog
{
Title = "Choose the alert sound byte",
Filter = "Audio files (*.mp3;*.wav;*.m4a;*.aac;*.ogg)|*.mp3;*.wav;*.m4a;*.aac;*.ogg|All files (*.*)|*.*"
};
if (dialog.ShowDialog() != true) return;
box.AlertAudioPath = dialog.FileName;
}
private void ClearAlertAudio(Source? box)
{
if (box == null || box.Type != SourceType.AlertBox) return;
box.AlertAudioPath = null;
box.AlertAudioLoop = false;
}
private void ResetAlertVideo(Source? box)
{
if (box == null || box.Type != SourceType.AlertBox) return;
+10 -1
View File
@@ -47,6 +47,10 @@ public partial class MainViewModel : ViewModelBase
// born yet (startup crash regression from TASK 31, 2026-09-01; headless RealApp
// tests never ran it, so it waited for the first native launch).
private SceneGraph _sceneGraph = new();
/// <summary>Alert audio plays on the default device (creator ruling 2026-11-01): the WASAPI
/// loopback then picks it up, so it is audible AND visible in the desktop sound bar.</summary>
private readonly Services.Audio.AlertAudioOutput _alertAudioOutput = new();
private readonly TransitionService _transition = new();
private WebView2Manager? _webView2Manager;
@@ -149,6 +153,9 @@ public partial class MainViewModel : ViewModelBase
public ICommand EditElementCommand { get; }
public ICommand ToggleElementVisibilityCommand { get; }
public ICommand BrowseAlertVideoCommand { get; }
/// <summary>Sound byte that plays with the alert clip (creator ruling 2026-11-01).</summary>
public ICommand BrowseAlertAudioCommand { get; }
public ICommand ClearAlertAudioCommand { get; }
public ICommand ResetAlertVideoCommand { get; }
public ICommand ActivateLicenseCommand { get; }
public ICommand OpenLicenseEntryCommand { get; }
@@ -213,6 +220,8 @@ public partial class MainViewModel : ViewModelBase
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
ToggleElementVisibilityCommand = new RelayCommand(element => { PushPreviewUndo(); ToggleElementVisibility(element as SceneElement); });
BrowseAlertVideoCommand = new RelayCommand(element => BrowseAlertVideo(element as Source), element => element is Source { Type: SourceType.AlertBox });
BrowseAlertAudioCommand = new RelayCommand(element => BrowseAlertAudio(element as Source), element => element is Source { Type: SourceType.AlertBox });
ClearAlertAudioCommand = new RelayCommand(element => ClearAlertAudio(element as Source), element => element is Source { Type: SourceType.AlertBox });
ResetAlertVideoCommand = new RelayCommand(element => ResetAlertVideo(element as Source), element => element is Source { Type: SourceType.AlertBox });
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInStagedScene(), _ => CanShowWebcamInStagedScene);
@@ -357,7 +366,7 @@ public partial class MainViewModel : ViewModelBase
new AlertRenderer(),
clipDecoderFactory: AlertClipDecoderFor,
clipPathResolver: ResolveAlertClipPath,
audioSink: _audioMixer.EnqueueAlertAudio,
audioSink: _alertAudioOutput.Submit,
tickerPreviewSink: PublishAlertTickerPreview);
_rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL
+9
View File
@@ -152,3 +152,12 @@ published decisions (recorded in `TASKS/task-43-native-alerts.md`):
limiter and added at **unity — never ducked** (creator ruling: "don't lower my game
audio for a tip"); `StartLive` clears it. Per-alert loudness = the Volume slider.
- **Alert sound byte (2026-11-01, creator ruling):** the alert layer can carry an audio file
(`AlertAudioPath`) that plays with the clip, plus `AlertAudioLoop` to repeat it under the clip
("Loop the sound until the video ends"). The video is always one pass to completion.
⛔ **Alert audio plays on the DEFAULT OUTPUT DEVICE, not into the mixer** (`Services/Audio/
AlertAudioOutput` for the clip's own PCM, `MediaPlayer` for the sound byte). That is deliberate:
only audio that reaches the speakers is captured by the WASAPI loopback, which is what makes it
audible to the creator *and* visible in the desktop sound bar. The old `EnqueueAlertAudio` path
produced sound that was in the stream but invisible in the meter and inaudible in the room.
Failure to open a device is logged and swallowed — a silent alert still renders.
@@ -0,0 +1,61 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// The alert sound byte (creator ruling 2026-11-01): a file that plays with the alert clip, with an
/// optional loop. It is configured on the alert layer, so it has to survive a save/reload — the
/// same startup path that a missing SELECT column broke once already.
/// </summary>
public class AlertAudioPersistenceTests
{
[Fact]
public void The_sound_byte_and_its_loop_flag_survive_a_save_and_reload()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"llamacasty-alertaudio-{Guid.NewGuid():N}.db");
try
{
var scenes = new ObservableCollection<Scene> { new() { Name = SceneCatalog.Live } };
var alert = new Source
{
Name = "Stream Alerts",
Type = SourceType.AlertBox,
X = 620, Y = 430, Width = 680, Height = 200,
AlertAudioPath = @"C:\sounds\ding.wav",
AlertAudioLoop = true,
};
scenes[0].Elements.Add(alert);
using (var store = new LayoutStore(dbPath)) store.Save(scenes, null, null);
using (var reopened = new LayoutStore(dbPath))
{
var loaded = reopened.Load().SelectMany(s => s.Elements).OfType<Source>()
.Single(s => s.Type == SourceType.AlertBox);
Assert.Equal(@"C:\sounds\ding.wav", loaded.AlertAudioPath);
Assert.True(loaded.AlertAudioLoop);
Assert.Equal("ding.wav", loaded.AlertAudioFileDisplay);
Assert.True(loaded.HasAlertAudio);
}
}
finally
{
try { File.Delete(dbPath); } catch (IOException) { }
}
}
[Fact]
public void A_fresh_alert_layer_has_no_sound_byte_and_does_not_loop()
{
var alert = new Source { Type = SourceType.AlertBox };
Assert.False(alert.HasAlertAudio);
Assert.False(alert.AlertAudioLoop);
Assert.Equal("No sound byte", alert.AlertAudioFileDisplay);
}
}