DirectAudio from its own release: driver set per Proton interface (CachyOS fix), the Steam client's own DirectAudio sink, binaries out of tree (#439)

DirectAudio from its own release: driver set per Proton audio interface (CachyOS fix), the Steam client's own DirectAudio sink, binaries out of tree

Games
- Two driver sets ship from the pinned DirectAudio release (tools/directaudio/release.env =
  directaudio-linux-v1.1.0, sha256-checked by tools/directaudio/fetch.sh in CI and
  build_local.sh): linux-wine11 for Valve Proton 11 / Experimental / GE-Proton, and
  linux-wine11-systhread for Proton-CachyOS, whose Wine carries the system-thread mmdevapi
  interface. steam-compatibility picks by whether the Proton's own winepulse.so imports
  PsCreateSystemThread, never by the version string (both say wine-11.0; the wrong set is
  silence). No matching set: DirectAudio off, Proton's audio runs, logged.
- Proton-CachyOS's launcher pre-sets WINE_AUDIO_DRIVER=pulse,alsa and its mmdevapi reads that
  before the registry (Valve's and GE's do not read it): the wrapper exports
  WINE_AUDIO_DRIVER=directaudio,pulse as well as writing the registry. On CachyOS this also
  makes DirectAudio engage on a game's first launch.
- A wineserver from an earlier Steam verb can save its in-memory registry over the appended
  Audio value; the wrapper says so when one is live. Writing through it (wine reg add) and
  creating a missing prefix first (proton run wineboot) were tried on the device and withdrawn:
  a second Proton/Wine run inside Steam's launch sequence got launches aborted.
- Tests: both sets, no set, no prefix (34 pass).

Steam client audio
- DirectAudio = module-directaudio-native-sink, the game driver's AAudio engine inside the
  PulseAudio daemon, one step from Android (adaptive buffer, two-burst floor, primed ring with
  fades, reopen on route change, watchdog, limiter). Default. Shared output = the previous relay
  route, kept. New pref clientAudioRoute; the old boolean is not read. If the native sink cannot
  open its stream the daemon keeps running (.nofail) and ensureClientSink loads the plain AAudio
  sink instead, logged to audio.log. Audio bundle stamp bumped.

