3704 字
10 分钟
React Native 通话监听实战:前台服务 + MIUI 录音扫描 + Vosk 离线转写

这篇记录一条完整可落地的能力链路:
通话挂断后,自动找到最近录音并离线转写,不依赖云端 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 步:

  1. RN 调原生模块,启动前台服务
  2. 服务注册通话状态监听
  3. 用户发生通话,状态进入 OFFHOOK
  4. 挂断后状态进入 IDLE
  5. 延迟一小段时间,扫描近期录音文件
  6. 校验 Vosk 模型是否就绪,执行离线转写
  7. 将结果写日志并更新通知预览

这个时序的关键是第 5 步:不能在 IDLE 瞬间立刻读文件,否则容易读到未写完的录音。

架构分层(为什么这么拆)#

为了让功能在 App 退到后台后依然可用,我把逻辑拆成三层:

  1. RN Service 封装层:只暴露 start/stop/isRunning
  2. Native Module 桥接层:负责 Promise 入参出参,最薄一层
  3. ForegroundService 业务层:通话监听、扫描录音、触发转写、发通知

这样做的好处是:

  • RN 页面不需要承担长生命周期逻辑
  • React Context 不活跃时也不会中断监听
  • 问题定位会更快(桥接问题 vs 业务问题)

关键实现一:前台服务保证后台可运行#

在原生模块里只做启动与停止,核心工作都放在服务里。
服务启动后进入前台,状态栏保留常驻通知,系统回收概率更低。

@ReactMethod
fun 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 -> RINGING
  • CALL_STATE_OFFHOOK -> OFFHOOK
  • CALL_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:压制短时间重复 IDLE
  • offhookMaxAgeMs:限制必须是“最近一次真实通话”
  • postCallDelayMs:挂断后延迟执行,等待录音文件落盘
  • pipelineLock + lastPipelineAt:串行化后处理链路,防止并发重入

这组策略解决的是两个高频线上问题:

  • 同一通话被重复转写
  • 录音文件还没写完就开始识别,结果空文本或失败

下图把「状态归一 + 转移触发 + 防抖/时间窗 + 延迟扫描」压成一条可读路径(与前台服务里的实现思想一致;RN 前台链路也会做类似的 OFFHOOK -> IDLE 判断,但后台兜底仍以前台服务为准):

flowchart TB subgraph phone["业务状态(Native 映射后)"] S0[IDLE] S1[RINGING] S2[OFFHOOK] end S0 -->|onCallStateChanged| S1 S1 -->|onCallStateChanged| S2 S0 -->|onCallStateChanged| S2 S2 -->|onCallStateChanged| S0 S0 --> Q1{转移是否为 OFFHOOK → IDLE?} Q1 -->|否| S0 Q1 -->|是| Q2{idleDebounce / offhookMaxAge 通过?} Q2 -->|否| S0 Q2 -->|是| Q3{pipelineLock 可进入?} Q3 -->|否| S0 Q3 -->|是| W[postCallDelay 等待落盘] W --> P[扫描近期录音 → Vosk 转写] P --> S0

5. 前台服务与 JS 的协同边界#

这里我专门做了职责切分,避免“两边都转写一次”:

  • App 非前台:JS 收到 IDLE 也不执行转写,交给前台服务处理
  • App 前台:JS 可执行前台链路(用于更快反馈)

也就是说,前台服务是最终兜底通道;JS 是增强通道,不是唯一通道。
这个边界明确后,架构会稳定很多。

6. 权限时序:先授权再 startListen#

READ_PHONE_STATE 没拿到时,即便 startListen() 调用了,系统通常也不会回调。
正确顺序是:

  1. 先检查/申请权限
  2. 权限通过后再 startListen
  3. 页面销毁或不再需要时 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 流读第一个字节),例如校验这些相对路径是否存在且可读:

  • uuid
  • am/final.mdl
  • graph/HCLr.fst
  • ivector/final.ie
  • conf/model.conf

任意缺失都视为 模型未就绪,在 UI 上给出明确提示,而不是等到 unpackRecognizer 才报错。

3. 首次加载:StorageService.unpack 解压到应用私有目录#

assets 里的是压缩包式资源,运行时一般要解压到应用可写目录。vosk-android 提供 StorageService.unpack

  • 入参:contextassets 下模型目录名、解压目标子目录名(如 "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 / .m4aMediaExtractor + MediaCodec 解码出 PCM → 转单声道 → 线性重采样到 16 kHz → 再喂 Recognizer

其它后缀直接在原生层 reject 或抛 IllegalArgumentException(例如「仅支持 wav/m4a/mp3」),RN 侧映射成 UNSUPPORTED_FORMAT,不要默默返回空字符串让用户以为“识别很差”。

解码路径上要特别注意:

  • 选第一条 audio/ MIME 的轨道
  • 处理 INFO_OUTPUT_FORMAT_CHANGED 更新采样率/声道
  • finallyrelease MediaCodecMediaExtractor,避免泄漏

6. 从结果 JSON 取文本#

Recognizer.finalResult 返回的是 JSON 字符串,常见形态里文本字段为 text。用 JSONObjectoptString("text", "") 即可;解析失败时返回空串并打日志,避免整个管道因 JSON 抖动崩溃。

7. React Native 侧:薄封装 + 两类 API#

建议 RN 只暴露两个能力,命名可以贴近实际文件类型(方法名带 Wav 也行,但注释要写清支持 mp3/m4a):

  1. checkModelReady() → 返回 { ready, modelPath, message? }
    • modelPath 建议放给人看的说明(例如「在 APK 的 assets/xxx」),不要假装是磁盘绝对路径
  2. 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();
},
};

页面或业务里推荐顺序:

  1. checkModelReady(),未就绪则提示卸载重装 / 检查 assets
  2. 再对扫描到的绝对路径调用 transcribeWavFile

原生 Promise 侧建议区分:

  • FILE_NOT_FOUND:路径错或文件尚未写完
  • UNSUPPORTED_FORMAT:后缀或解码失败
  • TRANSCRIBE_FAILED:其它运行时错误

这样 UI 可以针对性提示,而不是统一「失败请重试」。

8. 排障速查表#

现象优先检查
一启动就 UnsatisfiedLinkErrorJNA 是否用 @aar,以及 exclude 是否生效
checkModelReady 永远 falseassets 目录名是否与代码常量一致、必备文件是否缺
转写永远空字符串采样率是否非 16k、WAV 是否非 PCM、挂断后是否读太早
只有 mp3/m4a 失败MediaCodec 是否不支持该编码、文件是否损坏

转写结果侧若要做产品化,可额外带上 sourcePath、耗时等字段,便于统计与客服回放。

权限与系统差异(最容易踩坑)#

扫描录音权限要按 Android 版本区分:

  • Android 13+:READ_MEDIA_AUDIO
  • 旧版本:READ_EXTERNAL_STORAGE

另外如果目录存在但读出来是空列表,往往是分区存储限制,不一定是“没有录音”。
这时候给用户一个明确引导(如去开启“所有文件访问”),比沉默失败更重要。

实际落地可以按“先失败后引导”的策略做:

  1. 先按标准权限流程申请
  2. 扫描失败时判定是“目录不存在”还是“目录可见但不可列举”
  3. 仅对后者弹系统设置引导,避免打扰无关用户

可观测性:最少要打哪些日志#

如果只打“转写成功/失败”,后续基本没法定位。至少建议保留这些点:

  • 服务启动/停止时间
  • 通话状态切换(OFFHOOK -> IDLE
  • 本次选中的录音文件名与 mtime
  • 模型就绪检查结果
  • 转写耗时与文本长度
  • 异常分类(权限 / 文件 / 模型 / 转写)

只要这些日志在,线上 80% 的问题都能在一轮排查内确认方向。

我这版方案的边界#

目前这套方案更偏小米目录约束,优点是稳定、可控;代价是泛化性一般。
如果要做成更通用产品,后续我会补这几块:

  • 不同 ROM 的录音目录策略抽象
  • 音频格式/采样率预处理统一化
  • 转写结果结构化(摘要、关键词、客户意图)

如果你准备把它产品化,建议再加两层:

  • 策略层:不同机型/ROM 的目录、权限、扫描深度策略
  • 编排层:转写后再接摘要、标签、CRM 入库、质检规则

小结#

这次实践的核心收获不是“RN 如何调原生”,而是:

把一条跨端链路做成可长期运行、可排障、可扩展的工程能力。

如果你也在做“App 后台事件 -> 本地文件 -> AI 处理”的流程,建议优先把这三件事做好:

  • 生命周期(服务保活)
  • 抖动控制(事件去重与时序)
  • 可观测性(日志 + 用户可读提示)

这样后续再加 OCR、摘要、自动入 CRM,都会轻松很多。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

React Native 通话监听实战:前台服务 + MIUI 录音扫描 + Vosk 离线转写
https://kongdf.com/posts/learning/projects/rncall-react-native-call-transcribe/
作者
孔大夫
发布于
2026-04-25
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时