docs(录音): 录音流程实施计划(9任务,含真机spike决策关+TDD/build验证)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lanterngamescn
2026-06-27 16:43:18 +08:00
co-authored by Claude Opus 4.8
parent 3a224fd7e9
commit 2e81c0ee47
@@ -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` | 端侧七牛 uploadTokenHMAC-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<void> {
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` 导出才能 importSpike 后可保留导出或改为 provider 内部 importTask 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 随手指变化。
ExpectedAMR: `amr start ok=true``amr file=...amr``hdc shell ls -l <cacheDir>/*.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';
/** 生成 uploadTokendeadline=now+1h)。 */
static async uploadToken(): Promise<string> {
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<Uint8Array> {
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 base6428 字符,含 '=' 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: AmrRecorderAVRecorder 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-NBCFT_AMR + AUDIO_AMR_NB / 8kHz / 单声道 / 12.2kbps),
* 对齐 Android RAW_AMR + AMR_NB。文件落 cacheDir/<uuid>.amrkey=<uuid>.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;
}
/** 七牛资源名 = 文件名(<uuid>.amr)。 */
key(): string {
return this.fileKey;
}
/** 创建 + 配置(AMR-NB) + 开始录音。成功 true(失败已自清理)。 */
async start(context: common.Context): Promise<boolean> {
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<string> {
const path: string = this.filePath;
await this.cleanup(false);
return path;
}
/** 停止并删除文件(取消/太短)。 */
async cancel(): Promise<void> {
await this.cleanup(true);
}
private async cleanup(deleteFile: boolean): Promise<void> {
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<boolean> {
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<void> {
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` + 回投空 urlH5 收 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 AMRTask 4 AmrRecorderCFT_AMR+AUDIO_AMR_NB/8k/mono/12.2k)✓
- D2 端侧 tokenTask 3 QiniuTokenHMAC-SHA1AK/SK 硬编码 + 注释)✓
- D3 手势方案 ATask 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 voicePlayingTask 5 effVolume = muted||recordMuted ✓
- 权限/明文:Task 8 ✓
- 并发/防抖、切后台清理:Task 5 isActive 守卫 + onBackground/onDestroy ✓
- getaudiourl 取消语义 {"",0} / 成功 {url,time,filepath}Task 5 emitAudioUrl ✓