Files
hope-voice-api/Sources/LiveKit/TranscriptHandler.swift
T

215 lines
8.6 KiB
Swift

import Foundation
import LiveKit
struct TranscriptEvent: Equatable, Identifiable {
enum Role: String {
case user
case agent
}
let id: String
let role: Role
var text: String
var isFinal: Bool
let timestamp: Date
}
/// Merges the primary transcription segments (Room delegate) with the
/// secondary `transcript` data topic (finals only).
///
/// **Single canonical collection:** `events` is the one visible transcript.
/// It is initialized from the persisted history snapshot at init time and
/// updated in place by live events. The view renders only `events`.
///
/// **Cross-source dedupe:** Records `(role, normalized text, source, timestamp)`
/// and suppresses only the *opposite-source* copy within `dedupeWindow` seconds.
/// Same-source repeated utterances are never suppressed, even inside the window.
@MainActor
final class TranscriptHandler: ObservableObject {
/// The single canonical visible transcript. Initialized from persisted
/// history; live events append/update here. Trimmed to 50 finals.
@Published private(set) var events: [TranscriptEvent] = []
var acceptsRoom: ((Room) -> Bool)?
let history: TranscriptHistory
// MARK: Cross-source dedupe state
enum Source {
case primary // LiveKit transcription segments
case data // `transcript` data topic
}
struct RecentFinal {
let source: Source
let timestamp: Date
}
/// Keyed by normalized (role, text). Records the most recent final for
/// each key and which source produced it. Only the opposite-source copy
/// is suppressed within the window; same-source repeats pass through.
private var recentFinals: [String: RecentFinal] = [:]
static let dedupeWindow: TimeInterval = 5
static let maxEvents = 50
/// Injectable clock for deterministic tests. Defaults to `Date()`.
var now: () -> Date = { Date() }
convenience init() {
self.init(history: TranscriptHistory())
}
init(history: TranscriptHistory) {
self.history = history
// Initialize the canonical events from the persisted snapshot so the
// UI shows prior-session transcripts without a separate "restored" store.
events = history.entries.map { entry in
TranscriptEvent(
id: "hist-\(entry.timestamp.timeIntervalSince1970)-\(entry.role)",
role: entry.role == "agent" ? .agent : .user,
text: entry.text,
isFinal: true,
timestamp: entry.timestamp
)
}
}
// MARK: Ingest
func receiveSegment(id: String, role: TranscriptEvent.Role, text: String, isFinal: Bool) {
guard !text.isEmpty else { return }
if isFinal {
// Cross-source dedupe: suppress only if the *data* source already
// recorded this exact final within the window. Same-source (primary)
// repeats are always allowed. When suppressing, consume (remove) the
// matched record so a second same-source repeat from primary is not
// also suppressed — the next arrival establishes a new pair.
let key = Self.dedupeKey(role: role, text: text)
if let recent = recentFinals[key],
recent.source == .data,
now().timeIntervalSince(recent.timestamp) < Self.dedupeWindow {
// Consume: remove the matched opposite-source record.
recentFinals[key] = nil
return
}
}
upsert(id: id, role: role, text: text, isFinal: isFinal)
if isFinal {
recordFinal(role: role, text: text, source: .primary)
trimEvents()
syncPersistence()
}
}
func receiveDataTranscript(role: TranscriptEvent.Role, text: String) {
guard !text.isEmpty else { return }
// When a data final arrives for a role that has an open partial, replace
// the partial's text with the final and mark it final in place. The data
// topic is the authoritative final; the user sees one row either way.
if let index = events.lastIndex(where: { !$0.isFinal && $0.role == role }) {
events[index].text = text
events[index].isFinal = true
recordFinal(role: role, text: text, source: .data)
trimEvents()
syncPersistence()
return
}
// No open partial: check whether the *primary* source already recorded
// this exact final within the window (primary→data order). Same-source
// (data) repeats are always allowed. When suppressing, consume (remove)
// the matched record so a second same-source repeat from data is not
// also suppressed — the next arrival establishes a new pair.
let key = Self.dedupeKey(role: role, text: text)
if let recent = recentFinals[key],
recent.source == .primary,
now().timeIntervalSince(recent.timestamp) < Self.dedupeWindow {
// Consume: remove the matched opposite-source record.
recentFinals[key] = nil
return
}
upsert(id: "final-\(role.rawValue)-\(UUID().uuidString)", role: role, text: text, isFinal: true)
recordFinal(role: role, text: text, source: .data)
trimEvents()
syncPersistence()
}
/// Mirrors the canonical finals into the persisted snapshot so the UI and
/// storage never diverge. Same-source repeats are preserved verbatim.
private func syncPersistence() {
let finals = events.filter(\.isFinal).map { (role: $0.role.rawValue, text: $0.text, timestamp: $0.timestamp) }
history.sync(finals: finals)
}
// MARK: Clear
/// Clears visible rows, dedupe state, and persistence atomically.
func clearAll() {
events.removeAll()
recentFinals.removeAll()
history.clear()
}
// MARK: Private
private func upsert(id: String, role: TranscriptEvent.Role, text: String, isFinal: Bool) {
if let index = events.firstIndex(where: { $0.id == id }) {
events[index].text = text
events[index].isFinal = isFinal
} else {
events.append(TranscriptEvent(id: id, role: role, text: text, isFinal: isFinal, timestamp: now()))
}
}
private func recordFinal(role: TranscriptEvent.Role, text: String, source: Source) {
let key = Self.dedupeKey(role: role, text: text)
recentFinals[key] = RecentFinal(source: source, timestamp: now())
// Prune expired entries to bound the dictionary.
let current = now()
for (k, recent) in recentFinals where current.timeIntervalSince(recent.timestamp) >= Self.dedupeWindow {
recentFinals[k] = nil
}
}
/// Keeps at most `maxEvents` finals in the canonical collection. Partial
/// (non-final) rows are never trimmed; only surplus old finals are dropped.
private func trimEvents() {
let finalCount = events.filter(\.isFinal).count
guard finalCount > Self.maxEvents else { return }
// Drop oldest finals until we're back to maxEvents.
var toRemove = finalCount - Self.maxEvents
var writeIndex = 0
for event in events {
if event.isFinal && toRemove > 0 {
toRemove -= 1
continue
}
events[writeIndex] = event
writeIndex += 1
}
events.removeSubrange(writeIndex...)
}
private static func dedupeKey(role: TranscriptEvent.Role, text: String) -> String {
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
return "\(role.rawValue):\(normalized)"
}
}
extension TranscriptHandler: RoomDelegate {
nonisolated func room(_ room: Room, participant: Participant, trackPublication: TrackPublication, didReceiveTranscriptionSegments segments: [TranscriptionSegment]) {
let role: TranscriptEvent.Role = participant.isAgent ? .agent : .user
let isLocal = participant.identity?.stringValue == room.localParticipant.identity?.stringValue
let resolvedRole: TranscriptEvent.Role = isLocal ? .user : role
let events = segments.map {
TranscriptEvent(id: $0.id, role: resolvedRole, text: $0.text, isFinal: $0.isFinal, timestamp: $0.lastReceivedTime)
}
Task { @MainActor [weak self] in
guard let self, self.acceptsRoom?(room) ?? true else { return }
for event in events {
self.receiveSegment(id: event.id, role: event.role, text: event.text, isFinal: event.isFinal)
}
}
}
}