@gotcos/glasses-server 6.21.7 → 6.21.10
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 +48 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/server/index.ts +5 -1
- package/server/lib/audio-retention.ts +50 -0
- 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/speaker-calibration-log.ts +86 -0
- package/server/lib/speaker-embeddings.ts +162 -36
- package/server/lib/voice-profile-store.ts +365 -0
- package/server/lib/whisper-local.ts +77 -6
- package/server/routes/health.ts +26 -3
- package/server/routes/meeting.ts +268 -52
- package/server/routes/transcribe-stream.ts +113 -2
- package/server/routes/voice.ts +268 -22
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
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from '../lib/hallucination-filter.js'
|
|
38
38
|
import { transcribeWhisperMeetingPreview } from '../lib/whisper-preview.js'
|
|
39
39
|
import { dataPath } from '../lib/data-dir.js'
|
|
40
|
+
import { ageHours, partitionExpiredAudio } from '../lib/audio-retention.js'
|
|
40
41
|
import {
|
|
41
42
|
countChunkWavs,
|
|
42
43
|
purgeExpiredQuarantine,
|
|
@@ -59,6 +60,10 @@ import {
|
|
|
59
60
|
} from '../lib/maintenance-lifecycle.js'
|
|
60
61
|
import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
|
|
61
62
|
import { preemptMetalBatchForLive, registerLiveActivityProbe } from '../lib/whisper-metal-gate.js'
|
|
63
|
+
import {
|
|
64
|
+
scheduleProgressiveHqCheckpoint,
|
|
65
|
+
stopProgressiveHqSession,
|
|
66
|
+
} from '../lib/meeting-batch-transcribe.js'
|
|
62
67
|
|
|
63
68
|
/** Preempt hook 2 of 2 (1C): the recording_chunk backstop. Live audio is
|
|
64
69
|
* arriving, so any Metal batch must yield the GPU now. This covers recovery,
|
|
@@ -114,6 +119,13 @@ function meetingTurboPreviewEnabled(): boolean {
|
|
|
114
119
|
const AUDIO_SAVE_DIR = dataPath('training-audio')
|
|
115
120
|
ensurePrivateDirectory(AUDIO_SAVE_DIR)
|
|
116
121
|
const MAX_SAVED_CHUNKS_PER_SPEAKER = 30 // ~5 min of audio per speaker, cleaned after training
|
|
122
|
+
// Age bound. The count cap above is NOT a retention policy: a speaker who never
|
|
123
|
+
// gets trained keeps 30 WAVs of their voice indefinitely, and the only cleanup
|
|
124
|
+
// path was a manual /voice/train-g2 call. ext-audio has had a 72h sweep since it
|
|
125
|
+
// was introduced; this closes the same gap for training audio. Deliberately
|
|
126
|
+
// longer than ext-audio's window because these chunks are the raw material for
|
|
127
|
+
// deliberate enrollment, not opportunistic retroactive matching.
|
|
128
|
+
const TRAINING_AUDIO_TTL_MS = 14 * 24 * 60 * 60 * 1000 // 14 days
|
|
117
129
|
|
|
118
130
|
// Unrecognized speaker audio: save Ext chunks for retroactive enrollment
|
|
119
131
|
const EXT_AUDIO_DIR = dataPath('ext-audio')
|
|
@@ -738,6 +750,44 @@ setInterval(() => {
|
|
|
738
750
|
}
|
|
739
751
|
} catch {}
|
|
740
752
|
|
|
753
|
+
// Purge training-audio WAVs past the retention window. Per file, not per
|
|
754
|
+
// directory: chunks for one speaker accumulate over weeks, so an
|
|
755
|
+
// all-or-nothing directory check would either keep month-old audio alive
|
|
756
|
+
// because one chunk is fresh, or delete today's capture because the directory
|
|
757
|
+
// is old.
|
|
758
|
+
try {
|
|
759
|
+
if (existsSync(AUDIO_SAVE_DIR)) {
|
|
760
|
+
const now = Date.now()
|
|
761
|
+
for (const dir of readdirSync(AUDIO_SAVE_DIR)) {
|
|
762
|
+
const dirPath = resolve(AUDIO_SAVE_DIR, dir)
|
|
763
|
+
try {
|
|
764
|
+
if (!statSync(dirPath).isDirectory()) continue
|
|
765
|
+
const candidates = readdirSync(dirPath)
|
|
766
|
+
.filter(f => f.endsWith('.wav'))
|
|
767
|
+
.map(name => {
|
|
768
|
+
let mtimeMs = 0
|
|
769
|
+
try { mtimeMs = statSync(resolve(dirPath, name)).mtimeMs } catch {}
|
|
770
|
+
return { name, mtimeMs }
|
|
771
|
+
})
|
|
772
|
+
const { expired, retained } = partitionExpiredAudio(candidates, now, TRAINING_AUDIO_TTL_MS)
|
|
773
|
+
for (const file of expired) {
|
|
774
|
+
try { unlinkSync(resolve(dirPath, file.name)) } catch {}
|
|
775
|
+
}
|
|
776
|
+
if (expired.length > 0) {
|
|
777
|
+
const oldest = Math.min(...expired.map(f => f.mtimeMs))
|
|
778
|
+
console.log(
|
|
779
|
+
`[training-audio] Purged ${expired.length} expired chunk(s) for ${dir}`,
|
|
780
|
+
`(oldest ${ageHours(oldest, now)}h, ${retained.length} retained)`,
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
if (retained.length === 0 && readdirSync(dirPath).length === 0) {
|
|
784
|
+
try { rmSync(dirPath, { recursive: true, force: true }) } catch {}
|
|
785
|
+
}
|
|
786
|
+
} catch {}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
} catch {}
|
|
790
|
+
|
|
741
791
|
// Purge ext-audio dirs older than 72 hours
|
|
742
792
|
try {
|
|
743
793
|
if (existsSync(EXT_AUDIO_DIR)) {
|
|
@@ -920,13 +970,68 @@ export function getSessionChunkEntries(sessionId: string): IndexedTranscriptChun
|
|
|
920
970
|
const session = sessions.get(sessionId)
|
|
921
971
|
if (!session) return null
|
|
922
972
|
const entries: IndexedTranscriptChunk[] = []
|
|
923
|
-
|
|
973
|
+
const maxIndex = Math.max(
|
|
974
|
+
session.chunks.length - 1,
|
|
975
|
+
...Object.keys(session.emptyCompletions ?? {}).map(Number).filter(Number.isInteger),
|
|
976
|
+
)
|
|
977
|
+
for (let chunkIndex = 0; chunkIndex <= maxIndex; chunkIndex++) {
|
|
924
978
|
const chunk = session.chunks[chunkIndex]
|
|
925
|
-
if (chunk?.text)
|
|
979
|
+
if (chunk?.text) {
|
|
980
|
+
entries.push({ chunkIndex, chunk })
|
|
981
|
+
continue
|
|
982
|
+
}
|
|
983
|
+
const empty = session.emptyCompletions?.[String(chunkIndex)]
|
|
984
|
+
if (empty) {
|
|
985
|
+
entries.push({
|
|
986
|
+
chunkIndex,
|
|
987
|
+
chunk: {
|
|
988
|
+
text: '',
|
|
989
|
+
speaker: empty.speaker || 'Ext',
|
|
990
|
+
elapsed: empty.elapsed,
|
|
991
|
+
similarity: 0,
|
|
992
|
+
backend: empty.backend,
|
|
993
|
+
asrProvider: empty.asrProvider === 'iphone-whisperkit-beta'
|
|
994
|
+
? 'iphone-whisperkit-beta'
|
|
995
|
+
: empty.asrProvider === 'server-whisper'
|
|
996
|
+
? 'server-whisper'
|
|
997
|
+
: undefined,
|
|
998
|
+
fallbackReason: empty.fallbackReason,
|
|
999
|
+
},
|
|
1000
|
+
})
|
|
1001
|
+
}
|
|
926
1002
|
}
|
|
927
1003
|
return entries
|
|
928
1004
|
}
|
|
929
1005
|
|
|
1006
|
+
/** Private local path for guarded progressive HQ cache work. */
|
|
1007
|
+
export function getSessionAudioDirectory(sessionId: string): string | null {
|
|
1008
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
|
|
1009
|
+
const path = resolve(SESSION_AUDIO_DIR, sessionId)
|
|
1010
|
+
return existsSync(path) ? path : null
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Snapshot the durable ASR-completion ledger for progressive HQ admission.
|
|
1014
|
+
* Includes canonical and intentionally empty/silent chunks. */
|
|
1015
|
+
export function getSessionAsrCompletedIndices(sessionId: string): number[] {
|
|
1016
|
+
return [...(sessions.get(sessionId)?.asrCompletedIndices ?? [])]
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function scheduleSessionProgressiveHq(sessionId: string): void {
|
|
1020
|
+
// Preserve the default-off contract at the route boundary. In addition to
|
|
1021
|
+
// avoiding any cache work, this keeps legacy/local-first transcription
|
|
1022
|
+
// independent of the progressive HQ capability surface when the canary is
|
|
1023
|
+
// disabled (including partial test and recovery environments).
|
|
1024
|
+
if (process.env.COS_MEETING_PROGRESSIVE_HQ !== '1') return
|
|
1025
|
+
const audioDir = getSessionAudioDirectory(sessionId)
|
|
1026
|
+
if (!audioDir) return
|
|
1027
|
+
scheduleProgressiveHqCheckpoint(
|
|
1028
|
+
sessionId,
|
|
1029
|
+
audioDir,
|
|
1030
|
+
getSessionChunkEntries(sessionId) ?? [],
|
|
1031
|
+
getSessionAsrCompletedIndices(sessionId),
|
|
1032
|
+
)
|
|
1033
|
+
}
|
|
1034
|
+
|
|
930
1035
|
/** Get session start time */
|
|
931
1036
|
export function getSessionStartTime(sessionId: string): number | null {
|
|
932
1037
|
return sessions.get(sessionId)?.startTime ?? null
|
|
@@ -1044,6 +1149,9 @@ function closeTranscriptSession(
|
|
|
1044
1149
|
reason: ClosedTranscriptSession['reason'],
|
|
1045
1150
|
options: { preserveAudio?: boolean } = {},
|
|
1046
1151
|
): void {
|
|
1152
|
+
// Progressive HQ is disposable cache state. Every terminal session path —
|
|
1153
|
+
// save, expiry, quarantine, or abandonment — must release the single owner.
|
|
1154
|
+
void stopProgressiveHqSession(sessionId)
|
|
1047
1155
|
const session = sessions.get(sessionId)
|
|
1048
1156
|
const now = Date.now()
|
|
1049
1157
|
const receivedIndices = [...(session?.receivedIndices ?? [])]
|
|
@@ -1610,6 +1718,7 @@ async function processStreamChunk(opts: {
|
|
|
1610
1718
|
session.emptyCompletions[String(chunkIndex)] = emptyCompletion
|
|
1611
1719
|
recordAsrCompleted(session, chunkIndex)
|
|
1612
1720
|
persistSessionRequired(sessionId)
|
|
1721
|
+
scheduleSessionProgressiveHq(sessionId)
|
|
1613
1722
|
return emptyChunkResponse(emptyCompletion, sessionId, chunkIndex)
|
|
1614
1723
|
}
|
|
1615
1724
|
|
|
@@ -1654,6 +1763,8 @@ async function processStreamChunk(opts: {
|
|
|
1654
1763
|
persistSessionRequired(sessionId)
|
|
1655
1764
|
console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
|
|
1656
1765
|
|
|
1766
|
+
scheduleSessionProgressiveHq(sessionId)
|
|
1767
|
+
|
|
1657
1768
|
emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
|
|
1658
1769
|
// Live Cues feed — fire-and-forget, NEVER awaited: transcription must not
|
|
1659
1770
|
// block on a cue, and no LLM runs on this path. .catch() is required — a
|