using System.Diagnostics; using ytLive.Services.Audio; using ytLive.Services.Encoder; namespace ytLive.Services; /// /// TASK 47: one alert clip's playback — video frames + on-stream audio from the /// same file, both paced to real time. The alert box owns a decoder per event /// (start at play, dispose at drain) — unlike MediaSource's app-wide refcounted /// session, an alert REPLAYS the same clip each event, so a fresh decode per /// play is required. Video is rawvideo BGRA scaled to the box rect (the /// shape); audio is ffmpeg f32le at the /// mixer's native 48 kHz stereo (so the mixer ring needs no resampler). The /// fade is applied by the alert layer, not here. /// public interface IAlertClipDecoder : IDisposable { /// Starts the decode loop (video + audio); raises the events off the /// calling thread. Paced to real time so playback matches wall clock. void Start(); /// Stops and kills any running decode subprocesses. void Stop(); /// One decoded video frame (box-sized, straight-alpha BGRA). event Action? FrameAvailable; /// One chunk of on-stream audio (48 kHz stereo float). event Action? AudioReady; /// Raised once when both streams have fully played out. event Action? Completed; } /// Default : two ffmpeg subprocesses on /// the same file. Video via on a rawvideo /// bgra pipe (-an), audio via an f32le -ac 2 -ar 48000 pipe (-vn). /// The seams (locator, process factory, frame-rate probe, delay) let tests drive /// a fake decoder without a real codec or Windows. public sealed class AlertClipDecoder : IAlertClipDecoder { public event Action? FrameAvailable; public event Action? AudioReady; public event Action? Completed; private const int AudioSampleRate = 48000; private const int AudioChannels = 2; private const int AudioChunkFloats = 4800; // 50ms of stereo private readonly string _path; private readonly int _width; private readonly int _height; private readonly IFfmpegLocator _locator; private readonly Func _processFactory; private readonly IFrameRateProbe? _probe; private readonly Func _delay; private readonly CancellationTokenSource _cts = new(); private Task? _run; public AlertClipDecoder( string path, int width, int height, IFfmpegLocator locator, Func processFactory, IFrameRateProbe? frameRateProbe = null, Func? delay = null) { _path = path; _width = width; _height = height; _locator = locator; _processFactory = processFactory; _probe = frameRateProbe; _delay = delay ?? ((t, ct) => Task.Delay(t, ct)); } public void Start() { if (_run != null) return; _run = Task.Run(RunAsync, CancellationToken.None); } public void Stop() => _cts.Cancel(); public void Dispose() { _cts.Cancel(); _cts.Dispose(); } private async Task RunAsync() { var ct = _cts.Token; try { double? frameRate = null; if (_probe != null) frameRate = await _probe.ProbeAsync(_path, ct).ConfigureAwait(false); var frameDuration = frameRate is > 0 ? TimeSpan.FromSeconds(1.0 / frameRate.Value) : TimeSpan.Zero; var ffmpegPath = await _locator.LocateAsync(ct).ConfigureAwait(false); var videoTask = RunVideoAsync(ffmpegPath, frameDuration, ct); var audioTask = RunAudioAsync(ffmpegPath, ct); await Task.WhenAll(videoTask, audioTask).ConfigureAwait(false); } catch (OperationCanceledException) { return; } catch { // A failed decode is not fatal: the alert layer falls back to the six // animations (or the frame it already holds) exactly as a missing file. } if (!ct.IsCancellationRequested) Completed?.Invoke(); } private async Task RunVideoAsync(string ffmpegPath, TimeSpan frameDuration, CancellationToken ct) { var args = $"-hide_banner -loglevel error -i \"{_path}\" " + $"-f rawvideo -pix_fmt bgra -vf scale={_width}:{_height} -an -"; using var process = _processFactory(); process.Start(new ProcessStartInfo { FileName = ffmpegPath, Arguments = args, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }); var reader = new RawVideoFrameReader(_width, _height); var buffer = new byte[65536]; var stream = process.StandardOutput; while (!ct.IsCancellationRequested && !process.HasExited) { var read = await stream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false); if (read <= 0) break; var chunk = new byte[read]; Buffer.BlockCopy(buffer, 0, chunk, 0, read); foreach (var frame in reader.Feed(chunk)) { FrameAvailable?.Invoke(frame); if (frameDuration > TimeSpan.Zero) await _delay(frameDuration, ct).ConfigureAwait(false); } } } private async Task RunAudioAsync(string ffmpegPath, CancellationToken ct) { var args = $"-hide_banner -loglevel error -i \"{_path}\" " + $"-f f32le -ac {AudioChannels} -ar {AudioSampleRate} -vn -"; using var process = _processFactory(); process.Start(new ProcessStartInfo { FileName = ffmpegPath, Arguments = args, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }); var stream = process.StandardOutput; var raw = new byte[65536]; var carry = new byte[0]; var floatScratch = new float[AudioChunkFloats]; var floatIndex = 0; // The pipe is byte-delimited only: a read may split a float, so leftover // bytes are carried into the next read instead of dropped (a dropped // half-float would desync the entire stream). while (!ct.IsCancellationRequested && !process.HasExited) { var read = await stream.ReadAsync(raw, 0, raw.Length, ct).ConfigureAwait(false); if (read <= 0) break; var total = carry.Length + read; var bytes = total <= raw.Length ? raw : new byte[total]; if (carry.Length > 0) { Buffer.BlockCopy(carry, 0, bytes, 0, carry.Length); Buffer.BlockCopy(raw, 0, bytes, carry.Length, read); } var floatsTo = total - (total % 4); for (var i = 0; i < floatsTo && floatIndex < floatScratch.Length; i += 4) floatScratch[floatIndex++] = BitConverter.ToSingle(bytes, i); carry = total > floatsTo ? bytes[floatsTo..total] : new byte[0]; while (floatIndex == floatScratch.Length) { AudioReady?.Invoke(new AudioSample(floatScratch, AudioSampleRate, AudioChannels)); // Pace the NEXT chunk so the whole clip's audio lands at real-time // cadence (the fade in the alert layer tracks the same wall clock). await _delay(TimeSpan.FromSeconds(AudioChunkFloats / (double)(AudioSampleRate * AudioChannels)), ct).ConfigureAwait(false); floatScratch = new float[AudioChunkFloats]; floatIndex = 0; } } } }