Skip to content

Expose a real microphone source instead of the sink monitor - #1804

Draft
Zum0DePapaya wants to merge 6 commits into
utkarshdalal:masterfrom
Zum0DePapaya:feat/microphone-input
Draft

Expose a real microphone source instead of the sink monitor#1804
Zum0DePapaya wants to merge 6 commits into
utkarshdalal:masterfrom
Zum0DePapaya:feat/microphone-input

Conversation

@Zum0DePapaya

@Zum0DePapaya Zum0DePapaya commented Aug 10, 2026

Copy link
Copy Markdown

Description

On audioDriver=pulseaudio the only recording device available to games is AAudioSink.monitor — the loopback PulseAudio creates automatically for every sink, which Wine enumerates as a Windows recording device. Games take it for a microphone, so anything with voice chat transmits its own output. In R.E.P.O. that means rebroadcasting the whole lobby back into the lobby. Switching to the ALSA driver gives no capture device at all (Microphone.devices is empty).

This loads module-aaudio-source alongside the sink so there is a real capture device. The module makes itself the default source, which is what actually outranks the monitor.

Discussed in #code-changes beforehand.

Paired with GameNative/pulseaudio-android#2, which is where the module itself lives. The compiled asset is bundled here (see below) so this branch is testable on its own, but #2 should land first so the asset is reproducible from source rather than being a binary nobody can regenerate.

What changed

app/src/main/assets/pulseaudio-gamenative-20260810.tzst (replaces -20260612)

  • Rebuilt from pulseaudio-android main with module-aaudio-source.so added. Without it the load-module line below has nothing to load, and since the daemon runs with --fail=false that failure is silent.
  • Verified file by file against the asset it replaces: pactl, libprotocol-native.so and module-native-protocol-unix.so are byte-identical. module-aaudio-sink.so is rebuilt, so it picks up the 16 KB page alignment from pulseaudio-android#1 — LOAD align goes 0x10000x4000 — with its exported symbols unchanged. module-aaudio-source.so is new, also 16 KB aligned.
  • All 51 pa_* symbols the new module needs resolve against the shipped libpulsecore / libpulsecommon / libpulse.
  • XServerScreen.kt's refreshComponentsFiles() reference is bumped to the new filename, following the existing convention of date-stamping the asset and dropping the old one.

PulseAudioComponent.java

  • Adds the load-module module-aaudio-source line to the generated default.pa, only when RECORD_AUDIO has actually been granted. When it hasn't, the config is byte-identical to today's.
  • updateSink() now suspends and resumes the source alongside the sink. Without that a paused game keeps holding the microphone open, leaving the Android privacy indicator lit and running into Android 14+ background-capture restrictions. A failure to suspend the source is logged rather than blocking the pause/resume transition, since the source is optional.

MainActivity.kt

  • Requests RECORD_AUDIO at startup, next to the existing POST_NOTIFICATIONS request. It was already declared in the manifest but never requested anywhere — there is no AudioRecord in the codebase at all.
  • The two are sequenced rather than fired together, because the platform drops a permission request made while another dialog is still showing.
  • Skipped on modernXr, which removes the permission via tools:node="remove".

No set-default-source call is needed; the module does it itself.

Why the prompt moved out of XServerScreen (@joshuatam)

