@gotcos/glasses-server 6.21.18 → 6.21.20
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 +35 -0
- package/package.json +1 -1
- package/server/lib/meeting-audio-archive.ts +50 -0
- package/server/lib/send-audio.ts +42 -0
- package/server/routes/meeting.ts +21 -4
- package/server/routes/voice.ts +3 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
## 6.21.20
|
|
2
|
+
|
|
3
|
+
- Audio playback works at all. Every play button in the Control speaker review
|
|
4
|
+
returned a 404 and made no sound, on every default install, since playback
|
|
5
|
+
shipped. `res.sendFile` delegates to `send`, which defaults to
|
|
6
|
+
`dotfiles: 'ignore'` and — with no `root` set — applies that policy to the
|
|
7
|
+
WHOLE absolute path rather than to anything the request supplied. The default
|
|
8
|
+
data home is `~/.cos-glasses/data`, and `.cos-glasses` is a dot component, so
|
|
9
|
+
the file was found, confirmed to exist, then refused on the way out the door.
|
|
10
|
+
All three audio routes were affected: meeting chunks, speaker-profile samples,
|
|
11
|
+
and ext-audio samples.
|
|
12
|
+
|
|
13
|
+
The tests could not have caught this. They point `COS_DATA_DIR` at
|
|
14
|
+
`mktemp -d` — `/var/folders/...` — which cannot contain a dot component, so
|
|
15
|
+
the suite was structurally incapable of reproducing a default install and
|
|
16
|
+
stayed green while the feature was dead. The three routes now share
|
|
17
|
+
`sendAudioFile`, and `send-audio.test.ts` serves from a dot-directory on
|
|
18
|
+
purpose, over a real listener, asserting on the returned bytes.
|
|
19
|
+
|
|
20
|
+
## 6.21.19
|
|
21
|
+
|
|
22
|
+
- Review playback falls back to ext-audio. The 7-day archive introduced in
|
|
23
|
+
6.21.18 is FORWARD-ONLY — it starts filling when a meeting is saved under that
|
|
24
|
+
version — so on upgrade day the panel had no play buttons at all, which is what
|
|
25
|
+
Miles hit. ext-audio already holds 72 hours of unrecognised-speaker audio keyed
|
|
26
|
+
by the same raw capture index; measured across 14 real meetings, 90-100% of
|
|
27
|
+
those files correspond to a chunk the sidecar labels `Ext`. That is exactly the
|
|
28
|
+
set a reviewer most needs to hear.
|
|
29
|
+
- `GET /meeting/:id/audio` merges both sources and reports `archivedChunks` and
|
|
30
|
+
`extAudioChunks` separately, because the windows differ (7 days vs 72 hours)
|
|
31
|
+
and a single retention figure would be wrong for half the list.
|
|
32
|
+
- Published under its own version rather than re-cutting 6.21.18: that version is
|
|
33
|
+
already on npm, and two different artifacts sharing a version number is a
|
|
34
|
+
defect in its own right.
|
|
35
|
+
|
|
1
36
|
## 6.21.18
|
|
2
37
|
|
|
3
38
|
Everything a human needs to correct who spoke, and to hear the voice before
|
package/package.json
CHANGED
|
@@ -213,6 +213,56 @@ export function meetingAudioChunkPath(sessionId: string, chunkIndex: number): st
|
|
|
213
213
|
return existsSync(path) ? path : null
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Fallback source: audio the capture path saved for an UNRECOGNISED speaker.
|
|
218
|
+
*
|
|
219
|
+
* `ext-audio/<sessionId>/ext_chunk<N>_<ts>.wav` is written live whenever the
|
|
220
|
+
* identifier finds no match, and `<N>` is the same RAW capture index the review
|
|
221
|
+
* addresses. Verified across 14 real meetings: 90-100% of these files correspond
|
|
222
|
+
* to a chunk the sidecar labels `Ext`.
|
|
223
|
+
*
|
|
224
|
+
* This matters because the 7-day archive is FORWARD-ONLY — it starts filling
|
|
225
|
+
* when a meeting is saved under 6.21.18, so on the day of that upgrade there is
|
|
226
|
+
* nothing to play. ext-audio already holds the unidentified voices from the last
|
|
227
|
+
* 72 hours, which is exactly the set a reviewer most needs to hear.
|
|
228
|
+
*
|
|
229
|
+
* Shorter window than the archive (72h vs 7 days), so the listing reports which
|
|
230
|
+
* source a chunk came from rather than implying one retention rule.
|
|
231
|
+
*/
|
|
232
|
+
export function extAudioChunkPath(sessionId: string, chunkIndex: number): string | null {
|
|
233
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
|
|
234
|
+
const dir = join(dataPath('ext-audio'), sessionId.replace(/:/g, '_'))
|
|
235
|
+
if (!resolve(dir).startsWith(resolve(dataPath('ext-audio')) + '/') || !existsSync(dir)) return null
|
|
236
|
+
try {
|
|
237
|
+
// Timestamped suffix, so match on the index and take the newest.
|
|
238
|
+
const prefix = `ext_chunk${chunkIndex}_`
|
|
239
|
+
const hits = readdirSync(dir).filter(n => n.startsWith(prefix) && n.endsWith('.wav')).sort()
|
|
240
|
+
const pick = hits[hits.length - 1]
|
|
241
|
+
if (!pick) return null
|
|
242
|
+
const path = resolve(dir, pick)
|
|
243
|
+
return resolve(path).startsWith(resolve(dir) + '/') ? path : null
|
|
244
|
+
} catch {
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Raw chunk indices this session has ext-audio for, ascending. */
|
|
250
|
+
export function listExtAudioChunks(sessionId: string): number[] {
|
|
251
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return []
|
|
252
|
+
const dir = join(dataPath('ext-audio'), sessionId.replace(/:/g, '_'))
|
|
253
|
+
if (!existsSync(dir)) return []
|
|
254
|
+
try {
|
|
255
|
+
const out = new Set<number>()
|
|
256
|
+
for (const n of readdirSync(dir)) {
|
|
257
|
+
const m = /^ext_chunk(\d+)_.*\.wav$/.exec(n)
|
|
258
|
+
if (m) out.add(Number(m[1]))
|
|
259
|
+
}
|
|
260
|
+
return [...out].sort((a, b) => a - b)
|
|
261
|
+
} catch {
|
|
262
|
+
return []
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
216
266
|
/** Chunk indices retained for a session, ascending. */
|
|
217
267
|
export function listMeetingAudioChunks(sessionId: string): number[] {
|
|
218
268
|
const dir = sessionDir(sessionId)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Serving a retained WAV back to a reviewer.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS AT ALL, because `res.sendFile(path)` looks like it would do.
|
|
4
|
+
//
|
|
5
|
+
// `send` (what Express delegates to) defaults to `dotfiles: 'ignore'`, and when
|
|
6
|
+
// no `root` option is given it applies that policy to the ENTIRE absolute path,
|
|
7
|
+
// not to the part a request supplied:
|
|
8
|
+
//
|
|
9
|
+
// parts = normalize(path).split(sep) // send/index.js — the whole path
|
|
10
|
+
// if (containsDotFile(parts)) ... error(404)
|
|
11
|
+
//
|
|
12
|
+
// The COS data home is `~/.cos-glasses/data` by default. `.cos-glasses` is a
|
|
13
|
+
// dot component, so every audio route 404'd on every default install — the play
|
|
14
|
+
// button rendered, the fetch failed, and nothing was heard. Verified directly
|
|
15
|
+
// against the installed module: `{}` -> 404, `{dotfiles:'allow'}` -> resolves.
|
|
16
|
+
//
|
|
17
|
+
// The tests never caught it because they point `COS_DATA_DIR` at
|
|
18
|
+
// `mktemp -d` (`/var/folders/...`), which structurally CANNOT contain a dot
|
|
19
|
+
// component. The suite was green and the feature was dead. `send-audio.test.ts`
|
|
20
|
+
// serves from a dot-directory on purpose.
|
|
21
|
+
//
|
|
22
|
+
// 'allow' is safe here rather than merely convenient: the dot component is ours
|
|
23
|
+
// (the data home), and nothing a caller supplies can introduce one. Every route
|
|
24
|
+
// resolves its path through a validator first — `sessionId` and speaker names
|
|
25
|
+
// match `[A-Za-z0-9:_-]`, chunk indices are integers, and the archive helpers
|
|
26
|
+
// re-check containment before returning. The path handed to this function is
|
|
27
|
+
// already known to exist and to live under the data dir.
|
|
28
|
+
|
|
29
|
+
import type { Response } from 'express'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Send a WAV that has already been resolved and containment-checked.
|
|
33
|
+
*
|
|
34
|
+
* Callers must have verified the file exists; a missing file here surfaces as
|
|
35
|
+
* send's own 404 HTML rather than the route's JSON, which is exactly the
|
|
36
|
+
* confusing failure this module documents.
|
|
37
|
+
*/
|
|
38
|
+
export function sendAudioFile(res: Response, path: string): void {
|
|
39
|
+
res.type('audio/wav')
|
|
40
|
+
// See the file header: without this, any data home under a dot-directory 404s.
|
|
41
|
+
res.sendFile(path, { dotfiles: 'allow' })
|
|
42
|
+
}
|
package/server/routes/meeting.ts
CHANGED
|
@@ -10,7 +10,10 @@ import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
|
|
|
10
10
|
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
11
11
|
import { appendCorrection, pendingCorrections } from '../lib/meeting-corrections.js'
|
|
12
12
|
import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
|
|
13
|
+
import { sendAudioFile } from '../lib/send-audio.js'
|
|
13
14
|
import {
|
|
15
|
+
extAudioChunkPath,
|
|
16
|
+
listExtAudioChunks,
|
|
14
17
|
listMeetingAudioChunks,
|
|
15
18
|
meetingAudioChunkPath,
|
|
16
19
|
meetingAudioRetentionDays,
|
|
@@ -1151,9 +1154,18 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1151
1154
|
res.status(400).json({ error: 'Invalid chunkIndex', reason: 'invalid_chunk_index' })
|
|
1152
1155
|
return
|
|
1153
1156
|
}
|
|
1157
|
+
// Archive first, then the live ext-audio the capture path already saved for
|
|
1158
|
+
// unrecognised speakers. The archive is forward-only, so without this
|
|
1159
|
+
// fallback there is nothing to play on any meeting predating 6.21.18 — while
|
|
1160
|
+
// 72 hours of unidentified-voice audio is sitting right there, and an
|
|
1161
|
+
// unidentified voice is exactly what a reviewer needs to hear.
|
|
1154
1162
|
const path = meetingAudioChunkPath(sessionId, chunkIndex)
|
|
1163
|
+
?? extAudioChunkPath(sessionId, chunkIndex)
|
|
1155
1164
|
if (!path) {
|
|
1156
|
-
const retained =
|
|
1165
|
+
const retained = [...new Set([
|
|
1166
|
+
...listMeetingAudioChunks(sessionId),
|
|
1167
|
+
...listExtAudioChunks(sessionId),
|
|
1168
|
+
])].sort((a, b) => a - b)
|
|
1157
1169
|
res.status(404).json({
|
|
1158
1170
|
error: retained.length === 0
|
|
1159
1171
|
? 'No audio retained for this meeting'
|
|
@@ -1167,8 +1179,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1167
1179
|
})
|
|
1168
1180
|
return
|
|
1169
1181
|
}
|
|
1170
|
-
res
|
|
1171
|
-
res.sendFile(path)
|
|
1182
|
+
sendAudioFile(res, path)
|
|
1172
1183
|
})
|
|
1173
1184
|
|
|
1174
1185
|
/** What audio a meeting still has, so the panel can show play buttons only
|
|
@@ -1180,11 +1191,17 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1180
1191
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1181
1192
|
return
|
|
1182
1193
|
}
|
|
1183
|
-
const
|
|
1194
|
+
const archived = listMeetingAudioChunks(sessionId)
|
|
1195
|
+
const ext = listExtAudioChunks(sessionId)
|
|
1196
|
+
const chunks = [...new Set([...archived, ...ext])].sort((a, b) => a - b)
|
|
1184
1197
|
res.json({
|
|
1185
1198
|
sessionId,
|
|
1186
1199
|
retained: chunks.length > 0,
|
|
1187
1200
|
chunks,
|
|
1201
|
+
// Reported separately: ext-audio runs a 72h window, the archive 7 days, so
|
|
1202
|
+
// one retention number would be wrong for half the list.
|
|
1203
|
+
archivedChunks: archived.length,
|
|
1204
|
+
extAudioChunks: ext.length,
|
|
1188
1205
|
// Config read, not a filesystem walk: this route used to stat every
|
|
1189
1206
|
// retained chunk to report one number.
|
|
1190
1207
|
retentionDays: meetingAudioRetentionDays(),
|
package/server/routes/voice.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
|
11
11
|
import { dataPath } from '../lib/data-dir.js'
|
|
12
12
|
import { purgeSpeakerCalibrationRows, relabelSpeakerCalibrationRows } from '../lib/speaker-calibration-log.js'
|
|
13
13
|
import { trainingSourceFor } from '../lib/training-audio-provenance.js'
|
|
14
|
+
import { sendAudioFile } from '../lib/send-audio.js'
|
|
14
15
|
|
|
15
16
|
// These MUST match the writer in transcribe-stream.ts, which saves under
|
|
16
17
|
// dataPath(). They previously resolved relative to __dirname — i.e. inside the
|
|
@@ -471,8 +472,7 @@ voiceRouter.get('/voice/profiles/:name/sample', (req, res) => {
|
|
|
471
472
|
})
|
|
472
473
|
return
|
|
473
474
|
}
|
|
474
|
-
res
|
|
475
|
-
res.sendFile(wav)
|
|
475
|
+
sendAudioFile(res, wav)
|
|
476
476
|
})
|
|
477
477
|
|
|
478
478
|
// GET /api/voice/ext-audio/:sessionId/sample — hear an unidentified voice.
|
|
@@ -492,8 +492,7 @@ voiceRouter.get('/voice/ext-audio/:sessionId/sample', (req, res) => {
|
|
|
492
492
|
})
|
|
493
493
|
return
|
|
494
494
|
}
|
|
495
|
-
res
|
|
496
|
-
res.sendFile(wav)
|
|
495
|
+
sendAudioFile(res, wav)
|
|
497
496
|
})
|
|
498
497
|
|
|
499
498
|
// GET /api/voice/profiles — enrolled people with sample counts and provenance.
|