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.
444 lines
14 KiB
Rust
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
|
|
}
|
|
}
|