An earlier revision requested the permission from XServerScreen, gated on the container actually using the pulseaudio driver, so that the prompt carried launch context. @joshuatam flagged that location as crash-prone, and he was right — for two separate reasons, both stemming from the dialog backgrounding the activity while the container is mid-boot:

  1. setupXEnvironment finishes with isActivityInForeground false, so the branch immediately after it suspends the environment. On the manual-resume suspend policy that leaves the user having to resume a game they just launched.
  2. onActivityDestroyed calls exit(xServerView!!…) with no null guard, and it is registered during composition — well before the AndroidView factory assigns xServerView. Anything that destroys the activity while the dialog is up (don't-keep-activities, memory pressure, a configuration change) lands on that non-null assertion.

To be clear about ownership: that second one is a pre-existing sharp edge, not something this PR introduced — the null window between registering the handler and creating the view is there on master today. But putting a system dialog at exactly that moment made it far easier to reach, which is a good enough reason not to do it. Worth hardening separately if you agree; I've left it alone here to keep this PR to its own scope.

Requesting at startup resolves the permission before any container exists, so default.pa is written correctly at daemon spawn and neither problem is reachable. The cost is that the prompt no longer carries context and is shown to users who will never touch voice chat. If you'd rather have it somewhere else — a container setting would be the nicest UX, though that's UI and so out of scope per CONTRIBUTING.md — say the word.

I have not been able to reproduce the crash directly, since testing needs an install under the real app.gamenative ID; the reasoning above is from the code paths rather than a captured stack trace.

Testing

Verified on a Poco X4 GT (Android 14, bionic container, proton-10.0-arm64ec-2) using an equivalent build with module-pipe-source standing in for the real module, since the asset doesn't carry module-aaudio-source yet:

Default Sink:   AAudioSink
Default Source: GameNativeMic          <- no longer the monitor

0  AAudioSink.monitor   module-aaudio-sink.c   s16le 2ch 48000Hz  IDLE
1  GameNativeMic        module-pipe-source.c   s16le 1ch 48000Hz  IDLE

Separately confirmed that Wine enumerates such a source as a Windows recording device and that its default capture endpoint follows PulseAudio's default source (tested with a mingw-built winmm + mmdevapi probe under Wine 9.0 / PA 16.1 on x86_64, not arm64ec Proton, so ordering could differ there).

Also confirmed AAudio input works from a bare native process with no Java context — 48 kHz mono PCM_I16 with the VOICE_COMMUNICATION preset, 144000/144000 frames over 3s — which is what module-aaudio-source relies on, since the daemon is a forked child of the app.

The bundled asset itself is verified as far as packaging goes: I read the .tzst back out of the built APK and confirmed it matches what I built (rather than a stale merged copy — Gradle has bitten me on that here before), and that module-aaudio-source.so is present inside it and 16 KB aligned.

Not yet verified end to end: voice actually transmitting in R.E.P.O. with the real module — that is the one remaining gap, and it needs an install under the real app.gamenative application ID. Worth knowing that the monitor remains in the device list at a lower index than the real source — PulseAudio 13 can't suppress a sink's monitor — so an app that picks by index rather than using the default endpoint could still land on it.

Recording

Still to come. Now that the asset is in the branch this is unblocked, so I'll add one from a real R.E.P.O. session rather than the module-pipe-source stand-in.

Draft

Left as a draft at @joshuatam's request so the changes can be reviewed piece by piece.

Type of Change

  • Bug fix
  • Performance / stability improvement
  • Compatibility improvements
  • Other (requires prior approval)

Checklist

  • Discussed in #code-changes and green-lit
  • Aligns with current project scope
  • Recording attached — to come, see above
  • Read and agree to the contribution guidelines in CONTRIBUTING.md

Summary by cubic

Expose a real microphone to games by loading a PulseAudio capture source instead of the AAudioSink.monitor loopback. The source becomes default when RECORD_AUDIO is granted, can be loaded into a running daemon, and is immediately suspended if audio is already paused.

  • Bug Fixes

    • Load module-aaudio-source with the sink when RECORD_AUDIO is granted; if granted late, load it via pactl and suspend it if audio is paused (no relaunch).
    • Suspend/resume the capture source with the sink; on pactl failures, blank/error output, or “No such entity”, log and stop issuing source suspend calls; never block pause/resume.
    • Request RECORD_AUDIO at container launch only for pulseaudio containers; skipped on modernXr.
  • Dependencies

    • Ships pulseaudio-gamenative-20260810.tzst with module-aaudio-source; rebuilt same day after switching the module to a weak symbol for AAudioStreamBuilder_setInputPreset (only the source module changed; filename unchanged). Older assets still fall back to current behavior.

Written for commit 80448b7. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added microphone support for PulseAudio on compatible XR builds.
    • The app requests microphone access when needed and enables audio capture when permission is granted.
  • Bug Fixes

    • Startup continues normally when microphone permission is denied.
    • Microphone capture pauses and resumes alongside audio output when available.
    • Improved handling when microphone capture is unavailable or fails to start.

On audioDriver=pulseaudio the only recording device available to games is
AAudioSink.monitor, which PulseAudio creates automatically for every sink and
which Wine enumerates as a Windows recording device. Games take it for a
microphone, so anything with voice chat transmits its own output - in R.E.P.O.
that means rebroadcasting the whole lobby back into the lobby. Switching to the
ALSA driver gives no capture device at all.

Load module-aaudio-source alongside the sink so there is a real capture device.
The module makes itself the default source, which is what actually outranks the
monitor; PulseAudio 13 cannot suppress a sink's monitor, so it stays in the
device list, but it is no longer what a game gets handed by default.

The module is only loaded when RECORD_AUDIO has actually been granted. When it
has not, the daemon starts exactly as before - it runs with --fail=false, so a
missing capture source degrades rather than taking audio down.

updateSink() now suspends and resumes the source alongside the sink. Without
that a paused game keeps holding the microphone open, which leaves the Android
privacy indicator lit and runs into the Android 14+ background-capture
restrictions. A failure to suspend the source is logged rather than blocking
the pause/resume transition, since the source is optional.

RECORD_AUDIO was already declared in the manifest but never requested - there is
no AudioRecord anywhere in the codebase. It is now requested from XServerScreen,
in context, and only for containers actually using the pulseaudio driver, rather
than at app startup. modernXr removes the permission via tools:node="remove", so
the request is skipped there.

Requires module-aaudio-source in the pulseaudio asset:
GameNative/pulseaudio-android#2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

XServerScreen requests microphone permission for applicable PulseAudio containers. PulseAudioComponent conditionally enables the AAudio capture source and synchronizes its pause and resume state with the audio sink.

Changes

Microphone audio support

Layer / File(s) Summary
Runtime microphone permission request
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
The screen requests RECORD_AUDIO for non-modern XR PulseAudio containers and logs the result without blocking startup.
Permission-gated PulseAudio capture
app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java
PulseAudio checks permission, conditionally loads module-aaudio-source, enables the source after permission is granted, and synchronizes source suspension with sink state. Source failures do not fail sink transitions.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: utkarshdalal

Sequence Diagram(s)

sequenceDiagram
  participant XServerScreen
  participant AndroidPermissionAPI
  participant PulseAudioComponent
  participant PulseAudioServer

  XServerScreen->>AndroidPermissionAPI: Request RECORD_AUDIO when needed
  AndroidPermissionAPI-->>XServerScreen: Return permission result
  XServerScreen->>PulseAudioComponent: Enable microphone when granted
  PulseAudioComponent->>AndroidPermissionAPI: Check RECORD_AUDIO
  AndroidPermissionAPI-->>PulseAudioComponent: Return permission state
  PulseAudioComponent->>PulseAudioServer: Load optional AAudio capture source
  PulseAudioComponent->>PulseAudioServer: Suspend or resume sink and capture source
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary change: replacing the sink monitor with a real microphone source.
Description check ✅ Passed The description covers the change, rationale, testing, dependencies, risks, and checklist; the recording is explicitly identified as still pending.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt`:
- Around line 392-411: Update the RECORD_AUDIO result callback in
XServerScreen’s micPermissionLauncher to refresh the active PulseAudioComponent
when permission is granted, ensuring default.pa gains module-aaudio-source for
the current container session; preserve the existing logging and no-op behavior
for denial. Add a test covering permission grant and verifying the PulseAudio
source refresh.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 021ef643-c045-4fe7-8f07-e0e67ee99165

📥 Commits

Reviewing files that changed from the base of the PR and between 4c3269c and e1dabfd.

📒 Files selected for processing (2)
  • app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
  • app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java

Comment thread app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java Outdated
Comment thread app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt Outdated
Two issues raised in review.

The daemon reads default.pa once, when it spawns. On a first launch the
RECORD_AUDIO dialog is answered while the container is still booting, so the
permission often arrives after startPulseAudio() has already decided not to load
the capture source - and nothing reloaded it afterwards, leaving no microphone
until the game was relaunched. The permission callback now loads the module into
the running daemon via pactl, so both orderings work: granted before the daemon
spawns goes through the config as before, granted after loads at runtime.

The suspend-source failure warning could also never fire for the case it exists
to report. micEnabled tracked whether permission was granted, not whether the
module actually loaded, and a missing source makes pactl print
"Failure: No such entity" - which contains neither "process timeout" nor
anything else the check looked for, so a failure read as success. That matters
today, since the shipped asset does not carry module-aaudio-source yet. The
check now also looks for a pactl failure, and on "no such entity" it stops
issuing suspend-source rather than warning on every pause for the session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Zum0DePapaya

Copy link
Copy Markdown
Author

Both findings were valid. Fixed in f703cdb.

The dead warning path (cubic, PulseAudioComponent.java:260) — correct, and the better catch of the two. micEnabled tracked whether the permission was granted, not whether the module actually loaded, so when the source is absent pactl prints Failure: No such entity, which contains neither process timeout nor anything else the check looked for. The failure read as success and the warning never fired — in the one situation it exists to report, which is also the situation that is true right now, since the shipped asset does not carry module-aaudio-source yet.

execPactlCommand passes includeStderr=true, so pactl's failure text is available; the check just wasn't looking for it. It now also matches failure, and on no such entity specifically it clears micEnabled so it warns once rather than on every pause for the rest of the session.

The first-launch race (both reviewers, XServerScreen.kt:399) — I had flagged this in the PR description as a known tradeoff, but you're right that documenting it wasn't good enough: there was no recovery path, so a user who answered the prompt slowly got no microphone and no indication why, with relaunching as the only fix.

Rather than gating startup on the permission result, the callback now loads the module into the already-running daemon with pactl load-module. That covers both orderings — granted before the daemon spawns goes through default.pa as before, granted after spawns loads at runtime — without restructuring the launch flow.

On the suggested unit test — skipping this one deliberately. Both paths are a pactl invocation against a live daemon and an Android permission callback; a test here would assert that a mock was called rather than that the microphone works. The behaviour that actually needs verifying is end-to-end on a device, which is still blocked on module-aaudio-source landing in the asset (GameNative/pulseaudio-android#2). Happy to be overruled if you'd rather have the coverage.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java`:
- Around line 267-273: Update the successful load path in the runtime
capture-source logic to check isPaused.get() and suspend the newly loaded source
when the sink is already paused. Reuse updateSink()’s missing-source failure
handling and only set micEnabled after the source is successfully suspended or
when no suspension is needed.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d050b202-c62c-4b0d-a6cb-2f8e14064ede

📥 Commits

Reviewing files that changed from the base of the PR and between e1dabfd and f703cdb.

📒 Files selected for processing (2)
  • app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
  • app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt

Comment thread app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java Outdated
Two more issues raised in review, both in the runtime load path added in the
previous commit.

enableMicrophone() loaded an active source without checking isPaused. Granting
RECORD_AUDIO pauses the activity while the dialog is up, so the grant callback
can easily fire while audio is suspended - and the new source would then hold
the microphone open until the next pause/resume cycle. That is exactly the
privacy problem the suspend handling exists to prevent. It now suspends the
source immediately when isPaused is set.

The success check also treated anything that was not a timeout or an explicit
pactl failure as success. ProcessHelper reports a failed exec as "Error: ..."
and returns an empty string when nothing was captured, so a module that never
loaded could set micEnabled and log that the capture source was ready. Blank and
"error"-prefixed output are now failures too.

Suspend/resume handling is extracted into updateSource() so both callers share
one implementation of the missing-source path rather than duplicating it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Zum0DePapaya

Copy link
Copy Markdown
Author

Both valid again. Fixed in 2a52ee6.

Capture source loaded while paused — a fair hit, and the more serious of the two. The comment three lines away in updateSink() says a suspended game must not hold the microphone open, and then enableMicrophone() went and loaded an active source without checking isPaused. The scenario is not hypothetical either: the permission dialog pauses the activity, so the grant callback can easily fire while audio is already suspended, and the mic would stay live until the next pause/resume cycle.

It now suspends the source immediately when isPaused is set. Both of you suggested reusing updateSink()'s missing-source handling, so rather than copying it I extracted updateSource(boolean) and both callers now share one implementation.

Loose success detection — also correct. Confirmed against ProcessHelper.execWithOutput: it appends "Error: " + e.getMessage() on exception and can return an empty string when nothing was captured, so a module that never loaded could flip micEnabled to true and log that the capture source was ready. Lower impact since updateSource() would later see No such entity and reset it, but the misleading log and the window of wrong state are worth closing. Blank and error-prefixed output are now treated as failures.

Worth noting for whoever reviews next: each round has found a real defect in the previous round's fix, which suggests this component has more edge cases than its size implies. None of it is genuinely validated until it runs on a device against an asset containing module-aaudio-source (GameNative/pulseaudio-android#2) — everything so far is reasoning plus a compile.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java">

<violation number="1" location="app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java:309">
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), `load-module module-aaudio-source` first brings the source up active and only afterwards is it suspended by a separate best-effort `updateSource(true)` call. Because loading an active source while the game/activity is suspended is exactly the background-capture the PR is trying to avoid, there is a brief window where the mic/privacy indicator is open during a paused state; and if the follow-up suspend fails (the suspend command is optional and only logged), the freshly loaded source stays active while the game remains paused for the rest of the session. Consider loading the module directly into a suspended state (if module-aaudio-source supports it) or treating a failed suspend after a successful load as a reason to unload the module again, so the paused/background guarantee holds. This is a minor edge concern given the explicitly non-blocking source handling.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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), load-module module-aaudio-source first brings the source up active and only afterwards is it suspended by a separate best-effort updateSource(true) call. Because loading an active source while the game/activity is suspended is exactly the background-capture the PR is trying to avoid, there is a brief window where the mic/privacy indicator is open during a paused state; and if the follow-up suspend fails (the suspend command is optional and only logged), the freshly loaded source stays active while the game remains paused for the rest of the session. Consider loading the module directly into a suspended state (if module-aaudio-source supports it) or treating a failed suspend after a successful load as a reason to unload the module again, so the paused/background guarantee holds. This is a minor edge concern given the explicitly non-blocking source handling.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/com/winlator/xenvironment/components/PulseAudioComponent.java, line 309:

