@gotcos/glasses-server 6.94.1 → 6.94.2

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.
@@ -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
+ }
@@ -0,0 +1,301 @@
1
+ /** Pure, conservative label projection. No file access, capture, inference or enrollment.
2
+ * V1 accepts whole automatic anonymous chunks only. Mixed chunks and words crossing
3
+ * their boundaries require a future, independently reviewed span-level projection.
4
+ * The caller owns durable intents, byte-level file CAS, revisions and rendering.
5
+ */
6
+ import { createHash } from 'node:crypto'
7
+ import { checkSpeakerName } from './speaker-name.js'
8
+ import { hasMacSpeakerProtectedRows, isMacSpeakerHumanProtected as isProtected } from './mac-speaker-protection.js'
9
+
10
+ type Doc = Record<string, any>
11
+ export type MacSpeakerRepairPath = Array<string | number>
12
+ export interface MacSpeakerRepairPatch {
13
+ path: MacSpeakerRepairPath
14
+ before: unknown
15
+ after: unknown
16
+ }
17
+ export interface MacSpeakerWordBinding {
18
+ segmentIndex: number
19
+ wordIndex: number
20
+ from: string
21
+ word: string
22
+ /** Exact segment-relative seconds, not rounded timestamps. */
23
+ start: number
24
+ end: number
25
+ }
26
+ export interface MacProfileMatchEvidence {
27
+ kind: 'profile-match'
28
+ profileName: string
29
+ profileSha256: string
30
+ modelSha256: string
31
+ algorithmVersion: string
32
+ /** Complete-link consistency; every independent pair must meet .90. */
33
+ crossWindowCosines: number[][]
34
+ windows: Array<{
35
+ id: string
36
+ captureStartSample: number
37
+ captureEndSample: number
38
+ pcmSha256: string
39
+ similarity: number
40
+ runnerUpSimilarity: number
41
+ rawChunks: Array<{ rawChunkIndex: number; audioSha256: string; sampleCount: number }>
42
+ }>
43
+ }
44
+ export interface MacSpeakerRepairAssignment {
45
+ rawChunkIndex: number
46
+ from: string
47
+ /** A settled Voice N label, or an existing profile name with targetEvidence. */
48
+ to: string
49
+ evidenceId: string
50
+ batchWords: MacSpeakerWordBinding[]
51
+ targetEvidence?: MacProfileMatchEvidence
52
+ }
53
+ export interface MacSpeakerRepairPlan {
54
+ id: string
55
+ evidenceHash: string
56
+ expectedDocumentHash: string
57
+ /** Explicit ledger-only protection, even for legacy edits without markers. */
58
+ protectedRawChunkIndices: number[]
59
+ /** Verified mono 16 kHz PCM sample counts, including silent/untranscribed raw files. */
60
+ rawAudioSampleCounts: Record<string, number>
61
+ assignments: MacSpeakerRepairAssignment[]
62
+ /** Explicitly withheld words partly outside qualified chunk unions. */
63
+ withheldBatchWords?: Array<MacSpeakerWordBinding & { reason: 'outside-qualified-span' }>
64
+ }
65
+ export interface MacSpeakerRepairReceipt {
66
+ version: 1
67
+ id: string
68
+ evidenceHash: string
69
+ beforeHash: string
70
+ afterHash: string
71
+ rawChunkIndices: number[]
72
+ evidenceIds: string[]
73
+ patches: MacSpeakerRepairPatch[]
74
+ assignments: MacSpeakerRepairAssignment[]
75
+ withheldBatchWords: Array<MacSpeakerWordBinding & { reason: 'outside-qualified-span' }>
76
+ receiptHash: string
77
+ }
78
+ /** Persisted model/profile proof; never a human confirmation or training input. */
79
+ export interface MacSpeakerMachineEvidence {
80
+ version: 1
81
+ correctionRevision: number
82
+ documentBinding: string
83
+ receipt: MacSpeakerRepairReceipt
84
+ rawAudioSampleCounts: Record<string, number>
85
+ }
86
+ /** Exact current speech surfaces, independent of lifecycle/sync metadata and
87
+ * the proof itself. This deliberately binds diagnostics as well as labels. */
88
+ export function hashMacSpeakerMachineBinding(doc: Doc): string {
89
+ return hashMacSpeakerDocument({
90
+ sessionId: doc.sessionId, sourceType: doc.sourceType, captureSource: doc.captureSource,
91
+ startTime: doc.startTime, durationMs: doc.durationMs,
92
+ chunks: doc.chunks, chunkEntries: doc.chunkEntries,
93
+ batchSegments: doc.batchSegments, batchTranscript: doc.batchTranscript, speakers: doc.speakers,
94
+ })
95
+ }
96
+ export function hashMacSpeakerDocument(doc: Doc): string {
97
+ return createHash('sha256').update(JSON.stringify(doc)).digest('hex')
98
+ }
99
+ const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
100
+ const anonymous = (label: unknown): label is string => typeof label === 'string' && /^(?:Ext|Unknown|Unidentified(?: \d+)?|Speaker \d+|Voice \d+)$/.test(label)
101
+ const hashPattern = /^[a-f0-9]{64}$/
102
+ function fail(message: string): never { throw new Error(`Mac speaker repair: ${message}`) }
103
+ /** Validate compacted position -> exact ordered raw chunk, without nearest joins. */
104
+ function rawMap(doc: Doc): Array<{ raw: number; entry: number }> {
105
+ if (!Array.isArray(doc.chunks) || !Array.isArray(doc.chunkEntries)) fail('missing raw chunk map')
106
+ const all = doc.chunkEntries.map((entry: any, i: number) => ({ entry, i }))
107
+ if (all.some(({ entry }: any) => !entry || !Number.isInteger(entry.chunkIndex) || entry.chunkIndex < 0)) fail('invalid raw chunk entry')
108
+ const indexes = all.map(({ entry }: any) => entry.chunkIndex)
109
+ if (new Set(indexes).size !== indexes.length || indexes.some((raw: number, i: number) => i > 0 && raw <= indexes[i - 1])) fail('ambiguous raw index ordering')
110
+ const spoken = all.filter(({ entry }: any) => entry.chunk?.text)
111
+ if (spoken.length !== doc.chunks.length || spoken.some(({ entry }: any, i: number) => entry.chunk.text !== doc.chunks[i]?.text)) fail('raw map and ordered text disagree')
112
+ return spoken.map(({ entry, i }: any) => ({ raw: entry.chunkIndex, entry: i }))
113
+ }
114
+ function labelRows(chunk: Doc, path: MacSpeakerRepairPath): Array<{ row: Doc; path: MacSpeakerRepairPath }> {
115
+ const rows = [{ row: chunk, path }]
116
+ for (const key of ['segments', 'finalSegments', 'words']) {
117
+ if (chunk[key] !== undefined && !Array.isArray(chunk[key])) fail(`invalid ${key} surface`)
118
+ for (const [i, row] of (chunk[key] ?? []).entries()) {
119
+ if (!row || typeof row !== 'object') fail(`invalid ${key} row`)
120
+ // Plain ASR words do not necessarily have any speaker field.
121
+ if (key !== 'words' || 'speaker' in row) rows.push({ row, path: [...path, key, i] })
122
+ }
123
+ }
124
+ return rows
125
+ }
126
+ function requireClosed(doc: Doc) {
127
+ if (doc.finalizationState !== 'complete' || doc.claimPending === true || doc.active === true || doc.isActive === true || doc.status === 'recording') fail('capture is not closed and finalized')
128
+ if (doc.sourceType !== 'mac_system_audio' || !/^mac_/.test(doc.sessionId ?? '') || doc.mergedFrom || doc.sourceSessionIds || doc.isMerged) fail('expected an original Mac capture')
129
+ }
130
+ function receiptDigest(receipt: Omit<MacSpeakerRepairReceipt, 'receiptHash'>): string { return hashMacSpeakerDocument(receipt) }
131
+
132
+ export function validateMacSpeakerTargetEvidence(input: Doc, plan: MacSpeakerRepairPlan, assignment: MacSpeakerRepairAssignment) {
133
+ if (/^Voice (?:[1-9]|[1-9]\d)$/.test(assignment.to)) return
134
+ const proof = assignment.targetEvidence
135
+ if (!checkSpeakerName(assignment.to).ok || !assignment.to || assignment.to.trim() !== assignment.to || assignment.to.length > 120 || /[\[\]\r\n]/.test(assignment.to) || anonymous(assignment.to) || !proof || proof.kind !== 'profile-match' || proof.profileName !== assignment.to || !hashPattern.test(proof.profileSha256) || !hashPattern.test(proof.modelSha256) || !proof.algorithmVersion?.trim() || !Array.isArray(proof.windows) || proof.windows.length < 2) fail('named target requires immutable independent profile evidence')
136
+ if (!Array.isArray(proof.crossWindowCosines) || proof.crossWindowCosines.length !== proof.windows.length || proof.crossWindowCosines.some(row => !Array.isArray(row) || row.length !== proof.windows.length)) fail('complete cross-window evidence matrix required')
137
+ for (let i = 0; i < proof.windows.length; i++) for (let j = 0; j < proof.windows.length; j++) {
138
+ const cosine = proof.crossWindowCosines[i][j]
139
+ if (!Number.isFinite(cosine) || cosine < .9 || cosine > 1.000001 || Math.abs(cosine - proof.crossWindowCosines[j][i]) > 1e-8 || (i === j && Math.abs(cosine - 1) > 1e-6)) fail('cross-window evidence is inconsistent')
140
+ }
141
+ const rawSeen = new Set<number>(), pcmSeen = new Set<string>(), ids = new Set<string>(), spans: Array<[number, number]> = []
142
+ let covered = false
143
+ for (const window of proof.windows) {
144
+ if (!window.id || ids.has(window.id) || !hashPattern.test(window.pcmSha256) || pcmSeen.has(window.pcmSha256) || !Number.isSafeInteger(window.captureStartSample) || !Number.isSafeInteger(window.captureEndSample) || window.captureStartSample < 0 || window.captureEndSample <= window.captureStartSample || !Number.isFinite(window.similarity) || window.similarity < .88 || window.similarity > 1 || !Number.isFinite(window.runnerUpSimilarity) || window.runnerUpSimilarity < -1 || window.runnerUpSimilarity > 1 || window.similarity - window.runnerUpSimilarity < .05 || !Array.isArray(window.rawChunks) || !window.rawChunks.length) fail('weak, repeated or invalid profile evidence')
145
+ if (spans.some(([start, end]) => window.captureStartSample < end && window.captureEndSample > start)) fail('overlapping profile evidence windows')
146
+ spans.push([window.captureStartSample, window.captureEndSample]); pcmSeen.add(window.pcmSha256); ids.add(window.id)
147
+ const duration = (window.captureEndSample - window.captureStartSample) / 16000
148
+ if (duration < 28 || duration > 31) fail('profile evidence requires full 28-31s windows')
149
+ let samples = 0, previous = -1, pureSeconds = 0
150
+ for (const raw of window.rawChunks) {
151
+ if (!Number.isInteger(raw.rawChunkIndex) || rawSeen.has(raw.rawChunkIndex) || (previous >= 0 && raw.rawChunkIndex !== previous + 1) || !hashPattern.test(raw.audioSha256) || !Number.isSafeInteger(raw.sampleCount) || raw.sampleCount <= 0 || plan.rawAudioSampleCounts[String(raw.rawChunkIndex)] !== raw.sampleCount) fail('profile evidence raw bindings disagree')
152
+ const chunk = input.chunkEntries.find((entry: Doc) => entry.chunkIndex === raw.rawChunkIndex)?.chunk
153
+ if (!chunk || chunk.audioSha256 !== raw.audioSha256 || !Number.isFinite(chunk.elapsed) || chunk.elapsed * 16 !== window.captureStartSample + samples) fail('profile evidence capture coordinates disagree')
154
+ if (chunk.speakerPolicy === 'anonymous' || !anonymous(chunk.speaker) || hasMacSpeakerProtectedRows(chunk) || labelRows(chunk, []).some(({row}) => isProtected(row) || row.speaker !== chunk.speaker)) fail('profile evidence contains named/manual/mixed or anonymous-policy chunks')
155
+ const segments = chunk.finalSegments?.length ? chunk.finalSegments : chunk.segments
156
+ if (!Array.isArray(segments) || !segments.length) fail('profile evidence lacks automatic pure segment coverage')
157
+ const ranges: Array<[number, number]> = segments.map((segment: Doc) => [segment.startSec, segment.endSec])
158
+ if (ranges.some(([start,end]) => !Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || end > raw.sampleCount / 16000)) fail('profile evidence segment coordinates disagree')
159
+ ranges.sort((a,b)=>a[0]-b[0]); let end = 0
160
+ for (const [start,stop] of ranges) { pureSeconds += Math.max(0,stop-Math.max(start,end)); end=Math.max(end,stop) }
161
+ if (raw.rawChunkIndex === assignment.rawChunkIndex) covered = true
162
+ rawSeen.add(raw.rawChunkIndex); previous = raw.rawChunkIndex; samples += raw.sampleCount
163
+ }
164
+ if (pureSeconds < 20) fail('profile evidence needs at least20s of automatic pure segment coverage')
165
+ if (samples !== window.captureEndSample - window.captureStartSample) fail('profile evidence sample span disagrees')
166
+ }
167
+ if (!covered) fail('assignment lies outside qualified profile evidence')
168
+ }
169
+
170
+ export function projectMacSpeakerRepair(input: Doc, plan: MacSpeakerRepairPlan): { doc: Doc; receipt: MacSpeakerRepairReceipt } {
171
+ requireClosed(input)
172
+ if (!plan.id?.trim() || !hashPattern.test(plan.evidenceHash) || plan.expectedDocumentHash !== hashMacSpeakerDocument(input)) fail('plan evidence or document CAS mismatch')
173
+ if (!Array.isArray(plan.protectedRawChunkIndices) || plan.protectedRawChunkIndices.some(i => !Number.isInteger(i) || i < 0)) fail('explicit ledger protection is required')
174
+ if (!Array.isArray(plan.assignments) || plan.assignments.length === 0) fail('no explicit assignments')
175
+ const mapping = rawMap(input)
176
+ const doc = structuredClone(input)
177
+ const patches: MacSpeakerRepairPatch[] = []
178
+ const patch = (path: MacSpeakerRepairPath, after: unknown) => {
179
+ let row = doc
180
+ for (const key of path.slice(0, -1)) row = row[key]
181
+ const key = path.at(-1)!
182
+ if (equal(row[key], after)) return
183
+ patches.push({ path, before: structuredClone(row[key]), after: structuredClone(after) })
184
+ row[key] = structuredClone(after)
185
+ }
186
+ if (!plan.rawAudioSampleCounts || typeof plan.rawAudioSampleCounts !== 'object') fail('verified raw audio sample counts are required')
187
+ const selected = new Map<number, { assignment: MacSpeakerRepairAssignment }>()
188
+ for (const assignment of plan.assignments) {
189
+ if (selected.has(assignment.rawChunkIndex) || plan.protectedRawChunkIndices.includes(assignment.rawChunkIndex)) fail('duplicate or ledger-protected chunk')
190
+ if (!anonymous(assignment.from) || !assignment.evidenceId?.trim() || !Array.isArray(assignment.batchWords)) fail('assignment requires anonymous labels and explicit evidence')
191
+ validateMacSpeakerTargetEvidence(input, plan, assignment)
192
+ const position = mapping.findIndex(row => row.raw === assignment.rawChunkIndex)
193
+ if (position < 0) fail('unknown raw chunk')
194
+ const chunk = input.chunks[position]
195
+ const entry = input.chunkEntries[mapping[position].entry].chunk
196
+ if (!Number.isFinite(chunk.elapsed) || !Number.isFinite(chunk.audioDurationMs) || chunk.audioDurationMs <= 0 || entry.elapsed !== chunk.elapsed || entry.audioDurationMs !== chunk.audioDurationMs) fail('raw chunk timing disagreement')
197
+ // Surface identity must match, including text/times/original diagnostic fields.
198
+ for (const key of ['speaker', 'segments', 'finalSegments', 'words']) if (!equal(chunk[key], entry[key])) fail('chunk and entry label surfaces disagree')
199
+ if (hasMacSpeakerProtectedRows(chunk) || hasMacSpeakerProtectedRows(entry)) fail('named or manual span is protected')
200
+ for (const target of [{ chunk, path: ['chunks', position] }, { chunk: entry, path: ['chunkEntries', mapping[position].entry, 'chunk'] }]) {
201
+ for (const { row, path } of labelRows(target.chunk, target.path)) {
202
+ if (isProtected(row) || !anonymous(row.speaker)) fail('named or manual span is protected')
203
+ if (row.speaker !== assignment.from) fail('mixed chunk requires exact span repair')
204
+ patch([...path, 'speaker'], assignment.to)
205
+ }
206
+ }
207
+ selected.set(assignment.rawChunkIndex, { assignment })
208
+ }
209
+ const withheld = new Map<string, MacSpeakerWordBinding>(), usedWithheld = new Set<string>()
210
+ for (const binding of plan.withheldBatchWords ?? []) {
211
+ const key = `${binding.segmentIndex}:${binding.wordIndex}`
212
+ if (binding.reason !== 'outside-qualified-span' || withheld.has(key) || !Number.isInteger(binding.segmentIndex) || !Number.isInteger(binding.wordIndex)) fail('invalid withheld boundary word binding')
213
+ withheld.set(key, binding)
214
+ }
215
+ const expectedWords = new Map<string, MacSpeakerRepairAssignment>()
216
+ const batchSegments = input.batchSegments ?? []
217
+ if (!Array.isArray(batchSegments)) fail('invalid batch segments')
218
+ for (const [si, segment] of batchSegments.entries()) {
219
+ if (!Array.isArray(segment.speakerWords)) fail('batch speaker words are missing')
220
+ const rangeSelected = [...selected.keys()].some(raw => raw >= segment.startChunkIdx && raw <= segment.endChunkIdx)
221
+ if (!rangeSelected) continue
222
+ if (!Number.isInteger(segment.startChunkIdx) || !Number.isInteger(segment.endChunkIdx) || segment.startChunkIdx < 0 || segment.endChunkIdx < segment.startChunkIdx) fail('invalid batch raw range')
223
+ // Batch seconds index concatenated PCM files, including silent files. They
224
+ // are NOT startElapsed + seconds on the compacted transcript timeline.
225
+ const intervals: Array<{ raw: number; start: number; end: number }> = []
226
+ let offset = 0
227
+ for (let raw = segment.startChunkIdx; raw <= segment.endChunkIdx; raw++) {
228
+ const samples = plan.rawAudioSampleCounts[String(raw)]
229
+ if (!Number.isSafeInteger(samples) || samples <= 0) fail('missing verified raw audio sample count')
230
+ const end = offset + samples / 16000
231
+ intervals.push({ raw, start: offset, end })
232
+ offset = end
233
+ }
234
+ for (const [wi, word] of segment.speakerWords.entries()) {
235
+ if (!Number.isFinite(word.start) || !Number.isFinite(word.end) || word.start < 0 || word.end < word.start || word.end > offset) fail('invalid batch word timing')
236
+ const overlaps = intervals.filter(s => word.end === word.start ? word.start >= s.start && word.start < s.end : word.start < s.end && word.end > s.start)
237
+ if (!overlaps.some(s => selected.has(s.raw))) continue
238
+ if (overlaps.some(s => !selected.has(s.raw))) {
239
+ const key = `${si}:${wi}`, binding = withheld.get(key)
240
+ if (!binding || word.word !== binding.word || word.start !== binding.start || word.end !== binding.end || word.speaker !== binding.from) fail('batch word crosses selected raw chunk boundaries')
241
+ usedWithheld.add(key); continue
242
+ }
243
+ if (new Set(overlaps.map(s => selected.get(s.raw)?.assignment.to)).size !== 1) fail('batch word crosses selected raw chunk boundaries')
244
+ const span = selected.get(overlaps[0].raw)!
245
+ if (isProtected(word) || !anonymous(word.speaker)) fail('named or manual batch word is protected')
246
+ expectedWords.set(`${si}:${wi}`, span.assignment)
247
+ }
248
+ }
249
+ if (usedWithheld.size !== withheld.size) fail('withheld boundary word is not outside qualified span')
250
+ const seen = new Set<string>()
251
+ for (const assignment of plan.assignments) for (const binding of assignment.batchWords) {
252
+ const key = `${binding.segmentIndex}:${binding.wordIndex}`
253
+ if (!Number.isInteger(binding.segmentIndex) || !Number.isInteger(binding.wordIndex) || seen.has(key) || expectedWords.get(key) !== assignment) fail('unexpected or duplicate batch word binding')
254
+ seen.add(key)
255
+ const word = batchSegments[binding.segmentIndex].speakerWords[binding.wordIndex]
256
+ if (word.word !== binding.word || word.start !== binding.start || word.end !== binding.end || word.speaker !== binding.from) fail('batch word evidence disagrees')
257
+ patch(['batchSegments', binding.segmentIndex, 'speakerWords', binding.wordIndex, 'speaker'], assignment.to)
258
+ }
259
+ if (seen.size !== expectedWords.size) fail('incomplete batch word coverage')
260
+ const labels: string[] = []
261
+ for (const chunk of doc.chunks) for (const { row } of labelRows(chunk, [])) if (typeof row.speaker === 'string' && row.speaker && !anonymous(row.speaker)) labels.push(row.speaker)
262
+ for (const segment of doc.batchSegments ?? []) for (const word of segment.speakerWords ?? []) if (typeof word.speaker === 'string' && word.speaker && !anonymous(word.speaker)) labels.push(word.speaker)
263
+ // Do not invent identities, and do not carry anonymous voices into a real-name list.
264
+ patch(['speakers'], [...new Set(labels)])
265
+ const unsigned: Omit<MacSpeakerRepairReceipt, 'receiptHash'> = {
266
+ version: 1, id: plan.id, evidenceHash: plan.evidenceHash,
267
+ beforeHash: hashMacSpeakerDocument(input), afterHash: hashMacSpeakerDocument(doc),
268
+ assignments: structuredClone(plan.assignments), withheldBatchWords: structuredClone(plan.withheldBatchWords ?? []),
269
+ rawChunkIndices: plan.assignments.map(row => row.rawChunkIndex), evidenceIds: plan.assignments.map(row => row.evidenceId), patches,
270
+ }
271
+ return { doc, receipt: { ...unsigned, receiptHash: receiptDigest(unsigned) } }
272
+ }
273
+
274
+ /** Full-document CAS is intentionally conservative: even unrelated drift needs
275
+ * a fresh review. This pure operation never overwrites a later correction.
276
+ * Replaying a completed apply/undo is idempotent; partial states fail closed.
277
+ */
278
+ export function applyMacSpeakerRepair(input: Doc, receipt: MacSpeakerRepairReceipt, direction: 'forward' | 'reverse' = 'forward'): Doc {
279
+ const { receiptHash, ...unsigned } = receipt
280
+ if (receipt.version !== 1 || receiptDigest(unsigned) !== receiptHash) fail('receipt integrity mismatch')
281
+ const forward = direction === 'forward'
282
+ const before = forward ? receipt.beforeHash : receipt.afterHash
283
+ const after = forward ? receipt.afterHash : receipt.beforeHash
284
+ const current = hashMacSpeakerDocument(input)
285
+ if (current === after) return structuredClone(input)
286
+ if (current !== before) fail('document drift; review before apply or undo')
287
+ const doc = structuredClone(input)
288
+ const ordered = forward ? receipt.patches : [...receipt.patches].reverse()
289
+ for (const patch of ordered) {
290
+ if (!patch.path.length || patch.path.some(key => ['__proto__', 'prototype', 'constructor'].includes(String(key)))) fail('unsafe patch path')
291
+ let row = doc
292
+ for (const key of patch.path.slice(0, -1)) { if (!row || typeof row !== 'object' || !(key in row)) fail('patch shape drift'); row = row[key] }
293
+ const key = patch.path.at(-1)!
294
+ if (!equal(row[key], forward ? patch.before : patch.after)) fail('patch value drift')
295
+ const value = forward ? patch.after : patch.before
296
+ if (value === undefined) delete row[key]
297
+ else row[key] = structuredClone(value)
298
+ }
299
+ if (hashMacSpeakerDocument(doc) !== after) fail('projection postimage mismatch')
300
+ return doc
301
+ }