Files
Grant Gilliam 53d7fcdac0 Client: dual-channel label-merge (mic_file + system_file)
The backend shipped dual-channel mode; wire the client to it. We already capture
mic (you) and system (others) separately, so send them as two files instead of the
mono mix — fixing the misattribution at the source.

- SparkControlClient: labelMergeDual(mic_file, system_file, self_name, self_vad);
  multipart generalized to N files; shared POST/retry/decode extracted.
- SessionPackager.rebasedSelfVadData: chunk-local [{start,end}] for self_vad;
  sliceAudio reused for both tracks.
- TranscriptPipeline.process: dual-channel chunking (slice mic+system, rebase
  timeline + self_vad per chunk) when system audio is healthy; mono mixed-file
  fallback (self folded into the timeline) otherwise.
- VisualCapture.finish: write the full visual_timeline.json (remote + self merged)
  but return REMOTE (vision) segments only — self travels via the mic channel.
- TranscriptAssembler: rank mic_channel highest (the user's own track wins).
- VoiceprintStore: store the clean mic_channel self voiceprint.
- SessionController: pass mic/system URLs + remote timeline + channel self-spans +
  self_name + systemHealthy; self_vad.json now reflects the channel-verified spans.

Validated END-TO-END against the live backend on the real misattributing session:
'Go Bitcoin' (remote) is now attributed to Unknown_0, NOT the user; the user's own
lines come back source=mic_channel; per-channel ASR recovered fuller remote text.
36/36 XCTest (4 new: self_vad rebase, mic_channel ranking + voiceprint storage).
2026-06-06 13:15:29 -05:00

54 lines
3.1 KiB
Swift

import XCTest
@testable import Ten31Transcripts
final class VoiceprintStoreTests: XCTestCase {
private func tempURL() -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent("vp_\(UUID().uuidString).json")
}
private func response() throws -> LabelMergeResponse {
let json = #"{"duration":10,"speakers":[{"cluster":"Speaker_0","name":"Grant","source":"visual","overlap_confidence":0.99,"fingerprint":[0.1,0.2,0.3]},{"cluster":"Speaker_1","name":"Sarah","source":"voiceprint","match_similarity":0.7,"fingerprint":[0.4,0.5,0.6]},{"cluster":"Speaker_2","name":"Bob","source":"visual","overlap_confidence":0.5,"fingerprint":[0.7,0.8,0.9]},{"cluster":"Speaker_3","name":"Unknown_0","source":"unmatched"}],"segments":[],"fingerprints":{"Grant":[0.1,0.2,0.3],"Sarah":[0.4,0.5,0.6]},"models":{}}"#
return try JSONDecoder().decode(LabelMergeResponse.self, from: Data(json.utf8))
}
func testStoresOnlyConfidentNamedSpeakers() throws {
let url = tempURL(); defer { try? FileManager.default.removeItem(at: url) }
let store = VoiceprintStore(fileURL: url)
store.update(with: try response())
XCTAssertNotNil(store.entries["Grant"]) // visual, high overlap
XCTAssertNotNil(store.entries["Sarah"]) // voiceprint match
XCTAssertNil(store.entries["Bob"]) // overlap 0.5 < 0.8
XCTAssertNil(store.entries["Unknown_0"])
XCTAssertEqual(store.knownVoiceprints()["Grant"], [0.1, 0.2, 0.3])
XCTAssertEqual(store.entries["Grant"]?.calls, 1)
}
func testStoresMicChannelSelf() throws {
let url = tempURL(); defer { try? FileManager.default.removeItem(at: url) }
let store = VoiceprintStore(fileURL: url)
let json = #"{"duration":10,"speakers":[{"cluster":"mic","name":"Grant","source":"mic_channel","fingerprint":[0.5,0.6]}],"segments":[],"fingerprints":{"Grant":[0.5,0.6]},"models":{}}"#
store.update(with: try JSONDecoder().decode(LabelMergeResponse.self, from: Data(json.utf8)))
XCTAssertEqual(store.knownVoiceprints()["Grant"], [0.5, 0.6]) // clean self print stored
}
func testPersistsAcrossInstancesAndIncrementsCalls() throws {
let url = tempURL(); defer { try? FileManager.default.removeItem(at: url) }
let store = VoiceprintStore(fileURL: url)
store.update(with: try response())
store.update(with: try response())
XCTAssertEqual(store.entries["Grant"]?.calls, 2)
let reopened = VoiceprintStore(fileURL: url)
XCTAssertEqual(reopened.knownVoiceprints().count, 2)
}
func testRenameRemoveReset() throws {
let url = tempURL(); defer { try? FileManager.default.removeItem(at: url) }
let store = VoiceprintStore(fileURL: url)
store.update(with: try response())
store.rename("Sarah", to: "Sarah Jones")
XCTAssertNotNil(store.entries["Sarah Jones"]); XCTAssertNil(store.entries["Sarah"])
store.remove("Grant"); XCTAssertNil(store.entries["Grant"])
store.reset(); XCTAssertTrue(store.entries.isEmpty)
}
}