@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.
- package/CHANGELOG.md +76 -0
- package/package.json +1 -1
- package/server/lib/meeting-audio-archive.ts +314 -0
- package/server/lib/meeting-relabel.ts +14 -2
- package/server/lib/meeting-speaker-review.ts +223 -6
- package/server/lib/training-audio-provenance.ts +70 -0
- package/server/routes/health.ts +3 -0
- package/server/routes/meeting.ts +330 -2
- package/server/routes/transcribe-stream.ts +31 -0
- package/server/routes/voice.ts +91 -2
package/server/routes/meeting.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 {}
|
package/server/routes/voice.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|