@gotcos/glasses-server 6.16.9 → 6.17.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/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
1
+ ## 6.17.0
2
+
3
+ - **Speaker diarization is now a bolt-on any install can enable.** The ~26 MB
4
+ voiceprint model stays out of the npm package, but the loader no longer looks
5
+ only inside the package (where a managed install has no copy, and where a
6
+ hand-placed one is destroyed by the next update). Resolution order:
7
+ `COS_SPEAKER_MODEL_PATH` → `~/.cos-glasses/models/` → bundled `server/models/`
8
+ (source checkouts). Put the model in `~/.cos-glasses/models/` and restart.
9
+ - **A bad model file can no longer take the server down.** onnxruntime aborts
10
+ the process on a malformed or mismatched model rather than throwing, which
11
+ under a KeepAlive LaunchAgent became a permanent restart loop that killed
12
+ queries, meetings, and transcription over an optional feature. The file is now
13
+ screened structurally and loaded in a throwaway child process first, so a
14
+ truncated download or an HTML error page saved as `.onnx` disables diarization
15
+ and nothing else.
16
+ - **`/api/health` reports `speaker_id`** as `active` / `unavailable` / `error`,
17
+ so a server running without voiceprints is no longer indistinguishable from
18
+ one doing real diarization. `error` means a model is installed but the runtime
19
+ rejected it.
20
+ - **Profile config is read from the data home.** `.cos-profile.json` is now
21
+ loaded from `~/.cos-glasses/` when present, ahead of the package-root copy.
22
+ Managed installs previously resolved it inside the generation directory, where
23
+ no profile exists and every field silently took its default — losing
24
+ transcription vocabulary, whisper corrections, and the wearer label.
25
+ - **`/api/voice/enroll` and `/api/voice/status` no longer hardcode `MU`.** Both
26
+ use `owner_speaker_label` (default `Me`), matching what identification already
27
+ used. **Upgrade note:** an install that enrolled under the old default holds a
28
+ profile named `MU`; `/api/voice/status` will report `enrolled: false` until you
29
+ set `owner_speaker_label` to `MU`. Do that rather than re-enrolling, which
30
+ splits one voice across two profiles.
31
+
1
32
  ## 6.16.9
2
33
 
3
34
  - **Message-era reset visible to a running server.** `currentMessageEraState`
package/README.md CHANGED
@@ -163,6 +163,55 @@ Telegram activity export is disabled by default even when a private COS
163
163
  pipeline contains `.telegram_config.json`; enable it only with the explicit
