|
@@ -99,8 +99,8 @@ final class MeetingTranscriptionService {
|
|
|
func transcribeMeeting(
|
|
func transcribeMeeting(
|
|
|
micURL: URL?,
|
|
micURL: URL?,
|
|
|
systemURL: URL?,
|
|
systemURL: URL?,
|
|
|
- chunkSeconds: TimeInterval = 30,
|
|
|
|
|
- overlapSeconds: TimeInterval = 0,
|
|
|
|
|
|
|
+ chunkSeconds: TimeInterval = 20,
|
|
|
|
|
+ overlapSeconds: TimeInterval = 1.5,
|
|
|
locales: [Locale] = [],
|
|
locales: [Locale] = [],
|
|
|
onProgress: (@Sendable (MeetingTranscriptionProgress) -> Void)? = nil
|
|
onProgress: (@Sendable (MeetingTranscriptionProgress) -> Void)? = nil
|
|
|
) async throws -> [TranscriptSegment] {
|
|
) async throws -> [TranscriptSegment] {
|
|
@@ -200,19 +200,33 @@ final class MeetingTranscriptionService {
|
|
|
) async throws -> [TranscriptSegment] {
|
|
) async throws -> [TranscriptSegment] {
|
|
|
var segments: [TranscriptSegment] = []
|
|
var segments: [TranscriptSegment] = []
|
|
|
segments.reserveCapacity(chunks.count)
|
|
segments.reserveCapacity(chunks.count)
|
|
|
|
|
+ var preferredLocale: Locale?
|
|
|
|
|
|
|
|
for plan in chunks {
|
|
for plan in chunks {
|
|
|
try Task.checkCancellation()
|
|
try Task.checkCancellation()
|
|
|
let buffer = try readChunkBuffer(url: url, startFrame: plan.startFrame, frameCount: plan.frameCount)
|
|
let buffer = try readChunkBuffer(url: url, startFrame: plan.startFrame, frameCount: plan.frameCount)
|
|
|
- let text = await transcribeBufferWithLocaleFallback(buffer: buffer, locales: locales)
|
|
|
|
|
|
|
+ let result = await transcribeBufferWithLocaleFallback(
|
|
|
|
|
+ buffer: buffer,
|
|
|
|
|
+ locales: locales,
|
|
|
|
|
+ preferredLocale: preferredLocale
|
|
|
|
|
+ )
|
|
|
await counter.increment()
|
|
await counter.increment()
|
|
|
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
|
+ preferredLocale = result.locale ?? preferredLocale
|
|
|
|
|
+ let trimmed = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
if trimmed.isEmpty { continue }
|
|
if trimmed.isEmpty { continue }
|
|
|
|
|
+ let textForSegment: String
|
|
|
|
|
+ if let previous = segments.last {
|
|
|
|
|
+ textForSegment = removeRepeatedPrefix(from: trimmed, previousText: previous.text)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ textForSegment = trimmed
|
|
|
|
|
+ }
|
|
|
|
|
+ let normalized = textForSegment.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ if normalized.isEmpty { continue }
|
|
|
segments.append(TranscriptSegment(
|
|
segments.append(TranscriptSegment(
|
|
|
speaker: speaker.rawValue,
|
|
speaker: speaker.rawValue,
|
|
|
startOffset: plan.startOffset,
|
|
startOffset: plan.startOffset,
|
|
|
endOffset: plan.endOffset,
|
|
endOffset: plan.endOffset,
|
|
|
- text: trimmed
|
|
|
|
|
|
|
+ text: normalized
|
|
|
))
|
|
))
|
|
|
}
|
|
}
|
|
|
return segments
|
|
return segments
|
|
@@ -228,24 +242,36 @@ final class MeetingTranscriptionService {
|
|
|
return buffer
|
|
return buffer
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func transcribeBufferWithLocaleFallback(buffer: AVAudioPCMBuffer, locales: [Locale]) async -> String {
|
|
|
|
|
- for locale in locales {
|
|
|
|
|
|
|
+ private func transcribeBufferWithLocaleFallback(
|
|
|
|
|
+ buffer: AVAudioPCMBuffer,
|
|
|
|
|
+ locales: [Locale],
|
|
|
|
|
+ preferredLocale: Locale?
|
|
|
|
|
+ ) async -> (text: String, locale: Locale?) {
|
|
|
|
|
+ var orderedLocales: [Locale] = []
|
|
|
|
|
+ if let preferredLocale {
|
|
|
|
|
+ orderedLocales.append(preferredLocale)
|
|
|
|
|
+ }
|
|
|
|
|
+ for locale in locales where orderedLocales.contains(where: { $0.identifier == locale.identifier }) == false {
|
|
|
|
|
+ orderedLocales.append(locale)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for locale in orderedLocales {
|
|
|
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else { continue }
|
|
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else { continue }
|
|
|
do {
|
|
do {
|
|
|
let text = try await transcribeBuffer(buffer: buffer, recognizer: recognizer)
|
|
let text = try await transcribeBuffer(buffer: buffer, recognizer: recognizer)
|
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
- if trimmed.isEmpty == false { return trimmed }
|
|
|
|
|
|
|
+ if trimmed.isEmpty == false { return (trimmed, locale) }
|
|
|
} catch {
|
|
} catch {
|
|
|
// One transient retry before moving on to the next locale.
|
|
// One transient retry before moving on to the next locale.
|
|
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
|
if let text = try? await transcribeBuffer(buffer: buffer, recognizer: recognizer) {
|
|
if let text = try? await transcribeBuffer(buffer: buffer, recognizer: recognizer) {
|
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
- if trimmed.isEmpty == false { return trimmed }
|
|
|
|
|
|
|
+ if trimmed.isEmpty == false { return (trimmed, locale) }
|
|
|
}
|
|
}
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- return ""
|
|
|
|
|
|
|
+ return ("", nil)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func preferredLocalesForGlobalRecognition() -> [Locale] {
|
|
private func preferredLocalesForGlobalRecognition() -> [Locale] {
|
|
@@ -290,6 +316,28 @@ final class MeetingTranscriptionService {
|
|
|
identifier.replacingOccurrences(of: "_", with: "-").lowercased()
|
|
identifier.replacingOccurrences(of: "_", with: "-").lowercased()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// Removes repeated leading words from a chunk that overlap with the
|
|
|
|
|
+ /// trailing words of the previous chunk.
|
|
|
|
|
+ private func removeRepeatedPrefix(from text: String, previousText: String) -> String {
|
|
|
|
|
+ let separators = CharacterSet.whitespacesAndNewlines
|
|
|
|
|
+ let currentWords = text.components(separatedBy: separators).filter { $0.isEmpty == false }
|
|
|
|
|
+ let previousWords = previousText.components(separatedBy: separators).filter { $0.isEmpty == false }
|
|
|
|
|
+ guard currentWords.isEmpty == false, previousWords.isEmpty == false else { return text }
|
|
|
|
|
+
|
|
|
|
|
+ let maxOverlap = min(12, currentWords.count, previousWords.count)
|
|
|
|
|
+ guard maxOverlap > 0 else { return text }
|
|
|
|
|
+
|
|
|
|
|
+ for overlap in stride(from: maxOverlap, through: 1, by: -1) {
|
|
|
|
|
+ let previousSuffix = Array(previousWords.suffix(overlap)).map { $0.lowercased() }
|
|
|
|
|
+ let currentPrefix = Array(currentWords.prefix(overlap)).map { $0.lowercased() }
|
|
|
|
|
+ if previousSuffix == currentPrefix {
|
|
|
|
|
+ let remainder = currentWords.dropFirst(overlap)
|
|
|
|
|
+ return remainder.joined(separator: " ")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return text
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func transcribeBuffer(buffer: AVAudioPCMBuffer, recognizer: SFSpeechRecognizer) async throws -> String {
|
|
private func transcribeBuffer(buffer: AVAudioPCMBuffer, recognizer: SFSpeechRecognizer) async throws -> String {
|
|
|
let request = SFSpeechAudioBufferRecognitionRequest()
|
|
let request = SFSpeechAudioBufferRecognitionRequest()
|
|
|
request.shouldReportPartialResults = false
|
|
request.shouldReportPartialResults = false
|