4.3 KiB
Android raw-PCM frame capture (audioFrame)
Adds an opt-in native AudioRecord path to TalkModePlugin.kt that streams
16 kHz mono 16-bit PCM frames to JS as a new audioFrame Capacitor event, so a
JS/bun consumer can run diarization / frame-VAD / wake-word on Android. The
default SpeechRecognizer (SODA) STT path is untouched and remains the default.
Why a new path was needed
Android STT (android.speech.SpeechRecognizer) yields transcript strings
only — onBufferReceived is a documented no-op on SODA, so there is no raw
PCM, and diarization/VAD/wake-word can't run. The new path captures PCM directly
with AudioRecord.
The mic-contention constraint and the chosen design
Android lets only one capture client own a given audio input at a time. A
parallel AudioRecord while SpeechRecognizer holds the mic does not get the
mic — AudioRecord either fails to reach RECORDSTATE_RECORDING or reads
silence. The three options from the brief:
- (a) AudioRecord-only mode replacing SpeechRecognizer (PCM -> local-inference ASR).
- (b) Coexistence, if the device permits it.
- (c) A distinct "diarization mode" that suspends SpeechRecognizer while AudioRecord runs.
Chosen: (c). startAudioFrames detects an active SpeechRecognizer session
and suspends it (recognizer.cancel() + destroy(), cancel the
restart/silence jobs) before opening AudioRecord; stopAudioFrames releases
AudioRecord and resumes SpeechRecognizer if a session is still active
(enabled && !stopRequested). This is the cleanest viable path that does NOT
regress the working SODA STT default: it is a separate opt-in method; start()
is unchanged and frame capture is off unless explicitly requested.
Coexistence (b) was measured infeasible on the Pixel 9a (see verification):
AudioRecord reaches RECORDING and frames flow only AFTER STT is suspended;
the design proactively suspends rather than relying on concurrent capture.
Option (a) — AudioRecord-only + local transcription — is a strict superset of this PCM capture (the PCM produced here is exactly what a local-ASR consumer would feed to local ASR). It is left to the JS consumer: this layer's job is to deliver verified PCM frames; transcription backend choice lives above it.
API (added)
startAudioFrames({ sampleRate = 16000, frameMs = 20 }) → { started, sampleRate, frameSamples, suspendedStt, error? }stopAudioFrames() → voidisCapturingAudioFrames() → { capturing }- Event
audioFrame:{ pcm16 (base64 LE s16 mono), sampleRate, channels: 1, samples, rms (0..1), timestamp (elapsedRealtime ms), frameIndex }
The AudioRecord is opened on VOICE_RECOGNITION (preprocessing-light, what
diarization wants), falling back to MIC. The read loop runs on
Dispatchers.IO; each frame is little-endian-packed, RMS-measured, base64-encoded,
and emitted via notifyListeners. Lifecycle is released in stop() and
handleOnDestroy().
On-device verification (Pixel 9a, 53081JEBF11586)
Driven via CDP against the WebView Capacitor context (TalkMode plugin), with
mic permission granted:
- PCM frames reach JS (frame-only):
startAudioFrames({16000, 20ms})→started:true, sampleRate:16000, frameSamples:320. 196 frames in 4 s (~49 fps = correct for 20 ms frames),samples:320,channels:1,sampleRate:16000, base64 length 856 (= 640 PCM bytes), RMS varied per frame (min 0.00007, max 0.00679, avg 0.00465 → live audio, not a silence stub),frameIndexstrictly monotonic,capturingtrue→false across start/stop. - Mic contention / suspend-resume:
start()(STT,state:listening) →startAudioFrames()returnedsuspendedStt:trueand captured 149 frames (avg RMS 0.00485) while STT was suspended →stopAudioFrames()resumed STT (state:listening,enabled:true). Proves AudioRecord only gets the mic once SpeechRecognizer is suspended, and STT cleanly resumes. - No STT regression: default
start()→started:true, state:listening, enabled:true;isCapturingAudioFramesstaysfalse(frame capture is off unless explicitly requested).
Build: :elizaos-capacitor-talkmode:compileDebugKotlin + :app:assembleDebug
(BUILD SUCCESSFUL); APK contains startAudioFrames/audioFrame in the dex;
installed + verified on-device.