@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.
- package/.env.example +8 -0
- package/CHANGELOG.md +38 -0
- package/README.md +63 -0
- package/managed-runtime-contract.json +4 -1
- package/package.json +3 -1
- package/server/index.ts +11 -0
- 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/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/transcribe-stream.ts +68 -5
|
@@ -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
|