Files
chanora/poc/audio-capture-playback-android-spike/src/lib.rs
T
EdisonJwa ec21a880d2 feat(poc/audio): add Android mobile audio spike
Closes the mobile half of the PoC plan §2 audio exit criterion
left open by poc/audio-capture-playback-spike. The desktop and
mobile halves together fully retire the audio PoC.

Stack:
  Kotlin (MainActivity) → JNI → Rust cdylib
    → cpal 0.16 → Oboe (AAudio / OpenSL ES) → Android audio HAL

Layout:
  rust crate (src/lib.rs)   — JNI_OnLoad, initContext,
                              playSine440, record1sToFile;
                              panic-catching at JNI boundary;
                              android_logger → logcat
  android/ (Gradle 8.7,     — minSdk 24, compileSdk 34, AGP 8.5.2.
   AGP 8.5.2, Kotlin 1.9.24)  cargoBuildRust task wraps cargo-ndk
                              -P 26 -t <abi> for all four ABIs;
                              wired into preBuild so AGP picks up
                              the produced .so files.

Verified on 2026-05-13 on a physical Motorola Moto G Stylus 5G
(2023), Android 14 SDK 34 arm64-v8a:
  - Playback: 500 ms 440 Hz mono sine, 22,050 frames emitted at
    44.1 kHz through cpal/Oboe/AAudio/device speaker.
  - Capture: 1 s from default input, 42,624 frames written to
    /data/data/app.chanora.poc.audio/files/chanora_poc_capture.wav.
    File pulled via 'adb exec-out run-as ... cat' and confirmed
    by file(1) as 'RIFF (little-endian) data, WAVE audio,
    Microsoft PCM, 16 bit, mono 44100 Hz'. Header bytes
    cross-checked against the reported frame count.

Notes:
  - cpal links libaaudio (introduced API 26), so cargo-ndk targets
    API 26 via -P 26 while the Android module's minSdk stays at 24
    (DEC-004). API 24/25 devices would fall back to OpenSL ES at
    runtime; not exercised here.
  - JNI panic safety: every JNI entry point wraps its body in
    std::panic::catch_unwind and a tracing panic hook routes
    panic messages to logcat under tag 'ChanoraAudioPoC'. Without
    this, cpal panicking inside an extern "system" function would
    abort the process.
  - The emulator AVD chanora-poc-api34 and its system image were
    installed during Phase 0 but emulator verification was skipped
    once the physical-device run succeeded. Real-device evidence
    is stronger.

Surfaced finding: DEC-011.1 mobile half promoted from Deferred to
Accepted for Android in the same docs commit; iOS remains
explicitly Deferred (requires macOS + Xcode hardware).

Authority: PoC plan §2, DEC-011, DEC-011.1.
Not product code; not promoted into chanora_audio.
2026-05-14 18:24:53 +08:00

444 lines
14 KiB
Rust

