feat: harden Flow cold-start/force-quit and polish macOS dictation UX
Fix cold-start overlay recursion that overflowed the main-thread stack when recording began while the ready overlay was still up; also remove temporary on-screen Flow DEBUG panels after the orange-mic investigation, and land the macOS overlay/catalog/layout polish plus related Flow recovery hardening.
This commit is contained in:
@@ -11,11 +11,16 @@
|
||||
final class MacAudioRecorder: @unchecked Sendable {
|
||||
enum RecorderError: Error, LocalizedError {
|
||||
case converterUnavailable
|
||||
case microphoneAccessDenied
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .converterUnavailable:
|
||||
return "无法初始化音频转换器 / Failed to initialize audio converter"
|
||||
case .microphoneAccessDenied:
|
||||
return "麦克风权限被拒绝——请在「系统设置 → 隐私与安全性 → 麦克风」中启用"
|
||||
+ " / Microphone access denied — enable it in System Settings"
|
||||
+ " → Privacy & Security → Microphone"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +35,20 @@ final class MacAudioRecorder: @unchecked Sendable {
|
||||
private var converter: AVAudioConverter?
|
||||
private let lock = NSLock()
|
||||
private var samples: [Float] = []
|
||||
private var snapshotContinuation: AsyncStream<AudioBufferSnapshot>.Continuation?
|
||||
private var isRunning = false
|
||||
/// Hard cap on accumulated audio: 10 minutes @16 kHz ≈ 38 MB of Float32.
|
||||
/// Recording is push-to-talk, but a stuck hotkey (or a latched Option
|
||||
/// key) would otherwise grow this buffer without bound; past the cap we
|
||||
/// keep the newest audio (drop from the front) so the take still ends
|
||||
/// with what the user last said.
|
||||
private static let maxSampleCount = 10 * 60 * 16_000
|
||||
/// Trim hysteresis: dropping from the front is an O(n) memmove of the
|
||||
/// whole ~38 MB buffer, done under the same lock the UI's level poll
|
||||
/// takes — doing it on EVERY tap callback once capped would stall the
|
||||
/// render thread ~12×/s. Let the buffer overshoot by 30 s and trim the
|
||||
/// whole excess in one move instead.
|
||||
private static let trimHysteresisSamples = 30 * 16_000
|
||||
private var smoothedLevel: Float = 0
|
||||
/// One-shot flag for the converter pull block. Taps are serialized per
|
||||
/// bus, so a plain instance property (not a captured local) is safe here.
|
||||
@@ -42,8 +60,51 @@ final class MacAudioRecorder: @unchecked Sendable {
|
||||
lock.withLock { smoothedLevel }
|
||||
}
|
||||
|
||||
func start() throws {
|
||||
lock.withLock { samples.removeAll(keepingCapacity: true) }
|
||||
/// Resolves microphone authorization before capture. Prompts on first
|
||||
/// use; throws `microphoneAccessDenied` once the user has declined so
|
||||
/// failures surface as a permission problem, not an empty transcription.
|
||||
private static func ensureMicrophoneAccess() async throws {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
return
|
||||
case .notDetermined:
|
||||
guard await AVCaptureDevice.requestAccess(for: .audio) else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
case .denied, .restricted:
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
@unknown default:
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
try await Self.ensureMicrophoneAccess()
|
||||
try startEngine()
|
||||
}
|
||||
|
||||
/// Live 16 kHz mono snapshots for streaming ASR while the mic is open.
|
||||
/// The stream is finished automatically in `stop()`.
|
||||
func makeSnapshotStream() -> AsyncStream<AudioBufferSnapshot> {
|
||||
AsyncStream { continuation in
|
||||
lock.withLock {
|
||||
snapshotContinuation?.finish()
|
||||
snapshotContinuation = continuation
|
||||
}
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
self?.lock.withLock {
|
||||
self?.snapshotContinuation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startEngine() throws {
|
||||
lock.withLock {
|
||||
samples.removeAll(keepingCapacity: true)
|
||||
snapshotContinuation?.finish()
|
||||
snapshotContinuation = nil
|
||||
}
|
||||
|
||||
let input = engine.inputNode
|
||||
let inputFormat = input.outputFormat(forBus: 0)
|
||||
@@ -67,6 +128,8 @@ final class MacAudioRecorder: @unchecked Sendable {
|
||||
engine.stop()
|
||||
isRunning = false
|
||||
return lock.withLock {
|
||||
snapshotContinuation?.finish()
|
||||
snapshotContinuation = nil
|
||||
let out = samples
|
||||
samples.removeAll(keepingCapacity: false)
|
||||
return out
|
||||
@@ -105,8 +168,14 @@ final class MacAudioRecorder: @unchecked Sendable {
|
||||
|
||||
lock.withLock {
|
||||
samples.append(contentsOf: chunk)
|
||||
if samples.count > Self.maxSampleCount + Self.trimHysteresisSamples {
|
||||
samples.removeFirst(samples.count - Self.maxSampleCount)
|
||||
}
|
||||
let factor: Float = normalized > smoothedLevel ? 0.5 : 0.15
|
||||
smoothedLevel += (normalized - smoothedLevel) * factor
|
||||
snapshotContinuation?.yield(
|
||||
AudioBufferSnapshot(samples: chunk, sampleRate: 16_000)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user