Files
youle_app_ohos/docs/superpowers/specs/2026-06-27-录音流程-design.md
lanterngamescnandClaude Opus 4.8 3a224fd7e9 docs(录音): 录音流程设计文档(prepareaudio→AMR→七牛端侧token→getaudiourl)
对齐 Android webviewActivity 录音流程;手势走方案A(Web父层并行手势)、
AMR-NB(CFT_AMR+AUDIO_AMR_NB)、七牛token端侧生成(对齐原工程不走服务端)、
分层静音(录音暂停全部原生声音+gameui_stop_voice,取消/太短/发送都恢复)。
对应契约 §8.7/§9/§10.6,无需改契约。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:33:30 +08:00

12 KiB
Raw Permalink Blame History

录音流程设计(prepareaudio → AMR 录音 → 七牛上传 → getaudiourl

  • 日期:2026-06-27
  • 契约依据:§8.7 / §9 / §10.6InboundHandlers.PrepareAudio='prepareaudio'OutboundHandlers.GetAudioUrl='getaudiourl'GameUiStopVoice='gameui_stop_voice'、DTO GetAudioUrlResp{audiourl,time,filepath?}(均已存在,无需改契约
  • 对照基准:原 Android webviewActivity.javaprepareAudio()onTouchupload()reset()+ com.nickming.view.AudioManager

1. 背景与现状

当前 HarmonyOS 侧 AudioProvider.PrepareAudio 是空桩(只打日志),录音三段(录音 / 手势 / 上传回传)全缺。本设计落地完整录音流程,对 H5 边界行为 100% 对齐 Android,原生内部按 HarmonyOS 最佳实践自由实现。

原 Android 录音是 H5 触发 + 原生手势 的混合流程:

  • H5 按钮 touchstart → 桥 prepareaudioH5 只调这一次;契约"松手/移动/取消"入站接口)
  • 原生 WebView onTouchreturn false 不吞)跟踪 DOWN/MOVE/UP:上滑取消、抬手发送、mTime<0.8s 太短
  • 正常结束 → 七牛上传(端侧硬编码 AK/SK)→ 回投 getaudiourl{audiourl,time,filepath}
  • 取消/太短 → 删文件 → 回投 getaudiourl{audiourl:"",time:0}

2. 关键决策(已与用户确认)

# 决策 结论
D1 录音格式 必须 AMR(跨端 App 互通)。HarmonyOS 支持:CFT_AMR 容器 + AUDIO_AMR_NB 编码(API 18+AVCodec 格式表明确支持)
D2 七牛 token 对齐原工程、不走服务端:端侧用 Android 同款 AK/SK 生成 uploadToken。⚠️ 刻意违背 CLAUDE.md 加固红线,代码注释标注
D3 手势接入 方案 A:Web 外层绑并行手势观察触摸(对齐 Android onTouch)。真机证伪不过则切方案 C(注入 JS shim)
D4 暂停声音范围 全部原生在播声音(语音消息+BGM+音效)静音;并对正在播的语音消息回投 gameui_stop_voice(user) 通知 H5
D5 恢复声音时机 取消/太短/正常发送 三种结束都恢复(恢复到 H5 voicePlaying 原本静音态,不盖 H5 意图)
D6 UI 波形 装饰动画(计时器驱动),非真实振幅——AVRecorder 不暴露振幅 API,用户已接受

3. 架构

套用现成的 Share 协作范式(ShareProvider emit EventBus → 容器弹叠加 UI → 一次性回投)。

