@gotcos/glasses-server 6.95.0 → 6.98.0
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 +157 -0
- package/README.md +4 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +12 -0
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +44 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +453 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +570 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +46 -27
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -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 +187 -21
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- 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-shadow-decide.ts +59 -22
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +2 -2
- package/server/lib/speaker-shadow-worker.ts +50 -15
- package/server/lib/speaker-shadow.ts +80 -19
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +10 -5
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +515 -61
- package/server/routes/session-find.ts +73 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +28 -18
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/agent-sessions/find (Control 0.5.363): which of this Mac's agent sessions the person means, by meaning.
|
|
3
|
+
* Auth comes from the parent /api mount. See lib/session-find.ts for what leaves the Mac, the cost controls and the gates.
|
|
4
|
+
*
|
|
5
|
+
* Body `{ query, consentedProvider, stage }`, exactly those keys. `query` is a string; the words are redacted and cut at 200
|
|
6
|
+
* code points before use. `consentedProvider` is `"jev"`, `"subq"` or `null`: the provider the person agreed to send to.
|
|
7
|
+
* `stage` is `"first"` (the newest 254 sessions) or `"full"` (the next 254 too, unless the first already settles it).
|
|
8
|
+
*
|
|
9
|
+
* A search that cannot run answers HTTP 200 with `available: false`, a `reason` and a `message`, so Control keeps its word
|
|
10
|
+
* search and says one line about why. A malformed body is 400. A walk that fails (the session store, the keyed-sessions file) is
|
|
11
|
+
* also a 200 with `reason: failed`, so Control keeps its word search. 503 `session_find_unavailable` is left for an unexpected
|
|
12
|
+
* error outside the finder's own handling: try later.
|
|
13
|
+
* An older server answers 404, which Control reads as "no meaning search here".
|
|
14
|
+
*
|
|
15
|
+
* A closed connection stops the walk and any stage that has not started. A model request already sent is not aborted (it is
|
|
16
|
+
* billed either way). The query and the sessions never reach a log line.
|
|
17
|
+
*/
|
|
18
|
+
import { Router, type Response } from 'express'
|
|
19
|
+
import { isAbortError } from '../lib/session-find-collect.js'
|
|
20
|
+
import { SESSION_FIND_LIMITS, SessionFinder, unavailableAnswer, type SessionFindAnswer } from '../lib/session-find.js'
|
|
21
|
+
|
|
22
|
+
export interface SessionFindRouteDependencies {
|
|
23
|
+
finder: Pick<SessionFinder, 'find'>
|
|
24
|
+
now: () => number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const BODY_KEYS = new Set(['query', 'consentedProvider', 'stage'])
|
|
28
|
+
/** One log line per degrade reason per 10 minutes: Control falls back quietly, so the server says why. Fixed words only, never the query or a session. */
|
|
29
|
+
export const SESSION_FIND_LOG_EVERY_MS = 10 * 60_000
|
|
30
|
+
|
|
31
|
+
export function createSessionFindRouter(overrides: Partial<SessionFindRouteDependencies> = {}): Router {
|
|
32
|
+
let finder: Pick<SessionFinder, 'find'> | null = overrides.finder ?? null
|
|
33
|
+
const now = overrides.now ?? Date.now
|
|
34
|
+
const lastLogged = new Map<string, number>()
|
|
35
|
+
const logDegrade = (reason: string) => {
|
|
36
|
+
const at = now()
|
|
37
|
+
const last = lastLogged.get(reason)
|
|
38
|
+
if (last !== undefined && at - last < SESSION_FIND_LOG_EVERY_MS) return
|
|
39
|
+
lastLogged.set(reason, at)
|
|
40
|
+
console.warn(`[session-find] unavailable: ${reason}`)
|
|
41
|
+
}
|
|
42
|
+
const router = Router()
|
|
43
|
+
router.use('/agent-sessions/find', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
44
|
+
|
|
45
|
+
router.post('/agent-sessions/find', async (req, res: Response) => {
|
|
46
|
+
const body = req.body
|
|
47
|
+
const bad = () => res.status(400).json({ error: { code: 'invalid_find_request',
|
|
48
|
+
message: 'Send a query, the provider you consented to (jev, subq or null) and a stage of first or full.' } })
|
|
49
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(key => !BODY_KEYS.has(key))) return bad()
|
|
50
|
+
if (typeof body.query !== 'string' || body.query.length > SESSION_FIND_LIMITS.queryRawMax) return bad()
|
|
51
|
+
if (body.consentedProvider !== null && body.consentedProvider !== undefined && body.consentedProvider !== 'jev' && body.consentedProvider !== 'subq') return bad()
|
|
52
|
+
if (body.stage !== 'first' && body.stage !== 'full') return bad()
|
|
53
|
+
|
|
54
|
+
const controller = new AbortController()
|
|
55
|
+
// `close` also fires when a response has finished normally; only a connection that closed before the answer counts.
|
|
56
|
+
res.on('close', () => { if (!res.writableEnded) controller.abort() })
|
|
57
|
+
let answer: SessionFindAnswer
|
|
58
|
+
try {
|
|
59
|
+
finder ??= new SessionFinder()
|
|
60
|
+
answer = await finder.find({ query: body.query, consentedProvider: body.consentedProvider ?? null, stage: body.stage }, controller.signal)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (isAbortError(error) || controller.signal.aborted) return
|
|
63
|
+
console.error('[session-find] search failed')
|
|
64
|
+
return res.status(503).json({ error: { code: 'session_find_unavailable', message: 'Meaning search could not read your sessions. Try again shortly.' } })
|
|
65
|
+
}
|
|
66
|
+
if (controller.signal.aborted) return
|
|
67
|
+
if (!answer.available && answer.reason) logDegrade(answer.reason)
|
|
68
|
+
return res.json(answer)
|
|
69
|
+
})
|
|
70
|
+
return router
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { unavailableAnswer }
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { takeMacSpeakerLivePublication } from '../lib/mac-speaker-live-compute.js'
|
|
2
|
+
import type { MacSpeakerLivePreparedPublication } from '../lib/mac-speaker-live-worker.js'
|
|
3
|
+
import { createMacSpeakerLiveScheduler, macSpeakerLiveEnabled, assertMacSpeakerLivePublicationBindings, type MacSpeakerLiveOutcome, type MacSpeakerLiveFileBinding } from '../lib/mac-speaker-live.js'
|
|
4
|
+
import { hashMacSpeakerLive, type MacSpeakerLiveState } from '../lib/mac-speaker-live-memory.js'
|
|
5
|
+
import { macSpeakerLiveOriginalOnly, macSpeakerLiveVoiceHighWater } from '../lib/mac-speaker-live-store.js'
|
|
1
6
|
import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, admitPhoneRecording, phoneQueryInteger, requirePhoneRecording, readPhoneRecording, validatePhoneWav, persistPhoneChunkReceipt } from '../lib/phone-recording.js'
|
|
2
7
|
import { matchMacLiveVoice, macAnonymousProjection, type MacLiveVoiceState } from '../lib/mac-live-voices.js'
|
|
3
8
|
import { chunkShowsVoiceLabel, macLiveVoicesMode } from '../lib/mac-voice-labels.js'
|
|
@@ -15,7 +20,7 @@ import { readSpeakerPolicy, policyLabel, type SpeakerMode } from '../lib/meeting
|
|
|
15
20
|
// Streams speaker-labeled transcript chunks for live meeting capture.
|
|
16
21
|
|
|
17
22
|
import { Router } from 'express'
|
|
18
|
-
import { createHash } from 'node:crypto'
|
|
23
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
19
24
|
import { chmodSync, readFileSync, writeFileSync, existsSync, lstatSync, mkdirSync, readdirSync, unlinkSync, rmSync, renameSync, statSync } from 'node:fs'
|
|
20
25
|
import { writeFile } from 'node:fs/promises'
|
|
21
26
|
import { resolve } from 'node:path'
|
|
@@ -86,6 +91,7 @@ import {
|
|
|
86
91
|
type EvenSpeakerRoleHistogram,
|
|
87
92
|
} from '../lib/even-hub-speaker-role.js'
|
|
88
93
|
import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
|
|
94
|
+
import { sweepStalePendingBatch } from '../lib/pending-batch-retention.js'
|
|
89
95
|
import {
|
|
90
96
|
DISCARDED_RETENTION_MS,
|
|
91
97
|
countChunkWavs,
|
|
@@ -339,6 +345,18 @@ export interface ProviderCandidateRecord {
|
|
|
339
345
|
}
|
|
340
346
|
|
|
341
347
|
interface TranscriptSession {
|
|
348
|
+
/** Capture-frozen opt-in; original ASR chunks remain immutable inputs. */
|
|
349
|
+
speakerIdentityPolicy?: 'mac-native-v1'
|
|
350
|
+
speakerGeneration?: string
|
|
351
|
+
speakerRevision?: number
|
|
352
|
+
speakerProjectionSHA256?: string
|
|
353
|
+
macSpeakerLive?: MacSpeakerLiveState
|
|
354
|
+
macSpeakerLiveQuarantineSHA256?: string
|
|
355
|
+
/** Ephemeral: recovered evidence cannot publish before asynchronous revalidation. */
|
|
356
|
+
macSpeakerLiveValidated?: boolean
|
|
357
|
+
macSpeakerLiveSourceBindingSHA256?: string
|
|
358
|
+
macSpeakerLiveFileBindings?: MacSpeakerLiveFileBinding[]
|
|
359
|
+
macSpeakerLiveStatus?: {reason:string;latencyMs:number;pendingWindows:number;completeEligibleSnapshot:boolean}
|
|
342
360
|
chunks: TranscriptChunk[]
|
|
343
361
|
/** Bounded anonymous acoustic anchors; active capture only, never training. */
|
|
344
362
|
macLiveVoices?: MacLiveVoiceState
|
|
@@ -370,6 +388,44 @@ interface TranscriptSession {
|
|
|
370
388
|
}
|
|
371
389
|
|
|
372
390
|
const sessions = new Map<string, TranscriptSession>()
|
|
391
|
+
function liveIndexed(session:TranscriptSession){return session.chunks.flatMap((c,i)=>c?.text?[{i,c}]:[])}
|
|
392
|
+
function liveSnapshot(captureId:string){
|
|
393
|
+
const s=sessions.get(captureId)
|
|
394
|
+
if(!maintenanceAdmissionsOpen()||!macSpeakerLiveEnabled()||!s||s.speakerIdentityPolicy!=='mac-native-v1'||!s.speakerGeneration||isSessionDeleted(captureId)||macChunkLocks.has(captureId))return null
|
|
395
|
+
return{captureId,generation:s.speakerGeneration,audioDir:resolve(SESSION_AUDIO_DIR,captureId),ledgerPath:dataPath('meeting-corrections',`${captureId}.jsonl`),
|
|
396
|
+
chunksIndexed:structuredClone(liveIndexed(s)) as unknown as {i:number;c:Record<string,unknown>}[],state:s.macSpeakerLive?structuredClone(s.macSpeakerLive):undefined,
|
|
397
|
+
voiceHighWater:macSpeakerLiveVoiceHighWater(s.chunks,Math.max(s.macWindowVoices?.voices??0,Number.isSafeInteger(s.macSpeakerLive?.voiceHighWater)?s.macSpeakerLive!.voiceHighWater:0)),revision:s.speakerRevision??0,previousDigest:s.speakerProjectionSHA256}
|
|
398
|
+
}
|
|
399
|
+
function commitMacSpeakerLive(captureId:string,outcome:MacSpeakerLiveOutcome):boolean{
|
|
400
|
+
const s=sessions.get(captureId)
|
|
401
|
+
if(!s||s.speakerIdentityPolicy!=='mac-native-v1'||!s.speakerGeneration||isSessionDeleted(captureId)||macChunkLocks.has(captureId)||hashMacSpeakerLive(liveIndexed(s))!==outcome.sourceBindingSHA256)return false
|
|
402
|
+
if(outcome.state&&(outcome.state.captureId!==captureId||outcome.state.generation!==s.speakerGeneration))return false
|
|
403
|
+
try{if(outcome.completeEligibleSnapshot)assertMacSpeakerLivePublicationBindings(outcome.fileBindings)}catch{
|
|
404
|
+
s.macSpeakerLiveValidated=false;s.macSpeakerLiveStatus={reason:'publication_binding_changed',latencyMs:outcome.latencyMs,pendingWindows:0,completeEligibleSnapshot:false};persistSessionBestEffort(captureId);return false
|
|
405
|
+
}
|
|
406
|
+
const certified=takeMacSpeakerLivePublication(outcome.capability,{captureId,generation:s.speakerGeneration,sourceBindingSHA256:outcome.sourceBindingSHA256,fileBindingsSHA256:hashMacSpeakerLive(outcome.fileBindings),revision:s.speakerRevision??0,
|
|
407
|
+
voiceHighWater:macSpeakerLiveVoiceHighWater(s.chunks,Math.max(s.macWindowVoices?.voices??0,Number.isSafeInteger(s.macSpeakerLive?.voiceHighWater)?s.macSpeakerLive!.voiceHighWater:0)),mode:'mac-native-v1'})
|
|
408
|
+
if(outcome.completeEligibleSnapshot&&!certified)return false
|
|
409
|
+
const previous={source:s.macSpeakerLiveSourceBindingSHA256,files:s.macSpeakerLiveFileBindings,state:s.macSpeakerLive,validated:s.macSpeakerLiveValidated,status:s.macSpeakerLiveStatus,window:s.macWindowVoices?structuredClone(s.macWindowVoices):undefined,revision:s.speakerRevision,digest:s.speakerProjectionSHA256}
|
|
410
|
+
if(certified)s.macSpeakerLive=certified.macSpeakerLive??outcome.state
|
|
411
|
+
s.macSpeakerLiveSourceBindingSHA256=outcome.sourceBindingSHA256;s.macSpeakerLiveFileBindings=outcome.fileBindings
|
|
412
|
+
s.macSpeakerLiveValidated=maintenanceAdmissionsOpen()&&macSpeakerLiveEnabled()&&!!certified&&!!outcome.state&&outcome.completeEligibleSnapshot
|
|
413
|
+
s.macSpeakerLiveStatus={reason:outcome.reason,latencyMs:outcome.latencyMs,pendingWindows:outcome.pendingWindows,completeEligibleSnapshot:outcome.completeEligibleSnapshot}
|
|
414
|
+
if(s.macWindowVoices&&s.macSpeakerLive)s.macWindowVoices.voices=Math.max(s.macWindowVoices.voices,s.macSpeakerLive.voiceHighWater)
|
|
415
|
+
try{persistSessionRequired(captureId,certified??undefined)}catch{Object.assign(s,{macSpeakerLiveSourceBindingSHA256:previous.source,macSpeakerLiveFileBindings:previous.files,macSpeakerLive:previous.state,macSpeakerLiveValidated:previous.validated,macSpeakerLiveStatus:previous.status,macWindowVoices:previous.window,speakerRevision:previous.revision,speakerProjectionSHA256:previous.digest});return false}
|
|
416
|
+
// Dedicated invalidation cursor; consumers fetch the durable full replacement.
|
|
417
|
+
emitDisplay({type:'mac_speaker_live_revision',data:{sessionId:captureId,speakerIdentityPolicy:'mac-native-v1',generation:s.speakerGeneration,speakerRevision:s.speakerRevision,speakerProjectionSHA256:s.speakerProjectionSHA256}})
|
|
418
|
+
return true
|
|
419
|
+
}
|
|
420
|
+
const macSpeakerLiveScheduler=createMacSpeakerLiveScheduler({snapshot:liveSnapshot,commit:commitMacSpeakerLive,lease:()=>acquireMaintenanceWork('mac_speaker_live'),runtime:{admit:captureId=>maintenanceAdmissionsOpen()&&macSpeakerLiveEnabled()&&sessions.has(captureId)&&!isSessionDeleted(captureId)&&!macChunkLocks.has(captureId)}})
|
|
421
|
+
export const __macSpeakerLiveForTests={snapshot:liveSnapshot,commit:commitMacSpeakerLive,persist:(id:string)=>persistSessionRequired(id),drain:()=>macSpeakerLiveScheduler.drain()}
|
|
422
|
+
/** Save consumers receive only the durable, fully validated exact-source snapshot. */
|
|
423
|
+
export function getMacSpeakerLiveSaveSnapshot(captureId:string):{state:MacSpeakerLiveState;sourceBindingSHA256:string}|null{
|
|
424
|
+
const s=sessions.get(captureId);if(!macSpeakerLiveEnabled()||!s?.macSpeakerLiveValidated||!s.macSpeakerLive||s.macSpeakerLiveStatus?.completeEligibleSnapshot!==true||s.macSpeakerLiveSourceBindingSHA256!==hashMacSpeakerLive(liveIndexed(s)))return null
|
|
425
|
+
try{assertMacSpeakerLivePublicationBindings(s.macSpeakerLiveFileBindings??[])}catch{return null}
|
|
426
|
+
return{state:structuredClone(s.macSpeakerLive),sourceBindingSHA256:hashMacSpeakerLive(liveIndexed(s))}
|
|
427
|
+
}
|
|
428
|
+
|
|
373
429
|
const CLOSED_SESSION_TTL_MS = LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
|
|
374
430
|
const CLOSED_SESSIONS_FILE = dataPath('closed-transcript-sessions.json')
|
|
375
431
|
|
|
@@ -704,7 +760,7 @@ function recoverClosedSessions(): void {
|
|
|
704
760
|
|
|
705
761
|
/** Persist a session before acknowledging any chunk. Throws on failure so a
|
|
706
762
|
* client never interprets a non-durable index as accepted. */
|
|
707
|
-
function persistSessionRequired(sessionId: string): void {
|
|
763
|
+
function persistSessionRequired(sessionId: string, certified?:MacSpeakerLivePreparedPublication): void {
|
|
708
764
|
const session = sessions.get(sessionId)
|
|
709
765
|
if (!session) throw makeHttpError(404, 'session not found', 'session_not_found')
|
|
710
766
|
const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
|
|
@@ -715,8 +771,23 @@ function persistSessionRequired(sessionId: string): void {
|
|
|
715
771
|
const c = session.chunks[i]
|
|
716
772
|
if (c && c.text) chunksIndexed.push({ i, c })
|
|
717
773
|
}
|
|
774
|
+
if(session.macSpeakerLiveValidated){
|
|
775
|
+
let reason:string|undefined
|
|
776
|
+
if(!macSpeakerLiveEnabled())reason='feature_disabled'
|
|
777
|
+
else if(session.macSpeakerLiveSourceBindingSHA256!==hashMacSpeakerLive(chunksIndexed))reason='new_source_pending'
|
|
778
|
+
else try{assertMacSpeakerLivePublicationBindings(session.macSpeakerLiveFileBindings??[])}catch{reason='resource_binding_changed'}
|
|
779
|
+
if(reason){session.macSpeakerLiveValidated=false;session.macSpeakerLiveStatus={reason,latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}}
|
|
780
|
+
}
|
|
781
|
+
const live = session.speakerIdentityPolicy === 'mac-native-v1' && session.speakerGeneration
|
|
782
|
+
? (session.macSpeakerLiveValidated?certified:undefined)??macSpeakerLiveOriginalOnly({captureId:sessionId,generation:session.speakerGeneration,chunksIndexed:chunksIndexed as unknown as {i:number;c:Record<string,unknown>}[],state:session.macSpeakerLive,revision:session.speakerRevision??0,previousDigest:session.speakerProjectionSHA256}) : null
|
|
783
|
+
const projectionWithdrawn=!!session.macSpeakerLive?.projections.length&&!live?.macSpeakerLive?.projections.length
|
|
784
|
+
const liveStatus=live?.macSpeakerLive?.withheldReason?{reason:live.macSpeakerLive.withheldReason,latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}
|
|
785
|
+
:projectionWithdrawn&&session.macSpeakerLiveValidated?{reason:'projection_binding_changed',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}
|
|
786
|
+
:!live?.macSpeakerLive?{reason:!macSpeakerLiveEnabled()?'feature_disabled':session.macSpeakerLiveStatus?.reason??'awaiting_qualified_evidence',latencyMs:session.macSpeakerLiveStatus?.latencyMs??0,pendingWindows:session.macSpeakerLiveStatus?.pendingWindows??0,completeEligibleSnapshot:false}
|
|
787
|
+
:session.macSpeakerLiveStatus??{reason:'awaiting_qualified_evidence',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}
|
|
718
788
|
const data = JSON.stringify({
|
|
719
789
|
sessionId,
|
|
790
|
+
...(live ? {speakerIdentityPolicy:live.speakerIdentityPolicy,sourceType:live.sourceType,speakerGeneration:live.speakerGeneration,speakerRevision:live.speakerRevision,speakerProjectionSHA256:live.speakerProjectionSHA256,speakerProjectedChunksIndexed:live.speakerProjectedChunksIndexed,...(live.macSpeakerLive?{macSpeakerLive:live.macSpeakerLive}:{}),...('macSpeakerLiveRetainedState'in live?{macSpeakerLiveRetainedState:live.macSpeakerLiveRetainedState}:{}),...(('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined)?{macSpeakerLiveQuarantineSHA256:('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined)}:{}),...(session.macSpeakerLiveQuarantineSHA256?{macSpeakerLiveQuarantineSHA256:session.macSpeakerLiveQuarantineSHA256}:{}),macSpeakerLiveStatus:liveStatus} : {}),
|
|
720
791
|
...(PHONE_CAPTURE_ID.test(sessionId) ? { captureSource: requirePhoneRecording(sessionId), sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' } : {}),
|
|
721
792
|
startTime: session.startTime,
|
|
722
793
|
lastActivityAt: session.lastActivityAt,
|
|
@@ -736,7 +807,11 @@ function persistSessionRequired(sessionId: string): void {
|
|
|
736
807
|
...(session.macWindowVoices !== undefined ? { macWindowVoices: session.macWindowVoices } : {}),
|
|
737
808
|
})
|
|
738
809
|
try {
|
|
810
|
+
if(certified&&session.macSpeakerLiveValidated){try{assertMacSpeakerLivePublicationBindings(session.macSpeakerLiveFileBindings??[])}catch{session.macSpeakerLiveValidated=false;session.macSpeakerLiveStatus={reason:'resource_binding_changed',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false};return persistSessionRequired(sessionId)}}
|
|
739
811
|
durableAtomicWriteFileSync(filePath, data, { mode: 0o600 })
|
|
812
|
+
if(live){session.macSpeakerLiveStatus=liveStatus;if(projectionWithdrawn)session.macSpeakerLiveValidated=false;session.macSpeakerLive=live.macSpeakerLive??('macSpeakerLiveRetainedState'in live?live.macSpeakerLiveRetainedState as MacSpeakerLiveState:undefined);session.speakerRevision=live.speakerRevision;session.speakerProjectionSHA256=live.speakerProjectionSHA256
|
|
813
|
+
if(('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined))session.macSpeakerLiveQuarantineSHA256=('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined)
|
|
814
|
+
if(live.invalid)session.macSpeakerLiveValidated=false}
|
|
740
815
|
} catch (error) {
|
|
741
816
|
console.error(`[transcribe-stream] Durable session write failed for ${sessionId}: ${errMsg(error)}`)
|
|
742
817
|
throw makeHttpError(503, 'meeting session persistence unavailable', 'session_persistence_failed')
|
|
@@ -918,6 +993,12 @@ function recoverSessions(): void {
|
|
|
918
993
|
for (const key of Object.keys(emptyCompletions)) insertSortedUnique(asrCompletedIndices, Number(key))
|
|
919
994
|
const session: TranscriptSession = {
|
|
920
995
|
chunks,
|
|
996
|
+
...(MAC_CAPTURE_ID.test(data.sessionId)&&data.speakerIdentityPolicy==='mac-native-v1'?{
|
|
997
|
+
speakerIdentityPolicy:'mac-native-v1' as const,speakerGeneration:typeof data.speakerGeneration==='string'&&data.speakerGeneration.length>0&&data.speakerGeneration.length<=128?data.speakerGeneration:randomUUID(),
|
|
998
|
+
speakerRevision:typeof data.speakerRevision==='number'?data.speakerRevision:Number.NaN,
|
|
999
|
+
speakerProjectionSHA256:data.speakerProjectionSHA256,macSpeakerLive:data.macSpeakerLiveRetainedState??data.macSpeakerLive,macSpeakerLiveValidated:false,
|
|
1000
|
+
macSpeakerLiveStatus:{reason:'recovery_revalidation_pending',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false},
|
|
1001
|
+
}:{}),
|
|
921
1002
|
startTime: data.startTime,
|
|
922
1003
|
title: data.title || '',
|
|
923
1004
|
lastActivityAt,
|
|
@@ -957,6 +1038,20 @@ function recoverSessions(): void {
|
|
|
957
1038
|
}
|
|
958
1039
|
sessions.set(data.sessionId, session)
|
|
959
1040
|
recoveredIds.add(data.sessionId)
|
|
1041
|
+
if(session.speakerIdentityPolicy==='mac-native-v1'){
|
|
1042
|
+
// Clear previously exposed claims durably before asynchronous model,
|
|
1043
|
+
// profile, ledger and every retained raw binding are revalidated.
|
|
1044
|
+
try{persistSessionRequired(data.sessionId)}catch(error){
|
|
1045
|
+
sessions.delete(data.sessionId);recoveredIds.delete(data.sessionId);rememberDeletedSession(data.sessionId)
|
|
1046
|
+
// Preserve evidence outside the directory read as active claims.
|
|
1047
|
+
// Do not adopt a capture whose mandatory withdrawal was not durable.
|
|
1048
|
+
ensurePrivateDirectory(STALE_ACTIVE_SESSION_DIR)
|
|
1049
|
+
renameSync(resolve(CHUNK_PERSIST_DIR,file),resolve(STALE_ACTIVE_SESSION_DIR,`${data.sessionId}.speaker-revalidation-${Date.now()}.json`))
|
|
1050
|
+
console.error(`[session-recovery] Speaker revalidation clear failed; capture quarantined: ${data.sessionId}`)
|
|
1051
|
+
continue
|
|
1052
|
+
}
|
|
1053
|
+
if(macSpeakerLiveEnabled())setImmediate(()=>macSpeakerLiveScheduler.schedule(data.sessionId))
|
|
1054
|
+
}
|
|
960
1055
|
if (cleaned > 0) {
|
|
961
1056
|
console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
|
|
962
1057
|
persistSessionBestEffort(data.sessionId) // re-persist cleaned data to disk
|
|
@@ -1269,30 +1364,7 @@ setInterval(() => {
|
|
|
1269
1364
|
try {
|
|
1270
1365
|
const batchBusy = ((maintenanceLifecycle.snapshot().activeByKind as Record<string, number>)
|
|
1271
1366
|
.meeting_batch_finalization ?? 0) > 0
|
|
1272
|
-
|
|
1273
|
-
const dirPath = resolve(PENDING_BATCH_DIR, dir)
|
|
1274
|
-
try {
|
|
1275
|
-
const files = readdirSync(dirPath)
|
|
1276
|
-
if (files.length === 0) { rmSync(dirPath, { recursive: true, force: true }); continue }
|
|
1277
|
-
// Prefer the marker file mtime over first chunk mtime
|
|
1278
|
-
const markerPath = resolve(dirPath, '_batch_pending.marker')
|
|
1279
|
-
let ageSource: number
|
|
1280
|
-
if (existsSync(markerPath)) {
|
|
1281
|
-
ageSource = statSync(markerPath).mtimeMs
|
|
1282
|
-
} else {
|
|
1283
|
-
// Fallback for pre-v5.4.3 dirs: use directory ctime (changes on rename)
|
|
1284
|
-
ageSource = statSync(dirPath).ctimeMs
|
|
1285
|
-
}
|
|
1286
|
-
if (Date.now() - ageSource > 12 * 60 * 60 * 1000) {
|
|
1287
|
-
if (batchBusy) {
|
|
1288
|
-
console.warn(`[cleanup] Retaining stale pending-batch while batch lease held: ${dir}`)
|
|
1289
|
-
continue
|
|
1290
|
-
}
|
|
1291
|
-
rmSync(dirPath, { recursive: true, force: true })
|
|
1292
|
-
console.log(`[cleanup] Purged stale pending-batch: ${dir}`)
|
|
1293
|
-
}
|
|
1294
|
-
} catch {}
|
|
1295
|
-
}
|
|
1367
|
+
sweepStalePendingBatch(PENDING_BATCH_DIR, { now: Date.now(), batchBusy })
|
|
1296
1368
|
} catch {}
|
|
1297
1369
|
|
|
1298
1370
|
// Meeting audio for review: 7-day window, then an 8 GB budget as a backstop.
|
|
@@ -1385,13 +1457,19 @@ setInterval(() => {
|
|
|
1385
1457
|
}, 60_000)
|
|
1386
1458
|
|
|
1387
1459
|
/** Get or create a transcript session */
|
|
1388
|
-
export function getSession(sessionId: string): TranscriptSession {
|
|
1460
|
+
export function getSession(sessionId: string, admission: {newMacRegistry?:boolean} = {}): TranscriptSession {
|
|
1389
1461
|
const phone = sessionId.startsWith('phone_') ? requirePhoneRecording(sessionId) : null
|
|
1390
1462
|
let session = sessions.get(sessionId)
|
|
1391
1463
|
if (!session) {
|
|
1464
|
+
// The durable registry proves this capture was already admitted. A missing
|
|
1465
|
+
// or unreadable active snapshot is not permission to reset its generation,
|
|
1466
|
+
// acoustic evidence or Voice namespace. Only the initial registry creator
|
|
1467
|
+
// may create a new Mac session; replayed starts must recover existing state.
|
|
1468
|
+
if(MAC_CAPTURE_ID.test(sessionId)&&readMacRecording(sessionId)&&!admission.newMacRegistry)throw macError(409,'mac_speaker_state_missing','Capture state is unavailable; preserve this recording and start a new capture')
|
|
1392
1469
|
const now = Date.now()
|
|
1393
1470
|
session = {
|
|
1394
1471
|
chunks: [],
|
|
1472
|
+
...(MAC_CAPTURE_ID.test(sessionId)&&macSpeakerLiveEnabled()?{speakerIdentityPolicy:'mac-native-v1' as const,speakerGeneration:randomUUID(),speakerRevision:0,macSpeakerLiveValidated:false}:{}),
|
|
1395
1473
|
startTime: phone?.startTime ?? now,
|
|
1396
1474
|
lastActivityAt: now,
|
|
1397
1475
|
title: '',
|
|
@@ -2478,6 +2556,7 @@ async function processStreamChunk(opts: {
|
|
|
2478
2556
|
const macWindowWithheld = macWindowOn ? macWindowWithheldReason(session.macWindowVoices, session.chunks) : null
|
|
2479
2557
|
if (macWindowWithheld) noteMacWindowWithheld(sessionId, macWindowWithheld)
|
|
2480
2558
|
const macWindowState = macWindowOn ? macWindowStateFor(session.macWindowVoices, session.chunks) : null
|
|
2559
|
+
if(macWindowState&&session.speakerIdentityPolicy==='mac-native-v1')macWindowState.voices=Math.max(macWindowState.voices,session.macSpeakerLive?.voiceHighWater??0)
|
|
2481
2560
|
const macWindow = macWindowState
|
|
2482
2561
|
? startMacWindowDiarize({
|
|
2483
2562
|
sessionId,
|
|
@@ -2510,7 +2589,8 @@ async function processStreamChunk(opts: {
|
|
|
2510
2589
|
const whisperContext = recentWhisperContext(sessionId, session)
|
|
2511
2590
|
console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)
|
|
2512
2591
|
|
|
2513
|
-
const
|
|
2592
|
+
const nativeIdentity = session.speakerIdentityPolicy === 'mac-native-v1'
|
|
2593
|
+
const speakerPromise = Promise.resolve(nativeIdentity ? {speaker:'Ext',similarity:0} : identifyChunkSpeaker(audioBuffer, sessionId, chunkIndex, clientSpeaker, capturePolicy))
|
|
2514
2594
|
|
|
2515
2595
|
let rawText = ''
|
|
2516
2596
|
let words: WhisperWord[] | undefined
|
|
@@ -2603,7 +2683,7 @@ async function processStreamChunk(opts: {
|
|
|
2603
2683
|
owner: ownerLabelOrNull(),
|
|
2604
2684
|
...(macWindow ? {
|
|
2605
2685
|
anonymousChannel: (channel: number) => macWindow.result()?.labelOf(channel) ?? null,
|
|
2606
|
-
} : MAC_CAPTURE_ID.test(sessionId) && macLiveVoicesMode() === 'anchors' ? {
|
|
2686
|
+
} : !nativeIdentity && MAC_CAPTURE_ID.test(sessionId) && macLiveVoicesMode() === 'anchors' ? {
|
|
2607
2687
|
anonymousVoice: (wav: Buffer) => {
|
|
2608
2688
|
// Fail closed after an upgrade/recovery lacking acoustic anchors:
|
|
2609
2689
|
// never reuse Voice 1 for an unrelated person from a later chunk.
|
|
@@ -2614,7 +2694,7 @@ async function processStreamChunk(opts: {
|
|
|
2614
2694
|
} : {}),
|
|
2615
2695
|
identify: wav => {
|
|
2616
2696
|
const policy = capturePolicy
|
|
2617
|
-
if (policy === 'anonymous') return { speaker: 'Ext', similarity: 0 }
|
|
2697
|
+
if (nativeIdentity || policy === 'anonymous') return { speaker: 'Ext', similarity: 0 }
|
|
2618
2698
|
// 6.90.0: a Mac track is never checked against the wearer first (the wearer
|
|
2619
2699
|
// is not in Mac playback) and is tagged so its calibration rows are separable.
|
|
2620
2700
|
const mac = MAC_CAPTURE_ID.test(sessionId)
|
|
@@ -2715,6 +2795,7 @@ async function processStreamChunk(opts: {
|
|
|
2715
2795
|
console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
|
|
2716
2796
|
|
|
2717
2797
|
scheduleSessionProgressiveHq(sessionId)
|
|
2798
|
+
if(nativeIdentity)macSpeakerLiveScheduler.schedule(sessionId)
|
|
2718
2799
|
|
|
2719
2800
|
emitDisplay({
|
|
2720
2801
|
type: 'transcript_chunk',
|
|
@@ -2725,7 +2806,7 @@ async function processStreamChunk(opts: {
|
|
|
2725
2806
|
// bare `void` on a rejecting promise is an unhandled rejection, which Node
|
|
2726
2807
|
// throws on by default. All gates live inside the feed.
|
|
2727
2808
|
// The ASR await above lets a late chunk land after Stop or save; a closed session never feeds cues.
|
|
2728
|
-
if (!isSessionDeleted(sessionId)) void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
|
|
2809
|
+
if (!isSessionDeleted(sessionId)) void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason), [speaker, ...(chunk.segments ?? []).map(segment => segment.speaker)]).catch(() => {})
|
|
2729
2810
|
|
|
2730
2811
|
// 6.85.0: timing and the character count only. This line runs for every chunk, so the meeting
|
|
2731
2812
|
// text itself never goes to the server log (transcribe-stream-log-privacy.test.ts).
|
|
@@ -2884,7 +2965,7 @@ transcribeStreamRouter.post('/mac-recording/start', (req, res) => {
|
|
|
2884
2965
|
if (sessions.has(req.body.captureId) && !readMacRecording(req.body.captureId)) throw macError(409, 'mac_capture_conflict', 'Capture ID already exists')
|
|
2885
2966
|
lease = acquireRecordingChunkLease({ allowDuringDrain: sessions.has(req.body.captureId) })
|
|
2886
2967
|
const { recording, replayed } = registerMacRecording(req.body)
|
|
2887
|
-
const session = getSession(recording.sessionId)
|
|
2968
|
+
const session = getSession(recording.sessionId,{newMacRegistry:!replayed})
|
|
2888
2969
|
if (!session.chunks.some(Boolean)) session.startTime = recording.startTime
|
|
2889
2970
|
persistSessionRequired(recording.sessionId)
|
|
2890
2971
|
// Arming happens only after the recording is admitted, and its answer never fails the start (armMacLiveCues returns a
|
|
@@ -2920,7 +3001,7 @@ transcribeStreamRouter.post('/mac-recording/:captureId/chunks', async (req, res)
|
|
|
2920
3001
|
const prior = session.chunks[chunkIndex] ?? session.emptyCompletions?.[String(chunkIndex)]
|
|
2921
3002
|
if (prior?.audioSha256 && prior.audioSha256 !== sha256Hex(audio)) throw macError(409, 'mac_chunk_conflict', 'Chunk index already holds different audio')
|
|
2922
3003
|
res.json(await processStreamChunk({ sessionId: id, chunkIndex, clientSpeaker: 'Ext', audioBuffer: audio, clientElapsed: elapsed - duration, audioDurationMs: duration, startTimeOverride: recording.startTime }))
|
|
2923
|
-
} catch (error) { sendStreamError(res, error) } finally { if (locked) macChunkLocks.delete(id); lease?.release() }
|
|
3004
|
+
} catch (error) { sendStreamError(res, error) } finally { if (locked) {macChunkLocks.delete(id);if(sessions.get(id)?.speakerIdentityPolicy==='mac-native-v1')macSpeakerLiveScheduler.schedule(id)}; lease?.release() }
|
|
2924
3005
|
})
|
|
2925
3006
|
|
|
2926
3007
|
const phoneChunkLocks = new Set<string>()
|
package/server/routes/voice.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import express, { Router } from 'express'
|
|
4
4
|
import { errMsg } from '../lib/utils.js'
|
|
5
|
+
import { forgetMacVoiceNamingPerson } from '../lib/mac-voice-naming-batch.js'
|
|
5
6
|
import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
|
|
6
7
|
import { resolve } from 'node:path'
|
|
7
8
|
import { checkSpeakerName } from '../lib/speaker-name.js'
|
|
9
|
+
import { bodyName, nfc, sameSpeakerName } from '../lib/speaker-name-nfc.js'
|
|
8
10
|
import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
|
|
9
11
|
import { statSync } from 'node:fs'
|
|
10
12
|
import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
|
|
@@ -51,7 +53,9 @@ voiceRouter.post('/voice/enroll', async (req, res) => {
|
|
|
51
53
|
// Default to the configured wearer label ('Me' unless owner_speaker_label
|
|
52
54
|
// is set). Hardcoding one user's initials here enrolled every other install
|
|
53
55
|
// under a stranger's name.
|
|
54
|
-
|
|
56
|
+
// `?name=a&name=b` is not a name; falling through to the owner's label would enroll a stranger's voice as the owner.
|
|
57
|
+
if (req.query.name !== undefined && typeof req.query.name !== 'string') return res.status(400).json({ error: 'name must be a single value' })
|
|
58
|
+
const name = nfc(typeof req.query.name === 'string' && req.query.name ? req.query.name : getOwnerSpeakerLabel())
|
|
55
59
|
|
|
56
60
|
// An old client can still send a whole spoken sentence as the name (the
|
|
57
61
|
// "enroll my voice" fall-through). Refuse it here: a junk profile can
|
|
@@ -115,7 +119,8 @@ voiceRouter.post('/voice/identify', async (req, res) => {
|
|
|
115
119
|
// POST /api/voice/train — train voiceprints from Fireflies meeting audio
|
|
116
120
|
voiceRouter.post('/voice/train', async (req, res) => {
|
|
117
121
|
try {
|
|
118
|
-
const { speakerNames, minSegments, minSegmentDuration, limit, maxEmbeddingsPerSpeaker, fresh } = req.body ?? {}
|
|
122
|
+
const { speakerNames: rawSpeakerNames, minSegments, minSegmentDuration, limit, maxEmbeddingsPerSpeaker, fresh } = req.body ?? {}
|
|
123
|
+
const speakerNames = Array.isArray(rawSpeakerNames) ? rawSpeakerNames.map((n: unknown) => typeof n === 'string' ? nfc(n) : n) : rawSpeakerNames
|
|
119
124
|
const report = await trainFromFireflies({
|
|
120
125
|
speakerNames,
|
|
121
126
|
minSegments,
|
|
@@ -160,7 +165,7 @@ const DEFAULT_MAX_TRAIN_PER_SPEAKER = 10
|
|
|
160
165
|
|
|
161
166
|
voiceRouter.post('/voice/train-g2', async (req, res) => {
|
|
162
167
|
try {
|
|
163
|
-
const targetSpeaker = req.body?.speaker
|
|
168
|
+
const targetSpeaker = typeof req.body?.speaker === 'string' ? nfc(req.body.speaker) : undefined
|
|
164
169
|
const confirmAll = req.body?.confirmAllSpeakers === true
|
|
165
170
|
const dryRun = req.body?.dryRun === true
|
|
166
171
|
const maxPerSpeaker = Number.isFinite(req.body?.maxPerSpeaker)
|
|
@@ -361,7 +366,8 @@ voiceRouter.get('/voice/ext-audio', (_req, res) => {
|
|
|
361
366
|
// If sessionId provided, only enroll from that session. Otherwise, enroll from all ext sessions.
|
|
362
367
|
voiceRouter.post('/voice/enroll-ext', async (req, res) => {
|
|
363
368
|
try {
|
|
364
|
-
const { name, sessionId } = req.body ?? {}
|
|
369
|
+
const { name: rawName, sessionId } = req.body ?? {}
|
|
370
|
+
const name = typeof rawName === 'string' ? nfc(rawName) : rawName
|
|
365
371
|
if (!name || typeof name !== 'string' || name.length < 2) {
|
|
366
372
|
return res.status(400).json({ error: 'name is required (min 2 chars)' })
|
|
367
373
|
}
|
|
@@ -485,7 +491,7 @@ function newestWav(dirPath: string): string | null {
|
|
|
485
491
|
// GET /api/voice/profiles/:name/sample — hear what a stored profile sounds like.
|
|
486
492
|
voiceRouter.get('/voice/profiles/:name/sample', (req, res) => {
|
|
487
493
|
res.set('Cache-Control', 'private, no-store')
|
|
488
|
-
const name = String(req.params.name ?? '')
|
|
494
|
+
const name = nfc(String(req.params.name ?? ''))
|
|
489
495
|
const dirPath = speakerDirPath(AUDIO_SAVE_DIR, name)
|
|
490
496
|
if (!dirPath) {
|
|
491
497
|
res.status(400).json({ error: 'Invalid speaker name', reason: 'invalid_speaker' })
|
|
@@ -587,7 +593,7 @@ voiceRouter.post('/voice/held-groups/enroll', async (req, res) => {
|
|
|
587
593
|
try {
|
|
588
594
|
const nameCheck = checkSpeakerName(req.body?.name, { ownerLabel: getOwnerSpeakerLabel() })
|
|
589
595
|
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())
|
|
596
|
+
const name = resolveStoredName(nfc(String(req.body.name).trim()))
|
|
591
597
|
// Held samples are audio this Mac captured. A member pointing at an imported
|
|
592
598
|
// or derived record is not held audio, so it never reaches the preview.
|
|
593
599
|
//
|
|
@@ -679,7 +685,7 @@ voiceRouter.get('/voice/profiles', (_req, res) => {
|
|
|
679
685
|
return {
|
|
680
686
|
name: p.name,
|
|
681
687
|
embeddings: p.embeddings.length,
|
|
682
|
-
isOwner: p.name
|
|
688
|
+
isOwner: sameSpeakerName(p.name, owner),
|
|
683
689
|
sources: bySource,
|
|
684
690
|
// 6.44.15: a profile whose name on disk was a spoken sentence was
|
|
685
691
|
// renamed at load; the client offers a rename.
|
|
@@ -793,21 +799,23 @@ function fanOutTotals(runs: readonly SpeakerRenameFanOut[]): {
|
|
|
793
799
|
// knows something the audio does not, and it is logged.
|
|
794
800
|
voiceRouter.post('/voice/merge-profiles', async (req, res) => {
|
|
795
801
|
try {
|
|
796
|
-
const into =
|
|
802
|
+
const into = bodyName(req.body?.into).trim()
|
|
797
803
|
const rawFrom = req.body?.from
|
|
798
804
|
const from = (Array.isArray(rawFrom) ? rawFrom : [rawFrom])
|
|
799
805
|
.filter((n: unknown): n is string => typeof n === 'string' && n.trim().length > 0)
|
|
800
|
-
.map((n: string) => n.trim())
|
|
806
|
+
.map((n: string) => nfc(n.trim()))
|
|
801
807
|
|
|
802
808
|
if (!into || from.length === 0) {
|
|
803
809
|
return res.status(400).json({ error: 'into (string) and from (string or string[]) are required' })
|
|
804
810
|
}
|
|
805
|
-
|
|
811
|
+
// The same name twice is refused, unless the store holds it twice (a profile written decomposed and its composed twin): those are two
|
|
812
|
+
// profiles and merging them is the point.
|
|
813
|
+
if (from.some(n => sameSpeakerName(n, into)) && readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, into)).length < 2) {
|
|
806
814
|
return res.status(400).json({ error: 'into and from must differ' })
|
|
807
815
|
}
|
|
808
816
|
|
|
809
817
|
const owner = getOwnerSpeakerLabel()
|
|
810
|
-
if (from.
|
|
818
|
+
if (from.some(n => sameSpeakerName(n, owner))) {
|
|
811
819
|
// Absorbing the owner label would delete the profile the live
|
|
812
820
|
// identification path checks FIRST, on every chunk.
|
|
813
821
|
return res.status(400).json({
|
|
@@ -907,11 +915,11 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
907
915
|
if (!name || typeof name !== 'string' || name.trim().length < 2) {
|
|
908
916
|
return res.status(400).json({ error: 'name is required (min 2 chars)' })
|
|
909
917
|
}
|
|
910
|
-
const target = name.trim()
|
|
918
|
+
const target = nfc(name.trim())
|
|
911
919
|
const dryRun = req.body?.dryRun === true
|
|
912
920
|
|
|
913
921
|
if (req.body?.confirm !== true && !dryRun) {
|
|
914
|
-
const existing = readVoiceProfiles().profiles.
|
|
922
|
+
const existing = readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, target))
|
|
915
923
|
const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
|
|
916
924
|
let wavs = 0
|
|
917
925
|
if (audioDir && existsSync(audioDir)) {
|
|
@@ -922,8 +930,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
922
930
|
error: 'confirmation required',
|
|
923
931
|
message: `Deleting "${target}" is not reversible. Pass { confirm: true } to proceed.`,
|
|
924
932
|
wouldRemove: {
|
|
925
|
-
profile: existing
|
|
926
|
-
embeddings: existing
|
|
933
|
+
profile: existing.length,
|
|
934
|
+
embeddings: existing.reduce((n, p) => n + p.embeddings.length, 0),
|
|
927
935
|
trainingAudioFiles: wavs,
|
|
928
936
|
calibrationRows: calibration.removed,
|
|
929
937
|
},
|
|
@@ -931,7 +939,7 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
931
939
|
}
|
|
932
940
|
|
|
933
941
|
if (dryRun) {
|
|
934
|
-
const existing = readVoiceProfiles().profiles.
|
|
942
|
+
const existing = readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, target))
|
|
935
943
|
const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
|
|
936
944
|
let wavs = 0
|
|
937
945
|
if (audioDir && existsSync(audioDir)) {
|
|
@@ -942,8 +950,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
942
950
|
name: target,
|
|
943
951
|
dryRun: true,
|
|
944
952
|
removed: {
|
|
945
|
-
profiles: existing
|
|
946
|
-
embeddings: existing
|
|
953
|
+
profiles: existing.length,
|
|
954
|
+
embeddings: existing.reduce((n, p) => n + p.embeddings.length, 0),
|
|
947
955
|
trainingAudioFiles: wavs,
|
|
948
956
|
calibrationRows: calibration.removed,
|
|
949
957
|
},
|
|
@@ -952,6 +960,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
|
|
|
952
960
|
|
|
953
961
|
// 1. Voice profile + sherpa manager registration.
|
|
954
962
|
const profileResult = removeSpeakerProfile(target)
|
|
963
|
+
// A deleted person's voiceprints must not survive in a naming batch's Undo journal.
|
|
964
|
+
forgetMacVoiceNamingPerson(target)
|
|
955
965
|
|
|
956
966
|
// 2. Saved G2 training audio for this person.
|
|
957
967
|
let trainingAudioFiles = 0
|