@gotcos/glasses-server 6.94.2 → 6.94.3

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.
@@ -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
- const name = (req.query.name as string) || getOwnerSpeakerLabel()
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 as string | undefined
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 === owner,
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 = typeof req.body?.into === 'string' ? req.body.into.trim() : ''
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
- if (from.includes(into)) {
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.includes(owner)) {
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.find(p => p.name === target)
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 ? 1 : 0,
926
- embeddings: existing?.embeddings.length ?? 0,
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.find(p => p.name === target)
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 ? 1 : 0,
946
- embeddings: existing?.embeddings.length ?? 0,
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
  },