@gotcos/glasses-server 6.21.14 → 6.21.18
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 +167 -0
- package/package.json +1 -1
- package/server/lib/chunk-embedding-store.ts +260 -0
- package/server/lib/embedding-eviction.ts +133 -0
- package/server/lib/meeting-audio-archive.ts +314 -0
- package/server/lib/meeting-corrections.ts +218 -0
- package/server/lib/meeting-relabel.ts +279 -0
- package/server/lib/meeting-speaker-review.ts +223 -6
- package/server/lib/speaker-embeddings.ts +63 -9
- package/server/lib/training-audio-provenance.ts +70 -0
- package/server/lib/voice-profile-store.ts +36 -1
- package/server/routes/health.ts +19 -1
- package/server/routes/meeting.ts +508 -2
- package/server/routes/transcribe-stream.ts +59 -0
- package/server/routes/voice.ts +91 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Speaker embedding extraction and verification using sherpa-onnx
|
|
2
|
+
import { chooseEviction, tierBreakdown } from './embedding-eviction.js'
|
|
2
3
|
// Wraps ECAPA-TDNN model for voiceprint-based speaker classification.
|
|
3
4
|
// Falls back gracefully if model is missing — amplitude classification continues.
|
|
4
5
|
//
|
|
@@ -15,6 +16,8 @@ import {
|
|
|
15
16
|
mergeProfilesInStore,
|
|
16
17
|
profileSimilarity,
|
|
17
18
|
describeRepairs,
|
|
19
|
+
alignedSources,
|
|
20
|
+
dropEmbeddingAt,
|
|
18
21
|
dropOldestEmbedding,
|
|
19
22
|
hasRepairs,
|
|
20
23
|
loadVoiceProfileStore,
|
|
@@ -87,7 +90,11 @@ const VERIFY_THRESHOLD = 0.65
|
|
|
87
90
|
const SEARCH_THRESHOLD = 0.55
|
|
88
91
|
const AUTO_ENROLL_THRESHOLD = 0.88 // High bar — must be very confident before auto-enrolling
|
|
89
92
|
const AUTO_ENROLL_CONSENSUS = 2 // Must match N times in same session before enrolling
|
|
90
|
-
|
|
93
|
+
// Raised 20 -> 40 on 2026-08-06. Measured, not guessed: search latency is 1 us
|
|
94
|
+
// at 20, 40 AND 80 samples per speaker (77 speakers, sherpa SpeakerEmbeddingManager),
|
|
95
|
+
// so the old cap defended nothing — while 61 of 77 profiles sat AT it, meaning
|
|
96
|
+
// every correction cost a sample. 20 extra slots per speaker is ~1.2 MB.
|
|
97
|
+
const MAX_EMBEDDINGS_PER_SPEAKER = 40
|
|
91
98
|
const SAMPLE_RATE = 16000
|
|
92
99
|
|
|
93
100
|
// Module-level state — sherpa-onnx-node is CJS with no TS types (SDK v0.0.7 interop)
|
|
@@ -308,8 +315,24 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
|
|
|
308
315
|
// `sources?.shift()` no-opped whenever sources was undefined or short,
|
|
309
316
|
// permanently offsetting provenance from the samples it described.
|
|
310
317
|
if (profile.embeddings.length >= MAX_EMBEDDINGS_PER_SPEAKER) {
|
|
311
|
-
|
|
312
|
-
|
|
318
|
+
// Weakest PROVENANCE goes, not the oldest sample. Age is the wrong axis:
|
|
319
|
+
// four profiles at cap would lose their only human-supplied sample to
|
|
320
|
+
// FIFO while unverified attendee-metadata samples sat untouched.
|
|
321
|
+
// alignedSources is defence-in-depth here, not load-bearing: loadProfileStore
|
|
322
|
+
// already pads sources[] to match embeddings[], so a ragged array cannot
|
|
323
|
+
// reach this line (mutation-verified — swapping it for profile.sources is
|
|
324
|
+
// unobservable through this path). It is kept for any future caller that
|
|
325
|
+
// builds a profile without going through the loader, and is unit-tested
|
|
326
|
+
// directly in voice-profile-store.test.ts.
|
|
327
|
+
const choice = chooseEviction(alignedSources(profile), source, MAX_EMBEDDINGS_PER_SPEAKER)
|
|
328
|
+
const { droppedSource } = choice
|
|
329
|
+
? dropEmbeddingAt(profile, choice.index)
|
|
330
|
+
: dropOldestEmbedding(profile)
|
|
331
|
+
console.log(
|
|
332
|
+
`[speaker] Profile full for "${name}" (${MAX_EMBEDDINGS_PER_SPEAKER}) — `
|
|
333
|
+
+ `${choice ? choice.reason : 'no provenance available, dropping the oldest'} `
|
|
334
|
+
+ `[dropped ${droppedSource ?? 'unknown'}, incoming ${source}]`,
|
|
335
|
+
)
|
|
313
336
|
rebuildSpeakerInManager(name, profile.embeddings)
|
|
314
337
|
}
|
|
315
338
|
}
|
|
@@ -342,7 +365,7 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
|
|
|
342
365
|
export function identifySpeaker(
|
|
343
366
|
wavBuffer: Buffer,
|
|
344
367
|
expectedSpeakers?: string[],
|
|
345
|
-
): { speaker: string; similarity: number } | null {
|
|
368
|
+
): { speaker: string; similarity: number; embedding?: Float32Array } | null {
|
|
346
369
|
if (!extractor || !manager) return null
|
|
347
370
|
|
|
348
371
|
try {
|
|
@@ -356,7 +379,7 @@ export function identifySpeaker(
|
|
|
356
379
|
if (isOwner) {
|
|
357
380
|
const similarity = computeCosineSimilarity(embedding, owner)
|
|
358
381
|
logCalibration(owner, similarity, true)
|
|
359
|
-
return { speaker: owner, similarity }
|
|
382
|
+
return { speaker: owner, similarity, embedding }
|
|
360
383
|
}
|
|
361
384
|
}
|
|
362
385
|
|
|
@@ -369,7 +392,7 @@ export function identifySpeaker(
|
|
|
369
392
|
if (matches) {
|
|
370
393
|
const similarity = computeCosineSimilarity(embedding, name)
|
|
371
394
|
logCalibration(name, similarity, true)
|
|
372
|
-
return { speaker: name, similarity }
|
|
395
|
+
return { speaker: name, similarity, embedding }
|
|
373
396
|
}
|
|
374
397
|
}
|
|
375
398
|
}
|
|
@@ -379,12 +402,14 @@ export function identifySpeaker(
|
|
|
379
402
|
if (found && found.length > 0) {
|
|
380
403
|
const similarity = computeCosineSimilarity(embedding, found)
|
|
381
404
|
logCalibration(found, similarity, true)
|
|
382
|
-
return { speaker: found, similarity }
|
|
405
|
+
return { speaker: found, similarity, embedding }
|
|
383
406
|
}
|
|
384
407
|
|
|
385
|
-
// No match — external speaker
|
|
408
|
+
// No match — external speaker. The embedding still goes back: an
|
|
409
|
+
// unidentified voice's vector is the most valuable thing to retain, because
|
|
410
|
+
// naming it later is exactly the correction that has no other evidence.
|
|
386
411
|
logCalibration('Ext', 0, false)
|
|
387
|
-
return { speaker: 'Ext', similarity: 0 }
|
|
412
|
+
return { speaker: 'Ext', similarity: 0, embedding }
|
|
388
413
|
} catch (err: unknown) {
|
|
389
414
|
console.error('[speaker] Identification error:', errMsg(err))
|
|
390
415
|
return null
|
|
@@ -665,6 +690,35 @@ export function speakerReadiness(
|
|
|
665
690
|
return state === 'active' ? 'ready' : 'unavailable'
|
|
666
691
|
}
|
|
667
692
|
|
|
693
|
+
/**
|
|
694
|
+
* Provenance composition across every stored profile, plus the profiles with no
|
|
695
|
+
* human-verified sample at all.
|
|
696
|
+
*
|
|
697
|
+
* Surfaced because the number that mattered here was invisible: the owner's own
|
|
698
|
+
* profile — the one driving owner detection — was 10 attendee-metadata samples,
|
|
699
|
+
* 9 identifier-labelled ones and 1 unlabelled, with nothing a human had ever
|
|
700
|
+
* confirmed. Nothing in any status output said so.
|
|
701
|
+
*/
|
|
702
|
+
export function profileProvenanceSummary(): {
|
|
703
|
+
profiles: number
|
|
704
|
+
atCap: number
|
|
705
|
+
cap: number
|
|
706
|
+
tiers: Record<string, number>
|
|
707
|
+
noHumanSample: string[]
|
|
708
|
+
} {
|
|
709
|
+
const store = loadProfileStore()
|
|
710
|
+
const tiers: Record<string, number> = {}
|
|
711
|
+
const noHumanSample: string[] = []
|
|
712
|
+
let atCap = 0
|
|
713
|
+
for (const p of store.profiles) {
|
|
714
|
+
const breakdown = tierBreakdown(alignedSources(p))
|
|
715
|
+
for (const [k, v] of Object.entries(breakdown)) tiers[k] = (tiers[k] ?? 0) + v
|
|
716
|
+
if (p.embeddings.length >= MAX_EMBEDDINGS_PER_SPEAKER) atCap++
|
|
717
|
+
if (breakdown.human === 0) noHumanSample.push(p.name)
|
|
718
|
+
}
|
|
719
|
+
return { profiles: store.profiles.length, atCap, cap: MAX_EMBEDDINGS_PER_SPEAKER, tiers, noHumanSample }
|
|
720
|
+
}
|
|
721
|
+
|
|
668
722
|
/** Compute actual cosine similarity between two raw embedding vectors */
|
|
669
723
|
export function rawCosineSimilarity(a: Float32Array, b: Float32Array): number {
|
|
670
724
|
if (a.length !== b.length) return 0
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Tracing a stored voice sample back to the meeting that produced it.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS NEEDED. De-attribution has to undo more than a label. When a voice
|
|
4
|
+
// is falsely attributed to someone, the identifier may ALSO have auto-enrolled
|
|
5
|
+
// those segments into that person's profile — so the wrong voice is now part of
|
|
6
|
+
// what the system thinks they sound like, and it will keep matching. Removing the
|
|
7
|
+
// label without removing the samples fixes the transcript and leaves the profile
|
|
8
|
+
// poisoned. That is the mechanism behind the phantom "Erick Hernandez": three
|
|
9
|
+
// mislabelled seeds compounded to eighteen samples through self-training.
|
|
10
|
+
//
|
|
11
|
+
// WHAT CAN AND CANNOT BE TRACED. Provenance strings differ in whether they name
|
|
12
|
+
// a session:
|
|
13
|
+
//
|
|
14
|
+
// auto:<sessionId> traceable — autoEnroll stamps it
|
|
15
|
+
// correction:<sessionId> traceable — the relabel ledger stamps it
|
|
16
|
+
// g2-training:<sessionId> traceable ONLY from 2026-08-06 onward (see below)
|
|
17
|
+
// g2-training NOT traceable — every sample written before that
|
|
18
|
+
// fireflies / manual / … NOT traceable — no session concept
|
|
19
|
+
//
|
|
20
|
+
// `train-g2` used to stamp a bare `g2-training`, discarding which meeting each
|
|
21
|
+
// sample came from even though the source WAV filename carries it
|
|
22
|
+
// (`meeting_1785190805524_uzxmn4_chunk327_sim0.57.wav`). Samples written before
|
|
23
|
+
// the stamp landed cannot be retracted per-meeting, and de-attribution reports
|
|
24
|
+
// that count rather than implying a clean sweep.
|
|
25
|
+
|
|
26
|
+
/** Session id embedded in a training-audio WAV name, or null if absent. */
|
|
27
|
+
export function sessionIdFromTrainingWav(filename: string): string | null {
|
|
28
|
+
// Session ids contain underscores (`meeting_1785190805524_uzxmn4`), so the
|
|
29
|
+
// split has to be on the `_chunk` marker, not on the last underscore.
|
|
30
|
+
const at = filename.indexOf('_chunk')
|
|
31
|
+
if (at <= 0) return null
|
|
32
|
+
const id = filename.slice(0, at)
|
|
33
|
+
return /^[A-Za-z0-9:_-]{3,96}$/.test(id) ? id : null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Provenance to record for a sample trained out of `filename`. */
|
|
37
|
+
export function trainingSourceFor(filename: string): string {
|
|
38
|
+
const id = sessionIdFromTrainingWav(filename)
|
|
39
|
+
return id ? `g2-training:${id}` : 'g2-training'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Did this stored sample come from `sessionId`?
|
|
44
|
+
*
|
|
45
|
+
* Exact match on the session part — a prefix test would let
|
|
46
|
+
* `auto:meeting_123_extra` match session `meeting_123` and retract a different
|
|
47
|
+
* meeting's evidence.
|
|
48
|
+
*/
|
|
49
|
+
export function isSampleFromSession(source: string | undefined | null, sessionId: string): boolean {
|
|
50
|
+
const s = String(source ?? '')
|
|
51
|
+
if (!sessionId) return false
|
|
52
|
+
for (const prefix of ['auto:', 'correction:', 'g2-training:']) {
|
|
53
|
+
if (s.startsWith(prefix) && s.slice(prefix.length) === sessionId) return true
|
|
54
|
+
}
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Samples that belong to this speaker but cannot be tied to any meeting.
|
|
60
|
+
*
|
|
61
|
+
* Reported so a de-attribution is honest about its reach: "retracted 4, and 9
|
|
62
|
+
* older samples cannot be traced to a meeting" is actionable, while silence
|
|
63
|
+
* implies the profile was fully cleaned.
|
|
64
|
+
*/
|
|
65
|
+
export function untraceableSampleCount(sources: Array<string | undefined | null>): number {
|
|
66
|
+
return sources.filter(s => {
|
|
67
|
+
const str = String(s ?? '')
|
|
68
|
+
return !str.includes(':')
|
|
69
|
+
}).length
|
|
70
|
+
}
|
|
@@ -69,7 +69,10 @@ export function describeRepairs(r: StoreRepairs): string {
|
|
|
69
69
|
return parts.join(', ')
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
/** Placeholder provenance for a sample whose source was never recorded.
|
|
73
|
+
* Exported so callers and tests reference the same value the store writes;
|
|
74
|
+
* 3 samples in the live store carry it. */
|
|
75
|
+
export const UNKNOWN_SOURCE = 'unknown'
|
|
73
76
|
|
|
74
77
|
function isUsableEmbedding(row: unknown): row is number[] {
|
|
75
78
|
return Array.isArray(row) && row.length > 0 && row.every(v => typeof v === 'number' && Number.isFinite(v))
|
|
@@ -317,6 +320,38 @@ export function dropOldestEmbedding(profile: VoiceProfile): { droppedSource: str
|
|
|
317
320
|
return { droppedSource }
|
|
318
321
|
}
|
|
319
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Drop the sample at `index`, keeping sources[] in lockstep.
|
|
325
|
+
*
|
|
326
|
+
* Alignment happens BEFORE the splice: a sources[] shorter than embeddings[]
|
|
327
|
+
* would otherwise make index N refer to two different samples in the two
|
|
328
|
+
* arrays, permanently offsetting provenance from the samples it describes —
|
|
329
|
+
* the same class of bug as the old inline `sources?.shift()`.
|
|
330
|
+
*/
|
|
331
|
+
export function dropEmbeddingAt(profile: VoiceProfile, index: number): { droppedSource: string | null } {
|
|
332
|
+
if (!Number.isInteger(index) || index < 0 || index >= profile.embeddings.length) {
|
|
333
|
+
return { droppedSource: null }
|
|
334
|
+
}
|
|
335
|
+
if (!Array.isArray(profile.sources)) profile.sources = []
|
|
336
|
+
while (profile.sources.length < profile.embeddings.length) profile.sources.push(UNKNOWN_SOURCE)
|
|
337
|
+
profile.embeddings.splice(index, 1)
|
|
338
|
+
const droppedSource = profile.sources.splice(index, 1)[0] ?? null
|
|
339
|
+
profile.sources.length = profile.embeddings.length
|
|
340
|
+
return { droppedSource }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Provenance of each stored sample, index-aligned with embeddings[].
|
|
345
|
+
*
|
|
346
|
+
* Holes and a short array both read as undefined, which the eviction policy
|
|
347
|
+
* classifies as `unknown` — the weakest tier. That is the safe direction: a
|
|
348
|
+
* sample whose provenance was lost must never inherit the protection given to
|
|
349
|
+
* one a human supplied.
|
|
350
|
+
*/
|
|
351
|
+
export function alignedSources(profile: VoiceProfile): Array<string | undefined> {
|
|
352
|
+
return Array.from({ length: profile.embeddings.length }, (_, i) => profile.sources?.[i])
|
|
353
|
+
}
|
|
354
|
+
|
|
320
355
|
/** Append a sample to both arrays together. */
|
|
321
356
|
export function appendEmbedding(profile: VoiceProfile, embedding: number[], source: string): void {
|
|
322
357
|
if (!Array.isArray(profile.sources)) profile.sources = []
|
package/server/routes/health.ts
CHANGED
|
@@ -6,7 +6,10 @@ import { serverMetrics } from '../lib/server-metrics.js'
|
|
|
6
6
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
7
7
|
import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
|
|
8
8
|
import { isSileroAvailable } from '../lib/vad-silero.js'
|
|
9
|
-
import { speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
|
|
9
|
+
import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
|
|
10
|
+
import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
|
|
11
|
+
import { correctionStoreStats } from '../lib/meeting-corrections.js'
|
|
12
|
+
import { meetingAudioStats } from '../lib/meeting-audio-archive.js'
|
|
10
13
|
import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
|
|
11
14
|
import {
|
|
12
15
|
isWhisperLocalAvailable,
|
|
@@ -116,6 +119,17 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
116
119
|
const speakerId = speakerModelState()
|
|
117
120
|
checks.speaker_id = speakerId.state
|
|
118
121
|
|
|
122
|
+
// Visible so the correction loop can be seen banking evidence rather than
|
|
123
|
+
// trusted to be. Counts only — no session ids on an unauthenticated surface.
|
|
124
|
+
const chunkEmbeddings = chunkEmbeddingStoreStats()
|
|
125
|
+
// `pending` is the number that matters here: an intent that never closed means
|
|
126
|
+
// some meeting's files may be half-rewritten.
|
|
127
|
+
const speakerCorrections = correctionStoreStats()
|
|
128
|
+
const reviewAudio = meetingAudioStats()
|
|
129
|
+
// `noHumanSample` is the one to read: a profile with no human-verified sample
|
|
130
|
+
// is trained entirely on labels the system chose for itself.
|
|
131
|
+
const voiceProvenance = speakerId.state === 'active' ? profileProvenanceSummary() : null
|
|
132
|
+
|
|
119
133
|
// Health is unauthenticated. Publish only availability; the actual CLI
|
|
120
134
|
// session id is a resumable runtime handle and belongs on authenticated
|
|
121
135
|
// query/debug surfaces.
|
|
@@ -232,6 +246,10 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
232
246
|
cursor_models,
|
|
233
247
|
meeting_sync,
|
|
234
248
|
unsaved_captures,
|
|
249
|
+
chunk_embeddings: chunkEmbeddings,
|
|
250
|
+
speaker_corrections: speakerCorrections,
|
|
251
|
+
review_audio: reviewAudio,
|
|
252
|
+
...(voiceProvenance ? { voice_provenance: voiceProvenance } : {}),
|
|
235
253
|
capabilities: {
|
|
236
254
|
transcription: {
|
|
237
255
|
...transcription,
|