53d7fcdac0
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).
54 lines
3.1 KiB
Swift
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)
|
|
}
|
|
}
|