A video file was picture-only: the decode pipe passed `-an`, so a starting-soon or BRB clip with music played silent everywhere. Adds sound to every clip and collapses the two decoders into one. - `Services/VideoClipPipe.cs`: the shared pipe behind `MediaVideoSource` (TASK 21) and `AlertClipDecoder` (TASK 47) — video (`rawvideo bgra`, scaled) + audio (`f32le -ac 2 -ar 48000`, 50 ms chunks, byte-carry across reads), both paced to real time, loop restarts both so picture and sound stay together. Alert behaviour is unchanged. - `MediaVideoSourceManager` applies the layer's Level/Mute per session and raises `AudioAvailable`; `MainViewModel` feeds it to the new `AudioMixer.EnqueueDesktopMediaAudio`, which joins the DESKTOP/GAME ring so the desktop fader and the mic auto-ducker apply exactly as they do for game audio and TRAX (creator ruling 2026-11-05: audio joins the group). - Per-layer `MediaMuted` + existing `MediaVolume` surfaced as Mute/Level in the properties panel; `MediaMuted` persisted (ALTER TABLE guard + SELECT/INSERT). - Level/mute are per SESSION because one decoder is shared per file path — the last layer touched wins; one decoder per layer would play the file twice. Test: `MediaSourceAudioTests` — the pipe emits 50 ms chunks at 48k stereo, the manager scales by level and drops when muted, and a media layer's sound moves the desktop meter. Alert/decode suites unchanged and green. Docs: task-21 closed on item 10, the "silent soundtrack" facts corrected in frame-pipeline/output/overlays, HANDOFF rewritten. verify.sh green (529 passed; the two documented pre-existing failures only).
125 lines
4.4 KiB
C#
125 lines
4.4 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Data.Sqlite;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
using ytLive.ViewModels;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK 21 Good Dog (2026-11-05): a MediaSource layer exists but nothing ever started
|
|
/// a decode session, so a video file layer rendered nothing in a running app. This
|
|
/// drives the real add path through <see cref="MainWindow"/> and asserts the piece the
|
|
/// unit adds — the session lifecycle: adding a video file starts exactly one decoder,
|
|
/// the decoded frames reach the layer, a loop flip reaches that decoder, and removing
|
|
/// the layer stops it.
|
|
/// </summary>
|
|
[Collection("RealApp")]
|
|
public sealed class MediaSourceAcquisitionTests
|
|
{
|
|
private readonly RealAppHost _app;
|
|
|
|
public MediaSourceAcquisitionTests(RealAppHost app) => _app = app;
|
|
|
|
[Fact]
|
|
public void AddingAVideoLayer_StartsTheDecoder_AndRemovingItStopsTheDecoder()
|
|
{
|
|
_app.Run(() =>
|
|
{
|
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
|
|
var videoPath = Path.Combine(Path.GetTempPath(), "starting-soon.mp4");
|
|
var decoders = new List<FakeMediaFrameSource>();
|
|
MainViewModel.LayoutPathOverride = tempDb;
|
|
MainViewModel.MediaFrameSourceFactoryOverride = key =>
|
|
{
|
|
var decoder = new FakeMediaFrameSource(key);
|
|
decoders.Add(decoder);
|
|
return decoder;
|
|
};
|
|
|
|
var window = new MainWindow();
|
|
var vm = (MainViewModel)window.DataContext;
|
|
try
|
|
{
|
|
window.Show();
|
|
window.UpdateLayout();
|
|
var scene = vm.StagedScene!;
|
|
|
|
vm.AddMediaSource(videoPath);
|
|
|
|
var layer = scene.Elements.OfType<Source>().Single(s => s.IsMediaSource);
|
|
Assert.Equal(videoPath, layer.MediaPath);
|
|
Assert.Equal(SourceType.MediaSource, layer.Type);
|
|
Assert.Single(decoders);
|
|
|
|
var decoder = decoders[0];
|
|
Assert.Equal(videoPath, decoder.Key);
|
|
Assert.Equal(1, decoder.StartCount);
|
|
|
|
// A decoded frame reaches the layer the preview/renderer draws from —
|
|
// otherwise the session would be running and still invisible.
|
|
Assert.Null(layer.DisplaySource);
|
|
decoder.Emit(new VideoFrame(64, 36, new byte[64 * 36 * 4]));
|
|
Assert.NotNull(layer.DisplaySource);
|
|
|
|
// Loop intent is per Source; the shared session is the per-path answer.
|
|
Assert.False(decoder.Looping);
|
|
layer.MediaIsLooping = true;
|
|
Assert.True(decoder.Looping);
|
|
|
|
vm.RemoveSourceCommand.Execute(layer);
|
|
|
|
Assert.DoesNotContain(layer, scene.Elements);
|
|
Assert.Equal(1, decoder.StopCount);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
MainViewModel.MediaFrameSourceFactoryOverride = null;
|
|
MainViewModel.LayoutPathOverride = null;
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>Stands in for the ffmpeg decode process (the manager's factory seam).</summary>
|
|
private sealed class FakeMediaFrameSource : IMediaFrameSource
|
|
{
|
|
public FakeMediaFrameSource(string key) => Key = key;
|
|
|
|
public string Key { get; }
|
|
public bool Looping { get; set; }
|
|
public int StartCount { get; private set; }
|
|
public int StopCount { get; private set; }
|
|
|
|
public event Action<VideoFrame>? FrameAvailable;
|
|
public event Action<ytLive.Services.Audio.AudioSample>? AudioAvailable { add { } remove { } }
|
|
public event Action? Completed { add { } remove { } }
|
|
|
|
public Task StartAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
StartCount++;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task StopAsync()
|
|
{
|
|
StopCount++;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public void Emit(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
}
|