Partial reads from the capture ring consumed samples that were then discarded when the send loop retried from buf[0]. For 20ms codecs this was invisible (single Oboe burst fills 960 samples in one read), but 40ms codecs (Opus6k, 1920 samples) needed 2 bursts — the first partial read consumed 960 real samples and threw them away. Result: Opus6k produced ~11 frames/s instead of 25 (~44% of expected). Fix: expose wzp_native_audio_capture_available() and check it before reading, matching the desktop capture_ring.available() pattern. Partial reads no longer occur because we only read when enough samples exist. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
157 lines
7.1 KiB
Rust
157 lines
7.1 KiB
Rust
//! Runtime binding to the standalone `wzp-native` cdylib.
|
|
//!
|
|
//! See `docs/incident-tauri-android-init-tcb.md` and the top of
|
|
//! `crates/wzp-native/src/lib.rs` for the full story on why this split
|
|
//! exists. Short version: Tauri's desktop cdylib cannot have any C++
|
|
//! compiled into it (via cc::Build) without landing in rust-lang/rust#104707's
|
|
//! staticlib symbol leak, which makes bionic's private `pthread_create`
|
|
//! symbols bind locally and SIGSEGV in `__init_tcb+4` at launch. So all
|
|
//! the Oboe + audio code lives in a standalone `wzp-native` .so built
|
|
//! with `cargo-ndk`, and we dlopen it here at runtime.
|
|
//!
|
|
//! The Library handle lives in a `'static` `OnceLock` for the lifetime of
|
|
//! the process; all function pointers cached below borrow from it safely.
|
|
|
|
#![cfg(target_os = "android")]
|
|
|
|
use std::sync::OnceLock;
|
|
|
|
// ─── Library handle (kept alive forever) ─────────────────────────────────
|
|
|
|
static LIB: OnceLock<libloading::Library> = OnceLock::new();
|
|
|
|
// Cached function pointers, resolved once at init(). Each is a raw
|
|
// `extern "C"` fn pointer with effectively `'static` lifetime because
|
|
// LIB is a OnceLock that never drops.
|
|
static VERSION: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
|
|
static HELLO: OnceLock<unsafe extern "C" fn(*mut u8, usize) -> usize> = OnceLock::new();
|
|
static AUDIO_START: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
|
|
static AUDIO_START_BT: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
|
|
static AUDIO_STOP: OnceLock<unsafe extern "C" fn()> = OnceLock::new();
|
|
static AUDIO_CAPTURE_AVAILABLE: OnceLock<extern "C" fn() -> usize> = OnceLock::new();
|
|
static AUDIO_READ_CAPTURE: OnceLock<unsafe extern "C" fn(*mut i16, usize) -> usize> = OnceLock::new();
|
|
static AUDIO_WRITE_PLAYOUT: OnceLock<unsafe extern "C" fn(*const i16, usize) -> usize> = OnceLock::new();
|
|
static AUDIO_IS_RUNNING: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
|
|
static AUDIO_CAPTURE_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new();
|
|
static AUDIO_PLAYOUT_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new();
|
|
|
|
/// Load `libwzp_native.so` and resolve every exported function we use.
|
|
/// Call this once at app startup (from the Tauri `setup()` callback).
|
|
/// Subsequent calls are no-ops.
|
|
pub fn init() -> Result<(), String> {
|
|
if LIB.get().is_some() {
|
|
return Ok(());
|
|
}
|
|
|
|
// Open the sibling cdylib. The Android dynamic linker searches
|
|
// /data/app/<pkg>/lib/arm64/ which gradle populates from jniLibs.
|
|
let lib = unsafe { libloading::Library::new("libwzp_native.so") }
|
|
.map_err(|e| format!("dlopen libwzp_native.so: {e}"))?;
|
|
|
|
// Stash the Library into the OnceLock first so all Symbol lookups
|
|
// below borrow from the 'static reference rather than a local.
|
|
LIB.set(lib).map_err(|_| "wzp_native::LIB already set")?;
|
|
let lib_ref: &'static libloading::Library = LIB.get().unwrap();
|
|
|
|
unsafe {
|
|
macro_rules! resolve {
|
|
($cell:expr, $ty:ty, $name:expr) => {{
|
|
let sym: libloading::Symbol<$ty> = lib_ref.get($name)
|
|
.map_err(|e| format!("dlsym {}: {e}", core::str::from_utf8($name).unwrap_or("?")))?;
|
|
// Dereference the Symbol to extract the raw fn pointer;
|
|
// it stays valid because lib_ref is 'static.
|
|
$cell.set(*sym).map_err(|_| format!("{} already set", core::str::from_utf8($name).unwrap_or("?")))?;
|
|
}};
|
|
}
|
|
|
|
resolve!(VERSION, unsafe extern "C" fn() -> i32, b"wzp_native_version");
|
|
resolve!(HELLO, unsafe extern "C" fn(*mut u8, usize) -> usize, b"wzp_native_hello");
|
|
resolve!(AUDIO_START, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start");
|
|
resolve!(AUDIO_START_BT, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start_bt");
|
|
resolve!(AUDIO_STOP, unsafe extern "C" fn(), b"wzp_native_audio_stop");
|
|
resolve!(AUDIO_CAPTURE_AVAILABLE, extern "C" fn() -> usize, b"wzp_native_audio_capture_available");
|
|
resolve!(AUDIO_READ_CAPTURE, unsafe extern "C" fn(*mut i16, usize) -> usize, b"wzp_native_audio_read_capture");
|
|
resolve!(AUDIO_WRITE_PLAYOUT, unsafe extern "C" fn(*const i16, usize) -> usize, b"wzp_native_audio_write_playout");
|
|
resolve!(AUDIO_IS_RUNNING, unsafe extern "C" fn() -> i32, b"wzp_native_audio_is_running");
|
|
resolve!(AUDIO_CAPTURE_LATENCY, unsafe extern "C" fn() -> f32, b"wzp_native_audio_capture_latency_ms");
|
|
resolve!(AUDIO_PLAYOUT_LATENCY, unsafe extern "C" fn() -> f32, b"wzp_native_audio_playout_latency_ms");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Is `init()` done and all symbols cached?
|
|
pub fn is_loaded() -> bool {
|
|
AUDIO_START.get().is_some()
|
|
}
|
|
|
|
// ─── Smoke-test accessors ────────────────────────────────────────────────
|
|
|
|
pub fn version() -> i32 {
|
|
VERSION.get().map(|f| unsafe { f() }).unwrap_or(-1)
|
|
}
|
|
|
|
pub fn hello() -> String {
|
|
let Some(f) = HELLO.get() else { return String::new(); };
|
|
let mut buf = [0u8; 64];
|
|
let n = unsafe { f(buf.as_mut_ptr(), buf.len()) };
|
|
String::from_utf8_lossy(&buf[..n]).into_owned()
|
|
}
|
|
|
|
// ─── Audio accessors ─────────────────────────────────────────────────────
|
|
|
|
/// Start the Oboe capture + playout streams. Returns `Err(code)` on
|
|
/// failure. Idempotent on the wzp-native side.
|
|
pub fn audio_start() -> Result<(), i32> {
|
|
let f = AUDIO_START.get().ok_or(-100_i32)?;
|
|
let ret = unsafe { f() };
|
|
if ret == 0 { Ok(()) } else { Err(ret) }
|
|
}
|
|
|
|
/// Start Oboe in Bluetooth SCO mode — capture skips sample rate and
|
|
/// input preset so the system routes to the BT SCO device natively.
|
|
pub fn audio_start_bt() -> Result<(), i32> {
|
|
let f = AUDIO_START_BT.get().ok_or(-100_i32)?;
|
|
let ret = unsafe { f() };
|
|
if ret == 0 { Ok(()) } else { Err(ret) }
|
|
}
|
|
|
|
/// Stop both streams. Safe to call even if not running.
|
|
pub fn audio_stop() {
|
|
if let Some(f) = AUDIO_STOP.get() {
|
|
unsafe { f() };
|
|
}
|
|
}
|
|
|
|
/// Number of capture samples available to read without blocking.
|
|
pub fn audio_capture_available() -> usize {
|
|
let Some(f) = AUDIO_CAPTURE_AVAILABLE.get() else { return 0; };
|
|
f()
|
|
}
|
|
|
|
/// Read captured i16 PCM into `out`. Returns bytes actually copied.
|
|
pub fn audio_read_capture(out: &mut [i16]) -> usize {
|
|
let Some(f) = AUDIO_READ_CAPTURE.get() else { return 0; };
|
|
unsafe { f(out.as_mut_ptr(), out.len()) }
|
|
}
|
|
|
|
/// Write i16 PCM into the playout ring. Returns samples enqueued.
|
|
pub fn audio_write_playout(input: &[i16]) -> usize {
|
|
let Some(f) = AUDIO_WRITE_PLAYOUT.get() else { return 0; };
|
|
unsafe { f(input.as_ptr(), input.len()) }
|
|
}
|
|
|
|
pub fn audio_is_running() -> bool {
|
|
AUDIO_IS_RUNNING.get().map(|f| unsafe { f() } != 0).unwrap_or(false)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn audio_capture_latency_ms() -> f32 {
|
|
AUDIO_CAPTURE_LATENCY.get().map(|f| unsafe { f() }).unwrap_or(0.0)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn audio_playout_latency_ms() -> f32 {
|
|
AUDIO_PLAYOUT_LATENCY.get().map(|f| unsafe { f() }).unwrap_or(0.0)
|
|
}
|