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.2ifauth
// 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 9getaudiourl({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` 直传后台 | 七牛 SDKADR-006 已选型) |
| 状态共享 | `static NSString *backgroundType` 全局变量 | `@MainActor` AudioPlayer 内部状态 |
| 总开关 | `static BOOL voicePlaying` | `actor VoiceCenter` 隔离 |
| 路径拼接 | 各处 `NSURL fileURLWithPath:` 散落 | 统一走 `SandboxPaths.lobbyAssets` |
### 8.2 ShareKit
策略模式分发,新增平台只加一个 conformance: