@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,342 @@
|
|
|
1
|
+
// Speech-to-text settings (6.72.0): which engine previews live speech, which
|
|
2
|
+
// engine writes the saved meeting (HQ), which engine gives HQ a second opinion,
|
|
3
|
+
// whether the resolver may use it, the ASR input front end and the quiet gate.
|
|
4
|
+
//
|
|
5
|
+
// Stored in <data>/stt-settings.json (atomic). Read on every use with an mtime
|
|
6
|
+
// check, so a change applies without a restart: the preview, front end and
|
|
7
|
+
// quiet gate from the next live chunk; HQ, second opinion and resolver from the
|
|
8
|
+
// next saved meeting (an HQ job snapshots the settings when it starts and never
|
|
9
|
+
// switches engines mid-job).
|
|
10
|
+
//
|
|
11
|
+
// Defaults and the evidence behind them: docs/stt-672-evaluation.md.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
14
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
15
|
+
import { dataPath } from './data-dir.js'
|
|
16
|
+
|
|
17
|
+
export const PREVIEW_IDS = ['turbo', 'large-v3', 'parakeet', 'nemotron-560'] as const
|
|
18
|
+
export const HQ_IDS = ['large-v3', 'large-v3-legacy', 'parakeet-ultra'] as const
|
|
19
|
+
export const SECOND_OPINION_IDS = ['off', 'parakeet-ultra', 'large-v3'] as const
|
|
20
|
+
export const FRONT_END_IDS = ['highpass', 'full'] as const
|
|
21
|
+
export const QUIET_GATE_IDS = ['silero', 'rms'] as const
|
|
22
|
+
|
|
23
|
+
export type PreviewId = typeof PREVIEW_IDS[number]
|
|
24
|
+
export type HqId = typeof HQ_IDS[number]
|
|
25
|
+
export type SecondOpinionId = typeof SECOND_OPINION_IDS[number]
|
|
26
|
+
export type FrontEndId = typeof FRONT_END_IDS[number]
|
|
27
|
+
export type QuietGateId = typeof QUIET_GATE_IDS[number]
|
|
28
|
+
|
|
29
|
+
export interface SttSettings {
|
|
30
|
+
preview: PreviewId
|
|
31
|
+
hq: HqId
|
|
32
|
+
secondOpinion: SecondOpinionId
|
|
33
|
+
resolver: boolean
|
|
34
|
+
frontEnd: FrontEndId
|
|
35
|
+
quietGate: QuietGateId
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Items 1 to 3 on. Second opinion off and the resolver off: on the shipped
|
|
39
|
+
* path (production 30 s windows, docs/stt-672-evaluation.md) neither
|
|
40
|
+
* resolver configuration passed the adoption rule (Large-v3 + Ultra -1.6% WER,
|
|
41
|
+
* CI -4.7 to +1.1), so Large-v3 alone writes the meeting. Parakeet Ultra stays
|
|
42
|
+
* installable for the preview and as the HQ primary. */
|
|
43
|
+
export const DEFAULT_STT_SETTINGS: Readonly<SttSettings> = Object.freeze({
|
|
44
|
+
preview: 'turbo',
|
|
45
|
+
hq: 'large-v3',
|
|
46
|
+
secondOpinion: 'off',
|
|
47
|
+
resolver: false,
|
|
48
|
+
frontEnd: 'highpass',
|
|
49
|
+
quietGate: 'silero',
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
export const STT_SETTINGS_FILE = 'stt-settings.json'
|
|
53
|
+
const settingsPath = () => dataPath(STT_SETTINGS_FILE)
|
|
54
|
+
|
|
55
|
+
function pick<T extends string>(ids: readonly T[], value: unknown, fallback: T): T {
|
|
56
|
+
return typeof value === 'string' && (ids as readonly string[]).includes(value) ? value as T : fallback
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Unknown or damaged stored values fall back to the default, key by key. */
|
|
60
|
+
export function normalizeStoredSettings(raw: unknown): SttSettings {
|
|
61
|
+
const o = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : {}
|
|
62
|
+
return {
|
|
63
|
+
preview: pick(PREVIEW_IDS, o.preview, DEFAULT_STT_SETTINGS.preview),
|
|
64
|
+
hq: pick(HQ_IDS, o.hq, DEFAULT_STT_SETTINGS.hq),
|
|
65
|
+
secondOpinion: pick(SECOND_OPINION_IDS, o.secondOpinion, DEFAULT_STT_SETTINGS.secondOpinion),
|
|
66
|
+
resolver: typeof o.resolver === 'boolean' ? o.resolver : DEFAULT_STT_SETTINGS.resolver,
|
|
67
|
+
frontEnd: pick(FRONT_END_IDS, o.frontEnd, DEFAULT_STT_SETTINGS.frontEnd),
|
|
68
|
+
quietGate: pick(QUIET_GATE_IDS, o.quietGate, DEFAULT_STT_SETTINGS.quietGate),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let cache: { mtimeMs: number; size: number; value: SttSettings } | null = null
|
|
73
|
+
let warnedCorrupt = false
|
|
74
|
+
|
|
75
|
+
/** Stored settings (no capability check). Cheap enough for every live chunk. */
|
|
76
|
+
export function readSttSettings(): SttSettings {
|
|
77
|
+
const path = settingsPath()
|
|
78
|
+
let stat: { mtimeMs: number; size: number } | null = null
|
|
79
|
+
try { const s = statSync(path); stat = { mtimeMs: s.mtimeMs, size: s.size } } catch { stat = null }
|
|
80
|
+
if (!stat) { cache = null; return { ...DEFAULT_STT_SETTINGS } }
|
|
81
|
+
if (cache && cache.mtimeMs === stat.mtimeMs && cache.size === stat.size) return { ...cache.value }
|
|
82
|
+
let value: SttSettings
|
|
83
|
+
try { value = normalizeStoredSettings(JSON.parse(readFileSync(path, 'utf8'))) } catch (error) {
|
|
84
|
+
// Unreadable settings run the defaults; say so once, not on every chunk.
|
|
85
|
+
if (!warnedCorrupt) { warnedCorrupt = true; console.warn(`[stt] ${STT_SETTINGS_FILE} is unreadable (${error instanceof Error ? error.message : error}); using the defaults until it is rewritten`) }
|
|
86
|
+
value = { ...DEFAULT_STT_SETTINGS }
|
|
87
|
+
}
|
|
88
|
+
cache = { ...stat, value }
|
|
89
|
+
return { ...value }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function writeSttSettings(next: SttSettings): void {
|
|
93
|
+
atomicWriteFileSync(settingsPath(), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 })
|
|
94
|
+
cache = null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function __resetSttSettingsCacheForTests(): void { cache = null; warnedCorrupt = false }
|
|
98
|
+
|
|
99
|
+
// ── Last HQ job (for GET: what actually happened, not only what was chosen) ──
|
|
100
|
+
|
|
101
|
+
export interface LastSttJob {
|
|
102
|
+
at: string
|
|
103
|
+
meeting: string
|
|
104
|
+
outcome: 'batch' | 'rejected' | 'failed'
|
|
105
|
+
summary: unknown
|
|
106
|
+
}
|
|
107
|
+
const LAST_JOB_FILE = 'stt-last-job.json'
|
|
108
|
+
|
|
109
|
+
export function recordLastSttJob(job: LastSttJob): void {
|
|
110
|
+
try { atomicWriteFileSync(dataPath(LAST_JOB_FILE), JSON.stringify(job, null, 2) + '\n', { mode: 0o600 }) } catch { /* telemetry only */ }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function readLastSttJob(): LastSttJob | null {
|
|
114
|
+
try { return JSON.parse(readFileSync(dataPath(LAST_JOB_FILE), 'utf8')) as LastSttJob } catch { return null }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Capabilities ──────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export interface SttCapabilities {
|
|
120
|
+
turboModel: boolean
|
|
121
|
+
/** The live commit worker runs Large-v3 (the Max tier). */
|
|
122
|
+
liveLargeV3: boolean
|
|
123
|
+
/** whisper-cli plus Large-v3 weights for meeting HQ. */
|
|
124
|
+
hqLargeV3: boolean
|
|
125
|
+
parakeetInstalled: boolean
|
|
126
|
+
parakeetPreviewWarm: boolean
|
|
127
|
+
parakeetPreviewWarming: boolean
|
|
128
|
+
/** The warm-up ran and failed (distinct from still warming). */
|
|
129
|
+
parakeetPreviewWarmFailed?: boolean
|
|
130
|
+
/** Repeated preview misses opened the preview breaker: Turbo until it closes. */
|
|
131
|
+
parakeetPreviewBreakerOpen?: boolean
|
|
132
|
+
previewDisabledByEnv: boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface SttOption {
|
|
136
|
+
id: string
|
|
137
|
+
label: string
|
|
138
|
+
available: boolean
|
|
139
|
+
experimental: boolean
|
|
140
|
+
note: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every preview `model` string this server can return (contract-tested;
|
|
144
|
+
* a subset of what phone 6.10.639 accepts). */
|
|
145
|
+
export const PREVIEW_MODEL_STRINGS = ['large-v3-turbo', 'parakeet-ultra'] as const
|
|
146
|
+
/** The request parameter a client uses to say which preview models it accepts. */
|
|
147
|
+
export const PREVIEW_MODELS_PARAM = 'previewModels'
|
|
148
|
+
export const PREVIEW_REQUIRES_APP = '6.10.639'
|
|
149
|
+
|
|
150
|
+
export function previewOptions(c: SttCapabilities): SttOption[] {
|
|
151
|
+
const parakeetNote = !c.parakeetInstalled
|
|
152
|
+
? 'Install Parakeet on this Mac first.'
|
|
153
|
+
: c.parakeetPreviewBreakerOpen
|
|
154
|
+
? 'Parakeet missed several previews in a row; Whisper Turbo previews for a few minutes, then Parakeet is tried again.'
|
|
155
|
+
: c.parakeetPreviewWarm
|
|
156
|
+
? 'Parakeet Ultra on the Neural Engine, about 0.3 s per chunk. Experimental: compare it live yourself. Needs COS Glasses 6.10.639; older phones keep Whisper Turbo.'
|
|
157
|
+
: c.parakeetPreviewWarmFailed
|
|
158
|
+
? 'Parakeet could not start; Whisper Turbo previews. It is tried again every 2 minutes.'
|
|
159
|
+
: c.parakeetPreviewWarming
|
|
160
|
+
? 'Warming up (the first load takes 15 to 25 s). Whisper Turbo previews until it is ready.'
|
|
161
|
+
: 'Warms up when selected and no recording is live. Whisper Turbo previews until it is ready.'
|
|
162
|
+
return [
|
|
163
|
+
{ id: 'turbo', label: 'Whisper Turbo', available: c.turboModel && !c.previewDisabledByEnv, experimental: false,
|
|
164
|
+
note: c.previewDisabledByEnv ? 'Meeting preview is turned off on this server (COS_WHISPER_MEETING_PREVIEW=0).' : c.turboModel ? 'Fast provisional text. Saved text never comes from the preview.' : 'Turbo weights are missing on this Mac.' },
|
|
165
|
+
{ id: 'large-v3', label: 'Whisper Large-v3', available: false, experimental: false,
|
|
166
|
+
note: 'Not offered: a Large-v3 preview would share the live Large-v3 worker that writes the saved text.' },
|
|
167
|
+
{ id: 'parakeet', label: 'Parakeet Ultra', available: c.parakeetInstalled && !c.previewDisabledByEnv, experimental: true, note: parakeetNote },
|
|
168
|
+
{ id: 'nemotron-560', label: 'Nemotron streaming 560 ms', available: false, experimental: true,
|
|
169
|
+
note: 'Not wired in this server: FluidAudio 0.17.4 runs Nemotron only as a whole-file batch, so it cannot stream a live preview yet.' },
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function hqOptions(c: SttCapabilities): SttOption[] {
|
|
174
|
+
const lv3 = c.hqLargeV3 ? '' : 'Needs whisper-cli and the Large-v3 weights. '
|
|
175
|
+
return [
|
|
176
|
+
{ id: 'large-v3', label: 'Whisper Large-v3', available: c.hqLargeV3, experimental: false, note: `${lv3}Word timestamps on, mapped through Whisper's own speech segments.`.trim() },
|
|
177
|
+
{ id: 'large-v3-legacy', label: 'Whisper Large-v3 (legacy decoder)', available: c.hqLargeV3, experimental: false, note: `${lv3}The pre-6.72 decoder without timestamps. Kept for rollback.`.trim() },
|
|
178
|
+
{ id: 'parakeet-ultra', label: 'Parakeet Ultra', available: c.parakeetInstalled, experimental: true,
|
|
179
|
+
note: c.parakeetInstalled ? 'Lower silver WER than Large-v3, weaker on names; see the evaluation.' : 'Install Parakeet on this Mac first.' },
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function secondOpinionOptions(c: SttCapabilities): SttOption[] {
|
|
184
|
+
return [
|
|
185
|
+
{ id: 'off', label: 'Off', available: true, experimental: false, note: 'One engine only.' },
|
|
186
|
+
{ id: 'parakeet-ultra', label: 'Parakeet Ultra', available: c.parakeetInstalled, experimental: false,
|
|
187
|
+
note: c.parakeetInstalled ? 'Hears every saved meeting again on the Neural Engine, about 0.6 s per 30 s of audio, after the meeting is saved. Disagreements are saved with the meeting.' : 'Install Parakeet on this Mac first.' },
|
|
188
|
+
{ id: 'large-v3', label: 'Whisper Large-v3', available: c.hqLargeV3, experimental: false, note: 'For a Parakeet HQ model. Adds a full Large-v3 pass to every saved meeting: about 0.2x the audio length on Metal, about 1x on a busy CPU.' },
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const FRONT_END_OPTIONS: SttOption[] = [
|
|
193
|
+
{ id: 'highpass', label: 'Highpass only', available: true, experimental: false, note: '80 Hz highpass. Does not lift faint background speech over the speech detector.' },
|
|
194
|
+
{ id: 'full', label: 'Full chain', available: true, experimental: false, note: 'Highpass, noise reduction and loudness normalization (before 6.72).' },
|
|
195
|
+
]
|
|
196
|
+
const QUIET_GATE_OPTIONS: SttOption[] = [
|
|
197
|
+
{ id: 'silero', label: 'Speech detector', available: true, experimental: false, note: 'A chunk with under 0.5 s of detected speech is treated as quiet.' },
|
|
198
|
+
{ id: 'rms', label: 'Volume threshold', available: true, experimental: false, note: 'RMS below 150 (before 6.72). Rarely fires on G2 audio.' },
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
/** The engine family a choice decodes with; a second opinion must differ from HQ. */
|
|
202
|
+
export function engineFamily(id: HqId | SecondOpinionId): 'whisper' | 'parakeet' | 'off' {
|
|
203
|
+
return id === 'off' ? 'off' : id.startsWith('parakeet') ? 'parakeet' : 'whisper'
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export class SttSettingsError extends Error {
|
|
207
|
+
constructor(message: string) { super(message); this.name = 'SttSettingsError' }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const KEYS = ['preview', 'hq', 'secondOpinion', 'resolver', 'frontEnd', 'quietGate'] as const
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Apply a partial PUT. Throws SttSettingsError with a plain sentence for an
|
|
214
|
+
* unknown key, an unknown id, an unavailable choice or a conflict.
|
|
215
|
+
* Changing HQ onto the second opinion's engine moves the second opinion to the
|
|
216
|
+
* other engine (unless the same request sets it), so one tap never fails.
|
|
217
|
+
*/
|
|
218
|
+
export function applySettingsPatch(current: SttSettings, body: unknown, c: SttCapabilities): SttSettings {
|
|
219
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new SttSettingsError('Send a JSON object, for example {"hq":"large-v3"}.')
|
|
220
|
+
const patch = body as Record<string, unknown>
|
|
221
|
+
const unknown = Object.keys(patch).filter(k => !(KEYS as readonly string[]).includes(k))
|
|
222
|
+
if (unknown.length) throw new SttSettingsError(`Unknown setting: ${unknown.join(', ')}.`)
|
|
223
|
+
const next = { ...current }
|
|
224
|
+
const choose = <T extends string>(key: string, ids: readonly T[], options: SttOption[], label: string): T | undefined => {
|
|
225
|
+
if (!(key in patch)) return undefined
|
|
226
|
+
const value = patch[key]
|
|
227
|
+
if (typeof value !== 'string' || !(ids as readonly string[]).includes(value)) {
|
|
228
|
+
throw new SttSettingsError(`${label} must be one of: ${ids.join(', ')}.`)
|
|
229
|
+
}
|
|
230
|
+
const option = options.find(o => o.id === value)
|
|
231
|
+
if (option && !option.available) throw new SttSettingsError(`${option.label} is not available: ${option.note}`)
|
|
232
|
+
return value as T
|
|
233
|
+
}
|
|
234
|
+
const preview = choose('preview', PREVIEW_IDS, previewOptions(c), 'Preview model')
|
|
235
|
+
const hq = choose('hq', HQ_IDS, hqOptions(c), 'HQ model')
|
|
236
|
+
const second = choose('secondOpinion', SECOND_OPINION_IDS, secondOpinionOptions(c), 'Second opinion')
|
|
237
|
+
const frontEnd = choose('frontEnd', FRONT_END_IDS, FRONT_END_OPTIONS, 'Front end')
|
|
238
|
+
const quietGate = choose('quietGate', QUIET_GATE_IDS, QUIET_GATE_OPTIONS, 'Quiet gate')
|
|
239
|
+
if ('resolver' in patch && typeof patch.resolver !== 'boolean') throw new SttSettingsError('resolver must be true or false.')
|
|
240
|
+
if (preview) next.preview = preview
|
|
241
|
+
if (hq) next.hq = hq
|
|
242
|
+
if (second) next.secondOpinion = second
|
|
243
|
+
if (frontEnd) next.frontEnd = frontEnd
|
|
244
|
+
if (quietGate) next.quietGate = quietGate
|
|
245
|
+
if (typeof patch.resolver === 'boolean') next.resolver = patch.resolver
|
|
246
|
+
if (engineFamily(next.hq) === engineFamily(next.secondOpinion)) {
|
|
247
|
+
if (second) throw new SttSettingsError('The second opinion must be a different engine from the HQ model.')
|
|
248
|
+
const other: SecondOpinionId = engineFamily(next.hq) === 'parakeet' ? 'large-v3' : 'parakeet-ultra'
|
|
249
|
+
const otherOk = secondOpinionOptions(c).find(o => o.id === other)?.available
|
|
250
|
+
next.secondOpinion = otherOk ? other : 'off'
|
|
251
|
+
}
|
|
252
|
+
return next
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Effective choices ─────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
export interface EffectiveStt {
|
|
258
|
+
settings: SttSettings
|
|
259
|
+
preview: { engine: 'turbo' | 'large-v3' | 'parakeet'; reason: string | null }
|
|
260
|
+
hq: { engine: HqId; reason: string | null }
|
|
261
|
+
secondOpinion: { engine: SecondOpinionId; reason: string | null }
|
|
262
|
+
resolver: boolean
|
|
263
|
+
frontEnd: FrontEndId
|
|
264
|
+
quietGate: QuietGateId
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export const REASON_TEXT: Record<string, string> = {
|
|
268
|
+
parakeet_not_installed: 'Parakeet is not installed on this Mac.',
|
|
269
|
+
parakeet_warming: 'Parakeet is warming up.',
|
|
270
|
+
parakeet_warm_failed: 'Parakeet could not start; using Turbo.',
|
|
271
|
+
parakeet_breaker_open: 'Parakeet missed several previews; using Turbo for a few minutes.',
|
|
272
|
+
large_v3_preview_not_offered: 'A Large-v3 preview is not offered; using Turbo.',
|
|
273
|
+
nemotron_not_wired: 'Nemotron preview is not available in this server; using Turbo.',
|
|
274
|
+
large_v3_unavailable: 'Large-v3 is not available on this Mac.',
|
|
275
|
+
same_engine_as_hq: 'Same engine as the HQ model.',
|
|
276
|
+
no_second_opinion: 'No second opinion is running, so nothing to refine with.',
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** What will actually run, given what is installed. A stored choice that is no
|
|
280
|
+
* longer possible falls back (preview to Turbo, HQ to Large-v3, second opinion
|
|
281
|
+
* to off) and says why with a code. */
|
|
282
|
+
export function effectiveStt(settings: SttSettings, c: SttCapabilities): EffectiveStt {
|
|
283
|
+
let preview: EffectiveStt['preview'] = { engine: 'turbo', reason: null }
|
|
284
|
+
if (settings.preview === 'large-v3') preview = { engine: 'turbo', reason: 'large_v3_preview_not_offered' }
|
|
285
|
+
else if (settings.preview === 'parakeet') {
|
|
286
|
+
preview = !c.parakeetInstalled ? { engine: 'turbo', reason: 'parakeet_not_installed' }
|
|
287
|
+
: c.parakeetPreviewBreakerOpen ? { engine: 'turbo', reason: 'parakeet_breaker_open' }
|
|
288
|
+
: c.parakeetPreviewWarm ? { engine: 'parakeet', reason: null }
|
|
289
|
+
: c.parakeetPreviewWarmFailed ? { engine: 'turbo', reason: 'parakeet_warm_failed' }
|
|
290
|
+
: { engine: 'turbo', reason: 'parakeet_warming' }
|
|
291
|
+
} else if (settings.preview === 'nemotron-560') preview = { engine: 'turbo', reason: 'nemotron_not_wired' }
|
|
292
|
+
let hq: EffectiveStt['hq'] = { engine: settings.hq, reason: null }
|
|
293
|
+
if (settings.hq === 'parakeet-ultra' && !c.parakeetInstalled) hq = { engine: 'large-v3', reason: 'parakeet_not_installed' }
|
|
294
|
+
let second: EffectiveStt['secondOpinion'] = { engine: settings.secondOpinion, reason: null }
|
|
295
|
+
if (engineFamily(second.engine) === 'parakeet' && !c.parakeetInstalled) second = { engine: 'off', reason: 'parakeet_not_installed' }
|
|
296
|
+
if (second.engine === 'large-v3' && !c.hqLargeV3) second = { engine: 'off', reason: 'large_v3_unavailable' }
|
|
297
|
+
if (second.engine !== 'off' && engineFamily(second.engine) === engineFamily(hq.engine)) second = { engine: 'off', reason: 'same_engine_as_hq' }
|
|
298
|
+
return {
|
|
299
|
+
settings,
|
|
300
|
+
preview,
|
|
301
|
+
hq,
|
|
302
|
+
secondOpinion: second,
|
|
303
|
+
resolver: settings.resolver && second.engine !== 'off',
|
|
304
|
+
frontEnd: settings.frontEnd,
|
|
305
|
+
quietGate: settings.quietGate,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export interface ParakeetStatus {
|
|
310
|
+
installed: boolean
|
|
311
|
+
installable: boolean
|
|
312
|
+
version: string
|
|
313
|
+
bytes: number
|
|
314
|
+
progress: { state: string; bytesDone: number; bytesTotal: number; error: string | null; source?: unknown }
|
|
315
|
+
warm: { preview: string; error?: string; at?: string }
|
|
316
|
+
preview?: { breaker: { open: boolean; retryAt: string | null; misses: number }; counters: Record<string, number> }
|
|
317
|
+
note: string
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function slotReason(code: string | null): { reason: string | null; reasonCode: string | null } {
|
|
321
|
+
return { reason: code ? (REASON_TEXT[code] ?? code) : null, reasonCode: code }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function settingsView(settings: SttSettings, c: SttCapabilities, parakeet: ParakeetStatus, lastJob: LastSttJob | null = null) {
|
|
325
|
+
const eff = effectiveStt(settings, c)
|
|
326
|
+
const resolverCode = settings.resolver && !eff.resolver ? 'no_second_opinion' : null
|
|
327
|
+
return {
|
|
328
|
+
preview: {
|
|
329
|
+
selected: settings.preview, effective: eff.preview.engine, ...slotReason(eff.preview.reason), options: previewOptions(c),
|
|
330
|
+
models: [...PREVIEW_MODEL_STRINGS], clientParam: PREVIEW_MODELS_PARAM, requiresApp: PREVIEW_REQUIRES_APP,
|
|
331
|
+
note: `Only phones that list a preview model in ${PREVIEW_MODELS_PARAM} get it (COS Glasses ${PREVIEW_REQUIRES_APP} and later); every other client gets Whisper Turbo.`,
|
|
332
|
+
},
|
|
333
|
+
hq: { selected: settings.hq, effective: eff.hq.engine, ...slotReason(eff.hq.reason), options: hqOptions(c) },
|
|
334
|
+
secondOpinion: { selected: settings.secondOpinion, effective: eff.secondOpinion.engine, ...slotReason(eff.secondOpinion.reason), options: secondOpinionOptions(c) },
|
|
335
|
+
resolver: { enabled: settings.resolver, effective: eff.resolver, ...slotReason(resolverCode) },
|
|
336
|
+
frontEnd: { selected: settings.frontEnd, options: FRONT_END_OPTIONS },
|
|
337
|
+
quietGate: { selected: settings.quietGate, options: QUIET_GATE_OPTIONS },
|
|
338
|
+
parakeet,
|
|
339
|
+
lastJob,
|
|
340
|
+
appliesTo: { preview: 'next live chunk', hq: 'next saved meeting' },
|
|
341
|
+
}
|
|
342
|
+
}
|
package/server/lib/vad-silero.ts
CHANGED
|
@@ -27,6 +27,12 @@ const MIN_SAMPLES = 512
|
|
|
27
27
|
let sherpaOnnx: any = null
|
|
28
28
|
let available = false
|
|
29
29
|
let vadInstance: any = null
|
|
30
|
+
// 6.72.0 quiet gate: its own detector, stricter than the trimming one. Chosen on
|
|
31
|
+
// bake-off canary 5 (docs/stt-672-evaluation.md): probability 0.8 with a 0.25 s
|
|
32
|
+
// minimum, and quiet below 0.5 s of speech, fired on 80.8% of Silero-negative
|
|
33
|
+
// chunks and 0.91% of speech chunks.
|
|
34
|
+
export const QUIET_GATE_VAD = { threshold: 0.8, minSilenceDuration: 0.3, minSpeechDuration: 0.25 } as const
|
|
35
|
+
let gateVad: any = null
|
|
30
36
|
|
|
31
37
|
export interface TrimResult {
|
|
32
38
|
trimmedWav: Buffer
|
|
@@ -60,6 +66,11 @@ export function initSileroVAD(): boolean {
|
|
|
60
66
|
10, // bufferSizeInSeconds — chunks are max 6s
|
|
61
67
|
)
|
|
62
68
|
vadInstance.reset() // verify it works
|
|
69
|
+
gateVad = new sherpaOnnx.Vad(
|
|
70
|
+
{ sileroVad: { model: MODEL_PATH, ...QUIET_GATE_VAD, windowSize: 512 }, sampleRate: SAMPLE_RATE, numThreads: 1, provider: 'cpu' },
|
|
71
|
+
10,
|
|
72
|
+
)
|
|
73
|
+
gateVad.reset()
|
|
63
74
|
available = true
|
|
64
75
|
console.log('[silero-vad] Initialized: Silero VAD active (reused instance, bufferSize=10s)')
|
|
65
76
|
return true
|
|
@@ -182,3 +193,44 @@ export function trimSilence(wavBuffer: Buffer): TrimResult {
|
|
|
182
193
|
return fallback
|
|
183
194
|
}
|
|
184
195
|
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Seconds of speech the quiet-gate Silero (QUIET_GATE_VAD) hears in a WAV (6.72.0).
|
|
199
|
+
*
|
|
200
|
+
* Feeds the detector one 512-sample window at a time and drains finished
|
|
201
|
+
* segments after each window. Handing sherpa-onnx the whole chunk in one
|
|
202
|
+
* acceptWaveform call (as trimSilence does) keeps only the last segment:
|
|
203
|
+
* measured 2026-10-13, a 6 s chunk of continuous speech read 0.32 s that way
|
|
204
|
+
* and 5.03 s windowed. Null when the detector is unavailable or the audio is
|
|
205
|
+
* too short to measure: a caller must never read null as silence.
|
|
206
|
+
*/
|
|
207
|
+
export function measureSpeechSeconds(wavBuffer: Buffer): number | null {
|
|
208
|
+
if (!available || !gateVad) return null
|
|
209
|
+
try {
|
|
210
|
+
if (wavBuffer.length <= WAV_HEADER_SIZE + MIN_SAMPLES * BYTES_PER_SAMPLE) return null
|
|
211
|
+
const pcmBytes = wavBuffer.buffer.slice(wavBuffer.byteOffset + WAV_HEADER_SIZE, wavBuffer.byteOffset + wavBuffer.length - ((wavBuffer.length - WAV_HEADER_SIZE) % 2))
|
|
212
|
+
const int16 = new Int16Array(pcmBytes)
|
|
213
|
+
if (int16.length < MIN_SAMPLES) return null
|
|
214
|
+
const float32 = new Float32Array(int16.length)
|
|
215
|
+
for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 32768
|
|
216
|
+
gateVad.reset()
|
|
217
|
+
let speechSamples = 0
|
|
218
|
+
const drain = () => {
|
|
219
|
+
while (!gateVad.isEmpty()) {
|
|
220
|
+
speechSamples += gateVad.front().samples.length
|
|
221
|
+
gateVad.pop()
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (let offset = 0; offset + MIN_SAMPLES <= float32.length; offset += MIN_SAMPLES) {
|
|
225
|
+
gateVad.acceptWaveform(float32.subarray(offset, offset + MIN_SAMPLES))
|
|
226
|
+
drain()
|
|
227
|
+
}
|
|
228
|
+
gateVad.flush()
|
|
229
|
+
drain()
|
|
230
|
+
gateVad.reset()
|
|
231
|
+
return speechSamples / SAMPLE_RATE
|
|
232
|
+
} catch (err: any) {
|
|
233
|
+
console.error('[silero-vad] measureSpeechSeconds error:', err.message)
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import type { WhisperWord } from './whisper-local.js'
|
|
2
|
+
import { readSttSettings } from './stt-settings.js'
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
+
/** Meeting batch HQ timestamps (6.72.0 default: on). The HQ setting decides:
|
|
5
|
+
* "large-v3" decodes with timestamps and remaps them through Whisper's own VAD
|
|
6
|
+
* table; "large-v3-legacy" keeps the pre-6.72 `--no-timestamps` decoder.
|
|
7
|
+
* COS_BATCH_HQ_TIMESTAMPS still overrides: "1" forces timestamps on, "0" off.
|
|
4
8
|
* Live HTTP keeps compact JSON; interactive HQ and DTW are unchanged. */
|
|
5
|
-
export function
|
|
6
|
-
|
|
9
|
+
export function batchTimestampsEnabled(hq?: string): boolean {
|
|
10
|
+
const flag = process.env.COS_BATCH_HQ_TIMESTAMPS
|
|
11
|
+
if (flag === '1') return true
|
|
12
|
+
if (flag === '0') return false
|
|
13
|
+
return (hq ?? readSttSettings().hq) !== 'large-v3-legacy'
|
|
7
14
|
}
|
|
8
15
|
|
|
16
|
+
/** Pre-6.72 name, kept for callers and tests. */
|
|
17
|
+
export const batchTimestampCanaryEnabled = (): boolean => batchTimestampsEnabled()
|
|
18
|
+
|
|
9
19
|
export interface VadTimeSegment {
|
|
10
20
|
originalStart: number
|
|
11
21
|
originalEnd: number
|
|
@@ -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} `
|