Skip to content
Draft
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
6 changes: 4 additions & 2 deletions Sources/Meeting/TranscriptionQueueCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -341,9 +341,11 @@ final class TranscriptionQueueCoordinator {
audioURL: audioURL,
outputFolder: MeetingStoragePaths.transcriptsFolder,
meetingTitle: suggestedTitle,
recordingDate: recordingDate
recordingDate: recordingDate,
onTerminal: { [weak self] in
self?.removeImportedJournal(for: job)
}
)
removeImportedJournal(for: job)
}
}

Expand Down
15 changes: 14 additions & 1 deletion Sources/TranscriptedCore/Pipeline/TranscriptionTaskManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public class TranscriptionTaskManager: ObservableObject {
private var savedTranscriptTaskIdsByURL: [URL: UUID] = [:]
var activeTasks: [UUID: Task<Void, Never>] = [:]
private var activeTaskAudio: [UUID: (micURL: URL?, systemURL: URL?, meetingTitle: String?, recordingDate: Date?)] = [:]
private var importedTranscriptionTerminalHandlers: [UUID: @MainActor @Sendable () -> Void] = [:]
private var preservedTaskIdsForShutdown: Set<UUID> = []
private var intentionallyCancelledTaskIds: Set<UUID> = []
private var committedTranscriptTaskIds: Set<UUID> = []
Expand Down Expand Up @@ -326,11 +327,13 @@ public class TranscriptionTaskManager: ObservableObject {
audioURL: URL,
outputFolder: URL,
meetingTitle: String? = nil,
recordingDate: Date? = nil
recordingDate: Date? = nil,
onTerminal: (@MainActor @Sendable () -> Void)? = nil
) {
if !activeTasks.isEmpty {
AppLogger.pipeline.warning("Rejecting imported transcription — another pipeline is already active", ["activeCount": "\(activeTasks.count)"])
removeRecordingFile(audioURL, label: "rejected imported recording")
onTerminal?()
publishFailure(
displayMessage: "Another transcript is already running. Wait for it to finish, then import the file again.",
diagnosticMessage: "Transcription already in progress"
Expand All @@ -343,6 +346,7 @@ public class TranscriptionTaskManager: ObservableObject {
if let audioDuration = audioDuration(url: audioURL), audioDuration < minDuration {
AppLogger.pipeline.info("Imported recording too short, skipping transcription", ["duration": String(format: "%.1fs", audioDuration)])
removeRecordingFile(audioURL, label: "short imported recording")
onTerminal?()
publishFailure(
displayMessage: "That audio file is too short to transcribe. Choose audio that is at least two seconds long.",
diagnosticMessage: "Recording too short"
Expand All @@ -360,6 +364,9 @@ public class TranscriptionTaskManager: ObservableObject {
meetingTitle: meetingTitle,
recordingDate: recordingDate
)
if let onTerminal {
importedTranscriptionTerminalHandlers[taskId] = onTerminal
}
publishNonFailureStatus(.gettingReady)

AppLogger.pipeline.info("Starting imported transcription task", [
Expand Down Expand Up @@ -1278,6 +1285,7 @@ public class TranscriptionTaskManager: ObservableObject {
// MARK: - Task Completion & Cleanup

func handleTaskCompletion(taskId: UUID) {
importedTranscriptionTerminalHandlers.removeValue(forKey: taskId)?()
activeTasks.removeValue(forKey: taskId)
activeTaskAudio.removeValue(forKey: taskId)
preservedTaskIdsForShutdown.remove(taskId)
Expand All @@ -1303,6 +1311,10 @@ public class TranscriptionTaskManager: ObservableObject {

func markTaskTranscriptCommitted(taskId: UUID) {
committedTranscriptTaskIds.insert(taskId)
// An imported queue journal is no longer needed once the transcript's
// durable side effects commit. Keep it through inference so a crash
// before this point can recover the scratch audio on the next launch.
importedTranscriptionTerminalHandlers.removeValue(forKey: taskId)?()
// Transcript is durably on disk — the crash-recovery journal for this
// recording's scratch audio is no longer needed.
if let micURL = activeTaskAudio[taskId]?.micURL {
Expand Down Expand Up @@ -1331,6 +1343,7 @@ public class TranscriptionTaskManager: ObservableObject {
activeCount = max(0, activeCount - 1)
backgroundTaskCount = max(0, backgroundTaskCount - 1)
}
importedTranscriptionTerminalHandlers.removeValue(forKey: taskId)?()
if activeCount == 0 {
publishNonFailureStatus(.idle)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,34 @@ extension TranscriptionTaskManagerMetadataTests {
speech.release()
}

func testImportedTranscriptionTerminalHandlerWaitsForTranscriptCommit() async throws {
let speech = BlockingMetadataStubSpeechToTextEngine(transcript: "Imported terminal handoff.")
let manager = makeManager(
speechToText: speech,
diarization: MetadataStubDiarizationEngine(segments: singleSpeakerSegments())
)
let copiedImportURL = tempDirectory
.appendingPathComponent("audio", isDirectory: true)
.appendingPathComponent("imported-terminal-handoff.wav")
try writeMonoWAV(to: copiedImportURL, duration: 2.5)

var didReachTerminal = false
manager.startImportedTranscription(
audioURL: copiedImportURL,
outputFolder: tempDirectory.appendingPathComponent("transcripts"),
onTerminal: { didReachTerminal = true }
)

try await waitUntil { speech.didStart }
XCTAssertFalse(didReachTerminal, "the import must remain recoverable while inference is running")
XCTAssertTrue(FileManager.default.fileExists(atPath: copiedImportURL.path))

speech.release()
try await waitUntil {
didReachTerminal && manager.lastSavedTranscriptURL != nil && manager.activeTasks.isEmpty
}
}

func testStartSavedAudioRetranscriptionDoesNotDeleteSourceWhenRejectedForActiveTask() throws {
let manager = makeManager()
let savedAudioDirectory = tempDirectory.appendingPathComponent("saved-meeting-audio", isDirectory: true)
Expand Down
Loading