feat(android): tier-A pad audio — the 0xD1 plane on the pad's USB endpoint (WP9)

The Android twin of `pf-client-core`'s pad_audio: drain the host's per-pad
DualSense streams, Opus-decode haptics (kind 0) and speaker (kind 1), interleave
into the pad's own 4-channel layout, and render on the pad itself.

Every other client hands that stream to the platform's audio graph. Android
cannot: AOSP's UsbAlsaManager denylists the DualSense's output by VID/PID, so
the kernel enumerates the pad's playback node and the framework discards it —
`hasOutput: false`, nothing for setPreferredDevice to target, /dev/snd closed by
SELinux, and UsbRequest rejects non-bulk/interrupt endpoints. So this drives the
pad's isochronous endpoint directly via uac-host on the descriptor Java owns.

That is measured, not assumed. On a Nothing Phone (3): the claim succeeds
unprivileged, the gamepad and the pad's microphone both keep working, and the
underrun-free floor is 4 ms — holding under eight-core load with the SoC in
severe thermal throttling. The renderer runs at 6 ms, one step of headroom,
because the same measurement found transient events that are not depth-dependent.

Structured to the crate's own convention: the mixer and PLC are ungated so they
compile and unit-test in the host workspace (8 tests), while everything touching
an Android-only dependency is cfg'd to android. Two details worth review:

- The kinds arrive on different cadences (5 ms vs 10 ms), so each has its own
  write cursor and both shift together on overflow — a haptics-only session
  renders with a silent speaker pair instead of stalling on a kind that will
  never arrive, and the two can never skew.
- An unrecognised kind is dropped rather than folded into the coil pair. A
  `min(1)` clamp would have rendered a future kind straight into the actuators.

Lifecycle mirrors MicCapture: dropping the handle joins the thread, and
nativeStopPadAudio returns only once it has, so Kotlin may close the
UsbDeviceConnection as soon as it returns and not before.

