diff --git a/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs b/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs
index 36124b25..907c2f08 100644
--- a/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs
+++ b/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs
@@ -133,10 +133,12 @@ internal static class Bink2MovieBridge
if (_playback.IsFinished)
{
var completedPath = _activePath;
+ var progress = _playback.PlaybackProgress;
CloseActiveLocked();
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge completed: " +
- Path.GetFileName(completedPath));
+ $"{Path.GetFileName(completedPath)} after " +
+ $"{progress.Seconds:F2}s at frame {progress.FrameIndex}");
AttachNextQueuedMovieLocked();
}
return false;
diff --git a/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs b/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs
index aa0600f4..1f392e6e 100644
--- a/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs
+++ b/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
+using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Bink;
@@ -36,6 +37,8 @@ internal sealed class BinkFramePlayback : IDisposable
private long _currentFrameIndex = -1;
private long _nextDecodedFrameIndex;
private long _playbackStartTimestamp;
+ private double _audioStartSeconds;
+ private long _lastSkewTraceTimestamp;
private bool _playbackClockStarted;
private bool _decoderCompleted;
private bool _stopRequested;
@@ -83,6 +86,26 @@ internal sealed class BinkFramePlayback : IDisposable
}
}
+ ///
+ /// 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.
+ ///
+ internal (double Seconds, long FrameIndex) PlaybackProgress
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return (
+ _playbackClockStarted
+ ? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
+ : 0,
+ _currentFrameIndex);
+ }
+ }
+ }
+
internal bool TryGetFrame(
bool advanceClock,
out byte[] pixels,
@@ -118,14 +141,13 @@ internal sealed class BinkFramePlayback : IDisposable
if (advanceClock && !_playbackClockStarted)
{
_playbackStartTimestamp = Stopwatch.GetTimestamp();
+ _audioStartSeconds = GuestAudioClock.PlayedSeconds;
_playbackClockStarted = true;
}
- var elapsedSeconds = _playbackClockStarted
- ? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
- : 0;
- var targetFrameIndex = (long)Math.Floor(
- elapsedSeconds * FramesPerSecondNumerator / FramesPerSecondDenominator);
+ var elapsedSeconds = CurrentPlaybackSecondsLocked();
+ TraceClockSkewLocked();
+ var targetFrameIndex = CurrentTargetFrameIndexLocked();
DecodedFrame? replacement = null;
while (_decodedFrames.Count > 0 &&
_decodedFrames.Peek().Index <= targetFrameIndex)
@@ -166,6 +188,86 @@ internal sealed class BinkFramePlayback : IDisposable
}
}
+ ///
+ /// 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.
+ ///
+ private static readonly bool _followGuestAudioClock = !string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_MOVIE_CLOCK"),
+ "wall",
+ StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// 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.
+ ///
+ 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);
+
+ ///
+ /// 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 .
+ ///
+ 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}");
+ }
+
+ ///
+ /// 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.
+ ///
+ private long CurrentTargetFrameIndexLocked() =>
+ _playbackClockStarted
+ ? (long)Math.Floor(
+ CurrentPlaybackSecondsLocked() *
+ FramesPerSecondNumerator / FramesPerSecondDenominator)
+ : -1;
+
private void DecodeLoop()
{
try
@@ -199,8 +301,28 @@ internal sealed class BinkFramePlayback : IDisposable
lock (_gate)
{
- _decodedFrames.Enqueue(new DecodedFrame(
- _nextDecodedFrameIndex++, destination));
+ var frameIndex = _nextDecodedFrameIndex++;
+
+ // 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);
}
}
diff --git a/src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs b/src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs
index ab0bd581..0551ad70 100644
--- a/src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs
+++ b/src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs
@@ -1,7 +1,9 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+using System.Buffers;
using FFmpeg.AutoGen;
+using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Bink;
@@ -13,13 +15,29 @@ namespace SharpEmu.Libs.Bink;
///
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 AVCodecContext* _codecContext;
+ private AVCodecContext* _audioCodecContext;
private SwsContext* _swsContext;
+ private SwrContext* _swrContext;
private AVFrame* _frame;
+ private AVFrame* _audioFrame;
private AVPacket* _packet;
+ private IHostAudioStream? _audioStream;
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 _audioDraining;
+ private bool _audioFailed;
private int _disposed;
public uint Width { get; }
@@ -34,6 +52,10 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
AVFormatContext* formatContext,
AVCodecContext* codecContext,
int videoStreamIndex,
+ AVCodecContext* audioCodecContext,
+ int audioStreamIndex,
+ IHostAudioStream? audioStream,
+ int audioOutputSampleRate,
uint width,
uint height,
uint framesPerSecondNumerator,
@@ -42,11 +64,16 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
_formatContext = formatContext;
_codecContext = codecContext;
_videoStreamIndex = videoStreamIndex;
+ _audioCodecContext = audioCodecContext;
+ _audioStreamIndex = audioStreamIndex;
+ _audioStream = audioStream;
+ _audioOutputSampleRate = audioOutputSampleRate;
Width = width;
Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator;
_frame = ffmpeg.av_frame_alloc();
+ _audioFrame = audioCodecContext is null ? null : ffmpeg.av_frame_alloc();
_packet = ffmpeg.av_packet_alloc();
}
@@ -93,6 +120,8 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
AVFormatContext* formatContext = null;
AVCodecContext* codecContext = null;
+ AVCodecContext* audioCodecContext = null;
+ IHostAudioStream? audioStream = null;
try
{
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 };
}
+ 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 outputHeight = (uint)codecContext->height;
if (maximumWidth > 0 && maximumHeight > 0 &&
@@ -175,12 +226,18 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
formatContext,
codecContext,
videoStreamIndex,
+ audioCodecContext,
+ audioStreamIndex,
+ audioStream,
+ audioOutputSampleRate,
outputWidth,
outputHeight,
(uint)frameRate.num,
(uint)frameRate.den);
formatContext = null;
codecContext = null;
+ audioCodecContext = null;
+ audioStream = null;
return true;
}
catch (DllNotFoundException)
@@ -194,6 +251,13 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
ffmpeg.avcodec_free_context(&codecContext);
}
+ if (audioCodecContext is not null)
+ {
+ ffmpeg.avcodec_free_context(&audioCodecContext);
+ }
+
+ audioStream?.Dispose();
+
if (formatContext is not null)
{
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 destination)
{
- var stride = checked((int)(Width * 4));
- var required = (long)stride * Height;
- if (destination.Length < required)
+ lock (_decodeGate)
{
- return false;
- }
+ if (Volatile.Read(ref _disposed) != 0)
+ {
+ return false;
+ }
- if (!TryReceiveFrame())
- {
- return false;
- }
+ var stride = checked((int)(Width * 4));
+ var required = (long)stride * Height;
+ if (destination.Length < required)
+ {
+ return false;
+ }
- _swsContext = ffmpeg.sws_getCachedContext(
- _swsContext,
- _frame->width,
- _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;
- }
+ if (!TryReceiveFrame())
+ {
+ 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 = ffmpeg.sws_getCachedContext(
_swsContext,
- _frame->data,
- _frame->linesize,
- 0,
+ _frame->width,
_frame->height,
- destinationPlanes,
- destinationStrides);
- ffmpeg.av_frame_unref(_frame);
- return convertedRows == (int)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)
+ {
+ 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;
ffmpeg.avcodec_send_packet(_codecContext, null);
+ DrainAudioDecoder();
return true;
}
+ if (_packet->stream_index == _audioStreamIndex)
+ {
+ DecodeAudioPacket(_packet);
+ ffmpeg.av_packet_unref(_packet);
+ continue;
+ }
+
if (_packet->stream_index != _videoStreamIndex)
{
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.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.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()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
@@ -318,38 +682,59 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
return;
}
- if (_swsContext is not null)
+ lock (_decodeGate)
{
- ffmpeg.sws_freeContext(_swsContext);
- _swsContext = null;
- }
+ FreeAudioResampler();
+ _audioStream?.Dispose();
+ _audioStream = null;
- if (_packet is not null)
- {
- var packet = _packet;
- ffmpeg.av_packet_free(&packet);
- _packet = null;
- }
+ if (_swsContext is not null)
+ {
+ ffmpeg.sws_freeContext(_swsContext);
+ _swsContext = null;
+ }
- if (_frame is not null)
- {
- var frame = _frame;
- ffmpeg.av_frame_free(&frame);
- _frame = null;
- }
+ if (_packet is not null)
+ {
+ var packet = _packet;
+ ffmpeg.av_packet_free(&packet);
+ _packet = null;
+ }
- if (_codecContext is not null)
- {
- var codecContext = _codecContext;
- ffmpeg.avcodec_free_context(&codecContext);
- _codecContext = null;
- }
+ if (_frame is not null)
+ {
+ var frame = _frame;
+ ffmpeg.av_frame_free(&frame);
+ _frame = null;
+ }
- if (_formatContext is not null)
- {
- var formatContext = _formatContext;
- ffmpeg.avformat_close_input(&formatContext);
- _formatContext = null;
+ if (_audioFrame is not null)
+ {
+ var frame = _audioFrame;
+ ffmpeg.av_frame_free(&frame);
+ _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;
+ }
}
}
}