@gotcos/glasses-server 6.94.2 → 6.94.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 +24 -0
- package/README.md +4 -0
- package/package.json +2 -1
- package/server/index.ts +6 -0
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/held-naming-batches.ts +8 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/mac-voice-assignment.ts +2 -1
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-speaker-review.ts +9 -8
- package/server/lib/session-find-ask.ts +288 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/voice-directory.ts +2 -1
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/meeting.ts +11 -10
- package/server/routes/session-find.ts +73 -0
- package/server/routes/voice.ts +25 -18
package/server/routes/voice.ts
CHANGED
|
@@ -5,6 +5,7 @@ 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
7
|
import { checkSpeakerName } from '../lib/speaker-name.js'
|
|
8
|
+
import { bodyName, nfc, sameSpeakerName } from '../lib/speaker-name-nfc.js'
|
|
8
9
|
import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
|
|
9
10
|
import { statSync } from 'node:fs'
|
|
10
11
|
import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
|
|
@@ -51,7 +52,9 @@ voiceRouter.post('/voice/enroll', async (req, res) => {
|
|
|
51
52
|
// Default to the configured wearer label ('Me' unless owner_speaker_label
|
|
52
53
|
// is set). Hardcoding one user's initials here enrolled every other install
|
|
53
54
|
// under a stranger's name.
|
|
54
|
-
|
|
55
|
+
// `?name=a&name=b` is not a name; falling through to the owner's label would enroll a stranger's voice as the owner.
|
|
56
|
+
if (req.query.name !== undefined && typeof req.query.name !== 'string') return res.status(400).json({ error: 'name must be a single value' })
|
|
57
|
+
const name = nfc(typeof req.query.name === 'string' && req.query.name ? req.query.name : getOwnerSpeakerLabel())
|
|
55
58
|
|
|
56
59
|
// An old client can still send a whole spoken sentence as the name (the
|
|
57
60
|
// "enroll my voice" fall-through). Refuse it here: a junk profile can
|
|
@@ -115,7 +118,8 @@ voiceRouter.post('/voice/identify', async (req, res) => {
|
|
|
115
118
|
// POST /api/voice/train — train voiceprints from Fireflies meeting audio
|
|
116
119
|
voiceRouter.post('/voice/train', async (req, res) => {
|
|
117
120
|
try {
|
|
118
|
-
const { speakerNames, minSegments, minSegmentDuration, limit, maxEmbeddingsPerSpeaker, fresh } = req.body ?? {}
|
|
121
|
+
const { speakerNames: rawSpeakerNames, minSegments, minSegmentDuration, limit, maxEmbeddingsPerSpeaker, fresh } = req.body ?? {}
|
|
122
|
+
const speakerNames = Array.isArray(rawSpeakerNames) ? rawSpeakerNames.map((n: unknown) => typeof n === 'string' ? nfc(n) : n) : rawSpeakerNames
|
|
119
123
|
const report = await trainFromFireflies({
|
|
120
124
|
speakerNames,
|
|
121
125
|
minSegments,
|
|
@@ -160,7 +164,7 @@ const DEFAULT_MAX_TRAIN_PER_SPEAKER = 10
|
|
|
160
164
|
|
|
161
165
|
voiceRouter.post('/voice/train-g2', async (req, res) => {
|
|
162
166
|
try {
|
|
163
|
-
const targetSpeaker = req.body?.speaker
|
|
167
|
+
const targetSpeaker = typeof req.body?.speaker === 'string' ? nfc(req.body.speaker) : undefined
|
|
164
168
|
const confirmAll = req.body?.confirmAllSpeakers === true
|
|
165
169
|
const dryRun = req.body?.dryRun === true
|
|
166
170
|
const maxPerSpeaker = Number.isFinite(req.body?.maxPerSpeaker)
|
|
@@ -361,7 +365,8 @@ voiceRouter.get('/voice/ext-audio', (_req, res) => {
|
|
|
361
365
|
// If sessionId provided, only enroll from that session. Otherwise, enroll from all ext sessions.
|
|
362
366
|
voiceRouter.post('/voice/enroll-ext', async (req, res) => {
|
|
363
367
|
try {
|
|
364
|
-
const { name, sessionId } = req.body ?? {}
|
|
368
|
+
const { name: rawName, sessionId } = req.body ?? {}
|
|
369
|
+
const name = typeof rawName === 'string' ? nfc(rawName) : rawName
|
|
365
370
|
if (!name || typeof name !== 'string' || name.length < 2) {
|
|
366
371
|
return res.status(400).json({ error: 'name is required (min 2 chars)' })
|
|
367
372
|
}
|
|
@@ -485,7 +490,7 @@ function newestWav(dirPath: string): string | null {
|
|
|
485
490
|
// GET /api/voice/profiles/:name/sample — hear what a stored profile sounds like.
|
|
486
491
|
voiceRouter.get('/voice/profiles/:name/sample', (req, res) => {
|
|
487
492
|
res.set('Cache-Control', 'private, no-store')
|
|
488
|
-
const name = String(req.params.name ?? '')
|
|
493
|
+
const name = nfc(String(req.params.name ?? ''))
|
|
489
494
|
const dirPath = speakerDirPath(AUDIO_SAVE_DIR, name)
|
|
490
495
|
if (!dirPath) {
|
|
491
496
|
res.status(400).json({ error: 'Invalid speaker name', reason: 'invalid_speaker' })
|
|
@@ -587,7 +592,7 @@ voiceRouter.post('/voice/held-groups/enroll', async (req, res) => {
|
|
|
587
592
|
try {
|
|
588
593
|
const nameCheck = checkSpeakerName(req.body?.name, { ownerLabel: getOwnerSpeakerLabel() })
|
|
589
594
|
if (!nameCheck.ok) return res.status(400).json({ success:false,error:nameCheck.message,reason:nameCheck.reason })
|
|
590
|
-
const name = resolveStoredName(String(req.body.name).trim())
|
|
595
|
+
const name = resolveStoredName(nfc(String(req.body.name).trim()))
|
|
591
596
|
// Held samples are audio this Mac captured. A member pointing at an imported
|
|
592
597
|
// or derived record is not held audio, so it never reaches the preview.
|
|
593
598
|
//
|
|
@@ -679,7 +684,7 @@ voiceRouter.get('/voice/profiles', (_req, res) => {
|
|
|
679
684
|
return {
|
|
680
685
|
name: p.name,
|
|
681
686
|
embeddings: p.embeddings.length,
|
|
682
|
-
isOwner: p.name
|
|
687
|
+
isOwner: sameSpeakerName(p.name, owner),
|
|
683
688
|
sources: bySource,
|
|
684
689
|
// 6.44.15: a profile whose name on disk was a spoken sentence was
|
|
685
690
|
// renamed at load; the client offers a rename.
|
|
@@ -793,21 +798,23 @@ function fanOutTotals(runs: readonly SpeakerRenameFanOut[]): {
|
|
|
793
798
|
// knows something the audio does not, and it is logged.
|
|
794
799
|
voiceRouter.post('/voice/merge-profiles', async (req, res) => {
|
|
795
800
|
try {
|
|
796
|
-
const into =
|
|
801
|
+
const into = bodyName(req.body?.into).trim()
|
|
797
802
|
const rawFrom = req.body?.from
|
|
798
803
|
const from = (Array.isArray(rawFrom) ? rawFrom : [rawFrom])
|
|
799
804
|
.filter((n: unknown): n is string => typeof n === 'string' && n.trim().length > 0)
|
|
800
|
-
.map((n: string) => n.trim())
|
|
805
|
+
.map((n: string) => nfc(n.trim()))
|
|
801
806
|
|
|
802
807
|
if (!into || from.length === 0) {
|
|
803
808
|
return res.status(400).json({ error: 'into (string) and from (string or string[]) are required' })
|
|
804
809
|
}
|
|
805
|
-
|
|
810
|
+
// The same name twice is refused, unless the store holds it twice (a profile written decomposed and its composed twin): those are two
|
|
811
|
+
// profiles and merging them is the point.
|
|
812
|
+
if (from.some(n => sameSpeakerName(n, into)) && readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, into)).length < 2) {
|
|
806
813
|
return res.status(400).json({ error: 'into and from must differ' })
|
|
807
814
|
}
|
|
808
815
|
|
|
809
816
|
const owner = getOwnerSpeakerLabel()
|
|
810
|
-
if (from.
|
|
817
|
+
if (from.some(n => sameSpeakerName(n, owner))) {
|
|
811
818
|
// Absorbing the owner label would delete the profile the live
|
|
812
819
|
// identification path checks FIRST, on every chunk.
|
|
813
820
|
return res.status(400).json({
|
|
@@ -907,11 +914,11 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
907
914
|
if (!name || typeof name !== 'string' || name.trim().length < 2) {
|
|
908
915
|
return res.status(400).json({ error: 'name is required (min 2 chars)' })
|
|
909
916
|
}
|
|
910
|
-
const target = name.trim()
|
|
917
|
+
const target = nfc(name.trim())
|
|
911
918
|
const dryRun = req.body?.dryRun === true
|
|
912
919
|
|
|
913
920
|
if (req.body?.confirm !== true && !dryRun) {
|
|
914
|
-
const existing = readVoiceProfiles().profiles.
|
|
921
|
+
const existing = readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, target))
|
|
915
922
|
const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
|
|
916
923
|
let wavs = 0
|
|
917
924
|
if (audioDir && existsSync(audioDir)) {
|
|
@@ -922,8 +929,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
922
929
|
error: 'confirmation required',
|
|
923
930
|
message: `Deleting "${target}" is not reversible. Pass { confirm: true } to proceed.`,
|
|
924
931
|
wouldRemove: {
|
|
925
|
-
profile: existing
|
|
926
|
-
embeddings: existing
|
|
932
|
+
profile: existing.length,
|
|
933
|
+
embeddings: existing.reduce((n, p) => n + p.embeddings.length, 0),
|
|
927
934
|
trainingAudioFiles: wavs,
|
|
928
935
|
calibrationRows: calibration.removed,
|
|
929
936
|
},
|
|
@@ -931,7 +938,7 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
931
938
|
}
|
|
932
939
|
|
|
933
940
|
if (dryRun) {
|
|
934
|
-
const existing = readVoiceProfiles().profiles.
|
|
941
|
+
const existing = readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, target))
|
|
935
942
|
const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
|
|
936
943
|
let wavs = 0
|
|
937
944
|
if (audioDir && existsSync(audioDir)) {
|
|
@@ -942,8 +949,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
942
949
|
name: target,
|
|
943
950
|
dryRun: true,
|
|
944
951
|
removed: {
|
|
945
|
-
profiles: existing
|
|
946
|
-
embeddings: existing
|
|
952
|
+
profiles: existing.length,
|
|
953
|
+
embeddings: existing.reduce((n, p) => n + p.embeddings.length, 0),
|
|
947
954
|
trainingAudioFiles: wavs,
|
|
948
955
|
calibrationRows: calibration.removed,
|
|
949
956
|
},
|