@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.
- package/.env.example +8 -0
- package/CHANGELOG.md +51 -0
- package/README.md +64 -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 +1005 -0
- package/server/lib/nemotron-live.ts +736 -0
- package/server/lib/nemotron-segments.ts +358 -0
- package/server/lib/speaker-embeddings.ts +9 -5
- package/server/lib/voiceprint-floor.ts +12 -0
- package/server/routes/health.ts +10 -0
- package/server/routes/meeting.ts +55 -2
- package/server/routes/transcribe-stream.ts +75 -6
|
@@ -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 <
|
|
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
|
|
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({
|
|
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
|