diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index c841730..2208871 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -780,6 +780,27 @@
HorizontalAlignment="Left"/>
+
+
+
+
+
+
+
+
+
diff --git a/Models/Source.cs b/Models/Source.cs
index 7a6667b..fbbfeb2 100644
--- a/Models/Source.cs
+++ b/Models/Source.cs
@@ -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);
+
+ /// True once a file is chosen — gates the loop switch and the clear button.
+ public bool HasAlertAudio => !string.IsNullOrWhiteSpace(_alertAudioPath);
+
private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
public AlertDisplayMethod AlertDisplayMethod
{
diff --git a/Services/AlertOverlayLayer.cs b/Services/AlertOverlayLayer.cs
index 26dec51..6c51dd6 100644
--- a/Services/AlertOverlayLayer.cs
+++ b/Services/AlertOverlayLayer.cs
@@ -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;
diff --git a/Services/Audio/AlertAudioOutput.cs b/Services/Audio/AlertAudioOutput.cs
new file mode 100644
index 0000000..67aed35
--- /dev/null
+++ b/Services/Audio/AlertAudioOutput.cs
@@ -0,0 +1,83 @@
+using System;
+using NAudio.Wave;
+using ytLive.Helpers;
+
+namespace ytLive.Services.Audio;
+
+///
+/// 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.
+///
+/// One long-lived 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.
+///
+public sealed class AlertAudioOutput : IDisposable
+{
+ private readonly object _gate = new();
+ private WaveOutEvent? _device;
+ private BufferedWaveProvider? _buffer;
+ private int _sampleRate;
+ private int _channels;
+
+ /// 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.
+ 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();
+ }
+ }
+ }
+
+ /// Stop and flush — called when the clip ends so a queued tail cannot bleed into the
+ /// next alert.
+ 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();
+}
diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs
index 22cfdb4..5c536d2 100644
--- a/Services/LayoutStore.Load.cs
+++ b/Services/LayoutStore.Load.cs
@@ -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)>();
diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs
index 2db47e2..741c3ed 100644
--- a/Services/LayoutStore.Migrations.cs
+++ b/Services/LayoutStore.Migrations.cs
@@ -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();
diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs
index 1482fd9..874d172 100644
--- a/Services/LayoutStore.Save.cs
+++ b/Services/LayoutStore.Save.cs
@@ -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();
}
}
diff --git a/ViewModels/MainViewModel.Chat.cs b/ViewModels/MainViewModel.Chat.cs
index ae5303c..21e3217 100644
--- a/ViewModels/MainViewModel.Chat.cs
+++ b/ViewModels/MainViewModel.Chat.cs
@@ -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;
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 74d53c7..33e7667 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -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();
+
+ /// 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.
+ 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; }
+ /// Sound byte that plays with the alert clip (creator ruling 2026-11-01).
+ 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
diff --git a/docs/ai/overlays.md b/docs/ai/overlays.md
index 9d5fca0..69c7abd 100644
--- a/docs/ai/overlays.md
+++ b/docs/ai/overlays.md
@@ -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.
diff --git a/ytLive.Tests/AlertAudioPersistenceTests.cs b/ytLive.Tests/AlertAudioPersistenceTests.cs
new file mode 100644
index 0000000..5605e7f
--- /dev/null
+++ b/ytLive.Tests/AlertAudioPersistenceTests.cs
@@ -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;
+
+///
+/// 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.
+///
+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 { 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()
+ .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);
+ }
+}