DirectAudio is a native Wine → Android AAudio mmdevapi driver by The412Banner, licensed LGPL-2.1-or-later. WinNative ships his release binaries unmodified and adds only the host-side plumbing. This document covers what the driver does, why it is the only audio path here that can carry a microphone, and what the app does to select and configure it.
DirectAudio by The412Banner (https://github.com/The412Banner/directaudio)
Bundled artifacts, checksums and the LGPL source offer: app/src/main/assets/directaudio/PROVENANCE.md.
Bundled version: 1.4.0.
This is structural, not missing configuration.
ALSA (custom aserver). The wire protocol between the guest ALSA plugin and the app’s Java
server has eight request codes — CLOSE START STOP PAUSE PREPARE WRITE DRAIN POINTER
(runtime/audio/alsaserver/RequestCodes.java) — and every one is output-side. There is no
READ. The guest plugin does not even reach the socket for a capture PCM:
/* audio_plugin/module_pcm_android_aserver.c:317 */
if (stream != SND_PCM_STREAM_PLAYBACK) return -ENOTSUP;
Adding capture here means designing a new verb on both sides of the socket plus an
AudioRecord feeder in ALSAClient. That is a new subsystem.
PulseAudio. The bundled pulseaudio.tzst asset contains exactly libprotocol-native.so,
module-aaudio-sink.so, module-native-protocol-unix.so and pactl, and
PulseAudioComponent loads only the protocol module and the sink. There is no source module of
any kind, so a capture endpoint winepulse advertises records silence or a monitor tap rather
than the microphone.
DirectAudio. There is no daemon in the path at all — the driver talks AAudio directly from Wine’s unixlib, so it is both the mmdevapi backend and the device layer. The capture half of the WASAPI vtable was always wired; from v1.3.2 on the driver exposes the endpoint and backs it with a real AAudio input stream.
It is the render mixer, inverted: one shared AAudio INPUT stream whose data callback fans PCM
out to every registered capture voice, reusing the render ring fields with the producer and
consumer roles swapped (the same approach Wine’s own winecoreaudio.drv uses). That sharing is
why create_stream, the per-period stream event and the vtable did not need duplicating.
| Stage | What happens |
|---|---|
| Gate | BANNER_AUDIO_DIRECT_MIC is read once at process attach. Default off. |
| Enumerate | With the gate off, get_endpoint_ids reports zero capture endpoints. |
| Open | First capture create_stream lazily opens the AAudio input — 48 kHz / float / stereo, VOICE_COMMUNICATION preset (platform AEC / NS / AGC). Enumeration alone never gets here. |
| Start | requestStart is deferred until the first Start, so the mic goes hot no earlier than the game actually records. |
| Produce | The callback rate-converts with a carried fractional position, folds channels, writes the client sample format, and drops the oldest frame on overrun. |
| Consume | get_capture_buffer serves device-period chunks, returning AUDCLNT_S_BUFFER_EMPTY until a full period is held. |
| Teardown | Last voice out → requestStop (the OS recording indicator clears), stream stays open for reuse. |
The driver honours the geometry AAudio actually grants rather than what it asked for, so a mono
microphone or a device-native rate still works. Capture gain is deliberately not scaled by
the stream volumes — the VOICE_COMMUNICATION auto-gain owns the mic level and a WASAPI capture
“volume” on top of it would fight the platform AGC.
A capture endpoint a game can enumerate but not open makes titles that probe the microphone
during load abandon audio initialisation and boot to a black screen. God of War and DiRT 3 are
the upstream device-proven examples. With BANNER_AUDIO_DIRECT_MIC unset the driver is
byte-identical to its render-only build: no capture endpoints, every capture op returns
AUDCLNT_E_DEVICE_INVALIDATED, and no AAudio input object is ever constructed.
All host-side logic lives in
runtime/audio/directaudio/DirectAudioDriver.kt.
Three things decide which bundled build is installed:
mmdevapi interface. The driver plugs straight into mmdevapi’s unixlib call table, so the build that fits is a property of the Proton binary, not of its version string. There are three interfaces:
| Set | Interface | Layers |
|---|---|---|
wine10 |
Wine 10 | Proton 9.0, Proton 10.0-4, GE-Proton 10.0-34 |
wine11 |
Wine 11 (classic) | Proton 11.0-1 / 11.0-2, GE-Proton 11.0-3 … 7.1 |
wine11-systhread |
Wine 11, system-thread | Proton-CachyOS 11.0-20260703 / 11.0-20261005 |
winealsa.so / winepulse.so export
__wine_unix_call_funcs, whose size is that table: 36 entries for Proton 9.0 and
10.0-4, 37 for Proton 11.x and for the CachyOS layers. DirectAudioDriver reads the
count out of <layer>/lib/wine/aarch64-unix/winealsa.so (or winepulse.so) and only
accepts a bundled build whose own unixlib reports the same count.sdk35 for a 16 KB-page kernel (p_align 0x4000), sdk28 for the
classic 4 KB (0x1000), read via Os.sysconf(_SC_PAGESIZE). Loading the wrong one fails to map.The table size alone cannot pick the interface. The system-thread interface replaces
main_loop + timer_loop with main_loop_start + main_loop_stop (the driver spawns its own
timer thread), so its table has the same 37 entries as classic Wine 11 while entries 3–9
are shifted by one. A classic driver loaded into a system-thread layer passes the size check,
but every call from get_endpoint_ids on is routed to the wrong function: mmdevapi registers
an endpoint with an empty device name (0, instead of 0,aaudio) and faults in wcsdup while
enumerating the next one, so the game dies at startup. The interface is therefore read from the
layer’s own unixlibs instead: a system-thread layer’s winealsa.so / winepulse.so import
PsCreateSystemThread, a classic one does not. The selection is:
host = hostInterface(<layer>/lib/wine) // entry count + imports PsCreateSystemThread
candidates = host.systhread ? [wine11-systhread] // never a classic build
: [wine11, wine10] // never the system-thread build
bundled = first candidate whose own unixlib has host.abi entries
No version parsing, so custom, GE and beta Protons work on the same footing as the stock ones,
and a Proton whose table matches no candidate is refused instead of being handed a mismatched
driver. The wine10/wine11 asset names are historical — the wine10 build serves Proton 9.0
as well, which the old version table refused outright.
A Wine 11 .drv has no PE export table, and that is correct: Wine 11’s own winealsa.drv
and winepulse.drv have exactly the same shape, because the .drv is a generic unixlib host
there and the driver logic lives entirely in the .so. Only Wine 10 and earlier resolve
get_device_guid / get_device_name_from_guid through PE exports.
The complete 3-file set is then overlaid onto the container’s Wine layer, stamped by bundled
version + set + table size + page size (<version>-<set>-abi<N>-<sdk>) so it is a no-op on every
launch after the first, and re-overlays when the bundled version, the layer’s interface or the
page size changes:
lib/wine/aarch64-windows/winedirectaudio.drv arm64ec PE — 64-bit guest games
lib/wine/i386-windows/winedirectaudio.drv 32-bit PE — wow64 guest games
lib/wine/aarch64-unix/winedirectaudio.so bionic unixlib — the AAudio backend
Which PE loads is decided by the guest game’s bitness, not the device, so both PE halves are always installed alongside the shared unixlib.
Upstream’s winedirectaudio.so declares DT_NEEDED libaaudio.so. libaaudio.so is a public
Android soname (/system/etc/public.libraries.txt), so bionic force-resolves it from the system
namespace, and the real /system/lib64/libaaudio.so pulls in the Android framework closure
(libbinder, libutils, libhidlbase, VNDK/APEX). WinNative’s Wine unixlib runs in a linker namespace
that cannot link that closure, so the stock unixlib fails to load with STATUS_DLL_NOT_FOUND
(c0000135) and mmdevapi produces silence with no fallback.
mmdevapi resolves the backend with LoadLibraryW(L"winedirectaudio.drv"), which searches the
container’s Wine prefix (drive_c/windows/system32, and syswow64 for wow64 guests) — not the
Wine layer the overlay writes to. Every other backend is already in the prefix because the container
pattern was built with them; winedirectaudio.drv is new, so it is absent and the load fails with
STATUS_DLL_NOT_FOUND (126 / c0000135) before the unixlib is ever reached. That failure looks
identical to a missing-dependency problem and is easy to misdiagnose as an AAudio/linker issue.
DirectAudioDriver.mirrorIntoPrefix() therefore copies both PE halves from the layer into the prefix
after the overlay. A file already in the prefix is only kept when its contents match the layer’s
(a size check is not enough: the PE halves of two interface builds can have the same size), so it is
a no-op once staged and a switch between layers restages correctly. This is the same thing
WinComponentSetup.restoreWineBuiltinDllFiles does for the stock builtins, and it is what makes the
driver work on a container created before DirectAudio existed.
Once the PE loads, two further pieces keep the unixlib loadable, entirely in the emulator, so any Proton layer works unchanged:
DirectAudioDriver.patchDirectAudioNeeded() rewrites the unixlib’s DT_NEEDED string
libaaudio.so → libwaudio.so in place (same length) after the overlay. It runs on every
install and is idempotent. libwaudio.so is a non-public soname, so bionic resolves it from
usr/lib via LD_LIBRARY_PATH instead of the system namespace.libwaudio.so is WinNative’s own AAudio bridge, built by the app
(app/src/main/cpp/wnaudiohook/wn_aaudio_shim.c → CMake target waudio) and staged into
imagefs/usr/lib by GuestProgramLauncherComponent when DirectAudio is selected — the same
ensureImageFsNativeLibrary path libnetshim.so uses. It exports only the 27 AAudio_* symbols
the driver imports, links only log dl (never android, whose closure is the same framework
stack), and never exports dlopen (an injected dlopen stalls the Steam launcher). On first
call it builds a namespace linked to the platform default set, android_dlopen_exts the real
/system/lib64/libaaudio.so through it, and forwards each call.This is host-side only: the shipped driver zips stay byte-verbatim (see assets/directaudio/),
the rename happens on-device, and nothing is baked into a specific Proton build. A future driver
that folds the bridge into the unixlib itself — removing both the rename and the separate shim — is
staged at https://github.com/WinNative-Emu/directaudio (winnative/self-contained-aaudio); once its
CI builds the artifacts and they are device-verified, dropping them here retires both pieces
above.
changeWineAudioDriver() in XServerDisplayActivity writes the Wine registry key that selects
the backend — Software\Wine\Drivers → Audio = directaudio. Selecting DirectAudio adds no
environment component and opens no socket, because there is no daemon to start.
That registry key is the whole activation, so it must never name a driver that is not on disk:
mmdevapi fails its LoadLibraryW, CoCreateInstance(MMDeviceEnumerator) fails with it, and a
title that initialises audio while loading does not start at all. The failure looks like a hung
launch, not like a missing sound device.
resolveAudioDriver() therefore runs the overlay before the registry is written and, when it
cannot complete, drops the launch back to Container.DEFAULT_AUDIO_DRIVER — the registry then
names PulseAudio, the PulseAudio component is added like any other launch, and a toast says why.
The container’s own saved choice is untouched, so the next launch retries the install.
install() reports that honestly: no bundled build matching the layer’s interface and unixlib
ABI, a failed stage into the prefix, or a unixlib with neither libaaudio.so nor libwaudio.so
in its dynamic section all return false rather than logging and returning success. A refused
driver is also deleted from system32/syswow64, so a prefix staged by an earlier build does
not keep a dead winedirectaudio.drv around.
Order matters: resolveAudioDriver() runs after setupWineSystemFiles(), because
ensureWinePrefixReady() can move a broken .wine aside and extract a fresh prefix. Staging
the driver before that would put it in the directory that is about to become
.wine.broken-backup.
The version it is asked about must be the Wine identifier (proton-11.0-6-arm64ec), not
WineInfo.fullVersion() (11.0-6) — wineAbiTag() requires the arm64ec arch in the string and
rejects everything else, so passing the bare version makes every install fail.
The toggle is per container and per shortcut (extra directAudioMic), surfaced in the audio
settings only when DirectAudio is the selected driver, and defaulted off. Turning it on requests
RECORD_AUDIO immediately rather than at app start.
At launch, BANNER_AUDIO_DIRECT_MIC=1 is set only when the user opted in and the permission
is actually granted. That second condition matters: setting the gate without the permission
would advertise a capture endpoint the AAudio input cannot open, which is precisely the
black-screen condition above. Withholding it degrades to render-only instead.
The Kotlin and Java side logs through LogManager (tags DirectAudio, DirectAudioHost,
GuestLauncher, XServerDisplayActivity), so the lines honour the app’s log toggles and tag
filters and end up in the shareable log files. Diagnostic D/I lines use the lambda variants,
which cost nothing when logging is off; warnings and errors use the plain variants so failures
always reach logcat. The native shim (wn_aaudio_shim.c) still logs under wn-aaudio-shim
through __android_log_print, which LogManager cannot see.
Run with the upstream diagnostics build plus BANNER_AUDIO_DIRECT_LOG=1 and watch logcat for
the DirectAudio tag. The open line reports the geometry AAudio actually granted, which is the
first thing to check when audio is present but wrong:
capture open: req 48000/float/2ch preset=voicecomm perf=1 - got rate=48000 ch=1 fmt=2
capture start
To confirm the right build was picked, look for the install line before the Wine session starts:
host abi=37 systhread=true -> wine11-systhread
and, with WINEDEBUG=+loaddll,+mmdevapi (the + form: err+mmdevapi only enables the err
class and prints nothing), check the endpoint key. A correct enumeration reports
Found matching device key L"0,aaudio"; L"0," means the driver’s table is out of step with
mmdevapi’s.
Upstream validated a real microphone recording and TF2’s Options → Voice mic-test meter on an AYANEO Pocket FIT (Adreno 750, Android 14). Reproduce that first.
A GameScope container runs Valve’s ARM64 Proton under glibc, where the bionic unixlib cannot load
and there is no libaaudio to call. The driver’s source is the same; what changes is who opens
the stream.
tools/linuxfs/directaudio/build-directaudio.sh builds upstream’s directaudio.c, unmodified,
for aarch64 glibc against the headers of Valve’s Wine 11 (pinned commit), and links it with
wn_aaudio_client.c: the 27 AAudio_* entry points, implemented as a client of the app. The
result ships as assets/directaudio/linux/winedirectaudio.so; the two PE halves are upstream’s
own, taken from the wine11 archive. The unixlib was built from release 1.3.2 and has not been
rebuilt for 1.4.0; the PE halves come from the bundled (1.4.0) wine11 set, which upstream
reports as unchanged for classic Wine 11.DirectAudioHost (libwnaudiohost.so) listens on a unix socket inside the session. One
connection is one stream: open/start/stop/close go over the socket, the PCM goes through a ring
in ASharedMemory whose descriptor rides on the open reply, and the real AAudio data callback
wakes the client’s pump thread through a futex after every burst. The protocol is
app/src/main/cpp/wnaudiohook/wn_aaudio_protocol.h.winnative-directaudio <steam root> on|off runs at session start. It copies the driver into
every Proton whose winealsa.so walks the same unixlib table (37 entries today; the x86 Proton
has no aarch64-unix and is left alone), and writes or removes Audio=directaudio in each
prefix that Proton runs, templates included. With the driver off the value is removed, so a
prefix never names a driver its session cannot serve.RECORD_AUDIO both hold, the same
gate as a Wine session.Measured on a RedMagic (Brawlhalla, 48 kHz float stereo): no underruns after the stream’s first
half second, 76 ms reported track latency against 116 ms for the PulseAudio track beside it. That
device denies AUDIO_OUTPUT_FLAG_FAST to every app track, this one included, so the burst is
960 frames; a device that grants it gets the fast mixer with no change here.
Upstream’s own honest list, plus what applies on the WinNative side:
| Gap | Detail |
|---|---|
| No capture watchdog | The render side has a dead-callback watchdog. The capture struct declares cb_count and last_cb_ns, but they are written and never read — the instrumentation exists, the watchdog does not. |
| Failed reopen keeps a dead stream | If reopening the input fails after a route change, nothing retries it and the mic is lost for the session. |
| Latency floor | An open input stream may knock the output off the AAudio fast path on some devices. Unmeasured — upstream flags this as the one item that could threaten the latency floor. |
| Online voice round-trip | Mic → VAC server → second player has not been validated end to end by anyone. An in-game meter moving proves capture, not transport. |
| Device spread | AAudio input under FEX is proven on one device. WinNative’s spread is much wider. |
| Background microphone | Android 9+ silences mic input for apps with no foreground process, regardless of target SDK. SessionKeepAliveService is declared specialUse\|dataSync with no microphone foreground-service type — fine while the game is foregrounded, silent when it is not. |
targetSdk 28 |
Android 14’s foreground-service-type enforcement does not apply at this target. Add the microphone FGS type when the target is raised. |
| GameScope and system-thread Protons | winnative-directaudio still picks Protons by unixlib table size alone, which cannot tell classic Wine 11 from the system-thread interface (see above). A system-thread Proton installed in a GameScope session would be handed the classic driver. There is no system-thread build of the Linux unixlib either. |
| New interfaces | Any future mmdevapi change that keeps the table size is invisible to the size check. A new interface needs a new set, a new candidate tag and its own discriminator in DirectAudioDriver. |
app/src/main/assets/directaudio/ (command in
PROVENANCE.md; one zip per interface and page size, six today), and refresh the checksums,
release and commit recorded there.BUNDLED_VERSION in DirectAudioDriver.kt so existing containers re-overlay.COPYING, NOTICE and AUTHORS again if upstream changed them.bundledDriverFor() and
teach hostInterface() how to recognise it, then document it in the table above.If you ever need to build from source rather than ship his binaries, upstream’s INTEGRATION.md
has the recipe: add the repo as a submodule at dlls/winedirectaudio.drv in the Proton tree —
which is also what keeps his commit history intact — apply ~10 lines to configure.ac and
dlls/mmdevapi/main.c, then ./configure --enable-archs=arm64ec --with-aaudio.