Skip to content
Merged
Show file tree
Hide file tree
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
9 changes: 9 additions & 0 deletions Sources/Dictation/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
## Files

- `DictationSessionTimeout.swift` — uptime-based timeout helper so sleep does not consume a session's remaining record window
- `DictationStoppedAudioRecovery.swift` — writes a private recovery WAV plus restart-discovery metadata immediately after recording stops and retains both until transcript persistence succeeds or the user explicitly discards the session
- `DictationStoragePaths.swift` — capture-library-backed storage root for dictation artifacts
- `DictationTranscriptWriter.swift` — groups completed dictations into one markdown file per day; serializes day-file writes through `DictationTranscriptMutationLock`
- `DictationTranscriptStore.swift` — shared seam for saving dictation markdown and reading the newest saved dictation back out
Expand All @@ -27,6 +28,13 @@
- root: `<capture-library>/dictations/`
- transcript folder: same as the dictation root
- file shape: one `Dictations_YYYY-MM-DD.md` file per day, with multiple timestamped sections
- stopped-audio recovery: `~/Library/Application Support/Transcripted/state/dictation-audio-recovery/`

Stopped-audio recovery is intentionally bounded and local. Launch scans at most
one pending metadata record for presentation, then `Show Audio` reveals the WAV
in Finder. The operational recovery path is Home -> Import Audio -> select that
WAV; this uses the normal local imported-audio transcription pipeline. Reveal or
restart never deletes the checkpoint.

Each section captures:

Expand All @@ -40,6 +48,7 @@ Each section captures:
## Test coverage

- `Tests/DictationSessionTimeoutTests.swift`
- `Tests/DictationStoppedAudioRecoveryTests.swift`
- `Tests/DictationTranscriptStoreTests.swift`
- `Tests/DictationTranscriptWriterTests.swift`

Expand Down
202 changes: 202 additions & 0 deletions Sources/Dictation/DictationStoppedAudioRecovery.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// DictationStoppedAudioRecovery.swift
// Durable audio checkpoint for a stopped dictation awaiting transcription.

import Foundation

struct DictationStoppedAudioRecovery: Equatable, Sendable {
let url: URL
let sessionID: UUID
let createdAt: Date
}

struct DictationStoppedAudioRecoveryRetryRegistry {
private var recoveries: [UUID: DictationStoppedAudioRecovery] = [:]

mutating func retain(_ recovery: DictationStoppedAudioRecovery, for failedMeetingID: UUID) {
recoveries[failedMeetingID] = recovery
}

func recovery(for failedMeetingID: UUID) -> DictationStoppedAudioRecovery? {
recoveries[failedMeetingID]
}

mutating func remove(for failedMeetingID: UUID) -> DictationStoppedAudioRecovery? {
recoveries.removeValue(forKey: failedMeetingID)
}
}

enum DictationStoppedAudioRecoveryCommitPolicy {
static func shouldPersist(
taskCancelled: Bool,
isDictating: Bool,
taskSessionID: UUID,
currentSessionID: UUID
) -> Bool {
!taskCancelled && isDictating && taskSessionID == currentSessionID
}

static func shouldRetainPersistedRecovery(
taskSessionID: UUID,
preservationSessionID: UUID?
) -> Bool {
taskSessionID == preservationSessionID
}
}

