@gotcos/glasses-server 6.71.14 → 6.72.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/.env.example +16 -6
- package/CHANGELOG.md +16 -0
- package/README.md +147 -0
- package/docs/stt-672-evaluation.md +113 -0
- package/package.json +3 -1
- package/server/index.ts +18 -1
- package/server/lib/batch-transcript-quality.ts +46 -0
- package/server/lib/meeting-batch-persistence.ts +23 -1
- package/server/lib/meeting-batch-transcribe.ts +408 -45
- package/server/lib/parakeet-manifest.ts +41 -0
- package/server/lib/parakeet.ts +665 -0
- package/server/lib/stt-live.ts +60 -0
- package/server/lib/stt-resolver.ts +327 -0
- package/server/lib/stt-runtime.ts +225 -0
- package/server/lib/stt-settings.ts +342 -0
- package/server/lib/vad-silero.ts +52 -0
- package/server/lib/whisper-batch-timing.ts +13 -3
- package/server/lib/whisper-local.ts +58 -17
- package/server/routes/meeting.ts +9 -3
- package/server/routes/stt-settings.ts +85 -0
- package/server/routes/transcribe-stream.ts +34 -22
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Speech-to-text settings (6.72.0). Token-protected by the /api middleware.
|
|
2
|
+
//
|
|
3
|
+
// GET /api/stt-settings the settings, every option with availability, Parakeet status
|
|
4
|
+
// PUT /api/stt-settings partial body: {"hq":"parakeet-ultra"}, {"resolver":false}, ...
|
|
5
|
+
// POST /api/stt-settings/parakeet/install install the pinned Parakeet CLI and models (202; poll GET)
|
|
6
|
+
// body {} | {"localSource":"/abs/path"} | {"localSource":"...","allowDownload":true}
|
|
7
|
+
// DELETE /api/stt-settings/parakeet/install cancel a running install (nothing partial is kept)
|
|
8
|
+
//
|
|
9
|
+
// Clients find the route with GET: a 404 means an older server, so they hide the switcher.
|
|
10
|
+
// The contract is shared with COS Control and the phone (see README "Speech-to-text settings").
|
|
11
|
+
|
|
12
|
+
import { Router } from 'express'
|
|
13
|
+
import { errMsg } from '../lib/utils.js'
|
|
14
|
+
import { cancelParakeetInstall, installParakeet, parakeetInstalled, parakeetInstallRunning, type InstallOptions } from '../lib/parakeet.js'
|
|
15
|
+
import { currentSttCapabilities, ensurePreviewWarm, parakeetStatus } from '../lib/stt-runtime.js'
|
|
16
|
+
import {
|
|
17
|
+
applySettingsPatch, readLastSttJob, readSttSettings, settingsView, SttSettingsError, writeSttSettings,
|
|
18
|
+
type SttCapabilities,
|
|
19
|
+
} from '../lib/stt-settings.js'
|
|
20
|
+
|
|
21
|
+
export interface SttSettingsRouterDeps {
|
|
22
|
+
recordingLive: () => boolean
|
|
23
|
+
capabilities?: () => SttCapabilities
|
|
24
|
+
install?: (options: InstallOptions) => Promise<void>
|
|
25
|
+
onChanged?: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createSttSettingsRouter(deps: SttSettingsRouterDeps): Router {
|
|
29
|
+
const router = Router()
|
|
30
|
+
const caps = deps.capabilities ?? currentSttCapabilities
|
|
31
|
+
const install = deps.install ?? installParakeet
|
|
32
|
+
const view = () => settingsView(readSttSettings(), caps(), parakeetStatus(deps.recordingLive()), readLastSttJob())
|
|
33
|
+
|
|
34
|
+
router.get('/stt-settings', (_req, res) => {
|
|
35
|
+
try { res.json(view()) } catch (err) { res.status(500).json({ error: errMsg(err) }) }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
router.put('/stt-settings', (req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const next = applySettingsPatch(readSttSettings(), req.body, caps())
|
|
41
|
+
writeSttSettings(next)
|
|
42
|
+
ensurePreviewWarm()
|
|
43
|
+
deps.onChanged?.()
|
|
44
|
+
res.json(view())
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err instanceof SttSettingsError) return void res.status(400).json({ error: err.message })
|
|
47
|
+
res.status(500).json({ error: errMsg(err) })
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
router.post('/stt-settings/parakeet/install', (req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
if (deps.recordingLive()) return void res.status(409).json({ error: 'A recording is live. Install Parakeet after it is saved.' })
|
|
54
|
+
if (parakeetInstalled().installed && !parakeetInstallRunning()) return void res.status(200).json({ started: false, ...view() })
|
|
55
|
+
const body = (req.body ?? {}) as { localSource?: unknown; allowDownload?: unknown }
|
|
56
|
+
const raw = body.localSource
|
|
57
|
+
if (raw !== undefined && (typeof raw !== 'string' || !raw.startsWith('/') || raw.length > 1024)) {
|
|
58
|
+
return void res.status(400).json({ error: 'localSource must be an absolute path.' })
|
|
59
|
+
}
|
|
60
|
+
if (body.allowDownload !== undefined && typeof body.allowDownload !== 'boolean') return void res.status(400).json({ error: 'allowDownload must be true or false.' })
|
|
61
|
+
// Only bytes whose SHA-256 matches the pinned manifest are ever copied from it.
|
|
62
|
+
// A named localSource that lacks a file fails plainly unless allowDownload is true.
|
|
63
|
+
const options: InstallOptions = raw ? { localSources: [raw], requireLocal: body.allowDownload !== true } : {}
|
|
64
|
+
if (!parakeetInstallRunning()) {
|
|
65
|
+
void install(options).then(() => { ensurePreviewWarm(); deps.onChanged?.() }).catch(error => {
|
|
66
|
+
console.warn(`[stt] Parakeet install failed: ${errMsg(error)}`)
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
res.status(202).json({ started: true, ...view() })
|
|
70
|
+
} catch (err) {
|
|
71
|
+
res.status(500).json({ error: errMsg(err) })
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
router.delete('/stt-settings/parakeet/install', (_req, res) => {
|
|
76
|
+
try {
|
|
77
|
+
const cancelled = cancelParakeetInstall()
|
|
78
|
+
res.status(cancelled ? 202 : 200).json({ cancelled, ...view() })
|
|
79
|
+
} catch (err) {
|
|
80
|
+
res.status(500).json({ error: errMsg(err) })
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
return router
|
|
85
|
+
}
|
|
@@ -37,7 +37,6 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
|
37
37
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
38
38
|
import { errMsg } from '../lib/utils.js'
|
|
39
39
|
import { transcribeLocal, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
|
|
40
|
-
import { enhanceAudio } from '../lib/audio-enhance.js'
|
|
41
40
|
import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
|
|
42
41
|
import { identifySpeaker, isEmbeddingAvailable, autoEnroll, AUTO_ENROLL_CANDIDATE_SIMILARITY } from '../lib/speaker-embeddings.js'
|
|
43
42
|
import { nemotronLive, resolveLiveLabels, resplitSegments, type ChunkSpeakerSegment } from '../lib/nemotron-live.js'
|
|
@@ -56,7 +55,9 @@ import {
|
|
|
56
55
|
streamSilenceDropReason,
|
|
57
56
|
isVocabEchoOnly,
|
|
58
57
|
} from '../lib/hallucination-filter.js'
|
|
59
|
-
import {
|
|
58
|
+
import { transcribeMeetingPreview } from '../lib/stt-runtime.js'
|
|
59
|
+
import { decideQuiet, asrFrontEnd, silenceDropsAllowed } from '../lib/stt-live.js'
|
|
60
|
+
import { readSttSettings } from '../lib/stt-settings.js'
|
|
60
61
|
import { dataPath } from '../lib/data-dir.js'
|
|
61
62
|
import { ageHours, partitionExpiredAudio } from '../lib/audio-retention.js'
|
|
62
63
|
import {
|
|
@@ -148,8 +149,12 @@ function ensurePrivateDirectory(path: string): void {
|
|
|
148
149
|
|
|
149
150
|
// Silence-hallucination drops (2026-05-29, v5.9.73). Contract in streamSilenceDropReason:
|
|
150
151
|
// brand-URL-only -> dropped ALWAYS (vocab-seeded, never real speech).
|
|
151
|
-
// any-URL-only / repeated-thank-you -> dropped only when
|
|
152
|
-
// softly-spoken utterance or a clearly-dictated
|
|
152
|
+
// any-URL-only / repeated-thank-you / quiet vocab echo -> dropped only when the chunk
|
|
153
|
+
// is RMS-quiet (rms<150), so a real softly-spoken utterance or a clearly-dictated
|
|
154
|
+
// third-party URL is never dropped. 6.72.0 keeps these TEXT drops on the RMS test on
|
|
155
|
+
// purpose: the Silero quiet gate (under 0.5 s of speech) also fires on a chunk holding
|
|
156
|
+
// one short real name, and dropping that as a "vocab echo" would delete real speech.
|
|
157
|
+
// The Silero gate decides only the prompt and the front end.
|
|
153
158
|
// Rollback: COS_WHISPER_STRIP_BRAND_URLS=0 (URL drops), COS_WHISPER_THANKYOU_FILTER=0.
|
|
154
159
|
const STRIP_BRAND_URLS = process.env.COS_WHISPER_STRIP_BRAND_URLS !== '0'
|
|
155
160
|
const THANKYOU_FILTER = process.env.COS_WHISPER_THANKYOU_FILTER !== '0'
|
|
@@ -2219,16 +2224,16 @@ async function processStreamChunk(opts: {
|
|
|
2219
2224
|
resolve(SESSION_AUDIO_DIR, sessionId, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`),
|
|
2220
2225
|
)
|
|
2221
2226
|
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
const rms =
|
|
2230
|
-
const isQuiet =
|
|
2231
|
-
const whisperAudio = isQuiet ? audioBuffer : await
|
|
2227
|
+
// 6.72.0: the quiet gate and the ASR front end follow the STT settings, read
|
|
2228
|
+
// per chunk so a change applies from the next chunk. Defaults: Silero speech
|
|
2229
|
+
// seconds (RMS 150 fired on 1% of G2 chunks) and highpass only (the full
|
|
2230
|
+
// chain lifted faint background speech over the VAD). Playback audio is
|
|
2231
|
+
// separate and unchanged.
|
|
2232
|
+
const stt = readSttSettings()
|
|
2233
|
+
const quiet = decideQuiet(audioBuffer, stt.quietGate)
|
|
2234
|
+
const rms = quiet.rms
|
|
2235
|
+
const isQuiet = quiet.isQuiet
|
|
2236
|
+
const whisperAudio = isQuiet ? audioBuffer : await asrFrontEnd(audioBuffer, stt.frontEnd)
|
|
2232
2237
|
const whisperContext = recentWhisperContext(sessionId, session)
|
|
2233
2238
|
console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)
|
|
2234
2239
|
|
|
@@ -2265,7 +2270,9 @@ async function processStreamChunk(opts: {
|
|
|
2265
2270
|
// uncorrected for those sources. Idempotent for the local path.
|
|
2266
2271
|
rawText = applyCorrections(rawText)
|
|
2267
2272
|
|
|
2268
|
-
|
|
2273
|
+
// Text drops stay on the pre-6.72 RMS test (see the contract above).
|
|
2274
|
+
const dropQuiet = silenceDropsAllowed(quiet)
|
|
2275
|
+
let sanitized = sanitizeStreamTranscript(sessionId, session, rawText, dropQuiet)
|
|
2269
2276
|
if (candidate && (!sanitized.text || sanitized.fallbackReason)) {
|
|
2270
2277
|
fallbackReason = sanitized.fallbackReason || 'empty_candidate'
|
|
2271
2278
|
const result = await transcribeWithServerWhisper(audioBuffer, whisperAudio, whisperContext, isQuiet)
|
|
@@ -2273,7 +2280,7 @@ async function processStreamChunk(opts: {
|
|
|
2273
2280
|
words = result.words
|
|
2274
2281
|
backend = result.backend
|
|
2275
2282
|
asrProvider = 'server-whisper'
|
|
2276
|
-
sanitized = sanitizeStreamTranscript(sessionId, session, rawText,
|
|
2283
|
+
sanitized = sanitizeStreamTranscript(sessionId, session, rawText, dropQuiet)
|
|
2277
2284
|
}
|
|
2278
2285
|
|
|
2279
2286
|
const voiceprint = await speakerPromise
|
|
@@ -2393,7 +2400,7 @@ async function processStreamChunk(opts: {
|
|
|
2393
2400
|
// throws on by default. All gates live inside the feed.
|
|
2394
2401
|
void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
|
|
2395
2402
|
|
|
2396
|
-
console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
|
|
2403
|
+
console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} gate=${quiet.gate}${quiet.speechSec === null ? '' : ` speech=${quiet.speechSec.toFixed(2)}s`} fe=${stt.frontEnd} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
|
|
2397
2404
|
return canonicalChunkResponse(chunk, sessionId, chunkIndex)
|
|
2398
2405
|
}
|
|
2399
2406
|
|
|
@@ -2420,13 +2427,15 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
|
|
|
2420
2427
|
}
|
|
2421
2428
|
|
|
2422
2429
|
/**
|
|
2423
|
-
*
|
|
2430
|
+
* Meeting preview (on unless COS_WHISPER_MEETING_PREVIEW=0).
|
|
2424
2431
|
*
|
|
2425
2432
|
* This route deliberately owns no meeting session, recovery ledger, speaker
|
|
2426
2433
|
* attribution, or persistence. It accepts a pinned copy of the still-open
|
|
2427
|
-
* phrase and returns provisional
|
|
2428
|
-
*
|
|
2429
|
-
*
|
|
2434
|
+
* phrase and returns provisional text from the preview engine the STT settings
|
|
2435
|
+
* choose (6.72.0: Whisper Turbo, or Parakeet Ultra when selected, warm, and the
|
|
2436
|
+
* client lists `parakeet-ultra` in `previewModels`; every other client gets
|
|
2437
|
+
* Turbo). Any contention or failure is a 204 cosmetic miss; the canonical
|
|
2438
|
+
* /transcribe-stream upload remains the only durable and speaker-attributed result.
|
|
2430
2439
|
*/
|
|
2431
2440
|
transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
|
|
2432
2441
|
try {
|
|
@@ -2459,7 +2468,10 @@ transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
|
|
|
2459
2468
|
|
|
2460
2469
|
meetingPreviewBusy = true
|
|
2461
2470
|
try {
|
|
2462
|
-
|
|
2471
|
+
// 6.72.0: the models this client accepts. Absent (every phone before 6.10.639): Turbo only.
|
|
2472
|
+
const rawModels = typeof req.query.previewModels === 'string' ? req.query.previewModels.slice(0, 200) : ''
|
|
2473
|
+
const accepted = rawModels.split(',').map(m => m.trim()).filter(Boolean).slice(0, 8)
|
|
2474
|
+
const result = await transcribeMeetingPreview(audio, accepted)
|
|
2463
2475
|
if (!result) return void res.status(204).send()
|
|
2464
2476
|
const cleaned = stripInlineHallucinationsOneShot(result.text.trim()).trim()
|
|
2465
2477
|
if (!cleaned || sharedIsFullHallucination(cleaned)) {
|