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:
joywayer
2026-06-24 01:36:25 +08:00
parent 173bf79a74
commit f2b456cf00
4 changed files with 215 additions and 24 deletions
@@ -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