@gotcos/glasses-server 6.21.17 → 6.21.18

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.
@@ -9,6 +9,37 @@ import { emitDisplay } from '../lib/display-bus.js'
9
9
  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
+ import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
13
+ import {
14
+ listMeetingAudioChunks,
15
+ meetingAudioChunkPath,
16
+ meetingAudioRetentionDays,
17
+ } from '../lib/meeting-audio-archive.js'
18
+ import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
19
+
20
+ /**
21
+ * The label a de-attributed voice takes, numbered within its meeting.
22
+ *
23
+ * NOT a shared `Ext`. De-attributing to one label folded every corrected voice
24
+ * into a single row — on the 2026-08-06 Ditto meeting Miles named five wrong
25
+ * attributions, and collapsing them would have destroyed his ability to tell
26
+ * those five voices apart, which is precisely what he needs playback for next.
27
+ *
28
+ * `Unidentified N` is prefix-matched by isUnattributed(), so the review panel
29
+ * treats the row as unnamed and autoEnroll skips it — a de-attributed stretch
30
+ * cannot re-poison a profile — while staying separable.
31
+ */
32
+ function nextDeattributedLabel(existing: string[]): string {
33
+ const used = new Set(
34
+ existing
35
+ .map(s => new RegExp(`^${DEATTRIBUTED_PREFIX} (\\d+)$`).exec(s)?.[1])
36
+ .filter((n): n is string => Boolean(n))
37
+ .map(Number),
38
+ )
39
+ let n = 1
40
+ while (used.has(n)) n++
41
+ return `${DEATTRIBUTED_PREFIX} ${n}`
42
+ }
12
43
  import {
13
44
  invalidLabelReason,
14
45
  relabelMeetingMarkdown,
@@ -76,7 +107,13 @@ import {
76
107
  findCosOperationsMeetingBySessionId,
77
108
  } from '../lib/cos-operations-meetings.js'
78
109
  import { getOwnerSpeakerLabel } from '../lib/profile.js'
79
- import { reviewMeetingSpeakers, type ReviewChunk } from '../lib/meeting-speaker-review.js'
110
+ import {
111
+ DEATTRIBUTED_PREFIX,
112
+ attachRawChunkIndices,
113
+ isUnattributed,
114
+ reviewMeetingSpeakers,
115
+ type ReviewChunk,
116
+ } from '../lib/meeting-speaker-review.js'
80
117
  import {
81
118
  acquireMaintenanceWork,
82
119
  maintenanceAdmissionsOpen,
@@ -676,9 +713,18 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
676
713
  return
677
714
  }
678
715
 
679
- const review = reviewMeetingSpeakers(chunks as ReviewChunk[], {
716
+ // Raw capture indices, so a phrase can address its own audio. Position in
717
+ // `chunks` is NOT the WAV number — see attachRawChunkIndices.
718
+ const sidecar = (JSON.parse(readFileSync(sidecarPath, 'utf-8')) ?? {}) as Record<string, unknown>
719
+ const withIndices = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
720
+ const review = reviewMeetingSpeakers(withIndices, {
680
721
  owner: getOwnerSpeakerLabel(),
681
722
  phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
723
+ // The sidecar's own durationMs is the meeting's true end. Deriving it from
724
+ // max(elapsed) uses the START of the last chunk, which made the final
725
+ // timeline span end where it began — a 1.5pt sliver labelled "1s" for what
726
+ // may be a long closing monologue.
727
+ durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
682
728
  })
683
729
  res.set('Cache-Control', 'private, no-store')
684
730
  res.json({
@@ -863,6 +909,288 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
863
909
  res.json({ ok: true, correctionId: id, ...preview })
864
910
  })
865
911
 
912
+
913
+ // ── De-attribution (6.21.18) ──────────────────────────────────────────
914
+ //
915
+ // The inverse of naming an unknown voice: this voice was NOT that person.
916
+ //
917
+ // Miles, on the 2026-08-06 Ditto meeting: none of the eleven attributed
918
+ // voices were actually in the room, and there was no way to say so. Naming an
919
+ // unknown was possible; un-naming a wrong guess was not.
920
+ //
921
+ // It undoes MORE than a label. When a voice is falsely attributed the
922
+ // identifier may also have auto-enrolled those segments into that person's
923
+ // profile, so the wrong voice is now part of what the system thinks they sound
924
+ // like and will keep matching. Removing only the label fixes the transcript and
925
+ // leaves the profile poisoned — the exact mechanism that grew the phantom
926
+ // "Erick Hernandez" from 3 mislabelled seeds to 18 samples.
927
+ router.post('/meeting/:sessionId/deattribute', (req, res) => {
928
+ res.set('Cache-Control', 'private, no-store')
929
+ const sessionId = String(req.params.sessionId ?? '')
930
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
931
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
932
+ return
933
+ }
934
+ const from = typeof req.body?.from === 'string' ? req.body.from : ''
935
+ const bad = invalidLabelReason(from)
936
+ if (bad) {
937
+ res.status(400).json({ error: `from: ${bad}`, reason: 'invalid_label' })
938
+ return
939
+ }
940
+ if (isUnattributed(from)) {
941
+ res.status(400).json({ error: `"${from}" is already unattributed`, reason: 'already_unattributed' })
942
+ return
943
+ }
944
+ const chunks = Array.isArray(req.body?.chunks)
945
+ ? (req.body.chunks as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0)
946
+ : []
947
+ if (Array.isArray(req.body?.chunks) && chunks.length !== req.body.chunks.length) {
948
+ res.status(400).json({ error: 'chunks must be non-negative integers', reason: 'invalid_chunks' })
949
+ return
950
+ }
951
+ // Retracting the training samples is the part that improves accuracy over
952
+ // time, so it defaults ON. Opt out to fix a transcript without touching the
953
+ // profile.
954
+ const retractTraining = req.body?.retractTraining !== false
955
+
956
+ const operations = cosOperationsMeetingsConfigured()
957
+ ? findCosOperationsMeetingBySessionId(sessionId)
958
+ : null
959
+ const saved = operations ? null : store.findBySessionId(sessionId)
960
+ if (!operations && !saved) {
961
+ res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
962
+ return
963
+ }
964
+ const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
965
+ const meetingPath = operations?.meetingPath ?? saved!.filepath
966
+ const title = operations?.title ?? saved!.title
967
+
968
+ let sidecarRaw: string
969
+ try {
970
+ sidecarRaw = readFileSync(sidecarPath, 'utf-8')
971
+ } catch {
972
+ res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
973
+ return
974
+ }
975
+
976
+ // A NUMBERED unidentified label, unique within this meeting, so removing
977
+ // several wrong names does not merge those voices together.
978
+ let existingSpeakers: string[] = []
979
+ try {
980
+ const parsed = JSON.parse(sidecarRaw) as { speakers?: unknown; chunks?: unknown }
981
+ existingSpeakers = Array.isArray(parsed.speakers)
982
+ ? parsed.speakers.filter((x): x is string => typeof x === 'string')
983
+ : []
984
+ // Also scan the chunks: a previous de-attribution may have left a label
985
+ // that never made it into `speakers`.
986
+ if (Array.isArray(parsed.chunks)) {
987
+ for (const c of parsed.chunks) {
988
+ const sp = (c as { speaker?: unknown } | null)?.speaker
989
+ if (typeof sp === 'string' && !existingSpeakers.includes(sp)) existingSpeakers.push(sp)
990
+ }
991
+ }
992
+ } catch { /* the relabel below reports a corrupt sidecar */ }
993
+ const DEATTRIBUTED_LABEL = nextDeattributedLabel(existingSpeakers)
994
+ const plan = relabelSidecarJson(sidecarRaw, from, DEATTRIBUTED_LABEL, chunks)
995
+ if (!plan.ok) {
996
+ res.status(422).json({ error: plan.error, reason: 'deattribute_rejected' })
997
+ return
998
+ }
999
+
1000
+ // What this would remove from the profile, and what it CANNOT reach.
1001
+ // readVoiceProfiles returns a ProfileStore, not an array.
1002
+ const profile = readVoiceProfiles().profiles.find(p => p.name === from)
1003
+ const profileSources: Array<string | undefined> = profile
1004
+ ? Array.from({ length: profile.embeddings.length }, (_, i) => profile.sources?.[i])
1005
+ : []
1006
+ const traceable = profileSources.filter(sourceString => isSampleFromSession(sourceString, sessionId)).length
1007
+ const untraceable = untraceableSampleCount(profileSources)
1008
+
1009
+ let markdownPlan: ReturnType<typeof relabelMeetingMarkdown> | null = null
1010
+ let markdownUnreadable = false
1011
+ if (plan.value.coveredAllWithLabel) {
1012
+ try {
1013
+ markdownPlan = relabelMeetingMarkdown(
1014
+ readFileSync(meetingPath, 'utf-8'), from, DEATTRIBUTED_LABEL,
1015
+ // Remove the attendee bullet outright: renaming it would write
1016
+ // `- Unidentified 2` into the list as though it were a person.
1017
+ { removeAttendee: true },
1018
+ )
1019
+ } catch { markdownPlan = null; markdownUnreadable = true }
1020
+ }
1021
+ const md = markdownPlan?.ok ? markdownPlan.value : null
1022
+
1023
+ const surfaces = {
1024
+ sidecar: plan.value.changed.length,
1025
+ attendees: md?.attendees ?? 0,
1026
+ transcript: md?.transcript ?? 0,
1027
+ }
1028
+ const preview = {
1029
+ sessionId,
1030
+ title,
1031
+ from,
1032
+ to: DEATTRIBUTED_LABEL,
1033
+ // 39% of operations sidecars have no .md beside them (the document was
1034
+ // archived). Without this the response reports sidecar changes and zero
1035
+ // markdown changes with no hint the document was never touched.
1036
+ markdownSkipped: !plan.value.coveredAllWithLabel
1037
+ ? 'partial de-attribution: transcript turns cannot be mapped to chunk indices'
1038
+ : markdownUnreadable ? 'meeting markdown unreadable' : null,
1039
+ scope: 'meeting' as const,
1040
+ chunks: plan.value.changed,
1041
+ surfaces,
1042
+ partial: !plan.value.coveredAllWithLabel,
1043
+ speakersAfter: plan.value.speakers,
1044
+ training: {
1045
+ retract: retractTraining,
1046
+ wouldRetract: retractTraining ? traceable : 0,
1047
+ profileSamples: profileSources.length,
1048
+ // Honest about reach: samples written before train-g2 started stamping
1049
+ // the session cannot be tied to a meeting and survive this.
1050
+ untraceable,
1051
+ },
1052
+ proseStale: md?.proseStale ?? false,
1053
+ proseHits: md?.proseHits ?? [],
1054
+ }
1055
+
1056
+ if (req.body?.dryRun === true || req.body?.confirm !== true) {
1057
+ const notes: string[] = []
1058
+ if (retractTraining && traceable > 0) {
1059
+ notes.push(`${traceable} training sample(s) from this meeting will be removed from "${from}"`)
1060
+ }
1061
+ if (retractTraining && traceable === 0) {
1062
+ notes.push(`no training sample from this meeting is traceable to "${from}", so the profile is unchanged`)
1063
+ }
1064
+ if (untraceable > 0) {
1065
+ notes.push(`${untraceable} older sample(s) on "${from}" carry no meeting provenance and cannot be retracted`)
1066
+ }
1067
+ res.status(req.body?.dryRun === true ? 200 : 400).json({
1068
+ ...(req.body?.dryRun === true ? {} : { error: 'confirmation required', reason: 'confirmation_required' }),
1069
+ message: `Removing "${from}" from ${surfaces.sidecar} segment(s) of this meeting. ${notes.join('. ')}`.trim(),
1070
+ ...preview,
1071
+ })
1072
+ return
1073
+ }
1074
+
1075
+ const stalled = pendingCorrections(sessionId)
1076
+ if (stalled.length > 0 && req.body?.force !== true) {
1077
+ res.status(409).json({
1078
+ error: 'a previous correction on this meeting never completed',
1079
+ reason: 'correction_pending',
1080
+ pending: stalled.map(r => ({ id: r.id, at: r.at, from: r.from, to: r.to })),
1081
+ })
1082
+ return
1083
+ }
1084
+
1085
+ const id = `${sessionId}:${from}>deattributed:${Date.now().toString(36)}`
1086
+ const at = new Date().toISOString()
1087
+ if (!appendCorrection(sessionId, {
1088
+ id, phase: 'intent', at, from, to: DEATTRIBUTED_LABEL, chunks: plan.value.changed, scope: 'meeting',
1089
+ })) {
1090
+ res.status(500).json({
1091
+ error: 'could not record the de-attribution, so nothing was changed',
1092
+ reason: 'ledger_unwritable',
1093
+ })
1094
+ return
1095
+ }
1096
+
1097
+ let retracted = 0
1098
+ try {
1099
+ durableAtomicWriteFileSync(sidecarPath, plan.value.json, { mode: 0o600 })
1100
+ if (md && (md.attendees > 0 || md.transcript > 0)) {
1101
+ durableAtomicWriteFileSync(meetingPath, md.markdown, { mode: 0o600 })
1102
+ }
1103
+ // Profile retraction happens AFTER the transcript is corrected: if the
1104
+ // write fails, the ledger shows an unclosed intent and the profile has not
1105
+ // yet been touched, which is the recoverable order.
1106
+ if (retractTraining && traceable > 0) {
1107
+ retracted = retractEmbeddingsBySource(from, s => isSampleFromSession(s, sessionId)).removed
1108
+ }
1109
+ } catch (err: unknown) {
1110
+ const message = err instanceof Error ? err.message : String(err)
1111
+ appendCorrection(sessionId, {
1112
+ id, phase: 'failed', at: new Date().toISOString(), from, to: DEATTRIBUTED_LABEL,
1113
+ chunks: plan.value.changed, scope: 'meeting', error: message,
1114
+ })
1115
+ res.status(500).json({ error: `de-attribution failed: ${message}`, reason: 'write_failed' })
1116
+ return
1117
+ }
1118
+
1119
+ appendCorrection(sessionId, {
1120
+ id, phase: 'applied', at: new Date().toISOString(), from, to: DEATTRIBUTED_LABEL,
1121
+ chunks: plan.value.changed, scope: 'meeting', surfaces, proseStale: preview.proseStale,
1122
+ })
1123
+
1124
+ res.json({
1125
+ ok: true,
1126
+ correctionId: id,
1127
+ ...preview,
1128
+ training: { ...preview.training, retracted },
1129
+ })
1130
+ })
1131
+
1132
+
1133
+ // ── Review playback (6.21.18) ─────────────────────────────────────────
1134
+ //
1135
+ // Miles: "I can quickly play that, and I'm going to hear the voice and know
1136
+ // immediately who the speaker is. As a final confirmation."
1137
+ //
1138
+ // A phrase in the panel is a weaker signal than three seconds of the actual
1139
+ // voice. Retention is 7 days (see meeting-audio-archive), so this answers with
1140
+ // 404 + a reason once the window has passed rather than pretending the audio
1141
+ // was never there.
1142
+ router.get('/meeting/:sessionId/audio/:chunkIndex', (req, res) => {
1143
+ res.set('Cache-Control', 'private, no-store')
1144
+ const sessionId = String(req.params.sessionId ?? '')
1145
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1146
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1147
+ return
1148
+ }
1149
+ const chunkIndex = Number(req.params.chunkIndex)
1150
+ if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
1151
+ res.status(400).json({ error: 'Invalid chunkIndex', reason: 'invalid_chunk_index' })
1152
+ return
1153
+ }
1154
+ const path = meetingAudioChunkPath(sessionId, chunkIndex)
1155
+ if (!path) {
1156
+ const retained = listMeetingAudioChunks(sessionId)
1157
+ res.status(404).json({
1158
+ error: retained.length === 0
1159
+ ? 'No audio retained for this meeting'
1160
+ : `Chunk ${chunkIndex} is not retained`,
1161
+ reason: retained.length === 0 ? 'audio_not_retained' : 'chunk_not_retained',
1162
+ // Which chunks CAN be played, so a UI can offer the nearest instead of
1163
+ // just failing.
1164
+ retainedChunks: retained.length,
1165
+ firstRetained: retained[0] ?? null,
1166
+ lastRetained: retained[retained.length - 1] ?? null,
1167
+ })
1168
+ return
1169
+ }
1170
+ res.type('audio/wav')
1171
+ res.sendFile(path)
1172
+ })
1173
+
1174
+ /** What audio a meeting still has, so the panel can show play buttons only
1175
+ * where they will work. */
1176
+ router.get('/meeting/:sessionId/audio', (req, res) => {
1177
+ res.set('Cache-Control', 'private, no-store')
1178
+ const sessionId = String(req.params.sessionId ?? '')
1179
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1180
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1181
+ return
1182
+ }
1183
+ const chunks = listMeetingAudioChunks(sessionId)
1184
+ res.json({
1185
+ sessionId,
1186
+ retained: chunks.length > 0,
1187
+ chunks,
1188
+ // Config read, not a filesystem walk: this route used to stat every
1189
+ // retained chunk to report one number.
1190
+ retentionDays: meetingAudioRetentionDays(),
1191
+ })
1192
+ })
1193
+
866
1194
  // ── Unsaved-capture recovery (6.19.0) ─────────────────────────────────