<comment>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), `load-module module-aaudio-source` first brings the source up active and only afterwards is it suspended by a separate best-effort `updateSource(true)` call. Because loading an active source while the game/activity is suspended is exactly the background-capture the PR is trying to avoid, there is a brief window where the mic/privacy indicator is open during a paused state; and if the follow-up suspend fails (the suspend command is optional and only logged), the freshly loaded source stays active while the game remains paused for the rest of the session. Consider loading the module directly into a suspended state (if module-aaudio-source supports it) or treating a failed suspend after a successful load as a reason to unload the module again, so the paused/background guarantee holds. This is a minor edge concern given the explicitly non-blocking source handling.</comment>

<file context>
@@ -264,13 +287,26 @@ public void enableMicrophone() {
+            // 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);
             }
         });
</file context>

@Zum0DePapaya

Copy link
Copy Markdown
Author

Not fixing this one, because the premise doesn't hold for this particular module — but chasing it turned up a real bug elsewhere, so thanks for it.

Why the window doesn't exist: module-aaudio-source (in GameNative/pulseaudio-android#2, so not visible from this repo) deliberately does not start the AAudio stream when the module loads. pa__init opens the stream only to discover the device's real sample rate and format, and requestStart is called only when the pa_source transitions to RUNNING, which requires a client to actually attach. That was one of two intentional divergences from the sink, for exactly the reason you raise: a source holding an idle input stream keeps the microphone open.

