Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions Sources/quill/RecordingSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
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"
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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."
)
}
}
}
}