Phase 3.A:srcIsloop 本地音频 + 3.B/3.C/3.D handler stub
3.A 本地音频完整落地(与 msext NewRootVC initJSdata 等价):
Source/Audio/AudioPlayer.swift(@MainActor 状态机):
- playOnce(url) 单次按钮音;buttonPlayers 数组保活池避免多次点击
互相打断(duration + 0.5s 后清理)
- loopBackground(url, type:) 循环背景音;numberOfLoops = -1 +
backgroundType 记录用于同名停止
- stopBackground(type:) 同名校验后才停止(msext 同款语义)
Source/Bridge/Handlers/LocalAudioHandler.swift:
- 注册 srcIsloop handler(@MainActor enum)
- 入参 src + isloop,按 0/1/-1 分支调 AudioPlayer.shared 对应方法
- audioFileURL 路径:{Caches}/{gamedir}/{gamestart}/assets/wav/{src}
与 msext NewRootVC 路径约定 1:1(H5 zip 内 assets/wav 子目录)
- audioFileURL 标 nonisolated 因 SandboxPaths 是 nonisolated 静态
- cb 字面 "Response from srcIsloop" 严格保持
Source/Bridge/Handlers/RemoteAudioHandler.swift(3.B/3.C/3.D stub):
- prepareaudio + mediaTypeAudio 仅 cb 维持契约,无真实业务
- 待 Phase 3.B(opencore-amr .a 接入)+ 3.C(七牛 SPM + token)+
3.D(远端播放)完整后升级
- 按 CLAUDE.md 原则 A:H5 调啥就得有人接,即使是 stub
WebContainerViewController.registerBridgeHandlers 接入:
- LocalAudioHandler.register(完整实现)
- RemoteAudioHandler.register(stub)
Plan §5 Phase 3.1 勾选;3.2 真机验证留待 H5 提供音频文件后做。
Verification-Checklist.md 新增 Phase 3 章节:
- H. 本地音频(E.14-E.16 srcIsloop 三个 isloop 值)
- I. prepareaudio / mediaTypeAudio stub(E.17 E.18)
BuildProject 通过
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
b2ee1b38eb
commit
0602b9d4b6
@@ -411,10 +411,14 @@ Contract Design Plan(本文档)
|
||||
|
||||
##### 3.A 本地音频
|
||||
|
||||
- [ ] **3.1** `Source/Audio/AudioPlayer.swift`(@MainActor)
|
||||
- `background` / `button` / `voice` 三类 AVAudioPlayer
|
||||
- `srcIsloop` handler:`isloop=0` 单次 / `isloop=1` 循环背景 / `isloop=-1` 停同名背景
|
||||
- [ ] **3.2** 真机验证:放一个 `.wav` 进 Resources,H5 调 `srcIsloop({src:"test.wav", isloop:0})` 出声
|
||||
- [x] **3.1** `Source/Audio/AudioPlayer.swift`(@MainActor)
|
||||
- `playOnce(url)` 单次按钮音(msext `buttunPlayer` 等价,多次点击不互打断 — 用 buttonPlayers 数组保活池)
|
||||
- `loopBackground(url, type:)` 循环背景音(msext `backgroundPlayer numberOfLoops = -1`,记录 backgroundType)
|
||||
- `stopBackground(type:)` 停同名背景音(type 与当前 backgroundType 匹配才停)
|
||||
- `Source/Bridge/Handlers/LocalAudioHandler.swift` 注册 `srcIsloop` handler,按 isloop=0/1/-1 分支调用 AudioPlayer
|
||||
- 音频文件路径:`{Caches}/{gamedir}/{gamestart}/assets/wav/{src}`(H5 zip 包内自带)
|
||||
- `Source/Bridge/Handlers/RemoteAudioHandler.swift` 注册 `prepareaudio` / `mediaTypeAudio` stub(仅 cb 维持契约,等 Phase 3.B/3.C/3.D 升级)
|
||||
- [ ] **3.2** 真机验证:H5 调 `srcIsloop({src:"xxx.wav", isloop:0/1/-1})` 听到对应音效 — 等 H5 团队提供测试音频后做(Verification-Checklist Phase 3 E.14-E.16)
|
||||
|
||||
##### 3.B AMR 转码
|
||||
|
||||
|
||||
@@ -149,12 +149,23 @@ H5 需先用 `bridge.registerHandler('callbackName', fn)` 注册:
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — 音频体系(待实现)
|
||||
## Phase 3 — 音频体系(部分实现:3.A 完成;3.B/C/D 待外部资源)
|
||||
|
||||
> Phase 3 实施后填充。预备验证项:
|
||||
> - srcIsloop 背景音循环 / 单次按钮音 / 停止
|
||||
> - mediaTypeAudio 远端语音回放 + gameui_play_voice / gameui_stop_voice
|
||||
> - prepareaudio 录音 + AMR 转码 + 七牛上传 + getaudiourl
|
||||
### H. 本地音频(srcIsloop,3.A 完成)
|
||||
|
||||
- [ ] **E.14 srcIsloop({src:"xxx.wav", isloop:0})** → 听到单次按钮音;cb "Response from srcIsloop"
|
||||
- [ ] **E.15 srcIsloop({src:"bg.wav", isloop:1})** → 听到背景循环音 + 同时调 srcIsloop({src:"另一首.wav", isloop:1}) → 替换为新背景循环
|
||||
- [ ] **E.16 srcIsloop({src:"bg.wav", isloop:-1})** → 停止 bg.wav 背景;如果当前背景不是 bg.wav 则无效(与 msext 同名 type 校验)
|
||||
|
||||
> **依赖**:测试需要 H5 zip 内 `assets/wav/` 目录有可用音频文件。
|
||||
> H5 团队 / 项目方提供后做。
|
||||
|
||||
### I. prepareaudio / mediaTypeAudio stub(3.B/3.C/3.D 待实现)
|
||||
|
||||
- [ ] **E.17 prepareaudio** → cb "Response from prepareaudio"(stub 仅 cb,无录音 / 无 getaudiourl 反向)
|
||||
- [ ] **E.18 mediaTypeAudio** → cb "Response from mediaTypeAudio"(stub 仅 cb,无下载 / 无播放 / 无 gameui_play_voice 反向)
|
||||
|
||||
> 3.B AMR 转码 + 3.C 七牛上传 + 3.D 远端播放完整实现后升级。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// AudioPlayer.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// 本地音频播放(srcIsloop 支撑):背景音循环 + 单次按钮音。
|
||||
//
|
||||
// 契约:docs/H5-Native-Contract.md §3.1 [3]srcIsloop
|
||||
// Design:§8.1 AudioKit
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import os.log
|
||||
|
||||
/// 本地音频播放器(@MainActor 隔离的状态机)。
|
||||
///
|
||||
/// 三类音频通道:
|
||||
/// - `background`:循环背景音,记录 backgroundType 用于 isloop=-1 时同名停止
|
||||
/// - `button`:单次按钮音(短促音效),互不抢占(每次调 playOnce 新建 player)
|
||||
///
|
||||
/// 与原 msext NewRootVC 的 `buttunPlayer` / `backgroundPlayer` /
|
||||
/// `backgroundType` 静态变量等价,封装到实例避免全局可变状态。
|
||||
@MainActor
|
||||
public final class AudioPlayer {
|
||||
|
||||
public static let shared = AudioPlayer()
|
||||
|
||||
private static let log = Logger(subsystem: "ylgamehall", category: "AudioPlayer")
|
||||
|
||||
private var backgroundPlayer: AVAudioPlayer?
|
||||
private var backgroundType: String?
|
||||
/// 单次按钮音的 player 保活池:同时点多个按钮也不会互相打断
|
||||
private var buttonPlayers: [AVAudioPlayer] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - srcIsloop 入口
|
||||
|
||||
/// 单次按钮音播放(msext `isloop == 0`)。
|
||||
public func playOnce(_ url: URL) {
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
player.numberOfLoops = 0
|
||||
player.prepareToPlay()
|
||||
player.play()
|
||||
buttonPlayers.append(player)
|
||||
// 短促音播放完就清理(依靠 AVAudioPlayer 自身的 duration + delay 释放)
|
||||
let duration = player.duration
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64((duration + 0.5) * 1_000_000_000))
|
||||
self?.buttonPlayers.removeAll { $0 === player }
|
||||
}
|
||||
Self.log.debug("playOnce \(url.lastPathComponent, privacy: .public) duration=\(duration, privacy: .public)s")
|
||||
} catch {
|
||||
Self.log.error("playOnce failed: \(error.localizedDescription, privacy: .public) url=\(url.path, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 循环背景音(msext `isloop == 1`)。同名再次启动会替换上一份。
|
||||
public func loopBackground(_ url: URL, type: String) {
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
player.numberOfLoops = -1 // 无限循环
|
||||
player.prepareToPlay()
|
||||
player.play()
|
||||
backgroundPlayer?.stop()
|
||||
backgroundPlayer = player
|
||||
backgroundType = type
|
||||
Self.log.debug("loopBackground type=\(type, privacy: .public) url=\(url.lastPathComponent, privacy: .public)")
|
||||
} catch {
|
||||
Self.log.error("loopBackground failed: \(error.localizedDescription, privacy: .public) url=\(url.path, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止同名背景音(msext `isloop == -1`)。仅 type 与当前 backgroundType 匹配时停止。
|
||||
public func stopBackground(type: String) {
|
||||
guard backgroundType == type else {
|
||||
Self.log.debug("stopBackground noop: requested type=\(type, privacy: .public) but current=\(self.backgroundType ?? "nil", privacy: .public)")
|
||||
return
|
||||
}
|
||||
backgroundPlayer?.stop()
|
||||
backgroundPlayer = nil
|
||||
backgroundType = nil
|
||||
Self.log.debug("stopBackground done type=\(type, privacy: .public)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// LocalAudioHandler.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// H5 → Native handler:srcIsloop — 本地音频播放(背景循环 / 按钮单次)
|
||||
// 契约:docs/H5-Native-Contract.md §3.1 [3]
|
||||
// 原项目:daoqi/msext NewRootVC.m initJSdata 关联 buttunPlayer / backgroundPlayer
|
||||
//
|
||||
// 本地音频文件路径:`{Caches}/{gamedir}/{gamestart}/assets/wav/{src}`
|
||||
// 与 SandboxPaths.lobbyIndex 同一根目录下 assets/wav 子目录
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public enum LocalAudioHandler {
|
||||
|
||||
public static func register(on bridge: any BridgeProtocol) {
|
||||
// 【3】srcIsloop — 本地音频播放
|
||||
// 入参 src : string 音频文件名(位于 lobbyAssets/assets/wav/{src})
|
||||
// 入参 isloop : int 0 单次 / 1 循环背景 / -1 停同名循环
|
||||
// cb: "Response from srcIsloop"
|
||||
bridge.register("srcIsloop") { data, callback in
|
||||
defer { callback?(.string("Response from srcIsloop")) }
|
||||
|
||||
guard let obj = data?.asObject,
|
||||
let src = obj["src"]?.asString,
|
||||
let isloop = obj["isloop"]?.asInt
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let url = audioFileURL(src: src)
|
||||
|
||||
switch isloop {
|
||||
case 0:
|
||||
await AudioPlayer.shared.playOnce(url)
|
||||
case 1:
|
||||
await AudioPlayer.shared.loopBackground(url, type: src)
|
||||
case -1:
|
||||
await AudioPlayer.shared.stopBackground(type: src)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `{lobbyAssets}/assets/wav/{src}` —— 与 msext NewRootVC 路径约定一致。
|
||||
/// H5 业务调用前会先用 zip 升级(gamehall.zip 内含 assets/wav/*.wav 音频文件)。
|
||||
nonisolated private static func audioFileURL(src: String) -> URL {
|
||||
SandboxPaths.lobbyIndex
|
||||
.deletingLastPathComponent() // {Caches}/{gamedir}/{gamestart}/
|
||||
.appendingPathComponent("assets")
|
||||
.appendingPathComponent("wav")
|
||||
.appendingPathComponent(src)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// RemoteAudioHandler.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// H5 → Native handler stub:prepareaudio / mediaTypeAudio
|
||||
// 契约:docs/H5-Native-Contract.md §3.1 [4][5] + §3.2 [7][8][9]
|
||||
//
|
||||
// Phase 3.A 阶段为 stub(仅 cb 维持契约,让 H5 启动不报 "no handler");
|
||||
// Phase 3.B AMR 转码 + Phase 3.C 录音上传 + Phase 3.D mediaTypeAudio 真实播放
|
||||
// 完整接入后升级为真实现。
|
||||
//
|
||||
// 按 CLAUDE.md 原则 A 第一准则:H5 调啥就得有人接,即使没真实业务也要注册。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum RemoteAudioHandler {
|
||||
|
||||
public static func register(on bridge: any BridgeProtocol) {
|
||||
// 【4】prepareaudio — 启动麦克风录音
|
||||
// 入参忽略;cb: "Response from prepareaudio"
|
||||
// 完整实现(Phase 3.C):权限检查 + AVAudioRecorder + WAV→AMR + 七牛上传 +
|
||||
// 反向 callback getaudiourl({audiourl, time}) + 子游戏额外 recordSuccess
|
||||
// stub 仅 cb 维持契约
|
||||
bridge.register("prepareaudio") { _, callback in
|
||||
callback?(.string("Response from prepareaudio"))
|
||||
}
|
||||
|
||||
// 【5】mediaTypeAudio — 远程语音回放
|
||||
// 入参 audiourl: string;user: string
|
||||
// cb: "Response from mediaTypeAudio"
|
||||
// 完整实现(Phase 3.D):voicePlaying 总开关短路 + 下载 AMR + AMR→WAV +
|
||||
// AVAudioPlayer 播放 + 反向 callback gameui_play_voice(user) /
|
||||
// gameui_stop_voice(user)
|
||||
// stub 仅 cb 维持契约(不下载、不播放)
|
||||
bridge.register("mediaTypeAudio") { _, callback in
|
||||
callback?(.string("Response from mediaTypeAudio"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,10 @@ public final class WebContainerViewController: UIViewController {
|
||||
BrowserHandler.register(on: bridge) // §3.1 [16]
|
||||
OpenSaomaHandler.register(on: bridge) // §3.1 [22]空 stub
|
||||
StartLocationHandler.register(on: bridge) // §3.1 [20]Phase 5 完整实现,Phase 2 stub
|
||||
|
||||
// Phase 3.A 本地音频 + 3.C/3.D stub
|
||||
LocalAudioHandler.register(on: bridge) // §3.1 [3]srcIsloop 完整实现
|
||||
RemoteAudioHandler.register(on: bridge) // §3.1 [4][5]Phase 3.B/3.C/3.D stub
|
||||
}
|
||||
|
||||
// MARK: - 摇一摇支持
|
||||
|
||||
Reference in New Issue
Block a user