@gotcos/glasses-server 6.27.11 → 6.27.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/package.json +1 -1
- package/server/lib/meeting-relabel-enrolment.ts +254 -0
- package/server/lib/voice-enrolment-selection.ts +202 -0
- package/server/routes/meeting.ts +28 -62
- package/server/routes/voice.ts +6 -32
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
## Unreleased
|
|
2
2
|
|
|
3
|
+
## 6.27.12
|
|
4
|
+
- **Naming an unidentified voice creates a real speaker profile — correctly this time.**
|
|
5
|
+
Re-enables what 6.27.10 shipped broken and 6.27.11 disabled.
|
|
6
|
+
- **The index join is fixed.** 6.27.10 fed COMPACTED sidecar positions to a store keyed
|
|
7
|
+
on RAW capture indices; on a live session that enrolled 73 of 103 rows belonging to
|
|
8
|
+
other people, 22 of them the owner. Enrolment now runs through
|
|
9
|
+
`attachRawChunkIndices` and **refuses outright** when the mapping is not established —
|
|
10
|
+
detected by reference identity, which is how that function signals "cannot map" —
|
|
11
|
+
reporting `skipped: 'no_index_mapping'`. There is no fallback to positions anywhere.
|
|
12
|
+
- **`Ext` is a bucket, not a person, so coherence is checked.** Only the dominant
|
|
13
|
+
MUTUALLY coherent cluster is enrolled and `clusterSkipped` reports the rest. Mutual
|
|
14
|
+
rather than medoid-star on purpose: a seed sitting between two voices merges them
|
|
15
|
+
otherwise. The floor is aliased from the exported `MERGE_SIMILARITY_FLOOR` so it
|
|
16
|
+
cannot drift from the identifier's own accept threshold.
|
|
17
|
+
- **Samples are stamped `correction:<sessionId>`.** A bare tag matched none of the three
|
|
18
|
+
prefixes `isSampleFromSession` accepts, so "Not in this meeting" — the app's only undo
|
|
19
|
+
— would have retracted nothing, the samples would have counted as untraceable, and
|
|
20
|
+
they would have landed in the weakest eviction tier with no correction quota.
|
|
21
|
+
- **Bounded.** `greedyDiversitySelect` caps at 20 before enrolling. Unbounded, ~41ms per
|
|
22
|
+
cycle against a 7.9 MB store x 109 chunks blocked the event loop past the helper's 30s
|
|
23
|
+
timeout, showing "Server stopped" for a correction that applied.
|
|
24
|
+
- **Reports honestly.** `enrolment: { enrolled, attempted, created, clusterSkipped,
|
|
25
|
+
skipped }`. A zero is now legible instead of indistinguishable from success.
|
|
26
|
+
`created: false` when appending to an existing profile, so nothing claims a profile
|
|
27
|
+
was made when samples were added. `enrolledEmbeddings` retained for existing clients.
|
|
28
|
+
- **Tests, 28 -> 42, plus 14 on the selection lib.** The chunk-embedding mock is GONE:
|
|
29
|
+
fixtures write real JSONL through the real encoder and the route reads it through the
|
|
30
|
+
real decoder and index filter. That double mock is why 6.27.10 shipped — the join was
|
|
31
|
+
never exercised, and a gapless 3-chunk fixture pinned the bug as the contract.
|
|
32
|
+
- Nine mutations, all landed in-target and confirmed present before each run. M1
|
|
33
|
+
(bypass the mapping) fails four tests including "enrols the RAW-index embeddings,
|
|
34
|
+
never the sidecar positions". M2 initially SURVIVED — the branch was unreached — so
|
|
35
|
+
the test was fixed rather than the code.
|
|
36
|
+
- Known: `skipDedupCheck` stays at its default `false`, so near-duplicate samples are
|
|
37
|
+
rejected and `enrolled` can fall below the 20 selected. Conservative and honestly
|
|
38
|
+
reported; `/api/voice/enroll-ext` passes `true`.
|
|
39
|
+
|
|
3
40
|
## 6.27.11
|
|
4
41
|
- **SAFETY REVERT: the 6.27.10 relabel enrolment is disabled.** It joined two
|
|
5
42
|
different index spaces. `plan.value.changed` are positions in the COMPACTED sidecar
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.27.
|
|
3
|
+
"version": "6.27.12",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Turning "that unidentified voice was Kirstyn Blum" into a real voice profile.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. Relabelling was TEXT ONLY: it rewrote `speaker` strings in the
|
|
4
|
+
// meeting sidecar and never touched the voice store. Naming a voice over 109
|
|
5
|
+
// segments labelled that one meeting and taught the system nothing — she never
|
|
6
|
+
// appeared in /api/voice/profiles, the review panel still offered her as `new
|
|
7
|
+
// name` inside the SAME meeting, no later meeting could match her, and there was
|
|
8
|
+
// no profile to accumulate further chunks against. Verified live 2026-08-13:
|
|
9
|
+
// `Kirstyn Blum` appears 70x in one sidecar and 112x in another while
|
|
10
|
+
// voice-profiles.json held 77 profiles and no Kirstyn.
|
|
11
|
+
//
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
// WHY THIS IS ITS OWN MODULE AND WHY IT IS THIS PARANOID
|
|
14
|
+
//
|
|
15
|
+
// 6.27.10 shipped this inline in the route and was reverted the same night.
|
|
16
|
+
// It joined TWO DIFFERENT INDEX SPACES:
|
|
17
|
+
//
|
|
18
|
+
// plan.value.changed positions in the COMPACTED sidecar array
|
|
19
|
+
// (meeting-relabel.ts, iterating rows already filtered
|
|
20
|
+
// to those carrying text)
|
|
21
|
+
// chunk-embedding store keyed on the RAW CAPTURE INDEX
|
|
22
|
+
// (transcribe-stream.ts writes `i: chunkIndex`)
|
|
23
|
+
//
|
|
24
|
+
// `getSessionChunks` is `session.chunks.filter(c => c && c.text)` while an
|
|
25
|
+
// embedding is written for every chunk with 2s+ of audio, BEFORE ASR is known.
|
|
26
|
+
// The two diverge at the first text-less chunk and the gap grows from there.
|
|
27
|
+
// 73 of 74 live sessions with embeddings have gaps, so divergence was the NORMAL
|
|
28
|
+
// case, not an edge case.
|
|
29
|
+
//
|
|
30
|
+
// Consequence, measured on `meeting_1786628481833_eagkaz`: naming one voice
|
|
31
|
+
// enrolled 73 of 103 rows belonging to OTHER PEOPLE — 22 chunks of MU (the device
|
|
32
|
+
// owner), 16 Vikas, 10 Vishnu, 8 Niranjan, 7 Anil, 6 Chris, 3 Manoj, 1 Navaz, and
|
|
33
|
+
// only 30 that were actually the named voice. It reported SUCCESS, because rows do
|
|
34
|
+
// come back from the store; they were simply the wrong rows.
|
|
35
|
+
//
|
|
36
|
+
// This writes to a SHARED, LONG-LIVED store that drives speaker attribution across
|
|
37
|
+
// every future meeting. A wrong write is permanent and silent. So every step below
|
|
38
|
+
// fails CLOSED: no mapping means no enrolment, never a fall back to positions.
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
chunkEmbeddingTtlMs,
|
|
42
|
+
chunkEmbeddingsEnabled,
|
|
43
|
+
readChunkEmbeddings,
|
|
44
|
+
} from './chunk-embedding-store.js'
|
|
45
|
+
import {
|
|
46
|
+
enrollEmbedding,
|
|
47
|
+
isEmbeddingAvailable,
|
|
48
|
+
readVoiceProfiles,
|
|
49
|
+
} from './speaker-embeddings.js'
|
|
50
|
+
import { attachRawChunkIndices, type ReviewChunk } from './meeting-speaker-review.js'
|
|
51
|
+
import {
|
|
52
|
+
MAX_ENROL_PER_CORRECTION,
|
|
53
|
+
dominantCoherentCluster,
|
|
54
|
+
greedyDiversitySelect,
|
|
55
|
+
} from './voice-enrolment-selection.js'
|
|
56
|
+
|
|
57
|
+
/** Labels the diariser invents when it does not know who is speaking. Naming one
|
|
58
|
+
* of these is a FIRST TRAINING RUN for a new person, not a correction. */
|
|
59
|
+
const PLACEHOLDER_LABEL = /^(ext|unknown|unidentified(\s+\d+)?|speaker\s*\d+)$/i
|
|
60
|
+
|
|
61
|
+
export const isPlaceholderLabel = (label: string): boolean => PLACEHOLDER_LABEL.test(label.trim())
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Why nothing was enrolled, when the answer is not "it worked".
|
|
65
|
+
*
|
|
66
|
+
* Reported rather than inferred, because there are four ways this silently
|
|
67
|
+
* enrols zero and they are indistinguishable from the outside:
|
|
68
|
+
* 1. the ~26 MB 3dspeaker model is absent (it is .npmignore'd, and a managed
|
|
69
|
+
* cutover has stranded it before) -> store_unavailable
|
|
70
|
+
* 2. COS_CHUNK_EMBEDDINGS=0 -> disabled
|
|
71
|
+
* 3. the 14-day embedding TTL has passed -> expired
|
|
72
|
+
* 4. the meeting predates the store -> no_embeddings
|
|
73
|
+
*
|
|
74
|
+
* `null` means nothing BLOCKED enrolment. It does not mean samples were written —
|
|
75
|
+
* read `enrolled` for that, and `clusterSkipped` for the coherence verdict.
|
|
76
|
+
*/
|
|
77
|
+
export type EnrolmentSkipReason =
|
|
78
|
+
| null
|
|
79
|
+
| 'no_index_mapping'
|
|
80
|
+
| 'no_embeddings'
|
|
81
|
+
| 'disabled'
|
|
82
|
+
| 'expired'
|
|
83
|
+
| 'store_unavailable'
|
|
84
|
+
|
|
85
|
+
export interface EnrolmentReport {
|
|
86
|
+
/** Samples the voice store actually accepted. */
|
|
87
|
+
enrolled: number
|
|
88
|
+
/** Candidate embeddings found for the relabelled chunks, before any gating.
|
|
89
|
+
* Zero here means this correction was never an enrolment (a real name being
|
|
90
|
+
* corrected to another real name), not that enrolment failed. */
|
|
91
|
+
attempted: number
|
|
92
|
+
/** True ONLY when a profile did not exist for this name and now does. An
|
|
93
|
+
* existing name is APPENDED to, and must never be reported as created. */
|
|
94
|
+
created: boolean
|
|
95
|
+
/** Candidates rejected as a different voice. `clusterSkipped === attempted`
|
|
96
|
+
* means the bucket had no dominant voice at all and nothing was written. */
|
|
97
|
+
clusterSkipped: number
|
|
98
|
+
skipped: EnrolmentSkipReason
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const IDLE: EnrolmentReport = {
|
|
102
|
+
enrolled: 0, attempted: 0, created: false, clusterSkipped: 0, skipped: null,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Compacted sidecar positions -> RAW capture indices, or null.
|
|
107
|
+
*
|
|
108
|
+
* `attachRawChunkIndices` signals "I cannot map this" by returning the SAME ARRAY
|
|
109
|
+
* it was given — when `chunkEntries` is absent, or when the text-bearing count
|
|
110
|
+
* disagrees with the compacted count. That unchanged case is exactly the
|
|
111
|
+
* poisoning case, so it is detected by REFERENCE IDENTITY and refused. There is
|
|
112
|
+
* deliberately no fall back to positions: a shifted mapping is worse than none,
|
|
113
|
+
* because it looks like it worked.
|
|
114
|
+
*
|
|
115
|
+
* A single unmappable position refuses the WHOLE set, for the same reason a
|
|
116
|
+
* partial mapping is refused: enrolling the subset that happened to map still
|
|
117
|
+
* writes whoever the unmapped ones turned out to be, and there is no way to tell
|
|
118
|
+
* from the result which happened.
|
|
119
|
+
*/
|
|
120
|
+
export function resolveRawChunkIndices(
|
|
121
|
+
sidecar: Record<string, unknown>,
|
|
122
|
+
positions: number[],
|
|
123
|
+
): number[] | null {
|
|
124
|
+
const chunks = sidecar.chunks
|
|
125
|
+
if (!Array.isArray(chunks)) return null
|
|
126
|
+
const withRaw = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
|
|
127
|
+
if (withRaw === chunks) return null
|
|
128
|
+
|
|
129
|
+
const raw: number[] = []
|
|
130
|
+
for (const position of positions) {
|
|
131
|
+
const index = withRaw[position]?.chunkIndex
|
|
132
|
+
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) return null
|
|
133
|
+
raw.push(index)
|
|
134
|
+
}
|
|
135
|
+
return raw
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A missing embedding file is either aged out or never written, and the two are
|
|
140
|
+
* worth telling apart: 'expired' means the correction arrived too late and the
|
|
141
|
+
* loop would have worked, 'no_embeddings' means this meeting could never train
|
|
142
|
+
* anything. The store cannot distinguish them — the file is simply gone — so the
|
|
143
|
+
* meeting's own start time against the live TTL is the evidence.
|
|
144
|
+
*/
|
|
145
|
+
function missingReason(sidecar: Record<string, unknown>): EnrolmentSkipReason {
|
|
146
|
+
const startTime = sidecar.startTime
|
|
147
|
+
if (typeof startTime !== 'number' || !Number.isFinite(startTime) || startTime <= 0) return 'no_embeddings'
|
|
148
|
+
return Date.now() - startTime > chunkEmbeddingTtlMs() ? 'expired' : 'no_embeddings'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Does a profile already exist under this name? Read from the PERSISTED store
|
|
152
|
+
* rather than `isEnrolled`, which asks the sherpa manager and therefore answers
|
|
153
|
+
* "no" for every name on an install with no model — which would report every
|
|
154
|
+
* append as a creation. */
|
|
155
|
+
function profileExists(name: string): boolean {
|
|
156
|
+
try {
|
|
157
|
+
return readVoiceProfiles().profiles.some(p => p.name === name)
|
|
158
|
+
} catch {
|
|
159
|
+
// Unknowable is not "new". Claiming creation is the lie that matters here.
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface EnrolNamedVoiceInput {
|
|
165
|
+
sessionId: string
|
|
166
|
+
from: string
|
|
167
|
+
to: string
|
|
168
|
+
/** `plan.value.changed` — COMPACTED sidecar positions. Never raw indices. */
|
|
169
|
+
changed: number[]
|
|
170
|
+
/** The parsed sidecar, for `chunks` + `chunkEntries` + `startTime`. */
|
|
171
|
+
sidecar: Record<string, unknown>
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Enrol the voice a human just named.
|
|
176
|
+
*
|
|
177
|
+
* SCOPED to placeholder -> real name. Correcting one real name to another is left
|
|
178
|
+
* alone deliberately: moving a voice between existing people is `merge-profiles`,
|
|
179
|
+
* which is explicit and confirmation-gated, and a sweep of this store put two
|
|
180
|
+
* DISTINCT people at 0.85 similarity, so doing it implicitly would poison both.
|
|
181
|
+
*
|
|
182
|
+
* An EXISTING name is appended to without a prompt, and reports `created: false`.
|
|
183
|
+
*
|
|
184
|
+
* Samples are stamped `correction:<sessionId>`, not a bare source string. The
|
|
185
|
+
* prefix is load-bearing in four places: `isSampleFromSession` accepts only
|
|
186
|
+
* `auto:` / `correction:` / `g2-training:`, so a bare tag makes the app's only
|
|
187
|
+
* undo ("Not in this meeting") retract NOTHING; `untraceableSampleCount` counts
|
|
188
|
+
* it as untraceable; `provenanceTier` falls to 'unknown', ranking a human-typed
|
|
189
|
+
* name BELOW Fireflies attendee metadata for eviction; and `isCorrection()` is a
|
|
190
|
+
* `startsWith('correction')` test, so the correction quota never protects it.
|
|
191
|
+
*
|
|
192
|
+
* NEVER THROWS. Enrolment is a bonus on top of a rename that is already durable
|
|
193
|
+
* on disk; a voice store that refuses must not undo what the user asked for.
|
|
194
|
+
*/
|
|
195
|
+
export function enrolNamedVoice(input: EnrolNamedVoiceInput): EnrolmentReport {
|
|
196
|
+
const { sessionId, from, to, changed, sidecar } = input
|
|
197
|
+
|
|
198
|
+
// Not an enrolment at all. `attempted: 0` with `skipped: null` is how the
|
|
199
|
+
// caller tells this apart from an enrolment that found no candidates.
|
|
200
|
+
if (!isPlaceholderLabel(from) || isPlaceholderLabel(to) || changed.length === 0) return IDLE
|
|
201
|
+
|
|
202
|
+
if (!chunkEmbeddingsEnabled()) return { ...IDLE, skipped: 'disabled' }
|
|
203
|
+
// No extractor/manager means every enrollEmbedding would return success:false.
|
|
204
|
+
// Naming that beats looping twenty times and reporting a bare zero.
|
|
205
|
+
if (!isEmbeddingAvailable()) return { ...IDLE, skipped: 'store_unavailable' }
|
|
206
|
+
|
|
207
|
+
const rawIndices = resolveRawChunkIndices(sidecar, changed)
|
|
208
|
+
if (!rawIndices) return { ...IDLE, skipped: 'no_index_mapping' }
|
|
209
|
+
|
|
210
|
+
let read: ReturnType<typeof readChunkEmbeddings>
|
|
211
|
+
try {
|
|
212
|
+
read = readChunkEmbeddings(sessionId)
|
|
213
|
+
} catch {
|
|
214
|
+
return { ...IDLE, skipped: 'store_unavailable' }
|
|
215
|
+
}
|
|
216
|
+
if (read.missing) return { ...IDLE, skipped: missingReason(sidecar) }
|
|
217
|
+
|
|
218
|
+
const wanted = new Set(rawIndices)
|
|
219
|
+
const candidates = read.rows.filter(r => wanted.has(r.i) && r.embedding && r.embedding.length > 0)
|
|
220
|
+
if (candidates.length === 0) return { ...IDLE, skipped: 'no_embeddings' }
|
|
221
|
+
|
|
222
|
+
const attempted = candidates.length
|
|
223
|
+
const cluster = dominantCoherentCluster(candidates.map(r => r.embedding))
|
|
224
|
+
const clusterSkipped = attempted - cluster.members.length
|
|
225
|
+
// Every candidate disagreed with every other one. There is no voice here to
|
|
226
|
+
// learn, only a bucket of strangers sharing one placeholder label.
|
|
227
|
+
if (cluster.members.length === 0) {
|
|
228
|
+
return { enrolled: 0, attempted, created: false, clusterSkipped, skipped: null }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Diversity trims the coherent group to a bounded, spread-out sample. Those
|
|
232
|
+
// trimmed are NOT counted in clusterSkipped — they were the right voice, just
|
|
233
|
+
// redundant, and reporting them as rejected would read as a coherence problem.
|
|
234
|
+
const coherent = cluster.members.map(m => candidates[m].embedding)
|
|
235
|
+
const selected = greedyDiversitySelect(coherent, MAX_ENROL_PER_CORRECTION)
|
|
236
|
+
|
|
237
|
+
const existedBefore = profileExists(to)
|
|
238
|
+
const source = `correction:${sessionId}`
|
|
239
|
+
let enrolled = 0
|
|
240
|
+
try {
|
|
241
|
+
for (const embedding of selected) {
|
|
242
|
+
if (enrollEmbedding(to, embedding, source).success) enrolled += 1
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
return {
|
|
246
|
+
enrolled, attempted, created: !existedBefore && enrolled > 0, clusterSkipped,
|
|
247
|
+
skipped: 'store_unavailable',
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
enrolled, attempted, created: !existedBefore && enrolled > 0, clusterSkipped, skipped: null,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// WHICH embeddings from a meeting may become training data for one person.
|
|
2
|
+
//
|
|
3
|
+
// Two independent filters, both pure over Float32Array[] so they are testable by
|
|
4
|
+
// execution rather than by reading them:
|
|
5
|
+
//
|
|
6
|
+
// dominantCoherentCluster — does this bag of chunks sound like ONE person?
|
|
7
|
+
// greedyDiversitySelect — of the ones that do, which 20 span the most range?
|
|
8
|
+
//
|
|
9
|
+
// THE PROBLEM THE FIRST ONE SOLVES. `Ext` is not a person. `identifySpeaker`
|
|
10
|
+
// returns 'Ext' for every voice that falls below its accept threshold, so a
|
|
11
|
+
// meeting with five unrecognised people produces ONE 'Ext' label covering all
|
|
12
|
+
// five. voice-directory.ts says it outright: "Ext/Unidentified clusters are
|
|
13
|
+
// meeting-local, not people."
|
|
14
|
+
//
|
|
15
|
+
// Measured on session `meeting_1786628481833_eagkaz` (2026-08-13): 115 'Ext'
|
|
16
|
+
// embeddings, 1,770 pairwise cosines, MEDIAN 0.170, and 98% of them below 0.55 —
|
|
17
|
+
// the identifier's own accept threshold. Enrolling that bucket wholesale under a
|
|
18
|
+
// name a human typed writes several strangers into one profile, permanently and
|
|
19
|
+
// silently, and the profile then matches all of them forever.
|
|
20
|
+
//
|
|
21
|
+
// So the rule is: enrol the DOMINANT COHERENT GROUP and report what was left
|
|
22
|
+
// behind, rather than enrolling everything and reporting success.
|
|
23
|
+
|
|
24
|
+
import { MERGE_SIMILARITY_FLOOR, rawCosineSimilarity } from './speaker-embeddings.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Two chunks are the same voice only above this cosine.
|
|
28
|
+
*
|
|
29
|
+
* Aliased from the already-exported `MERGE_SIMILARITY_FLOOR` rather than written
|
|
30
|
+
* as a literal, because that constant IS `SEARCH_THRESHOLD` (speaker-embeddings.ts)
|
|
31
|
+
* — the threshold `identifySpeaker` itself uses to accept a match. Deriving it
|
|
32
|
+
* means the gate cannot drift away from the identifier it is second-guessing; a
|
|
33
|
+
* hardcoded 0.55 here would silently disagree the day that value is tuned.
|
|
34
|
+
*/
|
|
35
|
+
export const VOICE_COHERENCE_FLOOR = MERGE_SIMILARITY_FLOOR
|
|
36
|
+
|
|
37
|
+
/** How many samples one correction may add to a profile. */
|
|
38
|
+
export const MAX_ENROL_PER_CORRECTION = 20
|
|
39
|
+
|
|
40
|
+
export interface CoherentCluster {
|
|
41
|
+
/** Indices into the input array, ascending. EMPTY when nothing coheres. */
|
|
42
|
+
members: number[]
|
|
43
|
+
/** Index the cluster formed around, or -1 when there is no cluster. */
|
|
44
|
+
seed: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The largest group of embeddings that plausibly share one voice.
|
|
49
|
+
*
|
|
50
|
+
* MUTUALLY COHERENT, not merely star-shaped, and not connected components.
|
|
51
|
+
* The distinction is the whole safety property:
|
|
52
|
+
*
|
|
53
|
+
* components A~B and B~C admits C alongside A even when A and C are nothing
|
|
54
|
+
* alike. Two people merge through anyone who sounds like both.
|
|
55
|
+
* star every member within `floor` of one seed. Better, but a seed
|
|
56
|
+
* sitting between two voices still admits both — measured: with
|
|
57
|
+
* a=[1,0], b=[1,1], c=[0,1], b agrees with a AND c while a and c
|
|
58
|
+
* are orthogonal, so the star around b is the whole crowd.
|
|
59
|
+
* mutual every PAIR within `floor`. Cannot admit two voices at once.
|
|
60
|
+
*
|
|
61
|
+
* Built as a seed star and then refined: repeatedly drop the member with the
|
|
62
|
+
* most internal disagreements (ties to the one with the lowest mean similarity,
|
|
63
|
+
* then the highest index) until every pair clears the floor. Greedy rather than
|
|
64
|
+
* a true maximum clique, which is NP-hard — greedy errs toward a SMALLER group,
|
|
65
|
+
* and under-enrolling is the safe direction when the alternative is writing a
|
|
66
|
+
* stranger into someone's profile permanently.
|
|
67
|
+
*
|
|
68
|
+
* A LONE candidate is returned as its own cluster: one chunk cannot contradict
|
|
69
|
+
* itself, and refusing it would mean a short correction never trains anything.
|
|
70
|
+
* But a bag of MANY mutually dissimilar embeddings returns EMPTY — the seed with
|
|
71
|
+
* the most agreement still has none, so picking one of them would be choosing an
|
|
72
|
+
* arbitrary stranger out of a crowd, which is precisely the failure this exists
|
|
73
|
+
* to stop. The caller reports that as `clusterSkipped === attempted`.
|
|
74
|
+
*
|
|
75
|
+
* Ties on seed choice break by mean similarity to the agreeing set, then by
|
|
76
|
+
* lowest index, so the result is deterministic for a given input.
|
|
77
|
+
*/
|
|
78
|
+
export function dominantCoherentCluster(
|
|
79
|
+
embeddings: Float32Array[],
|
|
80
|
+
floor: number = VOICE_COHERENCE_FLOOR,
|
|
81
|
+
): CoherentCluster {
|
|
82
|
+
const n = embeddings.length
|
|
83
|
+
if (n === 0) return { members: [], seed: -1 }
|
|
84
|
+
if (n === 1) return { members: [0], seed: 0 }
|
|
85
|
+
|
|
86
|
+
// Full pairwise matrix once: the refinement below reads it repeatedly, and
|
|
87
|
+
// recomputing a 192-dim cosine inside that loop is the difference between
|
|
88
|
+
// microseconds and seconds on a long meeting.
|
|
89
|
+
const sim: number[][] = Array.from({ length: n }, () => new Array<number>(n).fill(0))
|
|
90
|
+
for (let i = 0; i < n; i++) {
|
|
91
|
+
sim[i][i] = 1
|
|
92
|
+
for (let j = i + 1; j < n; j++) {
|
|
93
|
+
const s = rawCosineSimilarity(embeddings[i], embeddings[j])
|
|
94
|
+
sim[i][j] = s
|
|
95
|
+
sim[j][i] = s
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let bestSeed = -1
|
|
100
|
+
let bestAgree: number[] = []
|
|
101
|
+
let bestMean = -Infinity
|
|
102
|
+
for (let i = 0; i < n; i++) {
|
|
103
|
+
const agree: number[] = []
|
|
104
|
+
let sum = 0
|
|
105
|
+
for (let j = 0; j < n; j++) {
|
|
106
|
+
if (i !== j && sim[i][j] >= floor) { agree.push(j); sum += sim[i][j] }
|
|
107
|
+
}
|
|
108
|
+
const meanSim = agree.length > 0 ? sum / agree.length : -Infinity
|
|
109
|
+
if (agree.length > bestAgree.length || (agree.length === bestAgree.length && meanSim > bestMean)) {
|
|
110
|
+
bestSeed = i
|
|
111
|
+
bestAgree = agree
|
|
112
|
+
bestMean = meanSim
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Nothing agreed with anything. There is no dominant voice here, only a crowd.
|
|
117
|
+
if (bestSeed === -1 || bestAgree.length === 0) return { members: [], seed: -1 }
|
|
118
|
+
|
|
119
|
+
let members = [bestSeed, ...bestAgree].sort((a, b) => a - b)
|
|
120
|
+
for (;;) {
|
|
121
|
+
let worst = -1, worstConflicts = 0, worstMean = Infinity
|
|
122
|
+
for (const m of members) {
|
|
123
|
+
let conflicts = 0, sum = 0
|
|
124
|
+
for (const other of members) {
|
|
125
|
+
if (m === other) continue
|
|
126
|
+
if (sim[m][other] < floor) conflicts++
|
|
127
|
+
sum += sim[m][other]
|
|
128
|
+
}
|
|
129
|
+
const meanSim = members.length > 1 ? sum / (members.length - 1) : 1
|
|
130
|
+
if (conflicts > worstConflicts || (conflicts === worstConflicts && conflicts > 0 && meanSim <= worstMean)) {
|
|
131
|
+
worst = m; worstConflicts = conflicts; worstMean = meanSim
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (worstConflicts === 0) break
|
|
135
|
+
members = members.filter(m => m !== worst)
|
|
136
|
+
// The seed itself can be the outlier — a voice sitting between two others is
|
|
137
|
+
// exactly the member that has to go for the rest to be mutually coherent.
|
|
138
|
+
if (members.length <= 1) break
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A refinement that ate everything but one member left no evidence of a shared
|
|
142
|
+
// voice, only the last survivor of a crowd. Same verdict as no cluster at all.
|
|
143
|
+
if (members.length < 2) return { members: [], seed: -1 }
|
|
144
|
+
|
|
145
|
+
// The seed may have been pruned; report the member that best represents what
|
|
146
|
+
// survived, so `seed` always names a row that is actually in the cluster.
|
|
147
|
+
let seed = members[0], seedMean = -Infinity
|
|
148
|
+
for (const m of members) {
|
|
149
|
+
let sum = 0
|
|
150
|
+
for (const other of members) if (m !== other) sum += sim[m][other]
|
|
151
|
+
const meanSim = sum / (members.length - 1)
|
|
152
|
+
if (meanSim > seedMean) { seed = m; seedMean = meanSim }
|
|
153
|
+
}
|
|
154
|
+
return { members, seed }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Pick the N most acoustically diverse embeddings — greedy max-min-distance.
|
|
159
|
+
*
|
|
160
|
+
* Moved here VERBATIM from routes/voice.ts, where it was a private function used
|
|
161
|
+
* by POST /api/voice/enroll-ext. It was copied rather than shared once already
|
|
162
|
+
* (voice-profile-store.ts `selectDiverseIndices` is the same algorithm over
|
|
163
|
+
* number[][], with a different return ORDER); this module is now the home for the
|
|
164
|
+
* Float32Array form so the relabel path and the ext-audio path cannot drift.
|
|
165
|
+
*
|
|
166
|
+
* Bounding the count is not cosmetic. Every `enrollEmbedding` does
|
|
167
|
+
* loadProfileStore -> persistProfile -> invalidateProfileCache, so the next
|
|
168
|
+
* iteration re-reads and re-parses the whole store — ~41 ms per cycle against a
|
|
169
|
+
* live 7.9 MB / 77-profile file, before the fsync'd write. At 109 chunks that
|
|
170
|
+
* blocks the event loop past COS Control's 30 s helper timeout, and the user is
|
|
171
|
+
* told "Server stopped" for a correction that actually applied.
|
|
172
|
+
*/
|
|
173
|
+
export function greedyDiversitySelect(embeddings: Float32Array[], maxN: number): Float32Array[] {
|
|
174
|
+
if (embeddings.length <= maxN) return embeddings
|
|
175
|
+
|
|
176
|
+
// Find the most dissimilar pair as seeds
|
|
177
|
+
let maxDist = -1, seedA = 0, seedB = 1
|
|
178
|
+
for (let i = 0; i < embeddings.length; i++) {
|
|
179
|
+
for (let j = i + 1; j < embeddings.length; j++) {
|
|
180
|
+
const dist = 1 - rawCosineSimilarity(embeddings[i], embeddings[j])
|
|
181
|
+
if (dist > maxDist) { maxDist = dist; seedA = i; seedB = j }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const selected = new Set([seedA, seedB])
|
|
186
|
+
while (selected.size < maxN) {
|
|
187
|
+
let bestIdx = -1, bestMinDist = -1
|
|
188
|
+
for (let i = 0; i < embeddings.length; i++) {
|
|
189
|
+
if (selected.has(i)) continue
|
|
190
|
+
let minDist = Infinity
|
|
191
|
+
for (const s of selected) {
|
|
192
|
+
const dist = 1 - rawCosineSimilarity(embeddings[i], embeddings[s])
|
|
193
|
+
if (dist < minDist) minDist = dist
|
|
194
|
+
}
|
|
195
|
+
if (minDist > bestMinDist) { bestMinDist = minDist; bestIdx = i }
|
|
196
|
+
}
|
|
197
|
+
if (bestIdx === -1) break
|
|
198
|
+
selected.add(bestIdx)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return [...selected].map(i => embeddings[i])
|
|
202
|
+
}
|
package/server/routes/meeting.ts
CHANGED
|
@@ -23,8 +23,8 @@ import {
|
|
|
23
23
|
meetingAudioChunkPath,
|
|
24
24
|
meetingAudioRetentionDays,
|
|
25
25
|
} from '../lib/meeting-audio-archive.js'
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
26
|
+
import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
|
|
27
|
+
import { enrolNamedVoice } from '../lib/meeting-relabel-enrolment.js'
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* The label a de-attributed voice takes, numbered within its meeting.
|
|
@@ -975,46 +975,6 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
975
975
|
})
|
|
976
976
|
})
|
|
977
977
|
|
|
978
|
-
/** Labels the diariser invents when it does not know who is speaking. Naming one
|
|
979
|
-
* of these is a FIRST TRAINING RUN for a new person, not a correction. */
|
|
980
|
-
const PLACEHOLDER_LABEL = /^(ext|unknown|unidentified(\s+\d+)?|speaker\s*\d+)$/i
|
|
981
|
-
const isPlaceholderLabel = (label: string): boolean => PLACEHOLDER_LABEL.test(label.trim())
|
|
982
|
-
|
|
983
|
-
/**
|
|
984
|
-
* Turn a named placeholder into a real voice profile.
|
|
985
|
-
*
|
|
986
|
-
* WHY THIS EXISTS. Relabelling was TEXT ONLY: it rewrote `speaker` strings in the
|
|
987
|
-
* meeting sidecar and never touched the voice store. So naming an unidentified
|
|
988
|
-
* voice "Kirstyn Blum" labelled that one meeting and taught the system nothing —
|
|
989
|
-
* she never appeared in `/api/voice/profiles`, the review panel still offered her
|
|
990
|
-
* as `new name` inside the SAME meeting, no later meeting could match her, and
|
|
991
|
-
* there was no profile to accumulate further chunks against. The server already
|
|
992
|
-
* had `/api/voice/enroll-ext` for exactly this and the naming flow never called it.
|
|
993
|
-
*
|
|
994
|
-
* Enrolment is ADDITIVE and scoped: it runs only when a placeholder becomes a real
|
|
995
|
-
* name. Correcting one real name to another is left alone deliberately — moving a
|
|
996
|
-
* voice between existing people is `merge-profiles`, which is explicit and
|
|
997
|
-
* confirmation-gated, and doing it implicitly here would poison profiles.
|
|
998
|
-
*
|
|
999
|
-
* `enrollEmbedding` owns the diversity gate and the FIFO cap, so feeding it the
|
|
1000
|
-
* relabelled chunks cannot bloat a profile.
|
|
1001
|
-
*/
|
|
1002
|
-
const enrolNamedVoice = (sessionId: string, from: string, to: string, changed: number[]): number => {
|
|
1003
|
-
if (!isPlaceholderLabel(from) || isPlaceholderLabel(to) || changed.length === 0) return 0
|
|
1004
|
-
let enrolled = 0
|
|
1005
|
-
try {
|
|
1006
|
-
for (const row of chunkEmbeddingsForIndices(sessionId, changed)) {
|
|
1007
|
-
if (!row.embedding) continue
|
|
1008
|
-
if (enrollEmbedding(to, row.embedding, 'meeting-relabel').success) enrolled += 1
|
|
1009
|
-
}
|
|
1010
|
-
} catch {
|
|
1011
|
-
// Enrolment is a bonus on top of the relabel. A voice store that refuses must
|
|
1012
|
-
// not fail the rename the user actually asked for.
|
|
1013
|
-
return enrolled
|
|
1014
|
-
}
|
|
1015
|
-
return enrolled
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
978
|
router.post('/meeting/:sessionId/relabel', (req, res) => {
|
|
1019
979
|
res.set('Cache-Control', 'private, no-store')
|
|
1020
980
|
const sessionId = String(req.params.sessionId ?? '')
|
|
@@ -1192,28 +1152,34 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1192
1152
|
})
|
|
1193
1153
|
|
|
1194
1154
|
// Enrol AFTER the sidecar and ledger are durable: the rename is the thing the
|
|
1195
|
-
// user asked for, and a voice store that refuses must not undo it.
|
|
1196
|
-
// the panel can say a profile was created rather than leaving the user to
|
|
1197
|
-
// discover, in another meeting, that it was not.
|
|
1198
|
-
// DISABLED in 6.27.11. 6.27.10 shipped this joining the WRONG INDEX SPACE:
|
|
1199
|
-
// `plan.value.changed` are positions in the COMPACTED sidecar array
|
|
1200
|
-
// (meeting-relabel.ts:119, over rows already filtered to those with text) while
|
|
1201
|
-
// the embedding store is keyed on the RAW capture index
|
|
1202
|
-
// (transcribe-stream.ts:1868). They diverge at the first text-less chunk.
|
|
1155
|
+
// user asked for, and a voice store that refuses must not undo it.
|
|
1203
1156
|
//
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1157
|
+
// `plan.value.changed` are COMPACTED SIDECAR POSITIONS. They are handed to
|
|
1158
|
+
// enrolNamedVoice together with the PARSED SIDECAR precisely so it can convert
|
|
1159
|
+
// them to raw capture indices via attachRawChunkIndices — the join 6.27.10 got
|
|
1160
|
+
// wrong, enrolling 73 of 103 rows belonging to other people including the
|
|
1161
|
+
// device owner. Never pass these positions to anything keyed on raw indices.
|
|
1208
1162
|
//
|
|
1209
|
-
// `
|
|
1210
|
-
//
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1163
|
+
// `sidecarRaw` parses by construction: relabelSidecarJson already parsed it
|
|
1164
|
+
// above and returned ok. The catch is for a caller that reorders those steps.
|
|
1165
|
+
let parsedSidecar: Record<string, unknown> = {}
|
|
1166
|
+
try {
|
|
1167
|
+
const doc = JSON.parse(sidecarRaw) as unknown
|
|
1168
|
+
if (doc && typeof doc === 'object' && !Array.isArray(doc)) parsedSidecar = doc as Record<string, unknown>
|
|
1169
|
+
} catch { /* enrolment refuses on an unusable sidecar; the rename already landed */ }
|
|
1170
|
+
|
|
1171
|
+
const enrolment = enrolNamedVoice({ sessionId, from, to, changed: plan.value.changed, sidecar: parsedSidecar })
|
|
1172
|
+
res.json({
|
|
1173
|
+
ok: true,
|
|
1174
|
+
correctionId: id,
|
|
1175
|
+
// Retained for COS Control builds that read the 6.27.10 field name. The
|
|
1176
|
+
// `enrolment` block is the honest report: a bare count cannot say whether a
|
|
1177
|
+
// profile was created, whether chunks were rejected as a different voice, or
|
|
1178
|
+
// whether the whole thing was skipped for a nameable reason.
|
|
1179
|
+
enrolledEmbeddings: enrolment.enrolled,
|
|
1180
|
+
enrolment,
|
|
1181
|
+
...preview,
|
|
1182
|
+
})
|
|
1217
1183
|
})
|
|
1218
1184
|
|
|
1219
1185
|
|
package/server/routes/voice.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { Router } from 'express'
|
|
|
4
4
|
import { errMsg } from '../lib/utils.js'
|
|
5
5
|
import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
|
|
6
6
|
import { resolve } from 'node:path'
|
|
7
|
-
import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding,
|
|
7
|
+
import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
|
|
8
8
|
import { statSync } from 'node:fs'
|
|
9
9
|
import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
|
|
10
10
|
import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
@@ -13,6 +13,7 @@ import { purgeSpeakerCalibrationRows, relabelSpeakerCalibrationRows } from '../l
|
|
|
13
13
|
import { trainingSourceFor } from '../lib/training-audio-provenance.js'
|
|
14
14
|
import { sendAudioFile } from '../lib/send-audio.js'
|
|
15
15
|
import { getVoiceDirectorySnapshot, invalidateVoiceDirectory } from '../lib/voice-directory.js'
|
|
16
|
+
import { greedyDiversitySelect } from '../lib/voice-enrolment-selection.js'
|
|
16
17
|
|
|
17
18
|
// These MUST match the writer in transcribe-stream.ts, which saves under
|
|
18
19
|
// dataPath(). They previously resolved relative to __dirname — i.e. inside the
|
|
@@ -736,34 +737,7 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
736
737
|
}
|
|
737
738
|
})
|
|
738
739
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
// Find the most dissimilar pair as seeds
|
|
744
|
-
let maxDist = -1, seedA = 0, seedB = 1
|
|
745
|
-
for (let i = 0; i < embeddings.length; i++) {
|
|
746
|
-
for (let j = i + 1; j < embeddings.length; j++) {
|
|
747
|
-
const dist = 1 - rawCosineSimilarity(embeddings[i], embeddings[j])
|
|
748
|
-
if (dist > maxDist) { maxDist = dist; seedA = i; seedB = j }
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
const selected = new Set([seedA, seedB])
|
|
753
|
-
while (selected.size < maxN) {
|
|
754
|
-
let bestIdx = -1, bestMinDist = -1
|
|
755
|
-
for (let i = 0; i < embeddings.length; i++) {
|
|
756
|
-
if (selected.has(i)) continue
|
|
757
|
-
let minDist = Infinity
|
|
758
|
-
for (const s of selected) {
|
|
759
|
-
const dist = 1 - rawCosineSimilarity(embeddings[i], embeddings[s])
|
|
760
|
-
if (dist < minDist) minDist = dist
|
|
761
|
-
}
|
|
762
|
-
if (minDist > bestMinDist) { bestMinDist = minDist; bestIdx = i }
|
|
763
|
-
}
|
|
764
|
-
if (bestIdx === -1) break
|
|
765
|
-
selected.add(bestIdx)
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
return [...selected].map(i => embeddings[i])
|
|
769
|
-
}
|
|
740
|
+
// `greedyDiversitySelect` moved VERBATIM to lib/voice-enrolment-selection.ts and
|
|
741
|
+
// is imported at the top of this file. The meeting-relabel enrolment path needs
|
|
742
|
+
// the same bound and the same selection, and a second copy of a greedy
|
|
743
|
+
// max-min-distance search is how two callers of "pick the diverse ones" drift.
|