Design §8.1:补 AudioKit 4 项 handler 完整实现骨架(方案 A 示范)

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 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-22 07:14:15 +08:00
co-authored by Claude Opus 4.7
parent c95e80df9c
commit 92298b657e
+185
View File
@@ -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 ### 8.2 ShareKit
策略模式分发,新增平台只加一个 conformance: 策略模式分发,新增平台只加一个 conformance: