@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.
@@ -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
+ }