Out of tree
- app/src/main/assets/directaudio/ is generated and ignored; tools/directaudio-relay/ and the
  vendored module-directaudio-sink.c + da_relay_proto.h are gone; tools/aaudio-sink keeps only
  module-aaudio-sink (the fallback). The release carries the relay and sink field fixes by
  MaxsTechReview (#338), credited there and in its AUTHORS.

Device-proven on an AYANEO Pocket FIT: client sink 12 ms / 4 ms bursts, 0 underruns; FEZ on
cachyos-11.0-20261005-slr on DirectAudio (took pulse before); FEZ on Proton Experimental
unchanged; Curse of the Dead Gods on CachyOS from a cold prefix, DirectAudio on first launch.

Co-authored-by: The412Banner <[email protected]>
This commit is contained in:
The412BannerandThe412Banner authored and GitHub committed 2026-10-08 12:35:00 -04:00
1 parent b6c1d3c09c
commit da42a5d7a7
35 files changed
+322 -2435

No files matched your search

-2
View File
@@ -6,7 +6,6 @@ on:
push:
paths:
- 'tools/aaudio-sink/**'
- 'tools/directaudio-relay/**'
- '.github/workflows/build-aaudio-sink.yml'
workflow_dispatch:
@@ -26,7 +25,6 @@ jobs:
set -euo pipefail
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/out"
tools/directaudio-relay/build.sh "$PWD/out"
- uses: actions/upload-artifact@v4
with:
name: audio-sink-modules
+6 -7
View File
@@ -331,6 +331,11 @@ jobs:
set -euo pipefail
curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz
# DirectAudio - the game driver (one set per Wine audio interface), the Android-side helper and
# the Steam client's sinks - comes from a pinned release of The412Banner/directaudio, checksummed,
# exactly like gamescope; nothing compiled for audio is kept in this repo.
- name: Fetch DirectAudio (tools/directaudio/release.env)
run: tools/directaudio/fetch.sh "$PWD" "$PWD/sink-out"
- name: Build the AAudio sink into the audio bundle
run: |
set -euo pipefail
@@ -340,17 +345,11 @@ jobs:
tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle
test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so
install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
install -m755 sink-out/module-directaudio-sink.so pa-bundle/modules/arm64/module-directaudio-sink.so
install -m755 sink-out/module-directaudio-native-sink.so pa-bundle/modules/arm64/module-directaudio-native-sink.so
(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
mv pulseaudio.tzst app/src/main/assets/pulseaudio.tzst
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
rm -rf pa-bundle sink-out
- name: Build the DirectAudio relay
run: |
set -euo pipefail
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
tools/directaudio-relay/build.sh app/src/main/jniLibs/arm64-v8a
- name: Cache proot
id: proot
uses: actions/cache@v4
+1
View File
@@ -35,3 +35,4 @@ __pycache__/
# Local signing with the release key (tools/build_local.sh); holds a password.
.signing.env
app/src/main/assets/directaudio/
@@ -1,115 +0,0 @@
# DirectAudio for the Linux Steam client (relay build)
This is the driver for games that run under **Valve's ARM64 Proton inside a Linux
runtime on Android** - Bannerlator's linuxfs session with the native Steam client -
where the game process is a glibc process and cannot touch Android's audio
libraries itself.
Two pieces, both required:
| piece | what it is | where it runs |
|---|---|---|
| `aarch64-unix/winedirectaudio.so` + the two PE shells | the Wine `mmdevapi` driver, glibc build, **no AAudio inside** | in the game, inside the Linux rootfs |
| `directaudio-relay` | a small bionic program that owns the AAudio output and microphone streams | on the Android side, under the app's uid |
They meet over a unix socket. The driver connects, sends its launch config, and
gets back two shared-memory rings: one it fills with mixed game audio, one the
helper fills with microphone audio. The helper plays and records; the driver
keeps doing everything else it always did.
## Which Proton
Built inside `ValveSoftware/wine` at `proton_11.0`. The private `mmdevapi`
interface it implements is byte-identical across **Proton 11.0 (ARM64)**,
**Proton Experimental (ARM64)** and our own Wine-11 layers (checked 2026-09-19),
so this one build serves any of them. A future Proton on a Wine 12 base will
need a rebuild - the interface is not versioned, and a mismatch is silence, not
an error.
## Installing the driver next to Proton (not into it)
Steam verifies and updates its Proton depots, so files added under
`steamapps/common/Proton .../files/lib/wine/` will not survive. Put the driver
in a directory of its own and point Wine at it:
```
<somewhere>/directaudio/lib/wine/aarch64-unix/winedirectaudio.so
<somewhere>/directaudio/lib/wine/aarch64-windows/winedirectaudio.drv
<somewhere>/directaudio/lib/wine/i386-windows/winedirectaudio.drv
```
and set, for the game process:
```
WINEDLLPATH=<somewhere>/directaudio/lib/wine
```
Wine searches `WINEDLLPATH` entries with the per-arch subdirectories appended,
so both the PE shell and the unixlib are found there. In Bannerlator's runtime
the natural place to export it is the `bannerlator-proton` compatibility-tool
wrapper, which every Proton launch goes through.
## Selecting the driver
`mmdevapi` only tries the drivers named in the registry (or its built-in
`pulse,alsa,oss,coreaudio` list, which does not know about this one). Set, in the
game's prefix:
```
wine reg add "HKCU\Software\Wine\Drivers" /v Audio /d directaudio /f
```
Under Proton the prefix is `compatdata/<appid>/pfx`; run the command with that
prefix as `WINEPREFIX` using Proton's own `bin-arm64/wine`, or write the key from
the wrapper before it hands off to Proton.
## Running the helper
```
directaudio-relay --socket <path> [--log]
```
- Run it **under the app's uid**, the way the app already runs its PulseAudio
daemon. Android checks `RECORD_AUDIO` against the calling uid, so a helper
started any other way can play but cannot record.
- Start it **before** the game: the driver asks "is a helper there?" once, when
Wine picks an audio driver, and falls back to unavailable if not.
- The socket path must be reachable from inside the Linux rootfs. Bannerlator
binds the app's files directory and its runtime directory into the session at
their own paths, so any socket under either works unchanged on both sides.
- The helper serves any number of game processes; each gets its own streams.
It exits on `SIGTERM`; a game exiting tears down that game's streams only.
## Telling the driver where the helper is
```
BANNER_AUDIO_DIRECT_RELAY=<the same socket path>
```
Unset, the driver looks for `$XDG_RUNTIME_DIR/directaudio-relay`.
## Everything else is unchanged
All `BANNER_AUDIO_DIRECT_*` knobs mean what they mean in the in-process build:
`_PERF`, `_ADAPTIVE`, `_DECAY`, `_MS`, `_MAXMS`, `_BF`, `_MBF`, `_EXCLUSIVE`,
`_WATCHDOG`, `_STALL_MS`, `_DECAY_*`, `_LOG`, `_PERIOD_MS`, `_MINPERIOD_MS`, and
the live-config mailbox `_RUNTIME` (the helper watches the file; it must be a
path the helper can read on the Android side). **`BANNER_AUDIO_DIRECT_MIC=1`**
exposes the capture endpoint exactly as before: the helper opens the input
stream (`VOICE_COMMUNICATION` preset) when the game connects and makes it hot on
the game's first capture start.
One thing is different: there is one extra hand-off, the ring. The helper's
callback pulls a burst from it, so the driver keeps two bursts queued (about 8 ms
on a 192-frame device) on top of the AAudio buffer the presets control, and the
helper raises that if a callback ever finds the ring short. `get_latency` reports
the sum, so a game that sizes its own buffers from it still sees the truth.
## Proving it is running
- Helper side: `logcat -s DA-Relay:I` shows `hello from <exe>`, then `open: buffer
... ring target ...`, then `capture open` / `capture start` for the mic.
- Game side: the session log carries `DirectAudio: relay: connected - ...`.
- The game process's `/proc/<pid>/maps` shows `winedirectaudio.so` and **no**
`libaaudio.so` (it is in the helper's maps instead). The AudioFlinger track is
owned by the helper's pid.
@@ -1,4 +0,0 @@
directaudio linux-wine11 relay build
source 3548a4a730e8d01cb0cb0268d51a2fb8f23dcd33
wine ValveSoftware/wine@proton_11.0
unixlib.h sha256 fdbed263338d0719c4d12e02b045c23721de33219ec8878e4c2d7ebc9efcbe09
@@ -160,7 +160,6 @@ class MainActivity : ComponentActivity() {
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
private var showPhantomGate by mutableStateOf(false)
private var directAudio by mutableStateOf(false)
private var clientDirectAudio by mutableStateOf(true)
private var stretch16x9 by mutableStateOf(false)
private var launcherFullscreen by mutableStateOf(true)
private var animationsEnabled by mutableStateOf(true)
@@ -1193,7 +1192,6 @@ class MainActivity : ComponentActivity() {
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
clientDirectAudio = clientDirectAudio,
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
mic = if (mode == SessionService.MODE_STEAM) mic else null,
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
@@ -1258,7 +1256,6 @@ class MainActivity : ComponentActivity() {
backActionsInverted = inverted
},
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
onClientDirectAudio = { on -> SessionPrefs.setClientDirectAudio(this, on); clientDirectAudio = on },
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
onMic = { on ->
SessionPrefs.setMicEnabled(this, on)
@@ -1423,7 +1420,6 @@ class MainActivity : ComponentActivity() {
oscMode = SessionPrefs.oscMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
directAudio = SessionPrefs.directAudio(this)
clientDirectAudio = SessionPrefs.clientDirectAudio(this)
stretch16x9 = SessionPrefs.stretch16x9(this)
mic = SessionPrefs.micEnabled(this)
refreshWifiDiscovery()
@@ -28,7 +28,9 @@ public class PulseAudioComponent extends SessionPart {
/** Where the guest reaches the daemon; the session exports PULSE_SERVER=unix:<this>. */
public static final String SOCKET_NAME = "PS0";
/** Identifies the bundled pulseaudio.tzst; a change here re-unpacks it over what a device has. */
private static final String BUNDLE_STAMP = "2026-09-23-pa13-suspend-r5";
private static final String BUNDLE_STAMP = "2026-10-08-pa13-directaudio-native";
private final File workingDir;
/** Where the daemon's own output is kept for this session, or null for logcat only. */
@@ -40,8 +42,6 @@ public class PulseAudioComponent extends SessionPart {
* turns that one stream into a source the client can see, named DirectAudioMic.
*/
private final String micFifoPath;
/** The DirectAudio relay's socket when the client's output should go through it, else null. */
private String relaySocketPath;
private volatile int pid = -1;
public PulseAudioComponent(Context context) {
@@ -54,15 +54,6 @@ public class PulseAudioComponent extends SessionPart {
this.micFifoPath = micFifoPath;
}
/**
* Route the daemon's output through the DirectAudio relay at this socket instead of an AAudio
* stream of its own. The relay owns the stream outside proot, with its adaptive buffer; the
* daemon only fills a shared ring. Set before {@link #start()}; the relay may start later.
*/
public void setRelaySocket(String path) {
this.relaySocketPath = path;
}
/** CRC-32 of the bundled pulseaudio.tzst, or "?" if it cannot be read. About 600 KB; cheap. */
private String bundleChecksum() {
java.util.zip.CRC32 crc = new java.util.zip.CRC32();
@@ -106,6 +97,7 @@ public class PulseAudioComponent extends SessionPart {
String have = FileUtils.readString(stamp);
String want = BUNDLE_STAMP + "-" + bundleChecksum();
if (!new File(modulesDir, "arm64/module-aaudio-sink.so").isFile()
|| !new File(modulesDir, "arm64/module-directaudio-native-sink.so").isFile()
|| !new File(workingDir, "pactl").isFile()
|| have == null || !want.equals(have.trim())) {
Log.i(TAG, "unpacking pulseaudio.tzst (" + want + "; had " + have + ")");
@@ -139,13 +131,15 @@ public class PulseAudioComponent extends SessionPart {
// the sink to 0% and the session plays silence.
// The sink's name is what the client's Audio settings show as the output device, so it
// says which road the sound takes.
if (relaySocketPath != null && !relaySocketPath.isEmpty()) {
config.add("load-module module-directaudio-sink sink_name=DirectAudio socket=\"" + relaySocketPath + "\" performance_mode=1 adaptive=1 volume=1.0");
config.add("set-default-sink DirectAudio");
} else {
config.add("load-module module-aaudio-sink sink_name=AAudioSink performance_mode=1 adaptive=1 volume=1.0");
config.add("set-default-sink AAudioSink");
}
// The client's sound: DirectAudio's own engine, in this process, one step from Android.
// decay=0: shrinking the device buffer after a quiet spell probes below what works and that
// probe is a click; the client plays menus. Between .nofail and .fail a module that cannot
// open its stream does not take the daemon down; ensureClientSink() then loads the plain
// AAudio sink in its place.
config.add(".nofail");
config.add("load-module module-directaudio-native-sink sink_name=DirectAudio performance_mode=1 adaptive=1 decay=0 limiter=1 watchdog=1 volume=1.0");
config.add("set-default-sink DirectAudio");
config.add(".fail");
if (micFifoPath != null && !micFifoPath.isEmpty()) {
// The format is the helper's, fixed at s16le/48000/mono: it resamples when the device
// grants another input rate, so the daemon is never told a rate the bytes are not.
@@ -177,6 +171,74 @@ public class PulseAudioComponent extends SessionPart {
Log.i(TAG, line);
if (out != null) synchronized (out) { out.println(line); out.flush(); }
});
Thread guard = new Thread(() -> ensureClientSink(out), "pulse-sink-guard");
guard.setDaemon(true);
guard.start();
}
/**
* The fallback: once the daemon answers, if the DirectAudio sink is not
* there (the module failed to load or to open its stream), load the plain AAudio sink so the
* client is never left without an output. Says so in the audio log either way.
*/
private void ensureClientSink(java.io.PrintWriter out) {
long deadline = System.currentTimeMillis() + 15000L;
String sinks = null;
while (System.currentTimeMillis() < deadline && pid > 1) {
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
}
String line;
if (sinks == null) {
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
} else {
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
&& pactl("set-default-sink", "AAudioSink") != null;
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
Log.w(TAG, line);
}
Log.i(TAG, line);
if (out != null) synchronized (out) { out.println(line); out.flush(); }
}
/** Runs pactl against this daemon; its stdout, or null when it failed or timed out. */
private String pactl(String... args) {
File pactl = new File(workingDir, "pactl");
if (!pactl.isFile() || pid <= 1) return null;
java.lang.Process process = null;
try {
File modules = new File(workingDir, "modules/arm64");
ArrayList<String> cmd = new ArrayList<>();
cmd.add(pactl.getAbsolutePath());
java.util.Collections.addAll(cmd, args);
ProcessBuilder builder = new ProcessBuilder(cmd);
builder.directory(workingDir);
builder.redirectErrorStream(true);
builder.environment().put("LD_LIBRARY_PATH", "/system/lib64:" + modules + ":" + workingDir);
builder.environment().put("HOME", workingDir.getAbsolutePath());
builder.environment().put("TMPDIR", workingDir.getAbsolutePath());
builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath());
process = builder.start();
String output;
try (InputStream in = process.getInputStream()) {
output = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
}
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly();
return null;
}
return process.exitValue() == 0 ? output : null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} catch (Exception e) {
return null;
} finally {
if (process != null && process.isAlive()) process.destroyForcibly();
}
}
@Override
@@ -167,7 +167,6 @@ object DeviceReport {
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
k("Microphone", SessionPrefs.micEnabled(context))
k("On-screen controls", SessionPrefs.oscMode(context))
k("Touch mode", SessionPrefs.touchMode(context))
@@ -16,6 +16,8 @@ object SessionFiles {
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
/** Where the DirectAudio driver lives inside the runtime; the wrappers get it as BL_DIRECTAUDIO. */
const val DIRECTAUDIO_DIR = "usr/local/lib/directaudio"
/** The driver sets staged under it: classic Wine 11 (Valve, GE) and the system-thread interface (Proton-CachyOS). */
val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread")
/**
* The libraries and scripts the session runs, refreshed from the apk at every launch.
@@ -152,30 +154,37 @@ object SessionFiles {
ComponentsManager.migrateLaunchDir(context)
EsyncPacks.stageBundled(context, root)
// The DirectAudio driver for games under Proton: the glibc build of winedirectaudio, which
// the Proton wrappers add to WINEDLLPATH when the session asks for it (BL_DIRECTAUDIO).
// Staged like the scripts, so a driver fix reaches an installed runtime without re-hosting.
// the Proton wrappers add to WINEDLLPATH when the session asks for it (BL_DIRECTAUDIO). Two
// sets, one per Wine audio interface - the wrapper picks by what the Proton's own winepulse.so
// imports (see steam-compatibility), because both Wines call themselves 11.0 and the wrong set
// plays silence. Staged like the scripts, so a driver fix reaches an installed runtime without
// re-hosting. The sets come from the pinned DirectAudio release (tools/directaudio/release.env).
val directAudio = arrayOf(
"aarch64-unix/winedirectaudio.so",
"aarch64-windows/winedirectaudio.drv",
"i386-windows/winedirectaudio.drv",
"version.txt",
)
for (relative in directAudio) {
val target = File(root, "$DIRECTAUDIO_DIR/lib/wine/$relative")
for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
val target = File(root, "$DIRECTAUDIO_DIR/$set/lib/wine/$relative")
val staged = File(target.parentFile, target.name + ".staged")
var installed = false
try {
target.parentFile?.mkdirs()
context.assets.open("directaudio/linux-wine11/$relative").use { input ->
context.assets.open("directaudio/$set/$relative").use { input ->
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "could not stage DirectAudio $relative", e)
Log.w(TAG, "could not stage DirectAudio $set/$relative", e)
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "DirectAudio $relative NOT staged")
if (!installed) Log.e(TAG, "DirectAudio $set/$relative NOT staged")
}
// A runtime from before the sets had names kept the driver at the directory's root; it would
// never be picked again, so it goes.
for (old in arrayOf("aarch64-unix", "aarch64-windows", "i386-windows")) File(root, "$DIRECTAUDIO_DIR/lib/wine/$old").deleteRecursively()
// What every process in the session preloads. LD_PRELOAD in the environment would not
// survive: the Steam client rebuilds it for each process it starts and appends its own
// overlay entry without a separator, which silently drops whatever was there.
@@ -160,15 +160,9 @@ object SessionPrefs {
prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).apply()
}
/**
* The Steam client's own sound through the DirectAudio relay instead of the classic AAudio
* sink. On unless the user picked Classic.
*/
fun clientDirectAudio(context: Context): Boolean = prefs(context).getBoolean("clientDirectAudio", true)
fun setClientDirectAudio(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("clientDirectAudio", on).apply()
}
// The Steam client's own sound has one route now: DirectAudio's engine inside the PulseAudio
// daemon (PulseAudioComponent), one step from Android. The old "clientDirectAudio" boolean
// (which meant the relay route, one hop more) is no longer read.
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
@@ -694,16 +694,13 @@ class SessionService : Service() {
val audioLog = File(sessionDir, "audio.log")
val pulse = PulseAudioComponent(this, micFifo?.absolutePath)
// With DirectAudio on, the client's own sound goes through the relay too: the daemon
// fills the relay's ring and the relay, outside proot, drives the device.
// The client's own sound: the classic AAudio sink unless the user chose the relay.
val clientDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.clientDirectAudio(this)
if (clientDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath)
// The client's own sound: DirectAudio's engine inside the daemon, one step from Android (the
// daemon runs on the Android side). The relay below is for games and the microphone only.
pulse.setLogFile(audioLog)
pulse.attach(this)
guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath)
components.add(pulse)
if (wantsDirectAudio || wantsMic || clientDirectAudio) {
if (wantsDirectAudio || wantsMic) {
// After the daemon in the list, so it can wait for the pipe the daemon makes.
val relay = DirectAudioRelayComponent(relaySocket, micFifo)
relay.setLogFile(audioLog)
@@ -717,7 +714,7 @@ class SessionService : Service() {
guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR)
guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
}
Log.i(TAG, "audio: client " + (if (clientDirectAudio) "DirectAudio" else "classic AAudio sink") + (if (wantsDirectAudio) " + DirectAudio for games" else "")
Log.i(TAG, "audio: client DirectAudio (in-process sink)" + (if (wantsDirectAudio) " + DirectAudio for games" else "")
+ (if (wantsMic) " + microphone" else "") +
(if (SessionPrefs.micEnabled(this) && !wantsMic) " (microphone wanted but RECORD_AUDIO not granted)" else ""))
return pulse
@@ -75,7 +75,6 @@ class ModeSettings(
/** Steam only: whether single and double Back actions are swapped. */
val backActionsInverted: Boolean = false,
val directAudio: Boolean?,
val clientDirectAudio: Boolean = true,
val mic: Boolean?,
val renderer: String?,
val gameStorage: String? = null,
@@ -140,7 +139,6 @@ class ModeSettingsActions(
val onOsc: (String) -> Unit,
val onBackActionsInverted: (Boolean) -> Unit = {},
val onDirectAudio: (Boolean) -> Unit,
val onClientDirectAudio: (Boolean) -> Unit = {},
val onMic: (Boolean) -> Unit,
val onRenderer: (String) -> Unit,
val onGameStorage: (path: String, label: String) -> Unit = { _, _ -> },
@@ -486,11 +484,6 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
if (steam && tab == ModeSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
ChoiceRow(
host, "clientAudio", stringResource(R.string.mode_client_audio), stringResource(R.string.mode_client_audio_hint),
listOf("classic" to stringResource(R.string.mode_client_audio_classic), "directaudio" to stringResource(R.string.mode_client_audio_direct)), if (s.clientDirectAudio) "directaudio" else "classic",
onPick = { id -> a.onClientDirectAudio(id == "directaudio") },
)
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
}
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
-4
View File
@@ -337,10 +337,6 @@
<string name="mode_audio">Audio</string>
<string name="mode_directaudio">DirectAudio para juegos</string>
<string name="mode_directaudio_hint">Evita PulseAudio para menor latencia.</string>
<string name="mode_client_audio">Audio del cliente de Steam</string>
<string name="mode_client_audio_hint">Se aplica en la próxima sesión.</string>
<string name="mode_client_audio_classic">Clásico</string>
<string name="mode_client_audio_direct">DirectAudio</string>
<string name="mode_mic">Micrófono</string>
<string name="mode_mic_hint">Para el chat de voz.</string>
<string name="mode_network">Red</string>
-4
View File
@@ -284,10 +284,6 @@
<string name="mode_audio">Audio</string>
<string name="mode_directaudio">Utiliser DirectAudio pour le jeu</string>
<string name="mode_directaudio_hint">Contourne PulseAudio pour réduire la latence.</string>
<string name="mode_client_audio">Audio du client Steam</string>
<string name="mode_client_audio_hint">Prendra effet à la prochaine session.</string>
<string name="mode_client_audio_classic">Classique</string>
<string name="mode_client_audio_direct">DirectAudio</string>
<string name="mode_mic">Microphone</string>
<string name="mode_mic_hint">Utilisé pour le chat vocal.</string>
<string name="mode_network">Réseau</string>
-4
View File
@@ -337,10 +337,6 @@
<string name="mode_audio">オーディオ</string>
<string name="mode_directaudio">ゲームでDirectAudioを使用</string>
<string name="mode_directaudio_hint">PulseAudioをバイパスして遅延を減らします。</string>
<string name="mode_client_audio">Steamクライアントオーディオ</string>
<string name="mode_client_audio_hint">次回のセッションで有効になります。</string>
<string name="mode_client_audio_classic">クラシック</string>
<string name="mode_client_audio_direct">DirectAudio</string>
<string name="mode_mic">マイク</string>
<string name="mode_mic_hint">ボイスチャットに使用します。</string>
<string name="mode_network">ネットワーク</string>
-4
View File
@@ -337,10 +337,6 @@
<string name="mode_audio">오디오</string>
<string name="mode_directaudio">게임에서 DirectAudio 사용</string>
<string name="mode_directaudio_hint">PulseAudio를 우회하여 지연 시간을 줄입니다.</string>
<string name="mode_client_audio">Steam 클라이언트 오디오</string>
<string name="mode_client_audio_hint">다음 세션에 적용됩니다.</string>
<string name="mode_client_audio_classic">클래식</string>
<string name="mode_client_audio_direct">DirectAudio</string>
<string name="mode_mic">마이크</string>
<string name="mode_mic_hint">음성 채팅에 사용됩니다.</string>
<string name="mode_network">네트워크</string>
-4
View File
@@ -312,10 +312,6 @@
<string name="mode_audio">Аудио</string>
<string name="mode_directaudio">DirectAudio для игр</string>
<string name="mode_directaudio_hint">Обходит PulseAudio для снижения задержки.</string>
<string name="mode_client_audio">Аудио клиента Steam</string>
<string name="mode_client_audio_hint">Вступит в силу в следующей сессии.</string>
<string name="mode_client_audio_classic">Классическое</string>
<string name="mode_client_audio_direct">DirectAudio</string>
<string name="mode_mic">Микрофон</string>
<string name="mode_mic_hint">Используется для голосового чата.</string>
<string name="mode_network">Сеть</string>
@@ -337,10 +337,6 @@
<string name="mode_audio">音频</string>
<string name="mode_directaudio">游戏使用 DirectAudio</string>
<string name="mode_directaudio_hint">绕过 PulseAudio 以降低延迟。</string>
<string name="mode_client_audio">Steam 客户端音频</string>
<string name="mode_client_audio_hint">将在下次会话生效。</string>
<string name="mode_client_audio_classic">经典</string>
<string name="mode_client_audio_direct">DirectAudio</string>
<string name="mode_mic">麦克风</string>
<string name="mode_mic_hint">用于语音聊天。</string>
<string name="mode_network">网络</string>
@@ -337,10 +337,6 @@
<string name="mode_audio">音訊</string>
<string name="mode_directaudio">遊戲使用 DirectAudio</string>
<string name="mode_directaudio_hint">繞過 PulseAudio 以減低延遲。</string>
<string name="mode_client_audio">Steam 用戶端音訊</string>
<string name="mode_client_audio_hint">下次會話生效。</string>
<string name="mode_client_audio_classic">經典</string>
<string name="mode_client_audio_direct">DirectAudio</string>
<string name="mode_mic">麥克風</string>
<string name="mode_mic_hint">用於語音聊天。</string>
<string name="mode_network">網絡</string>
@@ -337,10 +337,6 @@
<string name="mode_audio">音訊</string>
<string name="mode_directaudio">遊戲使用 DirectAudio</string>
<string name="mode_directaudio_hint">繞過 PulseAudio 以降低延遲。</string>
<string name="mode_client_audio">Steam 用戶端音訊</string>
<string name="mode_client_audio_hint">下次工作階段生效。</string>
<string name="mode_client_audio_classic">傳統</string>
<string name="mode_client_audio_direct">DirectAudio</string>
<string name="mode_mic">麥克風</string>
<string name="mode_mic_hint">用於語音聊天。</string>
<string name="mode_network">網路</string>
-4
View File
@@ -341,10 +341,6 @@
<string name="mode_audio">Audio</string>
<string name="mode_directaudio">DirectAudio for games</string>
<string name="mode_directaudio_hint">Bypasses PulseAudio for lower latency.</string>
<string name="mode_client_audio">Steam client audio</string>
<string name="mode_client_audio_hint">Applies next session.</string>
<string name="mode_client_audio_classic">Classic</string>
<string name="mode_client_audio_direct">DirectAudio</string>
<string name="mode_mic">Microphone</string>
<string name="mode_mic_hint">For voice chat.</string>
<string name="mode_network">Network</string>
+55
View File
@@ -7,6 +7,61 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
---
## 2026-10-08 - `feat/directaudio-from-release`: DirectAudio from its own release, picked by interface, and the client gets the real engine
Three things were wrong with audio at once, and one tidy-up @xXJSONDeruloXx asked for.
- **Proton-CachyOS games never got DirectAudio.** Both CachyOS Protons we offer carry Wine's newer
*system-thread* audio interface (`dlls/mmdevapi/unixlib.h` differs from Valve's); the one driver
we shipped was built for the classic table, and the wrapper only asked "is this Wine 11?", which
CachyOS passes. Now two driver sets ship (`linux-wine11` for Valve Proton 11 / Experimental /
GE-Proton, `linux-wine11-systhread` for Proton-CachyOS) and `steam-compatibility` picks by what
the Proton's own `winepulse.so` imports (`PsCreateSystemThread` only on the system-thread table) -
never by the version string. No set for the interface, or no `winepulse.so`: DirectAudio stays
off and Proton's audio runs. Confirmed on the FIT: Experimental 20260924 and GE-Proton11-7
classic, cachyos-11.0-20261005-slr system-thread.
- **CachyOS also overrode the registry** (device log: `init_driver Loading driver list
L"pulse,alsa"`): its launcher sets `WINE_AUDIO_DRIVER=pulse,alsa` (setdefault) and its mmdevapi
reads that variable before the registry; Valve's and GE's mmdevapi ignore it. The wrapper now
exports `WINE_AUDIO_DRIVER=directaudio,pulse` as well as writing the registry. On CachyOS that
also means DirectAudio engages on a game's first launch, no registry value needed.
- **Some games ran on PulseAudio while the log said DirectAudio.** Two prefixes on the FIT had the
driver linked into `system32` but a pristine `user.reg` with no `Audio` value: Steam runs the
compat tool several times per launch, and a wineserver from an earlier verb still holding the
registry saves its copy over the line we appended. The wrapper now says so when a server is
live. Two stronger fixes were tried on the device and withdrawn the same day: writing the value
through the live server with a bare `wine reg add`, and having Proton create a missing prefix
first (`proton run wineboot`) - the second Proton run inside Steam's launch sequence got the
launch aborted (`KeyboardInterrupt` in Proton, games bounced back to their page, a half-made
prefix). On Valve/GE a first launch therefore still takes DirectAudio from the second run.
- **The Steam client's own sound was "DirectAudio" in name only.** PulseAudio → sink → relay →
AAudio is one hop more than a plain AAudio sink; the only gain was sharing the games' stream.
The daemon runs on the Android side, so DirectAudio's engine can run *in* it:
`module-directaudio-native-sink` (from the DirectAudio release) - the game driver's adaptive
buffer, two-burst floor, primed ring with fades, route-change reopen, watchdog and limiter, one
step from Android. It is now the only route: the *Steam client audio* choice is gone from the
menu (the relay route was one hop more for no gain, and the old "Classic" sink is the automatic
fallback). If the native sink cannot open its stream the daemon keeps running (`.nofail`) and
`PulseAudioComponent.ensureClientSink` loads the plain AAudio sink instead, saying so in
`audio.log`. The relay helper now starts only for games and the microphone.
- **Binaries out of tree** (@xXJSONDeruloXx): the driver sets, the relay helper and both sinks come
from the pinned release in `tools/directaudio/release.env` (`directaudio-linux-v1.1.0`, four
zips, sha256-checked) through `tools/directaudio/fetch.sh`, in CI and `build_local.sh`, like
gamescope. Deleted: `app/src/main/assets/directaudio/` (now generated, ignored),
`tools/directaudio-relay/`, `tools/aaudio-sink/module-directaudio-sink.c` + `da_relay_proto.h`.
`tools/aaudio-sink` keeps only `module-aaudio-sink` (the fallback). The release carries
MaxsTechReview's #338 fixes, credited there.
- Audio bundle stamp bumped so an installed app re-unpacks the modules.
- **Device-proven on the FIT, four cases:** the client's sink opened at 12 ms / 4 ms bursts with
0 underruns after 10 s (`client sink: DirectAudio (module-directaudio-native-sink)`); FEZ on
`cachyos-11.0-20261005-slr` took the system-thread set - Proton log `Loading driver list
L"directaudio,pulse"` → `Selecting driver L"directaudio"`, relay `hello from FEZ.exe`, 12 ms;
FEZ on Proton Experimental 20260924 on `linux-wine11`, relay hello, 12 ms (no regression);
Curse of the Dead Gods on CachyOS from a cold prefix - DirectAudio on its first launch, one
early xrun and the buffer grew 12 → 16 ms. Open: a Thor (20 ms bursts). An added game (Noita,
a Winlator-era folder never launched here before) bounced on launch with and without this
branch's wrapper steps, before Proton wrote a log - its own issue.
## 2026-09-29 - `feat/controller-input`: the pad the way SteamOS has it
Device-tested on the AYN Thor (Katamari under Proton Experimental ARM64).
+2 -2
View File
@@ -2,7 +2,8 @@
# Compiles module-aaudio-sink.so against the upstream PulseAudio 13.0 headers and the daemon's own
# libraries the app ships (app/src/main/jniLibs/arm64-v8a), with the Android NDK.
# NDK=<ndk root> tools/aaudio-sink/build.sh <pulseaudio-13.0 source dir> <output dir>
# Produces module-aaudio-sink.so and module-directaudio-sink.so in the output dir.
# Produces module-aaudio-sink.so in the output dir (the fallback sink; the DirectAudio sinks come
# from the pinned DirectAudio release, see tools/directaudio/fetch.sh).
set -euo pipefail
PA_SRC=$1
OUTDIR=$2
@@ -61,4 +62,3 @@ build_one() {
ls -l "$OUT"
}
build_one module-aaudio-sink.c "$OUTDIR/module-aaudio-sink.so" -laaudio
build_one module-directaudio-sink.c "$OUTDIR/module-directaudio-sink.so" ""
-209
View File
@@ -1,209 +0,0 @@
/* DirectAudio relay - the contract between the driver and the relay helper.
*
* Copyright 2026 The412Banner
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* WHY THIS EXISTS. The driver's whole premise is an in-process AAudio client:
* the guest's WASAPI buffers are mixed and handed to Android's audio server
* from inside the game, no daemon in the path. That is only possible when the
* game process is a bionic process. Under a glibc runtime (the Linux Steam
* client, where the game runs inside a proot'd Arch rootfs on Valve's own
* ARM64 Proton) libaaudio cannot be loaded at all: it is a bionic library and
* the rootfs's dynamic linker does not know how to bring it into a glibc
* process. The game side therefore has to stop at a boundary it CAN cross, and
* the only thing both worlds agree on is the kernel: a unix socket, a memfd and
* a futex word.
*
* SHAPE. One helper process (directaudio-relay, bionic, links libaaudio) runs
* on the Android side and listens on a unix socket. Each game process that
* needs audio connects once, sends its launch config (the same knobs the
* in-process build reads from BANNER_AUDIO_DIRECT_*), and gets back one or two
* shared rings (memfds passed with SCM_RIGHTS):
*
* render ring game (producer) -> helper (consumer) -> AAudio OUTPUT
* capture ring helper (producer, AAudio INPUT) -> game (consumer)
*
* The driver keeps its mixer and its per-voice WASAPI rings exactly as in the
* in-process build; what used to be the AAudio data callback is now a pump
* thread that keeps the render ring topped up, and the capture callback is a
* pump that drains the capture ring into the capture voices. Everything that
* needs an AAudioStream (open, adaptive buffer growth and decay, route-change
* reopen, the stalled-callback watchdog, the live-config mailbox) moves to the
* helper unchanged in spirit.
*
* COST. One extra hand-off: the ring. The helper's callback pulls a burst from
* the ring, so the ring must hold at least one burst at every callback; the
* driver keeps DA_RING_TARGET_BURSTS bursts queued and the helper raises that
* target when a callback finds the ring short. Everything else - the AAudio
* buffer, AudioFlinger's own latency - is what the in-process build pays too.
*
* WIRE FORMAT. Fixed-width, native-endian, same-machine only. The two sides are
* different libcs but the same CPU, so no marshalling beyond fixed-size structs.
* Bump DA_RELAY_VERSION on any layout change; the helper refuses a mismatch.
*/
#ifndef DA_RELAY_PROTO_H
#define DA_RELAY_PROTO_H
#include <stdint.h>
#include <stddef.h>
#define DA_RELAY_VERSION 1
#define DA_RING_MAGIC 0x44415247u /* 'DARG' */
#define DA_HELLO_MAGIC 0x44414831u /* 'DAH1' */
#define DA_ACK_MAGIC 0x44414131u /* 'DAA1' */
/* Socket path. The driver reads BANNER_AUDIO_DIRECT_RELAY; when unset it falls
* back to $XDG_RUNTIME_DIR/DA_RELAY_DEFAULT_NAME, which a host that already
* hands its runtime dir into the session gets for free. */
#define DA_RELAY_ENV "BANNER_AUDIO_DIRECT_RELAY"
#define DA_RELAY_DEFAULT_NAME "directaudio-relay"
/* Both rings carry float32 interleaved stereo. The render ring is always
* 48 kHz (the mixer's output rate); the capture ring runs at whatever rate the
* input stream was granted, published in da_ring.rate, and the driver resamples
* per voice as the in-process build already does. */
#define DA_RING_RATE 48000
#define DA_RING_CHANNELS 2
/* Render ring sizing. Capacity is the ceiling adaptive growth can reach; the
* initial target is what the driver keeps queued. Two bursts: the helper's
* callback consumes one burst and wakes the pump, which then has a full burst
* period to refill before the next callback. */
#define DA_RING_CAP_MS 250
#define DA_RING_TARGET_BURSTS 2
#define DA_RING_MAX_TARGET_MS 100
/* Capture ring: 200 ms is far more than a game ever leaves queued; on overrun
* the helper drops the newest block, matching the in-process "guest is not
* draining" behaviour of dropping data rather than blocking the mic thread. */
#define DA_CAPTURE_RING_MS 200
enum da_ring_state
{
DA_RING_OPENING = 0,
DA_RING_PLAYING = 1,
DA_RING_ERROR = 2,
};
struct da_ring
{
uint32_t magic;
uint32_t version;
uint32_t cap_frames; /* ring capacity in frames */
uint32_t rate; /* frames per second of the data in this ring */
uint32_t channels; /* DA_RING_CHANNELS */
uint32_t _pad0;
/* Single producer, single consumer; monotonic frame totals, wrap-safe with
* unsigned arithmetic. Producer writes widx with RELEASE after the data;
* consumer writes ridx with RELEASE after it has copied the data out. */
volatile uint32_t widx;
volatile uint32_t ridx;
/* Futex word the CONSUMER of a render ring / PRODUCER of a capture ring
* bumps after each block, so the other side can sleep on it instead of
* polling. Waiters always use a timeout, so a missed wake only costs one
* period. */
volatile uint32_t wake;
volatile uint32_t quit; /* either side: tear down */
volatile uint32_t state; /* enum da_ring_state, helper-owned */
/* Render ring only, helper-owned, read by the driver: */
volatile int32_t target_frames; /* keep at least this many queued */
volatile int32_t hw_buf_frames; /* AAudio buffer size right now (get_latency) */
volatile int32_t hw_burst; /* frames per burst of the output stream */
volatile uint32_t underruns; /* callbacks that found fewer than they needed */
volatile uint32_t cb_count; /* data callbacks so far (liveness) */
uint32_t _pad1[3];
float data[]; /* cap_frames * channels */
};
static inline size_t da_ring_bytes(uint32_t cap_frames)
{
return sizeof(struct da_ring) + (size_t)cap_frames * DA_RING_CHANNELS * sizeof(float);
}
static inline uint32_t da_ring_avail(const struct da_ring *r)
{
uint32_t w = __atomic_load_n(&r->widx, __ATOMIC_ACQUIRE);
uint32_t rd = __atomic_load_n(&r->ridx, __ATOMIC_ACQUIRE);
return w - rd;
}
static inline uint32_t da_ring_space(const struct da_ring *r)
{
return r->cap_frames - da_ring_avail(r);
}
/* ---- wire messages ------------------------------------------------------- */
#define DA_HELLO_MIC 0x01u /* also open an INPUT stream + capture ring */
#define DA_HELLO_EXCLUSIVE 0x02u /* AAUDIO_SHARING_MODE_EXCLUSIVE requested */
#define DA_HELLO_ADAPTIVE 0x04u
#define DA_HELLO_DECAY 0x08u
#define DA_HELLO_WATCHDOG 0x10u
/* First message on a fresh connection, driver -> helper. Mirrors the launch
* config the in-process build derives from BANNER_AUDIO_DIRECT_* so a preset
* means the same thing under both runtimes. */
struct da_hello
{
uint32_t magic; /* DA_HELLO_MAGIC */
uint32_t version; /* DA_RELAY_VERSION */
uint32_t flags; /* DA_HELLO_* */
int32_t perf; /* 0 NONE, 1 LOW_LATENCY, 2 POWER_SAVING (host convention) */
int32_t target_ms; /* AAudio buffer, ms; 0 = use target_frames or the default */
int32_t max_ms; /* adaptive ceiling, ms; 0 = max_frames or the default */
int32_t target_frames; /* frame forms, used only when the ms forms are 0 */
int32_t max_frames;
uint32_t stall_ms; /* watchdog: callback silent this long => rebuild */
uint32_t quiet_ms; /* decay pacing, see the driver */
uint32_t punish_ms;
uint32_t max_backoff;
int32_t pid; /* the game process, for the log */
uint32_t log; /* BANNER_AUDIO_DIRECT_LOG */
char runtime_path[256]; /* BANNER_AUDIO_DIRECT_RUNTIME mailbox, "" = none */
char name[64]; /* process name, for the log */
};
#define DA_ACK_MIC_FAILED 0x01u /* mic asked for, input stream would not open */
/* Reply, helper -> driver, followed by SCM_RIGHTS carrying the ring fds:
* fd[0] = render ring, fd[1] = capture ring (present iff in_cap_frames > 0). */
struct da_ack
{
uint32_t magic; /* DA_ACK_MAGIC */
int32_t status; /* 0 = ok, else an AAudio error code (negative) */
int32_t rate; /* output stream rate (48000) */
int32_t burst; /* output frames per burst */
int32_t buf_frames; /* output buffer size as opened */
int32_t out_cap_frames; /* render ring capacity */
int32_t in_cap_frames; /* capture ring capacity, 0 = no capture ring */
int32_t in_rate; /* rate of the capture ring's data */
uint32_t flags; /* DA_ACK_* */
uint32_t _pad;
};
/* Subsequent messages, driver -> helper, on the same socket. The helper never
* writes after the ack; everything it has to say is in the ring header. EOF on
* the socket is how the helper learns the game is gone. */
enum da_msg_type
{
DA_MSG_MIC_START = 1, /* first capture voice started: make the mic hot */
DA_MSG_MIC_STOP = 2, /* last capture voice gone: stop (keep open) */
DA_MSG_PING = 3,
};
struct da_msg
{
uint32_t type;
int32_t arg;
};
#endif /* DA_RELAY_PROTO_H */
-481
View File
@@ -1,481 +0,0 @@
/*
* module-directaudio-sink: a PulseAudio 13 sink that hands the daemon's mixed output to the
* DirectAudio relay, the bionic helper that owns the AAudio stream on the Android side.
*
* The daemon runs under proot, where every system call is traced; an AAudio stream driven from
* in here is at the mercy of that. The relay is not: it is the same helper that plays games'
* DirectAudio, with its adaptive buffer, route-change reopen and watchdog. This module is a
* producer for its render ring (da_relay_proto.h): float stereo at the ring's rate, kept topped
* up to the target the relay asks for.
*
* The relay comes up after the daemon, so the sink loads without it and connects when the socket
* appears; until then it runs as a clocked null sink so clients are not stalled.
*
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
#ifdef HAVE_CONFIG_H
#include <config.h>
#endif
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <sys/socket.h>
#include <sys/syscall.h>
#include <sys/un.h>
#include <time.h>
#include <unistd.h>
#include <pulse/rtclock.h>
#include <pulse/timeval.h>
#include <pulse/util.h>
#include <pulse/xmalloc.h>
#include <pulsecore/core-util.h>
#include <pulsecore/log.h>
#include <pulsecore/macro.h>
#include <pulsecore/modargs.h>
#include <pulsecore/module.h>
#include <pulsecore/rtpoll.h>
#include <pulsecore/sink.h>
#include <pulsecore/thread-mq.h>
#include <pulsecore/thread.h>
#include "da_relay_proto.h"
PA_MODULE_AUTHOR("The412Banner");
PA_MODULE_DESCRIPTION("Android audio output through the DirectAudio relay");
PA_MODULE_VERSION(PACKAGE_VERSION);
PA_MODULE_LOAD_ONCE(false);
PA_MODULE_USAGE(
"socket=<the relay's unix socket> "
"sink_name=<name for the sink> "
"sink_properties=<properties for the sink> "
"volume=<initial volume, linear, 1.0 = full> "
"performance_mode=<0 none, 1 low latency, 2 power saving> "
"adaptive=<let the relay grow its buffer after underruns: 0 or 1> "
"buffer_ms=<device buffer the relay starts with, in ms; rounded up to whole bursts>");
#define DEFAULT_SINK_NAME "DirectAudio"
#define RECONNECT_USEC (500 * PA_USEC_PER_MSEC)
#define OFFLINE_TICK_USEC (20 * PA_USEC_PER_MSEC)
#define STATS_EVERY_USEC (30 * PA_USEC_PER_SEC)
/* Never queue more than this ahead of the relay's target, whatever it asks for. */
#define MAX_AHEAD_MS 250
/* Always keep at least this much queued. The relay starts its target at two bursts (8 ms), which
* a producer inside proot misses while the client is loading: two underruns in the first seconds
* on the FIT, steady stutter on a slower phone. 40 ms of slack costs 40 ms of latency at most. */
#define MIN_AHEAD_MS 40
/* The device buffer asked of the relay. Its own default is 12 ms rounded up to whole bursts: three
* 4 ms bursts on a device that grants a fast stream, but ONE 20 ms burst where Android grants a
* legacy one (an AYN Thor, Android 13), and a single burst has nothing to cover a late callback.
* 24 ms is two bursts there, Android's recommended minimum; the relay still grows it on xruns. */
#define DEFAULT_BUFFER_MS 24
#define AUDIO_NICE (-16)
static const char* const valid_modargs[] = {
"socket", "sink_name", "sink_properties", "volume", "performance_mode", "adaptive", "buffer_ms", NULL
};
struct userdata {
pa_core *core;
pa_module *module;
pa_sink *sink;
pa_thread *thread;
pa_thread_mq thread_mq;
pa_rtpoll *rtpoll;
char *socket_path;
int perf;
bool adaptive;
uint32_t buffer_ms;
pa_sample_spec ss;
size_t frame_size;
int fd;
struct da_ring *ring;
size_t ring_bytes;
int32_t burst;
pa_usec_t next_connect;
pa_usec_t offline_at;
pa_usec_t stats_at;
uint32_t underruns_reported;
pa_usec_t settled_at;
};
static void disconnect_relay(struct userdata *u) {
if (u->ring) {
__atomic_store_n(&u->ring->quit, 1, __ATOMIC_RELEASE);
munmap(u->ring, u->ring_bytes);
u->ring = NULL;
}
if (u->fd >= 0) {
close(u->fd);
u->fd = -1;
}
}
/* One try at the socket; on success the render ring is mapped and u->burst known. */
static int connect_relay(struct userdata *u) {
struct sockaddr_un addr;
struct da_hello hello;
struct da_ack ack;
struct msghdr msg;
struct iovec iov;
struct cmsghdr *cm;
union { struct cmsghdr align; char buf[CMSG_SPACE(sizeof(int) * 2)]; } cmsg;
int fds[2] = { -1, -1 };
ssize_t n;
if (strlen(u->socket_path) >= sizeof(addr.sun_path))
return -1;
if ((u->fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0)) < 0)
return -1;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, u->socket_path);
if (connect(u->fd, (struct sockaddr*) &addr, sizeof(addr)) < 0)
goto fail;
memset(&hello, 0, sizeof(hello));
hello.magic = DA_HELLO_MAGIC;
hello.version = DA_RELAY_VERSION;
hello.flags = u->adaptive ? DA_HELLO_ADAPTIVE : 0;
hello.perf = u->perf;
hello.target_ms = (int32_t) u->buffer_ms;
hello.pid = (int32_t) getpid();
pa_snprintf(hello.name, sizeof(hello.name), "pulseaudio");
if (pa_loop_write(u->fd, &hello, sizeof(hello), NULL) != (ssize_t) sizeof(hello))
goto fail;
memset(&msg, 0, sizeof(msg));
iov.iov_base = &ack;
iov.iov_len = sizeof(ack);
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg.buf;
msg.msg_controllen = sizeof(cmsg.buf);
do {
n = recvmsg(u->fd, &msg, MSG_WAITALL);
} while (n < 0 && errno == EINTR);
if (n != (ssize_t) sizeof(ack) || ack.magic != DA_ACK_MAGIC)
goto fail;
for (cm = CMSG_FIRSTHDR(&msg); cm; cm = CMSG_NXTHDR(&msg, cm)) {
if (cm->cmsg_level == SOL_SOCKET && cm->cmsg_type == SCM_RIGHTS) {
size_t count = (cm->cmsg_len - CMSG_LEN(0)) / sizeof(int);
if (count > 2) count = 2;
memcpy(fds, CMSG_DATA(cm), count * sizeof(int));
}
}
if (fds[1] >= 0)
close(fds[1]); /* a capture ring, not for this sink */
if (ack.status != 0 || fds[0] < 0 || ack.out_cap_frames <= 0) {
pa_log("directaudio-sink: the relay refused (status %d)", ack.status);
goto fail;
}
u->ring_bytes = da_ring_bytes((uint32_t) ack.out_cap_frames);
u->ring = mmap(NULL, u->ring_bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fds[0], 0);
close(fds[0]);
if (u->ring == MAP_FAILED) {
u->ring = NULL;
goto fail;
}
if (u->ring->magic != DA_RING_MAGIC || u->ring->version != DA_RELAY_VERSION || u->ring->channels != DA_RING_CHANNELS) {
pa_log("directaudio-sink: ring header mismatch");
goto fail;
}
u->burst = ack.burst > 0 ? ack.burst : 192;
if (u->ring->rate != u->ss.rate)
pa_log("directaudio-sink: the relay's ring runs at %u Hz, the sink at %u; expect resampling artefacts", u->ring->rate, u->ss.rate);
u->stats_at = pa_rtclock_now();
u->settled_at = u->stats_at + 10 * PA_USEC_PER_SEC;
u->underruns_reported = u->ring->underruns;
pa_log("directaudio-sink: connected to the relay: %d Hz, burst %d, device buffer %d frames (asked %u ms), ring %d frames, target %d",
ack.rate, ack.burst, ack.buf_frames, u->buffer_ms, ack.out_cap_frames, (int) u->ring->target_frames);
return 0;
fail:
if (fds[0] >= 0) close(fds[0]);
disconnect_relay(u);
return -1;
}
static pa_usec_t frames_to_usec(const struct userdata *u, int64_t frames) {
return (pa_usec_t) (frames * (int64_t) PA_USEC_PER_SEC / (int64_t) u->ss.rate);
}
static pa_usec_t sink_latency(struct userdata *u) {
if (!u->ring)
return 0;
return frames_to_usec(u, (int64_t) da_ring_avail(u->ring) + (u->ring->hw_buf_frames > 0 ? u->ring->hw_buf_frames : 0));
}
/* Renders `frames` frames from the sink (silence when it is not open) into the ring. */
static void produce(struct userdata *u, uint32_t frames) {
struct da_ring *r = u->ring;
uint32_t w = __atomic_load_n(&r->widx, __ATOMIC_ACQUIRE);
uint32_t pos = w % r->cap_frames;
uint32_t first = PA_MIN(frames, r->cap_frames - pos);
float *dst = r->data + (size_t) pos * DA_RING_CHANNELS;
size_t bytes = (size_t) frames * u->frame_size;
if (PA_SINK_IS_OPENED(u->sink->thread_info.state)) {
pa_memchunk chunk;
const uint8_t *src;
if (u->sink->thread_info.rewind_requested)
pa_sink_process_rewind(u->sink, 0);
pa_sink_render_full(u->sink, bytes, &chunk);
src = pa_memblock_acquire_chunk(&chunk);
memcpy(dst, src, (size_t) first * u->frame_size);
if (frames > first)
memcpy(r->data, src + (size_t) first * u->frame_size, (size_t) (frames - first) * u->frame_size);
pa_memblock_release(chunk.memblock);
pa_memblock_unref(chunk.memblock);
} else {
memset(dst, 0, (size_t) first * u->frame_size);
if (frames > first)
memset(r->data, 0, (size_t) (frames - first) * u->frame_size);
}
__atomic_store_n(&r->widx, w + frames, __ATOMIC_RELEASE);
}
/* Tops the ring up to the relay's target. */
static int service_ring(struct userdata *u) {
struct da_ring *r = u->ring;
uint32_t avail, target, space;
if (__atomic_load_n(&r->quit, __ATOMIC_ACQUIRE) || __atomic_load_n(&r->state, __ATOMIC_ACQUIRE) == DA_RING_ERROR) {
pa_log("directaudio-sink: the relay closed the ring; reconnecting");
return -1;
}
target = (uint32_t) (r->target_frames > 0 ? r->target_frames : 2 * u->burst);
if (target < u->ss.rate / 1000 * MIN_AHEAD_MS)
target = u->ss.rate / 1000 * MIN_AHEAD_MS;
if (target > (uint32_t) (u->ss.rate / 1000 * MAX_AHEAD_MS))
target = (uint32_t) (u->ss.rate / 1000 * MAX_AHEAD_MS);
avail = da_ring_avail(r);
space = da_ring_space(r);
if (avail < target) {
uint32_t want = PA_MIN(target - avail, space);
if (want > 0)
produce(u, want);
}
/* Once, ten seconds in: what the relay's buffer has settled at, so a session log shows it. */
if (u->settled_at && pa_rtclock_now() >= u->settled_at) {
pa_log("directaudio-sink: after 10 s: device buffer %d frames, ring target %d, relay underruns %u",
(int) r->hw_buf_frames, (int) r->target_frames, r->underruns);
u->settled_at = 0;
}
if (r->underruns != u->underruns_reported && pa_rtclock_now() - u->stats_at >= STATS_EVERY_USEC) {
pa_log("directaudio-sink: relay reports %u underrun(s), target %d frames, device buffer %d", r->underruns, (int) r->target_frames, (int) r->hw_buf_frames);
u->underruns_reported = r->underruns;
u->stats_at = pa_rtclock_now();
}
return 0;
}
static int sink_process_msg(pa_msgobject *o, int code, void *data, int64_t offset, pa_memchunk *chunk) {
struct userdata *u = PA_SINK(o)->userdata;
switch (code) {
case PA_SINK_MESSAGE_GET_LATENCY:
*((pa_usec_t*) data) = sink_latency(u);
return 0;
}
return pa_sink_process_msg(o, code, data, offset, chunk);
}
static void thread_func(void *userdata) {
struct userdata *u = userdata;
pa_log_debug("IO thread starting");
pa_thread_mq_install(&u->thread_mq);
if (setpriority(PRIO_PROCESS, (id_t) syscall(SYS_gettid), AUDIO_NICE) < 0)
pa_log_debug("directaudio-sink: could not raise the IO thread's priority (%s)", strerror(errno));
if (u->core->realtime_scheduling)
pa_thread_make_realtime(u->core->realtime_priority);
pa_rtpoll_set_timer_relative(u->rtpoll, 0);
for (;;) {
int ret;
pa_usec_t now = pa_rtclock_now();
if (!u->ring && now >= u->next_connect) {
if (connect_relay(u) < 0) {
u->next_connect = now + RECONNECT_USEC;
u->offline_at = now;
}
}
if (u->ring) {
if (service_ring(u) < 0) {
disconnect_relay(u);
u->next_connect = pa_rtclock_now() + RECONNECT_USEC;
u->offline_at = u->next_connect;
}
pa_rtpoll_set_timer_relative(u->rtpoll, PA_MAX(frames_to_usec(u, u->burst) / 2, PA_USEC_PER_MSEC));
} else {
/* No relay yet: a clocked null sink, so clients keep flowing and time keeps passing. */
if (PA_SINK_IS_OPENED(u->sink->thread_info.state) && now >= u->offline_at) {
pa_memchunk chunk;
if (u->sink->thread_info.rewind_requested)
pa_sink_process_rewind(u->sink, 0);
pa_sink_render_full(u->sink, pa_usec_to_bytes(OFFLINE_TICK_USEC, &u->ss), &chunk);
pa_memblock_unref(chunk.memblock);
u->offline_at = now + OFFLINE_TICK_USEC;
}
pa_rtpoll_set_timer_absolute(u->rtpoll, PA_MIN(u->offline_at, u->next_connect));
}
if ((ret = pa_rtpoll_run(u->rtpoll)) < 0)
goto fail;
if (ret == 0)
goto finish;
}
fail:
pa_asyncmsgq_post(u->thread_mq.outq, PA_MSGOBJECT(u->core), PA_CORE_MESSAGE_UNLOAD_MODULE, u->module, 0, NULL, NULL);
pa_asyncmsgq_wait_for(u->thread_mq.inq, PA_MESSAGE_SHUTDOWN);
finish:
pa_log_debug("IO thread shutting down");
}
void pa__done(pa_module *m);
int pa__init(pa_module *m) {
struct userdata *u = NULL;
pa_modargs *ma = NULL;
pa_sink_new_data data;
pa_channel_map map;
pa_cvolume volume;
uint32_t perf = 1, buffer_ms = DEFAULT_BUFFER_MS;
double linear_volume = 1.0;
bool adaptive = true;
const char *path;
pa_assert(m);
if (!(ma = pa_modargs_new(m->argument, valid_modargs))) {
pa_log("Failed to parse module arguments.");
goto fail;
}
if (!(path = pa_modargs_get_value(ma, "socket", NULL))) {
pa_log("socket= is required: the DirectAudio relay's unix socket");
goto fail;
}
if (pa_modargs_get_value_u32(ma, "performance_mode", &perf) < 0 || perf > 2) {
pa_log("performance_mode must be 0, 1 or 2");
goto fail;
}
if (pa_modargs_get_value_u32(ma, "buffer_ms", &buffer_ms) < 0 || buffer_ms > 200) {
pa_log("buffer_ms must be between 0 and 200");
goto fail;
}
if (pa_modargs_get_value_boolean(ma, "adaptive", &adaptive) < 0) {
pa_log("adaptive must be 0 or 1");
goto fail;
}
if (pa_modargs_get_value_double(ma, "volume", &linear_volume) < 0 || linear_volume < 0.0 || linear_volume > 10.0) {
pa_log("volume must be a linear factor between 0 and 10");
goto fail;
}
m->userdata = u = pa_xnew0(struct userdata, 1);
u->core = m->core;
u->module = m;
u->fd = -1;
u->socket_path = pa_xstrdup(path);
u->perf = (int) perf;
u->adaptive = adaptive;
u->buffer_ms = buffer_ms;
/* The ring's format, fixed by the relay: float stereo at 48 kHz. */
u->ss.format = PA_SAMPLE_FLOAT32NE;
u->ss.rate = DA_RING_RATE;
u->ss.channels = DA_RING_CHANNELS;
u->frame_size = pa_frame_size(&u->ss);
u->burst = 192;
pa_channel_map_init_stereo(&map);
u->rtpoll = pa_rtpoll_new();
if (pa_thread_mq_init(&u->thread_mq, m->core->mainloop, u->rtpoll) < 0) {
pa_log("pa_thread_mq_init() failed.");
goto fail;
}
pa_sink_new_data_init(&data);
data.driver = __FILE__;
data.module = m;
pa_sink_new_data_set_name(&data, pa_modargs_get_value(ma, "sink_name", DEFAULT_SINK_NAME));
pa_sink_new_data_set_sample_spec(&data, &u->ss);
pa_sink_new_data_set_channel_map(&data, &map);
pa_cvolume_set(&volume, u->ss.channels, pa_sw_volume_from_linear(linear_volume));
pa_sink_new_data_set_volume(&data, &volume);
pa_proplist_sets(data.proplist, PA_PROP_DEVICE_STRING, "directaudio");
pa_proplist_sets(data.proplist, PA_PROP_DEVICE_DESCRIPTION, "Android audio output (DirectAudio)");
pa_proplist_sets(data.proplist, PA_PROP_DEVICE_CLASS, "sound");
pa_proplist_sets(data.proplist, PA_PROP_DEVICE_API, "directaudio");
if (pa_modargs_get_proplist(ma, "sink_properties", data.proplist, PA_UPDATE_REPLACE) < 0) {
pa_log("Invalid properties");
pa_sink_new_data_done(&data);
goto fail;
}
u->sink = pa_sink_new(m->core, &data, PA_SINK_HARDWARE | PA_SINK_LATENCY);
pa_sink_new_data_done(&data);
if (!u->sink) {
pa_log("Failed to create sink.");
goto fail;
}
u->sink->parent.process_msg = sink_process_msg;
u->sink->userdata = u;
pa_sink_set_asyncmsgq(u->sink, u->thread_mq.inq);
pa_sink_set_rtpoll(u->sink, u->rtpoll);
pa_sink_set_max_request(u->sink, pa_usec_to_bytes(MAX_AHEAD_MS * PA_USEC_PER_MSEC, &u->ss));
pa_sink_set_fixed_latency(u->sink, 40 * PA_USEC_PER_MSEC);
if (!(u->thread = pa_thread_new("directaudio-sink", thread_func, u))) {
pa_log("Failed to create thread.");
goto fail;
}
pa_sink_put(u->sink);
pa_modargs_free(ma);
return 0;
fail:
if (ma)
pa_modargs_free(ma);
pa__done(m);
return -1;
}
int pa__get_n_used(pa_module *m) {
struct userdata *u;
pa_assert(m);
pa_assert_se(u = m->userdata);
return pa_sink_linked_by(u->sink);
}
void pa__done(pa_module *m) {
struct userdata *u;
pa_assert(m);
if (!(u = m->userdata))
return;
if (u->sink)
pa_sink_unlink(u->sink);
if (u->thread) {
pa_asyncmsgq_send(u->thread_mq.inq, NULL, PA_MESSAGE_SHUTDOWN, NULL, 0, NULL);
pa_thread_free(u->thread);
}
pa_thread_mq_done(&u->thread_mq);
if (u->sink)
pa_sink_unref(u->sink);
disconnect_relay(u);
if (u->rtpoll)
pa_rtpoll_free(u->rtpoll);
pa_xfree(u->socket_path);
pa_xfree(u);
}
+5 -5
View File
@@ -300,8 +300,8 @@ if [[ ! -f "${pa_source}/src/pulse/version.h.in" ]]; then
fi
sink_output="${staging_dir}/sink-out"
"${repo_root}/tools/directaudio/fetch.sh" "${repo_root}" "${sink_output}"
"${repo_root}/tools/aaudio-sink/build.sh" "${pa_source}" "${sink_output}"
"${repo_root}/tools/directaudio-relay/build.sh" "${repo_root}/app/src/main/jniLibs/arm64-v8a"
# proot is rebuilt only when its sources (source.env, the patches, the build script) changed since
# the libraries in jniLibs were built.
proot_out="${repo_root}/app/src/main/jniLibs/arm64-v8a"
@@ -319,14 +319,14 @@ bundle_dir="${staging_dir}/pulseaudio-bundle"
mkdir -p "${bundle_dir}"
zstd -dc "${bundle_asset}" | tar -xf - -C "${bundle_dir}"
if [[ -e "${bundle_dir}/modules/arm64/module-aaudio-sink.so" \
|| -e "${bundle_dir}/modules/arm64/module-directaudio-sink.so" ]]; then
|| -e "${bundle_dir}/modules/arm64/module-directaudio-native-sink.so" ]]; then
echo "The committed audio bundle already contains a built ARM64 sink." >&2
exit 1
fi
install -m755 "${sink_output}/module-aaudio-sink.so" \
"${bundle_dir}/modules/arm64/module-aaudio-sink.so"
install -m755 "${sink_output}/module-directaudio-sink.so" \
"${bundle_dir}/modules/arm64/module-directaudio-sink.so"
install -m755 "${sink_output}/module-directaudio-native-sink.so" \
"${bundle_dir}/modules/arm64/module-directaudio-native-sink.so"
tar -cf - -C "${bundle_dir}" . | zstd -19 -T0 -c > "${staging_dir}/pulseaudio.tzst"
bundle_replaced=1
mv "${staging_dir}/pulseaudio.tzst" "${bundle_asset}"
@@ -344,7 +344,7 @@ unzip -p "${apk}" assets/pulseaudio.tzst | zstd -dc | tar -xf - -C "${audio_chec
for audio_file in \
pactl \
modules/arm64/module-aaudio-sink.so \
modules/arm64/module-directaudio-sink.so; do
modules/arm64/module-directaudio-native-sink.so; do
if [[ ! -f "${audio_check}/${audio_file}" ]]; then
echo "APK audio bundle is missing ${audio_file}." >&2
exit 1
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTDIR=$1
mkdir -p "$OUTDIR"
: "${NDK:?set NDK to the Android NDK root}"
API=28
HERE=$(cd "$(dirname "$0")" && pwd)
REPO=$(cd "$HERE/../.." && pwd)
case "$(uname -s):$(uname -m)" in
Darwin:arm64) NDK_HOST=darwin-arm64 ;;
Darwin:x86_64) NDK_HOST=darwin-x86_64 ;;
Linux:x86_64) NDK_HOST=linux-x86_64 ;;
Linux:aarch64|Linux:arm64) NDK_HOST=linux-aarch64 ;;
*) echo "Unsupported build host: $(uname -s) $(uname -m)" >&2; exit 1 ;;
esac
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/$NDK_HOST/bin"
if [[ ! -d "$TOOLCHAIN" && "$NDK_HOST" == darwin-arm64 && -d "$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin" ]]; then
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin"
fi
if [[ ! -d "$TOOLCHAIN" && "$NDK_HOST" == linux-aarch64 && -d "$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" ]]; then
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
fi
CC="$TOOLCHAIN/aarch64-linux-android${API}-clang"
if [[ ! -x "$CC" ]]; then
echo "Android NDK toolchain not found at $TOOLCHAIN" >&2
exit 1
fi
OUT="$OUTDIR/libdirectaudiorelay.so"
"$CC" -O2 -Wall -Wextra -Wno-unused-parameter -Wno-sign-compare -fPIE -pie -Wl,-z,max-page-size=16384 \
-I"$REPO/tools/aaudio-sink" \
-o "$OUT" "$HERE/directaudio-relay.c" -laaudio -llog
"$TOOLCHAIN/llvm-strip" --strip-unneeded "$OUT"
NEEDED=$("$TOOLCHAIN/llvm-readelf" -d "$OUT" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p' | tr '\n' ' ')
echo "$(basename "$OUT") NEEDED: $NEEDED"
for lib in $NEEDED; do
case $lib in libaaudio.so|liblog.so|libc.so|libm.so|libdl.so) ;;
*) echo "ERROR: unexpected dependency $lib"; exit 1 ;;
esac
done
ls -l "$OUT"
File diff suppressed because it is too large. Load diff
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Fetches the DirectAudio pieces DroidDeck ships from the pinned release in tools/directaudio/release.env
# and puts them where the build reads them. Four zips, checksummed:
# directaudio-linux-wine11.zip the game driver for Valve Proton 11 / Experimental / GE-Proton
# directaudio-linux-wine11-systhread.zip the game driver for Proton-CachyOS (system-thread mmdevapi)
# directaudio-linux-relay.zip the Android-side helper (speaker + microphone for games)
# directaudio-linux-sink.zip the PulseAudio sinks; the app ships module-directaudio-native-sink
#
# tools/directaudio/fetch.sh <repo root> <dir to receive the sink modules>
#
# Driver sets -> app/src/main/assets/directaudio/<set>/ (generated, ignored by git)
# Helper -> app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so (generated, ignored by git)
# Sink -> <sink dir>/module-directaudio-native-sink.so for the audio bundle, next to the
# module-aaudio-sink.so tools/aaudio-sink builds (the fallback).
set -euo pipefail
repo_root=$1
sink_out=$2
# shellcheck source=release.env
. "${repo_root}/tools/directaudio/release.env"
for v in DIRECTAUDIO_REPO DIRECTAUDIO_TAG DIRECTAUDIO_WINE11_SHA256 DIRECTAUDIO_SYSTHREAD_SHA256 DIRECTAUDIO_RELAY_SHA256 DIRECTAUDIO_SINK_SHA256; do
[[ -n "${!v:-}" && "${!v}" != TBD ]] || { echo "tools/directaudio/release.env: ${v} is not set" >&2; exit 1; }
done
work=$(mktemp -d)
trap 'rm -rf "${work}"' EXIT
fetch() {
local name=$1 sha=$2
curl -fsSL --retry 3 -o "${work}/${name}" \
"https://github.com/${DIRECTAUDIO_REPO}/releases/download/${DIRECTAUDIO_TAG}/${name}"
echo "${sha} ${work}/${name}" | sha256sum -c - >/dev/null || { echo "${name}: checksum mismatch" >&2; exit 1; }
}
fetch directaudio-linux-wine11.zip "${DIRECTAUDIO_WINE11_SHA256}"
fetch directaudio-linux-wine11-systhread.zip "${DIRECTAUDIO_SYSTHREAD_SHA256}"
fetch directaudio-linux-relay.zip "${DIRECTAUDIO_RELAY_SHA256}"
fetch directaudio-linux-sink.zip "${DIRECTAUDIO_SINK_SHA256}"
assets="${repo_root}/app/src/main/assets/directaudio"
rm -rf "${assets}"
for set in linux-wine11 linux-wine11-systhread; do
mkdir -p "${assets}/${set}"
unzip -q "${work}/directaudio-${set}.zip" -d "${assets}/${set}"
for f in aarch64-unix/winedirectaudio.so aarch64-windows/winedirectaudio.drv i386-windows/winedirectaudio.drv version.txt; do
test -f "${assets}/${set}/${f}" || { echo "${set}: ${f} missing from the release zip" >&2; exit 1; }
done
rm -f "${assets}/${set}/README.md"
done
# Each set says which interface it was built for; the name must not lie.
grep -q '^abi wine11$' "${assets}/linux-wine11/version.txt"
grep -q '^abi systhread$' "${assets}/linux-wine11-systhread/version.txt"
mkdir -p "${work}/relay" "${work}/sink" "${sink_out}" "${repo_root}/app/src/main/jniLibs/arm64-v8a"
unzip -q "${work}/directaudio-linux-relay.zip" -d "${work}/relay"
install -m755 "${work}/relay/libdirectaudiorelay.so" "${repo_root}/app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so"
unzip -q "${work}/directaudio-linux-sink.zip" -d "${work}/sink"
install -m755 "${work}/sink/module-directaudio-native-sink.so" "${sink_out}/"
echo "DirectAudio ${DIRECTAUDIO_TAG}: two driver sets, the relay helper and the client sink in place"
+9
View File
@@ -0,0 +1,9 @@
# The DirectAudio release this build ships, pinned: nothing compiled for audio lives in this repo.
# Source and release notes: https://github.com/The412Banner/directaudio
# Bump the tag and the four checksums together (sha256 of each zip, from the release's SHA256SUMS.txt).
DIRECTAUDIO_REPO=The412Banner/directaudio
DIRECTAUDIO_TAG=directaudio-linux-v1.1.0
DIRECTAUDIO_WINE11_SHA256=835e6cab072f5148893c43379e60506483aeb3f1444e777b430c295cbeb472c9
DIRECTAUDIO_SYSTHREAD_SHA256=2ddc47f446142d5940540ad40a423ca8d47fd536e3225b888df1077c6be7329e
DIRECTAUDIO_RELAY_SHA256=b68eb6d7a892fd92a1cd73fac36dc05cabb77cc73792e2c582d3eeccad355942
DIRECTAUDIO_SINK_SHA256=1d5d3ed709e5389ef7603f019c62b07e4e8428bb42882193d4a144a5c15425b6
@@ -206,7 +206,7 @@ BL_DIRECTAUDIO_SETUP = """
bl_directaudio() {
base=$1
[ -n "${BL_DIRECTAUDIO:-}" ] || return 0
[ -d "$BL_DIRECTAUDIO/lib/wine" ] || return 0
[ -d "$BL_DIRECTAUDIO" ] || return 0
version=$("$base/files/bin-arm64/wine" --version 2>/dev/null)
major=${version#wine-}
major=${major%%.*}
@@ -214,10 +214,32 @@ bl_directaudio() {
echo "droiddeck-proton: DirectAudio left off - it is built for Wine 11 and this Proton is '${version:-unknown}'. Pairing them would be silent rather than broken, so it is not worth guessing." >&2
return 0
fi
# Two builds of the driver ship, one per Wine audio interface, and both Wines call themselves
# 11.0: the private mmdevapi table is not versioned, and the wrong set plays silence, not an
# error. Proton's own winepulse.so tells them apart - it imports PsCreateSystemThread only on
# the system-thread table (Proton-CachyOS), never on the classic one (Valve, GE-Proton).
winepulse="$base/files/lib/wine/aarch64-unix/winepulse.so"
if [ ! -f "$winepulse" ]; then
echo "droiddeck-proton: DirectAudio left off - no winepulse.so to read this Proton's audio interface from" >&2
return 0
fi
if grep -aq PsCreateSystemThread "$winepulse"; then set=linux-wine11-systhread; else set=linux-wine11; fi
da="$BL_DIRECTAUDIO/$set"
if [ ! -d "$da/lib/wine" ]; then
echo "droiddeck-proton: DirectAudio left off - no driver set for the $set interface is staged" >&2
return 0
fi
# Wine appends the per-architecture subdirectory to each entry, so the directory holding
# aarch64-unix/ and aarch64-windows/ is what goes here.
WINEDLLPATH="$BL_DIRECTAUDIO/lib/wine${WINEDLLPATH:+:$WINEDLLPATH}"
WINEDLLPATH="$da/lib/wine${WINEDLLPATH:+:$WINEDLLPATH}"
export WINEDLLPATH
# Proton-CachyOS's launcher sets WINE_AUDIO_DRIVER=pulse,alsa (setdefault, so an existing value
# wins), and its mmdevapi reads that variable BEFORE the registry - so the selection below was
# being overridden to pulse on every CachyOS game. Valve's and GE's mmdevapi do not read the
# variable at all and take the registry, which is why both are done. A value the user set for
# the game still wins: the per-game environment is applied after this.
WINE_AUDIO_DRIVER="directaudio,pulse"
export WINE_AUDIO_DRIVER
# mmdevapi only tries the drivers named in the registry; its built-in list is pulse, alsa, oss and
# coreaudio and knows nothing about this one. The key is appended to the prefix's user.reg rather
# than written with `wine reg`, which would start a wineserver here just to set one value.
@@ -226,25 +248,36 @@ bl_directaudio() {
if [ -f "$reg" ]; then
for pair in system32:aarch64-windows syswow64:i386-windows; do
dir="$pfx/drive_c/windows/${pair%%:*}"
src="$BL_DIRECTAUDIO/lib/wine/${pair#*:}/winedirectaudio.drv"
src="$da/lib/wine/${pair#*:}/winedirectaudio.drv"
if [ -d "$dir" ] && [ -f "$src" ] && [ "$(readlink "$dir/winedirectaudio.drv" 2>/dev/null)" != "$src" ]; then
ln -sfn "$src" "$dir/winedirectaudio.drv.bl-new" && mv -f "$dir/winedirectaudio.drv.bl-new" "$dir/winedirectaudio.drv"
fi
done
key='[Software\\\\Wine\\\\Drivers]'
if [ "$(readlink "$pfx/drive_c/windows/system32/winedirectaudio.drv" 2>/dev/null)" != "$BL_DIRECTAUDIO/lib/wine/aarch64-windows/winedirectaudio.drv" ]; then
if [ "$(readlink "$pfx/drive_c/windows/system32/winedirectaudio.drv" 2>/dev/null)" != "$da/lib/wine/aarch64-windows/winedirectaudio.drv" ]; then
echo "droiddeck-proton: DirectAudio could not be placed in the prefix; Proton's own audio stays" >&2
elif ! BL_KEY="$key" awk 'index($0, "[") == 1 { inside = index($0, ENVIRON["BL_KEY"] " ") == 1 } inside && index($0, "\\"Audio\\"=") == 1 { last = $0 } END { exit last != "\\"Audio\\"=\\"directaudio,pulse\\"" }' "$reg" 2>/dev/null; then
printf '\\n%s %s\\n"Audio"="directaudio,pulse"\\n' "$key" "$(date +%s)" >> "$reg"
echo "droiddeck-proton: DirectAudio selected in the prefix" >&2
# Steam runs this tool several times per launch (getcompatpath, run, waitforexitandrun); a
# wineserver from an earlier verb that still holds the registry saves its own copy on exit
# and drops this line. Said out loud so a game that stays on pulse once is explained; it
# takes on the next launch. (Writing through the live server with a bare `wine reg add` was
# tried and withdrawn: a second Wine invocation inside Steam's launch sequence is not safe.)
srv="${BL_WINE_SERVER_BASE:-/tmp/.wine-$(id -u)}/server-$(printf '%x-%x' $(stat -c '%d %i' "$pfx" 2>/dev/null || echo 0 0))/socket"
if [ -S "$srv" ]; then
echo "droiddeck-proton: a wineserver is already running for this prefix; DirectAudio may only engage on the next launch" >&2
fi
fi
else
# A prefix that does not exist yet is created by Proton moments from now, after this runs, so
# the key lands on the NEXT launch of that game. Said out loud because silent audio on a first
# launch would otherwise look like a fault.
# the value lands on the NEXT launch of that game. Said out loud so a first launch on Proton's
# own audio does not look like a fault. (Having Proton create the prefix from here first was
# tried and withdrawn: a second Proton run inside Steam's launch sequence got the launch
# aborted - Steam games bounced back to their page with a half-made prefix.)
echo "droiddeck-proton: no prefix yet - DirectAudio takes effect the next time this game starts" >&2
fi
echo "droiddeck-proton: DirectAudio ready (Wine $major)" >&2
echo "droiddeck-proton: DirectAudio ready (Wine $major, $set)" >&2
}
"""
+44 -8
View File
@@ -394,11 +394,18 @@ class DirectAudioPrefixTest(unittest.TestCase):
wine = self.tool / "files/bin-arm64/wine"
wine.write_text("#!/bin/sh\necho wine-11.0-4c8f2e1 '(Staging)'\n")
wine.chmod(0o755)
self.audio = base / "directaudio"
for arch in ("aarch64-windows", "i386-windows", "aarch64-unix"):
(self.audio / "lib/wine" / arch).mkdir(parents=True)
(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv").write_bytes(b"arm64x")
(self.audio / "lib/wine/i386-windows/winedirectaudio.drv").write_bytes(b"i386")
# Proton's own winepulse.so names the audio interface: the classic table never imports
# PsCreateSystemThread, the system-thread one (Proton-CachyOS) always does.
(self.tool / "files/lib/wine/aarch64-unix").mkdir(parents=True)
self.winepulse = self.tool / "files/lib/wine/aarch64-unix/winepulse.so"
self.winepulse.write_bytes(b"\x7fELF classic wine 11 pulse driver")
self.sets = base / "directaudio"
for set_name in ("linux-wine11", "linux-wine11-systhread"):
for arch in ("aarch64-windows", "i386-windows", "aarch64-unix"):
(self.sets / set_name / "lib/wine" / arch).mkdir(parents=True)
(self.sets / set_name / "lib/wine/aarch64-windows/winedirectaudio.drv").write_bytes(b"arm64x-" + set_name.encode())
(self.sets / set_name / "lib/wine/i386-windows/winedirectaudio.drv").write_bytes(b"i386")
self.audio = self.sets / "linux-wine11"
self.compat = base / "compat"
self.windows = self.compat / "pfx/drive_c/windows"
for folder in ("system32", "syswow64"):
@@ -406,9 +413,10 @@ class DirectAudioPrefixTest(unittest.TestCase):
self.reg = self.compat / "pfx/user.reg"
def run_setup(self):
script = COMPAT["BL_DIRECTAUDIO_SETUP"] + '\nbl_directaudio "$1"\necho "WINEDLLPATH=$WINEDLLPATH"\n'
env = dict(os.environ, BL_DIRECTAUDIO=str(self.audio), STEAM_COMPAT_DATA_PATH=str(self.compat))
script = COMPAT["BL_DIRECTAUDIO_SETUP"] + '\nbl_directaudio "$1"\necho "WINEDLLPATH=$WINEDLLPATH"\necho "WINE_AUDIO_DRIVER=${WINE_AUDIO_DRIVER:-}"\n'
env = dict(os.environ, BL_DIRECTAUDIO=str(self.sets), STEAM_COMPAT_DATA_PATH=str(self.compat))
env.pop("WINEDLLPATH", None)
env.pop("WINE_AUDIO_DRIVER", None)
return subprocess.run(["bash", "-c", script, "bash", str(self.tool)], env=env, capture_output=True, text=True, check=True)
def audio_values(self):
@@ -429,15 +437,43 @@ class DirectAudioPrefixTest(unittest.TestCase):
result = self.run_setup()
self.assertIn("DirectAudio selected", result.stderr)
self.assertIn("WINEDLLPATH=%s/lib/wine" % self.audio, result.stdout)
# Proton-CachyOS's mmdevapi takes the driver list from this variable, and its launcher
# pre-sets it to pulse,alsa unless it is already set.
self.assertIn("WINE_AUDIO_DRIVER=directaudio,pulse\n", result.stdout)
self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv"))
self.assertEqual(self.link("syswow64"), str(self.audio / "lib/wine/i386-windows/winedirectaudio.drv"))
self.assertEqual((self.windows / "system32/winedirectaudio.drv").read_bytes(), b"arm64x")
self.assertEqual((self.windows / "system32/winedirectaudio.drv").read_bytes(), b"arm64x-linux-wine11")
self.assertEqual(self.audio_values(), [("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio,pulse"')])
before = self.reg.read_text()
self.assertNotIn("DirectAudio selected", self.run_setup().stderr)
self.assertEqual(self.reg.read_text(), before)
self.assertEqual(sorted(os.listdir(self.windows / "system32")), ["winedirectaudio.drv"])
def test_without_a_prefix_it_waits_for_the_next_launch(self):
result = self.run_setup()
self.assertIn("takes effect the next time this game starts", result.stderr)
self.assertEqual(self.link("system32"), None)
def test_the_system_thread_set_is_picked_from_winepulse_not_the_version(self):
self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
self.winepulse.write_bytes(b"\x7fELF cachyos pulse driver PsCreateSystemThread NtSetInformationThread")
result = self.run_setup()
systhread = self.sets / "linux-wine11-systhread"
self.assertIn("DirectAudio ready (Wine 11, linux-wine11-systhread)", result.stderr)
self.assertIn("WINEDLLPATH=%s/lib/wine" % systhread, result.stdout)
self.assertEqual(self.link("system32"), str(systhread / "lib/wine/aarch64-windows/winedirectaudio.drv"))
self.assertEqual((self.windows / "system32/winedirectaudio.drv").read_bytes(), b"arm64x-linux-wine11-systhread")
def test_no_set_for_the_interface_leaves_protons_own_audio(self):
self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
self.winepulse.write_bytes(b"\x7fELF PsCreateSystemThread")
shutil.rmtree(self.sets / "linux-wine11-systhread")
result = self.run_setup()
self.assertIn("no driver set for the linux-wine11-systhread interface", result.stderr)
self.assertIn("WINEDLLPATH=\n", result.stdout)
self.assertIn("WINE_AUDIO_DRIVER=\n", result.stdout)
self.assertFalse((self.windows / "system32/winedirectaudio.drv").exists())
def test_older_selections_are_upgraded(self):
self.reg.write_text('WINE REGISTRY Version 2\n\n[SoftwareWineDrivers] 1790995967\n#time=1dd52e24550e980\n"Audio"="directaudio"\n'
'\n[Software\\\\Wine\\\\Drivers] 1790995986\n#time=1dd52e250b31ec4\n"Audio"="directaudio"\n')