@gotcos/glasses-server 6.11.0 → 6.12.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 CHANGED
@@ -58,8 +58,10 @@ BIND_HOST=0.0.0.0
58
58
 
59
59
  # ── VOICE (optional) ────────────────────────────────────────────────────
60
60
  # Local transcription is FREE via whisper.cpp (brew install whisper-cpp; the
61
- # model auto-downloads on first run). If whisper.cpp is absent, voice falls
62
- # back to the OpenAI API (~$0.006/min), which needs this key.
61
+ # model auto-downloads on first run). Voice is local-only by default. Merely
62
+ # configuring a key never uploads audio. To allow OpenAI Whisper only after a
63
+ # local failure, set BOTH the exact opt-in and a key:
64
+ # COS_OPENAI_WHISPER_FALLBACK=1
63
65
  # OPENAI_API_KEY=sk-...
64
66
 
65
67
  # ── FULL COS PIPELINE (optional — leave unset for standalone) ────────────
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.12.0
4
+
5
+ Local-first transcription policy and capability-safe recovery diagnostics for
6
+ COS Glasses build 210+.
7
+
8
+ - **Local means local.** Prompt, one-shot, and meeting transcription now remain
9
+ on local Whisper by default. Finding an OpenAI key is not permission to upload
10
+ audio. Cloud Whisper is reachable only when the exact
11
+ `COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a resolved key are both present.
12
+ - **Every cloud chokepoint is fenced.** Both one-shot/prompt finalization and
13
+ continuous meeting transcription recheck the policy immediately before any
14
+ OpenAI request, preventing a future call-site regression from bypassing the
15
+ top-level selection logic.
16
+ - **Failure stays recoverable.** A local ASR outage returns a typed retryable
17
+ `503` instead of silently switching providers. Durable prompt chunks and raw
18
+ meeting audio remain available for retry; meeting receipt and batch-audio
19
+ retention behavior is unchanged.
20
+ - **Clients can tell policy from health.** `/api/health` and `/api/models`
21
+ publish additive `capabilities.transcription` fields. Public installs also
22
+ advertise every privileged recovery control as unsupported, allowing newer
23
+ phone Recovery Centers to hide controls instead of reporting false outages.
24
+ - **Backward compatible.** Existing routes and response fields remain in place.
25
+ Older apps keep their current query, prompt, meeting, image, and display
26
+ paths; cloud fallback remains available to operators who explicitly enable it.
27
+
3
28
  ## 6.11.0
4
29
 
5
30
  Local-first meeting recovery for COS Glasses build 209+.
package/README.md CHANGED
@@ -27,7 +27,7 @@ without silently losing completed replies.
27
27
  - **Claude Code CLI** (Opus/Fable/Sonnet) — https://claude.ai/download, then `claude login`
28
28
  _or_ **Codex CLI** (GPT Frontier/Balanced) — https://developers.openai.com/codex/, then `codex login`
29
29
  - **Even G2 glasses** + the **COS Glasses** app from the Even Hub
30
- - _Optional:_ `brew install whisper-cpp` for free local voice (otherwise OpenAI API)
30
+ - `brew install whisper-cpp` for free local voice (the launcher can download the model)
31
31
  - _Optional:_ `brew install ffmpeg` for phone/output image attachments (text chat remains available without it)
32
32
  - _Optional:_ **Tailscale** so your phone reaches your Mac from anywhere
33
33
 
@@ -76,7 +76,10 @@ The built-in IP allowlist blocks public-internet traffic regardless.
76
76
  locally through a network interruption. Reconnecting reconciles the exact
77
77
  chunks already stored by the Mac, uploads only missing audio, and finalizes
78
78
  through an idempotent save receipt without duplicating the meeting.
79
- - Local whisper.cpp transcription (free) with OpenAI fallback (optional)
79
+ - Local whisper.cpp transcription (free and local-only by default). OpenAI
80
+ Whisper fallback is optional and requires both the exact
81
+ `COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a configured key; a key alone never
82
+ uploads audio.
80
83
  - Tasks / calendar / people context **if** you run the