//! Chanora PoC — Android mobile audio spike.
//!
//! Closes the mobile half of the PoC plan §2 audio exit criterion:
//! "Capture/playback works on at least one desktop and one mobile target."
//!
//! Desktop half is closed by `poc/audio-capture-playback-spike` on Linux/PipeWire.
//! This crate is the mobile-Android half.
//!
//! Approach:
//! * `cpal` 0.16 with no extra features — on Android it automatically
//! uses the Oboe backend (AAudio + OpenSL ES fallback). Matches
//! DEC-011 "platform-native first".
//! * The Kotlin app calls two JNI entry points:
//! - Java_app_chanora_poc_audio_NativeAudio_playSine440()
//! - Java_app_chanora_poc_audio_NativeAudio_record1sToFile(path)
//! * `tracing` is not used; on Android we route `log` macros to logcat
//! via `android_logger` so all output is visible via `adb logcat`.
#![cfg(target_os = "android")]
use std::panic::{self, AssertUnwindSafe};
use std::sync::Once;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{SampleFormat, SizedSample};
use hound::{SampleFormat as HoundSampleFormat, WavSpec, WavWriter};
use jni::objects::{JClass, JString};
use jni::sys::{jint, jlong, JNI_VERSION_1_6};
use jni::JNIEnv;
use log::{error, info, warn, LevelFilter};
const TAG: &str = "ChanoraAudioPoC";
static LOG_INIT: Once = Once::new();
// Stash the JavaVM* captured at library-load time. We need it to
// resolve a JNIEnv on the audio thread later if we ever want to make
// JNI calls there. cpal's Oboe backend uses `ndk_context` instead,
// which we initialise from the Kotlin side once we have a Context.
static mut JAVA_VM: *mut std::ffi::c_void = std::ptr::null_mut();
/// JNI entry point invoked by the Android runtime when our cdylib is
/// loaded via `System.loadLibrary`. Captures the JavaVM* but does NOT
/// yet initialise the ndk_context — that needs a `Context` reference,
/// which only the Activity has.
#[no_mangle]
pub extern "system" fn JNI_OnLoad(vm: *mut std::ffi::c_void, _reserved: *mut std::ffi::c_void) -> jint {
ensure_logging();
unsafe {
JAVA_VM = vm;
}
info!("JNI_OnLoad: captured JavaVM");
JNI_VERSION_1_6
}
/// Called by `MainActivity.onCreate` with the activity reference.
/// Initialises the `ndk_context` so cpal's Oboe backend can locate
/// the Android `Context` it needs.
#[no_mangle]
pub extern "system" fn Java_app_chanora_poc_audio_NativeAudio_initContext<'local>(
env: JNIEnv<'local>,
_class: JClass<'local>,
context: jni::objects::JObject<'local>,
) {
ensure_logging();
// Promote the local-ref Context to a global ref so it survives
// beyond this JNI call. ndk_context borrows the pointer.
let global = match env.new_global_ref(&context) {
Ok(g) => g,
Err(e) => {
error!("new_global_ref(context) failed: {e}");
return;
}
};
let raw_ctx = global.as_obj().as_raw() as *mut std::ffi::c_void;
// Leak the global ref intentionally — it must live for the
// lifetime of the library (which is the lifetime of the process).
std::mem::forget(global);
unsafe {
ndk_context::initialize_android_context(JAVA_VM, raw_ctx);
}
info!("initContext: ndk_context initialised (vm={:p}, ctx={:p})", unsafe { JAVA_VM }, raw_ctx);
}
fn ensure_logging() {
LOG_INIT.call_once(|| {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(LevelFilter::Trace)
.with_tag(TAG),
);
// Route panics into logcat so they don't disappear into the void.
panic::set_hook(Box::new(|info| {
let payload = if let Some(s) = info.payload().downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"<unknown panic payload>".to_string()
};
let location = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_else(|| "<unknown location>".to_string());
error!("RUST PANIC at {location}: {payload}");
}));
info!("logging initialised");
});
}
/// Run a closure that produces a result, catching any panic and
/// converting it to an error string. Required because our JNI
/// functions are `extern "system"` and panicking across that
/// boundary is undefined behaviour (and aborts the process).
fn catch<F: FnOnce() -> Result<u64, String>>(label: &str, f: F) -> jlong {
let r = panic::catch_unwind(AssertUnwindSafe(f));
match r {
Ok(Ok(n)) => {
info!("{label} ok n={n}");
n as jlong
}
Ok(Err(e)) => {
error!("{label} returned err: {e}");
-1
}
Err(_) => {
// The panic hook already logged the details.
error!("{label} panicked (caught)");
-2
}
}
}
/// Synth a 440 Hz mono sine for `duration_ms` directly through the
/// default output device. Returns a status code (0 = OK, non-zero =
/// error code). Errors are also logged to logcat.
#[no_mangle]
pub extern "system" fn Java_app_chanora_poc_audio_NativeAudio_playSine440(
_env: JNIEnv,
_class: JClass,
duration_ms: jlong,
) -> jlong {
ensure_logging();
let dur = Duration::from_millis(duration_ms.max(0) as u64);
catch("playSine440", || play_sine_inner(440.0, dur))
}
fn play_sine_inner(freq_hz: f32, duration: Duration) -> Result<u64, String> {
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or_else(|| "no default output device".to_string())?;
let device_name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
let cfg = device
.default_output_config()
.map_err(|e| format!("default_output_config: {e}"))?;
info!(
"playback device='{}' sample_rate={} channels={} fmt={:?}",
device_name,
cfg.sample_rate().0,
cfg.channels(),
cfg.sample_format()
);
let sample_rate = cfg.sample_rate().0 as f32;
let channels = cfg.channels() as usize;
let total_frames = (duration.as_secs_f32() * sample_rate) as u64;
let phase = Arc::new(Mutex::new(0.0f32));
let frames_emitted = Arc::new(Mutex::new(0u64));
let done = Arc::new(Mutex::new(false));
let stream_config: cpal::StreamConfig = cfg.clone().into();
fn err_fn(e: cpal::StreamError) {
error!("playback stream error: {e}");
}
let stream = match cfg.sample_format() {
SampleFormat::F32 => build_play_stream::<f32>(
&device,
&stream_config,
sample_rate,
freq_hz,
channels,
total_frames,
phase.clone(),
frames_emitted.clone(),
done.clone(),
err_fn,
)?,
SampleFormat::I16 => build_play_stream::<i16>(
&device,
&stream_config,
sample_rate,
freq_hz,
channels,
total_frames,
phase.clone(),
frames_emitted.clone(),
done.clone(),
err_fn,
)?,
SampleFormat::U16 => build_play_stream::<u16>(
&device,
&stream_config,
sample_rate,
freq_hz,
channels,
total_frames,
phase.clone(),
frames_emitted.clone(),
done.clone(),
err_fn,
)?,
other => return Err(format!("unsupported sample format: {other:?}")),
};
stream
.play()
.map_err(|e| format!("play(): {e}"))?;
// Wait until either the producer reaches total_frames or a generous timeout.
let timeout = duration + Duration::from_millis(500);
let start = std::time::Instant::now();
loop {
if *done.lock().unwrap() {
break;
}
if start.elapsed() > timeout {
warn!("playback timeout reached");
break;
}
std::thread::sleep(Duration::from_millis(20));
}
drop(stream);
let final_frames = *frames_emitted.lock().unwrap();
Ok(final_frames)
}
fn build_play_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
sample_rate: f32,
freq_hz: f32,
channels: usize,
total_frames: u64,
phase: Arc<Mutex<f32>>,
frames_emitted: Arc<Mutex<u64>>,
done: Arc<Mutex<bool>>,
err_fn: fn(cpal::StreamError),
) -> Result<cpal::Stream, String>
where
T: SizedSample + FromSineSample + Send + 'static,
{
let phase_inc = 2.0 * std::f32::consts::PI * freq_hz / sample_rate;
let stream = device
.build_output_stream(
config,
move |out: &mut [T], _| {
let mut ph = phase.lock().unwrap();
let mut emitted = frames_emitted.lock().unwrap();
for frame in out.chunks_mut(channels) {
if *emitted >= total_frames {
for s in frame.iter_mut() {
*s = T::from_f32(0.0);
}
*done.lock().unwrap() = true;
} else {
let v = (*ph).sin() * 0.5;
*ph = (*ph + phase_inc) % (2.0 * std::f32::consts::PI);
for s in frame.iter_mut() {
*s = T::from_f32(v);
}
*emitted += 1;
}
}
},
err_fn,
None,
)
.map_err(|e| format!("build_output_stream: {e}"))?;
Ok(stream)
}
pub trait FromSineSample {
fn from_f32(v: f32) -> Self;
}
impl FromSineSample for f32 {
fn from_f32(v: f32) -> Self {
v
}
}
impl FromSineSample for i16 {
fn from_f32(v: f32) -> Self {
(v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16
}
}
impl FromSineSample for u16 {
fn from_f32(v: f32) -> Self {
let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32;
(s + i32::from(i16::MAX) + 1) as u16
}
}
/// Record `duration_ms` of mono 16-bit PCM to `path` from the default
/// input device. Returns frames captured (>= 0) or -1 on error.
#[no_mangle]
pub extern "system" fn Java_app_chanora_poc_audio_NativeAudio_record1sToFile(
mut env: JNIEnv,
_class: JClass,
path: JString,
duration_ms: jlong,
) -> jlong {
ensure_logging();
let path_str: String = match env.get_string(&path) {
Ok(s) => s.into(),
Err(e) => {
error!("get_string(path) failed: {e}");
return -1;
}
};
let dur = Duration::from_millis(duration_ms.max(0) as u64);
catch("record1sToFile", move || record_inner(&path_str, dur))
}
fn record_inner(path: &str, duration: Duration) -> Result<u64, String> {
let host = cpal::default_host();
let device = host
.default_input_device()
.ok_or_else(|| "no default input device".to_string())?;
let device_name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
let cfg = device
.default_input_config()
.map_err(|e| format!("default_input_config: {e}"))?;
info!(
"capture device='{}' sample_rate={} channels={} fmt={:?}",
device_name,
cfg.sample_rate().0,
cfg.channels(),
cfg.sample_format()
);
let spec = WavSpec {
channels: 1,
sample_rate: cfg.sample_rate().0,
bits_per_sample: 16,
sample_format: HoundSampleFormat::Int,
};
let writer = Arc::new(Mutex::new(Some(
WavWriter::create(path, spec).map_err(|e| format!("wav create: {e}"))?,
)));
let frames = Arc::new(Mutex::new(0u64));
let channels = cfg.channels() as usize;
let stream_config: cpal::StreamConfig = cfg.clone().into();
fn err_fn(e: cpal::StreamError) {
error!("capture stream error: {e}");
}
let stream = match cfg.sample_format() {
SampleFormat::F32 => build_capture_stream::<f32>(
&device, &stream_config, writer.clone(), frames.clone(), channels, err_fn,
)?,
SampleFormat::I16 => build_capture_stream::<i16>(
&device, &stream_config, writer.clone(), frames.clone(), channels, err_fn,
)?,
SampleFormat::U16 => build_capture_stream::<u16>(
&device, &stream_config, writer.clone(), frames.clone(), channels, err_fn,
)?,
other => return Err(format!("unsupported sample format: {other:?}")),
};
stream
.play()
.map_err(|e| format!("capture play(): {e}"))?;
std::thread::sleep(duration);
drop(stream);
if let Some(w) = writer.lock().unwrap().take() {
w.finalize().map_err(|e| format!("wav finalize: {e}"))?;
}
let n = *frames.lock().unwrap();
Ok(n)
}
fn build_capture_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
writer: Arc<Mutex<Option<WavWriter<std::io::BufWriter<std::fs::File>>>>>,
frames: Arc<Mutex<u64>>,
channels: usize,
err_fn: fn(cpal::StreamError),
) -> Result<cpal::Stream, String>
where
T: SizedSample + ToI16Sample + Send + 'static,
{
let stream = device
.build_input_stream(
config,
move |data: &[T], _| {
let mut w_guard = writer.lock().unwrap();
if let Some(w) = w_guard.as_mut() {
let mut n = 0u64;
for frame in data.chunks(channels) {
let mut acc: i32 = 0;
for s in frame {
acc += s.to_i16_sample() as i32;
}
let mono = (acc / frame.len() as i32) as i16;
let _ = w.write_sample(mono);
n += 1;
}
*frames.lock().unwrap() += n;
}
},
err_fn,
None,
)
.map_err(|e| format!("build_input_stream: {e}"))?;
Ok(stream)
}
pub trait ToI16Sample {
fn to_i16_sample(&self) -> i16;
}
impl ToI16Sample for i16 {
fn to_i16_sample(&self) -> i16 {
*self
}
}
impl ToI16Sample for u16 {
fn to_i16_sample(&self) -> i16 {
(i32::from(*self) - i32::from(i16::MAX) - 1) as i16
}
}
impl ToI16Sample for f32 {
fn to_i16_sample(&self) -> i16 {
let v = (*self * f32::from(i16::MAX)).clamp(f32::from(i16::MIN), f32::from(i16::MAX));
v as i16
}
}