blitztext-app-linux/BlitztextMac/Features/Workflows/TranscriptionWorkflow.swift
2026-05-23 07:28:46 +02:00

139 lines
4.7 KiB
Swift

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<Void, Never>?
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)
}
}
}
}