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):