@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.
- package/CHANGELOG.md +30 -0
- 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/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/transcribe-stream.ts +116 -23
|
@@ -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
|
+
}
|