@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.
@@ -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 { transcribeWhisperMeetingPreview } from '../lib/whisper-preview.js'
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 isQuiet (rms<150), so a real
152
- // softly-spoken utterance or a clearly-dictated third-party URL is never dropped.
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
- const pcmData = audioBuffer.subarray(44)
2223
- let sumSq = 0
2224
- const nSamples = Math.floor(pcmData.length / 2)
2225
- for (let i = 0; i < nSamples; i++) {
2226
- const s = pcmData.readInt16LE(i * 2)
2227
- sumSq += s * s
2228
- }
2229
- const rms = Math.sqrt(sumSq / Math.max(1, nSamples))
2230
- const isQuiet = rms < 150
2231
- const whisperAudio = isQuiet ? audioBuffer : await enhanceAudio(audioBuffer)
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
- let sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
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, isQuiet)
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
- * Default-off meeting preview canary.
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 Turbo text. Any contention or failure is a
2428
- * 204 cosmetic miss; the canonical /transcribe-stream upload remains the only
2429
- * durable and speaker-attributed result.
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
- const result = await transcribeWhisperMeetingPreview(audio)
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)) {