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