这篇记录一条完整可落地的能力链路:
通话挂断后,自动找到最近录音并离线转写,不依赖云端 ASR。
这篇不讲“从 0 创建 RN 项目”,只讲一条可落地的链路:
- RN 调用原生模块启动前台服务
- Android 持续监听通话状态
- 挂断后扫描 MIUI 通话录音目录
- 用 Vosk 做本地离线转写
- 用通知给用户回馈当前处理状态
目标与约束
这条链路看起来简单,实际实现里会同时受 3 类约束:
- 生命周期约束:App 退到后台时,JS 层不可靠,监听不能依赖页面存活
- 系统差异约束:Android 版本、权限模型、ROM 录音路径差异都很大
- 时序约束:挂断事件、录音落盘、文件可读之间有时间差
所以实现重点不只是“能跑通”,而是“跑得稳、可定位问题”。
技术路线
- RN 侧:TypeScript +
NativeModules封装服务调用 - Android 侧:Kotlin 原生模块 + 前台服务(Foreground Service)
- 通话状态:Android 12+ 用
TelephonyCallback,低版本回退PhoneStateListener - 转写引擎:Vosk(模型随 APK 打包进 assets)
- 录音来源:小米目录
MIUI/sound_recorder/call_rec
端到端时序
为了方便排障,我把流程固定成这 7 步:
- RN 调原生模块,启动前台服务
- 服务注册通话状态监听
- 用户发生通话,状态进入
OFFHOOK - 挂断后状态进入
IDLE - 延迟一小段时间,扫描近期录音文件
- 校验 Vosk 模型是否就绪,执行离线转写
- 将结果写日志并更新通知预览
这个时序的关键是第 5 步:不能在 IDLE 瞬间立刻读文件,否则容易读到未写完的录音。
架构分层(为什么这么拆)
为了让功能在 App 退到后台后依然可用,我把逻辑拆成三层:
- RN Service 封装层:只暴露
start/stop/isRunning - Native Module 桥接层:负责 Promise 入参出参,最薄一层
- ForegroundService 业务层:通话监听、扫描录音、触发转写、发通知
这样做的好处是:
- RN 页面不需要承担长生命周期逻辑
- React Context 不活跃时也不会中断监听
- 问题定位会更快(桥接问题 vs 业务问题)
关键实现一:前台服务保证后台可运行
在原生模块里只做启动与停止,核心工作都放在服务里。
服务启动后进入前台,状态栏保留常驻通知,系统回收概率更低。
@ReactMethodfun startBackgroundMonitor(promise: Promise) { try { CallMonitorForegroundService.start(reactApplicationContext) promise.resolve(true) } catch (e: Exception) { promise.reject("ERR", e.message, e) }}设计点:
- 失败直接
reject,RN 侧好做统一提示 - 服务
START_STICKY,异常被杀后系统会尝试拉起 - 通知文案分阶段更新(监听中 / 处理中 / 转写完成),减少“无反馈”的不确定感
电话状态桥接(重点)
这条链路里最关键的不是“拿到一个状态回调”,而是把Native 状态变化稳定传递给 RN 业务层,同时避免重复处理。
我最终用的是 双通道设计:
- 通道 A(前台服务):后台常驻,负责“最终转写”这条强一致任务
- 通道 B(JS 事件):页面活跃时给交互层实时反馈(例如状态文案、前台触发补充逻辑)
这样做的目的是:即使 RN 页面被回收,核心能力仍由前台服务兜底。
1. Native 监听:按 Android 版本分流
Native 模块里统一监听电话状态:
- Android 12+(API 31+)使用
TelephonyCallback.CallStateListener - 旧版本回退
PhoneStateListener.LISTEN_CALL_STATE
并把系统状态映射成业务状态字符串:
CALL_STATE_RINGING->RINGINGCALL_STATE_OFFHOOK->OFFHOOKCALL_STATE_IDLE->IDLE
这一步的价值是“归一化”。后续 JS 层无需理解 Android 细节,只处理 3 个稳定状态。
2. RN 事件桥接:NativeEventEmitter 的正确姿势
JS 侧用 NativeEventEmitter 订阅 onCallStateChanged,并在组件卸载时 remove;
Native 模块实现 addListener/removeListeners 空方法,避免 RN 新架构下的告警与兼容问题。
另一个容易忽略的点:Native 发事件前先判断 hasActiveReactInstance()。
否则 React 实例未激活时直接 emit,在某些机型上会抛异常或丢事件。
3. 为什么不走电话广播(ACTION_PHONE_STATE_CHANGED)
理论上广播也能拿到状态,但实践里在 Android 13+ 与部分 ROM 上,动态注册标志和系统策略都更容易踩坑。
所以我选择以 Telephony API 为主通道,广播只作为历史方案参考,不作为核心链路依赖。
如果你的目标是“上线可长期稳定运行”,建议直接走 TelephonyCallback/PhoneStateListener 这条主线。
4. 状态机与防抖:避免重复转写的关键
我用的是“状态机 + 时间窗”组合,而不是“收到 IDLE 就立刻转写”:
- 仅在
OFFHOOK -> IDLE这个转移上触发后处理 idleDebounceMs:压制短时间重复 IDLEoffhookMaxAgeMs:限制必须是“最近一次真实通话”postCallDelayMs:挂断后延迟执行,等待录音文件落盘pipelineLock+lastPipelineAt:串行化后处理链路,防止并发重入
这组策略解决的是两个高频线上问题:
- 同一通话被重复转写
- 录音文件还没写完就开始识别,结果空文本或失败
下图把「状态归一 + 转移触发 + 防抖/时间窗 + 延迟扫描」压成一条可读路径(与前台服务里的实现思想一致;RN 前台链路也会做类似的 OFFHOOK -> IDLE 判断,但后台兜底仍以前台服务为准):
5. 前台服务与 JS 的协同边界
这里我专门做了职责切分,避免“两边都转写一次”:
- App 非前台:JS 收到
IDLE也不执行转写,交给前台服务处理 - App 前台:JS 可执行前台链路(用于更快反馈)
也就是说,前台服务是最终兜底通道;JS 是增强通道,不是唯一通道。
这个边界明确后,架构会稳定很多。
6. 权限时序:先授权再 startListen
READ_PHONE_STATE 没拿到时,即便 startListen() 调用了,系统通常也不会回调。
正确顺序是:
- 先检查/申请权限
- 权限通过后再
startListen - 页面销毁或不再需要时
stopListen
并且要把“模块不可用”“权限被拒绝”两类失败区分展示,方便用户自助处理。
关键实现三:MIUI 录音扫描不能只读一层目录
小米通话录音目录常见在:
/storage/emulated/0/MIUI/sound_recorder/call_rec/sdcard/MIUI/sound_recorder/call_rec
我在 RN 扫描逻辑里做了几件事:
- 支持递归扫描(
call_rec常有子目录) - 支持目录路径尾斜杠 fallback(部分机型路径敏感)
- 音频后缀白名单过滤(mp3/m4a/wav…)
- 按
mtime排序后取最近 N 条
我这里还加了两个防误判策略:
- 扫描时做
seenPath去重,避免符号链接路径重复计入 - 顶层目录“存在但结果为空”时抛出可读错误,提示用户检查系统文件访问权限
这个组合可以显著减少“明明有录音却扫不到”的误报。
Vosk 部署与调用(重点)
下面按「依赖 → 模型 → 加载 → 转写 → RN 调用」顺序写,和你在 Android 工程里落地的顺序一致。
1. Gradle:vosk-android 与 JNA
vosk-android 依赖 JNA 做本地库加载。若只引 Vosk、不处理 JNA 的打包形态,真机上常见直接崩:
UnsatisfiedLinkError: ... libjnidispatch.so not found in resource path
做法是:显式引入 JNA 的 AAR(内含各 ABI 的 libjnidispatch.so),并在 Vosk 依赖里 exclude 掉它自带的 jna 模块,避免版本/打包冲突。示例:
// Vosk 依赖 JNA;须使用 jna 的 AAR(内含各 ABI 的 libjnidispatch.so)implementation("net.java.dev.jna:jna:5.13.0@aar")implementation("com.alphacephei:vosk-android:0.3.38") { exclude group: "net.java.dev.jna", module: "jna"}版本号可按官方发布更新;关键是 AAR + exclude 这一对组合,不要只写一行 vosk-android 就以为万事大吉。
2. 模型放进 assets:目录名与必备文件
从 Vosk 模型列表 下载中文小模型(示例目录名 vosk-model-small-cn-0.22),解压后整个目录放到:
android/app/src/main/assets/vosk-model-small-cn-0.22/
模型是否“真的打进 APK”,不要靠猜,建议在代码里做一次 必备文件探测(打开 assets 流读第一个字节),例如校验这些相对路径是否存在且可读:
uuidam/final.mdlgraph/HCLr.fstivector/final.ieconf/model.conf
任意缺失都视为 模型未就绪,在 UI 上给出明确提示,而不是等到 unpack 或 Recognizer 才报错。
3. 首次加载:StorageService.unpack 解压到应用私有目录
assets 里的是压缩包式资源,运行时一般要解压到应用可写目录。vosk-android 提供 StorageService.unpack:
- 入参:
context、assets下模型目录名、解压目标子目录名(如"model") - 成功回调里拿到
Model实例 - 失败回调里拿到异常(磁盘空间、IO、资源损坏等)
业务侧建议把 Model 做成 进程内单例(@Volatile + synchronized),避免前台服务与 RN Module 各加载一份、占双倍内存。
4. 线程模型:不要在主线程解压 / 转写
unpack 与转写都是重活。推荐固定套路:
- RN 暴露的
@ReactMethod:只做executor.execute { ... promise.resolve/reject },避免阻塞 JS 桥 - 前台服务里同步转写:单独
Executor+CountDownLatch阻塞等待完成,保证服务逻辑串行、可预期
同一套转写逻辑建议抽成 Helper 单例(例如 VoskTranscribeHelper),Module 与 Service 共用,避免两份实现漂移。
5. 调用转写:Recognizer 只吃「16 kHz 波形」
Vosk 的 Recognizer(model, 16000.0f) 表示 16 kHz 采样率。因此输入音频必须归一到这条约束:
| 扩展名 | 处理方式 |
|---|---|
.wav | 假定 16 kHz PCM(实现里可跳过 44 字节 WAV 头后,按 acceptWaveForm 喂入) |
.mp3 / .m4a | 用 MediaExtractor + MediaCodec 解码出 PCM → 转单声道 → 线性重采样到 16 kHz → 再喂 Recognizer |
其它后缀直接在原生层 reject 或抛 IllegalArgumentException(例如「仅支持 wav/m4a/mp3」),RN 侧映射成 UNSUPPORTED_FORMAT,不要默默返回空字符串让用户以为“识别很差”。
解码路径上要特别注意:
- 选第一条
audio/MIME 的轨道 - 处理
INFO_OUTPUT_FORMAT_CHANGED更新采样率/声道 finally里 releaseMediaCodec与MediaExtractor,避免泄漏
6. 从结果 JSON 取文本
Recognizer.finalResult 返回的是 JSON 字符串,常见形态里文本字段为 text。用 JSONObject 取 optString("text", "") 即可;解析失败时返回空串并打日志,避免整个管道因 JSON 抖动崩溃。
7. React Native 侧:薄封装 + 两类 API
建议 RN 只暴露两个能力,命名可以贴近实际文件类型(方法名带 Wav 也行,但注释要写清支持 mp3/m4a):
checkModelReady()→ 返回{ ready, modelPath, message? }modelPath建议放给人看的说明(例如「在 APK 的 assets/xxx」),不要假装是磁盘绝对路径
transcribeWavFile(absolutePath)→ 返回{ text, path }
TypeScript 侧用 NativeModules 强类型包一层,统一处理「模块不存在」(未编译进 dev client)的情况:
const { VoskTranscribe } = NativeModules as { VoskTranscribe?: VoskTranscribeModuleType };
export const transcribeService = { isAvailable() { return Platform.OS === 'android' && !!VoskTranscribe?.transcribeWavFile; }, async transcribeWavFile(filePath: string) { if (!this.isAvailable()) throw new Error('离线转写模块不可用'); return VoskTranscribe!.transcribeWavFile(filePath); }, async checkModelReady() { if (!this.isAvailable()) { return { ready: false, modelPath: '…说明需完整编译安装…', message: '…' }; } return VoskTranscribe!.checkModelReady(); },};页面或业务里推荐顺序:
- 先
checkModelReady(),未就绪则提示卸载重装 / 检查 assets - 再对扫描到的绝对路径调用
transcribeWavFile
原生 Promise 侧建议区分:
FILE_NOT_FOUND:路径错或文件尚未写完UNSUPPORTED_FORMAT:后缀或解码失败TRANSCRIBE_FAILED:其它运行时错误
这样 UI 可以针对性提示,而不是统一「失败请重试」。
8. 排障速查表
| 现象 | 优先检查 |
|---|---|
一启动就 UnsatisfiedLinkError | JNA 是否用 @aar,以及 exclude 是否生效 |
checkModelReady 永远 false | assets 目录名是否与代码常量一致、必备文件是否缺 |
| 转写永远空字符串 | 采样率是否非 16k、WAV 是否非 PCM、挂断后是否读太早 |
| 只有 mp3/m4a 失败 | MediaCodec 是否不支持该编码、文件是否损坏 |
转写结果侧若要做产品化,可额外带上 sourcePath、耗时等字段,便于统计与客服回放。
权限与系统差异(最容易踩坑)
扫描录音权限要按 Android 版本区分:
- Android 13+:
READ_MEDIA_AUDIO - 旧版本:
READ_EXTERNAL_STORAGE
另外如果目录存在但读出来是空列表,往往是分区存储限制,不一定是“没有录音”。
这时候给用户一个明确引导(如去开启“所有文件访问”),比沉默失败更重要。
实际落地可以按“先失败后引导”的策略做:
- 先按标准权限流程申请
- 扫描失败时判定是“目录不存在”还是“目录可见但不可列举”
- 仅对后者弹系统设置引导,避免打扰无关用户
可观测性:最少要打哪些日志
如果只打“转写成功/失败”,后续基本没法定位。至少建议保留这些点:
- 服务启动/停止时间
- 通话状态切换(
OFFHOOK->IDLE) - 本次选中的录音文件名与
mtime - 模型就绪检查结果
- 转写耗时与文本长度
- 异常分类(权限 / 文件 / 模型 / 转写)
只要这些日志在,线上 80% 的问题都能在一轮排查内确认方向。
我这版方案的边界
目前这套方案更偏小米目录约束,优点是稳定、可控;代价是泛化性一般。
如果要做成更通用产品,后续我会补这几块:
- 不同 ROM 的录音目录策略抽象
- 音频格式/采样率预处理统一化
- 转写结果结构化(摘要、关键词、客户意图)
如果你准备把它产品化,建议再加两层:
- 策略层:不同机型/ROM 的目录、权限、扫描深度策略
- 编排层:转写后再接摘要、标签、CRM 入库、质检规则
小结
这次实践的核心收获不是“RN 如何调原生”,而是:
把一条跨端链路做成可长期运行、可排障、可扩展的工程能力。
如果你也在做“App 后台事件 -> 本地文件 -> AI 处理”的流程,建议优先把这三件事做好:
- 生命周期(服务保活)
- 抖动控制(事件去重与时序)
- 可观测性(日志 + 用户可读提示)
这样后续再加 OCR、摘要、自动入 CRM,都会轻松很多。
部分信息可能已经过时