Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 141 additions & 22 deletions src/ui/Logic/VideoPlayers/Ffmpeg/Audio/WaveOutAudioSink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

/// <summary>
/// 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 -
Expand All @@ -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)]
Expand Down Expand Up @@ -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
{
Expand All @@ -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)
{
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -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<byte> pcm, int serial)
Expand Down Expand Up @@ -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++)
Expand Down Expand Up @@ -326,20 +415,43 @@ public void Resume()
}
}

private void CloseCore()
/// <summary>
/// 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 <see cref="_lock"/>.
/// </summary>
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;
}

Expand All @@ -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()
Expand Down
38 changes: 38 additions & 0 deletions tests/UI/Logic/FfmpegPlayerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
Loading