From 3a224fd7e9577bdfe3111b4a71a4e3b367ffeafe Mon Sep 17 00:00:00 2001 From: lanterngamescn Date: Sat, 27 Jun 2026 16:33:30 +0800 Subject: [PATCH] =?UTF-8?q?docs(=E5=BD=95=E9=9F=B3):=20=E5=BD=95=E9=9F=B3?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3(preparea?= =?UTF-8?q?udio=E2=86=92AMR=E2=86=92=E4=B8=83=E7=89=9B=E7=AB=AF=E4=BE=A7to?= =?UTF-8?q?ken=E2=86=92getaudiourl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐 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) --- .../specs/2026-06-27-录音流程-design.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-27-录音流程-design.md diff --git a/docs/superpowers/specs/2026-06-27-录音流程-design.md b/docs/superpowers/specs/2026-06-27-录音流程-design.md new file mode 100644 index 0000000..b4bc0cb --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-录音流程-design.md @@ -0,0 +1,159 @@ +# 录音流程设计(prepareaudio → AMR 录音 → 七牛上传 → getaudiourl) + +- 日期:2026-06-27 +- 契约依据:§8.7 / §9 / §10.6;`InboundHandlers.PrepareAudio='prepareaudio'`、`OutboundHandlers.GetAudioUrl='getaudiourl'`、`GameUiStopVoice='gameui_stop_voice'`、DTO `GetAudioUrlResp{audiourl,time,filepath?}`(均已存在,**无需改契约**) +- 对照基准:原 Android `webviewActivity.java`(`prepareAudio()`、`onTouch`、`upload()`、`reset()`)+ `com.nickming.view.AudioManager` + +## 1. 背景与现状 + +当前 HarmonyOS 侧 `AudioProvider.PrepareAudio` 是空桩(只打日志),录音三段(录音 / 手势 / 上传回传)全缺。本设计落地完整录音流程,**对 H5 边界行为 100% 对齐 Android**,原生内部按 HarmonyOS 最佳实践自由实现。 + +原 Android 录音是 **H5 触发 + 原生手势** 的混合流程: +- H5 按钮 `touchstart` → 桥 `prepareaudio`(H5 只调这一次;契约**无**"松手/移动/取消"入站接口) +- 原生 WebView `onTouch`(return 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` AVRecorder(AMR-NB),录到 cache 目录 `UUID.amr`,无 UI;start/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 `mTime` 由 `mGetVoiceLevelRunnable` 每 100ms +0.1,非录音器时长)。`send` 时传回的 `timeSec` 即此 UI 计时值。 + +**事件协议(AudioEvents)**: +- Provider → 容器:`SHOW_RECORDING { resultEvent: string }` +- 容器 → Provider:emit `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 给 `document` 挂 `touchmove/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 = 8000`(AMR-NB 标准) +- `audioChannels = 1` +- `audioBitrate = 12200`(AMR-NB 最高档) + +输出:cache 目录下 `UUID.amr`(`util.generateRandomUUID()`),AVRecorder 用 fd(`fileIo.open` 后取 `fd://`)。AVRecorder 状态机:`prepare → start → stop → release`,stop 后取文件路径上传。 + +## 7. 七牛 token 端侧生成(D2,对齐原工程) + +- AK/SK:Android 同款(`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` cryptoFramework(`HMAC` + `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` 现有 `muted`(H5 经 `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.MICROPHONE`(reason 文案 + 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.ets`、`platform/.../upload/QiniuToken.ets`、`feature_capabilities/.../providers/AmrRecorder.ets`、`entry/.../components/RecordingOverlay.ets` +- **修改**:`feature_capabilities/.../providers/AudioProvider.ets`(实现 prepareaudio + 分层静音 + currentVoiceUser)、`entry/.../pages/BridgeGameContainer.ets`(手势观察 + overlay + AudioEvents 接线)、`entry/.../di/AppModule.ets`(如需注入七牛参数)、`module.json5`(MICROPHONE + cleartext 域名) +- **无需改**:contracts(DTO/枚举齐全)、桥核心 + +## 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 一行不改