Skip to content

DirectAudio v1.2 — MIDI spin fix (returns a CPU core to every game)

Choose a tag to compare

@github-actions github-actions released this 13 Aug 23:10
· 32 commits to main since this release

DirectAudio v1.2 fixes a bug that has been present since v1: a Wine MIDI notify thread spinning at 100% of one CPU core for the entire lifetime of every DirectAudio session.

The visible symptom was that heavy titles — God of War in particular — never got past a black screen while audio played perfectly. The invisible symptom was that every other game silently lost a core.


The bug

mmdevapi picks a MIDI driver at startup (dlls/mmdevapi/main.c):

midi_drvname[0] = 0;
wine_unix_call( midi_get_driver, midi_drvname );
if (midi_drvname[0]) load_driver( midi_drvname, &midi_driver );
else                 midi_driver = drvs;      // <-- DirectAudio, by default

DirectAudio left midi_get_driver unimplemented, so it became its own MIDI driver. DriverProc(DRV_LOAD) then spawned notify_thread():

while (1) {
    MIDI_CALL( midi_notify_wait, &params );
    if (quit) break;                          // <-- `quit` is UNINITIALISED
    if (notify.send_notify) notify_client(&notify);
}

midi_notify_wait is contractually blockingwinealsa's implementation waits on a real event and sets *quit on shutdown. DirectAudio's stub returned STATUS_SUCCESS immediately and never wrote *quit, turning the loop into a tight infinite spin that also hammered the PE→Unix call boundary.

On device the thread appears as mmdevapi_midi_n holding 34079 of the process's 34314 utime jiffies.

This is why the "0 fps / GPU 0% / CPU 17%" signature was so misleading: 17% is one pegged core out of eight, which reads as an idle, deadlocked process rather than a spinning one.

The fix

Mirror what winepulse.drv has always done — implement midi_get_driver to return L"alsa", so mmdevapi loads winealsa.drv for MIDI. midi_notify_wait is also implemented defensively (sets *quit on the first call) so the loop can never spin even if DirectAudio somehow becomes the MIDI driver. Both the native and wow64 vtables are updated.

The audio path is untouched. No change to stream creation, buffer sizing, mixing, resampling or the AAudio path.

Impact

  • Unblocks heavy DX12/VKD3D titles that previously black-screened.
  • Returns a full CPU core to every DirectAudio game. Titles that already "worked" were paying this tax invisibly.
  • Makes low latency reachable in practice — with the core back, small buffers are sustainable.

Test results

Seven games, all on one container (Proton 11.0-5-arm64ec-5), all at a 4 ms requested buffer, all measured live during actual gameplay — not menus.

The fix itself

Game Process Graphics Audio API mmdevapi_midi_n Threads CPU /30 s
Insane 2 i2.exe D3D9 FAudio none 37 2 182
GTA V Enhanced PlayGTAV.exe D3D12 / VKD3D WASAPI none 121 5 788
DiRT Showdown showdown.exe D3D11 WASAPI none 92 5 928
Hades Hades.exe D3D11 FAudio none 51 2 408
GTA IV GTAIV.exe D3D9 DirectSound none 48 7 632
God of War GoW.exe D3D11 FAudio / XAudio2 none 69 14 250
DiRT 3 dirt3_game.exe D3D11 WASAPI none 103 5 557

The spinning thread is gone in every case. winedirectaudio.so confirmed loaded with in-process AAudio in all seven, so every result is genuinely this driver and not a fallback.

Latency and stability

Game Buffer Track latency Was Underruns (30 s, foreground)
Insane 2 192 fr · 4 ms 25.00 ms 0
GTA V Enhanced 192 fr · 4 ms 25.00 ms 0
DiRT Showdown 192 fr · 4 ms 25.00 ms 83.50 ms 0
Hades 192 fr · 4 ms 25.00 ms 81.00 ms 0
GTA IV 192 fr · 4 ms 25.00 ms 81.00 ms 0
God of War 384 fr · 8 ms 29.00 ms 45.00 ms 0
DiRT 3 576 fr · 12 ms 33.00 ms 87.50 ms 0

Five of seven sit on the hardware floor. God of War and DiRT 3 could not sustain 4 ms, so the adaptive engine grew their buffers a burst at a time until they stopped underrunning — the safety net working, not a failure. God of War is the heaviest load in the set (14 250 CPU jiffies/30 s, roughly triple the others), which is exactly why it needed the headroom.

