# 录音流程 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 ✓