@gotcos/glasses-server 6.60.0 → 6.61.1

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.
@@ -9,7 +9,7 @@ import { chmodSync, readFileSync, writeFileSync, existsSync, lstatSync, mkdirSyn
9
9
  import { writeFile } from 'node:fs/promises'
10
10
  import { resolve } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
- import { getVocabulary, getOwnerName } from '../lib/profile.js'
12
+ import { getVocabulary, getOwnerName, getOwnerSpeakerLabel } from '../lib/profile.js'
13
13
  import { getOpenAIKey } from '../lib/openai-key.js'
14
14
  import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from '../lib/transcription-policy.js'
15
15
  import { STRANDED_STALE_MS } from '../lib/stranded-sessions.js'
@@ -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
 
@@ -1879,10 +1908,15 @@ async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Bu
1879
1908
  }
1880
1909
  }
1881
1910
 
1911
+ /** The wearer's label for the 6.61.1 owner guard; null when the profile cannot say. */
1912
+ function ownerLabelOrNull(): string | null {
1913
+ try { return getOwnerSpeakerLabel() } catch { return null }
1914
+ }
1915
+
1882
1916
  function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex: number, clientSpeaker: string): { speaker: string; similarity: number } {
1883
1917
  const expectedSpeakers = undefined
1884
1918
  const audioDurationSec = Math.max(0, (audioBuffer.length - 44)) / 32000
1885
- if (!isEmbeddingAvailable() || audioDurationSec < 2.0) return { speaker: clientSpeaker, similarity: 0 }
1919
+ if (!isEmbeddingAvailable() || audioDurationSec < VOICEPRINT_MIN_AUDIO_SEC) return { speaker: clientSpeaker, similarity: 0 }
1886
1920
 
1887
1921
  const tEmb = performance.now()
1888
1922
  const embeddingResult = identifySpeaker(audioBuffer, expectedSpeakers)
@@ -2033,6 +2067,15 @@ async function processStreamChunk(opts: {
2033
2067
  recordReceivedChunk(session, chunkIndex)
2034
2068
  persistSessionRequired(sessionId)
2035
2069
 
2070
+ // 6.61.0: Nemotron reads the durable WAV just written, in parallel with
2071
+ // Whisper, on the Neural Engine, inside its own budget. It is started before
2072
+ // the synchronous voiceprint so the process is already running while that
2073
+ // blocks. diarize() never throws; a fallback returns a reason.
2074
+ const diarizeStartedAt = performance.now()
2075
+ const diarizePromise = nemotronLive.diarize(
2076
+ resolve(SESSION_AUDIO_DIR, sessionId, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`),
2077
+ )
2078
+
2036
2079
  const pcmData = audioBuffer.subarray(44)
2037
2080
  let sumSq = 0
2038
2081
  const nSamples = Math.floor(pcmData.length / 2)
@@ -2090,14 +2133,14 @@ async function processStreamChunk(opts: {
2090
2133
  sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
2091
2134
  }
2092
2135
 
2093
- const { speaker, similarity } = await speakerPromise
2136
+ const voiceprint = await speakerPromise
2094
2137
  if (evenHubSpeakerRole) {
2095
2138
  console.log(formatEvenRoleAgreement({
2096
2139
  chunkIndex,
2097
2140
  even: evenHubSpeakerRole,
2098
2141
  amp: clientSpeaker,
2099
- emb: speaker,
2100
- similarity,
2142
+ emb: voiceprint.speaker,
2143
+ similarity: voiceprint.similarity,
2101
2144
  }))
2102
2145
  }
2103
2146
  // Client time is authoritative for live network jitter and deferred replay.
@@ -2106,6 +2149,27 @@ async function processStreamChunk(opts: {
2106
2149
  : Date.now() - session.startTime
2107
2150
  const trimmedText = sanitized.text
2108
2151
 
2152
+ // Nemotron separates the voices; the voiceprint names each one. Any fallback
2153
+ // keeps today's whole-chunk voiceprint label. Only chunks with text count.
2154
+ const labels = trimmedText
2155
+ ? await resolveLiveLabels({
2156
+ diarize: diarizePromise,
2157
+ startedAt: diarizeStartedAt,
2158
+ text: trimmedText,
2159
+ words,
2160
+ audio: audioBuffer,
2161
+ voiceprint,
2162
+ clientSpeaker,
2163
+ owner: ownerLabelOrNull(),
2164
+ identify: wav => {
2165
+ const named = identifySpeaker(wav, undefined, { event: 'nemotron-track' })
2166
+ return named ? { speaker: named.speaker, similarity: named.similarity } : null
2167
+ },
2168
+ })
2169
+ : { speaker: voiceprint.speaker, similarity: voiceprint.similarity }
2170
+ if (labels.record) nemotronLive.record({ sessionId, chunkIndex }, labels.record)
2171
+ const { speaker, similarity } = labels
2172
+
2109
2173
  if (!trimmedText) {
2110
2174
  console.log(`[hallucination] Filtered (${sanitized.fallbackReason || fallbackReason || 'empty'}): "${rawText.slice(0, 60)}"`)
2111
2175
  if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
@@ -2145,6 +2209,8 @@ async function processStreamChunk(opts: {
2145
2209
  audioSha256,
2146
2210
  canonical: true,
2147
2211
  evenHubSpeakerRole,
2212
+ ...(labels.segments?.length ? { segments: labels.segments } : {}),
2213
+ ...(labels.diarizer ? { diarizer: labels.diarizer } : {}),
2148
2214
  }
2149
2215
  const finalExisting = session.chunks[chunkIndex]
2150
2216
  if (finalExisting?.text) {
@@ -2174,7 +2240,10 @@ async function processStreamChunk(opts: {
2174
2240
 
2175
2241
  scheduleSessionProgressiveHq(sessionId)
2176
2242
 
2177
- emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
2243
+ emitDisplay({
2244
+ type: 'transcript_chunk',
2245
+ data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId, ...(chunk.segments ? { segments: chunk.segments } : {}) },
2246
+ })
2178
2247
  // Live Cues feed — fire-and-forget, NEVER awaited: transcription must not
2179
2248
  // block on a cue, and no LLM runs on this path. .catch() is required — a
2180
2249
  // bare `void` on a rejecting promise is an unhandled rejection, which Node