@gotcos/glasses-server 6.95.0 → 6.98.0
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 +157 -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 +453 -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 +570 -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
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/** Pure PREPARATION only. Hashes bind supplied bytes; they do not authenticate native
|
|
2
|
+
* execution, reconstructed PCM, model provenance, or a caller's complete export.
|
|
3
|
+
* A future trusted caller must verify those before any durable transaction and
|
|
4
|
+
* run this bounded but potentially expensive full-cohort computation off-thread
|
|
5
|
+
* under a wall-time/memory budget. This is not an HTTP/main-thread entry point. */
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { decide, exactOverlap, nativeTurns, type ShadowEmbeddingRow } from './speaker-shadow-decide.js'
|
|
8
|
+
import { shadowEntries, shadowProductionSegments, type ShadowTimelineRow } from './speaker-shadow-timeline.js'
|
|
9
|
+
import { hasMacSpeakerProtectedRows, isMacSpeakerHumanProtected } from './mac-speaker-protection.js'
|
|
10
|
+
import { hashMacSpeakerDocument, projectMacSpeakerRepair, type MacSpeakerRepairPlan, type MacSpeakerWordBinding } from './mac-speaker-repair-projection.js'
|
|
11
|
+
|
|
12
|
+
type Doc = Record<string, any>
|
|
13
|
+
export interface MacShadowRepairSource {
|
|
14
|
+
originalJSON: string
|
|
15
|
+
currentJSON: string
|
|
16
|
+
ledgerJSONL: string
|
|
17
|
+
rawAudio: Array<{ rawChunkIndex: number; wavSHA256: string; pcmSHA256: string; sampleCount: number }>
|
|
18
|
+
timeline: { rows: ShadowTimelineRow[]; total: number; pcmSHA256: string }
|
|
19
|
+
cohort: { nativeJSON: string; embeddingsJSON: string; modelSHA256: string; cliSHA256: string }
|
|
20
|
+
}
|
|
21
|
+
export interface MacShadowRepairInput extends MacShadowRepairSource { sourceBindingSHA256: string }
|
|
22
|
+
export interface MacShadowRepairPreview {
|
|
23
|
+
plan: MacSpeakerRepairPlan | null
|
|
24
|
+
provenance: { productionQualified: false; nativeAuthenticated: false; sourceBindingSHA256: string; originalSHA256: string; currentSHA256: string; ledgerSHA256: string }
|
|
25
|
+
protectedRawChunkIndices: number[]
|
|
26
|
+
withheld: Array<{ rawChunkIndex: number; reason: string }>
|
|
27
|
+
counts: { raw: number; spoken: number; embeddings: number; acousticAcceptedMerges: number; candidateChunks: number; assignedChunks: number; protectedChunks: number; withheldChunks: number; beforeLabels: number; afterLabels: number; beforeRootLabels: number; afterRootLabels: number }
|
|
28
|
+
}
|
|
29
|
+
const HASH = /^[a-f0-9]{64}$/
|
|
30
|
+
const VOICE = /^Voice (?:[1-9]|[1-9]\d)$/
|
|
31
|
+
const sha = (value: string) => createHash('sha256').update(value).digest('hex')
|
|
32
|
+
const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
33
|
+
const fail = (message: string): never => { throw new Error(`Mac shadow repair: ${message}`) }
|
|
34
|
+
/** Integrity helper, NOT a native execution/qualification certificate. */
|
|
35
|
+
export function bindMacSpeakerShadowRepair(source: MacShadowRepairSource): string { return sha(JSON.stringify(source)) }
|
|
36
|
+
function boundedJSON(text: string, max: number): any {
|
|
37
|
+
if (typeof text !== 'string' || Buffer.byteLength(text) > max) fail('JSON byte bound')
|
|
38
|
+
return JSON.parse(text)
|
|
39
|
+
}
|
|
40
|
+
function closed(doc: Doc, immutableView = false) {
|
|
41
|
+
if (!doc || !Array.isArray(doc.chunks) || !Array.isArray(doc.chunkEntries) || doc.finalizationState !== 'complete' || doc.sourceType !== 'mac_system_audio' || !/^mac_/.test(doc.sessionId) || doc.active || doc.isActive || doc.claimPending || doc.status === 'recording' || doc.mergedFrom || doc.sourceSessionIds || doc.isMerged) fail('closed original Mac capture required')
|
|
42
|
+
// Even a valid prior reader proof binds the complete speech document. A new
|
|
43
|
+
// label patch would invalidate it. This adapter cannot compose those receipts.
|
|
44
|
+
if (!immutableView && ['speakerRepair', 'speakerProfilePass'].some(key => Object.hasOwn(doc, key))) fail('prior repair/profile receipt requires proof composition')
|
|
45
|
+
}
|
|
46
|
+
const ANNOTATION_KEYS = new Set(['speaker', 'originalSpeaker', 'speakerCorrectionBatchId', 'speakerCorrectionId', 'manualSpeaker', 'speakerManuallyAssigned', 'confirmedByHuman', 'manuallyAssigned', 'manuallyCorrected', 'speakerOrigin', 'speakerSource', 'speakerPolicy', 'speakerProvenance', 'source'])
|
|
47
|
+
/** Compare the complete source row recursively, allowing only speaker annotation
|
|
48
|
+
* changes. Annotation differences are handled by protection, never by copying
|
|
49
|
+
* labels back from ORIGINAL. Array presence, text, clocks and diagnostics remain
|
|
50
|
+
* exact on every support/competitor row, not just eventual assignments. */
|
|
51
|
+
function sourceGeometry(value: any): any {
|
|
52
|
+
if (Array.isArray(value)) return value.map(sourceGeometry)
|
|
53
|
+
if (!value || typeof value !== 'object') return value
|
|
54
|
+
return Object.fromEntries(Object.keys(value).filter(key => !ANNOTATION_KEYS.has(key)).sort().map(key => [key, sourceGeometry(value[key])]))
|
|
55
|
+
}
|
|
56
|
+
function rows(chunk: Doc): Doc[] {
|
|
57
|
+
const out = [chunk]
|
|
58
|
+
for (const key of ['segments', 'finalSegments', 'words']) {
|
|
59
|
+
if (chunk[key] !== undefined && !Array.isArray(chunk[key])) fail('malformed label surface')
|
|
60
|
+
for (const row of chunk[key] ?? []) {
|
|
61
|
+
if (!row || typeof row !== 'object') fail('malformed label row')
|
|
62
|
+
if (key !== 'words' || 'speaker' in row) out.push(row)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
function mapping(doc: Doc): Array<{ raw: number; chunk: Doc; entry: Doc }> {
|
|
68
|
+
const entries = shadowEntries(doc)
|
|
69
|
+
if (entries.length > 6000 || entries.some((e, i) => e.chunkIndex !== i)) fail('complete ordered raw map required')
|
|
70
|
+
const spoken = entries.filter(e => e.chunk.text)
|
|
71
|
+
if (spoken.length !== doc.chunks.length) fail('compacted map count')
|
|
72
|
+
return spoken.map((e, i) => {
|
|
73
|
+
const chunk = doc.chunks[i]
|
|
74
|
+
if (!chunk || ['text', 'elapsed', 'audioSha256', 'audioDurationMs'].some(k => !equal(chunk[k], e.chunk[k]))) fail('ambiguous compacted raw join')
|
|
75
|
+
if (typeof chunk.text !== 'string' || !HASH.test(chunk.audioSha256)) fail('unbound spoken source')
|
|
76
|
+
return { raw: e.chunkIndex, chunk, entry: e.chunk }
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
function ledgerProtection(text: string, map: ReturnType<typeof mapping>, original: ReturnType<typeof mapping>): Set<number> {
|
|
80
|
+
if (Buffer.byteLength(text) > 8 * 1024 * 1024) fail('ledger byte bound')
|
|
81
|
+
const history = new Map<string, Doc[]>()
|
|
82
|
+
const replay = original.map(m => m.chunk.speaker)
|
|
83
|
+
const lines = text.split('\n').filter(x => x.trim())
|
|
84
|
+
if (lines.length > 20000) fail('ledger row bound')
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const row = JSON.parse(line)
|
|
87
|
+
if (!row || typeof row.id !== 'string' || !row.id || !['intent', 'applied', 'failed', 'confirmed', 'confirmed-chunks', 'reverted'].includes(row.phase) || !Array.isArray(row.chunks) || row.chunks.some((i: number) => !Number.isInteger(i) || !map[i]) || new Set(row.chunks).size !== row.chunks.length) fail('ambiguous correction ledger')
|
|
88
|
+
const prior = history.get(row.id) ?? []
|
|
89
|
+
if (prior.at(-1)?.phase === 'intent' && !['applied', 'failed'].includes(row.phase)) fail('incomplete correction intent')
|
|
90
|
+
const intended = prior.at(-1)
|
|
91
|
+
const resolvedIntent = intended?.chunks.length ? intended.chunks : replay.flatMap((label, i) => label === intended?.from ? [i] : [])
|
|
92
|
+
// CorrectionRow documents an explicit empty intent as all rows carrying from;
|
|
93
|
+
// applied rows normally carry the resolved list. Missing chunks is not that
|
|
94
|
+
// explicit contract and was rejected above.
|
|
95
|
+
if (row.phase === 'intent' && prior.length || row.phase === 'applied' && (intended?.phase !== 'intent' || row.chunks.length && !equal(resolvedIntent, row.chunks) || intended?.from !== row.from || intended?.to !== row.to) || row.phase === 'failed' && intended?.phase !== 'intent' || row.phase === 'reverted' && !prior.some(r => r.phase === 'applied')) fail('ambiguous correction history')
|
|
96
|
+
if (row.phase === 'applied' && row.chunks.length) {
|
|
97
|
+
if (typeof row.from !== 'string' || typeof row.to !== 'string' || !row.to || row.chunks.some((i: number) => replay[i] !== row.from)) fail('correction history source label mismatch')
|
|
98
|
+
for (const i of row.chunks) replay[i] = row.to
|
|
99
|
+
}
|
|
100
|
+
if (row.phase === 'reverted') {
|
|
101
|
+
const applied = prior.find(e => e.phase === 'applied')!
|
|
102
|
+
if (!applied.chunks.length || applied.chunks.some((i: number) => replay[i] !== applied.to)) fail('ambiguous correction revert')
|
|
103
|
+
for (const i of applied.chunks) replay[i] = applied.from
|
|
104
|
+
}
|
|
105
|
+
history.set(row.id, [...prior, row])
|
|
106
|
+
}
|
|
107
|
+
const protectedRaw = new Set<number>()
|
|
108
|
+
for (const events of history.values()) {
|
|
109
|
+
if (events.at(-1)?.phase === 'intent') fail('incomplete correction intent')
|
|
110
|
+
// Retain protection even for a historical reverted decision. No machine write
|
|
111
|
+
// should silently reinterpret human history. Empty confirmations keep the
|
|
112
|
+
// existing offline CLI's conservative protect-all behavior.
|
|
113
|
+
for (const e of events.filter(e => ['applied', 'confirmed', 'confirmed-chunks'].includes(e.phase))) {
|
|
114
|
+
if (!e.chunks.length) for (const m of map) protectedRaw.add(m.raw)
|
|
115
|
+
else for (const i of e.chunks) protectedRaw.add(map[i].raw)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return protectedRaw
|
|
119
|
+
}
|
|
120
|
+
function batchBindings(original: Doc, current: Doc, entries: ReturnType<typeof shadowEntries>, samples: Record<string, number>, protection: Set<number>) {
|
|
121
|
+
const wordSpans: Array<{ binding: MacSpeakerWordBinding; raw: number[]; protectedRaw: number[]; protected: boolean }> = []
|
|
122
|
+
if (current.batchSegments !== undefined && !Array.isArray(current.batchSegments)) fail('batch schema')
|
|
123
|
+
if (!equal((original.batchSegments ?? []).map((b: Doc) => [b.startChunkIdx, b.endChunkIdx, b.speakerWords?.length]), (current.batchSegments ?? []).map((b: Doc) => [b.startChunkIdx, b.endChunkIdx, b.speakerWords?.length]))) fail('current batch topology drift')
|
|
124
|
+
for (const [si, batch] of (current.batchSegments ?? []).entries()) {
|
|
125
|
+
if (!Number.isInteger(batch.startChunkIdx) || !Number.isInteger(batch.endChunkIdx) || batch.startChunkIdx < 0 || batch.endChunkIdx < batch.startChunkIdx || batch.endChunkIdx >= entries.length || !Array.isArray(batch.speakerWords)) fail('batch raw clock range')
|
|
126
|
+
let offset = 0
|
|
127
|
+
const intervals = []
|
|
128
|
+
for (let raw = batch.startChunkIdx; raw <= batch.endChunkIdx; raw++) { const end = offset + samples[String(raw)] / 16000; intervals.push({ raw, start: offset, end }); offset = end }
|
|
129
|
+
for (const [wi, word] of batch.speakerWords.entries()) {
|
|
130
|
+
if (!word || typeof word.word !== 'string' || !Number.isFinite(word.start) || !Number.isFinite(word.end) || word.start < 0 || word.end < word.start || word.end > offset) fail('batch word clock')
|
|
131
|
+
const raw = intervals.filter(r => word.end === word.start ? word.start >= r.start && word.start < r.end : word.start < r.end && word.end > r.start).map(r => r.raw)
|
|
132
|
+
const oldWord = original.batchSegments[si].speakerWords[wi], changedWord = !equal(oldWord, word)
|
|
133
|
+
// Preserve even an anonymous, unmarked edit. An altered word clock protects
|
|
134
|
+
// both its original and current source intersections; never normalize it.
|
|
135
|
+
const priorRaw = changedWord ? intervals.filter(r => oldWord?.end === oldWord?.start ? oldWord.start >= r.start && oldWord.start < r.end : oldWord?.start < r.end && oldWord?.end > r.start).map(r => r.raw) : []
|
|
136
|
+
if (changedWord) for (const r of priorRaw) protection.add(r)
|
|
137
|
+
wordSpans.push({ binding: { segmentIndex: si, wordIndex: wi, word: word.word, start: word.start, end: word.end, from: word.speaker }, raw, protectedRaw: [...new Set([...raw, ...priorRaw])], protected: changedWord || isMacSpeakerHumanProtected(word) || !/^(?:Voice [1-9]\d?|Ext|Unknown|Unidentified(?: \d+)?|Speaker \d+)$/.test(word.speaker) })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const word of wordSpans) if (word.protected) for (const raw of word.protectedRaw) protection.add(raw)
|
|
141
|
+
return wordSpans
|
|
142
|
+
}
|
|
143
|
+
/** No remap/proposal input: recompute unchanged floor15/.8/1.5 decision from the
|
|
144
|
+
* entire bound export and production segments reconstructed from ORIGINAL rows. */
|
|
145
|
+
function inspectSource(input: MacShadowRepairInput, immutableView: boolean) {
|
|
146
|
+
if (Object.keys(input).some(k => !['originalJSON', 'currentJSON', 'ledgerJSONL', 'rawAudio', 'timeline', 'cohort', 'sourceBindingSHA256'].includes(k))) fail('unsupported proposal/remap input')
|
|
147
|
+
for (const [value, maximum] of [[input.originalJSON, 32], [input.currentJSON, 32], [input.ledgerJSONL, 8], [input.cohort?.nativeJSON, 64], [input.cohort?.embeddingsJSON, 96]] as const) if (typeof value !== 'string' || Buffer.byteLength(value) > maximum * 1024 * 1024) fail('input byte bound')
|
|
148
|
+
if (!Array.isArray(input.rawAudio) || input.rawAudio.length > 6000 || !Array.isArray(input.timeline?.rows) || input.timeline.rows.length > 6000) fail('raw/timeline schema bound')
|
|
149
|
+
const { sourceBindingSHA256, ...source } = input
|
|
150
|
+
if (!HASH.test(sourceBindingSHA256) || bindMacSpeakerShadowRepair(source) !== sourceBindingSHA256) fail('full source binding drift')
|
|
151
|
+
const original = boundedJSON(input.originalJSON, 32 * 1024 * 1024), current = boundedJSON(input.currentJSON, 32 * 1024 * 1024)
|
|
152
|
+
closed(original, immutableView); closed(current, immutableView)
|
|
153
|
+
if (original.sessionId !== current.sessionId) fail('capture mismatch')
|
|
154
|
+
const oldMap = mapping(original), map = mapping(current)
|
|
155
|
+
if (oldMap.some(m => !equal(sourceGeometry(m.chunk), sourceGeometry(m.entry)))) fail('original root/raw source geometry disagreement')
|
|
156
|
+
if (['startTime', 'durationMs'].some(key => !equal(original[key], current[key]))) fail('current capture clock drift')
|
|
157
|
+
if (oldMap.length !== map.length || oldMap.some((m, i) => m.raw !== map[i].raw || ['text', 'elapsed', 'audioSha256', 'audioDurationMs'].some(k => !equal(m.chunk[k], map[i].chunk[k])))) fail('current speech source drift')
|
|
158
|
+
if (oldMap.some((m, i) => !equal(sourceGeometry(m.chunk), sourceGeometry(map[i].chunk)))) fail('current compacted source geometry drift')
|
|
159
|
+
const entries = shadowEntries(original), currentEntries = shadowEntries(current)
|
|
160
|
+
if (entries.length !== currentEntries.length || entries.some((e, i) => ['text', 'elapsed', 'audioSha256', 'audioDurationMs'].some(k => !equal(e.chunk[k], currentEntries[i].chunk[k])))) fail('current raw source drift')
|
|
161
|
+
for (const entry of [...entries, ...currentEntries]) rows(entry.chunk)
|
|
162
|
+
if (entries.some((e, i) => !equal(sourceGeometry(e.chunk), sourceGeometry(currentEntries[i].chunk)))) fail('current raw source geometry drift')
|
|
163
|
+
if (!Array.isArray(input.rawAudio) || input.rawAudio.length !== entries.length || !input.rawAudio.length) fail('all raw WAV bindings required')
|
|
164
|
+
const samples: Record<string, number> = {}
|
|
165
|
+
const placed = input.rawAudio.map((raw, i) => {
|
|
166
|
+
const chunk = entries[i].chunk
|
|
167
|
+
if (raw.rawChunkIndex !== i || !HASH.test(raw.wavSHA256) || !HASH.test(raw.pcmSHA256) || chunk.audioSha256 && raw.wavSHA256 !== chunk.audioSha256 || !Number.isSafeInteger(raw.sampleCount) || raw.sampleCount <= 0 || raw.sampleCount > 480000 || !Number.isFinite(chunk.elapsed) || (chunk.elapsed as number) < 0) fail('raw WAV source binding')
|
|
168
|
+
samples[String(i)] = raw.sampleCount
|
|
169
|
+
return { rawChunkIndex: i, startSample: Math.floor((chunk.elapsed as number) * 16 + .5), sampleCount: raw.sampleCount, usedSampleCount: 0 }
|
|
170
|
+
}).sort((a, b) => a.startSample - b.startSample || a.rawChunkIndex - b.rawChunkIndex)
|
|
171
|
+
for (let i = 0; i < placed.length; i++) {
|
|
172
|
+
const row = placed[i]
|
|
173
|
+
row.usedSampleCount = i + 1 < placed.length ? Math.min(row.sampleCount, placed[i + 1].startSample - row.startSample) : row.sampleCount
|
|
174
|
+
if (row.usedSampleCount <= 0) fail('shadowed timeline row')
|
|
175
|
+
}
|
|
176
|
+
const total = Math.max(...placed.map(r => r.startSample + r.sampleCount))
|
|
177
|
+
if (total > 4 * 3600 * 16000 || !equal(placed, input.timeline.rows) || total !== input.timeline.total || !HASH.test(input.timeline.pcmSHA256)) fail('timeline geometry drift')
|
|
178
|
+
if (!HASH.test(input.cohort.modelSHA256) || !HASH.test(input.cohort.cliSHA256)) fail('model/CLI binding required')
|
|
179
|
+
// Validate every export row, including short confusers that decide will reject.
|
|
180
|
+
const native = boundedJSON(input.cohort.nativeJSON, 64 * 1024 * 1024), embeddings = boundedJSON(input.cohort.embeddingsJSON, 96 * 1024 * 1024)
|
|
181
|
+
if (!native || !Array.isArray(native.segments) || native.segments.length > 20000 || !Array.isArray(embeddings) || embeddings.length > 10000) fail('cohort schema bound')
|
|
182
|
+
const vector = (v: unknown) => Array.isArray(v) && v.length === 256 && v.every(x => typeof x === 'number' && Number.isFinite(x) && Math.abs(x) <= 1e10) && v.some(x => x !== 0)
|
|
183
|
+
for (const s of native.segments) {
|
|
184
|
+
if (!s || typeof s.speakerId !== 'string' || !s.speakerId || s.speakerId.length > 100 || !Number.isFinite(s.startTimeSeconds) || !Number.isFinite(s.endTimeSeconds) || s.startTimeSeconds < 0 || s.endTimeSeconds <= s.startTimeSeconds || s.endTimeSeconds > total / 16000 + 1) fail('native turn bound')
|
|
185
|
+
if (s.embedding !== undefined && !vector(s.embedding)) fail('native vector must be finite nonzero256d')
|
|
186
|
+
}
|
|
187
|
+
const excludedEmbeddingWindows: Array<{ originalIndex: number; startTime: number; endTime: number; windowSHA256: string; reason: 'window-crosses-capture-end' }> = []
|
|
188
|
+
const effectiveEmbeddings: ShadowEmbeddingRow[] = []
|
|
189
|
+
for (const [originalIndex, e] of embeddings.entries()) {
|
|
190
|
+
if (!e || !vector(e.embedding256) || !Number.isInteger(e.cluster) || e.cluster < 0 || e.cluster > 9999 || !Number.isFinite(e.startTime) || !Number.isFinite(e.endTime) || e.startTime < 0 || e.endTime <= e.startTime) fail('embedding must be bounded finite nonzero256d')
|
|
191
|
+
if (e.endTime > total / 16000 + 1e-6) {
|
|
192
|
+
// A vector covering source AND terminal model padding is not a vector for
|
|
193
|
+
// a cropped source window. Never clip its time or reuse its vector. The
|
|
194
|
+
// immutable-view protocol can exclude the ENTIRE terminal window while
|
|
195
|
+
// retaining the complete export in its source binding and audit receipt.
|
|
196
|
+
// The physical writer remains strict. Windows outside the capture or an
|
|
197
|
+
// overrun beyond the native export's one-second envelope remain fatal.
|
|
198
|
+
if (!immutableView || e.startTime >= total / 16000 || e.endTime > total / 16000 + 1) fail('embedding must be bounded finite nonzero256d')
|
|
199
|
+
excludedEmbeddingWindows.push({ originalIndex, startTime: e.startTime, endTime: e.endTime, windowSHA256: sha(JSON.stringify(e)), reason: 'window-crosses-capture-end' })
|
|
200
|
+
} else effectiveEmbeddings.push(e)
|
|
201
|
+
}
|
|
202
|
+
const { turns, dropped } = nativeTurns(native.segments, total / 16000)
|
|
203
|
+
if (dropped) fail('malformed/dropped native turn')
|
|
204
|
+
const ledgerRawProtection = ledgerProtection(input.ledgerJSONL, map, oldMap)
|
|
205
|
+
const currentRoots = new Map(map.map(m => [m.raw, m.chunk]))
|
|
206
|
+
// Historical ASR occasionally extends a generic Ext segment past its own WAV.
|
|
207
|
+
// An immutable view can quarantine that WHOLE row, never clip it or synthesize
|
|
208
|
+
// replacement support. Keep every native/confuser row and bind the unmodified
|
|
209
|
+
// original JSON; physical writers and malformed Voice/human rows still refuse.
|
|
210
|
+
const excludedProductionSegments: Array<{ rawChunkIndex: number; segmentIndex: number; rowSHA256: string; startSec: number; endSec: number; sourceSamples: number; overrunSamples: number; reason: 'generic-segment-crosses-source-end' }> = []
|
|
211
|
+
const productionEntries = entries.map(e => ({ ...e, chunk: { ...e.chunk, segments: ((e.chunk.segments as Doc[] | undefined) ?? []).filter((s, segmentIndex) => {
|
|
212
|
+
if (!s || typeof s.speaker !== 'string' || !Number.isFinite(s.startSec) || !Number.isFinite(s.endSec) || s.startSec < 0 || s.endSec <= s.startSec) fail('production segment coordinates')
|
|
213
|
+
const sourceSamples = samples[String(e.chunkIndex)], overrunSamples = s.endSec * 16000 - sourceSamples
|
|
214
|
+
if (overrunSamples <= 1e-6) return true
|
|
215
|
+
const currentRow = currentEntries[e.chunkIndex].chunk
|
|
216
|
+
const currentRoot = currentRoots.get(e.chunkIndex)
|
|
217
|
+
if (!immutableView || s.speaker !== 'Ext' || e.chunk.speaker !== 'Ext' || currentRow.speaker !== 'Ext'
|
|
218
|
+
|| currentRoot && currentRoot.speaker !== 'Ext' || s.startSec * 16000 >= sourceSamples || overrunSamples > 16000
|
|
219
|
+
|| ledgerRawProtection.has(e.chunkIndex)
|
|
220
|
+
|| [e.chunk, currentRow, ...(currentRoot ? [currentRoot] : [])].some(c => hasMacSpeakerProtectedRows(c) || !equal(rows(c), rows(e.chunk)) && c !== e.chunk)) fail('production segment coordinates')
|
|
221
|
+
excludedProductionSegments.push({ rawChunkIndex: e.chunkIndex, segmentIndex, rowSHA256: sha(JSON.stringify(s)), startSec: s.startSec, endSec: s.endSec, sourceSamples, overrunSamples, reason: 'generic-segment-crosses-source-end' })
|
|
222
|
+
return false
|
|
223
|
+
}) } }))
|
|
224
|
+
const production = shadowProductionSegments(productionEntries, placed, total)
|
|
225
|
+
if (production.segs.length > 40000) fail('production segment bound')
|
|
226
|
+
const decision = decide(production.segs, total, turns, effectiveEmbeddings)
|
|
227
|
+
const protection = new Set(ledgerRawProtection)
|
|
228
|
+
for (const row of excludedProductionSegments) protection.add(row.rawChunkIndex)
|
|
229
|
+
for (const m of map) if ([m.chunk, m.entry].some(c => hasMacSpeakerProtectedRows(c) || rows(c).some(r => !VOICE.test(r.speaker)))) protection.add(m.raw)
|
|
230
|
+
const wordSpans = batchBindings(original, current, entries, samples, protection)
|
|
231
|
+
// Anonymous estimate aliases inherit the original machine localization; they
|
|
232
|
+
// must not confuse a structural Ext/Voice boundary with a human correction.
|
|
233
|
+
// Keep this separate from the stronger interval-projection exclusion above.
|
|
234
|
+
// No tail or mixed boundary admitted here becomes new acoustic support.
|
|
235
|
+
const humanProtection = new Set(ledgerRawProtection)
|
|
236
|
+
for (const row of excludedProductionSegments) humanProtection.add(row.rawChunkIndex)
|
|
237
|
+
const named = (row: Doc) => typeof row.speaker === 'string' && row.speaker.trim() !== ''
|
|
238
|
+
&& !/^(?:Voice [1-9]\d?|Ext|Unknown|Unidentified(?: \d+)?|Speaker \d+)$/.test(row.speaker)
|
|
239
|
+
for (const [i, m] of map.entries()) {
|
|
240
|
+
const before = oldMap[i]
|
|
241
|
+
if (!equal(rows(before.chunk), rows(m.chunk)) || !equal(rows(before.entry), rows(m.entry))
|
|
242
|
+
|| [m.chunk, m.entry].some(c => hasMacSpeakerProtectedRows(c) || rows(c).some(named))) humanProtection.add(m.raw)
|
|
243
|
+
}
|
|
244
|
+
for (const word of wordSpans) if (word.protected) for (const raw of word.protectedRaw) humanProtection.add(raw)
|
|
245
|
+
// A view keeps all base bytes and previous proof receipts intact. It may use
|
|
246
|
+
// this inspection, but it is not permission to feed an old receipt into the
|
|
247
|
+
// physical-patch writer below.
|
|
248
|
+
return { original, current, oldMap, map, entries, currentEntries, samples, placed, total, native, embeddings: effectiveEmbeddings, excludedEmbeddingWindows, excludedProductionSegments, turns, production, decision, protection, humanProtection, wordSpans }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Complete immutable-source inspection for the call-wide derived view. This
|
|
252
|
+
* accepts existing proof metadata because consumers MUST validate it against
|
|
253
|
+
* the unmodified base, and MUST NOT patch that base. No native execution is
|
|
254
|
+
* authenticated by a caller-supplied hash. */
|
|
255
|
+
export function inspectMacSpeakerShadowCallSource(input: MacShadowRepairInput) {
|
|
256
|
+
return inspectSource(input, true)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function buildMacSpeakerShadowRepair(input: MacShadowRepairInput): MacShadowRepairPreview {
|
|
260
|
+
const { sourceBindingSHA256 } = input
|
|
261
|
+
const { original, current, oldMap, map, entries, currentEntries, samples, placed, total, native, embeddings, turns, production, decision, protection, wordSpans } = inspectSource(input, false)
|
|
262
|
+
const withheld = new Map<number, string>(), chosen = new Map<number, { from: string; to: string }>()
|
|
263
|
+
let candidateChunks = 0
|
|
264
|
+
for (let i = 0; i < oldMap.length; i++) {
|
|
265
|
+
const before = oldMap[i], now = map[i], to = decision.remap.get(before.chunk.speaker)
|
|
266
|
+
if (!to) { withheld.set(now.raw, VOICE.test(before.chunk.speaker) ? 'no-qualified-merge' : 'non-Voice'); continue }
|
|
267
|
+
candidateChunks++
|
|
268
|
+
if (!VOICE.test(to) || !VOICE.test(before.chunk.speaker)) { withheld.set(now.raw, 'non-Voice'); continue }
|
|
269
|
+
if (protection.has(now.raw)) { withheld.set(now.raw, 'human-or-named-protected'); continue }
|
|
270
|
+
if (!equal(rows(before.chunk), rows(now.chunk)) || !equal(rows(now.chunk), rows(now.entry)) || rows(now.chunk).some(r => r.speaker !== before.chunk.speaker)) { withheld.set(now.raw, 'mixed-or-current-surfaces-differ'); continue }
|
|
271
|
+
const row = placed.find(r => r.rawChunkIndex === now.raw)!, segs = production.segs.filter((_, j) => production.sources[j].chunkIndex === now.raw)
|
|
272
|
+
const cluster = decision.rows.get(before.chunk.speaker)?.topCluster
|
|
273
|
+
if (row.usedSampleCount !== row.sampleCount || !segs.length || !cluster || segs.some(([lo, hi, label]) => label !== before.chunk.speaker || [...exactOverlap(lo, hi, decision.index)].some(([key]) => key !== cluster))) { withheld.set(now.raw, 'outside-fully-qualified-chunk'); continue }
|
|
274
|
+
chosen.set(now.raw, { from: now.chunk.speaker, to })
|
|
275
|
+
}
|
|
276
|
+
for (const raw of chosen.keys()) if (protection.has(raw)) { chosen.delete(raw); withheld.set(raw, 'batch-protected-or-crosses-unqualified-boundary') }
|
|
277
|
+
// Remove entire chunks to a fixed point; never write a partly unqualified word.
|
|
278
|
+
let changed = true
|
|
279
|
+
while (changed) {
|
|
280
|
+
changed = false
|
|
281
|
+
for (const word of wordSpans) {
|
|
282
|
+
const touched = word.raw.filter(r => chosen.has(r))
|
|
283
|
+
if (touched.length && (word.protected || word.raw.some(r => !chosen.has(r)) || new Set(touched.map(r => chosen.get(r)!.to)).size !== 1)) for (const raw of touched) { chosen.delete(raw); withheld.set(raw, 'batch-protected-or-crosses-unqualified-boundary'); changed = true }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const protectedRawChunkIndices = [...protection].sort((a, b) => a - b)
|
|
287
|
+
const assignments = [...chosen].map(([rawChunkIndex, pair]) => ({ rawChunkIndex, ...pair, evidenceId: `shadow256_${sourceBindingSHA256}`, batchWords: wordSpans.filter(w => w.raw[0] === rawChunkIndex).map(w => w.binding) }))
|
|
288
|
+
let plan: MacSpeakerRepairPlan | null = assignments.length ? { id: `shadow256_${sourceBindingSHA256.slice(0, 24)}`, evidenceHash: sourceBindingSHA256, expectedDocumentHash: hashMacSpeakerDocument(current), protectedRawChunkIndices, rawAudioSampleCounts: samples, assignments } : null
|
|
289
|
+
let after = current
|
|
290
|
+
if (plan) {
|
|
291
|
+
try { after = projectMacSpeakerRepair(current, plan).doc }
|
|
292
|
+
catch { for (const a of assignments) withheld.set(a.rawChunkIndex, 'strict-projector-refused'); plan = null }
|
|
293
|
+
}
|
|
294
|
+
const rootLabels = (d: Doc) => new Set(d.chunks.map((c: Doc) => c.speaker)).size
|
|
295
|
+
const labels = (d: Doc) => new Set([
|
|
296
|
+
...d.chunks.flatMap((c: Doc) => rows(c).map(r => r.speaker)),
|
|
297
|
+
...d.chunkEntries.flatMap((e: Doc) => rows(e.chunk).map(r => r.speaker)),
|
|
298
|
+
...(d.batchSegments ?? []).flatMap((b: Doc) => b.speakerWords.map((w: Doc) => w.speaker)),
|
|
299
|
+
].filter(v => typeof v === 'string' && v)).size
|
|
300
|
+
// An acoustic proposal alone is not the requested fragmentation repair. The
|
|
301
|
+
// CURRENT root display must improve without increasing any stored label set.
|
|
302
|
+
if (plan && (rootLabels(after) >= rootLabels(current) || labels(after) > labels(current))) {
|
|
303
|
+
for (const assignment of plan.assignments) withheld.set(assignment.rawChunkIndex, 'non-reducing-plan')
|
|
304
|
+
plan = null; after = current
|
|
305
|
+
}
|
|
306
|
+
return { plan, provenance: { productionQualified: false, nativeAuthenticated: false, sourceBindingSHA256, originalSHA256: sha(input.originalJSON), currentSHA256: sha(input.currentJSON), ledgerSHA256: sha(input.ledgerJSONL) }, protectedRawChunkIndices, withheld: [...withheld].map(([rawChunkIndex, reason]) => ({ rawChunkIndex, reason })), counts: { raw: entries.length, spoken: map.length, embeddings: embeddings.length, acousticAcceptedMerges: decision.accepted, candidateChunks, assignedChunks: plan?.assignments.length ?? 0, protectedChunks: protection.size, withheldChunks: withheld.size, beforeLabels: labels(current), afterLabels: labels(after), beforeRootLabels: rootLabels(current), afterRootLabels: rootLabels(after) } }
|
|
307
|
+
}
|
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
greedyDiversitySelect,
|
|
78
78
|
} from './voice-enrolment-selection.js'
|
|
79
79
|
import { VOICEPRINT_MIN_AUDIO_SEC } from './voiceprint-floor.js'
|
|
80
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
80
81
|
|
|
81
82
|
/** A turn shorter than this is not sliced out of a mixed chunk. */
|
|
82
83
|
export const MAC_ASSIGN_MIN_SLICE_SEC = 3.0
|
|
@@ -196,24 +197,55 @@ function emptyReport(person: string, skipped: MacAssignSkip): MacAssignReport {
|
|
|
196
197
|
}
|
|
197
198
|
}
|
|
198
199
|
|
|
199
|
-
interface
|
|
200
|
-
interface
|
|
200
|
+
export interface MacVoiceTurn { speaker: string; startSec?: number; endSec?: number }
|
|
201
|
+
export interface MacVoiceSlice { startSec: number; endSec: number }
|
|
202
|
+
interface Unit { raw: number; slice: MacVoiceSlice | null }
|
|
201
203
|
|
|
202
|
-
function
|
|
204
|
+
export function macVoiceTurnsOf(chunk: Record<string, unknown> | undefined): MacVoiceTurn[] {
|
|
203
205
|
if (!chunk) return []
|
|
204
206
|
const final = chunk.finalSegments
|
|
205
207
|
const live = chunk.segments
|
|
206
208
|
const list = Array.isArray(final) && final.length > 0 ? final : Array.isArray(live) ? live : []
|
|
207
|
-
return list.filter((t): t is
|
|
209
|
+
return list.filter((t): t is MacVoiceTurn => !!t && typeof t === 'object' && typeof (t as MacVoiceTurn).speaker === 'string')
|
|
208
210
|
}
|
|
209
211
|
|
|
210
|
-
|
|
212
|
+
/** At most `max` items, evenly spread across the list. */
|
|
213
|
+
export function spreadMacVoiceUnits<T>(items: T[], max: number): T[] {
|
|
211
214
|
if (items.length <= max) return items
|
|
212
215
|
const out: T[] = []
|
|
213
216
|
for (let k = 0; k < max; k++) out.push(items[Math.floor((k * items.length) / max)])
|
|
214
217
|
return out
|
|
215
218
|
}
|
|
216
219
|
|
|
220
|
+
/**
|
|
221
|
+
* What one chunk contributes for a voice: `whole` when every turn is that voice or the chunk has no turns,
|
|
222
|
+
* otherwise that voice's turns long enough to slice, inset at each end. Empty means nothing here is usable.
|
|
223
|
+
* Voice suggestions (mac-voice-suggestions.ts) use the same per-chunk rule. They do not pick the same units
|
|
224
|
+
* overall: this plan spreads to the cap before the 2 s filter, suggestions spread after it, as the study did.
|
|
225
|
+
*/
|
|
226
|
+
export function macVoiceChunkUnits(turns: MacVoiceTurn[], voice: string): 'whole' | MacVoiceSlice[] {
|
|
227
|
+
if (turns.length === 0 || turns.every(t => t.speaker === voice)) return 'whole'
|
|
228
|
+
return turns
|
|
229
|
+
.filter(t => t.speaker === voice
|
|
230
|
+
&& Number.isFinite(t.startSec) && Number.isFinite(t.endSec)
|
|
231
|
+
&& (t.endSec as number) - (t.startSec as number) >= MAC_ASSIGN_MIN_SLICE_SEC)
|
|
232
|
+
.map(t => ({ startSec: (t.startSec as number) + MAC_ASSIGN_SLICE_INSET_SEC, endSec: (t.endSec as number) - MAC_ASSIGN_SLICE_INSET_SEC }))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The audio a unit addresses and its length. Null when the WAV is unusable or the slice is empty. */
|
|
236
|
+
export function macVoiceUnitAudio(wav: Buffer, slice: MacVoiceSlice | null): { audio: Buffer; sec: number } | null {
|
|
237
|
+
const pcm = readPcm16Mono(wav)
|
|
238
|
+
if (!pcm || pcm.sampleRate !== SAMPLE_RATE) return null
|
|
239
|
+
let audio = wav
|
|
240
|
+
if (slice) {
|
|
241
|
+
const from16 = Math.max(0, Math.round(slice.startSec * SAMPLE_RATE)) * 2
|
|
242
|
+
const to16 = Math.min(pcm.pcm.length, Math.round(slice.endSec * SAMPLE_RATE) * 2)
|
|
243
|
+
if (to16 <= from16) return null
|
|
244
|
+
audio = pcm16ToWav(pcm.pcm.subarray(from16, to16))
|
|
245
|
+
}
|
|
246
|
+
return { audio, sec: (audio.length - 44) / (SAMPLE_RATE * 2) }
|
|
247
|
+
}
|
|
248
|
+
|
|
217
249
|
function centroid(vectors: Float32Array[]): Float32Array | null {
|
|
218
250
|
if (vectors.length === 0) return null
|
|
219
251
|
const out = new Float32Array(vectors[0].length)
|
|
@@ -239,7 +271,7 @@ export async function planMacVoiceAssignment(input: MacAssignInput): Promise<Mac
|
|
|
239
271
|
if (isPlaceholderLabel(to)) return none('unknown_profile')
|
|
240
272
|
if (input.ownerLabel && sameName(to, input.ownerLabel)) return none('owner_profile')
|
|
241
273
|
let profile: { name: string; embeddings: number[][]; sources?: string[] } | undefined
|
|
242
|
-
try { profile = readVoiceProfiles().profiles.find(p => p.name
|
|
274
|
+
try { profile = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, to)) } catch { return none('store_unavailable') }
|
|
243
275
|
if (!profile) return none('unknown_profile')
|
|
244
276
|
if (!chunkEmbeddingsEnabled()) return none('disabled')
|
|
245
277
|
if (!isEmbeddingAvailable()) return none('store_unavailable')
|
|
@@ -261,22 +293,16 @@ export async function planMacVoiceAssignment(input: MacAssignInput): Promise<Mac
|
|
|
261
293
|
const report = emptyReport(to, null)
|
|
262
294
|
const units: Unit[] = []
|
|
263
295
|
for (const raw of [...new Set(rawIndices)].sort((a, b) => a - b)) {
|
|
264
|
-
const
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
&& (t.endSec as number) - (t.startSec as number) >= MAC_ASSIGN_MIN_SLICE_SEC)
|
|
269
|
-
if (usable.length === 0) { report.multiVoiceSkipped += 1; continue }
|
|
270
|
-
for (const t of usable) units.push({
|
|
271
|
-
raw,
|
|
272
|
-
slice: { startSec: (t.startSec as number) + MAC_ASSIGN_SLICE_INSET_SEC, endSec: (t.endSec as number) - MAC_ASSIGN_SLICE_INSET_SEC },
|
|
273
|
-
})
|
|
296
|
+
const chunkUnits = macVoiceChunkUnits(macVoiceTurnsOf(chunksByRaw.get(raw)), from)
|
|
297
|
+
if (chunkUnits === 'whole') { units.push({ raw, slice: null }); continue }
|
|
298
|
+
if (chunkUnits.length === 0) { report.multiVoiceSkipped += 1; continue }
|
|
299
|
+
for (const slice of chunkUnits) units.push({ raw, slice })
|
|
274
300
|
}
|
|
275
301
|
|
|
276
302
|
const embeddings: Float32Array[] = []
|
|
277
303
|
const secs: number[] = []
|
|
278
304
|
let foundAudio = 0
|
|
279
|
-
const picked =
|
|
305
|
+
const picked = spreadMacVoiceUnits(units, MAC_ASSIGN_MAX_UNITS)
|
|
280
306
|
stats.units = picked.length
|
|
281
307
|
const deadline = started + (input.deadlineMs ?? deadlineMsFromEnv())
|
|
282
308
|
for (const unit of picked) {
|
|
@@ -302,16 +328,9 @@ export async function planMacVoiceAssignment(input: MacAssignInput): Promise<Mac
|
|
|
302
328
|
if (hit) { stats.reused += 1; embeddings.push(hit.vec); secs.push(hit.sec); continue }
|
|
303
329
|
let wav: Buffer
|
|
304
330
|
try { wav = readFileSync(path) } catch { report.missingAudio += 1; continue }
|
|
305
|
-
const
|
|
306
|
-
if (!
|
|
307
|
-
|
|
308
|
-
if (unit.slice) {
|
|
309
|
-
const from16 = Math.max(0, Math.round(unit.slice.startSec * SAMPLE_RATE)) * 2
|
|
310
|
-
const to16 = Math.min(pcm.pcm.length, Math.round(unit.slice.endSec * SAMPLE_RATE) * 2)
|
|
311
|
-
if (to16 <= from16) continue
|
|
312
|
-
audio = pcm16ToWav(pcm.pcm.subarray(from16, to16))
|
|
313
|
-
}
|
|
314
|
-
const sec = (audio.length - 44) / (SAMPLE_RATE * 2)
|
|
331
|
+
const cut = macVoiceUnitAudio(wav, unit.slice)
|
|
332
|
+
if (!cut) continue
|
|
333
|
+
const { audio, sec } = cut
|
|
315
334
|
if (sec < VOICEPRINT_MIN_AUDIO_SEC) { report.shortSkipped += 1; continue }
|
|
316
335
|
const embedding = extractEmbedding(audio)
|
|
317
336
|
stats.embedded += 1
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// 6.96.3: how a saved Mac recording's anonymous voices are DISPLAYED in speaker review.
|
|
2
|
+
//
|
|
3
|
+
// WHY. One real call returned 61 rows from GET /meeting/:sessionId/speakers: 58 `Voice N`, Ext and two
|
|
4
|
+
// names. Speaker shadow already logged, for that call, which of those voices its gates would merge
|
|
5
|
+
// (speaker-shadow.ts, proposals.accepted). Miles approved showing that at read time: merged groups
|
|
6
|
+
// first, then one expandable row for voices under 15 s after merging. Display only.
|
|
7
|
+
//
|
|
8
|
+
// WHAT THIS IS NOT. It writes nothing and applies nothing: no label, name, sample, packet or sidecar
|
|
9
|
+
// changes, and the words stay one click away under the same labels. There is no flag. The field exists
|
|
10
|
+
// only when this Mac has a `proposed` shadow record for this session, so a Mac with shadow off answers
|
|
11
|
+
// exactly what it answered before.
|
|
12
|
+
//
|
|
13
|
+
// THE RESPONSE WINS OVER THE RECORD. The record is a log from when the meeting finished; the labels in
|
|
14
|
+
// the current response are the truth. A voice named since then leaves its group, and a current voice the
|
|
15
|
+
// record never saw still gets a row. Every anonymous label in the response lands in exactly one bucket.
|
|
16
|
+
//
|
|
17
|
+
// FAILS TO ABSENT. Any malformed record, unreadable store or unexpected value omits the field, which
|
|
18
|
+
// means Control shows what it shows today. Nothing here throws.
|
|
19
|
+
|
|
20
|
+
import { readdir, stat } from 'node:fs/promises'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { isVoiceLabel } from './mac-voice-labels.js'
|
|
23
|
+
import { readSpeakerShadowRecords, SPEAKER_SHADOW_RECORDS } from './speaker-shadow.js'
|
|
24
|
+
import { speakerShadowRoot } from './speaker-shadow-models.js'
|
|
25
|
+
|
|
26
|
+
/** A voice with less known speech than this, after merging, folds into the one expandable row. */
|
|
27
|
+
export const VOICE_DISPLAY_FOLD_UNDER_SECONDS = 15 as const
|
|
28
|
+
/** Past this the store is not read at all. At 12 runs a day it is about two years of records. */
|
|
29
|
+
const MAX_STORE_BYTES = 256 * 1024 * 1024
|
|
30
|
+
const MAX_RECORDS_PER_SESSION = 100
|
|
31
|
+
const EVIDENCE_CACHE_MAX = 32
|
|
32
|
+
|
|
33
|
+
export interface VoiceDisplay {
|
|
34
|
+
source: 'speaker-shadow'
|
|
35
|
+
recordedAt: string
|
|
36
|
+
foldUnderSeconds: typeof VOICE_DISPLAY_FOLD_UNDER_SECONDS
|
|
37
|
+
/** Two or more labels, largest first. */
|
|
38
|
+
groups: Array<{ id: string; labels: string[]; seconds: number | null }>
|
|
39
|
+
/** 15 s or more, or unknown duration. */
|
|
40
|
+
singles: Array<{ label: string; seconds: number | null }>
|
|
41
|
+
/** Known duration under 15 s after merging. */
|
|
42
|
+
short: Array<{ label: string; seconds: number }>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The parts of one shadow record display needs. Validated once, so building a view cannot fail. */
|
|
46
|
+
export interface VoiceDisplayEvidence {
|
|
47
|
+
recordedAt: string
|
|
48
|
+
groups: Array<{ id: string; labels: string[] }>
|
|
49
|
+
seconds: ReadonlyMap<string, number>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value)
|
|
53
|
+
const validSeconds = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
54
|
+
|
|
55
|
+
/** Null on any problem at all. A label may appear once across the whole record. */
|
|
56
|
+
export function voiceDisplayEvidence(record: unknown): VoiceDisplayEvidence | null {
|
|
57
|
+
if (!isRecord(record) || record.status !== 'proposed' || typeof record.recordedAt !== 'string' || !record.recordedAt) return null
|
|
58
|
+
const proposals = record.proposals
|
|
59
|
+
if (!isRecord(proposals)) return null
|
|
60
|
+
const { accepted, rejected, untouched } = proposals
|
|
61
|
+
if (!Array.isArray(accepted) || !Array.isArray(rejected) || !Array.isArray(untouched)) return null
|
|
62
|
+
|
|
63
|
+
const seconds = new Map<string, number>()
|
|
64
|
+
const seen = new Set<string>()
|
|
65
|
+
const claim = (label: unknown): label is string => {
|
|
66
|
+
if (typeof label !== 'string' || seen.has(label)) return false
|
|
67
|
+
seen.add(label)
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
const groups: VoiceDisplayEvidence['groups'] = []
|
|
71
|
+
const ids = new Set<string>()
|
|
72
|
+
for (const group of accepted) {
|
|
73
|
+
if (!isRecord(group) || typeof group.cluster !== 'string' || !group.cluster || ids.has(group.cluster)) return null
|
|
74
|
+
ids.add(group.cluster)
|
|
75
|
+
if (!Array.isArray(group.merge) || !Array.isArray(group.voices)) return null
|
|
76
|
+
const labels: string[] = []
|
|
77
|
+
for (const label of [group.keepLabel, ...group.merge]) {
|
|
78
|
+
if (!claim(label)) return null
|
|
79
|
+
labels.push(label)
|
|
80
|
+
}
|
|
81
|
+
for (const row of group.voices) {
|
|
82
|
+
if (!isRecord(row) || typeof row.voice !== 'string' || !labels.includes(row.voice) || !validSeconds(row.seconds)) return null
|
|
83
|
+
seconds.set(row.voice, row.seconds)
|
|
84
|
+
}
|
|
85
|
+
groups.push({ id: `shadow-${group.cluster}`, labels })
|
|
86
|
+
}
|
|
87
|
+
for (const row of [...rejected, ...untouched]) {
|
|
88
|
+
if (!isRecord(row) || !claim(row.voice) || !validSeconds(row.seconds)) return null
|
|
89
|
+
seconds.set(row.voice, row.seconds)
|
|
90
|
+
}
|
|
91
|
+
return { recordedAt: record.recordedAt, groups, seconds }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const bySecondsThenLabel = (a: { seconds: number | null; key: string }, b: { seconds: number | null; key: string }): number => {
|
|
95
|
+
if (a.seconds === null && b.seconds !== null) return 1
|
|
96
|
+
if (a.seconds !== null && b.seconds === null) return -1
|
|
97
|
+
if (a.seconds !== null && b.seconds !== null && a.seconds !== b.seconds) return b.seconds - a.seconds
|
|
98
|
+
return a.key.localeCompare(b.key, 'en', { numeric: true })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Pure. `currentLabels` is the label of every row in the response being answered. */
|
|
102
|
+
export function buildVoiceDisplay(evidence: VoiceDisplayEvidence, currentLabels: readonly string[]): VoiceDisplay {
|
|
103
|
+
const current = new Set(currentLabels.filter(isVoiceLabel))
|
|
104
|
+
const secondsOf = (label: string): number | null => evidence.seconds.get(label) ?? null
|
|
105
|
+
const groups: VoiceDisplay['groups'] = []
|
|
106
|
+
const singles: VoiceDisplay['singles'] = []
|
|
107
|
+
const short: VoiceDisplay['short'] = []
|
|
108
|
+
const place = (label: string): void => {
|
|
109
|
+
const seconds = secondsOf(label)
|
|
110
|
+
if (seconds !== null && seconds < VOICE_DISPLAY_FOLD_UNDER_SECONDS) short.push({ label, seconds })
|
|
111
|
+
else singles.push({ label, seconds })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const grouped = new Set<string>()
|
|
115
|
+
for (const group of evidence.groups) {
|
|
116
|
+
const labels = group.labels.filter(label => current.has(label))
|
|
117
|
+
for (const label of labels) grouped.add(label)
|
|
118
|
+
if (labels.length === 1) place(labels[0])
|
|
119
|
+
else if (labels.length >= 2) {
|
|
120
|
+
const known = labels.map(secondsOf)
|
|
121
|
+
const seconds = known.every((s): s is number => s !== null)
|
|
122
|
+
? Math.round(known.reduce((sum, s) => sum + s, 0) * 100) / 100
|
|
123
|
+
: null
|
|
124
|
+
groups.push({ id: group.id, labels, seconds })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const label of current) if (!grouped.has(label)) place(label)
|
|
128
|
+
|
|
129
|
+
groups.sort((a, b) => bySecondsThenLabel({ seconds: a.seconds, key: a.labels[0] }, { seconds: b.seconds, key: b.labels[0] }))
|
|
130
|
+
singles.sort((a, b) => bySecondsThenLabel({ seconds: a.seconds, key: a.label }, { seconds: b.seconds, key: b.label }))
|
|
131
|
+
short.sort((a, b) => bySecondsThenLabel({ seconds: a.seconds, key: a.label }, { seconds: b.seconds, key: b.label }))
|
|
132
|
+
return { source: 'speaker-shadow', recordedAt: evidence.recordedAt, foldUnderSeconds: VOICE_DISPLAY_FOLD_UNDER_SECONDS, groups, singles, short }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Who a voice suggestion is for: one entry per group, single and short row when there is a display,
|
|
137
|
+
* otherwise one per anonymous label. Ext and names never appear, on either path.
|
|
138
|
+
*/
|
|
139
|
+
export function voiceDisplayScopes(display: VoiceDisplay | null, currentLabels: readonly string[]): string[][] {
|
|
140
|
+
if (!display) return [...new Set(currentLabels.filter(isVoiceLabel))].map(label => [label])
|
|
141
|
+
return [
|
|
142
|
+
...display.groups.map(group => group.labels),
|
|
143
|
+
...display.singles.map(row => [row.label]),
|
|
144
|
+
...display.short.map(row => [row.label]),
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Keyed on every proposal file's name, size and mtime. /speakers is opened again and again on one
|
|
149
|
+
// meeting; a session with no record would otherwise scan the whole store each time.
|
|
150
|
+
const evidenceCache = new Map<string, { signature: string; evidence: VoiceDisplayEvidence | null }>()
|
|
151
|
+
|
|
152
|
+
export function __clearVoiceDisplayCacheForTests(): void { evidenceCache.clear() }
|
|
153
|
+
|
|
154
|
+
async function storeSignature(root: string): Promise<string | null> {
|
|
155
|
+
const names = (await readdir(root)).filter(name => name === SPEAKER_SHADOW_RECORDS || /^proposals-[0-9TZ-]+\.jsonl$/.test(name)).sort()
|
|
156
|
+
if (names.length === 0) return null
|
|
157
|
+
let total = 0
|
|
158
|
+
const parts: string[] = []
|
|
159
|
+
for (const name of names) {
|
|
160
|
+
const info = await stat(join(root, name))
|
|
161
|
+
total += info.size
|
|
162
|
+
parts.push(`${name}:${info.size}:${info.mtimeMs}`)
|
|
163
|
+
}
|
|
164
|
+
return total > MAX_STORE_BYTES ? null : parts.join('|')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The latest `proposed` record for a session. Null when there is none or anything about it is off. Never throws. */
|
|
168
|
+
export async function readVoiceDisplayEvidence(sessionId: string, root: string = speakerShadowRoot()): Promise<VoiceDisplayEvidence | null> {
|
|
169
|
+
try {
|
|
170
|
+
const signature = await storeSignature(root)
|
|
171
|
+
if (signature === null) return null
|
|
172
|
+
const key = `${root}\u0000${sessionId}`
|
|
173
|
+
const cached = evidenceCache.get(key)
|
|
174
|
+
if (cached?.signature === signature) return cached.evidence
|
|
175
|
+
const records = await readSpeakerShadowRecords(root, { sessionId, limit: MAX_RECORDS_PER_SESSION })
|
|
176
|
+
const latest = records.find(record => record.status === 'proposed')
|
|
177
|
+
const evidence = latest ? voiceDisplayEvidence(latest) : null
|
|
178
|
+
evidenceCache.delete(key)
|
|
179
|
+
evidenceCache.set(key, { signature, evidence })
|
|
180
|
+
while (evidenceCache.size > EVIDENCE_CACHE_MAX) evidenceCache.delete(evidenceCache.keys().next().value as string)
|
|
181
|
+
return evidence
|
|
182
|
+
} catch {
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The optional `voiceDisplay` field. Null means leave it out. Never throws. */
|
|
188
|
+
export async function readVoiceDisplay(sessionId: string, currentLabels: readonly string[], root: string = speakerShadowRoot()): Promise<VoiceDisplay | null> {
|
|
189
|
+
try {
|
|
190
|
+
const evidence = await readVoiceDisplayEvidence(sessionId, root)
|
|
191
|
+
return evidence ? buildVoiceDisplay(evidence, currentLabels) : null
|
|
192
|
+
} catch {
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
}
|