@gotcos/glasses-server 6.60.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.
@@ -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 < 2.0) return { speaker: clientSpeaker, similarity: 0 }
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 { speaker, similarity } = await speakerPromise
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({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
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