393 lines
18 KiB
Swift
393 lines
18 KiB
Swift
//
|
||
// 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<Bool, Never>) 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)
|
||
}
|
||
}
|