@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.
@@ -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
- const MAX_EMBEDDINGS_PER_SPEAKER = 20 // FIFO cap oldest drops when full
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
- console.log(`[speaker] Profile cap reached for "${name}" (${profile.embeddings.length}/${MAX_EMBEDDINGS_PER_SPEAKER}) dropping oldest`)
312
- dropOldestEmbedding(profile)
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
- const UNKNOWN_SOURCE = 'unknown'
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 = []
@@ -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,