Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
36 KiB
录音流程 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(...) 链上临时加一行(验证后移除):
.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 临时加一个验证方法(验证后移除):
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导出才能 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 <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
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
/**
* 录音事件名 + 载荷(跨模块共享,仿 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 的导出行,新增:
export { AudioEvents, ShowRecordingRequest, RecordResult } from './src/main/ets/event/AudioEvents';
- Step 3: 构建
Run: devecocli build
Expected: BUILD SUCCESSFUL
- Step 4: Commit
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
/**
* 七牛 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<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),新增:
export { QiniuToken } from './src/main/ets/upload/QiniuToken';
- Step 3: 写结构单测(DevEco IDE 运行;devecocli 不跑 test)
entry/src/test/QiniuToken.test.ets:
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
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
/**
* AVRecorder 录音封装(设计 §6):AMR-NB(CFT_AMR + AUDIO_AMR_NB / 8kHz / 单声道 / 12.2kbps),
* 对齐 Android RAW_AMR + AMR_NB。文件落 cacheDir/<uuid>.amr,key=<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 需要则导出):
export { AmrRecorder } from './src/main/ets/providers/AmrRecorder';
- Step 3: 构建
Run: devecocli build
Expected: BUILD SUCCESSFUL
- Step 4: Commit
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 区追加:
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已在现有OutboundHandlersimport 内(若无则补);Logger已 import。
- Step 2: 加字段(类内现有字段附近)
/** 进程内实例计数:给 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: 加有效静音工具 + 重构现有音量应用
新增方法:
/** 有效静音 = 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 改为复用:
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 改为:
bridge.registerHandler(InboundHandlers.PrepareAudio, (_d: string, _cb: (resp: string) => void) => {
this.onPrepareAudio();
});
- Step 6: 加编排方法
类内新增:
/** 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;那行是为防止fileIoimport 被 lint 裁剪的占位,若文件其它处已用fileIo则删除该行。设计 §11 的"发送失败 toast"为避免在 Provider 耦合 UIContext,本期改为仅log.w+ 回投空 url(H5 收 audiourl="" 即知失败)。
- Step 7: onBackground/onDestroy 兼顾录音中
在现有 onBackground() 与 onDestroy() 内追加(停录音 + 恢复静音 + 注销 once):
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
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
/**
* 录音浮层(设计 §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
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 已在内。追加:
import { RecordingOverlay } from '../components/RecordingOverlay';
- Step 2: 加状态/字段
类内(分享相关字段附近)新增:
/** 录音浮层显隐与状态('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() 末尾追加:
this.cancels.push(EventBus.on(AudioEvents.SHOW_RECORDING, (p?: EventPayload) => this.onShowRecording(p)));
- Step 4: 加录音协作方法
类内新增:
/** 录音器已起:手指仍在按则显示浮层+计时;已松开(异步起录音期间抬手)则立即按取消回投。 */
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, ... }) 链上各加一行(不消费触摸):
.onTouch((e: TouchEvent) => this.onWebTouch(e))
- Step 6: 挂浮层到 Stack 顶层
在 build() 的 Stack(){...} 内,分享面板/指引窗叠加块之后追加:
// 录音浮层(顶层叠加,触摸穿透到下方 Web 由 onTouch 观察)
if (this.recordOverlayVisible) {
RecordingOverlay({ state: this.recordState })
}
- Step 7: aboutToDisappear 清理计时器
aboutToDisappear() 内 this.cancels = []; 之后追加:
if (this.recordTimer >= 0) {
clearInterval(this.recordTimer);
this.recordTimer = -1;
}
- Step 8: 构建
Run: devecocli build
Expected: BUILD SUCCESSFUL
- Step 9: Commit
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 数组内新增:
{ "name": "reason_microphone", "value": "用于按住说话录制语音消息" }
- Step 2: module.json5 声明 MICROPHONE 权限
module.json5 的 module.requestPermissions 数组内新增(无该数组则创建):
{
"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
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(
QiniuTokenAK/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
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 ✓