@gotcos/glasses-server 6.94.1 → 6.94.3
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 +26 -0
- package/README.md +27 -0
- package/package.json +2 -1
- package/server/index.ts +6 -0
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/held-naming-batches.ts +8 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/mac-speaker-machine-evidence.ts +111 -0
- package/server/lib/mac-speaker-profile-extractor.ts +41 -0
- package/server/lib/mac-speaker-profile-finalizer.ts +257 -0
- package/server/lib/mac-speaker-protection.ts +22 -0
- package/server/lib/mac-speaker-reconcile.ts +300 -0
- package/server/lib/mac-speaker-repair-projection.ts +301 -0
- package/server/lib/mac-speaker-repair-render.ts +105 -0
- package/server/lib/mac-speaker-windows.ts +239 -0
- package/server/lib/mac-voice-assignment.ts +2 -1
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-scribe-content.ts +8 -1
- package/server/lib/meeting-speaker-review.ts +46 -7
- package/server/lib/nemotron-final.ts +64 -14
- package/server/lib/session-find-ask.ts +281 -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-trainer.ts +2 -1
- package/server/lib/voice-directory.ts +7 -2
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/meeting.ts +43 -15
- package/server/routes/session-find.ts +73 -0
- package/server/routes/voice.ts +25 -18
- package/server/scripts/mac-speaker-repair.ts +324 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/** Qualified, named, exact-window finalization only. No anonymous merging,
|
|
2
|
+
* enrollment, guessed participant count, model-channel naming or lower floor.
|
|
3
|
+
* A durable attempt and two-file before/after journal make retries fail closed. */
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
7
|
+
import { join, resolve } from 'node:path'
|
|
8
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
9
|
+
import { MAC_CAPTURE_ID } from './mac-recording.js'
|
|
10
|
+
import { dataPath } from './data-dir.js'
|
|
11
|
+
import { readPcm16Mono, wavHeader } from './nemotron-segments.js'
|
|
12
|
+
import { planMacSpeakerWindows, qualifyMacSpeakerWindows, type VerifiedMacRawChunk, type MacWindowEmbeddingReceipt } from './mac-speaker-windows.js'
|
|
13
|
+
import { normalizedSpeakerCosine, type MacSpeakerProfile } from './mac-speaker-reconcile.js'
|
|
14
|
+
import { hashMacSpeakerDocument, hashMacSpeakerMachineBinding, projectMacSpeakerRepair, type MacProfileMatchEvidence, type MacSpeakerRepairAssignment, type MacSpeakerWordBinding } from './mac-speaker-repair-projection.js'
|
|
15
|
+
import { boundWords, renderBoundSpeakerLabels, renderMarkdown } from './mac-speaker-repair-render.js'
|
|
16
|
+
import { readMacSpeakerMachineEvidence } from './mac-speaker-machine-evidence.js'
|
|
17
|
+
import { extractQualifiedMacWindow } from './mac-speaker-profile-extractor.js'
|
|
18
|
+
type Doc = Record<string, any>
|
|
19
|
+
const sha = (v: string | Buffer) => createHash('sha256').update(v).digest('hex')
|
|
20
|
+
const yieldLoop = () => new Promise<void>(r => setImmediate(r))
|
|
21
|
+
const MAX_WINDOWS = 12, MAX_RAW = 2048, MAX_MS = 90_000
|
|
22
|
+
export const MAX_PROFILE_BYTES = 32 * 1024 * 1024
|
|
23
|
+
function fail(reason: string): never { throw new Error(`qualified Mac profile pass: ${reason}`) }
|
|
24
|
+
export interface QualifiedMacProfileInput { sidecarPath: string; meetingPath: string; audioDir: string; resumeOnly?: boolean }
|
|
25
|
+
export interface QualifiedMacProfileDeps {
|
|
26
|
+
/** Test seams never invoke native inference. Production freezes the complete cohort. */
|
|
27
|
+
runtime?: () => Promise<{ modelPath: string; profilesPath: string } | null>
|
|
28
|
+
extract?: (pcm: Buffer, modelPath: string, timeoutMs: number) => Promise<readonly number[][] | null>
|
|
29
|
+
ledgerPath?: (sessionId: string) => string
|
|
30
|
+
now?: () => number
|
|
31
|
+
beforeCommit?: () => void
|
|
32
|
+
afterWrite?: (index: number) => void
|
|
33
|
+
}
|
|
34
|
+
export interface QualifiedMacProfileOutcome { status: 'applied' | 'skipped'; reason: string; assignments?: number }
|
|
35
|
+
interface Image { path: string; before: string; after: string }
|
|
36
|
+
interface Journal { version: 1; status: 'attempted' | 'prepared' | 'complete'; sessionId: string; sourceSidecarHash: string; imageBinding?: string; rawBindings?: Array<{ name: string; sha256: string }>; reason?: string; images?: Image[]; ledgerPath: string; ledgerHash: string | null; profilePath: string; profileHash: string; modelPath: string; modelHash: string }
|
|
37
|
+
const active = new Set<string>()
|
|
38
|
+
let extractionBusy = false
|
|
39
|
+
function regularRead(path: string, max = 12 * 1024 * 1024): string {
|
|
40
|
+
const s = lstatSync(path)
|
|
41
|
+
if (!s.isFile() || s.isSymbolicLink() || s.size > max) fail('unsafe or oversized file')
|
|
42
|
+
return readFileSync(path, 'utf8')
|
|
43
|
+
}
|
|
44
|
+
function ledgerHash(path: string): string | null {
|
|
45
|
+
try { return sha(regularRead(path, 4 * 1024 * 1024)) } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; throw e }
|
|
46
|
+
}
|
|
47
|
+
function ledgerClear(path: string): boolean {
|
|
48
|
+
const digest = ledgerHash(path)
|
|
49
|
+
if (digest === null) return true
|
|
50
|
+
// Every historical human action (including intent/revert) protects the capture.
|
|
51
|
+
return regularRead(path).trim() === ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function strictPcm(wav: Buffer) {
|
|
55
|
+
if (wav.length < 44 || wav.readUInt32LE(4) !== wav.length - 8 || wav.toString('ascii', 12, 16) !== 'fmt ' || wav.readUInt32LE(16) !== 16 || wav.readUInt16LE(20) !== 1 || wav.readUInt16LE(22) !== 1 || wav.readUInt32LE(24) !== 16000 || wav.readUInt32LE(28) !== 32000 || wav.readUInt16LE(32) !== 2 || wav.readUInt16LE(34) !== 16 || wav.toString('ascii', 36, 40) !== 'data' || wav.readUInt32LE(40) !== wav.length - 44 || (wav.length - 44) % 2) return null
|
|
56
|
+
return readPcm16Mono(wav)
|
|
57
|
+
}
|
|
58
|
+
async function runtimeDefault() {
|
|
59
|
+
const { speakerModelState } = await import('./speaker-embeddings.js')
|
|
60
|
+
const model = speakerModelState()
|
|
61
|
+
return model.state === 'active' && model.path ? { modelPath: model.path, profilesPath: dataPath('voice-profiles.json') } : null
|
|
62
|
+
}
|
|
63
|
+
function recover(journalPath: string, journal: Journal, input: QualifiedMacProfileInput, deps: QualifiedMacProfileDeps): QualifiedMacProfileOutcome {
|
|
64
|
+
if (journal.version !== 1 || !MAC_CAPTURE_ID.test(journal.sessionId) || journal.images?.length !== 2
|
|
65
|
+
|| journal.images[0].path !== resolve(input.meetingPath) || journal.images[1].path !== resolve(input.sidecarPath)
|
|
66
|
+
|| ledgerHash(journal.ledgerPath) !== journal.ledgerHash) fail('journal or human history drift')
|
|
67
|
+
if (journal.status !== 'complete' && (sha(regularRead(journal.profilePath, MAX_PROFILE_BYTES)) !== journal.profileHash || sha(readFileSync(journal.modelPath)) !== journal.modelHash)) fail('journal profile/model drift')
|
|
68
|
+
const images = journal.images
|
|
69
|
+
if (journal.imageBinding !== hashMacSpeakerDocument(images) || sha(images[1].before) !== journal.sourceSidecarHash) fail('journal image integrity drift')
|
|
70
|
+
const beforeDoc = JSON.parse(images[1].before), afterDoc = JSON.parse(images[1].after)
|
|
71
|
+
const proofRead = readMacSpeakerMachineEvidence({ ...afterDoc, finalizationState: 'complete' })
|
|
72
|
+
if (beforeDoc.sessionId !== journal.sessionId || afterDoc.sessionId !== journal.sessionId || proofRead.status !== 'qualified' || !proofRead.positions.size || !afterDoc.speakerRepair?.machineEvidence?.receipt) fail('journal proof rejected')
|
|
73
|
+
const expectedSequence = boundWords(beforeDoc, afterDoc)
|
|
74
|
+
if (renderBoundSpeakerLabels(beforeDoc.batchTranscript, expectedSequence) !== afterDoc.batchTranscript || renderMarkdown(images[0].before, expectedSequence) !== images[0].after) fail('journal word invariant drift')
|
|
75
|
+
if (journal.status !== 'complete') {
|
|
76
|
+
if (!Array.isArray(journal.rawBindings) || !journal.rawBindings.length || journal.rawBindings.length > MAX_RAW) fail('journal raw bindings invalid')
|
|
77
|
+
for (const raw of journal.rawBindings) { if (!/^chunk_\d{4,}\.wav$/.test(raw.name) || sha(readFileSync(join(input.audioDir, raw.name))) !== raw.sha256) fail('journal raw drift') }
|
|
78
|
+
}
|
|
79
|
+
const current = images.map(image => regularRead(image.path))
|
|
80
|
+
if (journal.status !== 'complete' && current.some((bytes, i) => bytes !== images[i].before && bytes !== images[i].after)) fail('journal precondition drift')
|
|
81
|
+
// Completed transactions never reapply over a later change; the exact speech
|
|
82
|
+
// binding/revision guard in readers separately invalidates later stale claims.
|
|
83
|
+
if (journal.status === 'complete') {
|
|
84
|
+
if (current.every((bytes, i) => bytes === images[i].after)) return { status: 'applied', reason: 'already_applied' }
|
|
85
|
+
const saved = JSON.parse(images[1].after), currentDoc = JSON.parse(current[1])
|
|
86
|
+
if (hashMacSpeakerMachineBinding(currentDoc) === hashMacSpeakerMachineBinding(saved) && currentDoc.correctionRevision === saved.correctionRevision && readMacSpeakerMachineEvidence(currentDoc).status === 'qualified') return { status: 'applied', reason: 'already_applied' }
|
|
87
|
+
fail('completed journal changed')
|
|
88
|
+
}
|
|
89
|
+
for (let i = 0; i < images.length; i++) {
|
|
90
|
+
if (ledgerHash(journal.ledgerPath) !== journal.ledgerHash) fail('human history changed')
|
|
91
|
+
for (let j = 0; j < images.length; j++) {
|
|
92
|
+
const bytes = regularRead(images[j].path)
|
|
93
|
+
if (bytes !== (j < i ? images[j].after : current[j])) fail('transaction changed during commit')
|
|
94
|
+
}
|
|
95
|
+
if (current[i] !== images[i].after) durableAtomicWriteFileSync(images[i].path, images[i].after)
|
|
96
|
+
deps.afterWrite?.(i)
|
|
97
|
+
}
|
|
98
|
+
if (images.some(image => regularRead(image.path) !== image.after)) fail('postimage changed')
|
|
99
|
+
journal.status = 'complete'; durableAtomicWriteFileSync(journalPath, JSON.stringify(journal))
|
|
100
|
+
return { status: 'applied', reason: 'qualified_profile' }
|
|
101
|
+
}
|
|
102
|
+
/** Six deterministic adjacent pairs spread across the entire call. Scheduling
|
|
103
|
+
* hints never add identity votes; each pair still needs acoustic qualification. */
|
|
104
|
+
export function selectQualifiedProfileWindows<T>(windows: readonly T[]): T[] {
|
|
105
|
+
if (windows.length <= MAX_WINDOWS) return [...windows]
|
|
106
|
+
const indexes = new Set<number>()
|
|
107
|
+
for (let i = 0; i < MAX_WINDOWS / 2; i++) { const index = Math.floor(i * (windows.length - 2) / (MAX_WINDOWS / 2 - 1)); indexes.add(index); indexes.add(index + 1) }
|
|
108
|
+
return [...indexes].sort((a, b) => a - b).map(i => windows[i])
|
|
109
|
+
}
|
|
110
|
+
/** Called twice by the finalizer: resumeOnly BEFORE any ASR retry, and ordinary
|
|
111
|
+
* mode AFTER ASR+diarization, before raw deletion or downstream copies/sync. */
|
|
112
|
+
export async function runQualifiedMacProfilePass(input: QualifiedMacProfileInput, deps: QualifiedMacProfileDeps = {}): Promise<QualifiedMacProfileOutcome> {
|
|
113
|
+
const key = resolve(input.sidecarPath), journalPath = `${key}.speaker-profile-pass.json`
|
|
114
|
+
if (active.has(key)) return { status: 'skipped', reason: 'active' }
|
|
115
|
+
active.add(key)
|
|
116
|
+
let ownsExtraction = false
|
|
117
|
+
try {
|
|
118
|
+
if (existsSync(journalPath)) {
|
|
119
|
+
const prior = JSON.parse(regularRead(journalPath, 64 * 1024 * 1024)) as Journal
|
|
120
|
+
if (prior.status === 'prepared' || prior.status === 'complete') return recover(journalPath, prior, input, deps)
|
|
121
|
+
if (prior.version !== 1 || prior.status !== 'attempted') fail('invalid attempt journal')
|
|
122
|
+
return { status: 'skipped', reason: prior.reason ?? 'previous_attempt' }
|
|
123
|
+
}
|
|
124
|
+
if (input.resumeOnly) return { status: 'skipped', reason: 'no_transaction' }
|
|
125
|
+
const now = deps.now ?? Date.now, deadline = now() + MAX_MS
|
|
126
|
+
const budget = () => { if (now() >= deadline) fail('budget_exhausted') }
|
|
127
|
+
const beforeSidecar = regularRead(key), source = JSON.parse(beforeSidecar) as Doc
|
|
128
|
+
if (source.sourceType !== 'mac_system_audio' || !MAC_CAPTURE_ID.test(source.sessionId) || !['batch_pending', 'ops_pending', 'complete'].includes(source.finalizationState)
|
|
129
|
+
|| source.claimPending || source.active || source.isActive || source.status === 'recording' || source.isMerged || source.mergedFrom || source.sourceSessionIds || source.supersededBy || source.superseded || source.speakerRepair
|
|
130
|
+
|| (source.correctionRevision ?? 0) !== 0 || source.batchApplied !== true || !Array.isArray(source.batchSegments) || !Array.isArray(source.chunkEntries) || source.chunkEntries.length > MAX_RAW) return { status: 'skipped', reason: 'source_ineligible' }
|
|
131
|
+
const ledgerPath = deps.ledgerPath?.(source.sessionId) ?? dataPath('meeting-corrections', `${source.sessionId}.jsonl`)
|
|
132
|
+
if (!ledgerClear(ledgerPath)) return { status: 'skipped', reason: 'human_corrected' }
|
|
133
|
+
const ledger = ledgerHash(ledgerPath), beforeMarkdown = regularRead(input.meetingPath, 4 * 1024 * 1024)
|
|
134
|
+
const runtime = await (deps.runtime ?? runtimeDefault)()
|
|
135
|
+
if (!runtime) return { status: 'skipped', reason: 'model_unavailable' }
|
|
136
|
+
const profileBytes = regularRead(runtime.profilesPath, MAX_PROFILE_BYTES), profileSHA256 = sha(profileBytes)
|
|
137
|
+
const cohort = JSON.parse(profileBytes).profiles
|
|
138
|
+
if (!Array.isArray(cohort) || !cohort.length || cohort.length > 1024) return { status: 'skipped', reason: 'profile_cohort_invalid' }
|
|
139
|
+
if ((await stat(runtime.modelPath)).size > 128 * 1024 * 1024) return { status: 'skipped', reason: 'model_oversized' }
|
|
140
|
+
const modelBytes = await readFile(runtime.modelPath); budget()
|
|
141
|
+
if (modelBytes.length > 128 * 1024 * 1024) return { status: 'skipped', reason: 'model_oversized' }
|
|
142
|
+
if (cohort.some(p => !p || typeof p.name !== 'string' || !Array.isArray(p.embeddings) || !p.embeddings.length || p.embeddings.length > 128 || p.embeddings.some((r: unknown) => !Array.isArray(r) || r.length !== 192 || r.some(v => !Number.isFinite(v)))) || cohort.reduce((n, p) => n + p.embeddings.length, 0) > 8192) return { status: 'skipped', reason: 'profile_cohort_invalid' }
|
|
143
|
+
const modelSHA256 = sha(modelBytes)
|
|
144
|
+
const profiles: MacSpeakerProfile[] = cohort.map(p => ({ name: p.name, embeddings: p.embeddings, profileSHA256 }))
|
|
145
|
+
// One bounded native profile job server-wide. A concurrent capture keeps
|
|
146
|
+
// its saved labels rather than multiplying native processes/CPU load.
|
|
147
|
+
if (extractionBusy) return { status: 'skipped', reason: 'profile_lane_busy' }
|
|
148
|
+
extractionBusy = true; ownsExtraction = true
|
|
149
|
+
const journal: Journal = { version: 1, status: 'attempted', sessionId: source.sessionId, sourceSidecarHash: sha(beforeSidecar), ledgerPath, ledgerHash: ledger, profilePath: resolve(runtime.profilesPath), profileHash: profileSHA256, modelPath: resolve(runtime.modelPath), modelHash: modelSHA256 }
|
|
150
|
+
durableAtomicWriteFileSync(journalPath, JSON.stringify(journal))
|
|
151
|
+
try {
|
|
152
|
+
const raw: VerifiedMacRawChunk[] = [], counts: Record<string, number> = {}
|
|
153
|
+
for (const entry of source.chunkEntries) {
|
|
154
|
+
budget()
|
|
155
|
+
if (!Number.isSafeInteger(entry.chunkIndex) || entry.chunkIndex < 0) fail('invalid raw index')
|
|
156
|
+
const path = join(input.audioDir, `chunk_${String(entry.chunkIndex).padStart(4, '0')}.wav`)
|
|
157
|
+
const info = await stat(path)
|
|
158
|
+
if (!info.isFile() || info.size > 960044) fail('invalid raw file')
|
|
159
|
+
const wav = await readFile(path), audioSHA256 = sha(wav), pcm = strictPcm(wav)
|
|
160
|
+
if (!pcm || pcm.sampleRate !== 16000 || wav.length > 960044 || (entry.chunk.audioSha256 !== undefined && audioSHA256 !== entry.chunk.audioSha256)) fail('raw hash/format drift')
|
|
161
|
+
const sampleCount = pcm.pcm.length / 2
|
|
162
|
+
counts[entry.chunkIndex] = sampleCount
|
|
163
|
+
raw.push({ chunkIndex: entry.chunkIndex, audioSHA256, pcmSHA256: sha(pcm.pcm), sampleRate: 16000, channels: 1, format: 'pcm_s16le', sampleCount, verified: true })
|
|
164
|
+
if (raw.length % 4 === 0) await yieldLoop()
|
|
165
|
+
}
|
|
166
|
+
const plan = planMacSpeakerWindows({ sidecar: source as any, rawChunks: raw, correctionHistory: 'verified-clear' })
|
|
167
|
+
plan.windows = selectQualifiedProfileWindows(plan.windows)
|
|
168
|
+
if (plan.windows.length < 2) fail('insufficient_long_windows')
|
|
169
|
+
const receipts: MacWindowEmbeddingReceipt[] = []
|
|
170
|
+
for (const window of plan.windows) {
|
|
171
|
+
budget(); await yieldLoop()
|
|
172
|
+
const buffers: Buffer[] = []
|
|
173
|
+
for (const s of window.sources) {
|
|
174
|
+
const wav = await readFile(join(input.audioDir, `chunk_${String(s.chunkIndex).padStart(4, '0')}.wav`)), pcm = strictPcm(wav)
|
|
175
|
+
if (sha(wav) !== s.audioSHA256 || !pcm || sha(pcm.pcm) !== s.pcmSHA256) fail('raw changed before extraction')
|
|
176
|
+
buffers.push(pcm.pcm)
|
|
177
|
+
}
|
|
178
|
+
const pcm = Buffer.concat(buffers), wav = Buffer.concat([wavHeader(pcm.length), pcm])
|
|
179
|
+
const vectors = await (deps.extract ?? extractQualifiedMacWindow)(pcm, runtime.modelPath, Math.min(15_000, deadline - now()))
|
|
180
|
+
budget()
|
|
181
|
+
if (!vectors || vectors.length !== 3) continue
|
|
182
|
+
receipts.push({ windowId: window.id, windowBindingSHA256: window.bindingSHA256, modelSHA256, audioSHA256: sha(wav), pcmSHA256: sha(pcm), sampleCount: pcm.length / 2, embedding: vectors[0], halfEmbeddings: [vectors[1], vectors[2]] })
|
|
183
|
+
}
|
|
184
|
+
const qualified = qualifyMacSpeakerWindows(plan, receipts, profiles, modelSHA256)
|
|
185
|
+
const byWindow = new Map(plan.windows.map(w => [w.id, w])), byReceipt = new Map(receipts.map(r => [r.windowId, r]))
|
|
186
|
+
const assignments: MacSpeakerRepairAssignment[] = []
|
|
187
|
+
const groups = new Map<string, typeof qualified>()
|
|
188
|
+
for (const c of qualified) if (c.status === 'qualified' && c.profileName) { const k = `${c.acousticIdentity}:${c.profileName}`; groups.set(k, [...(groups.get(k) ?? []), c]) }
|
|
189
|
+
for (const group of groups.values()) {
|
|
190
|
+
if (group.length < 2) continue
|
|
191
|
+
const profile = profiles.find(p => p.name === group[0].profileName)!
|
|
192
|
+
// Same raw-average centroid used by the production recognizer. The
|
|
193
|
+
// qualifier already rejected corrupt/mismatched rows and profile ties.
|
|
194
|
+
const rows = profile.embeddings, dimensions = rows[0].length
|
|
195
|
+
const centroid = Array.from({ length: dimensions }, (_, i) => rows.reduce((n, r) => n + r[i] / rows.length, 0))
|
|
196
|
+
const centroids = profiles.map(p => ({ name: p.name, vector: Array.from({ length: dimensions }, (_, i) => p.embeddings.reduce((n, r) => n + r[i] / p.embeddings.length, 0)) }))
|
|
197
|
+
const proof: MacProfileMatchEvidence = { kind: 'profile-match', profileName: profile.name, profileSha256: profileSHA256, modelSha256: modelSHA256, algorithmVersion: 'qualified-long-window-profile-v1',
|
|
198
|
+
crossWindowCosines: group.map(a => group.map(b => normalizedSpeakerCosine(byReceipt.get(a.windowId)!.embedding, byReceipt.get(b.windowId)!.embedding)!)),
|
|
199
|
+
windows: group.map(c => { const w = byWindow.get(c.windowId)!, r = byReceipt.get(c.windowId)!; return { id: w.id, captureStartSample: w.startSample, captureEndSample: w.endSample, pcmSha256: r.pcmSHA256,
|
|
200
|
+
similarity: normalizedSpeakerCosine(r.embedding, centroid)!, runnerUpSimilarity: Math.max(-1, ...centroids.filter(p => p.name !== profile.name).map(p => normalizedSpeakerCosine(r.embedding, p.vector)!)),
|
|
201
|
+
rawChunks: w.sources.map(s => ({ rawChunkIndex: s.chunkIndex, audioSha256: s.audioSHA256, sampleCount: s.sampleCount })) } }) }
|
|
202
|
+
for (const c of group) for (const s of byWindow.get(c.windowId)!.sources) assignments.push({ rawChunkIndex: s.chunkIndex, from: c.originalVoiceHint, to: profile.name, evidenceId: c.acousticIdentity!, targetEvidence: proof, batchWords: [] })
|
|
203
|
+
}
|
|
204
|
+
if (!assignments.length) fail('no_qualified_profile')
|
|
205
|
+
const selected = new Map(assignments.map(a => [a.rawChunkIndex, a])), withheldBatchWords: Array<MacSpeakerWordBinding & { reason: 'outside-qualified-span' }> = []
|
|
206
|
+
source.batchSegments.forEach((segment: Doc, segmentIndex: number) => {
|
|
207
|
+
const spans: { raw: number; start: number; end: number }[] = []; let end = 0
|
|
208
|
+
if (!Number.isSafeInteger(segment.startChunkIdx) || !Number.isSafeInteger(segment.endChunkIdx) || segment.endChunkIdx - segment.startChunkIdx > MAX_RAW) fail('invalid batch range')
|
|
209
|
+
for (let raw = segment.startChunkIdx; raw <= segment.endChunkIdx; raw++) { if (!counts[raw]) fail('missing batch raw clock'); spans.push({ raw, start: end, end: end + counts[raw] / 16000 }); end += counts[raw] / 16000 }
|
|
210
|
+
segment.speakerWords.forEach((word: Doc, wordIndex: number) => {
|
|
211
|
+
const overlaps = spans.filter(s => word.end === word.start ? word.start >= s.start && word.start < s.end : word.start < s.end && word.end > s.start)
|
|
212
|
+
if (!overlaps.some(s => selected.has(s.raw))) return
|
|
213
|
+
const binding = { segmentIndex, wordIndex, word: word.word, from: word.speaker, start: word.start, end: word.end }
|
|
214
|
+
if (overlaps.some(s => !selected.has(s.raw))) withheldBatchWords.push({ ...binding, reason: 'outside-qualified-span' })
|
|
215
|
+
else if (new Set(overlaps.map(s => selected.get(s.raw)!.to)).size === 1) selected.get(overlaps[0].raw)!.batchWords.push(binding)
|
|
216
|
+
else fail('crossed identities')
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
budget()
|
|
220
|
+
const staging = { ...source, finalizationState: 'complete' }, repairPlan = { id: `profile_${sha(beforeSidecar).slice(0, 24)}`, evidenceHash: hashMacSpeakerDocument(receipts), expectedDocumentHash: hashMacSpeakerDocument(staging), protectedRawChunkIndices: [], rawAudioSampleCounts: counts, assignments, withheldBatchWords }
|
|
221
|
+
const projected = projectMacSpeakerRepair(staging, repairPlan), after = projected.doc, sequence = boundWords(staging, after)
|
|
222
|
+
after.batchTranscript = renderBoundSpeakerLabels(source.batchTranscript, sequence)
|
|
223
|
+
const afterMarkdown = renderMarkdown(beforeMarkdown, sequence)
|
|
224
|
+
after.finalizationState = source.finalizationState
|
|
225
|
+
after.correctionRevision = 1; after.lifecycleRevision = (source.lifecycleRevision ?? 0) + 1
|
|
226
|
+
after.speakerRepair = { version: 1, id: repairPlan.id, evidenceHash: repairPlan.evidenceHash, machineEvidence: { version: 1, correctionRevision: 1, documentBinding: hashMacSpeakerMachineBinding(after), receipt: projected.receipt, rawAudioSampleCounts: counts } }
|
|
227
|
+
after.speakerProfilePass = { version: 1, status: 'applied', windows: receipts.length, assignments: assignments.length }
|
|
228
|
+
deps.beforeCommit?.(); budget()
|
|
229
|
+
for (const c of assignments) {
|
|
230
|
+
const rawBytes = await readFile(join(input.audioDir, `chunk_${String(c.rawChunkIndex).padStart(4, '0')}.wav`))
|
|
231
|
+
if (sha(rawBytes) !== raw.find(r => r.chunkIndex === c.rawChunkIndex)!.audioSHA256) fail('raw changed before commit')
|
|
232
|
+
}
|
|
233
|
+
const currentModelHash = sha(await readFile(runtime.modelPath))
|
|
234
|
+
if (regularRead(key) !== beforeSidecar || regularRead(input.meetingPath) !== beforeMarkdown || ledgerHash(ledgerPath) !== ledger || sha(regularRead(runtime.profilesPath, MAX_PROFILE_BYTES)) !== profileSHA256 || currentModelHash !== modelSHA256) fail('source/profile/model/history drift')
|
|
235
|
+
journal.images = [{ path: resolve(input.meetingPath), before: beforeMarkdown, after: afterMarkdown }, { path: key, before: beforeSidecar, after: JSON.stringify(after, null, 2) + '\n' }]
|
|
236
|
+
journal.imageBinding = hashMacSpeakerDocument(journal.images)
|
|
237
|
+
journal.rawBindings = assignments.map(a => ({ name: `chunk_${String(a.rawChunkIndex).padStart(4, '0')}.wav`, sha256: raw.find(r => r.chunkIndex === a.rawChunkIndex)!.audioSHA256 }))
|
|
238
|
+
journal.status = 'prepared'; durableAtomicWriteFileSync(journalPath, JSON.stringify(journal))
|
|
239
|
+
const outcome = recover(journalPath, journal, input, deps)
|
|
240
|
+
return { ...outcome, assignments: assignments.length }
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// Prepared transactions must be retried before ASR, never reported as an
|
|
243
|
+
// ordinary no-match that permits downstream filing of mixed generations.
|
|
244
|
+
if (journal.status === 'prepared') throw error
|
|
245
|
+
journal.reason = error instanceof Error ? error.message : 'unqualified'; durableAtomicWriteFileSync(journalPath, JSON.stringify(journal))
|
|
246
|
+
return { status: 'skipped', reason: journal.reason }
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
// Recovery failures must block ASR/copying; ordinary unavailable resources
|
|
250
|
+
// leave the already saved recording alone.
|
|
251
|
+
if (existsSync(journalPath)) {
|
|
252
|
+
const saved = JSON.parse(regularRead(journalPath, 64 * 1024 * 1024)) as Journal
|
|
253
|
+
if (saved.status === 'prepared' || saved.status === 'complete') throw error
|
|
254
|
+
}
|
|
255
|
+
return { status: 'skipped', reason: error instanceof Error ? error.message : 'unavailable' }
|
|
256
|
+
} finally { if (ownsExtraction) extractionBusy = false; active.delete(key) }
|
|
257
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** One conservative predicate for automatic acoustic support and label writes.
|
|
2
|
+
* Human markers protect even still-anonymous labels and unlabeled ASR words. */
|
|
3
|
+
export function isMacSpeakerHumanProtected(value: unknown): boolean {
|
|
4
|
+
if (!value || typeof value !== 'object') return false
|
|
5
|
+
const row = value as Record<string, unknown>
|
|
6
|
+
if (['speakerCorrectionBatchId', 'speakerCorrectionId', 'manualSpeaker', 'speakerManuallyAssigned', 'confirmedByHuman', 'manuallyAssigned', 'manuallyCorrected'].some(key => !!row[key])) return true
|
|
7
|
+
return ['speakerOrigin', 'speakerSource', 'speakerPolicy', 'speakerProvenance', 'source'].some(key =>
|
|
8
|
+
typeof row[key] === 'string' && /(?:manual|human|correction|confirmed)/i.test(row[key] as string))
|
|
9
|
+
}
|
|
10
|
+
/** Inspect every stored chunk representation before using any of its audio as
|
|
11
|
+
* positive evidence. Malformed declared surfaces also fail closed. */
|
|
12
|
+
export function hasMacSpeakerProtectedRows(value: unknown): boolean {
|
|
13
|
+
if (!value || typeof value !== 'object') return true
|
|
14
|
+
const chunk = value as Record<string, unknown>
|
|
15
|
+
if (isMacSpeakerHumanProtected(chunk)) return true
|
|
16
|
+
for (const key of ['segments', 'finalSegments', 'words']) {
|
|
17
|
+
if (chunk[key] === undefined) continue
|
|
18
|
+
if (!Array.isArray(chunk[key])) return true
|
|
19
|
+
if ((chunk[key] as unknown[]).some(row => !row || typeof row !== 'object' || isMacSpeakerHumanProtected(row))) return true
|
|
20
|
+
}
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Pure, opt-in acoustic evidence engine. No audio/model loading, file IO, enrollment,
|
|
2
|
+
// transcript mutation, attendee inference or production enablement belongs here.
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { checkSpeakerName } from './speaker-name.js'
|
|
5
|
+
|
|
6
|
+
const SHA = /^[a-f0-9]{64}$/
|
|
7
|
+
const uint = (n: unknown): n is number => Number.isSafeInteger(n) && Number(n) >= 0
|
|
8
|
+
const digest = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
9
|
+
const fail = (why: string): never => { throw new Error(`mac_speaker_reconcile_${why}`) }
|
|
10
|
+
export const MAC_SPEAKER_RECONCILE_VERSION = 'acoustic-memory-v1'
|
|
11
|
+
export interface MacSpeakerThresholds {
|
|
12
|
+
dimensions: number
|
|
13
|
+
anonymousCosine: number
|
|
14
|
+
anonymousMargin: number
|
|
15
|
+
identityCosine: number
|
|
16
|
+
identityMargin: number
|
|
17
|
+
minimumIndependentMatches: number
|
|
18
|
+
minimumSamples: number
|
|
19
|
+
minimumPurity: number
|
|
20
|
+
maxIdentities: number
|
|
21
|
+
maxAnchorsPerIdentity: number
|
|
22
|
+
maxOriginals: number
|
|
23
|
+
}
|
|
24
|
+
/** Anonymous recurrence is not real-name recognition. These are conservative,
|
|
25
|
+
* unqualified defaults, not measured accuracy claims. Identity cannot go below .88. */
|
|
26
|
+
export const DEFAULT_MAC_SPEAKER_THRESHOLDS: Readonly<MacSpeakerThresholds> = Object.freeze({
|
|
27
|
+
dimensions: 192, anonymousCosine: .90, anonymousMargin: .06,
|
|
28
|
+
identityCosine: .88, identityMargin: .05, minimumIndependentMatches: 2,
|
|
29
|
+
minimumSamples: 32_000, minimumPurity: .95,
|
|
30
|
+
maxIdentities: 128, maxAnchorsPerIdentity: 4, maxOriginals: 8192,
|
|
31
|
+
})
|
|
32
|
+
export interface MacSpeakerOriginal {
|
|
33
|
+
chunkIndex: number
|
|
34
|
+
audioSHA256: string
|
|
35
|
+
pcmSHA256: string
|
|
36
|
+
startSample: number
|
|
37
|
+
sampleCount: number
|
|
38
|
+
}
|
|
39
|
+
export interface MacSpeakerObservation {
|
|
40
|
+
captureId: string
|
|
41
|
+
modelSHA256: string
|
|
42
|
+
original: MacSpeakerOriginal
|
|
43
|
+
startSample: number
|
|
44
|
+
endSample: number
|
|
45
|
+
exclusiveSamples: number
|
|
46
|
+
purity: number
|
|
47
|
+
overlapSamples: number
|
|
48
|
+
mixedSpeakers: boolean
|
|
49
|
+
embedding: readonly number[]
|
|
50
|
+
/** Existing human/manual identity is immutable and never contributes training. */
|
|
51
|
+
protectedLabel?: { label: string; origin: 'named' | 'manual' }
|
|
52
|
+
}
|
|
53
|
+
export interface MacSpeakerAnchor {
|
|
54
|
+
original: MacSpeakerOriginal
|
|
55
|
+
startSample: number
|
|
56
|
+
endSample: number
|
|
57
|
+
exclusiveSamples: number
|
|
58
|
+
purity: number
|
|
59
|
+
embedding: number[]
|
|
60
|
+
/** Binds normalized vector + exact original/sample geometry to model/capture. */
|
|
61
|
+
observationSHA256: string
|
|
62
|
+
}
|
|
63
|
+
export interface MacAcousticIdentity {
|
|
64
|
+
id: number
|
|
65
|
+
/** Null means provisional evidence only; never display it as a settled voice. */
|
|
66
|
+
voice: number | null
|
|
67
|
+
anchors: MacSpeakerAnchor[]
|
|
68
|
+
}
|
|
69
|
+
export interface MacSpeakerMemory {
|
|
70
|
+
version: typeof MAC_SPEAKER_RECONCILE_VERSION
|
|
71
|
+
captureId: string
|
|
72
|
+
modelSHA256: string
|
|
73
|
+
thresholds: MacSpeakerThresholds
|
|
74
|
+
voiceHighWater: number
|
|
75
|
+
identities: MacAcousticIdentity[]
|
|
76
|
+
/** Compact lineage survives per-identity anchor retention. Never prune it to
|
|
77
|
+
* make a crop/replay appear independent; exhaustion explicitly withholds. */
|
|
78
|
+
originals: MacSpeakerOriginal[]
|
|
79
|
+
}
|
|
80
|
+
export type MacSpeakerReason = 'invalid_observation' | 'protected_label' | 'duplicate_source' | 'source_conflict'
|
|
81
|
+
| 'crossed_originals' | 'mixed_or_overlap' | 'insufficient_clean_audio' | 'budget_exhausted'
|
|
82
|
+
| 'ambiguous' | 'inconsistent_anchors' | 'provisional' | 'confirmed_anonymous' | 'recurring_anonymous'
|
|
83
|
+
| 'insufficient_independent_matches' | 'profile_below_floor' | 'profile_ambiguity' | 'profile_disagreement' | 'matched_profile'
|
|
84
|
+
export interface MacSpeakerDecision {
|
|
85
|
+
reason: MacSpeakerReason
|
|
86
|
+
label: string | null
|
|
87
|
+
confidence: 'withheld' | 'provisional' | 'acoustic' | 'protected' | 'profile'
|
|
88
|
+
identityId?: number
|
|
89
|
+
evidence: string[]
|
|
90
|
+
scores: { candidate: string; minimumCosine: number; maximumCosine: number }[]
|
|
91
|
+
provenance: { algorithm: typeof MAC_SPEAKER_RECONCILE_VERSION; captureId: string; modelSHA256: string; thresholds: MacSpeakerThresholds; profileSHA256?: string }
|
|
92
|
+
}
|
|
93
|
+
function config(overrides: Partial<MacSpeakerThresholds> = {}): MacSpeakerThresholds {
|
|
94
|
+
const c = { ...DEFAULT_MAC_SPEAKER_THRESHOLDS, ...overrides }
|
|
95
|
+
for (const k of ['dimensions', 'minimumIndependentMatches', 'minimumSamples', 'maxIdentities', 'maxAnchorsPerIdentity', 'maxOriginals'] as const)
|
|
96
|
+
if (!uint(c[k]) || c[k] < 1) fail('thresholds')
|
|
97
|
+
if (c.dimensions > 4096 || c.minimumIndependentMatches < 2 || c.maxAnchorsPerIdentity < c.minimumIndependentMatches
|
|
98
|
+
|| c.maxAnchorsPerIdentity > 64 || c.maxIdentities > 1024 || c.maxOriginals > 65536) fail('thresholds')
|
|
99
|
+
for (const k of ['anonymousCosine', 'anonymousMargin', 'identityCosine', 'identityMargin', 'minimumPurity'] as const)
|
|
100
|
+
if (!Number.isFinite(c[k]) || c[k] < 0 || c[k] > 1) fail('thresholds')
|
|
101
|
+
if (c.identityCosine < .88 || c.identityMargin <= 0 || c.anonymousMargin <= 0) fail('identity_floor_or_margin')
|
|
102
|
+
return c
|
|
103
|
+
}
|
|
104
|
+
/** Scale before norm to avoid overflow/underflow; no unnormalized dot products. */
|
|
105
|
+
export function normalizedSpeakerEmbedding(values: readonly number[], dimensions: number): number[] | null {
|
|
106
|
+
if (!Array.isArray(values) || values.length !== dimensions || Object.keys(values).length !== values.length || values.some(v => !Number.isFinite(v))) return null
|
|
107
|
+
const scale = Math.max(...values.map(Math.abs))
|
|
108
|
+
if (!(scale > 0)) return null
|
|
109
|
+
const scaled = values.map(v => v / scale)
|
|
110
|
+
const norm = Math.sqrt(scaled.reduce((sum, v) => sum + v * v, 0))
|
|
111
|
+
return scaled.map(v => v / norm)
|
|
112
|
+
}
|
|
113
|
+
export function normalizedSpeakerCosine(a: readonly number[], b: readonly number[]): number | null {
|
|
114
|
+
if (a.length !== b.length || a.length < 1) return null
|
|
115
|
+
const x = normalizedSpeakerEmbedding(a, a.length), y = normalizedSpeakerEmbedding(b, b.length)
|
|
116
|
+
return x && y ? Math.max(-1, Math.min(1, x.reduce((sum, v, i) => sum + v * y[i], 0))) : null
|
|
117
|
+
}
|
|
118
|
+
function validOriginal(o: MacSpeakerOriginal): boolean {
|
|
119
|
+
return !!o && uint(o.chunkIndex) && SHA.test(o.audioSHA256) && SHA.test(o.pcmSHA256)
|
|
120
|
+
&& uint(o.startSample) && uint(o.sampleCount) && o.sampleCount > 0 && o.sampleCount <= 480_000
|
|
121
|
+
&& Number.isSafeInteger(o.startSample + o.sampleCount)
|
|
122
|
+
}
|
|
123
|
+
function anchorHash(s: Pick<MacSpeakerMemory, 'captureId' | 'modelSHA256'>, a: Omit<MacSpeakerAnchor, 'observationSHA256'>): string {
|
|
124
|
+
return digest({ captureId: s.captureId, modelSHA256: s.modelSHA256, ...a })
|
|
125
|
+
}
|
|
126
|
+
function independent(originals: readonly MacSpeakerOriginal[], o: MacSpeakerOriginal): MacSpeakerReason | null {
|
|
127
|
+
for (const previous of originals) {
|
|
128
|
+
if (previous.chunkIndex === o.chunkIndex) return digest(previous) === digest(o) ? 'duplicate_source' : 'source_conflict'
|
|
129
|
+
if (previous.audioSHA256 === o.audioSHA256 || previous.pcmSHA256 === o.pcmSHA256) return 'duplicate_source'
|
|
130
|
+
if (Math.max(previous.startSample, o.startSample) < Math.min(previous.startSample + previous.sampleCount, o.startSample + o.sampleCount)) return 'crossed_originals'
|
|
131
|
+
}
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
export function createMacSpeakerMemory(captureId: string, modelSHA256: string, overrides: Partial<MacSpeakerThresholds> = {}): MacSpeakerMemory {
|
|
135
|
+
if (!captureId || captureId.length > 200 || !SHA.test(modelSHA256)) fail('binding')
|
|
136
|
+
return { version: MAC_SPEAKER_RECONCILE_VERSION, captureId, modelSHA256, thresholds: config(overrides), voiceHighWater: 0, identities: [], originals: [] }
|
|
137
|
+
}
|
|
138
|
+
/** Restored state never resets numbers or accepts modified observation receipts.
|
|
139
|
+
* Integrity is structural; the caller must authenticate persisted bytes and verify
|
|
140
|
+
* extractor/audio hashes. A receipt is not proof of acoustic speaker purity. */
|
|
141
|
+
export function validateMacSpeakerMemory(s: MacSpeakerMemory): void {
|
|
142
|
+
if (!s || s.version !== MAC_SPEAKER_RECONCILE_VERSION || !s.captureId || s.captureId.length > 200 || !SHA.test(s.modelSHA256)) fail('state_binding')
|
|
143
|
+
const c = config(s.thresholds)
|
|
144
|
+
if (!uint(s.voiceHighWater) || s.voiceHighWater > 99 || !Array.isArray(s.identities) || s.identities.length > c.maxIdentities
|
|
145
|
+
|| !Array.isArray(s.originals) || s.originals.length > c.maxOriginals) fail('state_bound')
|
|
146
|
+
const chunkKeys = new Set<number>(), audioKeys = new Set<string>(), pcmKeys = new Set<string>()
|
|
147
|
+
for (const o of s.originals) {
|
|
148
|
+
if (!validOriginal(o) || chunkKeys.has(o.chunkIndex) || audioKeys.has(o.audioSHA256) || pcmKeys.has(o.pcmSHA256)) fail('state_lineage')
|
|
149
|
+
chunkKeys.add(o.chunkIndex); audioKeys.add(o.audioSHA256); pcmKeys.add(o.pcmSHA256)
|
|
150
|
+
}
|
|
151
|
+
const ordered = [...s.originals].sort((a, b) => a.startSample - b.startSample)
|
|
152
|
+
if (ordered.some((o, i) => i > 0 && ordered[i - 1].startSample + ordered[i - 1].sampleCount > o.startSample)) fail('state_lineage')
|
|
153
|
+
const originalBindings = new Map(s.originals.map(o => [o.chunkIndex, digest(o)]))
|
|
154
|
+
const voices = new Set<number>()
|
|
155
|
+
const used = new Set<string>()
|
|
156
|
+
for (let i = 0; i < s.identities.length; i++) {
|
|
157
|
+
const t = s.identities[i]
|
|
158
|
+
if (!t || t.id !== i + 1 || !Array.isArray(t.anchors) || !t.anchors.length || t.anchors.length > c.maxAnchorsPerIdentity
|
|
159
|
+
|| (t.voice !== null && (!uint(t.voice) || t.voice < 1 || t.voice > s.voiceHighWater || voices.has(t.voice)))
|
|
160
|
+
|| (t.voice === null ? t.anchors.length >= c.minimumIndependentMatches : t.anchors.length < c.minimumIndependentMatches)) fail('state_identity')
|
|
161
|
+
if (t.voice !== null) voices.add(t.voice)
|
|
162
|
+
for (const a of t.anchors) {
|
|
163
|
+
const { observationSHA256, ...body } = a
|
|
164
|
+
const embedding = normalizedSpeakerEmbedding(a.embedding, c.dimensions)
|
|
165
|
+
if (!embedding || !validOriginal(a.original) || originalBindings.get(a.original.chunkIndex) !== digest(a.original) || used.has(a.original.pcmSHA256)
|
|
166
|
+
|| !uint(a.startSample) || !uint(a.endSample) || a.startSample < a.original.startSample || a.endSample > a.original.startSample + a.original.sampleCount
|
|
167
|
+
|| a.endSample - a.startSample < c.minimumSamples || !uint(a.exclusiveSamples) || a.exclusiveSamples < c.minimumSamples || a.exclusiveSamples > a.endSample - a.startSample
|
|
168
|
+
|| !Number.isFinite(a.purity) || a.purity < c.minimumPurity || a.purity > 1
|
|
169
|
+
|| Math.abs(a.embedding.reduce((sum, v) => sum + v * v, 0) - 1) > 1e-8 || anchorHash(s, body) !== observationSHA256) fail('state_anchor')
|
|
170
|
+
used.add(a.original.pcmSHA256)
|
|
171
|
+
if (t.anchors.some(b => normalizedSpeakerCosine(a.embedding, b.embedding)! < c.anonymousCosine)) fail('state_incoherent')
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (voices.size !== s.voiceHighWater) fail('state_high_water')
|
|
175
|
+
}
|
|
176
|
+
function decision(s: MacSpeakerMemory, reason: MacSpeakerReason, extras: Partial<MacSpeakerDecision> = {}): MacSpeakerDecision {
|
|
177
|
+
return { reason, label: null, confidence: 'withheld', evidence: [], scores: [],
|
|
178
|
+
provenance: { algorithm: MAC_SPEAKER_RECONCILE_VERSION, captureId: s.captureId, modelSHA256: s.modelSHA256, thresholds: { ...s.thresholds } }, ...extras }
|
|
179
|
+
}
|
|
180
|
+
function observationProblem(s: MacSpeakerMemory, o: MacSpeakerObservation): MacSpeakerReason | null {
|
|
181
|
+
if (!o || o.captureId !== s.captureId || o.modelSHA256 !== s.modelSHA256 || !validOriginal(o.original)
|
|
182
|
+
|| !uint(o.startSample) || !uint(o.endSample) || o.startSample < o.original.startSample || o.endSample > o.original.startSample + o.original.sampleCount
|
|
183
|
+
|| o.endSample <= o.startSample || !uint(o.exclusiveSamples) || o.exclusiveSamples > o.endSample - o.startSample
|
|
184
|
+
|| !Number.isFinite(o.purity) || o.purity < 0 || o.purity > 1 || !uint(o.overlapSamples) || typeof o.mixedSpeakers !== 'boolean'
|
|
185
|
+
|| !normalizedSpeakerEmbedding(o.embedding, s.thresholds.dimensions)) return 'invalid_observation'
|
|
186
|
+
if (o.mixedSpeakers || o.overlapSamples > 0) return 'mixed_or_overlap'
|
|
187
|
+
if (o.exclusiveSamples < s.thresholds.minimumSamples || o.purity < s.thresholds.minimumPurity) return 'insufficient_clean_audio'
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
/** Immutable value transition: caller persists the returned state before publishing
|
|
191
|
+
* a decision. No elapsed-time eviction, silent merge, number reuse or enrollment.
|
|
192
|
+
* Memory budgets describe resources, never the number of people in a recording. */
|
|
193
|
+
export function reconcileMacSpeakerObservation(input: MacSpeakerMemory, o: MacSpeakerObservation): { state: MacSpeakerMemory; decision: MacSpeakerDecision } {
|
|
194
|
+
validateMacSpeakerMemory(input)
|
|
195
|
+
const state = structuredClone(input)
|
|
196
|
+
const result = (reason: MacSpeakerReason, extras: Partial<MacSpeakerDecision> = {}) => ({ state, decision: decision(state, reason, extras) })
|
|
197
|
+
if (o?.protectedLabel && typeof o.protectedLabel.label === 'string' && o.protectedLabel.label.length > 0 && o.protectedLabel.label.length <= 200
|
|
198
|
+
&& ['named', 'manual'].includes(o.protectedLabel.origin)) return result('protected_label', { label: o.protectedLabel.label, confidence: 'protected' })
|
|
199
|
+
if (o?.protectedLabel) return result('invalid_observation')
|
|
200
|
+
const problem = observationProblem(state, o)
|
|
201
|
+
if (problem) return result(problem)
|
|
202
|
+
const repeated = independent(state.originals, o.original)
|
|
203
|
+
if (repeated) return result(repeated)
|
|
204
|
+
if (state.originals.length >= state.thresholds.maxOriginals) return result('budget_exhausted')
|
|
205
|
+
const embedding = normalizedSpeakerEmbedding(o.embedding, state.thresholds.dimensions)!
|
|
206
|
+
const scores = state.identities.map(t => {
|
|
207
|
+
const cosines = t.anchors.map(a => normalizedSpeakerCosine(a.embedding, embedding)!)
|
|
208
|
+
return { candidate: String(t.id), minimumCosine: Math.min(...cosines), maximumCosine: Math.max(...cosines) }
|
|
209
|
+
}).sort((a, b) => b.minimumCosine - a.minimumCosine || Number(a.candidate) - Number(b.candidate))
|
|
210
|
+
const best = scores[0]
|
|
211
|
+
let target: MacAcousticIdentity | undefined
|
|
212
|
+
if (best && best.minimumCosine >= state.thresholds.anonymousCosine) {
|
|
213
|
+
const competitor = Math.max(-1, ...scores.slice(1).map(s => s.maximumCosine))
|
|
214
|
+
if (best.minimumCosine - competitor < state.thresholds.anonymousMargin) return result('ambiguous', { scores })
|
|
215
|
+
target = state.identities[Number(best.candidate) - 1]
|
|
216
|
+
} else if (scores.some(s => s.maximumCosine >= state.thresholds.anonymousCosine)) return result('inconsistent_anchors', { scores })
|
|
217
|
+
if (!target) {
|
|
218
|
+
if (state.identities.length >= state.thresholds.maxIdentities) return result('budget_exhausted', { scores })
|
|
219
|
+
target = { id: state.identities.length + 1, voice: null, anchors: [] }
|
|
220
|
+
state.identities.push(target)
|
|
221
|
+
}
|
|
222
|
+
if (target.voice === null && target.anchors.length + 1 >= state.thresholds.minimumIndependentMatches && state.voiceHighWater >= 99) return result('budget_exhausted', { scores })
|
|
223
|
+
const body = { original: structuredClone(o.original), startSample: o.startSample, endSample: o.endSample, exclusiveSamples: o.exclusiveSamples, purity: o.purity, embedding }
|
|
224
|
+
const anchor = { ...body, observationSHA256: anchorHash(state, body) }
|
|
225
|
+
state.originals.push(structuredClone(o.original))
|
|
226
|
+
const wasSettled = target.voice !== null
|
|
227
|
+
target.anchors.push(anchor)
|
|
228
|
+
// Keep initial independent witnesses forever; only replace later reference
|
|
229
|
+
// observations. This prevents a chain of moving centroids drifting identities.
|
|
230
|
+
if (target.anchors.length > state.thresholds.maxAnchorsPerIdentity) target.anchors.splice(state.thresholds.minimumIndependentMatches, 1)
|
|
231
|
+
if (target.voice === null && target.anchors.length >= state.thresholds.minimumIndependentMatches) {
|
|
232
|
+
if (state.voiceHighWater >= Number.MAX_SAFE_INTEGER) fail('number_exhausted')
|
|
233
|
+
target.voice = ++state.voiceHighWater
|
|
234
|
+
}
|
|
235
|
+
const evidence = [...new Set([...target.anchors.map(a => a.observationSHA256), anchor.observationSHA256])]
|
|
236
|
+
validateMacSpeakerMemory(state)
|
|
237
|
+
return result(target.voice === null ? 'provisional' : wasSettled ? 'recurring_anonymous' : 'confirmed_anonymous', {
|
|
238
|
+
confidence: target.voice === null ? 'provisional' : 'acoustic', label: target.voice === null ? null : `Voice ${target.voice}`, identityId: target.id, evidence, scores,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface MacSpeakerProfile {
|
|
243
|
+
name: string
|
|
244
|
+
/** Freeze/hash the source profile file separately; these are its raw rows. */
|
|
245
|
+
embeddings: readonly (readonly number[])[]
|
|
246
|
+
profileSHA256: string
|
|
247
|
+
}
|
|
248
|
+
/** Deliberately preserves production raw-average then normalize centroid math.
|
|
249
|
+
* Diagnostics based on best-row scores must never replace this recognition rule. */
|
|
250
|
+
function profileCentroid(rows: readonly (readonly number[])[], dimensions: number): number[] | null {
|
|
251
|
+
if (!Array.isArray(rows) || !rows.length || rows.length > 8192 || Object.keys(rows).length !== rows.length || rows.some(r => !normalizedSpeakerEmbedding(r, dimensions))) return null
|
|
252
|
+
let scale = 0
|
|
253
|
+
for (const row of rows) for (const value of row) scale = Math.max(scale, Math.abs(value))
|
|
254
|
+
const mean = Array.from({ length: dimensions }, (_, i) => rows.reduce((sum, r) => sum + r[i] / scale / rows.length, 0))
|
|
255
|
+
return normalizedSpeakerEmbedding(mean, dimensions)
|
|
256
|
+
}
|
|
257
|
+
/** Separate, read-only real-name proposal. All independent query witnesses must
|
|
258
|
+
* agree on the same profile, individually clear .88 and the top-two margin.
|
|
259
|
+
* Does not mutate acoustic memory or enroll any evidence. */
|
|
260
|
+
export function matchMacSpeakerProfile(memory: MacSpeakerMemory, observations: readonly MacSpeakerObservation[], profiles: readonly MacSpeakerProfile[]): MacSpeakerDecision {
|
|
261
|
+
validateMacSpeakerMemory(memory)
|
|
262
|
+
const result = (reason: MacSpeakerReason, extras: Partial<MacSpeakerDecision> = {}) => decision(memory, reason, extras)
|
|
263
|
+
if (!Array.isArray(observations) || observations.length > memory.thresholds.maxOriginals || !Array.isArray(profiles) || profiles.length > 1024) return result('invalid_observation')
|
|
264
|
+
if (Object.keys(profiles).length !== profiles.length || profiles.some(p => !p || typeof p.name !== 'string')) return result('invalid_observation')
|
|
265
|
+
const candidates = profiles.map(p => ({ ...p, centroid: profileCentroid(p.embeddings, memory.thresholds.dimensions) }))
|
|
266
|
+
if (candidates.some(p => !checkSpeakerName(p.name).ok || /^(?:Voice\s+\d+|Ext|Speaker\s+\d+)$/i.test(p.name) || !SHA.test(p.profileSHA256) || !p.centroid)
|
|
267
|
+
|| new Set(candidates.map(p => p.name.toLowerCase())).size !== candidates.length) return result('invalid_observation')
|
|
268
|
+
const originals: MacSpeakerOriginal[] = []
|
|
269
|
+
const anchors: MacSpeakerAnchor[] = []
|
|
270
|
+
for (const o of observations) {
|
|
271
|
+
if (o?.protectedLabel) {
|
|
272
|
+
if (typeof o.protectedLabel.label !== 'string' || !o.protectedLabel.label.length || o.protectedLabel.label.length > 200 || !['named', 'manual'].includes(o.protectedLabel.origin)) return result('invalid_observation')
|
|
273
|
+
return result('protected_label', { label: o.protectedLabel.label, confidence: 'protected' })
|
|
274
|
+
}
|
|
275
|
+
const problem = observationProblem(memory, o)
|
|
276
|
+
if (problem) return result(problem)
|
|
277
|
+
const repeated = independent(originals, o.original)
|
|
278
|
+
if (repeated) return result(repeated)
|
|
279
|
+
originals.push(o.original)
|
|
280
|
+
const body = { original: structuredClone(o.original), startSample: o.startSample, endSample: o.endSample, exclusiveSamples: o.exclusiveSamples, purity: o.purity, embedding: normalizedSpeakerEmbedding(o.embedding, memory.thresholds.dimensions)! }
|
|
281
|
+
anchors.push({ ...body, observationSHA256: anchorHash(memory, body) })
|
|
282
|
+
}
|
|
283
|
+
if (anchors.length < memory.thresholds.minimumIndependentMatches) return result('insufficient_independent_matches')
|
|
284
|
+
if (anchors.some(a => anchors.some(b => normalizedSpeakerCosine(a.embedding, b.embedding)! < memory.thresholds.anonymousCosine))) return result('inconsistent_anchors')
|
|
285
|
+
let winner: typeof candidates[number] | undefined
|
|
286
|
+
const scores: MacSpeakerDecision['scores'] = candidates.map(p => {
|
|
287
|
+
const cosines = anchors.map(a => normalizedSpeakerCosine(a.embedding, p.centroid!)!)
|
|
288
|
+
return { candidate: p.name, minimumCosine: Math.min(...cosines), maximumCosine: Math.max(...cosines) }
|
|
289
|
+
}).sort((a, b) => b.minimumCosine - a.minimumCosine || a.candidate.localeCompare(b.candidate))
|
|
290
|
+
for (const a of anchors) {
|
|
291
|
+
const ranking = candidates.map(p => ({ profile: p, score: normalizedSpeakerCosine(a.embedding, p.centroid!)! })).sort((a, b) => b.score - a.score)
|
|
292
|
+
if (!ranking.length || ranking[0].score < memory.thresholds.identityCosine) return result('profile_below_floor', { scores })
|
|
293
|
+
if (ranking[0].score - (ranking[1]?.score ?? -1) < memory.thresholds.identityMargin) return result('profile_ambiguity', { scores })
|
|
294
|
+
if (winner && winner.name !== ranking[0].profile.name) return result('profile_disagreement', { scores })
|
|
295
|
+
winner = ranking[0].profile
|
|
296
|
+
}
|
|
297
|
+
return result('matched_profile', { label: winner!.name, confidence: 'profile', scores, evidence: anchors.map(a => a.observationSHA256), provenance: {
|
|
298
|
+
algorithm: MAC_SPEAKER_RECONCILE_VERSION, captureId: memory.captureId, modelSHA256: memory.modelSHA256, thresholds: { ...memory.thresholds }, profileSHA256: winner!.profileSHA256,
|
|
299
|
+
} })
|
|
300
|
+
}
|