3.E:prepareaudio 完整实现 + 大厅/子游戏分流接入
替换 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 错误。
This commit is contained in:
@@ -2,39 +2,216 @@
|
||||
// RemoteAudioHandler.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// H5 → Native handler stub:prepareaudio / mediaTypeAudio
|
||||
// H5 → Native handlers:prepareaudio / mediaTypeAudio
|
||||
// 契约:docs/H5-Native-Contract.md §3.1 [4][5] + §3.2 [7][8][9]
|
||||
//
|
||||
// Phase 3.A 阶段为 stub(仅 cb 维持契约,让 H5 启动不报 "no handler");
|
||||
// Phase 3.B AMR 转码 + Phase 3.C 录音上传 + Phase 3.D mediaTypeAudio 真实播放
|
||||
// 完整接入后升级为真实现。
|
||||
// ## prepareaudio 完整链路(Phase 3.E)
|
||||
//
|
||||
// 按 CLAUDE.md 原则 A 第一准则:H5 调啥就得有人接,即使没真实业务也要注册。
|
||||
// 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 {
|
||||
|
||||
public static func register(on bridge: any BridgeProtocol) {
|
||||
// 【4】prepareaudio — 启动麦克风录音
|
||||
// 入参忽略;cb: "Response from prepareaudio"
|
||||
// 完整实现(Phase 3.C):权限检查 + AVAudioRecorder + WAV→AMR + 七牛上传 +
|
||||
// 反向 callback getaudiourl({audiourl, time}) + 子游戏额外 recordSuccess
|
||||
// stub 仅 cb 维持契约
|
||||
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
|
||||
callback?(.string("Response from prepareaudio"))
|
||||
Task { @MainActor in
|
||||
await handlePrepareAudio(
|
||||
bridge: bridge,
|
||||
hostVC: hostVC,
|
||||
isSubGame: isSubGame,
|
||||
callback: callback
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 【5】mediaTypeAudio — 远程语音回放
|
||||
// 入参 audiourl: string;user: string
|
||||
// cb: "Response from mediaTypeAudio"
|
||||
// 完整实现(Phase 3.D):voicePlaying 总开关短路 + 下载 AMR + AMR→WAV +
|
||||
// AVAudioPlayer 播放 + 反向 callback gameui_play_voice(user) /
|
||||
// 【5】mediaTypeAudio — Phase 3.F stub(仅 cb 维持契约)
|
||||
// 完整实现(Phase 3.F):voicePlaying 总开关短路 + 下载 AMR + AMR→WAV +
|
||||
// VoicePlayer 播放 + 反向 callback gameui_play_voice(user) /
|
||||
// gameui_stop_voice(user)
|
||||
// stub 仅 cb 维持契约(不下载、不播放)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,10 +49,11 @@ public actor QiniuUploader {
|
||||
throw QiniuUploadError.fileNotFound(localFile.path)
|
||||
}
|
||||
|
||||
// 生成 key:`{recordingDirectory}{uuid}.{ext}`(与 msext QiniuManager.m:55-60 等价)
|
||||
let uuid = UUID().uuidString.lowercased().replacingOccurrences(of: "-", with: "")
|
||||
let ext = localFile.pathExtension
|
||||
let key = QiniuConfig.recordingDirectory + uuid + (ext.isEmpty ? "" : ".\(ext)")
|
||||
// 七牛 key 直接用本地文件名(调用方负责命名)。对齐 daoqi 录音文件命名规则:
|
||||
// - 大厅:yyyyMMddHHmmss%08X.amr(NewRootVC.m:1908,无下划线)
|
||||
// - 子游戏:yyyyMMddHHmmss_%08X.amr(gameController.m:2269,有下划线)
|
||||
// H5 通过 audiourl 转发给对端,对端按文件名规律假设——必须严格对齐。
|
||||
let key = QiniuConfig.recordingDirectory + localFile.lastPathComponent
|
||||
|
||||
// 客户端自签 token(参 QiniuTokenSigner / msext QiniuManager 同款算法)
|
||||
let token = QiniuTokenSigner.uploadToken(key: key)
|
||||
|
||||
@@ -103,7 +103,13 @@ public final class SubGameViewController: UIViewController {
|
||||
.deletingLastPathComponent()
|
||||
}
|
||||
)
|
||||
RemoteAudioHandler.register(on: bridge)
|
||||
// 子游戏 isSubGame=true:录音上传成功后额外触发 recordSuccess 反向 callback
|
||||
// (对齐 daoqi gameController.m:2310;大厅 NewRootVC 无此 callHandler)
|
||||
RemoteAudioHandler.register(
|
||||
on: bridge,
|
||||
hostVC: { [weak self] in self },
|
||||
isSubGame: true
|
||||
)
|
||||
|
||||
AccreditLoginHandler.register(on: bridge)
|
||||
FriendsShareHandler.register(on: bridge)
|
||||
|
||||
@@ -84,7 +84,14 @@ public final class WebContainerViewController: UIViewController {
|
||||
on: bridge,
|
||||
assetsRoot: { SandboxPaths.lobbyIndex.deletingLastPathComponent() }
|
||||
) // §3.1 [3]srcIsloop 完整实现
|
||||
RemoteAudioHandler.register(on: bridge) // §3.1 [4][5]Phase 3.B/3.C/3.D stub
|
||||
// §3.1 [4]prepareaudio 完整实现 / [5]mediaTypeAudio Phase 3.F stub
|
||||
// 大厅 isSubGame=false:录音上传成功后仅触发 getaudiourl,不触发 recordSuccess
|
||||
// (对齐 daoqi NewRootVC 无 recordSuccess callHandler 的行为)
|
||||
RemoteAudioHandler.register(
|
||||
on: bridge,
|
||||
hostVC: { [weak self] in self },
|
||||
isSubGame: false
|
||||
)
|
||||
|
||||
// Phase 4.A 登录 + 分享 stub(Phase 4.B 微信 SDK / Phase 4.C QQ URL Scheme 后升级)
|
||||
AccreditLoginHandler.register(on: bridge) // §3.1 [1]Phase 4.E 完整微信 SDK
|
||||
|
||||
Reference in New Issue
Block a user