diff --git a/docs/superpowers/plans/2026-06-27-录音流程.md b/docs/superpowers/plans/2026-06-27-录音流程.md new file mode 100644 index 0000000..5a057d7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-录音流程.md @@ -0,0 +1,992 @@ +# 录音流程 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 HarmonyOS 大厅/子游戏容器实现"按住说话"录音:`prepareaudio` → AMR-NB 录音 → 七牛端侧上传 → `getaudiourl` 回投,对 H5 行为 100% 对齐原 Android。 + +**Architecture:** 套用现成 Share 协作范式——`AudioProvider`(feature_capabilities) 持录音器/上传/回投并 emit `AudioEvents.SHOW_RECORDING`;`BridgeGameContainer`(entry) 用 Web `onTouch` 并行观察触摸做"上滑取消/抬手发送/太短"判定 + 挂纯展示浮层 `RecordingOverlay`,经一次性 `resultEvent` 回投。录音器 AMR-NB、七牛 token 端侧 HMAC-SHA1 生成(对齐原工程、不走服务端)。 + +**Tech Stack:** ArkTS / ArkUI、`@kit.MediaKit` AVRecorder、`@kit.CryptoArchitectureKit` HMAC、`@qiniu/upload`、EventBus、devecocli。 + +**验证现实(重要):** 本项目无 test 运行器,`devecocli build` 是每个任务的硬性编译关(见记忆 `arkts-verify-with-build`/`devecocli-no-test-command`);行为正确性靠真机(`devecocli run`/`log` + hdc)。纯逻辑单测写入 `entry/src/test` 作为交付物(在 DevEco IDE 跑),但 per-task 门是 build。 + +**契约:** `InboundHandlers.PrepareAudio='prepareaudio'`、`OutboundHandlers.GetAudioUrl='getaudiourl'`/`GameUiStopVoice='gameui_stop_voice'`、`GetAudioUrlResp{audiourl,time,filepath?}` 均已存在,**不改契约**。 + +--- + +## File Structure + +| 文件 | 责任 | 动作 | +|---|---|---| +| `common/src/main/ets/event/AudioEvents.ets` | 录音事件名 + 载荷 DTO | 新建 | +| `common/Index.ets` | 导出 AudioEvents/载荷 | 修改 | +| `platform/src/main/ets/upload/QiniuToken.ets` | 端侧七牛 uploadToken(HMAC-SHA1) | 新建 | +| `platform/Index.ets` | 导出 QiniuToken | 修改 | +| `feature_capabilities/src/main/ets/providers/AmrRecorder.ets` | AVRecorder AMR-NB 封装 | 新建 | +| `feature_capabilities/src/main/ets/providers/AudioProvider.ets` | prepareaudio 编排 + 分层静音 + 上传回投 | 修改 | +| `entry/src/main/ets/components/RecordingOverlay.ets` | 纯展示录音浮层 | 新建 | +| `entry/src/main/ets/pages/BridgeGameContainer.ets` | 触摸观察 + 浮层 + 事件接线 + 计时/决策 | 修改 | +| `entry/src/main/module.json5` | MICROPHONE 权限(+ 明文域名核对) | 修改 | +| `entry/src/test/QiniuToken.test.ets` | token 结构单测 | 新建 | + +--- + +## Task 1: Spike — 真机验证方案 A 手势 + AMR 编码器(决策关) + +先证伪两个最高风险,再投入全量实现。**两关任一不过都有兜底,不卡死。** + +**Files:** +- Modify(临时埋点,验证后回退): `entry/src/main/ets/pages/BridgeGameContainer.ets` +- Create(临时): `feature_capabilities/src/main/ets/providers/AmrRecorder.ets`(即 Task 4 成品,提前落以便验证 AMR) + +- [ ] **Step 1: 在大厅 Web 上临时加 onTouch 埋点** + +在 `BridgeGameContainer.build()` 大厅 `Web(...)` 链上临时加一行(验证后移除): + +```typescript +.onTouch((e: TouchEvent) => { + Logger.tag('SpikeTouch').i(`web onTouch type=${e.type} n=${e.touches.length} y=${e.touches.length > 0 ? e.touches[0].y : -1}`); +}) +``` + +- [ ] **Step 2: 落 AmrRecorder 成品(同 Task 4 代码)并临时在大厅 onPageEnd 后录 2 秒** + +先按 Task 4 完整创建 `AmrRecorder.ets`。再在 `BridgeGameContainer` 临时加一个验证方法(验证后移除): + +```typescript +private async spikeAmr(): Promise { + const ctx = this.hostCtx; + if (ctx === undefined) { return; } + const rec = new (await import('feature_capabilities')).AmrRecorder(); + const ok = await rec.start(ctx); + Logger.tag('SpikeAmr').i(`amr start ok=${ok}`); + setTimeout(() => { + rec.stop().then((fp: string) => Logger.tag('SpikeAmr').i(`amr file=${fp}`)); + }, 2000); +} +``` + +> 注:`AmrRecorder` 需先在 `feature_capabilities/Index.ets` 导出才能 import;Spike 后可保留导出或改为 provider 内部 import(Task 4 决定)。临时在大厅 `onPageEnd` 调一次 `this.spikeAmr()`。 + +- [ ] **Step 3: 构建** + +Run: `devecocli build` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 4: 真机运行并看日志** + +Run: `devecocli run` 然后 `devecocli log --keyword Spike --tail 200`(在 H5 大厅上按住并上滑、抬手) +Expected(手势): 日志连续出现 `web onTouch type=...`,含 Down/Move/Up(`TouchType` 枚举值),且 y 随手指变化。 +Expected(AMR): `amr start ok=true` 且 `amr file=...amr`;`hdc shell ls -l /*.amr` 文件 > 0 字节,头 6 字节为 `#!AMR\n`(`hdc file recv` 后查)。 + +- [ ] **Step 5: 决策 + 回退临时埋点** + +- 手势 onTouch 收到 Down/Move/Up → **方案 A 成立**,Task 7 用 `onTouch`。否则改用 `parallelGesture(PanGesture)`(方案 A 变体)或方案 C(注入 JS shim,见设计 §5),并在本计划 Task 7 注记切换。 +- AMR `ok=true` + 有效文件 → 编码器可用。否则记入 `docs/设计文档/Plan/03_风险登记册.md` 并升级(换 AudioCapturer+自编码,超本计划范围)。 +- 移除 Step 1/Step 2 的临时埋点代码(保留 `AmrRecorder.ets` 成品)。 + +Run: `devecocli build` → `BUILD SUCCESSFUL` + +- [ ] **Step 6: Commit** + +```bash +git add feature_capabilities/src/main/ets/providers/AmrRecorder.ets feature_capabilities/Index.ets +git commit -m "feat(录音): AmrRecorder(AMR-NB) + 真机证伪方案A手势/AMR编码器(T-M3 录音 spike)" +``` + +--- + +## Task 2: AudioEvents(事件通道) + +**Files:** +- Create: `common/src/main/ets/event/AudioEvents.ets` +- Modify: `common/Index.ets` + +- [ ] **Step 1: 新建 AudioEvents.ets** + +```typescript +/** + * 录音事件名 + 载荷(跨模块共享,仿 ShareEvents): + * AudioProvider(feature_capabilities) 起录音成功后 emit SHOW_RECORDING → + * BridgeGameContainer(entry) 显示录音浮层 + 观察 Web 触摸 → 用户抬手/上滑后经一次性 resultEvent 回投。 + * 载荷用具名 interface(ArkTS 严格:禁无类型对象字面量),禁传函数。 + */ +export class AudioEvents { + /** 录音器已 start 成功,请求显示录音浮层并开始观察触摸。 */ + static readonly SHOW_RECORDING: string = 'audio.showRecording'; +} + +/** SHOW_RECORDING 载荷。 */ +export interface ShowRecordingRequest { + /** 用户抬手/上滑后回投的一次性事件名(AudioProvider 已 EventBus.once 监听)。 */ + resultEvent: string; +} + +/** resultEvent 回投载荷(容器手势决策结果)。 */ +export interface RecordResult { + /** 'send'(正常发送)| 'cancel'(上滑取消)| 'tooShort'(时长<0.8s)。 */ + action: string; + /** 录音时长秒(UI 计时,对齐 Android mTime)。 */ + timeSec: number; +} +``` + +- [ ] **Step 2: 在 common/Index.ets 导出** + +仿同文件 ShareEvents 的导出行,新增: + +```typescript +export { AudioEvents, ShowRecordingRequest, RecordResult } from './src/main/ets/event/AudioEvents'; +``` + +- [ ] **Step 3: 构建** + +Run: `devecocli build` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 4: Commit** + +```bash +git add common/src/main/ets/event/AudioEvents.ets common/Index.ets +git commit -m "feat(录音): AudioEvents 事件通道(SHOW_RECORDING + RecordResult 载荷)" +``` + +--- + +## Task 3: QiniuToken(端侧七牛 uploadToken) + +**Files:** +- Create: `platform/src/main/ets/upload/QiniuToken.ets` +- Modify: `platform/Index.ets` +- Test: `entry/src/test/QiniuToken.test.ets` + +- [ ] **Step 1: 新建 QiniuToken.ets** + +```typescript +/** + * 七牛 uploadToken 端侧生成(设计 §7)。 + * ⚠️ 刻意对齐原 Android 工程:AK/SK 端侧硬编码、不走服务端——违背 CLAUDE.md 附录B 加固红线, + * 已与用户明确确认(对齐原工程)。token 算法见七牛官方:AK:urlsafeB64(HMAC_SHA1(SK,policy)):policy。 + */ +import { cryptoFramework } from '@kit.CryptoArchitectureKit'; +import { buffer, util } from '@kit.ArkTS'; +import { BusinessError } from '@kit.BasicServicesKit'; + +export class QiniuToken { + private static readonly ACCESS_KEY: string = 'ngN3rFW1j8dn7ZGpATKl7mreaNmp2Ei_l9AIhkIf'; + private static readonly SECRET_KEY: string = '6VXav9eqUCORJTYvTFOeTVRonAyk4Hrs-PIZ8jmZ'; + private static readonly BUCKET: string = 'gameauio'; + + /** 生成 uploadToken(deadline=now+1h)。 */ + static async uploadToken(): Promise { + const deadline: number = Math.floor(Date.now() / 1000) + 3600; + const policy: string = `{"scope":"${QiniuToken.BUCKET}","deadline":${deadline}}`; + const encodedPolicy: string = + QiniuToken.urlsafeBase64(new Uint8Array(buffer.from(policy, 'utf-8').buffer)); + const sign: Uint8Array = await QiniuToken.hmacSha1(QiniuToken.SECRET_KEY, encodedPolicy); + const encodedSign: string = QiniuToken.urlsafeBase64(sign); + return `${QiniuToken.ACCESS_KEY}:${encodedSign}:${encodedPolicy}`; + } + + private static async hmacSha1(secret: string, message: string): Promise { + const gen = cryptoFramework.createSymKeyGenerator('HMAC'); + const key = await gen.convertKey({ data: new Uint8Array(buffer.from(secret, 'utf-8').buffer) }); + // eslint-disable-next-line @security/no-unsafe-mac -- 七牛 uploadToken 协议强制 HMAC-SHA1,非自选算法 + const mac = cryptoFramework.createMac('SHA1'); + await mac.init(key); + await mac.update({ data: new Uint8Array(buffer.from(message, 'utf-8').buffer) }); + const out = await mac.doFinal(); + return out.data; + } + + /** 标准 Base64 → 七牛 URL-safe(保留 padding,+→- /→_)。 */ + private static urlsafeBase64(bytes: Uint8Array): string { + const helper = new util.Base64Helper(); + const std: string = helper.encodeToStringSync(bytes, util.Type.BASIC); + return std.replace(/\+/g, '-').replace(/\//g, '_'); + } +} + +// 保留 BusinessError 类型引用(catch 强转用);如未用到可删。 +export type QiniuTokenError = BusinessError; +``` + +- [ ] **Step 2: 在 platform/Index.ets 导出** + +仿现有导出(如 QiniuUploader),新增: + +```typescript +export { QiniuToken } from './src/main/ets/upload/QiniuToken'; +``` + +- [ ] **Step 3: 写结构单测(DevEco IDE 运行;devecocli 不跑 test)** + +`entry/src/test/QiniuToken.test.ets`: + +```typescript +import { describe, it, expect } from '@ohos/hypium'; +import { QiniuToken } from 'platform'; +import { util } from '@kit.ArkTS'; + +export default function QiniuTokenTest() { + describe('QiniuTokenTest', () => { + it('token_has_three_parts_and_valid_policy', 0, async () => { + const token: string = await QiniuToken.uploadToken(); + const parts: string[] = token.split(':'); + expect(parts.length).assertEqual(3); + // part0 = AccessKey + expect(parts[0]).assertEqual('ngN3rFW1j8dn7ZGpATKl7mreaNmp2Ei_l9AIhkIf'); + // part1 = 20 字节 SHA1 的 urlsafe base64(28 字符,含 '=' padding) + expect(parts[1].length).assertEqual(28); + // part2 解码回 policy JSON,含 scope=gameauio + const helper = new util.Base64Helper(); + const std: string = parts[2].replace(/-/g, '+').replace(/_/g, '/'); + const decoded: Uint8Array = helper.decodeSync(std); + const policy: string = String.fromCharCode.apply(null, Array.from(decoded)); + expect(policy.indexOf('"scope":"gameauio"') >= 0).assertTrue(); + }); + }); +} +``` + +- [ ] **Step 4: 构建** + +Run: `devecocli build` +Expected: `BUILD SUCCESSFUL`(单测在 DevEco IDE 跑:3 段 token、AK 前缀、policy 含 gameauio) + +- [ ] **Step 5: Commit** + +```bash +git add platform/src/main/ets/upload/QiniuToken.ets platform/Index.ets entry/src/test/QiniuToken.test.ets +git commit -m "feat(录音): 七牛 uploadToken 端侧生成(HMAC-SHA1,对齐原工程不走服务端) + 结构单测" +``` + +--- + +## Task 4: AmrRecorder(AVRecorder AMR-NB 封装) + +> 若 Task 1 已落此文件,本任务为复核/补全;代码以此为准。 + +**Files:** +- Create: `feature_capabilities/src/main/ets/providers/AmrRecorder.ets` +- Modify: `feature_capabilities/Index.ets`(如需导出供 Spike;正式由 AudioProvider 相对 import) + +- [ ] **Step 1: 新建 AmrRecorder.ets** + +```typescript +/** + * AVRecorder 录音封装(设计 §6):AMR-NB(CFT_AMR + AUDIO_AMR_NB / 8kHz / 单声道 / 12.2kbps), + * 对齐 Android RAW_AMR + AMR_NB。文件落 cacheDir/.amr,key=.amr。无 UI。 + */ +import { media } from '@kit.MediaKit'; +import { fileIo } from '@kit.CoreFileKit'; +import { common } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { util } from '@kit.ArkTS'; +import { Logger } from 'common'; + +export class AmrRecorder { + private readonly log: Logger = Logger.tag('AmrRecorder'); + private recorder: media.AVRecorder | undefined = undefined; + private fd: number = -1; + private filePath: string = ''; + private fileKey: string = ''; + + isActive(): boolean { + return this.recorder !== undefined; + } + + /** 七牛资源名 = 文件名(.amr)。 */ + key(): string { + return this.fileKey; + } + + /** 创建 + 配置(AMR-NB) + 开始录音。成功 true(失败已自清理)。 */ + async start(context: common.Context): Promise { + if (this.recorder !== undefined) { + return false; + } + const uuid: string = util.generateRandomUUID(true); + this.fileKey = `${uuid}.amr`; + this.filePath = `${context.cacheDir}/${this.fileKey}`; + try { + const file: fileIo.File = fileIo.openSync(this.filePath, + fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE); + this.fd = file.fd; + const recorder: media.AVRecorder = await media.createAVRecorder(); + this.recorder = recorder; + recorder.on('error', (e: BusinessError) => this.log.w(`recorder error: ${e.code} ${e.message}`)); + const profile: media.AVRecorderProfile = { + audioBitrate: 12200, + audioChannels: 1, + audioCodec: media.CodecMimeType.AUDIO_AMR_NB, + audioSampleRate: 8000, + fileFormat: media.ContainerFormatType.CFT_AMR, + }; + const config: media.AVRecorderConfig = { + audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC, + profile: profile, + url: `fd://${this.fd}`, + }; + await recorder.prepare(config); + await recorder.start(); + return true; + } catch (e) { + this.log.w(`start failed: ${(e as BusinessError).message}`); + await this.cleanup(true); + return false; + } + } + + /** 停止并保留文件,返回本地路径(上传后由调用方删)。 */ + async stop(): Promise { + const path: string = this.filePath; + await this.cleanup(false); + return path; + } + + /** 停止并删除文件(取消/太短)。 */ + async cancel(): Promise { + await this.cleanup(true); + } + + private async cleanup(deleteFile: boolean): Promise { + const recorder: media.AVRecorder | undefined = this.recorder; + this.recorder = undefined; + if (recorder !== undefined) { + try { + if (recorder.state === 'started' || recorder.state === 'paused') { + await recorder.stop(); + } + await recorder.release(); + } catch (e) { + this.log.w(`cleanup recorder failed: ${(e as BusinessError).message}`); + } + } + if (this.fd >= 0) { + try { + fileIo.closeSync(this.fd); + } catch (e) { + this.log.w(`close fd failed: ${(e as BusinessError).message}`); + } + this.fd = -1; + } + if (deleteFile && this.filePath !== '') { + try { + fileIo.unlinkSync(this.filePath); + } catch (e) { + this.log.w(`unlink failed: ${(e as BusinessError).message}`); + } + this.filePath = ''; + this.fileKey = ''; + } + } +} +``` + +- [ ] **Step 2: 导出(如 Spike 需要)** + +`feature_capabilities/Index.ets` 增(若正式仅 AudioProvider 相对 import,可不导出;Task 1 Spike 需要则导出): + +```typescript +export { AmrRecorder } from './src/main/ets/providers/AmrRecorder'; +``` + +- [ ] **Step 3: 构建** + +Run: `devecocli build` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 4: Commit** + +```bash +git add feature_capabilities/src/main/ets/providers/AmrRecorder.ets feature_capabilities/Index.ets +git commit -m "feat(录音): AmrRecorder AVRecorder(AMR-NB) 封装 start/stop/cancel" +``` + +--- + +## Task 5: AudioProvider 实现 prepareaudio(编排 + 分层静音 + 上传回投) + +**Files:** +- Modify: `feature_capabilities/src/main/ets/providers/AudioProvider.ets` + +- [ ] **Step 1: 补 import** + +文件顶部 import 区追加: + +```typescript +import { abilityAccessCtrl } from '@kit.AbilityKit'; +import { EventBus, EventPayload, AudioEvents, ShowRecordingRequest, RecordResult } from 'common'; +import { GetAudioUrlResp } from 'contracts'; +import { QiniuUploader, QiniuToken } from 'platform'; +import { AmrRecorder } from './AmrRecorder'; +``` + +> 注:`GameUiStopVoice`、`GetAudioUrl` 已在现有 `OutboundHandlers` import 内(若无则补);`Logger` 已 import。 + +- [ ] **Step 2: 加字段(类内现有字段附近)** + +```typescript + /** 进程内实例计数:给 resultEvent 命名空间隔离(仿 ShareProvider)。 */ + private static instanceCounter: number = 0; + private readonly instanceId: number = AudioProvider.instanceCounter++; + private seq: number = 0; + /** 录音期叠加静音(与 H5 voicePlaying 设定的 muted 叠加:有效静音 = muted || recordMuted)。 */ + private recordMuted: boolean = false; + /** 当前正在播的语音消息的 user(录音开始时回投 gameui_stop_voice 用)。 */ + private currentVoiceUser: string = ''; + private readonly recorder: AmrRecorder = new AmrRecorder(); + private recordResultCancel: (() => void) | undefined = undefined; +``` + +- [ ] **Step 3: 加有效静音工具 + 重构现有音量应用** + +新增方法: + +```typescript + /** 有效静音 = H5 voicePlaying 静音 || 录音期静音。 */ + private effVolume(): number { + return (this.muted || this.recordMuted) ? 0 : 1; + } + + /** 把有效音量应用到当前全部播放器。 */ + private applyAllVolume(): void { + const vol: number = this.effVolume(); + try { + this.voicePlayer?.setVolume(vol); + this.loopPlayers.forEach((p: media.AVPlayer) => p.setVolume(vol)); + this.oneShots.forEach((p: media.AVPlayer) => p.setVolume(vol)); + } catch (e) { + this.log.w(`applyAllVolume failed: ${(e as Error).message}`); + } + } +``` + +把现有 `setMuted` 改为复用: + +```typescript + private setMuted(muted: boolean): void { + this.muted = muted; + this.applyAllVolume(); + } +``` + +把 `playVoiceMessage` 与 `playSfx/spawn` 内 `player.setVolume(this.muted ? 0 : 1)` 全部替换为 `player.setVolume(this.effVolume())`(共 2 处 prepared 分支)。 + +- [ ] **Step 4: playVoiceMessage 跟踪 currentVoiceUser** + +在 `playVoiceMessage` 内 `const user: string = req.user;` 后加 `this.currentVoiceUser = user;`;在 `releaseVoice()` 方法内(设 `this.voicePlayer = undefined;` 处附近)加 `this.currentVoiceUser = '';`。 + +- [ ] **Step 5: 替换 PrepareAudio handler 为真实编排** + +把现有 `registerHandler(InboundHandlers.PrepareAudio, ...)` 的空桩 body 改为: + +```typescript + bridge.registerHandler(InboundHandlers.PrepareAudio, (_d: string, _cb: (resp: string) => void) => { + this.onPrepareAudio(); + }); +``` + +- [ ] **Step 6: 加编排方法** + +类内新增: + +```typescript + /** prepareaudio:暂停声音 → 通知在播语音停 → 申请麦克风 → 起录音 → 通知容器显示浮层。 */ + private onPrepareAudio(): void { + if (this.recorder.isActive()) { + return; // 并发/防抖(仿 Android 1s 间隔) + } + const ctx: common.UIAbilityContext | undefined = this.context; + if (ctx === undefined) { + return; + } + // 1. 暂停全部原生在播声音 + this.recordMuted = true; + this.applyAllVolume(); + // 2. 正在播的语音消息:通知 H5 停座位动画 + 释放(对齐 Android prepareAudio) + if (this.voicePlayer !== undefined && this.currentVoiceUser !== '') { + this.bridge?.callHandler(OutboundHandlers.GameUiStopVoice, this.currentVoiceUser); + this.releaseVoice(); + } + // 3. 申请麦克风权限 → 起录音 + this.ensureMicPermission(ctx).then((granted: boolean) => { + if (!granted || this.disposed) { + this.recordMuted = false; + this.applyAllVolume(); + return; + } + this.recorder.start(ctx).then((ok: boolean) => { + if (!ok || this.disposed) { + this.recordMuted = false; + this.applyAllVolume(); + if (this.disposed) { + this.recorder.cancel(); + } + return; + } + const resultEvent: string = `audio.record.${this.instanceId}.${++this.seq}`; + this.recordResultCancel = EventBus.once(resultEvent, (p?: EventPayload) => this.onRecordResult(p)); + const req: ShowRecordingRequest = { resultEvent }; + EventBus.emit(AudioEvents.SHOW_RECORDING, req); + }); + }); + } + + private async ensureMicPermission(ctx: common.UIAbilityContext): Promise { + try { + const at = abilityAccessCtrl.createAtManager(); + const res = await at.requestPermissionsFromUser(ctx, ['ohos.permission.MICROPHONE']); + return res.authResults.length > 0 && res.authResults[0] === 0; + } catch (e) { + this.log.w(`request mic perm failed: ${(e as BusinessError).message}`); + return false; + } + } + + /** 容器回投手势结果:恢复声音 → 按 action 上传或丢弃。 */ + private onRecordResult(p?: EventPayload): void { + this.recordResultCancel = undefined; + // 任何结束都恢复声音(回到 H5 voicePlaying 原态) + this.recordMuted = false; + this.applyAllVolume(); + const r: RecordResult | undefined = p as RecordResult | undefined; + const action: string = r !== undefined ? r.action : 'cancel'; + const timeSec: number = r !== undefined ? r.timeSec : 0; + if (action === 'send') { + const key: string = this.recorder.key(); + this.recorder.stop().then((filePath: string) => this.doUpload(filePath, key, timeSec)); + } else { + this.recorder.cancel(); + this.emitAudioUrl('', 0); + } + } + + private async doUpload(filePath: string, key: string, timeSec: number): Promise { + const ctx: common.UIAbilityContext | undefined = this.context; + if (ctx === undefined || filePath === '') { + this.emitAudioUrl('', 0); + return; + } + try { + const token: string = await QiniuToken.uploadToken(); + const ok: boolean = await QiniuUploader.upload(ctx, filePath, key, () => Promise.resolve(token)); + try { + fileIo.closeSync; // no-op guard (保持 import 不被裁剪) + fileIo.unlinkSync(filePath); + } catch (e) { + this.log.w(`cleanup uploaded file failed: ${(e as Error).message}`); + } + if (ok) { + this.emitAudioUrl(`http://gameaudio.daoqi88.cn/${key}`, timeSec); + } else { + this.log.w(`qiniu upload failed key=${key}`); + this.emitAudioUrl('', 0); + } + } catch (e) { + this.log.w(`upload error: ${(e as BusinessError).message}`); + this.emitAudioUrl('', 0); + } + } + + private emitAudioUrl(audiourl: string, time: number): void { + const resp: GetAudioUrlResp = { + audiourl: audiourl, + time: time, + filepath: audiourl !== '' ? audiourl : undefined, + }; + this.bridge?.callHandler(OutboundHandlers.GetAudioUrl, JSON.stringify(resp)); + } +``` + +> 注:`doUpload` 里 `fileIo.closeSync;` 那行是为防止 `fileIo` import 被 lint 裁剪的占位,若文件其它处已用 `fileIo` 则删除该行。设计 §11 的"发送失败 toast"为避免在 Provider 耦合 UIContext,本期改为仅 `log.w` + 回投空 url(H5 收 audiourl="" 即知失败)。 + +- [ ] **Step 7: onBackground/onDestroy 兼顾录音中** + +在现有 `onBackground()` 与 `onDestroy()` 内追加(停录音 + 恢复静音 + 注销 once): + +```typescript + if (this.recorder.isActive()) { + this.recorder.cancel(); + } + this.recordMuted = false; + if (this.recordResultCancel !== undefined) { + this.recordResultCancel(); + this.recordResultCancel = undefined; + } +``` + +- [ ] **Step 8: 构建** + +Run: `devecocli build` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 9: Commit** + +```bash +git add feature_capabilities/src/main/ets/providers/AudioProvider.ets +git commit -m "feat(录音): AudioProvider 实现 prepareaudio 编排+分层静音+七牛上传+getaudiourl 回投" +``` + +--- + +## Task 6: RecordingOverlay(纯展示录音浮层) + +**Files:** +- Create: `entry/src/main/ets/components/RecordingOverlay.ets` + +- [ ] **Step 1: 新建 RecordingOverlay.ets** + +```typescript +/** + * 录音浮层(设计 §10,纯展示,仿微信"按住说话")。状态由容器手势驱动,自身不处理触摸 + * (HitTestMode.None 让触摸穿透到下方 Web,由 Web.onTouch 观察手势)。 + * - recording:跳动波形 + "手指上滑,取消发送" + * - cancel:红底 + "✕" + "松开手指,取消发送" + * - tooShort:黑底 + "!" + "说话时间太短" + */ +@Component +export struct RecordingOverlay { + /** 'recording' | 'cancel' | 'tooShort' */ + @Prop state: string; + @State private bars: number[] = [0.4, 0.7, 0.5, 0.9, 0.6, 0.8, 0.45]; + private timer: number = -1; + + aboutToAppear(): void { + this.timer = setInterval(() => { + this.bars = this.bars.map(() => 0.25 + 0.75 * Math.random()); + }, 120); + } + + aboutToDisappear(): void { + if (this.timer >= 0) { + clearInterval(this.timer); + this.timer = -1; + } + } + + private hintText(): string { + if (this.state === 'cancel') { + return '松开手指,取消发送'; + } + if (this.state === 'tooShort') { + return '说话时间太短'; + } + return '手指上滑,取消发送'; + } + + build() { + Column() { + Column({ space: 14 }) { + if (this.state === 'recording') { + Row({ space: 4 }) { + ForEach(this.bars, (h: number, i: number) => { + Column() + .width(5) + .height(48 * h) + .borderRadius(3) + .backgroundColor('#FFFFFF') + }, (h: number, i: number) => i.toString()) + } + .height(52) + .alignItems(VerticalAlign.Center) + } else { + Text(this.state === 'cancel' ? '✕' : '!') + .fontSize(42) + .fontWeight(FontWeight.Bold) + .fontColor('#FFFFFF') + } + Text(this.hintText()) + .fontSize(14) + .fontColor('#FFFFFF') + } + .width(168) + .height(168) + .justifyContent(FlexAlign.Center) + .borderRadius(18) + .backgroundColor(this.state === 'cancel' ? '#C0392B' : 'rgba(0, 0, 0, 0.78)') + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .hitTestBehavior(HitTestMode.None) + } +} +``` + +- [ ] **Step 2: 构建** + +Run: `devecocli build` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 3: Commit** + +```bash +git add entry/src/main/ets/components/RecordingOverlay.ets +git commit -m "feat(录音): RecordingOverlay 录音浮层(波形/上滑取消/太短,触摸穿透)" +``` + +--- + +## Task 7: BridgeGameContainer 接线(触摸观察 + 浮层 + 事件 + 计时/决策) + +> 若 Task 1 判定 `onTouch` 不可用,将本任务的 `.onTouch(...)` 改为 `.parallelGesture(PanGesture()...)` 或方案 C;其余逻辑不变。 + +**Files:** +- Modify: `entry/src/main/ets/pages/BridgeGameContainer.ets` + +- [ ] **Step 1: 补 import** + +现有 `common` import 块追加 `AudioEvents, ShowRecordingRequest, RecordResult`,并确保 `EventBus, EventPayload` 已在内。追加: + +```typescript +import { RecordingOverlay } from '../components/RecordingOverlay'; +``` + +- [ ] **Step 2: 加状态/字段** + +类内(分享相关字段附近)新增: + +```typescript + /** 录音浮层显隐与状态('recording'|'cancel'|'tooShort')。 */ + @State private recordOverlayVisible: boolean = false; + @State private recordState: string = 'recording'; + /** 当前录音的一次性回投事件名。 */ + private recordResultEvent: string = ''; + /** 触摸跟踪(始终更新,供录音激活时读取)。 */ + private touchDown: boolean = false; + private touchStartY: number = 0; + /** 录音激活中(收到 SHOW_RECORDING 且手指在按)。 */ + private recordingActive: boolean = false; + private wantCancel: boolean = false; + private recordSeconds: number = 0; + private recordTimer: number = -1; +``` + +- [ ] **Step 3: 订阅 SHOW_RECORDING** + +`subscribeEvents()` 末尾追加: + +```typescript + this.cancels.push(EventBus.on(AudioEvents.SHOW_RECORDING, (p?: EventPayload) => this.onShowRecording(p))); +``` + +- [ ] **Step 4: 加录音协作方法** + +类内新增: + +```typescript + /** 录音器已起:手指仍在按则显示浮层+计时;已松开(异步起录音期间抬手)则立即按取消回投。 */ + private onShowRecording(p?: EventPayload): void { + if (p === undefined) { + return; + } + const req = p as ShowRecordingRequest; + this.recordResultEvent = req.resultEvent; + if (!this.touchDown) { + this.emitRecordResult('cancel', 0); + return; + } + this.recordingActive = true; + this.wantCancel = false; + this.recordSeconds = 0; + this.recordState = 'recording'; + this.recordOverlayVisible = true; + this.recordTimer = setInterval(() => { + this.recordSeconds += 0.1; + }, 100); + } + + /** Web 触摸观察(不消费):Down 记起点 / Move 判上滑取消 / Up 结束决策。 */ + private onWebTouch(e: TouchEvent): void { + if (e.type === TouchType.Down) { + this.touchDown = true; + this.touchStartY = e.touches.length > 0 ? e.touches[0].y : 0; + if (this.recordingActive) { + this.wantCancel = false; + this.recordState = 'recording'; + } + } else if (e.type === TouchType.Move) { + if (this.recordingActive && e.touches.length > 0) { + const dy: number = this.touchStartY - e.touches[0].y; + this.wantCancel = dy > 100; + this.recordState = this.wantCancel ? 'cancel' : 'recording'; + } + } else if (e.type === TouchType.Up || e.type === TouchType.Cancel) { + this.touchDown = false; + if (this.recordingActive) { + this.finishRecording(); + } + } + } + + /** 抬手:按时长/上滑决定 send/cancel/tooShort,撤浮层并回投。 */ + private finishRecording(): void { + this.recordingActive = false; + if (this.recordTimer >= 0) { + clearInterval(this.recordTimer); + this.recordTimer = -1; + } + const elapsed: number = this.recordSeconds; + if (elapsed < 0.8) { + this.recordState = 'tooShort'; + setTimeout(() => { + this.recordOverlayVisible = false; + }, 1300); + this.emitRecordResult('tooShort', elapsed); + } else if (this.wantCancel) { + this.recordOverlayVisible = false; + this.emitRecordResult('cancel', elapsed); + } else { + this.recordOverlayVisible = false; + this.emitRecordResult('send', elapsed); + } + } + + private emitRecordResult(action: string, timeSec: number): void { + const ev: string = this.recordResultEvent; + this.recordResultEvent = ''; + if (ev !== '') { + const r: RecordResult = { action: action, timeSec: timeSec }; + EventBus.emit(ev, r); + } + } +``` + +- [ ] **Step 5: 给两个 Web 挂 onTouch** + +在大厅 `Web({ src: this.lobbySrc(), ... })` 链与子游戏 `Web({ src: this.subgameUrl, ... })` 链上各加一行(不消费触摸): + +```typescript + .onTouch((e: TouchEvent) => this.onWebTouch(e)) +``` + +- [ ] **Step 6: 挂浮层到 Stack 顶层** + +在 `build()` 的 `Stack(){...}` 内,分享面板/指引窗叠加块**之后**追加: + +```typescript + // 录音浮层(顶层叠加,触摸穿透到下方 Web 由 onTouch 观察) + if (this.recordOverlayVisible) { + RecordingOverlay({ state: this.recordState }) + } +``` + +- [ ] **Step 7: aboutToDisappear 清理计时器** + +`aboutToDisappear()` 内 `this.cancels = [];` 之后追加: + +```typescript + if (this.recordTimer >= 0) { + clearInterval(this.recordTimer); + this.recordTimer = -1; + } +``` + +- [ ] **Step 8: 构建** + +Run: `devecocli build` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 9: Commit** + +```bash +git add entry/src/main/ets/pages/BridgeGameContainer.ets +git commit -m "feat(录音): 容器接 Web触摸观察+录音浮层+SHOW_RECORDING事件+计时/上滑取消/太短决策" +``` + +--- + +## Task 8: 权限与明文域名 + +**Files:** +- Modify: `entry/src/main/module.json5` +- Modify(如需 reason 文案): `entry/src/main/resources/base/element/string.json` + +- [ ] **Step 1: 加 string 资源(麦克风用途文案)** + +`entry/src/main/resources/base/element/string.json` 的 `string` 数组内新增: + +```json +{ "name": "reason_microphone", "value": "用于按住说话录制语音消息" } +``` + +- [ ] **Step 2: module.json5 声明 MICROPHONE 权限** + +`module.json5` 的 `module.requestPermissions` 数组内新增(无该数组则创建): + +```json +{ + "name": "ohos.permission.MICROPHONE", + "reason": "$string:reason_microphone", + "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } +} +``` + +- [ ] **Step 3: 核对明文 HTTP 放行** + +确认 `gameaudio.daoqi88.cn` 回放走的明文 HTTP 已被现有网络安全配置覆盖(H5 经 `mediaTypeAudio` 远程 URL 播放该域名已是既有路径)。Run: `grep -n "cleartext\|security\|domain" entry/src/main/module.json5`。若有显式白名单且缺该域名,则补 `gameaudio.daoqi88.cn`;若无白名单机制(默认允许明文)则跳过。 + +- [ ] **Step 4: 构建** + +Run: `devecocli build` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 5: Commit** + +```bash +git add entry/src/main/module.json5 entry/src/main/resources/base/element/string.json +git commit -m "feat(录音): 声明 MICROPHONE 权限 + 核对 gameaudio 明文域名" +``` + +--- + +## Task 9: 端到端真机验证 + 进度更新 + +**Files:** +- Modify: `docs/设计文档/Plan/01_任务分解WBS.md`(标记录音任务状态) + +- [ ] **Step 1: 真机端到端验证** + +Run: `devecocli run`,在 H5 大厅长按"按住说话"按钮: + +按设计 §14 验收口径逐项核对(用 `devecocli log --keyword AudioProvider --keyword AmrRecorder --tail 300` + hdc 截图): +- 长按 → 录音浮层出现、波形跳动、游戏声被静音 +- 上滑 → 浮层转红"松开取消";松手 → 不上传、声音恢复、H5 收 `getaudiourl{audiourl:"",time:0}` +- 正常松手 ≥0.8s → 七牛上传成功、H5 收 `getaudiourl{audiourl,time,filepath}`、声音恢复、H5 能回放该 `.amr`(经 `mediaTypeAudio` 播放链路) +- <0.8s 松手 → 浮层"说话时间太短"、不上传、`getaudiourl{"",0}`、声音恢复 +- 全程 H5 未改一行 + +- [ ] **Step 2: 失败排查口径** + +- 上传 401/失败 → 查 token(`QiniuToken` AK/SK/scope/deadline)与网络;`getaudiourl` 应回空。 +- 无声/录不到 → 查 MICROPHONE 授权弹窗是否出现、`AmrRecorder.start ok`。 +- 手势不灵 → 回到 Task 1 决策,切 `parallelGesture`/方案 C。 + +- [ ] **Step 3: 更新 WBS 进度** + +在 `docs/设计文档/Plan/01_任务分解WBS.md` 把录音相关任务(prepareaudio/录音)标记为 `☑ 完成`;如手势走了兜底方案,在 `03_风险登记册.md` 登记。 + +- [ ] **Step 4: Commit** + +```bash +git add docs/设计文档/Plan/01_任务分解WBS.md docs/设计文档/Plan/03_风险登记册.md +git commit -m "docs(录音): 端到端真机验证通过, 更新 WBS 进度(录音流程完成)" +``` + +--- + +## 自检对照(spec coverage) + +- D1 AMR:Task 4 AmrRecorder(CFT_AMR+AUDIO_AMR_NB/8k/mono/12.2k)✓ +- D2 端侧 token:Task 3 QiniuToken(HMAC-SHA1,AK/SK 硬编码 + 注释)✓ +- D3 手势方案 A:Task 1 证伪 + Task 7 onTouch(兜底 parallelGesture/方案C)✓ +- D4 暂停范围(全部声音 + gameui_stop_voice):Task 5 Step 6 onPrepareAudio ✓ +- D5 恢复时机(send/cancel/tooShort 都恢复):Task 5 onRecordResult 顶部恢复 ✓ +- D6 装饰波形:Task 6 RecordingOverlay ✓ +- 分层静音不盖 H5 voicePlaying:Task 5 effVolume = muted||recordMuted ✓ +- 权限/明文:Task 8 ✓ +- 并发/防抖、切后台清理:Task 5 isActive 守卫 + onBackground/onDestroy ✓ +- getaudiourl 取消语义 {"",0} / 成功 {url,time,filepath}:Task 5 emitAudioUrl ✓