fix(android): give the renderer its own USB connection, and add a real-world self test
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m17s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m3s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 4m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m21s
ci / rust (pull_request) Successful in 13m11s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m17s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m3s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 4m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m21s
ci / rust (pull_request) Successful in 13m11s
**The bug.** The renderer was handed `HidUsbLink`'s file descriptor. That link's own comment states the hazard exactly — "only one thread may drive a connection's UsbRequests (requestWait() returns ANY completed request; a second waiter would steal the reader's completions)" — and it is just as true of the usbfs reap underneath: the isochronous ring and the HID reader were reaping each other's URB completions. The standalone harness works because it owns its descriptor by construction, which is precisely why it could never have caught this. `DsCapture` now opens a dedicated connection via `openAuxConnection()` and closes it only after the render thread is joined. **The test.** Nothing exercised the CLIENT path without a host, so the two things most likely to be wrong were invisible: whether the descriptor handed over is exclusively ours, and whether the claim succeeds on this kernel. Neither is unit-testable and a harness proves neither. `nativePadAudioSelfTest` drives the voice coils with a tone through the real path — the same aux connection, claim, sink and write loop the renderer uses — and is triggered by `adb shell setprop debug.punktfunk.pad_audio_selftest 3`, matching this repo's existing debug.punktfunk.* convention. It runs INSTEAD of the renderer for that capture, never alongside it: two engines on one descriptor is the fault being tested for, and I nearly shipped it into the test itself. Underruns are deliberately not a failure condition — that is producer pacing. The pass condition is data reaching the bus.
This commit is contained in:
@@ -280,6 +280,106 @@ mod sink {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the self test ------------------------------------------------------------------------------
|
||||
|
||||
/// Drive the pad directly with a synthetic tone, through **the real client path**.
|
||||
///
|
||||
/// This exists because the two things most likely to be wrong here cannot be unit-tested and are
|
||||
/// invisible without a host: whether the descriptor Kotlin handed over is one this renderer may
|
||||
/// drive exclusively, and whether the interface claim succeeds on this kernel. A standalone
|
||||
/// harness proves neither — it owns its descriptor by construction, which is exactly the condition
|
||||
/// that was violated when this renderer was handed the HID link's fd and the two engines began
|
||||
/// stealing each other's URB completions.
|
||||
///
|
||||
/// Opens the sink the same way [`render`] does and writes a sine into the voice-coil pair, which
|
||||
/// is felt rather than heard. Returns sample frames written, or a negative [`SelfTest`] code.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `fd` must be a live usbfs descriptor whose connection outlives the call, and which **nothing
|
||||
/// else is driving transfers on**.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) unsafe fn self_test(fd: i32, seconds: i32, hz: i32) -> i32 {
|
||||
// SAFETY: the caller's contract.
|
||||
let dev = unsafe { sink::device(fd) };
|
||||
let mut playback = match sink::open(&dev) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::warn!("pad audio self-test: could not open the stream: {e}");
|
||||
return SelfTest::OPEN_FAILED;
|
||||
}
|
||||
};
|
||||
log::info!(
|
||||
"pad audio self-test: {} ch {} at {} Hz, {} us in flight",
|
||||
playback.channels(),
|
||||
playback.format(),
|
||||
playback.rate(),
|
||||
playback.schedule().in_flight_us()
|
||||
);
|
||||
|
||||
let rate = playback.rate();
|
||||
let channels = playback.channels() as usize;
|
||||
let frames_per_chunk = (rate as usize / 1000).max(1);
|
||||
let mut chunk = vec![0i16; frames_per_chunk * channels];
|
||||
let mut phase = 0.0f32;
|
||||
let step = std::f32::consts::TAU * hz.clamp(20, 500) as f32 / rate as f32;
|
||||
let total = u64::from(rate) * seconds.clamp(1, 30) as u64;
|
||||
let mut written = 0u64;
|
||||
|
||||
while written < total {
|
||||
for frame in chunk.chunks_mut(channels) {
|
||||
let sample = (phase.sin() * 16_384.0) as i16;
|
||||
phase += step;
|
||||
if phase >= std::f32::consts::TAU {
|
||||
phase -= std::f32::consts::TAU;
|
||||
}
|
||||
frame.fill(0);
|
||||
// Channels 2 and 3 are the voice coils; the speaker pair stays silent so a pass is
|
||||
// unambiguously FELT rather than merely audible.
|
||||
for c in 2..channels {
|
||||
frame[c] = sample;
|
||||
}
|
||||
}
|
||||
if let Err(e) = playback.write_interleaved(&chunk) {
|
||||
log::warn!("pad audio self-test: write failed after {written} frames: {e}");
|
||||
return SelfTest::WRITE_FAILED;
|
||||
}
|
||||
written += frames_per_chunk as u64;
|
||||
}
|
||||
let _ = playback.drain(Duration::from_millis(500));
|
||||
|
||||
let stats = playback.stats();
|
||||
log::info!(
|
||||
"pad audio self-test: {} frames, {} urbs, {} underruns, {} short bytes, {} urb errors",
|
||||
playback.frames_written(),
|
||||
stats.urbs_completed,
|
||||
stats.underruns,
|
||||
stats.short_bytes,
|
||||
stats.urb_errors
|
||||
);
|
||||
// Underruns are a producer-pacing property and deliberately NOT a failure here: the question
|
||||
// this answers is whether the client can drive the pad at all. Data reaching the bus is the
|
||||
// pass condition.
|
||||
if stats.urb_errors > 0 || playback.frames_written() == 0 {
|
||||
return SelfTest::NO_DATA;
|
||||
}
|
||||
playback.frames_written().min(i32::MAX as u64) as i32
|
||||
}
|
||||
|
||||
/// Negative results from [`self_test`]. Positive values are sample frames written.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) struct SelfTest;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl SelfTest {
|
||||
/// The claim or stream open failed — the OEM-kernel case, or a descriptor another engine owns.
|
||||
pub(crate) const OPEN_FAILED: i32 = -1;
|
||||
/// The stream opened but a write failed part-way.
|
||||
pub(crate) const WRITE_FAILED: i32 = -2;
|
||||
/// It ran, but nothing reached the bus.
|
||||
pub(crate) const NO_DATA: i32 = -3;
|
||||
}
|
||||
|
||||
// ---- the renderer worker -----------------------------------------------------------------------
|
||||
|
||||
/// A running renderer: the stop flag and the thread, joined on drop.
|
||||
|
||||
@@ -512,6 +512,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
|
||||
/// tone through the real client render path, with no host and no session involved.
|
||||
///
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
fd: jni::sys::jint,
|
||||
seconds: jni::sys::jint,
|
||||
hz: jni::sys::jint,
|
||||
) -> jni::sys::jint {
|
||||
jni_guard(-1, || {
|
||||
if fd < 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
|
||||
// other transfers on it (it opens a dedicated connection for exactly this).
|
||||
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — 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
|
||||
|
||||
Reference in New Issue
Block a user