@gotcos/glasses-server 6.7.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.
@@ -0,0 +1,249 @@
1
+ // Generic post-meeting transcription. Raw WAV chunks are concatenated into
2
+ // larger windows so Whisper gets enough context to improve the live stream.
3
+ // The candidate is never canonical until batch-transcript-quality accepts it.
4
+
5
+ import { existsSync, readFileSync, readdirSync, utimesSync, writeFileSync } from 'node:fs'
6
+ import { join, resolve } from 'node:path'
7
+ import { enhanceAudio } from './audio-enhance.js'
8
+ import { transcribeHighQuality, type WhisperWord } from './whisper-local.js'
9
+ import type { IndexedTranscriptChunk } from '../routes/transcribe-stream.js'
10
+ import {
11
+ evaluateBatchQuality,
12
+ type BatchResult,
13
+ type BatchSegment,
14
+ type BatchTranscription,
15
+ } from './batch-transcript-quality.js'
16
+
17
+ const WAV_HEADER_SIZE = 44
18
+ const SAMPLE_RATE = 16_000
19
+ const BITS_PER_SAMPLE = 16
20
+ const NUM_CHANNELS = 1
21
+
22
+ function refreshPendingLease(audioDir: string): void {
23
+ const marker = join(audioDir, '_batch_pending.marker')
24
+ const now = new Date()
25
+ try {
26
+ if (existsSync(marker)) utimesSync(marker, now, now)
27
+ else writeFileSync(marker, String(Date.now()), { encoding: 'utf8', mode: 0o600 })
28
+ } catch {
29
+ // Failure to refresh cannot justify deleting evidence; the persistence gate
30
+ // still retains raw audio unless accepted text + metadata both commit.
31
+ }
32
+ }
33
+
34
+ function createWavHeader(pcmLength: number): Buffer {
35
+ const bytesPerSample = BITS_PER_SAMPLE / 8
36
+ const byteRate = SAMPLE_RATE * NUM_CHANNELS * bytesPerSample
37
+ const header = Buffer.alloc(WAV_HEADER_SIZE)
38
+ header.write('RIFF', 0)
39
+ header.writeUInt32LE(pcmLength + WAV_HEADER_SIZE - 8, 4)
40
+ header.write('WAVE', 8)
41
+ header.write('fmt ', 12)
42
+ header.writeUInt32LE(16, 16)
43
+ header.writeUInt16LE(1, 20)
44
+ header.writeUInt16LE(NUM_CHANNELS, 22)
45
+ header.writeUInt32LE(SAMPLE_RATE, 24)
46
+ header.writeUInt32LE(byteRate, 28)
47
+ header.writeUInt16LE(NUM_CHANNELS * bytesPerSample, 32)
48
+ header.writeUInt16LE(BITS_PER_SAMPLE, 34)
49
+ header.write('data', 36)
50
+ header.writeUInt32LE(pcmLength, 40)
51
+ return header
52
+ }
53
+
54
+ export function concatenateWavChunks(audioDir: string, startChunk: number, endChunk: number): Buffer {
55
+ const pcmBuffers: Buffer[] = []
56
+ for (let index = startChunk; index <= endChunk; index++) {
57
+ const filename = `chunk_${String(index).padStart(4, '0')}.wav`
58
+ const filepath = resolve(audioDir, filename)
59
+ if (!existsSync(filepath)) continue
60
+ const wav = readFileSync(filepath)
61
+ if (wav.length <= WAV_HEADER_SIZE || wav.toString('ascii', 0, 4) !== 'RIFF') {
62
+ console.warn(`[meeting-batch] ${filename} is not a valid WAV; skipped`)
63
+ continue
64
+ }
65
+ pcmBuffers.push(wav.subarray(WAV_HEADER_SIZE))
66
+ }
67
+ if (pcmBuffers.length === 0) {
68
+ throw new Error(`No valid WAV chunks in range ${startChunk}-${endChunk}`)
69
+ }
70
+ const pcm = Buffer.concat(pcmBuffers)
71
+ return Buffer.concat([createWavHeader(pcm.length), pcm])
72
+ }
73
+
74
+ /** Group live transcript chunks into roughly 30-second Whisper windows. */
75
+ export function segmentTranscriptChunks(entries: IndexedTranscriptChunk[], targetMs = 30_000): BatchSegment[] {
76
+ if (entries.length === 0) return []
77
+ const segments: BatchSegment[] = []
78
+ let segmentStartPosition = 0
79
+
80
+ for (let position = 0; position < entries.length; position++) {
81
+ const entry = entries[position]
82
+ const chunk = entry.chunk
83
+ const first = entries[segmentStartPosition]
84
+ if (!first) {
85
+ segmentStartPosition = position
86
+ continue
87
+ }
88
+ if (chunk.elapsed - first.chunk.elapsed >= targetMs && position > segmentStartPosition) {
89
+ const window = entries.slice(segmentStartPosition, position + 1)
90
+ segments.push({
91
+ startChunkIdx: first.chunkIndex,
92
+ endChunkIdx: entry.chunkIndex,
93
+ startElapsed: first.chunk.elapsed,
94
+ endElapsed: chunk.elapsed,
95
+ speakers: [...new Set(window.map(item => item.chunk.speaker).filter(speaker => speaker && speaker !== 'Ext'))],
96
+ })
97
+ segmentStartPosition = position + 1
98
+ }
99
+ }
100
+
101
+ if (segmentStartPosition < entries.length) {
102
+ const window = entries.slice(segmentStartPosition)
103
+ const first = entries[segmentStartPosition]
104
+ const last = entries.at(-1)
105
+ if (window.length > 0 && first && last) {
106
+ segments.push({
107
+ startChunkIdx: first.chunkIndex,
108
+ endChunkIdx: last.chunkIndex,
109
+ startElapsed: first.chunk.elapsed,
110
+ endElapsed: last.chunk.elapsed,
111
+ speakers: [...new Set(window.map(item => item.chunk.speaker).filter(speaker => speaker && speaker !== 'Ext'))],
112
+ })
113
+ }
114
+ }
115
+ return segments
116
+ }
117
+
118
+ function stripOverlap(newText: string, previousText: string): string {
119
+ const normalize = (value: string): string => value
120
+ .toLowerCase()
121
+ .replace(/[.!?,;:'"()\-\n]/g, '')
122
+ .replace(/\s+/g, ' ')
123
+ .trim()
124
+ const previousWords = normalize(previousText).split(' ').filter(Boolean)
125
+ const newWords = normalize(newText).split(' ').filter(Boolean)
126
+ if (previousWords.length < 3 || newWords.length < 3) return newText
127
+
128
+ const maxOverlap = Math.min(previousWords.length, newWords.length, 25)
129
+ let overlap = 0
130
+ for (let count = maxOverlap; count >= 3; count--) {
131
+ if (previousWords.slice(-count).join(' ') === newWords.slice(0, count).join(' ')) {
132
+ overlap = count
133
+ break
134
+ }
135
+ }
136
+ if (overlap === 0) return newText
137
+ return newText.trim().split(/\s+/).slice(overlap).join(' ') || newText
138
+ }
139
+
140
+ function mapWordsToSpeakers(
141
+ words: WhisperWord[],
142
+ segment: BatchSegment,
143
+ entries: IndexedTranscriptChunk[],
144
+ ): Array<{ word: string; start: number; end: number; speaker: string }> {
145
+ return words.map(word => {
146
+ const absoluteElapsed = segment.startElapsed + word.start * 1000
147
+ let speaker = segment.speakers[0] || 'Unknown'
148
+ let bestDistance = Number.POSITIVE_INFINITY
149
+ for (const entry of entries) {
150
+ if (entry.chunkIndex < segment.startChunkIdx || entry.chunkIndex > segment.endChunkIdx) continue
151
+ const chunk = entry.chunk
152
+ const distance = Math.abs(chunk.elapsed - absoluteElapsed)
153
+ if (distance < bestDistance) {
154
+ bestDistance = distance
155
+ speaker = chunk.speaker
156
+ }
157
+ }
158
+ if (bestDistance > 3_500) speaker = segment.speakers[0] || 'Unknown'
159
+ return { word: word.word, start: word.start, end: word.end, speaker }
160
+ })
161
+ }
162
+
163
+ async function transcribeSegments(
164
+ audioDir: string,
165
+ segments: BatchSegment[],
166
+ entries: IndexedTranscriptChunk[],
167
+ ): Promise<BatchResult[]> {
168
+ const results: BatchResult[] = []
169
+ for (const segment of segments) {
170
+ try {
171
+ refreshPendingLease(audioDir)
172
+ const combined = concatenateWavChunks(audioDir, segment.startChunkIdx, segment.endChunkIdx)
173
+ const enhanced = await enhanceAudio(combined)
174
+ const previousText = results.at(-1)?.text
175
+ const result = await transcribeHighQuality(enhanced, previousText?.slice(-250))
176
+ const text = previousText ? stripOverlap(result.text, previousText) : result.text
177
+ const words = result.words ?? []
178
+ results.push({
179
+ segment,
180
+ text,
181
+ words,
182
+ speakerWords: mapWordsToSpeakers(words, segment, entries),
183
+ })
184
+ refreshPendingLease(audioDir)
185
+ } catch (error) {
186
+ console.error(
187
+ `[meeting-batch] Segment ${segment.startChunkIdx}-${segment.endChunkIdx} failed: `
188
+ + `${error instanceof Error ? error.message : String(error)}`,
189
+ )
190
+ }
191
+ }
192
+ return results
193
+ }
194
+
195
+ let batchQueueTail: Promise<void> = Promise.resolve()
196
+
197
+ /** Serialize 16-thread HQ decoders across meetings on a public user's Mac. */
198
+ export function runMeetingBatchPipeline(
199
+ audioDir: string,
200
+ entries: IndexedTranscriptChunk[],
201
+ streamingWordCount: number,
202
+ ): Promise<BatchTranscription> {
203
+ // Lease immediately, including time spent behind another HQ decoder. Without
204
+ // this, the two-hour cleanup could delete a queued meeting before it starts.
205
+ refreshPendingLease(audioDir)
206
+ const lease = setInterval(() => refreshPendingLease(audioDir), 60_000)
207
+ lease.unref()
208
+ const job = batchQueueTail.then(() => runMeetingBatchPipelineNow(
209
+ audioDir,
210
+ entries,
211
+ streamingWordCount,
212
+ )).finally(() => clearInterval(lease))
213
+ batchQueueTail = job.then(() => undefined, () => undefined)
214
+ return job
215
+ }
216
+
217
+ async function runMeetingBatchPipelineNow(
218
+ audioDir: string,
219
+ entries: IndexedTranscriptChunk[],
220
+ streamingWordCount: number,
221
+ ): Promise<BatchTranscription> {
222
+ try {
223
+ refreshPendingLease(audioDir)
224
+ if (!existsSync(audioDir)) return { transcriptionQuality: 'streaming' }
225
+ if (!readdirSync(audioDir).some(filename => filename.endsWith('.wav'))) {
226
+ return { transcriptionQuality: 'streaming' }
227
+ }
228
+ const segments = segmentTranscriptChunks(entries)
229
+ if (segments.length === 0) return { transcriptionQuality: 'streaming' }
230
+
231
+ const batchSegments = await transcribeSegments(audioDir, segments, entries)
232
+ const batchTranscript = batchSegments.map(result => result.text).join(' ')
233
+ const qualityReport = evaluateBatchQuality(batchSegments, streamingWordCount)
234
+ if (!qualityReport.accepted) {
235
+ console.warn(
236
+ `[meeting-batch] Candidate rejected (${qualityReport.reason}): `
237
+ + `${qualityReport.batchWordCount} batch words, `
238
+ + `${qualityReport.streamingWordCount} live words, `
239
+ + `${(qualityReport.duplicateWordRatio * 100).toFixed(1)}% duplicate`,
240
+ )
241
+ return { transcriptionQuality: 'streaming', qualityReport }
242
+ }
243
+
244
+ return { transcriptionQuality: 'batch', batchTranscript, batchSegments, qualityReport }
245
+ } catch (error) {
246
+ console.error(`[meeting-batch] Pipeline failed: ${error instanceof Error ? error.message : String(error)}`)
247
+ return { transcriptionQuality: 'streaming' }
248
+ }
249
+ }