From 7bc35297220a3f4df7cc118a3d5f38b29ab1385f Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Fri, 30 Oct 2026 16:34:45 -0400 Subject: [PATCH] fix(audio): DirectAudio no longer crackles where Android refuses low-latency output (#1862) * fix(audio): DirectAudio no longer crackles where Android refuses low-latency output On devices where Android won't give the game a low-latency output (AYN Thor, Android 13), DirectAudio kept Wine's 10 ms period and 12 ms buffer. An emulated game audio thread can't keep that pace there, so audio crackled. The session now checks for low-latency output and, only when it is refused, asks DirectAudio for a 20 ms period and a 60 ms buffer. DirectAudio's settings are now AUDIO_DIRECT_* everywhere in DroidDeck and are handed to the driver under the names it reads when DirectAudio is set up. AUDIO_DIRECT_MS and AUDIO_DIRECT_PERIOD_MS can be set per game. * fix(audio): game profile DirectAudio settings and the microphone-off setting reach the driver --- .../launcher/audio/DirectAudioTiming.kt | 42 +++++++++++++++++ .../launcher/core/GameEnvironmentOptions.kt | 2 + .../launcher/session/SessionService.kt | 10 ++-- .../launcher/audio/DirectAudioTimingTest.kt | 17 +++++++ .../overlay/usr/local/bin/droiddeck-game-env | 12 ++++- .../overlay/usr/local/bin/steam-compatibility | 5 ++ tools/tests/test_game_environment.py | 47 ++++++++++++++++--- 7 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/audio/DirectAudioTiming.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/audio/DirectAudioTimingTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioTiming.kt b/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioTiming.kt new file mode 100644 index 000000000..56fbedbe6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioTiming.kt @@ -0,0 +1,42 @@ +package com.droiddeck.launcher.audio + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioTrack +import android.util.Log + +object DirectAudioTiming { + private const val TAG = "DirectAudioTiming" + const val PERIOD_ENV = "AUDIO_DIRECT_PERIOD_MS" + const val BUFFER_ENV = "AUDIO_DIRECT_MS" + const val SLOW_OUTPUT_PERIOD_MS = 20 + const val SLOW_OUTPUT_BUFFER_MS = 60 + + fun environment(lowLatencyOutput: Boolean): List = + if (lowLatencyOutput) emptyList() + else listOf("$PERIOD_ENV=$SLOW_OUTPUT_PERIOD_MS", "$BUFFER_ENV=$SLOW_OUTPUT_BUFFER_MS") + + fun lowLatencyOutput(): Boolean = try { + val track = AudioTrack.Builder() + .setAudioAttributes(AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build()) + .setAudioFormat(AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_FLOAT) + .setSampleRate(48000) + .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) + .build()) + .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + try { + track.performanceMode == AudioTrack.PERFORMANCE_MODE_LOW_LATENCY + } finally { + track.release() + } + } catch (e: Exception) { + Log.w(TAG, "audio: could not check for low-latency output; DirectAudio keeps its defaults", e) + true + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt index bcf3a0448..5958c5e99 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt @@ -34,6 +34,8 @@ object GameEnvironmentOptions { Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT), Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT), Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER), + Option("AUDIO_DIRECT_MS", "", 0, Type.NUMBER), + Option("AUDIO_DIRECT_PERIOD_MS", "", 0, Type.NUMBER), Option("DROIDDECK_GAME_CORES", "", R.string.game_env_cores, Type.TEXT), Option("DROIDDECK_ANTICHEAT_OFFLINE", "1", R.string.game_env_anticheat_offline, Type.TOGGLE, listOf("0", "1")), Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")), diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 09f16f642..ef8344bd9 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -818,7 +818,7 @@ class SessionService : Service() { if (!SessionPrefs.zramClient(this)) guest.add("BL_ZRAM_CLIENT=0") } SessionPrefs.vramCapMb(this).takeIf { it > 0 }?.let { guest.add("BL_VRAM_MB=$it") } - guest.add("BANNER_AUDIO_DIRECT_DECAY=0") + guest.add("AUDIO_DIRECT_DECAY=0") val extra = extraEnv() + agentEnvironment if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else "")) extra.forEach { guest.add(it) } @@ -841,7 +841,7 @@ class SessionService : Service() { val wantsMic = SessionPrefs.micEnabled(this) && checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED guest.add("BL_MIC_ENABLED=" + if (wantsMic) "1" else "0") - guest.add("BANNER_AUDIO_DIRECT_MIC=" + if (wantsMic) "1" else "0") + guest.add("AUDIO_DIRECT_MIC=" + if (wantsMic) "1" else "0") val audioDir = File(filesDir, "directaudio").apply { mkdirs() } val relaySocket = File(audioDir, "relay.sock") val micFifo = if (wantsMic) File(audioDir, "mic.fifo") else null @@ -869,7 +869,11 @@ class SessionService : Service() { } if (wantsDirectAudio) { guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR) - guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath) + guest.add("AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath) + val lowLatency = com.droiddeck.launcher.audio.DirectAudioTiming.lowLatencyOutput() + val timing = com.droiddeck.launcher.audio.DirectAudioTiming.environment(lowLatency) + guest.addAll(timing) + Log.i(TAG, "audio: Android output " + (if (lowLatency) "is low-latency; DirectAudio keeps its defaults" else "is not low-latency; DirectAudio uses " + timing.joinToString(" "))) } Log.i(TAG, "audio: client DirectAudio (in-process sink)" + (if (wantsDirectAudio) " + DirectAudio for games" else "") + (if (wantsMic) " + microphone" else "") + diff --git a/app/src/test/java/com/droiddeck/launcher/audio/DirectAudioTimingTest.kt b/app/src/test/java/com/droiddeck/launcher/audio/DirectAudioTimingTest.kt new file mode 100644 index 000000000..db52954f3 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/audio/DirectAudioTimingTest.kt @@ -0,0 +1,17 @@ +package com.droiddeck.launcher.audio + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DirectAudioTimingTest { + @Test + fun lowLatencyOutputKeepsTheDriverDefaults() { + assertTrue(DirectAudioTiming.environment(lowLatencyOutput = true).isEmpty()) + } + + @Test + fun outputWithoutLowLatencyGetsALongerPeriodAndBuffer() { + assertEquals(listOf("AUDIO_DIRECT_PERIOD_MS=20", "AUDIO_DIRECT_MS=60"), DirectAudioTiming.environment(lowLatencyOutput = false)) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 696a95581..6a9cd1079 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -727,6 +727,15 @@ def record_install_scripts(digest, env): print("droiddeck-game-env: install scripts not recorded (%s)" % error, file=sys.stderr) +def directaudio_settings(env): + if "directaudio" not in env.get("WINE_AUDIO_DRIVER", "").split(","): + return env + for name, value in list(env.items()): + if name.startswith("AUDIO_DIRECT_"): + env["BANNER_" + name] = value + return env + + def start(launch, launch_env, digest): if digest: code = subprocess.run(launch, env=launch_env, check=False).returncode @@ -797,7 +806,8 @@ def main(): if app_id and len(command) > 1 and command[1] == "waitforexitandrun": command = offline_without_anticheat(command, env) if mic_enabled == "0": - env["BANNER_AUDIO_DIRECT_MIC"] = "0" + env["AUDIO_DIRECT_MIC"] = "0" + env = directaudio_settings(env) env = vram_cap(env) env = force_ssbs(command, env) game_cores(env) diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 47c83fbd9..4d81df93e 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -335,6 +335,11 @@ bl_directaudio() { fi WINEDLLPATH="$da/lib/wine${WINEDLLPATH:+:$WINEDLLPATH}" export WINEDLLPATH + for name in $(compgen -e); do + case $name in + AUDIO_DIRECT_*) export "BANNER_$name=${!name}" ;; + esac + done WINE_AUDIO_DRIVER="directaudio,pulse" export WINE_AUDIO_DRIVER pfx="${STEAM_COMPAT_DATA_PATH:-}/pfx" diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 1f178c0f8..28564375b 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -194,23 +194,43 @@ class GameEnvironmentTest(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: home = Path(tmp) probe = home / "probe" - probe.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ.get('BANNER_AUDIO_DIRECT_MIC', 'unset'))\n") + probe.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ.get('AUDIO_DIRECT_MIC', 'unset'))\n") probe.chmod(0o755) config = home / ".config/droiddeck/game-environment.json" config.parent.mkdir(parents=True) for enabled, override, expected in (("1", None, "1"), ("0", "1", "0"), ("1", "0", "0")): with self.subTest(enabled=enabled, override=override): profile = {} if override is None else { - "BANNER_AUDIO_DIRECT_MIC": override, "BL_MIC_ENABLED": override, + "AUDIO_DIRECT_MIC": override, "BL_MIC_ENABLED": override, } config.write_text(json.dumps({"version": 1, "games": {"42": profile}})) env = dict(os.environ, HOME=tmp, BL_MIC_ENABLED=enabled, - BANNER_AUDIO_DIRECT_MIC="1", STEAM_COMPAT_DATA_PATH="/compatdata/42") + AUDIO_DIRECT_MIC="1", STEAM_COMPAT_DATA_PATH="/compatdata/42") result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun"], env=env, text=True, capture_output=True, check=True) self.assertEqual(result.stdout.strip(), expected) + def test_game_profile_directaudio_settings_reach_the_driver(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + probe = home / "probe" + probe.write_text("#!" + sys.executable + "\nimport os\nprint(' '.join(sorted(k + '=' + v for k, v in os.environ.items() if k.startswith('BANNER_AUDIO_DIRECT_'))))\n") + probe.chmod(0o755) + config = home / ".config/droiddeck/game-environment.json" + config.parent.mkdir(parents=True) + config.write_text(json.dumps({"version": 1, "games": {"42": {"AUDIO_DIRECT_MS": "80"}}})) + base = {key: value for key, value in os.environ.items() if "AUDIO_DIRECT_" not in key} + base.update(HOME=tmp, BL_MIC_ENABLED="0", STEAM_COMPAT_DATA_PATH="/compatdata/42", + AUDIO_DIRECT_MS="60", AUDIO_DIRECT_PERIOD_MS="20", AUDIO_DIRECT_MIC="1", + BANNER_AUDIO_DIRECT_MS="60", BANNER_AUDIO_DIRECT_MIC="1") + for driver, expected in (("directaudio,pulse", "BANNER_AUDIO_DIRECT_MIC=0 BANNER_AUDIO_DIRECT_MS=80 BANNER_AUDIO_DIRECT_PERIOD_MS=20"), + ("pulse", "BANNER_AUDIO_DIRECT_MIC=1 BANNER_AUDIO_DIRECT_MS=60")): + with self.subTest(driver=driver): + result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun"], + env=dict(base, WINE_AUDIO_DRIVER=driver), text=True, capture_output=True, check=True) + self.assertEqual(result.stdout.strip(), expected) + def test_desktop_games_restart_on_the_gpu_with_steams_preloads_handed_back(self): with tempfile.TemporaryDirectory() as tmp: home = Path(tmp) @@ -1497,11 +1517,11 @@ class DirectAudioPrefixTest(unittest.TestCase): (self.windows / folder).mkdir(parents=True) self.reg = self.compat / "pfx/user.reg" - def run_setup(self, verb="waitforexitandrun", target="game.exe", cwd=None): - script = COMPAT["BL_DIRECTAUDIO_SETUP"] + '\nverb=$2\nbl_directaudio "$1" "$3"\necho "WINEDLLPATH=$WINEDLLPATH"\necho "WINE_AUDIO_DRIVER=${WINE_AUDIO_DRIVER:-}"\n' + def run_setup(self, verb="waitforexitandrun", target="game.exe", cwd=None, extra=None): + script = COMPAT["BL_DIRECTAUDIO_SETUP"] + '\nverb=$2\nbl_directaudio "$1" "$3"\necho "WINEDLLPATH=$WINEDLLPATH"\necho "WINE_AUDIO_DRIVER=${WINE_AUDIO_DRIVER:-}"\nenv | grep "^BANNER_AUDIO_DIRECT_" | sort\n' steam = self.compat.parent / "Steam" env = dict(os.environ, BL_DIRECTAUDIO=str(self.sets), STEAM_COMPAT_DATA_PATH=str(self.compat), - STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam)) + STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam), **(extra or {})) env.pop("WINEDLLPATH", None) env.pop("WINE_AUDIO_DRIVER", None) return subprocess.run(["bash", "-c", script, "bash", str(self.tool), verb, target], cwd=cwd, env=env, @@ -1549,6 +1569,21 @@ class DirectAudioPrefixTest(unittest.TestCase): self.assertIn("DirectAudio selected", result.stderr) self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv")) self.assertEqual(self.audio_values(), [("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio,pulse"')]) + def test_audio_direct_settings_reach_the_driver_under_its_own_names(self): + self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n") + result = self.run_setup(extra={"AUDIO_DIRECT_MS": "60", "AUDIO_DIRECT_PERIOD_MS": "20", + "AUDIO_DIRECT_RELAY": "/run/relay.sock", "OTHER_AUDIO_DIRECT_MS": "5"}) + lines = [line for line in result.stdout.splitlines() if line.startswith("BANNER_")] + self.assertEqual(lines, ["BANNER_AUDIO_DIRECT_MS=60", "BANNER_AUDIO_DIRECT_PERIOD_MS=20", + "BANNER_AUDIO_DIRECT_RELAY=/run/relay.sock"]) + + def test_audio_direct_settings_are_not_handed_on_when_directaudio_stays_off(self): + legacycompat = self.compat.parent / "Steam/legacycompat" + legacycompat.mkdir(parents=True) + (legacycompat / "iscriptevaluator.exe").write_bytes(b"Steam install-script evaluator") + result = self.run_setup(target="iscriptevaluator.exe", cwd=legacycompat, extra={"AUDIO_DIRECT_MS": "60"}) + self.assertNotIn("BANNER_AUDIO_DIRECT_MS", result.stdout) + def test_non_launch_verbs_preserve_audio_configuration(self): for verb in ("getcompatpath", "getnativepath", "destroyprefix"): with self.subTest(verb=verb):