@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.
- package/.env.example +30 -1
- package/CHANGELOG.md +57 -29
- package/README.md +58 -0
- package/docs/active-query-jobs.md +1 -1
- package/docs/test-plan-6.71.7.md +1 -1
- package/managed-runtime-contract.json +2 -0
- package/package.json +2 -1
- package/server/lib/ask-session-id.ts +2 -1
- package/server/lib/embedding-eviction.ts +60 -2
- package/server/lib/fuzzy-correct.ts +1 -1
- package/server/lib/hallucination-filter.ts +1 -1
- package/server/lib/imported-library-rows.ts +4 -2
- package/server/lib/live-cos-ask.ts +3 -2
- package/server/lib/mac-live-voices.ts +57 -0
- package/server/lib/mac-recording-asr.ts +16 -8
- package/server/lib/mac-speaker-projection.ts +211 -0
- package/server/lib/mac-stream-diarizer.ts +185 -0
- package/server/lib/mac-voice-assignment.ts +361 -0
- package/server/lib/mac-voice-labels.ts +70 -8
- package/server/lib/mac-window-replay.ts +271 -0
- package/server/lib/mac-window-voices.ts +390 -0
- package/server/lib/meeting-actions.ts +1 -1
- package/server/lib/meeting-ask-owner.ts +4 -4
- package/server/lib/meeting-ask-transcript-sources.ts +41 -3
- package/server/lib/meeting-audio-archive.ts +4 -0
- package/server/lib/meeting-batch-transcribe.ts +7 -1
- package/server/lib/meeting-context-keys.ts +6 -3
- package/server/lib/meeting-corrections.ts +4 -4
- package/server/lib/meeting-fact-check.ts +3 -3
- package/server/lib/meeting-parse.ts +1 -1
- package/server/lib/meeting-relabel-enrolment.ts +8 -8
- package/server/lib/meeting-relabel.ts +3 -3
- package/server/lib/meeting-scribe-content.ts +1 -1
- package/server/lib/meeting-speaker-review.ts +8 -8
- package/server/lib/meeting-store.ts +19 -9
- package/server/lib/nemotron-final.ts +114 -9
- package/server/lib/nemotron-live.ts +58 -6
- package/server/lib/phone-recording.ts +114 -0
- package/server/lib/prompt-tail-guard.ts +1 -1
- package/server/lib/speaker-embeddings.ts +154 -28
- package/server/lib/speaker-name.ts +1 -1
- package/server/lib/stranded-session-actions.ts +7 -2
- package/server/lib/training-audio-provenance.ts +3 -2
- package/server/lib/transcript-cleanup-context.ts +3 -3
- package/server/lib/transcript-cleanup-model.ts +1 -1
- package/server/lib/transcript-cleanup-spans.ts +10 -10
- package/server/lib/transcript-cleanup-text.ts +4 -4
- package/server/lib/transcript-cleanup.ts +1 -1
- package/server/lib/unsaved-audio-quarantine.ts +10 -1
- package/server/lib/voice-directory.ts +1 -1
- package/server/lib/voice-evidence-guard.ts +2 -1
- package/server/lib/voice-profile-store.ts +11 -5
- package/server/lib/whisper-local.ts +1 -1
- package/server/routes/health.ts +8 -0
- package/server/routes/meeting.ts +189 -19
- package/server/routes/meetings.ts +9 -5
- package/server/routes/transcribe-stream.ts +140 -15
- package/server/routes/voice.ts +12 -4
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
- 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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
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 !
|
|
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
|
/**
|