@gotcos/glasses-server 6.15.5 → 6.16.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 +5 -1
- package/CHANGELOG.md +18 -0
- package/README.md +30 -0
- package/package.json +1 -1
- package/server/lib/prompt-draft-store.ts +1 -0
- package/server/lib/transcribe-audio.ts +11 -2
- package/server/lib/whisper-local.ts +78 -3
- package/server/routes/health.ts +9 -3
- package/server/routes/prompt-drafts.ts +44 -3
- package/server/routes/transcribe.ts +9 -1
package/.env.example
CHANGED
|
@@ -70,11 +70,15 @@ BIND_HOST=0.0.0.0
|
|
|
70
70
|
|
|
71
71
|
# ── VOICE (optional) ────────────────────────────────────────────────────
|
|
72
72
|
# Local transcription is FREE via whisper.cpp (brew install whisper-cpp; the
|
|
73
|
-
# model auto-downloads on first run).
|
|
73
|
+
# real-time turbo model auto-downloads on first run). Full HQ dictation also
|
|
74
|
+
# needs ggml-large-v3.bin as documented in README. Voice is local-only by default. Merely
|
|
74
75
|
# configuring a key never uploads audio. To allow OpenAI Whisper only after a
|
|
75
76
|
# local failure, set BOTH the exact opt-in and a key:
|
|
76
77
|
# COS_OPENAI_WHISPER_FALLBACK=1
|
|
77
78
|
# OPENAI_API_KEY=sk-...
|
|
79
|
+
# COS_HQ_SPECULATIVE_WARM=0 # disable background HQ warm
|
|
80
|
+
# COS_BATCH_LARGE_V3=0 # explicitly use turbo instead of full HQ
|
|
81
|
+
# COS_HQ_BEAM_INTERACTIVE=2 # interactive only; meetings stay at beam 5
|
|
78
82
|
|
|
79
83
|
# Spoken reply playback defaults to local Kokoro on Apple silicon Macs. The
|
|
80
84
|
# first run creates a private venv and downloads the model. Local mode fails
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## 6.16.0
|
|
2
|
+
|
|
3
|
+
- **Truthful HQ results.** An HQ request is reported as HQ only when the full
|
|
4
|
+
local large-v3 decoder actually ran. Turbo, real-time server, long-audio, and
|
|
5
|
+
decode-error fallbacks now retain the requested mode while returning their
|
|
6
|
+
actual quality, backend, degradation flag, and bounded reason code.
|
|
7
|
+
- **HQ capability health.** `/api/health` and `/api/models` add a path-free
|
|
8
|
+
`capabilities.transcription.hq` block with availability, model, backend, and
|
|
9
|
+
a user-safe missing-prerequisite reason. Generic Whisper liveness no longer
|
|
10
|
+
implies that large-v3 HQ is installed.
|
|
11
|
+
- **Phone-visible fallback telemetry.** One-shot transcription and prompt-draft
|
|
12
|
+
finalize responses expose the same additive quality fields. Draft finalize
|
|
13
|
+
aggregates the records it actually used and reuses a successful degraded
|
|
14
|
+
warm result instead of paying for an identical second turbo decode.
|
|
15
|
+
- **Default unchanged.** Absent an explicit Fast request, prompt dictation still
|
|
16
|
+
requests HQ. `COS_HQ_SPECULATIVE_WARM=0` remains the immediate warm-path
|
|
17
|
+
rollback, and meeting batch beam/isolation behavior is unchanged.
|
|
18
|
+
|
|
1
19
|
## 6.15.5
|
|
2
20
|
|
|
3
21
|
- **Speculative HQ warm (no EHPK).** While a prompt-draft chunk is acknowledged,
|
package/README.md
CHANGED
|
@@ -106,6 +106,10 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
|
|
|
106
106
|
Whisper fallback is optional and requires both the exact
|
|
107
107
|
`COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a configured key; a key alone never
|
|
108
108
|
uploads audio.
|
|
109
|
+
- HQ prompt dictation is requested by default; the phone's **Fast mode** switch
|
|
110
|
+
opts into turbo. Server 6.16.0 reports whether full local large-v3 actually
|
|
111
|
+
ran, and compatible companions alert once if an HQ request used Fast or
|
|
112
|
+
Cloud instead of silently claiming HQ.
|
|
109
113
|
- Local-first spoken reply playback through Kokoro on Apple silicon. The first
|
|
110
114
|
use creates a private Python environment and downloads its model without
|
|
111
115
|
blocking the API. Selecting Local fails closed; `local_first` can fall back
|
|
@@ -137,6 +141,32 @@ Telegram activity export is disabled by default even when a private COS
|
|
|
137
141
|
pipeline contains `.telegram_config.json`; enable it only with the explicit
|
|
138
142
|
`COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
|
|
139
143
|
|
|
144
|
+
## HQ dictation
|
|
145
|
+
|
|
146
|
+
Prompt dictation defaults to HQ. The phone owns the preference: **Fast mode
|
|
147
|
+
OFF** requests HQ, and **Fast mode ON** requests turbo. The Mac performs all
|
|
148
|
+
decoding; the phone does not run Whisper.
|
|
149
|
+
|
|
150
|
+
The first server start downloads the real-time turbo model. True HQ additionally
|
|
151
|
+
requires the full `ggml-large-v3.bin` model (about 3.1 GB):
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
mkdir -p "$HOME/.local/share/whisper-models"
|
|
155
|
+
curl -fL --progress-bar \
|
|
156
|
+
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin \
|
|
157
|
+
-o "$HOME/.local/share/whisper-models/ggml-large-v3.bin.partial"
|
|
158
|
+
mv "$HOME/.local/share/whisper-models/ggml-large-v3.bin.partial" \
|
|
159
|
+
"$HOME/.local/share/whisper-models/ggml-large-v3.bin"
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Restart the server, then confirm
|
|
163
|
+
`capabilities.transcription.hq.hqAvailable: true` at `/api/health`. The response
|
|
164
|
+
does not expose local paths. If the CLI or model is unavailable, dictation stays
|
|
165
|
+
usable on Fast and reports the downgrade truthfully. Set
|
|
166
|
+
`COS_HQ_SPECULATIVE_WARM=0` to disable background HQ warm immediately; set
|
|
167
|
+
`COS_BATCH_LARGE_V3=0` to explicitly use turbo. Interactive HQ uses beam 2 by
|
|
168
|
+
default (`COS_HQ_BEAM_INTERACTIVE`); meeting batch remains beam 5.
|
|
169
|
+
|
|
140
170
|
## Run from source
|
|
141
171
|
|
|
142
172
|
```bash
|
package/package.json
CHANGED
|
@@ -34,6 +34,7 @@ export interface TranscribeAudioResult {
|
|
|
34
34
|
requestedMode: TranscribeMode
|
|
35
35
|
actualQuality: 'hq' | 'fast' | 'cloud'
|
|
36
36
|
degraded: boolean
|
|
37
|
+
degradationReason?: string
|
|
37
38
|
elapsedMs: number
|
|
38
39
|
audioBytes: number
|
|
39
40
|
}
|
|
@@ -160,6 +161,7 @@ export async function transcribeAudioBuffer(
|
|
|
160
161
|
let text: string
|
|
161
162
|
let backend: string
|
|
162
163
|
let actualQuality: 'hq' | 'fast' | 'cloud'
|
|
164
|
+
let degradationReason: string | undefined = effectiveMode !== requestedMode ? 'audio_too_long' : undefined
|
|
163
165
|
const tStart = performance.now()
|
|
164
166
|
|
|
165
167
|
if (effectiveMode === 'hq') {
|
|
@@ -169,10 +171,16 @@ export async function transcribeAudioBuffer(
|
|
|
169
171
|
const enhanced = await enhanceAudio(audioBuffer, { profile: enhanceProfile })
|
|
170
172
|
const result = await transcribeHighQuality(enhanced)
|
|
171
173
|
text = result.text
|
|
172
|
-
|
|
173
|
-
actualQuality
|
|
174
|
+
actualQuality = result.actualQuality
|
|
175
|
+
if (result.actualQuality === 'hq') {
|
|
176
|
+
backend = enhanceProfile === 'light' ? 'hq-large-v3-light' : 'hq-large-v3'
|
|
177
|
+
} else {
|
|
178
|
+
backend = result.backend === 'whisper-cli' ? 'fast-cli-turbo' : 'fast-local-server'
|
|
179
|
+
degradationReason = result.degradationReason ?? 'hq_unavailable'
|
|
180
|
+
}
|
|
174
181
|
} catch (hqErr: any) {
|
|
175
182
|
console.warn(`[transcribe] HQ path failed, falling back to fast: ${hqErr.message}`)
|
|
183
|
+
degradationReason = 'hq_decode_failed'
|
|
176
184
|
try {
|
|
177
185
|
const result = await transcribeLocal(audioBuffer)
|
|
178
186
|
text = result.text
|
|
@@ -268,6 +276,7 @@ export async function transcribeAudioBuffer(
|
|
|
268
276
|
requestedMode,
|
|
269
277
|
actualQuality,
|
|
270
278
|
degraded: requestedMode === 'hq' && actualQuality !== 'hq',
|
|
279
|
+
...(degradationReason ? { degradationReason } : {}),
|
|
271
280
|
elapsedMs,
|
|
272
281
|
audioBytes: audioBuffer.length,
|
|
273
282
|
}
|
|
@@ -42,6 +42,49 @@ export interface WhisperSegment {
|
|
|
42
42
|
words?: WhisperWord[]
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export type HighQualityUnavailableReason =
|
|
46
|
+
| 'hq_disabled'
|
|
47
|
+
| 'whisper_cli_missing'
|
|
48
|
+
| 'turbo_model_missing'
|
|
49
|
+
| 'large_v3_model_missing'
|
|
50
|
+
|
|
51
|
+
export interface HighQualityTranscriptionCapability {
|
|
52
|
+
hqAvailable: boolean
|
|
53
|
+
model: 'large-v3' | null
|
|
54
|
+
backend: 'whisper-cli' | null
|
|
55
|
+
reason: HighQualityUnavailableReason | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface HighQualityTranscriptionResult {
|
|
59
|
+
text: string
|
|
60
|
+
words?: WhisperWord[]
|
|
61
|
+
model: 'large-v3' | 'turbo'
|
|
62
|
+
backend: 'whisper-cli' | 'whisper-server'
|
|
63
|
+
actualQuality: 'hq' | 'fast'
|
|
64
|
+
degradationReason?: HighQualityUnavailableReason
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function classifyHighQualityTranscriptionCapability(input: {
|
|
68
|
+
enabled: boolean
|
|
69
|
+
cliPresent: boolean
|
|
70
|
+
turboReady: boolean
|
|
71
|
+
largeV3Present: boolean
|
|
72
|
+
}): HighQualityTranscriptionCapability {
|
|
73
|
+
if (!input.enabled) {
|
|
74
|
+
return { hqAvailable: false, model: null, backend: null, reason: 'hq_disabled' }
|
|
75
|
+
}
|
|
76
|
+
if (!input.cliPresent) {
|
|
77
|
+
return { hqAvailable: false, model: null, backend: null, reason: 'whisper_cli_missing' }
|
|
78
|
+
}
|
|
79
|
+
if (!input.turboReady) {
|
|
80
|
+
return { hqAvailable: false, model: null, backend: null, reason: 'turbo_model_missing' }
|
|
81
|
+
}
|
|
82
|
+
if (!input.largeV3Present) {
|
|
83
|
+
return { hqAvailable: false, model: null, backend: null, reason: 'large_v3_model_missing' }
|
|
84
|
+
}
|
|
85
|
+
return { hqAvailable: true, model: 'large-v3', backend: 'whisper-cli', reason: null }
|
|
86
|
+
}
|
|
87
|
+
|
|
45
88
|
interface WhisperJsonResponse {
|
|
46
89
|
text?: unknown
|
|
47
90
|
}
|
|
@@ -554,6 +597,20 @@ export function getWhisperHealth(): {
|
|
|
554
597
|
}
|
|
555
598
|
}
|
|
556
599
|
|
|
600
|
+
/** Public, path-free truth about whether an HQ request can actually run the
|
|
601
|
+
* full large-v3 decoder. Keep this separate from generic Whisper liveness: the
|
|
602
|
+
* persistent turbo server may be healthy while HQ weights or the CLI are not. */
|
|
603
|
+
export function getHighQualityTranscriptionCapability(): HighQualityTranscriptionCapability {
|
|
604
|
+
// cliAvailable also proves the turbo model exists. That fallback is part of
|
|
605
|
+
// the current decoder contract and is initialized once at process startup.
|
|
606
|
+
return classifyHighQualityTranscriptionCapability({
|
|
607
|
+
enabled: BATCH_LARGE_V3_ENABLED,
|
|
608
|
+
cliPresent: existsSync(WHISPER_CLI),
|
|
609
|
+
turboReady: cliAvailable,
|
|
610
|
+
largeV3Present: existsSync(BATCH_MODEL_LARGE_V3),
|
|
611
|
+
})
|
|
612
|
+
}
|
|
613
|
+
|
|
557
614
|
/**
|
|
558
615
|
* Reconcile a cached unavailable flag with the daemon's live health endpoint.
|
|
559
616
|
* Only successful inference resets the failure count: /health can be responsive
|
|
@@ -641,10 +698,18 @@ export async function transcribeHighQuality(
|
|
|
641
698
|
audioBuffer: Buffer,
|
|
642
699
|
context?: string,
|
|
643
700
|
opts: { priority?: 'interactive' | 'batch' } = {},
|
|
644
|
-
): Promise<
|
|
701
|
+
): Promise<HighQualityTranscriptionResult> {
|
|
645
702
|
if (!cliAvailable) {
|
|
646
703
|
// Fall back to server (no beam search available via HTTP API)
|
|
647
|
-
|
|
704
|
+
const fallback = await transcribeLocal(audioBuffer, context)
|
|
705
|
+
return {
|
|
706
|
+
text: fallback.text,
|
|
707
|
+
words: fallback.words,
|
|
708
|
+
model: 'turbo',
|
|
709
|
+
backend: fallback.backend === 'server' ? 'whisper-server' : 'whisper-cli',
|
|
710
|
+
actualQuality: 'fast',
|
|
711
|
+
degradationReason: existsSync(WHISPER_CLI) ? 'turbo_model_missing' : 'whisper_cli_missing',
|
|
712
|
+
}
|
|
648
713
|
}
|
|
649
714
|
|
|
650
715
|
const start = Date.now()
|
|
@@ -769,7 +834,17 @@ export async function transcribeHighQuality(
|
|
|
769
834
|
`${words ? `, ${words.length} words` : ''}): ` +
|
|
770
835
|
`"${corrected.slice(0, 80)}${corrected.length > 80 ? '...' : ''}"`,
|
|
771
836
|
)
|
|
772
|
-
|
|
837
|
+
const metadata = useLargeV3
|
|
838
|
+
? { model: 'large-v3' as const, backend: 'whisper-cli' as const, actualQuality: 'hq' as const }
|
|
839
|
+
: {
|
|
840
|
+
model: 'turbo' as const,
|
|
841
|
+
backend: 'whisper-cli' as const,
|
|
842
|
+
actualQuality: 'fast' as const,
|
|
843
|
+
degradationReason: BATCH_LARGE_V3_ENABLED
|
|
844
|
+
? 'large_v3_model_missing' as const
|
|
845
|
+
: 'hq_disabled' as const,
|
|
846
|
+
}
|
|
847
|
+
return words ? { text: corrected, words, ...metadata } : { text: corrected, ...metadata }
|
|
773
848
|
} finally {
|
|
774
849
|
try { unlinkSync(tmpWav) } catch { /* cleanup */ }
|
|
775
850
|
if (captureBatchWords) {
|
package/server/routes/health.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
|
8
8
|
import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
|
|
9
9
|
import { isSileroAvailable } from '../lib/vad-silero.js'
|
|
10
10
|
import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
isWhisperLocalAvailable,
|
|
13
|
+
getWhisperHealth,
|
|
14
|
+
getHighQualityTranscriptionCapability,
|
|
15
|
+
} from '../lib/whisper-local.js'
|
|
12
16
|
import { getOpenAIWhisperBudgetState } from '../lib/openai-whisper-budget.js'
|
|
13
17
|
import { getKeyStatus } from '../lib/openai-key.js'
|
|
14
18
|
import {
|
|
@@ -150,6 +154,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
150
154
|
const durableJobs = durableQueryJobStatus()
|
|
151
155
|
const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
|
|
152
156
|
const transcription = getTranscriptionPolicySnapshot()
|
|
157
|
+
const transcriptionHq = getHighQualityTranscriptionCapability()
|
|
153
158
|
const recovery = managedRuntimeCapability()
|
|
154
159
|
const maintenance = maintenanceLifecycle.snapshot()
|
|
155
160
|
const tts_local = getLocalTtsHealth()
|
|
@@ -213,7 +218,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
213
218
|
tts_local,
|
|
214
219
|
codex_models,
|
|
215
220
|
capabilities: {
|
|
216
|
-
transcription,
|
|
221
|
+
transcription: { ...transcription, hq: transcriptionHq },
|
|
217
222
|
recovery,
|
|
218
223
|
maintenance: {
|
|
219
224
|
state: maintenance.state,
|
|
@@ -242,6 +247,7 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
242
247
|
const durableJobs = durableQueryJobStatus()
|
|
243
248
|
const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
|
|
244
249
|
const transcription = getTranscriptionPolicySnapshot()
|
|
250
|
+
const transcriptionHq = getHighQualityTranscriptionCapability()
|
|
245
251
|
res.json({
|
|
246
252
|
...catalog,
|
|
247
253
|
serverInstanceId: getServerInstanceId(),
|
|
@@ -250,7 +256,7 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
250
256
|
enabled: durableJobs.enabled,
|
|
251
257
|
protocolVersion: durableJobs.protocolVersion,
|
|
252
258
|
},
|
|
253
|
-
transcription,
|
|
259
|
+
transcription: { ...transcription, hq: transcriptionHq },
|
|
254
260
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
255
261
|
recovery: managedRuntimeCapability(),
|
|
256
262
|
...(localFirstMeetings ? { localFirstMeetings } : {}),
|
|
@@ -199,6 +199,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
199
199
|
actualQuality: warm?.actualQuality ?? (mode === 'hq' ? 'hq' : 'fast'),
|
|
200
200
|
backend: warm?.backend ?? 'shared-inflight',
|
|
201
201
|
degraded: warm?.degraded ?? false,
|
|
202
|
+
...(warm?.degradationReason ? { degradationReason: warm.degradationReason } : {}),
|
|
202
203
|
}, 'final')
|
|
203
204
|
}
|
|
204
205
|
}
|
|
@@ -221,6 +222,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
|
|
|
221
222
|
const record: PromptDraftTranscriptRecord = {
|
|
222
223
|
text, hash, requestedMode: result.requestedMode, actualQuality: result.actualQuality,
|
|
223
224
|
backend: result.backend, degraded: result.degraded,
|
|
225
|
+
...(result.degradationReason ? { degradationReason: result.degradationReason } : {}),
|
|
224
226
|
}
|
|
225
227
|
await markPromptDraftChunkTranscript(draftId, chunkIndex, record, purpose)
|
|
226
228
|
console.log(`[prompt-draft] chunk ${draftId}/${chunkIndex}: ${result.elapsedMs.toFixed(1)}ms | ${result.backend} | ${purpose}/${mode} | ${text.length} chars`)
|
|
@@ -247,6 +249,7 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
247
249
|
const meta = loadPromptDraftMeta(draftId)
|
|
248
250
|
if (!meta) throw Object.assign(new Error('draft not found'), { status: 404 })
|
|
249
251
|
const texts: string[] = []
|
|
252
|
+
const transcriptRecords: PromptDraftTranscriptRecord[] = []
|
|
250
253
|
for (const chunk of readPromptDraftChunks(draftId)) {
|
|
251
254
|
try {
|
|
252
255
|
const hash = audioHash(chunk.audioBuffer)
|
|
@@ -259,10 +262,27 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
259
262
|
}
|
|
260
263
|
const current = loadPromptDraftMeta(draftId)
|
|
261
264
|
const cached = current?.finalTranscripts?.[String(chunk.chunkIndex)] ?? current?.warmTranscripts?.[String(chunk.chunkIndex)]
|
|
262
|
-
|
|
265
|
+
// Reuse the exact requested-mode decode even when HQ truthfully degraded
|
|
266
|
+
// to turbo. Finalize's automatic policy would make the same local choice
|
|
267
|
+
// again after a successful turbo result, so a second decode adds latency
|
|
268
|
+
// without improving quality. Legacy records stay excluded because their
|
|
269
|
+
// decoder provenance was reconstructed during migration.
|
|
270
|
+
const reusable = Boolean(
|
|
271
|
+
cached
|
|
272
|
+
&& cached.hash === hash
|
|
273
|
+
&& cached.requestedMode === mode
|
|
274
|
+
&& !cached.backend.startsWith('legacy'),
|
|
275
|
+
)
|
|
263
276
|
const raw = reusable ? cached!.text : await transcribeChunk(draftId, chunk.chunkIndex, chunk.audioBuffer, mode, 'final')
|
|
264
277
|
const text = sanitizeTranscript(draftId, raw, !reusable)
|
|
265
|
-
if (text.trim())
|
|
278
|
+
if (text.trim()) {
|
|
279
|
+
texts.push(text.trim())
|
|
280
|
+
const latest = loadPromptDraftMeta(draftId)
|
|
281
|
+
const used = latest?.finalTranscripts?.[String(chunk.chunkIndex)]
|
|
282
|
+
?? latest?.warmTranscripts?.[String(chunk.chunkIndex)]
|
|
283
|
+
?? cached
|
|
284
|
+
if (used && used.hash === hash) transcriptRecords.push(used)
|
|
285
|
+
}
|
|
266
286
|
} catch (err) {
|
|
267
287
|
if (err instanceof NoSpeechDetectedError) continue
|
|
268
288
|
throw err
|
|
@@ -275,7 +295,28 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
275
295
|
}
|
|
276
296
|
const finalText = await cleanOutboundDictation(text, { ...autoClean, signal })
|
|
277
297
|
const finalized = await markPromptDraftFinalized(draftId, finalText)
|
|
278
|
-
|
|
298
|
+
const qualities = transcriptRecords.map(record => record.actualQuality)
|
|
299
|
+
const actualQuality: 'hq' | 'fast' | 'cloud' = qualities.includes('cloud')
|
|
300
|
+
? 'cloud'
|
|
301
|
+
: qualities.length > 0 && qualities.every(quality => quality === 'hq')
|
|
302
|
+
? 'hq'
|
|
303
|
+
: 'fast'
|
|
304
|
+
const degraded = mode === 'hq' && (actualQuality !== 'hq' || transcriptRecords.some(record => record.degraded))
|
|
305
|
+
const backends = [...new Set(transcriptRecords.map(record => record.backend))]
|
|
306
|
+
const degradationReason = transcriptRecords.find(record => record.degradationReason)?.degradationReason
|
|
307
|
+
return {
|
|
308
|
+
draftId,
|
|
309
|
+
text: finalText,
|
|
310
|
+
recovered: true,
|
|
311
|
+
chunkCount: finalized.receivedChunkIndexes.length,
|
|
312
|
+
missingChunks: getMissingChunkIndexes(finalized),
|
|
313
|
+
expiresAt: finalized.expiresAt,
|
|
314
|
+
requestedMode: mode,
|
|
315
|
+
actualQuality,
|
|
316
|
+
degraded,
|
|
317
|
+
backend: backends.length === 1 ? backends[0] : 'mixed',
|
|
318
|
+
...(degradationReason ? { degradationReason } : {}),
|
|
319
|
+
}
|
|
279
320
|
}
|
|
280
321
|
|
|
281
322
|
const prunedAtBoot = maintenanceAdmissionsOpen() ? prunePromptDrafts() : 0
|
|
@@ -44,7 +44,15 @@ transcribeRouter.post('/transcribe', async (req, res) => {
|
|
|
44
44
|
|
|
45
45
|
const result = await transcribeAudioBuffer(audioBuffer, { mode: resolveMode(req) })
|
|
46
46
|
console.log(`[perf] /transcribe: ${result.elapsedMs.toFixed(1)}ms | mode=${result.mode} | ${result.backend} | ${result.audioBytes}b | ${result.text.length} chars`)
|
|
47
|
-
res.json({
|
|
47
|
+
res.json({
|
|
48
|
+
text: result.text,
|
|
49
|
+
backend: result.backend,
|
|
50
|
+
mode: result.mode,
|
|
51
|
+
requestedMode: result.requestedMode,
|
|
52
|
+
actualQuality: result.actualQuality,
|
|
53
|
+
degraded: result.degraded,
|
|
54
|
+
...(result.degradationReason ? { degradationReason: result.degradationReason } : {}),
|
|
55
|
+
})
|
|
48
56
|
} catch (err: any) {
|
|
49
57
|
if (err instanceof MaintenanceLifecycleError) {
|
|
50
58
|
if (err.retryAfterSeconds != null) res.setHeader('Retry-After', String(err.retryAfterSeconds))
|