diff --git a/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/WaveOutAudioSink.cs b/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/WaveOutAudioSink.cs index 01cf6fdf97..d09427530c 100644 --- a/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/WaveOutAudioSink.cs +++ b/src/ui/Logic/VideoPlayers/Ffmpeg/Audio/WaveOutAudioSink.cs @@ -6,6 +6,49 @@ namespace Nikse.SubtitleEdit.Logic.VideoPlayers.Ffmpeg.Audio; +internal static class WaveOutPosition +{ + internal const uint TimeMilliseconds = 0x0001; + internal const uint TimeSamples = 0x0002; + internal const uint TimeBytes = 0x0004; + + internal static long? CounterToBytes(uint type, uint value, int blockAlign, int bytesPerSecond) + { + return type switch + { + TimeBytes => value, + TimeSamples => (long)value * blockAlign, + TimeMilliseconds => (long)value * bytesPerSecond / 1000, + _ => null, + }; + } + + internal static long CounterWrapBytes(uint type, int blockAlign, int bytesPerSecond) + { + const long counterSpan = 1L << 32; + return type switch + { + TimeBytes => counterSpan, + TimeSamples => counterSpan * blockAlign, + TimeMilliseconds => counterSpan * bytesPerSecond / 1000, + _ => 0, + }; + } + + internal static long WrapBaseAfterFormatChange(long convertedBytes, long lastPositionBytes, long wrapBytes) + { + if (wrapBytes <= 0 || convertedBytes >= lastPositionBytes) + { + return 0; + } + + // Pick the nearest wrap epoch. A driver switching from samples to milliseconds can round + // the same instant a few bytes backwards; that is not evidence of a 32-bit counter wrap. + var difference = lastPositionBytes - convertedBytes; + return ((difference + wrapBytes / 2) / wrapBytes) * wrapBytes; + } +} + /// /// Windows audio output through the classic waveOut API (winmm.dll). It is available on every /// Windows, needs no COM apartment, and reports the played position straight from the driver - @@ -21,8 +64,6 @@ public sealed unsafe partial class WaveOutAudioSink : IAudioSink private const uint WaveMapper = 0xFFFFFFFF; private const uint CallbackEvent = 0x00050000; private const uint WhdrDone = 0x00000001; - private const uint TimeBytes = 0x0004; - private const uint TimeSamples = 0x0002; private const uint MmSysErrNoError = 0; [StructLayout(LayoutKind.Sequential)] @@ -114,12 +155,18 @@ private struct MmTime // the counter value seen at the reset. private long _positionBase; private long _lastRawPosition; + private uint _lastPositionType; + private uint _lastPositionCounter; + private long _positionWrapBaseBytes; public void Open(int sampleRate, int channels) { lock (_lock) { - CloseCore(); + if (!CloseCore()) + { + throw new InvalidOperationException("Previous waveOut device could not be closed safely"); + } var format = new WaveFormatEx { @@ -137,6 +184,11 @@ public void Open(int sampleRate, int channels) _bufferBytes -= _bufferBytes % format.nBlockAlign; _doneEvent = CreateEventW(IntPtr.Zero, false, false, IntPtr.Zero); + if (_doneEvent == IntPtr.Zero) + { + throw new InvalidOperationException($"CreateEventW failed with error {Marshal.GetLastWin32Error()}"); + } + var result = waveOutOpen(out _device, WaveMapper, ref format, _doneEvent, IntPtr.Zero, CallbackEvent); if (result != MmSysErrNoError) { @@ -146,6 +198,10 @@ public void Open(int sampleRate, int channels) _headers = Marshal.AllocHGlobal(sizeof(WaveHdr) * BufferCount); _data = Marshal.AllocHGlobal(_bufferBytes * BufferCount); + + // Initialize every header before preparing any of them. If preparation later fails, + // CloseCore can safely unprepare the whole array instead of touching uninitialized + // native memory after the failing index. for (var i = 0; i < BufferCount; i++) { var header = (WaveHdr*)_headers + i; @@ -155,13 +211,27 @@ public void Open(int sampleRate, int channels) dwBufferLength = (uint)_bufferBytes, dwFlags = 0, // must be zero when prepared }; - waveOutPrepareHeader(_device, (IntPtr)header, (uint)sizeof(WaveHdr)); + } + + for (var i = 0; i < BufferCount; i++) + { + var header = (WaveHdr*)_headers + i; + result = waveOutPrepareHeader(_device, (IntPtr)header, (uint)sizeof(WaveHdr)); + if (result != MmSysErrNoError) + { + CloseCore(); + throw new InvalidOperationException($"waveOutPrepareHeader failed with error {result}"); + } + header->dwFlags |= WhdrDone; // free } _nextBuffer = 0; _positionBase = 0; _lastRawPosition = 0; + _lastPositionType = 0; + _lastPositionCounter = 0; + _positionWrapBaseBytes = 0; _serial = 0; _paused = false; } @@ -186,35 +256,51 @@ public double PlayedSeconds private long GetRawPositionBytes() { - var time = new MmTime { wType = TimeBytes }; + // 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; } - long position; - if (time.wType == TimeBytes) + var converted = WaveOutPosition.CounterToBytes(time.wType, time.u, _blockAlign, _bytesPerSecond); + if (!converted.HasValue) { - position = time.u; + return _lastRawPosition; } - else if (time.wType == TimeSamples) + + var wrapBytes = WaveOutPosition.CounterWrapBytes(time.wType, _blockAlign, _bytesPerSecond); + if (_lastPositionType == time.wType) { - // Some drivers refuse TIME_BYTES and answer in sample frames instead. - position = (long)time.u * _blockAlign; + if (time.u < _lastPositionCounter && wrapBytes > 0) + { + _positionWrapBaseBytes += wrapBytes; + } } else { - return _lastRawPosition; + // A driver is allowed to answer a later query in a different format. Choose the wrap + // epoch nearest the previous absolute position; small conversion-rounding differences + // must not be mistaken for an entire 32-bit counter wrap. + _positionWrapBaseBytes = WaveOutPosition.WrapBaseAfterFormatChange( + converted.Value, + _lastRawPosition, + wrapBytes); } - // The 32-bit counter wraps after ~6 hours of 48 kHz stereo; keep it monotonic. - if (position < (_lastRawPosition & 0xFFFFFFFF)) + var position = _positionWrapBaseBytes + converted.Value; + if (position < _lastRawPosition) { - _lastRawPosition += 0x100000000; + // Millisecond conversion can round a format switch slightly backwards. + position = _lastRawPosition; } - _lastRawPosition = (_lastRawPosition & ~0xFFFFFFFFL) | position; - return _lastRawPosition; + _lastPositionType = time.wType; + _lastPositionCounter = time.u; + _lastRawPosition = position; + return position; } public bool Write(ReadOnlySpan pcm, int serial) @@ -287,6 +373,9 @@ public void Reset(int serial) // Drivers differ on whether waveOutReset rewinds the position counter; forget the // wrap-around history first so a rewind to 0 is not mistaken for a 32-bit wrap. _lastRawPosition = 0; + _lastPositionType = 0; + _lastPositionCounter = 0; + _positionWrapBaseBytes = 0; _positionBase = GetRawPositionBytes(); _nextBuffer = 0; for (var i = 0; i < BufferCount; i++) @@ -326,20 +415,43 @@ public void Resume() } } - private void CloseCore() + /// + /// Releases native resources only after WinMM confirms it no longer owns any waveform + /// buffers. A failure keeps the complete device/buffer/event boundary alive so Dispose can + /// be retried instead of freeing memory that the driver may still reference. + /// Called under . + /// + private bool CloseCore() { if (_device != IntPtr.Zero) { - waveOutReset(_device); + var resetResult = waveOutReset(_device); + if (resetResult != MmSysErrNoError) + { + Se.LogError($"ffmpeg player: waveOutReset failed during teardown with error {resetResult}; retaining WinMM resources"); + return false; + } + if (_headers != IntPtr.Zero) { for (var i = 0; i < BufferCount; i++) { - waveOutUnprepareHeader(_device, (IntPtr)((WaveHdr*)_headers + i), (uint)sizeof(WaveHdr)); + var unprepareResult = waveOutUnprepareHeader(_device, (IntPtr)((WaveHdr*)_headers + i), (uint)sizeof(WaveHdr)); + if (unprepareResult != MmSysErrNoError) + { + Se.LogError($"ffmpeg player: waveOutUnprepareHeader failed during teardown with error {unprepareResult}; retaining WinMM resources"); + return false; + } } } - waveOutClose(_device); + var closeResult = waveOutClose(_device); + if (closeResult != MmSysErrNoError) + { + Se.LogError($"ffmpeg player: waveOutClose failed during teardown with error {closeResult}; retaining WinMM resources"); + return false; + } + _device = IntPtr.Zero; } @@ -357,9 +469,16 @@ private void CloseCore() if (_doneEvent != IntPtr.Zero) { - CloseHandle(_doneEvent); + if (!CloseHandle(_doneEvent)) + { + Se.LogError($"ffmpeg player: CloseHandle failed during waveOut teardown with error {Marshal.GetLastWin32Error()}; retaining event handle"); + return false; + } + _doneEvent = IntPtr.Zero; } + + return true; } public void Dispose() diff --git a/tests/UI/Logic/FfmpegPlayerTests.cs b/tests/UI/Logic/FfmpegPlayerTests.cs index 30e0bb2e50..58e69b4ba3 100644 --- a/tests/UI/Logic/FfmpegPlayerTests.cs +++ b/tests/UI/Logic/FfmpegPlayerTests.cs @@ -525,6 +525,44 @@ public void ShouldResendPacket_RequiresRejectedInputDecoderProgressAndCurrentSer Assert.False(FfmpegPlayer.ShouldResendPacket(ffmpeg.AVERROR_EOF, receivedOutput: true, interrupted: false)); } + [Fact] + public void WaveOutPositionCounterToBytes_AcceptsDriverFallbackFormats() + { + Assert.Equal(1234, WaveOutPosition.CounterToBytes(WaveOutPosition.TimeBytes, 1234, blockAlign: 4, bytesPerSecond: 192000)); + Assert.Equal(4000, WaveOutPosition.CounterToBytes(WaveOutPosition.TimeSamples, 1000, blockAlign: 4, bytesPerSecond: 192000)); + Assert.Equal(176400, WaveOutPosition.CounterToBytes(WaveOutPosition.TimeMilliseconds, 1000, blockAlign: 4, bytesPerSecond: 176400)); + Assert.Null(WaveOutPosition.CounterToBytes(0x40, 1000, blockAlign: 4, bytesPerSecond: 192000)); + } + + [Fact] + public void WaveOutPositionCounterWrapBytes_UsesTheReturnedCounterUnits() + { + const long span = 1L << 32; + + Assert.Equal(span, WaveOutPosition.CounterWrapBytes(WaveOutPosition.TimeBytes, blockAlign: 4, bytesPerSecond: 192000)); + Assert.Equal(span * 4, WaveOutPosition.CounterWrapBytes(WaveOutPosition.TimeSamples, blockAlign: 4, bytesPerSecond: 192000)); + Assert.Equal(span * 176400 / 1000, WaveOutPosition.CounterWrapBytes(WaveOutPosition.TimeMilliseconds, blockAlign: 4, bytesPerSecond: 176400)); + } + + [Fact] + public void WaveOutPositionWrapBase_FormatSwitchRoundingDoesNotInventAFullWrap() + { + var wrap = WaveOutPosition.CounterWrapBytes( + WaveOutPosition.TimeMilliseconds, + blockAlign: 4, + bytesPerSecond: 192000); + + Assert.Equal(0, WaveOutPosition.WrapBaseAfterFormatChange( + convertedBytes: 3838, + lastPositionBytes: 4000, + wrapBytes: wrap)); + + Assert.Equal(wrap, WaveOutPosition.WrapBaseAfterFormatChange( + convertedBytes: 4000, + lastPositionBytes: wrap + 4000, + wrapBytes: wrap)); + } + [Theory] [InlineData(AVSampleFormat.AV_SAMPLE_FMT_U8, false)] [InlineData(AVSampleFormat.AV_SAMPLE_FMT_S16, false)]