@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.
@@ -6,14 +6,23 @@ import { createHash } from 'node:crypto'
6
6
  import { existsSync, readFileSync, readdirSync, utimesSync, writeFileSync } from 'node:fs'
7
7
  import { availableParallelism } from 'node:os'
8
8
  import { basename, join, resolve } from 'node:path'
9
- import { enhanceAudio } from './audio-enhance.js'
10
9
  import {
10
+ applyCorrections,
11
11
  getHighQualityCheckpointFingerprint,
12
12
  getHighQualityTranscriptionCapability,
13
13
  getWhisperCommitCapability,
14
14
  transcribeHighQuality,
15
+ type HighQualityTranscriptionResult,
15
16
  type WhisperWord,
16
17
  } from './whisper-local.js'
18
+ import { asrFrontEnd } from './stt-live.js'
19
+ import { measureSpeechSeconds } from './vad-silero.js'
20
+ import { batchTimestampsEnabled } from './whisper-batch-timing.js'
21
+ import { readSttSettings, recordLastSttJob, type FrontEndId, type HqId, type SecondOpinionId } from './stt-settings.js'
22
+ import { currentEffectiveStt } from './stt-runtime.js'
23
+ import { transcribeParakeet, type ParakeetResult, type ParakeetRunOptions } from './parakeet.js'
24
+ import { isDeletionProneEngine, resolveHypotheses, RESOLVER_VERSION, type Hypothesis } from './stt-resolver.js'
25
+ import { getVocabulary } from './profile.js'
17
26
  import { durableAtomicWriteFileSync } from './atomic-fs.js'
18
27
  import { isMetalBatchPreempted } from './whisper-metal-gate.js'
19
28
  import {
@@ -27,7 +36,9 @@ import {
27
36
  evaluateBatchQuality,
28
37
  type BatchResult,
29
38
  type BatchSegment,
39
+ type BatchSttSummary,
30
40
  type BatchTranscription,
41
+ type SegmentStt,
31
42
  } from './batch-transcript-quality.js'
32
43
 
33
44
  const WAV_HEADER_SIZE = 44
@@ -223,15 +234,22 @@ function segmentSourceHash(audioDir: string, segment: BatchSegment): string | nu
223
234
  return hash.digest('hex')
224
235
  }
225
236
 
237
+ /** The decoder identity a checkpoint was made under: timestamps and front end (6.72.0). */
238
+ export interface CheckpointIdentity { timestamps: boolean; frontEnd: FrontEndId }
239
+ function currentCheckpointIdentity(): CheckpointIdentity {
240
+ return { timestamps: batchTimestampsEnabled(), frontEnd: readSttSettings().frontEnd }
241
+ }
242
+
226
243
  function progressiveFailureIdentity(
227
244
  audioDir: string,
228
245
  segment: BatchSegment,
229
246
  previousText?: string,
247
+ identity: CheckpointIdentity = currentCheckpointIdentity(),
230
248
  ): string | null {
231
249
  const sourceHash = segmentSourceHash(audioDir, segment)
232
250
  if (!sourceHash) return null
233
251
  return createHash('sha256').update([
234
- checkpointKey(segment), sourceHash, contextHash(previousText), getHighQualityCheckpointFingerprint(),
252
+ checkpointKey(segment), sourceHash, contextHash(previousText), getHighQualityCheckpointFingerprint(identity),
235
253
  ].join('\0')).digest('hex')
236
254
  }
237
255
 
