mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
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
This commit is contained in:
1 parent
9cc56631cd
commit
7bc3529722
7 files changed
+125
-10
No files matched your search
@@ -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<String> =
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
@@ -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 "") +
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user