feat(录音): AudioProvider 实现 prepareaudio 编排+分层静音+七牛上传+getaudiourl 回投

- 补 import: abilityAccessCtrl/EventBus/AudioEvents/ShowRecordingRequest/RecordResult/GetAudioUrlResp/QiniuUploader/QiniuToken/AmrRecorder
- 新增字段: instanceId/seq/recordMuted/currentVoiceUser/recorder/recordResultCancel
- 新增 effVolume()/applyAllVolume() 实现分层静音(muted||recordMuted)
- 重构 setMuted() 改用 applyAllVolume()
- 原 2 处 setVolume(muted?0:1) 换成 setVolume(effVolume())
- playVoiceMessage prepared 分支、spawn prepared 分支各 1 处
- 跟踪 currentVoiceUser:playVoiceMessage 创建时设置,releaseVoice() 时清空
- PrepareAudio handler 改为调用 onPrepareAudio() 真实编排
- 新增方法:onPrepareAudio/ensureMicPermission/onRecordResult/doUpload/emitAudioUrl
- onBackground/onDestroy 各追加:cancel 录音/清 recordMuted/取消 recordResultCancel 订阅
- 对应契约 §10.6 / T-M3-10
This commit is contained in:
lanterngamescn
2026-06-27 17:13:39 +08:00
parent 481afc803d
commit 2680590590
@@ -14,19 +14,20 @@
* 各自独立瞬时播放器(oneShots,播完自释)。互不打断、不杀 BGM(复刻 Android SoundPool 并发一次性 + MediaPlayer 循环)。
* - isloop<0:停止该 src 的循环(Android 语义"停止背景音乐")。
*
* ⚠ prepareaudio 录音为分档实现:录音上传链路用 platform QiniuUploadertoken 走服务端),但"按住说话"的原生触摸
* 起止手势与麦克风权限需与容器触摸集成,且需七牛服务端 token 端点——三者就绪前为安全空实现(不录、不卡死)
* 分层静音:有效静音 = muted(H5 voicePlaying 控) || recordMuted(录音期临时叠加)。
* 恢复时只清 recordMuted,不动 muted——保留 H5 意图
*/
import { media } from '@kit.MediaKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';
import { common, abilityAccessCtrl } from '@kit.AbilityKit';
import { fileIo } from '@kit.CoreFileKit';
import { BridgeController } from 'feature_bridge';
import { InboundHandlers, OutboundHandlers, MediaTypeAudioReq, SrcIsLoopReq } from 'contracts';
import { InboundHandlers, OutboundHandlers, MediaTypeAudioReq, SrcIsLoopReq, GetAudioUrlResp } from 'contracts';
import { ConfigManager, ResourceManager } from 'domain_resource';
import { FileSystem } from 'platform';
import { Logger } from 'common';
import { FileSystem, QiniuUploader, QiniuToken } from 'platform';
import { Logger, EventBus, EventPayload, AudioEvents, ShowRecordingRequest, RecordResult } from 'common';
import { CapabilityContext, CapabilityProvider } from '../core/CapabilityProvider';
import { AmrRecorder } from './AmrRecorder';
export class AudioProvider implements CapabilityProvider {
readonly name: string = 'audio';
@@ -46,6 +47,16 @@ export class AudioProvider implements CapabilityProvider {
private muted: boolean = false;
private disposed: boolean = false;
private static instanceCounter: number = 0;
private readonly instanceId: number = AudioProvider.instanceCounter++;
private seq: number = 0;
/** 录音期叠加静音(有效静音 = muted || recordMuted)。 */
private recordMuted: boolean = false;
/** 当前正在播的语音消息 user(录音开始时回投 gameui_stop_voice 用)。 */
private currentVoiceUser: string = '';
private readonly recorder: AmrRecorder = new AmrRecorder();
private recordResultCancel: (() => void) | undefined = undefined;
register(bridge: BridgeController, ctx: CapabilityContext): void {
this.bridge = bridge;
this.context = ctx.uiAbilityContext;
@@ -61,9 +72,7 @@ export class AudioProvider implements CapabilityProvider {
this.setMuted(data !== '1');
});
bridge.registerHandler(InboundHandlers.PrepareAudio, (_d: string, _cb: (resp: string) => void) => {
// TODO(M3 录音):按住说话录音(AVRecorder)→ platform QiniuUploader.upload(token 走服务端) →
// callHandler(getaudiourl, {audiourl,time,filepath})。需容器触摸手势(起/止)、麦克风权限、七牛服务端 token 端点。
this.log.i('prepareaudio: recording requires touch-gesture + mic perm + qiniu server token (stub)');
this.onPrepareAudio();
});
}
@@ -71,12 +80,45 @@ export class AudioProvider implements CapabilityProvider {
onBackground(): void {
this.releaseVoice();
this.releaseAllSfx();
if (this.recorder.isActive()) {
this.recorder.cancel();
}
this.recordMuted = false;
if (this.recordResultCancel !== undefined) {
this.recordResultCancel();
this.recordResultCancel = undefined;
}
}
onDestroy(): void {
this.disposed = true;
this.releaseVoice();
this.releaseAllSfx();
if (this.recorder.isActive()) {
this.recorder.cancel();
}
this.recordMuted = false;
if (this.recordResultCancel !== undefined) {
this.recordResultCancel();
this.recordResultCancel = undefined;
}
}
/** 有效音量:静音(muted 或录音期 recordMuted)时为 0,否则为 1。 */
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}`);
}
}
/** 播放语音消息(远程 urlAVPlayer.url 接受网络地址),上报 gameui_play_voice/gameui_stop_voice。 */
@@ -99,12 +141,13 @@ export class AudioProvider implements CapabilityProvider {
return;
}
this.voicePlayer = player;
this.currentVoiceUser = user;
player.on('stateChange', (state: string) => {
if (state === 'initialized') {
player.prepare();
} else if (state === 'prepared') {
player.loop = false;
player.setVolume(this.muted ? 0 : 1);
player.setVolume(this.effVolume());
player.play();
this.bridge?.callHandler(OutboundHandlers.GameUiPlayVoice, user);
} else if (state === 'completed') {
@@ -183,7 +226,7 @@ export class AudioProvider implements CapabilityProvider {
player.prepare();
} else if (state === 'prepared') {
player.loop = loop;
player.setVolume(this.muted ? 0 : 1);
player.setVolume(this.effVolume());
player.play();
} else if (state === 'completed') {
if (!loop) {
@@ -211,22 +254,16 @@ export class AudioProvider implements CapabilityProvider {
}
}
/** 语音总开关:静音/取消静音,作用于当前全部播放器。 */
/** 语音总开关H5 voicePlaying):静音/取消静音,不影响录音期叠加静音。 */
private setMuted(muted: boolean): void {
this.muted = muted;
const vol: number = muted ? 0 : 1;
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(`setVolume failed: ${(e as Error).message}`);
}
this.applyAllVolume();
}
private releaseVoice(): void {
const p = this.voicePlayer;
this.voicePlayer = undefined;
this.currentVoiceUser = '';
if (p !== undefined) {
p.release().catch((e: BusinessError) => this.log.w(`release voice failed: ${e.message}`));
}
@@ -277,4 +314,107 @@ export class AudioProvider implements CapabilityProvider {
this.log.w(`close fd failed: ${(e as Error).message}`);
}
}
// ─── 录音编排(prepareaudio,契约 §10.6T-M3-10) ───────────────────────
/** prepareaudio:暂停声音 → 通知在播语音停 → 申请麦克风 → 起录音 → 通知容器显示浮层。 */
private onPrepareAudio(): void {
if (this.recorder.isActive()) {
return;
}
const ctx: common.UIAbilityContext | undefined = this.context;
if (ctx === undefined) {
return;
}
this.recordMuted = true;
this.applyAllVolume();
if (this.voicePlayer !== undefined && this.currentVoiceUser !== '') {
this.bridge?.callHandler(OutboundHandlers.GameUiStopVoice, this.currentVoiceUser);
this.releaseVoice();
}
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: 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;
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.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));
}
}