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.
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user