@gotcos/glasses-server 6.7.0 → 6.9.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 +50 -0
- package/README.md +3 -1
- package/package.json +1 -1
- package/server/index.ts +4 -0
- package/server/lib/atomic-fs.ts +77 -1
- package/server/lib/batch-transcript-quality.ts +243 -0
- package/server/lib/display-bus.ts +1 -1
- 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/routes/health.ts +1 -0
- package/server/routes/meeting.ts +329 -0
- package/server/routes/meetings.ts +66 -0
- package/server/routes/prompt-drafts.ts +12 -1
- package/server/routes/transcribe-stream.ts +116 -23
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { Router } from 'express'
|
|
6
6
|
import { createHash } from 'node:crypto'
|
|
7
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, rmSync, renameSync, statSync } from 'node:fs'
|
|
7
|
+
import { chmodSync, readFileSync, writeFileSync, existsSync, lstatSync, mkdirSync, readdirSync, unlinkSync, rmSync, renameSync, statSync } from 'node:fs'
|
|
8
8
|
import { writeFile } from 'node:fs/promises'
|
|
9
9
|
import { resolve } from 'node:path'
|
|
10
10
|
import { fileURLToPath } from 'node:url'
|
|
@@ -31,6 +31,16 @@ import {
|
|
|
31
31
|
streamSilenceDropReason,
|
|
32
32
|
isVocabEchoOnly,
|
|
33
33
|
} from '../lib/hallucination-filter.js'
|
|
34
|
+
import { dataPath } from '../lib/data-dir.js'
|
|
35
|
+
|
|
36
|
+
function ensurePrivateDirectory(path: string): void {
|
|
37
|
+
if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
38
|
+
const stat = lstatSync(path)
|
|
39
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
40
|
+
throw new Error(`Unsafe private audio directory: ${path}`)
|
|
41
|
+
}
|
|
42
|
+
try { chmodSync(path, 0o700) } catch { /* individual writes still force 0600 */ }
|
|
43
|
+
}
|
|
34
44
|
|
|
35
45
|
// Silence-hallucination drops (2026-05-29, v5.9.73). Contract in streamSilenceDropReason:
|
|
36
46
|
// brand-URL-only -> dropped ALWAYS (vocab-seeded, never real speech).
|
|
@@ -41,13 +51,13 @@ const STRIP_BRAND_URLS = process.env.COS_WHISPER_STRIP_BRAND_URLS !== '0'
|
|
|
41
51
|
const THANKYOU_FILTER = process.env.COS_WHISPER_THANKYOU_FILTER !== '0'
|
|
42
52
|
|
|
43
53
|
// Audio persistence: save G2-mic chunks for speakers who need more training data
|
|
44
|
-
import { dataPath } from '../lib/data-dir.js'
|
|
45
54
|
const AUDIO_SAVE_DIR = dataPath('training-audio')
|
|
55
|
+
ensurePrivateDirectory(AUDIO_SAVE_DIR)
|
|
46
56
|
const MAX_SAVED_CHUNKS_PER_SPEAKER = 30 // ~5 min of audio per speaker, cleaned after training
|
|
47
57
|
|
|
48
58
|
// Unrecognized speaker audio: save Ext chunks for retroactive enrollment
|
|
49
59
|
const EXT_AUDIO_DIR = dataPath('ext-audio')
|
|
50
|
-
|
|
60
|
+
ensurePrivateDirectory(EXT_AUDIO_DIR)
|
|
51
61
|
const EXT_AUDIO_TTL_MS = 72 * 60 * 60 * 1000 // 72-hour retention
|
|
52
62
|
const MAX_EXT_CHUNKS_PER_SESSION = 40 // cap per session to avoid runaway storage
|
|
53
63
|
const extAudioCounts = new Map<string, number>() // sessionId → chunk count
|
|
@@ -73,16 +83,27 @@ const _unused_hallucination_constants_placeholder = 0 as const
|
|
|
73
83
|
|
|
74
84
|
// Session audio persistence: save all WAV chunks for batch re-transcription at save time
|
|
75
85
|
const SESSION_AUDIO_DIR = dataPath('session-audio')
|
|
76
|
-
|
|
86
|
+
ensurePrivateDirectory(SESSION_AUDIO_DIR)
|
|
77
87
|
const PENDING_BATCH_DIR = dataPath('pending-batch')
|
|
78
|
-
|
|
88
|
+
ensurePrivateDirectory(PENDING_BATCH_DIR)
|
|
79
89
|
const MAX_SESSION_AUDIO_BYTES = 500 * 1024 * 1024 // 500MB cap per session (~2hr meeting ≈ 260MB)
|
|
90
|
+
const PRESERVED_SESSION_AUDIO_MARKER = '_meeting_save_preserved.marker'
|
|
91
|
+
const PRESERVED_SESSION_AUDIO_TTL_MS = 2 * 60 * 60 * 1000
|
|
80
92
|
const MAX_CANDIDATE_WAV_BASE64_CHARS = 8 * 1024 * 1024 // stay below server/index.ts 10mb JSON parser cap
|
|
81
93
|
const MAX_CANDIDATE_TEXT_CHARS = 8000
|
|
82
94
|
const MAX_CANDIDATE_WORDS = 1200
|
|
83
95
|
const sessionAudioBytes = new Map<string, number>() // track per-session byte count
|
|
84
96
|
const sessionAudioWrites = new Map<string, Set<Promise<void>>>()
|
|
85
97
|
|
|
98
|
+
function hasFreshPreservedAudioMarker(dirPath: string): boolean {
|
|
99
|
+
try {
|
|
100
|
+
const marker = resolve(dirPath, PRESERVED_SESSION_AUDIO_MARKER)
|
|
101
|
+
return existsSync(marker) && Date.now() - statSync(marker).mtimeMs <= PRESERVED_SESSION_AUDIO_TTL_MS
|
|
102
|
+
} catch {
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
// In-memory training audio counts — lazy-initialized from disk on first access per speaker
|
|
87
108
|
const trainingAudioCounts = new Map<string, number>()
|
|
88
109
|
function getTrainingCount(speakerDir: string): number {
|
|
@@ -191,7 +212,7 @@ const CLOSED_SESSIONS_FILE = dataPath('closed-transcript-sessions.json')
|
|
|
191
212
|
|
|
192
213
|
// Incremental chunk persistence — survive server restarts
|
|
193
214
|
const CHUNK_PERSIST_DIR = dataPath('active-sessions')
|
|
194
|
-
|
|
215
|
+
ensurePrivateDirectory(CHUNK_PERSIST_DIR)
|
|
195
216
|
|
|
196
217
|
function readClosedSessions(): Record<string, number> {
|
|
197
218
|
if (!existsSync(CLOSED_SESSIONS_FILE)) return {}
|
|
@@ -219,8 +240,9 @@ function persistClosedSessions(): void {
|
|
|
219
240
|
}
|
|
220
241
|
try {
|
|
221
242
|
const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
|
|
222
|
-
writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf-8')
|
|
243
|
+
writeFileSync(tmp, JSON.stringify(merged, null, 2), { encoding: 'utf-8', mode: 0o600 })
|
|
223
244
|
renameSync(tmp, CLOSED_SESSIONS_FILE)
|
|
245
|
+
try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
|
|
224
246
|
} catch { /* best-effort tombstones */ }
|
|
225
247
|
}
|
|
226
248
|
|
|
@@ -239,8 +261,9 @@ function recoverClosedSessions(): void {
|
|
|
239
261
|
if (dirty) {
|
|
240
262
|
try {
|
|
241
263
|
const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
|
|
242
|
-
writeFileSync(tmp, JSON.stringify(closed, null, 2), 'utf-8')
|
|
264
|
+
writeFileSync(tmp, JSON.stringify(closed, null, 2), { encoding: 'utf-8', mode: 0o600 })
|
|
243
265
|
renameSync(tmp, CLOSED_SESSIONS_FILE)
|
|
266
|
+
try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
|
|
244
267
|
} catch {}
|
|
245
268
|
}
|
|
246
269
|
}
|
|
@@ -271,7 +294,8 @@ function persistSession(sessionId: string): void {
|
|
|
271
294
|
maxChunkIndex: session.maxChunkIndex ?? -1,
|
|
272
295
|
providerCandidates: session.providerCandidates ?? {},
|
|
273
296
|
})
|
|
274
|
-
writeFileSync(filePath, data, 'utf-8')
|
|
297
|
+
writeFileSync(filePath, data, { encoding: 'utf-8', mode: 0o600 })
|
|
298
|
+
try { chmodSync(filePath, 0o600) } catch { /* best effort on recovered installs */ }
|
|
275
299
|
} catch { /* non-critical — don't break transcription for persistence */ }
|
|
276
300
|
}
|
|
277
301
|
|
|
@@ -371,7 +395,7 @@ function recoverSessions(): void {
|
|
|
371
395
|
try {
|
|
372
396
|
if (existsSync(SESSION_AUDIO_DIR)) {
|
|
373
397
|
for (const dir of readdirSync(SESSION_AUDIO_DIR)) {
|
|
374
|
-
if (!recoveredIds.has(dir)) {
|
|
398
|
+
if (!recoveredIds.has(dir) && !hasFreshPreservedAudioMarker(resolve(SESSION_AUDIO_DIR, dir))) {
|
|
375
399
|
rmSync(resolve(SESSION_AUDIO_DIR, dir), { recursive: true, force: true })
|
|
376
400
|
console.log(`[session-recovery] Cleaned orphaned session-audio: ${dir}`)
|
|
377
401
|
}
|
|
@@ -412,7 +436,7 @@ setInterval(() => {
|
|
|
412
436
|
// Purge orphaned session-audio dirs (no matching active session)
|
|
413
437
|
try {
|
|
414
438
|
for (const dir of readdirSync(SESSION_AUDIO_DIR)) {
|
|
415
|
-
if (!sessions.has(dir)) {
|
|
439
|
+
if (!sessions.has(dir) && !hasFreshPreservedAudioMarker(resolve(SESSION_AUDIO_DIR, dir))) {
|
|
416
440
|
rmSync(resolve(SESSION_AUDIO_DIR, dir), { recursive: true, force: true })
|
|
417
441
|
}
|
|
418
442
|
}
|
|
@@ -585,6 +609,23 @@ export function getSessionChunks(sessionId: string): TranscriptChunk[] | null {
|
|
|
585
609
|
return session.chunks.filter(c => c && c.text)
|
|
586
610
|
}
|
|
587
611
|
|
|
612
|
+
export interface IndexedTranscriptChunk {
|
|
613
|
+
chunkIndex: number
|
|
614
|
+
chunk: TranscriptChunk
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Preserve original raw-WAV indices for post-meeting batch assembly. */
|
|
618
|
+
export function getSessionChunkEntries(sessionId: string): IndexedTranscriptChunk[] | null {
|
|
619
|
+
const session = sessions.get(sessionId)
|
|
620
|
+
if (!session) return null
|
|
621
|
+
const entries: IndexedTranscriptChunk[] = []
|
|
622
|
+
for (let chunkIndex = 0; chunkIndex < session.chunks.length; chunkIndex++) {
|
|
623
|
+
const chunk = session.chunks[chunkIndex]
|
|
624
|
+
if (chunk?.text) entries.push({ chunkIndex, chunk })
|
|
625
|
+
}
|
|
626
|
+
return entries
|
|
627
|
+
}
|
|
628
|
+
|
|
588
629
|
/** Get session start time */
|
|
589
630
|
export function getSessionStartTime(sessionId: string): number | null {
|
|
590
631
|
return sessions.get(sessionId)?.startTime ?? null
|
|
@@ -592,18 +633,34 @@ export function getSessionStartTime(sessionId: string): number | null {
|
|
|
592
633
|
|
|
593
634
|
/** Move session audio to pending-batch before deletion (batch re-transcription needs it) */
|
|
594
635
|
export function moveSessionAudioToPending(sessionId: string): string | null {
|
|
636
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
|
|
595
637
|
const srcDir = resolve(SESSION_AUDIO_DIR, sessionId)
|
|
596
638
|
if (!existsSync(srcDir)) return null
|
|
597
|
-
|
|
639
|
+
let destDir = resolve(PENDING_BATCH_DIR, sessionId)
|
|
598
640
|
try {
|
|
641
|
+
// A prior interrupted finalization can leave the canonical destination in
|
|
642
|
+
// place. Never sacrifice the new raw audio: choose a private unique sibling.
|
|
643
|
+
if (existsSync(destDir)) {
|
|
644
|
+
let suffix = `${Date.now()}_${process.pid}`
|
|
645
|
+
destDir = resolve(PENDING_BATCH_DIR, `${sessionId}_${suffix}`)
|
|
646
|
+
while (existsSync(destDir)) {
|
|
647
|
+
suffix = `${Date.now()}_${process.pid}_${Math.random().toString(16).slice(2, 8)}`
|
|
648
|
+
destDir = resolve(PENDING_BATCH_DIR, `${sessionId}_${suffix}`)
|
|
649
|
+
}
|
|
650
|
+
}
|
|
599
651
|
// Rename is atomic on same filesystem
|
|
600
652
|
renameSync(srcDir, destDir)
|
|
653
|
+
try { chmodSync(destDir, 0o700) } catch {}
|
|
601
654
|
// Write marker file with move timestamp. The cleanup interval checks THIS file's
|
|
602
655
|
// mtime for TTL, not individual chunk files (whose mtimes date from original write).
|
|
603
656
|
// Without this marker, meetings > 1 hour have first chunks older than the 1-hour
|
|
604
657
|
// TTL, causing the interval to purge pending-batch before batch re-transcription
|
|
605
658
|
// can run. Observed 2026-04-13: 103-min meeting lost audio to this race condition.
|
|
606
|
-
try {
|
|
659
|
+
try {
|
|
660
|
+
const markerPath = resolve(destDir, '_batch_pending.marker')
|
|
661
|
+
writeFileSync(markerPath, String(Date.now()), { encoding: 'utf-8', mode: 0o600 })
|
|
662
|
+
chmodSync(markerPath, 0o600)
|
|
663
|
+
} catch {}
|
|
607
664
|
return destDir
|
|
608
665
|
} catch (err: unknown) {
|
|
609
666
|
console.warn(`[session-audio] Failed to move to pending-batch: ${errMsg(err)}`)
|
|
@@ -611,12 +668,17 @@ export function moveSessionAudioToPending(sessionId: string): string | null {
|
|
|
611
668
|
}
|
|
612
669
|
}
|
|
613
670
|
|
|
671
|
+
export function hasSessionAudio(sessionId: string): boolean {
|
|
672
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return false
|
|
673
|
+
try { return statSync(resolve(SESSION_AUDIO_DIR, sessionId)).isDirectory() } catch { return false }
|
|
674
|
+
}
|
|
675
|
+
|
|
614
676
|
export function getSessionProviderCandidates(sessionId: string): Record<string, ProviderCandidateRecord> {
|
|
615
677
|
return sessions.get(sessionId)?.providerCandidates ?? {}
|
|
616
678
|
}
|
|
617
679
|
|
|
618
680
|
/** Delete session after save */
|
|
619
|
-
export function deleteSession(sessionId: string): void {
|
|
681
|
+
export function deleteSession(sessionId: string, options: { preserveAudio?: boolean } = {}): void {
|
|
620
682
|
sessions.delete(sessionId)
|
|
621
683
|
sessionAudioBytes.delete(sessionId)
|
|
622
684
|
// Clean up inline hallucination tracking (was leaking until 4-hour interval fired)
|
|
@@ -633,7 +695,15 @@ export function deleteSession(sessionId: string): void {
|
|
|
633
695
|
try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)) } catch {}
|
|
634
696
|
// Clean up session audio if it wasn't moved to pending-batch
|
|
635
697
|
const audioDir = resolve(SESSION_AUDIO_DIR, sessionId)
|
|
636
|
-
|
|
698
|
+
if (options.preserveAudio && existsSync(audioDir)) {
|
|
699
|
+
try {
|
|
700
|
+
const marker = resolve(audioDir, PRESERVED_SESSION_AUDIO_MARKER)
|
|
701
|
+
writeFileSync(marker, String(Date.now()), { encoding: 'utf8', mode: 0o600 })
|
|
702
|
+
chmodSync(marker, 0o600)
|
|
703
|
+
} catch {}
|
|
704
|
+
} else {
|
|
705
|
+
try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
|
|
706
|
+
}
|
|
637
707
|
}
|
|
638
708
|
|
|
639
709
|
function isIosAsrCandidateEnabled(): boolean {
|
|
@@ -695,7 +765,7 @@ async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Pr
|
|
|
695
765
|
|
|
696
766
|
async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number, audioBuffer: Buffer): Promise<void> {
|
|
697
767
|
const sessionDir = resolve(SESSION_AUDIO_DIR, sessionId)
|
|
698
|
-
|
|
768
|
+
ensurePrivateDirectory(sessionDir)
|
|
699
769
|
const chunkPath = resolve(sessionDir, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`)
|
|
700
770
|
const existingSize = existsSync(chunkPath) ? statSync(chunkPath).size : 0
|
|
701
771
|
const currentBytes = sessionAudioBytes.get(sessionId) ?? 0
|
|
@@ -704,7 +774,7 @@ async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number
|
|
|
704
774
|
if (chunkIndex % 50 === 0) console.warn(`[session-audio] Session ${sessionId} hit 500MB cap — skipping WAV saves`)
|
|
705
775
|
return
|
|
706
776
|
}
|
|
707
|
-
const writeJob = writeFile(chunkPath, audioBuffer)
|
|
777
|
+
const writeJob = writeFile(chunkPath, audioBuffer, { mode: 0o600 })
|
|
708
778
|
await trackSessionAudioWrite(sessionId, writeJob)
|
|
709
779
|
sessionAudioBytes.set(sessionId, Math.max(0, nextBytes))
|
|
710
780
|
}
|
|
@@ -856,12 +926,12 @@ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex
|
|
|
856
926
|
if (embCount < 20) {
|
|
857
927
|
try {
|
|
858
928
|
const speakerDir = resolve(AUDIO_SAVE_DIR, speaker.replace(/\s+/g, '_'))
|
|
859
|
-
|
|
929
|
+
ensurePrivateDirectory(speakerDir)
|
|
860
930
|
const existing = getTrainingCount(speakerDir)
|
|
861
931
|
if (existing < MAX_SAVED_CHUNKS_PER_SPEAKER) {
|
|
862
932
|
const filename = `${sessionId}_chunk${chunkIndex}_sim${embeddingResult.similarity.toFixed(2)}.wav`
|
|
863
933
|
const savePath = resolve(speakerDir, filename)
|
|
864
|
-
writeFile(savePath, audioBuffer).catch(err =>
|
|
934
|
+
writeFile(savePath, audioBuffer, { mode: 0o600 }).catch(err =>
|
|
865
935
|
console.warn(`[training-audio] Async save failed for ${speaker}: ${err.message}`)
|
|
866
936
|
)
|
|
867
937
|
trainingAudioCounts.set(speakerDir, existing + 1)
|
|
@@ -877,11 +947,11 @@ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex
|
|
|
877
947
|
|
|
878
948
|
if (speaker === 'Ext' && audioBuffer.length >= 16000) {
|
|
879
949
|
const extSessionDir = resolve(EXT_AUDIO_DIR, sessionId)
|
|
880
|
-
|
|
950
|
+
ensurePrivateDirectory(extSessionDir)
|
|
881
951
|
const extCount = extAudioCounts.get(sessionId) ?? 0
|
|
882
952
|
if (extCount < MAX_EXT_CHUNKS_PER_SESSION) {
|
|
883
953
|
const filename = `ext_chunk${chunkIndex}_${Date.now()}.wav`
|
|
884
|
-
writeFile(resolve(extSessionDir, filename), audioBuffer).catch(err =>
|
|
954
|
+
writeFile(resolve(extSessionDir, filename), audioBuffer, { mode: 0o600 }).catch(err =>
|
|
885
955
|
console.warn(`[ext-audio] Save failed: ${err.message}`)
|
|
886
956
|
)
|
|
887
957
|
extAudioCounts.set(sessionId, extCount + 1)
|
|
@@ -910,6 +980,8 @@ async function processStreamChunk(opts: {
|
|
|
910
980
|
audioBuffer: Buffer
|
|
911
981
|
candidate?: StreamCandidateInput
|
|
912
982
|
clientElapsed?: number
|
|
983
|
+
/** Original client recording start, applied only before canonical chunks. */
|
|
984
|
+
startTimeOverride?: number
|
|
913
985
|
}): Promise<{ text: string; speaker: string; chunkIndex: number; elapsed: number; sessionId: string; backend?: string; asrProvider?: string; fallbackReason?: string }> {
|
|
914
986
|
const { sessionId, chunkIndex, clientSpeaker, audioBuffer, candidate } = opts
|
|
915
987
|
const tReq = performance.now()
|
|
@@ -920,6 +992,9 @@ async function processStreamChunk(opts: {
|
|
|
920
992
|
|
|
921
993
|
const audioSha256 = sha256Hex(audioBuffer)
|
|
922
994
|
const session = getSession(sessionId)
|
|
995
|
+
if (opts.startTimeOverride && session.chunks.filter(Boolean).length === 0) {
|
|
996
|
+
session.startTime = opts.startTimeOverride
|
|
997
|
+
}
|
|
923
998
|
// Transfer integrity: log this index as delivered before any text filtering,
|
|
924
999
|
// so a silent/hallucination-filtered chunk is NOT mistaken for a lost one.
|
|
925
1000
|
recordReceivedChunk(session, chunkIndex)
|
|
@@ -1019,7 +1094,10 @@ async function processStreamChunk(opts: {
|
|
|
1019
1094
|
}
|
|
1020
1095
|
|
|
1021
1096
|
const { speaker, similarity } = await speakerPromise
|
|
1022
|
-
|
|
1097
|
+
// Client time is authoritative for live network jitter and deferred replay.
|
|
1098
|
+
const elapsed = Number.isFinite(opts.clientElapsed) && (opts.clientElapsed as number) >= 0
|
|
1099
|
+
? Math.round(opts.clientElapsed as number)
|
|
1100
|
+
: Date.now() - session.startTime
|
|
1023
1101
|
const trimmedText = sanitized.text
|
|
1024
1102
|
|
|
1025
1103
|
if (!trimmedText) {
|
|
@@ -1094,8 +1172,23 @@ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
|
|
|
1094
1172
|
const sessionId = (req.query.sessionId as string) || `g2_${Date.now()}`
|
|
1095
1173
|
const chunkIndex = parseInt((req.query.chunkIndex as string) || '0', 10)
|
|
1096
1174
|
const clientSpeaker = (req.query.speaker as string) || 'Unknown'
|
|
1175
|
+
const clientElapsedRaw = Number(req.query.elapsed)
|
|
1176
|
+
const clientElapsed = Number.isFinite(clientElapsedRaw) && clientElapsedRaw >= 0
|
|
1177
|
+
? clientElapsedRaw
|
|
1178
|
+
: undefined
|
|
1179
|
+
const startTimeRaw = Number(req.query.startTime)
|
|
1180
|
+
const startTimeOverride = Number.isFinite(startTimeRaw) && startTimeRaw > 0
|
|
1181
|
+
? startTimeRaw
|
|
1182
|
+
: undefined
|
|
1097
1183
|
const audioBuffer = await readRawBody(req)
|
|
1098
|
-
res.json(await processStreamChunk({
|
|
1184
|
+
res.json(await processStreamChunk({
|
|
1185
|
+
sessionId,
|
|
1186
|
+
chunkIndex,
|
|
1187
|
+
clientSpeaker,
|
|
1188
|
+
audioBuffer,
|
|
1189
|
+
clientElapsed,
|
|
1190
|
+
startTimeOverride,
|
|
1191
|
+
}))
|
|
1099
1192
|
} catch (err: unknown) {
|
|
1100
1193
|
sendStreamError(res, err)
|
|
1101
1194
|
}
|