@gotcos/glasses-server 6.21.6 → 6.21.7
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 +12 -0
- package/package.json +1 -1
- package/server/lib/whisper-preview.ts +34 -0
- package/server/routes/transcribe-stream.ts +87 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 6.21.7
|
|
2
|
+
|
|
3
|
+
- Add a default-off, authenticated meeting-preview endpoint for private canaries.
|
|
4
|
+
It accepts bounded, server-pinned audio snapshots and returns disposable
|
|
5
|
+
Large-v3-Turbo text without creating or mutating meeting sessions.
|
|
6
|
+
- Keep canonical Large-v3 transcription, speaker attribution, recovery, save, HQ
|
|
7
|
+
polish, and indexing unchanged. Preview never falls back to the canonical worker
|
|
8
|
+
and drops under canonical Metal contention.
|
|
9
|
+
- Reject stale server pins and oversized bodies before inference, recheck
|
|
10
|
+
maintenance admission after slow uploads, and drop concurrent preview work rather
|
|
11
|
+
than building a latency queue. `COS_WHISPER_MEETING_PREVIEW=1` is required.
|
|
12
|
+
|
|
1
13
|
## 6.21.6
|
|
2
14
|
|
|
3
15
|
- Make server-owned durable query jobs the default so accepted replies keep
|
package/package.json
CHANGED
|
@@ -434,3 +434,37 @@ export async function transcribeWhisperPreview(audioBuffer: Buffer): Promise<{
|
|
|
434
434
|
throw error
|
|
435
435
|
}
|
|
436
436
|
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Meeting-preview canary: use only the isolated Turbo sidecar.
|
|
440
|
+
*
|
|
441
|
+
* Unlike prompt preview, this path never falls through to the canonical
|
|
442
|
+
* Large-v3 worker. A missing, busy, preempted, or failed sidecar is a silent
|
|
443
|
+
* cosmetic miss; durable meeting transcription continues on its unchanged
|
|
444
|
+
* canonical path.
|
|
445
|
+
*/
|
|
446
|
+
export async function transcribeWhisperMeetingPreview(audioBuffer: Buffer): Promise<{
|
|
447
|
+
text: string
|
|
448
|
+
model: 'large-v3-turbo'
|
|
449
|
+
backend: 'whisper-preview-server'
|
|
450
|
+
} | null> {
|
|
451
|
+
if (!previewAvailable || previewWorkerModel !== 'large-v3-turbo') return null
|
|
452
|
+
const previewLease = tryAcquireMetalPreview()
|
|
453
|
+
if (!previewLease) return null
|
|
454
|
+
try {
|
|
455
|
+
return {
|
|
456
|
+
text: await transcribeViaPreviewServer(audioBuffer, previewLease.signal),
|
|
457
|
+
model: 'large-v3-turbo',
|
|
458
|
+
backend: 'whisper-preview-server',
|
|
459
|
+
}
|
|
460
|
+
} catch (error) {
|
|
461
|
+
if (previewLease.signal.aborted) return null
|
|
462
|
+
previewAvailable = false
|
|
463
|
+
previewWorkerModel = null
|
|
464
|
+
previewFailure = 'preview_sidecar_unavailable'
|
|
465
|
+
console.warn(`[whisper-preview] meeting Turbo preview failed; canonical meeting ASR was not affected: ${error instanceof Error ? error.message : error}`)
|
|
466
|
+
return null
|
|
467
|
+
} finally {
|
|
468
|
+
previewLease.release()
|
|
469
|
+
}
|
|
470
|
+
}
|
|
@@ -29,11 +29,13 @@ import {
|
|
|
29
29
|
} from '../lib/openai-whisper-budget.js'
|
|
30
30
|
import {
|
|
31
31
|
stripInlineHallucinations as sharedStripInlineHallucinations,
|
|
32
|
+
stripInlineHallucinationsOneShot,
|
|
32
33
|
isFullHallucination as sharedIsFullHallucination,
|
|
33
34
|
clearSessionHallucinationState,
|
|
34
35
|
streamSilenceDropReason,
|
|
35
36
|
isVocabEchoOnly,
|
|
36
37
|
} from '../lib/hallucination-filter.js'
|
|
38
|
+
import { transcribeWhisperMeetingPreview } from '../lib/whisper-preview.js'
|
|
37
39
|
import { dataPath } from '../lib/data-dir.js'
|
|
38
40
|
import {
|
|
39
41
|
countChunkWavs,
|
|
@@ -101,6 +103,12 @@ function ensurePrivateDirectory(path: string): void {
|
|
|
101
103
|
// Rollback: COS_WHISPER_STRIP_BRAND_URLS=0 (URL drops), COS_WHISPER_THANKYOU_FILTER=0.
|
|
102
104
|
const STRIP_BRAND_URLS = process.env.COS_WHISPER_STRIP_BRAND_URLS !== '0'
|
|
103
105
|
const THANKYOU_FILTER = process.env.COS_WHISPER_THANKYOU_FILTER !== '0'
|
|
106
|
+
const MEETING_PREVIEW_MAX_BYTES = 512 * 1024
|
|
107
|
+
let meetingPreviewBusy = false
|
|
108
|
+
|
|
109
|
+
function meetingTurboPreviewEnabled(): boolean {
|
|
110
|
+
return process.env.COS_WHISPER_MEETING_PREVIEW === '1'
|
|
111
|
+
}
|
|
104
112
|
|
|
105
113
|
// Audio persistence: save G2-mic chunks for speakers who need more training data
|
|
106
114
|
const AUDIO_SAVE_DIR = dataPath('training-audio')
|
|
@@ -1180,6 +1188,21 @@ async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Pr
|
|
|
1180
1188
|
return Buffer.concat(buffers)
|
|
1181
1189
|
}
|
|
1182
1190
|
|
|
1191
|
+
async function readBoundedRawBody(
|
|
1192
|
+
req: AsyncIterable<Buffer | Uint8Array | string>,
|
|
1193
|
+
maxBytes: number,
|
|
1194
|
+
): Promise<Buffer> {
|
|
1195
|
+
const buffers: Buffer[] = []
|
|
1196
|
+
let total = 0
|
|
1197
|
+
for await (const chunk of req) {
|
|
1198
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
1199
|
+
total += buffer.length
|
|
1200
|
+
if (total > maxBytes) throw makeHttpError(413, 'audio chunk too large', 'chunk_too_large')
|
|
1201
|
+
buffers.push(buffer)
|
|
1202
|
+
}
|
|
1203
|
+
return Buffer.concat(buffers)
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1183
1206
|
async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number, audioBuffer: Buffer): Promise<void> {
|
|
1184
1207
|
const sessionDir = resolve(SESSION_AUDIO_DIR, sessionId)
|
|
1185
1208
|
ensurePrivateDirectory(sessionDir)
|
|
@@ -1664,6 +1687,70 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
|
|
|
1664
1687
|
return res.status(status).json({ error: errMsg(err), reason: (err as any)?.reason })
|
|
1665
1688
|
}
|
|
1666
1689
|
|
|
1690
|
+
/**
|
|
1691
|
+
* Default-off meeting preview canary.
|
|
1692
|
+
*
|
|
1693
|
+
* This route deliberately owns no meeting session, recovery ledger, speaker
|
|
1694
|
+
* attribution, or persistence. It accepts a pinned copy of the still-open
|
|
1695
|
+
* phrase and returns provisional Turbo text. Any contention or failure is a
|
|
1696
|
+
* 204 cosmetic miss; the canonical /transcribe-stream upload remains the only
|
|
1697
|
+
* durable and speaker-attributed result.
|
|
1698
|
+
*/
|
|
1699
|
+
transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
|
|
1700
|
+
try {
|
|
1701
|
+
if (!meetingTurboPreviewEnabled() || !maintenanceAdmissionsOpen()) {
|
|
1702
|
+
return void res.status(204).send()
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
const headerPin = req.get('X-COS-Server-Instance')?.trim() ?? ''
|
|
1706
|
+
const queryPin = typeof req.query.serverInstanceId === 'string'
|
|
1707
|
+
? req.query.serverInstanceId.trim()
|
|
1708
|
+
: ''
|
|
1709
|
+
if (!headerPin && !queryPin) {
|
|
1710
|
+
throw makeHttpError(400, 'server identity pin required', 'server_identity_pin_required')
|
|
1711
|
+
}
|
|
1712
|
+
const serverInstanceId = assertPinnedServerIdentity(headerPin, queryPin)
|
|
1713
|
+
const sessionId = String(req.query.sessionId ?? '')
|
|
1714
|
+
validateSessionId(sessionId)
|
|
1715
|
+
const chunkIndex = Number(req.query.chunkIndex)
|
|
1716
|
+
validateChunkIndex(chunkIndex)
|
|
1717
|
+
const previewGen = Number(req.query.previewGen)
|
|
1718
|
+
if (!Number.isInteger(previewGen) || previewGen < 0 || previewGen > 1_000_000_000) {
|
|
1719
|
+
throw makeHttpError(400, 'invalid previewGen', 'invalid_preview_generation')
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
const audio = await readBoundedRawBody(req, MEETING_PREVIEW_MAX_BYTES)
|
|
1723
|
+
if (audio.length < 100) throw makeHttpError(400, 'audio too short', 'audio_too_short')
|
|
1724
|
+
if (!maintenanceAdmissionsOpen() || meetingPreviewBusy) {
|
|
1725
|
+
return void res.status(204).send()
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
meetingPreviewBusy = true
|
|
1729
|
+
try {
|
|
1730
|
+
const result = await transcribeWhisperMeetingPreview(audio)
|
|
1731
|
+
if (!result) return void res.status(204).send()
|
|
1732
|
+
const cleaned = stripInlineHallucinationsOneShot(result.text.trim()).trim()
|
|
1733
|
+
if (!cleaned || sharedIsFullHallucination(cleaned)) {
|
|
1734
|
+
return void res.status(204).send()
|
|
1735
|
+
}
|
|
1736
|
+
return void res.json({
|
|
1737
|
+
sessionId,
|
|
1738
|
+
chunkIndex,
|
|
1739
|
+
previewGen,
|
|
1740
|
+
text: cleaned,
|
|
1741
|
+
provisional: true,
|
|
1742
|
+
model: result.model,
|
|
1743
|
+
backend: result.backend,
|
|
1744
|
+
serverInstanceId,
|
|
1745
|
+
})
|
|
1746
|
+
} finally {
|
|
1747
|
+
meetingPreviewBusy = false
|
|
1748
|
+
}
|
|
1749
|
+
} catch (err: unknown) {
|
|
1750
|
+
sendStreamError(res, err)
|
|
1751
|
+
}
|
|
1752
|
+
})
|
|
1753
|
+
|
|
1667
1754
|
transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
|
|
1668
1755
|
let maintenanceLease: MaintenanceWorkLease | undefined
|
|
1669
1756
|
try {
|