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 // RemoteAudioHandler.swift
// ylgamehall // ylgamehall
// //
// H5 Native handler stubprepareaudio / mediaTypeAudio // H5 Native handlersprepareaudio / mediaTypeAudio
// docs/H5-Native-Contract.md §3.1 45 + §3.2 789 // docs/H5-Native-Contract.md §3.1 45 + §3.2 789
// //
// Phase 3.A stub cb H5 "no handler" // ## prepareaudio Phase 3.E
// Phase 3.B AMR + Phase 3.C + Phase 3.D mediaTypeAudio
//
// //
// 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
//
// ## mediaTypeAudioPhase 3.F stub
//
// stubPhase 3.F amr amrToWav
// VoicePlayer.play + gameui_play_voice / gameui_stop_voice
// //
import Foundation import Foundation
import AVFoundation
import UIKit
import os.log
public enum RemoteAudioHandler { public enum RemoteAudioHandler {
public static func register(on bridge: any BridgeProtocol) { private static let log = Logger(subsystem: "ylgamehall", category: "RemoteAudio")
// 4prepareaudio
// cb: "Response from prepareaudio" /// prepareaudio / mediaTypeAudio
// Phase 3.C + AVAudioRecorder + WAVAMR + + /// - Parameters:
// callback getaudiourl({audiourl, time}) + recordSuccess /// - bridge:
// stub cb /// - 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
) {
// 4prepareaudio Phase 3.E
// cb: "Response from prepareaudio"
// / daoqi
bridge.register("prepareaudio") { _, callback in bridge.register("prepareaudio") { _, callback in
callback?(.string("Response from prepareaudio")) Task { @MainActor in
await handlePrepareAudio(
bridge: bridge,
hostVC: hostVC,
isSubGame: isSubGame,
callback: callback
)
}
} }
// 5mediaTypeAudio // 5mediaTypeAudio Phase 3.F stub cb
// audiourl: stringuser: string // Phase 3.FvoicePlaying + AMR + AMRWAV +
// cb: "Response from mediaTypeAudio" // VoicePlayer + callback gameui_play_voice(user) /
// Phase 3.DvoicePlaying + AMR + AMRWAV +
// AVAudioPlayer + callback gameui_play_voice(user) /
// gameui_stop_voice(user) // gameui_stop_voice(user)
// stub cb
bridge.register("mediaTypeAudio") { _, callback in bridge.register("mediaTypeAudio") { _, callback in
callback?(.string("Response from mediaTypeAudio")) 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) throw QiniuUploadError.fileNotFound(localFile.path)
} }
// key`{recordingDirectory}{uuid}.{ext}` msext QiniuManager.m:55-60 // key daoqi
let uuid = UUID().uuidString.lowercased().replacingOccurrences(of: "-", with: "") // - yyyyMMddHHmmss%08X.amrNewRootVC.m:1908线
let ext = localFile.pathExtension // - yyyyMMddHHmmss_%08X.amrgameController.m:2269线
let key = QiniuConfig.recordingDirectory + uuid + (ext.isEmpty ? "" : ".\(ext)") // H5 audiourl
let key = QiniuConfig.recordingDirectory + localFile.lastPathComponent
// token QiniuTokenSigner / msext QiniuManager // token QiniuTokenSigner / msext QiniuManager
let token = QiniuTokenSigner.uploadToken(key: key) let token = QiniuTokenSigner.uploadToken(key: key)
@@ -103,7 +103,13 @@ public final class SubGameViewController: UIViewController {
.deletingLastPathComponent() .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) AccreditLoginHandler.register(on: bridge)
FriendsShareHandler.register(on: bridge) FriendsShareHandler.register(on: bridge)
@@ -84,7 +84,14 @@ public final class WebContainerViewController: UIViewController {
on: bridge, on: bridge,
assetsRoot: { SandboxPaths.lobbyIndex.deletingLastPathComponent() } assetsRoot: { SandboxPaths.lobbyIndex.deletingLastPathComponent() }
) // §3.1 3srcIsloop ) // §3.1 3srcIsloop
RemoteAudioHandler.register(on: bridge) // §3.1 45Phase 3.B/3.C/3.D stub // §3.1 4prepareaudio / 5mediaTypeAudio 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 + stubPhase 4.B SDK / Phase 4.C QQ URL Scheme // Phase 4.A + stubPhase 4.B SDK / Phase 4.C QQ URL Scheme
AccreditLoginHandler.register(on: bridge) // §3.1 1Phase 4.E SDK AccreditLoginHandler.register(on: bridge) // §3.1 1Phase 4.E SDK