diff --git a/Sources/Fluid/Persistence/FileTranscriptionHistoryStore.swift b/Sources/Fluid/Persistence/FileTranscriptionHistoryStore.swift index a646acd09..417ee0945 100644 --- a/Sources/Fluid/Persistence/FileTranscriptionHistoryStore.swift +++ b/Sources/Fluid/Persistence/FileTranscriptionHistoryStore.swift @@ -10,7 +10,7 @@ import Foundation // MARK: - File Transcription Entry Model -struct FileTranscriptionEntry: Codable, Identifiable, Equatable { +nonisolated struct FileTranscriptionEntry: Codable, Identifiable, Equatable { let id: UUID let timestamp: Date let fileName: String @@ -20,6 +20,8 @@ struct FileTranscriptionEntry: Codable, Identifiable, Equatable { let text: String /// Speaker-attributed segments when diarization was enabled; empty otherwise. let speakerSegments: [SpeakerTranscriptSegment] + let speakerLabelingNotice: String? + let speakerLabelingGaps: [SpeakerTranscriptGap] init( id: UUID = UUID(), @@ -29,7 +31,9 @@ struct FileTranscriptionEntry: Codable, Identifiable, Equatable { processingTime: TimeInterval, confidence: Float, text: String, - speakerSegments: [SpeakerTranscriptSegment] = [] + speakerSegments: [SpeakerTranscriptSegment] = [], + speakerLabelingNotice: String? = nil, + speakerLabelingGaps: [SpeakerTranscriptGap] = [] ) { self.id = id self.timestamp = timestamp @@ -39,6 +43,8 @@ struct FileTranscriptionEntry: Codable, Identifiable, Equatable { self.confidence = confidence self.text = text self.speakerSegments = speakerSegments + self.speakerLabelingNotice = speakerLabelingNotice + self.speakerLabelingGaps = speakerLabelingGaps } init(from result: TranscriptionResult) { @@ -50,10 +56,13 @@ struct FileTranscriptionEntry: Codable, Identifiable, Equatable { self.confidence = result.confidence self.text = result.text self.speakerSegments = result.speakerSegments + self.speakerLabelingNotice = result.speakerLabelingNotice + self.speakerLabelingGaps = result.speakerLabelingGaps } enum CodingKeys: String, CodingKey { case id, timestamp, fileName, duration, processingTime, confidence, text, speakerSegments + case speakerLabelingNotice, speakerLabelingGaps } init(from decoder: Decoder) throws { @@ -67,6 +76,8 @@ struct FileTranscriptionEntry: Codable, Identifiable, Equatable { self.text = try c.decode(String.self, forKey: .text) // Older history entries predate speaker labels — tolerate a missing key. self.speakerSegments = try c.decodeIfPresent([SpeakerTranscriptSegment].self, forKey: .speakerSegments) ?? [] + self.speakerLabelingNotice = try c.decodeIfPresent(String.self, forKey: .speakerLabelingNotice) + self.speakerLabelingGaps = try c.decodeIfPresent([SpeakerTranscriptGap].self, forKey: .speakerLabelingGaps) ?? [] } func encode(to encoder: Encoder) throws { @@ -81,6 +92,10 @@ struct FileTranscriptionEntry: Codable, Identifiable, Equatable { if !self.speakerSegments.isEmpty { try c.encode(self.speakerSegments, forKey: .speakerSegments) } + try c.encodeIfPresent(self.speakerLabelingNotice, forKey: .speakerLabelingNotice) + if !self.speakerLabelingGaps.isEmpty { + try c.encode(self.speakerLabelingGaps, forKey: .speakerLabelingGaps) + } } /// Preview text for list display (first 80 chars) @@ -117,7 +132,9 @@ struct FileTranscriptionEntry: Codable, Identifiable, Equatable { processingTime: self.processingTime, fileName: self.fileName, timestamp: self.timestamp, - speakerSegments: self.speakerSegments + speakerSegments: self.speakerSegments, + speakerLabelingNotice: self.speakerLabelingNotice, + speakerLabelingGaps: self.speakerLabelingGaps ) } } diff --git a/Sources/Fluid/Services/MeetingTranscriptionService.swift b/Sources/Fluid/Services/MeetingTranscriptionService.swift index 227ffa859..2f84cf55b 100644 --- a/Sources/Fluid/Services/MeetingTranscriptionService.swift +++ b/Sources/Fluid/Services/MeetingTranscriptionService.swift @@ -5,7 +5,7 @@ import Foundation import UniformTypeIdentifiers /// One speaker-attributed portion of a file transcription. -struct SpeakerTranscriptSegment: Identifiable, Sendable, Codable, Equatable { +nonisolated struct SpeakerTranscriptSegment: Identifiable, Sendable, Codable, Equatable { let speaker: String let startSeconds: Double let endSeconds: Double @@ -35,8 +35,198 @@ struct SpeakerTranscriptSegment: Identifiable, Sendable, Codable, Equatable { } } +/// An audio interval for which speaker-attributed ASR produced no usable text. +nonisolated struct SpeakerTranscriptGap: Sendable, Codable, Equatable { + let startSeconds: Double + let endSeconds: Double + + var durationSeconds: Double { + max(0, self.endSeconds - self.startSeconds) + } + + var timestampRangeText: String { + "\(Self.timestamp(self.startSeconds))-\(Self.timestamp(self.endSeconds))" + } + + private static func timestamp(_ seconds: Double) -> String { + let safeSeconds = max(0, seconds) + let wholeMinutes = Int(safeSeconds) / 60 + let remainingSeconds = safeSeconds - Double(wholeMinutes * 60) + return String(format: "%d:%04.1f", wholeMinutes, remainingSeconds) + } +} + +nonisolated struct SpeakerChunkTranscription: Sendable, Equatable { + let text: String + let confidence: Float +} + +nonisolated struct SpeakerTurnTranscription: Sendable, Equatable { + let text: String + let confidence: Float + let gaps: [SpeakerTranscriptGap] +} + +nonisolated struct SpeakerRecognizedTurn: Sendable, Equatable { + let speaker: String + let startSeconds: Double + let endSeconds: Double + let transcription: SpeakerTurnTranscription +} + +nonisolated struct SpeakerLabeledTranscript: Sendable, Equatable { + let segments: [SpeakerTranscriptSegment] + let confidence: Float + let gaps: [SpeakerTranscriptGap] + let notice: String? +} + +nonisolated struct SpeakerLabelingCoverage: Sendable, Equatable { + let gapCount: Int + let skippedDurationSeconds: Double + let maxGapDurationSeconds: Double + let diarizedDurationSeconds: Double + + var skippedRatio: Double { + guard self.diarizedDurationSeconds.isFinite, self.diarizedDurationSeconds > 0 else { + return self.skippedDurationSeconds > 0 ? .infinity : 0 + } + return self.skippedDurationSeconds / self.diarizedDurationSeconds + } +} + +nonisolated enum SpeakerLabeledTranscriptionPolicy { + static func transcribeChunks( + _ ranges: [SpeakerTranscriptGap], + operation: (SpeakerTranscriptGap) async throws -> SpeakerChunkTranscription? + ) async rethrows -> SpeakerTurnTranscription { + var pieces: [String] = [] + var confidenceSum: Float = 0 + var gaps: [SpeakerTranscriptGap] = [] + + for range in ranges { + let result = try await operation(range) + let text = result?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !text.isEmpty, let result else { + gaps.append(range) + continue + } + + pieces.append(text) + confidenceSum += result.confidence + } + + let confidence = pieces.isEmpty ? 0 : confidenceSum / Float(pieces.count) + return SpeakerTurnTranscription( + text: pieces.joined(separator: " "), + confidence: confidence, + gaps: gaps + ) + } + + static func shouldKeepSpeakerLabels( + hasRecognizedText: Bool, + gaps: [SpeakerTranscriptGap], + diarizedDurationSeconds: Double + ) -> Bool { + guard hasRecognizedText else { return false } + guard !gaps.isEmpty else { return true } + guard diarizedDurationSeconds.isFinite, diarizedDurationSeconds > 0 else { return false } + + let coverage = self.coverage( + gaps: gaps, + diarizedDurationSeconds: diarizedDurationSeconds + ) + guard coverage.maxGapDurationSeconds <= 5 else { return false } + + let allowedSkippedDuration = min(30, diarizedDurationSeconds * 0.01) + return coverage.skippedDurationSeconds <= allowedSkippedDuration + } + + static func coverage( + gaps: [SpeakerTranscriptGap], + diarizedDurationSeconds: Double + ) -> SpeakerLabelingCoverage { + let durations = gaps.map(\.durationSeconds) + return SpeakerLabelingCoverage( + gapCount: gaps.count, + skippedDurationSeconds: durations.reduce(0, +), + maxGapDurationSeconds: durations.max() ?? 0, + diarizedDurationSeconds: diarizedDurationSeconds + ) + } + + static func fallbackDiagnostic( + hasRecognizedText: Bool, + gaps: [SpeakerTranscriptGap], + diarizedDurationSeconds: Double + ) -> String { + guard hasRecognizedText else { + return "Speaker labeling produced no recognized text" + } + guard diarizedDurationSeconds.isFinite, diarizedDurationSeconds > 0 else { + return "Speaker labeling produced an invalid diarized duration" + } + + let coverage = self.coverage( + gaps: gaps, + diarizedDurationSeconds: diarizedDurationSeconds + ) + return String( + format: "Speaker labeling omitted too much audio (gaps=%d, skipped=%.3fs, maxGap=%.3fs, diarized=%.3fs, ratio=%.4f)", + coverage.gapCount, + coverage.skippedDurationSeconds, + coverage.maxGapDurationSeconds, + coverage.diarizedDurationSeconds, + coverage.skippedRatio + ) + } + + static func limitationNotice(for gaps: [SpeakerTranscriptGap]) -> String? { + guard !gaps.isEmpty else { return nil } + let noun = gaps.count == 1 ? "section" : "sections" + let duration = gaps.reduce(0) { $0 + $1.durationSeconds } + return "Speaker labels were kept, but \(gaps.count) short audio \(noun) totaling \(String(format: "%.1f", duration)) seconds produced no text." + } + + static func assembleTurns(_ turns: [SpeakerRecognizedTurn]) -> SpeakerLabeledTranscript? { + let gaps = turns.flatMap(\.transcription.gaps) + let recognizedTurns = turns.filter { + !$0.transcription.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + let diarizedDuration = turns.reduce(0) { + $0 + max(0, $1.endSeconds - $1.startSeconds) + } + guard self.shouldKeepSpeakerLabels( + hasRecognizedText: !recognizedTurns.isEmpty, + gaps: gaps, + diarizedDurationSeconds: diarizedDuration + ) else { + return nil + } + + let segments = recognizedTurns.map { turn in + SpeakerTranscriptSegment( + speaker: turn.speaker, + startSeconds: turn.startSeconds, + endSeconds: turn.endSeconds, + text: turn.transcription.text + ) + } + let confidence = recognizedTurns.reduce(0) { + $0 + $1.transcription.confidence + } / Float(recognizedTurns.count) + return SpeakerLabeledTranscript( + segments: segments, + confidence: confidence, + gaps: gaps, + notice: self.limitationNotice(for: gaps) + ) + } +} + /// Result of a transcription operation -struct TranscriptionResult: Identifiable, Sendable, Codable { +nonisolated struct TranscriptionResult: Identifiable, Sendable, Codable { let id: UUID let text: String let confidence: Float @@ -46,6 +236,10 @@ struct TranscriptionResult: Identifiable, Sendable, Codable { let timestamp: Date /// Speaker-attributed segments when diarization was enabled; empty otherwise. let speakerSegments: [SpeakerTranscriptSegment] + /// Persisted explanation when speaker labeling was partial or unavailable. + let speakerLabelingNotice: String? + /// Exact intervals omitted from an otherwise accepted speaker-attributed transcript. + let speakerLabelingGaps: [SpeakerTranscriptGap] init( id: UUID = UUID(), @@ -55,7 +249,9 @@ struct TranscriptionResult: Identifiable, Sendable, Codable { processingTime: TimeInterval, fileName: String, timestamp: Date = Date(), - speakerSegments: [SpeakerTranscriptSegment] = [] + speakerSegments: [SpeakerTranscriptSegment] = [], + speakerLabelingNotice: String? = nil, + speakerLabelingGaps: [SpeakerTranscriptGap] = [] ) { self.id = id self.text = text @@ -65,10 +261,13 @@ struct TranscriptionResult: Identifiable, Sendable, Codable { self.fileName = fileName self.timestamp = timestamp self.speakerSegments = speakerSegments + self.speakerLabelingNotice = speakerLabelingNotice + self.speakerLabelingGaps = speakerLabelingGaps } enum CodingKeys: String, CodingKey { case text, confidence, duration, processingTime, fileName, timestamp, speakerSegments + case speakerLabelingNotice, speakerLabelingGaps } init(from decoder: Decoder) throws { @@ -81,6 +280,8 @@ struct TranscriptionResult: Identifiable, Sendable, Codable { self.fileName = try c.decode(String.self, forKey: .fileName) self.timestamp = try c.decode(Date.self, forKey: .timestamp) self.speakerSegments = try c.decodeIfPresent([SpeakerTranscriptSegment].self, forKey: .speakerSegments) ?? [] + self.speakerLabelingNotice = try c.decodeIfPresent(String.self, forKey: .speakerLabelingNotice) + self.speakerLabelingGaps = try c.decodeIfPresent([SpeakerTranscriptGap].self, forKey: .speakerLabelingGaps) ?? [] } func encode(to encoder: Encoder) throws { @@ -94,6 +295,27 @@ struct TranscriptionResult: Identifiable, Sendable, Codable { if !self.speakerSegments.isEmpty { try c.encode(self.speakerSegments, forKey: .speakerSegments) } + try c.encodeIfPresent(self.speakerLabelingNotice, forKey: .speakerLabelingNotice) + if !self.speakerLabelingGaps.isEmpty { + try c.encode(self.speakerLabelingGaps, forKey: .speakerLabelingGaps) + } + } + + var textExport: String { + var metadata = [ + "Transcription: \(self.fileName)", + "Date: \(self.timestamp.formatted())", + "Duration: \(String(format: "%.1f", self.duration))s", + "Processing Time: \(String(format: "%.1f", self.processingTime))s", + "Confidence: \(String(format: "%.1f%%", self.confidence * 100))", + ] + if let speakerLabelingNotice { + metadata.append("Speaker labeling: \(speakerLabelingNotice)") + } + if !self.speakerLabelingGaps.isEmpty { + metadata.append("Unlabeled audio ranges: \(self.speakerLabelingGaps.map(\.timestampRangeText).joined(separator: ", "))") + } + return metadata.joined(separator: "\n") + "\n\n---\n\n" + self.text } } @@ -276,7 +498,8 @@ final class MeetingTranscriptionService: ObservableObject { confidence: nativeResult.confidence, duration: duration, processingTime: processingTime, - fileName: fileURL.lastPathComponent + fileName: fileURL.lastPathComponent, + speakerLabelingNotice: self.fallbackNotice ) self.currentStatus = "Complete!" @@ -399,7 +622,8 @@ final class MeetingTranscriptionService: ObservableObject { confidence: transcriptionResult.confidence, duration: duration, processingTime: processingTime, - fileName: fileURL.lastPathComponent + fileName: fileURL.lastPathComponent, + speakerLabelingNotice: self.fallbackNotice ) self.result = result @@ -418,19 +642,7 @@ final class MeetingTranscriptionService: ObservableObject { /// Export transcription result to text file nonisolated func exportToText(_ result: TranscriptionResult, to destinationURL: URL) throws { - let content = """ - Transcription: \(result.fileName) - Date: \(result.timestamp.formatted()) - Duration: \(String(format: "%.1f", result.duration))s - Processing Time: \(String(format: "%.1f", result.processingTime))s - Confidence: \(String(format: "%.1f%%", result.confidence * 100)) - - --- - - \(result.text) - """ - - try content.write(to: destinationURL, atomically: true, encoding: .utf8) + try result.textExport.write(to: destinationURL, atomically: true, encoding: .utf8) } /// Export transcription result to JSON @@ -455,8 +667,9 @@ final class MeetingTranscriptionService: ObservableObject { // MARK: - Speaker-Labeled Transcription /// Diarize-first pipeline: identify speaker turns, then transcribe the audio slice for - /// each turn with the active provider. Returns nil when diarization fails or yields - /// nothing usable, so the caller can fall back to the standard transcription paths. + /// each turn with the active provider. Tiny empty ASR intervals can be retained as explicit + /// gaps. Material omissions or genuine errors return nil so the caller can run the standard + /// full-file transcription path instead. private func transcribeFileWithSpeakerLabels( _ fileURL: URL, provider: TranscriptionProvider, @@ -501,21 +714,18 @@ final class MeetingTranscriptionService: ObservableObject { return nil } - var segments: [SpeakerTranscriptSegment] = [] - var totalConfidence: Float = 0 + var recognizedTurns: [SpeakerRecognizedTurn] = [] for (index, turn) in turns.enumerated() { self.currentStatus = "Transcribing speaker segments (\(index + 1)/\(turns.count))..." self.progress = 0.3 + (Double(index) / Double(turns.count)) * 0.65 - let transcribed: (text: String, confidence: Float)? + let transcribed: SpeakerTurnTranscription do { transcribed = try await self.transcribeSpeakerTurn(turn, from: audioFile, provider: provider) } catch { - // A genuine audio-read or ASR failure would drop this turn's time range, - // leaving a silent gap in the labeled transcript. Abandon the labeled path - // so the caller re-transcribes the whole file — baseline behavior is never - // at risk (a complete unlabeled transcript beats a labeled one with holes). + // A genuine audio-read or ASR failure can omit an unknown amount of speech. + // Use the standard full-file path rather than accepting uncertain labels. DebugLogger.shared.warning( "Speaker labeling aborted at segment \(index + 1)/\(turns.count) (\(String(format: "%.1f", turn.startSeconds))s): \(error.localizedDescription); falling back to standard transcription", source: "MeetingTranscriptionService" @@ -523,46 +733,48 @@ final class MeetingTranscriptionService: ObservableObject { return nil } - // An empty result would omit this turn's time range. Fall back to the complete - // full-file transcript rather than persist a labeled transcript with a silent gap. - guard let transcribed else { - DebugLogger.shared.warning( - "Speaker segment \(index + 1)/\(turns.count) produced no text; falling back to standard transcription", - source: "MeetingTranscriptionService" - ) - return nil - } - - segments.append(SpeakerTranscriptSegment( + recognizedTurns.append(SpeakerRecognizedTurn( speaker: turn.speakerLabel, startSeconds: turn.startSeconds, endSeconds: turn.endSeconds, - text: transcribed.text + transcription: transcribed )) - totalConfidence += transcribed.confidence } - guard !segments.isEmpty else { + guard let labeledTranscript = SpeakerLabeledTranscriptionPolicy.assembleTurns(recognizedTurns) else { + let gaps = recognizedTurns.flatMap(\.transcription.gaps) + let diarizedDuration = recognizedTurns.reduce(0) { + $0 + max(0, $1.endSeconds - $1.startSeconds) + } + let hasRecognizedText = recognizedTurns.contains { + !$0.transcription.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + let diagnostic = SpeakerLabeledTranscriptionPolicy.fallbackDiagnostic( + hasRecognizedText: hasRecognizedText, + gaps: gaps, + diarizedDurationSeconds: diarizedDuration + ) DebugLogger.shared.warning( - "No speaker segments produced text", + "\(diagnostic); falling back to standard transcription", source: "MeetingTranscriptionService" ) return nil } - let labeledText = segments + let labeledText = labeledTranscript.segments .map(\.plainText) .joined(separator: "\n\n") - let avgConfidence = totalConfidence / Float(segments.count) let processingTime = Date().timeIntervalSince(startTime) let result = TranscriptionResult( text: labeledText, - confidence: avgConfidence, + confidence: labeledTranscript.confidence, duration: duration, processingTime: processingTime, fileName: fileURL.lastPathComponent, - speakerSegments: segments + speakerSegments: labeledTranscript.segments, + speakerLabelingNotice: labeledTranscript.notice, + speakerLabelingGaps: labeledTranscript.gaps ) self.currentStatus = "Complete!" @@ -574,56 +786,44 @@ final class MeetingTranscriptionService: ObservableObject { } /// Transcribe a single speaker turn, splitting overlong turns into bounded chunks. - /// Returns the concatenated text and mean confidence, or nil - /// when the turn holds no usable audio (too short or silent). Throws on a genuine audio-read - /// or ASR failure so the caller can fall back to full-file transcription rather than emit a - /// transcript with silent gaps. + /// Returns the concatenated text, mean confidence, and explicit empty ASR intervals. + /// Throws on a genuine audio-read or ASR failure so the caller can use full-file transcription. private func transcribeSpeakerTurn( _ turn: SpeakerDiarizationService.SpeakerTurn, from audioFile: AVAudioFile, provider: TranscriptionProvider - ) async throws -> (text: String, confidence: Float)? { + ) async throws -> SpeakerTurnTranscription { // Bound memory for unusually long single-speaker stretches. Providers remain free to // apply their own model-specific, energy-aware chunking within each request. let maxChunkSeconds: Double = 20 * 60 - var ranges: [(start: Double, end: Double)] = [] + var ranges: [SpeakerTranscriptGap] = [] if turn.endSeconds - turn.startSeconds > maxChunkSeconds { var chunkStart = turn.startSeconds while chunkStart < turn.endSeconds { let chunkEnd = min(chunkStart + maxChunkSeconds, turn.endSeconds) - ranges.append((chunkStart, chunkEnd)) + ranges.append(SpeakerTranscriptGap(startSeconds: chunkStart, endSeconds: chunkEnd)) chunkStart = chunkEnd } } else { - ranges.append((turn.startSeconds, turn.endSeconds)) + ranges.append(SpeakerTranscriptGap(startSeconds: turn.startSeconds, endSeconds: turn.endSeconds)) } - var pieces: [String] = [] - var confidenceSum: Float = 0 - var transcribedChunks = 0 - - for range in ranges { + return try await SpeakerLabeledTranscriptionPolicy.transcribeChunks(ranges) { range in let samples = try self.readSamples( from: audioFile, - startSeconds: range.start, - endSeconds: range.end, + startSeconds: range.startSeconds, + endSeconds: range.endSeconds, minimumDurationSeconds: 1.1 ) - // Mirror the standard path's 1-second ASR minimum. guard samples.count >= 16_000 else { return nil } let chunkResult = try await provider.transcribe(samples) - let text = chunkResult.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return nil } - - pieces.append(text) - confidenceSum += chunkResult.confidence - transcribedChunks += 1 + return SpeakerChunkTranscription( + text: chunkResult.text, + confidence: chunkResult.confidence + ) } - - guard transcribedChunks > 0 else { return nil } - return (pieces.joined(separator: " "), confidenceSum / Float(transcribedChunks)) } /// Read a time range from an audio file as 16kHz mono Float32 samples. diff --git a/Sources/Fluid/UI/MeetingTranscriptionView.swift b/Sources/Fluid/UI/MeetingTranscriptionView.swift index 129889b11..29596cccd 100644 --- a/Sources/Fluid/UI/MeetingTranscriptionView.swift +++ b/Sources/Fluid/UI/MeetingTranscriptionView.swift @@ -362,7 +362,7 @@ struct MeetingTranscriptionView: View { .buttonStyle(.borderless) } - if let notice = transcriptionService.fallbackNotice { + if let notice = result.speakerLabelingNotice ?? transcriptionService.fallbackNotice { Label(notice, systemImage: "exclamationmark.triangle.fill") .font(.caption) .foregroundColor(.secondary) @@ -535,6 +535,11 @@ struct MeetingTranscriptionView: View { } .buttonStyle(.borderless) } + if let notice = entry.speakerLabelingNotice { + Label(notice, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.secondary) + } Divider() ScrollView { Text(entry.text) diff --git a/Tests/FluidDictationIntegrationTests/SpeakerTurnMergingTests.swift b/Tests/FluidDictationIntegrationTests/SpeakerTurnMergingTests.swift index cb3c25225..52b8f6c12 100644 --- a/Tests/FluidDictationIntegrationTests/SpeakerTurnMergingTests.swift +++ b/Tests/FluidDictationIntegrationTests/SpeakerTurnMergingTests.swift @@ -141,4 +141,369 @@ final class SpeakerTranscriptSegmentTests: XCTestCase { } } +final class SpeakerLabeledTranscriptionPolicyTests: XCTestCase { + private enum StubError: Error { + case failed + } + + func testEmptyLaterChunkKeepsEarlierTextAndRecordsOnlyTheMissingRange() async { + let ranges = [ + SpeakerTranscriptGap(startSeconds: 0, endSeconds: 1_200), + SpeakerTranscriptGap(startSeconds: 1_200, endSeconds: 1_205), + ] + + let result = await SpeakerLabeledTranscriptionPolicy.transcribeChunks(ranges) { range in + if range.startSeconds == 0 { + return SpeakerChunkTranscription(text: "Recognized first chunk", confidence: 0.8) + } + return SpeakerChunkTranscription(text: "", confidence: 0.1) + } + + XCTAssertEqual(result.text, "Recognized first chunk") + XCTAssertEqual(result.confidence, 0.8, accuracy: 0.0001) + XCTAssertEqual(result.gaps, [ranges[1]]) + } + + func testWhitespaceOnlyChunksAreGapsAndConfidenceUsesRecognizedChunksOnly() async { + let ranges = [ + SpeakerTranscriptGap(startSeconds: 0, endSeconds: 0.4), + SpeakerTranscriptGap(startSeconds: 0.4, endSeconds: 4), + SpeakerTranscriptGap(startSeconds: 4, endSeconds: 4.6), + SpeakerTranscriptGap(startSeconds: 4.6, endSeconds: 8), + ] + let responses = [ + SpeakerChunkTranscription(text: " \n", confidence: 0.1), + SpeakerChunkTranscription(text: "First", confidence: 0.6), + SpeakerChunkTranscription(text: "\t", confidence: 0.2), + SpeakerChunkTranscription(text: "Second", confidence: 1.0), + ] + var index = 0 + + let result = await SpeakerLabeledTranscriptionPolicy.transcribeChunks(ranges) { _ in + defer { index += 1 } + return responses[index] + } + + XCTAssertEqual(result.text, "First Second") + XCTAssertEqual(result.confidence, 0.8, accuracy: 0.0001) + XCTAssertEqual(result.gaps, [ranges[0], ranges[2]]) + } + + func testEmptyChunksAtBeginningAndEndKeepMiddleText() async { + let ranges = [ + SpeakerTranscriptGap(startSeconds: 0, endSeconds: 0.2), + SpeakerTranscriptGap(startSeconds: 0.2, endSeconds: 9.8), + SpeakerTranscriptGap(startSeconds: 9.8, endSeconds: 10), + ] + let responses: [SpeakerChunkTranscription?] = [ + nil, + SpeakerChunkTranscription(text: "Recognized middle", confidence: 0.75), + SpeakerChunkTranscription(text: "", confidence: 0.2), + ] + var index = 0 + + let result = await SpeakerLabeledTranscriptionPolicy.transcribeChunks(ranges) { _ in + defer { index += 1 } + return responses[index] + } + + XCTAssertEqual(result.text, "Recognized middle") + XCTAssertEqual(result.confidence, 0.75, accuracy: 0.0001) + XCTAssertEqual(result.gaps, [ranges[0], ranges[2]]) + } + + func testAllEmptyChunksProduceOnlyGaps() async { + let ranges = [ + SpeakerTranscriptGap(startSeconds: 0, endSeconds: 1), + SpeakerTranscriptGap(startSeconds: 1, endSeconds: 2), + ] + + let result = await SpeakerLabeledTranscriptionPolicy.transcribeChunks(ranges) { _ in + nil + } + + XCTAssertEqual(result.text, "") + XCTAssertEqual(result.confidence, 0) + XCTAssertEqual(result.gaps, ranges) + } + + func testProviderErrorStillAbortsAfterEarlierSuccess() async { + let ranges = [ + SpeakerTranscriptGap(startSeconds: 0, endSeconds: 1), + SpeakerTranscriptGap(startSeconds: 1, endSeconds: 2), + ] + var index = 0 + + do { + _ = try await SpeakerLabeledTranscriptionPolicy.transcribeChunks(ranges) { _ in + defer { index += 1 } + if index == 0 { + return SpeakerChunkTranscription(text: "First", confidence: 0.9) + } + throw StubError.failed + } + XCTFail("A genuine provider error must fall back to the full-file transcript") + } catch { + XCTAssertTrue(error is StubError) + } + } + + func testMaterialityAcceptsExactLimits() { + let gaps = (0..<10).map { index in + SpeakerTranscriptGap(startSeconds: Double(index) * 3, endSeconds: Double(index + 1) * 3) + } + + XCTAssertTrue(SpeakerLabeledTranscriptionPolicy.shouldKeepSpeakerLabels( + hasRecognizedText: true, + gaps: gaps, + diarizedDurationSeconds: 3_000 + )) + } + + func testMaterialityAcceptsSmallGapAboveThreeSecondsWhenTotalIsNegligible() { + XCTAssertTrue(SpeakerLabeledTranscriptionPolicy.shouldKeepSpeakerLabels( + hasRecognizedText: true, + gaps: [SpeakerTranscriptGap(startSeconds: 10, endSeconds: 13.311)], + diarizedDurationSeconds: 10_000 + )) + } + + func testMaterialityRejectsOneGapLongerThanFiveSeconds() { + XCTAssertFalse(SpeakerLabeledTranscriptionPolicy.shouldKeepSpeakerLabels( + hasRecognizedText: true, + gaps: [SpeakerTranscriptGap(startSeconds: 10, endSeconds: 15.001)], + diarizedDurationSeconds: 10_000 + )) + } + + func testMaterialityAcceptsOneGapAtFiveSecondLimit() { + XCTAssertTrue(SpeakerLabeledTranscriptionPolicy.shouldKeepSpeakerLabels( + hasRecognizedText: true, + gaps: [SpeakerTranscriptGap(startSeconds: 10, endSeconds: 15)], + diarizedDurationSeconds: 10_000 + )) + } + + func testMaterialityRejectsMoreThanOnePercentOmitted() { + let gaps = [ + SpeakerTranscriptGap(startSeconds: 0, endSeconds: 2.6), + SpeakerTranscriptGap(startSeconds: 10, endSeconds: 12.6), + SpeakerTranscriptGap(startSeconds: 20, endSeconds: 22.6), + SpeakerTranscriptGap(startSeconds: 30, endSeconds: 32.6), + ] + + XCTAssertFalse(SpeakerLabeledTranscriptionPolicy.shouldKeepSpeakerLabels( + hasRecognizedText: true, + gaps: gaps, + diarizedDurationSeconds: 1_000 + )) + } + + func testMaterialityCapsTotalAllowanceAtThirtySeconds() { + let gaps = (0..<11).map { index in + SpeakerTranscriptGap(startSeconds: Double(index) * 4, endSeconds: Double(index) * 4 + 3) + } + + XCTAssertFalse(SpeakerLabeledTranscriptionPolicy.shouldKeepSpeakerLabels( + hasRecognizedText: true, + gaps: gaps, + diarizedDurationSeconds: 20_000 + )) + } + + func testMaterialityRejectsAllEmptyTranscriptEvenWithoutGaps() { + XCTAssertFalse(SpeakerLabeledTranscriptionPolicy.shouldKeepSpeakerLabels( + hasRecognizedText: false, + gaps: [], + diarizedDurationSeconds: 60 + )) + } + + func testCoverageReportsTheEvidenceUsedByTheFallbackDecision() { + let gaps = [ + SpeakerTranscriptGap(startSeconds: 10, endSeconds: 11.5), + SpeakerTranscriptGap(startSeconds: 20, endSeconds: 24.25), + ] + + let coverage = SpeakerLabeledTranscriptionPolicy.coverage( + gaps: gaps, + diarizedDurationSeconds: 200 + ) + + XCTAssertEqual(coverage.gapCount, 2) + XCTAssertEqual(coverage.skippedDurationSeconds, 5.75, accuracy: 0.0001) + XCTAssertEqual(coverage.maxGapDurationSeconds, 4.25, accuracy: 0.0001) + XCTAssertEqual(coverage.diarizedDurationSeconds, 200, accuracy: 0.0001) + XCTAssertEqual(coverage.skippedRatio, 0.02875, accuracy: 0.000001) + } + + func testFallbackDiagnosticNamesMissingRecognizedTextInsteadOfOmittedAudio() { + let description = SpeakerLabeledTranscriptionPolicy.fallbackDiagnostic( + hasRecognizedText: false, + gaps: [], + diarizedDurationSeconds: 100 + ) + + XCTAssertEqual(description, "Speaker labeling produced no recognized text") + } + + func testSpeakerLabelingNoticeNamesSkippedCountAndDuration() { + let gaps = [ + SpeakerTranscriptGap(startSeconds: 10, endSeconds: 10.4), + SpeakerTranscriptGap(startSeconds: 20, endSeconds: 21), + ] + + XCTAssertEqual( + SpeakerLabeledTranscriptionPolicy.limitationNotice(for: gaps), + "Speaker labels were kept, but 2 short audio sections totaling 1.4 seconds produced no text." + ) + } + + func testResultRoundTripsPersistentSpeakerLabelingDetails() throws { + let gaps = [SpeakerTranscriptGap(startSeconds: 10, endSeconds: 10.4)] + let result = TranscriptionResult( + text: "[0:00] Speaker 1: Hello", + confidence: 0.9, + duration: 120, + processingTime: 3, + fileName: "meeting.m4a", + speakerSegments: [ + SpeakerTranscriptSegment( + speaker: "Speaker 1", + startSeconds: 0, + endSeconds: 5, + text: "Hello" + ), + ], + speakerLabelingNotice: "One short section was omitted.", + speakerLabelingGaps: gaps + ) + + let decoded = try JSONDecoder().decode( + TranscriptionResult.self, + from: JSONEncoder().encode(result) + ) + + XCTAssertEqual(decoded.speakerLabelingNotice, "One short section was omitted.") + XCTAssertEqual(decoded.speakerLabelingGaps, gaps) + XCTAssertTrue(decoded.textExport.contains("Speaker labeling: One short section was omitted.")) + XCTAssertTrue(decoded.textExport.contains("Unlabeled audio ranges: 0:10.0-0:10.4")) + } + + func testOlderResultWithoutSpeakerLabelingDetailsStillDecodes() throws { + struct LegacyResult: Encodable { + let text = "Hello" + let confidence: Float = 0.9 + let duration: TimeInterval = 5 + let processingTime: TimeInterval = 1 + let fileName = "old.wav" + let timestamp = Date(timeIntervalSince1970: 1_000) + } + + let decoded = try JSONDecoder().decode( + TranscriptionResult.self, + from: JSONEncoder().encode(LegacyResult()) + ) + + XCTAssertNil(decoded.speakerLabelingNotice) + XCTAssertTrue(decoded.speakerLabelingGaps.isEmpty) + } + + func testHistoryEntryKeepsSpeakerLabelingDetails() { + let gaps = [SpeakerTranscriptGap(startSeconds: 1, endSeconds: 1.2)] + let result = TranscriptionResult( + text: "Hello", + confidence: 0.9, + duration: 5, + processingTime: 1, + fileName: "meeting.wav", + speakerLabelingNotice: "A short section was omitted.", + speakerLabelingGaps: gaps + ) + + let restored = FileTranscriptionEntry(from: result).toTranscriptionResult() + + XCTAssertEqual(restored.speakerLabelingNotice, result.speakerLabelingNotice) + XCTAssertEqual(restored.speakerLabelingGaps, gaps) + } + + func testOlderHistoryEntryWithoutSpeakerLabelingDetailsStillDecodes() throws { + struct LegacyEntry: Encodable { + let id = UUID() + let timestamp = Date(timeIntervalSince1970: 1_000) + let fileName = "old.wav" + let duration: TimeInterval = 5 + let processingTime: TimeInterval = 1 + let confidence: Float = 0.9 + let text = "Hello" + } + + let decoded = try JSONDecoder().decode( + FileTranscriptionEntry.self, + from: JSONEncoder().encode(LegacyEntry()) + ) + + XCTAssertNil(decoded.speakerLabelingNotice) + XCTAssertTrue(decoded.speakerLabelingGaps.isEmpty) + } + + func testTinyEmptyTurnKeepsNeighboringSpeakerSegmentsInOrder() { + let emptyGap = SpeakerTranscriptGap(startSeconds: 500, endSeconds: 501) + let turns = [ + SpeakerRecognizedTurn( + speaker: "Speaker 1", + startSeconds: 0, + endSeconds: 500, + transcription: SpeakerTurnTranscription(text: "First", confidence: 0.8, gaps: []) + ), + SpeakerRecognizedTurn( + speaker: "Speaker 2", + startSeconds: 500, + endSeconds: 501, + transcription: SpeakerTurnTranscription(text: "", confidence: 0, gaps: [emptyGap]) + ), + SpeakerRecognizedTurn( + speaker: "Speaker 3", + startSeconds: 501, + endSeconds: 1_001, + transcription: SpeakerTurnTranscription(text: "Last", confidence: 1, gaps: []) + ), + ] + + let result = SpeakerLabeledTranscriptionPolicy.assembleTurns(turns) + + XCTAssertEqual(result?.segments.map(\.speaker), ["Speaker 1", "Speaker 3"]) + XCTAssertEqual(result?.segments.map(\.text), ["First", "Last"]) + XCTAssertEqual(result?.confidence ?? 0, 0.9, accuracy: 0.0001) + XCTAssertEqual(result?.gaps, [emptyGap]) + XCTAssertNotNil(result?.notice) + } + + func testMaterialEmptyTurnRejectsLabeledTranscript() { + let materialGap = SpeakerTranscriptGap(startSeconds: 500, endSeconds: 506) + let turns = [ + SpeakerRecognizedTurn( + speaker: "Speaker 1", + startSeconds: 0, + endSeconds: 500, + transcription: SpeakerTurnTranscription(text: "First", confidence: 0.8, gaps: []) + ), + SpeakerRecognizedTurn( + speaker: "Speaker 2", + startSeconds: 500, + endSeconds: 506, + transcription: SpeakerTurnTranscription(text: "", confidence: 0, gaps: [materialGap]) + ), + SpeakerRecognizedTurn( + speaker: "Speaker 3", + startSeconds: 506, + endSeconds: 1_006, + transcription: SpeakerTurnTranscription(text: "Last", confidence: 1, gaps: []) + ), + ] + + XCTAssertNil(SpeakerLabeledTranscriptionPolicy.assembleTurns(turns)) + } +} + #endif