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 循环)。
|
* 各自独立瞬时播放器(oneShots,播完自释)。互不打断、不杀 BGM(复刻 Android SoundPool 并发一次性 + MediaPlayer 循环)。
|
||||||
* - isloop<0:停止该 src 的循环(Android 语义"停止背景音乐")。
|
* - isloop<0:停止该 src 的循环(Android 语义"停止背景音乐")。
|
||||||
*
|
*
|
||||||
* ⚠ prepareaudio 录音为分档实现:录音上传链路用 platform QiniuUploader(token 走服务端),但"按住说话"的原生触摸
|
* 分层静音:有效静音 = muted(H5 voicePlaying 控) || recordMuted(录音期临时叠加)。
|
||||||
* 起止手势与麦克风权限需与容器触摸集成,且需七牛服务端 token 端点——三者就绪前为安全空实现(不录、不卡死)。
|
* 恢复时只清 recordMuted,不动 muted——保留 H5 意图。
|
||||||
*/
|
*/
|
||||||
import { media } from '@kit.MediaKit';
|
import { media } from '@kit.MediaKit';
|
||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common, abilityAccessCtrl } from '@kit.AbilityKit';
|
||||||
import { fileIo } from '@kit.CoreFileKit';
|
import { fileIo } from '@kit.CoreFileKit';
|
||||||
import { BridgeController } from 'feature_bridge';
|
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 { ConfigManager, ResourceManager } from 'domain_resource';
|
||||||
import { FileSystem } from 'platform';
|
import { FileSystem, QiniuUploader, QiniuToken } from 'platform';
|
||||||
import { Logger } from 'common';
|
import { Logger, EventBus, EventPayload, AudioEvents, ShowRecordingRequest, RecordResult } from 'common';
|
||||||
import { CapabilityContext, CapabilityProvider } from '../core/CapabilityProvider';
|
import { CapabilityContext, CapabilityProvider } from '../core/CapabilityProvider';
|
||||||
|
import { AmrRecorder } from './AmrRecorder';
|
||||||
|
|
||||||
export class AudioProvider implements CapabilityProvider {
|
export class AudioProvider implements CapabilityProvider {
|
||||||
readonly name: string = 'audio';
|
readonly name: string = 'audio';
|
||||||
@@ -46,6 +47,16 @@ export class AudioProvider implements CapabilityProvider {
|
|||||||
private muted: boolean = false;
|
private muted: boolean = false;
|
||||||
private disposed: 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 {
|
register(bridge: BridgeController, ctx: CapabilityContext): void {
|
||||||
this.bridge = bridge;
|
this.bridge = bridge;
|
||||||
this.context = ctx.uiAbilityContext;
|
this.context = ctx.uiAbilityContext;
|
||||||
@@ -61,9 +72,7 @@ export class AudioProvider implements CapabilityProvider {
|
|||||||
this.setMuted(data !== '1');
|
this.setMuted(data !== '1');
|
||||||
});
|
});
|
||||||
bridge.registerHandler(InboundHandlers.PrepareAudio, (_d: string, _cb: (resp: string) => void) => {
|
bridge.registerHandler(InboundHandlers.PrepareAudio, (_d: string, _cb: (resp: string) => void) => {
|
||||||
// TODO(M3 录音):按住说话录音(AVRecorder)→ platform QiniuUploader.upload(token 走服务端) →
|
this.onPrepareAudio();
|
||||||
// callHandler(getaudiourl, {audiourl,time,filepath})。需容器触摸手势(起/止)、麦克风权限、七牛服务端 token 端点。
|
|
||||||
this.log.i('prepareaudio: recording requires touch-gesture + mic perm + qiniu server token (stub)');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +80,45 @@ export class AudioProvider implements CapabilityProvider {
|
|||||||
onBackground(): void {
|
onBackground(): void {
|
||||||
this.releaseVoice();
|
this.releaseVoice();
|
||||||
this.releaseAllSfx();
|
this.releaseAllSfx();
|
||||||
|
if (this.recorder.isActive()) {
|
||||||
|
this.recorder.cancel();
|
||||||
|
}
|
||||||
|
this.recordMuted = false;
|
||||||
|
if (this.recordResultCancel !== undefined) {
|
||||||
|
this.recordResultCancel();
|
||||||
|
this.recordResultCancel = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(): void {
|
onDestroy(): void {
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
this.releaseVoice();
|
this.releaseVoice();
|
||||||
this.releaseAllSfx();
|
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。 */
|
/** 播放语音消息(远程 url,AVPlayer.url 接受网络地址),上报 gameui_play_voice/gameui_stop_voice。 */
|
||||||
@@ -99,12 +141,13 @@ export class AudioProvider implements CapabilityProvider {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.voicePlayer = player;
|
this.voicePlayer = player;
|
||||||
|
this.currentVoiceUser = user;
|
||||||
player.on('stateChange', (state: string) => {
|
player.on('stateChange', (state: string) => {
|
||||||
if (state === 'initialized') {
|
if (state === 'initialized') {
|
||||||
player.prepare();
|
player.prepare();
|
||||||
} else if (state === 'prepared') {
|
} else if (state === 'prepared') {
|
||||||
player.loop = false;
|
player.loop = false;
|
||||||
player.setVolume(this.muted ? 0 : 1);
|
player.setVolume(this.effVolume());
|
||||||
player.play();
|
player.play();
|
||||||
this.bridge?.callHandler(OutboundHandlers.GameUiPlayVoice, user);
|
this.bridge?.callHandler(OutboundHandlers.GameUiPlayVoice, user);
|
||||||
} else if (state === 'completed') {
|
} else if (state === 'completed') {
|
||||||
@@ -183,7 +226,7 @@ export class AudioProvider implements CapabilityProvider {
|
|||||||
player.prepare();
|
player.prepare();
|
||||||
} else if (state === 'prepared') {
|
} else if (state === 'prepared') {
|
||||||
player.loop = loop;
|
player.loop = loop;
|
||||||
player.setVolume(this.muted ? 0 : 1);
|
player.setVolume(this.effVolume());
|
||||||
player.play();
|
player.play();
|
||||||
} else if (state === 'completed') {
|
} else if (state === 'completed') {
|
||||||
if (!loop) {
|
if (!loop) {
|
||||||
@@ -211,22 +254,16 @@ export class AudioProvider implements CapabilityProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 语音总开关:静音/取消静音,作用于当前全部播放器。 */
|
/** 语音总开关(H5 voicePlaying):静音/取消静音,不影响录音期叠加静音。 */
|
||||||
private setMuted(muted: boolean): void {
|
private setMuted(muted: boolean): void {
|
||||||
this.muted = muted;
|
this.muted = muted;
|
||||||
const vol: number = muted ? 0 : 1;
|
this.applyAllVolume();
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private releaseVoice(): void {
|
private releaseVoice(): void {
|
||||||
const p = this.voicePlayer;
|
const p = this.voicePlayer;
|
||||||
this.voicePlayer = undefined;
|
this.voicePlayer = undefined;
|
||||||
|
this.currentVoiceUser = '';
|
||||||
if (p !== undefined) {
|
if (p !== undefined) {
|
||||||
p.release().catch((e: BusinessError) => this.log.w(`release voice failed: ${e.message}`));
|
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}`);
|
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