角色 归属 职责
AudioProvider feature_capabilities 注册 prepareaudio;持有 AmrRecorder;端侧生成七牛 token、上传、回投 getaudiourl;声音暂停/恢复(分层静音)
AmrRecorder(新) feature_capabilities 封装 @kit.MediaKit AVRecorderAMR-NB),录到 cache 目录 UUID.amr,无 UIstart/stop/cancel(删文件)
BridgeGameContainer entry/pages Web 外层绑并行手势观察 DOWN/MOVE/UP + UI 计时器 + 太短判定;挂 RecordingOverlay;经 AudioEvents 与 Provider 协作
RecordingOverlay(新) entry/components 纯展示浮层,由容器手势状态驱动(复刻 Android DialogManager 只画不管逻辑)
AudioEvents(新) common/event Provider↔容器 事件通道(仿 ShareEvents
QiniuToken(新) platform 端侧 HMAC-SHA1 生成七牛 uploadToken

依赖方向不变(单向向下):entry(①②) → feature_capabilities(④) / platform(⑥) / common(横切)。桥核心零感知不变。

4. 数据流(职责切分对齐 Android)

H5 touchstart → prepareaudio ──► AudioProvider.onPrepareAudio()
                                   │ 1. recordMuted=true(静音全部原生在播声音)
                                   │ 2. 若有在播语音消息 → callHandler(gameui_stop_voice, user) + releaseVoice
                                   │ 3. 申请 MICROPHONE 权限(首次)
                                   │ 4. AmrRecorder.start()  ──成功──► emit AudioEvents.SHOW_RECORDING{resultEvent}
                                   │                          ──失败──► recordMuted=false 恢复,静默放弃(不弹浮层)
                                   ▼
容器收到 SHOW_RECORDING:显示 RecordingOverlay + 激活手势观察 + 启动 UI 计时器(mTime: 每100ms+0.1)
   · MOVE 上滑 > 阈值 → 想取消态(红,"松开取消");否则 录音中态
   · UP
       - mTime < 0.8s   → 太短态显示 1.3s → emit resultEvent{action:'tooShort'}
       - 上滑(想取消)    → emit resultEvent{action:'cancel'}
       - 正常           → emit resultEvent{action:'send', timeSec: mTime}
   · 撤浮层 + 解除手势观察
                                   ▼
AudioProvider 收到 resultEvent{action}
   · send     → AmrRecorder.stop() → 端侧 token → QiniuUploader.upload(UUID.amr)
                  ├ 成功 → callHandler(getaudiourl, {audiourl:url, time:timeSec, filepath:url})
                  └ 失败 → callHandler(getaudiourl, {audiourl:"", time:0}) + toast
   · cancel/tooShort → AmrRecorder.cancel()(停+删) → callHandler(getaudiourl, {audiourl:"", time:0})
   · 任何 action 结束 → recordMuted=false(恢复声音)

计时归属:UI 计时器在容器(复刻 Android mTimemGetVoiceLevelRunnable 每 100ms +0.1,非录音器时长)。send 时传回的 timeSec 即此 UI 计时值。

事件协议(AudioEvents

  • Provider → 容器:SHOW_RECORDING { resultEvent: string }
  • 容器 → Provideremit resultEvent 携带 { action: 'send'|'cancel'|'tooShort', timeSec?: number }
  • 复用 Share 既有的"一次性 once 回投 + 悬挂保护"约定(新请求来时先按 cancel 回投旧请求)

5. 手势接入(方案 A,对齐 Android onTouch

H5 按下时手指已落在 Web 上,原生须跟踪进行中的同一次触摸。

  • 在包裹激活 Web 的 Stack 上绑 parallelGesture(PanGesture) + onTouch,与 Web 内部事件并行识别、不吞触摸。
  • 仅在录音激活期(收到 SHOW_RECORDING 到 UP 之间)消费这些回调;非录音期忽略,绝不影响 H5 正常触摸/滚动。
  • 上滑取消阈值对齐 Android wantToCancel:位移 > 100vp(按 Android 的 100px 量级取等价 vp)。
  • 浮层纯展示,状态机:录音中 / 想取消 / 太短,完全由手势回调驱动。

兜底方案 C(A 真机证伪不过时):prepareaudio 后原生注入 JS 给 documenttouchmove/touchend,坐标经 yy:// 回报,结束移除。对 H5 源码零改动(注入在原生侧)。

6. AMR 录音参数(对齐 Android RAW_AMR + AMR_NB

media.AVRecorderProfile

  • audioSourceType = media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC
  • fileFormat = media.ContainerFormatType.CFT_AMR
  • audioCodec = media.CodecMimeType.AUDIO_AMR_NB
  • audioSampleRate = 8000AMR-NB 标准)
  • audioChannels = 1
  • audioBitrate = 12200AMR-NB 最高档)

输出:cache 目录下 UUID.amrutil.generateRandomUUID()),AVRecorder 用 fdfileIo.open 后取 fd://<fd>)。AVRecorder 状态机:prepare → start → stop → releasestop 后取文件路径上传。

7. 七牛 token 端侧生成(D2,对齐原工程)

  • AK/SKAndroid 同款(ngN3rFW1j8dn7ZGpATKl7mreaNmp2Ei_l9AIhkIf / 6VXav9eqUCORJTYvTFOeTVRonAyk4Hrs-PIZ8jmZ),bucket/scope gameauio,回放域名 gameaudio.daoqi88.cn
  • token 算法:AK : urlsafeBase64(HMAC_SHA1(SK, encodedPolicy)) : encodedPolicy,其中 encodedPolicy = urlsafeBase64(JSON({scope:"gameauio", deadline: nowSec+3600}))
  • HMAC-SHA1 经 @kit.CryptoArchitectureKit cryptoFrameworkHMAC + SHA1
  • ⚠️ lint @security/no-unsafe-mac 会报 SHA1 → 行内 // eslint-disable + 注释说明七牛协议强制 SHA1、且 AK/SK 端侧为刻意对齐原工程
  • key=UUID.amr → 回放 http://gameaudio.daoqi88.cn/UUID.amr
  • 复用现成 platform/QiniuUploader.upload(ctx, localPath, key, tokenProvider)tokenProvider = () => Promise.resolve(token)

8. 声音暂停/恢复(D4/D5,分层静音)

  • AudioProvider 现有 mutedH5 经 voicePlaying 设定)之上加 recordMuted: boolean
  • 有效静音 = muted || recordMuted;重构 setVolume 调用点统一走有效静音
  • 录音开始:recordMuted=true + 应用;任何结束:recordMuted=false + 应用 → 恢复到 muted 原态(不解掉 H5 主动静音)
  • 录音开始时若 voicePlayer 在播:取其 user(新增 currentVoiceUser 跟踪)→ callHandler(gameui_stop_voice, currentVoiceUser) + releaseVoice()(对齐 Android

9. 权限

  • module.json5 声明 ohos.permission.MICROPHONEreason 文案 + abilities 内 when:"inuse"
  • 首次 prepareaudio 运行时 abilityAccessCtrl.requestPermissionsFromUser
  • 拒绝 → 不录、recordMuted=false 恢复、静默放弃(不回 getaudiourl,对齐 Android resetPrepared
  • gameaudio.daoqi88.cn 明文 HTTP:确认/补充到 module.json5 网络安全 cleartext 放行清单

10. UI 浮层(D6,现代微信式)

  • 居中圆角深色半透卡(约 160×160vp):顶部麦克风图标 + 装饰音量波形/跳动竖条(计时器驱动随机高度)+ 底部提示
  • 录音中:提示"手指上滑,取消发送"
  • 想取消:卡片转红、图标→取消/叉、提示"松开手指,取消发送"
  • 太短:图标→感叹号、文案"说话时间太短",显示 ~1.3s 后自动消失
  • 叠加层级与 SharePanel 同级(容器 Stack 顶层),不抢系统安全区
  • 资源:麦克风/取消图标若 docs/Res 无现成,用 ArkUI symbol/矢量或简单绘制

11. 错误处理

场景 处理
AVRecorder create/prepare/start 失败 不弹浮层;recordMuted=false 恢复;可选 toast;不回 getaudiourl
上传失败 getaudiourl{audiourl:"",time:0} + toast"语音发送失败";恢复声音
录音中切后台/容器销毁 AmrRecorder.cancel()(停+删)+ 恢复声音 + 撤浮层
并发:录音中再来 prepareaudio 忽略(isRecording 守卫,仿 Android 1s 防抖)
权限拒绝 见 §9

12. 受影响文件

  • 新增common/.../event/AudioEvents.etsplatform/.../upload/QiniuToken.etsfeature_capabilities/.../providers/AmrRecorder.etsentry/.../components/RecordingOverlay.ets
  • 修改feature_capabilities/.../providers/AudioProvider.ets(实现 prepareaudio + 分层静音 + currentVoiceUser)、entry/.../pages/BridgeGameContainer.ets(手势观察 + overlay + AudioEvents 接线)、entry/.../di/AppModule.ets(如需注入七牛参数)、module.json5MICROPHONE + cleartext 域名)
  • 无需改contractsDTO/枚举齐全)、桥核心

13. 真机阻塞验证关(实施第一步,仿 M1/M2)

  1. 手势:真机证伪方案 A——Web 父层并行手势能否稳定收到 DOWN/MOVE/UP。不过 → 切方案 C。
  2. AMR 编码器:真机(TLR-AL00 / API 23)验证 CFT_AMR+AUDIO_AMR_NB AVRecorder 能创建/录出有效 .amr("编码能力设备强相关")。
  3. 端到端:录音 → 端侧 token → 七牛上传 → getaudiourl → H5 回放 .amr(经现有 mediaTypeAudio 远程 URL 播放链路验证可放)。

14. 验收口径

  • H5 长按按钮 → 现代录音浮层出现、波形跳动、声音被静音
  • 上滑 → 转红"松开取消";松开 → 不发送、声音恢复、getaudiourl{"",0}
  • 正常松手且 ≥0.8s → 上传成功、getaudiourl{audiourl,time,filepath}、声音恢复、H5 能回放该语音
  • <0.8s 松手 → "说话时间太短"、不上传、getaudiourl{"",0}、声音恢复
  • 全程 H5 一行不改