@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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
428
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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]
|