// // 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) // cb: "Response from mediaTypeAudio"(在下载+转码完成后触发,对齐 daoqi // 同步阻塞下载语义;下载失败时不触发 cb) bridge.register("mediaTypeAudio") { data, callback in Task { @MainActor in await handleMediaTypeAudio( data: data, bridge: bridge, callback: callback ) } } } // MARK: - prepareaudio 处理(MainActor) @MainActor private static func handlePrepareAudio( bridge: any BridgeProtocol, hostVC: @MainActor @Sendable () -> UIViewController?, isSubGame: Bool, callback: BridgeCallback? ) async { // 1. 麦克风权限**只读**状态检查(对齐 daoqi FuncPublic.m:1144-1170 ifauth 实现)。 // 主动请求权限(requestRecordPermission)会立即弹出 iOS 系统对话框; // 在 H5 按住状态下弹系统对话框 → 用户必须松手点"允许" → // RecordingAwareWindow.touchObserver 捕获松手立刻 stop → 浮层瞬间关闭, // 用户体验异常(首次启动 app 录音永远失败)。 let status = AVCaptureDevice.authorizationStatus(for: .audio) switch status { case .denied, .restricted: // 明确拒绝 → 弹 Alert + 不触发 cb(对齐 daoqi NewRootVC.m:314-319) showMicrophonePermissionAlert(on: hostVC()) return case .notDetermined: // 首次启动:弹系统权限对话框,**不**启动录音浮层(避免与 touchObserver // 死锁——touchObserver 会捕获用户松手点"允许"的动作立刻 stop 录音)。 // 用户做完选择 / 第二次按录音按钮时走 .authorized 分支正常录音。 // // 优先用 iOS 17+ 的 AVAudioApplication.requestRecordPermission() // (原生 async,Swift Concurrency 一等公民);iOS 26+ 上 // deprecated 的 AVAudioSession.requestRecordPermission(_:) + // withCheckedContinuation 桥接路径实测会触发 Thread 10 // EXC_BREAKPOINT(疑似系统内部桥接新 API 时状态机异常)。 _ = await requestMicrophonePermission() // ⚠️ 必须触发 cb(对齐 daoqi ifauth==1 路径行为,daoqi NewRootVC.m:322 // 在 [recorderVC beginRecordByFileName:] 后总是 responseCallback)。 // // 不触发 cb 的后果:H5 业务在 callHandler 调用后通常会上"录音中"业务锁 // (加触摸遮罩、禁用按钮等),等收到 cb 才解锁。系统权限对话框已经把 // H5 的 in-flight touch 打断为 touchcancel,H5 业务的 touchcancel 处理 // 可能不完善 → 业务锁不释放 → 整个 H5 后续无法点击。 // // 触发 cb 让 H5 释放业务锁,即使本次实际未录音也无害(H5 会按"无 // getaudiourl"的失败路径走,与 daoqi 转码失败 silent 路径等价)。 callback?(.string("Response from prepareaudio")) return case .authorized: break // 继续启动录音 @unknown default: 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" } // MARK: - mediaTypeAudio 处理(MainActor) @MainActor private static func handleMediaTypeAudio( data: BridgeData?, bridge: any BridgeProtocol, callback: BridgeCallback? ) async { // 1. 解析入参(核心字段 audiourl + user,对齐 daoqi NewRootVC.m:326-328 / // gameController.m:435-437;H5 实测也会带 type 字段但 daoqi 注释掉、不用)。 // // ⚠️ user 字段在 H5 实测中是 number(如 0、12345),不是 string;用 // asLooseString 兼容两种类型(对齐 daoqi `%@` 格式化容忍 NSNumber 行为) guard let obj = data?.asObject, let urlString = obj["audiourl"]?.asString, let user = obj["user"]?.asLooseString, let downloadURL = URL(string: urlString) else { log.error("mediaTypeAudio: bad params \(String(describing: data), privacy: .public)") return // 参数不全 → 不触发 cb(对齐 daoqi 入参空时走到 file 不存在 return) } // 2. 注册 VoicePlayer.onFinish(每次 mediaTypeAudio 更新,绑定当前 bridge; // [weak bridge] 让 bridge 释放后 onFinish 自动失效)。 // onFinish 触发时机:自然结束 / 解码失败 / 被新一次 play 打断; // payload `finishedUser` 是被结束 / 被打断那条语音绑定的 user // (由 VoicePlayer 内部 currentUser 跟踪),不是入参里最新的 user。 VoicePlayer.shared.onFinish = { [weak bridge] finishedUser in Task { @MainActor in bridge?.call( "gameui_stop_voice", data: .string(finishedUser), callback: nil ) } } // 3. 异步下载 AMR let amrData: Data do { let (d, _) = try await URLSession.shared.data(from: downloadURL) amrData = d } catch { log.error("mediaTypeAudio download failed: \(error.localizedDescription, privacy: .public)") return // 下载失败 → 不触发 cb(对齐 daoqi NewRootVC.m:335-337 // `if (![[NSFileManager defaultManager] fileExistsAtPath:filename]) return;`) } // 4. 写到沙盒 .amr 临时文件 let tempName = VoiceRecorder.currentTimeString() let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let amrLocal = docs.appendingPathComponent(tempName).appendingPathExtension("amr") try? FileManager.default.removeItem(at: amrLocal) do { try amrData.write(to: amrLocal) } catch { log.error("mediaTypeAudio write amr failed: \(error.localizedDescription, privacy: .public)") return } // 5. AMR → WAV 转码 // daoqi 文件名约定:tempName + "_AmrToWav.wav"(NewRootVC.m:341) let wavLocal = docs .appendingPathComponent("\(tempName)_AmrToWav") .appendingPathExtension("wav") try? FileManager.default.removeItem(at: wavLocal) let transcoded: Bool do { try AMRCodec.amrToWav(amrLocal, to: wavLocal) transcoded = true } catch { // 转码失败:log,**但 cb 仍触发**(对齐 daoqi NewRootVC.m:355-357 // 失败仅 NSLog 不影响 responseCallback 触发) log.error("mediaTypeAudio amr→wav failed: \(error.localizedDescription, privacy: .public)") transcoded = false } // 6. voicePlaying 总开关 + 实际播放 + gameui_play_voice // (仅转码成功 + 开关 enabled 时触发,对齐 daoqi // NewRootVC.m:343-353 if (canbofang) { play + callHandler })。 // 若当前已有正在播放的 player,VoicePlayer.play 会先停旧 player 并 // 通过 onFinish 反向调 gameui_stop_voice 让 H5 停旧动画,再起新播放。 let canPlay = await VoiceCenter.shared.isEnabled log.debug("mediaTypeAudio user=\(user, privacy: .public) transcoded=\(transcoded, privacy: .public) canPlay=\(canPlay, privacy: .public)") if transcoded, canPlay { do { try VoicePlayer.shared.play(wavLocal, user: user) // gameui_play_voice 数据是 user 字符串(非 dict) bridge.call( "gameui_play_voice", data: .string(user), callback: nil ) log.debug("gameui_play_voice user=\(user, privacy: .public)") } catch { log.error("VoicePlayer.play failed: \(error.localizedDescription, privacy: .public)") } } // 7. 触发 mediaTypeAudio cb(对齐 daoqi NewRootVC.m:357,**总是**触发 // 即使转码失败 / canbofang=NO 都触发;仅下载失败的 return 路径不触发) callback?(.string("Response from mediaTypeAudio")) } /// 麦克风权限请求(弹系统对话框)。 /// /// iOS 17+ 用新 `AVAudioApplication.requestRecordPermission()`(原生 async); /// iOS 15.6 / 16 用 deprecated 的 `AVAudioSession.requestRecordPermission(_:)` + /// withCheckedContinuation 桥接。 /// /// **不用** deprecated API 走新 SDK 路径的原因:实测 iOS 26.5 SDK 上 deprecated /// API 内部桥接到 AVAudioApplication 时与 withCheckedContinuation 的状态机交互 /// 异常,会在 Thread 10 触发 EXC_BREAKPOINT(疑似系统 bug 或 Swift Concurrency /// 与 ObjC 桥接的 corner case)。新 API 直接 await 不经 Continuation 状态机, /// 行为稳定。 private static func requestMicrophonePermission() async -> Bool { if #available(iOS 17.0, *) { return await AVAudioApplication.requestRecordPermission() } else { return await withCheckedContinuation { (cont: CheckedContinuation) in AVAudioSession.sharedInstance().requestRecordPermission { granted in cont.resume(returning: granted) } } } } @MainActor private static func showMicrophonePermissionAlert(on hostVC: UIViewController?) { // 用户已拒绝麦克风权限时调用。iOS 不允许再次主动弹系统权限对话框 // (requestRecordPermission 在 .denied 状态下不再弹窗、直接返回 false), // 唯一恢复路径是用户手动去「设置 → app → 麦克风」开启。 // // 文案保留 daoqi NewRootVC.m:316 的引导语;按钮提供「去授权」直接跳 // app 设置页(比 daoqi 仅"确定"更易用),「取消」关闭 Alert。 let alert = UIAlertController( title: nil, message: "需要访问您的麦克风,请启用麦克风-设置/隐私/麦克风!", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "去授权", style: .default) { _ in // 跳转到本 app 在「设置」中的页面。用户在此页打开麦克风开关后回到 // app,状态自动变为 .authorized,下次 prepareaudio 走正常分支。 if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } }) hostVC?.present(alert, animated: true) } }