@@ -250,8 +268,9 @@ function readProgressiveCheckpoint(
250
268
  segment: BatchSegment,
251
269
  previousText?: string,
252
270
  expectedSessionId?: string,
271
+ identity: CheckpointIdentity = currentCheckpointIdentity(),
253
272
  ): BatchResult | null {
254
- const cached = readProgressiveCheckpointMetadata(audioDir, segment, previousText, undefined, expectedSessionId)
273
+ const cached = readProgressiveCheckpointMetadata(audioDir, segment, previousText, undefined, expectedSessionId, identity)
255
274
  if (!cached) return null
256
275
  const manifest = loadProgressiveManifest(audioDir)
257
276
  const checkpoint = manifest?.checkpoints?.[checkpointKey(segment)]
@@ -270,13 +289,14 @@ function readProgressiveCheckpointMetadata(
270
289
  previousText?: string,
271
290
  manifest = loadProgressiveManifest(audioDir),
272
291
  expectedSessionId?: string,
292
+ identity: CheckpointIdentity = currentCheckpointIdentity(),
273
293
  ): BatchResult | null {
274
294
  if (expectedSessionId && manifest?.sessionId !== expectedSessionId) return null
275
295
  const checkpoint = manifest?.checkpoints?.[checkpointKey(segment)]
276
296
  if (
277
297
  !checkpoint
278
298
  || checkpoint.contextHash !== contextHash(previousText)
279
- || checkpoint.configFingerprint !== getHighQualityCheckpointFingerprint()
299
+ || checkpoint.configFingerprint !== getHighQualityCheckpointFingerprint(identity)
280
300
  ) return null
281
301
  const cached = checkpoint.result
282
302
  const validWord = (word: unknown, speakerRequired: boolean): boolean => {
@@ -310,7 +330,12 @@ function readProgressiveCheckpointMetadata(
310
330
  || !Array.isArray(cached.speakerWords)
311
331
  || cached.speakerWords.some(word => !validWord(word, true))
312
332
  ) return null
313
- return cached
333
+ // Optional 6.72.0 fields: dropped (never trusted) when malformed, so reuse falls back to record-only.
334
+ const spokenOk = Array.isArray(cached.spokenWords) && cached.spokenWords.every(word => validWord(word, false))
335
+ const spansOk = Array.isArray(cached.speechSpans) && cached.speechSpans.every(span => Array.isArray(span) && span.length === 2
336
+ && span.every(x => typeof x === 'number' && Number.isFinite(x) && x >= 0) && span[1] >= span[0])
337
+ const { spokenWords: _s, speechSpans: _p, ...rest } = cached
338
+ return { ...rest, ...(spokenOk ? { spokenWords: cached.spokenWords } : {}), ...(spansOk ? { speechSpans: cached.speechSpans } : {}) }
314
339
  }
315
340
 
316
341
  function writeProgressiveCheckpoint(
@@ -392,14 +417,337 @@ export function readProgressiveAskRefinements(
392
417
  return results
393
418
  }
394
419
 
420
+ /** The STT plan one HQ job runs with, frozen when the job starts (6.72.0). A
421
+ * settings change applies to the next saved meeting, never mid-job. */
422
+ export interface HqJobPlan {
423
+ engine: HqId
424
+ secondOpinion: SecondOpinionId
425
+ resolver: boolean
426
+ frontEnd: FrontEndId
427
+ }
428
+
429
+ export function currentHqJobPlan(): HqJobPlan {
430
+ const eff = currentEffectiveStt()
431
+ return { engine: eff.hq.engine, secondOpinion: eff.secondOpinion.engine, resolver: eff.resolver, frontEnd: eff.frontEnd }
432
+ }
433
+
434
+ /** Legacy pre-6.72 behaviour, for callers and tests that pass no plan. */
435
+ export const LEGACY_HQ_PLAN: Readonly<HqJobPlan> = Object.freeze({ engine: 'large-v3-legacy', secondOpinion: 'off', resolver: false, frontEnd: 'full' })
436
+
437
+ export interface HqDecodeDeps {
438
+ whisper: typeof transcribeHighQuality
439
+ parakeet: (wav: Buffer, options: ParakeetRunOptions) => Promise<ParakeetResult>
440
+ frontEnd: (audio: Buffer, frontEnd: FrontEndId) => Promise<Buffer>
441
+ vocabulary: () => string[]
442
+ /** Silero speech seconds of a window (null when unmeasured); judges an empty Parakeet primary. */
443
+ speechSeconds?: (wav: Buffer) => number | null
444
+ }
445
+
446
+ // Arrow wrappers, not references: a test that mocks one of these modules
447
+ // without every export must still be able to import this one.
448
+ const defaultHqDeps: HqDecodeDeps = {
449
+ whisper: (...args) => transcribeHighQuality(...args),
450
+ parakeet: (wav, options) => transcribeParakeet(wav, options),
451
+ frontEnd: (audio, frontEnd) => asrFrontEnd(audio, frontEnd),
452
+ vocabulary: () => getVocabulary(),
453
+ speechSeconds: wav => measureSpeechSeconds(wav),
454
+ }
455
+
456
+ /** A first Parakeet run in a process may compile CoreML (12 to 25 s); later runs are well under a second. */
457
+ export const PARAKEET_FIRST_WINDOW_TIMEOUT_MS = 180_000
458
+ export const PARAKEET_WINDOW_TIMEOUT_MS = 30_000
459
+
460
+ interface WholeDecode {
461
+ engine: 'large-v3' | 'large-v3-legacy' | 'parakeet-ultra'
462
+ /** Name-corrected text. */
463
+ text: string
464
+ /** What BatchResult.words carries (Whisper tokens, or Parakeet words), name-corrected. */
465
+ words: WhisperWord[]
466
+ /** Uncorrected spoken words for the resolver; absent when the clocks are not trustworthy. */
467
+ spoken?: WhisperWord[]
468
+ /** Where the primary's own VAD heard speech (window clock); gap fill needs it. */
469
+ speechSpans?: Array<[number, number]>
470
+ timestamps: boolean
471
+ }
472
+
473
+ function errorReason(error: unknown): string {
474
+ return error instanceof Error ? `${error.name}: ${error.message}`.slice(0, 200) : String(error).slice(0, 200)
475
+ }
476
+
477
+ /** Parakeet failures in a row before the job stops calling it (each can cost its deadline). */
478
+ export const PARAKEET_JOB_BREAKER_FAILURES = 2
479
+
480
+ interface JobState {
481
+ parakeetRan: boolean
482
+ parakeetFailures: number
483
+ parakeetStopped: boolean
484
+ }
485
+
486
+ async function decodeWhisperOnce(
487
+ deps: HqDecodeDeps,
488
+ input: Buffer,
489
+ previousText: string | undefined,
490
+ timestamps: boolean,
491
+ segment: BatchSegment,
492
+ audioDir: string,
493
+ ): Promise<HighQualityTranscriptionResult> {
494
+ try {
495
+ return await deps.whisper(input, previousText?.slice(-250), { priority: 'batch', timestamps })
496
+ } catch (error) {
497
+ // A live meeting took the GPU mid-segment. The truncated Metal output
498
+ // was already discarded upstream (BLOCKER contract) — it is never
499
+ // accepted. Retry this SAME segment once on CPU, which cannot itself be
500
+ // preempted, so a busy day degrades to slow rather than to a silently
501
+ // missing stretch of transcript.
502
+ if (!isMetalBatchPreempted(error)) throw error
503
+ console.log(
504
+ `[meeting-batch] Segment ${segment.startChunkIdx}-${segment.endChunkIdx} preempted off Metal; `
505
+ + 'retrying once on CPU',
506
+ )
507
+ refreshPendingLease(audioDir)
508
+ return deps.whisper(input, previousText?.slice(-250), { priority: 'batch', forceCpu: true, forceCpuReason: 'preempt_retry', timestamps })
509
+ }
510
+ }
511
+
512
+ function asWholeWhisper(result: HighQualityTranscriptionResult, engine: 'large-v3' | 'large-v3-legacy', timestamps: boolean): WholeDecode {
513
+ const ran = result.timestamps ?? timestamps
514
+ return {
515
+ engine,
516
+ text: result.text,
517
+ words: result.words ?? [],
518
+ ...(ran && result.spokenWords ? { spoken: result.spokenWords } : {}),
519
+ ...(ran && result.speechSpans ? { speechSpans: result.speechSpans } : {}),
520
+ timestamps: ran,
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Whisper for one window. A timestamp decode that fails (a VAD-table or clock
526
+ * check, or anything else) is retried once with the legacy decoder, so a
527
+ * timing problem never costs the window's text.
528
+ */
529
+ async function decodeWhisperWindow(
530
+ deps: HqDecodeDeps,
531
+ input: Buffer,
532
+ previousText: string | undefined,
533
+ engine: 'large-v3' | 'large-v3-legacy',
534
+ segment: BatchSegment,
535
+ audioDir: string,
536
+ ): Promise<{ decode: WholeDecode; fallbackReason?: string }> {
537
+ const timestamps = batchTimestampsEnabled(engine)
538
+ try {
539
+ return { decode: asWholeWhisper(await decodeWhisperOnce(deps, input, previousText, timestamps, segment, audioDir), engine, timestamps) }
540
+ } catch (error) {
541
+ if (!timestamps) throw error
542
+ console.warn(`[meeting-batch] Timestamp decode failed for ${segment.startChunkIdx}-${segment.endChunkIdx}; retrying with the legacy decoder: ${errorReason(error)}`)
543
+ const legacy = await decodeWhisperOnce(deps, input, previousText, false, segment, audioDir)
544
+ return { decode: asWholeWhisper(legacy, 'large-v3-legacy', false), fallbackReason: `timestamps_failed: ${errorReason(error)}` }
545
+ }
546
+ }
547
+
548
+ /** A Parakeet window with no words over audio the speech detector says holds speech is a failed decode. */
549
+ export const PARAKEET_EMPTY_SPEECH_SEC = 0.5
550
+
551
+ async function decodeParakeetWindow(
552
+ deps: HqDecodeDeps,
553
+ input: Buffer,
554
+ timeoutMs: number,
555
+ model: 'parakeet-ultra',
556
+ requireWordsOverSpeech: boolean,
557
+ ): Promise<WholeDecode> {
558
+ const r = await deps.parakeet(input, { model, lane: 'batch', timeoutMs })
559
+ if (requireWordsOverSpeech && !r.text.trim()) {
560
+ const speech = deps.speechSeconds?.(input) ?? null
561
+ if (speech !== null && speech >= PARAKEET_EMPTY_SPEECH_SEC) {
562
+ throw Object.assign(new Error(`Parakeet returned no words over ${speech.toFixed(1)} s of speech`), { reason: 'bad_output' })
563
+ }
564
+ }
565
+ const spoken = r.words.map(w => ({ word: w.word, start: w.start, end: w.end, probability: w.probability ?? 0 }))
566
+ return {
567
+ engine: model,
568
+ text: applyCorrections(r.text),
569
+ words: spoken.map(w => ({ ...w, word: applyCorrections(w.word) })),
570
+ spoken,
571
+ timestamps: true,
572
+ }
573
+ }
574
+
575
+ const isParakeet = (engine: string): boolean => engine.startsWith('parakeet')
576
+
577
+ function asHypothesis(d: WholeDecode): Hypothesis {
578
+ return { engine: d.engine, text: d.text, ...(d.spoken ? { words: d.spoken } : {}) }
579
+ }
580
+
581
+ function hypothesisRecord(d: WholeDecode): SegmentStt['primary'] {
582
+ return { engine: d.engine, text: d.text, ...(d.spoken ? { words: d.spoken.map(w => ({ word: w.word, start: w.start, end: w.end, probability: w.probability })) } : {}) }
583
+ }
584
+
585
+ function parakeetTimeout(job: JobState): number {
586
+ const t = job.parakeetRan ? PARAKEET_WINDOW_TIMEOUT_MS : PARAKEET_FIRST_WINDOW_TIMEOUT_MS
587
+ job.parakeetRan = true
588
+ return t
589
+ }
590
+
591
+ /** Run Parakeet under the job breaker: after PARAKEET_JOB_BREAKER_FAILURES in a row it is not called again this job. */
592
+ async function parakeetGuarded(job: JobState, run: () => Promise<WholeDecode>): Promise<WholeDecode> {
593
+ if (job.parakeetStopped) throw Object.assign(new Error('Parakeet stopped for this job after repeated failures'), { reason: 'job_breaker' })
594
+ try {
595
+ const d = await run()
596
+ job.parakeetFailures = 0
597
+ return d
598
+ } catch (error) {
599
+ job.parakeetFailures++
600
+ if (job.parakeetFailures >= PARAKEET_JOB_BREAKER_FAILURES) {
601
+ job.parakeetStopped = true
602
+ console.warn(`[meeting-batch] Parakeet failed ${job.parakeetFailures} windows in a row; not calling it again this job`)
603
+ }
604
+ throw error
605
+ }
606
+ }
607
+
608
+ /**
609
+ * One HQ window under the job's plan: the primary engine (or a reused
610
+ * progressive checkpoint), then (if planned) the second opinion on the SAME
611
+ * front-end audio, then the resolver. No failure costs the window its text:
612
+ * Parakeet primary failing falls back to Large-v3; Whisper failing outright
613
+ * falls back to a planned Parakeet second opinion as the primary; a failed
614
+ * second opinion leaves the primary exactly as heard.
615
+ */
616
+ async function decodeHqWindow(
617
+ input: Buffer,
618
+ previousText: string | undefined,
619
+ segment: BatchSegment,
620
+ entries: IndexedTranscriptChunk[],
621
+ plan: HqJobPlan,
622
+ deps: HqDecodeDeps,
623
+ job: JobState,
624
+ audioDir: string,
625
+ reused?: BatchResult,
626
+ ): Promise<BatchResult> {
627
+ let primary: WholeDecode
628
+ let fallbackReason: string | undefined
629
+ let usedSecondAsPrimary = false
630
+ const secondModel = plan.secondOpinion === 'parakeet-ultra' ? plan.secondOpinion : null
631
+ if (reused) {
632
+ const ts = batchTimestampsEnabled(plan.engine === 'large-v3-legacy' ? 'large-v3-legacy' : 'large-v3')
633
+ primary = {
634
+ engine: plan.engine === 'large-v3-legacy' ? 'large-v3-legacy' : 'large-v3',
635
+ text: reused.text,
636
+ words: reused.words,
637
+ ...(ts && reused.spokenWords ? { spoken: reused.spokenWords } : {}),
638
+ ...(ts && reused.speechSpans ? { speechSpans: reused.speechSpans } : {}),
639
+ timestamps: ts,
640
+ }
641
+ } else if (plan.engine === 'parakeet-ultra') {
642
+ try {
643
+ primary = await parakeetGuarded(job, () => decodeParakeetWindow(deps, input, parakeetTimeout(job), 'parakeet-ultra', true))
644
+ } catch (error) {
645
+ fallbackReason = `parakeet_failed: ${errorReason(error)}`
646
+ console.warn(`[meeting-batch] Parakeet HQ failed for ${segment.startChunkIdx}-${segment.endChunkIdx}; using Large-v3: ${errorReason(error)}`)
647
+ const w = await decodeWhisperWindow(deps, input, previousText, 'large-v3', segment, audioDir)
648
+ primary = w.decode
649
+ if (w.fallbackReason) fallbackReason += `; ${w.fallbackReason}`
650
+ }
651
+ } else {
652
+ try {
653
+ const w = await decodeWhisperWindow(deps, input, previousText, plan.engine, segment, audioDir)
654
+ primary = w.decode
655
+ fallbackReason = w.fallbackReason
656
+ } catch (error) {
657
+ if (!secondModel || job.parakeetStopped) throw error
658
+ // Whisper failed both ways: the planned Parakeet second opinion keeps the window's text.
659
+ primary = await parakeetGuarded(job, () => decodeParakeetWindow(deps, input, parakeetTimeout(job), secondModel, true))
660
+ fallbackReason = `whisper_failed: ${errorReason(error)}`
661
+ usedSecondAsPrimary = true
662
+ console.warn(`[meeting-batch] Whisper failed for ${segment.startChunkIdx}-${segment.endChunkIdx}; Parakeet (${secondModel}) is this window's primary`)
663
+ }
664
+ }
665
+
666
+ let second: WholeDecode | null = null
667
+ let secondError: string | undefined
668
+ const secondEngine = plan.secondOpinion
669
+ const sameFamily = secondEngine !== 'off' && isParakeet(secondEngine) === isParakeet(primary.engine)
670
+ if (secondEngine !== 'off' && !sameFamily && !usedSecondAsPrimary) {
671
+ try {
672
+ if (secondEngine === 'large-v3') {
673
+ second = (await decodeWhisperWindow(deps, input, previousText, 'large-v3', segment, audioDir)).decode
674
+ } else {
675
+ second = await parakeetGuarded(job, () => decodeParakeetWindow(deps, input, parakeetTimeout(job), secondEngine, false))
676
+ }
677
+ } catch (error) {
678
+ secondError = errorReason(error)
679
+ console.warn(`[meeting-batch] Second opinion (${secondEngine}) failed for ${segment.startChunkIdx}-${segment.endChunkIdx}; keeping the primary: ${secondError}`)
680
+ }
681
+ }
682
+
683
+ let text = primary.text
684
+ let words = primary.words
685
+ let applied = false
686
+ let wordsFromSecond = 0
687
+ let skipped: string | undefined
688
+ let disagreements: SegmentStt['disagreements'] = []
689
+ if (second) {
690
+ // The resolver may change words only when the primary's clocks are real
691
+ // (timestamps on, or Parakeet); otherwise it only records disagreements.
692
+ const apply = plan.resolver && primary.spoken !== undefined
693
+ const resolved = resolveHypotheses(asHypothesis(primary), asHypothesis(second), {
694
+ vocabulary: deps.vocabulary(),
695
+ primaryDeletionProne: isDeletionProneEngine(primary.engine),
696
+ speechSpans: primary.speechSpans ?? null,
697
+ apply,
698
+ })
699
+ skipped = resolved.skipped
700
+ disagreements = resolved.disagreements
701
+ if (apply && resolved.wordsFromSecond > 0 && resolved.timed) {
702
+ applied = true
703
+ wordsFromSecond = resolved.wordsFromSecond
704
+ // Spoken words are uncorrected: join, then correct the text exactly once.
705
+ text = applyCorrections(resolved.text)
706
+ words = resolved.words.map(w => ({ word: applyCorrections(w.word), start: w.start, end: w.end, probability: w.probability ?? 0 }))
707
+ } else {
708
+ // What is saved must say what was applied.
709
+ disagreements = disagreements.map(d => ({ ...d, taken: 'primary' as const }))
710
+ }
711
+ }
712
+ // Only a prompted Whisper decode can echo the previous window's text.
713
+ if (previousText && !isParakeet(primary.engine)) text = stripOverlap(text, previousText)
714
+
715
+ const stt: SegmentStt = {
716
+ engine: primary.engine,
717
+ requestedEngine: plan.engine,
718
+ ...(fallbackReason ? { fallbackReason } : {}),
719
+ ...(reused ? { reusedCheckpoint: true } : {}),
720
+ frontEnd: plan.frontEnd,
721
+ timestamps: primary.timestamps,
722
+ primary: hypothesisRecord(primary),
723
+ ...(second ? { secondOpinion: hypothesisRecord(second) } : secondError ? { secondOpinion: { engine: secondEngine, text: '', error: secondError } } : {}),
724
+ resolver: { version: RESOLVER_VERSION, enabled: plan.resolver, applied, wordsFromSecond, ...(skipped ? { skipped } : {}) },
725
+ disagreements,
726
+ }
727
+ return {
728
+ segment, text, words, speakerWords: mapWordsToSpeakers(words, segment, entries),
729
+ ...(primary.spoken ? { spokenWords: primary.spoken } : {}),
730
+ ...(primary.speechSpans ? { speechSpans: primary.speechSpans } : {}),
731
+ stt,
732
+ }
733
+ }
734
+
395
735
  export async function transcribeSegments(
396
736
  audioDir: string,
397
737
  segments: BatchSegment[],
398
738
  entries: IndexedTranscriptChunk[],
399
- expectedSessionId?: string,
739
+ expectedSessionId: string | undefined,
740
+ plan: HqJobPlan,
741
+ deps: HqDecodeDeps = defaultHqDeps,
400
742
  ): Promise<BatchResult[]> {
401
743
  const results: BatchResult[] = []
402
744
  const meetingId = basename(audioDir)
745
+ const job: JobState = { parakeetRan: false, parakeetFailures: 0, parakeetStopped: false }
746
+ // A progressive checkpoint holds one Whisper decode made under the same
747
+ // timing mode and front end. It is reused as the primary for a Whisper plan;
748
+ // with a second opinion planned, only the second opinion is decoded.
749
+ const identity: CheckpointIdentity = { timestamps: batchTimestampsEnabled(plan.engine === 'parakeet-ultra' ? 'large-v3' : plan.engine), frontEnd: plan.frontEnd }
750
+ const checkpointsUsable = plan.engine !== 'parakeet-ultra'
403
751
  writeMeetingBatchProgress(audioDir, {
404
752
  phase: 'hq_polish',
405
753
  segmentsDone: 0,
@@ -410,11 +758,12 @@ export async function transcribeSegments(
410
758
  try {
411
759
  refreshPendingLease(audioDir)
412
760
  const previousText = results.at(-1)?.text
413
- const cached = progressiveMeetingHqEnabled()
414
- ? readProgressiveCheckpoint(audioDir, segment, previousText, expectedSessionId)
761
+ const cached = checkpointsUsable && progressiveMeetingHqEnabled()
762
+ ? readProgressiveCheckpoint(audioDir, segment, previousText, expectedSessionId, identity)
415
763
  : null
416
- if (cached) {
417
- results.push(cached)
764
+ if (cached && plan.secondOpinion === 'off') {
765
+ results.push({ ...cached, stt: { engine: plan.engine, requestedEngine: plan.engine, reusedCheckpoint: true, frontEnd: plan.frontEnd, timestamps: identity.timestamps,
766
+ primary: { engine: plan.engine, text: cached.text }, resolver: { version: RESOLVER_VERSION, enabled: plan.resolver, applied: false, wordsFromSecond: 0 }, disagreements: [] } })
418
767
  writeMeetingBatchProgress(audioDir, {
419
768
  phase: 'hq_polish',
420
769
  segmentsDone: results.length,
@@ -424,36 +773,8 @@ export async function transcribeSegments(
424
773
  continue
425
774
  }
426
775
  const combined = concatenateWavChunks(audioDir, segment.startChunkIdx, segment.endChunkIdx)
427
- const enhanced = await enhanceAudio(combined)
428
- let result
429
- try {
430
- result = await transcribeHighQuality(enhanced, previousText?.slice(-250), { priority: 'batch' })
431
- } catch (error) {
432
- // A live meeting took the GPU mid-segment. The truncated Metal output
433
- // was already discarded upstream (BLOCKER contract) — it is never
434
- // accepted. Retry this SAME segment once on CPU, which cannot itself be
435
- // preempted, so a busy day degrades to slow rather than to a silently
436
- // missing stretch of transcript.
437
- if (!isMetalBatchPreempted(error)) throw error
438
- console.log(
439
- `[meeting-batch] Segment ${segment.startChunkIdx}-${segment.endChunkIdx} preempted off Metal; `
440
- + 'retrying once on CPU',
441
- )
442
- refreshPendingLease(audioDir)
443
- result = await transcribeHighQuality(enhanced, previousText?.slice(-250), {
444
- priority: 'batch',
445
- forceCpu: true,
446
- forceCpuReason: 'preempt_retry',
447
- })
448
- }
449
- const text = previousText ? stripOverlap(result.text, previousText) : result.text
450
- const words = result.words ?? []
451
- results.push({
452
- segment,
453
- text,
454
- words,
455
- speakerWords: mapWordsToSpeakers(words, segment, entries),
456
- })
776
+ const input = await deps.frontEnd(combined, plan.frontEnd)
777
+ results.push(await decodeHqWindow(input, previousText, segment, entries, plan, deps, job, audioDir, cached ?? undefined))
457
778
  refreshPendingLease(audioDir)
458
779
  writeMeetingBatchProgress(audioDir, {
459
780
  phase: 'hq_polish',
@@ -477,6 +798,35 @@ export async function transcribeSegments(
477
798
  return results
478
799
  }
479
800
 
801
+ /** Orphan recovery's HQ work: the plan is taken when the recovery is accepted
802
+ * (like a save), and the same plan both decodes and summarizes. */
803
+ export function recoveryHqJob(
804
+ quarantineDir: string,
805
+ segments: BatchSegment[],
806
+ entries: IndexedTranscriptChunk[],
807
+ deps: HqDecodeDeps = defaultHqDeps,
808
+ ): { plan: HqJobPlan; run: () => Promise<BatchResult[]> } {
809
+ const plan = currentHqJobPlan()
810
+ return { plan, run: () => transcribeSegments(quarantineDir, segments, entries, undefined, plan, deps) }
811
+ }
812
+
813
+ export function summarizeStt(plan: HqJobPlan, results: readonly BatchResult[], segmentsPlanned = results.length): BatchSttSummary {
814
+ return {
815
+ plan: { engine: plan.engine, secondOpinion: plan.secondOpinion, resolver: plan.resolver, frontEnd: plan.frontEnd,
816
+ timestamps: batchTimestampsEnabled(plan.engine === 'parakeet-ultra' ? 'large-v3' : plan.engine) },
817
+ resolverVersion: RESOLVER_VERSION,
818
+ windows: results.length,
819
+ windowsLost: Math.max(0, segmentsPlanned - results.length),
820
+ disagreements: results.reduce((n, r) => n + (r.stt?.disagreements.length ?? 0), 0),
821
+ wordsFromSecond: results.reduce((n, r) => n + (r.stt?.resolver.wordsFromSecond ?? 0), 0),
822
+ secondOpinionFailures: results.filter(r => r.stt?.secondOpinion?.error).length,
823
+ primaryFallbacks: results.filter(r => r.stt?.fallbackReason).length,
824
+ timestampFallbacks: results.filter(r => r.stt?.fallbackReason?.includes('timestamps_failed')).length,
825
+ checkpointsReused: results.filter(r => r.stt?.reusedCheckpoint).length,
826
+ resolverSkipped: results.filter(r => r.stt?.resolver.skipped).length,
827
+ }
828
+ }
829
+
480
830
  let batchQueueTail: Promise<void> = Promise.resolve()
481
831
 
482
832
  /** Chain arbitrary HQ-decoder work onto the same serialization tail the batch
@@ -650,7 +1000,8 @@ function queueProgressiveWork(sessionId: string, state: ProgressiveSessionState)
650
1000
  pending.segment.startChunkIdx,
651
1001
  pending.segment.endChunkIdx,
652
1002
  )
653
- const enhanced = await enhanceAudio(combined)
1003
+ const identity = currentCheckpointIdentity()
1004
+ const enhanced = await asrFrontEnd(combined, identity.frontEnd)
654
1005
  if (controller.signal.aborted || state.closed) return
655
1006
  const decoded = await transcribeHighQuality(enhanced, pending.previousText?.slice(-250), {
656
1007
  priority: 'batch',
@@ -659,6 +1010,7 @@ function queueProgressiveWork(sessionId: string, state: ProgressiveSessionState)
659
1010
  threads: progressiveThreads(),
660
1011
  backgroundCpu: true,
661
1012
  signal: controller.signal,
1013
+ timestamps: identity.timestamps,
662
1014
  })
663
1015
  if (controller.signal.aborted || state.closed) return
664
1016
  const text = pending.previousText
@@ -670,12 +1022,15 @@ function queueProgressiveWork(sessionId: string, state: ProgressiveSessionState)
670
1022
  text,
671
1023
  words,
672
1024
  speakerWords: mapWordsToSpeakers(words, pending.segment, state.entries),
1025
+ // Kept so the save-time job can reuse this decode as a resolver primary.
1026
+ ...(decoded.timestamps && decoded.spokenWords ? { spokenWords: decoded.spokenWords } : {}),
1027
+ ...(decoded.timestamps && decoded.speechSpans ? { speechSpans: decoded.speechSpans } : {}),
673
1028
  }
674
1029
  writeProgressiveCheckpoint(state.audioDir, sessionId, {
675
1030
  key: checkpointKey(pending.segment),
676
1031
  sourceHash,
677
1032
  contextHash: contextHash(pending.previousText),
678
- configFingerprint: getHighQualityCheckpointFingerprint(),
1033
+ configFingerprint: getHighQualityCheckpointFingerprint(identity),
679
1034
  completedAt: new Date().toISOString(),
680
1035
  wallTimeMs: Date.now() - started,
681
1036
  result,
@@ -846,11 +1201,15 @@ export function runMeetingBatchPipeline(
846
1201
  })
847
1202
  const lease = setInterval(() => refreshPendingLease(audioDir), 60_000)
848
1203
  lease.unref()
1204
+ // Frozen at SAVE time: a settings change applies to the next saved meeting,
1205
+ // never to one already queued behind another HQ job.
1206
+ const plan = currentHqJobPlan()
849
1207
  const job = batchQueueTail.then(() => runMeetingBatchPipelineNow(
850
1208
  audioDir,
851
1209
  entries,
852
1210
  streamingWordCount,
853
1211
  sessionId,
1212
+ plan,
854
1213
  )).finally(() => {
855
1214
  clearInterval(lease)
856
1215
  clearMeetingBatchProgress(audioDir)
@@ -863,7 +1222,8 @@ async function runMeetingBatchPipelineNow(
863
1222
  audioDir: string,
864
1223
  entries: IndexedTranscriptChunk[],
865
1224
  streamingWordCount: number,
866
- sessionId?: string,
1225
+ sessionId: string | undefined,
1226
+ plan: HqJobPlan,
867
1227
  ): Promise<BatchTranscription> {
868
1228
  try {
869
1229
  refreshPendingLease(audioDir)
@@ -885,7 +1245,8 @@ async function runMeetingBatchPipelineNow(
885
1245
  segmentsTotal: segments.length,
886
1246
  meetingId: basename(audioDir),
887
1247
  })
888
- const batchSegments = await transcribeSegments(audioDir, segments, entries, sessionId)
1248
+ console.log(`[meeting-batch] STT plan: ${JSON.stringify(plan)}`)
1249
+ const batchSegments = await transcribeSegments(audioDir, segments, entries, sessionId, plan)
889
1250
  writeMeetingBatchProgress(audioDir, {
890
1251
  phase: 'quality_check',
891
1252
  segmentsDone: segments.length,
@@ -894,6 +1255,8 @@ async function runMeetingBatchPipelineNow(
894
1255
  })
895
1256
  const batchTranscript = batchSegments.map(result => result.text).join(' ')
896
1257
  const qualityReport = evaluateBatchQuality(batchSegments, streamingWordCount)
1258
+ const sttSummary = summarizeStt(plan, batchSegments, segments.length)
1259
+ recordLastSttJob({ at: new Date().toISOString(), meeting: basename(audioDir), outcome: qualityReport.accepted ? 'batch' : 'rejected', summary: sttSummary })
897
1260
  if (!qualityReport.accepted) {
898
1261
  console.warn(
899
1262
  `[meeting-batch] Candidate rejected (${qualityReport.reason}): `
@@ -908,7 +1271,7 @@ async function runMeetingBatchPipelineNow(
908
1271
  return { transcriptionQuality: 'streaming', qualityReport }
909
1272
  }
910
1273
 
911
- return { transcriptionQuality: 'batch', batchTranscript, batchSegments, qualityReport }
1274
+ return { transcriptionQuality: 'batch', batchTranscript, batchSegments, qualityReport, stt: sttSummary }
912
1275
  } catch (error) {
913
1276
  console.error(`[meeting-batch] Pipeline failed: ${error instanceof Error ? error.message : String(error)}`)
914
1277
  writeMeetingBatchTerminal(audioDir, {
@@ -0,0 +1,41 @@
1
+ // GENERATED from the 2026-10-10 bake-off model manifest (private corpus MODEL_MANIFEST.json).
2
+ // Every file of each pinned Hugging Face revision, with its size and SHA-256. The installer
3
+ // refuses any byte that does not match. Do not edit by hand: a changed hash is a new model.
4
+
5
+ export interface PinnedFile { path: string; size: number; sha256: string }
6
+ export interface PinnedModel { id: string; repo: string; revision: string; modelVersion: string; files: PinnedFile[] }
7
+
8
+ /** fluidaudiocli 0.17.4, the same binary the Nemotron diarizer pins (nemotron-live.ts KNOWN_CLI_SHA256). */
9
+ export const PARAKEET_CLI = { version: '0.17.4', sha256: '4714154623b59c40404563828cb0fbeb4da6878e90f582ad776c1c2cd061d35a', size: 22039856 } as const
10
+
11
+ export const PARAKEET_ULTRA: PinnedModel = {
12
+ id: 'parakeet-ultra', repo: 'FluidInference/parakeet-ultra-coreml', revision: '95eaa59a39d4394f047a4dc5cce480388a60d1b6', modelVersion: 'ultra',
13
+ files: [
14
+ { path: 'Decoder.mlmodelc/analytics/coremldata.bin', size: 243, sha256: 'fe92b6cfaa012abd5248c0bc877832f19807015abffc60d87b8ccc8ccb48b3b5' },
15
+ { path: 'Decoder.mlmodelc/coremldata.bin', size: 560, sha256: '3b06e66768f0df7e21795f50e2b29300e33eeb1a2579dc42c695279c2d308497' },
16
+ { path: 'Decoder.mlmodelc/model.mil', size: 13110, sha256: '956f600207f88396017ca5c96cfa3acd5bfee60835a5b44b762e033a8fb28955' },
17
+ { path: 'Decoder.mlmodelc/weights/weight.bin', size: 23604992, sha256: '02a0d219f281b9665bc10c8768649403b2eebbbf4b44c627041d948e0de11bb4' },
18
+ { path: 'Encoder.mlmodelc/analytics/coremldata.bin', size: 243, sha256: 'd87101d824d6723cf95304da33755c3c60e762663b9ef2b0c4bd0aa166a09a0d' },
19
+ { path: 'Encoder.mlmodelc/coremldata.bin', size: 514, sha256: '397a84a4062f563cbc5f56077c674f09a61d85be5090f61d2f1932afb92ac0fe' },
20
+ { path: 'Encoder.mlmodelc/model.mil', size: 1002653, sha256: 'f5d601568a4171d99a314c0fe3f6bc67715da2623732a3fb566e050ea83e5848' },
21
+ { path: 'Encoder.mlmodelc/weights/weight.bin', size: 594211328, sha256: '315ba01f33cadbf601d43ac7f5c86208b7aa75fdaa34705c9869d5abe3521c9b' },
22
+ { path: 'JointDecisionv3.mlmodelc/analytics/coremldata.bin', size: 243, sha256: '68d38ca646aebafa7a9329e2efda50f5767c49e89fdb5f77f212072bb66f97c4' },
23
+ { path: 'JointDecisionv3.mlmodelc/coremldata.bin', size: 592, sha256: '5e3af5a4ce686f6c237cadbd9284e10d333bc0e1546633cd0e430e6194044bc4' },
24
+ { path: 'JointDecisionv3.mlmodelc/model.mil', size: 11777, sha256: '791b3c3cf3eb2079c84623fc880f6bba008d1366e5f8b03e9a8ed8bd4d7194a0' },
25
+ { path: 'JointDecisionv3.mlmodelc/weights/weight.bin', size: 12642764, sha256: '3f310b85b82341c53ec383025ab094a4e462ee1c592e1ad7c6bfe39cff66ca25' },
26
+ { path: 'Preprocessor.mlmodelc/analytics/coremldata.bin', size: 243, sha256: 'c9beeb989c8d66f8be11df59bc6df277ec76cee404f6865b46243835ef562f6d' },
27
+ { path: 'Preprocessor.mlmodelc/coremldata.bin', size: 486, sha256: 'dbde3f2300842c1fd51ef3ff948a0bcffe65ffd2dca10707f2509f32c1d65b1d' },
28
+ { path: 'Preprocessor.mlmodelc/metadata.json', size: 2841, sha256: '2a98699e22d279dd37fa1d238aeb1c6db1df0d6fad687775324157689d8f3acf' },
29
+ { path: 'Preprocessor.mlmodelc/model.mil', size: 28181, sha256: '4b8518a956450fec57f06c2a21bdffc26973f7f1fa6842fb38fe917f896b6b93' },
30
+ { path: 'Preprocessor.mlmodelc/weights/weight.bin', size: 491072, sha256: '129b76e3aeafa8afa3ea76d995b964b145fe83700d579f6ff42c4c38fa0968ea' },
31
+ { path: 'README.md', size: 2235, sha256: '9e9e26b6da628a0dc34b9c55f2029e486fc02304ccd7f83825333572ae11c158' },
32
+ { path: 'config.json', size: 414, sha256: '965c69bf500c7be18958df6438dc1ba897721690ae412937ca7c91e28cec1870' },
33
+ { path: 'parakeet_v3_vocab.json', size: 151122, sha256: '7ec60e05f1b24480736ec0eed40900f4626bce1fa9a60fd700ec7e2a59198735' },
34
+ { path: 'parakeet_vocab.json', size: 151122, sha256: '7ec60e05f1b24480736ec0eed40900f4626bce1fa9a60fd700ec7e2a59198735' },
35
+ ],
36
+ }
37
+
38
+ // Parakeet TDT v2 (FluidInference/parakeet-tdt-0.6b-v2-coreml@ee09c569, 464 MB) was
39
+ // evaluated and removed in 6.72.0: on the shipped path it gave no benefit Ultra
40
+ // cannot (docs/stt-672-evaluation.md), so it is never downloaded or installed.
41
+ export const PARAKEET_MODELS: readonly PinnedModel[] = [PARAKEET_ULTRA]