@gotcos/glasses-server 6.21.9 → 6.21.13
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 +77 -0
- package/package.json +1 -1
- package/server/lib/audio-retention.ts +50 -0
- package/server/lib/meeting-speaker-review.ts +268 -0
- package/server/lib/meeting-store.ts +57 -2
- package/server/lib/speaker-calibration-log.ts +146 -0
- package/server/lib/speaker-embeddings.ts +266 -36
- package/server/lib/voice-profile-store.ts +507 -0
- package/server/routes/health.ts +11 -3
- package/server/routes/meeting.ts +56 -1
- package/server/routes/transcribe-stream.ts +46 -0
- package/server/routes/voice.ts +347 -22
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from '../lib/hallucination-filter.js'
|
|
38
38
|
import { transcribeWhisperMeetingPreview } from '../lib/whisper-preview.js'
|
|
39
39
|
import { dataPath } from '../lib/data-dir.js'
|
|
40
|
+
import { ageHours, partitionExpiredAudio } from '../lib/audio-retention.js'
|
|
40
41
|
import {
|
|
41
42
|
countChunkWavs,
|
|
42
43
|
purgeExpiredQuarantine,
|
|
@@ -118,6 +119,13 @@ function meetingTurboPreviewEnabled(): boolean {
|
|
|
118
119
|
const AUDIO_SAVE_DIR = dataPath('training-audio')
|
|
119
120
|
ensurePrivateDirectory(AUDIO_SAVE_DIR)
|
|
120
121
|
const MAX_SAVED_CHUNKS_PER_SPEAKER = 30 // ~5 min of audio per speaker, cleaned after training
|
|
122
|
+
// Age bound. The count cap above is NOT a retention policy: a speaker who never
|
|
123
|
+
// gets trained keeps 30 WAVs of their voice indefinitely, and the only cleanup
|
|
124
|
+
// path was a manual /voice/train-g2 call. ext-audio has had a 72h sweep since it
|
|
125
|
+
// was introduced; this closes the same gap for training audio. Deliberately
|
|
126
|
+
// longer than ext-audio's window because these chunks are the raw material for
|
|
127
|
+
// deliberate enrollment, not opportunistic retroactive matching.
|
|
128
|
+
const TRAINING_AUDIO_TTL_MS = 14 * 24 * 60 * 60 * 1000 // 14 days
|
|
121
129
|
|
|
122
130
|
// Unrecognized speaker audio: save Ext chunks for retroactive enrollment
|
|
123
131
|
const EXT_AUDIO_DIR = dataPath('ext-audio')
|
|
@@ -742,6 +750,44 @@ setInterval(() => {
|
|
|
742
750
|
}
|
|
743
751
|
} catch {}
|
|
744
752
|
|
|
753
|
+
// Purge training-audio WAVs past the retention window. Per file, not per
|
|
754
|
+
// directory: chunks for one speaker accumulate over weeks, so an
|
|
755
|
+
// all-or-nothing directory check would either keep month-old audio alive
|
|
756
|
+
// because one chunk is fresh, or delete today's capture because the directory
|
|
757
|
+
// is old.
|
|
758
|
+
try {
|
|
759
|
+
if (existsSync(AUDIO_SAVE_DIR)) {
|
|
760
|
+
const now = Date.now()
|
|
761
|
+
for (const dir of readdirSync(AUDIO_SAVE_DIR)) {
|
|
762
|
+
const dirPath = resolve(AUDIO_SAVE_DIR, dir)
|
|
763
|
+
try {
|
|
764
|
+
if (!statSync(dirPath).isDirectory()) continue
|
|
765
|
+
const candidates = readdirSync(dirPath)
|
|
766
|
+
.filter(f => f.endsWith('.wav'))
|
|
767
|
+
.map(name => {
|
|
768
|
+
let mtimeMs = 0
|
|
769
|
+
try { mtimeMs = statSync(resolve(dirPath, name)).mtimeMs } catch {}
|
|
770
|
+
return { name, mtimeMs }
|
|
771
|
+
})
|
|
772
|
+
const { expired, retained } = partitionExpiredAudio(candidates, now, TRAINING_AUDIO_TTL_MS)
|
|
773
|
+
for (const file of expired) {
|
|
774
|
+
try { unlinkSync(resolve(dirPath, file.name)) } catch {}
|
|
775
|
+
}
|
|
776
|
+
if (expired.length > 0) {
|
|
777
|
+
const oldest = Math.min(...expired.map(f => f.mtimeMs))
|
|
778
|
+
console.log(
|
|
779
|
+
`[training-audio] Purged ${expired.length} expired chunk(s) for ${dir}`,
|
|
780
|
+
`(oldest ${ageHours(oldest, now)}h, ${retained.length} retained)`,
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
if (retained.length === 0 && readdirSync(dirPath).length === 0) {
|
|
784
|
+
try { rmSync(dirPath, { recursive: true, force: true }) } catch {}
|
|
785
|
+
}
|
|
786
|
+
} catch {}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
} catch {}
|
|
790
|
+
|
|
745
791
|
// Purge ext-audio dirs older than 72 hours
|
|
746
792
|
try {
|
|
747
793
|
if (existsSync(EXT_AUDIO_DIR)) {
|
package/server/routes/voice.ts
CHANGED
|
@@ -4,15 +4,33 @@ import { Router } from 'express'
|
|
|
4
4
|
import { errMsg } from '../lib/utils.js'
|
|
5
5
|
import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
|
|
6
6
|
import { resolve } from 'node:path'
|
|
7
|
-
import {
|
|
8
|
-
import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount } from '../lib/speaker-embeddings.js'
|
|
7
|
+
import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
|
|
9
8
|
import { statSync } from 'node:fs'
|
|
10
9
|
import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
|
|
11
10
|
import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
import { dataPath } from '../lib/data-dir.js'
|
|
12
|
+
import { purgeSpeakerCalibrationRows, relabelSpeakerCalibrationRows } from '../lib/speaker-calibration-log.js'
|
|
13
|
+
|
|
14
|
+
// These MUST match the writer in transcribe-stream.ts, which saves under
|
|
15
|
+
// dataPath(). They previously resolved relative to __dirname — i.e. inside the
|
|
16
|
+
// installed package generation, a directory the writer never touches and that
|
|
17
|
+
// every managed update replaces. Every reader below therefore reported zero
|
|
18
|
+
// speakers and zero sessions while real audio accumulated in the data home.
|
|
19
|
+
const AUDIO_SAVE_DIR = dataPath('training-audio')
|
|
20
|
+
const EXT_AUDIO_DIR = dataPath('ext-audio')
|
|
21
|
+
// Must match speaker-embeddings.ts, which appends every identification decision.
|
|
22
|
+
const CALIBRATION_LOG = dataPath('speaker-calibration.jsonl')
|
|
23
|
+
|
|
24
|
+
/** A speaker directory name is derived from a label by replacing spaces with
|
|
25
|
+
* underscores. Resolve back through basename so a crafted `speaker` value
|
|
26
|
+
* cannot escape the audio root. */
|
|
27
|
+
function speakerDirPath(root: string, speakerName: string): string | null {
|
|
28
|
+
const dirName = speakerName.trim().replace(/\s+/g, '_')
|
|
29
|
+
if (!dirName || dirName.includes('/') || dirName.includes('\\') || dirName.includes('..')) return null
|
|
30
|
+
const path = resolve(root, dirName)
|
|
31
|
+
if (!path.startsWith(resolve(root) + '/')) return null
|
|
32
|
+
return path
|
|
33
|
+
}
|
|
16
34
|
|
|
17
35
|
export const voiceRouter = Router()
|
|
18
36
|
|
|
@@ -97,19 +115,73 @@ voiceRouter.get('/voice/training-status', async (_req, res) => {
|
|
|
97
115
|
})
|
|
98
116
|
|
|
99
117
|
// POST /api/voice/train-g2 — train from saved G2-mic audio chunks
|
|
100
|
-
// These accumulate during meetings for speakers who need more embeddings
|
|
118
|
+
// These accumulate during meetings for speakers who need more embeddings.
|
|
119
|
+
//
|
|
120
|
+
// Body: { speaker?, confirmAllSpeakers?, dryRun?, maxPerSpeaker? }
|
|
121
|
+
//
|
|
122
|
+
// This endpoint permanently rewrites voice profiles AND deletes the source WAVs,
|
|
123
|
+
// so the unscoped form now requires an explicit confirmation. Two reasons, both
|
|
124
|
+
// load-bearing:
|
|
125
|
+
//
|
|
126
|
+
// 1. Until the reader path above was fixed it saw an empty directory, so a
|
|
127
|
+
// no-argument call was harmless. It is not harmless any more — it now reaches
|
|
128
|
+
// every accumulated speaker directory at once.
|
|
129
|
+
// 2. Enrolling N samples into a profile capped at 20 evicts the oldest sample N
|
|
130
|
+
// times. A 30-WAV directory would therefore discard EVERY pre-existing
|
|
131
|
+
// embedding for that speaker, replacing months of curated training with one
|
|
132
|
+
// meeting's audio. Diversity selection bounds the enrollment instead.
|
|
133
|
+
const DEFAULT_MAX_TRAIN_PER_SPEAKER = 10
|
|
134
|
+
|
|
101
135
|
voiceRouter.post('/voice/train-g2', async (req, res) => {
|
|
102
136
|
try {
|
|
103
137
|
const targetSpeaker = req.body?.speaker as string | undefined
|
|
138
|
+
const confirmAll = req.body?.confirmAllSpeakers === true
|
|
139
|
+
const dryRun = req.body?.dryRun === true
|
|
140
|
+
const maxPerSpeaker = Number.isFinite(req.body?.maxPerSpeaker)
|
|
141
|
+
? Math.max(1, Math.min(20, Number(req.body.maxPerSpeaker)))
|
|
142
|
+
: DEFAULT_MAX_TRAIN_PER_SPEAKER
|
|
143
|
+
|
|
104
144
|
if (!existsSync(AUDIO_SAVE_DIR)) {
|
|
105
145
|
return res.json({ trained: 0, speakers: [], message: 'No saved G2 audio yet' })
|
|
106
146
|
}
|
|
107
147
|
|
|
108
|
-
|
|
148
|
+
let speakerDirs = readdirSync(AUDIO_SAVE_DIR, { withFileTypes: true })
|
|
109
149
|
.filter(d => d.isDirectory())
|
|
110
|
-
.filter(d => !targetSpeaker || d.name === targetSpeaker.replace(/\s+/g, '_'))
|
|
111
150
|
|
|
112
|
-
|
|
151
|
+
if (targetSpeaker) {
|
|
152
|
+
const wanted = speakerDirPath(AUDIO_SAVE_DIR, targetSpeaker)
|
|
153
|
+
if (!wanted) return res.status(400).json({ error: 'invalid speaker name' })
|
|
154
|
+
speakerDirs = speakerDirs.filter(d => resolve(AUDIO_SAVE_DIR, d.name) === wanted)
|
|
155
|
+
if (speakerDirs.length === 0) {
|
|
156
|
+
return res.status(404).json({ error: `No saved G2 audio for "${targetSpeaker}"` })
|
|
157
|
+
}
|
|
158
|
+
} else if (!confirmAll && !dryRun) {
|
|
159
|
+
// Fail closed with the inventory, so the caller can see exactly what a
|
|
160
|
+
// confirmation would rewrite before granting it.
|
|
161
|
+
const pending = speakerDirs.map(d => {
|
|
162
|
+
const name = d.name.replace(/_/g, ' ')
|
|
163
|
+
let chunks = 0
|
|
164
|
+
try { chunks = readdirSync(resolve(AUDIO_SAVE_DIR, d.name)).filter(f => f.endsWith('.wav')).length } catch {}
|
|
165
|
+
return { speaker: name, chunks, currentEmbeddings: getEmbeddingCount(name) }
|
|
166
|
+
}).filter(s => s.chunks > 0)
|
|
167
|
+
return res.status(400).json({
|
|
168
|
+
error: 'confirmation required',
|
|
169
|
+
message: 'Training every speaker at once rewrites their profiles and deletes the source audio. '
|
|
170
|
+
+ 'Pass { speaker } to scope it, { dryRun: true } to preview, or { confirmAllSpeakers: true } to proceed.',
|
|
171
|
+
wouldTrain: pending,
|
|
172
|
+
totalSpeakers: pending.length,
|
|
173
|
+
totalChunks: pending.reduce((sum, s) => sum + s.chunks, 0),
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const results: Array<{
|
|
178
|
+
speaker: string
|
|
179
|
+
chunks: number
|
|
180
|
+
embeddingsExtracted: number
|
|
181
|
+
selected: number
|
|
182
|
+
enrolled: number
|
|
183
|
+
audioRetained?: boolean
|
|
184
|
+
}> = []
|
|
113
185
|
|
|
114
186
|
for (const dir of speakerDirs) {
|
|
115
187
|
const speakerName = dir.name.replace(/_/g, ' ')
|
|
@@ -127,28 +199,53 @@ voiceRouter.post('/voice/train-g2', async (req, res) => {
|
|
|
127
199
|
}
|
|
128
200
|
|
|
129
201
|
if (embeddings.length === 0) {
|
|
130
|
-
results.push({ speaker: speakerName, chunks: wavFiles.length, enrolled: 0 })
|
|
202
|
+
results.push({ speaker: speakerName, chunks: wavFiles.length, embeddingsExtracted: 0, selected: 0, enrolled: 0, audioRetained: true })
|
|
131
203
|
continue
|
|
132
204
|
}
|
|
133
205
|
|
|
134
|
-
|
|
206
|
+
const selected = greedyDiversitySelect(embeddings, maxPerSpeaker)
|
|
207
|
+
|
|
208
|
+
if (dryRun) {
|
|
209
|
+
results.push({
|
|
210
|
+
speaker: speakerName,
|
|
211
|
+
chunks: wavFiles.length,
|
|
212
|
+
embeddingsExtracted: embeddings.length,
|
|
213
|
+
selected: selected.length,
|
|
214
|
+
enrolled: 0,
|
|
215
|
+
audioRetained: true,
|
|
216
|
+
})
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Enroll the diverse subset (enrollEmbedding handles dedup gate + FIFO cap)
|
|
135
221
|
let enrolled = 0
|
|
136
|
-
for (const emb of
|
|
222
|
+
for (const emb of selected) {
|
|
137
223
|
const result = enrollEmbedding(speakerName, emb, 'g2-training')
|
|
138
224
|
if (result.success) enrolled++
|
|
139
225
|
}
|
|
140
226
|
|
|
141
|
-
results.push({
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
227
|
+
results.push({
|
|
228
|
+
speaker: speakerName,
|
|
229
|
+
chunks: wavFiles.length,
|
|
230
|
+
embeddingsExtracted: embeddings.length,
|
|
231
|
+
selected: selected.length,
|
|
232
|
+
enrolled,
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// Clean up processed audio. Only when something was actually enrolled —
|
|
236
|
+
// deleting the source after enrolling nothing is pure data loss.
|
|
237
|
+
if (enrolled > 0) {
|
|
238
|
+
for (const wav of wavFiles) {
|
|
239
|
+
try { unlinkSync(resolve(speakerPath, wav)) } catch {}
|
|
240
|
+
}
|
|
241
|
+
try { rmdirSync(speakerPath) } catch {}
|
|
242
|
+
} else {
|
|
243
|
+
results[results.length - 1].audioRetained = true
|
|
146
244
|
}
|
|
147
|
-
try { rmdirSync(speakerPath) } catch {}
|
|
148
245
|
}
|
|
149
246
|
|
|
150
247
|
const totalEnrolled = results.reduce((sum, r) => sum + r.enrolled, 0)
|
|
151
|
-
res.json({ trained: totalEnrolled, speakers: results })
|
|
248
|
+
res.json({ trained: totalEnrolled, dryRun, maxPerSpeaker, speakers: results })
|
|
152
249
|
} catch (err: unknown) {
|
|
153
250
|
res.status(500).json({ error: errMsg(err) })
|
|
154
251
|
}
|
|
@@ -236,11 +333,31 @@ voiceRouter.post('/voice/enroll-ext', async (req, res) => {
|
|
|
236
333
|
// Collect target directories
|
|
237
334
|
const targetDirs: string[] = []
|
|
238
335
|
if (sessionId) {
|
|
239
|
-
const dirPath =
|
|
240
|
-
if (existsSync(dirPath)) targetDirs.push(dirPath)
|
|
336
|
+
const dirPath = speakerDirPath(EXT_AUDIO_DIR, String(sessionId))
|
|
337
|
+
if (dirPath && existsSync(dirPath)) targetDirs.push(dirPath)
|
|
241
338
|
else return res.status(404).json({ error: `Session ${sessionId} not found in ext-audio` })
|
|
242
339
|
} else {
|
|
243
340
|
const sessionDirs = readdirSync(EXT_AUDIO_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
|
|
341
|
+
// Same reasoning as train-g2: with the reader path fixed, the unscoped
|
|
342
|
+
// form now attributes EVERY unrecognized session in the retention window
|
|
343
|
+
// to one person and then recursively deletes them all. Different sessions
|
|
344
|
+
// are usually different people, so that is a profile-poisoning default.
|
|
345
|
+
if (req.body?.confirmAllSessions !== true) {
|
|
346
|
+
const inventory = sessionDirs.map(d => {
|
|
347
|
+
let chunks = 0
|
|
348
|
+
try { chunks = readdirSync(resolve(EXT_AUDIO_DIR, d.name)).filter(f => f.endsWith('.wav')).length } catch {}
|
|
349
|
+
return { sessionId: d.name, chunks }
|
|
350
|
+
}).filter(s => s.chunks > 0)
|
|
351
|
+
return res.status(400).json({
|
|
352
|
+
error: 'confirmation required',
|
|
353
|
+
message: `Enrolling every ext-audio session as "${name}" assumes one speaker across all of them, `
|
|
354
|
+
+ 'and deletes the audio afterwards. Pass { sessionId } to scope it, '
|
|
355
|
+
+ 'or { confirmAllSessions: true } to proceed.',
|
|
356
|
+
wouldEnrollFrom: inventory,
|
|
357
|
+
totalSessions: inventory.length,
|
|
358
|
+
totalChunks: inventory.reduce((sum, s) => sum + s.chunks, 0),
|
|
359
|
+
})
|
|
360
|
+
}
|
|
244
361
|
for (const d of sessionDirs) targetDirs.push(resolve(EXT_AUDIO_DIR, d.name))
|
|
245
362
|
}
|
|
246
363
|
|
|
@@ -290,6 +407,214 @@ voiceRouter.post('/voice/enroll-ext', async (req, res) => {
|
|
|
290
407
|
}
|
|
291
408
|
})
|
|
292
409
|
|
|
410
|
+
// GET /api/voice/profiles — enrolled people with sample counts and provenance.
|
|
411
|
+
// The review surfaces need to see the store; until now the only window into it
|
|
412
|
+
// was a per-name count, so a misattributed profile was invisible.
|
|
413
|
+
voiceRouter.get('/voice/profiles', (_req, res) => {
|
|
414
|
+
try {
|
|
415
|
+
const { profiles } = readVoiceProfiles()
|
|
416
|
+
const owner = getOwnerSpeakerLabel()
|
|
417
|
+
res.json({
|
|
418
|
+
owner,
|
|
419
|
+
count: profiles.length,
|
|
420
|
+
totalEmbeddings: profiles.reduce((sum, p) => sum + p.embeddings.length, 0),
|
|
421
|
+
profiles: profiles
|
|
422
|
+
.map(p => {
|
|
423
|
+
const bySource: Record<string, number> = {}
|
|
424
|
+
for (const source of p.sources ?? []) {
|
|
425
|
+
// Collapse auto:<sessionId> so one poisoned session is visible
|
|
426
|
+
// without leaking a session id per row.
|
|
427
|
+
const key = source.startsWith('auto:') ? 'auto' : source
|
|
428
|
+
bySource[key] = (bySource[key] ?? 0) + 1
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
name: p.name,
|
|
432
|
+
embeddings: p.embeddings.length,
|
|
433
|
+
isOwner: p.name === owner,
|
|
434
|
+
sources: bySource,
|
|
435
|
+
// Provenance alignment is now an invariant; surfacing it makes a
|
|
436
|
+
// future regression visible instead of silent.
|
|
437
|
+
sourcesAligned: (p.sources?.length ?? 0) === p.embeddings.length,
|
|
438
|
+
}
|
|
439
|
+
})
|
|
440
|
+
.sort((a, b) => b.embeddings - a.embeddings),
|
|
441
|
+
})
|
|
442
|
+
} catch (err: unknown) {
|
|
443
|
+
res.status(500).json({ error: errMsg(err) })
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
// POST /api/voice/merge-profiles — fold two names for one person together.
|
|
448
|
+
// Body: { into, from: string[]|string, confirm: true, dryRun?, force? }
|
|
449
|
+
//
|
|
450
|
+
// Two profiles for one voice is worse than it looks: the sherpa manager holds
|
|
451
|
+
// one centroid per NAME, so both compete on every search and each is capped at
|
|
452
|
+
// 20 samples independently — 40 samples of one person, split, each half a
|
|
453
|
+
// weaker representation of them than the union would be.
|
|
454
|
+
//
|
|
455
|
+
// Fails closed below the search-accept threshold. A wrong merge destroys BOTH
|
|
456
|
+
// identities at once and cannot be undone from the store alone, so the only
|
|
457
|
+
// acceptable evidence is acoustic. `force` exists for the case where Miles
|
|
458
|
+
// knows something the audio does not, and it is logged.
|
|
459
|
+
voiceRouter.post('/voice/merge-profiles', (req, res) => {
|
|
460
|
+
try {
|
|
461
|
+
const into = typeof req.body?.into === 'string' ? req.body.into.trim() : ''
|
|
462
|
+
const rawFrom = req.body?.from
|
|
463
|
+
const from = (Array.isArray(rawFrom) ? rawFrom : [rawFrom])
|
|
464
|
+
.filter((n: unknown): n is string => typeof n === 'string' && n.trim().length > 0)
|
|
465
|
+
.map((n: string) => n.trim())
|
|
466
|
+
|
|
467
|
+
if (!into || from.length === 0) {
|
|
468
|
+
return res.status(400).json({ error: 'into (string) and from (string or string[]) are required' })
|
|
469
|
+
}
|
|
470
|
+
if (from.includes(into)) {
|
|
471
|
+
return res.status(400).json({ error: 'into and from must differ' })
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const owner = getOwnerSpeakerLabel()
|
|
475
|
+
if (from.includes(owner)) {
|
|
476
|
+
// Absorbing the owner label would delete the profile the live
|
|
477
|
+
// identification path checks FIRST, on every chunk.
|
|
478
|
+
return res.status(400).json({
|
|
479
|
+
error: `refusing to absorb the owner label "${owner}" — merge INTO it instead`,
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const dryRun = req.body?.dryRun === true
|
|
484
|
+
const force = req.body?.force === true
|
|
485
|
+
|
|
486
|
+
if (req.body?.confirm !== true && !dryRun) {
|
|
487
|
+
const preview = mergeSpeakerProfiles(into, from, { force, dryRun: true })
|
|
488
|
+
return res.status(400).json({
|
|
489
|
+
error: 'confirmation required',
|
|
490
|
+
message: `Merging is not reversible from the store alone. Review the similarity scores, then pass { confirm: true }.`,
|
|
491
|
+
preview,
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const report = mergeSpeakerProfiles(into, from, { force, dryRun })
|
|
496
|
+
|
|
497
|
+
if (report.missing.length > 0 && report.merged.length === 0) {
|
|
498
|
+
return res.status(404).json({ error: 'no such profile(s)', missing: report.missing, report })
|
|
499
|
+
}
|
|
500
|
+
if (report.refused && report.merged.length === 0) {
|
|
501
|
+
return res.status(409).json({
|
|
502
|
+
error: 'similarity below the merge floor',
|
|
503
|
+
message: 'These centroids are further apart than the threshold at which identification would '
|
|
504
|
+
+ 'accept a match between them, so they are probably different people. Pass { force: true } '
|
|
505
|
+
+ 'only if you know they are the same person.',
|
|
506
|
+
report,
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Relabel rather than drop the absorbed name's calibration history: after a
|
|
511
|
+
// merge it is one person's history, and it is the only evidence for whether
|
|
512
|
+
// the merge improved identification.
|
|
513
|
+
const calibration: Record<string, number> = {}
|
|
514
|
+
if (!dryRun) {
|
|
515
|
+
for (const name of report.merged) {
|
|
516
|
+
calibration[name] = relabelSpeakerCalibrationRows(CALIBRATION_LOG, name, into).relabeled
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
res.json({ ...report, dryRun, forced: force, calibrationRowsRelabeled: calibration })
|
|
521
|
+
} catch (err: unknown) {
|
|
522
|
+
res.status(500).json({ error: errMsg(err) })
|
|
523
|
+
}
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
// POST /api/voice/delete-person — remove one person from every store that
|
|
527
|
+
// carries their name. Body: { name, confirm: true, dryRun? }
|
|
528
|
+
//
|
|
529
|
+
// Built before more data accumulates, and returns a per-store count so the sweep
|
|
530
|
+
// is auditable rather than a bare success. Two stores are deliberately NOT swept:
|
|
531
|
+
// ext-audio and session-audio are keyed by session, not by person, so there is no
|
|
532
|
+
// name to match on — they age out on their own retention instead.
|
|
533
|
+
voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
534
|
+
try {
|
|
535
|
+
const name = req.body?.name
|
|
536
|
+
if (!name || typeof name !== 'string' || name.trim().length < 2) {
|
|
537
|
+
return res.status(400).json({ error: 'name is required (min 2 chars)' })
|
|
538
|
+
}
|
|
539
|
+
const target = name.trim()
|
|
540
|
+
const dryRun = req.body?.dryRun === true
|
|
541
|
+
|
|
542
|
+
if (req.body?.confirm !== true && !dryRun) {
|
|
543
|
+
const existing = readVoiceProfiles().profiles.find(p => p.name === target)
|
|
544
|
+
const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
|
|
545
|
+
let wavs = 0
|
|
546
|
+
if (audioDir && existsSync(audioDir)) {
|
|
547
|
+
try { wavs = readdirSync(audioDir).filter(f => f.endsWith('.wav')).length } catch {}
|
|
548
|
+
}
|
|
549
|
+
const calibration = purgeSpeakerCalibrationRows(CALIBRATION_LOG, target, { dryRun: true })
|
|
550
|
+
return res.status(400).json({
|
|
551
|
+
error: 'confirmation required',
|
|
552
|
+
message: `Deleting "${target}" is not reversible. Pass { confirm: true } to proceed.`,
|
|
553
|
+
wouldRemove: {
|
|
554
|
+
profile: existing ? 1 : 0,
|
|
555
|
+
embeddings: existing?.embeddings.length ?? 0,
|
|
556
|
+
trainingAudioFiles: wavs,
|
|
557
|
+
calibrationRows: calibration.removed,
|
|
558
|
+
},
|
|
559
|
+
})
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (dryRun) {
|
|
563
|
+
const existing = readVoiceProfiles().profiles.find(p => p.name === target)
|
|
564
|
+
const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
|
|
565
|
+
let wavs = 0
|
|
566
|
+
if (audioDir && existsSync(audioDir)) {
|
|
567
|
+
try { wavs = readdirSync(audioDir).filter(f => f.endsWith('.wav')).length } catch {}
|
|
568
|
+
}
|
|
569
|
+
const calibration = purgeSpeakerCalibrationRows(CALIBRATION_LOG, target, { dryRun: true })
|
|
570
|
+
return res.json({
|
|
571
|
+
name: target,
|
|
572
|
+
dryRun: true,
|
|
573
|
+
removed: {
|
|
574
|
+
profiles: existing ? 1 : 0,
|
|
575
|
+
embeddings: existing?.embeddings.length ?? 0,
|
|
576
|
+
trainingAudioFiles: wavs,
|
|
577
|
+
calibrationRows: calibration.removed,
|
|
578
|
+
},
|
|
579
|
+
})
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// 1. Voice profile + sherpa manager registration.
|
|
583
|
+
const profileResult = removeSpeakerProfile(target)
|
|
584
|
+
|
|
585
|
+
// 2. Saved G2 training audio for this person.
|
|
586
|
+
let trainingAudioFiles = 0
|
|
587
|
+
const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
|
|
588
|
+
if (audioDir && existsSync(audioDir)) {
|
|
589
|
+
try {
|
|
590
|
+
const wavs = readdirSync(audioDir).filter(f => f.endsWith('.wav'))
|
|
591
|
+
trainingAudioFiles = wavs.length
|
|
592
|
+
rmSync(audioDir, { recursive: true, force: true })
|
|
593
|
+
} catch { /* reported as 0 rather than claimed */ }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 3. Calibration rows (the name appears in every row).
|
|
597
|
+
const calibration = purgeSpeakerCalibrationRows(CALIBRATION_LOG, target)
|
|
598
|
+
|
|
599
|
+
res.json({
|
|
600
|
+
name: target,
|
|
601
|
+
removed: {
|
|
602
|
+
profiles: profileResult.removedProfiles,
|
|
603
|
+
embeddings: profileResult.removedEmbeddings,
|
|
604
|
+
trainingAudioFiles,
|
|
605
|
+
calibrationRows: calibration.removed,
|
|
606
|
+
},
|
|
607
|
+
notAttributable: {
|
|
608
|
+
extAudio: 'keyed by session, not by person — ages out on its own retention',
|
|
609
|
+
sessionAudio: 'keyed by session, not by person — ages out on its own retention',
|
|
610
|
+
},
|
|
611
|
+
calibrationRetained: calibration.retained,
|
|
612
|
+
})
|
|
613
|
+
} catch (err: unknown) {
|
|
614
|
+
res.status(500).json({ error: errMsg(err) })
|
|
615
|
+
}
|
|
616
|
+
})
|
|
617
|
+
|
|
293
618
|
/** Greedy diversity selection — pick N most acoustically diverse embeddings */
|
|
294
619
|
function greedyDiversitySelect(embeddings: Float32Array[], maxN: number): Float32Array[] {
|
|
295
620
|
if (embeddings.length <= maxN) return embeddings
|