usbfs-iso/uac-host enter as git dependencies pinned by revision — a transport
under a real-time deadline should move when we choose. They become version
dependencies once published to crates.io.
This commit is contained in:
enricobuehler
2026-08-02 23:39:25 +02:00
parent ed3d236ab8
commit b5f91d50bb
7 changed files with 703 additions and 0 deletions
Generated
+18
View File
@@ -3347,6 +3347,8 @@ dependencies = [
"opus",
"punktfunk-core",
"tracing",
"uac-host",
"usbfs-iso",
]
[[package]]
@@ -4986,6 +4988,14 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uac-host"
version = "0.1.0"
source = "git+https://github.com/unom-io/usbfs-iso?rev=fb01ea69c59e3bf08b3918f53a159287b5187ed2#fb01ea69c59e3bf08b3918f53a159287b5187ed2"
dependencies = [
"usbfs-iso",
]
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -5065,6 +5075,14 @@ dependencies = [
"serde",
]
[[package]]
name = "usbfs-iso"
version = "0.1.0"
source = "git+https://github.com/unom-io/usbfs-iso?rev=fb01ea69c59e3bf08b3918f53a159287b5187ed2#fb01ea69c59e3bf08b3918f53a159287b5187ed2"
dependencies = [
"libc",
]
[[package]]
name = "usbip-sim"
version = "0.8.0"
+8
View File
@@ -64,6 +64,14 @@ libc = "0.2"
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
opus = "0.3"
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
# should move when we choose to. Becomes a plain version dependency once the crates are published.
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "fb01ea69c59e3bf08b3918f53a159287b5187ed2" }
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "fb01ea69c59e3bf08b3918f53a159287b5187ed2" }
[lints]
workspace = true
+2
View File
@@ -37,6 +37,8 @@ mod discovery;
mod feedback;
#[cfg(target_os = "android")]
mod mic;
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
mod pad_audio;
mod session;
mod stats;
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
+592
View File
@@ -0,0 +1,592 @@
//! Pad audio on Android (the 0xD1 plane) — tier A, WP9.
//!
//! The Android twin of [`pf_client_core::pad_audio`]: drain the host's per-pad DualSense streams,
//! Opus-decode haptics (kind 0) and speaker (kind 1), interleave them into the pad's own
//! 4-channel layout, and render them on the physical pad.
//!
//! # Why this needs a USB driver instead of an audio API
//!
//! Every other client hands the 4-channel stream to the platform's audio graph — WASAPI on
//! Windows, PipeWire on Linux, CoreAudio on Apple. **Android has no such option for this device.**
//! AOSP's `UsbAlsaManager` carries a hardcoded VID/PID denylist that includes the DualSense
//! (`054c:0ce6`), so the kernel enumerates the pad's playback node and the framework then discards
//! it: `hasOutput: false`. There is no `AudioDeviceInfo` for `setPreferredDevice` to target, and
//! `/dev/snd` is closed to apps by SELinux. Android's own `UsbRequest` API cannot help either — it
//! rejects any endpoint that is not bulk or interrupt.
//!
//! So this path drives the pad's isochronous endpoint directly, through `uac-host` on the file
//! descriptor Java already owns. That is measured, not hoped: on a Nothing Phone (3) the claim
//! succeeds unprivileged, the gamepad and the pad's microphone both keep working, and the
//! underrun-free floor is **4 ms in flight** — including under eight-core load with the SoC in
//! severe thermal throttling.
//!
//! # The firmware exclusivity that shapes everything here
//!
//! `valid_flag0` bit 1 (`HAPTICS_SELECT`) *disables* audio haptics and selects classic rumble, and
//! Linux's `hid-playstation` sets it on every force-feedback update — as does SDL, and as does our
//! own [`crate::feedback`] path. **Tier A and tier C are mutually exclusive in the pad's firmware**,
//! so a pad rendering this stream must have its wire rumble suppressed rather than mixed. The
//! arbitration is a selection, never a blend.
use std::collections::VecDeque;
use punktfunk_core::audio::AudioGapTracker;
use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER};
#[cfg(target_os = "android")]
use punktfunk_core::client::NativeClient;
#[cfg(target_os = "android")]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(target_os = "android")]
use std::sync::Arc;
#[cfg(target_os = "android")]
use std::thread::JoinHandle;
#[cfg(target_os = "android")]
use std::time::Duration;
/// The pad's render layout: 4 interleaved channels — speaker FL/FR on 0/1, the voice coils on
/// 2/3. Feeding a 2-channel stream would leave the coils silent rather than fail, which is the
/// failure mode most worth not having.
const PAD_CHANNELS: usize = 4;
/// Both plane kinds decode as 48 kHz stereo.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
const SAMPLE_RATE: u32 = 48_000;
/// Ring ceiling, in sample frames. 60 ms — far above the in-flight depth, because this bounds
/// *decoder* backlog when the USB side stalls, not stream latency. Overflow drops the oldest.
const MAX_BUFFER_FRAMES: usize = (SAMPLE_RATE as usize / 1000) * 60;
/// Largest Opus frame this decodes in one call: 120 ms at 48 kHz, the codec's maximum.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
const MAX_FRAME_SAMPLES: usize = 5760;
/// How much audio to keep in flight on the USB endpoint.
///
/// WP7 measured the underrun-free floor on real hardware at **4 ms** (clean across three sweeps,
/// including one under eight-core load with the CPU thermally throttled); 3 ms was marginal and
/// 2 ms never survived. 6 ms takes one step of headroom above that floor, because the same
/// measurement found isolated transient events roughly once per three seconds that are *not*
/// depth-dependent — so the floor is a floor, not a target.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
const IN_FLIGHT_MS: u32 = 6;
// ---- the 4-channel mixer ---------------------------------------------------------------------
/// Interleave the two independent stereo streams into one 4-channel frame stream.
///
/// The kinds arrive on different cadences (haptics 5 ms, speaker 10 ms), so each has its own
/// write cursor and [`pop`](Self::pop) emits everything the further-ahead kind has filled, with
/// the lagging or absent kind's pair reading silence. A haptics-only session therefore renders
/// the coils with a silent speaker pair, and vice versa, instead of stalling on the missing kind.
///
/// Samples are `i16` — the DualSense's own wire format — so nothing converts on the hot path.
/// Pure logic, unit-tested below; pacing lives in the USB ring downstream.
pub(crate) struct QuadMixer {
/// Interleaved 4-channel samples; the front is the next frame out. Always
/// `ready_frames() * PAD_CHANNELS` long.
ring: VecDeque<i16>,
/// Per-kind write cursor in FRAMES relative to the ring front, indexed by the wire `kind`.
written: [usize; 2],
/// Frames dropped to the ceiling — a stalled USB side, visible in the logs.
dropped: u64,
}
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
impl QuadMixer {
pub(crate) fn new() -> QuadMixer {
QuadMixer {
ring: VecDeque::new(),
written: [0; 2],
dropped: 0,
}
}
/// Write one decoded stereo chunk (interleaved L/R) for `kind` at that kind's cursor,
/// zero-extending as needed. Both cursors shift together on overflow, so the two kinds can
/// never skew relative to one another.
pub(crate) fn push(&mut self, kind: u8, stereo: &[i16]) {
// Name both kinds rather than defaulting: a kind this build does not know belongs
// nowhere in a 4-channel frame, and quietly folding it into the coil pair would render
// an unknown stream straight into the actuators.
let (k, off) = match kind {
PAD_AUDIO_KIND_HAPTICS => (0usize, 2usize),
PAD_AUDIO_KIND_SPEAKER => (1usize, 0usize),
_ => return,
};
let frames = stereo.len() / 2;
let base = self.written[k];
let need = (base + frames) * PAD_CHANNELS;
if self.ring.len() < need {
self.ring.resize(need, 0);
}
for (i, fr) in stereo.chunks_exact(2).enumerate() {
let at = (base + i) * PAD_CHANNELS + off;
self.ring[at] = fr[0];
self.ring[at + 1] = fr[1];
}
self.written[k] = base + frames;
let over = self.ready_frames().saturating_sub(MAX_BUFFER_FRAMES);
if over > 0 {
self.dropped += over as u64;
self.drop_front(over);
}
}
/// Frames ready to output: the further-ahead kind's cursor.
pub(crate) fn ready_frames(&self) -> usize {
self.written[0].max(self.written[1])
}
/// Frames discarded to the ceiling since construction.
pub(crate) fn dropped_frames(&self) -> u64 {
self.dropped
}
/// Append every ready frame (interleaved 4-channel) to `out`; returns the frame count.
pub(crate) fn pop(&mut self, out: &mut Vec<i16>) -> usize {
let frames = self.ready_frames();
let n = frames * PAD_CHANNELS;
out.extend(self.ring.drain(..n.min(self.ring.len())));
for w in &mut self.written {
*w = w.saturating_sub(frames);
}
frames
}
/// Throw the ready frames away — no sink to render them on right now.
pub(crate) fn discard(&mut self) {
let f = self.ready_frames();
self.drop_front(f);
}
fn drop_front(&mut self, frames: usize) {
let n = (frames * PAD_CHANNELS).min(self.ring.len());
self.ring.drain(..n);
let f = n / PAD_CHANNELS;
for w in &mut self.written {
*w = w.saturating_sub(f);
}
}
}
// ---- decode + packet loss concealment ---------------------------------------------------------
#[cfg(target_os = "android")]
/// Per-kind decode state: a stereo 48 kHz Opus decoder, the seq-gap tracker, and the last decoded
/// frame size, which is the unit PLC synthesises in.
struct KindStream {
dec: opus::Decoder,
gaps: AudioGapTracker,
frame_samples: usize,
}
/// Concealment frames to synthesise before decoding `seq`.
///
/// Zero until something has decoded, because there is nothing to size the PLC from yet. The
/// tracker is fed regardless, so a gap seen before the first real frame cannot resurface later as
/// a phantom. Pure, and unit-tested.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
fn plc_frames(gaps: &mut AudioGapTracker, seq: u32, frame_samples: usize) -> u32 {
let missing = gaps.missing_before(seq);
if frame_samples == 0 {
0
} else {
missing
}
}
// ---- the USB sink ------------------------------------------------------------------------------
/// Everything that talks to the pad. Linux and Android only: `usbfs` is a Linux kernel ABI, and
/// this crate also builds as a host cdylib on macOS dev boxes, where the mixer and PLC above still
/// compile and still run their tests.
#[cfg(target_os = "android")]
mod sink {
use super::{IN_FLIGHT_MS, PAD_CHANNELS, SAMPLE_RATE};
/// Open the pad's 4-channel playback stream on a descriptor Java owns.
///
/// # Safety
///
/// `fd` must be a live usbfs descriptor from an open `UsbDeviceConnection` that outlives the
/// returned device — this **borrows** it and never closes it, because closing is
/// `UsbDeviceConnection.close()`'s job and a double close would strand an unrelated
/// descriptor much later.
pub(super) unsafe fn device(fd: i32) -> usbfs_iso::UsbFsDevice {
// SAFETY: forwarded from this function's own contract, which the JNI entry point upholds
// by keeping the Java connection open for the lifetime of the renderer thread.
unsafe { usbfs_iso::UsbFsDevice::from_borrowed_fd(fd) }
}
/// Find the pad's 4-channel playback stream and open it.
///
/// Four channels is a hard requirement, not a preference: the voice coils *are* channels 3
/// and 4, so a 2-channel alternate setting would open successfully and then render haptics
/// into nothing.
pub(super) fn open<'d>(
dev: &'d usbfs_iso::UsbFsDevice,
) -> Result<uac_host::Playback<'d>, uac_host::Error> {
let blob = dev.raw_descriptors()?;
let function = uac_host::parse(&blob)?;
let stream = function
.output_streams()
.find(|s| usize::from(s.channels()) == PAD_CHANNELS)
.ok_or(uac_host::Error::NoAudioFunction)?;
let opts = uac_host::OpenOptions {
depth: usbfs_iso::Depth::Millis(IN_FLIGHT_MS),
// One packet per URB: the finest granularity the bus offers, and what WP7 measured
// the 4 ms floor with. Packing more multiplies one completion's latency.
packets_per_urb: Some(1),
// Keep the endpoint fed rather than gapping when the decoder is momentarily late.
// A hole in an isochronous stream is silence forever; silence we chose is better.
underrun: usbfs_iso::Underrun::FillSilence,
..Default::default()
};
stream.open_with(dev, uac_host::Format::S16Le, SAMPLE_RATE, opts)
}
}
// ---- the renderer worker -----------------------------------------------------------------------
/// A running renderer: the stop flag and the thread, joined on drop.
///
/// Mirrors [`crate::mic::MicCapture`]'s discipline — dropping the handle is what stops the stream,
/// so a session teardown that forgets a step cannot leave a thread writing to a descriptor Java is
/// about to close.
#[cfg(target_os = "android")]
pub(crate) struct PadAudio {
stop: Arc<AtomicBool>,
join: Option<JoinHandle<()>>,
}
#[cfg(target_os = "android")]
impl Drop for PadAudio {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(j) = self.join.take() {
let _ = j.join();
}
}
}
/// Start the renderer for a pad whose descriptor Java has handed over.
///
/// Returns `None` when neither kind is enabled (nothing to render) or the thread will not start.
/// **The caller must keep the `UsbDeviceConnection` open until the returned handle is dropped** —
/// the renderer borrows the descriptor and never closes it.
#[cfg(target_os = "android")]
pub(crate) fn start(
client: Arc<NativeClient>,
fd: i32,
haptics: bool,
speaker: bool,
) -> Option<PadAudio> {
if !haptics && !speaker {
return None;
}
let stop = Arc::new(AtomicBool::new(false));
let join = spawn(client, Arc::clone(&stop), fd, haptics, speaker)?;
Some(PadAudio {
stop,
join: Some(join),
})
}
/// Spawn the pad-audio renderer — the 0xD1 plane's single consumer on Android.
///
/// `fd` is the pad's usbfs descriptor from `UsbDeviceConnection.getFileDescriptor()`; the caller
/// **must** keep that connection open until [`stop`](AtomicBool) has been observed and the handle
/// joined. Returns `None` if the thread could not be started.
#[cfg(target_os = "android")]
pub(crate) fn spawn(
client: Arc<NativeClient>,
stop: Arc<AtomicBool>,
fd: i32,
haptics: bool,
speaker: bool,
) -> Option<JoinHandle<()>> {
std::thread::Builder::new()
.name("pf-pad-audio".into())
.spawn(move || run(&client, &stop, fd, haptics, speaker))
.map_err(|e| log::warn!("pad-audio thread failed to start: {e}"))
.ok()
}
#[cfg(target_os = "android")]
fn run(client: &NativeClient, stop: &AtomicBool, fd: i32, haptics: bool, speaker: bool) {
// Ask the scheduler for audio priority. Android does not hand SCHED_FIFO to ordinary app
// threads, so -16 (ANDROID_PRIORITY_AUDIO) is the realistic knob — and WP7 measured that it
// both applies and is enough to hold the 4 ms floor against eight busy cores.
// SAFETY: `setpriority` on the calling thread; no pointers, no shared state.
unsafe {
libc::setpriority(libc::PRIO_PROCESS, 0, -16);
}
// SAFETY: the caller's contract — the Java connection outlives this thread.
let dev = unsafe { sink::device(fd) };
// Through a reference, deliberately: `UsbFsDevice` has a `Drop`, and opening the stream in
// this same scope would make the borrow outlive the value it borrows.
render(&dev, client, stop, haptics, speaker);
}
/// Open the pad's stream and render on it until the session stops or the device goes away.
#[cfg(target_os = "android")]
fn render(
dev: &usbfs_iso::UsbFsDevice,
client: &NativeClient,
stop: &AtomicBool,
haptics: bool,
speaker: bool,
) {
match sink::open(dev) {
Ok(mut playback) => {
log::info!(
"pad audio: {} ch {} at {} Hz, {} us in flight",
playback.channels(),
playback.format(),
playback.rate(),
playback.schedule().in_flight_us()
);
pump(client, stop, haptics, speaker, &mut playback);
}
Err(e) => {
// The interesting failure is a kernel that refuses the claim: some OEM kernels do, and
// there is no app-side fix, so the session carries on without tier A rather than
// treating it as fatal.
log::warn!("pad audio unavailable, falling back: {e}");
drain_until_stop(client, stop);
}
}
}
#[cfg(target_os = "android")]
/// Keep the plane drained without rendering, so a host that is sending 0xD1 does not back up
/// against a consumer that never reads.
fn drain_until_stop(client: &NativeClient, stop: &AtomicBool) {
while !stop.load(Ordering::Relaxed) {
if client.next_pad_audio(Duration::from_millis(20)).is_none()
&& stop.load(Ordering::Relaxed)
{
return;
}
}
}
/// The steady state: decode arriving frames, interleave, and hand whole frames to the pad.
#[cfg(target_os = "android")]
fn pump(
client: &NativeClient,
stop: &AtomicBool,
haptics: bool,
speaker: bool,
playback: &mut uac_host::Playback<'_>,
) {
let mut mixer = QuadMixer::new();
let mut streams: [Option<KindStream>; 2] = [None, None];
let mut pcm: Vec<i16> = Vec::with_capacity(MAX_FRAME_SAMPLES * 2);
let mut out: Vec<i16> = Vec::with_capacity(MAX_BUFFER_FRAMES * PAD_CHANNELS);
while !stop.load(Ordering::Relaxed) {
let Some(frame) = client.next_pad_audio(Duration::from_millis(10)) else {
continue;
};
// The settings gate each kind independently: haptics off but speaker on is a legitimate
// configuration, and the host may still be sending both.
let wanted = match frame.kind {
PAD_AUDIO_KIND_HAPTICS => haptics,
PAD_AUDIO_KIND_SPEAKER => speaker,
_ => false,
};
if !wanted {
continue;
}
let k = usize::from(frame.kind).min(1);
let st = match &mut streams[k] {
Some(s) => s,
slot @ None => match opus::Decoder::new(SAMPLE_RATE, opus::Channels::Stereo) {
Ok(dec) => slot.insert(KindStream {
dec,
gaps: AudioGapTracker::default(),
frame_samples: 0,
}),
Err(e) => {
log::warn!("pad audio: no Opus decoder for kind {}: {e}", frame.kind);
continue;
}
},
};
// Conceal whatever the sequence numbers say is missing, before decoding what arrived.
let missing = plc_frames(&mut st.gaps, frame.seq, st.frame_samples);
for _ in 0..missing {
pcm.resize(st.frame_samples * 2, 0);
match st.dec.decode(&[], &mut pcm, false) {
Ok(n) => mixer.push(frame.kind, &pcm[..n * 2]),
Err(_) => break,
}
}
// An empty payload is DTX silence: the tracker has already accounted for the sequence,
// and there is nothing to decode.
if !frame.opus.is_empty() {
pcm.resize(MAX_FRAME_SAMPLES * 2, 0);
match st.dec.decode(&frame.opus, &mut pcm, false) {
Ok(n) => {
st.frame_samples = n;
mixer.push(frame.kind, &pcm[..n * 2]);
}
Err(e) => log::debug!("pad audio: opus decode failed: {e}"),
}
}
// Hand over whole frames only. `write` stages any remainder internally, so a partial
// chunk is never padded with silence mid-stream.
out.clear();
if mixer.pop(&mut out) > 0 {
if let Err(e) = playback.write_interleaved(&out) {
if is_fatal(&e) {
log::warn!("pad audio: stream lost: {e}");
return;
}
log::debug!("pad audio: write hiccup: {e}");
mixer.discard();
}
}
}
let _ = playback.drain(Duration::from_millis(100));
let stats = playback.stats();
log::info!(
"pad audio stopped: {} frames, {} underruns, {} short bytes, {} dropped by backlog",
playback.frames_written(),
stats.underruns,
stats.short_bytes,
mixer.dropped_frames(),
);
}
/// Is this the end of the stream, or just a bad moment?
///
/// A vanished device is unrecoverable here — the descriptor belongs to a `UsbDeviceConnection`
/// that Java must re-open — so the thread exits and the session continues without tier A. Anything
/// else is treated as transient.
#[cfg(target_os = "android")]
fn is_fatal(e: &uac_host::Error) -> bool {
matches!(e, uac_host::Error::Transport(t) if t.is_disconnected())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn speaker_lands_on_the_front_pair_and_haptics_on_the_coils() {
let mut m = QuadMixer::new();
m.push(PAD_AUDIO_KIND_SPEAKER, &[100, 200]);
m.push(PAD_AUDIO_KIND_HAPTICS, &[300, 400]);
let mut out = Vec::new();
assert_eq!(m.pop(&mut out), 1);
// Channels 0/1 are the speaker, 2/3 are the voice coils — the pad's own layout.
assert_eq!(out, vec![100, 200, 300, 400]);
}
#[test]
fn a_haptics_only_session_still_renders_with_a_silent_speaker_pair() {
// The case that matters most: `pad_speaker = "off"` must not stall the coils waiting for
// a kind that will never arrive.
let mut m = QuadMixer::new();
m.push(PAD_AUDIO_KIND_HAPTICS, &[7, 8, 9, 10]);
let mut out = Vec::new();
assert_eq!(m.pop(&mut out), 2);
assert_eq!(out, vec![0, 0, 7, 8, 0, 0, 9, 10]);
}
#[test]
fn the_two_kinds_never_skew_when_the_ceiling_drops_frames() {
let mut m = QuadMixer::new();
// Push well past the ceiling on one kind, then a marker on the other. Both cursors must
// have moved together, so the marker still lands on the same output frame boundary.
let flood = vec![1i16; (MAX_BUFFER_FRAMES + 500) * 2];
m.push(PAD_AUDIO_KIND_HAPTICS, &flood);
assert!(m.dropped_frames() > 0);
assert_eq!(m.ready_frames(), MAX_BUFFER_FRAMES);
m.push(PAD_AUDIO_KIND_SPEAKER, &[42, 43]);
let mut out = Vec::new();
let frames = m.pop(&mut out);
assert_eq!(frames, MAX_BUFFER_FRAMES);
assert_eq!(out.len(), frames * PAD_CHANNELS);
// The speaker sample went to the FRONT of the ring (its cursor was reset with the drop),
// not to wherever the flooded kind happened to be.
assert_eq!(&out[..4], &[42, 43, 1, 1]);
}
#[test]
fn interleaving_survives_uneven_cadences() {
// Haptics arrive at 5 ms and the speaker at 10 ms; popping mid-flight must not lose the
// lagging kind's alignment.
let mut m = QuadMixer::new();
m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 1, 2, 2]);
m.push(PAD_AUDIO_KIND_SPEAKER, &[9, 9]);
let mut out = Vec::new();
assert_eq!(m.pop(&mut out), 2);
assert_eq!(out, vec![9, 9, 1, 1, 0, 0, 2, 2]);
// Next round: both cursors are back at zero, so a fresh speaker frame aligns with a fresh
// haptics frame rather than inheriting the previous round's offset.
out.clear();
m.push(PAD_AUDIO_KIND_SPEAKER, &[5, 5]);
m.push(PAD_AUDIO_KIND_HAPTICS, &[6, 6]);
assert_eq!(m.pop(&mut out), 1);
assert_eq!(out, vec![5, 5, 6, 6]);
}
#[test]
fn an_unknown_kind_is_dropped_rather_than_rendered_into_the_coils() {
let mut m = QuadMixer::new();
m.push(9, &[999, 999]);
assert_eq!(
m.ready_frames(),
0,
"an unknown kind must not occupy a channel pair"
);
let mut out = Vec::new();
m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 2]);
assert_eq!(m.pop(&mut out), 1);
assert_eq!(out, vec![0, 0, 1, 2]);
}
#[test]
fn discard_empties_without_disturbing_alignment() {
let mut m = QuadMixer::new();
m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 2, 3, 4]);
m.discard();
assert_eq!(m.ready_frames(), 0);
let mut out = Vec::new();
m.push(PAD_AUDIO_KIND_SPEAKER, &[8, 9]);
assert_eq!(m.pop(&mut out), 1);
assert_eq!(out, vec![8, 9, 0, 0]);
}
#[test]
fn plc_stays_silent_until_something_has_decoded() {
let mut g = AudioGapTracker::default();
// A gap before the first decode has nothing to size concealment from, and must not be
// replayed later as a phantom.
assert_eq!(plc_frames(&mut g, 5, 0), 0);
assert_eq!(plc_frames(&mut g, 6, 480), 0);
}
#[test]
fn plc_conceals_a_real_gap_once_a_frame_size_is_known() {
let mut g = AudioGapTracker::default();
assert_eq!(plc_frames(&mut g, 0, 0), 0);
assert_eq!(plc_frames(&mut g, 1, 480), 0);
// Sequence 2 and 3 never arrived.
assert_eq!(plc_frames(&mut g, 4, 480), 2);
}
}
@@ -291,6 +291,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
audio: Mutex::new(None),
#[cfg(target_os = "android")]
mic: Mutex::new(None),
#[cfg(target_os = "android")]
pad_audio: Mutex::new(None),
// A fresh session is never muted (mute is per-session UI state, not a setting).
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
+15
View File
@@ -61,6 +61,11 @@ pub(crate) struct SessionHandle {
audio: Mutex<Option<crate::audio::AudioPlayback>>,
#[cfg(target_os = "android")]
mic: Mutex<Option<crate::mic::MicCapture>>,
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
/// `Option` because a session may have no wired DualSense at all, which is the common case.
#[cfg(target_os = "android")]
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
/// for the same reason the stats gate is: the mic stops and restarts across a surface
@@ -99,6 +104,14 @@ impl SessionHandle {
fn stop_mic(&self) {
let _ = self.mic.lock().unwrap().take();
}
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
/// `UsbDeviceConnection`. Idempotent.
#[cfg(target_os = "android")]
pub(crate) fn stop_pad_audio(&self) {
let _ = self.pad_audio.lock().unwrap().take();
}
}
impl Drop for SessionHandle {
@@ -108,6 +121,8 @@ impl Drop for SessionHandle {
self.stop_audio();
#[cfg(target_os = "android")]
self.stop_mic();
#[cfg(target_os = "android")]
self.stop_pad_audio();
}
}
@@ -460,6 +460,72 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
})
}
/// `NativeBridge.nativeStartPadAudio(handle, fd, haptics, speaker): Boolean` — start tier-A
/// DualSense pad audio on a descriptor Kotlin has already obtained.
///
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
/// streaming interface. Kotlin owns that connection and **must keep it open until
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
/// closing early would pull it out from under an in-flight isochronous transfer.
///
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
/// app-side fix worth blocking a session on.
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
_env: JNIEnv,
_this: JObject,
handle: jlong,
fd: jni::sys::jint,
haptics: jboolean,
speaker: jboolean,
) -> jboolean {
jni_guard(0, || {
if handle == 0 || fd < 0 {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
// Replace any previous renderer first: dropping it joins the old thread, so two of them
// can never hold the same descriptor at once.
h.stop_pad_audio();
match crate::pad_audio::start(
std::sync::Arc::clone(&h.client),
fd,
haptics != 0,
speaker != 0,
) {
Some(p) => {
*h.pad_audio.lock().unwrap() = Some(p);
1
}
None => 0,
}
})
}
/// `NativeBridge.nativeStopPadAudio(handle)` — stop tier-A pad audio and join its thread.
///
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
/// `UsbDeviceConnection` as soon as this returns and not before.
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stop_pad_audio();
}
})
}
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
///
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung