@gotcos/glasses-server 6.37.1 → 6.37.3

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/.env.example CHANGED
@@ -218,7 +218,26 @@ BIND_HOST=0.0.0.0
218
218
  # 72 covers a long weekend away from the Mac.
219
219
  # COS_UNSAVED_AUDIO_RETENTION_HOURS=72
220
220
 
221
- # ── LIVE CUES (optional requires the FULL COS PIPELINE above) ──────────
221
+ # ── LIVE CUES (optional, OFF unless you set it costs model calls) ───────
222
+ #
223
+ # The closest thing COS has to conversing with you DURING a meeting rather than
224
+ # after it: it reads the running transcript and puts short prompts on the lens —
225
+ # the question you would have wanted to ask, while it is still useful.
226
+ #
227
+ # WHAT IT COSTS, PLAINLY. Every cue is a model call on YOUR agent quota, not a
228
+ # free local pass. It is bounded, but it is not free:
229
+ #
230
+ # at most 8 cue pipelines per meeting MAX_PIPELINES_PER_MEETING
231
+ # at most one start per 60 seconds FLOOR_BETWEEN_STARTS_MS
232
+ # 30s cooldown after a cue fires COOLDOWN_AFTER_CUE_MS
233
+ #
234
+ # So a long meeting can spend up to 8 model calls it would not otherwise make,
235
+ # and each one runs the full planner -> memory -> insight chain below. On a
236
+ # metered plan, leave this off. It IS off unless you set it.
237
+ #
238
+ # Runs on Cursor Composer. That is the only supported model in v1 — any other
239
+ # value fails closed rather than quietly billing a model you did not choose.
240
+ #
222
241
  # Live meeting coaching cues on the lens: transcript window -> Composer
223
242
  # planner -> Qdrant -> LightRAG -> Composer insight -> coaching_nudge.
224
243
  # Requires COS_SCRIPTS_DIR (memory hops) plus the Cursor Agent CLI.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,71 @@
1
+ ## 6.37.3
2
+
3
+ Speaker ID works out of the box. A silent capture stops retrying forever. Live
4
+ Cues says what it costs.
5
+
6
+ SPEAKER MODEL IN NORMAL SETUP. 6.37.2 added `--setup-speaker-model` as a command
7
+ you had to know about. Now the voiceprint model is fetched during ordinary setup,
8
+ with `SKIP_SPEAKER_MODEL_DOWNLOAD=1` as the escape hatch — the same shape the
9
+ whisper models already use, and those are 1.5 GB, sixty times larger.
10
+
11
+ Deliberately NOT a lazy fetch on first use. Lazy would fire the download at the
12
+ START OF A MEETING: 26 MB on hotel wifi degrading the exact session it exists to
13
+ improve, and a GitHub release asset promoted from an install-time dependency to a
14
+ runtime one. A failed fetch is non-fatal — diarization is opt-in by design, so
15
+ the server falls back to wearer/Ext rather than refusing to start.
16
+
17
+ A CAPTURE WITH NO SPEECH IS RECOVERED, NOT FAILED. The recover route threw
18
+ `recovery produced an empty transcript` when whisper returned nothing, which left
19
+ the capture in the unsaved list and retried it on every boot and every button
20
+ press. Measured on this machine: one 33-second capture failed that way 1,131
21
+ times, alternating in the log with the auto-recover path claiming success for the
22
+ same session, while the panel showed only "1 recoverable" and the error went to
23
+ stderr where nobody looks. The user presses Recover and nothing happens, because
24
+ nothing can.
25
+
26
+ Silence now writes a receipt with `outcome: 'no_speech'` and clears. The audio is
27
+ NOT deleted — it leaves on the ordinary retention clock, so a capture wrongly
28
+ judged silent by a bad decode is still on disk for its full window.
29
+
30
+ LIVE CUES NOW STATES ITS COST. The flag was already documented as a master
31
+ switch; what was missing was that every cue is a model call on your own agent
32
+ quota. Now stated with the real bounds — at most 8 pipelines per meeting, one
33
+ start per 60s, 30s cooldown — and the real model, Cursor Composer, which is the
34
+ only supported value in v1.
35
+
36
+ 223 files, 3117 tests.
37
+
38
+ ## 6.37.2
39
+
40
+ `--setup-speaker-model` — one command to install the voiceprint model.
41
+
42
+ Named per-speaker diarization needs a ~26 MB model that is deliberately not in
43
+ the npm tarball. Until now the only way to get it was the README telling you to
44
+ "put the .onnx there", which meant a managed install had no way to obtain it at
45
+ all. The server then degrades SILENTLY to amplitude fallback (wearer vs Ext), so
46
+ voice training appears to run and never learns anything. A beta user hit exactly
47
+ that on 2026-08-25 and reported it as "voice training didn't work" -- it did run;
48
+ it had nothing to train against.
49
+
50
+ npx --yes @gotcos/glasses-server@latest --setup-speaker-model
51
+
52
+ - Downloads to ~/.cos-glasses/models/, which survives server updates. Anything
53
+ inside the package is destroyed by the next one.
54
+ - Pinned SHA-256, verified BEFORE the file is moved into place. A mismatch is
55
+ deleted, not installed -- this file is handed to a native loader. The hash was
56
+ checked against the model on a working install and matches byte for byte.
57
+ - Idempotent: an already-correct file is verified and left alone. A corrupted one
58
+ is replaced.
59
+ - Synchronous, using the same curl idiom as the whisper download. The first
60
+ version was async and fire-and-forget, and because bin/cli.cjs is CJS with no
61
+ top-level await the command fell straight through into normal server startup
62
+ and installed nothing. Found by running it, not by reading it.
63
+
64
+ The 26 MB stays out of the tarball; the install path is now a command instead of
65
+ a paragraph.
66
+
67
+ 222 files, 3112 tests.
68
+
1
69
  ## 6.37.1
