diff --git a/feature_capabilities/src/main/ets/providers/AudioProvider.ets b/feature_capabilities/src/main/ets/providers/AudioProvider.ets index c615c21..15aa041 100644 --- a/feature_capabilities/src/main/ets/providers/AudioProvider.ets +++ b/feature_capabilities/src/main/ets/providers/AudioProvider.ets @@ -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 { + 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 { + 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)); + } }