import Foundation /// Local persistence of named voiceprints — the compounding-identity layer. /// /// File `~/Ten31Transcripts/voiceprints.json`: /// `{ "": { "vector": [192 floats], "updated": , "calls": } }` /// /// On send → `knownVoiceprints()` feeds `label-merge`. On response → `update(with:)` /// stores/refreshes vectors for speakers resolved by **visual** (overlap ≥ ~0.8) /// or **voiceprint** match. Never stores `Unknown_N` / `Speaker_unknown`. /// /// Thread-safe (lock-guarded); the sequential pipeline is the only writer. final class VoiceprintStore { struct Entry: Codable, Equatable { var vector: [Float] var updated: String var calls: Int } private let url: URL private let minOverlapToStore: Double private let lock = NSLock() private var entriesStore: [String: Entry] = [:] init(fileURL: URL, minOverlapToStore: Double = 0.8) { self.url = fileURL self.minOverlapToStore = minOverlapToStore load() } var entries: [String: Entry] { lock.lock(); defer { lock.unlock() } return entriesStore } /// Vectors keyed by name, for the `known_voiceprints` field. func knownVoiceprints() -> [String: [Float]] { lock.lock(); defer { lock.unlock() } return entriesStore.mapValues { $0.vector } } /// Persist fingerprints from a `label-merge` response for confidently-named /// speakers only. func update(with response: LabelMergeResponse) { lock.lock(); defer { lock.unlock() } let now = ISO8601DateFormatter().string(from: Date()) for sp in response.speakers { guard !Self.isUnknown(sp.name) else { continue } let acceptable: Bool switch sp.source { case "mic_channel": acceptable = true // the user's own clean mic voiceprint case "visual": acceptable = (sp.overlapConfidence ?? 0) >= minOverlapToStore case "voiceprint": acceptable = true // already matched a known print default: acceptable = false // unmatched } guard acceptable, let vector = sp.fingerprint ?? response.fingerprints[sp.name], !vector.isEmpty else { continue } var entry = entriesStore[sp.name] ?? Entry(vector: vector, updated: now, calls: 0) entry.vector = vector entry.updated = now entry.calls += 1 entriesStore[sp.name] = entry } save() } func rename(_ old: String, to new: String) { lock.lock(); defer { lock.unlock() } guard old != new, let e = entriesStore.removeValue(forKey: old) else { return } entriesStore[new] = e save() } /// Enroll/refresh a voiceprint under `name` (e.g. after the user renames an /// "Unknown" speaker to a real name — we learn that voice for future calls). func enroll(name: String, vector: [Float]) { guard !name.isEmpty, !Self.isUnknown(name), !vector.isEmpty else { return } lock.lock(); defer { lock.unlock() } let now = ISO8601DateFormatter().string(from: Date()) var entry = entriesStore[name] ?? Entry(vector: vector, updated: now, calls: 0) entry.vector = vector entry.updated = now entry.calls += 1 entriesStore[name] = entry save() } /// Merge `absorbed` into `survivor`: drop the absorbed entry, keep the survivor's /// print (the user said they're the same person). func merge(_ absorbed: String, into survivor: String) { lock.lock(); defer { lock.unlock() } guard absorbed != survivor else { return } entriesStore.removeValue(forKey: absorbed) save() } func remove(_ name: String) { lock.lock(); defer { lock.unlock() } entriesStore.removeValue(forKey: name) save() } func reset() { lock.lock(); defer { lock.unlock() } entriesStore = [:] save() } // MARK: - Persistence (call with lock held) private func load() { guard let data = try? Data(contentsOf: url), let decoded = try? JSONDecoder().decode([String: Entry].self, from: data) else { return } entriesStore = decoded } private func save() { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) if let data = try? encoder.encode(entriesStore) { try? data.write(to: url) } } private static func isUnknown(_ name: String) -> Bool { LabelMergeResponse.isUnknownName(name) } }