Typical improvement versus the previous default: 81–87 ms → 25 ms.

God of War, before and after

The title that exposed the bug:

before after
screen black renders
fps 0.0 26.9
GPU 0% 70%
CPU 17% (one pegged core of eight) 97% (real work)
threads 63, all futex_wait + 1 spinning 75, 9 running incl. dxvk-cs, DxRenderThread
mmdevapi_midi_n R, 34079 / 34314 utime gone

Gameplay confirmed, not just process liveness

Screenshots taken at the end of each run: GTA V mid-heist at 66.4 fps, GTA IV mid-mission at 106.1 fps, DiRT 3 mid-race at 65.2 fps, Insane 2 mid-race at 60.0 fps, God of War at the main menu at 26.9 fps.

Listening test

A human played each title through menus and gameplay while the runs were in progress and reported no crackling on any of them. This is the one check no instrumentation performs, and it is the check a starved audio thread would fail first.

Method

Latency and underruns come from dumpsys media.audio_flinger's track row for the game's own PID. Underruns are reported as a delta sampled over 30 s with the game in the foreground — cumulative totals are unusable, because backgrounding the app freezes the guest process and manufactures underruns that never happened during play. The MIDI verdict is a scan of /proc/<pid>/task/*/comm for a *midi* thread.

Device: AYANEO Pocket FIT — Adreno 750, Android 14, 192-frame AudioFlinger burst, 21.00 ms output latency.

Not tested: Ninja Gaiden Σ.

About the latency numbers

Quote 25 ms, not 4 ms. The 4 ms figure is the buffer size — one AudioFlinger burst. Underneath it sits 21 ms of Android mixer and HAL that no application can remove; native Android games pay it too.

  4 ms  DirectAudio buffer   (tunable)
+ 21 ms Android mixer + HAL  (fixed, unavoidable)
= 25 ms track latency        (what the player hears)

25 ms is the hardware floor on this device. For reference, PulseAudio's default PULSE_LATENCY_MSEC=100 works out to roughly 121 ms.

Behaviour change worth knowing

winealsa.drv is now loaded into the process as the MIDI driver. It is a dormant library in the game's own process — no daemon, no extra process, and no audio passes through it. On a device with no MIDI hardware it never even starts a thread. If winealsa is unavailable, mmdevapi skips MIDI entirely and DirectAudio is unaffected.

DirectAudio remains daemon-free: game → winedirectaudio.drv → in-process mixer → AAudio.

Downloads

Two builds, both arm64ec, for SDK 28 and SDK 35:

  • directaudio-v1.2 — the release build. Use this one.
  • directaudio-v1.2-diagnostics — identical fix plus 23 logcat probes under the DirectAudio tag (stream lifecycle, buffer/burst/xrun heartbeats, per-call tracing). For debugging a specific title; slightly more overhead.

Each archive contains winedirectaudio.drv (PE) and winedirectaudio.so (unixlib). Drop them into lib/wine/aarch64-windows/ and lib/wine/aarch64-unix/ of a Proton 11.0 layer.

Getting the low-latency configuration

The default preset is conservative (~62.5 ms buffer). The 25 ms results above require the custom preset — auto and low hardcode their buffer and ignore buffer_frames:

BANNER_AUDIO_DIRECT_PRESET=custom
BANNER_AUDIO_DIRECT_PERF=1
BANNER_AUDIO_DIRECT_ADAPTIVE=1
BANNER_AUDIO_DIRECT_BF=192

Keep ADAPTIVE=1. Every 25 ms result above depended on it — two of the seven games needed to grow their buffer, and without the safety net they would crackle permanently instead.

Known limits

  • Tested on a single device (Adreno 750, Android 14). Mali GPUs untested.
  • Microphone capture, true surround, and mature output-route handling are still gaps versus PulseAudio; see the roadmap.
  • ABI-pinned to Wine 11.0 — the mmdevapi unixlib vtable must match the mmdevapi.dll it ships beside.

Credits

Root cause found by comparing DirectAudio's driver vtable against winealsa.drv and winepulse.drv in the Wine tree; the fix follows winepulse's existing pattern.