From 92298b657eb5f61a45265b72f187322c3b9520e9 Mon Sep 17 00:00:00 2001 From: joywayer Date: Mon, 22 Jun 2026 07:14:15 +0800 Subject: [PATCH] =?UTF-8?q?Design=20=C2=A78.1=EF=BC=9A=E8=A1=A5=20AudioKit?= =?UTF-8?q?=204=20=E9=A1=B9=20handler=20=E5=AE=8C=E6=95=B4=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E9=AA=A8=E6=9E=B6=EF=BC=88=E6=96=B9=E6=A1=88=20A=20?= =?UTF-8?q?=E7=A4=BA=E8=8C=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H5↔Native 接口实现层缺口补全启动(用户选 A:分散到各 module 章节补)。 本 commit 作为风格示范,后续 commit 按相同模板补 DeviceKit / Location / Shake / OpenurlTitleData 共 11 项 handler。 §8.1 末尾新增两小节: §8.1.1 — H5 audio handlers 实现骨架(185 行) - AudioHandlers struct 集中注册 4 项 handler,便于发现 / 单测 - 【3】srcIsloop 入参解析 src/isloop → SandboxPaths.lobbyAssets/wav + AudioPlayer.playOnce / loopBackground / stopBackground 三态 + responseCallback "Response from srcIsloop" - 【4】prepareaudio 权限三态分支(denied → bridge.call alertMessage 而非原生弹窗,契约 §6.2 ifauth 语义)+ recordAndUpload + getaudiourl 反向 callback(time 字段必须 string,契约硬约束) - 【5】mediaTypeAudio voiceCenter 总开关短路 + 下载 → AMR→WAV → playVoice with onStart/onEnd 桥到 gameui_play_voice/ gameui_stop_voice;失败也补 stop_voice 避免 H5 UI 卡"播放中" - 【6】voicePlaying actor VoiceCenter 隔离 isEnabled 状态 §8.1.2 — 与原 msext 的差异(不要照抄的部分) - 录音 UI / 上传后端 / 状态共享 / 总开关 / 路径拼接 5 维度对照表 骨架级别(结构 + 关键调用 + 注释 + 契约引用),不是产品代码 — 实施 Phase 2 时按此骨架落地真实代码。 Co-Authored-By: Claude Opus 4.7 --- docs/H5-Native-Implementation-Design.md | 185 ++++++++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/docs/H5-Native-Implementation-Design.md b/docs/H5-Native-Implementation-Design.md index 1baa5d2..31bfd21 100644 --- a/docs/H5-Native-Implementation-Design.md +++ b/docs/H5-Native-Implementation-Design.md @@ -1629,6 +1629,191 @@ public actor AudioRecorder { } ``` +#### 8.1.1 H5 audio handlers 实现骨架 + +把契约 §3.1 §B 的 4 项 audio handler(【3】–【6】)粘到 AudioKit 上的胶水代码。注册集中到 `AudioHandlers` 一个 struct,便于发现 / 单测。 + +```swift +// Source/Bridge/Handlers/AudioHandlers.swift +import Foundation + +public struct AudioHandlers { + + let bridge: BridgeProtocol + let player: AudioPlayer + let recorder: AudioRecorder + let voiceCenter: VoiceCenter // 8.1.1 引入,控制远端语音的播放总开关 + + public func register() { + bridge.register("srcIsloop", handler: srcIsloop) + bridge.register("prepareaudio", handler: prepareAudio) + bridge.register("mediaTypeAudio", handler: mediaTypeAudio) + bridge.register("voicePlaying", handler: voicePlaying) + } + + // MARK: - 【3】 srcIsloop — 本地音频播放 + // + // 契约 §3.1 [3]: + // 入参 src : string 音频文件名,路径 = Library/Caches/{gamedir}/{gamestart}/assets/wav/{src} + // 入参 isloop : int 0 单次 / 1 循环 / -1 停同类循环 + // responseCallback: "Response from srcIsloop" + // + // 与原 msext 的差异(不破契约): + // - 原项目用 NSURL fileURLWithPath: 拼接 + AVAudioPlayer 同步初始化; + // 新外壳通过 SandboxPaths 统一管理,所有路径在一处可校验 + // - 原项目 backgroundType 用全局 NSString *;新外壳把状态封装在 + // @MainActor 的 AudioPlayer 内部,避免跨线程访问竞态 + + private func srcIsloop(_ data: BridgeData?, _ cb: BridgeCallback?) async { + defer { cb?(.string("Response from srcIsloop")) } + guard let obj = data?.asObject, + let src = obj["src"]?.asString, + let isloop = obj["isloop"]?.asInt + else { + Log.warn("srcIsloop: bad payload \(String(describing: data))") + return + } + + let url = SandboxPaths.lobbyAssets + .appendingPathComponent("wav") + .appendingPathComponent(src) + + await MainActor.run { + switch isloop { + case 0: player.playOnce(url) + case 1: player.loopBackground(url, type: src) + case -1: player.stopBackground(type: src) + default: Log.warn("srcIsloop: unexpected isloop=\(isloop)") + } + } + } + + // MARK: - 【4】 prepareaudio — 启动麦克风录音 + // + // 契约 §3.1 [4]: + // 入参 : 忽略 + // 副作用 : 权限检查 → recordAndUpload → getaudiourl callback + // responseCallback: "Response from prepareaudio" + // + // 权限态参考契约 §6.2「ifauth」三态语义: + // 0 denied/restricted → 弹 alert 并放弃 + // 1 not determined → AudioRecorder 内部触发系统询问 + // 2 authorized → 直接录 + // + // 与原 msext 的差异: + // - 原项目 VoiceRecorderBaseVC 是 UI 录音控件,含 cancel/redo 按钮, + // 与 H5 的录音 UI 重复。新外壳走"无 UI 录音"路径:H5 自己画按钮, + // 原生只负责拿到 AVAudioRecorder 数据 → 转码 → 上传,不弹任何原生界面 + // - 上传走七牛 SDK(已在 ADR-006 选型);旧 PostFile 接口不再支持 + + private func prepareAudio(_ data: BridgeData?, _ cb: BridgeCallback?) async { + cb?(.string("Response from prepareaudio")) + + switch await Permissions.microphone() { + case .denied, .restricted: + // 契约要求:通过 H5 alert 提示(不是原生弹窗),文案 = "{gamehallname}需要访问您的麦克风" + bridge.call("alertMessage", + data: .string("\(BundleConfig.shared.appDisplayName)需要访问您的麦克风"), + callback: nil) + return + case .notDetermined, .authorized: + break + } + + do { + let result = try await recorder.recordAndUpload() + // 契约 §3.2 [9]:getaudiourl({audiourl, time}) + bridge.call("getaudiourl", + data: .object([ + "audiourl": .string(result.audioUrl), + "time": .string(String(result.time)) // ⚠️ 字符串,非 number + ]), + callback: nil) + } catch { + Log.error("prepareaudio failed: \(error)") + } + } + + // MARK: - 【5】 mediaTypeAudio — 远程语音回放 + // + // 契约 §3.1 [5]: + // 入参 audiourl: string 远端 AMR URL + // 入参 user : string 发声方用户 ID + // responseCallback: "Response from mediaTypeAudio" + // 开始播放: gameui_play_voice(user) (§3.2 [7]) + // 播放结束: gameui_stop_voice(user) (§3.2 [8]) + // + // 总开关由 voicePlaying 控制(【6】),关闭态直接静默不播 + + private func mediaTypeAudio(_ data: BridgeData?, _ cb: BridgeCallback?) async { + defer { cb?(.string("Response from mediaTypeAudio")) } + + guard let obj = data?.asObject, + let urlStr = obj["audiourl"]?.asString, + let user = obj["user"]?.asString, + let remote = URL(string: urlStr) + else { + Log.warn("mediaTypeAudio: bad payload") + return + } + + guard await voiceCenter.isEnabled else { + Log.info("mediaTypeAudio: voicePlaying off, skip user=\(user)") + return + } + + do { + let amr = try await AudioDownloader.fetchToCache(remote) + let wav = try await VoiceCoder.amrToWavAsync(amr) + await MainActor.run { + player.playVoice(wav, + onStart: { [weak bridge] in + bridge?.call("gameui_play_voice", data: .string(user), callback: nil) + }, + onEnd: { [weak bridge] in + bridge?.call("gameui_stop_voice", data: .string(user), callback: nil) + }) + } + } catch { + Log.error("mediaTypeAudio failed for user=\(user): \(error)") + // 失败也要补 stop_voice,避免 H5 端 UI 永远停在"播放中"状态 + bridge.call("gameui_stop_voice", data: .string(user), callback: nil) + } + } + + // MARK: - 【6】 voicePlaying — 语音播放总开关 + // + // 契约 §3.1 [6]: + // 入参: int 1=允许 / 其他=静默 + // responseCallback: "Response from voicePlaying" + // + // VoiceCenter 持有 actor 隔离的 bool 状态,mediaTypeAudio 每次读它 + // 决定要不要走完整的下载→转码→播放链路 + + private func voicePlaying(_ data: BridgeData?, _ cb: BridgeCallback?) async { + defer { cb?(.string("Response from voicePlaying")) } + let on = (data?.asInt ?? 0) == 1 + await voiceCenter.setEnabled(on) + } +} + +/// 远端语音播放总开关(actor 隔离,跨 bridge handler 共享) +public actor VoiceCenter { + public private(set) var isEnabled: Bool = true + public func setEnabled(_ on: Bool) { isEnabled = on } +} +``` + +#### 8.1.2 与原 msext 的差异(不要照抄的部分) + +| 维度 | msext 现状(不要照抄) | 新外壳决策 | +|------|--------------------|----------| +| 录音 UI | `VoiceRecorderBaseVC` 原生录音控件 + cancel/redo 按钮 | 无原生 UI;H5 自己画按钮,原生只录 + 转 + 传 | +| 上传 | 旧 `gameapi.0791ts.cn/api/UpLoad/PostFile` 直传后台 | 七牛 SDK(ADR-006 已选型) | +| 状态共享 | `static NSString *backgroundType` 全局变量 | `@MainActor` AudioPlayer 内部状态 | +| 总开关 | `static BOOL voicePlaying` | `actor VoiceCenter` 隔离 | +| 路径拼接 | 各处 `NSURL fileURLWithPath:` 散落 | 统一走 `SandboxPaths.lobbyAssets` | + ### 8.2 ShareKit 策略模式分发,新增平台只加一个 conformance: