@gotcos/glasses-server 6.59.0 → 6.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/CHANGELOG.md +66 -0
- package/README.md +142 -7
- package/bin/hooks/cos-session-hook +24 -4
- package/managed-runtime-contract.json +6 -1
- package/package.json +4 -1
- package/server/index.ts +34 -2
- package/server/lib/claude-hooks-installer.ts +120 -17
- package/server/lib/client-liveness.ts +92 -4
- package/server/lib/diarizer-backend.ts +163 -0
- package/server/lib/meeting-relabel-enrolment.ts +44 -7
- package/server/lib/meeting-store.ts +3 -1
- package/server/lib/nemotron-cli.ts +256 -0
- package/server/lib/nemotron-final.ts +979 -0
- package/server/lib/nemotron-live.ts +693 -0
- package/server/lib/nemotron-segments.ts +358 -0
- package/server/lib/permission-broker.ts +560 -31
- package/server/lib/session-hooks-runtime.ts +23 -0
- package/server/lib/speaker-embeddings.ts +7 -4
- package/server/lib/voiceprint-floor.ts +4 -0
- package/server/routes/health.ts +10 -0
- package/server/routes/meeting.ts +55 -2
- package/server/routes/permission-broker.ts +37 -3
- package/server/routes/transcribe-stream.ts +68 -5
|
@@ -335,6 +335,13 @@ export interface SessionHooksHealth {
|
|
|
335
335
|
state: HookStatus['state']
|
|
336
336
|
installed: boolean
|
|
337
337
|
scriptSha: string | null
|
|
338
|
+
/**
|
|
339
|
+
* 6.60.0: our PermissionRequest hook's `timeout` in the Claude settings file (630 after
|
|
340
|
+
* Install hooks, 130 on an install from before 6.60.0), or null when it cannot be read.
|
|
341
|
+
*/
|
|
342
|
+
permissionHookTimeoutS: number | null
|
|
343
|
+
/** 6.60.0: the only drift is that earlier PermissionRequest wait (`HookStatus.priorWaitOnly`). */
|
|
344
|
+
priorWaitOnly: boolean
|
|
338
345
|
tokenPresent: boolean
|
|
339
346
|
lastEventAt: string | null
|
|
340
347
|
spoolBacklog: number
|
|
@@ -377,6 +384,8 @@ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth }
|
|
|
377
384
|
state: status.state,
|
|
378
385
|
installed: status.installed,
|
|
379
386
|
scriptSha: status.scriptSha,
|
|
387
|
+
permissionHookTimeoutS: status.permissionHookTimeoutS,
|
|
388
|
+
priorWaitOnly: status.priorWaitOnly,
|
|
380
389
|
tokenPresent: status.tokenPresent,
|
|
381
390
|
lastEventAt: newest ? new Date(newest).toISOString() : null,
|
|
382
391
|
spoolBacklog: spool?.backlog ?? 0,
|
|
@@ -398,6 +407,14 @@ export function signalFor(sessionId: string): SessionSignal | undefined {
|
|
|
398
407
|
return sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId)
|
|
399
408
|
}
|
|
400
409
|
|
|
410
|
+
/** 6.60.0: told when a row derives `ended`, so the broker stops saying the Mac is waiting on it. */
|
|
411
|
+
const rowEndedListeners = new Set<(sessionId: string) => void>()
|
|
412
|
+
|
|
413
|
+
export function onSessionRowEnded(listener: (sessionId: string) => void): () => void {
|
|
414
|
+
rowEndedListeners.add(listener)
|
|
415
|
+
return () => { rowEndedListeners.delete(listener) }
|
|
416
|
+
}
|
|
417
|
+
|
|
401
418
|
/**
|
|
402
419
|
* Derive with the two-scan memory kept here, keyed by full id when known. Rows the
|
|
403
420
|
* caller has no facts for at all get nothing, so an older payload stays byte-identical.
|
|
@@ -419,6 +436,11 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
|
|
|
419
436
|
prevDeadSince: prev?.since ?? null,
|
|
420
437
|
attachedTurn: input.attachedTurn === true,
|
|
421
438
|
})
|
|
439
|
+
if (derived.agent_state === 'ended') {
|
|
440
|
+
for (const listener of rowEndedListeners) {
|
|
441
|
+
try { listener(key) } catch { /* a listener never breaks a row */ }
|
|
442
|
+
}
|
|
443
|
+
}
|
|
422
444
|
// A reader with no registry facts (the live feed derives from the signal alone) must
|
|
423
445
|
// not touch the two-scan memory the row readers keep: a derive that saw no registry
|
|
424
446
|
// would reset a dead pid's count between two list requests.
|
|
@@ -431,6 +453,7 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
|
|
|
431
453
|
}
|
|
432
454
|
|
|
433
455
|
export function __resetSessionHooksForTests(): void {
|
|
456
|
+
rowEndedListeners.clear()
|
|
434
457
|
sessionSignalStore.__resetForTests()
|
|
435
458
|
deadById.clear()
|
|
436
459
|
parentCache.clear()
|
|
@@ -372,6 +372,9 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
|
|
|
372
372
|
export function identifySpeaker(
|
|
373
373
|
wavBuffer: Buffer,
|
|
374
374
|
expectedSpeakers?: string[],
|
|
375
|
+
/** 6.61.0: `event` tags the calibration rows, e.g. 'nemotron-track' for one
|
|
376
|
+
* Nemotron track's audio, so they are never read as whole-chunk checks. */
|
|
377
|
+
options: { event?: string } = {},
|
|
375
378
|
): { speaker: string; similarity: number; embedding?: Float32Array } | null {
|
|
376
379
|
if (!extractor || !manager) return null
|
|
377
380
|
|
|
@@ -385,7 +388,7 @@ export function identifySpeaker(
|
|
|
385
388
|
const isOwner = manager.verify({ name: owner, v: embedding, threshold: VERIFY_THRESHOLD })
|
|
386
389
|
if (isOwner) {
|
|
387
390
|
const similarity = computeCosineSimilarity(embedding, owner)
|
|
388
|
-
logCalibration(owner, similarity, true)
|
|
391
|
+
logCalibration(owner, similarity, true, options.event)
|
|
389
392
|
return { speaker: owner, similarity, embedding }
|
|
390
393
|
}
|
|
391
394
|
}
|
|
@@ -398,7 +401,7 @@ export function identifySpeaker(
|
|
|
398
401
|
const matches = manager.verify({ name, v: embedding, threshold: SEARCH_THRESHOLD })
|
|
399
402
|
if (matches) {
|
|
400
403
|
const similarity = computeCosineSimilarity(embedding, name)
|
|
401
|
-
logCalibration(name, similarity, true)
|
|
404
|
+
logCalibration(name, similarity, true, options.event)
|
|
402
405
|
return { speaker: name, similarity, embedding }
|
|
403
406
|
}
|
|
404
407
|
}
|
|
@@ -408,14 +411,14 @@ export function identifySpeaker(
|
|
|
408
411
|
const found = manager.search({ v: embedding, threshold: SEARCH_THRESHOLD })
|
|
409
412
|
if (found && found.length > 0) {
|
|
410
413
|
const similarity = computeCosineSimilarity(embedding, found)
|
|
411
|
-
logCalibration(found, similarity, true)
|
|
414
|
+
logCalibration(found, similarity, true, options.event)
|
|
412
415
|
return { speaker: found, similarity, embedding }
|
|
413
416
|
}
|
|
414
417
|
|
|
415
418
|
// No match — external speaker. The embedding still goes back: an
|
|
416
419
|
// unidentified voice's vector is the most valuable thing to retain, because
|
|
417
420
|
// naming it later is exactly the correction that has no other evidence.
|
|
418
|
-
logCalibration('Ext', 0, false)
|
|
421
|
+
logCalibration('Ext', 0, false, options.event)
|
|
419
422
|
return { speaker: 'Ext', similarity: 0, embedding }
|
|
420
423
|
} catch (err: unknown) {
|
|
421
424
|
console.error('[speaker] Identification error:', errMsg(err))
|
package/server/routes/health.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
|
10
10
|
import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
|
|
11
11
|
import { isSileroAvailable } from '../lib/vad-silero.js'
|
|
12
12
|
import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
|
|
13
|
+
import { nemotronLive } from '../lib/nemotron-live.js'
|
|
14
|
+
import { finalPassSnapshot } from '../lib/nemotron-final.js'
|
|
13
15
|
import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
|
|
14
16
|
import { mintDisplayTicket, DISPLAY_TICKET_TTL_SECONDS } from '../lib/display-ticket.js'
|
|
15
17
|
import { correctionStoreStats } from '../lib/meeting-corrections.js'
|
|
@@ -336,6 +338,14 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
336
338
|
// Asserted, not merely reported: `speaker_id` above says what the state IS,
|
|
337
339
|
// this says whether that state is acceptable.
|
|
338
340
|
speakerId: speakerReadinessState,
|
|
341
|
+
// 6.61.0: what is labelling chunks RIGHT NOW. `active` reads `nemotron` only
|
|
342
|
+
// while the model is warm, the breaker is closed and most of the last five
|
|
343
|
+
// chunks really were labelled by Nemotron; otherwise `warming` or `embedding`
|
|
344
|
+
// with the reason. Plus the per-chunk outcome counts since start and the last
|
|
345
|
+
// save-time final pass, as codes and counts only: this route is public and
|
|
346
|
+
// must never carry a speaker name, a path or raw error text. A Nemotron
|
|
347
|
+
// fallback is the designed voiceprint path, so it never degrades `status`.
|
|
348
|
+
diarizer: { ...nemotronLive.snapshot(), final: finalPassSnapshot() },
|
|
339
349
|
}
|
|
340
350
|
const openai_whisper_budget = getOpenAIWhisperBudgetState()
|
|
341
351
|
const meeting_summary = {
|
package/server/routes/meeting.ts
CHANGED
|
@@ -160,6 +160,10 @@ import {
|
|
|
160
160
|
type MeetingFinalizationJob,
|
|
161
161
|
} from '../lib/meeting-finalization-jobs.js'
|
|
162
162
|
import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
|
|
163
|
+
import { recordFinalPassSkip, runNemotronFinalPass, type FinalPassOptions, type FinalPassOutcome } from '../lib/nemotron-final.js'
|
|
164
|
+
|
|
165
|
+
/** 6.61.0: the save-time Nemotron pass. Injectable so tests never spawn the CLI. */
|
|
166
|
+
export type FinalDiarize = (options: Pick<FinalPassOptions, 'meetingPath' | 'sidecarPath' | 'audioDir'>) => Promise<FinalPassOutcome>
|
|
163
167
|
|
|
164
168
|
function cosOpsPipelineConfigured(): boolean {
|
|
165
169
|
// Read env live (not the module-load COS_SCRIPTS_DIR const) so unit tests that
|
|
@@ -200,6 +204,7 @@ export interface MeetingRouteDependencies {
|
|
|
200
204
|
scheduleBackground?: (task: Promise<void>) => void
|
|
201
205
|
emit?: typeof emitDisplay
|
|
202
206
|
finalizationJobs?: MeetingFinalizationJobStore
|
|
207
|
+
finalDiarize?: FinalDiarize
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
const activeFinalizationJobs = new Set<string>()
|
|
@@ -210,6 +215,7 @@ interface FinalizationRuntime {
|
|
|
210
215
|
runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
|
|
211
216
|
scheduleBackground: (task: Promise<void>) => void
|
|
212
217
|
sessions?: MeetingSessionSource
|
|
218
|
+
finalDiarize?: FinalDiarize
|
|
213
219
|
}
|
|
214
220
|
|
|
215
221
|
function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
|
|
@@ -311,6 +317,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
|
|
|
311
317
|
sidecarPath: current.sidecarPath,
|
|
312
318
|
sessionId: current.sessionId,
|
|
313
319
|
runBatch: runtime.runBatch,
|
|
320
|
+
finalDiarize: runtime.finalDiarize ?? runNemotronFinalPass,
|
|
314
321
|
})
|
|
315
322
|
} else {
|
|
316
323
|
console.warn(`[meeting/save] Pending HQ audio missing for ${current.sessionId}; preserving streaming canonical`)
|
|
@@ -469,6 +476,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
469
476
|
const scheduleBackground = deps.scheduleBackground ?? (task => { void task })
|
|
470
477
|
const emit = deps.emit ?? emitDisplay
|
|
471
478
|
const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
|
|
479
|
+
const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
|
|
472
480
|
const router = Router()
|
|
473
481
|
const savingSessions = new Set<string>()
|
|
474
482
|
setNamingSessionLiveness(id => sessions.getStartTime(id) !== null || savingSessions.has(id) || activeFinalizationJobs.has(id))
|
|
@@ -553,6 +561,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
553
561
|
runBatch,
|
|
554
562
|
scheduleBackground,
|
|
555
563
|
sessions,
|
|
564
|
+
finalDiarize,
|
|
556
565
|
})
|
|
557
566
|
res.set('Cache-Control', 'private, no-store')
|
|
558
567
|
res.json(publicSaveResponse(alreadySaved, true))
|
|
@@ -717,6 +726,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
717
726
|
runBatch,
|
|
718
727
|
scheduleBackground,
|
|
719
728
|
sessions,
|
|
729
|
+
finalDiarize,
|
|
720
730
|
})
|
|
721
731
|
}
|
|
722
732
|
} catch (error) {
|
|
@@ -797,6 +807,25 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
797
807
|
}
|
|
798
808
|
}
|
|
799
809
|
|
|
810
|
+
/**
|
|
811
|
+
* 6.61.0 (QA W2): refuse a correction while the meeting is finalizing.
|
|
812
|
+
*
|
|
813
|
+
* The Nemotron final pass rewrites the sidecar and the transcript inside the
|
|
814
|
+
* finalization job, after a CLI run of tens of seconds. A relabel or a
|
|
815
|
+
* deattribution landing in that window would race it (the pass also re-reads
|
|
816
|
+
* both files and stands down, but the edit and the pass should not interleave
|
|
817
|
+
* at all). Same rule held naming uses: while a finalization job exists for the
|
|
818
|
+
* session the answer is 409, and a retry after it finishes works.
|
|
819
|
+
*/
|
|
820
|
+
function finalizingRefusal(sessionId: string): { status: number; body: Record<string, unknown> } | null {
|
|
821
|
+
const running = [...activeFinalizationJobs].some(key => key.endsWith(`:${sessionId}`))
|
|
822
|
+
if (!running && !finalizationJobs.get(sessionId)) return null
|
|
823
|
+
return {
|
|
824
|
+
status: 409,
|
|
825
|
+
body: { error: 'Meeting is finalizing; retry after it finishes', reason: 'meeting_finalizing', retryable: true },
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
800
829
|
// ── Speaker review (6.21.12) ──────────────────────────────────────────
|
|
801
830
|
// Backs COS Control's naming panel. Read-only: it reports what a saved
|
|
802
831
|
// meeting's sidecar already contains and never writes. Naming, merging, and
|
|
@@ -1191,7 +1220,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1191
1220
|
// A merged G2 session is NOT refused here: it is a real capture with real
|
|
1192
1221
|
// audio, and relabelling it is how its profile gets better. Only an imported
|
|
1193
1222
|
// or derived IDENTITY is refused.
|
|
1194
|
-
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
|
|
1223
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? finalizingRefusal(sessionId)
|
|
1195
1224
|
if (evidenceRefusal) {
|
|
1196
1225
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
1197
1226
|
return
|
|
@@ -1525,7 +1554,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1525
1554
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1526
1555
|
return
|
|
1527
1556
|
}
|
|
1528
|
-
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
|
|
1557
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? finalizingRefusal(sessionId)
|
|
1529
1558
|
if (evidenceRefusal) {
|
|
1530
1559
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
1531
1560
|
return
|
|
@@ -2194,6 +2223,7 @@ async function finalizeBatch(options: {
|
|
|
2194
2223
|
sidecarPath: string
|
|
2195
2224
|
sessionId?: string
|
|
2196
2225
|
runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
|
|
2226
|
+
finalDiarize?: FinalDiarize
|
|
2197
2227
|
}): Promise<void> {
|
|
2198
2228
|
const result = await options.runBatch(
|
|
2199
2229
|
options.audioDir,
|
|
@@ -2236,6 +2266,29 @@ async function finalizeBatch(options: {
|
|
|
2236
2266
|
)
|
|
2237
2267
|
}
|
|
2238
2268
|
|
|
2269
|
+
// 6.61.0: the Nemotron final pass relabels the transcript just persisted,
|
|
2270
|
+
// while the raw chunk audio still exists and BEFORE this job reaches
|
|
2271
|
+
// ops_pending, so COS sync (sync_meetings.py --g2-import-file, run by the
|
|
2272
|
+
// handoff after this function returns) only ever imports the relabelled
|
|
2273
|
+
// meeting. It never throws and writes nothing unless every word survives;
|
|
2274
|
+
// on any failure the meeting stays exactly as saved above.
|
|
2275
|
+
// Skipped when the batch text landed but its sidecar did not: the two would
|
|
2276
|
+
// disagree about which transcript is canonical. Counted and logged like any
|
|
2277
|
+
// other skip, so health shows it.
|
|
2278
|
+
if (options.finalDiarize && transcriptApplied && !metadataPersisted) {
|
|
2279
|
+
recordFinalPassSkip('metadata_not_persisted', 'the batch transcript was applied but its sidecar was not written')
|
|
2280
|
+
} else if (options.finalDiarize) {
|
|
2281
|
+
try {
|
|
2282
|
+
await options.finalDiarize({
|
|
2283
|
+
meetingPath: options.meetingPath,
|
|
2284
|
+
sidecarPath: options.sidecarPath,
|
|
2285
|
+
audioDir: options.audioDir,
|
|
2286
|
+
})
|
|
2287
|
+
} catch (error) {
|
|
2288
|
+
console.warn(`[diarizer] final pass threw; the saved transcript is unchanged: ${error instanceof Error ? error.message : String(error)}`)
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2239
2292
|
if (canDeletePendingBatchAudio(transcriptApplied, metadataPersisted)) {
|
|
2240
2293
|
rmSync(options.audioDir, { recursive: true, force: true })
|
|
2241
2294
|
} else {
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
// so a surface that only shows a count can poll without making the Mac hold questions it
|
|
26
26
|
// cannot answer. It checks X-Cos-Token itself as well, in constant time, and a request
|
|
27
27
|
// without it is refused and never counted, whatever sits in front of this router.
|
|
28
|
+
// 6.60.0: a poll with `&hold=1&presenceAgeMs=<int>` (COS Glasses 6.9.563 and later) records
|
|
29
|
+
// wearer presence for the away hold instead of the 6.59.0 liveness; a `hold=1` poll without
|
|
30
|
+
// a valid `presenceAgeMs` makes no client live and enables no hold. The response adds
|
|
31
|
+
// `awayHoldMs`, `awayRecentClientMs`, `settled`, `waitingAtMac` and `serverStartedAt`.
|
|
28
32
|
|
|
29
33
|
import express, { Router, type NextFunction, type Request, type Response } from 'express'
|
|
30
34
|
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
@@ -37,7 +41,7 @@ import {
|
|
|
37
41
|
type HookReplyChannel,
|
|
38
42
|
type PermissionBroker,
|
|
39
43
|
} from '../lib/permission-broker.js'
|
|
40
|
-
import { noteQuestionsPoll } from '../lib/client-liveness.js'
|
|
44
|
+
import { noteQuestionsPoll, validPresenceAgeMs, type QuestionsPollFacts } from '../lib/client-liveness.js'
|
|
41
45
|
|
|
42
46
|
export const PERMISSION_BROKER_HOOK_PATH = '/api/permission-requests/ask'
|
|
43
47
|
/** The hook posts its spool envelope, capped at 1 MiB by the script itself. */
|
|
@@ -133,8 +137,26 @@ export interface SessionQuestionsRouterDeps {
|
|
|
133
137
|
/** The pairing token (X-Cos-Token). A poll counts as a live client only with it. */
|
|
134
138
|
apiToken: () => string
|
|
135
139
|
/** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
|
|
136
|
-
notePoll?: (at: number) => void
|
|
140
|
+
notePoll?: (at: number, poll: QuestionsPollFacts) => void
|
|
137
141
|
now?: () => number
|
|
142
|
+
/** 6.60.0: when this server process started (ISO); defaults to the process's own start. */
|
|
143
|
+
serverStartedAt?: string
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 6.60.0: this process's start, so a client can tell a restart from "nothing waiting". */
|
|
147
|
+
export const SERVER_STARTED_AT = new Date(Date.now() - Math.round(process.uptime() * 1000)).toISOString()
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 6.60.0, the contract shared with COS Glasses 6.9.563: `hold=1` (this client shows held
|
|
151
|
+
* questions) and `presenceAgeMs`, a whole number of ms within [0, 24 h] since the latest real
|
|
152
|
+
* wearer evidence. Anything else is no presence: the poll then counts only as a 6.59.0 poll
|
|
153
|
+
* (without `hold=1`) or as a `hold=1` poll with no presence.
|
|
154
|
+
*/
|
|
155
|
+
export function questionsPollFacts(query: Record<string, unknown>): QuestionsPollFacts {
|
|
156
|
+
const hold = query.hold === '1'
|
|
157
|
+
const raw = typeof query.presenceAgeMs === 'string' ? query.presenceAgeMs : ''
|
|
158
|
+
const parsed = /^\d{1,8}$/.test(raw) ? Number(raw) : Number.NaN
|
|
159
|
+
return { hold, presenceAgeMs: hold && validPresenceAgeMs(parsed) ? parsed : null }
|
|
138
160
|
}
|
|
139
161
|
|
|
140
162
|
export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps): Router {
|
|
@@ -152,7 +174,7 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
|
|
|
152
174
|
}
|
|
153
175
|
// Only a client that says it can answer (and shows the cards) counts as live.
|
|
154
176
|
const client = typeof req.query.client === 'string' ? req.query.client : ''
|
|
155
|
-
if (ANSWERING_CLIENTS.has(client)) notePoll(now())
|
|
177
|
+
if (ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
|
|
156
178
|
const health = deps.broker.health()
|
|
157
179
|
// Listed first: a deadline the timer has not caught yet is settled by the listing.
|
|
158
180
|
const items = deps.broker.list()
|
|
@@ -165,6 +187,18 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
|
|
|
165
187
|
pending: deps.broker.pendingCount(),
|
|
166
188
|
stats: deps.broker.stats(),
|
|
167
189
|
items,
|
|
190
|
+
// 6.60.0, additive: the away hold now (0 when off) and the recent-client window it needs,
|
|
191
|
+
// recently settled items (codes and times only, newest first), and how many of them the
|
|
192
|
+
// Mac's own dialog still waits on.
|
|
193
|
+
awayHoldMs: health.enabled ? deps.broker.awayHoldNowMs() : 0,
|
|
194
|
+
awayRecentClientMs: health.enabled && deps.broker.awayHoldNowMs() > 0 ? deps.broker.awayRecentClientWindowMs() : 0,
|
|
195
|
+
settled: deps.broker.settledList(),
|
|
196
|
+
waitingAtMac: deps.broker.waitingAtMacCount(),
|
|
197
|
+
serverStartedAt: deps.serverStartedAt ?? SERVER_STARTED_AT,
|
|
198
|
+
// 6.60.0 (Ghost Hunter W1): a waiting entry settled at or before this time may have been
|
|
199
|
+
// forgotten (the server started then, or dropped it while it still waited). Missing from
|
|
200
|
+
// `settled` and settled after it: the session moved past it.
|
|
201
|
+
waitingAtMacForgottenBefore: new Date(deps.broker.waitingAtMacForgottenBeforeMs()).toISOString(),
|
|
168
202
|
})
|
|
169
203
|
})
|
|
170
204
|
router.post('/session-questions/:id/answer', (req: Request, res: Response) => {
|
|
@@ -40,6 +40,8 @@ import { transcribeLocal, applyCorrections, type WhisperWord } from '../lib/whis
|
|
|
40
40
|
import { enhanceAudio } from '../lib/audio-enhance.js'
|
|
41
41
|
import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
|
|
42
42
|
import { identifySpeaker, isEmbeddingAvailable, autoEnroll, AUTO_ENROLL_CANDIDATE_SIMILARITY } from '../lib/speaker-embeddings.js'
|
|
43
|
+
import { nemotronLive, resolveLiveLabels, resplitSegments, type ChunkSpeakerSegment } from '../lib/nemotron-live.js'
|
|
44
|
+
import { VOICEPRINT_MIN_AUDIO_SEC } from '../lib/voiceprint-floor.js'
|
|
43
45
|
import {
|
|
44
46
|
assertOpenAIWhisperBudget,
|
|
45
47
|
recordOpenAIWhisperUsage,
|
|
@@ -266,6 +268,23 @@ export interface TranscriptChunk {
|
|
|
266
268
|
audioSha256?: string
|
|
267
269
|
canonical?: boolean
|
|
268
270
|
evenHubSpeakerRole?: EvenSpeakerRoleHistogram
|
|
271
|
+
/** 6.61.0: who spoke when inside this chunk, in time order. Present when
|
|
272
|
+
* Nemotron separated the chunk's voices; `speaker` is then the dominant name. */
|
|
273
|
+
segments?: ChunkSpeakerSegment[]
|
|
274
|
+
/** 6.61.0: which path labelled the chunk. Absent when COS_DIARIZER selects the voiceprint. */
|
|
275
|
+
diarizer?: ChunkDiarizerInfo
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface ChunkDiarizerInfo {
|
|
279
|
+
engine: 'nemotron' | 'voiceprint'
|
|
280
|
+
/** Why Nemotron did not label this chunk although it was requested. */
|
|
281
|
+
fallback?: string
|
|
282
|
+
/** The whole-chunk voiceprint label, kept when the dominant track's name differs. */
|
|
283
|
+
voiceprintSpeaker?: string
|
|
284
|
+
voiceprintSimilarity?: number
|
|
285
|
+
tracks?: number
|
|
286
|
+
timing?: 'words' | 'estimated'
|
|
287
|
+
ms?: number
|
|
269
288
|
}
|
|
270
289
|
|
|
271
290
|
export interface ProviderCandidateRecord {
|
|
@@ -345,6 +364,9 @@ interface StreamChunkCompletionResponse {
|
|
|
345
364
|
backend?: string
|
|
346
365
|
asrProvider?: string
|
|
347
366
|
fallbackReason?: string
|
|
367
|
+
/** 6.61.0, additive: the chunk's speaker turns in time order. Their texts
|
|
368
|
+
* joined with one space equal `text`; `speaker` stays the dominant name. */
|
|
369
|
+
segments?: ChunkSpeakerSegment[]
|
|
348
370
|
}
|
|
349
371
|
|
|
350
372
|
const closedSessionRecords = new Map<string, ClosedTranscriptSession>()
|
|
@@ -772,6 +794,12 @@ function recoverSessions(): void {
|
|
|
772
794
|
const stripped = stripInlineHallucinations(chunk.text, data.sessionId)
|
|
773
795
|
if (stripped !== chunk.text) {
|
|
774
796
|
chunk.text = stripped
|
|
797
|
+
// 6.61.0: the stored turns must still join to the text.
|
|
798
|
+
if (chunk.segments) {
|
|
799
|
+
const refit = resplitSegments(chunk.segments, stripped)
|
|
800
|
+
if (refit?.length) chunk.segments = refit
|
|
801
|
+
else delete chunk.segments
|
|
802
|
+
}
|
|
775
803
|
cleaned++
|
|
776
804
|
}
|
|
777
805
|
}
|
|
@@ -1837,6 +1865,7 @@ function canonicalChunkResponse(
|
|
|
1837
1865
|
backend: existing.backend,
|
|
1838
1866
|
asrProvider: existing.asrProvider,
|
|
1839
1867
|
fallbackReason: existing.fallbackReason,
|
|
1868
|
+
...(existing.segments?.length ? { segments: existing.segments } : {}),
|
|
1840
1869
|
}
|
|
1841
1870
|
}
|
|
1842
1871
|
|
|
@@ -1882,7 +1911,7 @@ async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Bu
|
|
|
1882
1911
|
function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex: number, clientSpeaker: string): { speaker: string; similarity: number } {
|
|
1883
1912
|
const expectedSpeakers = undefined
|
|
1884
1913
|
const audioDurationSec = Math.max(0, (audioBuffer.length - 44)) / 32000
|
|
1885
|
-
if (!isEmbeddingAvailable() || audioDurationSec <
|
|
1914
|
+
if (!isEmbeddingAvailable() || audioDurationSec < VOICEPRINT_MIN_AUDIO_SEC) return { speaker: clientSpeaker, similarity: 0 }
|
|
1886
1915
|
|
|
1887
1916
|
const tEmb = performance.now()
|
|
1888
1917
|
const embeddingResult = identifySpeaker(audioBuffer, expectedSpeakers)
|
|
@@ -2033,6 +2062,15 @@ async function processStreamChunk(opts: {
|
|
|
2033
2062
|
recordReceivedChunk(session, chunkIndex)
|
|
2034
2063
|
persistSessionRequired(sessionId)
|
|
2035
2064
|
|
|
2065
|
+
// 6.61.0: Nemotron reads the durable WAV just written, in parallel with
|
|
2066
|
+
// Whisper, on the Neural Engine, inside its own budget. It is started before
|
|
2067
|
+
// the synchronous voiceprint so the process is already running while that
|
|
2068
|
+
// blocks. diarize() never throws; a fallback returns a reason.
|
|
2069
|
+
const diarizeStartedAt = performance.now()
|
|
2070
|
+
const diarizePromise = nemotronLive.diarize(
|
|
2071
|
+
resolve(SESSION_AUDIO_DIR, sessionId, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`),
|
|
2072
|
+
)
|
|
2073
|
+
|
|
2036
2074
|
const pcmData = audioBuffer.subarray(44)
|
|
2037
2075
|
let sumSq = 0
|
|
2038
2076
|
const nSamples = Math.floor(pcmData.length / 2)
|
|
@@ -2090,14 +2128,14 @@ async function processStreamChunk(opts: {
|
|
|
2090
2128
|
sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
|
|
2091
2129
|
}
|
|
2092
2130
|
|
|
2093
|
-
const
|
|
2131
|
+
const voiceprint = await speakerPromise
|
|
2094
2132
|
if (evenHubSpeakerRole) {
|
|
2095
2133
|
console.log(formatEvenRoleAgreement({
|
|
2096
2134
|
chunkIndex,
|
|
2097
2135
|
even: evenHubSpeakerRole,
|
|
2098
2136
|
amp: clientSpeaker,
|
|
2099
|
-
emb: speaker,
|
|
2100
|
-
similarity,
|
|
2137
|
+
emb: voiceprint.speaker,
|
|
2138
|
+
similarity: voiceprint.similarity,
|
|
2101
2139
|
}))
|
|
2102
2140
|
}
|
|
2103
2141
|
// Client time is authoritative for live network jitter and deferred replay.
|
|
@@ -2106,6 +2144,26 @@ async function processStreamChunk(opts: {
|
|
|
2106
2144
|
: Date.now() - session.startTime
|
|
2107
2145
|
const trimmedText = sanitized.text
|
|
2108
2146
|
|
|
2147
|
+
// Nemotron separates the voices; the voiceprint names each one. Any fallback
|
|
2148
|
+
// keeps today's whole-chunk voiceprint label. Only chunks with text count.
|
|
2149
|
+
const labels = trimmedText
|
|
2150
|
+
? await resolveLiveLabels({
|
|
2151
|
+
diarize: diarizePromise,
|
|
2152
|
+
startedAt: diarizeStartedAt,
|
|
2153
|
+
text: trimmedText,
|
|
2154
|
+
words,
|
|
2155
|
+
audio: audioBuffer,
|
|
2156
|
+
voiceprint,
|
|
2157
|
+
clientSpeaker,
|
|
2158
|
+
identify: wav => {
|
|
2159
|
+
const named = identifySpeaker(wav, undefined, { event: 'nemotron-track' })
|
|
2160
|
+
return named ? { speaker: named.speaker, similarity: named.similarity } : null
|
|
2161
|
+
},
|
|
2162
|
+
})
|
|
2163
|
+
: { speaker: voiceprint.speaker, similarity: voiceprint.similarity }
|
|
2164
|
+
if (labels.record) nemotronLive.record({ sessionId, chunkIndex }, labels.record)
|
|
2165
|
+
const { speaker, similarity } = labels
|
|
2166
|
+
|
|
2109
2167
|
if (!trimmedText) {
|
|
2110
2168
|
console.log(`[hallucination] Filtered (${sanitized.fallbackReason || fallbackReason || 'empty'}): "${rawText.slice(0, 60)}"`)
|
|
2111
2169
|
if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
|
|
@@ -2145,6 +2203,8 @@ async function processStreamChunk(opts: {
|
|
|
2145
2203
|
audioSha256,
|
|
2146
2204
|
canonical: true,
|
|
2147
2205
|
evenHubSpeakerRole,
|
|
2206
|
+
...(labels.segments?.length ? { segments: labels.segments } : {}),
|
|
2207
|
+
...(labels.diarizer ? { diarizer: labels.diarizer } : {}),
|
|
2148
2208
|
}
|
|
2149
2209
|
const finalExisting = session.chunks[chunkIndex]
|
|
2150
2210
|
if (finalExisting?.text) {
|
|
@@ -2174,7 +2234,10 @@ async function processStreamChunk(opts: {
|
|
|
2174
2234
|
|
|
2175
2235
|
scheduleSessionProgressiveHq(sessionId)
|
|
2176
2236
|
|
|
2177
|
-
emitDisplay({
|
|
2237
|
+
emitDisplay({
|
|
2238
|
+
type: 'transcript_chunk',
|
|
2239
|
+
data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId, ...(chunk.segments ? { segments: chunk.segments } : {}) },
|
|
2240
|
+
})
|
|
2178
2241
|
// Live Cues feed — fire-and-forget, NEVER awaited: transcription must not
|
|
2179
2242
|
// block on a cue, and no LLM runs on this path. .catch() is required — a
|
|
2180
2243
|
// bare `void` on a rejecting promise is an unhandled rejection, which Node
|