So a freshly loaded source with no client attached is IDLE, nothing is being captured, and the follow-up updateSource(true) is belt-and-braces rather than the thing preventing capture. The same reasoning covers the second half of the comment — if that suspend fails, the source is still IDLE, and IDLE and SUSPENDED both leave the stream stopped.

Unloading the module on a failed suspend would also be a net negative: a transient pactl timeout would tear down a working capture source for the rest of the session, to guard against a state that cannot occur.

What it did surface. Checking the "does loading actually open the mic" question properly, I found that AAudioStreamBuilder_setInputPreset is API 28 and was being linked directly. Both modules are built with -z now, so undefined symbols resolve at dlopen time — meaning on an API 26/27 device the module would fail to load entirely, and the sdk >= 28 runtime guard is useless because the failure happens before any module code runs. The NDK confirms it: linking that symbol at API 26 fails outright.

Fixed in the module PR by resolving it through dlsym. Worth noting the shipped module-aaudio-sink.so has the same pattern with AAudioStreamBuilder_setUsage, which would affect the legacy flavour (minSdk 26) — flagged separately for the maintainers, since that's pre-existing and not mine to change here.

@utkarshdalal

Copy link
Copy Markdown
Owner

@joshuatam , can you help with this one? Requires changes to pulseaudio

