Skip to content
Closed
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
186 changes: 186 additions & 0 deletions Sources/Dictation/DictationStoppedAudioRecovery.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// 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
}

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)
}
}
}
21 changes: 19 additions & 2 deletions Sources/Meeting/MeetingSessionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ final class MeetingSessionController: ObservableObject {
var liveCodexFinalTranscriptNeedsQueuedJobID = false
var liveCodexAwaitedTranscriptionJobID: UUID?
var activeQueuedTranscriptionJobID: UUID?
var activeStoppedAudioRecovery: DictationStoppedAudioRecovery?

var shouldConfirmQuitForActiveCapture: Bool {
isCaptureSessionActive || isFinishingRecording
Expand Down Expand Up @@ -1453,11 +1454,15 @@ final class MeetingSessionController: ObservableObject {
return false
}

let stoppedAudioRecovery = DictationStoppedAudioRecoveryStore
.pendingRecoveries(limit: Int.max)
.first { $0.url.standardizedFileURL == sourceURL.standardizedFileURL }
let outcome = transcriptionQueue.enqueueImportedAudioJob(
audioURL: preparedAudio.copiedAudioURL,
suggestedTitle: preparedAudio.suggestedTitle,
recordingDate: preparedAudio.recordingDate,
startTrigger: .fileImport
startTrigger: .fileImport,
stoppedAudioRecovery: stoppedAudioRecovery
)

DiagnosticsTrail.record(
Expand Down Expand Up @@ -1541,8 +1546,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 @@ -2589,6 +2595,15 @@ final class MeetingSessionController: ObservableObject {
switch status {
case .transcriptSaved:
lastTerminalTranscriptionOutcome = .transcriptSaved
if let stoppedAudioRecovery = activeStoppedAudioRecovery {
activeStoppedAudioRecovery = nil
Task.detached(priority: .utility) {
DictationStoppedAudioRecoveryStore.cleanup(
stoppedAudioRecovery,
transcriptPersisted: true
)
}
}
let transcriptionTrigger = activeTranscriptionTrigger
let promptTelemetryProperties = activeDetectedPromptTranscriptionTelemetryProperties
let promptRecordingStartedAt = activeDetectedPromptTranscriptionRecordingStartedAt
Expand Down Expand Up @@ -2618,6 +2633,8 @@ final class MeetingSessionController: ObservableObject {
activeTranscriptionCaptureDiagnostics = nil
case .failed(let message):
lastTerminalTranscriptionOutcome = .failed(message)
// A failed import must retain its original stopped-audio checkpoint.
activeStoppedAudioRecovery = nil
let transcriptionTrigger = activeTranscriptionTrigger
let diagnosticMessage = taskManager.lastFailureDiagnosticMessage ?? message
let failureKind = MeetingFailureKind.classify(message: diagnosticMessage)
Expand Down
7 changes: 6 additions & 1 deletion Sources/Meeting/TranscriptionQueueCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ final class TranscriptionQueueCoordinator {
let kind: Kind
let startTrigger: MeetingSessionController.StartTrigger
let sttModel: TranscriptionModelChoice
let stoppedAudioRecovery: DictationStoppedAudioRecovery?
let promptTelemetryProperties: [String: String]?
let promptRecordingStartedAt: Date?

Expand Down Expand Up @@ -120,6 +121,7 @@ final class TranscriptionQueueCoordinator {
),
startTrigger: startTrigger,
sttModel: controller.sttRouter.selectedModel,
stoppedAudioRecovery: nil,
promptTelemetryProperties: promptTelemetryProperties,
promptRecordingStartedAt: promptRecordingStartedAt
)
Expand All @@ -135,7 +137,8 @@ final class TranscriptionQueueCoordinator {
audioURL: URL,
suggestedTitle: String,
recordingDate: Date,
startTrigger: MeetingSessionController.StartTrigger
startTrigger: MeetingSessionController.StartTrigger,
stoppedAudioRecovery: DictationStoppedAudioRecovery? = nil
) -> QueueInsertionOutcome {
let job = QueuedTranscriptionJob(
kind: .imported(
Expand All @@ -145,6 +148,7 @@ final class TranscriptionQueueCoordinator {
),
startTrigger: startTrigger,
sttModel: controller.sttRouter.selectedModel,
stoppedAudioRecovery: stoppedAudioRecovery,
promptTelemetryProperties: nil,
promptRecordingStartedAt: nil
)
Expand Down Expand Up @@ -298,6 +302,7 @@ final class TranscriptionQueueCoordinator {
controller.sttAdapter.selectPreparedModel(job.sttModel)
queuedRuntimeDiagnosticsJobIDs.remove(job.id)
controller.activeQueuedTranscriptionJobID = job.id
controller.activeStoppedAudioRecovery = job.stoppedAudioRecovery

switch job.kind {
case .recorded(let micURL, let systemURL, let healthInfo, _, let meetingTitle, let recordingDate):
Expand Down
Loading
Loading