@gotcos/glasses-server 6.21.7 → 6.21.9

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.
@@ -2,10 +2,19 @@
2
2
  // larger windows so Whisper gets enough context to improve the live stream.
3
3
  // The candidate is never canonical until batch-transcript-quality accepts it.
4
4
 
5
+ import { createHash } from 'node:crypto'
5
6
  import { existsSync, readFileSync, readdirSync, utimesSync, writeFileSync } from 'node:fs'
7
+ import { availableParallelism } from 'node:os'
6
8
  import { basename, join, resolve } from 'node:path'
7
9
  import { enhanceAudio } from './audio-enhance.js'
8
- import { transcribeHighQuality, type WhisperWord } from './whisper-local.js'
10
+ import {
11
+ getHighQualityCheckpointFingerprint,
12
+ getHighQualityTranscriptionCapability,
13
+ getWhisperCommitCapability,
14
+ transcribeHighQuality,
15
+ type WhisperWord,
16
+ } from './whisper-local.js'
17
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
9
18
  import { isMetalBatchPreempted } from './whisper-metal-gate.js'
10
19
  import {
11
20
  clearMeetingBatchProgress,
@@ -79,7 +88,11 @@ export function concatenateWavChunks(audioDir: string, startChunk: number, endCh
79
88
  }
80
89
 
81
90
  /** Group live transcript chunks into roughly 30-second Whisper windows. */
82
- export function segmentTranscriptChunks(entries: IndexedTranscriptChunk[], targetMs = 30_000): BatchSegment[] {
91
+ export function segmentTranscriptChunks(
92
+ entries: IndexedTranscriptChunk[],
93
+ targetMs = 30_000,
94
+ includeOpenTail = true,
95
+ ): BatchSegment[] {
83
96
  if (entries.length === 0) return []
84
97
  const segments: BatchSegment[] = []
85
98
  let segmentStartPosition = 0
@@ -105,7 +118,7 @@ export function segmentTranscriptChunks(entries: IndexedTranscriptChunk[], targe
105
118
  }
106
119
  }
107
120
 
108
- if (segmentStartPosition < entries.length) {
121
+ if (includeOpenTail && segmentStartPosition < entries.length) {
109
122
  const window = entries.slice(segmentStartPosition)
110
123
  const first = entries[segmentStartPosition]
111
124
  const last = entries.at(-1)
@@ -122,7 +135,7 @@ export function segmentTranscriptChunks(entries: IndexedTranscriptChunk[], targe
122
135
  return segments
123
136
  }
124
137
 
125
- function stripOverlap(newText: string, previousText: string): string {
138
+ export function stripOverlap(newText: string, previousText: string): string {
126
139
  const normalize = (value: string): string => value
127
140
  .toLowerCase()
128
141
  .replace(/[.!?,;:'"()\-\n]/g, '')
@@ -144,14 +157,15 @@ function stripOverlap(newText: string, previousText: string): string {
144
157
  return newText.trim().split(/\s+/).slice(overlap).join(' ') || newText
145
158
  }
146
159
 
147
- function mapWordsToSpeakers(
160
+ export function mapWordsToSpeakers(
148
161
  words: WhisperWord[],
149
162
  segment: BatchSegment,
150
163
  entries: IndexedTranscriptChunk[],
151
- ): Array<{ word: string; start: number; end: number; speaker: string }> {
164
+ ): Array<{ word: string; start: number; end: number; speaker: string; similarity: number }> {
152
165
  return words.map(word => {
153
166
  const absoluteElapsed = segment.startElapsed + word.start * 1000
154
167
  let speaker = segment.speakers[0] || 'Unknown'
168
+ let similarity = 0
155
169
  let bestDistance = Number.POSITIVE_INFINITY
156
170
  for (const entry of entries) {
157
171
  if (entry.chunkIndex < segment.startChunkIdx || entry.chunkIndex > segment.endChunkIdx) continue
@@ -160,17 +174,193 @@ function mapWordsToSpeakers(
160
174
  if (distance < bestDistance) {
161
175
  bestDistance = distance
162
176
  speaker = chunk.speaker
177
+ similarity = Number.isFinite(chunk.similarity) ? chunk.similarity : 0
163
178
  }
164
179
  }
165
- if (bestDistance > 3_500) speaker = segment.speakers[0] || 'Unknown'
166
- return { word: word.word, start: word.start, end: word.end, speaker }
180
+ if (bestDistance > 3_500) {
181
+ speaker = segment.speakers[0] || 'Unknown'
182
+ similarity = 0
183
+ }
184
+ return { word: word.word, start: word.start, end: word.end, speaker, similarity }
167
185
  })
168
186
  }
169
187
 
188
+ const PROGRESSIVE_MANIFEST = '_progressive_hq.json'
189
+
190
+ interface ProgressiveCheckpoint {
191
+ key: string
192
+ sourceHash: string
193
+ contextHash: string
194
+ configFingerprint: string
195
+ completedAt: string
196
+ wallTimeMs: number
197
+ result: BatchResult
198
+ }
199
+
200
+ interface ProgressiveManifest {
201
+ schemaVersion: 1
202
+ sessionId: string
203
+ checkpoints: Record<string, ProgressiveCheckpoint>
204
+ }
205
+
206
+ function checkpointKey(segment: BatchSegment): string {
207
+ return `${segment.startChunkIdx}-${segment.endChunkIdx}`
208
+ }
209
+
210
+ function contextHash(previousText?: string): string {
211
+ return createHash('sha256').update(previousText?.slice(-250) ?? '').digest('hex')
212
+ }
213
+
214
+ function segmentSourceHash(audioDir: string, segment: BatchSegment): string | null {
215
+ const hash = createHash('sha256')
216
+ for (let index = segment.startChunkIdx; index <= segment.endChunkIdx; index++) {
217
+ const path = resolve(audioDir, `chunk_${String(index).padStart(4, '0')}.wav`)
218
+ if (!existsSync(path)) return null
219
+ const wav = readFileSync(path)
220
+ if (wav.length <= WAV_HEADER_SIZE || wav.toString('ascii', 0, 4) !== 'RIFF') return null
221
+ hash.update(String(index)).update('\0').update(wav)
222
+ }
223
+ return hash.digest('hex')
224
+ }
225
+
226
+ function progressiveFailureIdentity(
227
+ audioDir: string,
228
+ segment: BatchSegment,
229
+ previousText?: string,
230
+ ): string | null {
231
+ const sourceHash = segmentSourceHash(audioDir, segment)
232
+ if (!sourceHash) return null
233
+ return createHash('sha256').update([
234
+ checkpointKey(segment), sourceHash, contextHash(previousText), getHighQualityCheckpointFingerprint(),
235
+ ].join('\0')).digest('hex')
236
+ }
237
+
238
+ function loadProgressiveManifest(audioDir: string): ProgressiveManifest | null {
239
+ try {
240
+ const parsed = JSON.parse(readFileSync(join(audioDir, PROGRESSIVE_MANIFEST), 'utf8')) as ProgressiveManifest
241
+ if (parsed?.schemaVersion !== 1 || typeof parsed.sessionId !== 'string' || !parsed.checkpoints) return null
242
+ return parsed
243
+ } catch {
244
+ return null
245
+ }
246
+ }
247
+
248
+ function readProgressiveCheckpoint(
249
+ audioDir: string,
250
+ segment: BatchSegment,
251
+ previousText?: string,
252
+ expectedSessionId?: string,
253
+ ): BatchResult | null {
254
+ const cached = readProgressiveCheckpointMetadata(audioDir, segment, previousText, undefined, expectedSessionId)
255
+ if (!cached) return null
256
+ const manifest = loadProgressiveManifest(audioDir)
257
+ const checkpoint = manifest?.checkpoints?.[checkpointKey(segment)]
258
+ if (!checkpoint) return null
259
+ const sourceHash = segmentSourceHash(audioDir, segment)
260
+ if (!sourceHash || checkpoint.sourceHash !== sourceHash) return null
261
+ return cached
262
+ }
263
+
264
+ /** Lightweight cache lookup for admission/status. Raw audio is immutable once
265
+ * durably written, while final Stop/save reuse still performs the full WAV
266
+ * hash validation above. This keeps the 12-second Control health poll cheap. */
267
+ function readProgressiveCheckpointMetadata(
268
+ audioDir: string,
269
+ segment: BatchSegment,
270
+ previousText?: string,
271
+ manifest = loadProgressiveManifest(audioDir),
272
+ expectedSessionId?: string,
273
+ ): BatchResult | null {
274
+ if (expectedSessionId && manifest?.sessionId !== expectedSessionId) return null
275
+ const checkpoint = manifest?.checkpoints?.[checkpointKey(segment)]
276
+ if (
277
+ !checkpoint
278
+ || checkpoint.contextHash !== contextHash(previousText)
279
+ || checkpoint.configFingerprint !== getHighQualityCheckpointFingerprint()
280
+ ) return null
281
+ const cached = checkpoint.result
282
+ const validWord = (word: unknown, speakerRequired: boolean): boolean => {
283
+ if (!word || typeof word !== 'object') return false
284
+ const item = word as Record<string, unknown>
285
+ return typeof item.word === 'string'
286
+ && typeof item.start === 'number'
287
+ && Number.isFinite(item.start)
288
+ && typeof item.end === 'number'
289
+ && Number.isFinite(item.end)
290
+ && item.start >= 0
291
+ && item.end >= item.start
292
+ && (!speakerRequired || (
293
+ typeof item.speaker === 'string'
294
+ && typeof item.similarity === 'number'
295
+ && Number.isFinite(item.similarity)
296
+ && item.similarity >= 0
297
+ && item.similarity <= 1
298
+ ))
299
+ }
300
+ if (
301
+ cached?.segment?.startChunkIdx !== segment.startChunkIdx
302
+ || cached.segment.endChunkIdx !== segment.endChunkIdx
303
+ || cached.segment.startElapsed !== segment.startElapsed
304
+ || cached.segment.endElapsed !== segment.endElapsed
305
+ || !Array.isArray(cached.segment.speakers)
306
+ || cached.segment.speakers.some(speaker => typeof speaker !== 'string')
307
+ || typeof cached.text !== 'string'
308
+ || !Array.isArray(cached.words)
309
+ || cached.words.some(word => !validWord(word, false))
310
+ || !Array.isArray(cached.speakerWords)
311
+ || cached.speakerWords.some(word => !validWord(word, true))
312
+ ) return null
313
+ return cached
314
+ }
315
+
316
+ function writeProgressiveCheckpoint(
317
+ audioDir: string,
318
+ sessionId: string,
319
+ checkpoint: ProgressiveCheckpoint,
320
+ ): void {
321
+ const prior = loadProgressiveManifest(audioDir)
322
+ if (prior && prior.sessionId !== sessionId) {
323
+ throw new Error(`Progressive HQ manifest session mismatch (${prior.sessionId} != ${sessionId})`)
324
+ }
325
+ const next: ProgressiveManifest = {
326
+ schemaVersion: 1,
327
+ sessionId,
328
+ checkpoints: { ...(prior?.checkpoints ?? {}), [checkpoint.key]: checkpoint },
329
+ }
330
+ durableAtomicWriteFileSync(
331
+ join(audioDir, PROGRESSIVE_MANIFEST),
332
+ JSON.stringify(next, null, 2),
333
+ { mode: 0o600 },
334
+ )
335
+ }
336
+
337
+ export const __progressiveHqTesting = {
338
+ checkpointKey,
339
+ segmentSourceHash,
340
+ readProgressiveCheckpoint,
341
+ readProgressiveCheckpointMetadata,
342
+ writeProgressiveCheckpoint,
343
+ contextHash,
344
+ segmentRangeIsComplete,
345
+ nextMissingSealedSegment,
346
+ progressiveFailureIdentity,
347
+ resolveProgressiveHqPolicy,
348
+ progressiveSessionIsIdle,
349
+ yieldIdleProgressiveSessions,
350
+ admitProgressiveSession,
351
+ progressiveSessionIds: () => [...progressiveSessions.keys()],
352
+ resetProgressiveSessions: () => {
353
+ for (const state of progressiveSessions.values()) state.controller?.abort()
354
+ progressiveSessions.clear()
355
+ progressiveActiveSessionId = null
356
+ },
357
+ }
358
+
170
359
  export async function transcribeSegments(
171
360
  audioDir: string,
172
361
  segments: BatchSegment[],
173
362
  entries: IndexedTranscriptChunk[],
363
+ expectedSessionId?: string,
174
364
  ): Promise<BatchResult[]> {
175
365
  const results: BatchResult[] = []
176
366
  const meetingId = basename(audioDir)
@@ -183,9 +373,22 @@ export async function transcribeSegments(
183
373
  for (const segment of segments) {
184
374
  try {
185
375
  refreshPendingLease(audioDir)
376
+ const previousText = results.at(-1)?.text
377
+ const cached = progressiveMeetingHqEnabled()
378
+ ? readProgressiveCheckpoint(audioDir, segment, previousText, expectedSessionId)
379
+ : null
380
+ if (cached) {
381
+ results.push(cached)
382
+ writeMeetingBatchProgress(audioDir, {
383
+ phase: 'hq_polish',
384
+ segmentsDone: results.length,
385
+ segmentsTotal: segments.length,
386
+ meetingId,
387
+ })
388
+ continue
389
+ }
186
390
  const combined = concatenateWavChunks(audioDir, segment.startChunkIdx, segment.endChunkIdx)
187
391
  const enhanced = await enhanceAudio(combined)
188
- const previousText = results.at(-1)?.text
189
392
  let result
190
393
  try {
191
394
  result = await transcribeHighQuality(enhanced, previousText?.slice(-250), { priority: 'batch' })
@@ -204,6 +407,7 @@ export async function transcribeSegments(
204
407
  result = await transcribeHighQuality(enhanced, previousText?.slice(-250), {
205
408
  priority: 'batch',
206
409
  forceCpu: true,
410
+ forceCpuReason: 'preempt_retry',
207
411
  })
208
412
  }
209
413
  const text = previousText ? stripOverlap(result.text, previousText) : result.text
@@ -249,11 +453,349 @@ export function enqueueSerializedHqWork<T>(work: () => Promise<T>): Promise<T> {
249
453
  return job
250
454
  }
251
455
 
456
+ interface ProgressiveSessionState {
457
+ sessionId: string
458
+ audioDir: string
459
+ entries: IndexedTranscriptChunk[]
460
+ asrCompletedIndices: number[]
461
+ queued: boolean
462
+ active: boolean
463
+ closed: boolean
464
+ controller?: AbortController
465
+ activeDecode?: Promise<void>
466
+ inputRevision: number
467
+ inputSignature: string
468
+ failedIdentity?: string
469
+ failedAttempts: number
470
+ retryAfter: number
471
+ lastInputAt: number
472
+ idleYieldLogged: boolean
473
+ }
474
+
475
+ const progressiveSessions = new Map<string, ProgressiveSessionState>()
476
+ let progressiveActiveSessionId: string | null = null
477
+ const PROGRESSIVE_IDLE_YIELD_MS = 45_000
478
+
479
+ interface ProgressiveHqPolicyInput {
480
+ requested: boolean
481
+ effectiveTier: 'balanced' | 'max'
482
+ hqAvailable: boolean
483
+ logicalCpus: number
484
+ requestedThreads?: number
485
+ }
486
+
487
+ export interface ProgressiveHqPolicy {
488
+ requested: boolean
489
+ enabled: boolean
490
+ tier: 'balanced' | 'max'
491
+ mode: 'balanced-conservative' | 'max-performance'
492
+ threads: number
493
+ reason: 'disabled_by_flag' | 'hq_unavailable' | null
494
+ }
495
+
496
+ /** Tier is the user-owned hardware admission signal. Balanced must remain safe
497
+ * on the lowest supported fanless M1/M2 Air, so it can never exceed two CPU
498
+ * decoder threads. Max may use six by default, while both tiers remain capped
499
+ * by the CPUs the OS actually makes available. An env override can lower a
500
+ * tier but cannot punch through its safety cap. */
501
+ function resolveProgressiveHqPolicy(input: ProgressiveHqPolicyInput): ProgressiveHqPolicy {
502
+ const logicalCpus = Number.isFinite(input.logicalCpus)
503
+ ? Math.max(1, Math.floor(input.logicalCpus))
504
+ : 1
505
+ const tierCap = input.effectiveTier === 'max' ? 8 : 2
506
+ const defaultThreads = input.effectiveTier === 'max'
507
+ ? Math.min(6, Math.max(1, logicalCpus - 2))
508
+ : Math.min(2, Math.max(1, Math.floor(logicalCpus / 4)))
509
+ const requestedThreads = Number.isFinite(input.requestedThreads)
510
+ ? Math.max(1, Math.floor(input.requestedThreads!))
511
+ : defaultThreads
512
+ const threads = Math.min(tierCap, logicalCpus, requestedThreads)
513
+ return {
514
+ requested: input.requested,
515
+ enabled: input.requested && input.hqAvailable,
516
+ tier: input.effectiveTier,
517
+ mode: input.effectiveTier === 'max' ? 'max-performance' : 'balanced-conservative',
518
+ threads,
519
+ reason: !input.requested ? 'disabled_by_flag' : !input.hqAvailable ? 'hq_unavailable' : null,
520
+ }
521
+ }
522
+
523
+ function currentProgressiveHqPolicy(): ProgressiveHqPolicy {
524
+ const commit = getWhisperCommitCapability()
525
+ const hq = getHighQualityTranscriptionCapability()
526
+ const rawThreads = Number.parseInt(process.env.COS_MEETING_PROGRESSIVE_HQ_THREADS || '', 10)
527
+ return resolveProgressiveHqPolicy({
528
+ requested: process.env.COS_MEETING_PROGRESSIVE_HQ === '1',
529
+ effectiveTier: commit.effectiveTier,
530
+ hqAvailable: hq.hqAvailable,
531
+ logicalCpus: availableParallelism(),
532
+ requestedThreads: Number.isFinite(rawThreads) ? rawThreads : undefined,
533
+ })
534
+ }
535
+
536
+ export function progressiveMeetingHqEnabled(): boolean {
537
+ return currentProgressiveHqPolicy().enabled
538
+ }
539
+
540
+ function progressiveThreads(): number {
541
+ return currentProgressiveHqPolicy().threads
542
+ }
543
+
544
+ function progressiveSessionIsIdle(state: ProgressiveSessionState, now = Date.now()): boolean {
545
+ return state.lastInputAt > 0 && now - state.lastInputAt >= PROGRESSIVE_IDLE_YIELD_MS
546
+ }
547
+
548
+ /** A stale recording may still be recoverable, but it must not own the CPU-HQ
549
+ * lane forever. Abort only its disposable checkpoint child; raw WAVs, the
550
+ * transcript ledger, completed checkpoints, and save/recovery semantics remain
551
+ * untouched. A later canonical chunk refreshes lastInputAt and resumes it. */
552
+ function yieldIdleProgressiveSessions(activeSessionId: string, now = Date.now()): void {
553
+ for (const [sessionId, state] of progressiveSessions) {
554
+ if (sessionId === activeSessionId || state.closed || !progressiveSessionIsIdle(state, now)) continue
555
+ if (!state.idleYieldLogged) {
556
+ console.log(`[meeting-hq-checkpoint] yielding idle session ${sessionId}`)
557
+ state.idleYieldLogged = true
558
+ }
559
+ state.controller?.abort()
560
+ }
561
+ }
562
+
563
+ function segmentRangeIsComplete(segment: BatchSegment, completedIndices: number[]): boolean {
564
+ const completed = new Set(completedIndices)
565
+ for (let index = segment.startChunkIdx; index <= segment.endChunkIdx; index++) {
566
+ if (!completed.has(index)) return false
567
+ }
568
+ return true
569
+ }
570
+
571
+ function nextMissingSealedSegment(
572
+ state: ProgressiveSessionState,
573
+ ): { segment: BatchSegment; previousText?: string } | null {
574
+ const sealed = segmentTranscriptChunks(state.entries, 30_000, false)
575
+ const manifest = loadProgressiveManifest(state.audioDir)
576
+ let previousText: string | undefined
577
+ for (const segment of sealed) {
578
+ if (!segmentRangeIsComplete(segment, state.asrCompletedIndices)) return null
579
+ const cached = readProgressiveCheckpointMetadata(state.audioDir, segment, previousText, manifest, state.sessionId)
580
+ if (!cached) {
581
+ const identity = progressiveFailureIdentity(state.audioDir, segment, previousText)
582
+ if (!identity) return null
583
+ if (state.failedIdentity === identity && (state.failedAttempts >= 3 || Date.now() < state.retryAfter)) return null
584
+ return { segment, previousText }
585
+ }
586
+ previousText = cached.text
587
+ }
588
+ return null
589
+ }
590
+
591
+ function queueProgressiveWork(sessionId: string, state: ProgressiveSessionState): void {
592
+ if (state.queued || state.active || state.closed || !progressiveMeetingHqEnabled()) return
593
+ if (progressiveSessionIsIdle(state)) return
594
+ if (!nextMissingSealedSegment(state)) return
595
+ state.queued = true
596
+
597
+ void enqueueSerializedHqWork(async () => {
598
+ state.queued = false
599
+ if (state.closed || progressiveSessionIsIdle(state) || !progressiveMeetingHqEnabled()) return
600
+ const pending = nextMissingSealedSegment(state)
601
+ if (!pending) return
602
+ const sourceHash = segmentSourceHash(state.audioDir, pending.segment)
603
+ if (!sourceHash) return
604
+
605
+ state.active = true
606
+ progressiveActiveSessionId = sessionId
607
+ const controller = new AbortController()
608
+ state.controller = controller
609
+ const started = Date.now()
610
+ state.activeDecode = (async () => {
611
+ if (controller.signal.aborted || state.closed) return
612
+ const combined = concatenateWavChunks(
613
+ state.audioDir,
614
+ pending.segment.startChunkIdx,
615
+ pending.segment.endChunkIdx,
616
+ )
617
+ const enhanced = await enhanceAudio(combined)
618
+ if (controller.signal.aborted || state.closed) return
619
+ const decoded = await transcribeHighQuality(enhanced, pending.previousText?.slice(-250), {
620
+ priority: 'batch',
621
+ forceCpu: true,
622
+ forceCpuReason: 'progressive_checkpoint',
623
+ threads: progressiveThreads(),
624
+ backgroundCpu: true,
625
+ signal: controller.signal,
626
+ })
627
+ if (controller.signal.aborted || state.closed) return
628
+ const text = pending.previousText
629
+ ? stripOverlap(decoded.text, pending.previousText)
630
+ : decoded.text
631
+ const words = decoded.words ?? []
632
+ const result: BatchResult = {
633
+ segment: pending.segment,
634
+ text,
635
+ words,
636
+ speakerWords: mapWordsToSpeakers(words, pending.segment, state.entries),
637
+ }
638
+ writeProgressiveCheckpoint(state.audioDir, sessionId, {
639
+ key: checkpointKey(pending.segment),
640
+ sourceHash,
641
+ contextHash: contextHash(pending.previousText),
642
+ configFingerprint: getHighQualityCheckpointFingerprint(),
643
+ completedAt: new Date().toISOString(),
644
+ wallTimeMs: Date.now() - started,
645
+ result,
646
+ })
647
+ state.failedIdentity = undefined
648
+ state.failedAttempts = 0
649
+ state.retryAfter = 0
650
+ })()
651
+
652
+ try {
653
+ await state.activeDecode
654
+ } catch (error) {
655
+ if ((error as Error)?.name !== 'AbortError') {
656
+ const identity = progressiveFailureIdentity(state.audioDir, pending.segment, pending.previousText)
657
+ if (!identity) return
658
+ state.failedAttempts = state.failedIdentity === identity ? state.failedAttempts + 1 : 1
659
+ state.failedIdentity = identity
660
+ state.retryAfter = Date.now() + [60_000, 120_000, 300_000][Math.min(2, state.failedAttempts - 1)]
661
+ console.warn(
662
+ `[meeting-hq-checkpoint] ${sessionId} ${pending.segment.startChunkIdx}-${pending.segment.endChunkIdx}: `
663
+ + `${error instanceof Error ? error.message : String(error)}`,
664
+ )
665
+ }
666
+ } finally {
667
+ state.active = false
668
+ state.controller = undefined
669
+ state.activeDecode = undefined
670
+ if (progressiveActiveSessionId === sessionId) progressiveActiveSessionId = null
671
+ // Each job handles one sealed window, then rejoins the shared FIFO tail.
672
+ // Multiple live meetings therefore make bounded round-robin progress
673
+ // while post-save finalization still uses the same single decoder queue.
674
+ if (!state.closed) queueProgressiveWork(sessionId, state)
675
+ }
676
+ }).catch(error => {
677
+ state.queued = false
678
+ console.warn(
679
+ `[meeting-hq-checkpoint] queue failed for ${sessionId}: `
680
+ + `${error instanceof Error ? error.message : String(error)}`,
681
+ )
682
+ })
683
+ }
684
+
685
+ function admitProgressiveSession(
686
+ sessionId: string,
687
+ audioDir: string,
688
+ entries: IndexedTranscriptChunk[],
689
+ asrCompletedIndices: number[],
690
+ now = Date.now(),
691
+ ): ProgressiveSessionState {
692
+ const existing = progressiveSessions.get(sessionId)
693
+ const state = existing ?? {
694
+ sessionId,
695
+ audioDir,
696
+ entries: [],
697
+ asrCompletedIndices: [],
698
+ queued: false,
699
+ active: false,
700
+ closed: false,
701
+ inputRevision: 0,
702
+ inputSignature: '',
703
+ failedAttempts: 0,
704
+ retryAfter: 0,
705
+ lastInputAt: 0,
706
+ idleYieldLogged: false,
707
+ }
708
+ state.audioDir = audioDir
709
+ state.entries = entries.map(entry => ({ ...entry, chunk: { ...entry.chunk } }))
710
+ state.asrCompletedIndices = [...asrCompletedIndices]
711
+ const inputSignature = createHash('sha256').update(JSON.stringify({
712
+ entries: state.entries.map(entry => ({
713
+ chunkIndex: entry.chunkIndex,
714
+ text: entry.chunk.text,
715
+ speaker: entry.chunk.speaker,
716
+ elapsed: entry.chunk.elapsed,
717
+ })),
718
+ asrCompletedIndices: state.asrCompletedIndices,
719
+ })).digest('hex')
720
+ if (inputSignature !== state.inputSignature) {
721
+ state.inputSignature = inputSignature
722
+ state.inputRevision += 1
723
+ state.lastInputAt = now
724
+ state.idleYieldLogged = false
725
+ }
726
+ progressiveSessions.set(sessionId, state)
727
+ return state
728
+ }
729
+
730
+ /** Coalesced, globally serialized progressive HQ admission after canonical
731
+ * persistence. Every live session may enqueue one window; no session owns the
732
+ * lane beyond that bounded unit of work. */
733
+ export function scheduleProgressiveHqCheckpoint(
734
+ sessionId: string,
735
+ audioDir: string,
736
+ entries: IndexedTranscriptChunk[],
737
+ asrCompletedIndices: number[],
738
+ ): void {
739
+ if (!progressiveMeetingHqEnabled()) return
740
+ const now = Date.now()
741
+ const state = admitProgressiveSession(sessionId, audioDir, entries, asrCompletedIndices, now)
742
+ yieldIdleProgressiveSessions(sessionId, now)
743
+ queueProgressiveWork(sessionId, state)
744
+ }
745
+
746
+ /** Stop/save barrier: abort at most one prefill child before audio-dir rename. */
747
+ export async function stopProgressiveHqSession(sessionId: string): Promise<void> {
748
+ const state = progressiveSessions.get(sessionId)
749
+ if (!state) return
750
+ state.closed = true
751
+ state.controller?.abort()
752
+ void state.activeDecode?.catch(() => { /* aborted cache work is disposable */ })
753
+ progressiveSessions.delete(sessionId)
754
+ }
755
+
756
+ export function getProgressiveHqSnapshot(): {
757
+ requested: boolean
758
+ enabled: boolean
759
+ tier: 'balanced' | 'max'
760
+ mode: 'balanced-conservative' | 'max-performance'
761
+ threads: number
762
+ reason: 'disabled_by_flag' | 'hq_unavailable' | null
763
+ activeSessionId: string | null
764
+ sessions: Array<{ sessionId: string; sealedDone: number; sealedTotal: number; state: string }>
765
+ } {
766
+ const policy = currentProgressiveHqPolicy()
767
+ return {
768
+ ...policy,
769
+ activeSessionId: progressiveActiveSessionId,
770
+ sessions: [...progressiveSessions.entries()].filter(([, state]) => !state.closed).map(([sessionId, state]) => {
771
+ const sealed = segmentTranscriptChunks(state.entries, 30_000, false)
772
+ const manifest = loadProgressiveManifest(state.audioDir)
773
+ let previousText: string | undefined
774
+ let sealedDone = 0
775
+ for (const segment of sealed) {
776
+ const cached = readProgressiveCheckpointMetadata(state.audioDir, segment, previousText, manifest, state.sessionId)
777
+ if (!cached) break
778
+ sealedDone += 1
779
+ previousText = cached.text
780
+ }
781
+ return {
782
+ sessionId,
783
+ sealedDone,
784
+ sealedTotal: sealed.length,
785
+ state: progressiveSessionIsIdle(state)
786
+ ? 'paused_idle'
787
+ : state.active ? 'active' : state.queued ? 'queued' : 'idle',
788
+ }
789
+ }),
790
+ }
791
+ }
792
+
252
793
  /** Serialize 16-thread HQ decoders across meetings on a public user's Mac. */
253
794
  export function runMeetingBatchPipeline(
254
795
  audioDir: string,
255
796
  entries: IndexedTranscriptChunk[],
256
797
  streamingWordCount: number,
798
+ sessionId?: string,
257
799
  ): Promise<BatchTranscription> {
258
800
  // Lease immediately, including time spent behind another HQ decoder. Without
259
801
  // this, the two-hour cleanup could delete a queued meeting before it starts.
@@ -272,6 +814,7 @@ export function runMeetingBatchPipeline(
272
814
  audioDir,
273
815
  entries,
274
816
  streamingWordCount,
817
+ sessionId,
275
818
  )).finally(() => {
276
819
  clearInterval(lease)
277
820
  clearMeetingBatchProgress(audioDir)
@@ -284,6 +827,7 @@ async function runMeetingBatchPipelineNow(
284
827
  audioDir: string,
285
828
  entries: IndexedTranscriptChunk[],
286
829
  streamingWordCount: number,
830
+ sessionId?: string,
287
831
  ): Promise<BatchTranscription> {
288
832
  try {
289
833
  refreshPendingLease(audioDir)
@@ -305,7 +849,7 @@ async function runMeetingBatchPipelineNow(
305
849
  segmentsTotal: segments.length,
306
850
  meetingId: basename(audioDir),
307
851
  })
308
- const batchSegments = await transcribeSegments(audioDir, segments, entries)
852
+ const batchSegments = await transcribeSegments(audioDir, segments, entries, sessionId)
309
853
  writeMeetingBatchProgress(audioDir, {
310
854
  phase: 'quality_check',
311
855
  segmentsDone: segments.length,