@joshuatam

Copy link
Copy Markdown
Contributor

@joshuatam , can you help with this one? Requires changes to pulseaudio

Yes, there is a discussion on code-changes

@Zum0DePapaya
Zum0DePapaya marked this pull request as draft August 10, 2026 15:32
Zum0DePapaya and others added 3 commits August 10, 2026 17:38
Rebuilt from GameNative/pulseaudio-android main with the AAudio capture
source module added, so the load-module line added in this branch has
something to load. Without it the daemon silently comes up without a
source, since it runs with --fail=false.

pactl, libprotocol-native.so and module-native-protocol-unix.so are
byte-identical to the 20260612 asset. module-aaudio-sink.so is rebuilt
and so picks up the 16 KB page alignment from pulseaudio-android#1
(LOAD align goes from 0x1000 to 0x4000); its exported symbols are
unchanged. module-aaudio-source.so is new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
module-aaudio-source now resolves AAudioStreamBuilder_setInputPreset via a
weak declaration rather than dlsym, matching the sink, so the module binary
changed. Only modules/module-aaudio-source.so differs from the previous
asset; pactl, libprotocol-native.so, module-native-protocol-unix.so and
module-aaudio-sink.so are byte-identical. Filename is unchanged since it is
a same-day rebuild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Requesting it from XServerScreen raised a system dialog at the worst
possible moment. The LaunchedEffect fired on screen entry, while the
container was still booting and before the AndroidView factory had
created xServerView, and the dialog backgrounds the activity for as long
as it is up. Two consequences:

- setupXEnvironment finishes with isActivityInForeground false, so the
  branch right after it suspends the environment immediately; on the
  manual-resume suspend policy the user has to resume a game they just
  launched.
- onActivityDestroyed calls exit(xServerView!!...) with no null guard,
  and it is registered during composition, well before xServerView is
  assigned. Anything that destroys the activity while the dialog is up
  (don't-keep-activities, memory pressure, a configuration change) lands
  on that non-null assertion. The sharp edge already existed; putting a
  dialog there made it far easier to reach.

Moving the request next to the existing POST_NOTIFICATIONS one resolves
the permission before any container exists, so default.pa is written
correctly at daemon spawn and neither problem is reachable. The two are
sequenced rather than fired together, since the platform drops a
permission request made while another dialog is showing.

The trade-off is that the prompt no longer carries launch context and is
shown to users who never touch voice chat. Reported by @joshuatam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Zum0DePapaya

Copy link
Copy Markdown
Author

Good catch on the permission request location — you were right, and for two reasons rather than one. Both come from the dialog backgrounding the activity while the container is still booting:

  1. setupXEnvironment finishes with isActivityInForeground false, so the branch straight after it calls xEnvironment?.onPause(). On the manual-resume suspend policy that means the user has to manually resume a game they just launched.
  2. onActivityDestroyed does exit(xServerView!!...) with no null guard, and it's registered in the DisposableEffect(Unit) during composition — well before the AndroidView factory assigns xServerView. Anything that destroys the activity while the dialog is up lands on that non-null assertion.

Being straight about ownership on the second one: that null window exists on master today and isn't something this PR introduced. But raising a system dialog at precisely that moment makes it far easier to hit, which is reason enough not to. Happy to harden it in a separate PR if you want — I've left it alone here so this one stays in scope.

Moved the request to MainActivity, next to the existing POST_NOTIFICATIONS one, so the permission is resolved before any container exists and default.pa is written correctly at daemon spawn. The two prompts are sequenced rather than fired together, since the platform drops a permission request made while another dialog is showing.

XServerScreen.kt is back to a one-line diff (just the asset filename). Both flavours compile clean.

The trade-off is the prompt losing its launch context and being shown to people who never touch voice chat. If you'd prefer it elsewhere, happy to move it again.

One caveat: I couldn't reproduce the crash directly — that needs an install under the real app.gamenative ID — so the above is reasoned from the code paths, not a captured stack trace. If you did get one, I'd like to see it in case it points somewhere I haven't looked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants