@gotcos/glasses-server 6.71.14 → 6.73.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 +32 -0
- package/README.md +188 -3
- 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/dictation-stt.ts +117 -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 +675 -0
- package/server/lib/prompt-draft-store.ts +5 -0
- package/server/lib/stt-live.ts +60 -0
- package/server/lib/stt-resolver.ts +327 -0
- package/server/lib/stt-runtime.ts +233 -0
- package/server/lib/stt-settings.ts +431 -0
- package/server/lib/transcribe-audio.ts +53 -4
- 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/prompt-drafts.ts +56 -17
- package/server/routes/stt-settings.ts +85 -0
- package/server/routes/transcribe-stream.ts +34 -22
- package/server/routes/transcribe.ts +6 -2
|
@@ -13,7 +13,8 @@ import { writeFileSync, unlinkSync, existsSync, readFileSync, statSync } from 'n
|
|
|
13
13
|
import { basename, join } from 'node:path'
|
|
14
14
|
import { homedir } from 'node:os'
|
|
15
15
|
import crypto from 'node:crypto'
|
|
16
|
-
import {
|
|
16
|
+
import { batchTimestampsEnabled, parseNativeVadTimeline, remapBatchWords } from './whisper-batch-timing.js'
|
|
17
|
+
import { readSttSettings, type FrontEndId } from './stt-settings.js'
|
|
17
18
|
import { getVocabulary, getOwnerName, getWhisperCorrections } from './profile.js'
|
|
18
19
|
import { stripBrandUrls } from './hallucination-filter.js'
|
|
19
20
|
import {
|
|
@@ -70,21 +71,34 @@ export interface HighQualityTranscriptionCapability {
|
|
|
70
71
|
export interface HighQualityTranscriptionResult {
|
|
71
72
|
text: string
|
|
72
73
|
words?: WhisperWord[]
|
|
74
|
+
/** Sub-word tokens merged into spoken words (6.72.0), NOT name-corrected.
|
|
75
|
+
* Clocks are only meaningful when the decode ran with timestamps. */
|
|
76
|
+
spokenWords?: WhisperWord[]
|
|
77
|
+
/** True when this decode ran with timestamps and the VAD remap. */
|
|
78
|
+
timestamps?: boolean
|
|
79
|
+
/** Where this decode's own VAD heard speech (seconds, input clock). Timestamp mode only. */
|
|
80
|
+
speechSpans?: Array<[number, number]>
|
|
73
81
|
model: 'large-v3' | 'turbo'
|
|
74
82
|
backend: 'whisper-cli' | 'whisper-server'
|
|
75
83
|
actualQuality: 'hq' | 'fast'
|
|
76
84
|
degradationReason?: HighQualityUnavailableReason
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
|
|
80
|
-
let highQualityCheckpointTimingMode: boolean | null = null
|
|
87
|
+
const highQualityCheckpointFingerprints = new Map<string, string>()
|
|
81
88
|
|
|
82
89
|
/** Stable, path-free cache identity for progressive meeting HQ checkpoints.
|
|
83
90
|
* Model/configuration is immutable for the lifetime of one server process, so
|
|
84
|
-
* cache this instead of stat'ing multi-GB model files on every health poll.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
* cache this instead of stat'ing multi-GB model files on every health poll.
|
|
92
|
+
* 6.72.0: the timing mode and the ASR front end are part of the identity, so a
|
|
93
|
+
* checkpoint decoded under one setting is never reused under another. With
|
|
94
|
+
* timestamps off and the full front end the identity is the pre-6.72 one, byte
|
|
95
|
+
* for byte. */
|
|
96
|
+
export function getHighQualityCheckpointFingerprint(opts: { timestamps?: boolean; frontEnd?: FrontEndId } = {}): string {
|
|
97
|
+
const timingMode = opts.timestamps ?? batchTimestampsEnabled()
|
|
98
|
+
const frontEnd: FrontEndId = opts.frontEnd ?? readSttSettings().frontEnd
|
|
99
|
+
const cacheKey = `${timingMode ? 1 : 0}|${frontEnd}`
|
|
100
|
+
const cached = highQualityCheckpointFingerprints.get(cacheKey)
|
|
101
|
+
if (cached) return cached
|
|
88
102
|
const identity = (path: string): Record<string, number | boolean> => {
|
|
89
103
|
try {
|
|
90
104
|
const stat = statSync(path)
|
|
@@ -93,9 +107,9 @@ export function getHighQualityCheckpointFingerprint(): string {
|
|
|
93
107
|
return { present: false, size: 0, mtimeMs: 0 }
|
|
94
108
|
}
|
|
95
109
|
}
|
|
96
|
-
|
|
110
|
+
const fingerprint = crypto.createHash('sha256').update(JSON.stringify({
|
|
97
111
|
schema: 2,
|
|
98
|
-
// Keep the existing legacy identity byte-for-byte when
|
|
112
|
+
// Keep the existing legacy identity byte-for-byte when timestamps are off.
|
|
99
113
|
...(timingMode ? { batchTiming: 'native-vad-timestamps-v1' } : {}),
|
|
100
114
|
serverVersion: process.env.COS_SERVER_VERSION?.trim() || 'development',
|
|
101
115
|
whisperCli: identity(WHISPER_CLI),
|
|
@@ -104,12 +118,12 @@ export function getHighQualityCheckpointFingerprint(): string {
|
|
|
104
118
|
beam: 5,
|
|
105
119
|
bestOf: 5,
|
|
106
120
|
vadEnabled: hqCliVadEnabled('batch'),
|
|
107
|
-
enhancement: 'audio-enhance-v1',
|
|
121
|
+
enhancement: frontEnd === 'full' ? 'audio-enhance-v1' : 'audio-enhance-highpass-v1',
|
|
108
122
|
prompt: buildWhisperPrompt(),
|
|
109
123
|
corrections: getWhisperCorrections(),
|
|
110
124
|
})).digest('hex')
|
|
111
|
-
|
|
112
|
-
return
|
|
125
|
+
highQualityCheckpointFingerprints.set(cacheKey, fingerprint)
|
|
126
|
+
return fingerprint
|
|
113
127
|
}
|
|
114
128
|
|
|
115
129
|
export function classifyHighQualityTranscriptionCapability(input: {
|
|
@@ -882,7 +896,7 @@ async function reconcileWhisperServerHealth(): Promise<boolean> {
|
|
|
882
896
|
* Batch/save path only. Never uses whisper-server `verbose_json` (the live
|
|
883
897
|
* VAD-empty crash vector). Special tokens like `[_BEG_]` / `<|...|>` are dropped.
|
|
884
898
|
*/
|
|
885
|
-
export function parseWhisperCliFullJson(raw: string): { text: string; words: WhisperWord[] } {
|
|
899
|
+
export function parseWhisperCliFullJson(raw: string): { text: string; words: WhisperWord[]; spokenWords: WhisperWord[] } {
|
|
886
900
|
const data = JSON.parse(raw) as {
|
|
887
901
|
transcription?: Array<{
|
|
888
902
|
text?: unknown
|
|
@@ -896,6 +910,7 @@ export function parseWhisperCliFullJson(raw: string): { text: string; words: Whi
|
|
|
896
910
|
const segments = Array.isArray(data.transcription) ? data.transcription : []
|
|
897
911
|
const texts: string[] = []
|
|
898
912
|
const words: WhisperWord[] = []
|
|
913
|
+
const spokenWords: WhisperWord[] = []
|
|
899
914
|
|
|
900
915
|
for (const segment of segments) {
|
|
901
916
|
if (typeof segment.text === 'string' && segment.text.trim()) {
|
|
@@ -909,18 +924,30 @@ export function parseWhisperCliFullJson(raw: string): { text: string; words: Whi
|
|
|
909
924
|
const fromMs = typeof token.offsets?.from === 'number' ? token.offsets.from : Number(token.offsets?.from)
|
|
910
925
|
const toMs = typeof token.offsets?.to === 'number' ? token.offsets.to : Number(token.offsets?.to)
|
|
911
926
|
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) continue
|
|
912
|
-
|
|
927
|
+
const word: WhisperWord = {
|
|
913
928
|
word: tokenText,
|
|
914
929
|
start: fromMs / 1000,
|
|
915
930
|
end: toMs / 1000,
|
|
916
931
|
probability: typeof token.p === 'number' && Number.isFinite(token.p) ? token.p : 0,
|
|
917
|
-
}
|
|
932
|
+
}
|
|
933
|
+
words.push(word)
|
|
934
|
+
// A token without a leading space continues the previous word ("I" + "'m").
|
|
935
|
+
const rawText = typeof token.text === 'string' ? token.text : ''
|
|
936
|
+
const last = spokenWords.at(-1)
|
|
937
|
+
if (last && !/^\s/.test(rawText) && !/^[\s]*$/.test(rawText)) {
|
|
938
|
+
last.word += tokenText
|
|
939
|
+
last.end = Math.max(last.end, word.end)
|
|
940
|
+
last.probability = Math.min(last.probability, word.probability)
|
|
941
|
+
} else {
|
|
942
|
+
spokenWords.push({ ...word })
|
|
943
|
+
}
|
|
918
944
|
}
|
|
919
945
|
}
|
|
920
946
|
|
|
921
947
|
return {
|
|
922
948
|
text: texts.join(' ').replace(/\s+/g, ' ').trim(),
|
|
923
949
|
words,
|
|
950
|
+
spokenWords,
|
|
924
951
|
}
|
|
925
952
|
}
|
|
926
953
|
|
|
@@ -949,6 +976,8 @@ export async function transcribeHighQuality(
|
|
|
949
976
|
threads?: number
|
|
950
977
|
backgroundCpu?: boolean
|
|
951
978
|
signal?: AbortSignal
|
|
979
|
+
/** Meeting batch only: timestamps on/off for this decode (the job's snapshot). */
|
|
980
|
+
timestamps?: boolean
|
|
952
981
|
} = {},
|
|
953
982
|
): Promise<HighQualityTranscriptionResult> {
|
|
954
983
|
if (!cliAvailable) {
|
|
@@ -972,7 +1001,9 @@ export async function transcribeHighQuality(
|
|
|
972
1001
|
// Word clocks on post-meeting CPU polish, and on any decode that asks for
|
|
973
1002
|
// them (the prompt tail guard). Other live decodes stay compact JSON.
|
|
974
1003
|
const captureWords = opts.priority === 'batch' || opts.words === true
|
|
975
|
-
|
|
1004
|
+
// 6.72.0: on by default for meeting batch (HQ setting "large-v3"); the job
|
|
1005
|
+
// passes its own snapshot so a settings change never lands mid-meeting.
|
|
1006
|
+
const timestampCanary = opts.priority === 'batch' && (opts.timestamps ?? batchTimestampsEnabled())
|
|
976
1007
|
let nativeDiagnostics = ''
|
|
977
1008
|
|
|
978
1009
|
const modelPath = resolveBatchModel()
|
|
@@ -1144,6 +1175,8 @@ export async function transcribeHighQuality(
|
|
|
1144
1175
|
|
|
1145
1176
|
let finalText = text
|
|
1146
1177
|
let words: WhisperWord[] | undefined
|
|
1178
|
+
let spokenWords: WhisperWord[] | undefined
|
|
1179
|
+
let speechSpans: Array<[number, number]> | undefined
|
|
1147
1180
|
if (captureWords) {
|
|
1148
1181
|
try {
|
|
1149
1182
|
if (existsSync(jsonPath)) {
|
|
@@ -1157,6 +1190,11 @@ export async function transcribeHighQuality(
|
|
|
1157
1190
|
words = timed.length > 0
|
|
1158
1191
|
? timed.map(w => ({ ...w, word: applyCorrections(w.word) }))
|
|
1159
1192
|
: []
|
|
1193
|
+
// Spoken words stay uncorrected: the resolver joins them and corrects the text once.
|
|
1194
|
+
spokenWords = timestampCanary ? remapBatchWords(parsed.spokenWords, nativeDiagnostics, useVad) : parsed.spokenWords
|
|
1195
|
+
if (timestampCanary && useVad && parsed.words.length) {
|
|
1196
|
+
speechSpans = parseNativeVadTimeline(nativeDiagnostics).map(s => [s.originalStart, s.originalEnd] as [number, number])
|
|
1197
|
+
}
|
|
1160
1198
|
} else if (timestampCanary) {
|
|
1161
1199
|
throw new Error('HQ timestamp candidate did not produce CLI JSON')
|
|
1162
1200
|
}
|
|
@@ -1193,7 +1231,10 @@ export async function transcribeHighQuality(
|
|
|
1193
1231
|
? 'large_v3_model_missing' as const
|
|
1194
1232
|
: 'hq_disabled' as const,
|
|
1195
1233
|
}
|
|
1196
|
-
|
|
1234
|
+
const timing = isBatch ? { timestamps: timestampCanary } : {}
|
|
1235
|
+
return words
|
|
1236
|
+
? { text: corrected, words, ...(spokenWords ? { spokenWords } : {}), ...(speechSpans ? { speechSpans } : {}), ...timing, ...metadata }
|
|
1237
|
+
: { text: corrected, ...timing, ...metadata }
|
|
1197
1238
|
} finally {
|
|
1198
1239
|
try { unlinkSync(tmpWav) } catch { /* cleanup */ }
|
|
1199
1240
|
if (captureWords) {
|
package/server/routes/meeting.ts
CHANGED
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
import {
|
|
67
67
|
canDeletePendingBatchAudio,
|
|
68
68
|
persistBatchDecisionSidecar,
|
|
69
|
+
persistSttSidecar,
|
|
69
70
|
replaceMeetingTranscriptAtomic,
|
|
70
71
|
} from '../lib/meeting-batch-persistence.js'
|
|
71
72
|
import {
|
|
@@ -77,6 +78,8 @@ import {
|
|
|
77
78
|
import {
|
|
78
79
|
enqueueSerializedHqWork,
|
|
79
80
|
runMeetingBatchPipeline,
|
|
81
|
+
recoveryHqJob,
|
|
82
|
+
summarizeStt,
|
|
80
83
|
segmentTranscriptChunks,
|
|
81
84
|
stopProgressiveHqSession,
|
|
82
85
|
transcribeSegments,
|
|
@@ -2178,9 +2181,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2178
2181
|
// decoder serialization — that lives in the queue tail. Chain onto it,
|
|
2179
2182
|
// or two back-to-back recoveries plus a live post-meeting batch would
|
|
2180
2183
|
// run parallel 16-thread large-v3 decoders on the user's Mac.
|
|
2181
|
-
const
|
|
2182
|
-
|
|
2183
|
-
)
|
|
2184
|
+
const hqJob = recoveryHqJob(quarantineDir, segments, entries)
|
|
2185
|
+
const plan = hqJob.plan
|
|
2186
|
+
const results = await enqueueSerializedHqWork(hqJob.run)
|
|
2184
2187
|
// Batch whisper over a dead session has no streaming baseline to
|
|
2185
2188
|
// compare against (evaluateBatchQuality needs one), but the inline
|
|
2186
2189
|
// hallucination filter needs none — run the same two-pass the boot
|
|
@@ -2240,6 +2243,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2240
2243
|
})),
|
|
2241
2244
|
transferIntegrity: null,
|
|
2242
2245
|
})
|
|
2246
|
+
try { persistSttSidecar(saved.sidecarPath, results, summarizeStt(plan, results, segments.length)) } catch (error) {
|
|
2247
|
+
console.warn(`[meeting/orphans] ${sessionId}: STT hypotheses not saved: ${error instanceof Error ? error.message : error}`)
|
|
2248
|
+
}
|
|
2243
2249
|
markRecovered(quarantineDir, saved.filename)
|
|
2244
2250
|
console.log(
|
|
2245
2251
|
`[meeting/orphans] Recovered ${sessionId} → ${saved.filename} `
|
|
@@ -40,6 +40,8 @@ import { applyCorrections } from '../lib/whisper-local.js'
|
|
|
40
40
|
import { applyFuzzyCorrections } from '../lib/fuzzy-correct.js'
|
|
41
41
|
import { getAllSpeakerNames } from '../lib/speaker-embeddings.js'
|
|
42
42
|
import { transcribeWhisperPreview } from '../lib/whisper-preview.js'
|
|
43
|
+
import { currentDictationPlan, runParakeetDictation } from '../lib/dictation-stt.js'
|
|
44
|
+
import { dictationSlotForMode, legacyDictationKey, type DictationPlan } from '../lib/stt-settings.js'
|
|
43
45
|
import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from '../lib/dictation-clean.js'
|
|
44
46
|
import { getVocabulary } from '../lib/profile.js'
|
|
45
47
|
import { createBreaker } from '../lib/claude-circuit.js'
|
|
@@ -240,14 +242,25 @@ async function sendDraftError(res: Response, draftId: string, err: any): Promise
|
|
|
240
242
|
res.status(err.status ?? 500).json({ error: err.message })
|
|
241
243
|
}
|
|
242
244
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
+
// 6.73.0: the setting key is part of every decode key, so a decode made under
|
|
246
|
+
// one STT selection is never shared with or reused for another.
|
|
247
|
+
function modeQualityKey(draftId: string, chunkIndex: number, mode: 'hq' | 'fast', hash: string, sttKey: string): string {
|
|
248
|
+
return `${draftId}:${chunkIndex}:${mode}:${hash}:${sttKey}`
|
|
245
249
|
}
|
|
246
250
|
|
|
247
|
-
|
|
251
|
+
/** The setting key a stored record was made under (pre-6.73 records: the 6.72.0 defaults). */
|
|
252
|
+
export function recordSttKey(record: PromptDraftTranscriptRecord): string {
|
|
253
|
+
return record.sttKey ?? legacyDictationKey(record.requestedMode)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function planFor(mode: 'hq' | 'fast'): DictationPlan {
|
|
257
|
+
return currentDictationPlan(dictationSlotForMode(mode))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffer, mode: 'hq' | 'fast', purpose: 'warm' | 'final', plan: DictationPlan = planFor(mode)): Promise<string> {
|
|
248
261
|
const hash = audioHash(audio)
|
|
249
|
-
const purposeKey = `${draftId}:${chunkIndex}:${purpose}:${mode}:${hash}`
|
|
250
|
-
const sharedKey = modeQualityKey(draftId, chunkIndex, mode, hash)
|
|
262
|
+
const purposeKey = `${draftId}:${chunkIndex}:${purpose}:${mode}:${hash}:${plan.key}`
|
|
263
|
+
const sharedKey = modeQualityKey(draftId, chunkIndex, mode, hash, plan.key)
|
|
251
264
|
|
|
252
265
|
// HQ warm and HQ finalize must share one decode (plan step 8a).
|
|
253
266
|
const existingShared = modeQualityJobs.get(sharedKey)
|
|
@@ -276,6 +289,9 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
276
289
|
backend: warmMatches ? warm!.backend : 'shared-inflight',
|
|
277
290
|
degraded: warmMatches ? warm!.degraded : false,
|
|
278
291
|
...(warmMatches && warm!.degradationReason ? { degradationReason: warm!.degradationReason } : {}),
|
|
292
|
+
sttKey: plan.key,
|
|
293
|
+
...(warmMatches && warm!.engine ? { engine: warm!.engine } : {}),
|
|
294
|
+
...(warmMatches && warm!.engineReason ? { engineReason: warm!.engineReason } : {}),
|
|
279
295
|
}
|
|
280
296
|
if (
|
|
281
297
|
!cachedFinal
|
|
@@ -298,7 +314,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
298
314
|
try {
|
|
299
315
|
// Speculative warm is always local-only. Finalize may use automatic cloud fallback.
|
|
300
316
|
const policy = purpose === 'warm' ? 'local-only' as const : 'automatic' as const
|
|
301
|
-
const result = await transcribeAudioBuffer(audio, { mode, policy })
|
|
317
|
+
const result = await transcribeAudioBuffer(audio, { mode, policy, dictationPlan: plan })
|
|
302
318
|
if (!isCurrentChunk(draftId, chunkIndex, audio)) return ''
|
|
303
319
|
// 6.45.4 — the sentence Whisper invents after the speaker stops. Never trims
|
|
304
320
|
// audio; drops a trailing sentence only on the junk lexicon or on silence
|
|
@@ -313,9 +329,11 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
313
329
|
text, hash, requestedMode: result.requestedMode, actualQuality: result.actualQuality,
|
|
314
330
|
backend: result.backend, degraded: result.degraded,
|
|
315
331
|
...(result.degradationReason ? { degradationReason: result.degradationReason } : {}),
|
|
332
|
+
sttKey: result.dictationKey, engine: result.engine,
|
|
333
|
+
...(result.engineReason ? { engineReason: result.engineReason } : {}),
|
|
316
334
|
}
|
|
317
335
|
await markPromptDraftChunkTranscript(draftId, chunkIndex, record, purpose)
|
|
318
|
-
console.log(`[prompt-draft] chunk ${draftId}/${chunkIndex}: ${result.elapsedMs.toFixed(1)}ms | ${result.backend} | ${purpose}/${mode} | ${text.length} chars`)
|
|
336
|
+
console.log(`[prompt-draft] chunk ${draftId}/${chunkIndex}: ${result.elapsedMs.toFixed(1)}ms | ${result.backend}${result.engineReason ? ` (${result.engineReason})` : ''} | ${purpose}/${mode} | ${text.length} chars`)
|
|
319
337
|
return text
|
|
320
338
|
} catch (err) {
|
|
321
339
|
if (err instanceof NoSpeechDetectedError) {
|
|
@@ -324,7 +342,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
324
342
|
// recognized failure shape instead of persisting either the artifact
|
|
325
343
|
// or an empty gap. Other no-speech results retain existing behavior.
|
|
326
344
|
if (mode === 'hq' && isCaptionCreditHallucination(err.rawText)) {
|
|
327
|
-
const fastJob = modeQualityJobs.
|
|
345
|
+
const fastJob = [...modeQualityJobs.entries()].find(([key]) => key.startsWith(`${draftId}:${chunkIndex}:fast:${hash}:`))?.[1]
|
|
328
346
|
if (fastJob) {
|
|
329
347
|
try { await fastJob } catch { /* normal no-speech path below */ }
|
|
330
348
|
}
|
|
@@ -342,6 +360,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
342
360
|
requestedMode: 'hq',
|
|
343
361
|
degraded: true,
|
|
344
362
|
degradationReason: 'hq_caption_credit_hallucination',
|
|
363
|
+
sttKey: plan.key,
|
|
345
364
|
}, 'final')
|
|
346
365
|
}
|
|
347
366
|
console.warn(`[prompt-draft] dropped HQ caption-credit hallucination ${draftId}/${chunkIndex}; retained Fast text`)
|
|
@@ -349,7 +368,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
349
368
|
}
|
|
350
369
|
}
|
|
351
370
|
await markPromptDraftChunkTranscript(draftId, chunkIndex, {
|
|
352
|
-
text: '', hash, requestedMode: mode, actualQuality: mode, backend: 'no-speech', degraded: false,
|
|
371
|
+
text: '', hash, requestedMode: mode, actualQuality: mode, backend: 'no-speech', degraded: false, sttKey: plan.key,
|
|
353
372
|
}, purpose)
|
|
354
373
|
return ''
|
|
355
374
|
}
|
|
@@ -364,12 +383,15 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
364
383
|
return job
|
|
365
384
|
}
|
|
366
385
|
|
|
367
|
-
function isReusableTranscript(
|
|
386
|
+
export function isReusableTranscript(
|
|
368
387
|
cached: PromptDraftTranscriptRecord | undefined,
|
|
369
388
|
hash: string,
|
|
370
389
|
mode: 'hq' | 'fast',
|
|
390
|
+
sttKey: string,
|
|
371
391
|
): boolean {
|
|
372
392
|
if (!cached || cached.hash !== hash || cached.requestedMode !== mode) return false
|
|
393
|
+
// 6.73.0: a decode made under a different STT selection is decoded again.
|
|
394
|
+
if (recordSttKey(cached) !== sttKey) return false
|
|
373
395
|
if (cached.backend.startsWith('legacy')) return false
|
|
374
396
|
if (mode === 'fast') return cached.actualQuality === 'fast' || cached.actualQuality === 'cloud'
|
|
375
397
|
// mode === 'hq'
|
|
@@ -385,12 +407,14 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
385
407
|
if (!meta) throw Object.assign(new Error('draft not found'), { status: 404 })
|
|
386
408
|
const texts: string[] = []
|
|
387
409
|
const transcriptRecords: PromptDraftTranscriptRecord[] = []
|
|
410
|
+
// One STT snapshot for the whole finalize: a settings change mid-finalize never mixes selections.
|
|
411
|
+
const plan = planFor(mode)
|
|
388
412
|
for (const chunk of readPromptDraftChunks(draftId)) {
|
|
389
413
|
try {
|
|
390
414
|
const hash = audioHash(chunk.audioBuffer)
|
|
391
415
|
// Await in-flight HQ warm before deciding cache miss (plan step 8b belt).
|
|
392
416
|
if (mode === 'hq') {
|
|
393
|
-
const inflight = modeQualityJobs.get(modeQualityKey(draftId, chunk.chunkIndex, 'hq', hash))
|
|
417
|
+
const inflight = modeQualityJobs.get(modeQualityKey(draftId, chunk.chunkIndex, 'hq', hash, plan.key))
|
|
394
418
|
if (inflight) {
|
|
395
419
|
try { await inflight } catch { /* finalize may retry with automatic below */ }
|
|
396
420
|
}
|
|
@@ -401,8 +425,8 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
401
425
|
// When Settings HQ is available, never treat a degraded turbo warm as final
|
|
402
426
|
// — that left finished chats on Fast text while REVIEW looked "HQ ready".
|
|
403
427
|
// Legacy records stay excluded (reconstructed provenance).
|
|
404
|
-
const reusable = isReusableTranscript(cached, hash, mode)
|
|
405
|
-
const raw = reusable ? cached!.text : await transcribeChunk(draftId, chunk.chunkIndex, chunk.audioBuffer, mode, 'final')
|
|
428
|
+
const reusable = isReusableTranscript(cached, hash, mode, plan.key)
|
|
429
|
+
const raw = reusable ? cached!.text : await transcribeChunk(draftId, chunk.chunkIndex, chunk.audioBuffer, mode, 'final', plan)
|
|
406
430
|
const text = sanitizeTranscript(draftId, raw, !reusable)
|
|
407
431
|
if (text.trim()) {
|
|
408
432
|
texts.push(text.trim())
|
|
@@ -433,6 +457,8 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
433
457
|
const degraded = mode === 'hq' && (actualQuality !== 'hq' || transcriptRecords.some(record => record.degraded))
|
|
434
458
|
const backends = [...new Set(transcriptRecords.map(record => record.backend))]
|
|
435
459
|
const degradationReason = transcriptRecords.find(record => record.degradationReason)?.degradationReason
|
|
460
|
+
const engines = [...new Set(transcriptRecords.map(record => record.engine ?? 'whisper'))]
|
|
461
|
+
const engineReason = transcriptRecords.find(record => record.engineReason)?.engineReason
|
|
436
462
|
return {
|
|
437
463
|
draftId,
|
|
438
464
|
text: finalText,
|
|
@@ -445,6 +471,9 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
445
471
|
degraded,
|
|
446
472
|
backend: backends.length === 1 ? backends[0] : 'mixed',
|
|
447
473
|
...(degradationReason ? { degradationReason } : {}),
|
|
474
|
+
// 6.73.0, additive.
|
|
475
|
+
engine: engines.length === 1 ? engines[0] : engines.length === 0 ? plan.engine : 'mixed',
|
|
476
|
+
...(engineReason ? { engineReason } : {}),
|
|
448
477
|
}
|
|
449
478
|
}
|
|
450
479
|
|
|
@@ -513,15 +542,24 @@ promptDraftsRouter.post('/prompt-drafts/:draftId/peek', async (req, res) => {
|
|
|
513
542
|
peekTail = peekTail.then(async () => {
|
|
514
543
|
lease.setPhase('active')
|
|
515
544
|
try {
|
|
516
|
-
|
|
545
|
+
// 6.73.0: the live preview follows the Preview model. Parakeet runs on the
|
|
546
|
+
// shared one-at-a-time preview lane; any miss is the 6.72.0 Whisper peek.
|
|
547
|
+
const plan = currentDictationPlan('preview')
|
|
548
|
+
let raw: string | null = null
|
|
549
|
+
let engine: 'whisper' | 'parakeet-ultra' = 'whisper'
|
|
550
|
+
if (plan.engine === 'parakeet-ultra') {
|
|
551
|
+
const outcome = await runParakeetDictation(audio, plan, 'preview')
|
|
552
|
+
if (outcome.ok) { raw = applyCorrections(outcome.text); engine = 'parakeet-ultra' }
|
|
553
|
+
}
|
|
554
|
+
if (raw === null) raw = (await transcribeWhisperPreview(audio)).text
|
|
517
555
|
// 6.45.4: the same whole-sentence filler rule as the commit path, so an
|
|
518
556
|
// invented closing line never paints on the lens only to vanish later.
|
|
519
|
-
const text = sanitizeTranscript(draftId, guardPromptTail({ text:
|
|
557
|
+
const text = sanitizeTranscript(draftId, guardPromptTail({ text: raw, speech: SPEECH_UNKNOWN }).text, false)
|
|
520
558
|
if (!text) return
|
|
521
559
|
if (!loadPromptDraftMeta(draftId)) return
|
|
522
560
|
emitDisplay({
|
|
523
561
|
type: 'prompt_transcript',
|
|
524
|
-
data: { draftId, chunkIndex, text, provisional: true, peekGen },
|
|
562
|
+
data: { draftId, chunkIndex, text, provisional: true, peekGen, engine },
|
|
525
563
|
})
|
|
526
564
|
} catch (err: any) {
|
|
527
565
|
if (!(err instanceof NoSpeechDetectedError)) {
|
|
@@ -575,9 +613,10 @@ promptDraftsRouter.post('/prompt-drafts/:draftId/chunks', async (req, res) => {
|
|
|
575
613
|
// transcript only after rechecking that this exact audio still owns the
|
|
576
614
|
// chunk index; a retry/replacement must never paint stale words.
|
|
577
615
|
if (!isCurrentChunk(req.params.draftId, chunkIndex, audio)) return
|
|
616
|
+
const warm = loadPromptDraftMeta(req.params.draftId)?.warmTranscripts?.[String(chunkIndex)]
|
|
578
617
|
emitDisplay({
|
|
579
618
|
type: 'prompt_transcript',
|
|
580
|
-
data: { draftId: req.params.draftId, chunkIndex, text },
|
|
619
|
+
data: { draftId: req.params.draftId, chunkIndex, text, ...(warm?.requestedMode === 'fast' && warm.engine ? { engine: warm.engine } : {}) },
|
|
581
620
|
})
|
|
582
621
|
}).catch(err => {
|
|
583
622
|
console.warn(`[prompt-draft] warm transcription failed ${req.params.draftId}/${chunkIndex}: ${err.message}`)
|
|
@@ -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)) {
|