-
-
Notifications
You must be signed in to change notification settings - Fork 421
Expose a real microphone source instead of the sink monitor #1804
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
e1dabfd
f703cdb
2a52ee6
b6b7d61
80448b7
f6e8963
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| package com.winlator.xenvironment.components; | ||
|
|
||
| import android.Manifest; | ||
| import android.content.Context; | ||
| import android.content.pm.PackageManager; | ||
|
|
||
| import com.winlator.core.AppUtils; | ||
| import com.winlator.core.FileUtils; | ||
|
|
@@ -41,12 +43,14 @@ | |
| public class PulseAudioComponent extends EnvironmentComponent { | ||
| private final UnixSocketConfig socketConfig; | ||
| private final String SINK_NAME = "AAudioSink"; | ||
| private final String SOURCE_NAME = "AAudioSource"; | ||
|
|
||
| private float volume = 1.0f; | ||
| private byte performanceMode = 1; | ||
| private final AtomicBoolean isPauseResumeRunning = new AtomicBoolean(false); | ||
| private final AtomicBoolean isPaused = new AtomicBoolean(false); | ||
| private boolean lowLatency = false; | ||
| private boolean micEnabled = false; | ||
|
|
||
| private final ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor(); | ||
|
|
||
|
|
@@ -171,10 +175,27 @@ private void startPulseAudio() { | |
| if (lowLatency) { | ||
| sinkParams += " low_latency=true"; | ||
| } | ||
| FileUtils.writeString(configFile, String.join("\n", | ||
| // Without a capture source the only recording device PulseAudio offers is | ||
| // AAudioSink.monitor, which Wine hands to games as a microphone - so a game with | ||
| // voice chat ends up transmitting its own output back into the lobby. Load a real | ||
| // source when we are allowed to; module-aaudio-source makes itself the default | ||
| // source, which is what actually outranks the monitor. | ||
| String config = String.join("\n", | ||
| "load-module module-native-protocol-unix auth-anonymous=1 auth-cookie-enabled=false socket=\""+socketConfig.path+"\"", | ||
| "load-module module-aaudio-sink " + sinkParams | ||
| )); | ||
| ); | ||
|
|
||
| if (hasMicrophonePermission(context)) { | ||
| config += "\nload-module module-aaudio-source source_name=" + SOURCE_NAME; | ||
| micEnabled = true; | ||
| } else { | ||
| // Not fatal: the daemon runs with --fail=false and simply comes up without a | ||
| // capture device, which is the behaviour before this change. | ||
| Timber.tag("PulseAudioComponent").i("RECORD_AUDIO not granted, starting without a capture source"); | ||
| micEnabled = false; | ||
| } | ||
|
|
||
| FileUtils.writeString(configFile, config); | ||
|
|
||
| String archName = AppUtils.getArchName(); | ||
| File modulesDir = new File(workingDir, "modules"); | ||
|
|
@@ -221,12 +242,87 @@ private String execPactlCommand(String command) { | |
| return ProcessHelper.execWithOutput(workingDir + "/pactl " + command, envVars.toStringArray(), workingDir, true, 5); | ||
| } | ||
|
|
||
| private boolean updateSink(boolean suspend) { | ||
| if (!suspend) { | ||
| return !execPactlCommand("suspend-sink " + SINK_NAME + " false").toLowerCase().contains("process timeout"); | ||
| } else { | ||
| return !execPactlCommand("suspend-sink " + SINK_NAME + " true").toLowerCase().contains("process timeout"); | ||
| private boolean hasMicrophonePermission(Context context) { | ||
| return context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED; | ||
| } | ||
|
|
||
| /** Suspend or resume the capture source, if one was loaded. No-op otherwise. */ | ||
| private void updateSource(boolean suspend) { | ||
| if (!micEnabled) return; | ||
|
|
||
| String result = execPactlCommand("suspend-source " + SOURCE_NAME + " " + (suspend ? "true" : "false")) | ||
| .toLowerCase(); | ||
|
|
||
| // pactl reports a missing source as "Failure: No such entity" on stderr, which | ||
| // execPactlCommand captures, so check for that as well as a timeout. | ||
| if (result.contains("failure") || result.contains("process timeout")) { | ||
| Timber.tag("PulseAudioComponent").w("Failed to %s source %s: %s", | ||
| suspend ? "suspend" : "resume", SOURCE_NAME, result.trim()); | ||
|
|
||
| if (result.contains("no such entity")) { | ||
| // The module never loaded - most likely the pulseaudio asset predates | ||
| // module-aaudio-source. Stop issuing suspend-source rather than warning | ||
| // on every pause for the rest of the session. | ||
| Timber.tag("PulseAudioComponent").w("Capture source absent, disabling source suspend handling"); | ||
| micEnabled = false; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Load the capture source into an already running daemon. | ||
| * | ||
| * The daemon reads default.pa exactly once, when it spawns. If the user grants | ||
| * RECORD_AUDIO after that - which is the normal case on a first launch, since the | ||
| * permission dialog is answered while the container is still booting - there would | ||
| * otherwise be no capture device until the game is relaunched. Loading the module at | ||
| * runtime avoids that. | ||
| */ | ||
| public void enableMicrophone() { | ||
| if (singleThreadExecutor.isShutdown()) return; | ||
|
|
||
| singleThreadExecutor.execute(() -> { | ||
| if (micEnabled) return; | ||
|
|
||
| Context context = environment.getContext(); | ||
| if (!hasMicrophonePermission(context)) return; | ||
|
|
||
| // On success pactl prints the new module index. ProcessHelper reports a failed | ||
| // exec as "Error: ..." and yields an empty string if nothing was captured, so | ||
| // treat both as failures rather than assuming the module loaded. | ||
| String result = execPactlCommand("load-module module-aaudio-source source_name=" + SOURCE_NAME); | ||
| String lower = result.toLowerCase().trim(); | ||
| if (lower.isEmpty() || lower.startsWith("error") || lower.contains("failure") | ||
| || lower.contains("process timeout")) { | ||
| Timber.tag("PulseAudioComponent").w("Failed to load capture source at runtime: %s", result.trim()); | ||
| return; | ||
| } | ||
|
|
||
| micEnabled = true; | ||
| Timber.tag("PulseAudioComponent").i("Capture source loaded after permission grant"); | ||
|
|
||
| // The permission dialog itself can pause the game, so the source may be | ||
| // arriving while audio is already suspended. Leaving it running would hold | ||
| // the microphone open until the next pause/resume cycle. | ||
| if (isPaused.get()) { | ||
| Timber.tag("PulseAudioComponent").d("Audio is paused, suspending the new capture source"); | ||
| updateSource(true); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: When the capture source is loaded at runtime while audio is already paused (the permission dialog can pause the activity, so this is a real path), Prompt for AI agents |
||
| } | ||
| }); | ||
| } | ||
|
|
||
| private boolean updateSink(boolean suspend) { | ||
| String state = suspend ? "true" : "false"; | ||
| boolean sinkUpdated = !execPactlCommand("suspend-sink " + SINK_NAME + " " + state) | ||
| .toLowerCase().contains("process timeout"); | ||
|
|
||
| // The capture source has to follow the sink. A suspended game must not keep | ||
| // holding the microphone open, or the Android privacy indicator stays lit and | ||
| // Android 14+ background-capture restrictions apply. A source failure is logged | ||
| // rather than blocking the pause/resume transition, since the source is optional. | ||
| updateSource(suspend); | ||
|
|
||
| return sinkUpdated; | ||
| } | ||
|
|
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.