import Foundation import AppKit import Observation import OSLog private let transcriptionLogger = Logger(subsystem: "app.blitztext.mac", category: "Transcription") private func elapsedMilliseconds(since start: Date, until end: Date = Date()) -> Int { Int((end.timeIntervalSince(start) * 1000).rounded()) } @Observable @MainActor final class TranscriptionWorkflow: Workflow { let type: WorkflowType var phase: WorkflowPhase = .idle { didSet { onPhaseChange?(phase) } } var onOutput: WorkflowOutputHandler? var onPhaseChange: WorkflowPhaseChangeHandler? private let recorder = AudioRecorder() private let customTerms: [String] private let language: String private let backend: TranscriptionBackend private let localModelName: String private var transcriptionTask: Task? init( type: WorkflowType = .transcription, customTerms: [String] = [], language: String = "de", backend: TranscriptionBackend = .remote, localModelName: String = LocalTranscriptionService.recommendedFastModelName ) { self.type = type self.customTerms = customTerms self.language = language self.backend = backend self.localModelName = localModelName } func start() { phase = .running("Aufnahme läuft ...") recorder.startRecording() if let error = recorder.errorMessage { phase = .error(error) } } func stop() { if recorder.isRecording { recorder.stopRecording() guard !TranscriptionQualityService.shouldRejectRecording(duration: recorder.lastRecordingDuration) else { recorder.discardRecording() phase = .error("Keine Aufnahme erkannt.") return } transcribe() } else { transcriptionTask?.cancel() phase = .idle } } func reset() { transcriptionTask?.cancel() if recorder.isRecording { recorder.stopRecording() } recorder.discardRecording() phase = .idle } var isRecording: Bool { recorder.isRecording } var audioLevel: Float { recorder.audioLevel } private func transcribe() { guard let url = recorder.recordingURL else { phase = .error("Keine Aufnahme vorhanden.") return } phase = .running(backend == .local ? "Wird lokal transkribiert ..." : "Wird transkribiert ...") let recordingDuration = recorder.lastRecordingDuration let vocabularyHints = recordingDuration >= 0.9 ? customTerms : [] let requestLanguage = language let stopTime = Date() transcriptionTask = Task(priority: .userInitiated) { defer { try? FileManager.default.removeItem(at: url) } let requestStart = Date() do { let text: String switch backend { case .remote: text = try await TranscriptionService.transcribe( audioURL: url, customTerms: vocabularyHints, language: requestLanguage ) case .local: text = try await LocalTranscriptionService.shared.transcribe( audioURL: url, language: requestLanguage, modelName: localModelName ) } try Task.checkCancellation() let responseReceivedAt = Date() let cleaned = TranscriptionQualityService.cleanedTranscript(text) guard !TranscriptionQualityService.isLikelyArtifact(cleaned, recordingDuration: recordingDuration) else { transcriptionLogger.info( "Transcription rejected short artifact after \(elapsedMilliseconds(since: stopTime)) ms" ) phase = .error("Keine Aufnahme erkannt.") return } transcriptionLogger.info( "Transcription ready in \(elapsedMilliseconds(since: stopTime, until: responseReceivedAt)) ms (request \(elapsedMilliseconds(since: requestStart, until: responseReceivedAt)) ms)" ) phase = .done(cleaned) onOutput?(cleaned) } catch { transcriptionLogger.error( "Transcription failed after \(elapsedMilliseconds(since: stopTime)) ms: \(error.localizedDescription, privacy: .private)" ) phase = .error(error.localizedDescription) } } } }