Files
LlamaCasty/ytLive.Tests/MediaVideoSourceManagerTests.cs
T
gramps 2670ac022b feat(media): play the clip's soundtrack and share one decode pipe
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).
2026-11-05 07:54:06 -08:00

217 lines
7.7 KiB
C#

using System.Threading;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Xunit;
using ytLive.Services;
using ytLive.Services.Audio;
namespace ytLive.Tests;
/// <summary>
/// MediaVideoSourceManager owns media-file decode sessions, refcounted by file
/// path and coalesced onto the UI dispatcher — a structural mirror of
/// ScreenCaptureManager. These tests pin that contract with a fake
/// IMediaFrameSource (the real ffmpeg decode is exercised on Windows at runtime).
/// </summary>
public class MediaVideoSourceManagerTests
{
private sealed class FakeMediaSource : IMediaFrameSource
{
private readonly List<string>? _started;
private readonly List<string>? _stopped;
public string Key { get; }
public bool Looping { get; set; }
public event Action<VideoFrame>? FrameAvailable;
public event Action<AudioSample>? AudioAvailable;
public event Action? Completed;
public FakeMediaSource(string key, List<string>? started = null, List<string>? stopped = null)
{
Key = key;
_started = started;
_stopped = stopped;
}
public Task StartAsync(CancellationToken cancellationToken = default)
{
_started?.Add(Key);
return Task.CompletedTask;
}
public Task StopAsync()
{
_stopped?.Add(Key);
return Task.CompletedTask;
}
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
public void EmitAudio(AudioSample sample) => AudioAvailable?.Invoke(sample);
public void Complete() => Completed?.Invoke();
public void Dispose() { }
}
private sealed class FailingMediaSource : IMediaFrameSource
{
public string Key { get; }
public bool Looping { get; set; }
public event Action<VideoFrame>? FrameAvailable;
public event Action<AudioSample>? AudioAvailable;
public event Action? Completed;
public FailingMediaSource(string key) => Key = key;
public Task StartAsync(CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("access denied");
public Task StopAsync() => Task.CompletedTask;
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
public void EmitAudio(AudioSample sample) => AudioAvailable?.Invoke(sample);
public void Complete() => Completed?.Invoke();
public void Dispose() { }
}
private sealed class DispatcherPump : IDisposable
{
private readonly Thread _thread;
private readonly ManualResetEventSlim _ready = new(false);
private Dispatcher? _dispatcher;
public Dispatcher Dispatcher => _dispatcher!;
public DispatcherPump()
{
_thread = new Thread(() =>
{
_dispatcher = Dispatcher.CurrentDispatcher;
_ready.Set();
Dispatcher.Run();
});
_thread.IsBackground = true;
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
_ready.Wait();
}
public void Drain() => Dispatcher.Invoke(() => { }, DispatcherPriority.ContextIdle);
public void Dispose()
{
Dispatcher.InvokeShutdown();
_thread.Join(3000);
}
}
private static byte[] Pixels(params byte[] raw) => raw;
[Fact]
public async Task Refcount_TwoAcquires_OneDecoderUntilLastRelease()
{
var started = new List<string>();
var stopped = new List<string>();
var manager = new MediaVideoSourceManager(key => new FakeMediaSource(key, started, stopped));
Assert.True(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
Assert.True(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
Assert.Single(started);
await manager.ReleaseAsync(@"C:\videos\clip.mp4");
Assert.Empty(stopped);
await manager.ReleaseAsync(@"C:\videos\clip.mp4");
Assert.Single(stopped);
}
[Fact]
public async Task ReleaseAll_StopsEvenWithMultipleRefs()
{
var started = new List<string>();
var stopped = new List<string>();
var manager = new MediaVideoSourceManager(key => new FakeMediaSource(key, started, stopped));
await manager.AcquireAsync(@"C:\videos\clip.mp4");
await manager.AcquireAsync(@"C:\videos\clip.mp4");
await manager.ReleaseAllAsync(@"C:\videos\clip.mp4");
Assert.Single(stopped);
}
[Fact]
public async Task Acquire_FailingSource_ReturnsFalseAndRaisesMediaFailed()
{
var manager = new MediaVideoSourceManager(key => new FailingMediaSource(key));
string? failedKey = null;
manager.MediaFailed += (key, _) => failedKey = key;
Assert.False(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
Assert.Equal(@"C:\videos\clip.mp4", failedKey);
}
[Fact]
public async Task Acquire_EmptyKey_ReturnsFalse()
{
var manager = new MediaVideoSourceManager(key => new FakeMediaSource(key));
Assert.False(await manager.AcquireAsync(" "));
}
[Fact]
public async Task GetLatestFrame_ReturnsLatestPump_UntilReleased()
{
FakeMediaSource? captured = null;
var manager = new MediaVideoSourceManager(key => captured = new FakeMediaSource(key));
Assert.Null(manager.GetLatestFrame(@"C:\videos\clip.mp4"));
Assert.True(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
Assert.Null(manager.GetLatestFrame(@"C:\videos\clip.mp4"));
var first = new VideoFrame(2, 2, Pixels(1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255));
var second = new VideoFrame(2, 2, Pixels(5, 0, 0, 255, 6, 0, 0, 255, 7, 0, 0, 255, 8, 0, 0, 255));
captured!.Pump(first);
captured.Pump(second);
Assert.Same(second, manager.GetLatestFrame(@"C:\videos\clip.mp4"));
await manager.ReleaseAsync(@"C:\videos\clip.mp4");
Assert.Null(manager.GetLatestFrame(@"C:\videos\clip.mp4"));
}
// The one integration test for this branch: one file creates one shared
// WriteableBitmap, published once, and back-to-back frames coalesce to the
// latest (a single pending UI copy per session) — mirror of ScreenCapture.
[Fact]
public async Task Frames_ShareOneBitmap_AndCoalesceToLatest()
{
using var pump = new DispatcherPump();
FakeMediaSource? captured = null;
var manager = new MediaVideoSourceManager(key => captured = new FakeMediaSource(key), pump.Dispatcher);
WriteableBitmap? published = null;
var publishedCount = 0;
manager.PreviewBitmapChanged += (_, bitmap) => { published = bitmap; publishedCount++; };
Assert.True(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
var first = new VideoFrame(2, 2, Pixels(1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255));
captured!.Pump(first);
pump.Drain();
Assert.NotNull(published);
Assert.Equal(1, publishedCount);
var dims = pump.Dispatcher.Invoke(() => new[] { published!.PixelWidth, published.PixelHeight });
Assert.Equal(new[] { 2, 2 }, dims);
var second = new VideoFrame(2, 2, Pixels(5, 0, 0, 255, 6, 0, 0, 255, 7, 0, 0, 255, 8, 0, 0, 255));
var third = new VideoFrame(2, 2, Pixels(9, 0, 0, 255, 10, 0, 0, 255, 11, 0, 0, 255, 12, 0, 0, 255));
captured.Pump(second);
captured.Pump(third);
pump.Drain();
var result = pump.Dispatcher.Invoke(() =>
{
var bytes = new byte[16];
published!.CopyPixels(new System.Windows.Int32Rect(0, 0, 2, 2), bytes, 8, 0);
return bytes;
});
Assert.Equal(third.BgraPixels, result);
}
}