@gotcos/glasses-server 6.45.3 → 6.45.4
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 +8 -0
- package/package.json +1 -1
- package/server/lib/embedding-eviction.ts +19 -0
- package/server/lib/held-voice-groups.ts +752 -0
- package/server/lib/prompt-tail-guard.ts +257 -0
- package/server/lib/speaker-embeddings.ts +8 -1
- package/server/lib/training-audio-provenance.ts +1 -1
- package/server/lib/transcribe-audio.ts +9 -1
- package/server/lib/vad-silero.ts +8 -3
- package/server/lib/voice-enrolment-selection.ts +31 -5
- package/server/lib/whisper-local.ts +12 -6
- package/server/routes/prompt-drafts.ts +13 -2
- package/server/routes/transcribe-stream.ts +5 -3
- package/server/routes/voice.ts +117 -1
package/server/routes/voice.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Voice enrollment, status, and multi-speaker training endpoints
|
|
2
2
|
|
|
3
|
-
import { Router } from 'express'
|
|
3
|
+
import express, { 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'
|
|
@@ -17,6 +17,7 @@ import { extAudioChunkPath, listExtAudioChunks } from '../lib/meeting-audio-arch
|
|
|
17
17
|
import { getVoiceDirectorySnapshot, invalidateVoiceDirectory } from '../lib/voice-directory.js'
|
|
18
18
|
import { greedyDiversitySelect } from '../lib/voice-enrolment-selection.js'
|
|
19
19
|
import { fanOutSpeakerRename, type SpeakerRenameFanOut } from '../lib/speaker-rename-fanout.js'
|
|
20
|
+
import { HeldGroupError, discardHeldSamples, enrollHeldGroup, heldVoiceGroups, parseHeldMembers, previewDiscard } from '../lib/held-voice-groups.js'
|
|
20
21
|
import { resolveCosOperationsDir } from '../lib/cos-operations-meetings.js'
|
|
21
22
|
|
|
22
23
|
// These MUST match the writer in transcribe-stream.ts, which saves under
|
|
@@ -537,6 +538,121 @@ voiceRouter.get('/voice/ext-audio/:sessionId/sample', (req, res) => {
|
|
|
537
538
|
sendAudioFile(res, wav)
|
|
538
539
|
})
|
|
539
540
|
|
|
541
|
+
// ── Held voices, grouped (6.45.4) ─────────────────────────────────────────
|
|
542
|
+
//
|
|
543
|
+
// The Add-a-voice panel listed held audio by SESSION. A session is not a voice:
|
|
544
|
+
// one meeting holds several strangers, and one stranger recurs across meetings.
|
|
545
|
+
// These three routes work at the grain a reviewer actually names — a GROUP of
|
|
546
|
+
// samples that sound like one person, across the whole retention window — and
|
|
547
|
+
// let the random artifacts be thrown out instead of named. See
|
|
548
|
+
// lib/held-voice-groups.ts for the rule (mutually coherent at the identifier's
|
|
549
|
+
// own floor) and for why the vectors cost no decode in the ordinary case.
|
|
550
|
+
//
|
|
551
|
+
// Both mutations fail closed like every sibling here: without `confirm: true`
|
|
552
|
+
// they answer 400 `confirmation required` with a preview of exactly what would
|
|
553
|
+
// change, and `dryRun: true` returns that preview as a 200. COS Control sends
|
|
554
|
+
// `confirm` after its own two-click gate.
|
|
555
|
+
|
|
556
|
+
// GET /api/voice/held-groups
|
|
557
|
+
voiceRouter.get('/voice/held-groups', (_req, res) => {
|
|
558
|
+
try {
|
|
559
|
+
res.set('Cache-Control', 'private, no-store')
|
|
560
|
+
res.json(heldVoiceGroups())
|
|
561
|
+
} catch (err: unknown) {
|
|
562
|
+
res.status(500).json({ error: errMsg(err) })
|
|
563
|
+
}
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
function heldGroupFailure(res: express.Response, err: unknown): void {
|
|
567
|
+
if (err instanceof HeldGroupError) {
|
|
568
|
+
res.status(err.status).json({ success: false, error: err.message, reason: err.reason, ...err.details })
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
res.status(500).json({ success: false, error: errMsg(err) })
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// POST /api/voice/held-groups/enroll — name a group (or any set of held
|
|
575
|
+
// samples) as one person. Body: { name, members: [{ sessionId, chunkIndex }],
|
|
576
|
+
// confirm?: true, dryRun?: true }. A name that already has a profile is
|
|
577
|
+
// APPENDED to: "add more fidelity in samples to a given voice". Only the
|
|
578
|
+
// coherent core is written and only its wavs are removed; samples that were
|
|
579
|
+
// not this voice stay held.
|
|
580
|
+
voiceRouter.post('/voice/held-groups/enroll', (req, res) => {
|
|
581
|
+
try {
|
|
582
|
+
const nameCheck = checkSpeakerName(req.body?.name, { ownerLabel: getOwnerSpeakerLabel() })
|
|
583
|
+
if (!nameCheck.ok) {
|
|
584
|
+
return res.status(400).json({ success: false, error: nameCheck.message, reason: nameCheck.reason })
|
|
585
|
+
}
|
|
586
|
+
const name = String(req.body.name).trim()
|
|
587
|
+
const members = parseHeldMembers(req.body?.members)
|
|
588
|
+
const dryRun = req.body?.dryRun === true
|
|
589
|
+
const confirm = req.body?.confirm === true
|
|
590
|
+
if (dryRun || !confirm) {
|
|
591
|
+
const plan = enrollHeldGroup(name, members, { dryRun: true })
|
|
592
|
+
const verb = plan.created ? 'create' : 'add to'
|
|
593
|
+
const summary = `Would ${verb} ${name} from ${plan.coherent} of ${plan.resolved} held sample${plan.resolved === 1 ? '' : 's'}`
|
|
594
|
+
+ (plan.leftBehind.length > 0 ? `, leaving ${plan.leftBehind.length} that do not sound like the same person` : '')
|
|
595
|
+
+ (plan.notReady.length > 0 ? `, with ${plan.notReady.length} still being read` : '')
|
|
596
|
+
+ `, and delete the audio it used.`
|
|
597
|
+
if (dryRun) return res.json({ success: true, ...plan, message: summary })
|
|
598
|
+
return res.status(400).json({
|
|
599
|
+
success: false,
|
|
600
|
+
error: 'confirmation required',
|
|
601
|
+
reason: 'confirmation_required',
|
|
602
|
+
message: `${summary} Pass { confirm: true } to proceed.`,
|
|
603
|
+
preview: plan,
|
|
604
|
+
})
|
|
605
|
+
}
|
|
606
|
+
const result = enrollHeldGroup(name, members)
|
|
607
|
+
invalidateVoiceDirectory()
|
|
608
|
+
const verb = result.created ? 'Created' : 'Added to'
|
|
609
|
+
res.json({
|
|
610
|
+
success: true,
|
|
611
|
+
...result,
|
|
612
|
+
message: result.leftBehind.length > 0
|
|
613
|
+
? `${verb} ${name} from ${result.coherent} of ${result.resolved} samples; ${result.leftBehind.length} did not sound like the same person and stay held.`
|
|
614
|
+
: `${verb} ${name} from ${result.coherent} sample${result.coherent === 1 ? '' : 's'}.`
|
|
615
|
+
+ (result.notReady.length > 0 ? ` ${result.notReady.length} still being read; they stay held.` : ''),
|
|
616
|
+
})
|
|
617
|
+
} catch (err: unknown) {
|
|
618
|
+
heldGroupFailure(res, err)
|
|
619
|
+
}
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
// POST /api/voice/held-groups/discard — throw held samples out without naming
|
|
623
|
+
// them. Body: { members: [{ sessionId, chunkIndex }], confirm?: true, dryRun?: true }.
|
|
624
|
+
// This is how the loose artifacts leave the panel.
|
|
625
|
+
voiceRouter.post('/voice/held-groups/discard', (req, res) => {
|
|
626
|
+
try {
|
|
627
|
+
const members = parseHeldMembers(req.body?.members)
|
|
628
|
+
const dryRun = req.body?.dryRun === true
|
|
629
|
+
const confirm = req.body?.confirm === true
|
|
630
|
+
if (dryRun || !confirm) {
|
|
631
|
+
const preview = previewDiscard(members)
|
|
632
|
+
const summary = `Would discard ${preview.present.length} held sample${preview.present.length === 1 ? '' : 's'}`
|
|
633
|
+
+ (preview.missing.length > 0 ? ` (${preview.missing.length} already gone)` : '') + '.'
|
|
634
|
+
if (dryRun) return res.json({ success: true, dryRun: true, wouldRemove: preview.present.length, missing: preview.missing, message: summary })
|
|
635
|
+
return res.status(400).json({
|
|
636
|
+
success: false,
|
|
637
|
+
error: 'confirmation required',
|
|
638
|
+
reason: 'confirmation_required',
|
|
639
|
+
message: `${summary} Pass { confirm: true } to proceed.`,
|
|
640
|
+
wouldRemove: preview.present.length,
|
|
641
|
+
missing: preview.missing,
|
|
642
|
+
})
|
|
643
|
+
}
|
|
644
|
+
const result = discardHeldSamples(members)
|
|
645
|
+
res.json({
|
|
646
|
+
success: true,
|
|
647
|
+
removed: result.removed.length,
|
|
648
|
+
missing: result.missing,
|
|
649
|
+
message: `Discarded ${result.removed.length} held sample${result.removed.length === 1 ? '' : 's'}.`,
|
|
650
|
+
})
|
|
651
|
+
} catch (err: unknown) {
|
|
652
|
+
heldGroupFailure(res, err)
|
|
653
|
+
}
|
|
654
|
+
})
|
|
655
|
+
|
|
540
656
|
// GET /api/voice/profiles — enrolled people with sample counts and provenance.
|
|
541
657
|
// The review surfaces need to see the store; until now the only window into it
|
|
542
658
|
// was a per-name count, so a misattributed profile was invisible.
|