@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.
@@ -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.