enum DictationStoppedAudioRecoveryStore {
static let sampleRate: UInt32 = 16_000

private struct Metadata: Codable {
let version: Int
let sessionID: UUID
let createdAt: Date
let audioFilename: String
}

static var defaultDirectory: URL {
FileManager.default.transcriptedStateDir
.appendingPathComponent("dictation-audio-recovery", isDirectory: true)
}

static func persist(
samples16k: [Float],
sessionID: UUID,
createdAt: Date = Date(),
directory: URL? = nil,
fileManager: FileManager = .default
) throws -> DictationStoppedAudioRecovery? {
guard !samples16k.isEmpty else { return nil }

let folder = directory ?? defaultDirectory
try fileManager.createPrivateDirectory(at: folder)
let url = folder.appendingPathComponent("dictation_\(sessionID.uuidString.lowercased()).wav")
try wavData(samples16k: samples16k).write(to: url, options: .atomic)
fileManager.restrictFileToOwnerOnly(at: url)
let recovery = DictationStoppedAudioRecovery(url: url, sessionID: sessionID, createdAt: createdAt)
do {
try writeMetadata(for: recovery, fileManager: fileManager)
} catch {
try? fileManager.removeItem(at: url)
throw error
}
return recovery
}

static func pendingRecoveries(
limit: Int = 10,
directory: URL? = nil,
fileManager: FileManager = .default
) -> [DictationStoppedAudioRecovery] {
guard limit > 0 else { return [] }
let folder = directory ?? defaultDirectory
guard let enumerator = fileManager.enumerator(
at: folder,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants]
) else { return [] }

var recoveries: [DictationStoppedAudioRecovery] = []
for case let metadataURL as URL in enumerator where metadataURL.pathExtension == "json" {
guard let metadata = try? JSONDecoder().decode(Metadata.self, from: Data(contentsOf: metadataURL)),
metadata.version == 1 else { continue }
let audioURL = folder.appendingPathComponent(metadata.audioFilename, isDirectory: false)
guard fileManager.fileExists(atPath: audioURL.path) else { continue }
recoveries.append(DictationStoppedAudioRecovery(
url: audioURL,
sessionID: metadata.sessionID,
createdAt: metadata.createdAt
))
}
return mostRecent(recoveries, limit: limit)
}

static func mostRecent(
_ recoveries: [DictationStoppedAudioRecovery],
limit: Int
) -> [DictationStoppedAudioRecovery] {
guard limit > 0 else { return [] }
return Array(
recoveries
.sorted { $0.createdAt > $1.createdAt }
.prefix(limit)
)
}

@discardableResult
static func cleanup(
_ recovery: DictationStoppedAudioRecovery?,
transcriptPersisted: Bool = false,
explicitDiscard: Bool = false,
fileManager: FileManager = .default
) -> Bool {
guard transcriptPersisted || explicitDiscard,
let recovery else { return false }

do {
if fileManager.fileExists(atPath: recovery.url.path) {
try fileManager.removeItem(at: recovery.url)
}
let metadataURL = metadataURL(for: recovery.url)
if fileManager.fileExists(atPath: metadataURL.path) {
try fileManager.removeItem(at: metadataURL)
}
return true
} catch {
return false
}
}

private static func writeMetadata(
for recovery: DictationStoppedAudioRecovery,
fileManager: FileManager
) throws {
let metadata = Metadata(
version: 1,
sessionID: recovery.sessionID,
createdAt: recovery.createdAt,
audioFilename: recovery.url.lastPathComponent
)
let url = metadataURL(for: recovery.url)
try JSONEncoder().encode(metadata).write(to: url, options: .atomic)
fileManager.restrictFileToOwnerOnly(at: url)
}

private static func metadataURL(for audioURL: URL) -> URL {
audioURL.deletingPathExtension().appendingPathExtension("json")
}

private static func wavData(samples16k: [Float]) -> Data {
let bytesPerSample: UInt16 = 2
let channelCount: UInt16 = 1
let dataByteCount = UInt32(samples16k.count) * UInt32(bytesPerSample)
var data = Data(capacity: 44 + Int(dataByteCount))

data.append(contentsOf: "RIFF".utf8)
append(UInt32(36) + dataByteCount, to: &data)
data.append(contentsOf: "WAVEfmt ".utf8)
append(UInt32(16), to: &data)
append(UInt16(1), to: &data)
append(channelCount, to: &data)
append(sampleRate, to: &data)
append(sampleRate * UInt32(channelCount) * UInt32(bytesPerSample), to: &data)
append(channelCount * bytesPerSample, to: &data)
append(UInt16(16), to: &data)
data.append(contentsOf: "data".utf8)
append(dataByteCount, to: &data)

for sample in samples16k {
let finiteSample = sample.isFinite ? sample : 0
let clamped = max(-1, min(1, finiteSample))
let pcm = Int16((clamped * Float(Int16.max)).rounded())
append(UInt16(bitPattern: pcm), to: &data)
}
return data
}