867
1195
  // Surface-only by decision (Miles, 2026-08-02): the server NEVER drives
868
1196
  // recovery on its own. It lists what the quarantine holds, and one
@@ -42,6 +42,7 @@ import {
42
42
  appendChunkEmbedding,
43
43
  sweepExpiredChunkEmbeddings,
44
44
  } from '../lib/chunk-embedding-store.js'
45
+ import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
45
46
  import {
46
47
  countChunkWavs,
47
48
  purgeExpiredQuarantine,
@@ -754,6 +755,22 @@ setInterval(() => {
754
755
  }
755
756
  } catch {}
756
757
 
758
+ // Meeting audio for review: 7-day window, then an 8 GB budget as a backstop.
759
+ // Order matters — sweep expiry FIRST so the cap only ever evicts audio still
760
+ // inside its window instead of racing the sweeper for the same files.
761
+ try {
762
+ const { swept, capped } = runMeetingAudioRetention()
763
+ if (swept.removed.length > 0) {
764
+ console.log(`[meeting-audio] Retention swept ${swept.removed.length} session(s), freed ${(swept.bytesFreed / 1e6).toFixed(1)} MB`)
765
+ }
766
+ if (capped.evicted.length > 0) {
767
+ console.warn(
768
+ `[meeting-audio] Over budget — evicted ${capped.evicted.length} oldest session(s): `
769
+ + `${(capped.bytesBefore / 1e9).toFixed(2)} GB -> ${(capped.bytesAfter / 1e9).toFixed(2)} GB`,
770
+ )
771
+ }
772
+ } catch { /* non-critical */ }
773
+
757
774
  // Purge training-audio WAVs past the retention window. Per file, not per
758
775
  // directory: chunks for one speaker accumulate over weeks, so an
759
776
  // all-or-nothing directory check would either keep month-old audio alive
@@ -1068,6 +1085,20 @@ export function moveSessionAudioToPending(sessionId: string): string | null {
1068
1085
  destDir = resolve(PENDING_BATCH_DIR, `${sessionId}_${suffix}`)
1069
1086
  }
1070
1087
  }
