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;
///
/// 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.
///
[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()),
delay: (_, _) => Task.CompletedTask);
var chunks = new List();
var completed = new TaskCompletionSource(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();
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();
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()
.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? FrameAvailable { add { } remove { } }
public event Action? 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 _pick;
private readonly MemoryStream _stream = new();
public FakeDecodeProcess(Func 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 LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe");
}
}