From ee6ca3a2a1aebdd4f2be7871cc3b61bb1cdcab39 Mon Sep 17 00:00:00 2001 From: BlackSpirits Date: Wed, 16 Sep 2026 18:45:25 +0200 Subject: [PATCH] Fail over when FFmpeg audio clock queries become unhealthy --- .../Ffmpeg/Audio/AudioQueueAudioSink.cs | 58 ++++++++++--- .../VideoPlayers/Ffmpeg/Audio/IAudioSink.cs | 25 ++++++ .../Ffmpeg/Audio/SilentAudioSink.cs | 17 +++- .../Ffmpeg/Audio/WaveOutAudioSink.cs | 58 +++++++++---- .../Logic/VideoPlayers/Ffmpeg/FfmpegPlayer.cs | 83 +++++++++++++++---- tests/UI/Logic/FfmpegPlayerTests.cs | 64 ++++++++++++++ 6 files changed, 261 insertions(+), 44 deletions(-) diff --git a/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/AudioQueueAudioSink.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/AudioQueueAudioSink.cs index fc4f46037ca..91b30e2ecce 100644 --- a/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/AudioQueueAudioSink.cs +++ b/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/AudioQueueAudioSink.cs @@ -122,6 +122,8 @@ private struct AudioTimeStamp private double _sampleBase; private double _lastSampleTime; private long _bytesWritten; + private bool _clockBaselineValid = true; + private int _clockReadFailures; public void Open(int sampleRate, int channels) { @@ -176,6 +178,8 @@ public void Open(int sampleRate, int channels) _sampleBase = 0; _lastSampleTime = 0; _bytesWritten = 0; + _clockBaselineValid = true; + _clockReadFailures = 0; } } @@ -214,16 +218,35 @@ public double PlayedSeconds { get { - lock (_lock) + TryGetPlayedSeconds(out var playedSeconds); + return playedSeconds; + } + } + + public bool TryGetPlayedSeconds(out double playedSeconds) + { + lock (_lock) + { + if (_queue == IntPtr.Zero || _bytesPerSecond == 0) { - if (_queue == IntPtr.Zero || _bytesPerSecond == 0) - { - return 0; - } + playedSeconds = 0; + return false; + } - var played = Math.Max(0, CurrentSampleTime() - _sampleBase) * _blockAlign; - return Math.Min(played, _bytesWritten) / _bytesPerSecond; + if (!_clockBaselineValid) + { + // A failed post-reset sample-time read leaves no trustworthy delta for this + // serial. Report zero played time and let the player leave the device clock; + // a later Reset can establish a fresh baseline. + playedSeconds = 0; + return false; } + + var readSucceeded = TryCurrentSampleTime(out var sampleTime); + _clockReadFailures = AudioSinkClockHealth.NextFailureCount(_clockReadFailures, readSucceeded); + var played = Math.Max(0, sampleTime - _sampleBase) * _blockAlign; + playedSeconds = Math.Min(played, _bytesWritten) / _bytesPerSecond; + return AudioSinkClockHealth.IsHealthy(baselineValid: true, _clockReadFailures); } } @@ -240,18 +263,28 @@ private static double SampleBaseAfterUnderrun(double currentSampleTime, long byt /// Queue clock in sample frames; the last known value when the queue is not running. private double CurrentSampleTime() { + var readSucceeded = TryCurrentSampleTime(out var sampleTime); + _clockReadFailures = AudioSinkClockHealth.NextFailureCount(_clockReadFailures, readSucceeded); + return sampleTime; + } + + private bool TryCurrentSampleTime(out double sampleTime) + { + sampleTime = _lastSampleTime; if (!_started) { - return _lastSampleTime; + return true; } var result = AudioQueueGetCurrentTime(_queue, IntPtr.Zero, out var time, IntPtr.Zero); - if (result == 0 && !double.IsNaN(time.mSampleTime)) + if (result != 0 || !double.IsFinite(time.mSampleTime)) { - _lastSampleTime = time.mSampleTime; + return false; } - return _lastSampleTime; + _lastSampleTime = time.mSampleTime; + sampleTime = _lastSampleTime; + return true; } public bool Write(ReadOnlySpan pcm, int serial) @@ -375,7 +408,8 @@ public void Reset(int serial) _inFlight = 0; _nextBuffer = 0; _bytesWritten = 0; - _sampleBase = CurrentSampleTime(); + _clockReadFailures = 0; + _clockBaselineValid = TryCurrentSampleTime(out _sampleBase); _startFailed = false; Volatile.Write(ref _serial, AudioSinkResetFence.SerialAfterReset(serial, succeeded: true)); _resetting = false; diff --git a/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/IAudioSink.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/IAudioSink.cs index cd796eac8dc..f0476087523 100644 --- a/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/IAudioSink.cs +++ b/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/IAudioSink.cs @@ -24,6 +24,13 @@ public interface IAudioSink : IDisposable /// Seconds of audio played since the last . double PlayedSeconds { get; } + /// + /// Returns the best known played position and whether the native clock read is currently + /// trustworthy. A false result still supplies the last known position so the player can + /// switch clocks without a discontinuity. + /// + bool TryGetPlayedSeconds(out double playedSeconds); + /// /// Drop everything queued, make the only serial accepted by /// , and restart the played counter at zero (seek, stop). @@ -43,3 +50,21 @@ internal static int SerialAfterReset(int requestedSerial, bool succeeded) return succeeded ? requestedSerial : RejectedSerial; } } + + +internal static class AudioSinkClockHealth +{ + internal const int ConsecutiveFailuresBeforeUnhealthy = 2; + + internal static int NextFailureCount(int currentFailures, bool readSucceeded) + { + return readSucceeded + ? 0 + : Math.Min(ConsecutiveFailuresBeforeUnhealthy, currentFailures + 1); + } + + internal static bool IsHealthy(bool baselineValid, int consecutiveFailures) + { + return baselineValid && consecutiveFailures < ConsecutiveFailuresBeforeUnhealthy; + } +} diff --git a/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/SilentAudioSink.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/SilentAudioSink.cs index c649c50e4c1..7ff250eb3ee 100644 --- a/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/SilentAudioSink.cs +++ b/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/SilentAudioSink.cs @@ -30,7 +30,22 @@ public void Open(int sampleRate, int channels) Reset(0); } - public double PlayedSeconds => Math.Min(_clock.Elapsed.TotalSeconds, Interlocked.Read(ref _bytesWritten) / (double)_bytesPerSecond); + public double PlayedSeconds + { + get + { + TryGetPlayedSeconds(out var playedSeconds); + return playedSeconds; + } + } + + public bool TryGetPlayedSeconds(out double playedSeconds) + { + playedSeconds = Math.Min( + _clock.Elapsed.TotalSeconds, + Interlocked.Read(ref _bytesWritten) / (double)_bytesPerSecond); + return true; + } public bool Write(ReadOnlySpan pcm, int serial) { diff --git a/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/WaveOutAudioSink.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/WaveOutAudioSink.cs index d09427530c5..7845d2f84cf 100644 --- a/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/WaveOutAudioSink.cs +++ b/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/WaveOutAudioSink.cs @@ -158,6 +158,8 @@ private struct MmTime private uint _lastPositionType; private uint _lastPositionCounter; private long _positionWrapBaseBytes; + private bool _clockBaselineValid = true; + private int _clockReadFailures; public void Open(int sampleRate, int channels) { @@ -232,6 +234,8 @@ public void Open(int sampleRate, int channels) _lastPositionType = 0; _lastPositionCounter = 0; _positionWrapBaseBytes = 0; + _clockBaselineValid = true; + _clockReadFailures = 0; _serial = 0; _paused = false; } @@ -241,34 +245,53 @@ public double PlayedSeconds { get { - lock (_lock) + TryGetPlayedSeconds(out var playedSeconds); + return playedSeconds; + } + } + + public bool TryGetPlayedSeconds(out double playedSeconds) + { + lock (_lock) + { + if (_device == IntPtr.Zero || _bytesPerSecond == 0) { - if (_device == IntPtr.Zero || _bytesPerSecond == 0) - { - return 0; - } + playedSeconds = 0; + return false; + } - var raw = GetRawPositionBytes(); - return Math.Max(0, raw - _positionBase) / (double)_bytesPerSecond; + if (!_clockBaselineValid) + { + // Without a trustworthy post-reset baseline the driver's absolute counter cannot + // be converted into a safe delta. Fall back from the audio master clock at the + // anchor itself; a later Reset can establish a new baseline and recover. + playedSeconds = 0; + return false; } + + var readSucceeded = TryGetRawPositionBytes(out var raw); + _clockReadFailures = AudioSinkClockHealth.NextFailureCount(_clockReadFailures, readSucceeded); + playedSeconds = Math.Max(0, raw - _positionBase) / (double)_bytesPerSecond; + return AudioSinkClockHealth.IsHealthy(baselineValid: true, _clockReadFailures); } } - private long GetRawPositionBytes() + private bool TryGetRawPositionBytes(out long position) { + position = _lastRawPosition; // Samples are Microsoft's preferred waveform position format. Drivers may still answer // in another supported MMTIME format, so normalize the returned type rather than assuming // the request was honoured. var time = new MmTime { wType = WaveOutPosition.TimeSamples }; if (waveOutGetPosition(_device, ref time, (uint)sizeof(MmTime)) != MmSysErrNoError) { - return _lastRawPosition; + return false; } var converted = WaveOutPosition.CounterToBytes(time.wType, time.u, _blockAlign, _bytesPerSecond); if (!converted.HasValue) { - return _lastRawPosition; + return false; } var wrapBytes = WaveOutPosition.CounterWrapBytes(time.wType, _blockAlign, _bytesPerSecond); @@ -290,17 +313,18 @@ private long GetRawPositionBytes() wrapBytes); } - var position = _positionWrapBaseBytes + converted.Value; - if (position < _lastRawPosition) + var absolutePosition = _positionWrapBaseBytes + converted.Value; + if (absolutePosition < _lastRawPosition) { // Millisecond conversion can round a format switch slightly backwards. - position = _lastRawPosition; + absolutePosition = _lastRawPosition; } _lastPositionType = time.wType; _lastPositionCounter = time.u; - _lastRawPosition = position; - return position; + _lastRawPosition = absolutePosition; + position = absolutePosition; + return true; } public bool Write(ReadOnlySpan pcm, int serial) @@ -376,7 +400,9 @@ public void Reset(int serial) _lastPositionType = 0; _lastPositionCounter = 0; _positionWrapBaseBytes = 0; - _positionBase = GetRawPositionBytes(); + _clockReadFailures = 0; + _clockBaselineValid = TryGetRawPositionBytes(out var positionBase); + _positionBase = positionBase; _nextBuffer = 0; for (var i = 0; i < BufferCount; i++) { diff --git a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPlayer.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPlayer.cs index 3d5f6d363f8..8da2fdb1399 100644 --- a/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPlayer.cs +++ b/src/ui/Logic/VideoPlayers/Ffmpeg/FfmpegPlayer.cs @@ -536,6 +536,19 @@ internal static double AudioClockFailoverPosition( : wallClockPosition; } + internal static bool AudioClockReadFailureIsDeviceFailure( + bool clockValid, + bool closing, + int audioAnchorSerial, + int currentSerial, + int requestedSerial) + { + return !clockValid && + !closing && + audioAnchorSerial == currentSerial && + currentSerial == requestedSerial; + } + private static double TimestampToSeconds(long timestamp, AVRational timeBase) { return timestamp == ffmpeg.AV_NOPTS_VALUE ? double.NaN : timestamp * ffmpeg.av_q2d(timeBase); @@ -961,6 +974,9 @@ public bool HasPlaybackRestartedSince(long stopwatchTimestamp) /// Current media time while playing. private double Clock() { + var clockFailed = false; + var failedPosition = 0.0; + if (_hasAudio) { lock (_seekLock) @@ -969,11 +985,41 @@ private double Clock() !double.IsNaN(_audioAnchorPts) && _audioAnchorSerial == _currentSerial) { - return _audioAnchorPts + _audioSink.PlayedSeconds * _audioSpeed; + var clockValid = _audioSink.TryGetPlayedSeconds(out var playedSeconds); + var audioPosition = _audioAnchorPts + playedSeconds * _audioSpeed; + if (clockValid) + { + return audioPosition; + } + + if (AudioClockReadFailureIsDeviceFailure( + clockValid, + _closing, + _audioAnchorSerial, + _currentSerial, + _requestedSerial)) + { + FailOverAudioClockLocked(audioPosition); + clockFailed = true; + failedPosition = audioPosition; + } + else + { + // A newer seek already owns the future state. Keep reporting the last + // known old-serial position until that transaction commits or rolls back. + return audioPosition; + } } } } + if (clockFailed) + { + Se.LogError("ffmpeg player: audio clock query failed, continuing with wall-clock timing"); + _presentWake.Set(); + return failedPosition; + } + return WallClockPosition(_wallClockBase, _wallClock.Elapsed.TotalSeconds, _speed); } @@ -1969,28 +2015,17 @@ private bool TryFailOverAudioClock(int serial) return false; } + _audioSink.TryGetPlayedSeconds(out var playedSeconds); var wallClockPosition = _wallClockBase + _wallClock.Elapsed.TotalSeconds * _speed; var position = AudioClockFailoverPosition( _audioAnchorPts, _audioAnchorSerial, serial, - _audioSink.PlayedSeconds, + playedSeconds, _audioSpeed, wallClockPosition); - _audioSinkFailed = true; - _audioAnchorPts = double.NaN; - _audioAnchorSerial = -1; - _wallClockBase = position; - if (_playing) - { - _wallClock.Restart(); - } - else - { - _pausedPosition = position; - _wallClock.Reset(); - } + FailOverAudioClockLocked(position); } Se.LogError("ffmpeg player: audio output failed, continuing with wall-clock timing"); @@ -1998,6 +2033,24 @@ private bool TryFailOverAudioClock(int serial) return true; } + /// Transitions from an audio-device clock to wall-clock timing. Called under _seekLock. + private void FailOverAudioClockLocked(double position) + { + _audioSinkFailed = true; + _audioAnchorPts = double.NaN; + _audioAnchorSerial = -1; + _wallClockBase = position; + if (_playing) + { + _wallClock.Restart(); + } + else + { + _pausedPosition = position; + _wallClock.Reset(); + } + } + private bool WriteAudioChunk( byte[] pcm, int bytes, diff --git a/tests/UI/Logic/FfmpegPlayerTests.cs b/tests/UI/Logic/FfmpegPlayerTests.cs index 58e69b4ba3b..101220c5047 100644 --- a/tests/UI/Logic/FfmpegPlayerTests.cs +++ b/tests/UI/Logic/FfmpegPlayerTests.cs @@ -136,6 +136,18 @@ public void SilentAudioSink_PlayedSecondsNeverExceedsWrittenAudio() Assert.InRange(sink.PlayedSeconds, 0.09, 0.101); } + [Fact] + public void SilentAudioSink_ClockReadingIsAlwaysValid() + { + using var sink = new SilentAudioSink(); + sink.Open(48000, 2); + sink.Resume(); + + Assert.True(sink.Write(new byte[4800], serial: 0)); + Assert.True(sink.TryGetPlayedSeconds(out var playedSeconds)); + Assert.InRange(playedSeconds, 0, 0.0251); + } + [Fact] public void SilentAudioSink_Reset_AbortsBlockedWrite() { @@ -206,6 +218,58 @@ public void AudioWriteFailureIsDeviceFailure_RequiresRejectedCurrentSerialWithou requestedSerial)); } + [Fact] + public void AudioSinkClockHealth_ToleratesOneTransientReadFailure() + { + var failures = AudioSinkClockHealth.NextFailureCount(0, readSucceeded: false); + Assert.Equal(1, failures); + Assert.True(AudioSinkClockHealth.IsHealthy(baselineValid: true, failures)); + + failures = AudioSinkClockHealth.NextFailureCount(failures, readSucceeded: true); + Assert.Equal(0, failures); + Assert.True(AudioSinkClockHealth.IsHealthy(baselineValid: true, failures)); + } + + [Fact] + public void AudioSinkClockHealth_TwoConsecutiveFailuresBecomeUnhealthy() + { + var failures = AudioSinkClockHealth.NextFailureCount(0, readSucceeded: false); + failures = AudioSinkClockHealth.NextFailureCount(failures, readSucceeded: false); + + Assert.Equal(AudioSinkClockHealth.ConsecutiveFailuresBeforeUnhealthy, failures); + Assert.False(AudioSinkClockHealth.IsHealthy(baselineValid: true, failures)); + } + + [Fact] + public void AudioSinkClockHealth_InvalidBaselineStaysUnhealthy() + { + Assert.False(AudioSinkClockHealth.IsHealthy(baselineValid: false, consecutiveFailures: 0)); + } + + [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, 3, 4, 4, false)] + [InlineData(false, false, 4, 4, 5, false)] + public void AudioClockReadFailureIsDeviceFailure_RequiresInvalidCurrentLatestSerial( + bool clockValid, + bool closing, + int audioAnchorSerial, + int currentSerial, + int requestedSerial, + bool expected) + { + Assert.Equal( + expected, + FfmpegPlayer.AudioClockReadFailureIsDeviceFailure( + clockValid, + closing, + audioAnchorSerial, + currentSerial, + requestedSerial)); + } + [Fact] public void AudioClockFailoverPosition_PreservesLastPlayedAudioPosition() {