3.C:录音组件落地(VoiceRecorder + Overlay + Presenter + Window 子类)
完整支撑 Phase 3.E prepareaudio handler 的录音子链路:用户按住 H5 麦克风按钮 → Native 录音 → 松手停止 → 触发完成回调。 ## 新增模块 - `RecordingAwareWindow`:UIWindow 子类,提供 touch 旁路监听通道 (static touchObserver closure)。默认行为与 UIWindow 完全一致,仅 RecordingPresenter 注册 observer 后才触发额外行为。daoqi msext CustomWindow.m + nScreenTouch 通知同款思路,改用 closure 注入 - `VoiceRecorder`:AVAudioRecorder + AudioSession + 0.1s 计时器封装。 ObservableObject + @Published(暴露 averagePower / elapsed 给 SwiftUI)。 录音参数 8kHz/16bit/mono PCM 严格对齐 daoqi(AMR-NB 编码硬要求)。 60s 上限对齐 daoqi kDefaultMaxRecordTime - `RecordingOverlay`:SwiftUI 浮层(UI 内部实现,按 CLAUDE.md「原则 B 原生 内部自由」与 daoqi 不一致)。半透明黑底胶囊 + SF Symbols 麦克风 + 音量 光晕动画 + 倒计时(剩 10s 显示秒数,对齐 daoqi UI 逻辑) - `RecordingPresenter`:组装入口。挂载 overlay 到 hostVC 全屏覆盖 + 注册 touchObserver。提供 `start(on:fileName:onFinish:)` 单一 API + `cancelActive()` 强制取消 ## SceneDelegate 改动 UIWindow → RecordingAwareWindow 实例化。变量类型仍为 UIWindow? (多态),sendEvent 走子类版本。零行为变化(observer 未注册时)。 ## 为何必须走 UIWindow 子类 H5 麦克风按钮按下触发 prepareaudio → 录音浮层在按住状态下中途弹出。 iOS touch 事件流是 view hit-test 链,**in-flight touch 不传给中途加入 的 view**,浮层 view 自身收不到 touchesEnded,用户松手时检测不到。 唯一可靠路径是 UIWindow.sendEvent 旁路截获(UIWindow 接收所有 touch event 无论 in-flight 还是新 touch)。daoqi msext 也是这条路。 ## Combine 引入 VoiceRecorder 用 ObservableObject + @Published 给 SwiftUI 数据绑定, 需要 import Combine。CLAUDE.md「避免 Combine」精神是禁用 Publisher 链 替代 async/await,不是禁止 ObservableObject(iOS 15.6 无 @Observable 宏可替代)。已在 VoiceRecorder.swift 注释说明。 ## 验证 BuildProject 通过 0 错误,全部新文件 XcodeRefreshCodeIssuesInFile 0 诊断。
This commit is contained in:
@@ -13,7 +13,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
// 使用 RecordingAwareWindow(UIWindow 子类)以便录音浮层旁路监听 touch
|
||||
// 事件。默认行为与 UIWindow 完全一致,仅在 RecordingPresenter 注册
|
||||
// touchObserver 后才有额外行为。详见 Source/Audio/RecordingAwareWindow.swift
|
||||
let window = RecordingAwareWindow(windowScene: windowScene)
|
||||
|
||||
// 用 UINavigationController 承载大厅,便于 Phase 6 push 子游戏 / Phase 7 push 弹层。
|
||||
// 栈深约束 ≤ 3(Design §2.4.3),由 AppCoordinator 在 push 处守门。
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// RecordingAwareWindow.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// UIWindow 子类:提供 touch 事件的旁路监听通道,供录音浮层用。
|
||||
//
|
||||
// ## 为何需要
|
||||
//
|
||||
// H5 麦克风按钮按下时触发 prepareaudio → 录音浮层在「按住状态下」中途
|
||||
// 弹出。iOS 的 touch 事件流走 view hit-test 链,**in-flight touch 不会
|
||||
// 传给中途加入的 view**,新弹浮层 view 收不到 touchesEnded,导致用户松
|
||||
// 手时 Native 检测不到。
|
||||
//
|
||||
// 必须在 UIWindow 层旁路截获 touch,绕开 view hit-test 链的不确定性。
|
||||
// daoqi msext `CustomWindow.m` 同款思路(msext 用 NotificationCenter
|
||||
// post `nScreenTouch` 通知,新外壳改用 closure 注入)。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 录音感知 UIWindow。
|
||||
///
|
||||
/// **默认行为完全不变**:未设置 `touchObserver` 时,`sendEvent` 直接调
|
||||
/// `super`,所有 touch 事件按正常 hit-test 链分发。设置 `touchObserver`
|
||||
/// 后,window 在每次 `.touches` event 时把 event 转给 observer,**触发
|
||||
/// 时机在 `super.sendEvent` 之后,不阻塞业务 view 的事件分发**。
|
||||
public final class RecordingAwareWindow: UIWindow {
|
||||
|
||||
/// 全局 touch event 观察 closure。
|
||||
///
|
||||
/// - 不为 nil 时,每次 `.touches` event 触发该 closure
|
||||
/// - 录音组件在 start 时设置、stop / cancel 时清空
|
||||
/// - **必须主线程访问**:UIWindow.sendEvent 由 UIKit 主线程调,
|
||||
/// 读写 closure 在主线程串行,无需加锁
|
||||
public static var touchObserver: ((UIEvent) -> Void)?
|
||||
|
||||
public override func sendEvent(_ event: UIEvent) {
|
||||
super.sendEvent(event)
|
||||
if event.type == .touches {
|
||||
Self.touchObserver?(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// RecordingOverlay.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// 录音浮层 SwiftUI 视图(**UI 内部实现**,不影响 H5 契约)。
|
||||
//
|
||||
// 按 CLAUDE.md 「原则 B 原生内部自由」,UI 设计与 daoqi 不一致:
|
||||
// - daoqi 是 ChatVoiceRecorderVC.xib 125×130 老式弹窗 + UIImageView 波形 +
|
||||
// "录音剩下:Ns秒" 文本
|
||||
// - 新外壳改用现代 SwiftUI:半透明黑底胶囊 + SF Symbols 麦克风 + 光晕动画 +
|
||||
// 倒计时文本
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct RecordingOverlay: View {
|
||||
|
||||
@ObservedObject var recorder: VoiceRecorder
|
||||
|
||||
/// 剩余秒数 (0 ~ maxDuration)
|
||||
private var remaining: TimeInterval {
|
||||
max(0, VoiceRecorder.maxDuration - recorder.elapsed)
|
||||
}
|
||||
|
||||
/// 倒计时提示,仅最后 10 秒显示秒数(对齐 daoqi
|
||||
/// ChatVoiceRecorderVC.m:233-236 「剩 10s 显示倒计时」逻辑)。
|
||||
private var hint: String {
|
||||
if remaining < 10 {
|
||||
"录音剩下:\(Int(ceil(remaining)))秒"
|
||||
} else {
|
||||
"松开发送"
|
||||
}
|
||||
}
|
||||
|
||||
private var hintColor: Color {
|
||||
remaining < 10 ? .yellow : .white.opacity(0.85)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 全屏背景:阻止下层 H5 接收点击。touch 事件由
|
||||
// RecordingAwareWindow.touchObserver 旁路捕获,与本 view 的
|
||||
// hit-test 无关;这里只是视觉上的"接管屏幕"
|
||||
Color.black.opacity(0.001)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
|
||||
VStack(spacing: 12) {
|
||||
MicrophoneWaveform(power: recorder.averagePower)
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
Text(hint)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(hintColor)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 20)
|
||||
.background(
|
||||
.black.opacity(0.7),
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
/// 麦克风波形指示:根据 averagePower 缩放光晕圆。
|
||||
private struct MicrophoneWaveform: View {
|
||||
let power: Float // -160 ~ 0 dB
|
||||
|
||||
/// dB 映射到 [0, 1]:截断 [-50, 0],等距归一化。
|
||||
/// -50 dB 视作静音底噪,0 dB 视作峰值。
|
||||
private var level: CGFloat {
|
||||
let clamped = max(-50, min(0, power))
|
||||
return CGFloat((clamped + 50) / 50)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.white.opacity(0.15 + Double(level) * 0.25))
|
||||
.scaleEffect(0.7 + level * 0.5)
|
||||
.animation(.easeOut(duration: 0.15), value: level)
|
||||
|
||||
Image(systemName: "mic.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// RecordingPresenter.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// 录音 UI 呈现器:组装 VoiceRecorder + RecordingOverlay + RecordingAwareWindow
|
||||
// 三方协作,对外暴露单一 `start` 入口。
|
||||
//
|
||||
// 内部职责:
|
||||
// 1. 创建 VoiceRecorder 启动录音
|
||||
// 2. 把 RecordingOverlay 挂载到 hostVC.view 全屏覆盖
|
||||
// 3. 注册 RecordingAwareWindow.touchObserver 监听松手
|
||||
// 4. 触发松手 / 60s 自动停 → recorder.stop → onFinish → 清理 overlay +
|
||||
// observer
|
||||
//
|
||||
// 调用方(Phase 3.E prepareaudio handler):
|
||||
// ```swift
|
||||
// let fileName = VoiceRecorder.currentTimeString()
|
||||
// try RecordingPresenter.start(on: hostVC, fileName: fileName) { wavURL, _ in
|
||||
// // 录音完成;调用方负责后续转码 / 上传 / 反向 callback
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
public enum RecordingPresenter {
|
||||
|
||||
/// 当前进行中会话;同时只能有一个录音
|
||||
private static var active: ActiveSession?
|
||||
|
||||
/// 启动录音 + 显示浮层 + 注册触摸监听。
|
||||
///
|
||||
/// 若已有进行中会话,会先 `cancel()` 旧的再开新的(防御性,正常流程下
|
||||
/// 不应发生——H5 prepareaudio 不会在录音中再次调用)。
|
||||
///
|
||||
/// - Throws: VoiceRecorder.RecorderError,调用方应捕获并向用户提示
|
||||
public static func start(
|
||||
on hostVC: UIViewController,
|
||||
fileName: String,
|
||||
onFinish: @escaping (URL, TimeInterval) -> Void
|
||||
) throws {
|
||||
// 防御:清理任何遗留会话
|
||||
cancelActive()
|
||||
|
||||
let recorder = VoiceRecorder()
|
||||
|
||||
try recorder.start(fileName: fileName) { wavURL, duration in
|
||||
// 完成回调:清理 UI + observer,转发给业务
|
||||
Self.teardown()
|
||||
onFinish(wavURL, duration)
|
||||
}
|
||||
|
||||
// 挂载 overlay 到 hostVC.view 全屏覆盖
|
||||
let overlayHost = UIHostingController(rootView: RecordingOverlay(recorder: recorder))
|
||||
overlayHost.view.backgroundColor = .clear
|
||||
hostVC.addChild(overlayHost)
|
||||
overlayHost.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
hostVC.view.addSubview(overlayHost.view)
|
||||
NSLayoutConstraint.activate([
|
||||
overlayHost.view.topAnchor.constraint(equalTo: hostVC.view.topAnchor),
|
||||
overlayHost.view.bottomAnchor.constraint(equalTo: hostVC.view.bottomAnchor),
|
||||
overlayHost.view.leadingAnchor.constraint(equalTo: hostVC.view.leadingAnchor),
|
||||
overlayHost.view.trailingAnchor.constraint(equalTo: hostVC.view.trailingAnchor),
|
||||
])
|
||||
overlayHost.didMove(toParent: hostVC)
|
||||
|
||||
active = ActiveSession(recorder: recorder, overlayHost: overlayHost)
|
||||
|
||||
// 注册触摸观察:任一 touch ended/cancelled → 停止录音
|
||||
RecordingAwareWindow.touchObserver = { [weak recorder] event in
|
||||
guard let recorder, let touches = event.allTouches else { return }
|
||||
let anyEnded = touches.contains {
|
||||
$0.phase == .ended || $0.phase == .cancelled
|
||||
}
|
||||
if anyEnded {
|
||||
recorder.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 外部强制取消(如 H5 业务退出 / 应用切到后台)。
|
||||
public static func cancelActive() {
|
||||
active?.recorder.cancel()
|
||||
teardown()
|
||||
}
|
||||
|
||||
private static func teardown() {
|
||||
active?.removeOverlay()
|
||||
active = nil
|
||||
RecordingAwareWindow.touchObserver = nil
|
||||
}
|
||||
|
||||
private struct ActiveSession {
|
||||
let recorder: VoiceRecorder
|
||||
let overlayHost: UIHostingController<RecordingOverlay>
|
||||
|
||||
func removeOverlay() {
|
||||
overlayHost.willMove(toParent: nil)
|
||||
overlayHost.view.removeFromSuperview()
|
||||
overlayHost.removeFromParent()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//
|
||||
// VoiceRecorder.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// 录音业务对象。管理 AVAudioRecorder + AudioSession + 实时音量/时长状态。
|
||||
//
|
||||
// - 不负责 UI(RecordingOverlay 通过 @ObservedObject 订阅 @Published 状态)
|
||||
// - 不负责触摸捕获(RecordingAwareWindow.touchObserver 实现,由
|
||||
// RecordingPresenter 串接)
|
||||
//
|
||||
// 契约:docs/H5-Native-Contract.md §3.1 [4]prepareaudio 的录音子链路。
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Combine // ObservableObject / @Published:SwiftUI 数据绑定标准用法。
|
||||
// 项目最低 iOS 15.6,无 iOS 17+ `@Observable` 宏可替代。
|
||||
// CLAUDE.md「避免 Combine」指禁止用 Publisher 链替代 async/await,
|
||||
// 不禁止 ObservableObject。
|
||||
import os.log
|
||||
|
||||
/// 录音状态机 + AVAudioRecorder 包装。
|
||||
public final class VoiceRecorder: ObservableObject {
|
||||
|
||||
private static let log = Logger(subsystem: "ylgamehall", category: "VoiceRecorder")
|
||||
|
||||
/// 最大录音时长 (秒),对齐 daoqi VoiceRecorderBaseVC.h:15 `kDefaultMaxRecordTime`。
|
||||
public static let maxDuration: TimeInterval = 60
|
||||
|
||||
/// 录音参数(8kHz / 16bit / mono PCM)。对齐 daoqi
|
||||
/// VoiceRecorderBaseVC.m:116-128 `getAudioRecorderSettingDict`。
|
||||
/// AMR-NB 编码硬要求 8kHz mono;改动会让 amrFileCodec.mm `ReadPCMFrame`
|
||||
/// 拿不到完整 160 sample 帧,转码失败。
|
||||
private static let recorderSettings: [String: Any] = [
|
||||
AVSampleRateKey: 8000.0 as Float,
|
||||
AVFormatIDKey: UInt32(kAudioFormatLinearPCM),
|
||||
AVLinearPCMBitDepthKey: 16,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
]
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var timer: Timer?
|
||||
private var startTime: Date?
|
||||
private var onFinishCallback: ((URL, TimeInterval) -> Void)?
|
||||
private var recordingFileURL: URL?
|
||||
|
||||
/// 实时音量峰值 (~-160 ~ 0 dB);UI 用作波形动画。
|
||||
@Published public private(set) var averagePower: Float = -160
|
||||
|
||||
/// 已录制秒数(0 ~ maxDuration)。
|
||||
@Published public private(set) var elapsed: TimeInterval = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
public enum RecorderError: Error, Sendable {
|
||||
case sessionFailed(String)
|
||||
case recorderCreateFailed(String)
|
||||
case startFailed
|
||||
}
|
||||
|
||||
/// 启动录音。调用前应已获得麦克风权限(由 prepareaudio handler 保证)。
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - fileName: wav 文件名(不含 .wav 扩展),对齐 daoqi `getCurrentTimeString` 产物
|
||||
/// - onFinish: 完成回调 (wavURL, durationSec)。durationSec 是 `Date.now -
|
||||
/// startTime`,**不**用作 `getaudiourl.time` 字段;Phase 3.E
|
||||
/// prepareaudio 实现里会用 AVAudioPlayer 重读 wav duration 再 round
|
||||
/// 取整秒,对齐 daoqi NewRootVC.m:1892-1893 行为
|
||||
public func start(
|
||||
fileName: String,
|
||||
onFinish: @escaping (URL, TimeInterval) -> Void
|
||||
) throws {
|
||||
// AudioSession 配置(对齐 daoqi ChatVoiceRecorderVC.m:122-126)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.playAndRecord, options: [.defaultToSpeaker])
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
throw RecorderError.sessionFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
let wavURL = Self.wavFileURL(named: fileName)
|
||||
try? FileManager.default.removeItem(at: wavURL)
|
||||
|
||||
let r: AVAudioRecorder
|
||||
do {
|
||||
r = try AVAudioRecorder(url: wavURL, settings: Self.recorderSettings)
|
||||
} catch {
|
||||
throw RecorderError.recorderCreateFailed(error.localizedDescription)
|
||||
}
|
||||
r.isMeteringEnabled = true
|
||||
guard r.prepareToRecord(), r.record() else {
|
||||
throw RecorderError.startFailed
|
||||
}
|
||||
|
||||
self.recorder = r
|
||||
self.recordingFileURL = wavURL
|
||||
self.onFinishCallback = onFinish
|
||||
self.startTime = Date()
|
||||
self.elapsed = 0
|
||||
self.averagePower = -160
|
||||
|
||||
startTimer()
|
||||
Self.log.debug("start file=\(fileName, privacy: .public)")
|
||||
}
|
||||
|
||||
/// 停止录音并触发 onFinish。已停止状态下重复调用是 no-op。
|
||||
public func stop() {
|
||||
guard let r = recorder, r.isRecording else { return }
|
||||
let actualDuration = elapsed
|
||||
r.stop()
|
||||
let wavURL = recordingFileURL
|
||||
let cb = onFinishCallback
|
||||
|
||||
cleanup()
|
||||
|
||||
if let wavURL, let cb {
|
||||
Self.log.debug("stop duration=\(actualDuration, privacy: .public)s")
|
||||
cb(wavURL, actualDuration)
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消录音并删除文件,**不**触发 onFinish。
|
||||
public func cancel() {
|
||||
guard let r = recorder else { return }
|
||||
if r.isRecording { r.stop() }
|
||||
if let url = recordingFileURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
cleanup()
|
||||
Self.log.debug("cancel")
|
||||
}
|
||||
|
||||
private func cleanup() {
|
||||
stopTimer()
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
recorder = nil
|
||||
recordingFileURL = nil
|
||||
onFinishCallback = nil
|
||||
startTime = nil
|
||||
}
|
||||
|
||||
// MARK: - 计时器(0.1s tick,更新音量峰值 + 检测 60s 上限)
|
||||
|
||||
private func startTimer() {
|
||||
let t = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.tick() }
|
||||
}
|
||||
RunLoop.main.add(t, forMode: .common)
|
||||
self.timer = t
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
guard let r = recorder, r.isRecording, let start = startTime else { return }
|
||||
r.updateMeters()
|
||||
averagePower = r.averagePower(forChannel: 0)
|
||||
elapsed = Date().timeIntervalSince(start)
|
||||
|
||||
if elapsed >= Self.maxDuration {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 沙盒文件路径
|
||||
|
||||
/// 录音 wav 写入路径。对齐 daoqi VoiceRecorderBaseVC.m:62-64
|
||||
/// `getCacheDirectory`(实际返回 NSDocumentDirectory[0],不是 Caches/)。
|
||||
private static func wavFileURL(named name: String) -> URL {
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent(name)
|
||||
.appendingPathExtension("wav")
|
||||
}
|
||||
|
||||
// MARK: - 公用工具
|
||||
|
||||
/// 生成 `yyyyMMddHHmmss` 时间戳字符串。对齐 daoqi
|
||||
/// VoiceRecorderBaseVC.m:50-54 `getCurrentTimeString`。
|
||||
public static func currentTimeString() -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyyMMddHHmmss"
|
||||
return f.string(from: Date())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user