mirror of
https://github.com/par274/sharpemu.git
synced 2026-09-02 14:56:49 +08:00
[bink] synced host movie playback to the guest audio clock
This commit is contained in:
@@ -133,10 +133,12 @@ internal static class Bink2MovieBridge
|
|||||||
if (_playback.IsFinished)
|
if (_playback.IsFinished)
|
||||||
{
|
{
|
||||||
var completedPath = _activePath;
|
var completedPath = _activePath;
|
||||||
|
var progress = _playback.PlaybackProgress;
|
||||||
CloseActiveLocked();
|
CloseActiveLocked();
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
"[LOADER][INFO] Bink2 bridge completed: " +
|
"[LOADER][INFO] Bink2 bridge completed: " +
|
||||||
Path.GetFileName(completedPath));
|
$"{Path.GetFileName(completedPath)} after " +
|
||||||
|
$"{progress.Seconds:F2}s at frame {progress.FrameIndex}");
|
||||||
AttachNextQueuedMovieLocked();
|
AttachNextQueuedMovieLocked();
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using SharpEmu.HLE.Host;
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Bink;
|
namespace SharpEmu.Libs.Bink;
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@ internal sealed class BinkFramePlayback : IDisposable
|
|||||||
private long _currentFrameIndex = -1;
|
private long _currentFrameIndex = -1;
|
||||||
private long _nextDecodedFrameIndex;
|
private long _nextDecodedFrameIndex;
|
||||||
private long _playbackStartTimestamp;
|
private long _playbackStartTimestamp;
|
||||||
|
private double _audioStartSeconds;
|
||||||
|
private long _lastSkewTraceTimestamp;
|
||||||
private bool _playbackClockStarted;
|
private bool _playbackClockStarted;
|
||||||
private bool _decoderCompleted;
|
private bool _decoderCompleted;
|
||||||
private bool _stopRequested;
|
private bool _stopRequested;
|
||||||
@@ -83,6 +86,26 @@ internal sealed class BinkFramePlayback : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wall-clock seconds since the first frame was presented, and the index of
|
||||||
|
/// the last frame shown. Playback is on its own time base when these agree
|
||||||
|
/// with the movie's frame rate.
|
||||||
|
/// </summary>
|
||||||
|
internal (double Seconds, long FrameIndex) PlaybackProgress
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
_playbackClockStarted
|
||||||
|
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
|
||||||
|
: 0,
|
||||||
|
_currentFrameIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal bool TryGetFrame(
|
internal bool TryGetFrame(
|
||||||
bool advanceClock,
|
bool advanceClock,
|
||||||
out byte[] pixels,
|
out byte[] pixels,
|
||||||
@@ -118,14 +141,13 @@ internal sealed class BinkFramePlayback : IDisposable
|
|||||||
if (advanceClock && !_playbackClockStarted)
|
if (advanceClock && !_playbackClockStarted)
|
||||||
{
|
{
|
||||||
_playbackStartTimestamp = Stopwatch.GetTimestamp();
|
_playbackStartTimestamp = Stopwatch.GetTimestamp();
|
||||||
|
_audioStartSeconds = GuestAudioClock.PlayedSeconds;
|
||||||
_playbackClockStarted = true;
|
_playbackClockStarted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var elapsedSeconds = _playbackClockStarted
|
var elapsedSeconds = CurrentPlaybackSecondsLocked();
|
||||||
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
|
TraceClockSkewLocked();
|
||||||
: 0;
|
var targetFrameIndex = CurrentTargetFrameIndexLocked();
|
||||||
var targetFrameIndex = (long)Math.Floor(
|
|
||||||
elapsedSeconds * FramesPerSecondNumerator / FramesPerSecondDenominator);
|
|
||||||
DecodedFrame? replacement = null;
|
DecodedFrame? replacement = null;
|
||||||
while (_decodedFrames.Count > 0 &&
|
while (_decodedFrames.Count > 0 &&
|
||||||
_decodedFrames.Peek().Index <= targetFrameIndex)
|
_decodedFrames.Peek().Index <= targetFrameIndex)
|
||||||
@@ -166,6 +188,86 @@ internal sealed class BinkFramePlayback : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time base for playback. A host-decoded movie runs on whatever clock it is
|
||||||
|
/// given, but the audio that belongs to it comes from the guest, which does
|
||||||
|
/// not advance at wall-clock rate on a slow frame. Following the audio keeps
|
||||||
|
/// the two together; SHARPEMU_MOVIE_CLOCK=wall restores the old behaviour.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly bool _followGuestAudioClock = !string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_MOVIE_CLOCK"),
|
||||||
|
"wall",
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seconds of playback elapsed on the movie's time base. Falls back to wall
|
||||||
|
/// clock whenever guest audio is not flowing: a movie whose audio never
|
||||||
|
/// starts — or stops early — must still finish rather than hang on a clock
|
||||||
|
/// that will never advance again.
|
||||||
|
/// </summary>
|
||||||
|
private double CurrentPlaybackSecondsLocked()
|
||||||
|
{
|
||||||
|
if (!_playbackClockStarted)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wallSeconds = Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds;
|
||||||
|
if (!_followGuestAudioClock || !GuestAudioClock.IsRunning)
|
||||||
|
{
|
||||||
|
return wallSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Clamp(GuestAudioClock.PlayedSeconds - _audioStartSeconds, 0, wallSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly bool _traceClockSkew = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_LOG_MOVIE_SYNC"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Logs how far the movie's wall clock has drifted from the guest audio the
|
||||||
|
/// movie is supposed to be in step with. A skew that is flat across playback
|
||||||
|
/// is a late audio start; one that grows is a rate mismatch, and the two need
|
||||||
|
/// different fixes. Caller holds <see cref="_gate"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void TraceClockSkewLocked()
|
||||||
|
{
|
||||||
|
if (!_traceClockSkew || !_playbackClockStarted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
if (_lastSkewTraceTimestamp != 0 &&
|
||||||
|
Stopwatch.GetElapsedTime(_lastSkewTraceTimestamp) < TimeSpan.FromSeconds(1))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastSkewTraceTimestamp = now;
|
||||||
|
var wallSeconds = Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds;
|
||||||
|
var audioSeconds = GuestAudioClock.PlayedSeconds - _audioStartSeconds;
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[PERF][MOVIE] wall_s={wallSeconds:F2} audio_s={audioSeconds:F2} " +
|
||||||
|
$"playback_s={CurrentPlaybackSecondsLocked():F2} " +
|
||||||
|
$"skew_s={wallSeconds - audioSeconds:F2} frame={_currentFrameIndex} " +
|
||||||
|
$"audio_running={GuestAudioClock.IsRunning}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The frame the movie's own time base says should be on screen right now.
|
||||||
|
/// Returns -1 until the first frame is presented, so the queue prefills
|
||||||
|
/// instead of instantly declaring everything late.
|
||||||
|
/// </summary>
|
||||||
|
private long CurrentTargetFrameIndexLocked() =>
|
||||||
|
_playbackClockStarted
|
||||||
|
? (long)Math.Floor(
|
||||||
|
CurrentPlaybackSecondsLocked() *
|
||||||
|
FramesPerSecondNumerator / FramesPerSecondDenominator)
|
||||||
|
: -1;
|
||||||
|
|
||||||
private void DecodeLoop()
|
private void DecodeLoop()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -199,8 +301,28 @@ internal sealed class BinkFramePlayback : IDisposable
|
|||||||
|
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
_decodedFrames.Enqueue(new DecodedFrame(
|
var frameIndex = _nextDecodedFrameIndex++;
|
||||||
_nextDecodedFrameIndex++, destination));
|
|
||||||
|
// Frames are pulled once per guest flip, so a title running
|
||||||
|
// well under the movie's frame rate cannot drain a queue
|
||||||
|
// this shallow fast enough and the movie stretches past its
|
||||||
|
// real duration — audio finishes while the last picture sits
|
||||||
|
// on screen and the next movie starts late. Once the clock
|
||||||
|
// has passed a queued frame it can never be shown, so retire
|
||||||
|
// it in favour of this newer one. Only superseded frames are
|
||||||
|
// dropped, never the newest, so a decoder that cannot keep
|
||||||
|
// up still advances the picture instead of freezing it.
|
||||||
|
var targetFrameIndex = CurrentTargetFrameIndexLocked();
|
||||||
|
if (frameIndex <= targetFrameIndex)
|
||||||
|
{
|
||||||
|
while (_decodedFrames.Count > 0 &&
|
||||||
|
_decodedFrames.Peek().Index <= targetFrameIndex)
|
||||||
|
{
|
||||||
|
_freeBuffers.Enqueue(_decodedFrames.Dequeue().Pixels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_decodedFrames.Enqueue(new DecodedFrame(frameIndex, destination));
|
||||||
Monitor.PulseAll(_gate);
|
Monitor.PulseAll(_gate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Buffers;
|
||||||
using FFmpeg.AutoGen;
|
using FFmpeg.AutoGen;
|
||||||
|
using SharpEmu.HLE.Host;
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Bink;
|
namespace SharpEmu.Libs.Bink;
|
||||||
|
|
||||||
@@ -13,13 +15,29 @@ namespace SharpEmu.Libs.Bink;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
||||||
{
|
{
|
||||||
|
private const int OutputAudioChannels = 2;
|
||||||
|
private const int OutputAudioBytesPerSample = sizeof(short);
|
||||||
|
|
||||||
|
private readonly object _decodeGate = new();
|
||||||
private AVFormatContext* _formatContext;
|
private AVFormatContext* _formatContext;
|
||||||
private AVCodecContext* _codecContext;
|
private AVCodecContext* _codecContext;
|
||||||
|
private AVCodecContext* _audioCodecContext;
|
||||||
private SwsContext* _swsContext;
|
private SwsContext* _swsContext;
|
||||||
|
private SwrContext* _swrContext;
|
||||||
private AVFrame* _frame;
|
private AVFrame* _frame;
|
||||||
|
private AVFrame* _audioFrame;
|
||||||
private AVPacket* _packet;
|
private AVPacket* _packet;
|
||||||
|
private IHostAudioStream? _audioStream;
|
||||||
private readonly int _videoStreamIndex;
|
private readonly int _videoStreamIndex;
|
||||||
|
private readonly int _audioStreamIndex;
|
||||||
|
private readonly int _audioOutputSampleRate;
|
||||||
|
private AVChannelLayout _swrInputLayout;
|
||||||
|
private AVSampleFormat _swrInputFormat = AVSampleFormat.AV_SAMPLE_FMT_NONE;
|
||||||
|
private int _swrInputSampleRate;
|
||||||
|
private bool _swrInputLayoutValid;
|
||||||
private bool _draining;
|
private bool _draining;
|
||||||
|
private bool _audioDraining;
|
||||||
|
private bool _audioFailed;
|
||||||
private int _disposed;
|
private int _disposed;
|
||||||
|
|
||||||
public uint Width { get; }
|
public uint Width { get; }
|
||||||
@@ -34,6 +52,10 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
|||||||
AVFormatContext* formatContext,
|
AVFormatContext* formatContext,
|
||||||
AVCodecContext* codecContext,
|
AVCodecContext* codecContext,
|
||||||
int videoStreamIndex,
|
int videoStreamIndex,
|
||||||
|
AVCodecContext* audioCodecContext,
|
||||||
|
int audioStreamIndex,
|
||||||
|
IHostAudioStream? audioStream,
|
||||||
|
int audioOutputSampleRate,
|
||||||
uint width,
|
uint width,
|
||||||
uint height,
|
uint height,
|
||||||
uint framesPerSecondNumerator,
|
uint framesPerSecondNumerator,
|
||||||
@@ -42,11 +64,16 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
|||||||
_formatContext = formatContext;
|
_formatContext = formatContext;
|
||||||
_codecContext = codecContext;
|
_codecContext = codecContext;
|
||||||
_videoStreamIndex = videoStreamIndex;
|
_videoStreamIndex = videoStreamIndex;
|
||||||
|
_audioCodecContext = audioCodecContext;
|
||||||
|
_audioStreamIndex = audioStreamIndex;
|
||||||
|
_audioStream = audioStream;
|
||||||
|
_audioOutputSampleRate = audioOutputSampleRate;
|
||||||
Width = width;
|
Width = width;
|
||||||
Height = height;
|
Height = height;
|
||||||
FramesPerSecondNumerator = framesPerSecondNumerator;
|
FramesPerSecondNumerator = framesPerSecondNumerator;
|
||||||
FramesPerSecondDenominator = framesPerSecondDenominator;
|
FramesPerSecondDenominator = framesPerSecondDenominator;
|
||||||
_frame = ffmpeg.av_frame_alloc();
|
_frame = ffmpeg.av_frame_alloc();
|
||||||
|
_audioFrame = audioCodecContext is null ? null : ffmpeg.av_frame_alloc();
|
||||||
_packet = ffmpeg.av_packet_alloc();
|
_packet = ffmpeg.av_packet_alloc();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +120,8 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
|||||||
|
|
||||||
AVFormatContext* formatContext = null;
|
AVFormatContext* formatContext = null;
|
||||||
AVCodecContext* codecContext = null;
|
AVCodecContext* codecContext = null;
|
||||||
|
AVCodecContext* audioCodecContext = null;
|
||||||
|
IHostAudioStream? audioStream = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
|
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
|
||||||
@@ -151,6 +180,28 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
|||||||
frameRate = new AVRational { num = 30, den = 1 };
|
frameRate = new AVRational { num = 30, den = 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var audioStreamIndex = TryOpenAudioDecoder(
|
||||||
|
formatContext,
|
||||||
|
out audioCodecContext,
|
||||||
|
out var audioOutputSampleRate);
|
||||||
|
if (audioStreamIndex >= 0 && audioCodecContext is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
audioStream = HostPlatform.Current.Audio.OpenStereoPcm16Stream(
|
||||||
|
checked((uint)audioOutputSampleRate));
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is InvalidOperationException or
|
||||||
|
ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] Bink audio output unavailable: {exception.Message}");
|
||||||
|
ffmpeg.avcodec_free_context(&audioCodecContext);
|
||||||
|
audioStreamIndex = -1;
|
||||||
|
audioOutputSampleRate = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var outputWidth = (uint)codecContext->width;
|
var outputWidth = (uint)codecContext->width;
|
||||||
var outputHeight = (uint)codecContext->height;
|
var outputHeight = (uint)codecContext->height;
|
||||||
if (maximumWidth > 0 && maximumHeight > 0 &&
|
if (maximumWidth > 0 && maximumHeight > 0 &&
|
||||||
@@ -175,12 +226,18 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
|||||||
formatContext,
|
formatContext,
|
||||||
codecContext,
|
codecContext,
|
||||||
videoStreamIndex,
|
videoStreamIndex,
|
||||||
|
audioCodecContext,
|
||||||
|
audioStreamIndex,
|
||||||
|
audioStream,
|
||||||
|
audioOutputSampleRate,
|
||||||
outputWidth,
|
outputWidth,
|
||||||
outputHeight,
|
outputHeight,
|
||||||
(uint)frameRate.num,
|
(uint)frameRate.num,
|
||||||
(uint)frameRate.den);
|
(uint)frameRate.den);
|
||||||
formatContext = null;
|
formatContext = null;
|
||||||
codecContext = null;
|
codecContext = null;
|
||||||
|
audioCodecContext = null;
|
||||||
|
audioStream = null;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (DllNotFoundException)
|
catch (DllNotFoundException)
|
||||||
@@ -194,6 +251,13 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
|||||||
ffmpeg.avcodec_free_context(&codecContext);
|
ffmpeg.avcodec_free_context(&codecContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (audioCodecContext is not null)
|
||||||
|
{
|
||||||
|
ffmpeg.avcodec_free_context(&audioCodecContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
audioStream?.Dispose();
|
||||||
|
|
||||||
if (formatContext is not null)
|
if (formatContext is not null)
|
||||||
{
|
{
|
||||||
ffmpeg.avformat_close_input(&formatContext);
|
ffmpeg.avformat_close_input(&formatContext);
|
||||||
@@ -201,52 +265,102 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int TryOpenAudioDecoder(
|
||||||
|
AVFormatContext* formatContext,
|
||||||
|
out AVCodecContext* codecContext,
|
||||||
|
out int outputSampleRate)
|
||||||
|
{
|
||||||
|
codecContext = null;
|
||||||
|
outputSampleRate = 0;
|
||||||
|
|
||||||
|
AVCodec* decoder = null;
|
||||||
|
var streamIndex = ffmpeg.av_find_best_stream(
|
||||||
|
formatContext, AVMediaType.AVMEDIA_TYPE_AUDIO, -1, -1, &decoder, 0);
|
||||||
|
if (streamIndex < 0 || decoder is null)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = ffmpeg.avcodec_alloc_context3(decoder);
|
||||||
|
if (candidate is null)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = formatContext->streams[streamIndex];
|
||||||
|
if (ffmpeg.avcodec_parameters_to_context(candidate, stream->codecpar) < 0)
|
||||||
|
{
|
||||||
|
ffmpeg.avcodec_free_context(&candidate);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate->thread_count = 0;
|
||||||
|
candidate->thread_type = ffmpeg.FF_THREAD_FRAME | ffmpeg.FF_THREAD_SLICE;
|
||||||
|
if (ffmpeg.avcodec_open2(candidate, decoder, null) < 0)
|
||||||
|
{
|
||||||
|
ffmpeg.avcodec_free_context(&candidate);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputSampleRate = candidate->sample_rate > 0 ? candidate->sample_rate : 48_000;
|
||||||
|
codecContext = candidate;
|
||||||
|
return streamIndex;
|
||||||
|
}
|
||||||
|
|
||||||
public bool TryDecodeNextFrame(Span<byte> destination)
|
public bool TryDecodeNextFrame(Span<byte> destination)
|
||||||
{
|
{
|
||||||
var stride = checked((int)(Width * 4));
|
lock (_decodeGate)
|
||||||
var required = (long)stride * Height;
|
|
||||||
if (destination.Length < required)
|
|
||||||
{
|
{
|
||||||
return false;
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
}
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!TryReceiveFrame())
|
var stride = checked((int)(Width * 4));
|
||||||
{
|
var required = (long)stride * Height;
|
||||||
return false;
|
if (destination.Length < required)
|
||||||
}
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
_swsContext = ffmpeg.sws_getCachedContext(
|
if (!TryReceiveFrame())
|
||||||
_swsContext,
|
{
|
||||||
_frame->width,
|
return false;
|
||||||
_frame->height,
|
}
|
||||||
(AVPixelFormat)_frame->format,
|
|
||||||
(int)Width,
|
|
||||||
(int)Height,
|
|
||||||
AVPixelFormat.AV_PIX_FMT_BGRA,
|
|
||||||
ffmpeg.SWS_FAST_BILINEAR,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null);
|
|
||||||
if (_swsContext is null)
|
|
||||||
{
|
|
||||||
ffmpeg.av_frame_unref(_frame);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
fixed (byte* destinationPointer = destination)
|
_swsContext = ffmpeg.sws_getCachedContext(
|
||||||
{
|
|
||||||
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
|
|
||||||
var destinationStrides = new int[4] { stride, 0, 0, 0 };
|
|
||||||
var convertedRows = ffmpeg.sws_scale(
|
|
||||||
_swsContext,
|
_swsContext,
|
||||||
_frame->data,
|
_frame->width,
|
||||||
_frame->linesize,
|
|
||||||
0,
|
|
||||||
_frame->height,
|
_frame->height,
|
||||||
destinationPlanes,
|
(AVPixelFormat)_frame->format,
|
||||||
destinationStrides);
|
(int)Width,
|
||||||
ffmpeg.av_frame_unref(_frame);
|
(int)Height,
|
||||||
return convertedRows == (int)Height;
|
AVPixelFormat.AV_PIX_FMT_BGRA,
|
||||||
|
ffmpeg.SWS_FAST_BILINEAR,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
if (_swsContext is null)
|
||||||
|
{
|
||||||
|
ffmpeg.av_frame_unref(_frame);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed (byte* destinationPointer = destination)
|
||||||
|
{
|
||||||
|
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
|
||||||
|
var destinationStrides = new int[4] { stride, 0, 0, 0 };
|
||||||
|
var convertedRows = ffmpeg.sws_scale(
|
||||||
|
_swsContext,
|
||||||
|
_frame->data,
|
||||||
|
_frame->linesize,
|
||||||
|
0,
|
||||||
|
_frame->height,
|
||||||
|
destinationPlanes,
|
||||||
|
destinationStrides);
|
||||||
|
ffmpeg.av_frame_unref(_frame);
|
||||||
|
return convertedRows == (int)Height;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,9 +405,17 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
|||||||
{
|
{
|
||||||
_draining = true;
|
_draining = true;
|
||||||
ffmpeg.avcodec_send_packet(_codecContext, null);
|
ffmpeg.avcodec_send_packet(_codecContext, null);
|
||||||
|
DrainAudioDecoder();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_packet->stream_index == _audioStreamIndex)
|
||||||
|
{
|
||||||
|
DecodeAudioPacket(_packet);
|
||||||
|
ffmpeg.av_packet_unref(_packet);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (_packet->stream_index != _videoStreamIndex)
|
if (_packet->stream_index != _videoStreamIndex)
|
||||||
{
|
{
|
||||||
ffmpeg.av_packet_unref(_packet);
|
ffmpeg.av_packet_unref(_packet);
|
||||||
@@ -311,6 +433,248 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DecodeAudioPacket(AVPacket* packet)
|
||||||
|
{
|
||||||
|
if (_audioCodecContext is null || _audioFrame is null || _audioFailed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendResult = ffmpeg.avcodec_send_packet(_audioCodecContext, packet);
|
||||||
|
if (sendResult == ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||||
|
{
|
||||||
|
DrainAvailableAudioFrames();
|
||||||
|
sendResult = ffmpeg.avcodec_send_packet(_audioCodecContext, packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sendResult < 0)
|
||||||
|
{
|
||||||
|
DisableAudio("packet decode failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DrainAvailableAudioFrames();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrainAudioDecoder()
|
||||||
|
{
|
||||||
|
if (_audioCodecContext is null || _audioFrame is null ||
|
||||||
|
_audioDraining || _audioFailed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_audioDraining = true;
|
||||||
|
var sendResult = ffmpeg.avcodec_send_packet(_audioCodecContext, null);
|
||||||
|
if (sendResult >= 0 || sendResult == ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||||
|
{
|
||||||
|
DrainAvailableAudioFrames();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrainAvailableAudioFrames()
|
||||||
|
{
|
||||||
|
while (_audioCodecContext is not null && _audioFrame is not null)
|
||||||
|
{
|
||||||
|
var receiveResult = ffmpeg.avcodec_receive_frame(_audioCodecContext, _audioFrame);
|
||||||
|
if (receiveResult == ffmpeg.AVERROR(ffmpeg.EAGAIN) ||
|
||||||
|
receiveResult == ffmpeg.AVERROR_EOF)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receiveResult < 0)
|
||||||
|
{
|
||||||
|
DisableAudio("frame decode failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SubmitAudioFrame())
|
||||||
|
{
|
||||||
|
ffmpeg.av_frame_unref(_audioFrame);
|
||||||
|
DisableAudio("host submission failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpeg.av_frame_unref(_audioFrame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool SubmitAudioFrame()
|
||||||
|
{
|
||||||
|
if (_audioStream is null || _audioFrame is null ||
|
||||||
|
_audioFrame->nb_samples <= 0 || _audioFrame->extended_data is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sampleRate = _audioFrame->sample_rate > 0
|
||||||
|
? _audioFrame->sample_rate
|
||||||
|
: _audioCodecContext->sample_rate;
|
||||||
|
if (sampleRate <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var inputLayout = _audioFrame->ch_layout;
|
||||||
|
var ownsInputLayout = false;
|
||||||
|
if (ffmpeg.av_channel_layout_check(&inputLayout) == 0)
|
||||||
|
{
|
||||||
|
inputLayout = _audioCodecContext->ch_layout;
|
||||||
|
}
|
||||||
|
if (ffmpeg.av_channel_layout_check(&inputLayout) == 0)
|
||||||
|
{
|
||||||
|
ffmpeg.av_channel_layout_default(
|
||||||
|
&inputLayout,
|
||||||
|
Math.Max(1, _audioFrame->ch_layout.nb_channels));
|
||||||
|
ownsInputLayout = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!EnsureAudioResampler(
|
||||||
|
&inputLayout,
|
||||||
|
(AVSampleFormat)_audioFrame->format,
|
||||||
|
sampleRate))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var maximumSamples = ffmpeg.swr_get_out_samples(
|
||||||
|
_swrContext, _audioFrame->nb_samples);
|
||||||
|
if (maximumSamples <= 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var outputBytes = checked(
|
||||||
|
maximumSamples * OutputAudioChannels * OutputAudioBytesPerSample);
|
||||||
|
var buffer = ArrayPool<byte>.Shared.Rent(outputBytes);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fixed (byte* output = buffer)
|
||||||
|
{
|
||||||
|
var outputPlanes = stackalloc byte*[1];
|
||||||
|
outputPlanes[0] = output;
|
||||||
|
var convertedSamples = ffmpeg.swr_convert(
|
||||||
|
_swrContext,
|
||||||
|
outputPlanes,
|
||||||
|
maximumSamples,
|
||||||
|
_audioFrame->extended_data,
|
||||||
|
_audioFrame->nb_samples);
|
||||||
|
if (convertedSamples < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var convertedBytes = checked(
|
||||||
|
convertedSamples * OutputAudioChannels * OutputAudioBytesPerSample);
|
||||||
|
return _audioStream.Submit(buffer.AsSpan(0, convertedBytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (ownsInputLayout)
|
||||||
|
{
|
||||||
|
ffmpeg.av_channel_layout_uninit(&inputLayout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool EnsureAudioResampler(
|
||||||
|
AVChannelLayout* inputLayout,
|
||||||
|
AVSampleFormat inputFormat,
|
||||||
|
int inputSampleRate)
|
||||||
|
{
|
||||||
|
var storedInputLayout = _swrInputLayout;
|
||||||
|
if (_swrContext is not null &&
|
||||||
|
_swrInputFormat == inputFormat &&
|
||||||
|
_swrInputSampleRate == inputSampleRate &&
|
||||||
|
ffmpeg.av_channel_layout_compare(&storedInputLayout, inputLayout) == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
FreeAudioResampler();
|
||||||
|
|
||||||
|
AVChannelLayout copiedInputLayout = default;
|
||||||
|
if (ffmpeg.av_channel_layout_copy(&copiedInputLayout, inputLayout) < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AVChannelLayout outputLayout = default;
|
||||||
|
ffmpeg.av_channel_layout_default(&outputLayout, OutputAudioChannels);
|
||||||
|
SwrContext* context = null;
|
||||||
|
var allocateResult = ffmpeg.swr_alloc_set_opts2(
|
||||||
|
&context,
|
||||||
|
&outputLayout,
|
||||||
|
AVSampleFormat.AV_SAMPLE_FMT_S16,
|
||||||
|
_audioOutputSampleRate,
|
||||||
|
&copiedInputLayout,
|
||||||
|
inputFormat,
|
||||||
|
inputSampleRate,
|
||||||
|
0,
|
||||||
|
null);
|
||||||
|
ffmpeg.av_channel_layout_uninit(&outputLayout);
|
||||||
|
if (allocateResult < 0 || context is null || ffmpeg.swr_init(context) < 0)
|
||||||
|
{
|
||||||
|
if (context is not null)
|
||||||
|
{
|
||||||
|
ffmpeg.swr_free(&context);
|
||||||
|
}
|
||||||
|
ffmpeg.av_channel_layout_uninit(&copiedInputLayout);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_swrContext = context;
|
||||||
|
_swrInputLayout = copiedInputLayout;
|
||||||
|
_swrInputLayoutValid = true;
|
||||||
|
_swrInputFormat = inputFormat;
|
||||||
|
_swrInputSampleRate = inputSampleRate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisableAudio(string reason)
|
||||||
|
{
|
||||||
|
if (_audioFailed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_audioFailed = true;
|
||||||
|
Console.Error.WriteLine($"[LOADER][WARN] Bink audio disabled: {reason}.");
|
||||||
|
FreeAudioResampler();
|
||||||
|
_audioStream?.Dispose();
|
||||||
|
_audioStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FreeAudioResampler()
|
||||||
|
{
|
||||||
|
if (_swrContext is not null)
|
||||||
|
{
|
||||||
|
var context = _swrContext;
|
||||||
|
ffmpeg.swr_free(&context);
|
||||||
|
_swrContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_swrInputLayoutValid)
|
||||||
|
{
|
||||||
|
var inputLayout = _swrInputLayout;
|
||||||
|
ffmpeg.av_channel_layout_uninit(&inputLayout);
|
||||||
|
_swrInputLayout = default;
|
||||||
|
_swrInputLayoutValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_swrInputFormat = AVSampleFormat.AV_SAMPLE_FMT_NONE;
|
||||||
|
_swrInputSampleRate = 0;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
@@ -318,38 +682,59 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_swsContext is not null)
|
lock (_decodeGate)
|
||||||
{
|
{
|
||||||
ffmpeg.sws_freeContext(_swsContext);
|
FreeAudioResampler();
|
||||||
_swsContext = null;
|
_audioStream?.Dispose();
|
||||||
}
|
_audioStream = null;
|
||||||
|
|
||||||
if (_packet is not null)
|
if (_swsContext is not null)
|
||||||
{
|
{
|
||||||
var packet = _packet;
|
ffmpeg.sws_freeContext(_swsContext);
|
||||||
ffmpeg.av_packet_free(&packet);
|
_swsContext = null;
|
||||||
_packet = null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (_frame is not null)
|
if (_packet is not null)
|
||||||
{
|
{
|
||||||
var frame = _frame;
|
var packet = _packet;
|
||||||
ffmpeg.av_frame_free(&frame);
|
ffmpeg.av_packet_free(&packet);
|
||||||
_frame = null;
|
_packet = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_codecContext is not null)
|
if (_frame is not null)
|
||||||
{
|
{
|
||||||
var codecContext = _codecContext;
|
var frame = _frame;
|
||||||
ffmpeg.avcodec_free_context(&codecContext);
|
ffmpeg.av_frame_free(&frame);
|
||||||
_codecContext = null;
|
_frame = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_formatContext is not null)
|
if (_audioFrame is not null)
|
||||||
{
|
{
|
||||||
var formatContext = _formatContext;
|
var frame = _audioFrame;
|
||||||
ffmpeg.avformat_close_input(&formatContext);
|
ffmpeg.av_frame_free(&frame);
|
||||||
_formatContext = null;
|
_audioFrame = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_codecContext is not null)
|
||||||
|
{
|
||||||
|
var codecContext = _codecContext;
|
||||||
|
ffmpeg.avcodec_free_context(&codecContext);
|
||||||
|
_codecContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_audioCodecContext is not null)
|
||||||
|
{
|
||||||
|
var codecContext = _audioCodecContext;
|
||||||
|
ffmpeg.avcodec_free_context(&codecContext);
|
||||||
|
_audioCodecContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_formatContext is not null)
|
||||||
|
{
|
||||||
|
var formatContext = _formatContext;
|
||||||
|
ffmpeg.avformat_close_input(&formatContext);
|
||||||
|
_formatContext = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user