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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)>();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user