@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
package/server/routes/meeting.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the standalone public meeting store. The live transcript and chunk metadata
|
|
3
3
|
// are durable before the session is closed; batch improvement runs afterward.
|
|
4
4
|
|
|
5
|
-
import { readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
|
5
|
+
import { existsSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
|
6
6
|
import { resolve } from 'node:path'
|
|
7
7
|
import { Router } from 'express'
|
|
8
8
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
enqueueSerializedHqWork,
|
|
29
29
|
runMeetingBatchPipeline,
|
|
30
30
|
segmentTranscriptChunks,
|
|
31
|
+
stopProgressiveHqSession,
|
|
31
32
|
transcribeSegments,
|
|
32
33
|
} from '../lib/meeting-batch-transcribe.js'
|
|
33
34
|
import {
|
|
@@ -63,8 +64,23 @@ import {
|
|
|
63
64
|
type TranscriptGapReport,
|
|
64
65
|
} from './transcribe-stream.js'
|
|
65
66
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
66
|
-
import {
|
|
67
|
-
|
|
67
|
+
import {
|
|
68
|
+
acquireMaintenanceWork,
|
|
69
|
+
maintenanceAdmissionsOpen,
|
|
70
|
+
type MaintenanceWorkLease,
|
|
71
|
+
} from '../lib/maintenance-lifecycle.js'
|
|
72
|
+
import {
|
|
73
|
+
claimMeetingInOperations,
|
|
74
|
+
earlyMeetingSyncEnabled,
|
|
75
|
+
handoffMeetingToOperations,
|
|
76
|
+
} from '../lib/g2-ops-handoff.js'
|
|
77
|
+
import {
|
|
78
|
+
canonicalFinalizationIsComplete,
|
|
79
|
+
MeetingFinalizationJobStore,
|
|
80
|
+
markCanonicalFinalizationState,
|
|
81
|
+
readFinalizationChunkEntries,
|
|
82
|
+
type MeetingFinalizationJob,
|
|
83
|
+
} from '../lib/meeting-finalization-jobs.js'
|
|
68
84
|
|
|
69
85
|
function cosOpsPipelineConfigured(): boolean {
|
|
70
86
|
// Read env live (not the module-load COS_SCRIPTS_DIR const) so unit tests that
|
|
@@ -92,9 +108,195 @@ export interface MeetingRouteDependencies {
|
|
|
92
108
|
audioDir: string,
|
|
93
109
|
entries: IndexedTranscriptChunk[],
|
|
94
110
|
streamingWordCount: number,
|
|
111
|
+
sessionId?: string,
|
|
95
112
|
) => Promise<BatchTranscription>
|
|
96
113
|
scheduleBackground?: (task: Promise<void>) => void
|
|
97
114
|
emit?: typeof emitDisplay
|
|
115
|
+
finalizationJobs?: MeetingFinalizationJobStore
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const activeFinalizationJobs = new Set<string>()
|
|
119
|
+
const finalizationRetryCounts = new Map<string, number>()
|
|
120
|
+
|
|
121
|
+
interface FinalizationRuntime {
|
|
122
|
+
finalizationJobs: MeetingFinalizationJobStore
|
|
123
|
+
runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
|
|
124
|
+
scheduleBackground: (task: Promise<void>) => void
|
|
125
|
+
sessions?: MeetingSessionSource
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
|
|
129
|
+
const entries: IndexedTranscriptChunk[] = []
|
|
130
|
+
for (const item of raw) {
|
|
131
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
|
|
132
|
+
const candidate = item as { chunkIndex?: unknown; chunk?: unknown }
|
|
133
|
+
if (!Number.isInteger(candidate.chunkIndex) || (candidate.chunkIndex as number) < 0) return null
|
|
134
|
+
if (!candidate.chunk || typeof candidate.chunk !== 'object' || Array.isArray(candidate.chunk)) return null
|
|
135
|
+
const chunk = candidate.chunk as Partial<TranscriptChunk>
|
|
136
|
+
if (typeof chunk.text !== 'string' || typeof chunk.speaker !== 'string') return null
|
|
137
|
+
if (typeof chunk.elapsed !== 'number' || !Number.isFinite(chunk.elapsed)) return null
|
|
138
|
+
if (typeof chunk.similarity !== 'number' || !Number.isFinite(chunk.similarity)) return null
|
|
139
|
+
entries.push({ chunkIndex: candidate.chunkIndex as number, chunk: chunk as TranscriptChunk })
|
|
140
|
+
}
|
|
141
|
+
return entries.length > 0 ? entries : null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: FinalizationRuntime): void {
|
|
145
|
+
const key = `${runtime.finalizationJobs.root}:${job.sessionId}`
|
|
146
|
+
if (activeFinalizationJobs.has(key)) return
|
|
147
|
+
activeFinalizationJobs.add(key)
|
|
148
|
+
|
|
149
|
+
// Acquire before the foreground save lease can leave scope: there is no
|
|
150
|
+
// zero-count proof gap between pending-audio handoff and queued finalizer.
|
|
151
|
+
const lease = acquireMaintenanceWork('meeting_batch_finalization', {
|
|
152
|
+
allowDuringDrain: true,
|
|
153
|
+
phase: 'queued',
|
|
154
|
+
})
|
|
155
|
+
const task = Promise.resolve().then(async () => {
|
|
156
|
+
lease.setPhase('active')
|
|
157
|
+
let current = runtime.finalizationJobs.get(job.sessionId) ?? job
|
|
158
|
+
if (canonicalFinalizationIsComplete(current)) {
|
|
159
|
+
runtime.finalizationJobs.remove(current.sessionId)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
if (current.phase === 'capture_pending') {
|
|
163
|
+
const source = runtime.sessions ?? defaultSessionSource
|
|
164
|
+
let audioDir = current.audioDir ?? runtime.finalizationJobs.findPendingAudioDir(current.sessionId)
|
|
165
|
+
if (!audioDir && source.hasAudio(current.sessionId)) {
|
|
166
|
+
try { await source.drainAudioWrites(current.sessionId) } catch { /* retain surviving evidence */ }
|
|
167
|
+
audioDir = source.moveAudioToPending(current.sessionId)
|
|
168
|
+
source.delete(current.sessionId, { preserveAudio: !audioDir })
|
|
169
|
+
}
|
|
170
|
+
const rawEntries = readFinalizationChunkEntries(current)
|
|
171
|
+
current = runtime.finalizationJobs.save({
|
|
172
|
+
sessionId: current.sessionId,
|
|
173
|
+
meetingPath: current.meetingPath,
|
|
174
|
+
sidecarPath: current.sidecarPath,
|
|
175
|
+
audioDir,
|
|
176
|
+
streamingWordCount: current.streamingWordCount,
|
|
177
|
+
phase: audioDir && rawEntries?.length ? 'batch_pending' : 'ops_pending',
|
|
178
|
+
claimPending: current.claimPending,
|
|
179
|
+
})
|
|
180
|
+
markCanonicalFinalizationState(current.sidecarPath, current.phase, current.claimPending)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (current.claimPending) {
|
|
184
|
+
let claimPending: boolean = current.claimPending
|
|
185
|
+
if (earlyMeetingSyncEnabled()) {
|
|
186
|
+
try {
|
|
187
|
+
await claimMeetingInOperations(current.meetingPath)
|
|
188
|
+
claimPending = false
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// Identity acceleration must never strand canonical HQ/final sync.
|
|
191
|
+
// Keep the durable bit for replay while the final handoff proceeds.
|
|
192
|
+
console.warn(
|
|
193
|
+
`[meeting/save] Early claim deferred for ${current.sessionId}: `
|
|
194
|
+
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
// Runtime rollback: disabling the canary must release old claim work.
|
|
199
|
+
claimPending = false
|
|
200
|
+
}
|
|
201
|
+
current = runtime.finalizationJobs.save({
|
|
202
|
+
sessionId: current.sessionId,
|
|
203
|
+
meetingPath: current.meetingPath,
|
|
204
|
+
sidecarPath: current.sidecarPath,
|
|
205
|
+
audioDir: current.audioDir,
|
|
206
|
+
streamingWordCount: current.streamingWordCount,
|
|
207
|
+
phase: current.phase,
|
|
208
|
+
claimPending,
|
|
209
|
+
})
|
|
210
|
+
markCanonicalFinalizationState(current.sidecarPath, current.phase, claimPending)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (current.phase === 'batch_pending') {
|
|
214
|
+
if (current.audioDir && existsSync(current.audioDir)) {
|
|
215
|
+
const rawEntries = readFinalizationChunkEntries(current)
|
|
216
|
+
const entries = rawEntries ? validReplayEntries(rawEntries) : null
|
|
217
|
+
if (!entries) throw new Error('Durable finalization sidecar has no valid chunkEntries')
|
|
218
|
+
await finalizeBatch({
|
|
219
|
+
audioDir: current.audioDir,
|
|
220
|
+
entries,
|
|
221
|
+
streamingWordCount: current.streamingWordCount,
|
|
222
|
+
meetingPath: current.meetingPath,
|
|
223
|
+
sidecarPath: current.sidecarPath,
|
|
224
|
+
sessionId: current.sessionId,
|
|
225
|
+
runBatch: runtime.runBatch,
|
|
226
|
+
})
|
|
227
|
+
} else {
|
|
228
|
+
console.warn(`[meeting/save] Pending HQ audio missing for ${current.sessionId}; preserving streaming canonical`)
|
|
229
|
+
}
|
|
230
|
+
current = runtime.finalizationJobs.save({
|
|
231
|
+
sessionId: current.sessionId,
|
|
232
|
+
meetingPath: current.meetingPath,
|
|
233
|
+
sidecarPath: current.sidecarPath,
|
|
234
|
+
audioDir: current.audioDir,
|
|
235
|
+
streamingWordCount: current.streamingWordCount,
|
|
236
|
+
phase: 'ops_pending',
|
|
237
|
+
claimPending: current.claimPending,
|
|
238
|
+
})
|
|
239
|
+
markCanonicalFinalizationState(current.sidecarPath, 'ops_pending', current.claimPending)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (cosOpsPipelineConfigured()) {
|
|
243
|
+
await handoffMeetingToOperations(current.meetingPath)
|
|
244
|
+
}
|
|
245
|
+
markCanonicalFinalizationState(current.sidecarPath, 'complete', false)
|
|
246
|
+
runtime.finalizationJobs.remove(current.sessionId)
|
|
247
|
+
finalizationRetryCounts.delete(key)
|
|
248
|
+
}).catch(error => {
|
|
249
|
+
const current = runtime.finalizationJobs.get(job.sessionId) ?? job
|
|
250
|
+
try {
|
|
251
|
+
runtime.finalizationJobs.save({
|
|
252
|
+
sessionId: current.sessionId,
|
|
253
|
+
meetingPath: current.meetingPath,
|
|
254
|
+
sidecarPath: current.sidecarPath,
|
|
255
|
+
audioDir: current.audioDir,
|
|
256
|
+
streamingWordCount: current.streamingWordCount,
|
|
257
|
+
phase: current.phase,
|
|
258
|
+
claimPending: current.claimPending,
|
|
259
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
260
|
+
})
|
|
261
|
+
} catch { /* keep the original durable job if error annotation fails */ }
|
|
262
|
+
console.error(
|
|
263
|
+
`[meeting/save] Durable finalization retained for retry (${job.sessionId}): `
|
|
264
|
+
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
265
|
+
)
|
|
266
|
+
const retryCount = finalizationRetryCounts.get(key) ?? 0
|
|
267
|
+
if (retryCount < 2) {
|
|
268
|
+
finalizationRetryCounts.set(key, retryCount + 1)
|
|
269
|
+
const retryRetained = () => {
|
|
270
|
+
if (!maintenanceAdmissionsOpen()) {
|
|
271
|
+
const waitForAdmissions = setTimeout(retryRetained, 30_000)
|
|
272
|
+
waitForAdmissions.unref()
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
const retained = runtime.finalizationJobs.get(job.sessionId)
|
|
276
|
+
if (retained) scheduleFinalizationJob(retained, runtime)
|
|
277
|
+
}
|
|
278
|
+
const retry = setTimeout(retryRetained, 60_000 * (retryCount + 1))
|
|
279
|
+
retry.unref()
|
|
280
|
+
}
|
|
281
|
+
}).finally(() => {
|
|
282
|
+
activeFinalizationJobs.delete(key)
|
|
283
|
+
lease.release()
|
|
284
|
+
})
|
|
285
|
+
runtime.scheduleBackground(task)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Boot hook: replay post-save HQ/operations work whose response already
|
|
289
|
+
* succeeded before a prior process exited. Safe to call more than once. */
|
|
290
|
+
export function resumeMeetingFinalizationJobs(): void {
|
|
291
|
+
const finalizationJobs = new MeetingFinalizationJobStore()
|
|
292
|
+
finalizationJobs.reconcileCanonicalSidecars()
|
|
293
|
+
for (const job of finalizationJobs.list()) {
|
|
294
|
+
scheduleFinalizationJob(job, {
|
|
295
|
+
finalizationJobs,
|
|
296
|
+
runBatch: runMeetingBatchPipeline,
|
|
297
|
+
scheduleBackground: task => { void task },
|
|
298
|
+
})
|
|
299
|
+
}
|
|
98
300
|
}
|
|
99
301
|
|
|
100
302
|
const defaultSessionSource: MeetingSessionSource = {
|
|
@@ -167,6 +369,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
167
369
|
const runBatch = deps.runBatch ?? runMeetingBatchPipeline
|
|
168
370
|
const scheduleBackground = deps.scheduleBackground ?? (task => { void task })
|
|
169
371
|
const emit = deps.emit ?? emitDisplay
|
|
372
|
+
const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
|
|
170
373
|
const router = Router()
|
|
171
374
|
const savingSessions = new Set<string>()
|
|
172
375
|
|
|
@@ -243,6 +446,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
243
446
|
// the sidecar by session ID so client retry/restart is idempotent.
|
|
244
447
|
const alreadySaved = store.findBySessionId(sessionId)
|
|
245
448
|
if (alreadySaved) {
|
|
449
|
+
finalizationJobs.reconcileCanonicalSidecars()
|
|
450
|
+
const pendingJob = finalizationJobs.get(sessionId)
|
|
451
|
+
if (pendingJob) scheduleFinalizationJob(pendingJob, {
|
|
452
|
+
finalizationJobs,
|
|
453
|
+
runBatch,
|
|
454
|
+
scheduleBackground,
|
|
455
|
+
sessions,
|
|
456
|
+
})
|
|
246
457
|
res.set('Cache-Control', 'private, no-store')
|
|
247
458
|
res.json(publicSaveResponse(alreadySaved, true))
|
|
248
459
|
return
|
|
@@ -279,6 +490,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
279
490
|
? durationFromTimeline
|
|
280
491
|
: Math.max(0, Date.now() - startTime)
|
|
281
492
|
const integrity = sessions.getIntegrity(sessionId)
|
|
493
|
+
const needsOperations = cosOpsPipelineConfigured()
|
|
494
|
+
const finalizationRequired = sessions.hasAudio(sessionId) || needsOperations
|
|
495
|
+
const claimPending = needsOperations && earlyMeetingSyncEnabled()
|
|
282
496
|
|
|
283
497
|
// Initial canonical text + structured metadata are published before any
|
|
284
498
|
// live state is removed or background work is scheduled.
|
|
@@ -293,8 +507,30 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
293
507
|
chunkEntries,
|
|
294
508
|
providerCandidates: sessions.getProviderCandidates(sessionId),
|
|
295
509
|
transferIntegrity: integrity,
|
|
510
|
+
finalizationRequired,
|
|
511
|
+
claimPending,
|
|
296
512
|
})
|
|
297
513
|
|
|
514
|
+
// Two-phase replay intent: the canonical sidecar above is the recovery
|
|
515
|
+
// source of truth, and this indexed job is its fast execution ledger.
|
|
516
|
+
let finalizationJob: MeetingFinalizationJob | null = null
|
|
517
|
+
if (finalizationRequired) {
|
|
518
|
+
finalizationJob = finalizationJobs.save({
|
|
519
|
+
sessionId,
|
|
520
|
+
meetingPath: saved.filepath,
|
|
521
|
+
sidecarPath: saved.sidecarPath,
|
|
522
|
+
audioDir: null,
|
|
523
|
+
streamingWordCount: countWords(transcript),
|
|
524
|
+
phase: 'capture_pending',
|
|
525
|
+
claimPending,
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Progressive HQ is a disposable cache, not a meeting owner. Abort any
|
|
530
|
+
// in-flight prefill before the session-audio directory is atomically
|
|
531
|
+
// renamed so no child can publish into a stale path after Stop.
|
|
532
|
+
await stopProgressiveHqSession(sessionId)
|
|
533
|
+
|
|
298
534
|
// Wait for every raw-WAV write before rename. If any write failed, retain
|
|
299
535
|
// surviving audio for recovery but do not run an incomplete batch.
|
|
300
536
|
let audioWritesReady = true
|
|
@@ -332,59 +568,37 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
332
568
|
console.warn('[meeting/save] Display notification failed after durable save:', error)
|
|
333
569
|
}
|
|
334
570
|
|
|
571
|
+
const canBatch = audioWritesReady && pendingAudioDir && chunkEntries.length > 0
|
|
572
|
+
if (canBatch || needsOperations) {
|
|
573
|
+
// The replay record is durable before the response. A lost response,
|
|
574
|
+
// process crash, or macOS update can therefore resume the exact same
|
|
575
|
+
// meeting without minting a second scribe.
|
|
576
|
+
finalizationJob = finalizationJobs.save({
|
|
577
|
+
sessionId,
|
|
578
|
+
meetingPath: saved.filepath,
|
|
579
|
+
sidecarPath: saved.sidecarPath,
|
|
580
|
+
audioDir: canBatch ? pendingAudioDir : null,
|
|
581
|
+
streamingWordCount: countWords(transcript),
|
|
582
|
+
phase: canBatch ? 'batch_pending' : 'ops_pending',
|
|
583
|
+
claimPending,
|
|
584
|
+
})
|
|
585
|
+
markCanonicalFinalizationState(saved.sidecarPath, finalizationJob.phase, claimPending)
|
|
586
|
+
} else if (finalizationJob) {
|
|
587
|
+
markCanonicalFinalizationState(saved.sidecarPath, 'complete', false)
|
|
588
|
+
finalizationJobs.remove(sessionId)
|
|
589
|
+
finalizationJob = null
|
|
590
|
+
}
|
|
591
|
+
|
|
335
592
|
res.set('Cache-Control', 'private, no-store')
|
|
336
593
|
res.json(publicSaveResponse(saved))
|
|
337
594
|
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
phase: 'queued',
|
|
595
|
+
if (finalizationJob) {
|
|
596
|
+
scheduleFinalizationJob(finalizationJob, {
|
|
597
|
+
finalizationJobs,
|
|
598
|
+
runBatch,
|
|
599
|
+
scheduleBackground,
|
|
600
|
+
sessions,
|
|
345
601
|
})
|
|
346
|
-
const task = Promise.resolve().then(async () => {
|
|
347
|
-
batchLease.setPhase('active')
|
|
348
|
-
try {
|
|
349
|
-
await finalizeBatch({
|
|
350
|
-
audioDir: pendingAudioDir,
|
|
351
|
-
entries: chunkEntries.map(entry => ({ ...entry, chunk: { ...entry.chunk } })),
|
|
352
|
-
streamingWordCount: countWords(transcript),
|
|
353
|
-
meetingPath: saved.filepath,
|
|
354
|
-
sidecarPath: saved.sidecarPath,
|
|
355
|
-
runBatch,
|
|
356
|
-
})
|
|
357
|
-
} catch (error) {
|
|
358
|
-
// Raw audio deliberately remains for bounded cleanup / retry.
|
|
359
|
-
console.error(
|
|
360
|
-
`[meeting/save] Batch finalization failed for ${sessionId}: `
|
|
361
|
-
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
362
|
-
)
|
|
363
|
-
}
|
|
364
|
-
// Always hand off the durable local scribe (streaming or batch) into
|
|
365
|
-
// operations/ when COS pipeline is configured — this is what was
|
|
366
|
-
// missing on managed public server and left today's G2 files unsynced.
|
|
367
|
-
if (cosOpsPipelineConfigured()) {
|
|
368
|
-
await handoffMeetingToOperations(saved.filepath)
|
|
369
|
-
}
|
|
370
|
-
}).catch(error => {
|
|
371
|
-
console.error(
|
|
372
|
-
`[meeting/save] G2 ops handoff failed for ${sessionId}: `
|
|
373
|
-
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
374
|
-
)
|
|
375
|
-
}).finally(() => batchLease.release())
|
|
376
|
-
scheduleBackground(task)
|
|
377
|
-
} else if (cosOpsPipelineConfigured()) {
|
|
378
|
-
// No HQ batch (no audio / incomplete writes) — still hand off the
|
|
379
|
-
// streaming scribe into operations when COS pipeline is configured.
|
|
380
|
-
scheduleBackground(
|
|
381
|
-
handoffMeetingToOperations(saved.filepath).catch(error => {
|
|
382
|
-
console.error(
|
|
383
|
-
`[meeting/save] G2 ops handoff failed for ${sessionId}: `
|
|
384
|
-
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
385
|
-
)
|
|
386
|
-
}),
|
|
387
|
-
)
|
|
388
602
|
}
|
|
389
603
|
} catch (error) {
|
|
390
604
|
if (error instanceof MeetingStoreError) {
|
|
@@ -654,12 +868,14 @@ async function finalizeBatch(options: {
|
|
|
654
868
|
streamingWordCount: number
|
|
655
869
|
meetingPath: string
|
|
656
870
|
sidecarPath: string
|
|
871
|
+
sessionId?: string
|
|
657
872
|
runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
|
|
658
873
|
}): Promise<void> {
|
|
659
874
|
const result = await options.runBatch(
|
|
660
875
|
options.audioDir,
|
|
661
876
|
options.entries,
|
|
662
877
|
options.streamingWordCount,
|
|
878
|
+
options.sessionId,
|
|
663
879
|
)
|
|
664
880
|
let transcriptApplied = false
|
|
665
881
|
let metadataPersisted = false
|
|
@@ -29,11 +29,13 @@ import {
|
|
|
29
29
|
} from '../lib/openai-whisper-budget.js'
|
|
30
30
|
import {
|
|
31
31
|
stripInlineHallucinations as sharedStripInlineHallucinations,
|
|
32
|
+
stripInlineHallucinationsOneShot,
|
|
32
33
|
isFullHallucination as sharedIsFullHallucination,
|
|
33
34
|
clearSessionHallucinationState,
|
|
34
35
|
streamSilenceDropReason,
|
|
35
36
|
isVocabEchoOnly,
|
|
36
37
|
} from '../lib/hallucination-filter.js'
|
|
38
|
+
import { transcribeWhisperMeetingPreview } from '../lib/whisper-preview.js'
|
|
37
39
|
import { dataPath } from '../lib/data-dir.js'
|
|
38
40
|
import {
|
|
39
41
|
countChunkWavs,
|
|
@@ -57,6 +59,10 @@ import {
|
|
|
57
59
|
} from '../lib/maintenance-lifecycle.js'
|
|
58
60
|
import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
|
|
59
61
|
import { preemptMetalBatchForLive, registerLiveActivityProbe } from '../lib/whisper-metal-gate.js'
|
|
62
|
+
import {
|
|
63
|
+
scheduleProgressiveHqCheckpoint,
|
|
64
|
+
stopProgressiveHqSession,
|
|
65
|
+
} from '../lib/meeting-batch-transcribe.js'
|
|
60
66
|
|
|
61
67
|
/** Preempt hook 2 of 2 (1C): the recording_chunk backstop. Live audio is
|
|
62
68
|
* arriving, so any Metal batch must yield the GPU now. This covers recovery,
|
|
@@ -101,6 +107,12 @@ function ensurePrivateDirectory(path: string): void {
|
|
|
101
107
|
// Rollback: COS_WHISPER_STRIP_BRAND_URLS=0 (URL drops), COS_WHISPER_THANKYOU_FILTER=0.
|
|
102
108
|
const STRIP_BRAND_URLS = process.env.COS_WHISPER_STRIP_BRAND_URLS !== '0'
|
|
103
109
|
const THANKYOU_FILTER = process.env.COS_WHISPER_THANKYOU_FILTER !== '0'
|
|
110
|
+
const MEETING_PREVIEW_MAX_BYTES = 512 * 1024
|
|
111
|
+
let meetingPreviewBusy = false
|
|
112
|
+
|
|
113
|
+
function meetingTurboPreviewEnabled(): boolean {
|
|
114
|
+
return process.env.COS_WHISPER_MEETING_PREVIEW === '1'
|
|
115
|
+
}
|
|
104
116
|
|
|
105
117
|
// Audio persistence: save G2-mic chunks for speakers who need more training data
|
|
106
118
|
const AUDIO_SAVE_DIR = dataPath('training-audio')
|
|
@@ -912,13 +924,68 @@ export function getSessionChunkEntries(sessionId: string): IndexedTranscriptChun
|
|
|
912
924
|
const session = sessions.get(sessionId)
|
|
913
925
|
if (!session) return null
|
|
914
926
|
const entries: IndexedTranscriptChunk[] = []
|
|
915
|
-
|
|
927
|
+
const maxIndex = Math.max(
|
|
928
|
+
session.chunks.length - 1,
|
|
929
|
+
...Object.keys(session.emptyCompletions ?? {}).map(Number).filter(Number.isInteger),
|
|
930
|
+
)
|
|
931
|
+
for (let chunkIndex = 0; chunkIndex <= maxIndex; chunkIndex++) {
|
|
916
932
|
const chunk = session.chunks[chunkIndex]
|
|
917
|
-
if (chunk?.text)
|
|
933
|
+
if (chunk?.text) {
|
|
934
|
+
entries.push({ chunkIndex, chunk })
|
|
935
|
+
continue
|
|
936
|
+
}
|
|
937
|
+
const empty = session.emptyCompletions?.[String(chunkIndex)]
|
|
938
|
+
if (empty) {
|
|
939
|
+
entries.push({
|
|
940
|
+
chunkIndex,
|
|
941
|
+
chunk: {
|
|
942
|
+
text: '',
|
|
943
|
+
speaker: empty.speaker || 'Ext',
|
|
944
|
+
elapsed: empty.elapsed,
|
|
945
|
+
similarity: 0,
|
|
946
|
+
backend: empty.backend,
|
|
947
|
+
asrProvider: empty.asrProvider === 'iphone-whisperkit-beta'
|
|
948
|
+
? 'iphone-whisperkit-beta'
|
|
949
|
+
: empty.asrProvider === 'server-whisper'
|
|
950
|
+
? 'server-whisper'
|
|
951
|
+
: undefined,
|
|
952
|
+
fallbackReason: empty.fallbackReason,
|
|
953
|
+
},
|
|
954
|
+
})
|
|
955
|
+
}
|
|
918
956
|
}
|
|
919
957
|
return entries
|
|
920
958
|
}
|
|
921
959
|
|
|
960
|
+
/** Private local path for guarded progressive HQ cache work. */
|
|
961
|
+
export function getSessionAudioDirectory(sessionId: string): string | null {
|
|
962
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
|
|
963
|
+
const path = resolve(SESSION_AUDIO_DIR, sessionId)
|
|
964
|
+
return existsSync(path) ? path : null
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Snapshot the durable ASR-completion ledger for progressive HQ admission.
|
|
968
|
+
* Includes canonical and intentionally empty/silent chunks. */
|
|
969
|
+
export function getSessionAsrCompletedIndices(sessionId: string): number[] {
|
|
970
|
+
return [...(sessions.get(sessionId)?.asrCompletedIndices ?? [])]
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function scheduleSessionProgressiveHq(sessionId: string): void {
|
|
974
|
+
// Preserve the default-off contract at the route boundary. In addition to
|
|
975
|
+
// avoiding any cache work, this keeps legacy/local-first transcription
|
|
976
|
+
// independent of the progressive HQ capability surface when the canary is
|
|
977
|
+
// disabled (including partial test and recovery environments).
|
|
978
|
+
if (process.env.COS_MEETING_PROGRESSIVE_HQ !== '1') return
|
|
979
|
+
const audioDir = getSessionAudioDirectory(sessionId)
|
|
980
|
+
if (!audioDir) return
|
|
981
|
+
scheduleProgressiveHqCheckpoint(
|
|
982
|
+
sessionId,
|
|
983
|
+
audioDir,
|
|
984
|
+
getSessionChunkEntries(sessionId) ?? [],
|
|
985
|
+
getSessionAsrCompletedIndices(sessionId),
|
|
986
|
+
)
|
|
987
|
+
}
|
|
988
|
+
|
|
922
989
|
/** Get session start time */
|
|
923
990
|
export function getSessionStartTime(sessionId: string): number | null {
|
|
924
991
|
return sessions.get(sessionId)?.startTime ?? null
|
|
@@ -1036,6 +1103,9 @@ function closeTranscriptSession(
|
|
|
1036
1103
|
reason: ClosedTranscriptSession['reason'],
|
|
1037
1104
|
options: { preserveAudio?: boolean } = {},
|
|
1038
1105
|
): void {
|
|
1106
|
+
// Progressive HQ is disposable cache state. Every terminal session path —
|
|
1107
|
+
// save, expiry, quarantine, or abandonment — must release the single owner.
|
|
1108
|
+
void stopProgressiveHqSession(sessionId)
|
|
1039
1109
|
const session = sessions.get(sessionId)
|
|
1040
1110
|
const now = Date.now()
|
|
1041
1111
|
const receivedIndices = [...(session?.receivedIndices ?? [])]
|
|
@@ -1180,6 +1250,21 @@ async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Pr
|
|
|
1180
1250
|
return Buffer.concat(buffers)
|
|
1181
1251
|
}
|
|
1182
1252
|
|
|
1253
|
+
async function readBoundedRawBody(
|
|
1254
|
+
req: AsyncIterable<Buffer | Uint8Array | string>,
|
|
1255
|
+
maxBytes: number,
|
|
1256
|
+
): Promise<Buffer> {
|
|
1257
|
+
const buffers: Buffer[] = []
|
|
1258
|
+
let total = 0
|
|
1259
|
+
for await (const chunk of req) {
|
|
1260
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
1261
|
+
total += buffer.length
|
|
1262
|
+
if (total > maxBytes) throw makeHttpError(413, 'audio chunk too large', 'chunk_too_large')
|
|
1263
|
+
buffers.push(buffer)
|
|
1264
|
+
}
|
|
1265
|
+
return Buffer.concat(buffers)
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1183
1268
|
async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number, audioBuffer: Buffer): Promise<void> {
|
|
1184
1269
|
const sessionDir = resolve(SESSION_AUDIO_DIR, sessionId)
|
|
1185
1270
|
ensurePrivateDirectory(sessionDir)
|
|
@@ -1587,6 +1672,7 @@ async function processStreamChunk(opts: {
|
|
|
1587
1672
|
session.emptyCompletions[String(chunkIndex)] = emptyCompletion
|
|
1588
1673
|
recordAsrCompleted(session, chunkIndex)
|
|
1589
1674
|
persistSessionRequired(sessionId)
|
|
1675
|
+
scheduleSessionProgressiveHq(sessionId)
|
|
1590
1676
|
return emptyChunkResponse(emptyCompletion, sessionId, chunkIndex)
|
|
1591
1677
|
}
|
|
1592
1678
|
|
|
@@ -1631,6 +1717,8 @@ async function processStreamChunk(opts: {
|
|
|
1631
1717
|
persistSessionRequired(sessionId)
|
|
1632
1718
|
console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
|
|
1633
1719
|
|
|
1720
|
+
scheduleSessionProgressiveHq(sessionId)
|
|
1721
|
+
|
|
1634
1722
|
emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
|
|
1635
1723
|
// Live Cues feed — fire-and-forget, NEVER awaited: transcription must not
|
|
1636
1724
|
// block on a cue, and no LLM runs on this path. .catch() is required — a
|
|
@@ -1664,6 +1752,70 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
|
|
|
1664
1752
|
return res.status(status).json({ error: errMsg(err), reason: (err as any)?.reason })
|
|
1665
1753
|
}
|
|
1666
1754
|
|
|
1755
|
+
/**
|
|
1756
|
+
* Default-off meeting preview canary.
|
|
1757
|
+
*
|
|
1758
|
+
* This route deliberately owns no meeting session, recovery ledger, speaker
|
|
1759
|
+
* attribution, or persistence. It accepts a pinned copy of the still-open
|
|
1760
|
+
* phrase and returns provisional Turbo text. Any contention or failure is a
|
|
1761
|
+
* 204 cosmetic miss; the canonical /transcribe-stream upload remains the only
|
|
1762
|
+
* durable and speaker-attributed result.
|
|
1763
|
+
*/
|
|
1764
|
+
transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
|
|
1765
|
+
try {
|
|
1766
|
+
if (!meetingTurboPreviewEnabled() || !maintenanceAdmissionsOpen()) {
|
|
1767
|
+
return void res.status(204).send()
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
const headerPin = req.get('X-COS-Server-Instance')?.trim() ?? ''
|
|
1771
|
+
const queryPin = typeof req.query.serverInstanceId === 'string'
|
|
1772
|
+
? req.query.serverInstanceId.trim()
|
|
1773
|
+
: ''
|
|
1774
|
+
if (!headerPin && !queryPin) {
|
|
1775
|
+
throw makeHttpError(400, 'server identity pin required', 'server_identity_pin_required')
|
|
1776
|
+
}
|
|
1777
|
+
const serverInstanceId = assertPinnedServerIdentity(headerPin, queryPin)
|
|
1778
|
+
const sessionId = String(req.query.sessionId ?? '')
|
|
1779
|
+
validateSessionId(sessionId)
|
|
1780
|
+
const chunkIndex = Number(req.query.chunkIndex)
|
|
1781
|
+
validateChunkIndex(chunkIndex)
|
|
1782
|
+
const previewGen = Number(req.query.previewGen)
|
|
1783
|
+
if (!Number.isInteger(previewGen) || previewGen < 0 || previewGen > 1_000_000_000) {
|
|
1784
|
+
throw makeHttpError(400, 'invalid previewGen', 'invalid_preview_generation')
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
const audio = await readBoundedRawBody(req, MEETING_PREVIEW_MAX_BYTES)
|
|
1788
|
+
if (audio.length < 100) throw makeHttpError(400, 'audio too short', 'audio_too_short')
|
|
1789
|
+
if (!maintenanceAdmissionsOpen() || meetingPreviewBusy) {
|
|
1790
|
+
return void res.status(204).send()
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
meetingPreviewBusy = true
|
|
1794
|
+
try {
|
|
1795
|
+
const result = await transcribeWhisperMeetingPreview(audio)
|
|
1796
|
+
if (!result) return void res.status(204).send()
|
|
1797
|
+
const cleaned = stripInlineHallucinationsOneShot(result.text.trim()).trim()
|
|
1798
|
+
if (!cleaned || sharedIsFullHallucination(cleaned)) {
|
|
1799
|
+
return void res.status(204).send()
|
|
1800
|
+
}
|
|
1801
|
+
return void res.json({
|
|
1802
|
+
sessionId,
|
|
1803
|
+
chunkIndex,
|
|
1804
|
+
previewGen,
|
|
1805
|
+
text: cleaned,
|
|
1806
|
+
provisional: true,
|
|
1807
|
+
model: result.model,
|
|
1808
|
+
backend: result.backend,
|
|
1809
|
+
serverInstanceId,
|
|
1810
|
+
})
|
|
1811
|
+
} finally {
|
|
1812
|
+
meetingPreviewBusy = false
|
|
1813
|
+
}
|
|
1814
|
+
} catch (err: unknown) {
|
|
1815
|
+
sendStreamError(res, err)
|
|
1816
|
+
}
|
|
1817
|
+
})
|
|
1818
|
+
|
|
1667
1819
|
transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
|
|
1668
1820
|
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
1669
1821
|
try {
|