Files
youle_app_ios_v2/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift
T
joywayer df2a099fb5 3.F:mediaTypeAudio 完整实现(远端语音下载→转码→播放→反向 callback)
替换 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)落地,语音功能完整对齐原项目。
2026-06-24 01:38:17 +08:00

322 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// RemoteAudioHandler.swift
// ylgamehall
//
// H5 → Native handlersprepareaudio / 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
//
// ## mediaTypeAudioPhase 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: 录音浮层挂载的宿主 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
) {
// 【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)
}
}