@gotcos/glasses-server 6.37.0 → 6.37.2
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 +8 -3
- package/CHANGELOG.md +61 -3
- package/README.md +10 -0
- package/bin/cli.cjs +100 -0
- package/package.json +1 -1
- package/server/lib/meeting-summary.ts +12 -3
- package/server/routes/transcribe-stream.ts +0 -25
- package/server/lib/even-hub-speaker-role.ts +0 -116
package/.env.example
CHANGED
|
@@ -182,12 +182,17 @@ BIND_HOST=0.0.0.0
|
|
|
182
182
|
# WITHOUT the COS operations pipeline (no COS_SCRIPTS_DIR). Spawns one
|
|
183
183
|
# `claude -p --model haiku` per meeting, at the end of finalization.
|
|
184
184
|
#
|
|
185
|
-
# Default
|
|
186
|
-
#
|
|
185
|
+
# Default ON since 6.37.0. Set to 0 to opt out; only a literal 0 disables.
|
|
186
|
+
# With it off you still get the deterministic tier — speakers, talk-time, word
|
|
187
|
+
# count, opening excerpt — which costs nothing and always runs.
|
|
188
|
+
#
|
|
189
|
+
# This spends tokens on YOUR provider plan, unlike the display toggles. It is
|
|
190
|
+
# bounded by a per-day cap (below) counted only on a validated summary, a
|
|
191
|
+
# circuit breaker, a minimum word floor, a single-slot queue and a wall budget.
|
|
187
192
|
#
|
|
188
193
|
# Never runs when COS_SCRIPTS_DIR is set: overwriting the summary section would
|
|
189
194
|
# erase the markers sync_meetings.py uses to decide what to process.
|
|
190
|
-
# COS_MEETING_SUMMARY=
|
|
195
|
+
# COS_MEETING_SUMMARY=0
|
|
191
196
|
#
|
|
192
197
|
# Calls per local day before the summariser falls back to the deterministic
|
|
193
198
|
# tier for the rest of the day. Default 40.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
+
## 6.37.2
|
|
2
|
+
|
|
3
|
+
`--setup-speaker-model` — one command to install the voiceprint model.
|
|
4
|
+
|
|
5
|
+
Named per-speaker diarization needs a ~26 MB model that is deliberately not in
|
|
6
|
+
the npm tarball. Until now the only way to get it was the README telling you to
|
|
7
|
+
"put the .onnx there", which meant a managed install had no way to obtain it at
|
|
8
|
+
all. The server then degrades SILENTLY to amplitude fallback (wearer vs Ext), so
|
|
9
|
+
voice training appears to run and never learns anything. A beta user hit exactly
|
|
10
|
+
that on 2026-08-25 and reported it as "voice training didn't work" -- it did run;
|
|
11
|
+
it had nothing to train against.
|
|
12
|
+
|
|
13
|
+
npx --yes @gotcos/glasses-server@latest --setup-speaker-model
|
|
14
|
+
|
|
15
|
+
- Downloads to ~/.cos-glasses/models/, which survives server updates. Anything
|
|
16
|
+
inside the package is destroyed by the next one.
|
|
17
|
+
- Pinned SHA-256, verified BEFORE the file is moved into place. A mismatch is
|
|
18
|
+
deleted, not installed -- this file is handed to a native loader. The hash was
|
|
19
|
+
checked against the model on a working install and matches byte for byte.
|
|
20
|
+
- Idempotent: an already-correct file is verified and left alone. A corrupted one
|
|
21
|
+
is replaced.
|
|
22
|
+
- Synchronous, using the same curl idiom as the whisper download. The first
|
|
23
|
+
version was async and fire-and-forget, and because bin/cli.cjs is CJS with no
|
|
24
|
+
top-level await the command fell straight through into normal server startup
|
|
25
|
+
and installed nothing. Found by running it, not by reading it.
|
|
26
|
+
|
|
27
|
+
The 26 MB stays out of the tarball; the install path is now a command instead of
|
|
28
|
+
a paragraph.
|
|
29
|
+
|
|
30
|
+
222 files, 3112 tests.
|
|
31
|
+
|
|
32
|
+
## 6.37.1
|
|
33
|
+
|
|
34
|
+
Standalone meeting summaries default ON.
|
|
35
|
+
|
|
36
|
+
6.37.0 shipped everything else in this release — the honest summary slot, the
|
|
37
|
+
two enrichment tiers, the voice-profile name validator, and Continue threads /
|
|
38
|
+
video uploads / adaptive audio cleanup all defaulting on — but it went out with
|
|
39
|
+
COS_MEETING_SUMMARY still defaulting OFF. That meant the headline fix, a meeting
|
|
40
|
+
that comes back with a real summary, reached nobody unless they found an
|
|
41
|
+
undocumented environment variable.
|
|
42
|
+
|
|
43
|
+
Only a literal '0' disables, so a stray or malformed value cannot silently
|
|
44
|
+
switch summaries off.
|
|
45
|
+
|
|
46
|
+
This is a different class of default from the display toggles in 6.37.0: it
|
|
47
|
+
spends tokens on the USER'S OWN provider plan. Every control in the module is
|
|
48
|
+
load-bearing rather than decorative — a per-day cap counted only on a validated
|
|
49
|
+
summary, a circuit breaker with its own accounting, a minimum word floor, a
|
|
50
|
+
single-slot queue, and a wall budget that keeps finalization under COS Control's
|
|
51
|
+
90s restart proof.
|
|
52
|
+
|
|
53
|
+
No other change. Anyone already on 6.37.0 who set COS_MEETING_SUMMARY=1 by hand
|
|
54
|
+
is unaffected.
|
|
55
|
+
|
|
1
56
|
## 6.37.0
|
|
2
57
|
|
|
3
58
|
Meetings recorded without the COS operations pipeline now get a real summary,
|
|
@@ -64,8 +119,11 @@ Two enrichment tiers, both standalone-only:
|
|
|
64
119
|
with talk-time split, opening excerpt. Fabricates nothing:
|
|
65
120
|
topics/decisions/actions come back empty rather than guessed.
|
|
66
121
|
|
|
67
|
-
llm
|
|
68
|
-
|
|
122
|
+
llm DEFAULT ON. One `claude -p --model haiku` per meeting at the
|
|
123
|
+
ops_pending phase of finalization. Set COS_MEETING_SUMMARY=0 to
|
|
124
|
+
opt out; only a literal 0 disables. This spends tokens on the
|
|
125
|
+
USER'S OWN provider plan, which is why every control below is
|
|
126
|
+
load-bearing rather than decorative.
|
|
69
127
|
|
|
70
128
|
It runs at ops_pending specifically because batch HQ transcription replaces the
|
|
71
129
|
whole transcript; summarising earlier would describe text the file no longer
|
|
@@ -79,7 +137,7 @@ erase them and the pipeline would skip the meeting entirely -- losing domain
|
|
|
79
137
|
reclassification, task extraction and the operations copy.
|
|
80
138
|
|
|
81
139
|
COST CONTROLS
|
|
82
|
-
- COS_MEETING_SUMMARY, read live (not frozen at module load)
|
|
140
|
+
- COS_MEETING_SUMMARY, read live (not frozen at module load), default ON
|
|
83
141
|
- haiku pinned explicitly, so it can never inherit an Opus session default
|
|
84
142
|
- own daily cap (COS_MEETING_SUMMARY_DAILY_CAP, default 40), never shared
|
|
85
143
|
with the archive counter; committed only after a validated summary, so a
|
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:')
|
package/package.json
CHANGED
|
@@ -87,10 +87,19 @@ export const meetingSummaryBreaker = breaker
|
|
|
87
87
|
|
|
88
88
|
/** Read LIVE, not at module load. COS Control rebuilds the runtime plist
|
|
89
89
|
* environment on update, and tests flip this per-case; a module-scope const
|
|
90
|
-
* would freeze the value at import and make both untestable.
|
|
90
|
+
* would freeze the value at import and make both untestable.
|
|
91
|
+
*
|
|
92
|
+
* DEFAULT ON since 6.37.0 (Miles 2026-08-25). Shipping this off meant the
|
|
93
|
+
* headline fix — a meeting that comes back with a real summary — reached
|
|
94
|
+
* nobody unless they found an undocumented env var.
|
|
95
|
+
*
|
|
96
|
+
* Unlike the display toggles that flipped on in the same release, this one
|
|
97
|
+
* spends tokens on the USER'S OWN provider plan, which is why every control in
|
|
98
|
+
* this module is load-bearing: a per-day cap committed only on a validated
|
|
99
|
+
* result, a breaker with its own accounting, a minimum word floor, a
|
|
100
|
+
* single-slot queue, and a wall budget. Only a literal '0' disables. */
|
|
91
101
|
export function meetingSummaryLLMEnabled(): boolean {
|
|
92
|
-
|
|
93
|
-
return raw === '1' || raw?.toLowerCase() === 'true'
|
|
102
|
+
return process.env.COS_MEETING_SUMMARY?.trim() !== '0'
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
// ── Transcript parsing ──────────────────────────────────────
|
|
@@ -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
|
-
}
|