uni-app x 蒸汽模式:实时流式语音识别(ASR)三大疑难杂症全记录
项目 :HermesAgent(uni-app x / uvue 蒸汽模式,Android)
链路 :
hi-audio-io(UTS 原生插件,AudioRecord 实时 PCM 采集)→ WebSocket 二进制推流 → 阿里云百炼 Paraformer-realtime-v2现象 :按住说话,服务端稳定返回
NO_VALID_AUDIO_ERROR,识别结果永远为空结局 :三个互相独立的底层 bug 逐一击破,链路全通(102 帧 PCM / 13 字识别成功)
环境:HBuilderX 5.24 / uni-app x 蒸汽模式 / 小米真机(Android,io.dcloud.uniappx)
目录
- 架构概览与故障链路
- 排查方法论:用"对照实验"缩小根因范围
- [Bug #1:采样率两端失配(采集 8k 声明 16k)](#1:采样率两端失配(采集 8k 声明 16k))
- [Bug #2:
ByteBuffer.allocate非 direct 缓冲区(JNI 拒绝 read)](#2:ByteBuffer.allocate 非 direct 缓冲区(JNI 拒绝 read)) - [Bug #3:UTS 回调未加
@UTSJS.keepAlive(首帧后全帧丢失)](#3:UTS 回调未加 @UTSJS.keepAlive(首帧后全帧丢失)) - 完整修复链与验证日志
- 踩坑清单与可复用经验
- 参考链接
1. 架构概览与故障链路
1.1 数据流
用户按住说话
│
▼
hi-audio-io(UTS 原生插件,app-android/index.uts)
· AudioRecord(16000Hz, MONO, PCM16, MIC)
· 采集线程 PcmCaptureThread:read(ByteBuffer) 逐帧读 PCM
· runOnUiThread 派发 onFrame 回调到 JS
│
▼(onFrame → pushPcm)
asr-stream.uts(WebSocket 推流层)
· SocketTask.send(ArrayBuffer) 发二进制 PCM 帧
· 文本帧 run-task / finish-task 控制握手
│
▼
阿里云百炼 Paraformer-realtime-v2(wss://...maas.aliyuncs.com)
· 服务端按 run-task 声明的 sample_rate 解析 PCM
· 出识别文本(interim / sentence / task-finished)
1.2 为什么必须用原生 AudioRecord(而不是 getRecorderManager)
uni.getRecorderManager() 在 App 端不支持 onFrameRecorded (官方文档:frameSize 仅 mp3 有效,且 App 端无实时帧回调)。流式 ASR 需要逐帧实时 PCM ,只能自建 AudioRecord 在 UTS 插件里读------这正是本项目 hi-audio-io 插件存在的理由。
关键认知:App 端要拿"实时 PCM 帧",框架 API 给不了,必须下沉到原生。下沉到原生就引入了三类框架层特有的坑(采样率、JNI 缓冲区、回调生命周期),后文逐一展开。
1.3 故障链路(初始现象)
按住说话
→ audioStart 成功(AudioRecord 构造 + startRecording 都 OK)
→ AudioRecord.read() 读不到任何 PCM 数据
→ 零帧推到 WS
→ 服务端 NO_VALID_AUDIO_ERROR
2. 排查方法论
这一节是整个排查的骨架。核心思路:用"完整录制能成功"做对照实验,把根因范围从"整个链路"压缩到"实时链路独有的 2 个环节",再用原生 logcat 日志把每个环节钉死。
2.1 对照实验:完整录制 vs 实时流式
| 环节 | 完整录制(✅能成功) | 实时流式(❌失败) |
|---|---|---|
| ① 采集 | uni.getRecorderManager() 录整段(系统 MediaRecorder) |
UTS 插件自建 AudioRecord 逐帧 read |
| ② 形态 | 一个完整音频文件 | 裸 PCM 二进制帧 |
| ③ 传输 | uni.uploadFile HTTP 文件上传 |
SocketTask.send WS 二进制帧 |
| ④ 引擎 | Groq(whisper 端点) | 百炼 Paraformer(实时 WS) |
对照实验证明了什么:
- ✅ 麦克风硬件没坏、权限 OK(完整录制能录到声音)
- ✅ 服务端能识别你的声音(完整录制转字成功)
- ❌ 排除"硬件坏 / 权限没给"
- ⚠️ 断点锁定在实时链路独有的环节:逐帧 PCM 产出 + WS 二进制发送
2.2 已排除的原因(证据表)
| 原因 | 证据 |
|---|---|
| API Key / 模型 / WS 连接 | task-started 正常(服务端回包了) |
| 麦克风权限 / 硬件 | 系统录音 App 正常 + 完整录制能成功 |
| VAD 误暂停 / 回调未注册 | 看门狗日志 vadPaused=false |
| 采样率 / 音源 | 16k/8k、MIC/VOICE_COMM 都试了(当时以为都 0 字节,实则是 Bug #2 的假象,见后文) |
2.3 诊断日志体系(排查的"眼睛")
整个排查能推进,靠的是三层诊断日志:
| 层 | 日志前缀 | 作用 |
|---|---|---|
| JS 业务层 | [ASR-DIAG] |
pushPcm 入口帧数、sendBinary 成功帧数、task-finished 累计推帧/全文长度 |
| UTS 插件层 | [AUDIO-DIAG](console.log) |
audioStart 时 cbFrame 状态、看门狗现场快照 |
| Android 原生层 | AUDIO-DIAG(Log.i)+ AudioRecord-JNI(系统) |
read 配对日志(准备 read / read 返回 n=?)、JNI 层错误 |
关键经验 :JS 层
console.log在原生子线程里可能走不通 。uni-app x 原生插件在独立采集线程里跑时,console.log可能被静默------必须用android.util.Log(logcat)才能在子线程里稳定输出。这是能定位到read n=-2的前提。
2.4 二分定位法(logcat 判读)
| 日志 | 含义 | 指向 |
|---|---|---|
pushPcm 入口 计数 = 0 |
帧没从原生采集层到 JS | 采集侧(read 不出帧) |
pushPcm 入口 > 0 但识别空 |
帧到了 JS 层 | 发送侧 / 服务端解析 |
sendBinary 抛错 |
二进制 send 失败 | WS 版本不支持二进制 |
sendBinary 成功但空 |
二进制发出去了 | 采样率/格式失配 |
read 返回 n=-2 |
JNI 拒绝(参数/缓冲区非法) | Bug #2 |
read 返回 n=0(持续) |
读了但没数据 | HAL 不吐 / 源路由 |
read 返回 n>0 |
读满 | 采集正常,看下游 |
3. Bug #1:采样率两端失配(采集 8k 声明 16k)
3.1 现象与矛盾
完整录制(Groq)能成功,实时流式不行。最初怀疑"采样率不匹配":
- 采集端:
voice.uts写死sampleRate: 8000 - ASR 声明端:
asr-stream.uts的run-task写死sample_rate: 16000
矛盾点 :如果 read 在正常出帧,采样率失配会导致"识别不准",但不会导致零帧 。而现象是"零帧"------所以采样率不是零帧的根因(但它是真实存在的 bug,会独立导致"出帧后识别为空")。
认知修正:采样率失配和"零帧"是两个独立问题。采样率失配会在"出帧之后"引爆(识别为空),而零帧是"出帧之前"的问题(read 不出数据)。排查时若只盯采样率,会在错误的变量上打转。
3.2 为什么"完整录制能成、实时失配会挂"
完整录制:录文件(8k/16k 真实采样率) → 落盘 → Groq 读文件头拿到【真实采样率】→ 正确解析 → 出字 ✅
实时流: 采集8k PCM ──(按16k声明)──▶ 百炼按 16000Hz 重新解释 8000Hz 数据
→ 相当于把声音快放 2 倍 → 音高/时长全错 → 判定"无有效语音" → NO_VALID_AUDIO ❌
关键差异在"采样率是否一致" :完整录制走文件,Groq 读文件头拿到真实采样率(不管 8k 还是 16k 都对);实时流是裸 PCM 无头,服务端只能信任你声明的 sample_rate------声明与实际不符 = 必挂。
3.3 修复:运行时探测 + 两端动态对齐
核心思想 :不硬编码采样率,运行时探测本机实际支持的最高 ASR 采样率,采集端与 ASR 声明端用同一个值。
改动 1:asr-stream.uts 的 startStream 加可选采样率参数
uts
// 默认 16000(向后兼容:不传则保持原行为)
export function startStream(cb : AsrStreamCallbacks, sampleRate : number = 16000) : boolean {
...
// 记录本次会话声明给服务端的采样率(与上层采集端对齐)
// 传 0/负数按默认 16000 处理,兼容旧调用
curSampleRate = sampleRate > 0 ? sampleRate : 16000
...
}
run-task 的 sample_rate 用 curSampleRate 替代写死的 16000:
uts
params.set('sample_rate', curSampleRate) // 与上层实际采集采样率一致(动态对齐)
改动 2:voice.uts 加探测函数
uts
let curAsrSampleRate = 16000 // 默认兜底(与 asr-stream 默认声明一致)
/**
* 探测本机实际支持的最高 ASR 采样率,写入 curAsrSampleRate。
* 手段:audioMinBufferSize(rate)(查 Android AudioRecord.getMinBufferSize)
* 返回 >=0 = 本机支持该采样率(单声道 PCM16);负值 = 不支持。
* 顺序:16000 → 12000 → 11025 → 8000(从高到低取第一个可用)。
* 前三档为 Paraformer 支持范围,8000 作最稳兜底(保证一定能采到数据)。
*/
function probeAsrSampleRate() : number {
const candidates : number[] = [16000, 12000, 11025, 8000]
for (let i = 0; i < candidates.length; i++) {
const rate = candidates[i]
const min = audioMinBufferSize(rate)
if (min >= 0) {
curAsrSampleRate = rate
console.log('[ASR-DIAG] 采样率探测: 本机支持 ' + rate + 'Hz')
return rate
}
}
curAsrSampleRate = 8000
return 8000
}
改动 3:两条链路(单轮/免提)在 audioInit 前探测,同一值喂给采集端 + ASR 声明端
uts
// 单轮链路 beginSingleStreamCapture
probeAsrSampleRate() // 探测 → 写 curAsrSampleRate
const initRes = audioInit({ sampleRate : curAsrSampleRate, ... }) // 采集端
...
startStream(cbs, curAsrSampleRate) // ASR 声明端(与采集端同一值)
闭环关键 :
startStream(ASR 声明)与audioInit(采集)必须用同一个探测值。若只改一端,失配依旧。
3.4 验证
[ASR-DIAG] 采样率探测: 本机支持 16000Hz(最小缓冲 1280 字节)
[ASR-DIAG] startStream 启动: ... sampleRate=16000
[ASR-DIAG] 单轮采集 audioInit 成功(采样率=16000,需与 ASR 声明一致): ... 采样率 16000Hz
两端均为 16000,失配消除。但此时 read 仍 0 字节 → 引出 Bug #2。
4. Bug #2:ByteBuffer.allocate 非 direct(JNI 拒绝 read)
4.1 现象:采样率对齐后仍 BAD_VALUE
采样率对齐到 16k 后,read 依然 0 字节、BAD_VALUE。logcat 暴露了真凶:
E AudioRecord-JNI: Buffer direct access is not supported, can't record
I AUDIO-DIAG: read 返回(第1次): n=-2
I AUDIO-DIAG: read 实际返回(第1次): n=-2
4.2 根因
AudioRecord.read(ByteBuffer, int) 由 Android 原生 JNI 实现 。JNI 层要求传入的 ByteBuffer 必须是 direct(堆外)缓冲区 ------即 ByteBuffer.allocateDirect(n),而不能 是 ByteBuffer.allocate(n)(堆内 buffer)。
| ByteBuffer 类型 | 创建方式 | 能否喂给 rec.read |
适用场景 |
|---|---|---|---|
| 堆内 buffer | ByteBuffer.allocate(n) |
❌ JNI 拒绝,返回 BAD_VALUE(-2) |
纯 Java 侧 putInt/getInt(如拼 WAV 头) |
| 堆外 direct buffer | ByteBuffer.allocateDirect(n) |
✅ | AudioRecord.read / AudioTrack.write |
传堆内 buffer 时,JNI 直接报 Buffer direct access is not supported, can't record,read 返回 BAD_VALUE(-2),一帧 PCM 都读不到。
4.3 为什么之前所有排查"绕过去了"
| 现象 | 真实原因 |
|---|---|
getMinBufferSize 返回正数(1280) |
参数本身合法(采样率/声道/格式全对)------只是 buffer 类型错了 |
| 8k/16k、MIC/VOICE_COMM 都 0 字节 | 与采样率/音源无关,是 buffer 类型问题(换任何参数,堆内 buffer 都读不到) |
| 采样率对齐后仍 BAD_VALUE | 采样率失配是另一个 bug,buffer 类型才是真正的零帧根因 |
| 看门狗走"现场快照"分支 | read 有返回(n=-2),但总字节=0,符合"读了但没数据" |
隐蔽性 :
getMinBufferSize只校验"参数是否合法",不校验 buffer 类型 。所以参数全对、构造成功,但read因 buffer 类型被 JNI 拒绝------这是"构造成功却读不到数据"的隐蔽陷阱。
4.4 修复(1 行精准改动)
hi-audio-io/app-android/index.uts 的 readFrameToBuffer:
uts
// ❌ 修复前:堆内 buffer,JNI 拒绝
const bb = ByteBuffer.allocate(frameBytes.toInt())
// ✅ 修复后:堆外 direct buffer,JNI 接受
const bb = ByteBuffer.allocateDirect(frameBytes.toInt())
为什么只改这一处:
readFrameToBuffer里的bb是唯一 喂给rec.read的缓冲区- 拼 WAV 头用的
ByteBuffer.allocate(44)(putInt写头字段)不需要 direct(纯 Java API) ArrayBuffer.fromByteBuffer(bb)官方 API 支持 direct buffer 转 ArrayBufferbb.flip()是 Java API,direct buffer 同样支持
4.5 验证
I AUDIO-DIAG: read 返回(第1次): n=1280 ← 读满一帧(不再是 n=-2)
I AUDIO-DIAG: 采集线程出帧: 累计读帧=1, rms×10000=0
I AUDIO-DIAG: 采集线程出帧: 累计读帧=29, rms×10000=514 ← 有声音
read 出帧了。但 累计推帧=1(只有首帧进了 pushPcm),后续全被一个错误拦住 → 引出 Bug #3。
5. Bug #3:UTS 回调未加 @UTSJS.keepAlive(首帧后全帧丢失)
5.1 现象:帧采到了,却只有首帧进 pushPcm
[AUDIO-DIAG] 采集线程出帧: 累计读帧=1, rms=0 ← 首帧采到
[ASR-DIAG] pushPcm 入口: 第1帧 ← 首帧进 JS ✅
[AUDIO-DIAG] 采集线程出帧: 累计读帧=2, rms=0
uts插件 audioSetCallbacks 回调函数已释放,不能再次执行 ← 第2帧被拦 ❌
...(后续 55+ 帧全被拦)
task-finished: 累计推帧=1, 最终全文len=0 ← 只推了 1 帧
矛盾 :采集线程明明在出帧(累计读帧 1→29→55),但 pushPcm 入口 只有第 1 帧,后续全报"回调已释放"。
5.2 根因:JS 回调被 GC 回收
audioSetCallbacks 把 JS 回调存到原生模块级变量 (cbFrame/cbVad/cbError),供采集线程跨多次 runOnUiThread 反复调用。
UTS 插件的 JS 回调默认不加引用保护 。若不在原生侧持有期间标记 keepAlive,JS 引擎会在原生侧仍持有引用时把回调函数 GC 回收。一旦被回收,原生侧再调用就报:
uts插件 xxx 回调函数已释放,不能再次执行
为什么首帧能过:首帧派发时回调刚注册、还没被 GC;随后 JS 引擎触发回收,后续所有帧的回调调用全部失败。
5.3 修复:加 @UTSJS.keepAlive 装饰器
官方方案 (HBuilderX 4.27+):用 @UTSJS.keepAlive 装饰器声明方法,使回调函数参数保持存活,支持在函数、自定义类型及类方法中使用。
改动 1:import 区引入 UTSJS 命名空间
uts
import { UTSJS } from 'io.dcloud.uts'
改动 2:给存回调的函数加装饰器
uts
// ❌ 修复前
export function audioSetCallbacks(onFrame : PcmFrameCallback | null, onVad : VadCallback | null, onError : AudioErrorCallback | null) : void {
cbFrame = onFrame
cbVad = onVad
cbError = onError
}
// ✅ 修复后
@UTSJS.keepAlive
export function audioSetCallbacks(onFrame : PcmFrameCallback | null, onVad : VadCallback | null, onError : AudioErrorCallback | null) : void {
cbFrame = onFrame
cbVad = onVad
cbError = onError
}
排查时逐个确认所有"存 JS 回调到原生变量、被反复调用"的函数,全部加 keepAlive:
| 函数 | 存什么 | 调用频率 | 是否需要 keepAlive |
|---|---|---|---|
audioSetCallbacks |
cbFrame/cbVad/cbError |
采集线程每帧反复调用 | ✅ 必须 |
audioSetUtteranceSink |
cbUtterance |
feedVad 每帧调用 | ✅ 必须 |
audioRequestPermission |
onResult |
系统权限弹窗一次性回调 | ⚠️ 可选(非反复调用) |
5.4 验证(全链路打通)
[ASR-DIAG] pushPcm 入口: 第1帧 / 第2帧 / 第3帧 ... ← 多帧连续进 JS
[ASR-DIAG] sendBinary 发送二进制帧成功: 本会话第1帧 / 第2帧 / ... / 第102帧
[AUDIO-DIAG] 采集线程出帧: 累计读帧=102, rms×10000=1273
[ASR-DIAG] task-finished 收口: 累计推帧=102, 最终全文len=13
[ASR-DIAG] 单轮 onEnd 收口: 最终文本len=13(嗯。这个应该没有问题了吧。)
102 帧 PCM 推到 WS,服务端识别出 13 个字------实时流式识别链路全通。
6. 完整修复链与验证日志
6.1 三个独立 bug 一览
| # | 文件 | 根因 | 修复 | 症状 |
|---|---|---|---|---|
| 1 | voice.uts + asr-stream.uts |
采样率两端失配(采集 8k 声明 16k) | 运行时探测 + 两端动态对齐 | 出帧后识别为空(失速音频) |
| 2 | hi-audio-io/app-android/index.uts L819 |
ByteBuffer.allocate 非 direct,JNI 拒绝 read |
allocate → allocateDirect |
read 返回 n=-2,零帧 |
| 3 | hi-audio-io/app-android/index.uts L1565/1577 |
UTS 回调未加 keepAlive,被 GC 回收 | 加 @UTSJS.keepAlive |
首帧后全帧丢失(累计推帧=1) |
关键认知 :这三个 bug 互相独立,修了一个不会顺带修好另外两个。排查时必须逐个击破,修一个验证一个,否则会陷入"改了一堆还是不行"的泥潭。
6.2 排查顺序(为什么是这个顺序)
1. 对照实验(完整录制能成)→ 排除硬件/权限,锁定"实时独有环节"
2. 看 JS 层日志(pushPcm 计数)→ 发现"零帧"→ 锁定采集侧
3. 看 logcat read 配对日志 → 发现 n=-2 + JNI "direct access" 错误 → 钉死 Bug #2
4. 修 Bug #2(allocateDirect)→ 验证出帧 → 发现"累计推帧=1" → 锁定 Bug #3
5. 看"回调已释放"报错 → 钉死 Bug #3(keepAlive)
6. 修 Bug #3 → 验证全通
(Bug #1 采样率对齐贯穿始终,是"出帧后识别为空"的独立隐患,必须一并修)
6.3 最终验证日志(成功状态)
18:01:51.893 [ASR-DIAG] 采样率探测: 本机支持 16000Hz(最小缓冲 1280 字节)
18:01:51.940 [ASR-DIAG] startStream 启动: model=paraformer-realtime-v2, sampleRate=16000
18:01:52.294 [ASR-DIAG] 单轮采集 audioInit 成功(采样率=16000): 采样率 16000Hz,单帧 1280 字节,音频源 MIC
18:01:52.365 [AUDIO-DIAG] audioStart 时 cbFrame=已注册,帧可正常派发
18:01:52.520 [AUDIO-DIAG] 采集线程出帧: 累计读帧=1, rms×10000=0
18:01:52.520 [ASR-DIAG] pushPcm 入口: 第1帧, state=1, bytes=1280
18:01:52.547 [AUDIO-DIAG] 采集线程出帧: 累计读帧=2 ...
18:01:52.560 [ASR-DIAG] pushPcm 入口: 第2帧 ...
18:01:52.579 [AUDIO-DIAG] 采集线程出帧: 累计读帧=3 ...
18:01:52.597 [ASR-DIAG] pushPcm 入口: 第3帧 ...
18:01:52.714 [ASR-DIAG] sendText 发送文本帧成功: 类型=run-task
18:01:52.808 [ASR-DIAG] 收到服务端事件: event=task-started
18:01:52.811 [ASR-DIAG] task-started 链路就绪: pending缓存帧=8, 即将flush补推
18:01:52.827 [ASR-DIAG] sendBinary 发送二进制帧成功: bytes=1280, 本会话第1帧, pushPcm累计=8帧
18:01:53.590 [AUDIO-DIAG] 采集线程出帧: 累计读帧=29, rms×10000=514
18:01:53.847 [ASR-DIAG] sendBinary 发送二进制帧成功: 本会话第35帧, pushPcm累计=34帧
18:01:54.620 [AUDIO-DIAG] 采集线程出帧: 累计读帧=55, rms×10000=1273
18:01:54.875 [ASR-DIAG] sendBinary 发送二进制帧成功: 本会话第61帧, pushPcm累计=60帧
18:01:55.877 [ASR-DIAG] sendBinary 发送二进制帧成功: 本会话第86帧, pushPcm累计=85帧
18:01:56.588 [ASR-DIAG] sendText 发送文本帧成功: 类型=finish-task
18:01:56.648 [ASR-DIAG] task-finished 收口: 累计推帧=102, 最终全文len=13
18:01:56.659 [ASR-DIAG] 单轮 onEnd 收口: 最终文本len=13(嗯。这个应该没有问题了吧。)
链路全通的判据:
pushPcm 入口多帧连续(1/2/3...)→ 回调存活sendBinary多帧成功(1/2/3.../102)→ 二进制推流正常task-finished: 累计推帧=102→ 推帧数 ≈ 采集帧数最终全文len=13> 0 → 服务端识别出字
7. 踩坑清单与可复用经验
7.1 uni-app x 蒸汽模式 + 原生 AudioRecord 特有坑
onFrameRecorded在 App 端不可用 :getRecorderManager拿不到实时 PCM 帧(frameSize 仅 mp3 有效),流式 ASR 必须自建AudioRecord下沉到 UTS 插件。read(ByteBuffer)必须 direct buffer :AudioRecord-JNI拒绝堆内 buffer(Buffer direct access is not supported),返回BAD_VALUE(-2)。getMinBufferSize不校验 buffer 类型,参数全对也会踩中。- UTS 回调必须 keepAlive :存到原生模块级变量、被反复调用的 JS 回调,不加
@UTSJS.keepAlive会被 GC 回收,表现"首帧后全帧丢失"。 - 子线程
console.log可能被静默 :uni-app x 原生插件在独立采集线程里跑时,console.log可能走不通;必须用android.util.Log(logcat)才能在子线程稳定输出。
7.2 采样率纪律
- 采集端与 ASR 声明端必须同采样率 :裸 PCM 无文件头,服务端只认你声明的
sample_rate;声明与实际不符 = 音频被"快放/慢放"解析 = 失速 → 判无有效语音。 - 不要硬编码采样率 :运行时用
AudioRecord.getMinBufferSize探测本机支持的最高 ASR 采样率(16k 优先),两端用同一值。低端机可能不支持 16k,硬编码会BAD_VALUE。
7.3 排查方法论
- 对照实验缩小范围:用"完整录制能成功"排除硬件/权限/服务端,把根因锁到"实时链路独有环节"。
- 三层日志 + 二分判读:JS 层(pushPcm 计数)→ 插件层(看门狗)→ 原生层(read 配对 + JNI 错误),逐层下钻。
- 修一个验证一个:三个 bug 互相独立,修一个、看日志验证、再修下一个。一次改多处会陷入"改了不知道哪个生效"的泥潭。
- 看 logcat 不看 JS 控制台 :
read n=?、AudioRecord-JNI这些关键证据只在 logcat(Log.i)里,JS 控制台看不到。
7.4 通用经验
BAD_VALUE(-2)不等于"参数错" :可能是 buffer 类型、可能是音源路由、可能是 HAL------必须看 JNI 层的具体错误信息(Buffer direct access is not supported才指向 buffer 类型)。n=-2触发的"降级自愈"会掩盖真根因 :本项目原本有"BAD_VALUE → 切 MIC 源重建"的自愈逻辑,它以为是 VOICE_COMMUNICATION 源的问题,实际是 buffer 类型问题。修好 buffer 后n正常,自愈不再误触发。- 诊断日志要"成对" :
准备 read / read 返回 n=?成对出现才能区分"阻塞(只出准备)"vs"读满(n>0)"vs"无数据(n=0)"vs"错误(n<0)"。单条日志无法切割。
8. 参考链接
- uni-app x UTS 插件 keepAlive 装饰器:https://doc.dcloud.net.cn/uni-app-x/plugin/uts-plugin.html
- uni-app x ArrayBuffer(
fromByteBuffer/toByteBuffer):https://doc.dcloud.net.cn/uni-app-x/uts/buildin-object-api/arraybuffer.html - uni-app x
getRecorderManager(App 端 onFrameRecorded 限制):https://doc.dcloud.net.cn/uni-app-x/api/get-recorder-manager.html - uni-app x
connectSocket(二进制 send 版本要求 4.61+):https://doc.dcloud.net.cn/uni-app-x/api/websocket.html - Android
AudioRecord.read(JNI 要求 direct buffer):https://developer.android.com/reference/android/media/AudioRecord - Java
ByteBuffer.allocateDirect:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/nio/ByteBuffer.html - 阿里云百炼 Paraformer 实时语音识别 API:https://help.aliyun.com/zh/model-studio/paraformer-realtime
总结 :uni-app x 蒸汽模式下做实时流式 ASR,下沉到原生
AudioRecord后会撞上三类框架层特有的坑------采样率对齐、JNI 缓冲区类型、UTS 回调生命周期。三者互相独立,需逐一击破。核心排查手段是"对照实验 + 三层日志 + logcat 原生错误信息"。修好这三处,链路即可全通。