@gotcos/glasses-server 6.71.14 → 6.73.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 +16 -6
- package/CHANGELOG.md +32 -0
- package/README.md +188 -3
- package/docs/stt-672-evaluation.md +113 -0
- package/package.json +4 -1
- package/server/index.ts +18 -1
- package/server/lib/batch-transcript-quality.ts +46 -0
- package/server/lib/dictation-stt.ts +117 -0
- package/server/lib/meeting-batch-persistence.ts +23 -1
- package/server/lib/meeting-batch-transcribe.ts +408 -45
- package/server/lib/parakeet-manifest.ts +41 -0
- package/server/lib/parakeet.ts +675 -0
- package/server/lib/prompt-draft-store.ts +5 -0
- package/server/lib/stt-live.ts +60 -0
- package/server/lib/stt-resolver.ts +327 -0
- package/server/lib/stt-runtime.ts +233 -0
- package/server/lib/stt-settings.ts +431 -0
- package/server/lib/transcribe-audio.ts +53 -4
- package/server/lib/vad-silero.ts +52 -0
- package/server/lib/whisper-batch-timing.ts +13 -3
- package/server/lib/whisper-local.ts +58 -17
- package/server/routes/meeting.ts +9 -3
- package/server/routes/prompt-drafts.ts +56 -17
- package/server/routes/stt-settings.ts +85 -0
- package/server/routes/transcribe-stream.ts +34 -22
- package/server/routes/transcribe.ts +6 -2
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Dictation speech-to-text (6.73.0): prompts and dictation follow the STT
|
|
2
|
+
// settings. The contract and the evidence behind every rule here:
|
|
3
|
+
// docs/dictation-stt-contract.md.
|
|
4
|
+
//
|
|
5
|
+
// live preview (peek) and Fast mode -> the Preview model
|
|
6
|
+
// final text (Fast mode off) -> the HQ model
|
|
7
|
+
//
|
|
8
|
+
// Only the Parakeet branch is new. Every Whisper path is the 6.72.0 code,
|
|
9
|
+
// unchanged, and it is also the fallback: a Parakeet decode that is cold,
|
|
10
|
+
// failed, busy or behind an open breaker leaves the request on Whisper with a
|
|
11
|
+
// reason code, so the user never sees a blank or an error because of it.
|
|
12
|
+
// The engine is chosen once when a decode starts and never changes during it.
|
|
13
|
+
// Second opinion and resolver never run here. No LLM is ever called.
|
|
14
|
+
|
|
15
|
+
import { transcribeParakeet, ParakeetError } from './parakeet.js'
|
|
16
|
+
import { asrFrontEnd } from './stt-live.js'
|
|
17
|
+
import {
|
|
18
|
+
currentSttCapabilities, ensurePreviewWarm, noteParakeetDictationHit, noteParakeetDictationMiss,
|
|
19
|
+
PARAKEET_PREVIEW_TIMEOUT_MS, PREVIEW_PARAKEET_MODEL,
|
|
20
|
+
} from './stt-runtime.js'
|
|
21
|
+
import {
|
|
22
|
+
dictationPlan, readSttSettings, type DictationPlan, type DictationSlot, type FrontEndId,
|
|
23
|
+
} from './stt-settings.js'
|
|
24
|
+
|
|
25
|
+
/** The plan for a dictation decode starting now. A Parakeet selection that is
|
|
26
|
+
* not warm yet starts a warm-up (never awaited) and decodes this one on Whisper. */
|
|
27
|
+
export function currentDictationPlan(slot: DictationSlot): DictationPlan {
|
|
28
|
+
let plan: DictationPlan
|
|
29
|
+
try {
|
|
30
|
+
const settings = readSttSettings()
|
|
31
|
+
plan = dictationPlan(slot, settings, currentSttCapabilities())
|
|
32
|
+
if (plan.reason === 'parakeet_warming' || plan.reason === 'parakeet_warm_failed') ensurePreviewWarm()
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.warn(`[stt] dictation settings unreadable, using Whisper: ${error instanceof Error ? error.message : error}`)
|
|
35
|
+
plan = { slot, engine: 'whisper', reason: null, frontEnd: 'highpass', key: slot === 'final' ? 'final:large-v3' : `${slot}:turbo` }
|
|
36
|
+
}
|
|
37
|
+
return plan
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Parakeet reads 16 kHz mono PCM16 WAV. The browser recorder posts WebM, which stays on Whisper. */
|
|
41
|
+
export function isPcm16kMonoWav(audio: Buffer): boolean {
|
|
42
|
+
if (audio.length < 44 || audio.toString('ascii', 0, 4) !== 'RIFF' || audio.toString('ascii', 8, 12) !== 'WAVE') return false
|
|
43
|
+
let offset = 12
|
|
44
|
+
while (offset + 8 <= audio.length) {
|
|
45
|
+
const id = audio.toString('ascii', offset, offset + 4)
|
|
46
|
+
const size = audio.readUInt32LE(offset + 4)
|
|
47
|
+
const body = offset + 8
|
|
48
|
+
if (id === 'fmt ') {
|
|
49
|
+
return body + 16 <= audio.length
|
|
50
|
+
&& audio.readUInt16LE(body) === 1 && audio.readUInt16LE(body + 2) === 1
|
|
51
|
+
&& audio.readUInt32LE(body + 4) === 16_000 && audio.readUInt16LE(body + 14) === 16
|
|
52
|
+
}
|
|
53
|
+
offset = body + size + (size % 2)
|
|
54
|
+
}
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A warm Parakeet decodes 6 s in about 0.3 s. The budget is generous so a busy
|
|
59
|
+
* Neural Engine is not a miss, and bounded so a hang falls to Whisper quickly. */
|
|
60
|
+
export function parakeetDictationTimeoutMs(audio: Buffer): number {
|
|
61
|
+
const seconds = Math.max(0, (audio.length - 44) / 32_000)
|
|
62
|
+
return Math.min(15_000, Math.round(2_000 + 150 * seconds))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type DictationLane = 'preview' | 'interactive'
|
|
66
|
+
|
|
67
|
+
export interface DictationParakeetDeps {
|
|
68
|
+
parakeet: (audio: Buffer, lane: DictationLane, timeoutMs: number) => Promise<{ text: string }>
|
|
69
|
+
frontEnd: (audio: Buffer, frontEnd: FrontEndId) => Promise<Buffer>
|
|
70
|
+
hit: () => void
|
|
71
|
+
miss: () => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const defaultDictationParakeetDeps: DictationParakeetDeps = {
|
|
75
|
+
parakeet: (audio, lane, timeoutMs) => transcribeParakeet(audio, { model: PREVIEW_PARAKEET_MODEL, lane, timeoutMs }),
|
|
76
|
+
frontEnd: (audio, fe) => asrFrontEnd(audio, fe),
|
|
77
|
+
hit: () => noteParakeetDictationHit(),
|
|
78
|
+
miss: () => noteParakeetDictationMiss(),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type DictationParakeetOutcome =
|
|
82
|
+
| { ok: true; text: string }
|
|
83
|
+
| { ok: false; reason: 'parakeet_busy' | 'parakeet_failed' | 'audio_not_pcm_wav' }
|
|
84
|
+
|
|
85
|
+
/** Applies the front end only on the Parakeet branch (see the contract). */
|
|
86
|
+
export const DICTATION_PARAKEET_FRONT_END = true
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One Parakeet decode for a dictation request. Never throws: any failure is an
|
|
90
|
+
* outcome the caller turns into its 6.72.0 Whisper decode of the same audio.
|
|
91
|
+
* Raw text only; the caller runs the same corrections and filters as Whisper text.
|
|
92
|
+
*/
|
|
93
|
+
export async function runParakeetDictation(
|
|
94
|
+
audio: Buffer,
|
|
95
|
+
plan: DictationPlan,
|
|
96
|
+
lane: DictationLane,
|
|
97
|
+
deps: DictationParakeetDeps = defaultDictationParakeetDeps,
|
|
98
|
+
): Promise<DictationParakeetOutcome> {
|
|
99
|
+
if (plan.engine !== 'parakeet-ultra') return { ok: false, reason: 'parakeet_failed' }
|
|
100
|
+
if (!isPcm16kMonoWav(audio)) return { ok: false, reason: 'audio_not_pcm_wav' }
|
|
101
|
+
const timeoutMs = lane === 'preview' ? PARAKEET_PREVIEW_TIMEOUT_MS : parakeetDictationTimeoutMs(audio)
|
|
102
|
+
let input = audio
|
|
103
|
+
if (DICTATION_PARAKEET_FRONT_END) {
|
|
104
|
+
// A front-end failure is not a Parakeet miss: decode the raw audio instead.
|
|
105
|
+
try { input = await deps.frontEnd(audio, plan.frontEnd) } catch { input = audio }
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const result = await deps.parakeet(input, lane, timeoutMs)
|
|
109
|
+
deps.hit()
|
|
110
|
+
return { ok: true, text: result.text }
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const busy = error instanceof ParakeetError ? error.reason === 'busy' : (error as { reason?: string })?.reason === 'busy'
|
|
113
|
+
if (!busy) deps.miss()
|
|
114
|
+
console.warn(`[stt] Parakeet dictation ${plan.slot} ${busy ? 'busy' : 'missed'}; Whisper decodes it: ${error instanceof Error ? error.message : error}`)
|
|
115
|
+
return { ok: false, reason: busy ? 'parakeet_busy' : 'parakeet_failed' }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
3
|
-
import type { BatchTranscription } from './batch-transcript-quality.js'
|
|
3
|
+
import type { BatchResult, BatchSttSummary, BatchTranscription } from './batch-transcript-quality.js'
|
|
4
4
|
|
|
5
5
|
/** Replace only the final canonical transcript section. Missing markers fail closed. */
|
|
6
6
|
export function replaceMeetingTranscriptAtomic(meetingPath: string, transcript: string): boolean {
|
|
@@ -38,10 +38,15 @@ export function persistBatchDecisionSidecar(
|
|
|
38
38
|
text: result.text,
|
|
39
39
|
words: result.words,
|
|
40
40
|
speakerWords: result.speakerWords,
|
|
41
|
+
// 6.72.0, additive: both raw hypotheses and every disagreement.
|
|
42
|
+
...(result.stt ? { stt: result.stt } : {}),
|
|
41
43
|
}))
|
|
44
|
+
if (batchResult.stt) sidecar.stt = batchResult.stt
|
|
45
|
+
else delete sidecar.stt
|
|
42
46
|
} else {
|
|
43
47
|
delete sidecar.batchTranscript
|
|
44
48
|
delete sidecar.batchSegments
|
|
49
|
+
delete sidecar.stt
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
durableAtomicWriteFileSync(sidecarPath, JSON.stringify(sidecar, null, 2), { mode: 0o600 })
|
|
@@ -52,3 +57,20 @@ export function persistBatchDecisionSidecar(
|
|
|
52
57
|
export function canDeletePendingBatchAudio(batchApplied: boolean, metadataPersisted: boolean): boolean {
|
|
53
58
|
return batchApplied && metadataPersisted
|
|
54
59
|
}
|
|
60
|
+
|
|
61
|
+
/** Orphan recovery (6.72.0): keep both hypotheses and the disagreements beside
|
|
62
|
+
* the recovered text, additively. Recovery never sets batchApplied. */
|
|
63
|
+
export function persistSttSidecar(sidecarPath: string, results: readonly BatchResult[], summary: BatchSttSummary): boolean {
|
|
64
|
+
if (!existsSync(sidecarPath)) return false
|
|
65
|
+
const parsed = JSON.parse(readFileSync(sidecarPath, 'utf8')) as unknown
|
|
66
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false
|
|
67
|
+
const sidecar = parsed as Record<string, unknown>
|
|
68
|
+
sidecar.stt = summary
|
|
69
|
+
sidecar.sttSegments = results.filter(r => r.stt).map(r => ({
|
|
70
|
+
startChunkIdx: r.segment.startChunkIdx,
|
|
71
|
+
endChunkIdx: r.segment.endChunkIdx,
|
|
72
|
+
stt: r.stt,
|
|
73
|
+
}))
|
|
74
|
+
durableAtomicWriteFileSync(sidecarPath, JSON.stringify(sidecar, null, 2), { mode: 0o600 })
|
|
75
|
+
return true
|
|
76
|
+
}
|