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)落地,语音功能完整对齐原项目。
This commit is contained in:
joywayer
2026-06-24 01:38:17 +08:00
parent f2b456cf00
commit df2a099fb5
@@ -62,12 +62,17 @@ public enum RemoteAudioHandler {
}
}
// 5mediaTypeAudio Phase 3.F stub cb
// Phase 3.FvoicePlaying + AMR + AMRWAV +
// VoicePlayer + callback gameui_play_voice(user) /
// gameui_stop_voice(user)
bridge.register("mediaTypeAudio") { _, callback in
callback?(.string("Response from mediaTypeAudio"))
// 5mediaTypeAudio 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
)
}
}
}
@@ -203,6 +208,105 @@ public enum RemoteAudioHandler {
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}访..."