diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 341fd5356..887478f9c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -95,5 +95,15 @@ android:name=".session.SessionService" android:exported="false" android:stopWithTask="false" /> + + + + diff --git a/app/src/main/assets/pulseaudio.tzst b/app/src/main/assets/pulseaudio.tzst index 60066f1a5..388ea8933 100644 Binary files a/app/src/main/assets/pulseaudio.tzst and b/app/src/main/assets/pulseaudio.tzst differ diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index ed1f1188d..7c3541806 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -32,6 +32,7 @@ import com.steamdeck.launcher.ui.DesktopAppsDialog import com.steamdeck.launcher.ui.PackageRow import com.steamdeck.launcher.session.OfflineMode import com.steamdeck.launcher.session.ProtonExtras +import com.steamdeck.launcher.session.SessionLogShare import com.steamdeck.launcher.session.SessionPrefs import com.steamdeck.launcher.ui.ProtonDialog import com.steamdeck.launcher.ui.ProtonRow @@ -99,6 +100,7 @@ class MainActivity : ComponentActivity() { private var prootNoSeccomp by mutableStateOf(false) private var phantomWarning by mutableStateOf(null) private var directAudio by mutableStateOf(false) + private var clientDirectAudio by mutableStateOf(false) private var mic by mutableStateOf(false) private var linuxRows by mutableStateOf>(emptyList()) private var linuxSteam by mutableStateOf("") @@ -240,6 +242,15 @@ class MainActivity : ComponentActivity() { SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this)) logsEnabled = SessionPrefs.logsEnabled(this) }, + onShareLogs = { + Thread({ + val zip = runCatching { SessionLogShare.zipLatest(this) }.getOrNull() + ui.post { + if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show() + else startActivity(SessionLogShare.shareIntent(this, zip)) + } + }, "share-logs").start() + }, onOffline = { OfflineMode.setEnabled(this, !OfflineMode.enabled(this)) offline = OfflineMode.enabled(this) @@ -298,9 +309,18 @@ class MainActivity : ComponentActivity() { // The session's logs land in Downloads so a failed run can be handed over as a folder // rather than dug out of app-private storage. targetSdk 28 means the old permission still // grants exactly that. + val wanted = ArrayList() if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { - requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1) + wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE) } + // The microphone is on by default; ask once, with the storage prompt, so voice chat works + // without a trip to the settings. A refusal is not asked again - the toggle asks when used. + if (SessionPrefs.micEnabled(this) && !SessionPrefs.micAsked(this) + && checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + wanted.add(Manifest.permission.RECORD_AUDIO) + SessionPrefs.setMicAsked(this) + } + if (wanted.isNotEmpty()) requestPermissions(wanted.toTypedArray(), 1) // A session folder left without its ending - the process was killed - gets it now. if (!SessionState.running) Thread({ SessionArtifacts.finishAbandoned(this) }, "finish-abandoned").start() } @@ -394,6 +414,7 @@ class MainActivity : ComponentActivity() { suspendPolicy = suspendPolicy, oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null, directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null, + clientDirectAudio = clientDirectAudio, mic = if (mode == SessionService.MODE_STEAM) mic else null, renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null, gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null, @@ -418,6 +439,7 @@ class MainActivity : ComponentActivity() { onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy }, onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on }, + onClientDirectAudio = { on -> SessionPrefs.setClientDirectAudio(this, on); clientDirectAudio = on }, onMic = { on -> SessionPrefs.setMicEnabled(this, on) mic = on @@ -509,6 +531,7 @@ class MainActivity : ComponentActivity() { suspendPolicy = SessionPrefs.suspendPolicy(this, mode) oscMode = SessionPrefs.oscMode(this) directAudio = SessionPrefs.directAudio(this) + clientDirectAudio = SessionPrefs.clientDirectAudio(this) mic = SessionPrefs.micEnabled(this) renderer = SessionPrefs.desktopRenderer(this) gameStorage = SessionPrefs.gameStorage(this) diff --git a/app/src/main/java/com/steamdeck/launcher/audio/DirectAudioRelayComponent.java b/app/src/main/java/com/steamdeck/launcher/audio/DirectAudioRelayComponent.java index ab7ee987d..01d6e2152 100644 --- a/app/src/main/java/com/steamdeck/launcher/audio/DirectAudioRelayComponent.java +++ b/app/src/main/java/com/steamdeck/launcher/audio/DirectAudioRelayComponent.java @@ -91,6 +91,9 @@ public class DirectAudioRelayComponent extends SessionPart { StringBuilder command = new StringBuilder(binary.getAbsolutePath()); command.append(" --socket ").append(socketPath.getAbsolutePath()); + // Diagnostics into the session's audio.log: every 1000 callbacks the device buffer, + // Android's own xrun count and the ring's fill, plus every buffer change. + command.append(" --log"); if (micFifoPath != null) { // The helper creates the pipe if it is not there. PulseAudio is told the same path and // reads from the other end of it. diff --git a/app/src/main/java/com/steamdeck/launcher/audio/PulseAudioComponent.java b/app/src/main/java/com/steamdeck/launcher/audio/PulseAudioComponent.java index acbaee62c..c3b3e5abf 100644 --- a/app/src/main/java/com/steamdeck/launcher/audio/PulseAudioComponent.java +++ b/app/src/main/java/com/steamdeck/launcher/audio/PulseAudioComponent.java @@ -28,7 +28,7 @@ public class PulseAudioComponent extends SessionPart { /** Where the guest reaches the daemon; the session exports PULSE_SERVER=unix:. */ 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-r4"; + private static final String BUNDLE_STAMP = "2026-09-23-pa13-suspend-r5"; private final File workingDir; /** Where the daemon's own output is kept for this session, or null for logcat only. */ @@ -63,6 +63,19 @@ public class PulseAudioComponent extends SessionPart { 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(); + byte[] buf = new byte[64 * 1024]; + try (java.io.InputStream in = app().getAssets().open("pulseaudio.tzst")) { + int n; + while ((n = in.read(buf)) > 0) crc.update(buf, 0, n); + } catch (java.io.IOException e) { + return "?"; + } + return Long.toHexString(crc.getValue()); + } + /** Send the daemon's output to this file as well as logcat. Set before {@link #start()}. */ public void setLogFile(File file) { this.logFile = file; @@ -86,17 +99,19 @@ public class PulseAudioComponent extends SessionPart { // once ever: an installed app kept the modules it unpacked on its first run, so a bundle // fixed in a later build never reached the device - which is how a 17.0 glibc build of // module-pipe-source sat beside the 13.0 daemon, failed to dlopen, and the microphone - // never appeared. Bump the stamp whenever pulseaudio.tzst changes. + // never appeared. The stamp carries a checksum of the bundle itself, so any change to it - + // a rebuilt sink module included - re-unpacks, whether or not BUNDLE_STAMP was bumped. File modulesDir = new File(workingDir, "modules"); File stamp = new File(modulesDir, ".bundle"); String have = FileUtils.readString(stamp); + String want = BUNDLE_STAMP + "-" + bundleChecksum(); if (!new File(modulesDir, "arm64/module-aaudio-sink.so").isFile() || !new File(workingDir, "pactl").isFile() - || have == null || !BUNDLE_STAMP.equals(have.trim())) { - Log.i(TAG, "unpacking pulseaudio.tzst (" + BUNDLE_STAMP + "; had " + have + ")"); + || have == null || !want.equals(have.trim())) { + Log.i(TAG, "unpacking pulseaudio.tzst (" + want + "; had " + have + ")"); FileUtils.delete(modulesDir); if (TarZst.extractAsset(app(), "pulseaudio.tzst", workingDir)) { - FileUtils.writeString(stamp, BUNDLE_STAMP); + FileUtils.writeString(stamp, want); } else { Log.e(TAG, "pulseaudio.tzst did not unpack"); } @@ -128,7 +143,8 @@ public class PulseAudioComponent extends SessionPart { 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"); + // The classic sink: the adaptive AAudio module 0.1.5 shipped, unchanged. + config.add("load-module module-aaudio-classic-sink sink_name=AAudioSink performance_mode=1 adaptive=1 volume=1.0"); config.add("set-default-sink AAudioSink"); } if (micFifoPath != null && !micFifoPath.isEmpty()) { @@ -149,7 +165,10 @@ public class PulseAudioComponent extends SessionPart { String command = workingDir.getAbsolutePath() + "/libpulseaudio.so" + " --system=false --disable-shm=true --fail=false" - + " -n --file=default.pa --daemonize=false --use-pid-file=false --exit-idle-time=-1"; + + " -n --file=default.pa --daemonize=false --use-pid-file=false --exit-idle-time=-1" + // Info level: the classic sink reports the stream Android granted (burst, capacity, + // starting buffer) only there, and that is what a stutter report needs. + + " --log-level=info"; // A module that refuses to load, a pipe that could not be made, the daemon exiting at // startup: all of it used to reach logcat and nothing else, so a user's folder said nothing // at all about sound. Four separate faults hid behind "no input device" in one night. diff --git a/app/src/main/java/com/steamdeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/steamdeck/launcher/core/DeviceReport.kt index 4bde1448a..9c6c08300 100644 --- a/app/src/main/java/com/steamdeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/steamdeck/launcher/core/DeviceReport.kt @@ -147,6 +147,7 @@ object DeviceReport { k("Skip xalia", SessionPrefs.noXalia(context)) k("proot without seccomp", SessionPrefs.prootNoSeccomp(context)) k("DirectAudio for games", SessionPrefs.directAudio(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)) diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionLogShare.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionLogShare.kt new file mode 100644 index 000000000..4ed4fc347 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionLogShare.kt @@ -0,0 +1,49 @@ +package com.steamdeck.launcher.session + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import com.steamdeck.launcher.runtime.LinuxRuntime +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Zips the most recent session's log folder and hands it to Android's share sheet. Works where + * the folder could not be written to Downloads (no storage permission, or a device that refuses + * it) and landed in the app's private files instead, which a user cannot otherwise reach. + */ +object SessionLogShare { + /** The newest session folder in either place logs are written, or null if there is none. */ + fun latest(context: Context): File? = + listOf(LinuxRuntime.debugLogDir(), File(context.filesDir, "logs")) + .flatMap { dir -> dir.listFiles { f -> f.isDirectory && f.name.startsWith("session-") }?.toList() ?: emptyList() } + .maxByOrNull { it.name } + + /** Builds the zip (blocking). Returns null when there is no session to share. */ + fun zipLatest(context: Context): File? { + val folder = latest(context) ?: return null + val out = File(context.cacheDir, "shared-logs").apply { deleteRecursively(); mkdirs() } + val zip = File(out, "SteamDeck-${folder.name}.zip") + ZipOutputStream(zip.outputStream().buffered()).use { z -> + folder.walkTopDown().filter { it.isFile }.forEach { f -> + z.putNextEntry(ZipEntry(folder.name + "/" + f.relativeTo(folder).path)) + f.inputStream().use { it.copyTo(z) } + z.closeEntry() + } + } + return zip + } + + fun shareIntent(context: Context, zip: File): Intent { + val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip) + val send = Intent(Intent.ACTION_SEND) + .setType("application/zip") + .putExtra(Intent.EXTRA_STREAM, uri) + .putExtra(Intent.EXTRA_SUBJECT, zip.nameWithoutExtension) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + send.clipData = ClipData.newRawUri(zip.name, uri) + return Intent.createChooser(send, "Share session logs").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } +} diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt index 10372c47d..bb9191790 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt @@ -69,17 +69,35 @@ object SessionPrefs { } /** - * DirectAudio for the games the client launches: Wine's audio driver inside them replaced by - * ours, which talks to a helper on this side. The client itself keeps PulseAudio either way. + * The Steam client's own sound through the DirectAudio relay instead of the classic AAudio + * sink. Off by default: on an AYN Thor (Android 13, 20 ms bursts) the relay path stayed choppy + * where the classic sink - the one 0.1.5 shipped - was fine. */ - fun directAudio(context: Context): Boolean = prefs(context).getBoolean("directAudio", false) + fun clientDirectAudio(context: Context): Boolean = prefs(context).getBoolean("clientDirectAudio", false) + + fun setClientDirectAudio(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("clientDirectAudio", on).apply() + } + + /** + * DirectAudio for games: their Wine audio driver talks to the relay helper on this side. On + * unless the user turned it off. + */ + fun directAudio(context: Context): Boolean = prefs(context).getBoolean("directAudio", true) fun setDirectAudio(context: Context, on: Boolean) { prefs(context).edit().putBoolean("directAudio", on).apply() } - /** The microphone, its own opt-in: the helper opens an input stream only when asked. */ - fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", false) + /** The microphone for voice chat, on unless turned off; used only once RECORD_AUDIO is granted. */ + fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", true) + + /** Whether the app has already asked for the microphone once at start-up. */ + fun micAsked(context: Context): Boolean = prefs(context).getBoolean("micAsked", false) + + fun setMicAsked(context: Context) { + prefs(context).edit().putBoolean("micAsked", true).apply() + } fun setMicEnabled(context: Context, on: Boolean) { prefs(context).edit().putBoolean("mic", on).apply() diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt index 9d509ad0a..7670833ca 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt @@ -316,12 +316,14 @@ class SessionService : Service() { 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. - if (wantsDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath) + // 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) pulse.setLogFile(audioLog) pulse.attach(this) guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath) components.add(pulse) - if (wantsDirectAudio || wantsMic) { + if (wantsDirectAudio || wantsMic || clientDirectAudio) { // After the daemon in the list, so it can wait for the pipe the daemon makes. val relay = DirectAudioRelayComponent(relaySocket, micFifo) relay.setLogFile(audioLog) @@ -335,7 +337,7 @@ class SessionService : Service() { guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR) guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath) } - Log.i(TAG, "audio: PulseAudio" + (if (wantsDirectAudio) " + DirectAudio for games" else "") + Log.i(TAG, "audio: client " + (if (clientDirectAudio) "DirectAudio" else "classic AAudio sink") + (if (wantsDirectAudio) " + DirectAudio for games" else "") + (if (wantsMic) " + microphone" else "") + (if (SessionPrefs.micEnabled(this) && !wantsMic) " (microphone wanted but RECORD_AUDIO not granted)" else "")) diff --git a/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt index e7294af2b..806c56160 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/FrontEndScreen.kt @@ -150,6 +150,7 @@ class FrontEndActions( val onRoms: () -> Unit, val onFiles: () -> Unit, val onLogs: () -> Unit, + val onShareLogs: () -> Unit = {}, val onOffline: () -> Unit, val onEmulatorHelp: () -> Unit, val onCredits: () -> Unit, @@ -363,6 +364,7 @@ private fun Rail( ) { MenuItem("On", checked = s.logsEnabled) { if (!s.logsEnabled) a.onLogs(); menus.open = null } MenuItem("Off", checked = !s.logsEnabled) { if (s.logsEnabled) a.onLogs(); menus.open = null } + MenuItem("Share latest logs", checked = false) { a.onShareLogs(); menus.open = null } } } Box { diff --git a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt index 0243fee04..9f486328e 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt @@ -30,6 +30,7 @@ class ModeSettings( /** Steam only. */ val oscMode: String?, val directAudio: Boolean?, + val clientDirectAudio: Boolean = false, val mic: Boolean?, val renderer: String?, val gameStorage: String? = null, @@ -61,6 +62,7 @@ class ModeSettingsActions( val onSuspendPolicy: (String) -> Unit, val onOsc: (String) -> Unit, val onDirectAudio: (Boolean) -> Unit, + val onClientDirectAudio: (Boolean) -> Unit = {}, val onMic: (Boolean) -> Unit, val onRenderer: (String) -> Unit, val onGameStorage: (path: String, label: String) -> Unit = { _, _ -> }, @@ -193,7 +195,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } if (steam && s.directAudio != null && s.mic != null) SettingsGroup("Audio") { - ToggleRow(host, "da", "DirectAudio", "Bypasses PulseAudio for lower latency, for games and the Steam client.", s.directAudio, onChange = a.onDirectAudio) + ToggleRow(host, "da", "DirectAudio for games", "Bypasses PulseAudio for lower latency in games.", s.directAudio, onChange = a.onDirectAudio) + ChoiceRow( + host, "clientAudio", "Steam client audio", "Classic is the AAudio sink from 0.1.5. DirectAudio goes through the relay. Applies next session.", + listOf("classic" to "Classic", "directaudio" to "DirectAudio"), if (s.clientDirectAudio) "directaudio" else "classic", + onPick = { id -> a.onClientDirectAudio(id == "directaudio") }, + ) ToggleRow(host, "mic", "Microphone", "Uses the device microphone for voice chat.", s.mic, onChange = a.onMic) } if (steam && s.gameStorage != null) SettingsGroup("Game storage") { diff --git a/app/src/main/res/xml/log_share_paths.xml b/app/src/main/res/xml/log_share_paths.xml new file mode 100644 index 000000000..cc98eee1e --- /dev/null +++ b/app/src/main/res/xml/log_share_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tools/aaudio-sink/module-directaudio-sink.c b/tools/aaudio-sink/module-directaudio-sink.c index eca10b307..b17520f0a 100644 --- a/tools/aaudio-sink/module-directaudio-sink.c +++ b/tools/aaudio-sink/module-directaudio-sink.c @@ -55,7 +55,8 @@ PA_MODULE_USAGE( "sink_properties= " "volume= " "performance_mode=<0 none, 1 low latency, 2 power saving> " - "adaptive="); + "adaptive= " + "buffer_ms="); #define DEFAULT_SINK_NAME "DirectAudio" #define RECONNECT_USEC (500 * PA_USEC_PER_MSEC) @@ -63,9 +64,18 @@ PA_MODULE_USAGE( #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 static const char* const valid_modargs[] = { - "socket", "sink_name", "sink_properties", "volume", "performance_mode", "adaptive", NULL + "socket", "sink_name", "sink_properties", "volume", "performance_mode", "adaptive", "buffer_ms", NULL }; struct userdata { @@ -80,6 +90,7 @@ struct userdata { char *socket_path; int perf; bool adaptive; + uint32_t buffer_ms; pa_sample_spec ss; size_t frame_size; @@ -91,6 +102,7 @@ struct userdata { 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) { @@ -132,6 +144,7 @@ static int connect_relay(struct userdata *u) { 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)) @@ -177,9 +190,10 @@ static int connect_relay(struct userdata *u) { 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, ring %d frames, target %d", - ack.rate, ack.burst, ack.buf_frames, ack.out_cap_frames, (int) u->ring->target_frames); + 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: @@ -239,6 +253,8 @@ static int service_ring(struct userdata *u) { 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); @@ -248,6 +264,12 @@ static int service_ring(struct userdata *u) { 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; @@ -333,7 +355,7 @@ int pa__init(pa_module *m) { pa_sink_new_data data; pa_channel_map map; pa_cvolume volume; - uint32_t perf = 1; + uint32_t perf = 1, buffer_ms = DEFAULT_BUFFER_MS; double linear_volume = 1.0; bool adaptive = true; const char *path; @@ -352,6 +374,10 @@ int pa__init(pa_module *m) { 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; @@ -368,6 +394,7 @@ int pa__init(pa_module *m) { 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;