@gotcos/glasses-server 6.92.0 → 6.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.env.example +30 -1
  2. package/CHANGELOG.md +57 -29
  3. package/README.md +58 -0
  4. package/docs/active-query-jobs.md +1 -1
  5. package/docs/test-plan-6.71.7.md +1 -1
  6. package/managed-runtime-contract.json +2 -0
  7. package/package.json +2 -1
  8. package/server/lib/ask-session-id.ts +2 -1
  9. package/server/lib/embedding-eviction.ts +60 -2
  10. package/server/lib/fuzzy-correct.ts +1 -1
  11. package/server/lib/hallucination-filter.ts +1 -1
  12. package/server/lib/imported-library-rows.ts +4 -2
  13. package/server/lib/live-cos-ask.ts +3 -2
  14. package/server/lib/mac-live-voices.ts +57 -0
  15. package/server/lib/mac-recording-asr.ts +16 -8
  16. package/server/lib/mac-speaker-projection.ts +211 -0
  17. package/server/lib/mac-stream-diarizer.ts +185 -0
  18. package/server/lib/mac-voice-assignment.ts +361 -0
  19. package/server/lib/mac-voice-labels.ts +70 -8
  20. package/server/lib/mac-window-replay.ts +271 -0
  21. package/server/lib/mac-window-voices.ts +390 -0
  22. package/server/lib/meeting-actions.ts +1 -1
  23. package/server/lib/meeting-ask-owner.ts +4 -4
  24. package/server/lib/meeting-ask-transcript-sources.ts +41 -3
  25. package/server/lib/meeting-audio-archive.ts +4 -0
  26. package/server/lib/meeting-batch-transcribe.ts +7 -1
  27. package/server/lib/meeting-context-keys.ts +6 -3
  28. package/server/lib/meeting-corrections.ts +4 -4
  29. package/server/lib/meeting-fact-check.ts +3 -3
  30. package/server/lib/meeting-parse.ts +1 -1
  31. package/server/lib/meeting-relabel-enrolment.ts +8 -8
  32. package/server/lib/meeting-relabel.ts +3 -3
  33. package/server/lib/meeting-scribe-content.ts +1 -1
  34. package/server/lib/meeting-speaker-review.ts +8 -8
  35. package/server/lib/meeting-store.ts +19 -9
  36. package/server/lib/nemotron-final.ts +114 -9
  37. package/server/lib/nemotron-live.ts +58 -6
  38. package/server/lib/phone-recording.ts +114 -0
  39. package/server/lib/prompt-tail-guard.ts +1 -1
  40. package/server/lib/speaker-embeddings.ts +154 -28
  41. package/server/lib/speaker-name.ts +1 -1
  42. package/server/lib/stranded-session-actions.ts +7 -2
  43. package/server/lib/training-audio-provenance.ts +3 -2
  44. package/server/lib/transcript-cleanup-context.ts +3 -3
  45. package/server/lib/transcript-cleanup-model.ts +1 -1
  46. package/server/lib/transcript-cleanup-spans.ts +10 -10
  47. package/server/lib/transcript-cleanup-text.ts +4 -4
  48. package/server/lib/transcript-cleanup.ts +1 -1
  49. package/server/lib/unsaved-audio-quarantine.ts +10 -1
  50. package/server/lib/voice-directory.ts +1 -1
  51. package/server/lib/voice-evidence-guard.ts +2 -1
  52. package/server/lib/voice-profile-store.ts +11 -5
  53. package/server/lib/whisper-local.ts +1 -1
  54. package/server/routes/health.ts +8 -0
  55. package/server/routes/meeting.ts +189 -19
  56. package/server/routes/meetings.ts +9 -5
  57. package/server/routes/transcribe-stream.ts +140 -15
  58. package/server/routes/voice.ts +12 -4
  59. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
  60. package/server/scripts/mac-window-voices-6930-mutation-gate.mjs +193 -0