private static func append<T: FixedWidthInteger>(_ value: T, to data: inout Data) {
var littleEndian = value.littleEndian
withUnsafeBytes(of: &littleEndian) { bytes in
data.append(contentsOf: bytes)
}
}
}
10 changes: 10 additions & 0 deletions Sources/Meeting/FailedMeetingStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ final class FailedMeetingStore {
private let canRetry: () -> Bool
private let prepareModelsForRetry: () async -> Bool
private let markRetryStarted: () -> Void
private let prepareStoppedAudioRecoveryForRetry: (UUID) -> Void
private let discardStoppedAudioRecoveryForRetry: (UUID) -> Void
private let publishRefresh: () -> Void
private let diagnosticsContext: ([String: String]) -> [String: String]

Expand All @@ -46,6 +48,8 @@ final class FailedMeetingStore {
canRetry: @escaping () -> Bool,
prepareModelsForRetry: @escaping () async -> Bool,
markRetryStarted: @escaping () -> Void,
prepareStoppedAudioRecoveryForRetry: @escaping (UUID) -> Void,
discardStoppedAudioRecoveryForRetry: @escaping (UUID) -> Void,
publishRefresh: @escaping () -> Void,
diagnosticsContext: @escaping ([String: String]) -> [String: String]
) {
Expand All @@ -54,6 +58,8 @@ final class FailedMeetingStore {
self.canRetry = canRetry
self.prepareModelsForRetry = prepareModelsForRetry
self.markRetryStarted = markRetryStarted
self.prepareStoppedAudioRecoveryForRetry = prepareStoppedAudioRecoveryForRetry
self.discardStoppedAudioRecoveryForRetry = discardStoppedAudioRecoveryForRetry
self.publishRefresh = publishRefresh
self.diagnosticsContext = diagnosticsContext
}
Expand All @@ -79,6 +85,7 @@ final class FailedMeetingStore {
failedAudioCompressionTask?.cancel()
retryingFailedMeetingIDs.insert(id)
markRetryStarted()
prepareStoppedAudioRecoveryForRetry(id)
publishRefresh()
let retryStartedAt = CFAbsoluteTimeGetCurrent()
WorkflowRecoveryTelemetry.attempted(
Expand Down Expand Up @@ -144,6 +151,9 @@ final class FailedMeetingStore {
func deleteFailedMeeting(id: UUID) -> Bool {
retryingFailedMeetingIDs.remove(id)
let didDelete = failedManager.deleteFailedTranscription(id: id)
if didDelete {
discardStoppedAudioRecoveryForRetry(id)
}
publishRefresh()
return didDelete || !failedManager.failedTranscriptions.contains(where: { $0.id == id })
}
Expand Down
56 changes: 54 additions & 2 deletions Sources/Meeting/MeetingSessionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ final class MeetingSessionController: ObservableObject {
var liveCodexFinalTranscriptNeedsQueuedJobID = false
var liveCodexAwaitedTranscriptionJobID: UUID?
var activeQueuedTranscriptionJobID: UUID?
var activeStoppedAudioRecovery: DictationStoppedAudioRecovery?
private var stoppedAudioRecoveryRetryRegistry = DictationStoppedAudioRecoveryRetryRegistry()

var shouldConfirmQuitForActiveCapture: Bool {
isCaptureSessionActive || isFinishingRecording
Expand Down Expand Up @@ -1461,13 +1463,17 @@ final class MeetingSessionController: ObservableObject {
return false
}

let stoppedAudioRecovery = DictationStoppedAudioRecoveryStore
.pendingRecoveries(limit: Int.max)
.first { $0.url.standardizedFileURL == sourceURL.standardizedFileURL }
let outcome: TranscriptionQueueCoordinator.QueueInsertionOutcome
do {
outcome = try transcriptionQueue.enqueueImportedAudioJob(
audioURL: preparedAudio.copiedAudioURL,
suggestedTitle: preparedAudio.suggestedTitle,
recordingDate: preparedAudio.recordingDate,
startTrigger: .fileImport
startTrigger: .fileImport,
stoppedAudioRecovery: stoppedAudioRecovery
)
} catch {
let preservedForRelaunch = failedMeetingStore.preserveFailedMeetingForRetry(
Expand Down Expand Up @@ -1573,8 +1579,9 @@ final class MeetingSessionController: ObservableObject {
taskManager.cancelAll()
if liveCodexSessionAwaitingFinalTranscript {
finishLiveCodexSession(status: .failed, shouldAwaitFinalTranscript: false)
activeQueuedTranscriptionJobID = nil
}
activeQueuedTranscriptionJobID = nil
activeStoppedAudioRecovery = nil
state = .ready
DiagnosticsTrail.record(
level: .warning,
Expand Down Expand Up @@ -1886,6 +1893,23 @@ final class MeetingSessionController: ObservableObject {
failedMeetingStore.deleteFailedMeeting(id: id)
}

private func prepareStoppedAudioRecoveryForRetry(failedMeetingID: UUID) {
activeStoppedAudioRecovery = stoppedAudioRecoveryRetryRegistry.recovery(
for: failedMeetingID
)
}

private func discardStoppedAudioRecoveryForRetry(failedMeetingID: UUID) {
let recovery = stoppedAudioRecoveryRetryRegistry.remove(for: failedMeetingID)
if activeQueuedTranscriptionJobID == failedMeetingID {
activeStoppedAudioRecovery = nil
}
guard recovery != nil else { return }
Task.detached(priority: .utility) {
DictationStoppedAudioRecoveryStore.cleanup(recovery, explicitDiscard: true)
}
}

// MARK: - Subscriptions

private func wireSubscriptions() {
Expand Down Expand Up @@ -2627,6 +2651,19 @@ final class MeetingSessionController: ObservableObject {
switch status {
case .transcriptSaved:
lastTerminalTranscriptionOutcome = .transcriptSaved
let completedJobID = activeQueuedTranscriptionJobID
if let stoppedAudioRecovery = activeStoppedAudioRecovery {
activeStoppedAudioRecovery = nil
Task.detached(priority: .utility) {
DictationStoppedAudioRecoveryStore.cleanup(
stoppedAudioRecovery,
transcriptPersisted: true
)
}
}
if let completedJobID {
stoppedAudioRecoveryRetryRegistry.remove(for: completedJobID)
}
let transcriptionTrigger = activeTranscriptionTrigger
let promptTelemetryProperties = activeDetectedPromptTranscriptionTelemetryProperties
let promptRecordingStartedAt = activeDetectedPromptTranscriptionRecordingStartedAt
Expand Down Expand Up @@ -2656,6 +2693,15 @@ final class MeetingSessionController: ObservableObject {
activeTranscriptionCaptureDiagnostics = nil
case .failed(let message):
lastTerminalTranscriptionOutcome = .failed(message)
// A failed import must retain its original stopped-audio checkpoint.
if let failedJobID = activeQueuedTranscriptionJobID,
let stoppedAudioRecovery = activeStoppedAudioRecovery {
stoppedAudioRecoveryRetryRegistry.retain(
stoppedAudioRecovery,
for: failedJobID
)
}
activeStoppedAudioRecovery = nil
let transcriptionTrigger = activeTranscriptionTrigger
let diagnosticMessage = taskManager.lastFailureDiagnosticMessage ?? message
let failureKind = MeetingFailureKind.classify(message: diagnosticMessage)
Expand Down Expand Up @@ -3181,6 +3227,12 @@ final class MeetingSessionController: ObservableObject {
markRetryStarted: { [weak self] in
self?.activeTranscriptionTrigger = .unknown
},
prepareStoppedAudioRecoveryForRetry: { [weak self] failedMeetingID in
self?.prepareStoppedAudioRecoveryForRetry(failedMeetingID: failedMeetingID)
},
discardStoppedAudioRecoveryForRetry: { [weak self] failedMeetingID in
self?.discardStoppedAudioRecoveryForRetry(failedMeetingID: failedMeetingID)
},
publishRefresh: { [weak self] in
self?.refreshFailedMeetings()
},
Expand Down
Loading
Loading