164
164
  `COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
165
165
 
166
+ ## Speaker diarization (opt-in)
167
+
168
+ Without a voiceprint model this server does not classify speakers at all — it
169
+ passes through whatever label the client sends (`Unknown` when the client sends
170
+ nothing; the COS companion sends its own wearer/`Ext` labels). Named
171
+ per-speaker diarization needs a ~26 MB voiceprint model that is deliberately
172
+ **not** shipped in the npm package, so it is a bolt-on:
173
+
174
+ ```bash
175
+ mkdir -p ~/.cos-glasses/models
176
+ # put 3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx there, then restart
177
+ ```
178
+
179
+ The server searches, in order: `COS_SPEAKER_MODEL_PATH` (explicit full path to
180
+ the `.onnx`), `~/.cos-glasses/models/`, then a bundled `server/models/` copy
181
+ (source checkouts only). **Use the data home, not the installed package** —
182
+ anything inside the package is destroyed by the next update, while
183
+ `~/.cos-glasses/` survives.
184
+
185
+ Use an **absolute** path if you set `COS_SPEAKER_MODEL_PATH` — a relative one
186
+ resolves against the working directory, which under the managed LaunchAgent is
187
+ the installed package.
188
+
189
+ Verify with `/api/health` → `speaker_id`:
190
+
191
+ | Value | Meaning |
192
+ |---|---|
193
+ | `active` | model loaded, diarization running |
194
+ | `unavailable` | no model found — labels come from the client |
195
+ | `error` | a model is present but the runtime rejected it (see the startup log) |
196
+
197
+ The model is read once at startup, so restart after adding it. A corrupt or
198
+ mismatched `.onnx` is screened and probed in a child process first, so a bad
199
+ download disables diarization instead of taking the server down — but it does
200
+ mean a wrong file fails silently apart from that log line.
201
+
202
+ The wearer's label comes from `owner_speaker_label` in
203
+ `~/.cos-glasses/.cos-profile.json` (default `Me`); set it to match the profile
204
+ name you enrol under. Train voices via `/api/voice/enroll?name=…` (the default
205
+ name is `owner_speaker_label`); profiles persist in
206
+ `~/.cos-glasses/data/voice-profiles.json`.
207
+
208
+ **Upgrading from an older server:** before this release the enrollment default
209
+ was hardcoded to `MU`, so an existing install may hold a profile under that name
210
+ while `owner_speaker_label` resolves to `Me`. `/api/voice/status` will then
211
+ report `enrolled: false`. Set `owner_speaker_label` to `MU` to keep the existing
212
+ voiceprints rather than re-enrolling, which would split the same voice across two
213
+ profiles.
214
+
166
215
  ## HQ dictation
167
216
 
168
217
  Prompt dictation defaults to HQ. The phone owns the preference: **Fast mode
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.16.9",
4
- "description": "COS Glasses self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
3
+ "version": "6.17.0",
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": {
7
7
  "glasses-server": "bin/cli.cjs",
@@ -1,16 +1,38 @@
1
1
  // Profile loader — reads user identity from .cos-profile.json (gitignored)
2
2
  // Falls back to generic defaults for users who haven't configured a profile
3
3
 
4
- import { readFileSync } from 'node:fs'
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
5
6
  import { resolve } from 'node:path'
6
7
  import { atomicWriteFileSync } from './atomic-fs.js'
7
8
 
8
9
  const APP_ROOT = resolve(import.meta.dirname, '../..')
10
+
11
+ /** The profile in the data home. Survives updates; the APP_ROOT copy does not. */
12
+ export function homeProfilePath(): string {
13
+ return resolve(homedir(), '.cos-glasses', '.cos-profile.json')
14
+ }
15
+
9
16
  // Single canonical path — used by BOTH the reader and the writer so a glossary
10
17
  // PUT can never write to a different file than the cache reads from. Lazy +
11
18
  // env-overridable (COS_PROFILE_PATH) so tests can target a temp file.
19
+ //
20
+ // APP_ROOT is the INSTALLED PACKAGE root. For a managed install that is inside
21
+ // the generation directory, which an update replaces wholesale — so a profile
22
+ // there is destroyed on every upgrade, and a fresh managed install has no
23
+ // profile at all (loadProfile() catches to {} and every field silently takes
24
+ // its default). Relying on COS_PROFILE_PATH alone is not enough either: the
25
+ // launcher rebuilds its environment from the release manifest rather than the
26
+ // existing plist, so an operator-set value is dropped by the next
27
+ // install/update/repair. Prefer the data home whenever a profile lives there.
28
+ //
29
+ // Order: explicit override → ~/.cos-glasses/.cos-profile.json → package root.
12
30
  function profilePath(): string {
13
- return process.env.COS_PROFILE_PATH || resolve(APP_ROOT, '.cos-profile.json')
31
+ const override = process.env.COS_PROFILE_PATH?.trim()
32
+ if (override) return resolve(override)
33
+ const home = homeProfilePath()
34
+ if (existsSync(home)) return home
35
+ return resolve(APP_ROOT, '.cos-profile.json')
14
36
  }
15
37
 
16
38
  let profileCache: Record<string, unknown> | null = null
@@ -8,18 +8,62 @@
8
8
  import { resolve } from 'node:path'
9
9
  import { errMsg } from './utils.js'
10
10
  import { getOwnerSpeakerLabel } from './profile.js'
11
- import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
11
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, statSync, openSync, readSync, closeSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { spawnSync } from 'node:child_process'
12
14
  import { fileURLToPath } from 'node:url'
13
15
 
16
+ /** A real voiceprint model is ~26 MB; anything this small is a bad download. */
17
+ const MIN_MODEL_BYTES = 1_000_000
18
+ const PROBE_TIMEOUT_MS = 30_000
19
+
14
20
  // sherpa-onnx-node is CJS — use createRequire for ESM compat
15
21
  import { createRequire } from 'node:module'
16
22
  const require = createRequire(import.meta.url)
17
23
 
18
24
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
19
25
 
20
- const MODEL_PATH = resolve(__dirname, '..', 'models',
21
- '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx')
22
26
  import { DATA_DIR } from './data-dir.js'
27
+
28
+ export const SPEAKER_MODEL_FILENAME = '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx'
29
+
30
+ /** Where the voiceprint model may live, in priority order:
31
+ *
32
+ * 1. COS_SPEAKER_MODEL_PATH — explicit override (full path to the .onnx).
33
+ * 2. ~/.cos-glasses/models/ — the bolt-on location. The model is ~26 MB and is
34
+ * deliberately NOT in the npm tarball, so a managed install has no bundled
35
+ * copy. The data home survives generation swaps; anything inside the
36
+ * installed package does not, and a model dropped there is destroyed by the
37
+ * next update.
38
+ * 3. server/models/ — bundled, which only exists in a source checkout.
39
+ *
40
+ * Anchored on homedir() rather than DATA_DIR/'..' on purpose: path.resolve is
41
+ * purely lexical, so deriving a sibling of a relocated COS_DATA_DIR could point
42
+ * the "durable" candidate at an unwritable root, or — if COS_DATA_DIR were ever
43
+ * set inside the package — collapse it back onto the very directory an update
44
+ * destroys, silently reintroducing the bug this ordering exists to fix.
45
+ *
46
+ * Diarization is opt-in by design: with no model the system stays on amplitude
47
+ * fallback (wearer vs Ext) rather than failing. speakerModelState() exists so
48
+ * that choice is VISIBLE in /api/health instead of silently degrading.
49
+ */
50
+ export function speakerModelCandidates(): string[] {
51
+ const override = process.env.COS_SPEAKER_MODEL_PATH?.trim()
52
+ return [
53
+ ...(override ? [resolve(override)] : []),
54
+ resolve(homedir(), '.cos-glasses', 'models', SPEAKER_MODEL_FILENAME),
55
+ resolve(__dirname, '..', 'models', SPEAKER_MODEL_FILENAME),
56
+ ]
57
+ }
58
+
59
+ function resolveSpeakerModelPath(): string | null {
60
+ // isFile, not existsSync: a directory passes an existence check and would be
61
+ // handed to the native loader as a model.
62
+ return speakerModelCandidates().find(p => {
63
+ try { return statSync(p).isFile() } catch { return false }
64
+ }) ?? null
65
+ }
66
+
23
67
  const PROFILES_PATH = resolve(DATA_DIR, 'voice-profiles.json')
24
68
  const CALIBRATION_LOG = resolve(DATA_DIR, 'speaker-calibration.jsonl')
25
69
 
@@ -58,14 +102,109 @@ interface ProfileStore {
58
102
  profiles: VoiceProfile[]
59
103
  }
60
104
 
105
+ /** Cheap structural screen for a downloaded model.
106
+ *
107
+ * Catches the common bad downloads — an HTML error/redirect page saved as
108
+ * .onnx, or a truncated transfer — before the file reaches the native runtime.
109
+ * ONNX is protobuf: field 1 (ir_version, varint) encodes as a leading 0x08.
110
+ * This is a screen, not validation; probeModelSafely() is the real gate. */
111
+ function looksLikeOnnxModel(path: string): boolean {
112
+ try {
113
+ if (statSync(path).size < MIN_MODEL_BYTES) return false
114
+ const head = Buffer.alloc(1)
115
+ const fd = openSync(path, 'r')
116
+ try { readSync(fd, head, 0, 1, 0) } finally { closeSync(fd) }
117
+ return head[0] === 0x08
118
+ } catch { return false }
119
+ }
120
+
121
+ /** Load the model in a throwaway child process first.
122
+ *
123
+ * onnxruntime does not throw on a malformed or mismatched model — it calls
124
+ * std::terminate, so the process dies with SIGABRT (or SIGSEGV for a valid-but-
125
+ * wrong model). No try/catch can intercept that. In a managed install the
126
+ * LaunchAgent has KeepAlive, so the death becomes a permanent restart loop that
127
+ * takes down queries, meetings, and transcription — the whole server, over an
128
+ * optional feature.
129
+ *
130
+ * Absorbing that crash in a child keeps a bad file a diarization problem
131
+ * instead of an outage. Cost is one short-lived process, once, and only when a
132
+ * model is actually present. */
133
+ function probeModelSafely(modelPath: string): { ok: true } | { ok: false; reason: string } {
134
+ const script =
135
+ "const{SpeakerEmbeddingExtractor}=require('sherpa-onnx-node');" +
136
+ "new SpeakerEmbeddingExtractor({model:process.argv[1],numThreads:1,provider:'cpu'});"
137
+ const probe = spawnSync(process.execPath, ['-e', script, modelPath], {
138
+ cwd: resolve(__dirname, '..', '..'), // package root, so sherpa-onnx-node resolves
139
+ timeout: PROBE_TIMEOUT_MS,
140
+ stdio: ['ignore', 'ignore', 'pipe'],
141
+ })
142
+ if (probe.signal) return { ok: false, reason: `native runtime aborted (${probe.signal})` }
143
+ if (probe.error) return { ok: false, reason: probe.error.message }
144
+ if (probe.status !== 0) {
145
+ const stderr = String(probe.stderr ?? '').trim().split('\n').pop() ?? ''
146
+ return { ok: false, reason: stderr || `probe exited ${probe.status}` }
147
+ }
148
+ return { ok: true }
149
+ }
150
+
61
151
  /** Initialize speaker embedding system. Returns false if model missing (graceful degradation). */
62
152
  export function initSpeakerEmbeddings(): boolean {
63
153
  if (initialized) return extractor !== null
64
154
 
65
155
  initialized = true
66
156
 
67
- if (!existsSync(MODEL_PATH)) {
68
- console.log('[speaker] Model not found at', MODEL_PATH, '— embedding disabled, using amplitude fallback')
157
+ const override = process.env.COS_SPEAKER_MODEL_PATH?.trim()
158
+ if (override) {
159
+ const overridePath = resolve(override)
160
+ let usable = false
161
+ try { usable = statSync(overridePath).isFile() } catch { usable = false }
162
+ if (!usable) {
163
+ // Falling through silently would leave the operator believing an override
164
+ // they mistyped (or pointed at a directory) is in effect.
165
+ console.warn(
166
+ `[speaker] COS_SPEAKER_MODEL_PATH=${overridePath} is not a readable file — ignoring it`,
167
+ 'and falling back to the remaining candidates.',
168
+ )
169
+ }
170
+ if (override !== overridePath) {
171
+ // Relative paths resolve against cwd, which under launchd is the installed
172
+ // package — a location the next update deletes.
173
+ console.warn(
174
+ `[speaker] COS_SPEAKER_MODEL_PATH is relative; resolved against the working directory to ${overridePath}.`,
175
+ 'Use an absolute path so it cannot move with the process.',
176
+ )
177
+ }
178
+ }
179
+
180
+ const modelPath = resolveSpeakerModelPath()
181
+ if (!modelPath) {
182
+ const boltOn = resolve(homedir(), '.cos-glasses', 'models')
183
+ console.log(
184
+ '[speaker] Voiceprint model not found — embedding disabled, speaker labels come from the client.',
185
+ `Searched: ${speakerModelCandidates().join(', ')}.`,
186
+ // Name the durable directory outright. An ordinal ("the second path")
187
+ // shifts with the override and pointed users at the package copy, which
188
+ // the next update deletes.
189
+ `To enable diarization put ${SPEAKER_MODEL_FILENAME} in ${boltOn}/ and restart.`,
190
+ )
191
+ return false
192
+ }
193
+
194
+ if (!looksLikeOnnxModel(modelPath)) {
195
+ console.error(
196
+ `[speaker] ${modelPath} does not look like an ONNX model (too small, or not protobuf) —`,
197
+ 'embedding disabled. A partial download or an HTML error page saved as .onnx does this.',
198
+ )
199
+ return false
200
+ }
201
+
202
+ const probe = probeModelSafely(modelPath)
203
+ if (!probe.ok) {
204
+ console.error(
205
+ `[speaker] ${modelPath} failed to load — embedding disabled. Reason: ${probe.reason}.`,
206
+ 'The server is otherwise unaffected; replace the model file and restart.',
207
+ )
69
208
  return false
70
209
  }
71
210
 
@@ -73,7 +212,7 @@ export function initSpeakerEmbeddings(): boolean {
73
212
  sherpaOnnx = require('sherpa-onnx-node')
74
213
 
75
214
  extractor = new sherpaOnnx.SpeakerEmbeddingExtractor({
76
- model: MODEL_PATH,
215
+ model: modelPath,
77
216
  numThreads: 2,
78
217
  provider: 'cpu',
79
218
  })
@@ -332,6 +471,30 @@ export function isEmbeddingAvailable(): boolean {
332
471
  return extractor !== null && manager !== null
333
472
  }
334
473
 
474
+ /** Reported on /api/health so an amplitude fallback is never mistaken for real
475
+ * diarization.
476
+ *
477
+ * `state` is the RUNTIME truth (isEmbeddingAvailable), not "is a model file on
478
+ * disk". Those diverge in both directions: a model deleted after a successful
479
+ * load leaves diarization working from memory, and a model present alongside a
480
+ * broken/ABI-mismatched sherpa-onnx never loads at all. `error` distinguishes
481
+ * that second case — a model is installed but the runtime rejected it — from a
482
+ * simply unconfigured install, which otherwise look identical to an operator.
483
+ *
484
+ * Declared after the module state it reads: hoisting it above `extractor`
485
+ * would make any import-time caller throw a ReferenceError on an
486
+ * unauthenticated endpoint. */
487
+ export function speakerModelState(): {
488
+ state: 'active' | 'unavailable' | 'error'
489
+ path: string | null
490
+ searched: string[]
491
+ } {
492
+ const path = resolveSpeakerModelPath()
493
+ const running = isEmbeddingAvailable()
494
+ const state = running ? 'active' : (path && initialized ? 'error' : 'unavailable')
495
+ return { state, path, searched: speakerModelCandidates() }
496
+ }
497
+
335
498
  /** Compute actual cosine similarity between two raw embedding vectors */
336
499
  export function rawCosineSimilarity(a: Float32Array, b: Float32Array): number {
337
500
  if (a.length !== b.length) return 0
@@ -7,6 +7,7 @@ import { serverMetrics } from '../lib/server-metrics.js'
7
7
  import { getServerInstanceId } from '../lib/server-instance-id.js'
8
8
  import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
9
9
  import { isSileroAvailable } from '../lib/vad-silero.js'
10
+ import { speakerModelState } from '../lib/speaker-embeddings.js'
10
11
  import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
11
12
  import {
12
13
  isWhisperLocalAvailable,
@@ -176,6 +177,12 @@ healthRouter.get('/health', async (_req, res) => {
176
177
 
177
178
  checks.silero_vad = isSileroAvailable() ? 'active' : 'disabled'
178
179
 
180
+ // Speaker diarization is opt-in (the ~26 MB voiceprint model ships outside the
181
+ // npm tarball), so publish its state rather than letting the amplitude
182
+ // fallback masquerade as working diarization. Availability only — the resolved
183
+ // path is a local filesystem detail and health is unauthenticated.
184
+ checks.speaker_id = speakerModelState().state
185
+
179
186
  // Health is unauthenticated. Publish only availability; the actual CLI
180
187
  // session id is a resumable runtime handle and belongs on authenticated
181
188
  // query/debug surfaces.
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
8
8
  import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount } from '../lib/speaker-embeddings.js'
9
9
  import { statSync } from 'node:fs'
10
10
  import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
11
+ import { getOwnerSpeakerLabel } from '../lib/profile.js'
11
12
 
12
13
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
13
14
  const AUDIO_SAVE_DIR = resolve(__dirname, '..', 'data', 'training-audio')
@@ -18,7 +19,10 @@ export const voiceRouter = Router()
18
19
  // POST /api/voice/enroll — accept WAV audio, extract embedding, store as profile
19
20
  voiceRouter.post('/voice/enroll', async (req, res) => {
20
21
  try {
21
- const name = (req.query.name as string) || 'MU'
22
+ // Default to the configured wearer label ('Me' unless owner_speaker_label
23
+ // is set). Hardcoding one user's initials here enrolled every other install
24
+ // under a stranger's name.
25
+ const name = (req.query.name as string) || getOwnerSpeakerLabel()
22
26
 
23
27
  // Collect raw audio body
24
28
  const buffers: Buffer[] = []
@@ -38,10 +42,12 @@ voiceRouter.post('/voice/enroll', async (req, res) => {
38
42
  }
39
43
  })
40
44
 
41
- // GET /api/voice/status — is MU enrolled?
45
+ // GET /api/voice/status — is the wearer enrolled?
42
46
  voiceRouter.get('/voice/status', (_req, res) => {
47
+ const owner = getOwnerSpeakerLabel()
43
48
  res.json({
44
- enrolled: isEnrolled('MU'),
49
+ owner,
50
+ enrolled: isEnrolled(owner),
45
51
  speakers: getAllSpeakerNames(),
46
52
  })
47
53
  })