@gotcos/glasses-server 6.95.0 → 6.98.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +166 -0
- package/README.md +4 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +12 -0
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +44 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +763 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +572 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +46 -27
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-speaker-review.ts +187 -21
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/session-find-ask.ts +288 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-shadow-decide.ts +59 -22
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +2 -2
- package/server/lib/speaker-shadow-worker.ts +50 -15
- package/server/lib/speaker-shadow.ts +80 -19
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +10 -5
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +515 -61
- package/server/routes/session-find.ts +73 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +28 -18
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
saveVoiceProfileStore,
|
|
27
27
|
type ProfileStore,
|
|
28
28
|
type VoiceProfile,
|
|
29
|
+
findStoredProfile,
|
|
29
30
|
} from './voice-profile-store.js'
|
|
30
31
|
import { homedir } from 'node:os'
|
|
31
32
|
import { spawnSync } from 'node:child_process'
|
|
@@ -43,6 +44,7 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
|
43
44
|
|
|
44
45
|
import { DATA_DIR } from './data-dir.js'
|
|
45
46
|
import { AUTO_ENROLL_THRESHOLD, OWNER_VERIFY_SIMILARITY } from './voiceprint-floor.js'
|
|
47
|
+
import { nfc, sameSpeakerName } from './speaker-name-nfc.js'
|
|
46
48
|
|
|
47
49
|
export const SPEAKER_MODEL_FILENAME = '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx'
|
|
48
50
|
|
|
@@ -263,10 +265,23 @@ export function initSpeakerEmbeddings(): boolean {
|
|
|
263
265
|
}
|
|
264
266
|
}
|
|
265
267
|
|
|
268
|
+
/**
|
|
269
|
+
* The manager is keyed on the STORED profile name, which may be decomposed (written before names were NFC) or composed. Every lookup
|
|
270
|
+
* therefore tries both forms of the name asked for, and returns the key it is actually held under.
|
|
271
|
+
*/
|
|
272
|
+
function managerKey(name: string): string | null {
|
|
273
|
+
if (!manager) return null
|
|
274
|
+
for (const key of new Set([name, nfc(name), name.normalize('NFD')])) if (manager.contains(key)) return key
|
|
275
|
+
return null
|
|
276
|
+
}
|
|
277
|
+
function removeFromManager(name: string): void {
|
|
278
|
+
if (!manager) return
|
|
279
|
+
for (const key of new Set([name, nfc(name), name.normalize('NFD')])) if (manager.contains(key)) manager.remove(key)
|
|
280
|
+
}
|
|
281
|
+
|
|
266
282
|
/** Check if a speaker is enrolled */
|
|
267
283
|
export function isEnrolled(name: string): boolean {
|
|
268
|
-
|
|
269
|
-
return manager.contains(name)
|
|
284
|
+
return managerKey(name) !== null
|
|
270
285
|
}
|
|
271
286
|
|
|
272
287
|
/** Get all enrolled speaker names */
|
|
@@ -307,7 +322,7 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
|
|
|
307
322
|
|
|
308
323
|
// Check diversity: skip if too similar to an existing embedding for this speaker
|
|
309
324
|
const store = loadProfileStore()
|
|
310
|
-
const profile = store.profiles.find(p => p.name
|
|
325
|
+
const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
|
|
311
326
|
if (profile) {
|
|
312
327
|
// Dedup check: skip if too similar to existing (unless bypassed by trainer)
|
|
313
328
|
if (!skipDedupCheck) {
|
|
@@ -338,7 +353,7 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
|
|
|
338
353
|
return { success: false, dim: extractor.dim, error: `Profile store write failed: ${errMsg(err)}` }
|
|
339
354
|
}
|
|
340
355
|
const updatedStore = loadProfileStore()
|
|
341
|
-
const updatedProfile = updatedStore.profiles.find(p => p.name
|
|
356
|
+
const updatedProfile = updatedStore.profiles.find(p => sameSpeakerName(p.name, name))
|
|
342
357
|
if (updatedProfile) {
|
|
343
358
|
rebuildSpeakerInManager(name, updatedProfile.embeddings)
|
|
344
359
|
console.log(`[speaker] Force-enrolled "${name}" via rebuild (${updatedProfile.embeddings.length} total, source: ${source})`)
|
|
@@ -415,7 +430,7 @@ export interface BatchEnrollResult {
|
|
|
415
430
|
export function enrollEmbeddingsBatch(name: string, items: BatchEnrollItem[]): BatchEnrollResult {
|
|
416
431
|
if (!extractor || !manager) return { enrolled: 0, declined: items.length, persisted: false, error: 'Speaker embeddings not initialized' }
|
|
417
432
|
const store = loadProfileStore()
|
|
418
|
-
const profile = store.profiles.find(p => p.name
|
|
433
|
+
const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
|
|
419
434
|
if (!profile) return { enrolled: 0, declined: items.length, persisted: false, error: 'No such profile' }
|
|
420
435
|
let added = 0
|
|
421
436
|
let declined = 0
|
|
@@ -474,13 +489,14 @@ export function identifySpeaker(
|
|
|
474
489
|
// Phase 4: scoped search — try expected attendees first
|
|
475
490
|
if (expectedSpeakers && expectedSpeakers.length > 0) {
|
|
476
491
|
for (const name of expectedSpeakers) {
|
|
477
|
-
if (name
|
|
478
|
-
|
|
479
|
-
|
|
492
|
+
if (sameSpeakerName(name, owner)) continue // wearer already checked
|
|
493
|
+
const held = managerKey(name)
|
|
494
|
+
if (!held) continue
|
|
495
|
+
const matches = manager.verify({ name: held, v: embedding, threshold: Math.max(SEARCH_THRESHOLD, minimum) })
|
|
480
496
|
if (matches) {
|
|
481
|
-
const similarity = computeCosineSimilarity(embedding,
|
|
482
|
-
logCalibration(
|
|
483
|
-
return { speaker:
|
|
497
|
+
const similarity = computeCosineSimilarity(embedding, held)
|
|
498
|
+
logCalibration(held, similarity, true, options.event)
|
|
499
|
+
return { speaker: held, similarity, embedding }
|
|
484
500
|
}
|
|
485
501
|
}
|
|
486
502
|
}
|
|
@@ -518,7 +534,7 @@ export function autoEnroll(
|
|
|
518
534
|
sessionId: string,
|
|
519
535
|
): { enrolled: boolean; reason: string } {
|
|
520
536
|
if (!extractor || !manager) return { enrolled: false, reason: 'not initialized' }
|
|
521
|
-
if (name
|
|
537
|
+
if (sameSpeakerName(name, getOwnerSpeakerLabel()) || name === 'Ext') return { enrolled: false, reason: 'skip owner/Ext' }
|
|
522
538
|
if (similarity < AUTO_ENROLL_THRESHOLD) return { enrolled: false, reason: `similarity ${similarity.toFixed(3)} < ${AUTO_ENROLL_THRESHOLD}` }
|
|
523
539
|
|
|
524
540
|
// Check if already auto-enrolled this session
|
|
@@ -565,7 +581,7 @@ export function clearAutoEnrollSession(sessionId: string): void {
|
|
|
565
581
|
/** Clear all embeddings for a speaker (used by fresh training mode) */
|
|
566
582
|
export function clearSpeakerEmbeddings(name: string): boolean {
|
|
567
583
|
const store = loadProfileStore()
|
|
568
|
-
const profile = store.profiles.find(p => p.name
|
|
584
|
+
const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
|
|
569
585
|
if (!profile || profile.embeddings.length === 0) return false
|
|
570
586
|
|
|
571
587
|
console.log(`[speaker] Clearing ${profile.embeddings.length} embeddings for "${name}"`)
|
|
@@ -573,9 +589,7 @@ export function clearSpeakerEmbeddings(name: string): boolean {
|
|
|
573
589
|
profile.sources = []
|
|
574
590
|
|
|
575
591
|
// Remove from manager
|
|
576
|
-
|
|
577
|
-
try { manager.remove(name) } catch { /* ignore */ }
|
|
578
|
-
}
|
|
592
|
+
try { removeFromManager(name) } catch { /* ignore */ }
|
|
579
593
|
|
|
580
594
|
// Persist. A "fresh training" clear legitimately empties one profile but must
|
|
581
595
|
// never be able to empty the whole store, so allowEmpty stays off here.
|
|
@@ -621,7 +635,7 @@ export function mergeSpeakerProfiles(
|
|
|
621
635
|
options: { force?: boolean; dryRun?: boolean } = {},
|
|
622
636
|
): MergeReport {
|
|
623
637
|
const store = loadProfileStore()
|
|
624
|
-
const target = store
|
|
638
|
+
const target = findStoredProfile(store, into)
|
|
625
639
|
const report: MergeReport = {
|
|
626
640
|
into,
|
|
627
641
|
merged: [],
|
|
@@ -641,10 +655,11 @@ export function mergeSpeakerProfiles(
|
|
|
641
655
|
const eligible: string[] = []
|
|
642
656
|
const refused: { name: string; similarity: number; floor: number }[] = []
|
|
643
657
|
for (const name of from) {
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
if (
|
|
647
|
-
|
|
658
|
+
// The target is never its own source, but a twin stored under the same name in the other form is a different profile and merges.
|
|
659
|
+
const sources = store.profiles.filter(p => p !== target && sameSpeakerName(p.name, name))
|
|
660
|
+
if (sources.length === 0) { if (!sameSpeakerName(name, into)) report.missing.push(name); continue }
|
|
661
|
+
// Every stored copy of the name is merged, so every copy has to clear the floor; the lowest one is what is reported.
|
|
662
|
+
const similarity = Math.min(...sources.map(source => profileSimilarity(target, source)))
|
|
648
663
|
report.similarity[name] = Math.round(similarity * 1000) / 1000
|
|
649
664
|
if (similarity < MERGE_SIMILARITY_FLOOR && !options.force) {
|
|
650
665
|
refused.push({ name, similarity: report.similarity[name], floor: MERGE_SIMILARITY_FLOOR })
|
|
@@ -696,7 +711,7 @@ export function mergeSpeakerProfiles(
|
|
|
696
711
|
// tests (no 26 MB model), so this side effect has no test seam — the reason
|
|
697
712
|
// it is expressed as a single reused call rather than bespoke logic.
|
|
698
713
|
for (const name of outcome.mergedFrom) rebuildSpeakerInManager(name, [])
|
|
699
|
-
rebuildSpeakerInManager(
|
|
714
|
+
rebuildSpeakerInManager(target.name, target.embeddings)
|
|
700
715
|
writeProfileStore(store)
|
|
701
716
|
console.log(
|
|
702
717
|
`[speaker] Merged ${outcome.mergedFrom.join(', ')} into "${into}"`,
|
|
@@ -712,9 +727,7 @@ export function removeSpeakerProfile(name: string): { removedProfiles: number; r
|
|
|
712
727
|
const result = deleteProfileFromStore(store, name)
|
|
713
728
|
if (result.removedProfiles === 0) return result
|
|
714
729
|
|
|
715
|
-
|
|
716
|
-
try { manager.remove(name) } catch { /* the on-disk removal is the durable half */ }
|
|
717
|
-
}
|
|
730
|
+
try { removeFromManager(name) } catch { /* the on-disk removal is the durable half */ }
|
|
718
731
|
// allowEmpty: deleting the only enrolled person is a legitimate reset, and the
|
|
719
732
|
// caller has already confirmed it explicitly.
|
|
720
733
|
writeProfileStore(store, { allowEmpty: true })
|
|
@@ -729,7 +742,7 @@ export function retractEmbeddingsBySource(
|
|
|
729
742
|
matches: (source: string) => boolean,
|
|
730
743
|
): { removed: number; remaining: number } {
|
|
731
744
|
const store = loadProfileStore()
|
|
732
|
-
const profile = store.profiles.find(p => p.name
|
|
745
|
+
const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
|
|
733
746
|
if (!profile) return { removed: 0, remaining: 0 }
|
|
734
747
|
|
|
735
748
|
const removed = removeEmbeddingsBySource(profile, matches)
|
|
@@ -743,7 +756,7 @@ export function retractEmbeddingsBySource(
|
|
|
743
756
|
/** Get embedding count for a speaker */
|
|
744
757
|
export function getEmbeddingCount(name: string): number {
|
|
745
758
|
const store = loadProfileStore()
|
|
746
|
-
const profile = store.profiles.find(p => p.name
|
|
759
|
+
const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
|
|
747
760
|
return profile?.embeddings.length ?? 0
|
|
748
761
|
}
|
|
749
762
|
|
|
@@ -1060,9 +1073,9 @@ function invalidateProfileCache(): void {
|
|
|
1060
1073
|
function restoreProfileFromDisk(name: string): void {
|
|
1061
1074
|
invalidateProfileCache()
|
|
1062
1075
|
try {
|
|
1063
|
-
const fresh = loadProfileStore().profiles.find(p => p.name
|
|
1064
|
-
if (fresh && fresh.embeddings.length > 0) rebuildSpeakerInManager(name, fresh.embeddings)
|
|
1065
|
-
else
|
|
1076
|
+
const fresh = loadProfileStore().profiles.find(p => sameSpeakerName(p.name, name))
|
|
1077
|
+
if (fresh && fresh.embeddings.length > 0) rebuildSpeakerInManager(fresh.name, fresh.embeddings)
|
|
1078
|
+
else removeFromManager(name)
|
|
1066
1079
|
} catch (err: unknown) {
|
|
1067
1080
|
console.error(`[speaker] Could not restore "${name}" from disk after a failed write:`, errMsg(err))
|
|
1068
1081
|
} finally {
|
|
@@ -1077,7 +1090,7 @@ function persistProfile(name: string, embedding: Float32Array, source: string =
|
|
|
1077
1090
|
try {
|
|
1078
1091
|
const store = loadProfileStore()
|
|
1079
1092
|
|
|
1080
|
-
let profile = store.profiles.find(p => p.name
|
|
1093
|
+
let profile = store.profiles.find(p => sameSpeakerName(p.name, name))
|
|
1081
1094
|
if (!profile) {
|
|
1082
1095
|
profile = { name, embeddings: [], sources: [] }
|
|
1083
1096
|
store.profiles.push(profile)
|
|
@@ -1131,10 +1144,8 @@ export function computeCentroid(embeddings: number[][]): Float32Array {
|
|
|
1131
1144
|
function rebuildSpeakerInManager(name: string, embeddings: number[][]): void {
|
|
1132
1145
|
if (!manager) return
|
|
1133
1146
|
try {
|
|
1134
|
-
// Remove existing entry
|
|
1135
|
-
|
|
1136
|
-
manager.remove(name)
|
|
1137
|
-
}
|
|
1147
|
+
// Remove the existing entry, whichever form it is held under
|
|
1148
|
+
removeFromManager(name)
|
|
1138
1149
|
if (embeddings.length === 0) return
|
|
1139
1150
|
|
|
1140
1151
|
// Register centroid of all embeddings
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speaker names are compared and stored in NFC.
|
|
3
|
+
*
|
|
4
|
+
* Foundation's `Process` turns an argument into decomposed Unicode (NFD): COS Control's helper sent `Pólya` and the server
|
|
5
|
+
* received the bytes `50 6f cc 81 6c 79 61`, where NFC is `50 c3 b3 6c 79 61`. A sidecar written by the pipeline holds NFC, so a
|
|
6
|
+
* confirm of "Brigitta Pólya" answered 409 `No chunk in this meeting is labelled` while 11 chunks carried that name.
|
|
7
|
+
*
|
|
8
|
+
* Two rules, applied everywhere a name arrives in a request or is matched against stored data:
|
|
9
|
+
* 1. Normalize a name to NFC at the route boundary (`bodyName`, `nfc`), so nothing decomposed is ever WRITTEN to a sidecar, a
|
|
10
|
+
* ledger or a voice profile.
|
|
11
|
+
* 2. Compare with NFC on both sides (`sameSpeakerName`), so a name already stored decomposed (written before this existed)
|
|
12
|
+
* still matches its composed spelling. Stored data is not rewritten.
|
|
13
|
+
*/
|
|
14
|
+
export function nfc(name: string): string { return name.normalize('NFC') }
|
|
15
|
+
|
|
16
|
+
export function sameSpeakerName(a: unknown, b: unknown): boolean {
|
|
17
|
+
return typeof a === 'string' && typeof b === 'string' && (a === b || nfc(a) === nfc(b))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A request value as a name: a string in NFC, or '' for anything else. */
|
|
21
|
+
export function bodyName(value: unknown): string { return typeof value === 'string' ? nfc(value) : '' }
|
|
22
|
+
|
|
23
|
+
/** A regular expression source that matches `name` in either form, for text that may hold either. */
|
|
24
|
+
export function nameForms(name: string, escape: (s: string) => string): string {
|
|
25
|
+
const composed = nfc(name), decomposed = name.normalize('NFD')
|
|
26
|
+
return composed === decomposed ? escape(composed) : `(?:${escape(composed)}|${escape(decomposed)})`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A record id carries a filename, and Control's helper hands it over decomposed: equal when they are the same text in NFC. */
|
|
30
|
+
export function sameRecordId(requested: unknown, expected: string): boolean {
|
|
31
|
+
return typeof requested === 'string' && (requested === expected || nfc(requested) === nfc(expected))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when raw text holds `name` in either Unicode form (a file written before names were normalized may hold the decomposed one). */
|
|
35
|
+
export function textHasName(raw: string, name: string): boolean {
|
|
36
|
+
return raw.includes(name) || raw.includes(nfc(name)) || raw.includes(name.normalize('NFD'))
|
|
37
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { readFileSync, readdirSync, lstatSync, realpathSync } from 'node:fs'
|
|
7
7
|
import { join } from 'node:path'
|
|
8
8
|
import { renameMeetingSpeaker } from './held-naming-batches.js'
|
|
9
|
+
import { nameForms, sameSpeakerName, textHasName } from './speaker-name-nfc.js'
|
|
9
10
|
|
|
10
11
|
/** A month directory, and nothing that merely looks like one. */
|
|
11
12
|
const CANONICAL_MONTH = /^\d{4}-\d{2}$/
|
|
@@ -153,7 +154,7 @@ export async function fanOutSpeakerRename(
|
|
|
153
154
|
if (!path.endsWith(SIDECAR_SUFFIX)) continue
|
|
154
155
|
const markdownPath = path.slice(0, -SIDECAR_SUFFIX.length) + '.md'
|
|
155
156
|
coveredMarkdown.add(markdownPath)
|
|
156
|
-
if (!raw
|
|
157
|
+
if (!textHasName(raw, from)) continue
|
|
157
158
|
try {
|
|
158
159
|
const doc = JSON.parse(raw)
|
|
159
160
|
if (!doc || Array.isArray(doc) || typeof doc !== 'object') throw new Error('sidecar is not a JSON object')
|
|
@@ -190,7 +191,7 @@ export async function fanOutSpeakerRename(
|
|
|
190
191
|
// Cloud-only and archived markdown has no raw G2 position authority. Keep it
|
|
191
192
|
// visible in the report rather than rewriting an attendee or quoted name.
|
|
192
193
|
for (const [path, raw] of entries) {
|
|
193
|
-
if (!path.endsWith('.md') || coveredMarkdown.has(path) || !raw
|
|
194
|
+
if (!path.endsWith('.md') || coveredMarkdown.has(path) || !textHasName(raw, from)) continue
|
|
194
195
|
if (hasNamedLabel(raw, from)) result.skipped.push({
|
|
195
196
|
path, reason: 'no G2 session sidecar; transcript labels cannot be safely aligned',
|
|
196
197
|
})
|
|
@@ -203,13 +204,13 @@ function errorText(error: unknown): string {
|
|
|
203
204
|
}
|
|
204
205
|
|
|
205
206
|
function carriesSpeaker(doc: Record<string, any>, name: string): boolean {
|
|
206
|
-
return (Array.isArray(doc.chunks) && doc.chunks.some((chunk: any) => chunk?.speaker
|
|
207
|
-
|| (Array.isArray(doc.chunkEntries) && doc.chunkEntries.some((entry: any) => entry?.chunk?.speaker
|
|
207
|
+
return (Array.isArray(doc.chunks) && doc.chunks.some((chunk: any) => sameSpeakerName(chunk?.speaker, name)))
|
|
208
|
+
|| (Array.isArray(doc.chunkEntries) && doc.chunkEntries.some((entry: any) => sameSpeakerName(entry?.chunk?.speaker, name)))
|
|
208
209
|
|| (Array.isArray(doc.batchSegments) && doc.batchSegments.some((segment: any) =>
|
|
209
|
-
Array.isArray(segment?.speakerWords) && segment.speakerWords.some((word: any) => word?.speaker
|
|
210
|
+
Array.isArray(segment?.speakerWords) && segment.speakerWords.some((word: any) => sameSpeakerName(word?.speaker, name))))
|
|
210
211
|
}
|
|
211
212
|
|
|
212
|
-
function escapedName(name: string): string { return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }
|
|
213
|
+
function escapedName(name: string): string { return nameForms(name, n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) }
|
|
213
214
|
function hasNamedLabel(markdown: string, name: string): boolean {
|
|
214
215
|
const escaped = escapedName(name)
|
|
215
216
|
return new RegExp(`^(?:\\[${escaped}\\]:|\\*\\*${escaped}(?::\\*\\*|\\*\\*)|[-*] ${escaped}$)`, 'm').test(markdown)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Speaker merge shadow mode
|
|
1
|
+
// Speaker merge shadow mode: the research gates plus the Wave-1 anchor gate.
|
|
2
2
|
//
|
|
3
3
|
// SOURCE OF TRUTH. `community-1-fluidaudio-v1/scripts/hybrid.py` `decide()` (round 2) and
|
|
4
4
|
// the report assembly in `round3/propose.py`, on CPython 3.15. Production `Voice N` labels
|
|
@@ -9,21 +9,31 @@
|
|
|
9
9
|
// (b) it has at least `floorSeconds` (15 s) of production speech;
|
|
10
10
|
// (c) its window-embedding centroid lies inside the target cluster's own spread,
|
|
11
11
|
// measured leave-fragment-out against the 95th percentile.
|
|
12
|
+
// (d) a nonanchor's centroid distance from the kept voice must be <=1.5 times
|
|
13
|
+
// that anchor's own p95 spread. Missing evidence fails closed. The anchor
|
|
14
|
+
// remains the longest voice passing (a)-(c); a rejected fragment never
|
|
15
|
+
// causes selection of another anchor.
|
|
12
16
|
// Named labels, `Ext` and every non-Voice label are frozen: never merged into or out of.
|
|
13
|
-
// The post-hoc anchor ratio is computed and REPORTED ONLY. It decides nothing.
|
|
14
17
|
//
|
|
15
18
|
// LOG ONLY. Nothing here writes, and nothing calls a writer: the output is a proposal record.
|
|
16
19
|
//
|
|
17
20
|
// Order and rounding are part of the contract: Python dicts iterate in insertion order,
|
|
18
|
-
// max()/most_common(1) keep the FIRST maximum, sorted() is stable, gates compare
|
|
19
|
-
// values and reason strings use Python %-formatting.
|
|
21
|
+
// max()/most_common(1) keep the FIRST maximum, sorted() is stable, research gates compare
|
|
22
|
+
// ROUNDED values and reason strings use Python %-formatting. Gate(d) uses the raw ratio.
|
|
23
|
+
// The research numerics remain unchanged below,
|
|
20
24
|
// with the Python-compatible numerics in speaker-shadow-numeric.ts.
|
|
21
25
|
|
|
22
26
|
import { PY_VOICE_LABEL, codePointCompare, pyClock, pyFirstInt, pyFixed, pyRound, pyRoundInt, pySum } from './speaker-shadow-numeric.js'
|
|
23
27
|
|
|
24
28
|
export const SHADOW_SAMPLE_RATE = 16_000
|
|
25
|
-
/**
|
|
26
|
-
export const SHADOW_GATES = Object.freeze({ majority: 0.8, minCoveredFraction: 0.5, floorSeconds: 15,
|
|
29
|
+
/** Existing round-2 gates plus the separately enforced Wave-1 anchor ratio. */
|
|
30
|
+
export const SHADOW_GATES = Object.freeze({ majority: 0.8, minCoveredFraction: 0.5, floorSeconds: 15, anchorRatioMax: 1.5,
|
|
31
|
+
/** Compatibility alias for older report consumers; this is now enforced. */
|
|
32
|
+
anchorRatioPostHoc: 1.5 })
|
|
33
|
+
|
|
34
|
+
export function passesShadowAnchorRatio(ratio: number | null): boolean {
|
|
35
|
+
return ratio !== null && Number.isFinite(ratio) && ratio >= 0 && ratio <= SHADOW_GATES.anchorRatioMax
|
|
36
|
+
}
|
|
27
37
|
|
|
28
38
|
/** One production segment on the reconstructed timeline: [lo, hi) samples and its label. */
|
|
29
39
|
export type ShadowSegment = readonly [lo: number, hi: number, label: string]
|
|
@@ -154,6 +164,10 @@ export interface ShadowVoiceRow {
|
|
|
154
164
|
fragmentEmbeddings: number
|
|
155
165
|
otherClusterMembers: number
|
|
156
166
|
outlier: ShadowOutlier
|
|
167
|
+
/** Unrounded ratio used by gate (d); null for the anchor or missing evidence. */
|
|
168
|
+
anchorRatio: number | null
|
|
169
|
+
/** null means this voice is the anchor or is not in a proposed group. */
|
|
170
|
+
anchorRatioPass: boolean | null
|
|
157
171
|
eligible: boolean
|
|
158
172
|
reasons: string[]
|
|
159
173
|
}
|
|
@@ -161,6 +175,20 @@ export interface ShadowVoiceRow {
|
|
|
161
175
|
export interface ShadowItem { vec: number[]; exportCluster: string; outputCluster: string | null; voice: string | null }
|
|
162
176
|
export interface ShadowGroup { cluster: string; voices: string[]; anchor: string | null; mergedIntoAnchor: string[] }
|
|
163
177
|
|
|
178
|
+
/** The anchor spread is independent of the fragment; do not inflate it with
|
|
179
|
+
* candidate fragments, and do not accept a missing/zero spread as agreement. */
|
|
180
|
+
function anchorRatio(fragment: readonly number[][], anchor: readonly number[][]): number | null {
|
|
181
|
+
if (fragment.length < 3 || anchor.length < 3 || [...fragment, ...anchor].some(v =>
|
|
182
|
+
v.length !== 256 || v.some(x => !Number.isFinite(x)) || !v.some(x => x !== 0))) return null
|
|
183
|
+
const centroid = meanDir(anchor)
|
|
184
|
+
const spread = p95(anchor.map(v => cosdist(v, centroid)))
|
|
185
|
+
const distance = cosdist(meanDir(fragment), centroid)
|
|
186
|
+
if (!Number.isFinite(spread) || !(spread > 0) || !Number.isFinite(distance) || distance < -1e-12) return null
|
|
187
|
+
// Unit-vector dot products can exceed one by a rounding ulp.
|
|
188
|
+
const ratio = Math.max(0, distance) / spread
|
|
189
|
+
return Number.isFinite(ratio) ? ratio : null
|
|
190
|
+
}
|
|
191
|
+
|
|
164
192
|
export interface ShadowDecision {
|
|
165
193
|
voices: Map<string, Array<[number, number]>>
|
|
166
194
|
rows: Map<string, ShadowVoiceRow>
|
|
@@ -233,7 +261,7 @@ export function decide(
|
|
|
233
261
|
topCluster: top,
|
|
234
262
|
majority: covered ? pyRound(topN / covered, 3) : 0,
|
|
235
263
|
fragmentEmbeddings: 0, otherClusterMembers: 0,
|
|
236
|
-
outlier: { pass: false, reason: '' }, eligible: false, reasons: [],
|
|
264
|
+
outlier: { pass: false, reason: '' }, anchorRatio: null, anchorRatioPass: null, eligible: false, reasons: [],
|
|
237
265
|
})
|
|
238
266
|
}
|
|
239
267
|
|
|
@@ -278,13 +306,30 @@ export function decide(
|
|
|
278
306
|
const remap = new Map<string, string>()
|
|
279
307
|
const rejected: ShadowDecision['rejected'] = []
|
|
280
308
|
const groups: ShadowGroup[] = []
|
|
309
|
+
const vecs = new Map<string, number[][]>()
|
|
310
|
+
for (const item of items) if (item.voice) {
|
|
311
|
+
const list = vecs.get(item.voice)
|
|
312
|
+
if (list) list.push(item.vec); else vecs.set(item.voice, [item.vec])
|
|
313
|
+
}
|
|
281
314
|
let proposed = 0, accepted = 0
|
|
282
315
|
for (const [k, vs] of [...byCluster].sort((x, y) => codePointCompare(x[0], y[0]))) {
|
|
283
316
|
if (vs.length < 2) continue
|
|
284
317
|
proposed += vs.length - 1
|
|
285
|
-
const
|
|
286
|
-
const anchor =
|
|
287
|
-
const
|
|
318
|
+
const baseEligible = longestFirst(vs.filter(v => rows.get(v)!.eligible))
|
|
319
|
+
const anchor = baseEligible[0] ?? null
|
|
320
|
+
if (anchor) for (const v of vs) {
|
|
321
|
+
if (v === anchor) continue
|
|
322
|
+
const row = rows.get(v)!
|
|
323
|
+
row.anchorRatio = anchorRatio(vecs.get(v) ?? [], vecs.get(anchor) ?? [])
|
|
324
|
+
row.anchorRatioPass = passesShadowAnchorRatio(row.anchorRatio)
|
|
325
|
+
if (row.eligible && !row.anchorRatioPass) {
|
|
326
|
+
row.eligible = false
|
|
327
|
+
row.reasons.push(row.anchorRatio === null ? '(d) anchor ratio evidence unavailable'
|
|
328
|
+
: `(d) anchor ratio ${pyFixed(row.anchorRatio, 4)} > ${pyFixed(SHADOW_GATES.anchorRatioMax, 2)}`)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const ok = baseEligible.filter(v => rows.get(v)!.eligible)
|
|
332
|
+
const merged = ok.filter(v => v !== anchor)
|
|
288
333
|
accepted += merged.length
|
|
289
334
|
for (const v of merged) remap.set(v, anchor!)
|
|
290
335
|
for (const v of vs) {
|
|
@@ -315,7 +360,9 @@ export interface ShadowVoiceEntry {
|
|
|
315
360
|
outlierDistance: number | null
|
|
316
361
|
outlierP95: number | null
|
|
317
362
|
outlierNote: string | null
|
|
318
|
-
/**
|
|
363
|
+
/** Applied unrounded ratio. Optional only for older persisted proposal rows. */
|
|
364
|
+
anchorRatio?: number | null
|
|
365
|
+
/** Rounded compatibility field; gate (d) uses the row's UNROUNDED ratio. */
|
|
319
366
|
anchorRatioPostHoc: number | null
|
|
320
367
|
eligible: boolean
|
|
321
368
|
reasons: string[]
|
|
@@ -368,16 +415,6 @@ export function buildShadowProposals(
|
|
|
368
415
|
const byLabel = new Map<string, ShadowSegmentSource[]>()
|
|
369
416
|
segs.forEach(([, , label], i) => { const list = byLabel.get(label); if (list) list.push(sources[i]); else byLabel.set(label, [sources[i]]) })
|
|
370
417
|
|
|
371
|
-
const vecs = new Map<string, number[][]>()
|
|
372
|
-
for (const it of D.items) if (it.voice) { const list = vecs.get(it.voice); if (list) list.push(it.vec); else vecs.set(it.voice, [it.vec]) }
|
|
373
|
-
const ratio = (v: string, anchor: string): number | null => {
|
|
374
|
-
const A = vecs.get(anchor) ?? [], B = vecs.get(v) ?? []
|
|
375
|
-
if (v === anchor || A.length < 3 || B.length < 3) return null
|
|
376
|
-
const ca = meanDir(A)
|
|
377
|
-
const thr = p95(A.map(x => cosdist(x, ca)))
|
|
378
|
-
return thr > 0 ? pyRound(cosdist(meanDir(B), ca) / thr, 2) : null
|
|
379
|
-
}
|
|
380
|
-
|
|
381
418
|
// Named / non-Voice labels sharing each FluidAudio cluster (caution only; never merged).
|
|
382
419
|
const named = new Map<string, Map<string, number>>()
|
|
383
420
|
for (const [lo, hi, label] of segs) {
|
|
@@ -399,7 +436,7 @@ export function buildShadowProposals(
|
|
|
399
436
|
outlierDistance: 'reason' in o ? null : o.fragmentCentroidDistance,
|
|
400
437
|
outlierP95: 'reason' in o ? null : o.clusterP95Distance,
|
|
401
438
|
outlierNote: 'reason' in o ? o.reason : null,
|
|
402
|
-
anchorRatioPostHoc:
|
|
439
|
+
anchorRatio: r.anchorRatio, anchorRatioPostHoc: r.anchorRatio === null ? null : pyRound(r.anchorRatio, 2), eligible: r.eligible, reasons: r.reasons,
|
|
403
440
|
listen: listenPointers(v, byLabel),
|
|
404
441
|
}
|
|
405
442
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Bounded read-only clock diagnostics. File dates and timestamps are not alignment proof.
|
|
2
|
+
import { open, readdir, lstat } from 'node:fs/promises'
|
|
3
|
+
import { constants } from 'node:fs'
|
|
4
|
+
import { basename, dirname, join } from 'node:path'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
export interface ShadowPairSource {
|
|
8
|
+
status: 'time_only' | 'ambiguous' | 'unpaired'
|
|
9
|
+
reason: string
|
|
10
|
+
sessionId?: string
|
|
11
|
+
sourceSha256?: string
|
|
12
|
+
g2?: { startTime: number; chunks: Array<{ speaker: string; elapsed: number; audioDurationMs: number }> }
|
|
13
|
+
}
|
|
14
|
+
const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)
|
|
15
|
+
function bounds(value: Record<string, unknown>, durationOverride?: number): [number, number] | null {
|
|
16
|
+
if (!finite(value.startTime) || !Array.isArray(value.chunks)) return null
|
|
17
|
+
const duration = durationOverride ?? value.durationMs
|
|
18
|
+
if (!finite(duration) || duration <= 0 || duration > 4 * 3600_000) return null
|
|
19
|
+
return [value.startTime, value.startTime + duration]
|
|
20
|
+
}
|
|
21
|
+
const day = (ms: number) => { const d = new Date(ms); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` }
|
|
22
|
+
/** Only the current recording directory is searched; absent cross-month pairs remain unpaired. */
|
|
23
|
+
export async function findShadowPairSource(sidecarPath: string, mac: Record<string, unknown>, durationMs?: number): Promise<ShadowPairSource> {
|
|
24
|
+
const target = bounds(mac, durationMs)
|
|
25
|
+
if (!target) return { status: 'unpaired', reason: 'invalid Mac clock or duration' }
|
|
26
|
+
const dir = dirname(sidecarPath)
|
|
27
|
+
// Official sidecars use this Mac's local calendar-day prefix. Include a four-hour
|
|
28
|
+
// lookback for a G2 capture that started before this Mac capture (maximum call length).
|
|
29
|
+
const days = new Set([day(target[0] - 4 * 3600_000), day(target[0]), day(target[1])])
|
|
30
|
+
const all = await readdir(dir)
|
|
31
|
+
const names = all.filter(n => n.endsWith('.g2-chunks.json') && n !== basename(sidecarPath))
|
|
32
|
+
.filter(n => { const prefix = n.match(/^(\d{4}-\d{2}-\d{2})_/); return !prefix || days.has(prefix[1]) }).sort()
|
|
33
|
+
if (names.length > 256) return { status: 'ambiguous', reason: 'day-window discovery file limit exceeded; no pair selected' }
|
|
34
|
+
let bytes = 0, estimated = false
|
|
35
|
+
const candidates: ShadowPairSource[] = []
|
|
36
|
+
for (const name of names) {
|
|
37
|
+
const path = join(dir, name)
|
|
38
|
+
let raw: string, value: Record<string, unknown>
|
|
39
|
+
try {
|
|
40
|
+
const stat = await lstat(path)
|
|
41
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return { status: 'ambiguous', reason: 'unreadable day-window candidate; no pair selected' }
|
|
42
|
+
if (stat.size > 16 * 1024 * 1024 || bytes + stat.size > 128 * 1024 * 1024) return { status: 'ambiguous', reason: 'day-window discovery byte limit exceeded; no pair selected' }
|
|
43
|
+
const parts: Buffer[] = []
|
|
44
|
+
let size = 0
|
|
45
|
+
// A candidate can change between lstat and open. Never wait on a FIFO/device
|
|
46
|
+
// substituted in that interval; verify the actual descriptor before reading.
|
|
47
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
48
|
+
try {
|
|
49
|
+
if (!(await handle.stat()).isFile()) throw new Error('candidate is no longer a regular file')
|
|
50
|
+
const buffer = Buffer.alloc(64 * 1024)
|
|
51
|
+
while (true) {
|
|
52
|
+
const { bytesRead } = await handle.read(buffer)
|
|
53
|
+
if (!bytesRead) break
|
|
54
|
+
size += bytesRead
|
|
55
|
+
if (size > 16 * 1024 * 1024 || bytes + size > 128 * 1024 * 1024) throw new Error('candidate grew beyond byte budget')
|
|
56
|
+
parts.push(Buffer.from(buffer.subarray(0, bytesRead)))
|
|
57
|
+
}
|
|
58
|
+
} finally { await handle.close() }
|
|
59
|
+
raw = Buffer.concat(parts).toString('utf8')
|
|
60
|
+
bytes += size
|
|
61
|
+
if (Buffer.byteLength(raw) > 16 * 1024 * 1024 || bytes > 128 * 1024 * 1024) return { status: 'ambiguous', reason: 'day-window discovery changed beyond byte limit; no pair selected' }
|
|
62
|
+
const parsed: unknown = JSON.parse(raw)
|
|
63
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid sidecar')
|
|
64
|
+
value = parsed as Record<string, unknown>
|
|
65
|
+
if (typeof value.sessionId !== 'string') throw new Error('missing source identity')
|
|
66
|
+
} catch { return { status: 'ambiguous', reason: 'unreadable or malformed day-window candidate; no pair selected' } }
|
|
67
|
+
if (typeof value.sessionId !== 'string' || !value.sessionId.startsWith('meeting_') || value.finalizationState !== 'complete') continue
|
|
68
|
+
const span = bounds(value)
|
|
69
|
+
if (!span) return { status: 'ambiguous', reason: 'complete G2 candidate lacks valid clock/duration; no pair selected' }
|
|
70
|
+
const overlap = Math.max(0, Math.min(target[1], span[1]) - Math.max(target[0], span[0]))
|
|
71
|
+
const macDuration = target[1] - target[0], g2Duration = span[1] - span[0]
|
|
72
|
+
if (overlap / Math.max(macDuration, g2Duration) < .5 || Math.min(macDuration, g2Duration) / Math.max(macDuration, g2Duration) < .5) continue
|
|
73
|
+
const chunks: NonNullable<ShadowPairSource['g2']>['chunks'] = []
|
|
74
|
+
for (const item of value.chunks as unknown[]) {
|
|
75
|
+
if (!item || typeof item !== 'object') return { status: 'ambiguous', reason: 'invalid G2 chunk; no pair selected' }
|
|
76
|
+
const row = item as Record<string, unknown>
|
|
77
|
+
if (typeof row.speaker !== 'string' || !finite(row.elapsed) || row.elapsed < 0) return { status: 'ambiguous', reason: 'invalid G2 timing or label; no pair selected' }
|
|
78
|
+
const exactDuration = row.audioDurationMs ?? row.durationMs
|
|
79
|
+
if (exactDuration !== undefined && (!finite(exactDuration) || exactDuration <= 0 || (row.audioDurationMs !== undefined && row.durationMs !== undefined && row.audioDurationMs !== row.durationMs))) return { status: 'ambiguous', reason: 'invalid G2 duration; no pair selected' }
|
|
80
|
+
// Historical G2 sidecars lack chunk durations: disclose the fixed-window
|
|
81
|
+
// approximation. This function can NEVER qualify alignment or transfer a name.
|
|
82
|
+
const duration = exactDuration === undefined ? 6000 : exactDuration as number
|
|
83
|
+
estimated ||= exactDuration === undefined
|
|
84
|
+
chunks.push({ speaker: row.speaker, elapsed: row.elapsed, audioDurationMs: duration })
|
|
85
|
+
}
|
|
86
|
+
candidates.push({ status: 'time_only', reason: '', sessionId: value.sessionId, sourceSha256: createHash('sha256').update(raw).digest('hex'), g2: { startTime: span[0], chunks } })
|
|
87
|
+
}
|
|
88
|
+
const scope = `Day-window scan: ${names.length} files, ${bytes} bytes, current directory only.`
|
|
89
|
+
return candidates.length === 1 ? { ...candidates[0], reason: `Clock overlap only; text offset/drift alignment has not been qualified. G2 names are estimates.${estimated ? ' G2 chunk durations use an approximate 6-second window where absent.' : ''} ${scope}` }
|
|
90
|
+
: { status: candidates.length ? 'ambiguous' : 'unpaired', reason: `${candidates.length ? `${candidates.length} overlapping G2 candidates; no pair chosen` : 'no complete overlapping G2 capture'}. ${scope}` }
|
|
91
|
+
}
|