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:
@@ -14,19 +14,20 @@
|
||||
* 各自独立瞬时播放器(oneShots,播完自释)。互不打断、不杀 BGM(复刻 Android SoundPool 并发一次性 + MediaPlayer 循环)。
|
||||
* - isloop<0:停止该 src 的循环(Android 语义"停止背景音乐")。
|
||||
*
|
||||
* ⚠ prepareaudio 录音为分档实现:录音上传链路用 platform QiniuUploader(token 走服务端),但"按住说话"的原生触摸
|
||||
* 起止手势与麦克风权限需与容器触摸集成,且需七牛服务端 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 播放语音消息(远程 url,AVPlayer.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.6,T-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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user