@gotcos/glasses-server 6.10.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 +4 -2
- package/CHANGELOG.md +51 -0
- package/README.md +23 -4
- package/bin/cli.cjs +11 -5
- package/package.json +1 -1
- package/server/lib/local-first-meetings-contract.ts +53 -0
- package/server/lib/transcribe-audio.ts +35 -10
- package/server/lib/transcription-policy.ts +29 -0
- package/server/lib/whisper-local.ts +7 -5
- package/server/routes/health.ts +27 -0
- package/server/routes/meeting.ts +31 -0
- package/server/routes/transcribe-stream.ts +275 -85
- package/server/routes/transcribe.ts +9 -0
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).
|
|
62
|
-
#
|
|
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,56 @@
|
|
|
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
|
+
|
|
28
|
+
## 6.11.0
|
|
29
|
+
|
|
30
|
+
Local-first meeting recovery for COS Glasses build 209+.
|
|
31
|
+
|
|
32
|
+
- **Record through network loss.** The server advertises a versioned
|
|
33
|
+
`localFirstMeetings` capability with its stable instance ID. Compatible
|
|
34
|
+
clients can keep audio locally, reconnect to the same server, and reconcile
|
|
35
|
+
the exact sparse set of chunks it durably received.
|
|
36
|
+
- **Durable means acknowledged.** Raw meeting WAVs and the received-index
|
|
37
|
+
ledger are committed atomically before a chunk receives success. Storage
|
|
38
|
+
failures return typed retryable errors; capacity exhaustion returns `507`
|
|
39
|
+
instead of silently discarding audio.
|
|
40
|
+
- **Long meetings stay alive.** Active-session retention is measured from the
|
|
41
|
+
last durable activity, not the meeting start time, so recordings longer than
|
|
42
|
+
four hours are not mistaken for abandoned sessions.
|
|
43
|
+
- **Safe reconnect and close.** Authenticated session-status responses expose
|
|
44
|
+
exact compressed receive ranges, retention, and closed/saved state. Durable
|
|
45
|
+
tombstones prevent a late or replaying client from recreating a completed
|
|
46
|
+
meeting after a restart.
|
|
47
|
+
- **Idempotent finalization.** Repeating `POST /api/meeting/save` for an already
|
|
48
|
+
saved session returns the original versioned receipt and filename without
|
|
49
|
+
creating a second meeting.
|
|
50
|
+
- **Backward compatible.** Existing live transcription, meeting save, prompt
|
|
51
|
+
recovery, durable queries, and older clients retain their prior routes and
|
|
52
|
+
fields. The new capability, receipt fields, and status route are additive.
|
|
53
|
+
|
|
3
54
|
## 6.10.0
|
|
4
55
|
|
|
5
56
|
Opt-in server-owned durable query jobs for COS Glasses build 204+.
|
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
|
-
-
|
|
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
|
|
|
@@ -72,7 +72,14 @@ The built-in IP allowlist blocks public-internet traffic regardless.
|
|
|
72
72
|
compatible app builds, their warm transcript also appears live while speaking;
|
|
73
73
|
final HQ transcription remains authoritative.
|
|
74
74
|
- Live voice capture + transcription during meetings
|
|
75
|
-
-
|
|
75
|
+
- With COS Glasses build 209+ and server 6.11.0+, meetings continue recording
|
|
76
|
+
locally through a network interruption. Reconnecting reconciles the exact
|
|
77
|
+
chunks already stored by the Mac, uploads only missing audio, and finalizes
|
|
78
|
+
through an idempotent save receipt without duplicating the meeting.
|
|
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.
|
|
76
83
|
- Tasks / calendar / people context **if** you run the
|
|
77
84
|
[COS Starter Kit](https://www.gotcos.com) (`COS_SCRIPTS_DIR`); otherwise it is
|
|
78
85
|
glasses + AI only
|
|
@@ -81,7 +88,8 @@ The built-in IP allowlist blocks public-internet traffic regardless.
|
|
|
81
88
|
|
|
82
89
|
Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
|
|
83
90
|
optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
|
|
84
|
-
`COS_API_TOKEN` (auto if unset), `
|
|
91
|
+
`COS_API_TOKEN` (auto if unset), `COS_OPENAI_WHISPER_FALLBACK=1` plus
|
|
92
|
+
`OPENAI_API_KEY` (explicit cloud voice fallback),
|
|
85
93
|
`COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
|
|
86
94
|
server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
|
|
87
95
|
location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
|
|
@@ -101,13 +109,24 @@ BIND_HOST=0.0.0.0 npm run start:server
|
|
|
101
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.
|
|
102
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.
|
|
103
111
|
- *AI queries fail* — run `claude --version` / `codex --version`, then `claude login` / `codex login`.
|
|
104
|
-
- *Voice getting billed?* —
|
|
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.
|
|
105
119
|
- *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
|
|
106
120
|
- *Prompt recovery unavailable?* — update with `npx @gotcos/glasses-server@latest`, then confirm `/api/health` reports `features.promptRecovery: true`.
|
|
107
121
|
- *Durable query recovery unavailable?* — build 204+ requires server 6.10.0+ and
|
|
108
122
|
`COS_DURABLE_QUERY_JOBS=1`. Restart once, then confirm `/api/health` reports
|
|
109
123
|
`features.durableQueryJobs: true`, protocol `1`, and state `ready`. To roll
|
|
110
124
|
back, remove the flag; accepted jobs still drain while new prompts use legacy streaming.
|
|
125
|
+
- *Offline meeting recovery unavailable?* — build 209+ requires server 6.11.0+.
|
|
126
|
+
Restart once, then confirm `/api/health` reports
|
|
127
|
+
`features.localFirstMeetings: true` and
|
|
128
|
+
`capabilities.localFirstMeetings.protocolVersion: 1`. Older app builds keep
|
|
129
|
+
using their existing live-transcription and meeting-save paths.
|
|
111
130
|
|
|
112
131
|
## License
|
|
113
132
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const LOCAL_FIRST_MEETINGS_PROTOCOL_VERSION = 1 as const
|
|
2
|
+
export const LOCAL_FIRST_MEETING_IDLE_RETENTION_MS = 4 * 60 * 60 * 1000
|
|
3
|
+
|
|
4
|
+
export interface LocalFirstMeetingsCapability {
|
|
5
|
+
protocolVersion: typeof LOCAL_FIRST_MEETINGS_PROTOCOL_VERSION
|
|
6
|
+
serverInstanceId: string
|
|
7
|
+
idempotentSave: true
|
|
8
|
+
sessionStatus: true
|
|
9
|
+
retentionMs: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type MeetingSessionState = 'active' | 'closed' | 'saved' | 'missing'
|
|
13
|
+
export type IndexRange = [start: number, end: number]
|
|
14
|
+
|
|
15
|
+
export function localFirstMeetingsCapability(serverInstanceId: string | null): LocalFirstMeetingsCapability | null {
|
|
16
|
+
if (!serverInstanceId) return null
|
|
17
|
+
return {
|
|
18
|
+
protocolVersion: LOCAL_FIRST_MEETINGS_PROTOCOL_VERSION,
|
|
19
|
+
serverInstanceId,
|
|
20
|
+
idempotentSave: true,
|
|
21
|
+
sessionStatus: true,
|
|
22
|
+
retentionMs: LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Exact, compact representation of a sparse received-index ledger. */
|
|
27
|
+
export function compressIndexRanges(indices: readonly number[]): IndexRange[] {
|
|
28
|
+
const sorted = Array.from(new Set(
|
|
29
|
+
indices.filter(value => Number.isInteger(value) && value >= 0),
|
|
30
|
+
)).sort((a, b) => a - b)
|
|
31
|
+
if (sorted.length === 0) return []
|
|
32
|
+
|
|
33
|
+
const ranges: IndexRange[] = []
|
|
34
|
+
let start = sorted[0]
|
|
35
|
+
let end = start
|
|
36
|
+
for (let index = 1; index < sorted.length; index++) {
|
|
37
|
+
const value = sorted[index]
|
|
38
|
+
if (value === end + 1) {
|
|
39
|
+
end = value
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
ranges.push([start, end])
|
|
43
|
+
start = value
|
|
44
|
+
end = value
|
|
45
|
+
}
|
|
46
|
+
ranges.push([start, end])
|
|
47
|
+
return ranges
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function retainedUntilIso(lastActivityAt: number | null): string | null {
|
|
51
|
+
if (lastActivityAt == null || !Number.isFinite(lastActivityAt)) return null
|
|
52
|
+
return new Date(lastActivityAt + LOCAL_FIRST_MEETING_IDLE_RETENTION_MS).toISOString()
|
|
53
|
+
}
|
|
@@ -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
|
-
|
|
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;
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
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()})
|
|
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
|
-
|
|
186
|
-
|
|
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 —
|
|
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
|
|
694
|
-
|
|
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
|
|
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.
|
|
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
|
package/server/routes/health.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { resolve } from 'node:path'
|
|
|
5
5
|
import { COS_SCRIPTS_DIR, COS_MODE, PYTHON_BIN } from '../lib/python-bridge.js'
|
|
6
6
|
import { serverMetrics } from '../lib/server-metrics.js'
|
|
7
7
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
8
|
+
import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
|
|
8
9
|
import { isSileroAvailable } from '../lib/vad-silero.js'
|
|
9
10
|
import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
|
|
10
11
|
import { isWhisperLocalAvailable, getWhisperHealth } from '../lib/whisper-local.js'
|
|
@@ -18,6 +19,7 @@ import { isMediaProcessingReady } from '../lib/image-safety.js'
|
|
|
18
19
|
import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
|
|
19
20
|
import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
|
|
20
21
|
import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
22
|
+
import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
|
|
21
23
|
|
|
22
24
|
export const healthRouter = Router()
|
|
23
25
|
|
|
@@ -138,6 +140,14 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
138
140
|
// work can decide whether to prompt for a key.
|
|
139
141
|
const keyStatus = getKeyStatus()
|
|
140
142
|
const durableJobs = durableQueryJobStatus()
|
|
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
|
+
}
|
|
141
151
|
const features = {
|
|
142
152
|
claude: claudeAvailable,
|
|
143
153
|
codex: codexAvailable,
|
|
@@ -151,6 +161,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
151
161
|
g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
|
|
152
162
|
durableQueryJobs: durableJobs.enabled,
|
|
153
163
|
durableQueryJobsProtocol: durableJobs.protocolVersion,
|
|
164
|
+
localFirstMeetings: localFirstMeetings !== null,
|
|
165
|
+
transcriptionPolicy: transcription.mode,
|
|
154
166
|
}
|
|
155
167
|
const voice = {
|
|
156
168
|
hasKey: keyStatus.hasKey,
|
|
@@ -170,6 +182,11 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
170
182
|
whisper_health,
|
|
171
183
|
openai_whisper_budget,
|
|
172
184
|
codex_models,
|
|
185
|
+
capabilities: {
|
|
186
|
+
transcription,
|
|
187
|
+
recovery,
|
|
188
|
+
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
189
|
+
},
|
|
173
190
|
// /api/health is intentionally unauthenticated for setup diagnostics.
|
|
174
191
|
// Publish capability only; job counts, retention identities, subscriber
|
|
175
192
|
// counts, and the storage fingerprint remain internal.
|
|
@@ -187,6 +204,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
187
204
|
healthRouter.get('/models', async (req, res) => {
|
|
188
205
|
const catalog = await getCodexModelCatalog(req.query.refresh === '1')
|
|
189
206
|
const durableJobs = durableQueryJobStatus()
|
|
207
|
+
const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
|
|
208
|
+
const transcription = getTranscriptionPolicySnapshot()
|
|
190
209
|
res.json({
|
|
191
210
|
...catalog,
|
|
192
211
|
serverInstanceId: getServerInstanceId(),
|
|
@@ -195,6 +214,14 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
195
214
|
enabled: durableJobs.enabled,
|
|
196
215
|
protocolVersion: durableJobs.protocolVersion,
|
|
197
216
|
},
|
|
217
|
+
transcription,
|
|
218
|
+
recovery: {
|
|
219
|
+
status: false,
|
|
220
|
+
restartWhisper: false,
|
|
221
|
+
restartServer: false,
|
|
222
|
+
managed: false,
|
|
223
|
+
},
|
|
224
|
+
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
198
225
|
},
|
|
199
226
|
})
|
|
200
227
|
})
|
package/server/routes/meeting.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
getSessionProviderCandidates,
|
|
32
32
|
getSessionStartTime,
|
|
33
33
|
getSessionTranscript,
|
|
34
|
+
getMeetingSessionStatus,
|
|
34
35
|
hasSessionAudio,
|
|
35
36
|
moveSessionAudioToPending,
|
|
36
37
|
type IndexedTranscriptChunk,
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
type TranscriptChunk,
|
|
39
40
|
type TranscriptGapReport,
|
|
40
41
|
} from './transcribe-stream.js'
|
|
42
|
+
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
41
43
|
|
|
42
44
|
interface MeetingSessionSource {
|
|
43
45
|
getTranscript(sessionId: string): string | null
|
|
@@ -98,6 +100,8 @@ function publicSaveResponse(saved: SavedMeeting, replayed = false): Record<strin
|
|
|
98
100
|
? Math.floor(integrity.completeness * 1_000) / 10
|
|
99
101
|
: 100
|
|
100
102
|
return {
|
|
103
|
+
receiptVersion: 1,
|
|
104
|
+
serverInstanceId: getServerInstanceId(),
|
|
101
105
|
saved: true,
|
|
102
106
|
// Keep the build199 string field without leaking an absolute host path.
|
|
103
107
|
filepath: `recordings/${saved.month}/${saved.filename}`,
|
|
@@ -125,6 +129,33 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
125
129
|
const router = Router()
|
|
126
130
|
const savingSessions = new Set<string>()
|
|
127
131
|
|
|
132
|
+
router.get('/meeting/sessions/:sessionId/status', (req, res) => {
|
|
133
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
134
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
135
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const serverInstanceId = getServerInstanceId()
|
|
139
|
+
if (!serverInstanceId) {
|
|
140
|
+
res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const saved = store.findBySessionId(sessionId)
|
|
144
|
+
const live = getMeetingSessionStatus(sessionId)
|
|
145
|
+
res.set('Cache-Control', 'private, no-store')
|
|
146
|
+
res.json({
|
|
147
|
+
sessionId,
|
|
148
|
+
state: saved ? 'saved' : live.state,
|
|
149
|
+
serverInstanceId,
|
|
150
|
+
receivedRanges: live.receivedRanges,
|
|
151
|
+
receivedCount: live.receivedCount,
|
|
152
|
+
maxChunkIndex: live.maxChunkIndex,
|
|
153
|
+
lastActivityAt: live.lastActivityAt,
|
|
154
|
+
retainedUntil: saved ? null : live.retainedUntil,
|
|
155
|
+
saveReceipt: saved ? publicSaveResponse(saved) : null,
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
128
159
|
router.post('/meeting/save', async (req, res) => {
|
|
129
160
|
let lockedSessionId: string | null = null
|
|
130
161
|
try {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// POST /api/transcribe-stream — Streaming transcription for continuous meeting capture
|
|
2
|
-
// Uses local Whisper
|
|
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,
|
|
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'
|
|
@@ -32,6 +35,13 @@ import {
|
|
|
32
35
|
isVocabEchoOnly,
|
|
33
36
|
} from '../lib/hallucination-filter.js'
|
|
34
37
|
import { dataPath } from '../lib/data-dir.js'
|
|
38
|
+
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
39
|
+
import {
|
|
40
|
+
LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
|
|
41
|
+
compressIndexRanges,
|
|
42
|
+
retainedUntilIso,
|
|
43
|
+
type IndexRange,
|
|
44
|
+
} from '../lib/local-first-meetings-contract.js'
|
|
35
45
|
|
|
36
46
|
function ensurePrivateDirectory(path: string): void {
|
|
37
47
|
if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
@@ -200,6 +210,8 @@ interface TranscriptSession {
|
|
|
200
210
|
// Sorted, de-duplicated. See computeGapReport()/analyzeTranscriptGaps().
|
|
201
211
|
receivedIndices?: number[]
|
|
202
212
|
maxChunkIndex?: number
|
|
213
|
+
/** Persisted idle-retention clock. Meeting date/duration still use startTime. */
|
|
214
|
+
lastActivityAt: number
|
|
203
215
|
// Count of consecutive vocab-echo (prompt-regurgitation) chunks. Reset to 0 by
|
|
204
216
|
// any real-content chunk. Used to drop a RUN of echoed brand names while keeping
|
|
205
217
|
// a single loud one-off (which could be a real terse list). See sanitizeStreamTranscript.
|
|
@@ -207,22 +219,60 @@ interface TranscriptSession {
|
|
|
207
219
|
}
|
|
208
220
|
|
|
209
221
|
const sessions = new Map<string, TranscriptSession>()
|
|
210
|
-
const CLOSED_SESSION_TTL_MS =
|
|
222
|
+
const CLOSED_SESSION_TTL_MS = LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
|
|
211
223
|
const CLOSED_SESSIONS_FILE = dataPath('closed-transcript-sessions.json')
|
|
212
224
|
|
|
225
|
+
interface ClosedTranscriptSession {
|
|
226
|
+
closedAt: number
|
|
227
|
+
lastActivityAt: number
|
|
228
|
+
receivedIndices: number[]
|
|
229
|
+
maxChunkIndex: number
|
|
230
|
+
reason: 'saved' | 'expired' | 'closed'
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const closedSessionRecords = new Map<string, ClosedTranscriptSession>()
|
|
234
|
+
|
|
213
235
|
// Incremental chunk persistence — survive server restarts
|
|
214
236
|
const CHUNK_PERSIST_DIR = dataPath('active-sessions')
|
|
215
237
|
ensurePrivateDirectory(CHUNK_PERSIST_DIR)
|
|
216
238
|
|
|
217
|
-
function readClosedSessions(): Record<string,
|
|
239
|
+
function readClosedSessions(): Record<string, ClosedTranscriptSession> {
|
|
218
240
|
if (!existsSync(CLOSED_SESSIONS_FILE)) return {}
|
|
219
241
|
try {
|
|
220
242
|
const parsed = JSON.parse(readFileSync(CLOSED_SESSIONS_FILE, 'utf-8')) as unknown
|
|
221
243
|
if (!parsed || typeof parsed !== 'object') return {}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
244
|
+
const normalized: Record<string, ClosedTranscriptSession> = {}
|
|
245
|
+
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
246
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(id)) continue
|
|
247
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
248
|
+
normalized[id] = {
|
|
249
|
+
closedAt: value,
|
|
250
|
+
lastActivityAt: value,
|
|
251
|
+
receivedIndices: [],
|
|
252
|
+
maxChunkIndex: -1,
|
|
253
|
+
reason: 'closed',
|
|
254
|
+
}
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
if (!value || typeof value !== 'object') continue
|
|
258
|
+
const raw = value as Record<string, unknown>
|
|
259
|
+
const closedAt = typeof raw.closedAt === 'number' && Number.isFinite(raw.closedAt) ? raw.closedAt : null
|
|
260
|
+
if (closedAt == null) continue
|
|
261
|
+
const lastActivityAt = typeof raw.lastActivityAt === 'number' && Number.isFinite(raw.lastActivityAt)
|
|
262
|
+
? raw.lastActivityAt
|
|
263
|
+
: closedAt
|
|
264
|
+
const receivedIndices = Array.isArray(raw.receivedIndices)
|
|
265
|
+
? Array.from(new Set(
|
|
266
|
+
raw.receivedIndices.filter((entry): entry is number => Number.isInteger(entry) && (entry as number) >= 0),
|
|
267
|
+
)).sort((a, b) => a - b)
|
|
268
|
+
: []
|
|
269
|
+
const maxChunkIndex = typeof raw.maxChunkIndex === 'number' && Number.isInteger(raw.maxChunkIndex)
|
|
270
|
+
? raw.maxChunkIndex
|
|
271
|
+
: (receivedIndices.at(-1) ?? -1)
|
|
272
|
+
const reason = raw.reason === 'saved' || raw.reason === 'expired' ? raw.reason : 'closed'
|
|
273
|
+
normalized[id] = { closedAt, lastActivityAt, receivedIndices, maxChunkIndex, reason }
|
|
274
|
+
}
|
|
275
|
+
return normalized
|
|
226
276
|
} catch {
|
|
227
277
|
try {
|
|
228
278
|
renameSync(CLOSED_SESSIONS_FILE, `${CLOSED_SESSIONS_FILE}.corrupt.${Date.now()}`)
|
|
@@ -234,25 +284,31 @@ function readClosedSessions(): Record<string, number> {
|
|
|
234
284
|
function persistClosedSessions(): void {
|
|
235
285
|
const now = Date.now()
|
|
236
286
|
const merged = readClosedSessions()
|
|
237
|
-
for (const id of deletedSessions)
|
|
238
|
-
|
|
239
|
-
|
|
287
|
+
for (const id of deletedSessions) {
|
|
288
|
+
merged[id] = closedSessionRecords.get(id) ?? {
|
|
289
|
+
closedAt: now,
|
|
290
|
+
lastActivityAt: now,
|
|
291
|
+
receivedIndices: [],
|
|
292
|
+
maxChunkIndex: -1,
|
|
293
|
+
reason: 'closed',
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
for (const [id, record] of Object.entries(merged)) {
|
|
297
|
+
if (now - record.closedAt > CLOSED_SESSION_TTL_MS) delete merged[id]
|
|
240
298
|
}
|
|
241
299
|
try {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
renameSync(tmp, CLOSED_SESSIONS_FILE)
|
|
245
|
-
try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
|
|
246
|
-
} catch { /* best-effort tombstones */ }
|
|
300
|
+
durableAtomicWriteFileSync(CLOSED_SESSIONS_FILE, JSON.stringify(merged, null, 2), { mode: 0o600 })
|
|
301
|
+
} catch { /* best-effort tombstones; saved receipts remain authoritative */ }
|
|
247
302
|
}
|
|
248
303
|
|
|
249
304
|
function recoverClosedSessions(): void {
|
|
250
305
|
const now = Date.now()
|
|
251
306
|
const closed = readClosedSessions()
|
|
252
307
|
let dirty = false
|
|
253
|
-
for (const [id,
|
|
254
|
-
if (now - closedAt <= CLOSED_SESSION_TTL_MS) {
|
|
255
|
-
|
|
308
|
+
for (const [id, record] of Object.entries(closed)) {
|
|
309
|
+
if (now - record.closedAt <= CLOSED_SESSION_TTL_MS) {
|
|
310
|
+
rememberDeletedSession(id)
|
|
311
|
+
closedSessionRecords.set(id, record)
|
|
256
312
|
} else {
|
|
257
313
|
delete closed[id]
|
|
258
314
|
dirty = true
|
|
@@ -260,20 +316,17 @@ function recoverClosedSessions(): void {
|
|
|
260
316
|
}
|
|
261
317
|
if (dirty) {
|
|
262
318
|
try {
|
|
263
|
-
|
|
264
|
-
writeFileSync(tmp, JSON.stringify(closed, null, 2), { encoding: 'utf-8', mode: 0o600 })
|
|
265
|
-
renameSync(tmp, CLOSED_SESSIONS_FILE)
|
|
266
|
-
try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
|
|
319
|
+
durableAtomicWriteFileSync(CLOSED_SESSIONS_FILE, JSON.stringify(closed, null, 2), { mode: 0o600 })
|
|
267
320
|
} catch {}
|
|
268
321
|
}
|
|
269
322
|
}
|
|
270
323
|
|
|
271
|
-
/** Persist a session
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
324
|
+
/** Persist a session before acknowledging any chunk. Throws on failure so a
|
|
325
|
+
* client never interprets a non-durable index as accepted. */
|
|
326
|
+
function persistSessionRequired(sessionId: string): void {
|
|
327
|
+
const session = sessions.get(sessionId)
|
|
328
|
+
if (!session) throw makeHttpError(404, 'session not found', 'session_not_found')
|
|
329
|
+
const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
|
|
277
330
|
// chunksIndexed preserves each chunk's original index (a plain filter()
|
|
278
331
|
// would collapse the sparse array and destroy gap positions on recovery).
|
|
279
332
|
const chunksIndexed: Array<{ i: number; c: TranscriptChunk }> = []
|
|
@@ -281,9 +334,10 @@ function persistSession(sessionId: string): void {
|
|
|
281
334
|
const c = session.chunks[i]
|
|
282
335
|
if (c && c.text) chunksIndexed.push({ i, c })
|
|
283
336
|
}
|
|
284
|
-
|
|
337
|
+
const data = JSON.stringify({
|
|
285
338
|
sessionId,
|
|
286
339
|
startTime: session.startTime,
|
|
340
|
+
lastActivityAt: session.lastActivityAt,
|
|
287
341
|
title: session.title,
|
|
288
342
|
// `chunks` = legacy dense form, kept for backward compatibility with
|
|
289
343
|
// existing readers; `chunksIndexed` preserves original indices so gap
|
|
@@ -294,9 +348,16 @@ function persistSession(sessionId: string): void {
|
|
|
294
348
|
maxChunkIndex: session.maxChunkIndex ?? -1,
|
|
295
349
|
providerCandidates: session.providerCandidates ?? {},
|
|
296
350
|
})
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
} catch {
|
|
351
|
+
try {
|
|
352
|
+
durableAtomicWriteFileSync(filePath, data, { mode: 0o600 })
|
|
353
|
+
} catch (error) {
|
|
354
|
+
console.error(`[transcribe-stream] Durable session write failed for ${sessionId}: ${errMsg(error)}`)
|
|
355
|
+
throw makeHttpError(503, 'meeting session persistence unavailable', 'session_persistence_failed')
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function persistSessionBestEffort(sessionId: string): void {
|
|
360
|
+
try { persistSessionRequired(sessionId) } catch { /* recovery cleanup is non-admission work */ }
|
|
300
361
|
}
|
|
301
362
|
|
|
302
363
|
/** Recover sessions from disk on server restart */
|
|
@@ -315,9 +376,14 @@ function recoverSessions(): void {
|
|
|
315
376
|
Array.isArray(data.chunksIndexed) ? data.chunksIndexed : null
|
|
316
377
|
const legacy: TranscriptChunk[] | null = Array.isArray(data.chunks) ? data.chunks : null
|
|
317
378
|
const hasChunks = (indexed && indexed.length > 0) || (legacy && legacy.length > 0)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
379
|
+
const persistedStat = statSync(resolve(CHUNK_PERSIST_DIR, file))
|
|
380
|
+
const lastActivityAt = typeof data.lastActivityAt === 'number' && Number.isFinite(data.lastActivityAt)
|
|
381
|
+
? data.lastActivityAt
|
|
382
|
+
: (Number.isFinite(persistedStat.mtimeMs) ? persistedStat.mtimeMs : data.startTime)
|
|
383
|
+
if (data.sessionId && (hasChunks || Array.isArray(data.receivedIndices) || Number.isFinite(lastActivityAt))) {
|
|
384
|
+
// Active retention is idle-based. Long meetings are not purged merely
|
|
385
|
+
// because their original start time is old.
|
|
386
|
+
if (Date.now() - lastActivityAt < LOCAL_FIRST_MEETING_IDLE_RETENTION_MS) {
|
|
321
387
|
const chunks: TranscriptChunk[] = []
|
|
322
388
|
if (indexed) {
|
|
323
389
|
for (const e of indexed) {
|
|
@@ -353,6 +419,7 @@ function recoverSessions(): void {
|
|
|
353
419
|
chunks,
|
|
354
420
|
startTime: data.startTime,
|
|
355
421
|
title: data.title || '',
|
|
422
|
+
lastActivityAt,
|
|
356
423
|
receivedIndices,
|
|
357
424
|
maxChunkIndex,
|
|
358
425
|
providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
|
|
@@ -380,12 +447,32 @@ function recoverSessions(): void {
|
|
|
380
447
|
recoveredIds.add(data.sessionId)
|
|
381
448
|
if (cleaned > 0) {
|
|
382
449
|
console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
|
|
383
|
-
|
|
450
|
+
persistSessionBestEffort(data.sessionId) // re-persist cleaned data to disk
|
|
384
451
|
}
|
|
385
452
|
const gaps = computeGapReport(session).missingIndices.length
|
|
386
453
|
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' : ''})` : ''}`)
|
|
387
454
|
} else {
|
|
388
|
-
//
|
|
455
|
+
// A stale unsaved session is a real closed state, not a missing
|
|
456
|
+
// session that a late/zombie client may silently recreate. Keep
|
|
457
|
+
// its exact receive ledger for one tombstone horizon after boot.
|
|
458
|
+
const receivedIndices = Array.isArray(data.receivedIndices)
|
|
459
|
+
? Array.from(new Set(
|
|
460
|
+
(data.receivedIndices as unknown[])
|
|
461
|
+
.filter((value): value is number => Number.isInteger(value) && (value as number) >= 0),
|
|
462
|
+
)).sort((left, right) => left - right)
|
|
463
|
+
: []
|
|
464
|
+
const maxChunkIndex = typeof data.maxChunkIndex === 'number' && Number.isInteger(data.maxChunkIndex)
|
|
465
|
+
? data.maxChunkIndex
|
|
466
|
+
: (receivedIndices.at(-1) ?? -1)
|
|
467
|
+
closedSessionRecords.set(data.sessionId, {
|
|
468
|
+
closedAt: Date.now(),
|
|
469
|
+
lastActivityAt,
|
|
470
|
+
receivedIndices,
|
|
471
|
+
maxChunkIndex,
|
|
472
|
+
reason: 'expired',
|
|
473
|
+
})
|
|
474
|
+
rememberDeletedSession(data.sessionId)
|
|
475
|
+
persistClosedSessions()
|
|
389
476
|
unlinkSync(resolve(CHUNK_PERSIST_DIR, file))
|
|
390
477
|
}
|
|
391
478
|
}
|
|
@@ -410,6 +497,12 @@ function recoverSessions(): void {
|
|
|
410
497
|
// Declared BEFORE deleteSession to avoid TDZ hazard (deleteSession references these).
|
|
411
498
|
const deletedSessions = new Set<string>()
|
|
412
499
|
const DELETED_SESSION_CAP = 50 // keep last 50 deleted IDs, trim older on overflow
|
|
500
|
+
function rememberDeletedSession(sessionId: string): void {
|
|
501
|
+
deletedSessions.add(sessionId)
|
|
502
|
+
if (deletedSessions.size <= DELETED_SESSION_CAP) return
|
|
503
|
+
const entries = Array.from(deletedSessions)
|
|
504
|
+
for (const id of entries.slice(0, entries.length - DELETED_SESSION_CAP)) deletedSessions.delete(id)
|
|
505
|
+
}
|
|
413
506
|
export function isSessionDeleted(sessionId: string): boolean {
|
|
414
507
|
return deletedSessions.has(sessionId)
|
|
415
508
|
}
|
|
@@ -418,19 +511,12 @@ export function isSessionDeleted(sessionId: string): boolean {
|
|
|
418
511
|
recoverClosedSessions()
|
|
419
512
|
recoverSessions()
|
|
420
513
|
|
|
421
|
-
// Auto-cleanup sessions
|
|
514
|
+
// Auto-cleanup sessions idle for the advertised retention horizon.
|
|
422
515
|
setInterval(() => {
|
|
423
|
-
const cutoff = Date.now() -
|
|
516
|
+
const cutoff = Date.now() - LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
|
|
424
517
|
for (const [id, session] of sessions) {
|
|
425
|
-
if (session.
|
|
426
|
-
|
|
427
|
-
sessionAudioBytes.delete(id)
|
|
428
|
-
sessionAudioWrites.delete(id)
|
|
429
|
-
clearSessionHallucinationState(id)
|
|
430
|
-
// Clean up persisted file too
|
|
431
|
-
try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${id}.json`)) } catch {}
|
|
432
|
-
// Clean up session audio
|
|
433
|
-
try { rmSync(resolve(SESSION_AUDIO_DIR, id), { recursive: true, force: true }) } catch {}
|
|
518
|
+
if (session.lastActivityAt < cutoff) {
|
|
519
|
+
closeTranscriptSession(id, 'expired')
|
|
434
520
|
}
|
|
435
521
|
}
|
|
436
522
|
// Purge orphaned session-audio dirs (no matching active session)
|
|
@@ -493,7 +579,8 @@ setInterval(() => {
|
|
|
493
579
|
export function getSession(sessionId: string): TranscriptSession {
|
|
494
580
|
let session = sessions.get(sessionId)
|
|
495
581
|
if (!session) {
|
|
496
|
-
|
|
582
|
+
const now = Date.now()
|
|
583
|
+
session = { chunks: [], startTime: now, lastActivityAt: now, title: '', providerCandidates: {} }
|
|
497
584
|
sessions.set(sessionId, session)
|
|
498
585
|
}
|
|
499
586
|
if (!session.providerCandidates) session.providerCandidates = {}
|
|
@@ -677,19 +764,81 @@ export function getSessionProviderCandidates(sessionId: string): Record<string,
|
|
|
677
764
|
return sessions.get(sessionId)?.providerCandidates ?? {}
|
|
678
765
|
}
|
|
679
766
|
|
|
767
|
+
export interface MeetingSessionStatusSnapshot {
|
|
768
|
+
state: 'active' | 'closed' | 'missing'
|
|
769
|
+
receivedRanges: IndexRange[]
|
|
770
|
+
receivedCount: number
|
|
771
|
+
maxChunkIndex: number
|
|
772
|
+
lastActivityAt: string | null
|
|
773
|
+
retainedUntil: string | null
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export function getMeetingSessionStatus(sessionId: string): MeetingSessionStatusSnapshot {
|
|
777
|
+
const active = sessions.get(sessionId)
|
|
778
|
+
if (active) {
|
|
779
|
+
const received = active.receivedIndices ?? []
|
|
780
|
+
return {
|
|
781
|
+
state: 'active',
|
|
782
|
+
receivedRanges: compressIndexRanges(received),
|
|
783
|
+
receivedCount: received.length,
|
|
784
|
+
maxChunkIndex: active.maxChunkIndex ?? (received.at(-1) ?? -1),
|
|
785
|
+
lastActivityAt: new Date(active.lastActivityAt).toISOString(),
|
|
786
|
+
retainedUntil: retainedUntilIso(active.lastActivityAt),
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
const closed = closedSessionRecords.get(sessionId)
|
|
790
|
+
if (closed) {
|
|
791
|
+
return {
|
|
792
|
+
state: 'closed',
|
|
793
|
+
receivedRanges: compressIndexRanges(closed.receivedIndices),
|
|
794
|
+
receivedCount: closed.receivedIndices.length,
|
|
795
|
+
maxChunkIndex: closed.maxChunkIndex,
|
|
796
|
+
lastActivityAt: new Date(closed.lastActivityAt).toISOString(),
|
|
797
|
+
retainedUntil: new Date(closed.closedAt + CLOSED_SESSION_TTL_MS).toISOString(),
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
state: 'missing',
|
|
802
|
+
receivedRanges: [],
|
|
803
|
+
receivedCount: 0,
|
|
804
|
+
maxChunkIndex: -1,
|
|
805
|
+
lastActivityAt: null,
|
|
806
|
+
retainedUntil: null,
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function closeTranscriptSession(
|
|
811
|
+
sessionId: string,
|
|
812
|
+
reason: ClosedTranscriptSession['reason'],
|
|
813
|
+
options: { preserveAudio?: boolean } = {},
|
|
814
|
+
): void {
|
|
815
|
+
const session = sessions.get(sessionId)
|
|
816
|
+
const now = Date.now()
|
|
817
|
+
const receivedIndices = [...(session?.receivedIndices ?? [])]
|
|
818
|
+
const maxChunkIndex = session?.maxChunkIndex ?? (receivedIndices.at(-1) ?? -1)
|
|
819
|
+
closedSessionRecords.set(sessionId, {
|
|
820
|
+
closedAt: now,
|
|
821
|
+
lastActivityAt: session?.lastActivityAt ?? now,
|
|
822
|
+
receivedIndices,
|
|
823
|
+
maxChunkIndex,
|
|
824
|
+
reason,
|
|
825
|
+
})
|
|
826
|
+
finishClosingTranscriptSession(sessionId, options)
|
|
827
|
+
}
|
|
828
|
+
|
|
680
829
|
/** Delete session after save */
|
|
681
830
|
export function deleteSession(sessionId: string, options: { preserveAudio?: boolean } = {}): void {
|
|
831
|
+
closeTranscriptSession(sessionId, 'saved', options)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function finishClosingTranscriptSession(sessionId: string, options: { preserveAudio?: boolean }): void {
|
|
682
835
|
sessions.delete(sessionId)
|
|
683
836
|
sessionAudioBytes.delete(sessionId)
|
|
837
|
+
sessionAudioWrites.delete(sessionId)
|
|
684
838
|
// Clean up inline hallucination tracking (was leaking until 4-hour interval fired)
|
|
685
839
|
clearSessionHallucinationState(sessionId)
|
|
686
840
|
// Track as deleted so orphan heartbeats get 410 Gone (prevents zombie client spam)
|
|
687
|
-
|
|
688
|
-
if (deletedSessions.size > DELETED_SESSION_CAP) {
|
|
689
|
-
// Trim oldest entries to prevent unbounded growth
|
|
690
|
-
const arr = Array.from(deletedSessions)
|
|
691
|
-
for (const id of arr.slice(0, arr.length - DELETED_SESSION_CAP)) deletedSessions.delete(id)
|
|
692
|
-
}
|
|
841
|
+
rememberDeletedSession(sessionId)
|
|
693
842
|
persistClosedSessions()
|
|
694
843
|
// Clean up persisted file
|
|
695
844
|
try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)) } catch {}
|
|
@@ -768,14 +917,27 @@ async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number
|
|
|
768
917
|
ensurePrivateDirectory(sessionDir)
|
|
769
918
|
const chunkPath = resolve(sessionDir, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`)
|
|
770
919
|
const existingSize = existsSync(chunkPath) ? statSync(chunkPath).size : 0
|
|
771
|
-
|
|
920
|
+
let currentBytes = sessionAudioBytes.get(sessionId)
|
|
921
|
+
if (currentBytes == null) {
|
|
922
|
+
currentBytes = 0
|
|
923
|
+
try {
|
|
924
|
+
for (const filename of readdirSync(sessionDir)) {
|
|
925
|
+
if (!/^chunk_\d{4}\.wav$/.test(filename)) continue
|
|
926
|
+
currentBytes += statSync(resolve(sessionDir, filename)).size
|
|
927
|
+
}
|
|
928
|
+
} catch (error) {
|
|
929
|
+
throw makeHttpError(503, `meeting audio inventory failed: ${errMsg(error)}`, 'session_audio_persistence_failed')
|
|
930
|
+
}
|
|
931
|
+
}
|
|
772
932
|
const nextBytes = currentBytes - existingSize + audioBuffer.length
|
|
773
|
-
if (nextBytes > MAX_SESSION_AUDIO_BYTES
|
|
774
|
-
|
|
775
|
-
|
|
933
|
+
if (nextBytes > MAX_SESSION_AUDIO_BYTES) {
|
|
934
|
+
throw makeHttpError(507, 'meeting audio capacity exceeded', 'meeting_audio_capacity_exceeded')
|
|
935
|
+
}
|
|
936
|
+
try {
|
|
937
|
+
durableAtomicWriteFileSync(chunkPath, audioBuffer, { mode: 0o600 })
|
|
938
|
+
} catch (error) {
|
|
939
|
+
throw makeHttpError(503, `meeting audio persistence failed: ${errMsg(error)}`, 'session_audio_persistence_failed')
|
|
776
940
|
}
|
|
777
|
-
const writeJob = writeFile(chunkPath, audioBuffer, { mode: 0o600 })
|
|
778
|
-
await trackSessionAudioWrite(sessionId, writeJob)
|
|
779
941
|
sessionAudioBytes.set(sessionId, Math.max(0, nextBytes))
|
|
780
942
|
}
|
|
781
943
|
|
|
@@ -885,18 +1047,26 @@ function canonicalChunkResponse(
|
|
|
885
1047
|
}
|
|
886
1048
|
|
|
887
1049
|
async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Buffer, whisperContext: string, isQuiet: boolean): Promise<{ text: string; words?: WhisperWord[]; backend: string }> {
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
+
)
|
|
896
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' }
|
|
897
1069
|
}
|
|
898
|
-
const text = await transcribeViaCloud(whisperAudio)
|
|
899
|
-
return { text, words: undefined, backend: 'cloud' }
|
|
900
1070
|
}
|
|
901
1071
|
|
|
902
1072
|
function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex: number, clientSpeaker: string): { speaker: string; similarity: number } {
|
|
@@ -995,9 +1165,6 @@ async function processStreamChunk(opts: {
|
|
|
995
1165
|
if (opts.startTimeOverride && session.chunks.filter(Boolean).length === 0) {
|
|
996
1166
|
session.startTime = opts.startTimeOverride
|
|
997
1167
|
}
|
|
998
|
-
// Transfer integrity: log this index as delivered before any text filtering,
|
|
999
|
-
// so a silent/hallucination-filtered chunk is NOT mistaken for a lost one.
|
|
1000
|
-
recordReceivedChunk(session, chunkIndex)
|
|
1001
1168
|
const alreadyCanonical = session.chunks[chunkIndex]
|
|
1002
1169
|
|
|
1003
1170
|
let candidateRecordKey: string | undefined
|
|
@@ -1020,21 +1187,24 @@ async function processStreamChunk(opts: {
|
|
|
1020
1187
|
// Do not let late duplicate/replayed candidates replace canonical raw audio.
|
|
1021
1188
|
// Batch re-transcription relies on chunk_000N.wav matching the accepted chunk.
|
|
1022
1189
|
if (alreadyCanonical?.canonical) {
|
|
1190
|
+
session.lastActivityAt = Date.now()
|
|
1191
|
+
recordReceivedChunk(session, chunkIndex)
|
|
1023
1192
|
if (candidate && candidateRecordKey) {
|
|
1024
1193
|
session.providerCandidates![candidateRecordKey].accepted =
|
|
1025
1194
|
alreadyCanonical.asrProvider === 'iphone-whisperkit-beta' && alreadyCanonical.audioSha256 === audioSha256
|
|
1026
1195
|
session.providerCandidates![candidateRecordKey].fallbackReason =
|
|
1027
1196
|
session.providerCandidates![candidateRecordKey].accepted ? undefined : 'canonical_exists'
|
|
1028
|
-
persistSession(sessionId)
|
|
1029
1197
|
}
|
|
1198
|
+
persistSessionRequired(sessionId)
|
|
1030
1199
|
return canonicalChunkResponse(alreadyCanonical, sessionId, chunkIndex)
|
|
1031
1200
|
}
|
|
1032
1201
|
|
|
1033
1202
|
await persistRawSessionAudioChunk(sessionId, chunkIndex, audioBuffer)
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1203
|
+
// Commit the received-index ledger only after the canonical raw WAV is
|
|
1204
|
+
// durable. A failure is typed non-2xx and a retry remains safe.
|
|
1205
|
+
session.lastActivityAt = Date.now()
|
|
1206
|
+
recordReceivedChunk(session, chunkIndex)
|
|
1207
|
+
persistSessionRequired(sessionId)
|
|
1038
1208
|
|
|
1039
1209
|
const pcmData = audioBuffer.subarray(44)
|
|
1040
1210
|
let sumSq = 0
|
|
@@ -1105,8 +1275,8 @@ async function processStreamChunk(opts: {
|
|
|
1105
1275
|
if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
|
|
1106
1276
|
session.providerCandidates[candidateRecordKey].accepted = false
|
|
1107
1277
|
session.providerCandidates[candidateRecordKey].fallbackReason = sanitized.fallbackReason || fallbackReason || 'empty'
|
|
1108
|
-
persistSession(sessionId)
|
|
1109
1278
|
}
|
|
1279
|
+
persistSessionRequired(sessionId)
|
|
1110
1280
|
return { text: '', speaker: clientSpeaker, chunkIndex, elapsed, sessionId, backend, asrProvider, fallbackReason: sanitized.fallbackReason || fallbackReason }
|
|
1111
1281
|
}
|
|
1112
1282
|
|
|
@@ -1132,8 +1302,9 @@ async function processStreamChunk(opts: {
|
|
|
1132
1302
|
finalExisting.asrProvider === 'iphone-whisperkit-beta' && finalExisting.audioSha256 === audioSha256
|
|
1133
1303
|
session.providerCandidates[candidateRecordKey].fallbackReason =
|
|
1134
1304
|
session.providerCandidates[candidateRecordKey].accepted ? undefined : 'canonical_exists'
|
|
1135
|
-
persistSession(sessionId)
|
|
1136
1305
|
}
|
|
1306
|
+
session.lastActivityAt = Date.now()
|
|
1307
|
+
persistSessionRequired(sessionId)
|
|
1137
1308
|
return canonicalChunkResponse(finalExisting, sessionId, chunkIndex)
|
|
1138
1309
|
}
|
|
1139
1310
|
session.chunks[chunkIndex] = chunk
|
|
@@ -1144,7 +1315,8 @@ async function processStreamChunk(opts: {
|
|
|
1144
1315
|
}
|
|
1145
1316
|
}
|
|
1146
1317
|
const tPersist = performance.now()
|
|
1147
|
-
|
|
1318
|
+
session.lastActivityAt = Date.now()
|
|
1319
|
+
persistSessionRequired(sessionId)
|
|
1148
1320
|
console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
|
|
1149
1321
|
|
|
1150
1322
|
emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
|
|
@@ -1154,6 +1326,14 @@ async function processStreamChunk(opts: {
|
|
|
1154
1326
|
}
|
|
1155
1327
|
|
|
1156
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
|
+
}
|
|
1157
1337
|
if (err instanceof OpenAIWhisperBudgetExhaustedError) {
|
|
1158
1338
|
console.error(`[transcribe-stream] ${err.message}`)
|
|
1159
1339
|
return res.status(503).json({
|
|
@@ -1207,7 +1387,8 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (
|
|
|
1207
1387
|
: undefined
|
|
1208
1388
|
if (startTime && session.chunks.filter(Boolean).length === 0) session.startTime = startTime
|
|
1209
1389
|
if (typeof body.title === 'string') session.title = body.title.slice(0, 160)
|
|
1210
|
-
|
|
1390
|
+
session.lastActivityAt = Date.now()
|
|
1391
|
+
persistSessionRequired(sessionId)
|
|
1211
1392
|
res.json({ sessionId, startTime: session.startTime, chunks: session.chunks.filter(Boolean).length })
|
|
1212
1393
|
} catch (err: unknown) {
|
|
1213
1394
|
sendStreamError(res, err)
|
|
@@ -1325,9 +1506,18 @@ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) =>
|
|
|
1325
1506
|
* A hung whisper-server + long meeting is the exact scenario this guards against —
|
|
1326
1507
|
* chunks stay empty on budget-exceeded instead of silently billing per chunk. */
|
|
1327
1508
|
async function transcribeViaCloud(audioBuffer: Buffer): Promise<string> {
|
|
1328
|
-
|
|
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
|
+
}
|
|
1329
1518
|
|
|
1330
1519
|
const key = getOpenAIKey()
|
|
1520
|
+
assertOpenAIWhisperBudget()
|
|
1331
1521
|
const audioSeconds = estimateAudioSeconds(audioBuffer)
|
|
1332
1522
|
|
|
1333
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
|
})
|