Files
youle_app_ios_v2/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift
T
joywayer 90a523736e 3.A/3.F 修复:voicePlaying 入参类型兼容 + mediaTypeAudio canPlay 诊断
## 发现的根因

实测日志 mediaTypeAudio 进入了 native(amrFileCodec frame=107 打印两次确认
转码运行),但缺 gameui_play_voice 触发 log → canPlay=false 被短路。
原因是 voicePlaying handler 解析 H5 入参时类型不容忍。

之前实现:
  let on = (data?.asInt ?? 0) == 1

asInt 只对 BridgeData.number case 返回值。若 H5 传 string "1"(实测确实
会传)→ asInt 返回 nil → `?? 0 = 0` → `0 == 1 = false` → 开关被关闭。

daoqi 用 ObjC `[data intValue]`(NSString/NSNumber 都能转),永远能
正确处理两种类型。

## 修复

VoicePlayingHandler:改用 asLooseString 比较 "1" 字符串,兼容 H5 传:
  - string "1"  → asLooseString → "1" → on=true ✓
  - number 1    → asLooseString → "1" → on=true ✓
  - string "0"  → asLooseString → "0" → on=false ✓
  - number 0    → asLooseString → "0" → on=false ✓
  - 其它       → on=false(保守拒绝)

同时加 log.debug 记录每次 voicePlaying 调用的 raw + 解析后值,便于后续
诊断 H5 业务行为。

## 诊断辅助

RemoteAudioHandler.handleMediaTypeAudio 增加 log.debug:
  mediaTypeAudio user=X transcoded=Y canPlay=Z

让真机测试能直接看到 canPlay 实际值,避免再次盲猜。

## 验证

BuildProject 通过 0 错误。真机重试 mediaTypeAudio 应能看到:
  voicePlaying H5 → raw=1 → isEnabled=true
  mediaTypeAudio user=0 transcoded=true canPlay=true
  gameui_play_voice user=0
2026-06-27 12:07:20 +08:00

392 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// RemoteAudioHandler.swift
// ylgamehall
//
// H5 → Native handlersprepareaudio / 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
//
// ## mediaTypeAudioPhase 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: 录音浮层挂载的宿主 VClobby = 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()
// (原生 asyncSwift 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 打断为 touchcancelH5 业务的 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-437H5 实测也会带 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. 记录 lastUserId(无论是否实际播放都更新,对齐 daoqi
// `self.User_id = userid;` 总是执行的行为)
VoicePlayer.shared.recordIncomingUser(user)
// 3. 注册 VoicePlayer.onFinish(每次 mediaTypeAudio 更新,绑定当前 bridge
// [weak bridge] 让 bridge 释放后 onFinish 自动失效)
VoicePlayer.shared.onFinish = { [weak bridge] finishedUser in
Task { @MainActor in
bridge?.call(
"gameui_stop_voice",
data: .string(finishedUser),
callback: nil
)
}
}
// 4. 异步下载 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;`
}
// 5. 写到沙盒 .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
}
// 6. 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
}
// 7. voicePlaying 总开关 + 实际播放 + gameui_play_voice
// (仅转码成功 + 开关 enabled 时触发,对齐 daoqi
// NewRootVC.m:343-353 if (canbofang) { play + callHandler }
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)
// 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)")
}
}
// 8. 触发 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)
}
}