@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.
Files changed (60) hide show
  1. package/.env.example +30 -1
  2. package/CHANGELOG.md +57 -29
  3. package/README.md +58 -0
  4. package/docs/active-query-jobs.md +1 -1
  5. package/docs/test-plan-6.71.7.md +1 -1
  6. package/managed-runtime-contract.json +2 -0
  7. package/package.json +2 -1
  8. package/server/lib/ask-session-id.ts +2 -1
  9. package/server/lib/embedding-eviction.ts +60 -2
  10. package/server/lib/fuzzy-correct.ts +1 -1
  11. package/server/lib/hallucination-filter.ts +1 -1
  12. package/server/lib/imported-library-rows.ts +4 -2
  13. package/server/lib/live-cos-ask.ts +3 -2
  14. package/server/lib/mac-live-voices.ts +57 -0
  15. package/server/lib/mac-recording-asr.ts +16 -8
  16. package/server/lib/mac-speaker-projection.ts +211 -0
  17. package/server/lib/mac-stream-diarizer.ts +185 -0
  18. package/server/lib/mac-voice-assignment.ts +361 -0
  19. package/server/lib/mac-voice-labels.ts +70 -8
  20. package/server/lib/mac-window-replay.ts +271 -0
  21. package/server/lib/mac-window-voices.ts +390 -0
  22. package/server/lib/meeting-actions.ts +1 -1
  23. package/server/lib/meeting-ask-owner.ts +4 -4
  24. package/server/lib/meeting-ask-transcript-sources.ts +41 -3
  25. package/server/lib/meeting-audio-archive.ts +4 -0
  26. package/server/lib/meeting-batch-transcribe.ts +7 -1
  27. package/server/lib/meeting-context-keys.ts +6 -3
  28. package/server/lib/meeting-corrections.ts +4 -4
  29. package/server/lib/meeting-fact-check.ts +3 -3
  30. package/server/lib/meeting-parse.ts +1 -1
  31. package/server/lib/meeting-relabel-enrolment.ts +8 -8
  32. package/server/lib/meeting-relabel.ts +3 -3
  33. package/server/lib/meeting-scribe-content.ts +1 -1
  34. package/server/lib/meeting-speaker-review.ts +8 -8
  35. package/server/lib/meeting-store.ts +19 -9
  36. package/server/lib/nemotron-final.ts +114 -9
  37. package/server/lib/nemotron-live.ts +58 -6
  38. package/server/lib/phone-recording.ts +114 -0
  39. package/server/lib/prompt-tail-guard.ts +1 -1
  40. package/server/lib/speaker-embeddings.ts +154 -28
  41. package/server/lib/speaker-name.ts +1 -1
  42. package/server/lib/stranded-session-actions.ts +7 -2
  43. package/server/lib/training-audio-provenance.ts +3 -2
  44. package/server/lib/transcript-cleanup-context.ts +3 -3
  45. package/server/lib/transcript-cleanup-model.ts +1 -1
  46. package/server/lib/transcript-cleanup-spans.ts +10 -10
  47. package/server/lib/transcript-cleanup-text.ts +4 -4
  48. package/server/lib/transcript-cleanup.ts +1 -1
  49. package/server/lib/unsaved-audio-quarantine.ts +10 -1
  50. package/server/lib/voice-directory.ts +1 -1
  51. package/server/lib/voice-evidence-guard.ts +2 -1
  52. package/server/lib/voice-profile-store.ts +11 -5
  53. package/server/lib/whisper-local.ts +1 -1
  54. package/server/routes/health.ts +8 -0
  55. package/server/routes/meeting.ts +189 -19
  56. package/server/routes/meetings.ts +9 -5
  57. package/server/routes/transcribe-stream.ts +140 -15
  58. package/server/routes/voice.ts +12 -4
  59. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
  60. package/server/scripts/mac-window-voices-6930-mutation-gate.mjs +193 -0
@@ -1,4 +1,9 @@
1
- import { MacParakeetUnavailableError, transcribeMacParakeet } from '../lib/mac-recording-asr.js'
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
- // Progressive checkpoints are Whisper-only and may not bypass a Mac's
1588
- // explicit Parakeet choice, even if the separate HQ canary is enabled.
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 evenHubSpeakerRole = evenSpeakerRoleMode() === 'off' ? undefined : opts.evenHubSpeakerRole
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
- const diarizePromise = nemotronLive.diarize(
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
- const { speaker, similarity } = policyLabel(labels, capturePolicy)
2558
- if (labels.segments) labels.segments = labels.segments.map(segment => policyLabel(segment, capturePolicy))
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
- const audioBuffer = await readRawBody(req)
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 ?? '')
@@ -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). Grace, 2026-09-12: the Add-a-voice panel let her
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
- const key = source.startsWith('auto:') ? 'auto' : source
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 Luke merge moved a neighbouring speaker from 0.818 to 0.842, close
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',