@gotcos/glasses-server 6.6.0 → 6.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +3 -0
- package/package.json +1 -1
- package/server/index.ts +6 -0
- package/server/lib/atomic-fs.ts +79 -3
- package/server/lib/batch-transcript-quality.ts +243 -0
- package/server/lib/dictation-clean.ts +76 -0
- package/server/lib/meeting-batch-persistence.ts +53 -0
- package/server/lib/meeting-batch-transcribe.ts +249 -0
- package/server/lib/meeting-store.ts +594 -0
- package/server/lib/prompt-draft-store.ts +279 -0
- package/server/lib/transcribe-audio.ts +53 -5
- package/server/lib/whisper-local.ts +49 -1
- package/server/routes/health.ts +2 -0
- package/server/routes/meeting.ts +329 -0
- package/server/routes/meetings.ts +66 -0
- package/server/routes/prompt-drafts.ts +276 -0
- package/server/routes/transcribe-stream.ts +116 -23
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
statSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
11
|
+
import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
12
|
+
import { dataPath } from './data-dir.js'
|
|
13
|
+
|
|
14
|
+
export type PromptDraftStatus = 'recording' | 'finalized' | 'error' | 'cancelled' | 'expired'
|
|
15
|
+
|
|
16
|
+
export interface PromptDraftTranscriptRecord {
|
|
17
|
+
text: string
|
|
18
|
+
hash: string
|
|
19
|
+
requestedMode: 'hq' | 'fast'
|
|
20
|
+
actualQuality: 'hq' | 'fast' | 'cloud'
|
|
21
|
+
backend: string
|
|
22
|
+
degraded: boolean
|
|
23
|
+
acceptedDegraded?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PromptDraftMeta {
|
|
27
|
+
v: 2
|
|
28
|
+
draftId: string
|
|
29
|
+
createdAt: string
|
|
30
|
+
updatedAt: string
|
|
31
|
+
expiresAt: string
|
|
32
|
+
status: PromptDraftStatus
|
|
33
|
+
receivedChunkIndexes: number[]
|
|
34
|
+
chunkBytes: Record<string, number>
|
|
35
|
+
chunkHashes: Record<string, string>
|
|
36
|
+
warmTranscripts: Record<string, PromptDraftTranscriptRecord>
|
|
37
|
+
finalTranscripts: Record<string, PromptDraftTranscriptRecord>
|
|
38
|
+
/** Compatibility mirror for pre-v2 clients and draft fixtures. */
|
|
39
|
+
chunkTranscripts?: Record<string, string>
|
|
40
|
+
finalizedText?: string
|
|
41
|
+
lastError?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Public installs run from an ephemeral npx cache. Persist draft audio under
|
|
45
|
+
// ~/.cos-glasses/data so package upgrades cannot erase a recoverable recording.
|
|
46
|
+
const DATA_DIR = process.env.COS_PROMPT_DRAFT_DIR
|
|
47
|
+
? path.resolve(process.env.COS_PROMPT_DRAFT_DIR)
|
|
48
|
+
: dataPath('prompt-drafts')
|
|
49
|
+
const META_NAME = 'meta.json'
|
|
50
|
+
const TTL_MS = 72 * 60 * 60 * 1000
|
|
51
|
+
const locks = new Map<string, Promise<unknown>>()
|
|
52
|
+
|
|
53
|
+
function ensureDir(dir: string): void {
|
|
54
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function nowIso(): string {
|
|
58
|
+
return new Date().toISOString()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function expiresFromNowIso(): string {
|
|
62
|
+
return new Date(Date.now() + TTL_MS).toISOString()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeDraftId(draftId: string): string {
|
|
66
|
+
const clean = String(draftId || '').replace(/[^a-zA-Z0-9_-]/g, '')
|
|
67
|
+
if (!clean) throw new Error('invalid draft id')
|
|
68
|
+
return clean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function draftDir(draftId: string): string {
|
|
72
|
+
return path.join(DATA_DIR, normalizeDraftId(draftId))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function metaPath(draftId: string): string {
|
|
76
|
+
return path.join(draftDir(draftId), META_NAME)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function chunkPath(draftId: string, chunkIndex: number): string {
|
|
80
|
+
if (!Number.isInteger(chunkIndex) || chunkIndex < 0) throw new Error('invalid chunk index')
|
|
81
|
+
return path.join(draftDir(draftId), `chunk-${String(chunkIndex).padStart(5, '0')}.wav`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeMeta(meta: PromptDraftMeta): PromptDraftMeta {
|
|
85
|
+
ensureDir(draftDir(meta.draftId))
|
|
86
|
+
atomicWriteFileSync(metaPath(meta.draftId), `${JSON.stringify(meta, null, 2)}\n`, { mode: 0o600 })
|
|
87
|
+
return meta
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createPromptDraft(requestedId?: string): PromptDraftMeta {
|
|
91
|
+
ensureDir(DATA_DIR)
|
|
92
|
+
const candidate = requestedId ? normalizeDraftId(requestedId) : ''
|
|
93
|
+
const draftId = candidate && !existsSync(metaPath(candidate)) ? candidate : randomBytes(8).toString('hex')
|
|
94
|
+
const now = nowIso()
|
|
95
|
+
return writeMeta({
|
|
96
|
+
v: 2,
|
|
97
|
+
draftId,
|
|
98
|
+
createdAt: now,
|
|
99
|
+
updatedAt: now,
|
|
100
|
+
expiresAt: expiresFromNowIso(),
|
|
101
|
+
status: 'recording',
|
|
102
|
+
receivedChunkIndexes: [],
|
|
103
|
+
chunkBytes: {},
|
|
104
|
+
chunkHashes: {},
|
|
105
|
+
warmTranscripts: {},
|
|
106
|
+
finalTranscripts: {},
|
|
107
|
+
chunkTranscripts: {},
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function loadPromptDraftMeta(draftId: string): PromptDraftMeta | null {
|
|
112
|
+
const loaded = loadJsonOrQuarantine<PromptDraftMeta & { v?: number }>(metaPath(draftId))
|
|
113
|
+
if (loaded.status === 'missing') return null
|
|
114
|
+
if (loaded.status === 'corrupt') {
|
|
115
|
+
console.warn(`[prompt-draft] corrupt metadata quarantined: ${loaded.quarantinedAs}`)
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
const data = loaded.data
|
|
119
|
+
if (data.v !== 2) {
|
|
120
|
+
const legacy = data.chunkTranscripts ?? {}
|
|
121
|
+
data.v = 2
|
|
122
|
+
data.chunkHashes = data.chunkHashes ?? {}
|
|
123
|
+
data.warmTranscripts = data.warmTranscripts ?? {}
|
|
124
|
+
data.finalTranscripts = data.finalTranscripts ?? {}
|
|
125
|
+
for (const [index, text] of Object.entries(legacy)) {
|
|
126
|
+
data.warmTranscripts[index] ??= {
|
|
127
|
+
text,
|
|
128
|
+
hash: data.chunkHashes[index] ?? '',
|
|
129
|
+
requestedMode: 'hq',
|
|
130
|
+
actualQuality: 'fast',
|
|
131
|
+
backend: 'legacy-unknown',
|
|
132
|
+
degraded: true,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
writeMeta(data)
|
|
136
|
+
}
|
|
137
|
+
data.chunkHashes ??= {}
|
|
138
|
+
data.warmTranscripts ??= {}
|
|
139
|
+
data.finalTranscripts ??= {}
|
|
140
|
+
data.chunkTranscripts ??= {}
|
|
141
|
+
return data
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function touchMeta(meta: PromptDraftMeta): PromptDraftMeta {
|
|
145
|
+
meta.updatedAt = nowIso()
|
|
146
|
+
meta.expiresAt = expiresFromNowIso()
|
|
147
|
+
return meta
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function withDraftLock<T>(draftId: string, fn: () => Promise<T> | T): Promise<T> {
|
|
151
|
+
const key = normalizeDraftId(draftId)
|
|
152
|
+
const previous = locks.get(key) ?? Promise.resolve()
|
|
153
|
+
let release!: () => void
|
|
154
|
+
const current = new Promise<void>((resolve) => { release = resolve })
|
|
155
|
+
const chained = previous.then(() => current)
|
|
156
|
+
locks.set(key, chained)
|
|
157
|
+
await previous.catch(() => {})
|
|
158
|
+
try {
|
|
159
|
+
return await fn()
|
|
160
|
+
} finally {
|
|
161
|
+
release()
|
|
162
|
+
if (locks.get(key) === chained) locks.delete(key)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function savePromptDraftChunk(draftId: string, chunkIndex: number, audioBuffer: Buffer): Promise<PromptDraftMeta> {
|
|
167
|
+
return withDraftLock(draftId, () => {
|
|
168
|
+
const meta = loadPromptDraftMeta(draftId)
|
|
169
|
+
if (!meta) throw new Error('draft not found')
|
|
170
|
+
ensureDir(draftDir(draftId))
|
|
171
|
+
const hash = createHash('sha256').update(audioBuffer).digest('hex')
|
|
172
|
+
const key = String(chunkIndex)
|
|
173
|
+
if (meta.chunkHashes[key] === hash && existsSync(chunkPath(draftId, chunkIndex))) {
|
|
174
|
+
return writeMeta(touchMeta(meta))
|
|
175
|
+
}
|
|
176
|
+
atomicWriteFileSync(chunkPath(draftId, chunkIndex), audioBuffer, { mode: 0o600 })
|
|
177
|
+
if (!meta.receivedChunkIndexes.includes(chunkIndex)) {
|
|
178
|
+
meta.receivedChunkIndexes.push(chunkIndex)
|
|
179
|
+
meta.receivedChunkIndexes.sort((a, b) => a - b)
|
|
180
|
+
}
|
|
181
|
+
meta.chunkBytes[key] = audioBuffer.length
|
|
182
|
+
meta.chunkHashes[key] = hash
|
|
183
|
+
if (!meta.chunkTranscripts) meta.chunkTranscripts = {}
|
|
184
|
+
delete meta.chunkTranscripts[key]
|
|
185
|
+
delete meta.warmTranscripts[key]
|
|
186
|
+
delete meta.finalTranscripts[key]
|
|
187
|
+
if (meta.status === 'error') meta.status = 'recording'
|
|
188
|
+
return writeMeta(touchMeta(meta))
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function readPromptDraftChunks(draftId: string): Array<{ chunkIndex: number; audioBuffer: Buffer }> {
|
|
193
|
+
const meta = loadPromptDraftMeta(draftId)
|
|
194
|
+
if (!meta) throw new Error('draft not found')
|
|
195
|
+
return meta.receivedChunkIndexes
|
|
196
|
+
.slice()
|
|
197
|
+
.sort((a, b) => a - b)
|
|
198
|
+
.map((chunkIndex) => ({ chunkIndex, audioBuffer: readFileSync(chunkPath(draftId, chunkIndex)) }))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function markPromptDraftFinalized(draftId: string, text: string): Promise<PromptDraftMeta> {
|
|
202
|
+
return withDraftLock(draftId, () => {
|
|
203
|
+
const meta = loadPromptDraftMeta(draftId)
|
|
204
|
+
if (!meta) throw new Error('draft not found')
|
|
205
|
+
meta.status = 'finalized'
|
|
206
|
+
meta.finalizedText = text
|
|
207
|
+
meta.lastError = undefined
|
|
208
|
+
return writeMeta(touchMeta(meta))
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function markPromptDraftChunkTranscript(
|
|
213
|
+
draftId: string,
|
|
214
|
+
chunkIndex: number,
|
|
215
|
+
record: PromptDraftTranscriptRecord | string,
|
|
216
|
+
purpose: 'warm' | 'final' = 'warm',
|
|
217
|
+
): Promise<PromptDraftMeta> {
|
|
218
|
+
return withDraftLock(draftId, () => {
|
|
219
|
+
const meta = loadPromptDraftMeta(draftId)
|
|
220
|
+
if (!meta) throw new Error('draft not found')
|
|
221
|
+
const key = String(chunkIndex)
|
|
222
|
+
const normalized: PromptDraftTranscriptRecord = typeof record === 'string'
|
|
223
|
+
? { text: record, hash: meta.chunkHashes[key] ?? '', requestedMode: 'hq', actualQuality: 'fast', backend: 'legacy', degraded: true }
|
|
224
|
+
: record
|
|
225
|
+
if (meta.chunkHashes[key] && normalized.hash && meta.chunkHashes[key] !== normalized.hash) return meta
|
|
226
|
+
if (purpose === 'final') meta.finalTranscripts[key] = normalized
|
|
227
|
+
else meta.warmTranscripts[key] = normalized
|
|
228
|
+
if (!meta.chunkTranscripts) meta.chunkTranscripts = {}
|
|
229
|
+
meta.chunkTranscripts[key] = normalized.text
|
|
230
|
+
return writeMeta(touchMeta(meta))
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function markPromptDraftError(draftId: string, error: string): Promise<PromptDraftMeta | null> {
|
|
235
|
+
return withDraftLock(draftId, () => {
|
|
236
|
+
const meta = loadPromptDraftMeta(draftId)
|
|
237
|
+
if (!meta) return null
|
|
238
|
+
meta.status = 'error'
|
|
239
|
+
meta.lastError = error
|
|
240
|
+
return writeMeta(touchMeta(meta))
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function getMissingChunkIndexes(meta: PromptDraftMeta): number[] {
|
|
245
|
+
if (meta.receivedChunkIndexes.length === 0) return []
|
|
246
|
+
const max = Math.max(...meta.receivedChunkIndexes)
|
|
247
|
+
const received = new Set(meta.receivedChunkIndexes)
|
|
248
|
+
const missing: number[] = []
|
|
249
|
+
for (let i = 0; i <= max; i++) {
|
|
250
|
+
if (!received.has(i)) missing.push(i)
|
|
251
|
+
}
|
|
252
|
+
return missing
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function prunePromptDrafts(): number {
|
|
256
|
+
ensureDir(DATA_DIR)
|
|
257
|
+
let pruned = 0
|
|
258
|
+
for (const entry of readdirSync(DATA_DIR, { withFileTypes: true })) {
|
|
259
|
+
if (!entry.isDirectory()) continue
|
|
260
|
+
const dir = path.join(DATA_DIR, entry.name)
|
|
261
|
+
const meta = loadPromptDraftMeta(entry.name)
|
|
262
|
+
const expiredByMeta = meta ? new Date(meta.expiresAt).getTime() <= Date.now() : false
|
|
263
|
+
let expiredByMtime = false
|
|
264
|
+
try {
|
|
265
|
+
expiredByMtime = Date.now() - statSync(dir).mtimeMs > TTL_MS
|
|
266
|
+
} catch {
|
|
267
|
+
expiredByMtime = true
|
|
268
|
+
}
|
|
269
|
+
if (expiredByMeta || expiredByMtime) {
|
|
270
|
+
try {
|
|
271
|
+
rmSync(dir, { recursive: true, force: true })
|
|
272
|
+
pruned++
|
|
273
|
+
} catch (err: any) {
|
|
274
|
+
console.warn(`[prompt-draft] prune failed for ${entry.name}: ${err.message}`)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return pruned
|
|
279
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
-
isWhisperLocalAvailable,
|
|
3
2
|
transcribeLocal,
|
|
4
3
|
transcribeHighQuality,
|
|
5
4
|
getWhisperBackend,
|
|
5
|
+
applyCorrections,
|
|
6
6
|
} from './whisper-local.js'
|
|
7
7
|
import { getVocabulary, getOwnerName } from './profile.js'
|
|
8
8
|
import { applyFuzzyCorrections } from './fuzzy-correct.js'
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
isVocabEchoOnly,
|
|
21
21
|
countVocabTerms,
|
|
22
22
|
} from './hallucination-filter.js'
|
|
23
|
-
import { getOpenAIKey } from './openai-key.js'
|
|
23
|
+
import { getOpenAIKey, tryGetOpenAIKey } from './openai-key.js'
|
|
24
24
|
|
|
25
25
|
export { OpenAIWhisperBudgetExhaustedError, estimateAudioSeconds }
|
|
26
26
|
|
|
@@ -30,10 +30,23 @@ export interface TranscribeAudioResult {
|
|
|
30
30
|
text: string
|
|
31
31
|
backend: string
|
|
32
32
|
mode: TranscribeMode
|
|
33
|
+
requestedMode: TranscribeMode
|
|
34
|
+
actualQuality: 'hq' | 'fast' | 'cloud'
|
|
35
|
+
degraded: boolean
|
|
33
36
|
elapsedMs: number
|
|
34
37
|
audioBytes: number
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
export type TranscriptionBackendPolicy = 'automatic' | 'local-only'
|
|
41
|
+
|
|
42
|
+
export class TranscriptionUnavailableError extends Error {
|
|
43
|
+
readonly status = 503
|
|
44
|
+
constructor(readonly reason: 'local_asr_unavailable' | 'local_asr_restarting' | 'openai_key_missing', message?: string) {
|
|
45
|
+
super(message ?? reason)
|
|
46
|
+
this.name = 'TranscriptionUnavailableError'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
export class NoSpeechDetectedError extends Error {
|
|
38
51
|
readonly reason = 'no_speech'
|
|
39
52
|
|
|
@@ -55,6 +68,10 @@ const HQ_MAX_SECONDS = 60
|
|
|
55
68
|
async function transcribeCloud(audioBuffer: Buffer): Promise<string> {
|
|
56
69
|
assertOpenAIWhisperBudget()
|
|
57
70
|
|
|
71
|
+
if (!tryGetOpenAIKey()) {
|
|
72
|
+
throw new TranscriptionUnavailableError('openai_key_missing', 'OpenAI key missing; local audio is preserved for retry')
|
|
73
|
+
}
|
|
74
|
+
|
|
58
75
|
const key = getOpenAIKey()
|
|
59
76
|
const audioSeconds = estimateAudioSeconds(audioBuffer)
|
|
60
77
|
|
|
@@ -106,8 +123,12 @@ export function resolveTranscribeMode(raw: unknown): TranscribeMode {
|
|
|
106
123
|
return String(raw ?? '').toLowerCase() === 'fast' ? 'fast' : 'hq'
|
|
107
124
|
}
|
|
108
125
|
|
|
109
|
-
export async function transcribeAudioBuffer(
|
|
126
|
+
export async function transcribeAudioBuffer(
|
|
127
|
+
audioBuffer: Buffer,
|
|
128
|
+
opts: { mode?: TranscribeMode; policy?: TranscriptionBackendPolicy } = {},
|
|
129
|
+
): Promise<TranscribeAudioResult> {
|
|
110
130
|
const requestedMode = opts.mode ?? 'hq'
|
|
131
|
+
const policy = opts.policy ?? 'automatic'
|
|
111
132
|
const audioSeconds = estimateAudioSeconds(audioBuffer)
|
|
112
133
|
const effectiveMode: TranscribeMode =
|
|
113
134
|
requestedMode === 'hq' && audioSeconds > HQ_MAX_SECONDS ? 'fast' : requestedMode
|
|
@@ -118,39 +139,63 @@ export async function transcribeAudioBuffer(audioBuffer: Buffer, opts: { mode?:
|
|
|
118
139
|
|
|
119
140
|
let text: string
|
|
120
141
|
let backend: string
|
|
142
|
+
let actualQuality: 'hq' | 'fast' | 'cloud'
|
|
121
143
|
const tStart = performance.now()
|
|
122
144
|
|
|
123
|
-
if (effectiveMode === 'hq'
|
|
145
|
+
if (effectiveMode === 'hq') {
|
|
124
146
|
try {
|
|
125
147
|
const enhanced = await enhanceAudio(audioBuffer)
|
|
126
148
|
const result = await transcribeHighQuality(enhanced)
|
|
127
149
|
text = result.text
|
|
128
150
|
backend = 'hq-large-v3'
|
|
151
|
+
actualQuality = 'hq'
|
|
129
152
|
} catch (hqErr: any) {
|
|
130
153
|
console.warn(`[transcribe] HQ path failed, falling back to fast: ${hqErr.message}`)
|
|
131
154
|
try {
|
|
132
155
|
const result = await transcribeLocal(audioBuffer)
|
|
133
156
|
text = result.text
|
|
134
157
|
backend = `fast-local-${result.backend}`
|
|
158
|
+
actualQuality = 'fast'
|
|
135
159
|
} 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})`)
|
|
162
|
+
}
|
|
136
163
|
console.warn(`[transcribe] Fast local also failed, falling back to cloud: ${localErr.message}`)
|
|
137
164
|
text = await transcribeCloud(audioBuffer)
|
|
138
165
|
backend = 'cloud'
|
|
166
|
+
actualQuality = 'cloud'
|
|
139
167
|
}
|
|
140
168
|
}
|
|
141
|
-
} else if (effectiveMode === 'fast'
|
|
169
|
+
} else if (effectiveMode === 'fast') {
|
|
142
170
|
try {
|
|
143
171
|
const result = await transcribeLocal(audioBuffer)
|
|
144
172
|
text = result.text
|
|
145
173
|
backend = `fast-local-${result.backend}`
|
|
174
|
+
actualQuality = 'fast'
|
|
146
175
|
} 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})`)
|
|
178
|
+
}
|
|
147
179
|
console.warn(`[transcribe] Local whisper failed (${getWhisperBackend()}), falling back to cloud: ${localErr.message}`)
|
|
148
180
|
text = await transcribeCloud(audioBuffer)
|
|
149
181
|
backend = 'cloud'
|
|
182
|
+
actualQuality = 'cloud'
|
|
150
183
|
}
|
|
151
184
|
} else {
|
|
185
|
+
if (policy === 'local-only') {
|
|
186
|
+
throw new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription unavailable; audio is preserved for retry')
|
|
187
|
+
}
|
|
152
188
|
text = await transcribeCloud(audioBuffer)
|
|
153
189
|
backend = 'cloud'
|
|
190
|
+
actualQuality = 'cloud'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (text && text.length > 0) {
|
|
194
|
+
try {
|
|
195
|
+
text = applyCorrections(text)
|
|
196
|
+
} catch (corrErr: any) {
|
|
197
|
+
console.warn(`[transcribe] applyCorrections failed (non-fatal): ${corrErr.message}`)
|
|
198
|
+
}
|
|
154
199
|
}
|
|
155
200
|
|
|
156
201
|
if (text && text.length > 0) {
|
|
@@ -187,6 +232,9 @@ export async function transcribeAudioBuffer(audioBuffer: Buffer, opts: { mode?:
|
|
|
187
232
|
text: text.trim(),
|
|
188
233
|
backend,
|
|
189
234
|
mode: effectiveMode,
|
|
235
|
+
requestedMode,
|
|
236
|
+
actualQuality,
|
|
237
|
+
degraded: requestedMode === 'hq' && actualQuality !== 'hq',
|
|
190
238
|
elapsedMs,
|
|
191
239
|
audioBytes: audioBuffer.length,
|
|
192
240
|
}
|
|
@@ -147,6 +147,8 @@ let serverProcess: ReturnType<typeof spawn> | null = null
|
|
|
147
147
|
let serverConsecutiveFailures = 0
|
|
148
148
|
const SERVER_FAILURE_THRESHOLD = 3 // After 3 consecutive failures, auto-restart
|
|
149
149
|
let serverRestarting = false // Prevents concurrent restart attempts
|
|
150
|
+
let serverStarting = false // Initial model load is not a circuit failure
|
|
151
|
+
let serverHealthProbe: Promise<boolean> | null = null
|
|
150
152
|
|
|
151
153
|
// Check CLI availability at import time
|
|
152
154
|
try {
|
|
@@ -163,6 +165,16 @@ try {
|
|
|
163
165
|
* Called from index.ts at server boot. Non-blocking.
|
|
164
166
|
*/
|
|
165
167
|
export async function startWhisperServer(): Promise<void> {
|
|
168
|
+
if (serverStarting) return
|
|
169
|
+
serverStarting = true
|
|
170
|
+
try {
|
|
171
|
+
await startWhisperServerAttempt()
|
|
172
|
+
} finally {
|
|
173
|
+
serverStarting = false
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function startWhisperServerAttempt(): Promise<void> {
|
|
166
178
|
if (!existsSync(WHISPER_SERVER) || !existsSync(MODEL_PATH)) {
|
|
167
179
|
console.log('[whisper-local] whisper-server or model not found — using CLI fallback')
|
|
168
180
|
return
|
|
@@ -310,11 +322,39 @@ export function getWhisperHealth(): {
|
|
|
310
322
|
server: serverAvailable,
|
|
311
323
|
cli: cliAvailable,
|
|
312
324
|
consecutiveFailures: serverConsecutiveFailures,
|
|
313
|
-
restarting: serverRestarting,
|
|
325
|
+
restarting: serverRestarting || serverStarting,
|
|
314
326
|
circuitOpen: serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD,
|
|
315
327
|
}
|
|
316
328
|
}
|
|
317
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Reconcile a cached unavailable flag with the daemon's live health endpoint.
|
|
332
|
+
* Only successful inference resets the failure count: /health can be responsive
|
|
333
|
+
* while the model worker is still hung, and that case must retain the existing
|
|
334
|
+
* three-strike controlled restart.
|
|
335
|
+
*/
|
|
336
|
+
async function reconcileWhisperServerHealth(): Promise<boolean> {
|
|
337
|
+
if (serverAvailable) return true
|
|
338
|
+
if (serverRestarting || serverStarting) return false
|
|
339
|
+
if (serverHealthProbe) return serverHealthProbe
|
|
340
|
+
|
|
341
|
+
serverHealthProbe = (async () => {
|
|
342
|
+
try {
|
|
343
|
+
const res = await fetch(`${WHISPER_SERVER_URL}/health`, { signal: AbortSignal.timeout(1_000) })
|
|
344
|
+
if (!res.ok) return false
|
|
345
|
+
serverAvailable = true
|
|
346
|
+
console.log(`[whisper-local] Health endpoint recovered; retrying inference after ${serverConsecutiveFailures} failure(s)`)
|
|
347
|
+
return true
|
|
348
|
+
} catch {
|
|
349
|
+
return false
|
|
350
|
+
}
|
|
351
|
+
})().finally(() => {
|
|
352
|
+
serverHealthProbe = null
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
return serverHealthProbe
|
|
356
|
+
}
|
|
357
|
+
|
|
318
358
|
/**
|
|
319
359
|
* High-quality transcription for batch/post-meeting use.
|
|
320
360
|
* Uses the full Whisper large-v3 (32-layer) decoder + Silero VAD + beam search.
|
|
@@ -591,6 +631,14 @@ export function resetDecoderCaches(): void {
|
|
|
591
631
|
export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQuiet?: boolean): Promise<{ text: string; backend: 'server' | 'cli'; words?: WhisperWord[] }> {
|
|
592
632
|
const start = Date.now()
|
|
593
633
|
|
|
634
|
+
if (!serverAvailable) {
|
|
635
|
+
await reconcileWhisperServerHealth()
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (!serverAvailable && (serverStarting || serverRestarting)) {
|
|
639
|
+
throw new Error('whisper-server starting — use preserved/cloud fallback')
|
|
640
|
+
}
|
|
641
|
+
|
|
594
642
|
// Try whisper-server first (fastest: ~50-100ms, includes DTW word timestamps)
|
|
595
643
|
if (serverAvailable) {
|
|
596
644
|
try {
|
package/server/routes/health.ts
CHANGED
|
@@ -112,6 +112,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
112
112
|
voice: keyStatus.hasKey,
|
|
113
113
|
cos_pipeline: COS_MODE,
|
|
114
114
|
whisper: isWhisperLocalAvailable(),
|
|
115
|
+
promptRecovery: true,
|
|
116
|
+
meetingFinalization: true,
|
|
115
117
|
iphoneAsrCandidates: process.env.COS_IOS_ASR_CANDIDATES === '1',
|
|
116
118
|
mediaProcessingReady: await isMediaProcessingReady(),
|
|
117
119
|
g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
|