@gotcos/glasses-server 6.21.17 → 6.21.19

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,39 @@ 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
+ extAudioChunkPath,
15
+ listExtAudioChunks,
16
+ listMeetingAudioChunks,
17
+ meetingAudioChunkPath,
18
+ meetingAudioRetentionDays,
19
+ } from '../lib/meeting-audio-archive.js'
20
+ import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
21
+
22
+ /**
23
+ * The label a de-attributed voice takes, numbered within its meeting.
24
+ *
25
+ * NOT a shared `Ext`. De-attributing to one label folded every corrected voice
26
+ * into a single row — on the 2026-08-06 Ditto meeting Miles named five wrong
27
+ * attributions, and collapsing them would have destroyed his ability to tell
28
+ * those five voices apart, which is precisely what he needs playback for next.
29
+ *
30
+ * `Unidentified N` is prefix-matched by isUnattributed(), so the review panel
31
+ * treats the row as unnamed and autoEnroll skips it — a de-attributed stretch
32
+ * cannot re-poison a profile — while staying separable.
33
+ */
34
+ function nextDeattributedLabel(existing: string[]): string {
35
+ const used = new Set(
36
+ existing
37
+ .map(s => new RegExp(`^${DEATTRIBUTED_PREFIX} (\\d+)$`).exec(s)?.[1])
38
+ .filter((n): n is string => Boolean(n))
39
+ .map(Number),
40
+ )
41
+ let n = 1
42
+ while (used.has(n)) n++
43
+ return `${DEATTRIBUTED_PREFIX} ${n}`
44
+ }
12
45
  import {
13
46
  invalidLabelReason,
14
47
  relabelMeetingMarkdown,
@@ -76,7 +109,13 @@ import {
76
109
  findCosOperationsMeetingBySessionId,
77
110
  } from '../lib/cos-operations-meetings.js'
78
111
  import { getOwnerSpeakerLabel } from '../lib/profile.js'
79
- import { reviewMeetingSpeakers, type ReviewChunk } from '../lib/meeting-speaker-review.js'
112
+ import {
113
+ DEATTRIBUTED_PREFIX,
114
+ attachRawChunkIndices,
115
+ isUnattributed,
116
+ reviewMeetingSpeakers,
117
+ type ReviewChunk,
118
+ } from '../lib/meeting-speaker-review.js'
80
119
  import {
81
120
  acquireMaintenanceWork,
82
121
  maintenanceAdmissionsOpen,
@@ -676,9 +715,18 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
676
715
  return
677
716
  }
678
717
 
679
- const review = reviewMeetingSpeakers(chunks as ReviewChunk[], {
718
+ // Raw capture indices, so a phrase can address its own audio. Position in
719
+ // `chunks` is NOT the WAV number — see attachRawChunkIndices.
720
+ const sidecar = (JSON.parse(readFileSync(sidecarPath, 'utf-8')) ?? {}) as Record<string, unknown>
721
+ const withIndices = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
722
+ const review = reviewMeetingSpeakers(withIndices, {
680
723
  owner: getOwnerSpeakerLabel(),
681
724
  phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
725
+ // The sidecar's own durationMs is the meeting's true end. Deriving it from
726
+ // max(elapsed) uses the START of the last chunk, which made the final
727
+ // timeline span end where it began — a 1.5pt sliver labelled "1s" for what
728
+ // may be a long closing monologue.
729
+ durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
682
730
  })
683
731
  res.set('Cache-Control', 'private, no-store')
684
732
  res.json({
@@ -863,6 +911,303 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
863
911
  res.json({ ok: true, correctionId: id, ...preview })
864
912
  })
865
913
 
914
+
915
+ // ── De-attribution (6.21.18) ──────────────────────────────────────────
916
+ //
917
+ // The inverse of naming an unknown voice: this voice was NOT that person.
918
+ //
919
+ // Miles, on the 2026-08-06 Ditto meeting: none of the eleven attributed
920
+ // voices were actually in the room, and there was no way to say so. Naming an
921
+ // unknown was possible; un-naming a wrong guess was not.
922
+ //
923
+ // It undoes MORE than a label. When a voice is falsely attributed the
924
+ // identifier may also have auto-enrolled those segments into that person's
925
+ // profile, so the wrong voice is now part of what the system thinks they sound
926
+ // like and will keep matching. Removing only the label fixes the transcript and
927
+ // leaves the profile poisoned — the exact mechanism that grew the phantom
928
+ // "Erick Hernandez" from 3 mislabelled seeds to 18 samples.
929
+ router.post('/meeting/:sessionId/deattribute', (req, res) => {
930
+ res.set('Cache-Control', 'private, no-store')
931
+ const sessionId = String(req.params.sessionId ?? '')
932
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
933
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
934
+ return
935
+ }
936
+ const from = typeof req.body?.from === 'string' ? req.body.from : ''
937
+ const bad = invalidLabelReason(from)
938
+ if (bad) {
939
+ res.status(400).json({ error: `from: ${bad}`, reason: 'invalid_label' })
940
+ return
941
+ }
942
+ if (isUnattributed(from)) {
943
+ res.status(400).json({ error: `"${from}" is already unattributed`, reason: 'already_unattributed' })
944
+ return
945
+ }
946
+ const chunks = Array.isArray(req.body?.chunks)
947
+ ? (req.body.chunks as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0)
948
+ : []
949
+ if (Array.isArray(req.body?.chunks) && chunks.length !== req.body.chunks.length) {
950
+ res.status(400).json({ error: 'chunks must be non-negative integers', reason: 'invalid_chunks' })
951
+ return
952
+ }
953
+ // Retracting the training samples is the part that improves accuracy over
954
+ // time, so it defaults ON. Opt out to fix a transcript without touching the
955
+ // profile.
956
+ const retractTraining = req.body?.retractTraining !== false
957
+
958
+ const operations = cosOperationsMeetingsConfigured()
959
+ ? findCosOperationsMeetingBySessionId(sessionId)
960
+ : null
961
+ const saved = operations ? null : store.findBySessionId(sessionId)
962
+ if (!operations && !saved) {
963
+ res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
964
+ return
965
+ }
966
+ const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
967
+ const meetingPath = operations?.meetingPath ?? saved!.filepath
968
+ const title = operations?.title ?? saved!.title
969
+
970
+ let sidecarRaw: string
971
+ try {
972
+ sidecarRaw = readFileSync(sidecarPath, 'utf-8')
973
+ } catch {
974
+ res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
975
+ return
976
+ }
977
+
978
+ // A NUMBERED unidentified label, unique within this meeting, so removing
979
+ // several wrong names does not merge those voices together.
980
+ let existingSpeakers: string[] = []
981
+ try {
982
+ const parsed = JSON.parse(sidecarRaw) as { speakers?: unknown; chunks?: unknown }
983
+ existingSpeakers = Array.isArray(parsed.speakers)
984
+ ? parsed.speakers.filter((x): x is string => typeof x === 'string')
985
+ : []
986
+ // Also scan the chunks: a previous de-attribution may have left a label
987
+ // that never made it into `speakers`.
988
+ if (Array.isArray(parsed.chunks)) {
989
+ for (const c of parsed.chunks) {
990
+ const sp = (c as { speaker?: unknown } | null)?.speaker
991
+ if (typeof sp === 'string' && !existingSpeakers.includes(sp)) existingSpeakers.push(sp)
992
+ }
993
+ }
994
+ } catch { /* the relabel below reports a corrupt sidecar */ }
995
+ const DEATTRIBUTED_LABEL = nextDeattributedLabel(existingSpeakers)
996
+ const plan = relabelSidecarJson(sidecarRaw, from, DEATTRIBUTED_LABEL, chunks)
997
+ if (!plan.ok) {
998
+ res.status(422).json({ error: plan.error, reason: 'deattribute_rejected' })
999
+ return
1000
+ }
1001
+
1002
+ // What this would remove from the profile, and what it CANNOT reach.
1003
+ // readVoiceProfiles returns a ProfileStore, not an array.
1004
+ const profile = readVoiceProfiles().profiles.find(p => p.name === from)
1005
+ const profileSources: Array<string | undefined> = profile
1006
+ ? Array.from({ length: profile.embeddings.length }, (_, i) => profile.sources?.[i])
1007
+ : []
1008
+ const traceable = profileSources.filter(sourceString => isSampleFromSession(sourceString, sessionId)).length
1009
+ const untraceable = untraceableSampleCount(profileSources)
1010
+
1011
+ let markdownPlan: ReturnType<typeof relabelMeetingMarkdown> | null = null
1012
+ let markdownUnreadable = false
1013
+ if (plan.value.coveredAllWithLabel) {
1014
+ try {
1015
+ markdownPlan = relabelMeetingMarkdown(
1016
+ readFileSync(meetingPath, 'utf-8'), from, DEATTRIBUTED_LABEL,
1017
+ // Remove the attendee bullet outright: renaming it would write
1018
+ // `- Unidentified 2` into the list as though it were a person.
1019
+ { removeAttendee: true },
1020
+ )
1021
+ } catch { markdownPlan = null; markdownUnreadable = true }
1022
+ }
1023
+ const md = markdownPlan?.ok ? markdownPlan.value : null
1024
+
1025
+ const surfaces = {
1026
+ sidecar: plan.value.changed.length,
1027
+ attendees: md?.attendees ?? 0,
1028
+ transcript: md?.transcript ?? 0,
1029
+ }
1030
+ const preview = {
1031
+ sessionId,
1032
+ title,
1033
+ from,
1034
+ to: DEATTRIBUTED_LABEL,
1035
+ // 39% of operations sidecars have no .md beside them (the document was
1036
+ // archived). Without this the response reports sidecar changes and zero
1037
+ // markdown changes with no hint the document was never touched.
1038
+ markdownSkipped: !plan.value.coveredAllWithLabel
1039
+ ? 'partial de-attribution: transcript turns cannot be mapped to chunk indices'
1040
+ : markdownUnreadable ? 'meeting markdown unreadable' : null,
1041
+ scope: 'meeting' as const,
1042
+ chunks: plan.value.changed,
1043
+ surfaces,
1044
+ partial: !plan.value.coveredAllWithLabel,
1045
+ speakersAfter: plan.value.speakers,
1046
+ training: {
1047
+ retract: retractTraining,
1048
+ wouldRetract: retractTraining ? traceable : 0,
1049
+ profileSamples: profileSources.length,
1050
+ // Honest about reach: samples written before train-g2 started stamping
1051
+ // the session cannot be tied to a meeting and survive this.
1052
+ untraceable,
1053
+ },
1054
+ proseStale: md?.proseStale ?? false,
1055
+ proseHits: md?.proseHits ?? [],
1056
+ }
1057
+
1058
+ if (req.body?.dryRun === true || req.body?.confirm !== true) {
1059
+ const notes: string[] = []
1060
+ if (retractTraining && traceable > 0) {
1061
+ notes.push(`${traceable} training sample(s) from this meeting will be removed from "${from}"`)
1062
+ }
1063
+ if (retractTraining && traceable === 0) {
1064
+ notes.push(`no training sample from this meeting is traceable to "${from}", so the profile is unchanged`)
1065
+ }
1066
+ if (untraceable > 0) {
1067
+ notes.push(`${untraceable} older sample(s) on "${from}" carry no meeting provenance and cannot be retracted`)
1068
+ }
1069
+ res.status(req.body?.dryRun === true ? 200 : 400).json({
1070
+ ...(req.body?.dryRun === true ? {} : { error: 'confirmation required', reason: 'confirmation_required' }),
1071
+ message: `Removing "${from}" from ${surfaces.sidecar} segment(s) of this meeting. ${notes.join('. ')}`.trim(),
1072
+ ...preview,
1073
+ })
1074
+ return
1075
+ }
1076
+
1077
+ const stalled = pendingCorrections(sessionId)
1078
+ if (stalled.length > 0 && req.body?.force !== true) {
1079
+ res.status(409).json({
1080
+ error: 'a previous correction on this meeting never completed',
1081
+ reason: 'correction_pending',
1082
+ pending: stalled.map(r => ({ id: r.id, at: r.at, from: r.from, to: r.to })),
1083
+ })
1084
+ return
1085
+ }
1086
+
1087
+ const id = `${sessionId}:${from}>deattributed:${Date.now().toString(36)}`
1088
+ const at = new Date().toISOString()
1089
+ if (!appendCorrection(sessionId, {
1090
+ id, phase: 'intent', at, from, to: DEATTRIBUTED_LABEL, chunks: plan.value.changed, scope: 'meeting',
1091
+ })) {
1092
+ res.status(500).json({
1093
+ error: 'could not record the de-attribution, so nothing was changed',
1094
+ reason: 'ledger_unwritable',
1095
+ })
1096
+ return
1097
+ }
1098
+
1099
+ let retracted = 0
1100
+ try {
1101
+ durableAtomicWriteFileSync(sidecarPath, plan.value.json, { mode: 0o600 })
1102
+ if (md && (md.attendees > 0 || md.transcript > 0)) {
1103
+ durableAtomicWriteFileSync(meetingPath, md.markdown, { mode: 0o600 })
1104
+ }
1105
+ // Profile retraction happens AFTER the transcript is corrected: if the
1106
+ // write fails, the ledger shows an unclosed intent and the profile has not
1107
+ // yet been touched, which is the recoverable order.
1108
+ if (retractTraining && traceable > 0) {
1109
+ retracted = retractEmbeddingsBySource(from, s => isSampleFromSession(s, sessionId)).removed
1110
+ }
1111
+ } catch (err: unknown) {
1112
+ const message = err instanceof Error ? err.message : String(err)
1113
+ appendCorrection(sessionId, {
1114
+ id, phase: 'failed', at: new Date().toISOString(), from, to: DEATTRIBUTED_LABEL,
1115
+ chunks: plan.value.changed, scope: 'meeting', error: message,
1116
+ })
1117
+ res.status(500).json({ error: `de-attribution failed: ${message}`, reason: 'write_failed' })
1118
+ return
1119
+ }
1120
+
1121
+ appendCorrection(sessionId, {
1122
+ id, phase: 'applied', at: new Date().toISOString(), from, to: DEATTRIBUTED_LABEL,
1123
+ chunks: plan.value.changed, scope: 'meeting', surfaces, proseStale: preview.proseStale,
1124
+ })
1125
+
1126
+ res.json({
1127
+ ok: true,
1128
+ correctionId: id,
1129
+ ...preview,
1130
+ training: { ...preview.training, retracted },
1131
+ })
1132
+ })
1133
+
1134
+
1135
+ // ── Review playback (6.21.18) ─────────────────────────────────────────
1136
+ //
1137
+ // Miles: "I can quickly play that, and I'm going to hear the voice and know
1138
+ // immediately who the speaker is. As a final confirmation."
1139
+ //
1140
+ // A phrase in the panel is a weaker signal than three seconds of the actual
1141
+ // voice. Retention is 7 days (see meeting-audio-archive), so this answers with
1142
+ // 404 + a reason once the window has passed rather than pretending the audio
1143
+ // was never there.
1144
+ router.get('/meeting/:sessionId/audio/:chunkIndex', (req, res) => {
1145
+ res.set('Cache-Control', 'private, no-store')
1146
+ const sessionId = String(req.params.sessionId ?? '')
1147
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1148
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1149
+ return
1150
+ }
1151
+ const chunkIndex = Number(req.params.chunkIndex)
1152
+ if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
1153
+ res.status(400).json({ error: 'Invalid chunkIndex', reason: 'invalid_chunk_index' })
1154
+ return
1155
+ }
1156
+ // Archive first, then the live ext-audio the capture path already saved for
1157
+ // unrecognised speakers. The archive is forward-only, so without this
1158
+ // fallback there is nothing to play on any meeting predating 6.21.18 — while
1159
+ // 72 hours of unidentified-voice audio is sitting right there, and an
1160
+ // unidentified voice is exactly what a reviewer needs to hear.
1161
+ const path = meetingAudioChunkPath(sessionId, chunkIndex)
1162
+ ?? extAudioChunkPath(sessionId, chunkIndex)
1163
+ if (!path) {
1164
+ const retained = [...new Set([
1165
+ ...listMeetingAudioChunks(sessionId),
1166
+ ...listExtAudioChunks(sessionId),
1167
+ ])].sort((a, b) => a - b)
1168
+ res.status(404).json({
1169
+ error: retained.length === 0
1170
+ ? 'No audio retained for this meeting'
1171
+ : `Chunk ${chunkIndex} is not retained`,
1172
+ reason: retained.length === 0 ? 'audio_not_retained' : 'chunk_not_retained',
1173
+ // Which chunks CAN be played, so a UI can offer the nearest instead of
1174
+ // just failing.
1175
+ retainedChunks: retained.length,
1176
+ firstRetained: retained[0] ?? null,
1177
+ lastRetained: retained[retained.length - 1] ?? null,
1178
+ })
1179
+ return
1180
+ }
1181
+ res.type('audio/wav')
1182
+ res.sendFile(path)
1183
+ })
1184
+
1185
+ /** What audio a meeting still has, so the panel can show play buttons only
1186
+ * where they will work. */
1187
+ router.get('/meeting/:sessionId/audio', (req, res) => {
1188
+ res.set('Cache-Control', 'private, no-store')
1189
+ const sessionId = String(req.params.sessionId ?? '')
1190
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1191
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1192
+ return
1193
+ }
1194
+ const archived = listMeetingAudioChunks(sessionId)
1195
+ const ext = listExtAudioChunks(sessionId)
1196
+ const chunks = [...new Set([...archived, ...ext])].sort((a, b) => a - b)
1197
+ res.json({
1198
+ sessionId,
1199
+ retained: chunks.length > 0,
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,
1205
+ // Config read, not a filesystem walk: this route used to stat every
1206
+ // retained chunk to report one number.
1207
+ retentionDays: meetingAudioRetentionDays(),
1208
+ })
1209
+ })
1210
+
866
1211
  // ── Unsaved-capture recovery (6.19.0) ─────────────────────────────────
867
1212
  // Surface-only by decision (Miles, 2026-08-02): the server NEVER drives
868
1213
  // 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.