[PATCH v12 0/1] MR9840: winepulse.drv: A potential solution to audio crackling
I have noticed that winepulse leaves too little headroom when draining the buffer, which very often leads to crackling that can't be fully resolved by manipulating latency. Let's check some math: Let's assume that the buffer (tlength) is at 33ms, and minreq is at 13.3ms. If 13.2 ms were drained, the refill would be denied. If the next timer trigger takes 14ms due to jitter, that leaves us with a 6.1ms margin. As such, if we get any jitter longer than that during refill, we get an underflow. I think this is what leads to crackles. My solution increases timer polling to 5x the frequency, so if 13.2ms were drained, the next timer trigger would happen at around 16ms. That would leave us with a 17ms headroom. The math might not be 100% representation of what happens, but I think it's quite close to reality. On practice, this does indeed help to fully get rid of crackling for an indefinite amount of time, when before they were unevitable. I think this problem is quite urgent, because while this does not happen on every system, it's frequent enough that we see "Audio: Crackling" reports on most games on protondb and there are many forum threads about audio crackling. From my experience, the only solution was switching to winealsa, which is very consistent and does not crackle at all. My experience has been very consistent with forum threads when it came to the nature of crackling. I first noticed it in Balatro, which is a very light game and should not be crackling at all on any setup. Some games have very frequent crackling every 20-30 seconds, or buzzsaw crackling every 10-15 minutes. I hope my idea brings us closer to the ultimate fix, I really look forward to your feedback! -- v12: winepulse.drv: Increase timer frequency https://gitlab.winehq.org/wine/wine/-/merge_requests/9840
From: Dzmitry Keremsha <vyro@lumencoil.com> --- dlls/winepulse.drv/pulse.c | 69 +++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/dlls/winepulse.drv/pulse.c b/dlls/winepulse.drv/pulse.c index 7c8d872c604..a09ee2f28bc 100644 --- a/dlls/winepulse.drv/pulse.c +++ b/dlls/winepulse.drv/pulse.c @@ -85,6 +85,8 @@ struct pulse_stream struct list packet_free_head; struct list packet_filled_head; + SIZE_T rem_samples; + INT event_count; }; typedef struct _ACPacket @@ -1180,7 +1182,7 @@ static NTSTATUS pulse_create_stream(void *args) stream->bufsize_frames = ceil((params->duration / 10000000.) * params->fmt->nSamplesPerSec); bufsize_bytes = stream->bufsize_frames * pa_frame_size(&stream->ss); - stream->mmdev_period_usec = params->period / 10; + stream->mmdev_period_usec = params->period / 50; stream->share = params->share; stream->flags = params->flags; @@ -1572,9 +1574,8 @@ static NTSTATUS pulse_timer_loop(void *args) struct pulse_stream *stream = handle_get_stream(params->stream); LARGE_INTEGER delay; pa_usec_t last_time; - UINT32 adv_bytes; + UINT32 adv_bytes=0; int success; - pulse_lock(); delay.QuadPart = -stream->mmdev_period_usec * 10; pa_stream_get_time(stream->stream, &last_time); @@ -1612,7 +1613,7 @@ static NTSTATUS pulse_timer_loop(void *args) if (diff > stream->mmdev_period_usec) { stream->just_started = FALSE; - last_time = now; + last_time = now; } } else @@ -1631,20 +1632,43 @@ static NTSTATUS pulse_timer_loop(void *args) last_time += stream->mmdev_period_usec; } - if (stream->dataflow == eRender) + if (stream->dataflow == eRender) { + SIZE_T frame_size = pa_frame_size(&stream->ss); + UINT64 total_samples; + SIZE_T safe_bytes; + SIZE_T limit; + pulse_write(stream); - /* regardless of what PA does, advance one period */ - adv_bytes = min(stream->period_bytes, stream->held_bytes); - stream->lcl_offs_bytes += adv_bytes; - stream->lcl_offs_bytes %= stream->real_bufsize_bytes; - stream->held_bytes -= adv_bytes; + total_samples = stream->mmdev_period_usec * stream->ss.rate + stream->rem_samples; + adv_bytes = (total_samples / 1000000) * frame_size; + stream->rem_samples = total_samples % 1000000; + + if (adv_bytes > stream->held_bytes) + { + adv_bytes = stream->held_bytes; + stream->rem_samples = 0; + } + + safe_bytes = adv_bytes; + + if (stream->held_bytes > stream->pa_held_bytes) + { + limit = stream->held_bytes - stream->pa_held_bytes; + if (safe_bytes > limit) + safe_bytes = limit; + } + stream->lcl_offs_bytes += safe_bytes; + stream->lcl_offs_bytes %= stream->real_bufsize_bytes; + stream->held_bytes -= safe_bytes; } + else if(stream->dataflow == eCapture) { pulse_read(stream); } + } else { @@ -1653,8 +1677,28 @@ static NTSTATUS pulse_timer_loop(void *args) } } - if (stream->event) - NtSetEvent(stream->event, NULL); + if (stream->event) + { + BOOL should_signal = FALSE; + if (stream->dataflow == eRender) + { + + stream->event_count++; + if (stream->event_count >= 5) + { + should_signal = TRUE; + stream->event_count = 0; + } + + } + else + { + should_signal = TRUE; + } + + if (should_signal) + NtSetEvent(stream->event, NULL); + } TRACE("%p after update, adv usec: %d, held: %u, delay usec: %u\n", stream, (int)adv_usec, @@ -1710,6 +1754,7 @@ static NTSTATUS pulse_start(void *args) { stream->started = TRUE; stream->just_started = TRUE; + stream->event_count = 4; } pulse_unlock(); return STATUS_SUCCESS; -- GitLab https://gitlab.winehq.org/wine/wine/-/merge_requests/9840
On Sun Jan 25 09:49:13 2026 +0000, Dzmitry Keremsha wrote:
Looked at what winealsa does, it seems the logic there is similar to my "safe_bytes" logic. If the audio server is slow - throttle pointer advancement. Since NtSetEvent is consistent, it eventually leads to full buffer RAM and denied refills. I guess this is okay? I don't know where this data goes then, but it never sounds like a part of audio is missing. It was easy to implement it with 5x timer polling without breaking anything, every tick add +1 to a counter, every 5 ticks (so exactly once per period) fire NtSetEvent. At stream start make the counter equal 4 so it would fire immediately. Passes tests, doesn't crackle, and unless I misunderstand something, looks exactly like winealsa to the game. Attaching a winealsa log with some additional logs from me so it's clear when a refill is missed. [honkai-winealsa.tar.gz](/uploads/c12ab1c11a1c36e7cf3bd784ec5bdf43/honkai-winealsa.tar.gz) I think I have found a genuine catch-22. held_bytes \< pa_held_bytes means pulseaudio server is slow and write and read pointer collision is imminent at some point. This is why ZZZ and ARC Raiders have a static buzz. We can make held_bytes stop advancing when it approaches pa_held_bytes and it would solve the problem, we would just wait for the server to catch up.
This is basically what winealsa does. But when I tried running tests in docker, the virtual audio hardware never moves, and pa grows way above held. But then how is it possible that winealsa doesn't fail tests? To see what is going on, I ran WINEDLLOVERRIDES="winepulse.drv=d" ./wine dlls/mmdevapi/tests/i386-windows/mmdevapi_test.exe spatialaudio and sure enough, I get the same 106 errors in spatial audio tests with winealsa as I do with winepulse if I stop the advancement. The winepulse driver assumes the audio server is never slow, which is not true in the real world. It's better to skip a refill than to end up with a static buzz, because a skipped refill won't be noticeable for the user. -- https://gitlab.winehq.org/wine/wine/-/merge_requests/9840#note_127995
participants (2)
-
Dzmitry Keremsha -
Dzmitry Keremsha (@Vyrolian)