Files
youle_app_ohos/docs/superpowers/plans/2026-06-27-录音流程.md

993 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 录音流程 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 ✓