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:
joywayer
2026-06-24 01:29:04 +08:00
parent 5b38456c66
commit c398a8dd58
5 changed files with 431 additions and 1 deletions
+4 -1
View File
@@ -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)
// 使 RecordingAwareWindowUIWindow 便 touch
// UIWindow RecordingPresenter
// touchObserver Source/Audio/RecordingAwareWindow.swift
let window = RecordingAwareWindow(windowScene: windowScene)
// UINavigationController 便 Phase 6 push / Phase 7 push
// 3Design §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()
}
}
}
+188
View File
@@ -0,0 +1,188 @@
//
// VoiceRecorder.swift
// ylgamehall
//
// AVAudioRecorder + AudioSession + /
//
// - UIRecordingOverlay @ObservedObject @Published
// - RecordingAwareWindow.touchObserver
// RecordingPresenter
//
// docs/H5-Native-Contract.md §3.1 4prepareaudio
//
import AVFoundation
import Combine // ObservableObject / @PublishedSwiftUI
// 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())
}
}