@@ -0,0 +1,361 @@
1
+ // 6.93.0: assign a Mac recording's `Voice N` to an EXISTING voice profile.
2
+ //
3
+ // Mac audio is call, video and podcast playback through one global tap. Until now a
4
+ // Mac rename was text only (6.90.0) and `enrolNamedVoice` still refuses every Mac
5
+ // capture on purpose. Miles asked for the one exception: when a person picks an
6
+ // ENROLLED name for a `Voice N` and says so, that voice's audio from THIS meeting
7
+ // teaches that person's profile. This module is that exception and nothing wider.
8
+ //
9
+ // WHERE THE AUDIO COMES FROM. A Mac recording banks no per-chunk embeddings
10
+ // (transcribe-stream.ts never calls appendChunkEmbedding for a Mac capture), so the
11
+ // chunk-embedding store holds nothing for it. What survives a save is the chunk
12
+ // WAVs in the 7-day meeting-audio archive, keyed on the RAW capture index, plus the
13
+ // sidecar's per-turn `startSec`/`endSec`. So embeddings are extracted here, now, from
14
+ // those WAVs. Nothing is invented: no archived audio means no samples and a named
15
+ // refusal reason (`audio_not_retained`).
16
+ //
17
+ // WHICH AUDIO COUNTS. Only chunks the rename actually relabels (their dominant turn
18
+ // is `from`). Inside such a chunk:
19
+ // - every turn is `from` (or the chunk has no turns): the whole chunk;
20
+ // - another voice also speaks: only this voice's turns of at least
21
+ // MAC_ASSIGN_MIN_SLICE_SEC, trimmed by MAC_ASSIGN_SLICE_INSET_SEC at each end so
22
+ // a neighbour's word estimate cannot leak in;
23
+ // - nothing long enough: the chunk is skipped (`multiVoiceSkipped`).
24
+ // Then the same two gates a G2 rename uses: the dominant mutually coherent group
25
+ // (dominantCoherentCluster, floor = the merge/search floor) and the diversity cap
26
+ // (greedyDiversitySelect, MAX_ENROL_PER_CORRECTION). Neither is changed here.
27
+ //
28
+ // FAIL CLOSED. `planMacVoiceAssignment` reads and writes nothing in the profile
29
+ // store; the caller shows its report, and only `commitMacVoiceAssignment` writes,
30
+ // using the SAME selected vectors the plan produced. The person must already be a
31
+ // profile: this path never creates one, and never the owner's.
32
+ //
33
+ // THREE GATES, all refused with a 409 unless the request carries `force: true` (6.93.0,
34
+ // the 2026-10-26 QA). A Mac `Voice N` is not a person: the saved pass keeps every live
35
+ // number, so a clean hand-over at a chunk boundary (R1) leaves two people under one
36
+ // number, and a name typed over it would write the wrong voice into a real profile.
37
+ // below_floor the voice's group sounds unlike the person's stored profile (cosine
38
+ // under MERGE_SIMILARITY_FLOOR, the same floor merge-profiles refuses at).
39
+ // mixed_voice the coherence gate threw out units as a different voice AND the
40
+ // kept group is under two thirds of what was embedded: the label
41
+ // holds more than one voice and the dominant one may be the wrong one.
42
+ // too_little_audio fewer than MAC_ASSIGN_MIN_UNITS coherent units and under
43
+ // MAC_ASSIGN_MIN_SINGLE_SEC of audio: one short unit cannot contradict itself.
44
+ // The report carries every number a person needs to judge it (attempted, clusterSkipped,
45
+ // similarity), so the caller shows them rather than a warning.
46
+ //
47
+ // SAMPLES ARE `mac-assign:<sessionId>`, NOT `correction:`. They rank as assisted, are not
48
+ // protected, do not vouch for identity, cannot displace any other sample (only another
49
+ // Mac-assigned one), and are capped per profile (embedding-eviction.ts). "Not in this
50
+ // meeting" still retracts them (training-audio-provenance.ts).
51
+ //
52
+ // DOES NOT STALL THE SERVER. Embedding is a synchronous native call (about 0.14 s a unit
53
+ // on an M3). The plan yields to the event loop between units, stops at a wall-clock
54
+ // deadline, and caches each vector, so the apply after a preview re-embeds nothing and a
55
+ // plan that hit the deadline resumes where it stopped when asked again. The commit is one
56
+ // batched store write (enrollEmbeddingsBatch).
57
+
58
+ import { readFileSync, statSync } from 'node:fs'
59
+ import { chunkEmbeddingsEnabled } from './chunk-embedding-store.js'
60
+ import { isMacCaptureSidecar, isVoiceLabel } from './mac-voice-labels.js'
61
+ import { meetingAudioChunkPath } from './meeting-audio-archive.js'
62
+ import { isPlaceholderLabel, resolveRawChunkIndices } from './meeting-relabel-enrolment.js'
63
+ import { pcm16ToWav, readPcm16Mono } from './nemotron-segments.js'
64
+ import { MAC_ASSIGN_SOURCE, isMacAssign, macAssignCapFor } from './embedding-eviction.js'
65
+ import {
66
+ MAX_EMBEDDINGS_PER_SPEAKER,
67
+ MERGE_SIMILARITY_FLOOR,
68
+ enrollEmbeddingsBatch,
69
+ extractEmbedding,
70
+ isEmbeddingAvailable,
71
+ rawCosineSimilarity,
72
+ readVoiceProfiles,
73
+ } from './speaker-embeddings.js'
74
+ import {
75
+ MAX_ENROL_PER_CORRECTION,
76
+ dominantCoherentCluster,
77
+ greedyDiversitySelect,
78
+ } from './voice-enrolment-selection.js'
79
+ import { VOICEPRINT_MIN_AUDIO_SEC } from './voiceprint-floor.js'
80
+
81
+ /** A turn shorter than this is not sliced out of a mixed chunk. */
82
+ export const MAC_ASSIGN_MIN_SLICE_SEC = 3.0
83
+ /** Trimmed from each end of a sliced turn. */
84
+ export const MAC_ASSIGN_SLICE_INSET_SEC = 0.15
85
+ /** Most audio units embedded per assignment, spread across the meeting. Bounds the CPU a request can spend. */
86
+ export const MAC_ASSIGN_MAX_UNITS = 40
87
+ /** A coherent group of fewer units than this needs MAC_ASSIGN_MIN_SINGLE_SEC of audio, or it is `too_little_audio`. */
88
+ export const MAC_ASSIGN_MIN_UNITS = 2
89
+ export const MAC_ASSIGN_MIN_SINGLE_SEC = 6
90
+ /** Wall-clock budget for one plan. A plan that hits it reports `deadline`; the vectors it made are kept for the retry. */
91
+ export const MAC_ASSIGN_DEADLINE_MS = 12_000
92
+ /** A kept group under this fraction of what was embedded, with units thrown out, is `mixed_voice`. */
93
+ export const MAC_ASSIGN_MIXED_FRACTION = 2 / 3
94
+ const SAMPLE_RATE = 16_000
95
+ const VECTOR_CACHE_MAX = 160
96
+
97
+ export type MacAssignSkip =
98
+ | null
99
+ | 'not_mac_capture'
100
+ | 'not_a_voice_label'
101
+ | 'unknown_profile'
102
+ | 'disabled'
103
+ | 'store_unavailable'
104
+ | 'no_index_mapping'
105
+ | 'audio_not_retained'
106
+ | 'no_usable_audio'
107
+ | 'owner_profile'
108
+ | 'no_stored_voice'
109
+ | 'profile_full'
110
+ | 'deadline'
111
+
112
+ /** Why a plan that has audio still needs `force: true` to be written. */
113
+ export type MacAssignGate = 'below_floor' | 'mixed_voice' | 'too_little_audio'
114
+
115
+ export interface MacAssignReport {
116
+ /** The enrolled person the samples would go to. */
117
+ person: string
118
+ /** Samples written, or, on a plan, the most that would be (the store's near-duplicate gate can still drop some). */
119
+ enrolled: number
120
+ /** Audio units embedded for this voice, before the coherence gate. */
121
+ attempted: number
122
+ /** Always false: this path appends to an existing profile and never makes one. */
123
+ created: false
124
+ /** Units rejected as a different voice by the coherence gate. */
125
+ clusterSkipped: number
126
+ /** Chunks where another voice also speaks and this one had no turn long enough to use. */
127
+ multiVoiceSkipped: number
128
+ /** Whole chunks under the voiceprint minimum. */
129
+ shortSkipped: number
130
+ /** Chunks whose archived audio is gone. */
131
+ missingAudio: number
132
+ /** Cosine of this voice's group centroid against the person's stored centroid, or null when there is no group. A suggestion, not a verdict. */
133
+ similarity: number | null
134
+ /** True when `similarity` is under the merge floor: the voice sounds unlike the stored profile. */
135
+ belowFloor: boolean
136
+ floor: number
137
+ /** Coherent units kept, before the diversity cap, and their total audio in seconds. */
138
+ groupSize: number
139
+ groupSec: number
140
+ /** Gates that need `force: true`, strongest first. Empty when the plan can be written as it stands. */
141
+ gates: MacAssignGate[]
142
+ skipped: MacAssignSkip
143
+ }
144
+
145
+ export interface MacAssignInput {
146
+ sessionId: string
147
+ from: string
148
+ to: string
149
+ /** `plan.value.changed`: COMPACTED sidecar positions, never raw indices. */
150
+ changed: number[]
151
+ /** The parsed sidecar BEFORE the rename (turn labels are still `from`). */
152
+ sidecar: Record<string, unknown>
153
+ /** The wearer's own speaker label. Never a target: Mac capture has no microphone. */
154
+ ownerLabel?: string
155
+ /** Test seams. */
156
+ deadlineMs?: number
157
+ now?: () => number
158
+ yieldToLoop?: () => Promise<void>
159
+ }
160
+
161
+ export interface MacAssignPlan {
162
+ report: MacAssignReport
163
+ /** The vectors `commitMacVoiceAssignment` will write. Empty unless `report.skipped === null`. */
164
+ selected: Float32Array[]
165
+ /** Counts for the log and the timing receipts. */
166
+ stats: { units: number; embedded: number; reused: number; ms: number }
167
+ }
168
+
169
+ const sameName = (a: string, b: string): boolean => a.normalize('NFKC').trim().toLowerCase() === b.normalize('NFKC').trim().toLowerCase()
170
+
171
+ function deadlineMsFromEnv(): number {
172
+ const raw = Number(process.env.COS_MAC_ASSIGN_DEADLINE_MS)
173
+ return Number.isFinite(raw) && raw >= 1000 && raw <= 25_000 ? Math.floor(raw) : MAC_ASSIGN_DEADLINE_MS
174
+ }
175
+
176
+ // One plan's vectors, kept for the next. Keyed on the chunk file's size and mtime plus the slice, so a changed
177
+ // file never reuses a stale vector. In memory only: a voiceprint never goes to disk from here.
178
+ const vectorCache = new Map<string, { vec: Float32Array; sec: number }>()
179
+ function cacheGet(key: string): { vec: Float32Array; sec: number } | undefined {
180
+ const hit = vectorCache.get(key)
181
+ if (hit) { vectorCache.delete(key); vectorCache.set(key, hit) }
182
+ return hit
183
+ }
184
+ function cacheSet(key: string, value: { vec: Float32Array; sec: number }): void {
185
+ vectorCache.delete(key)
186
+ vectorCache.set(key, value)
187
+ while (vectorCache.size > VECTOR_CACHE_MAX) vectorCache.delete(vectorCache.keys().next().value as string)
188
+ }
189
+ export function __clearMacAssignVectorCacheForTests(): void { vectorCache.clear() }
190
+
191
+ function emptyReport(person: string, skipped: MacAssignSkip): MacAssignReport {
192
+ return {
193
+ person, enrolled: 0, attempted: 0, created: false, clusterSkipped: 0, multiVoiceSkipped: 0,
194
+ shortSkipped: 0, missingAudio: 0, similarity: null, belowFloor: false, floor: MERGE_SIMILARITY_FLOOR,
195
+ groupSize: 0, groupSec: 0, gates: [], skipped,
196
+ }
197
+ }
198
+
199
+ interface Turn { speaker: string; startSec?: number; endSec?: number }
200
+ interface Unit { raw: number; slice: { startSec: number; endSec: number } | null }
201
+
202
+ function turnsOf(chunk: Record<string, unknown> | undefined): Turn[] {
203
+ if (!chunk) return []
204
+ const final = chunk.finalSegments
205
+ const live = chunk.segments
206
+ const list = Array.isArray(final) && final.length > 0 ? final : Array.isArray(live) ? live : []
207
+ return list.filter((t): t is Turn => !!t && typeof t === 'object' && typeof (t as Turn).speaker === 'string')
208
+ }
209
+
210
+ function spread<T>(items: T[], max: number): T[] {
211
+ if (items.length <= max) return items
212
+ const out: T[] = []
213
+ for (let k = 0; k < max; k++) out.push(items[Math.floor((k * items.length) / max)])
214
+ return out
215
+ }
216
+
217
+ function centroid(vectors: Float32Array[]): Float32Array | null {
218
+ if (vectors.length === 0) return null
219
+ const out = new Float32Array(vectors[0].length)
220
+ for (const v of vectors) {
221
+ if (v.length !== out.length) return null
222
+ for (let i = 0; i < out.length; i++) out[i] += v[i]
223
+ }
224
+ for (let i = 0; i < out.length; i++) out[i] /= vectors.length
225
+ return out
226
+ }
227
+
228
+ /** Read what assigning `from` to `to` WOULD add. Writes nothing to the profile store. Yields to the event loop between units. */
229
+ export async function planMacVoiceAssignment(input: MacAssignInput): Promise<MacAssignPlan> {
230
+ const { sessionId, from, to, changed, sidecar } = input
231
+ const now = input.now ?? Date.now
232
+ const yieldToLoop = input.yieldToLoop ?? (() => new Promise<void>(resolve => setImmediate(resolve)))
233
+ const started = now()
234
+ const stats = { units: 0, embedded: 0, reused: 0, ms: 0 }
235
+ const none = (skipped: MacAssignSkip): MacAssignPlan => ({ report: emptyReport(to, skipped), selected: [], stats })
236
+
237
+ if (!isMacCaptureSidecar(sidecar, sessionId)) return none('not_mac_capture')
238
+ if (!isVoiceLabel(from)) return none('not_a_voice_label')
239
+ if (isPlaceholderLabel(to)) return none('unknown_profile')
240
+ if (input.ownerLabel && sameName(to, input.ownerLabel)) return none('owner_profile')
241
+ let profile: { name: string; embeddings: number[][]; sources?: string[] } | undefined
242
+ try { profile = readVoiceProfiles().profiles.find(p => p.name === to) } catch { return none('store_unavailable') }
243
+ if (!profile) return none('unknown_profile')
244
+ if (!chunkEmbeddingsEnabled()) return none('disabled')
245
+ if (!isEmbeddingAvailable()) return none('store_unavailable')
246
+
247
+ const stored = centroid(profile.embeddings.map(e => new Float32Array(e)))
248
+ if (!stored) return none('no_stored_voice')
249
+ const macCount = (profile.sources ?? []).filter(src => isMacAssign(src)).length
250
+ const capacity = Math.min(macAssignCapFor(profile.embeddings.length, macCount), Math.max(0, MAX_EMBEDDINGS_PER_SPEAKER - profile.embeddings.length) + macCount)
251
+ if (capacity <= 0) return none('profile_full')
252
+
253
+ const rawIndices = resolveRawChunkIndices(sidecar, changed)
254
+ if (!rawIndices) return none('no_index_mapping')
255
+
256
+ const chunksByRaw = new Map<number, Record<string, unknown>>()
257
+ for (const entry of Array.isArray(sidecar.chunkEntries) ? sidecar.chunkEntries as Array<Record<string, unknown>> : []) {
258
+ if (typeof entry?.chunkIndex === 'number' && entry.chunk && typeof entry.chunk === 'object') chunksByRaw.set(entry.chunkIndex, entry.chunk as Record<string, unknown>)
259
+ }
260
+
261
+ const report = emptyReport(to, null)
262
+ const units: Unit[] = []
263
+ for (const raw of [...new Set(rawIndices)].sort((a, b) => a - b)) {
264
+ const turns = turnsOf(chunksByRaw.get(raw))
265
+ if (turns.length === 0 || turns.every(t => t.speaker === from)) { units.push({ raw, slice: null }); continue }
266
+ const usable = turns.filter(t => t.speaker === from
267
+ && Number.isFinite(t.startSec) && Number.isFinite(t.endSec)
268
+ && (t.endSec as number) - (t.startSec as number) >= MAC_ASSIGN_MIN_SLICE_SEC)
269
+ if (usable.length === 0) { report.multiVoiceSkipped += 1; continue }
270
+ for (const t of usable) units.push({
271
+ raw,
272
+ slice: { startSec: (t.startSec as number) + MAC_ASSIGN_SLICE_INSET_SEC, endSec: (t.endSec as number) - MAC_ASSIGN_SLICE_INSET_SEC },
273
+ })
274
+ }
275
+
276
+ const embeddings: Float32Array[] = []
277
+ const secs: number[] = []
278
+ let foundAudio = 0
279
+ const picked = spread(units, MAC_ASSIGN_MAX_UNITS)
280
+ stats.units = picked.length
281
+ const deadline = started + (input.deadlineMs ?? deadlineMsFromEnv())
282
+ for (const unit of picked) {
283
+ if (now() >= deadline) {
284
+ // Not a partial answer: a subset of the meeting's spread is not what the person previewed. The vectors made
285
+ // so far are cached, so asking again continues from here.
286
+ stats.ms = now() - started
287
+ report.attempted = embeddings.length
288
+ report.skipped = 'deadline'
289
+ console.warn(`[mac-assign] plan hit its ${input.deadlineMs ?? deadlineMsFromEnv()} ms deadline: units=${picked.length} embedded=${stats.embedded} reused=${stats.reused}`)
290
+ return { report, selected: [], stats }
291
+ }
292
+ await yieldToLoop()
293
+ const path = meetingAudioChunkPath(sessionId, unit.raw)
294
+ if (!path) { report.missingAudio += 1; continue }
295
+ let key: string
296
+ try {
297
+ const info = statSync(path)
298
+ key = `${sessionId}|${unit.raw}|${unit.slice ? `${unit.slice.startSec.toFixed(3)}-${unit.slice.endSec.toFixed(3)}` : 'whole'}|${info.size}|${Math.round(info.mtimeMs)}|${info.ino}|${Math.round(info.ctimeMs)}`
299
+ } catch { report.missingAudio += 1; continue }
300
+ foundAudio += 1
301
+ const hit = cacheGet(key)
302
+ if (hit) { stats.reused += 1; embeddings.push(hit.vec); secs.push(hit.sec); continue }
303
+ let wav: Buffer
304
+ try { wav = readFileSync(path) } catch { report.missingAudio += 1; continue }
305
+ const pcm = readPcm16Mono(wav)
306
+ if (!pcm || pcm.sampleRate !== SAMPLE_RATE) continue
307
+ let audio = wav
308
+ if (unit.slice) {
309
+ const from16 = Math.max(0, Math.round(unit.slice.startSec * SAMPLE_RATE)) * 2
310
+ const to16 = Math.min(pcm.pcm.length, Math.round(unit.slice.endSec * SAMPLE_RATE) * 2)
311
+ if (to16 <= from16) continue
312
+ audio = pcm16ToWav(pcm.pcm.subarray(from16, to16))
313
+ }
314
+ const sec = (audio.length - 44) / (SAMPLE_RATE * 2)
315
+ if (sec < VOICEPRINT_MIN_AUDIO_SEC) { report.shortSkipped += 1; continue }
316
+ const embedding = extractEmbedding(audio)
317
+ stats.embedded += 1
318
+ if (embedding && embedding.length === 192) {
319
+ cacheSet(key, { vec: embedding, sec })
320
+ embeddings.push(embedding)
321
+ secs.push(sec)
322
+ }
323
+ }
324
+ stats.ms = now() - started
325
+ console.log(`[mac-assign] plan: units=${picked.length} embedded=${stats.embedded} reused=${stats.reused} ${stats.ms} ms`)
326
+
327
+ report.attempted = embeddings.length
328
+ if (foundAudio === 0) { report.skipped = units.length === 0 ? 'no_usable_audio' : 'audio_not_retained'; return { report, selected: [], stats } }
329
+ const cluster = dominantCoherentCluster(embeddings)
330
+ report.clusterSkipped = embeddings.length - cluster.members.length
331
+ if (cluster.members.length === 0) { report.skipped = 'no_usable_audio'; return { report, selected: [], stats } }
332
+ report.groupSize = cluster.members.length
333
+ report.groupSec = Math.round(cluster.members.reduce((sum, m) => sum + secs[m], 0) * 10) / 10
334
+ // greedyDiversitySelect seeds with a PAIR, so a cap of 1 would return 2: slice to the room this profile really has.
335
+ const selected = greedyDiversitySelect(cluster.members.map(m => embeddings[m]), Math.min(MAX_ENROL_PER_CORRECTION, capacity)).slice(0, capacity)
336
+
337
+ const voice = centroid(selected)
338
+ if (voice) {
339
+ report.similarity = Math.round(rawCosineSimilarity(voice, stored) * 1000) / 1000
340
+ report.belowFloor = report.similarity < MERGE_SIMILARITY_FLOOR
341
+ }
342
+ if (report.belowFloor) report.gates.push('below_floor')
343
+ // With nothing thrown out the group IS everything embedded, so this only fires when units were rejected as a different voice.
344
+ if (report.groupSize < embeddings.length * MAC_ASSIGN_MIXED_FRACTION) report.gates.push('mixed_voice')
345
+ if (report.groupSize < MAC_ASSIGN_MIN_UNITS && report.groupSec < MAC_ASSIGN_MIN_SINGLE_SEC) report.gates.push('too_little_audio')
346
+ report.enrolled = selected.length
347
+ return { report, selected, stats }
348
+ }
349
+
350
+ /** Write a plan's vectors into the person's profile in ONE store write. NEVER THROWS: the rename is already durable. */
351
+ export function commitMacVoiceAssignment(sessionId: string, plan: MacAssignPlan): MacAssignReport {
352
+ if (plan.report.skipped !== null || plan.selected.length === 0) return { ...plan.report, enrolled: 0 }
353
+ const source = `${MAC_ASSIGN_SOURCE}:${sessionId}`
354
+ try {
355
+ const result = enrollEmbeddingsBatch(plan.report.person, plan.selected.map(embedding => ({ embedding, source })))
356
+ if (!result.persisted) return { ...plan.report, enrolled: 0, skipped: 'store_unavailable' }
357
+ return { ...plan.report, enrolled: result.enrolled }
358
+ } catch {
359
+ return { ...plan.report, enrolled: 0, skipped: 'store_unavailable' }
360
+ }
361
+ }
@@ -1,11 +1,12 @@
1
1
  // 6.90.0: unnamed, meeting-stable voice labels for Mac recordings.
