@gotcos/glasses-server 6.92.0 → 6.93.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 +30 -1
- package/CHANGELOG.md +57 -29
- package/README.md +58 -0
- package/docs/active-query-jobs.md +1 -1
- package/docs/test-plan-6.71.7.md +1 -1
- package/managed-runtime-contract.json +2 -0
- package/package.json +2 -1
- package/server/lib/ask-session-id.ts +2 -1
- package/server/lib/embedding-eviction.ts +60 -2
- package/server/lib/fuzzy-correct.ts +1 -1
- package/server/lib/hallucination-filter.ts +1 -1
- package/server/lib/imported-library-rows.ts +4 -2
- package/server/lib/live-cos-ask.ts +3 -2
- package/server/lib/mac-live-voices.ts +57 -0
- package/server/lib/mac-recording-asr.ts +16 -8
- package/server/lib/mac-speaker-projection.ts +211 -0
- package/server/lib/mac-stream-diarizer.ts +185 -0
- package/server/lib/mac-voice-assignment.ts +361 -0
- package/server/lib/mac-voice-labels.ts +70 -8
- package/server/lib/mac-window-replay.ts +271 -0
- package/server/lib/mac-window-voices.ts +390 -0
- package/server/lib/meeting-actions.ts +1 -1
- package/server/lib/meeting-ask-owner.ts +4 -4
- package/server/lib/meeting-ask-transcript-sources.ts +41 -3
- package/server/lib/meeting-audio-archive.ts +4 -0
- package/server/lib/meeting-batch-transcribe.ts +7 -1
- package/server/lib/meeting-context-keys.ts +6 -3
- package/server/lib/meeting-corrections.ts +4 -4
- package/server/lib/meeting-fact-check.ts +3 -3
- package/server/lib/meeting-parse.ts +1 -1
- package/server/lib/meeting-relabel-enrolment.ts +8 -8
- package/server/lib/meeting-relabel.ts +3 -3
- package/server/lib/meeting-scribe-content.ts +1 -1
- package/server/lib/meeting-speaker-review.ts +8 -8
- package/server/lib/meeting-store.ts +19 -9
- package/server/lib/nemotron-final.ts +114 -9
- package/server/lib/nemotron-live.ts +58 -6
- package/server/lib/phone-recording.ts +114 -0
- package/server/lib/prompt-tail-guard.ts +1 -1
- package/server/lib/speaker-embeddings.ts +154 -28
- package/server/lib/speaker-name.ts +1 -1
- package/server/lib/stranded-session-actions.ts +7 -2
- package/server/lib/training-audio-provenance.ts +3 -2
- package/server/lib/transcript-cleanup-context.ts +3 -3
- package/server/lib/transcript-cleanup-model.ts +1 -1
- package/server/lib/transcript-cleanup-spans.ts +10 -10
- package/server/lib/transcript-cleanup-text.ts +4 -4
- package/server/lib/transcript-cleanup.ts +1 -1
- package/server/lib/unsaved-audio-quarantine.ts +10 -1
- package/server/lib/voice-directory.ts +1 -1
- package/server/lib/voice-evidence-guard.ts +2 -1
- package/server/lib/voice-profile-store.ts +11 -5
- package/server/lib/whisper-local.ts +1 -1
- package/server/routes/health.ts +8 -0
- package/server/routes/meeting.ts +189 -19
- package/server/routes/meetings.ts +9 -5
- package/server/routes/transcribe-stream.ts +140 -15
- package/server/routes/voice.ts +12 -4
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
- package/server/scripts/mac-window-voices-6930-mutation-gate.mjs +193 -0
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, admitPhoneRecording, phoneQueryInteger, requirePhoneRecording, readPhoneRecording, validatePhoneWav, persistPhoneChunkReceipt } from '../lib/phone-recording.js'
|
|
2
|
+
import { matchMacLiveVoice, macAnonymousProjection, type MacLiveVoiceState } from '../lib/mac-live-voices.js'
|
|
3
|
+
import { chunkShowsVoiceLabel, macLiveVoicesMode } from '../lib/mac-voice-labels.js'
|
|
4
|
+
import { macWindowDisabledFor, macWindowStateFor, macWindowWithheldReason, noteMacWindowWithheld, startMacWindowDiarize } from '../lib/mac-window-replay.js'
|
|
5
|
+
import { macWindowVoicesEnabled, type MacWindowVoiceState } from '../lib/mac-window-voices.js'
|
|
6
|
+
import { MacParakeetUnavailableError, transcribeMacParakeet, transcribePhoneParakeet } from '../lib/mac-recording-asr.js'
|
|
2
7
|
import { getMeetingStore } from '../lib/meeting-store.js'
|
|
3
8
|
import { appendAutonomousSaveLog, getStopIntentStore, savedChunkReplay, type StopIntent } from '../lib/meeting-stop-intents.js'
|
|
4
9
|
import { MAC_AUDIO, MAC_CAPTURE_ID, closeMacRecording, persistMacChunkReceipt, macError, registerMacRecording, requireMacRecording, readMacRecording, validateMacWav } from '../lib/mac-recording.js'
|
|
@@ -46,7 +51,7 @@ import { emitDisplay } from '../lib/display-bus.js'
|
|
|
46
51
|
import { errMsg } from '../lib/utils.js'
|
|
47
52
|
import { transcribeLocal, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
|
|
48
53
|
import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
|
|
49
|
-
import { identifySpeaker, isEmbeddingAvailable, autoEnroll, AUTO_ENROLL_CANDIDATE_SIMILARITY } from '../lib/speaker-embeddings.js'
|
|
54
|
+
import { identifySpeaker, extractEmbedding, isEmbeddingAvailable, autoEnroll, AUTO_ENROLL_CANDIDATE_SIMILARITY } from '../lib/speaker-embeddings.js'
|
|
50
55
|
import { nemotronLive, resolveLiveLabels, resplitSegments, type ChunkSpeakerSegment } from '../lib/nemotron-live.js'
|
|
51
56
|
import { VOICEPRINT_MIN_AUDIO_SEC } from '../lib/voiceprint-floor.js'
|
|
52
57
|
import {
|
|
@@ -286,6 +291,7 @@ export interface TranscriptChunk {
|
|
|
286
291
|
elapsed: number // ms since session start
|
|
287
292
|
similarity: number // speaker identification confidence (0-1)
|
|
288
293
|
/** Capture-time policy survives batch and final passes; absent means legacy Standard. */
|
|
294
|
+
sourceType?: typeof PHONE_SOURCE_TYPE
|
|
289
295
|
speakerPolicy?: SpeakerMode
|
|
290
296
|
words?: WhisperWord[] // word-level timestamps from DTW alignment
|
|
291
297
|
asrProvider?: 'server-whisper' | 'server-parakeet' | 'iphone-whisperkit-beta'
|
|
@@ -334,6 +340,10 @@ export interface ProviderCandidateRecord {
|
|
|
334
340
|
|
|
335
341
|
interface TranscriptSession {
|
|
336
342
|
chunks: TranscriptChunk[]
|
|
343
|
+
/** Bounded anonymous acoustic anchors; active capture only, never training. */
|
|
344
|
+
macLiveVoices?: MacLiveVoiceState
|
|
345
|
+
/** 6.93.0 window-replay continuity (per-frame track ids, no audio); active capture only. */
|
|
346
|
+
macWindowVoices?: MacWindowVoiceState
|
|
337
347
|
startTime: number
|
|
338
348
|
title: string
|
|
339
349
|
providerCandidates?: Record<string, ProviderCandidateRecord>
|
|
@@ -392,6 +402,8 @@ interface StreamChunkCompletionResponse {
|
|
|
392
402
|
chunkIndex: number
|
|
393
403
|
elapsed: number
|
|
394
404
|
sessionId: string
|
|
405
|
+
sourceType?: typeof PHONE_SOURCE_TYPE
|
|
406
|
+
speakerPolicy?: 'anonymous'
|
|
395
407
|
serverInstanceId: string
|
|
396
408
|
asrCompleted: true
|
|
397
409
|
canonical: boolean
|
|
@@ -499,6 +511,7 @@ export const __sessionsForTests = sessions
|
|
|
499
511
|
export const __closedSessionRecordsForTests = closedSessionRecords
|
|
500
512
|
|
|
501
513
|
export interface StrandedCapture {
|
|
514
|
+
sourceType?: typeof PHONE_SOURCE_TYPE
|
|
502
515
|
sessionId: string
|
|
503
516
|
/** Minutes since the last ACK'd chunk. */
|
|
504
517
|
idleMinutes: number
|
|
@@ -542,6 +555,7 @@ export function getStrandedCaptures(now = Date.now()): StrandedCapture[] {
|
|
|
542
555
|
const transcriptState: StrandedCapture['transcriptState'] = stale.chunks ? 'ready' : noSpeech ? 'no_speech' : 'processing'
|
|
543
556
|
return [{
|
|
544
557
|
sessionId: stale.sessionId,
|
|
558
|
+
...(PHONE_CAPTURE_ID.test(stale.sessionId) ? { sourceType: PHONE_SOURCE_TYPE } : {}),
|
|
545
559
|
idleMinutes: Math.round(stale.silentForMs / 60_000),
|
|
546
560
|
capturedMinutes: Math.round(
|
|
547
561
|
Math.max(0, lastActivityAt - (session?.startTime ?? lastActivityAt)) / 60_000,
|
|
@@ -703,6 +717,7 @@ function persistSessionRequired(sessionId: string): void {
|
|
|
703
717
|
}
|
|
704
718
|
const data = JSON.stringify({
|
|
705
719
|
sessionId,
|
|
720
|
+
...(PHONE_CAPTURE_ID.test(sessionId) ? { captureSource: requirePhoneRecording(sessionId), sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' } : {}),
|
|
706
721
|
startTime: session.startTime,
|
|
707
722
|
lastActivityAt: session.lastActivityAt,
|
|
708
723
|
title: session.title,
|
|
@@ -717,6 +732,8 @@ function persistSessionRequired(sessionId: string): void {
|
|
|
717
732
|
maxChunkIndex: session.maxChunkIndex ?? -1,
|
|
718
733
|
providerCandidates: session.providerCandidates ?? {},
|
|
719
734
|
chunkLevels: session.chunkLevels ?? [],
|
|
735
|
+
...(session.macLiveVoices !== undefined ? { macLiveVoices: session.macLiveVoices } : {}),
|
|
736
|
+
...(session.macWindowVoices !== undefined ? { macWindowVoices: session.macWindowVoices } : {}),
|
|
720
737
|
})
|
|
721
738
|
try {
|
|
722
739
|
durableAtomicWriteFileSync(filePath, data, { mode: 0o600 })
|
|
@@ -826,6 +843,12 @@ function recoverSessions(): void {
|
|
|
826
843
|
else logStaleMoveFailure(data.sessionId, `[session-recovery] Could not move the active-session file aside for ${data.sessionId} (already ${terminal.kind}); left in place, not adopted`)
|
|
827
844
|
continue
|
|
828
845
|
}
|
|
846
|
+
if (String(data.sessionId).startsWith('phone_')) {
|
|
847
|
+
const phone = requirePhoneRecording(data.sessionId)
|
|
848
|
+
if (data.sourceType !== PHONE_SOURCE_TYPE || data.speakerPolicy !== 'anonymous' || data.captureSource?.version !== 1 || data.captureSource?.sessionId !== phone.sessionId || data.captureSource?.startTime !== phone.startTime || data.captureSource?.captureId !== phone.captureId || data.captureSource?.sourceType !== PHONE_SOURCE_TYPE || data.captureSource?.speakerPolicy !== 'anonymous' || data.startTime !== phone.startTime) throw Error('Missing iPhone session provenance')
|
|
849
|
+
const held = [...(data.chunksIndexed ?? []).map((e: { c: TranscriptChunk }) => e.c), ...(data.chunks ?? [])]
|
|
850
|
+
if (held.some((c: TranscriptChunk) => c && (c.speakerPolicy !== 'anonymous' || c.speaker !== 'Ext' || c.segments?.some(seg => seg.speaker !== 'Ext')))) throw Error('Invalid iPhone anonymous transcript')
|
|
851
|
+
}
|
|
829
852
|
// Reconstruct the (sparse) chunk array. New format keeps original
|
|
830
853
|
// indices via chunksIndexed; legacy format stored a dense `chunks`.
|
|
831
854
|
const indexed: Array<{ i: number; c: TranscriptChunk }> | null =
|
|
@@ -906,6 +929,8 @@ function recoverSessions(): void {
|
|
|
906
929
|
? data.providerCandidates
|
|
907
930
|
: {},
|
|
908
931
|
chunkLevels: restoreChunkLevels(data.chunkLevels),
|
|
932
|
+
...(data.macLiveVoices !== undefined && MAC_CAPTURE_ID.test(data.sessionId) ? { macLiveVoices: data.macLiveVoices } : {}),
|
|
933
|
+
...(data.macWindowVoices !== undefined && MAC_CAPTURE_ID.test(data.sessionId) ? { macWindowVoices: data.macWindowVoices } : {}),
|
|
909
934
|
}
|
|
910
935
|
// Retroactively strip inline hallucinations from recovered chunks.
|
|
911
936
|
// Two-pass: (1) scan all chunks to build frequency/blocklist,
|
|
@@ -1361,12 +1386,13 @@ setInterval(() => {
|
|
|
1361
1386
|
|
|
1362
1387
|
/** Get or create a transcript session */
|
|
1363
1388
|
export function getSession(sessionId: string): TranscriptSession {
|
|
1389
|
+
const phone = sessionId.startsWith('phone_') ? requirePhoneRecording(sessionId) : null
|
|
1364
1390
|
let session = sessions.get(sessionId)
|
|
1365
1391
|
if (!session) {
|
|
1366
1392
|
const now = Date.now()
|
|
1367
1393
|
session = {
|
|
1368
1394
|
chunks: [],
|
|
1369
|
-
startTime: now,
|
|
1395
|
+
startTime: phone?.startTime ?? now,
|
|
1370
1396
|
lastActivityAt: now,
|
|
1371
1397
|
title: '',
|
|
1372
1398
|
providerCandidates: {},
|
|
@@ -1550,6 +1576,7 @@ export function getSessionChunkEntries(sessionId: string): IndexedTranscriptChun
|
|
|
1550
1576
|
chunk: {
|
|
1551
1577
|
text: '',
|
|
1552
1578
|
speaker: empty.speaker || 'Ext',
|
|
1579
|
+
...(PHONE_CAPTURE_ID.test(sessionId) ? { sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' as const } : {}),
|
|
1553
1580
|
elapsed: empty.elapsed,
|
|
1554
1581
|
similarity: 0,
|
|
1555
1582
|
backend: empty.backend,
|
|
@@ -1584,8 +1611,9 @@ export function getSessionAsrCompletedIndices(sessionId: string): number[] {
|
|
|
1584
1611
|
}
|
|
1585
1612
|
|
|
1586
1613
|
function scheduleSessionProgressiveHq(sessionId: string): void {
|
|
1587
|
-
//
|
|
1588
|
-
//
|
|
1614
|
+
// Phone audio is always Parakeet, including after a global settings change.
|
|
1615
|
+
// Mac retains its existing selected-engine contract.
|
|
1616
|
+
if (PHONE_CAPTURE_ID.test(sessionId)) return
|
|
1589
1617
|
if (MAC_CAPTURE_ID.test(sessionId) && readSttSettings().hq === 'parakeet-ultra') return
|
|
1590
1618
|
// Preserve the default-off contract at the route boundary. In addition to
|
|
1591
1619
|
// avoiding any cache work, this keeps legacy/local-first transcription
|
|
@@ -2165,6 +2193,7 @@ function canonicalChunkResponse(
|
|
|
2165
2193
|
elapsed: existing.elapsed,
|
|
2166
2194
|
sessionId,
|
|
2167
2195
|
serverInstanceId,
|
|
2196
|
+
...(PHONE_CAPTURE_ID.test(sessionId) ? { sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' as const } : {}),
|
|
2168
2197
|
asrCompleted: true,
|
|
2169
2198
|
canonical: true,
|
|
2170
2199
|
backend: existing.backend,
|
|
@@ -2187,6 +2216,7 @@ function emptyChunkResponse(
|
|
|
2187
2216
|
chunkIndex,
|
|
2188
2217
|
sessionId,
|
|
2189
2218
|
serverInstanceId,
|
|
2219
|
+
...(PHONE_CAPTURE_ID.test(sessionId) ? { sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' as const } : {}),
|
|
2190
2220
|
asrCompleted: true,
|
|
2191
2221
|
canonical: false,
|
|
2192
2222
|
}
|
|
@@ -2359,7 +2389,8 @@ async function processStreamChunk(opts: {
|
|
|
2359
2389
|
evenHubSpeakerRole?: EvenSpeakerRoleHistogram
|
|
2360
2390
|
}): Promise<StreamChunkCompletionResponse> {
|
|
2361
2391
|
const { sessionId, chunkIndex, clientSpeaker, audioBuffer, candidate } = opts
|
|
2362
|
-
const
|
|
2392
|
+
const phoneCapture = sessionId.startsWith('phone_') ? requirePhoneRecording(sessionId) : null
|
|
2393
|
+
const evenHubSpeakerRole = phoneCapture || evenSpeakerRoleMode() === 'off' ? undefined : opts.evenHubSpeakerRole
|
|
2363
2394
|
if (evenHubSpeakerRole) warnEvenSpeakerRoleApplyNotImplemented()
|
|
2364
2395
|
const tReq = performance.now()
|
|
2365
2396
|
validateSessionId(sessionId)
|
|
@@ -2422,7 +2453,7 @@ async function processStreamChunk(opts: {
|
|
|
2422
2453
|
|
|
2423
2454
|
// Pin identity policy at acceptance, before persistence, ASR or diarization
|
|
2424
2455
|
// yields. A later loosening applies to the next chunk, never this audio.
|
|
2425
|
-
const capturePolicy = MAC_CAPTURE_ID.test(sessionId) ? (requireMacRecording(sessionId), 'high-confidence' as const) : readSpeakerPolicy(sessionId).mode
|
|
2456
|
+
const capturePolicy = phoneCapture ? 'anonymous' as const : MAC_CAPTURE_ID.test(sessionId) ? (requireMacRecording(sessionId), 'high-confidence' as const) : readSpeakerPolicy(sessionId).mode
|
|
2426
2457
|
await persistRawSessionAudioChunk(sessionId, chunkIndex, audioBuffer)
|
|
2427
2458
|
// Commit the received-index ledger only after the canonical raw WAV is
|
|
2428
2459
|
// durable. A failure is typed non-2xx and a retry remains safe.
|
|
@@ -2435,7 +2466,32 @@ async function processStreamChunk(opts: {
|
|
|
2435
2466
|
// the synchronous voiceprint so the process is already running while that
|
|
2436
2467
|
// blocks. diarize() never throws; a fallback returns a reason.
|
|
2437
2468
|
const diarizeStartedAt = performance.now()
|
|
2438
|
-
|
|
2469
|
+
// 6.93.0 (COS_MAC_LIVE_VOICES, window by default): a Mac chunk is diarized inside a
|
|
2470
|
+
// window of the durable chunks before it, so voices keep their number across
|
|
2471
|
+
// boundaries (the first chunk and a gap run a window of their own, one chunk or the
|
|
2472
|
+
// contiguous run after the gap). With the switch off (COS_MAC_LIVE_VOICES=off), or no window to run (an
|
|
2473
|
+
// unplannable chunk, corrupt state, earlier Voice labels), the chunk takes the plain
|
|
2474
|
+
// per-chunk run with no anchors: `Ext`, as 6.92.0. A window run that times out or is
|
|
2475
|
+
// refused falls back to that per-chunk run too, and two window timeouts in a row stop the
|
|
2476
|
+
// window for the rest of the capture (mac-window-replay.ts).
|
|
2477
|
+
const macWindowOn = MAC_CAPTURE_ID.test(sessionId) && macWindowVoicesEnabled() && !macWindowDisabledFor(sessionId)
|
|
2478
|
+
const macWindowWithheld = macWindowOn ? macWindowWithheldReason(session.macWindowVoices, session.chunks) : null
|
|
2479
|
+
if (macWindowWithheld) noteMacWindowWithheld(sessionId, macWindowWithheld)
|
|
2480
|
+
const macWindowState = macWindowOn ? macWindowStateFor(session.macWindowVoices, session.chunks) : null
|
|
2481
|
+
const macWindow = macWindowState
|
|
2482
|
+
? startMacWindowDiarize({
|
|
2483
|
+
sessionId,
|
|
2484
|
+
sessionDir: resolve(SESSION_AUDIO_DIR, sessionId),
|
|
2485
|
+
chunkIndex,
|
|
2486
|
+
audio: audioBuffer,
|
|
2487
|
+
audioSha256,
|
|
2488
|
+
state: macWindowState,
|
|
2489
|
+
// A window timeout has a per-chunk fallback, so it must not cool Nemotron for every capture.
|
|
2490
|
+
diarizeWindow: (wavPath, timeoutMs) => nemotronLive.diarize(wavPath, { timeoutMs, timeoutTripsBreaker: false }),
|
|
2491
|
+
diarizeChunk: wavPath => nemotronLive.diarize(wavPath),
|
|
2492
|
+
})
|
|
2493
|
+
: null
|
|
2494
|
+
const diarizePromise = phoneCapture ? Promise.resolve({ ok: false as const, reason: 'embedding_requested' as const }) : macWindow?.diarize ?? nemotronLive.diarize(
|
|
2439
2495
|
resolve(SESSION_AUDIO_DIR, sessionId, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`),
|
|
2440
2496
|
)
|
|
2441
2497
|
|
|
@@ -2472,6 +2528,9 @@ async function processStreamChunk(opts: {
|
|
|
2472
2528
|
model = candidate.model
|
|
2473
2529
|
mode = candidate.mode
|
|
2474
2530
|
latencyMs = candidate.latencyMs
|
|
2531
|
+
} else if (phoneCapture) {
|
|
2532
|
+
const result = await transcribePhoneParakeet(whisperAudio, quiet.speechSec)
|
|
2533
|
+
rawText = result.text; words = result.words; backend = result.backend; asrProvider = result.asrProvider; model = result.model; latencyMs = result.latencyMs
|
|
2475
2534
|
} else if (MAC_CAPTURE_ID.test(sessionId) && stt.hq === 'parakeet-ultra') {
|
|
2476
2535
|
// Use the stored choice directly. effectiveStt/preview intentionally fall
|
|
2477
2536
|
// back when unavailable, which is not an acceptable canonical Mac commit.
|
|
@@ -2526,9 +2585,13 @@ async function processStreamChunk(opts: {
|
|
|
2526
2585
|
: Date.now() - session.startTime
|
|
2527
2586
|
const trimmedText = sanitized.text
|
|
2528
2587
|
|
|
2588
|
+
// Stage continuity alongside this chunk. Empty/losing results cannot mutate
|
|
2589
|
+
// the session's anchors. Accepted canonical chunks commit both in one durable
|
|
2590
|
+
// active-session write; a failed write returns non-2xx and replay retries it.
|
|
2591
|
+
let nextMacLiveVoices = session.macLiveVoices === undefined ? undefined : structuredClone(session.macLiveVoices)
|
|
2529
2592
|
// Nemotron separates the voices; the voiceprint names each one. Any fallback
|
|
2530
2593
|
// keeps today's whole-chunk voiceprint label. Only chunks with text count.
|
|
2531
|
-
const labels = trimmedText
|
|
2594
|
+
const labels = trimmedText && !phoneCapture
|
|
2532
2595
|
? await resolveLiveLabels({
|
|
2533
2596
|
diarize: diarizePromise,
|
|
2534
2597
|
startedAt: diarizeStartedAt,
|
|
@@ -2538,6 +2601,17 @@ async function processStreamChunk(opts: {
|
|
|
2538
2601
|
voiceprint,
|
|
2539
2602
|
clientSpeaker,
|
|
2540
2603
|
owner: ownerLabelOrNull(),
|
|
2604
|
+
...(macWindow ? {
|
|
2605
|
+
anonymousChannel: (channel: number) => macWindow.result()?.labelOf(channel) ?? null,
|
|
2606
|
+
} : MAC_CAPTURE_ID.test(sessionId) && macLiveVoicesMode() === 'anchors' ? {
|
|
2607
|
+
anonymousVoice: (wav: Buffer) => {
|
|
2608
|
+
// Fail closed after an upgrade/recovery lacking acoustic anchors:
|
|
2609
|
+
// never reuse Voice 1 for an unrelated person from a later chunk.
|
|
2610
|
+
if (!nextMacLiveVoices && session.chunks.some(chunkShowsVoiceLabel)) return null
|
|
2611
|
+
nextMacLiveVoices ??= { version: 1, anchors: [] }
|
|
2612
|
+
return matchMacLiveVoice(nextMacLiveVoices, extractEmbedding(wav), chunkIndex, audioSha256)
|
|
2613
|
+
},
|
|
2614
|
+
} : {}),
|
|
2541
2615
|
identify: wav => {
|
|
2542
2616
|
const policy = capturePolicy
|
|
2543
2617
|
if (policy === 'anonymous') return { speaker: 'Ext', similarity: 0 }
|
|
@@ -2554,8 +2628,15 @@ async function processStreamChunk(opts: {
|
|
|
2554
2628
|
})
|
|
2555
2629
|
: { speaker: voiceprint.speaker, similarity: voiceprint.similarity }
|
|
2556
2630
|
if (labels.record) nemotronLive.record({ sessionId, chunkIndex }, labels.record)
|
|
2557
|
-
|
|
2558
|
-
|
|
2631
|
+
// Strict named identity is unchanged. Only this server's successful Mac
|
|
2632
|
+
// Nemotron path can retain an anonymous label at similarity zero.
|
|
2633
|
+
const project = <T extends { speaker: string; similarity?: number }>(value: T): T => {
|
|
2634
|
+
const strict = policyLabel(value, capturePolicy)
|
|
2635
|
+
return MAC_CAPTURE_ID.test(sessionId) && labels.diarizer?.engine === 'nemotron' && macLiveVoicesMode() !== 'off'
|
|
2636
|
+
? macAnonymousProjection(value, strict) : strict
|
|
2637
|
+
}
|
|
2638
|
+
const { speaker, similarity } = project(labels)
|
|
2639
|
+
if (labels.segments) labels.segments = labels.segments.map(project)
|
|
2559
2640
|
|
|
2560
2641
|
if (!trimmedText) {
|
|
2561
2642
|
console.log(`[hallucination] Filtered (${sanitized.fallbackReason || fallbackReason || 'empty'}): chars=${rawText.length}`)
|
|
@@ -2565,7 +2646,7 @@ async function processStreamChunk(opts: {
|
|
|
2565
2646
|
}
|
|
2566
2647
|
const emptyCompletion: EmptyTranscriptCompletion = {
|
|
2567
2648
|
text: '',
|
|
2568
|
-
speaker: clientSpeaker,
|
|
2649
|
+
speaker: phoneCapture ? 'Ext' : clientSpeaker,
|
|
2569
2650
|
elapsed,
|
|
2570
2651
|
backend,
|
|
2571
2652
|
asrProvider,
|
|
@@ -2585,6 +2666,7 @@ async function processStreamChunk(opts: {
|
|
|
2585
2666
|
|
|
2586
2667
|
const chunk: TranscriptChunk = {
|
|
2587
2668
|
text: trimmedText,
|
|
2669
|
+
...(phoneCapture ? { sourceType: PHONE_SOURCE_TYPE } : {}),
|
|
2588
2670
|
speakerPolicy: capturePolicy,
|
|
2589
2671
|
...(opts.audioDurationMs !== undefined ? { audioDurationMs: opts.audioDurationMs } : {}),
|
|
2590
2672
|
speaker,
|
|
@@ -2617,6 +2699,9 @@ async function processStreamChunk(opts: {
|
|
|
2617
2699
|
return canonicalChunkResponse(finalExisting, sessionId, chunkIndex)
|
|
2618
2700
|
}
|
|
2619
2701
|
session.chunks[chunkIndex] = chunk
|
|
2702
|
+
if (nextMacLiveVoices !== undefined) session.macLiveVoices = nextMacLiveVoices
|
|
2703
|
+
const macWindowNext = macWindow?.result()?.state
|
|
2704
|
+
if (macWindowNext !== undefined) session.macWindowVoices = macWindowNext
|
|
2620
2705
|
recordAsrCompleted(session, chunkIndex, sessionId)
|
|
2621
2706
|
if (candidate && candidateRecordKey) {
|
|
2622
2707
|
if (session.providerCandidates?.[candidateRecordKey]) {
|
|
@@ -2686,6 +2771,7 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
|
|
|
2686
2771
|
*/
|
|
2687
2772
|
transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
|
|
2688
2773
|
try {
|
|
2774
|
+
if (assertPhoneProvenance(String(req.query.sessionId ?? ''), req.query)) return void res.status(204).send()
|
|
2689
2775
|
if (!meetingTurboPreviewEnabled() || !maintenanceAdmissionsOpen()) {
|
|
2690
2776
|
return void res.status(204).send()
|
|
2691
2777
|
}
|
|
@@ -2837,17 +2923,31 @@ transcribeStreamRouter.post('/mac-recording/:captureId/chunks', async (req, res)
|
|
|
2837
2923
|
} catch (error) { sendStreamError(res, error) } finally { if (locked) macChunkLocks.delete(id); lease?.release() }
|
|
2838
2924
|
})
|
|
2839
2925
|
|
|
2926
|
+
const phoneChunkLocks = new Set<string>()
|
|
2840
2927
|
transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
|
|
2928
|
+
let phoneLocked: string | undefined
|
|
2841
2929
|
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
2842
2930
|
try {
|
|
2843
2931
|
// Reject a wrong Mac before consuming or persisting any upload bytes.
|
|
2844
2932
|
assertPinnedServerIdentity(req.get('X-COS-Server-Instance'), req.query.serverInstanceId)
|
|
2933
|
+
// A repeated key is an array. The G2 path already refused it (its regex fails on the
|
|
2934
|
+
// joined commas); say so before the phone check reads it as a string.
|
|
2935
|
+
if (req.query.sessionId !== undefined && typeof req.query.sessionId !== 'string') throw makeHttpError(400, 'invalid sessionId', 'invalid_session_id')
|
|
2845
2936
|
const sessionId = (req.query.sessionId as string) || `g2_${Date.now()}`
|
|
2937
|
+
const phone = assertPhoneProvenance(sessionId, req.query)
|
|
2938
|
+
if (phone) {
|
|
2939
|
+
requireMacPin(req)
|
|
2940
|
+
if (getMeetingStore().findBySessionId(sessionId) || isSessionDeleted(sessionId)) throw macError(410, 'session_closed', 'iPhone capture is already saved or closed')
|
|
2941
|
+
if (!readPhoneRecording(sessionId) && listUnsavedCaptures().some(capture => capture.sessionId === sessionId)) throw macError(409, 'phone_capture_provenance_missing', 'Quarantined audio has no admitted iPhone provenance')
|
|
2942
|
+
if (req.query.eh !== undefined || (req.query.speaker !== undefined && req.query.speaker !== 'Ext')) throw macError(400, 'phone_speaker_metadata_forbidden', 'iPhone microphone has no Even/wearer speaker metadata')
|
|
2943
|
+
if (phoneChunkLocks.has(sessionId)) throw macError(409, 'phone_chunk_in_progress', 'Another chunk is processing; retry after its receipt')
|
|
2944
|
+
phoneChunkLocks.add(sessionId); phoneLocked = sessionId
|
|
2945
|
+
}
|
|
2846
2946
|
if (sessionId.startsWith('mac_')) throw macError(400, 'mac_transport_required', 'Use the Mac recording transport for Mac captures')
|
|
2847
2947
|
maintenanceLease = acquireRecordingChunkLease({
|
|
2848
2948
|
allowDuringDrain: sessions.has(sessionId),
|
|
2849
2949
|
})
|
|
2850
|
-
const chunkIndex = parseInt((req.query.chunkIndex as string) || '0', 10)
|
|
2950
|
+
const chunkIndex = phone ? phoneQueryInteger(req.query.chunkIndex, 'invalid_chunk_index', 'invalid chunkIndex') : parseInt((req.query.chunkIndex as string) || '0', 10)
|
|
2851
2951
|
const clientSpeaker = (req.query.speaker as string) || 'Unknown'
|
|
2852
2952
|
const clientElapsedRaw = Number(req.query.elapsed)
|
|
2853
2953
|
const clientElapsed = Number.isFinite(clientElapsedRaw) && clientElapsedRaw >= 0
|
|
@@ -2857,12 +2957,28 @@ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
|
|
|
2857
2957
|
const startTimeOverride = Number.isFinite(startTimeRaw) && startTimeRaw > 0
|
|
2858
2958
|
? startTimeRaw
|
|
2859
2959
|
: undefined
|
|
2860
|
-
|
|
2960
|
+
let phoneStartTime: number | undefined
|
|
2961
|
+
if (phone) {
|
|
2962
|
+
validateChunkIndex(chunkIndex)
|
|
2963
|
+
const phoneElapsed = phoneQueryInteger(req.query.elapsed, 'invalid_elapsed', 'Original iPhone chunk elapsed time required')
|
|
2964
|
+
if (phoneElapsed > 86400000) throw macError(400, 'invalid_elapsed', 'Original iPhone chunk elapsed time required')
|
|
2965
|
+
if (req.query.startTime !== undefined) phoneStartTime = phoneQueryInteger(req.query.startTime, 'phone_start_time_required', 'Original iPhone capture start time required')
|
|
2966
|
+
}
|
|
2967
|
+
const audioBuffer = phone ? await readBoundedRawBody(req, MAC_AUDIO.maxChunkBytes) : await readRawBody(req)
|
|
2968
|
+
let phoneDuration: number | undefined
|
|
2969
|
+
if (phone) {
|
|
2970
|
+
phoneDuration = validatePhoneWav(audioBuffer)
|
|
2971
|
+
admitPhoneRecording(sessionId, phoneStartTime, requireMacPin(req))
|
|
2972
|
+
persistPhoneChunkReceipt(sessionId, chunkIndex, sha256Hex(audioBuffer), clientElapsedRaw, phoneDuration)
|
|
2973
|
+
const dir = resolve(SESSION_AUDIO_DIR, sessionId); ensurePrivateDirectory(dir)
|
|
2974
|
+
durableAtomicWriteFileSync(resolve(dir, '_capture.json'), JSON.stringify(requirePhoneRecording(sessionId)), { mode: 0o600 })
|
|
2975
|
+
}
|
|
2861
2976
|
res.json(await processStreamChunk({
|
|
2862
2977
|
sessionId,
|
|
2863
2978
|
chunkIndex,
|
|
2864
|
-
clientSpeaker,
|
|
2979
|
+
clientSpeaker: phone ? 'Ext' : clientSpeaker,
|
|
2865
2980
|
audioBuffer,
|
|
2981
|
+
...(phoneDuration !== undefined ? { audioDurationMs: phoneDuration } : {}),
|
|
2866
2982
|
clientElapsed,
|
|
2867
2983
|
startTimeOverride,
|
|
2868
2984
|
evenHubSpeakerRole: parseEvenHubSpeakerRoleQuery(req.query.eh),
|
|
@@ -2870,6 +2986,7 @@ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
|
|
|
2870
2986
|
} catch (err: unknown) {
|
|
2871
2987
|
sendStreamError(res, err)
|
|
2872
2988
|
} finally {
|
|
2989
|
+
if (phoneLocked) phoneChunkLocks.delete(phoneLocked)
|
|
2873
2990
|
maintenanceLease?.release()
|
|
2874
2991
|
}
|
|
2875
2992
|
})
|
|
@@ -2877,6 +2994,8 @@ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
|
|
|
2877
2994
|
transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (req, res) => {
|
|
2878
2995
|
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
2879
2996
|
try {
|
|
2997
|
+
const phoneId = String((req.params as Record<string, string>).sessionId ?? req.body?.sessionId ?? req.query.sessionId ?? '')
|
|
2998
|
+
if (phoneId.startsWith('phone_') || req.body?.sourceType === PHONE_SOURCE_TYPE || req.query.sourceType === PHONE_SOURCE_TYPE) throw macError(400, 'phone_indexed_transport_required', 'Use canonical indexed iPhone microphone uploads')
|
|
2880
2999
|
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
2881
3000
|
const body = req.body ?? {}
|
|
2882
3001
|
const sessionId = String(body.sessionId ?? '')
|
|
@@ -2904,6 +3023,8 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (
|
|
|
2904
3023
|
transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chunks', async (req, res) => {
|
|
2905
3024
|
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
2906
3025
|
try {
|
|
3026
|
+
const phoneId = String((req.params as Record<string, string>).sessionId ?? req.body?.sessionId ?? req.query.sessionId ?? '')
|
|
3027
|
+
if (phoneId.startsWith('phone_') || req.body?.sourceType === PHONE_SOURCE_TYPE || req.query.sourceType === PHONE_SOURCE_TYPE) throw macError(400, 'phone_indexed_transport_required', 'Use canonical indexed iPhone microphone uploads')
|
|
2907
3028
|
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
2908
3029
|
const sessionId = String(req.params.sessionId ?? '')
|
|
2909
3030
|
validateSessionId(sessionId)
|
|
@@ -2951,6 +3072,8 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chun
|
|
|
2951
3072
|
transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/finalize', async (req, res) => {
|
|
2952
3073
|
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
2953
3074
|
try {
|
|
3075
|
+
const phoneId = String((req.params as Record<string, string>).sessionId ?? req.body?.sessionId ?? req.query.sessionId ?? '')
|
|
3076
|
+
if (phoneId.startsWith('phone_') || req.body?.sourceType === PHONE_SOURCE_TYPE || req.query.sourceType === PHONE_SOURCE_TYPE) throw macError(400, 'phone_indexed_transport_required', 'Use canonical indexed iPhone microphone uploads')
|
|
2954
3077
|
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
2955
3078
|
const sessionId = String(req.params.sessionId ?? '')
|
|
2956
3079
|
validateSessionId(sessionId)
|
|
@@ -2983,6 +3106,8 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/fina
|
|
|
2983
3106
|
transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) => {
|
|
2984
3107
|
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
2985
3108
|
try {
|
|
3109
|
+
const phoneId = String((req.params as Record<string, string>).sessionId ?? req.body?.sessionId ?? req.query.sessionId ?? '')
|
|
3110
|
+
if (phoneId.startsWith('phone_') || req.body?.sourceType === PHONE_SOURCE_TYPE || req.query.sourceType === PHONE_SOURCE_TYPE) throw macError(400, 'phone_indexed_transport_required', 'Use canonical indexed iPhone microphone uploads')
|
|
2986
3111
|
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
2987
3112
|
const body = req.body ?? {}
|
|
2988
3113
|
const sessionId = String(body.sessionId ?? '')
|
package/server/routes/voice.ts
CHANGED
|
@@ -341,7 +341,7 @@ voiceRouter.get('/voice/ext-audio', (_req, res) => {
|
|
|
341
341
|
ageHours: parseFloat(ageHours),
|
|
342
342
|
expiresIn: `${Math.max(0, 72 - parseFloat(ageHours)).toFixed(1)}h`,
|
|
343
343
|
// 6.45.3 — the chunk indices a reviewer can ask to hear (`?chunk=` on
|
|
344
|
-
// the sample route).
|
|
344
|
+
// the sample route). Faith, 2026-09-12: the Add-a-voice panel let her
|
|
345
345
|
// name a session but not listen to it; naming is a guess without this.
|
|
346
346
|
chunkIndices: listExtAudioChunks(d.name),
|
|
347
347
|
}
|
|
@@ -671,8 +671,9 @@ voiceRouter.get('/voice/profiles', (_req, res) => {
|
|
|
671
671
|
const bySource: Record<string, number> = {}
|
|
672
672
|
for (const source of p.sources ?? []) {
|
|
673
673
|
// Collapse auto:<sessionId> so one poisoned session is visible
|
|
674
|
-
// without leaking a session id per row.
|
|
675
|
-
|
|
674
|
+
// without leaking a session id per row. mac-assign:<sessionId> (6.93.0)
|
|
675
|
+
// collapses the same way, as voice-directory.ts does.
|
|
676
|
+
const key = source.startsWith('auto:') ? 'auto' : source.startsWith('mac-assign:') ? 'mac-assign' : source
|
|
676
677
|
bySource[key] = (bySource[key] ?? 0) + 1
|
|
677
678
|
}
|
|
678
679
|
return {
|
|
@@ -746,7 +747,7 @@ voiceRouter.get('/voice/directory', async (req, res) => {
|
|
|
746
747
|
* `enrolNamedVoice`, which is right when a human names a voice and wrong here: the
|
|
747
748
|
* merge has ALREADY absorbed those embeddings, so re-enrolling the same audio across
|
|
748
749
|
* every affected meeting would double-count it and drag the centroid. That matters
|
|
749
|
-
* concretely -- the
|
|
750
|
+
* concretely -- the Theo merge moved a neighbouring speaker from 0.818 to 0.842, close
|
|
750
751
|
* enough to the identification threshold to start producing wrong attributions.
|
|
751
752
|
*
|
|
752
753
|
* NEVER THROWS. A merge that succeeded in the store must not report failure because a
|
|
@@ -839,6 +840,13 @@ voiceRouter.post('/voice/merge-profiles', async (req, res) => {
|
|
|
839
840
|
if (report.missing.length > 0 && report.merged.length === 0) {
|
|
840
841
|
return res.status(404).json({ error: 'no such profile(s)', missing: report.missing, report })
|
|
841
842
|
}
|
|
843
|
+
if (report.refusal) {
|
|
844
|
+
return res.status(409).json({
|
|
845
|
+
error: report.refusal.code,
|
|
846
|
+
message: report.refusal.message,
|
|
847
|
+
report,
|
|
848
|
+
})
|
|
849
|
+
}
|
|
842
850
|
if (report.refused && report.merged.length === 0) {
|
|
843
851
|
return res.status(409).json({
|
|
844
852
|
error: 'similarity below the merge floor',
|