Files
LlamaCasty/ytLive.Tests/MediaSourceAudioTests.cs
T
gramps ea24405ba2 feat(chroma): offer to remove a green or blue screen, and key it in the decode
TASK 64 slices D and E: the feature is usable end to end.

- Add a video file → one frame is probed (new `IChromaProbe`/`FfmpegChromaProbe`, one ffmpeg
  pass at 160x90, off the UI thread) and the layer gets an offer: "Looks like a green screen —
  Remove it", with the measured colour as a swatch.
- Accepting it turns the offer into settings (Amount / Softness / Fringe) that reach ffmpeg as
  `colorkey` + `despill` + `format=bgra`, so the key is computed in the pipe and both consumers
  (the shared preview bitmap and the compositor's frame) carry the same alpha.
- Media sessions are now keyed by **path + look** (`MainViewModel.MediaSessionKey`), so two
  layers with the same settings still share one decoder, and changing the look releases the old
  session and starts the new one. Removal, load reconciliation, loop and audio settings all move
  to the same key.
- Settings persist on the `Source` row (six ALTER TABLE guards + SELECT/INSERT), so a keyed layer
  comes back keyed.
- Panel section is plain creator words: Green/Blue Screen, Amount, Softness, Fringe.

Test: `ChromaKeyApplyTests` — the pipe asks ffmpeg to key (and does not when off); a green-screen
clip is offered, accepting it starts a keyed session, the layer's resolver then reads that session
rather than the old one; the setting survives a save.

Docs: TASK 64 D/E ticked with slice F (auto-apply setting + alert clips) left open; frame-pipeline
records the decode-option design; HANDOFF rewritten. verify.sh: 544 passed, 0 failed, 0 warnings.
2026-11-05 08:15:32 -08:00

192 lines
7.1 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Services;
using ytLive.Services.Audio;
using ytLive.Services.Encoder;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// TASK 21 audio slice (2026-11-05): a video file's own soundtrack used to be thrown away
/// (`-an` on the decode pipe), so a "starting soon" clip with music played silent. This pins
/// the replacement: the shared clip pipe decodes the sound, the manager applies the layer's
/// level/mute, and a media layer's audio lands in the mixer's desktop/game group.
/// </summary>
[Collection("RealApp")]
public sealed class MediaSourceAudioTests
{
private readonly RealAppHost _app;
public MediaSourceAudioTests(RealAppHost app) => _app = app;
// ─── the pipe decodes the soundtrack ───
[Fact]
public async Task ClipPipe_EmitsTheSoundtrackInPacedChunks()
{
// 4800 stereo floats = one 50 ms chunk.
var audioBytes = new byte[4800 * 4];
for (var i = 0; i < 4800; i++)
BitConverter.TryWriteBytes(audioBytes.AsSpan(i * 4, 4), 0.5f);
using var pipe = new VideoClipPipe(
"clip.mp4", 1, 1,
new FakeLocator(),
() => new FakeDecodeProcess(args => args.Contains("-vn") ? audioBytes : Array.Empty<byte>()),
delay: (_, _) => Task.CompletedTask);
var chunks = new List<AudioSample>();
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
pipe.AudioAvailable += sample => chunks.Add(sample);
pipe.Completed += () => completed.TrySetResult(true);
await pipe.StartAsync();
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Single(chunks);
Assert.Equal(4800, chunks[0].Samples.Length);
Assert.Equal(48000, chunks[0].SampleRate);
Assert.Equal(2, chunks[0].Channels);
}
// ─── the manager applies the layer's controls ───
[Fact]
public async Task Manager_ScalesTheSoundtrackByTheSessionsLevel_AndDropsItWhenMuted()
{
var source = new FakeMediaSource("clip.mp4");
var manager = new MediaVideoSourceManager((_, _) => source, uiDispatcher: null);
var heard = new List<AudioSample>();
manager.AudioAvailable += (_, sample) => heard.Add(sample);
Assert.True(await manager.AcquireAsync("clip.mp4"));
manager.SetAudioSettings("clip.mp4", 0.5, muted: false);
source.EmitAudio(new AudioSample(new[] { 1.0f, -1.0f }, 48000, 2));
Assert.Single(heard);
Assert.Equal(0.5f, heard[0].Samples[0], 3);
Assert.Equal(-0.5f, heard[0].Samples[1], 3);
manager.SetAudioSettings("clip.mp4", 1.0, muted: true);
source.EmitAudio(new AudioSample(new[] { 1.0f, -1.0f }, 48000, 2));
Assert.Single(heard); // muted: nothing reaches the mixer
manager.Dispose();
}
// ─── the layer's audio reaches the desktop/game group ───
[Fact]
public void MediaLayerSound_ReachesTheDesktopAudioGroup()
{
_app.Run(() =>
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
var videoPath = Path.Combine(Path.GetTempPath(), "brb-bed.mp4");
var decoders = new List<FakeMediaSource>();
MainViewModel.LayoutPathOverride = tempDb;
MainViewModel.MediaFrameSourceFactoryOverride = key =>
{
var decoder = new FakeMediaSource(key);
decoders.Add(decoder);
return decoder;
};
var window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
try
{
window.Show();
window.UpdateLayout();
vm.AddMediaSource(videoPath);
var decoder = decoders.Single();
// The desktop/game meter is what the creator watches. It has to move for a
// media layer's sound, or the soundtrack is in the mix but invisible.
Assert.Equal(0.0, vm.GameAudioLevel);
var loud = Enumerable.Repeat(0.8f, 9600).ToArray();
for (var i = 0; i < 6; i++)
decoder.EmitAudio(new AudioSample(loud, 48000, 2));
Assert.True(vm.GameAudioLevel > 0.05,
$"the media soundtrack never reached the desktop group (level {vm.GameAudioLevel})");
vm.StagedScene!.Elements.OfType<ytLive.Models.Source>()
.Single(s => s.IsMediaSource).MediaMuted = true;
var afterMute = vm.GameAudioLevel;
decoder.EmitAudio(new AudioSample(loud, 48000, 2));
Assert.Equal(afterMute, vm.GameAudioLevel); // muted layer stops feeding the meter
}
finally
{
window.Close();
MainViewModel.MediaFrameSourceFactoryOverride = null;
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
});
}
private sealed class FakeMediaSource : IMediaFrameSource
{
public FakeMediaSource(string key) => Key = key;
public string Key { get; }
public bool Looping { get; set; }
public event Action<VideoFrame>? FrameAvailable { add { } remove { } }
public event Action<AudioSample>? AudioAvailable;
public event Action? Completed { add { } remove { } }
public void EmitAudio(AudioSample sample) => AudioAvailable?.Invoke(sample);
public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task StopAsync() => Task.CompletedTask;
public void Dispose()
{
}
}
private sealed class FakeDecodeProcess : IDecodeProcess
{
private readonly Func<string, byte[]> _pick;
private readonly MemoryStream _stream = new();
public FakeDecodeProcess(Func<string, byte[]> pick) => _pick = pick;
public void Start(ProcessStartInfo startInfo)
{
// The pipe runs a video process and an audio process; the payload depends on
// which one this is, so the fake reads the arguments.
var bytes = _pick(startInfo.Arguments);
_stream.SetLength(0);
_stream.Write(bytes, 0, bytes.Length);
_stream.Position = 0;
}
public Stream StandardOutput => _stream;
public bool HasExited => _stream.Position >= _stream.Length;
public int ExitCode => 0;
public void Kill() { }
public Task WaitForExitAsync(CancellationToken ct = default) => Task.CompletedTask;
public void Dispose() => _stream.Dispose();
}
private sealed class FakeLocator : IFfmpegLocator
{
public Task<string> LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe");
}
}