2
2
  //
3
3
  // A Mac recording hears call audio, video and podcasts through one global tap.
4
- // Naming a voice from that audio is not safe (no calibration exists, see
5
- // CHANGELOG 6.73.6 and the 2026-10-14 Mac plan section 5), so every Mac chunk is
6
- // `Ext`. What IS safe is telling voices apart: one Nemotron pass over the whole
7
- // saved meeting gives channels that are consistent for the call, and each
8
- // confident channel becomes `Voice 1`, `Voice 2`, ... in order of first speech.
4
+ // Named identity still requires the existing strict matching policy. Unknown
5
+ // speech stays `Ext` unless clean, independent acoustic observations establish
6
+ // capture-local continuity through mac-live-voices.ts. Chunk-local channel IDs
7
+ // never become meeting identities. Finalization preserves established live
8
+ // numbers; when no live numbers exist, confident final channels can become
9
+ // `Voice 1`, `Voice 2`, ... in order of first supporting speech.
9
10
  //
10
11
  // A `Voice N` label asserts NO identity. Everywhere a real name is checked it is
11
12
  // a placeholder, exactly like `Ext` and `Unidentified N`: it is never a profile
@@ -13,7 +14,27 @@
13
14
  // `speakers` list. A person may rename it (text only) through the relabel route.
