替换 mediaTypeAudio handler 的 stub 为完整链路。至此 Phase 3 录音 + 远端
语音播放整套流程接通;prepareaudio / mediaTypeAudio / VoicePlaying / 反向
callback 全部对齐 daoqi msext 行为。
## 完整链路
1. H5 调 mediaTypeAudio({audiourl, user})
2. 解析入参;参数不全 → log + return(不触发 cb,对齐 daoqi 入参空时
走到 file 不存在 return 的语义)
3. `VoicePlayer.shared.recordIncomingUser(user)` 更新 lastUserId
⚠️ 无论后续是否实际播放都更新(对齐 daoqi gameController.m:438
`self.User_id=userid;` 总是执行的行为)
4. 注册 VoicePlayer.onFinish 绑定当前 bridge([weak bridge])→ 播完触发
`gameui_stop_voice(lastUserId)` 反向 callback
5. `URLSession.shared.data(from:)` 异步下载 AMR
- 下载失败 → log + return,**不触发 cb**
(对齐 daoqi NewRootVC.m:335-337 file 不存在 return)
6. 写入沙盒 .amr 临时文件
7. `AMRCodec.amrToWav` 转码到 `{tempName}_AmrToWav.wav`
(文件名约定对齐 daoqi NewRootVC.m:341)
- 转码失败 → log,**但 cb 仍触发**(对齐 daoqi NewRootVC.m:355-357
amr转wav失败 NSLog 后仍触发 responseCallback)
8. `await VoiceCenter.shared.isEnabled`(canbofang 等价)+ 实际播放
- 仅转码成功 + 开关 enabled 时 play + 触发 `gameui_play_voice(user)`
对齐 daoqi NewRootVC.m:343-353 if (canbofang) { play + callHandler }
- `gameui_play_voice` data 是 user 字符串(非 dict)
9. 触发 `cb("Response from mediaTypeAudio")` —— **总是**触发
(除下载失败 return 路径外)
## VoicePlayer.onFinish 绑定时机
每次 mediaTypeAudio 调用时更新 onFinish 闭包,绑定当前 bridge。
[weak bridge] 防止 bridge 释放后 onFinish 触发野指针。
## 关键契约点(已验证字面字符串)
- handler 名:"mediaTypeAudio"
- 入参字段:"audiourl" / "user"
- 回包字面:"Response from mediaTypeAudio"
- 反向 callback:
- "gameui_play_voice" data = user String
- "gameui_stop_voice" data = lastUserId String
## 验证
BuildProject 通过 0 错误。至此 Phase 3 全部子任务(3.A / 3.B / 3.C /
3.D / 3.E / 3.F)落地,语音功能完整对齐原项目。
322 lines
13 KiB
Swift
322 lines
13 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. 麦克风权限请求
|
||
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"
|
||
}
|
||
|
||
// 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)
|
||
guard let obj = data?.asObject,
|
||
let urlString = obj["audiourl"]?.asString,
|
||
let user = obj["user"]?.asString,
|
||
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
|
||
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"))
|
||
}
|
||
|
||
@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)
|
||
}
|
||
}
|