2
70
 
3
71
  Standalone meeting summaries default ON.
package/README.md CHANGED
@@ -199,6 +199,16 @@ nothing; the COS companion sends its own wearer/`Ext` labels). Named
199
199
  per-speaker diarization needs a ~26 MB voiceprint model that is deliberately
200
200
  **not** shipped in the npm package, so it is a bolt-on:
201
201
 
202
+ ```bash
203
+ npx --yes @gotcos/glasses-server@latest --setup-speaker-model
204
+ ```
205
+
206
+ That downloads the model to `~/.cos-glasses/models/`, verifies it against a
207
+ pinned SHA-256, and refuses to install anything that does not match. Restart the
208
+ server afterwards and check `/api/health` — `speaker_id` should read `active`.
209
+
210
+ To place it by hand instead:
211
+
202
212
  ```bash
203
213
  mkdir -p ~/.cos-glasses/models
204
214
  # put 3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx there, then restart
package/bin/cli.cjs CHANGED
@@ -26,6 +26,7 @@ const PKG_ROOT = resolve(__dirname, '..')
26
26
  const CONFIG_DIR = join(homedir(), '.cos-glasses')
27
27
  const PREPARE_ONLY = process.argv.includes('--prepare-only')
28
28
  const SETUP_TRANSCRIPTION = process.argv.includes('--setup-transcription')
29
+ const SETUP_SPEAKER_MODEL = process.argv.includes('--setup-speaker-model')
29
30
  function optionValue(name) {
30
31
  const index = process.argv.indexOf(name)
31
32
  return index >= 0 ? process.argv[index + 1] : undefined
@@ -53,6 +54,104 @@ const yellow = (s) => `\x1b[33m${s}\x1b[0m`
53
54
  const dim = (s) => `\x1b[2m${s}\x1b[0m`
54
55
  const bold = (s) => `\x1b[1m${s}\x1b[0m`
55
56
 
57
+ /**
58
+ * The ~26 MB voiceprint model, fetched on request.
59
+ *
60
+ * WHY THIS EXISTS. Named per-speaker diarization needs this model, and it is
61
+ * deliberately NOT in the npm tarball -- 26 MB on every install for a feature
62
+ * most users never turn on. The README documented a manual bolt-on ("put the
63
+ * .onnx there"), which left a managed install with no way to obtain it: the
64
+ * server then degrades silently to amplitude fallback (wearer vs Ext), so voice
65
+ * training appears to run and never learns anything. A beta user hit exactly
66
+ * that on 2026-08-25 and reported it as "voice training didn't work".
67
+ *
68
+ * Installed to ~/.cos-glasses/models/ deliberately. Anything inside the package
69
+ * is destroyed by the next update; the data home survives.
70
+ *
71
+ * The SHA-256 below was verified against the model on a working install and
72
+ * matched byte for byte. A download that does not match is DELETED, not
73
+ * installed -- this file is handed to a native loader.
74
+ */
75
+ const SPEAKER_MODEL = {
76
+ filename: '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx',
77
+ url: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx',
78
+ sha256: 'c59158379255ad66e161679cca6af8d52d51e389e3224ab7d7a7baae295c2db5',
79
+ bytes: 26485263,
80
+ }
81
+
82
+ function sha256File(path) {
83
+ const { createHash } = require('crypto')
84
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
85
+ }
86
+
87
+ function setupSpeakerModel() {
88
+ const modelsDir = join(CONFIG_DIR, 'models')
89
+ const dest = join(modelsDir, SPEAKER_MODEL.filename)
90
+
91
+ if (existsSync(dest)) {
92
+ const have = sha256File(dest)
93
+ if (have === SPEAKER_MODEL.sha256) {
94
+ console.log(' Voiceprint model already installed and verified.')
95
+ console.log(` ${dest}`)
96
+ return 0
97
+ }
98
+ console.log(' A file is present but does not match the expected checksum.')
99
+ console.log(` Replacing it. (found ${have.slice(0, 16)}...)`)
100
+ try { unlinkSync(dest) } catch {}
101
+ }
102
+
103
+ mkdirSync(modelsDir, { recursive: true })
104
+ const partial = `${dest}.partial`
105
+ console.log(' Downloading the voiceprint model (~26 MB)...')
106
+
107
+ // SYNCHRONOUS on purpose. bin/cli.cjs is CJS with no top-level await, so an
108
+ // async download does not block the rest of this file -- the first version of
109
+ // this command fell straight through into normal server startup and installed
110
+ // nothing. Same curl idiom the whisper model download already uses.
111
+ try {
112
+ execFileSync('curl', [
113
+ '-fL',
114
+ '--retry', '5',
115
+ '--retry-all-errors',
116
+ '--retry-delay', '2',
117
+ '--connect-timeout', '30',
118
+ '--progress-bar',
119
+ SPEAKER_MODEL.url,
120
+ '-o', partial,
121
+ ], { stdio: 'inherit', timeout: 10 * 60_000 })
122
+ } catch (err) {
123
+ try { if (existsSync(partial)) unlinkSync(partial) } catch {}
124
+ console.error(` Download failed: ${err && err.message ? err.message : err}`)
125
+ return 1
126
+ }
127
+
128
+ // VERIFY BEFORE INSTALLING. Never rename unverified bytes into the path a
129
+ // native model loader reads from.
130
+ const got = sha256File(partial)
131
+ if (got !== SPEAKER_MODEL.sha256) {
132
+ try { unlinkSync(partial) } catch {}
133
+ console.error(' Checksum mismatch - refusing to install.')
134
+ console.error(` expected ${SPEAKER_MODEL.sha256}`)
135
+ console.error(` got ${got}`)
136
+ return 1
137
+ }
138
+
139
+ renameSync(partial, dest)
140
+ console.log(' Installed and verified:')
141
+ console.log(` ${dest}`)
142
+ console.log('')
143
+ console.log(' Restart the server for it to take effect.')
144
+ console.log(' Then check /api/health -> speaker_id: it should read "active".')
145
+ return 0
146
+ }
147
+
148
+ if (SETUP_SPEAKER_MODEL) {
149
+ console.log('')
150
+ console.log(bold(' COS Glasses - voiceprint model'))
151
+ console.log('')
152
+ process.exit(setupSpeakerModel())
153
+ }
154
+
56
155
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
57
156
  console.log('')
58
157
  console.log(bold(' COS Glasses Server'))
@@ -61,6 +160,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
61
160
  console.log(' npx --yes @gotcos/glasses-server@latest')
62
161
  console.log(' npx --yes @gotcos/glasses-server@latest --setup-transcription')
63
162
  console.log(' npx --yes @gotcos/glasses-server@latest --setup-transcription --transcription-tier balanced|max')
163
+ console.log(' npx --yes @gotcos/glasses-server@latest --setup-speaker-model')
64
164
  console.log(' npx --yes @gotcos/glasses-server@latest --prepare-only')
65
165
  console.log('')
66
166
  console.log(' Requirements:')
@@ -606,6 +706,38 @@ if (whisperCliPath && SETUP_TRANSCRIPTION) {
606
706
  }
607
707
  }
608
708
 
709
+ // Step 5b: the voiceprint model — named per-speaker diarization.
710
+ //
711
+ // Folded into normal setup rather than fetched lazily on first use. Lazy would
712
+ // fire the download at the START OF A MEETING, which is the worst possible
713
+ // moment: 26 MB on hotel wifi degrades the exact session it exists to improve,
714
+ // and it makes a GitHub release asset a runtime dependency instead of an
715
+ // install-time one.
716
+ //
717
+ // This follows the precedent already set by the whisper models, which are 1.5 GB
718
+ // -- sixty times larger -- downloaded here with SKIP_WHISPER_DOWNLOAD as the
719
+ // escape hatch. Same shape, same opt-out, so nobody on a metered or restricted
720
+ // network is forced into it.
721
+ if (!SETUP_SPEAKER_MODEL && process.env.SKIP_SPEAKER_MODEL_DOWNLOAD !== '1') {
722
+ const spkDest = join(CONFIG_DIR, 'models', SPEAKER_MODEL.filename)
723
+ if (existsSync(spkDest) && sha256File(spkDest) === SPEAKER_MODEL.sha256) {
724
+ console.log(green(' ✓') + ' Voiceprint model ready ' + dim('— named speakers available'))
725
+ } else {
726
+ console.log(' ' + dim('Fetching the voiceprint model (~26 MB) for named speakers.'))
727
+ console.log(' ' + dim('Skip: SKIP_SPEAKER_MODEL_DOWNLOAD=1 npx --yes @gotcos/glasses-server@latest'))
728
+ const spkCode = setupSpeakerModel()
729
+ if (spkCode !== 0) {
730
+ // NOT fatal. Diarization is opt-in by design: without the model the server
731
+ // stays on wearer/Ext fallback rather than failing, so a failed fetch must
732
+ // not block a working install.
733
+ console.log(yellow(' ⚠') + ' Voiceprint model unavailable ' + dim('— speakers stay wearer/Ext'))
734
+ console.log(' ' + dim('Retry later: npx --yes @gotcos/glasses-server@latest --setup-speaker-model'))
735
+ }
736
+ }
737
+ } else if (process.env.SKIP_SPEAKER_MODEL_DOWNLOAD === '1') {
738
+ console.log(yellow(' ⚠') + ' SKIP_SPEAKER_MODEL_DOWNLOAD=1 — named speakers unavailable')
739
+ }
740
+
609
741
  if (PREPARE_ONLY && SETUP_TRANSCRIPTION) {
610
742
  console.log('')
611
743
  if (transcriptionSetupFailures.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.37.1",
3
+ "version": "6.37.3",
4
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",
5
5
  "type": "module",
6
6
  "bin": {
@@ -303,6 +303,41 @@ export function isRecoveryActive(dirName: string): boolean {
303
303
  return activeRecoveries.has(dirName)
304
304
  }
305
305
 
306
+ /**
307
+ * A capture that held no speech is RECOVERED, not failed.
308
+ *
309
+ * The recover route used to throw `recovery produced an empty transcript` when
310
+ * whisper returned nothing, which left the capture in the unsaved list and
311
+ * retried it on every boot and every button press. Measured 2026-08-25: one
312
+ * 33-second capture failed this way 1,131 times, alternating with the
313
+ * auto-recover path claiming success, while the panel said only "1 recoverable"
314
+ * and the error went to stderr where nobody looks.
315
+ *
316
+ * Silence is a legitimate outcome. There is nothing to save and nothing to
317
+ * retry, so the capture is receipted and clears -- with `outcome: 'no_speech'`
318
+ * so the distinction stays auditable rather than looking like a normal save.
319
+ *
320
+ * The audio is NOT deleted here. It leaves on the ordinary retention clock, so a
321
+ * capture wrongly judged silent (a bad decode, a broken model) is still on disk
322
+ * for its full window.
323
+ */
324
+ export function markRecoveredNoSpeech(dirPath: string, chunkFiles: number): void {
325
+ try {
326
+ writeFileSync(
327
+ resolve(dirPath, RECOVERED_RECEIPT),
328
+ `${JSON.stringify({
329
+ schemaVersion: 1,
330
+ recoveredAt: new Date().toISOString(),
331
+ outcome: 'no_speech',
332
+ chunkFiles,
333
+ words: 0,
334
+ note: 'Transcription produced no words. Audio retained until the retention clock clears it.',
335
+ })}\n`,
336
+ { encoding: 'utf8', mode: 0o600 },
337
+ )
338
+ } catch { /* receipt is best-effort; findBySessionId remains the true guard */ }
339
+ }
340
+
306
341
  export function markRecovered(dirPath: string, savedFilename: string): void {
307
342
  try {
308
343
  writeFileSync(
@@ -83,6 +83,7 @@ import {
83
83
  findQuarantineDir,
84
84
  listUnsavedCaptures,
85
85
  markRecovered,
86
+ markRecoveredNoSpeech,
86
87
  registerActiveRecovery,
87
88
  } from '../lib/unsaved-audio-quarantine.js'
88
89
  import {
@@ -1915,7 +1916,24 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1915
1916
  clearSessionHallucinationState(sessionId)
1916
1917
  const transcript = cleanFinalTranscript(results.map(item => item.text).join(' '))
1917
1918
  if (!transcript.trim()) {
1918
- throw new Error('recovery produced an empty transcript')
1919
+ // SILENCE IS AN OUTCOME, NOT A FAILURE.
1920
+ //
1921
+ // This used to throw, which left the capture in the unsaved list and
1922
+ // retried it forever -- 1,131 times for one 33-second capture on
1923
+ // 2026-08-25, alternating with the auto-recover path claiming success,
1924
+ // while the panel showed only "1 recoverable" and the error went to
1925
+ // stderr. The user sees a Recover button that does nothing, because it
1926
+ // literally cannot succeed on audio with no speech in it.
1927
+ //
1928
+ // Receipt it and let it clear. The audio stays on disk for its full
1929
+ // retention window, so a capture wrongly judged silent is not lost.
1930
+ markRecoveredNoSpeech(quarantineDir, entries.length)
1931
+ console.log(
1932
+ `[meeting/orphans] ${sessionId} held no speech `
1933
+ + `(${entries.length} chunks, ${Math.round((Date.now() - startedAt) / 1000)}s) `
1934
+ + '— receipted as no_speech; audio retained until retention expires',
1935
+ )
1936
+ return
1919
1937
  }
1920
1938
  const recoveredChunks = results.map(item => ({
1921
1939
  text: item.text,
@@ -61,14 +61,6 @@ import {
61
61
  appendChunkEmbedding,
62
62
  sweepExpiredChunkEmbeddings,
63
63
  } from '../lib/chunk-embedding-store.js'
64
- import {
65
- evenSpeakerRoleMode,
66
- formatEvenRoleAgreement,
67
- parseEvenHubSpeakerRoleBody,
68
- parseEvenHubSpeakerRoleQuery,
69
- warnEvenSpeakerRoleApplyNotImplemented,
70
- type EvenSpeakerRoleHistogram,
71
- } from '../lib/even-hub-speaker-role.js'
72
64
  import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
73
65
  import {
74
66
  countChunkWavs,
@@ -284,7 +276,6 @@ export interface TranscriptChunk {
284
276
  latencyMs?: number
285
277
  audioSha256?: string
286
278
  canonical?: boolean
287
- evenHubSpeakerRole?: EvenSpeakerRoleHistogram
288
279
  }
289
280
 
290
281
  export interface ProviderCandidateRecord {
@@ -1954,11 +1945,8 @@ async function processStreamChunk(opts: {
1954
1945
  clientElapsed?: number
1955
1946
  /** Original client recording start, applied only before canonical chunks. */
1956
1947
  startTimeOverride?: number
1957
- evenHubSpeakerRole?: EvenSpeakerRoleHistogram
1958
1948
  }): Promise<StreamChunkCompletionResponse> {
1959
1949
  const { sessionId, chunkIndex, clientSpeaker, audioBuffer, candidate } = opts
1960
- const evenHubSpeakerRole = evenSpeakerRoleMode() === 'off' ? undefined : opts.evenHubSpeakerRole
1961
- if (evenHubSpeakerRole) warnEvenSpeakerRoleApplyNotImplemented()
1962
1950
  const tReq = performance.now()
1963
1951
  validateSessionId(sessionId)
1964
1952
  validateChunkIndex(chunkIndex)
@@ -2078,15 +2066,6 @@ async function processStreamChunk(opts: {
2078
2066
  }
2079
2067
 
2080
2068
  const { speaker, similarity } = await speakerPromise
2081
- if (evenHubSpeakerRole) {
2082
- console.log(formatEvenRoleAgreement({
2083
- chunkIndex,
2084
- even: evenHubSpeakerRole,
2085
- amp: clientSpeaker,
2086
- emb: speaker,
2087
- similarity,
2088
- }))
2089
- }
2090
2069
  // Client time is authoritative for live network jitter and deferred replay.
2091
2070
  const elapsed = Number.isFinite(opts.clientElapsed) && (opts.clientElapsed as number) >= 0
2092
2071
  ? Math.round(opts.clientElapsed as number)
@@ -2131,7 +2110,6 @@ async function processStreamChunk(opts: {
2131
2110
  latencyMs,
2132
2111
  audioSha256,
2133
2112
  canonical: true,
2134
- evenHubSpeakerRole,
2135
2113
  }
2136
2114
  const finalExisting = session.chunks[chunkIndex]
2137
2115
  if (finalExisting?.text) {
@@ -2285,7 +2263,6 @@ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
2285
2263
  audioBuffer,
2286
2264
  clientElapsed,
2287
2265
  startTimeOverride,
2288
- evenHubSpeakerRole: parseEvenHubSpeakerRoleQuery(req.query.eh),
2289
2266
  }))
2290
2267
  } catch (err: unknown) {
2291
2268
  sendStreamError(res, err)
@@ -2350,7 +2327,6 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chun
2350
2327
  clientSpeaker: String(body.clientSpeaker ?? 'Unknown'),
2351
2328
  audioBuffer,
2352
2329
  clientElapsed: typeof body.elapsed === 'number' ? body.elapsed : Number(body.elapsed ?? 0),
2353
- evenHubSpeakerRole: parseEvenHubSpeakerRoleBody(body.evenHubSpeakerRole),
2354
2330
  candidate: {
2355
2331
  provider: 'iphone-whisperkit-beta',
2356
2332
  text: normalizeCandidateText(candidate.text),
@@ -2429,7 +2405,6 @@ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) =>
2429
2405
  clientSpeaker: String(body.clientSpeaker ?? 'Unknown'),
2430
2406
  audioBuffer,
2431
2407
  clientElapsed: typeof body.elapsed === 'number' ? body.elapsed : Number(body.elapsed ?? 0),
2432
- evenHubSpeakerRole: parseEvenHubSpeakerRoleBody(body.evenHubSpeakerRole),
2433
2408
  candidate: {
2434
2409
  provider: 'iphone-whisperkit-beta',
2435
2410
  text: normalizeCandidateText(candidate.text),
@@ -1,116 +0,0 @@
1
- // Even Hub 0.0.14 wearer-vs-other histogram, carried on a meeting chunk.
2
- // Identity is a suggestion. This module parses and logs. It does not name
3
- // people and does not change identifyChunkSpeaker.
4
-
5
- export type EvenSpeakerRole = 'self' | 'other' | 'unknown'
6
- export type EvenSpeakerRoleMajority = EvenSpeakerRole | 'tie'
7
-
8
- export interface EvenSpeakerRoleHistogram {
9
- schema: 1
10
- frames: number
11
- self: number
12
- other: number
13
- unknown: number
14
- majority: EvenSpeakerRoleMajority
15
- directionPresent: number
16
- directionLast: number | null
17
- }
18
-
19
- export type EvenSpeakerRoleMode = 'off' | 'log' | 'apply'
20
-
21
- export function evenSpeakerRoleMode(): EvenSpeakerRoleMode {
22
- const raw = (process.env.COS_EVEN_SPEAKER_ROLE ?? 'log').trim().toLowerCase()
23
- if (raw === 'off' || raw === '0' || raw === 'false') return 'off'
24
- if (raw === 'apply') return 'apply'
25
- return 'log'
26
- }
27
-
28
- let applyNotImplementedWarned = false
29
-
30
- /** Gate A is not in this slice. apply must not silently change labels. */
31
- export function warnEvenSpeakerRoleApplyNotImplemented(): void {
32
- if (evenSpeakerRoleMode() !== 'apply' || applyNotImplementedWarned) return
33
- applyNotImplementedWarned = true
34
- console.warn('[even-role] COS_EVEN_SPEAKER_ROLE=apply is not implemented; logging only')
35
- }
36
-
37
- function majorityOf(self: number, other: number, unknown: number, frames: number): EvenSpeakerRoleMajority {
38
- if (frames <= 0) return 'unknown'
39
- if (self > other && self > unknown) return 'self'
40
- if (other > self && other > unknown) return 'other'
41
- if (unknown > self && unknown > other) return 'unknown'
42
- return 'tie'
43
- }
44
-
45
- function asNonNegInt(raw: unknown): number | null {
46
- const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw !== '' ? Number(raw) : NaN
47
- if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return null
48
- return n
49
- }
50
-
51
- /** Compact query `eh=self,other,unknown,frames,directionPresent,directionLast`. */
52
- export function parseEvenHubSpeakerRoleQuery(raw: unknown): EvenSpeakerRoleHistogram | undefined {
53
- if (typeof raw !== 'string' || raw.length === 0) return undefined
54
- const parts = raw.split(',')
55
- if (parts.length < 4 || parts.length > 6) return undefined
56
- const self = asNonNegInt(parts[0])
57
- const other = asNonNegInt(parts[1])
58
- const unknown = asNonNegInt(parts[2])
59
- const frames = asNonNegInt(parts[3])
60
- if (self == null || other == null || unknown == null || frames == null) return undefined
61
- if (self + other + unknown !== frames) return undefined
62
- const directionPresent = parts.length >= 5 ? asNonNegInt(parts[4]) : 0
63
- if (directionPresent == null) return undefined
64
- let directionLast: number | null = null
65
- if (parts.length === 6 && parts[5] !== '') {
66
- const last = Number(parts[5])
67
- if (!Number.isFinite(last)) return undefined
68
- directionLast = last
69
- }
70
- return {
71
- schema: 1,
72
- frames,
73
- self,
74
- other,
75
- unknown,
76
- majority: majorityOf(self, other, unknown, frames),
77
- directionPresent,
78
- directionLast,
79
- }
80
- }
81
-
82
- export function parseEvenHubSpeakerRoleBody(raw: unknown): EvenSpeakerRoleHistogram | undefined {
83
- if (!raw || typeof raw !== 'object') return undefined
84
- const o = raw as Record<string, unknown>
85
- const self = asNonNegInt(o.self)
86
- const other = asNonNegInt(o.other)
87
- const unknown = asNonNegInt(o.unknown)
88
- const frames = asNonNegInt(o.frames)
89
- if (self == null || other == null || unknown == null || frames == null) return undefined
90
- if (self + other + unknown !== frames) return undefined
91
- const directionPresent = o.directionPresent == null ? 0 : asNonNegInt(o.directionPresent)
92
- if (directionPresent == null) return undefined
93
- const directionLast = o.directionLast == null || o.directionLast === ''
94
- ? null
95
- : (typeof o.directionLast === 'number' && Number.isFinite(o.directionLast) ? o.directionLast : null)
96
- return {
97
- schema: 1,
98
- frames,
99
- self,
100
- other,
101
- unknown,
102
- majority: majorityOf(self, other, unknown, frames),
103
- directionPresent,
104
- directionLast,
105
- }
106
- }
107
-
108
- export function formatEvenRoleAgreement(opts: {
109
- chunkIndex: number
110
- even: EvenSpeakerRoleHistogram
111
- amp: string
112
- emb: string
113
- similarity: number
114
- }): string {
115
- return `[even-role] chunk=${opts.chunkIndex} even=${opts.even.majority} amp=${opts.amp} emb=${opts.emb} sim=${opts.similarity.toFixed(2)} frames=${opts.even.frames}`
116
- }