14
15
  //
15
16
  // Switch: COS_MAC_VOICE_LABELS, on by default; 0, false, off or no turns it off
16
- // and Mac recordings stay all `Ext` exactly as 6.89.0 saved them.
17
+ // and unknown Mac speech stays `Ext`; strict named matching keeps its policy.
18
+ // It governs ONLY the after-save pass (6.90.0) and is the master off for the live
19
+ // paths below. An explicit `1` is the default, not an opt-in to anything live.
20
+ //
21
+ // Live voices (6.93.0, on by default): `Voice N` while a Mac recording is running.
22
+ // One switch with three modes:
23
+ // COS_MAC_LIVE_VOICES unset / empty / window window:
24
+ // the 6.93.0 window replay (bounded timeout budget, back-off
25
+ // to per-chunk when 2 of the last 3 window runs timed out). THE DEFAULT.
26
+ // Any other value not named below also means window, and logs
27
+ // `mac_live_voices_unrecognised` once so a typo is not silent.
28
+ // COS_MAC_LIVE_VOICES=anchors the 6.92.1 per-chunk acoustic anchors.
29
+ // COS_MAC_LIVE_VOICES=off (also 0, false, no, none, disable, disabled, never) the opt-out:
30
+ // every live Mac chunk is `Ext`, exactly as 6.92.0; the saved pass
31
+ // numbers all voices.
32
+ // COS_MAC_VOICE_LABELS off also turns both live modes off.
33
+ // Known limit: a clean hand-over between two people exactly at a chunk boundary can
34
+ // give the new speaker the previous number (R1). The saved pass keeps every live number,
35
+ // so it stays in the saved meeting. Assigning such a `Voice N` to a profile is gated
36
+ // (mac-voice-assignment.ts: below the floor, a mixed group, or too little audio is refused
37
+ // unless forced), so a wrong number does not silently become a wrong voiceprint.
17
38
 
