@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 +31 -0
- package/README.md +49 -0
- package/package.json +2 -2
- package/server/lib/profile.ts +24 -2
- package/server/lib/speaker-embeddings.ts +169 -6
- package/server/routes/health.ts +7 -0
- package/server/routes/voice.ts +9 -3
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.
|
|
4
|
-
"description": "COS Glasses
|
|
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",
|
package/server/lib/profile.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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:
|
|
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
|
package/server/routes/health.ts
CHANGED
|
@@ -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.
|
package/server/routes/voice.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
49
|
+
owner,
|
|
50
|
+
enrolled: isEnrolled(owner),
|
|
45
51
|
speakers: getAllSpeakerNames(),
|
|
46
52
|
})
|
|
47
53
|
})
|