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.
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; v1.3.2 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.
Two axes decide which bundled build is installed:
- mmdevapi unixlib ABI — the driver plugs straight into mmdevapi's unixlib table, so the
build that fits is a property of the Proton binary, not of its version string. Both the driver
and every Proton's own
winealsa.soexport__wine_unix_call_funcs, whose size is that table: 36 entries for Proton 9.0 and 10.0-4, 37 for Proton 11.x.DirectAudioDriverreads the count out of<layer>/lib/wine/aarch64-unix/winealsa.soand picks the bundled build whose own unixlib reports the same count. No version parsing, so custom, GE and beta Protons work on the same footing as the stock ones, and a Proton whose table matches neither build is refused instead of being handed a mismatched driver. - Kernel page size —
sdk35for a 16 KB-page kernel (p_align0x4000),sdk28for the classic 4 KB (0x1000), read viaOs.sysconf(_SC_PAGESIZE). Loading the wrong one fails to map. Nothing else in the app needed page size before, so the check is new.
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 + variant so it is a no-op on every launch after the first:
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 the loader picks 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, size-gated so it is a no-op once staged. 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'sDT_NEEDEDstringlibaaudio.so→libwaudio.soin place (same length) after the overlay. It runs on every install and is idempotent.libwaudio.sois a non-public soname, so bionic resolves it fromusr/libviaLD_LIBRARY_PATHinstead of the system namespace.libwaudio.sois WinNative's own AAudio bridge, built by the app (app/src/main/cpp/wnaudiohook/wn_aaudio_shim.c→ CMake targetwaudio) and staged intoimagefs/usr/libbyGuestProgramLauncherComponentwhen DirectAudio is selected — the sameensureImageFsNativeLibrarypathlibnetshim.souses. It exports only the 27AAudio_*symbols the driver imports, links onlylog dl(neverandroid, whose closure is the same framework stack), and never exportsdlopen(an injecteddlopenstalls the Steam launcher). On first call it builds a namespace linked to the platform default set,android_dlopen_exts the real/system/lib64/libaaudio.sothrough 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 four 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 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.
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
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.shbuilds upstream'sdirectaudio.c, unmodified, for aarch64 glibc against the headers of Valve's Wine 11 (pinned commit), and links it withwn_aaudio_client.c: the 27AAudio_*entry points, implemented as a client of the app. The result ships asassets/directaudio/linux/winedirectaudio.so; the two PE halves are upstream's own, taken from thewine11archive.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 inASharedMemorywhose 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 isapp/src/main/cpp/wnaudiohook/wn_aaudio_protocol.h.- PulseAudio always runs in these sessions, because the Steam client and native games know nothing else. DirectAudio only takes the Windows games.
winnative-directaudio <steam root> on|offruns at session start. It copies the driver into every Proton whosewinealsa.sowalks the same unixlib table (37 entries today; the x86 Proton has noaarch64-unixand is left alone), and writes or removesAudio=directaudioin 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.- Capture is refused by the host unless the mic opt-in and
RECORD_AUDIOboth 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. |
- Download the new release artifacts into
app/src/main/assets/directaudio/(command inPROVENANCE.md), and refresh the checksums recorded there. - Bump
BUNDLED_VERSIONinDirectAudioDriver.ktso existing containers re-overlay. - Copy
COPYING,NOTICEandAUTHORSagain if upstream changed them.
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.