18
39
  // No imports on purpose: placeholder checks are read from the speaker-review,
19
40
  // relabel and store layers, and this must never pull the Mac registry into them.
@@ -31,10 +52,51 @@ export function voiceLabel(n: number): string {
31
52
  return `Voice ${n}`
32
53
  }
33
54
 
34
- /** On unless explicitly switched off. Read at use, so a managed restart is not needed to turn it off. */
55
+ /** Every spelling of "off" these switches accept (6.93.0 added none, disable, disabled, never). */
56
+ export const OFF_WORDS: readonly string[] = ['0', 'false', 'off', 'no', 'none', 'disable', 'disabled', 'never']
57
+
58
+ /** On unless explicitly switched off. The environment is read once at server start, so a change needs a restart. */
35
59
  export function macVoiceLabelsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
36
60
  const raw = String(env.COS_MAC_VOICE_LABELS ?? '').trim().toLowerCase()
37
- return !['0', 'false', 'off', 'no'].includes(raw)
61
+ return !OFF_WORDS.includes(raw)
62
+ }
63
+
64
+ /**
65
+ * True when a stored chunk (or any of its turns) already carries a `Voice N`.
66
+ * `segments` is shape-checked: a recovered chunk with a malformed field must not
67
+ * throw into the chunk route (QA N3).
68
+ */
69
+ export function chunkShowsVoiceLabel(chunk: { speaker?: unknown; segments?: unknown } | null | undefined): boolean {
70
+ if (isVoiceLabel(chunk?.speaker)) return true
71
+ const segments = chunk?.segments
72
+ return Array.isArray(segments) && segments.some(segment => isVoiceLabel((segment as { speaker?: unknown } | null)?.speaker))
73
+ }
74
+
75
+ export type MacLiveVoicesMode = 'off' | 'anchors' | 'window'
76
+
77
+ const warnedUnrecognised = new Set<string>()
78
+
79
+ /** Test seam: forget which values have already been warned about. */
80
+ export function __resetMacLiveVoicesWarningsForTests(): void { warnedUnrecognised.clear() }
81
+
82
+ /**
83
+ * Which live `Voice N` path may run for a Mac chunk. `window` unless COS_MAC_LIVE_VOICES
84
+ * says off (any word in OFF_WORDS) or `anchors`, or COS_MAC_VOICE_LABELS is off. A value
85
+ * that is none of those still means `window` (a typo of window is the likely case), but
86
+ * logs `mac_live_voices_unrecognised` once per value. The environment is read once at
87
+ * server start, so changing either switch needs a restart.
88
+ */
89
+ export function macLiveVoicesMode(env: NodeJS.ProcessEnv = process.env, warn: (line: string) => void = console.warn): MacLiveVoicesMode {
90
+ if (!macVoiceLabelsEnabled(env)) return 'off'
91
+ const raw = String(env.COS_MAC_LIVE_VOICES ?? '').trim().toLowerCase()
92
+ if (OFF_WORDS.includes(raw)) return 'off'
93
+ if (raw === 'anchors') return 'anchors'
94
+ if (raw !== '' && raw !== 'window' && !warnedUnrecognised.has(raw)) {
95
+ if (warnedUnrecognised.size >= 16) warnedUnrecognised.clear()
96
+ warnedUnrecognised.add(raw)
97
+ warn(`[mac-voices] mac_live_voices_unrecognised: COS_MAC_LIVE_VOICES=${raw.slice(0, 40)} is not a mode (window, anchors, off); using window`)
98
+ }
99
+ return 'window'
38
100
  }
39
101
 
40
102
  /**