@gotcos/glasses-server 6.1.0 → 6.2.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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.2.0
|
|
4
|
+
|
|
5
|
+
Reliability release — ports the hardening the full COS Glasses app shipped in June.
|
|
6
|
+
|
|
7
|
+
- **Transfer integrity (lost-chunk detection).** The server now records every
|
|
8
|
+
received chunk index. A chunk lost in transit surfaces as an inline
|
|
9
|
+
`[… audio gap …]` marker in the gap-aware transcript instead of being
|
|
10
|
+
silently stitched over. Gap state survives a mid-meeting server restart;
|
|
11
|
+
legacy persisted sessions recover without false alarms. The Even Hub client
|
|
12
|
+
(1.0.153+) already retries failed uploads durably — this is the server half.
|
|
13
|
+
- **Vocab-echo hallucination filter.** Whisper is seeded with your profile
|
|
14
|
+
vocabulary; on silence/music it can echo those terms back as phantom words
|
|
15
|
+
("POS Nation. Thrift Cart.") the user never said. Bare-name echoes are now
|
|
16
|
+
dropped session-aware (silence echo, back-to-back run, or exact repeat) on
|
|
17
|
+
both the meeting and dictation paths. Real sentences that mention a term are
|
|
18
|
+
never dropped; plain single-word terms (names, cities) never trigger it.
|
|
19
|
+
- **Name corrections on every path.** The `whisper_corrections` map now also
|
|
20
|
+
applies to iPhone-ASR candidate text and the cloud fallback, not just local
|
|
21
|
+
whisper.
|
|
22
|
+
- **SIGTERM parity.** Production stops (service managers, `kill`) now flush
|
|
23
|
+
active session logs exactly like Ctrl-C did.
|
|
24
|
+
- **`COS_G2_DEFAULT_MODEL` fix.** The documented default-model switch now
|
|
25
|
+
applies on the primary query path, not only the OpenAI-compat surface.
|
|
26
|
+
|
|
3
27
|
## 6.1.0
|
|
4
28
|
|
|
5
29
|
- **Codex backend.** Chat now routes to your local **Codex CLI** (`codex-high`) in
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -138,7 +138,13 @@ app.get('/', (_req, res) => {
|
|
|
138
138
|
})
|
|
139
139
|
|
|
140
140
|
// Graceful shutdown — stop whisper-server child process
|
|
141
|
-
process.on('SIGTERM', () => {
|
|
141
|
+
process.on('SIGTERM', () => {
|
|
142
|
+
// Production stops (kill, service managers) send SIGTERM — flush session logs
|
|
143
|
+
// exactly like SIGINT so active conversations aren't lost on shutdown.
|
|
144
|
+
try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
|
|
145
|
+
stopWhisperServer()
|
|
146
|
+
process.exit(0)
|
|
147
|
+
})
|
|
142
148
|
process.on('SIGINT', () => { logActiveSessionsOnShutdown(); stopWhisperServer(); process.exit(0) })
|
|
143
149
|
|
|
144
150
|
// Crash protection — log and survive instead of dying mid-meeting
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// isFullHallucination(text) — returns true if the text IS a hallucination in its
|
|
12
12
|
// entirety (silence artifacts, caption training, foreign script, filler-only).
|
|
13
13
|
|
|
14
|
-
import { getNegativeRules } from './profile.js'
|
|
14
|
+
import { getNegativeRules, getVocabulary, getOwnerName, loadProfileField } from './profile.js'
|
|
15
15
|
|
|
16
16
|
// ── Whole-chunk silence hallucinations ─────────────────────────────────────
|
|
17
17
|
const KNOWN_HALLUCINATIONS = [
|
|
@@ -312,6 +312,76 @@ export function isRepeatedThankYouOnly(text: string): boolean {
|
|
|
312
312
|
return content.length === 0
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
// ── Vocab-echo (prompt-regurgitation) hallucination ────────────────────────
|
|
316
|
+
// Whisper is seeded with an initial_prompt = the profile vocabulary (owner +
|
|
317
|
+
// brands + products + people). On silence / music / ambiguous audio it ECHOES
|
|
318
|
+
// that prompt, emitting the seeded terms the user never said. The brand-URL
|
|
319
|
+
// filters above only catch URL echoes; a bare brand-NAME echo slips through.
|
|
320
|
+
// This detector drops a chunk that is NOTHING but seeded vocab terms (+
|
|
321
|
+
// punctuation). Real speech that MENTIONS a term in a sentence keeps its
|
|
322
|
+
// non-vocab content words and is never dropped.
|
|
323
|
+
let _vocabEchoRe: RegExp | null = null
|
|
324
|
+
|
|
325
|
+
/** Bust the cached vocab matcher after a profile write. */
|
|
326
|
+
export function resetVocabEchoCache(): void { _vocabEchoRe = null }
|
|
327
|
+
|
|
328
|
+
function getVocabEchoMatcher(): RegExp {
|
|
329
|
+
if (_vocabEchoRe) return _vocabEchoRe
|
|
330
|
+
const raw = new Set<string>()
|
|
331
|
+
const owner = getOwnerName()
|
|
332
|
+
if (owner) raw.add(owner)
|
|
333
|
+
for (const v of getVocabulary()) if (v && v.trim()) raw.add(v.trim())
|
|
334
|
+
// Include whisper_corrections key/value variants so the echo matches whatever
|
|
335
|
+
// spelling whisper emits ("POS Nation" ↔ "POSNation", "Jewel 360" ↔ "Jewel360").
|
|
336
|
+
try {
|
|
337
|
+
const corrRaw = loadProfileField('whisper_corrections', '')
|
|
338
|
+
if (corrRaw) {
|
|
339
|
+
const map = JSON.parse(corrRaw) as Record<string, string>
|
|
340
|
+
for (const [k, val] of Object.entries(map)) { if (k) raw.add(k); if (val) raw.add(val) }
|
|
341
|
+
}
|
|
342
|
+
} catch { /* malformed corrections — ignore */ }
|
|
343
|
+
// Only UNAMBIGUOUS terms trigger an echo drop: multi-word phrases ("POS Nation",
|
|
344
|
+
// "IT Retail", "Jeremy Sokolic") and brand-shaped single tokens with an internal
|
|
345
|
+
// capital or digit ("POSNation", "CaratIQ", "Jewel360"). Plain single-word tokens
|
|
346
|
+
// ("Austin", "Miles", "Ukaoma") are common words / ambiguous and are EXCLUDED —
|
|
347
|
+
// they carry too much false-drop risk for an always-on list rule.
|
|
348
|
+
const terms = [...raw].filter(t => {
|
|
349
|
+
if (t.length < 2) return false
|
|
350
|
+
if (/\s/.test(t)) return true // multi-word phrase
|
|
351
|
+
return /[A-Z0-9]/.test(t.slice(1)) // single token only if brand-shaped
|
|
352
|
+
}).sort((a, b) => b.length - a.length)
|
|
353
|
+
if (terms.length === 0) { _vocabEchoRe = /(?!)/g; return _vocabEchoRe }
|
|
354
|
+
// Escape regex metachars; flex internal whitespace so "IT Retail" also matches
|
|
355
|
+
// "ITRetail". Word-bounded so terms don't match inside larger words.
|
|
356
|
+
const alt = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s*')).join('|')
|
|
357
|
+
_vocabEchoRe = new RegExp(`\\b(?:${alt})\\b`, 'gi')
|
|
358
|
+
return _vocabEchoRe
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Count distinct seeded-vocab terms present in `text`. */
|
|
362
|
+
export function countVocabTerms(text: string): number {
|
|
363
|
+
if (!text) return 0
|
|
364
|
+
const re = getVocabEchoMatcher()
|
|
365
|
+
re.lastIndex = 0
|
|
366
|
+
const found = new Set<string>()
|
|
367
|
+
let m: RegExpExecArray | null
|
|
368
|
+
while ((m = re.exec(text)) !== null) {
|
|
369
|
+
found.add(m[0].toLowerCase().replace(/\s+/g, ''))
|
|
370
|
+
if (m.index === re.lastIndex) re.lastIndex++ // guard against a zero-width match loop
|
|
371
|
+
}
|
|
372
|
+
return found.size
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** True iff `text` is non-empty and contains NOTHING but seeded vocab terms (plus
|
|
376
|
+
* punctuation/whitespace) — the prompt-echo hallucination shape. Any non-vocab
|
|
377
|
+
* content word (incl. connectives like "and"/"the") makes it real speech → false. */
|
|
378
|
+
export function isVocabEchoOnly(text: string): boolean {
|
|
379
|
+
if (!text || !text.trim()) return false
|
|
380
|
+
if (countVocabTerms(text) === 0) return false
|
|
381
|
+
const residual = text.replace(getVocabEchoMatcher(), ' ').replace(/[^a-z0-9]/gi, '')
|
|
382
|
+
return residual.length === 0
|
|
383
|
+
}
|
|
384
|
+
|
|
315
385
|
/** Streaming-chunk silence-drop decision. Pure + exported so the gate is testable
|
|
316
386
|
* (sanitizeStreamTranscript is private). Returns a fallbackReason or null. Contract:
|
|
317
387
|
* brand-URL-only -> 'brand_url' DROP ALWAYS — brand URLs are vocab-seeded,
|
|
@@ -320,6 +390,9 @@ export function isRepeatedThankYouOnly(text: string): boolean {
|
|
|
320
390
|
* third-party URL during speech is preserved.
|
|
321
391
|
* thank-you-only -> 'thankyou_silence' DROP only when isQuiet — soft real closings
|
|
322
392
|
* stay (see isRepeatedThankYouOnly).
|
|
393
|
+
* Vocab-echo (prompt regurgitation) is handled SEPARATELY in sanitizeStreamTranscript
|
|
394
|
+
* because the safe rule is session-aware (drop a silence echo or a back-to-back RUN,
|
|
395
|
+
* but keep a single loud one-off that could be a real terse brand/name list).
|
|
323
396
|
* Real speech (any chunk with content words) always returns null. */
|
|
324
397
|
export function streamSilenceDropReason(text: string, isQuiet: boolean): 'brand_url' | 'url_silence' | 'thankyou_silence' | null {
|
|
325
398
|
if (!text || !text.trim()) return null
|
|
@@ -24,7 +24,11 @@ export async function callModelStreaming(
|
|
|
24
24
|
): Promise<string> {
|
|
25
25
|
const sid = getOrCreateSession(sessionId)
|
|
26
26
|
const sessionModel = getSessionModel(sid)
|
|
27
|
-
|
|
27
|
+
// COS_G2_DEFAULT_MODEL is the documented default-model switch (CHANGELOG 6.1.0);
|
|
28
|
+
// it must win over the hardcoded DEFAULT_MODEL on this primary query path, not
|
|
29
|
+
// just the OpenAI-compat surface.
|
|
30
|
+
const envDefault = normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL)
|
|
31
|
+
const resolvedModel = normalizeModelPreference(model) ?? sessionModel ?? envDefault ?? DEFAULT_MODEL
|
|
28
32
|
|
|
29
33
|
setSessionModel(sid, resolvedModel)
|
|
30
34
|
|
|
@@ -17,6 +17,8 @@ import { enhanceAudio } from './audio-enhance.js'
|
|
|
17
17
|
import {
|
|
18
18
|
stripInlineHallucinationsOneShot,
|
|
19
19
|
isFullHallucination,
|
|
20
|
+
isVocabEchoOnly,
|
|
21
|
+
countVocabTerms,
|
|
20
22
|
} from './hallucination-filter.js'
|
|
21
23
|
import { getOpenAIKey } from './openai-key.js'
|
|
22
24
|
|
|
@@ -173,7 +175,11 @@ export async function transcribeAudioBuffer(audioBuffer: Buffer, opts: { mode?:
|
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
const elapsedMs = performance.now() - tStart
|
|
176
|
-
|
|
178
|
+
// A one-shot message/dictation that is NOTHING but a list of seeded vocab terms
|
|
179
|
+
// (>=2 distinct) is a whisper prompt-echo, not speech — drop it like the meeting
|
|
180
|
+
// path does. A single terse brand mention stays (could be a real one-word message).
|
|
181
|
+
const vocabEcho = isVocabEchoOnly(text) && countVocabTerms(text) >= 2
|
|
182
|
+
if (!text || isFullHallucination(text) || vocabEcho) {
|
|
177
183
|
throw new NoSpeechDetectedError(text || '')
|
|
178
184
|
}
|
|
179
185
|
|
|
@@ -14,7 +14,7 @@ import { getOpenAIKey } from '../lib/openai-key.js'
|
|
|
14
14
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
15
15
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
16
16
|
import { errMsg } from '../lib/utils.js'
|
|
17
|
-
import { transcribeLocal, isWhisperLocalAvailable, type WhisperWord } from '../lib/whisper-local.js'
|
|
17
|
+
import { transcribeLocal, isWhisperLocalAvailable, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
|
|
18
18
|
import { enhanceAudio } from '../lib/audio-enhance.js'
|
|
19
19
|
import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
|
|
20
20
|
import { identifySpeaker, isEmbeddingAvailable, autoEnroll, getEmbeddingCount } from '../lib/speaker-embeddings.js'
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
isFullHallucination as sharedIsFullHallucination,
|
|
30
30
|
clearSessionHallucinationState,
|
|
31
31
|
streamSilenceDropReason,
|
|
32
|
+
isVocabEchoOnly,
|
|
32
33
|
} from '../lib/hallucination-filter.js'
|
|
33
34
|
|
|
34
35
|
// Silence-hallucination drops (2026-05-29, v5.9.73). Contract in streamSilenceDropReason:
|
|
@@ -170,6 +171,18 @@ interface TranscriptSession {
|
|
|
170
171
|
startTime: number
|
|
171
172
|
title: string
|
|
172
173
|
providerCandidates?: Record<string, ProviderCandidateRecord>
|
|
174
|
+
// ── Transfer integrity (lost-chunk detection) ──────────────
|
|
175
|
+
// Every chunkIndex the server received an audio POST for — recorded at
|
|
176
|
+
// ingest BEFORE any text filtering, so a "received but silent" chunk counts
|
|
177
|
+
// as delivered (not a gap). A genuine hole (index in [0, maxChunkIndex] that
|
|
178
|
+
// never arrived = a chunk lost in transit) is the only thing flagged as a gap.
|
|
179
|
+
// Sorted, de-duplicated. See computeGapReport()/analyzeTranscriptGaps().
|
|
180
|
+
receivedIndices?: number[]
|
|
181
|
+
maxChunkIndex?: number
|
|
182
|
+
// Count of consecutive vocab-echo (prompt-regurgitation) chunks. Reset to 0 by
|
|
183
|
+
// any real-content chunk. Used to drop a RUN of echoed brand names while keeping
|
|
184
|
+
// a single loud one-off (which could be a real terse list). See sanitizeStreamTranscript.
|
|
185
|
+
vocabEchoStreak?: number
|
|
173
186
|
}
|
|
174
187
|
|
|
175
188
|
const sessions = new Map<string, TranscriptSession>()
|
|
@@ -238,11 +251,24 @@ function persistSession(sessionId: string): void {
|
|
|
238
251
|
const session = sessions.get(sessionId)
|
|
239
252
|
if (!session) return
|
|
240
253
|
const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
|
|
254
|
+
// chunksIndexed preserves each chunk's original index (a plain filter()
|
|
255
|
+
// would collapse the sparse array and destroy gap positions on recovery).
|
|
256
|
+
const chunksIndexed: Array<{ i: number; c: TranscriptChunk }> = []
|
|
257
|
+
for (let i = 0; i < session.chunks.length; i++) {
|
|
258
|
+
const c = session.chunks[i]
|
|
259
|
+
if (c && c.text) chunksIndexed.push({ i, c })
|
|
260
|
+
}
|
|
241
261
|
const data = JSON.stringify({
|
|
242
262
|
sessionId,
|
|
243
263
|
startTime: session.startTime,
|
|
244
264
|
title: session.title,
|
|
245
|
-
|
|
265
|
+
// `chunks` = legacy dense form, kept for backward compatibility with
|
|
266
|
+
// existing readers; `chunksIndexed` preserves original indices so gap
|
|
267
|
+
// detection survives recovery. Recovery prefers chunksIndexed.
|
|
268
|
+
chunks: chunksIndexed.map(e => e.c),
|
|
269
|
+
chunksIndexed,
|
|
270
|
+
receivedIndices: session.receivedIndices ?? [],
|
|
271
|
+
maxChunkIndex: session.maxChunkIndex ?? -1,
|
|
246
272
|
providerCandidates: session.providerCandidates ?? {},
|
|
247
273
|
})
|
|
248
274
|
writeFileSync(filePath, data, 'utf-8')
|
|
@@ -259,13 +285,52 @@ function recoverSessions(): void {
|
|
|
259
285
|
if (!file.endsWith('.json')) continue
|
|
260
286
|
try {
|
|
261
287
|
const data = JSON.parse(readFileSync(resolve(CHUNK_PERSIST_DIR, file), 'utf-8'))
|
|
262
|
-
|
|
288
|
+
// Reconstruct the (sparse) chunk array. New format keeps original
|
|
289
|
+
// indices via chunksIndexed; legacy format stored a dense `chunks`.
|
|
290
|
+
const indexed: Array<{ i: number; c: TranscriptChunk }> | null =
|
|
291
|
+
Array.isArray(data.chunksIndexed) ? data.chunksIndexed : null
|
|
292
|
+
const legacy: TranscriptChunk[] | null = Array.isArray(data.chunks) ? data.chunks : null
|
|
293
|
+
const hasChunks = (indexed && indexed.length > 0) || (legacy && legacy.length > 0)
|
|
294
|
+
if (data.sessionId && hasChunks) {
|
|
263
295
|
// Only recover sessions less than 4 hours old
|
|
264
296
|
if (Date.now() - data.startTime < 4 * 60 * 60 * 1000) {
|
|
297
|
+
const chunks: TranscriptChunk[] = []
|
|
298
|
+
if (indexed) {
|
|
299
|
+
for (const e of indexed) {
|
|
300
|
+
if (e && Number.isInteger(e.i) && e.i >= 0 && e.c) chunks[e.i] = e.c
|
|
301
|
+
}
|
|
302
|
+
} else if (legacy) {
|
|
303
|
+
for (let k = 0; k < legacy.length; k++) if (legacy[k]) chunks[k] = legacy[k]
|
|
304
|
+
}
|
|
305
|
+
// Restore the received-index ledger. A legacy file (pre-feature)
|
|
306
|
+
// has no ledger and no way to know whether a chunk was truly lost,
|
|
307
|
+
// so deriving from stored positions would FALSELY flag a
|
|
308
|
+
// received-but-silent chunk as a gap. Instead, treat a legacy
|
|
309
|
+
// session as contiguous (0..maxStored) → it reports 100%, never a
|
|
310
|
+
// false alarm. New-format files carry their own ledger and are exact.
|
|
311
|
+
const hasLedger = Array.isArray(data.receivedIndices)
|
|
312
|
+
let receivedIndices: number[]
|
|
313
|
+
let maxChunkIndex: number
|
|
314
|
+
if (hasLedger) {
|
|
315
|
+
receivedIndices = Array.from(new Set(
|
|
316
|
+
(data.receivedIndices as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0),
|
|
317
|
+
)).sort((a, b) => a - b)
|
|
318
|
+
maxChunkIndex = typeof data.maxChunkIndex === 'number' && data.maxChunkIndex >= 0
|
|
319
|
+
? data.maxChunkIndex
|
|
320
|
+
: (receivedIndices.length > 0 ? receivedIndices[receivedIndices.length - 1] : -1)
|
|
321
|
+
} else {
|
|
322
|
+
let maxStored = -1
|
|
323
|
+
for (let k = 0; k < chunks.length; k++) if (chunks[k]) maxStored = k
|
|
324
|
+
receivedIndices = []
|
|
325
|
+
for (let k = 0; k <= maxStored; k++) receivedIndices.push(k)
|
|
326
|
+
maxChunkIndex = maxStored
|
|
327
|
+
}
|
|
265
328
|
const session: TranscriptSession = {
|
|
266
|
-
chunks
|
|
329
|
+
chunks,
|
|
267
330
|
startTime: data.startTime,
|
|
268
331
|
title: data.title || '',
|
|
332
|
+
receivedIndices,
|
|
333
|
+
maxChunkIndex,
|
|
269
334
|
providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
|
|
270
335
|
? data.providerCandidates
|
|
271
336
|
: {},
|
|
@@ -293,7 +358,8 @@ function recoverSessions(): void {
|
|
|
293
358
|
console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
|
|
294
359
|
persistSession(data.sessionId) // re-persist cleaned data to disk
|
|
295
360
|
}
|
|
296
|
-
|
|
361
|
+
const gaps = computeGapReport(session).missingIndices.length
|
|
362
|
+
console.log(`[session-recovery] Recovered ${session.chunks.filter(c => c && c.text).length} chunks for ${data.sessionId}${gaps > 0 ? ` (${gaps} lost-chunk gap${gaps > 1 ? 's' : ''})` : ''}`)
|
|
297
363
|
} else {
|
|
298
364
|
// Stale — clean up
|
|
299
365
|
unlinkSync(resolve(CHUNK_PERSIST_DIR, file))
|
|
@@ -410,13 +476,106 @@ export function getSession(sessionId: string): TranscriptSession {
|
|
|
410
476
|
return session
|
|
411
477
|
}
|
|
412
478
|
|
|
413
|
-
/**
|
|
414
|
-
|
|
479
|
+
/** Insert a non-negative integer into a sorted array, keeping it sorted and
|
|
480
|
+
* unique. Common case (a new highest value) is O(1); out-of-order (a retry)
|
|
481
|
+
* is a binary-search insert. Mutates `arr`. Exported for unit tests. */
|
|
482
|
+
export function insertSortedUnique(arr: number[], value: number): void {
|
|
483
|
+
if (!Number.isInteger(value) || value < 0) return
|
|
484
|
+
const last = arr.length > 0 ? arr[arr.length - 1] : -1
|
|
485
|
+
if (value > last) { arr.push(value); return }
|
|
486
|
+
if (value === last) return
|
|
487
|
+
let lo = 0, hi = arr.length
|
|
488
|
+
while (lo < hi) {
|
|
489
|
+
const mid = (lo + hi) >> 1
|
|
490
|
+
if (arr[mid] < value) lo = mid + 1
|
|
491
|
+
else hi = mid
|
|
492
|
+
}
|
|
493
|
+
if (arr[lo] !== value) arr.splice(lo, 0, value)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Record that the server received an audio POST for this chunk index.
|
|
497
|
+
* Called at ingest before any text filtering, so silent/empty chunks still
|
|
498
|
+
* count as delivered (not a gap). */
|
|
499
|
+
function recordReceivedChunk(session: TranscriptSession, chunkIndex: number): void {
|
|
500
|
+
if (!Number.isInteger(chunkIndex) || chunkIndex < 0) return
|
|
501
|
+
if (!session.receivedIndices) session.receivedIndices = []
|
|
502
|
+
insertSortedUnique(session.receivedIndices, chunkIndex)
|
|
503
|
+
if (session.maxChunkIndex == null || chunkIndex > session.maxChunkIndex) {
|
|
504
|
+
session.maxChunkIndex = chunkIndex
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export interface TranscriptGapReport {
|
|
509
|
+
received: number // distinct chunk indices the server got
|
|
510
|
+
stored: number // chunks that survived filtering (have text)
|
|
511
|
+
maxIndex: number // highest chunk index seen (-1 if none)
|
|
512
|
+
expected: number // maxIndex + 1
|
|
513
|
+
missingIndices: number[] // indices in [0, maxIndex] never received = lost in transit
|
|
514
|
+
completeness: number // received / expected (1 when nothing expected)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Pure gap math over a received-index ledger. Exported for unit tests. */
|
|
518
|
+
export function analyzeChunkGaps(receivedIndices: number[], maxChunkIndex: number, storedCount = 0): TranscriptGapReport {
|
|
519
|
+
const maxIndex = maxChunkIndex
|
|
520
|
+
const expected = maxIndex + 1
|
|
521
|
+
const recvSet = new Set(receivedIndices.filter(n => Number.isInteger(n) && n >= 0))
|
|
522
|
+
const missingIndices: number[] = []
|
|
523
|
+
for (let i = 0; i <= maxIndex; i++) {
|
|
524
|
+
if (!recvSet.has(i)) missingIndices.push(i)
|
|
525
|
+
}
|
|
526
|
+
const completeness = expected > 0 ? recvSet.size / expected : 1
|
|
527
|
+
return { received: recvSet.size, stored: storedCount, maxIndex, expected, missingIndices, completeness }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Gap report bound to a live session's ledger + stored-chunk count. */
|
|
531
|
+
function computeGapReport(session: TranscriptSession): TranscriptGapReport {
|
|
532
|
+
const received = session.receivedIndices ?? []
|
|
533
|
+
const maxIndex = session.maxChunkIndex ?? (received.length > 0 ? received[received.length - 1] : -1)
|
|
534
|
+
const stored = session.chunks.filter(c => c && c.text).length
|
|
535
|
+
return analyzeChunkGaps(received, maxIndex, stored)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Transfer-integrity report for a live session — null if the session is gone. */
|
|
539
|
+
export function analyzeTranscriptGaps(sessionId: string): TranscriptGapReport | null {
|
|
415
540
|
const session = sessions.get(sessionId)
|
|
416
541
|
if (!session) return null
|
|
417
|
-
return session
|
|
418
|
-
|
|
419
|
-
|
|
542
|
+
return computeGapReport(session)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Get full accumulated transcript for a session (with speaker labels).
|
|
546
|
+
* With { withGaps: true }, walks the index sequence and inserts an explicit
|
|
547
|
+
* marker wherever one or more chunks were never received — so permanently
|
|
548
|
+
* lost audio is visible in the saved transcript instead of silently stitched. */
|
|
549
|
+
export function getSessionTranscript(sessionId: string, opts: { withGaps?: boolean } = {}): string | null {
|
|
550
|
+
const session = sessions.get(sessionId)
|
|
551
|
+
if (!session) return null
|
|
552
|
+
const renderChunk = (c: TranscriptChunk): string => (c.speaker ? `[${c.speaker}]: ${c.text}` : c.text)
|
|
553
|
+
if (!opts.withGaps) {
|
|
554
|
+
return session.chunks.map(renderChunk).join('\n')
|
|
555
|
+
}
|
|
556
|
+
const report = computeGapReport(session)
|
|
557
|
+
if (report.missingIndices.length === 0) {
|
|
558
|
+
return session.chunks.map(renderChunk).join('\n')
|
|
559
|
+
}
|
|
560
|
+
const missing = new Set(report.missingIndices)
|
|
561
|
+
const lines: string[] = []
|
|
562
|
+
let gapRun = 0
|
|
563
|
+
const flushGap = (): void => {
|
|
564
|
+
if (gapRun > 0) {
|
|
565
|
+
// Marker is intentionally >40 inner chars so it can't match the
|
|
566
|
+
// bracket-shaped hallucination filter (/^\s*\[[^\]\n]{1,40}\]\s*$/).
|
|
567
|
+
lines.push(`[… audio gap — ${gapRun} chunk${gapRun > 1 ? 's' : ''} lost in transit, not received by server …]`)
|
|
568
|
+
gapRun = 0
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
for (let i = 0; i <= report.maxIndex; i++) {
|
|
572
|
+
if (missing.has(i)) { gapRun++; continue }
|
|
573
|
+
flushGap()
|
|
574
|
+
const c = session.chunks[i]
|
|
575
|
+
if (c && c.text) lines.push(renderChunk(c))
|
|
576
|
+
}
|
|
577
|
+
flushGap()
|
|
578
|
+
return lines.join('\n')
|
|
420
579
|
}
|
|
421
580
|
|
|
422
581
|
/** Get structured chunks with timing + speaker confidence (for blended meeting pipeline) */
|
|
@@ -599,6 +758,26 @@ function sanitizeStreamTranscript(sessionId: string, session: TranscriptSession,
|
|
|
599
758
|
return { text: '', fallbackReason: dropReason }
|
|
600
759
|
}
|
|
601
760
|
}
|
|
761
|
+
// Vocab-echo (whisper regurgitating its seeded vocab prompt — phantom brand names).
|
|
762
|
+
// Session-aware so we never drop a real one-off: a chunk that is NOTHING but seeded
|
|
763
|
+
// terms is dropped only when (a) the audio is quiet (a silence echo), (b) it's the
|
|
764
|
+
// 2nd+ consecutive such chunk (a RUN — the "repeated multiple times" symptom), or
|
|
765
|
+
// (c) it exactly repeats a recent chunk. A single loud, non-repeating vocab-only
|
|
766
|
+
// chunk is KEPT (it could be a real terse brand/name list). Accepted trade: if a
|
|
767
|
+
// user genuinely dictates a brand list with NO connectives across consecutive
|
|
768
|
+
// chunks ("POS Nation," | "Thrift Cart," | "and IT Retail"), the middle chunk can
|
|
769
|
+
// be dropped — rare (whisper usually bundles a spoken list into one chunk, which
|
|
770
|
+
// is kept) and far less harmful than the phantom-brand spam this prevents.
|
|
771
|
+
if (trimmedText && STRIP_BRAND_URLS && isVocabEchoOnly(trimmedText)) {
|
|
772
|
+
const streak = (session.vocabEchoStreak ?? 0) + 1
|
|
773
|
+
session.vocabEchoStreak = streak
|
|
774
|
+
if (isQuiet || streak >= 2 || isCrossChunkRepeat(session, trimmedText)) {
|
|
775
|
+
console.log(`[hallucination] Dropped (vocab_echo, q=${isQuiet ? 1 : 0}, streak=${streak}): "${trimmedText.slice(0, 60)}"`)
|
|
776
|
+
return { text: '', fallbackReason: 'vocab_echo' }
|
|
777
|
+
}
|
|
778
|
+
} else if (trimmedText) {
|
|
779
|
+
session.vocabEchoStreak = 0
|
|
780
|
+
}
|
|
602
781
|
if (trimmedText && isServerHallucination(trimmedText)) {
|
|
603
782
|
return { text: '', fallbackReason: 'hallucination' }
|
|
604
783
|
}
|
|
@@ -741,6 +920,9 @@ async function processStreamChunk(opts: {
|
|
|
741
920
|
|
|
742
921
|
const audioSha256 = sha256Hex(audioBuffer)
|
|
743
922
|
const session = getSession(sessionId)
|
|
923
|
+
// Transfer integrity: log this index as delivered before any text filtering,
|
|
924
|
+
// so a silent/hallucination-filtered chunk is NOT mistaken for a lost one.
|
|
925
|
+
recordReceivedChunk(session, chunkIndex)
|
|
744
926
|
const alreadyCanonical = session.chunks[chunkIndex]
|
|
745
927
|
|
|
746
928
|
let candidateRecordKey: string | undefined
|
|
@@ -819,11 +1001,17 @@ async function processStreamChunk(opts: {
|
|
|
819
1001
|
backend = result.backend
|
|
820
1002
|
}
|
|
821
1003
|
|
|
1004
|
+
// Apply deterministic name corrections (whisper_corrections map) on EVERY live
|
|
1005
|
+
// path: the iPhone-ASR candidate path and the cloud fallback skip
|
|
1006
|
+
// transcribeLocal's internal pass, so without this the lens would show names
|
|
1007
|
+
// uncorrected for those sources. Idempotent for the local path.
|
|
1008
|
+
rawText = applyCorrections(rawText)
|
|
1009
|
+
|
|
822
1010
|
let sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
|
|
823
1011
|
if (candidate && (!sanitized.text || sanitized.fallbackReason)) {
|
|
824
1012
|
fallbackReason = sanitized.fallbackReason || 'empty_candidate'
|
|
825
1013
|
const result = await transcribeWithServerWhisper(audioBuffer, whisperAudio, whisperContext, isQuiet)
|
|
826
|
-
rawText = result.text
|
|
1014
|
+
rawText = applyCorrections(result.text)
|
|
827
1015
|
words = result.words
|
|
828
1016
|
backend = result.backend
|
|
829
1017
|
asrProvider = 'server-whisper'
|