diff --git a/Sources/quill/RecordingSession.swift b/Sources/quill/RecordingSession.swift index 5bbe6ba..d56c3f6 100644 --- a/Sources/quill/RecordingSession.swift +++ b/Sources/quill/RecordingSession.swift @@ -11,6 +11,19 @@ final class RecordingSession { private let mic = MicRecorder() private let system = SystemAudioRecorder() + // Track-liveness watchdog. Both .caf files grow continuously while their + // capture is healthy; a track whose file freezes mid-session (a call app + // reconfiguring the input device, a died tap — anything) is a recording + // silently going wrong, and the user should hear about it now, not after + // the meeting (2026.07.28: a 19min call yielded a 1.7s mic track with no + // visible symptom until the transcript came out one-sided). + private var watchdog: Timer? + private var trackSize: [String: Int64] = [:] + private var trackLastGrew: [String: Date] = [:] + private var trackStalled: Set = [] + private static let watchdogInterval: TimeInterval = 15 + private static let stallThreshold: TimeInterval = 45 + private static let folderFormat: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyy.MM.dd-HHmm" @@ -42,10 +55,17 @@ final class RecordingSession { system.stop() throw error } + watchdog = Timer.scheduledTimer( + withTimeInterval: Self.watchdogInterval, repeats: true + ) { [weak self] _ in + self?.checkTrackLiveness() + } } /// Stop both tracks and write meta.json. func stop() { + watchdog?.invalidate() + watchdog = nil mic.stop() system.stop() @@ -75,4 +95,38 @@ final class RecordingSession { try? data.write(to: dir.appendingPathComponent("meta.json")) } } + + // MARK: - + + /// Compare each track file's size against the last poll. Growth clears any + /// stall state (and announces recovery); a freeze past the threshold + /// notifies once per stall episode, so a track that dies, recovers, and + /// dies again alerts both times without spamming in between. + private func checkTrackLiveness() { + let now = Date() + for name in ["mic", "system"] { + let path = dir.appendingPathComponent("\(name).caf").path + guard let size = (try? FileManager.default + .attributesOfItem(atPath: path))?[.size] as? Int64 else { continue } + + if size != trackSize[name] { + trackSize[name] = size + trackLastGrew[name] = now + if trackStalled.remove(name) != nil { + notifyUser( + title: "quill: \(name) track recovered", + body: "\(name) audio is being written again." + ) + } + } else if let last = trackLastGrew[name], !trackStalled.contains(name), + now.timeIntervalSince(last) >= Self.stallThreshold { + trackStalled.insert(name) + notifyUser( + title: "quill: \(name) track stalled", + body: "No \(name) audio written for \(Int(now.timeIntervalSince(last)))s" + + " — the recording may be incomplete." + ) + } + } + } }