@gotcos/glasses-server 6.21.6 → 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.
- package/.env.example +14 -0
- package/CHANGELOG.md +35 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/server/index.ts +5 -1
- package/server/lib/batch-transcript-quality.ts +1 -1
- package/server/lib/g2-enrichment-runner.ts +23 -6
- package/server/lib/g2-ops-handoff.ts +193 -32
- package/server/lib/meeting-batch-transcribe.ts +554 -10
- package/server/lib/meeting-finalization-jobs.ts +235 -0
- package/server/lib/meeting-store.ts +9 -0
- package/server/lib/whisper-local.ts +77 -6
- package/server/lib/whisper-preview.ts +34 -0
- package/server/routes/health.ts +15 -0
- package/server/routes/meeting.ts +268 -52
- package/server/routes/transcribe-stream.ts +154 -2
|
@@ -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 {
|
|
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(
|
|
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)
|
|
166
|
-
|
|
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,
|