替换 RemoteAudioHandler 中 prepareaudio 的 stub 为完整链路:权限检查 →
录音浮层 → 转码 → 七牛上传 → 反向 callback getaudiourl + 子游戏额外
recordSuccess。mediaTypeAudio 仍为 stub(Phase 3.F 处理)。
## 完整链路
1. H5 调 prepareaudio
2. `AVAudioSession.requestRecordPermission`
- 拒绝:弹 Alert("需要访问您的麦克风..."),**不触发 cb**
(对齐 daoqi NewRootVC.m:314-319)
3. 同意 → `RecordingPresenter.start(on: hostVC, fileName:)`
- 启动失败:log + return,**不触发 cb**
- 启动成功:立即触发 `cb("Response from prepareaudio")`,对齐 daoqi
NewRootVC.m:322 `[recorderVC beginRecordByFileName:..]` 之后 cb 行为
4. 用户松手 / 60s 自动停 → `processCompletedRecording` 后台 Task
- 用 AVAudioPlayer 读 wav `duration` → `round` 取整秒(对齐 daoqi
NewRootVC.m:1891-1893)
- 生成 amr 文件名:
- 大厅 `yyyyMMddHHmmss%08X.amr`(无下划线,NewRootVC.m:1908)
- 子游戏 `yyyyMMddHHmmss_%08X.amr`(有下划线,gameController.m:2269)
- `AMRCodec.wavToAmr`
- `QiniuUploader.upload`(key = 本地文件名,对齐 daoqi 上传命名)
5. 反向 callback(MainActor):
- `getaudiourl({audiourl: String, time: String})`
⚠️ time 是字符串(%ld 整秒),不是 number — 对齐 daoqi
NewRootVC.m:1923 / gameController.m:2305
- **仅子游戏**额外 `recordSuccess({fileUrl, fileName, fileKey})`
对齐 daoqi gameController.m:2310;大厅 NewRootVC 无此 callHandler
## register 签名扩展
新增参数:
- `hostVC: @escaping @MainActor @Sendable () -> UIViewController?`
—— 每次录音重新求值,避免捕获过时引用
- `isSubGame: Bool` —— 决定是否触发 recordSuccess
WebContainerViewController(大厅)传 `isSubGame: false`;
SubGameViewController(子游戏)传 `isSubGame: true`。
## QiniuUploader 改动
key 生成方式:UUID → 本地文件名(lastPathComponent)。让调用方完全控制
key 命名,对齐 daoqi 录音文件命名规则。H5 通过 audiourl 转发给对端,对端
按文件名规律假设,必须严格对齐。
## 错误处理
启动失败 / 转码失败 / 上传失败:log + return,不触发反向 callback。
对齐 daoqi 同款 silent return 行为(mediaTypeAudio 下载失败、转码失败
也是同样 silent return 模式)。
## 验证
BuildProject 通过 0 错误。
218 lines
8.8 KiB
Swift
218 lines
8.8 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 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)
|
||
}
|
||
}
|