diff --git a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPlayer.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPlayer.cs index 8927549cc1..5c8ebc069c 100644 --- a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPlayer.cs +++ b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPlayer.cs @@ -410,6 +410,32 @@ internal static bool AudioWriteCanAnchor(bool writeAccepted, int serial, int cur return writeAccepted && serial == currentSerial; } + internal static bool AudioWriteFailureIsDeviceFailure( + bool writeAccepted, + bool closing, + int serial, + int currentSerial, + int requestedSerial) + { + return !writeAccepted && + !closing && + serial == currentSerial && + serial == requestedSerial; + } + + internal static double AudioClockFailoverPosition( + double audioAnchorPts, + int audioAnchorSerial, + int serial, + double playedSeconds, + double audioSpeed, + double wallClockPosition) + { + return !double.IsNaN(audioAnchorPts) && audioAnchorSerial == serial + ? audioAnchorPts + playedSeconds * audioSpeed + : wallClockPosition; + } + private static double TimestampToSeconds(long timestamp, AVRational timeBase) { return timestamp == ffmpeg.AV_NOPTS_VALUE ? double.NaN : timestamp * ffmpeg.av_q2d(timeBase); @@ -469,6 +495,7 @@ private sealed class Session : IDisposable private double _audioAnchorPts = double.NaN; // media time of the first sample written since the last sink reset private int _audioAnchorSerial = -1; private double _audioSpeed = 1.0; // speed the audio currently queued was resampled for + private bool _audioSinkFailed; // Restart tracking (see IVideoPlayer.HasPlaybackRestartedSince). private long _lastRestartTimestamp; @@ -767,7 +794,9 @@ private double Clock() { lock (_seekLock) { - if (!double.IsNaN(_audioAnchorPts) && _audioAnchorSerial == _currentSerial) + if (!_audioSinkFailed && + !double.IsNaN(_audioAnchorPts) && + _audioAnchorSerial == _currentSerial) { return _audioAnchorPts + _audioSink.PlayedSeconds * _audioSpeed; } @@ -891,6 +920,7 @@ private void PerformSeek(double target, int serial) _audioAnchorPts = double.NaN; _audioAnchorSerial = -1; _audioSpeed = _speed; + _audioSinkFailed = false; _wallClockBase = target; if (_playing) { @@ -1385,6 +1415,16 @@ private void AudioLoop() } var packet = entry.Packet; + if (ShouldDrainAudioAfterSinkFailure()) + { + if (packet != null) + { + ffmpeg.av_packet_free(&packet); + } + + continue; + } + if (packet != null && packet->stream_index != codecStreamIndex) { if (codec != null) @@ -1552,7 +1592,11 @@ private void AudioLoop() var writeAccepted = _audioSink.Write(new ReadOnlySpan(pcm, 0, bytes), serial); if (!writeAccepted) { - break; // reset, device failure, seek or close while waiting for room + // A seek/reset/close also rejects writes and is a normal interruption. + // Only a rejection for the still-current serial means the device path + // itself failed; switch the master clock to wall time in that case. + TryFailOverAudioClock(serial); + break; } if (!anchored) @@ -1608,6 +1652,57 @@ private void AudioLoop() } } + private bool ShouldDrainAudioAfterSinkFailure() + { + lock (_seekLock) + { + return _audioSinkFailed; + } + } + + private bool TryFailOverAudioClock(int serial) + { + lock (_seekLock) + { + if (!AudioWriteFailureIsDeviceFailure( + writeAccepted: false, + _closing, + serial, + _currentSerial, + _requestedSerial)) + { + return false; + } + + var wallClockPosition = _wallClockBase + _wallClock.Elapsed.TotalSeconds * _speed; + var position = AudioClockFailoverPosition( + _audioAnchorPts, + _audioAnchorSerial, + serial, + _audioSink.PlayedSeconds, + _audioSpeed, + wallClockPosition); + + _audioSinkFailed = true; + _audioAnchorPts = double.NaN; + _audioAnchorSerial = -1; + _wallClockBase = position; + if (_playing) + { + _wallClock.Restart(); + } + else + { + _pausedPosition = position; + _wallClock.Reset(); + } + } + + Se.LogError("ffmpeg player: audio output failed, continuing with wall-clock timing"); + _presentWake.Set(); + return true; + } + private static void ApplyGain(byte[] pcm, int bytes, float gain) { if (Math.Abs(gain - 1f) < 0.001f) diff --git a/tests/UI/Logic/FfmpegPlayerTests.cs b/tests/UI/Logic/FfmpegPlayerTests.cs index f209441456..9427280aa5 100644 --- a/tests/UI/Logic/FfmpegPlayerTests.cs +++ b/tests/UI/Logic/FfmpegPlayerTests.cs @@ -182,6 +182,56 @@ public void AudioWriteCanAnchor_RequiresAcceptedCurrentSerial() Assert.False(FfmpegPlayer.AudioWriteCanAnchor(writeAccepted: true, serial: 4, currentSerial: 5)); } + [Theory] + [InlineData(false, false, 4, 4, 4, true)] + [InlineData(true, false, 4, 4, 4, false)] + [InlineData(false, true, 4, 4, 4, false)] + [InlineData(false, false, 4, 5, 5, false)] + [InlineData(false, false, 4, 4, 5, false)] + public void AudioWriteFailureIsDeviceFailure_RequiresRejectedCurrentSerialWithoutClose( + bool writeAccepted, + bool closing, + int serial, + int currentSerial, + int requestedSerial, + bool expected) + { + Assert.Equal( + expected, + FfmpegPlayer.AudioWriteFailureIsDeviceFailure( + writeAccepted, + closing, + serial, + currentSerial, + requestedSerial)); + } + + [Fact] + public void AudioClockFailoverPosition_PreservesLastPlayedAudioPosition() + { + Assert.Equal( + 12.5, + FfmpegPlayer.AudioClockFailoverPosition( + audioAnchorPts: 10, + audioAnchorSerial: 7, + serial: 7, + playedSeconds: 1.25, + audioSpeed: 2, + wallClockPosition: 99), + precision: 6); + + Assert.Equal( + 99, + FfmpegPlayer.AudioClockFailoverPosition( + audioAnchorPts: double.NaN, + audioAnchorSerial: -1, + serial: 7, + playedSeconds: 1.25, + audioSpeed: 2, + wallClockPosition: 99), + precision: 6); + } + [Fact] public void AudioQueueStartFence_FailsClosedOnAnyCoreAudioStartError() {