81
84
  [COS Starter Kit](https://www.gotcos.com) (`COS_SCRIPTS_DIR`); otherwise it is
82
85
  glasses + AI only
@@ -85,7 +88,8 @@ The built-in IP allowlist blocks public-internet traffic regardless.
85
88
 
86
89
  Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
87
90
  optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
88
- `COS_API_TOKEN` (auto if unset), `OPENAI_API_KEY` (cloud voice fallback),
91
+ `COS_API_TOKEN` (auto if unset), `COS_OPENAI_WHISPER_FALLBACK=1` plus
92
+ `OPENAI_API_KEY` (explicit cloud voice fallback),
89
93
  `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
90
94
  server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
91
95
  location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
@@ -105,7 +109,13 @@ BIND_HOST=0.0.0.0 npm run start:server
105
109
  - *Phone can't connect* — check `BIND_HOST=0.0.0.0`, the same Tailscale account on both devices, and the correct `100.x` IP + token.
106
110
  - *Safari connects but the app does not* — confirm `npx @gotcos/glasses-server@latest` is 6.6.0+, then use the app's server reconnect/edit control to verify the current URL and token. Do not run a second source or `npx` server alongside it.
107
111
  - *AI queries fail* — run `claude --version` / `codex --version`, then `claude login` / `codex login`.
108
- - *Voice getting billed?* — install `whisper-cpp` for free local transcription.
112
+ - *Voice getting billed?* — voice is local-only by default in 6.12.0+. Confirm
113
+ `/api/health` reports `capabilities.transcription.mode: "local-only"`. Remove
114
+ `COS_OPENAI_WHISPER_FALLBACK` (or set it to `0`) to disable an earlier opt-in.
115
+ - *Local voice unavailable?* — install `whisper-cpp`, restart the server, and
116
+ confirm `/api/health` reports `features.whisper: true`. A typed retryable 503
117
+ keeps compatible prompt/meeting audio available for retry instead of silently
118
+ sending it to OpenAI.
109
119
  - *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
110
120
  - *Prompt recovery unavailable?* — update with `npx @gotcos/glasses-server@latest`, then confirm `/api/health` reports `features.promptRecovery: true`.
111
121
  - *Durable query recovery unavailable?* — build 204+ requires server 6.10.0+ and
package/bin/cli.cjs CHANGED
@@ -133,7 +133,8 @@ if (!existsSync(PROFILE_FILE) && existsSync(PROFILE_EXAMPLE)) {
133
133
  }
134
134
  if (!process.env.COS_PROFILE_PATH) process.env.COS_PROFILE_PATH = PROFILE_FILE
135
135
 
136
- // Step 5: local Whisper detection + model download (free voice; OpenAI fallback otherwise)
136
+ // Step 5: local Whisper detection + model download. Voice stays local-only by
137
+ // default; cloud fallback requires an explicit flag plus a configured key.
137
138
  const WHISPER_KNOWN_PATHS = ['/opt/homebrew/bin/whisper-cli', '/usr/local/bin/whisper-cli']
138
139
  const WHISPER_MODEL_DIR = join(homedir(), '.local/share/whisper-models')
139
140
  const WHISPER_MODEL_PATH = join(WHISPER_MODEL_DIR, 'ggml-large-v3-turbo.bin')
@@ -161,10 +162,10 @@ if (whisperCliPath && hasValidModel) {
161
162
  if (existsSync(WHISPER_MODEL_PATH)) { try { unlinkSync(WHISPER_MODEL_PATH) } catch {} }
162
163
  if (existsSync(WHISPER_MODEL_PARTIAL)) { try { unlinkSync(WHISPER_MODEL_PARTIAL) } catch {} }
163
164
  console.log(yellow(' ⚠') + ' whisper.cpp installed but model missing')
164
- console.log(' ' + dim('Downloading ggml-large-v3-turbo (~1.5 GB). Ctrl-C to skip (uses OpenAI API instead).'))
165
+ console.log(' ' + dim('Downloading ggml-large-v3-turbo (~1.5 GB). Ctrl-C to skip (voice remains unavailable by default).'))
165
166
  console.log(' ' + dim('Skip permanently: SKIP_WHISPER_DOWNLOAD=1 npx @gotcos/glasses-server'))
166
167
  if (process.env.SKIP_WHISPER_DOWNLOAD === '1') {
167
- console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — voice will use OpenAI API')
168
+ console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — local voice unavailable')
168
169
  } else {
169
170
  try {
170
171
  mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
@@ -175,14 +176,19 @@ if (whisperCliPath && hasValidModel) {
175
176
  console.log(green(' ✓') + ' Model downloaded ' + dim('— voice = local (FREE)'))
176
177
  } catch (err) {
177
178
  try { unlinkSync(WHISPER_MODEL_PARTIAL) } catch {}
178
- console.log(red(' ✗') + ' Model download failed ' + dim('— voice will use OpenAI API'))
179
+ console.log(red(' ✗') + ' Model download failed ' + dim('— local voice unavailable'))
179
180
  console.log(' ' + dim('Error: ' + (err.message || err).toString().slice(0, 120)))
180
181
  }
181
182
  }
182
183
  } else {
183
- console.log(yellow(' ⚠') + ' whisper.cpp not installed ' + dim('— voice will use OpenAI API ($0.006/min)'))
184
+ console.log(yellow(' ⚠') + ' whisper.cpp not installed ' + dim('— local voice unavailable'))
184
185
  console.log(' Free local voice: ' + bold('brew install whisper-cpp') + dim(' (no Homebrew? https://brew.sh)'))
185
186
  }
187
+ if (process.env.COS_OPENAI_WHISPER_FALLBACK === '1') {
188
+ console.log(yellow(' ⚠') + ' Explicit OpenAI Whisper fallback requested ' + dim('— activates only if a key resolves; see /api/health'))
189
+ } else {
190
+ console.log(green(' ✓') + ' Transcription policy: local-only ' + dim('— a key alone never uploads audio'))
191
+ }
186
192
 
187
193
  // Step 6: image capability — ffmpeg validates, strips metadata, normalizes,
188
194
  // and builds the exact 288x144 G2 variant. It is optional so text/voice remain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.11.0",
3
+ "version": "6.12.0",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@ import {
21
21
  countVocabTerms,
22
22
  } from './hallucination-filter.js'
23
23
  import { getOpenAIKey, tryGetOpenAIKey } from './openai-key.js'
24
+ import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from './transcription-policy.js'
24
25
 
25
26
  export { OpenAIWhisperBudgetExhaustedError, estimateAudioSeconds }
26
27
 
@@ -61,17 +62,30 @@ export class NoSpeechDetectedError extends Error {
61
62
  // ceiling (anything longer is a dictation, not a query — use meetings instead).
62
63
  const HQ_MAX_SECONDS = 60
63
64
 
65
+ function unavailableAfterLocalFailure(): TranscriptionUnavailableError | null {
66
+ const fallback = getTranscriptionPolicySnapshot()
67
+ if (fallback.openaiFallbackReady) return null
68
+ return fallback.openaiFallbackConfigured
69
+ ? new TranscriptionUnavailableError('openai_key_missing', 'Local transcription is unavailable and the explicitly configured OpenAI fallback has no key')
70
+ : new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
71
+ }
72
+
64
73
  /** Transcribe via OpenAI Whisper API (cloud fallback).
65
74
  * Budget-gated: throws OpenAIWhisperBudgetExhaustedError if today's $5 cap is spent.
66
75
  * Ledger only ticks on SUCCESSFUL responses so retries that never reach the API
67
76
  * aren't double-counted. */
68
77
  async function transcribeCloud(audioBuffer: Buffer): Promise<string> {
69
- assertOpenAIWhisperBudget()
78
+ // Defense in depth: every cloud chokepoint rechecks the explicit two-factor
79
+ // opt-in. A key alone is never authority to upload user audio.
80
+ if (!isOpenAIWhisperFallbackReady()) {
81
+ throw unavailableAfterLocalFailure()!
82
+ }
70
83
 
71
84
  if (!tryGetOpenAIKey()) {
72
- throw new TranscriptionUnavailableError('openai_key_missing', 'OpenAI key missing; local audio is preserved for retry')
85
+ throw new TranscriptionUnavailableError('openai_key_missing', 'OpenAI key missing; retry after local Whisper recovers')
73
86
  }
74
87
 
88
+ assertOpenAIWhisperBudget()
75
89
  const key = getOpenAIKey()
76
90
  const audioSeconds = estimateAudioSeconds(audioBuffer)
77
91
 
@@ -157,10 +171,14 @@ export async function transcribeAudioBuffer(
157
171
  backend = `fast-local-${result.backend}`
158
172
  actualQuality = 'fast'
159
173
  } catch (localErr: any) {
160
- if (policy === 'local-only') {
161
- throw new TranscriptionUnavailableError('local_asr_unavailable', `Local transcription unavailable; audio is preserved for retry (${localErr.message})`)
174
+ const unavailable = policy === 'local-only'
175
+ ? new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
176
+ : unavailableAfterLocalFailure()
177
+ if (unavailable) {
178
+ console.warn(`[transcribe] Fast local unavailable; preserving audio for retry: ${localErr.message}`)
179
+ throw unavailable
162
180
  }
163
- console.warn(`[transcribe] Fast local also failed, falling back to cloud: ${localErr.message}`)
181
+ console.warn(`[transcribe] Fast local also failed; using explicitly enabled OpenAI fallback: ${localErr.message}`)
164
182
  text = await transcribeCloud(audioBuffer)
165
183
  backend = 'cloud'
166
184
  actualQuality = 'cloud'
@@ -173,17 +191,24 @@ export async function transcribeAudioBuffer(
173
191
  backend = `fast-local-${result.backend}`
174
192
  actualQuality = 'fast'
175
193
  } catch (localErr: any) {
176
- if (policy === 'local-only') {
177
- throw new TranscriptionUnavailableError('local_asr_unavailable', `Local transcription unavailable; audio is preserved for retry (${localErr.message})`)
194
+ const unavailable = policy === 'local-only'
195
+ ? new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
196
+ : unavailableAfterLocalFailure()
197
+ if (unavailable) {
198
+ console.warn(`[transcribe] Local unavailable; preserving audio for retry: ${localErr.message}`)
199
+ throw unavailable
178
200
  }
179
- console.warn(`[transcribe] Local whisper failed (${getWhisperBackend()}), falling back to cloud: ${localErr.message}`)
201
+ console.warn(`[transcribe] Local whisper failed (${getWhisperBackend()}); using explicitly enabled OpenAI fallback: ${localErr.message}`)
180
202
  text = await transcribeCloud(audioBuffer)
181
203
  backend = 'cloud'
182
204
  actualQuality = 'cloud'
183
205
  }
184
206
  } else {
185
- if (policy === 'local-only') {
186
- throw new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription unavailable; audio is preserved for retry')
207
+ const unavailable = policy === 'local-only'
208
+ ? new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
209
+ : unavailableAfterLocalFailure()
210
+ if (unavailable) {
211
+ throw unavailable
187
212
  }
188
213
  text = await transcribeCloud(audioBuffer)
189
214
  backend = 'cloud'
@@ -0,0 +1,29 @@
1
+ import { getKeyStatus } from './openai-key.js'
2
+
3
+ /**
4
+ * Cloud transcription is a two-factor opt-in. Merely having an OpenAI key on
5
+ * the machine must never route voice away from local Whisper.
6
+ */
7
+ export const OPENAI_WHISPER_FALLBACK_ENV = 'COS_OPENAI_WHISPER_FALLBACK'
8
+
9
+ export interface TranscriptionPolicySnapshot {
10
+ mode: 'local-only' | 'local-then-openai'
11
+ localRequired: true
12
+ openaiFallbackConfigured: boolean
13
+ openaiFallbackReady: boolean
14
+ }
15
+
16
+ export function getTranscriptionPolicySnapshot(): TranscriptionPolicySnapshot {
17
+ const openaiFallbackConfigured = process.env[OPENAI_WHISPER_FALLBACK_ENV] === '1'
18
+ const openaiFallbackReady = openaiFallbackConfigured && getKeyStatus().hasKey
19
+ return {
20
+ mode: openaiFallbackReady ? 'local-then-openai' : 'local-only',
21
+ localRequired: true,
22
+ openaiFallbackConfigured,
23
+ openaiFallbackReady,
24
+ }
25
+ }
26
+
27
+ export function isOpenAIWhisperFallbackReady(): boolean {
28
+ return getTranscriptionPolicySnapshot().openaiFallbackReady
29
+ }
@@ -636,7 +636,7 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
636
636
  }
637
637
 
638
638
  if (!serverAvailable && (serverStarting || serverRestarting)) {
639
- throw new Error('whisper-server starting — use preserved/cloud fallback')
639
+ throw new Error('whisper-server starting — preserve audio for retry')
640
640
  }
641
641
 
642
642
  // Try whisper-server first (fastest: ~50-100ms, includes DTW word timestamps)
@@ -690,13 +690,15 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
690
690
  restartWhisperServer()
691
691
  }
692
692
 
693
- // Throw so caller uses cloud fallback CLI is intentionally skipped for real-time
694
- throw new Error('whisper-server unavailable use cloud fallback')
693
+ // Throw so the caller applies the configured recovery policy. CLI is
694
+ // intentionally skipped for real-time transcription.
695
+ throw new Error('whisper-server unavailable — apply configured recovery policy')
695
696
  }
696
697
 
697
698
  /**
698
699
  * Auto-restart whisper-server after circuit breaker triggers.
699
- * Non-blocking — runs in background while callers use cloud fallback.
700
+ * Non-blocking — runs in background while callers preserve audio or apply the
701
+ * explicitly configured fallback policy.
700
702
  */
701
703
  async function restartWhisperServer(): Promise<void> {
702
704
  if (serverRestarting) return
@@ -734,7 +736,7 @@ async function restartWhisperServer(): Promise<void> {
734
736
  // Without this, the counter stays >= threshold but serverRestarting is false,
735
737
  // so every subsequent call would re-trigger restart in a tight loop
736
738
  serverConsecutiveFailures = 0
737
- console.error('[whisper-local] Server restart failed — reset counter, will retry after next 3 failures. Using cloud fallback.')
739
+ console.error('[whisper-local] Server restart failed — reset counter, will retry after next 3 failures. Caller recovery policy remains active.')
738
740
  }
739
741
  } catch (err: any) {
740
742
  serverConsecutiveFailures = 0 // Same reset — allow future retry cycle
@@ -19,6 +19,7 @@ import { isMediaProcessingReady } from '../lib/image-safety.js'
19
19
  import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
20
20
  import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
21
21
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
22
+ import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
22
23
 
23
24
  export const healthRouter = Router()
24
25
 
@@ -140,6 +141,13 @@ healthRouter.get('/health', async (_req, res) => {
140
141
  const keyStatus = getKeyStatus()
141
142
  const durableJobs = durableQueryJobStatus()
142
143
  const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
144
+ const transcription = getTranscriptionPolicySnapshot()
145
+ const recovery = {
146
+ status: false,
147
+ restartWhisper: false,
148
+ restartServer: false,
149
+ managed: false,
150
+ }
143
151
  const features = {
144
152
  claude: claudeAvailable,
145
153
  codex: codexAvailable,
@@ -154,6 +162,7 @@ healthRouter.get('/health', async (_req, res) => {
154
162
  durableQueryJobs: durableJobs.enabled,
155
163
  durableQueryJobsProtocol: durableJobs.protocolVersion,
156
164
  localFirstMeetings: localFirstMeetings !== null,
165
+ transcriptionPolicy: transcription.mode,
157
166
  }
158
167
  const voice = {
159
168
  hasKey: keyStatus.hasKey,
@@ -173,7 +182,11 @@ healthRouter.get('/health', async (_req, res) => {
173
182
  whisper_health,
174
183
  openai_whisper_budget,
175
184
  codex_models,
176
- capabilities: localFirstMeetings ? { localFirstMeetings } : {},
185
+ capabilities: {
186
+ transcription,
187
+ recovery,
188
+ ...(localFirstMeetings ? { localFirstMeetings } : {}),
189
+ },
177
190
  // /api/health is intentionally unauthenticated for setup diagnostics.
178
191
  // Publish capability only; job counts, retention identities, subscriber
179
192
  // counts, and the storage fingerprint remain internal.
@@ -192,6 +205,7 @@ healthRouter.get('/models', async (req, res) => {
192
205
  const catalog = await getCodexModelCatalog(req.query.refresh === '1')
193
206
  const durableJobs = durableQueryJobStatus()
194
207
  const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
208
+ const transcription = getTranscriptionPolicySnapshot()
195
209
  res.json({
196
210
  ...catalog,
197
211
  serverInstanceId: getServerInstanceId(),
@@ -200,6 +214,13 @@ healthRouter.get('/models', async (req, res) => {
200
214
  enabled: durableJobs.enabled,
201
215
  protocolVersion: durableJobs.protocolVersion,
202
216
  },
217
+ transcription,
218
+ recovery: {
219
+ status: false,
220
+ restartWhisper: false,
221
+ restartServer: false,
222
+ managed: false,
223
+ },
203
224
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
204
225
  },
205
226
  })
@@ -1,5 +1,6 @@
1
1
  // POST /api/transcribe-stream — Streaming transcription for continuous meeting capture
2
- // Uses local Whisper (50ms) with OpenAI API fallback.
2
+ // Uses local Whisper. OpenAI API fallback is disabled by default and requires
3
+ // both COS_OPENAI_WHISPER_FALLBACK=1 and a configured key.
3
4
  // Streams speaker-labeled transcript chunks for live meeting capture.
4
5
 
5
6
  import { Router } from 'express'
@@ -10,11 +11,13 @@ import { resolve } from 'node:path'
10
11
  import { fileURLToPath } from 'node:url'
11
12
  import { getVocabulary, getOwnerName } from '../lib/profile.js'
12
13
  import { getOpenAIKey } from '../lib/openai-key.js'
14
+ import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from '../lib/transcription-policy.js'
15
+ import { TranscriptionUnavailableError } from '../lib/transcribe-audio.js'
13
16
 
14
17
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
15
18
  import { emitDisplay } from '../lib/display-bus.js'
16
19
  import { errMsg } from '../lib/utils.js'
17
- import { transcribeLocal, isWhisperLocalAvailable, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
20
+ import { transcribeLocal, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
18
21
  import { enhanceAudio } from '../lib/audio-enhance.js'
19
22
  import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
20
23
  import { identifySpeaker, isEmbeddingAvailable, autoEnroll, getEmbeddingCount } from '../lib/speaker-embeddings.js'
@@ -1044,18 +1047,26 @@ function canonicalChunkResponse(
1044
1047
  }
1045
1048
 
1046
1049
  async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Buffer, whisperContext: string, isQuiet: boolean): Promise<{ text: string; words?: WhisperWord[]; backend: string }> {
1047
- if (isWhisperLocalAvailable()) {
1048
- try {
1049
- const result = await transcribeLocal(whisperAudio, whisperContext || undefined, isQuiet)
1050
- return { text: result.text, words: result.words, backend: `local-${result.backend}` }
1051
- } catch (err: unknown) {
1052
- console.warn(`[transcribe-stream] Local Whisper failed, falling back to cloud: ${errMsg(err)}`)
1053
- const text = await transcribeViaCloud(whisperAudio)
1054
- return { text, words: undefined, backend: 'cloud' }
1050
+ // The worker owns reconciliation of stale health. Always attempt local ASR
1051
+ // once; an availability snapshot must not divert meeting audio to cloud.
1052
+ try {
1053
+ const result = await transcribeLocal(whisperAudio, whisperContext || undefined, isQuiet)
1054
+ return { text: result.text, words: result.words, backend: `local-${result.backend}` }
1055
+ } catch (err: unknown) {
1056
+ if (!isOpenAIWhisperFallbackReady()) {
1057
+ const fallback = getTranscriptionPolicySnapshot()
1058
+ console.warn(`[transcribe-stream] Local Whisper unavailable; preserving chunk for retry: ${errMsg(err)}`)
1059
+ throw new TranscriptionUnavailableError(
1060
+ fallback.openaiFallbackConfigured ? 'openai_key_missing' : 'local_asr_unavailable',
1061
+ fallback.openaiFallbackConfigured
1062
+ ? 'Local transcription is unavailable; audio is preserved for retry and the configured OpenAI fallback has no key'
1063
+ : 'Local transcription is unavailable; audio is preserved for retry',
1064
+ )
1055
1065
  }
1066
+ console.warn(`[transcribe-stream] Local Whisper failed; using explicitly enabled OpenAI fallback: ${errMsg(err)}`)
1067
+ const text = await transcribeViaCloud(whisperAudio)
1068
+ return { text, words: undefined, backend: 'cloud' }
1056
1069
  }
1057
- const text = await transcribeViaCloud(whisperAudio)
1058
- return { text, words: undefined, backend: 'cloud' }
1059
1070
  }
1060
1071
 
1061
1072
  function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex: number, clientSpeaker: string): { speaker: string; similarity: number } {
@@ -1315,6 +1326,14 @@ async function processStreamChunk(opts: {
1315
1326
  }
1316
1327
 
1317
1328
  function sendStreamError(res: { status: (code: number) => { json: (body: unknown) => unknown } }, err: unknown): unknown {
1329
+ if (err instanceof TranscriptionUnavailableError) {
1330
+ console.warn(`[transcribe-stream] ${err.message}`)
1331
+ return res.status(err.status).json({
1332
+ error: err.message,
1333
+ reason: err.reason,
1334
+ retryable: true,
1335
+ })
1336
+ }
1318
1337
  if (err instanceof OpenAIWhisperBudgetExhaustedError) {
1319
1338
  console.error(`[transcribe-stream] ${err.message}`)
1320
1339
  return res.status(503).json({
@@ -1487,9 +1506,18 @@ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) =>
1487
1506
  * A hung whisper-server + long meeting is the exact scenario this guards against —
1488
1507
  * chunks stay empty on budget-exceeded instead of silently billing per chunk. */
1489
1508
  async function transcribeViaCloud(audioBuffer: Buffer): Promise<string> {
1490
- assertOpenAIWhisperBudget()
1509
+ if (!isOpenAIWhisperFallbackReady()) {
1510
+ const fallback = getTranscriptionPolicySnapshot()
1511
+ throw new TranscriptionUnavailableError(
1512
+ fallback.openaiFallbackConfigured ? 'openai_key_missing' : 'local_asr_unavailable',
1513
+ fallback.openaiFallbackConfigured
1514
+ ? 'Local transcription is unavailable; audio is preserved for retry and the configured OpenAI fallback has no key'
1515
+ : 'Local transcription is unavailable; audio is preserved for retry',
1516
+ )
1517
+ }
1491
1518
 
1492
1519
  const key = getOpenAIKey()
1520
+ assertOpenAIWhisperBudget()
1493
1521
  const audioSeconds = estimateAudioSeconds(audioBuffer)
1494
1522
 
1495
1523
  const isWav = audioBuffer.length >= 4 && audioBuffer.toString('ascii', 0, 4) === 'RIFF'
@@ -9,6 +9,7 @@ import {
9
9
  resolveTranscribeMode,
10
10
  NoSpeechDetectedError,
11
11
  OpenAIWhisperBudgetExhaustedError,
12
+ TranscriptionUnavailableError,
12
13
  } from '../lib/transcribe-audio.js'
13
14
 
14
15
  export const transcribeRouter = Router()
@@ -50,6 +51,14 @@ transcribeRouter.post('/transcribe', async (req, res) => {
50
51
  cap_usd: err.capUsd,
51
52
  })
52
53
  }
54
+ if (err instanceof TranscriptionUnavailableError) {
55
+ console.warn(`[transcribe] ${err.message}`)
56
+ return res.status(err.status).json({
57
+ error: err.message,
58
+ reason: err.reason,
59
+ retryable: true,
60
+ })
61
+ }
53
62
  res.status(500).json({ error: err.message })
54
63
  }
55
64
  })