// // RemoteAudioHandler.swift // ylgamehall // // H5 → Native handlers:prepareaudio / mediaTypeAudio // 契约:docs/H5-Native-Contract.md §3.1 [4][5] + §3.2 [7][8][9] // // ## prepareaudio 完整链路(Phase 3.E) // // H5 调用 → 权限检查 → // ├─ 拒绝:弹 Alert,不触发 cb(对齐 daoqi NewRootVC.m:314-319) // └─ 同意:RecordingPresenter 启动录音浮层 + 立即回 cb // ↓ 用户松手 / 60s // 录音完成 (wavURL) // ↓ // AVAudioPlayer 读 duration → round 取整秒 // ↓ // 生成 amr 文件名(大厅 / 子游戏不同) // ↓ // AMRCodec.wavToAmr → 七牛上传 // ↓ // 反向 callback getaudiourl + 子游戏额外 recordSuccess // // ## mediaTypeAudio(Phase 3.F 实现,本提交仍为 stub) // // 本文件仍保留 stub;Phase 3.F 会替换为完整链路(下载 amr → amrToWav → // VoicePlayer.play + gameui_play_voice / gameui_stop_voice)。 // import Foundation import AVFoundation import UIKit import os.log public enum RemoteAudioHandler { private static let log = Logger(subsystem: "ylgamehall", category: "RemoteAudio") /// 注册 prepareaudio / mediaTypeAudio。 /// - Parameters: /// - bridge: 桥 /// - hostVC: 录音浮层挂载的宿主 VC(lobby = WebContainerViewController, /// subGame = SubGameViewController)。每次录音重新求值,避免捕获过时引用 /// - isSubGame: 是否子游戏。子游戏录音成功后额外触发 recordSuccess /// 反向 callback(对齐 daoqi gameController.m:2310;大厅 NewRootVC 不触发) public static func register( on bridge: any BridgeProtocol, hostVC: @escaping @MainActor @Sendable () -> UIViewController?, isSubGame: Bool ) { // 【4】prepareaudio — 完整实现(Phase 3.E) // cb: "Response from prepareaudio"(仅录音启动成功后触发; // 权限拒绝 / 启动失败时不触发,对齐 daoqi 行为) bridge.register("prepareaudio") { _, callback in Task { @MainActor in await handlePrepareAudio( bridge: bridge, hostVC: hostVC, isSubGame: isSubGame, callback: callback ) } } // 【5】mediaTypeAudio — Phase 3.F stub(仅 cb 维持契约) // 完整实现(Phase 3.F):voicePlaying 总开关短路 + 下载 AMR + AMR→WAV + // VoicePlayer 播放 + 反向 callback gameui_play_voice(user) / // gameui_stop_voice(user) bridge.register("mediaTypeAudio") { _, callback in callback?(.string("Response from mediaTypeAudio")) } } // MARK: - prepareaudio 处理(MainActor) @MainActor private static func handlePrepareAudio( bridge: any BridgeProtocol, hostVC: @MainActor @Sendable () -> UIViewController?, isSubGame: Bool, callback: BridgeCallback? ) async { // 1. 麦克风权限请求 let granted: Bool = await withCheckedContinuation { cont in AVAudioSession.sharedInstance().requestRecordPermission { ok in cont.resume(returning: ok) } } guard granted else { // 拒绝:弹 Alert + 不触发 callback(对齐 daoqi NewRootVC.m:314-319) showMicrophonePermissionAlert(on: hostVC()) return } // 2. 启动录音浮层 guard let vc = hostVC() else { log.error("prepareaudio: no host VC") return } let fileName = VoiceRecorder.currentTimeString() do { try RecordingPresenter.start(on: vc, fileName: fileName) { wavURL, _ in // 录音完成回调(@MainActor)。后续转码 / 上传是 IO 重活,挪到 // 后台 Task 避免阻塞主线程 Task.detached(priority: .userInitiated) { await processCompletedRecording( wavURL: wavURL, bridge: bridge, isSubGame: isSubGame ) } } } catch { log.error("prepareaudio start failed: \(error.localizedDescription, privacy: .public)") return // 启动失败不触发 cb(对齐 daoqi handler 内 return 行为) } // 3. 启动成功,立即触发 callback(对齐 daoqi NewRootVC.m:322 // [recorderVC beginRecordByFileName:..] 之后 responseCallback) callback?(.string("Response from prepareaudio")) } // MARK: - 录音完成后处理(后台 Task) private static func processCompletedRecording( wavURL: URL, bridge: any BridgeProtocol, isSubGame: Bool ) async { // 1. 读 wav duration → round 取整秒(对齐 daoqi NewRootVC.m:1891-1893 // AVAudioPlayer.duration + round 行为) let durationSec: Int do { let probe = try AVAudioPlayer(contentsOf: wavURL) durationSec = Int(probe.duration.rounded()) } catch { log.error("read wav duration failed: \(error.localizedDescription, privacy: .public)") return } // 2. 生成上传文件名(大厅 vs 子游戏命名规则不同) let amrFileName = uploadFileName(isSubGame: isSubGame) let amrURL = wavURL.deletingLastPathComponent().appendingPathComponent(amrFileName) // 3. WAV → AMR 转码 do { try AMRCodec.wavToAmr(wavURL, to: amrURL) } catch { log.error("wav→amr failed: \(error.localizedDescription, privacy: .public)") return } // 4. 七牛上传(key = amrURL.lastPathComponent,由 QiniuUploader 取) let uploaded: UploadedFile do { uploaded = try await QiniuUploader.shared.upload(amrURL, timeSec: durationSec) } catch { log.error("qiniu upload failed: \(error.localizedDescription, privacy: .public)") return } // 5. 反向 callback(在 MainActor) await MainActor.run { // getaudiourl // ⚠️ time 是 String(%ld 整秒),不是 number — 对齐 daoqi // NewRootVC.m:1923 / gameController.m:2305 bridge.call( "getaudiourl", data: .object([ "audiourl": .string(uploaded.fileUrl), "time": .string(String(durationSec)) ]), callback: nil ) log.debug("getaudiourl audiourl=\(uploaded.fileUrl, privacy: .public) time=\(durationSec, privacy: .public)") // 子游戏额外 recordSuccess(对齐 daoqi gameController.m:2310; // 大厅 NewRootVC 无此 callHandler) if isSubGame { bridge.call( "recordSuccess", data: .object([ "fileUrl": .string(uploaded.fileUrl), "fileName": .string(uploaded.fileName), "fileKey": .string(uploaded.fileKey) ]), callback: nil ) log.debug("recordSuccess fileKey=\(uploaded.fileKey, privacy: .public)") } } } // MARK: - 工具 /// 上传文件名(同时是七牛 key、本地 .amr 文件名): /// 大厅:`yyyyMMddHHmmss%08X.amr` (无下划线,对齐 daoqi NewRootVC.m:1908) /// 子游戏:`yyyyMMddHHmmss_%08X.amr` (有下划线,对齐 daoqi gameController.m:2269) private static func uploadFileName(isSubGame: Bool) -> String { let timestamp = VoiceRecorder.currentTimeString() let random = String(format: "%08X", arc4random()) let separator = isSubGame ? "_" : "" return "\(timestamp)\(separator)\(random).amr" } @MainActor private static func showMicrophonePermissionAlert(on hostVC: UIViewController?) { // 对齐 daoqi NewRootVC.m:316 文案("{gamehallname}需要访问您的麦克风...") let alert = UIAlertController( title: nil, message: "需要访问您的麦克风,请启用麦克风-设置/隐私/麦克风!", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "确定", style: .default)) hostVC?.present(alert, animated: true) } }