diff --git a/ylgamehall/SceneDelegate.swift b/ylgamehall/SceneDelegate.swift index 8c595d2..1ea7e4a 100644 --- a/ylgamehall/SceneDelegate.swift +++ b/ylgamehall/SceneDelegate.swift @@ -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 处守门。 diff --git a/ylgamehall/Source/Audio/RecordingAwareWindow.swift b/ylgamehall/Source/Audio/RecordingAwareWindow.swift new file mode 100644 index 0000000..8ae5b89 --- /dev/null +++ b/ylgamehall/Source/Audio/RecordingAwareWindow.swift @@ -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) + } + } +} diff --git a/ylgamehall/Source/Audio/RecordingOverlay.swift b/ylgamehall/Source/Audio/RecordingOverlay.swift new file mode 100644 index 0000000..1a813e2 --- /dev/null +++ b/ylgamehall/Source/Audio/RecordingOverlay.swift @@ -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) + } + } +} diff --git a/ylgamehall/Source/Audio/RecordingPresenter.swift b/ylgamehall/Source/Audio/RecordingPresenter.swift new file mode 100644 index 0000000..d4b30c8 --- /dev/null +++ b/ylgamehall/Source/Audio/RecordingPresenter.swift @@ -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 + + func removeOverlay() { + overlayHost.willMove(toParent: nil) + overlayHost.view.removeFromSuperview() + overlayHost.removeFromParent() + } + } +} diff --git a/ylgamehall/Source/Audio/VoiceRecorder.swift b/ylgamehall/Source/Audio/VoiceRecorder.swift new file mode 100644 index 0000000..3cacf6c --- /dev/null +++ b/ylgamehall/Source/Audio/VoiceRecorder.swift @@ -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()) + } +}