@gotcos/glasses-server 6.71.13 → 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 +29 -0
- package/README.md +160 -1
- package/docs/stt-672-evaluation.md +113 -0
- package/package.json +4 -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/stale-active-session-files.ts +178 -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/unsaved-audio-quarantine.ts +114 -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 +84 -4
- package/server/routes/stt-settings.ts +85 -0
- package/server/routes/transcribe-stream.ts +179 -24
|
@@ -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 {
|
|
@@ -77,8 +78,16 @@ import {
|
|
|
77
78
|
listUnsavedCaptures,
|
|
78
79
|
purgeExpiredQuarantine,
|
|
79
80
|
quarantineSessionAudio,
|
|
81
|
+
readCaptureDisposition,
|
|
80
82
|
sweepOrphanedSessionAudio,
|
|
81
83
|
} from '../lib/unsaved-audio-quarantine.js'
|
|
84
|
+
import {
|
|
85
|
+
STALE_ACTIVE_SESSION_DIR_NAME,
|
|
86
|
+
moveActiveSessionFileAside,
|
|
87
|
+
purgeExpiredStaleActiveSessionFiles,
|
|
88
|
+
sweepStaleActiveSessionFiles,
|
|
89
|
+
type StaleActiveSessionMove,
|
|
90
|
+
} from '../lib/stale-active-session-files.js'
|
|
82
91
|
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
83
92
|
import { saveTrainingAudioSample } from '../lib/training-audio-save.js'
|
|
84
93
|
import {
|
|
@@ -92,6 +101,7 @@ import {
|
|
|
92
101
|
acquireMaintenanceWork,
|
|
93
102
|
maintenanceAdmissionsOpen,
|
|
94
103
|
maintenanceLifecycle,
|
|
104
|
+
onMaintenanceAdmissionsOpen,
|
|
95
105
|
type MaintenanceWorkLease,
|
|
96
106
|
} from '../lib/maintenance-lifecycle.js'
|
|
97
107
|
import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
|
|
@@ -139,8 +149,12 @@ function ensurePrivateDirectory(path: string): void {
|
|
|
139
149
|
|
|
140
150
|
// Silence-hallucination drops (2026-05-29, v5.9.73). Contract in streamSilenceDropReason:
|
|
141
151
|
// brand-URL-only -> dropped ALWAYS (vocab-seeded, never real speech).
|
|
142
|
-
// any-URL-only / repeated-thank-you -> dropped only when
|
|
143
|
-
// 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.
|
|
144
158
|
// Rollback: COS_WHISPER_STRIP_BRAND_URLS=0 (URL drops), COS_WHISPER_THANKYOU_FILTER=0.
|
|
145
159
|
const STRIP_BRAND_URLS = process.env.COS_WHISPER_STRIP_BRAND_URLS !== '0'
|
|
146
160
|
const THANKYOU_FILTER = process.env.COS_WHISPER_THANKYOU_FILTER !== '0'
|
|
@@ -542,6 +556,8 @@ export function getTranscriptionSessionLiveness(now = Date.now()): Transcription
|
|
|
542
556
|
// Incremental chunk persistence — survive server restarts
|
|
543
557
|
const CHUNK_PERSIST_DIR = dataPath('active-sessions')
|
|
544
558
|
ensurePrivateDirectory(CHUNK_PERSIST_DIR)
|
|
559
|
+
/** 6.71.14: where an active-session file the server no longer owns is moved, never deleted in place. */
|
|
560
|
+
const STALE_ACTIVE_SESSION_DIR = dataPath(STALE_ACTIVE_SESSION_DIR_NAME)
|
|
545
561
|
|
|
546
562
|
function readClosedSessions(): Record<string, ClosedTranscriptSession> {
|
|
547
563
|
if (!existsSync(CLOSED_SESSIONS_FILE)) return {}
|
|
@@ -691,6 +707,71 @@ function persistSessionBestEffort(sessionId: string): void {
|
|
|
691
707
|
try { persistSessionRequired(sessionId) } catch { /* recovery cleanup is non-admission work */ }
|
|
692
708
|
}
|
|
693
709
|
|
|
710
|
+
/**
|
|
711
|
+
* 6.71.14: one pass of the stale active-session sweep, against the real session
|
|
712
|
+
* map. Exported so a test drives the exact wiring the minute interval runs.
|
|
713
|
+
*/
|
|
714
|
+
export function sweepStaleActiveSessionFilesNow(now: number): StaleActiveSessionMove[] {
|
|
715
|
+
const moves = sweepStaleActiveSessionFiles({
|
|
716
|
+
dir: CHUNK_PERSIST_DIR,
|
|
717
|
+
targetDir: STALE_ACTIVE_SESSION_DIR,
|
|
718
|
+
isLive: id => sessions.has(id),
|
|
719
|
+
now,
|
|
720
|
+
idleRetentionMs: LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
|
|
721
|
+
})
|
|
722
|
+
for (const move of moves) {
|
|
723
|
+
if (move.target) {
|
|
724
|
+
staleMoveFailuresLogged.delete(move.sessionId)
|
|
725
|
+
console.warn(
|
|
726
|
+
`[cleanup] Moved stale active-session file aside: ${move.sessionId} `
|
|
727
|
+
+ `(${move.reason}, idle ${Math.round((move.idleMs ?? 0) / 60_000)}m, not live) \u2192 ${move.target}`,
|
|
728
|
+
)
|
|
729
|
+
} else {
|
|
730
|
+
logStaleMoveFailure(move.sessionId, `[cleanup] Could not move stale active-session file aside for ${move.sessionId} (${move.reason}); it stays in active-sessions`)
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const purged = purgeExpiredStaleActiveSessionFiles(STALE_ACTIVE_SESSION_DIR, now)
|
|
734
|
+
if (purged > 0) console.log(`[cleanup] Purged ${purged} stale active-session file(s) past retention`)
|
|
735
|
+
return moves
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** A failed move repeats every minute; say so once per session id, not every tick. */
|
|
739
|
+
const staleMoveFailuresLogged = new Set<string>()
|
|
740
|
+
function logStaleMoveFailure(sessionId: string, message: string): void {
|
|
741
|
+
if (staleMoveFailuresLogged.has(sessionId)) return
|
|
742
|
+
staleMoveFailuresLogged.add(sessionId)
|
|
743
|
+
if (staleMoveFailuresLogged.size > 500) staleMoveFailuresLogged.delete(staleMoveFailuresLogged.values().next().value as string)
|
|
744
|
+
console.error(message)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* 6.71.14: is this active-session file the leftover of a capture that is already
|
|
749
|
+
* decoded (recovered or receipted no_speech), rather than a session that RESUMED
|
|
750
|
+
* under the same id after that capture was quarantined?
|
|
751
|
+
*
|
|
752
|
+
* A late chunk is accepted (the phone deletes its copy on a 410), so a session can
|
|
753
|
+
* restart under an id whose older quarantine dir is already receipted. Its file is
|
|
754
|
+
* then newer than the quarantine and its own audio sits in session-audio/<id>.
|
|
755
|
+
* Only a file that is no newer than the quarantine AND has no chunk audio of its
|
|
756
|
+
* own is the leftover; anything else is adopted as 6.71.13 would, so a meeting is
|
|
757
|
+
* never split across a restart.
|
|
758
|
+
*/
|
|
759
|
+
function isTerminalCaptureFile(sessionId: string, recordedLastActivityAt: unknown, filePath: string): boolean {
|
|
760
|
+
const disposition = readCaptureDisposition(sessionId)
|
|
761
|
+
if (!disposition || (disposition.kind !== 'no_speech' && disposition.kind !== 'recovered')) return false
|
|
762
|
+
const terminalAtIso = disposition.quarantinedAt ?? disposition.receiptedAt
|
|
763
|
+
const terminalAt = terminalAtIso ? Date.parse(terminalAtIso) : NaN
|
|
764
|
+
if (!Number.isFinite(terminalAt)) return false
|
|
765
|
+
let fileActivity = typeof recordedLastActivityAt === 'number' && Number.isFinite(recordedLastActivityAt)
|
|
766
|
+
? recordedLastActivityAt
|
|
767
|
+
: NaN
|
|
768
|
+
if (!Number.isFinite(fileActivity)) {
|
|
769
|
+
try { fileActivity = statSync(filePath).mtimeMs } catch { return false }
|
|
770
|
+
}
|
|
771
|
+
if (terminalAt < fileActivity) return false
|
|
772
|
+
return countChunkWavs(resolve(SESSION_AUDIO_DIR, sessionId)) === 0
|
|
773
|
+
}
|
|
774
|
+
|
|
694
775
|
/** Recover sessions from disk on server restart */
|
|
695
776
|
function recoverSessions(): void {
|
|
696
777
|
try {
|
|
@@ -701,6 +782,27 @@ function recoverSessions(): void {
|
|
|
701
782
|
if (!file.endsWith('.json')) continue
|
|
702
783
|
try {
|
|
703
784
|
const data = JSON.parse(readFileSync(resolve(CHUNK_PERSIST_DIR, file), 'utf-8'))
|
|
785
|
+
// 6.71.14: recovery can now run after boot (deferred behind a closed
|
|
786
|
+
// maintenance gate). A session already in memory owns its file; the disk
|
|
787
|
+
// copy must never overwrite it.
|
|
788
|
+
if (typeof data.sessionId === 'string' && sessions.has(data.sessionId)) continue
|
|
789
|
+
// A capture whose quarantined audio was already decoded to a terminal
|
|
790
|
+
// outcome (a saved recovery or a no_speech receipt) is done. Adopting its
|
|
791
|
+
// file would bring back a live session for it; tombstoning would delete the
|
|
792
|
+
// file. Move it aside instead. Plain `quarantined` (never decoded) is NOT
|
|
793
|
+
// done: a crash between a chunk's first WAV write and its first
|
|
794
|
+
// active-session persist quarantines the audio, and the phone's retry then
|
|
795
|
+
// starts the session again. That file is adopted exactly as before.
|
|
796
|
+
if (typeof data.sessionId === 'string' && isTerminalCaptureFile(data.sessionId, data.lastActivityAt, resolve(CHUNK_PERSIST_DIR, file))) {
|
|
797
|
+
const terminal = readCaptureDisposition(data.sessionId)!
|
|
798
|
+
const target = moveActiveSessionFileAside(
|
|
799
|
+
resolve(CHUNK_PERSIST_DIR, file), data.sessionId, STALE_ACTIVE_SESSION_DIR, 'terminal_capture', Date.now(),
|
|
800
|
+
{ lastActivityAt: typeof data.lastActivityAt === 'number' ? data.lastActivityAt : null },
|
|
801
|
+
)
|
|
802
|
+
if (target) console.warn(`[session-recovery] ${data.sessionId} was already ${terminal.kind}; moved its active-session file aside: ${target}`)
|
|
803
|
+
else logStaleMoveFailure(data.sessionId, `[session-recovery] Could not move the active-session file aside for ${data.sessionId} (already ${terminal.kind}); left in place, not adopted`)
|
|
804
|
+
continue
|
|
805
|
+
}
|
|
704
806
|
// Reconstruct the (sparse) chunk array. New format keeps original
|
|
705
807
|
// indices via chunksIndexed; legacy format stored a dense `chunks`.
|
|
706
808
|
const indexed: Array<{ i: number; c: TranscriptChunk }> | null =
|
|
@@ -859,7 +961,9 @@ function recoverSessions(): void {
|
|
|
859
961
|
try {
|
|
860
962
|
if (existsSync(SESSION_AUDIO_DIR)) {
|
|
861
963
|
const actions = sweepOrphanedSessionAudio(SESSION_AUDIO_DIR, {
|
|
862
|
-
|
|
964
|
+
// `|| sessions.has`: deferred recovery (6.71.14) can run while sessions
|
|
965
|
+
// are live in memory; their audio is not orphaned.
|
|
966
|
+
isLive: id => recoveredIds.has(id) || sessions.has(id),
|
|
863
967
|
hasFreshPreservedMarker: hasFreshPreservedAudioMarker,
|
|
864
968
|
reason: 'boot_sweep_unsaved',
|
|
865
969
|
})
|
|
@@ -896,9 +1000,40 @@ export function isSessionDeleted(sessionId: string): boolean {
|
|
|
896
1000
|
// A committed cross-boot maintenance operation owns durable state until the
|
|
897
1001
|
// trusted controller adopts the candidate. Boot recovery must not mutate or
|
|
898
1002
|
// promote sessions while that gate is closed.
|
|
899
|
-
|
|
1003
|
+
//
|
|
1004
|
+
// 6.71.14: and once that gate opens, recovery MUST still run. Until 6.71.13 this was
|
|
1005
|
+
// a module-scope `if` evaluated once at import: a boot behind a closed gate (every
|
|
1006
|
+
// managed update) skipped it for the life of the process. Persisted sessions were
|
|
1007
|
+
// never adopted or tombstoned, and the closed-session tombstones were never
|
|
1008
|
+
// restored. On 2026-10-12 that left `active-sessions/<id>.json` for a receipted
|
|
1009
|
+
// capture on disk for 17 hours, reported as live by COS Control and `missing` by
|
|
1010
|
+
// the status route.
|
|
1011
|
+
let durableSessionRecoveryRan = false
|
|
1012
|
+
function runDurableSessionRecovery(trigger: 'boot' | 'admissions_open'): void {
|
|
1013
|
+
if (durableSessionRecoveryRan) return
|
|
1014
|
+
durableSessionRecoveryRan = true
|
|
900
1015
|
recoverClosedSessions()
|
|
901
1016
|
recoverSessions()
|
|
1017
|
+
if (trigger === 'admissions_open') {
|
|
1018
|
+
console.log(`[session-recovery] Ran deferred durable recovery after the maintenance gate opened (${sessions.size} live session(s))`)
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
if (maintenanceAdmissionsOpen()) {
|
|
1022
|
+
runDurableSessionRecovery('boot')
|
|
1023
|
+
} else {
|
|
1024
|
+
// Delivered on every later open; the flag above makes every delivery after the
|
|
1025
|
+
// first a no-op, so disk is never re-read over a live session map.
|
|
1026
|
+
onMaintenanceAdmissionsOpen(() => runDurableSessionRecovery('admissions_open'))
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* 6.71.14: has durable session recovery run in this process yet? Until it has, the
|
|
1031
|
+
* in-memory map does not yet hold the persisted sessions, so "not in memory" is not
|
|
1032
|
+
* evidence of anything. The status route answers 503 `recovery_pending` instead of
|
|
1033
|
+
* `missing` while this is false.
|
|
1034
|
+
*/
|
|
1035
|
+
export function durableSessionRecoveryHasRun(): boolean {
|
|
1036
|
+
return durableSessionRecoveryRan
|
|
902
1037
|
}
|
|
903
1038
|
|
|
904
1039
|
/**
|
|
@@ -1011,6 +1146,10 @@ function promoteStrandedCapture(sessionId: string, lastActivityAt: number): void
|
|
|
1011
1146
|
// Auto-cleanup sessions idle for the advertised retention horizon.
|
|
1012
1147
|
setInterval(() => {
|
|
1013
1148
|
if (!maintenanceAdmissionsOpen()) return
|
|
1149
|
+
// 6.71.14: the sweeps below judge a session dead by its absence from the
|
|
1150
|
+
// in-memory map. Before recovery has adopted the persisted sessions that map is
|
|
1151
|
+
// empty by construction, so sweeping then would quarantine a live capture.
|
|
1152
|
+
if (!durableSessionRecoveryRan) return
|
|
1014
1153
|
// A capture whose phone went away must still become a meeting. This loop used
|
|
1015
1154
|
// to close every idle session as 'expired', which quarantined its audio and
|
|
1016
1155
|
// produced NO meeting — recoverable for 72h, but only by whoever thought to
|
|
@@ -1053,6 +1192,15 @@ setInterval(() => {
|
|
|
1053
1192
|
// log, and nothing to reason about, because any throw in this block vanished.
|
|
1054
1193
|
console.error(`[cleanup] Orphan-audio sweep failed: ${error instanceof Error ? error.message : error}`)
|
|
1055
1194
|
}
|
|
1195
|
+
// 6.71.14: its own try, for the same reason as auto-recovery below. An
|
|
1196
|
+
// active-session file whose session is not live and has been idle past the
|
|
1197
|
+
// retention horizon is moved aside; COS Control reads that directory directly
|
|
1198
|
+
// and would otherwise show it as a live recording indefinitely.
|
|
1199
|
+
try {
|
|
1200
|
+
sweepStaleActiveSessionFilesNow(Date.now())
|
|
1201
|
+
} catch (error) {
|
|
1202
|
+
console.error(`[cleanup] Stale active-session sweep failed: ${error instanceof Error ? error.message : error}`)
|
|
1203
|
+
}
|
|
1056
1204
|
// DELIBERATELY ITS OWN TRY. This used to sit inside the block above, one line after
|
|
1057
1205
|
// purgeExpiredQuarantine, so a throw anywhere in that sweep meant auto-recovery
|
|
1058
1206
|
// silently never ran — which is exactly what happened in production on 6.23.1
|
|
@@ -2076,16 +2224,16 @@ async function processStreamChunk(opts: {
|
|
|
2076
2224
|
resolve(SESSION_AUDIO_DIR, sessionId, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`),
|
|
2077
2225
|
)
|
|
2078
2226
|
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
const rms =
|
|
2087
|
-
const isQuiet =
|
|
2088
|
-
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)
|
|
2089
2237
|
const whisperContext = recentWhisperContext(sessionId, session)
|
|
2090
2238
|
console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)
|
|
2091
2239
|
|
|
@@ -2122,7 +2270,9 @@ async function processStreamChunk(opts: {
|
|
|
2122
2270
|
// uncorrected for those sources. Idempotent for the local path.
|
|
2123
2271
|
rawText = applyCorrections(rawText)
|
|
2124
2272
|
|
|
2125
|
-
|
|
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)
|
|
2126
2276
|
if (candidate && (!sanitized.text || sanitized.fallbackReason)) {
|
|
2127
2277
|
fallbackReason = sanitized.fallbackReason || 'empty_candidate'
|
|
2128
2278
|
const result = await transcribeWithServerWhisper(audioBuffer, whisperAudio, whisperContext, isQuiet)
|
|
@@ -2130,7 +2280,7 @@ async function processStreamChunk(opts: {
|
|
|
2130
2280
|
words = result.words
|
|
2131
2281
|
backend = result.backend
|
|
2132
2282
|
asrProvider = 'server-whisper'
|
|
2133
|
-
sanitized = sanitizeStreamTranscript(sessionId, session, rawText,
|
|
2283
|
+
sanitized = sanitizeStreamTranscript(sessionId, session, rawText, dropQuiet)
|
|
2134
2284
|
}
|
|
2135
2285
|
|
|
2136
2286
|
const voiceprint = await speakerPromise
|
|
@@ -2250,7 +2400,7 @@ async function processStreamChunk(opts: {
|
|
|
2250
2400
|
// throws on by default. All gates live inside the feed.
|
|
2251
2401
|
void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
|
|
2252
2402
|
|
|
2253
|
-
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)}"`)
|
|
2254
2404
|
return canonicalChunkResponse(chunk, sessionId, chunkIndex)
|
|
2255
2405
|
}
|
|
2256
2406
|
|
|
@@ -2277,13 +2427,15 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
|
|
|
2277
2427
|
}
|
|
2278
2428
|
|
|
2279
2429
|
/**
|
|
2280
|
-
*
|
|
2430
|
+
* Meeting preview (on unless COS_WHISPER_MEETING_PREVIEW=0).
|
|
2281
2431
|
*
|
|
2282
2432
|
* This route deliberately owns no meeting session, recovery ledger, speaker
|
|
2283
2433
|
* attribution, or persistence. It accepts a pinned copy of the still-open
|
|
2284
|
-
* phrase and returns provisional
|
|
2285
|
-
*
|
|
2286
|
-
*
|
|
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.
|
|
2287
2439
|
*/
|
|
2288
2440
|
transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
|
|
2289
2441
|
try {
|
|
@@ -2316,7 +2468,10 @@ transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
|
|
|
2316
2468
|
|
|
2317
2469
|
meetingPreviewBusy = true
|
|
2318
2470
|
try {
|
|
2319
|
-
|
|
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)
|
|
2320
2475
|
if (!result) return void res.status(204).send()
|
|
2321
2476
|
const cleaned = stripInlineHallucinationsOneShot(result.text.trim()).trim()
|
|
2322
2477
|
if (!cleaned || sharedIsFullHallucination(cleaned)) {
|