mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 11:31:08 +00:00
Merge pull request #11 from The412Banner/fix/directaudio-default
Audio defaults: classic client sink, DirectAudio for games and as a client option; mic on; Share latest logs
This commit is contained in:
13 files changed
+187
-22
No files matched your search
@@ -95,5 +95,15 @@
|
||||
android:name=".session.SessionService"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false" />
|
||||
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.logs"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/log_share_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
Binary file not shown.
@@ -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<String?>(null)
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var clientDirectAudio by mutableStateOf(false)
|
||||
private var mic by mutableStateOf(false)
|
||||
private var linuxRows by mutableStateOf<List<DriverRow>>(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<String>()
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -28,7 +28,7 @@ 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-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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ""))
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="shared_logs" path="shared-logs/" />
|
||||
</paths>
|
||||
@@ -55,7 +55,8 @@ PA_MODULE_USAGE(
|
||||
"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>");
|
||||
"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)
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user