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()
{