diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index ebaad40..b788f69 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -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 转码 diff --git a/docs/Verification-Checklist.md b/docs/Verification-Checklist.md index 4a77434..cc13185 100644 --- a/docs/Verification-Checklist.md +++ b/docs/Verification-Checklist.md @@ -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 远端播放完整实现后升级。 --- diff --git a/ylgamehall/Source/Audio/AudioPlayer.swift b/ylgamehall/Source/Audio/AudioPlayer.swift new file mode 100644 index 0000000..28dc414 --- /dev/null +++ b/ylgamehall/Source/Audio/AudioPlayer.swift @@ -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)") + } +} diff --git a/ylgamehall/Source/Bridge/Handlers/LocalAudioHandler.swift b/ylgamehall/Source/Bridge/Handlers/LocalAudioHandler.swift new file mode 100644 index 0000000..e4bdcab --- /dev/null +++ b/ylgamehall/Source/Bridge/Handlers/LocalAudioHandler.swift @@ -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) + } +} diff --git a/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift b/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift new file mode 100644 index 0000000..a84c23a --- /dev/null +++ b/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift @@ -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")) + } + } +} diff --git a/ylgamehall/Source/WebView/WebContainerViewController.swift b/ylgamehall/Source/WebView/WebContainerViewController.swift index 5832054..9543e22 100644 --- a/ylgamehall/Source/WebView/WebContainerViewController.swift +++ b/ylgamehall/Source/WebView/WebContainerViewController.swift @@ -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: - 摇一摇支持