1088
+ // Retain a reviewable copy BEFORE the rename. Hard links, so this costs no
1089
+ // extra disk and the audio outlives the batch pipeline's own cleanup — which
1090
+ // is what used to end the audio's life entirely (session-audio held 0 files
1091
+ // on 2026-08-06, so the review panel could never play anything back).
1092
+ try {
1093
+ const archived = archiveSessionAudio(sessionId, srcDir)
1094
+ if (archived.linked + archived.copied > 0) {
1095
+ console.log(
1096
+ `[meeting-audio] Retained ${archived.linked + archived.copied} chunk(s) for review: ${sessionId}`
1097
+ + (archived.copied ? ` (${archived.copied} copied, link unavailable)` : '')
1098
+ + (archived.failed ? ` — ${archived.failed} failed` : ''),
1099
+ )
1100
+ }
1101
+ } catch { /* review audio is never worth failing a save for */ }
1071
1102
  // Rename is atomic on same filesystem
1072
1103
  renameSync(srcDir, destDir)
1073
1104
  try { chmodSync(destDir, 0o700) } catch {}
@@ -10,6 +10,7 @@ import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js
10
10
  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
+ import { trainingSourceFor } from '../lib/training-audio-provenance.js'
13
14
 
14
15
  // These MUST match the writer in transcribe-stream.ts, which saves under
15
16
  // dataPath(). They previously resolved relative to __dirname — i.e. inside the
@@ -192,10 +193,14 @@ voiceRouter.post('/voice/train-g2', async (req, res) => {
192
193
 
193
194
  // Extract all embeddings, select most diverse
194
195
  const embeddings: Float32Array[] = []
196
+ // Parallel to `embeddings`: the WAV each one came from, so the enrollment
197
+ // below can stamp WHICH MEETING produced the sample. Without it a later
198
+ // de-attribution cannot retract this meeting's contribution to the profile.
199
+ const embeddingFiles: string[] = []
195
200
  for (const wav of wavFiles) {
196
201
  const buffer = readFileSync(resolve(speakerPath, wav))
197
202
  const emb = extractEmbedding(buffer)
198
- if (emb) embeddings.push(emb)
203
+ if (emb) { embeddings.push(emb); embeddingFiles.push(wav) }
199
204
  }
200
205
 
201
206
  if (embeddings.length === 0) {
@@ -220,7 +225,13 @@ voiceRouter.post('/voice/train-g2', async (req, res) => {
220
225
  // Enroll the diverse subset (enrollEmbedding handles dedup gate + FIFO cap)
221
226
  let enrolled = 0
222
227
  for (const emb of selected) {
223
- const result = enrollEmbedding(speakerName, emb, 'g2-training')
228
+ // Reference identity, NOT a re-selection: greedyDiversitySelect returns
229
+ // the very same Float32Array objects, so indexOf recovers the filename
230
+ // without changing which samples were chosen. Swapping the selector for
231
+ // an index-returning one would silently alter that choice.
232
+ const at = embeddings.indexOf(emb)
233
+ const source = at >= 0 ? trainingSourceFor(embeddingFiles[at]) : 'g2-training'
234
+ const result = enrollEmbedding(speakerName, emb, source)
224
235
  if (result.success) enrolled++
225
236
  }
226
237
 
@@ -407,6 +418,84 @@ voiceRouter.post('/voice/enroll-ext', async (req, res) => {
407
418
  }
408
419
  })
409
420
 
421
+ // ── Voice sample playback (6.21.18) ───────────────────────────────────────
422
+ //
423
+ // Miles: hearing three seconds of a voice settles an identity question that a
424
+ // similarity score cannot. These two paths need NO retention change — the audio
425
+ // already exists:
426
+ //
427
+ // training-audio what the system thinks a NAMED person sounds like
428
+ // ext-audio an UNIDENTIFIED voice, 72-hour window
429
+ //
430
+ // The first is the higher-value one for the review panel: "is this really Navaz?"
431
+ // is answered by playing Navaz's own profile sample, not by playing the segment
432
+ // under review.
433
+
434
+ /** Newest WAV in a directory — the most representative recent sample. */
435
+ function newestWav(dirPath: string): string | null {
436
+ try {
437
+ const wavs = readdirSync(dirPath).filter(f => f.endsWith('.wav'))
438
+ if (wavs.length === 0) return null
439
+ let best = wavs[0], bestAt = 0
440
+ for (const w of wavs) {
441
+ try {
442
+ const at = statSync(resolve(dirPath, w)).mtimeMs
443
+ if (at >= bestAt) { bestAt = at; best = w }
444
+ } catch { /* skip unreadable */ }
445
+ }
446
+ return resolve(dirPath, best)
447
+ } catch {
448
+ return null
449
+ }
450
+ }
451
+
452
+ // GET /api/voice/profiles/:name/sample — hear what a stored profile sounds like.
453
+ voiceRouter.get('/voice/profiles/:name/sample', (req, res) => {
454
+ res.set('Cache-Control', 'private, no-store')
455
+ const name = String(req.params.name ?? '')
456
+ const dirPath = speakerDirPath(AUDIO_SAVE_DIR, name)
457
+ if (!dirPath) {
458
+ res.status(400).json({ error: 'Invalid speaker name', reason: 'invalid_speaker' })
459
+ return
460
+ }
461
+ const wav = existsSync(dirPath) ? newestWav(dirPath) : null
462
+ if (!wav) {
463
+ // A profile can exist with no retained audio: it may have been built from
464
+ // Fireflies seeds, or its training audio may have aged out. Say which rather
465
+ // than implying the person is unknown.
466
+ res.status(404).json({
467
+ error: `No retained audio for "${name}"`,
468
+ reason: 'no_sample_audio',
469
+ enrolled: isEnrolled(name),
470
+ embeddings: getEmbeddingCount(name),
471
+ })
472
+ return
473
+ }
474
+ res.type('audio/wav')
475
+ res.sendFile(wav)
476
+ })
477
+
478
+ // GET /api/voice/ext-audio/:sessionId/sample — hear an unidentified voice.
479
+ voiceRouter.get('/voice/ext-audio/:sessionId/sample', (req, res) => {
480
+ res.set('Cache-Control', 'private, no-store')
481
+ const sessionId = String(req.params.sessionId ?? '')
482
+ const dirPath = speakerDirPath(EXT_AUDIO_DIR, sessionId)
483
+ if (!dirPath) {
484
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
485
+ return
486
+ }
487
+ const wav = existsSync(dirPath) ? newestWav(dirPath) : null
488
+ if (!wav) {
489
+ res.status(404).json({
490
+ error: 'No ext-audio retained for this session',
491
+ reason: 'no_ext_audio',
492
+ })
493
+ return
494
+ }
495
+ res.type('audio/wav')
496
+ res.sendFile(wav)
497
+ })
498
+
410
499
  // GET /api/voice/profiles — enrolled people with sample counts and provenance.
411
500
  // The review surfaces need to see the store; until now the only window into it
412
501
  // was a per-name count, so a misattributed profile was invisible.