@gotcos/glasses-server 6.95.0 → 6.98.1
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 +166 -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 +763 -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 +572 -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,548 @@
|
|
|
1
|
+
// 6.96.3: on-click voice suggestions for a saved Mac recording's anonymous voices.
|
|
2
|
+
//
|
|
3
|
+
// WHY. A 6 s Mac chunk almost never clears the .88 bar automatic names use, so Mac recordings stay full of
|
|
4
|
+
// `Voice N`. The fresh-ear study (speaker-diarization-claude-20261030/fresh-ear-192-v1) judged whole voices
|
|
5
|
+
// against the shared 192d profiles instead: on 12 voices Miles judged by ear, the rule below gave 10 right,
|
|
6
|
+
// 0 wrong and 2 no suggestion, with 0 false names when the person was removed from the profiles.
|
|
7
|
+
//
|
|
8
|
+
// A SUGGESTION, NEVER A NAME. Every result carries `requiresConfirmation`. A person confirms through the
|
|
9
|
+
// normal naming path, which is the only thing that writes. Automatic names keep their own rule untouched:
|
|
10
|
+
// this file does not read or change meeting-speaker-policy.ts, identifySpeaker, SEARCH_THRESHOLD or
|
|
11
|
+
// AUTO_ENROLL_THRESHOLD.
|
|
12
|
+
//
|
|
13
|
+
// ON CLICK ONLY. POST /meeting/:sessionId/voice-suggestions is the one caller of `start`. Nothing here runs
|
|
14
|
+
// from GET /speakers, at save, on a sweep or at startup.
|
|
15
|
+
//
|
|
16
|
+
// WRITES NOTHING. No profile, label, sidecar or vector file. Results live in memory, bound to the meeting's
|
|
17
|
+
// sourceRevision, and vectors are dropped as soon as a voice is decided. A restart means clicking again.
|
|
18
|
+
//
|
|
19
|
+
// UNITS AND GATES ARE THE STUDY'S. The per-chunk rule is shared with mac-voice-assignment.ts. Parity with the
|
|
20
|
+
// study's predictions.json is proven by a canary on copied audio; see the 6.96.3 report.
|
|
21
|
+
//
|
|
22
|
+
// EVERY LABEL IS SCORED ON ITS OWN. The study scored single labels and ran no groups. A pooled group lets a large
|
|
23
|
+
// voice carry a small one: 37 units of one person and 3 of a stranger pass as one voice, and a batch would then
|
|
24
|
+
// name the stranger and write wrong samples. So a display group's labels merge into one suggestion only after each
|
|
25
|
+
// passed the rule alone for the same person, at the lowest score among them. Nothing else is ever listed.
|
|
26
|
+
//
|
|
27
|
+
// A RESULT EXPIRES. It counts only for the meeting revision and the profile store it was computed from. A chunk a
|
|
28
|
+
// person renamed is skipped: POST /relabel patches `chunks[]`, not `chunkEntries`, so an entry's label can be stale.
|
|
29
|
+
//
|
|
30
|
+
// DOES NOT STALL THE SERVER. Embedding is a synchronous native call (about 130 ms a unit). The job yields to
|
|
31
|
+
// the event loop between units, as mac-assign does, and stops at a 6 minute deadline. A recording or a
|
|
32
|
+
// maintenance drain that starts mid-run ends it as `failed` with that reason. Dictation only pauses it, because a
|
|
33
|
+
// person dictates often and a job that dies each time could never finish.
|
|
34
|
+
//
|
|
35
|
+
// REFUSED IS AN ANSWER, NOT A STATE. A click that cannot start gets `refused` from POST and leaves nothing behind,
|
|
36
|
+
// so GET never reports a recording that ended an hour ago. GET is idle, running, done or failed.
|
|
37
|
+
//
|
|
38
|
+
// HOW IT STOPS. One job at a time machine-wide, one user click per job, the deadline, and the live-work
|
|
39
|
+
// probes. No LLM, no network, no retained data.
|
|
40
|
+
|
|
41
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
42
|
+
import { chunkEmbeddingsEnabled } from './chunk-embedding-store.js'
|
|
43
|
+
import { dataPath } from './data-dir.js'
|
|
44
|
+
import { maintenanceLifecycle } from './maintenance-lifecycle.js'
|
|
45
|
+
import {
|
|
46
|
+
MAC_ASSIGN_MAX_UNITS,
|
|
47
|
+
MAC_ASSIGN_MIN_SINGLE_SEC,
|
|
48
|
+
MAC_ASSIGN_MIN_UNITS,
|
|
49
|
+
MAC_ASSIGN_MIXED_FRACTION,
|
|
50
|
+
macVoiceChunkUnits,
|
|
51
|
+
macVoiceTurnsOf,
|
|
52
|
+
macVoiceUnitAudio,
|
|
53
|
+
spreadMacVoiceUnits,
|
|
54
|
+
type MacVoiceSlice,
|
|
55
|
+
} from './mac-voice-assignment.js'
|
|
56
|
+
import { meetingAudioChunkPath } from './meeting-audio-archive.js'
|
|
57
|
+
import { attachRawChunkIndices, type ReviewChunk } from './meeting-speaker-review.js'
|
|
58
|
+
import { getOwnerSpeakerLabel } from './profile.js'
|
|
59
|
+
import { extractEmbedding, isEmbeddingAvailable, readVoiceProfiles } from './speaker-embeddings.js'
|
|
60
|
+
import { SPEAKER_SHADOW_LIVE_LEASES } from './speaker-shadow.js'
|
|
61
|
+
import { dominantCoherentCluster } from './voice-enrolment-selection.js'
|
|
62
|
+
import { VOICEPRINT_MIN_AUDIO_SEC } from './voiceprint-floor.js'
|
|
63
|
+
|
|
64
|
+
/** Frozen in fresh-ear-192-v1/policy.json before the fresh run. Not a tuning knob. */
|
|
65
|
+
export const VOICE_SUGGESTION_FLOOR = 0.7
|
|
66
|
+
export const VOICE_SUGGESTION_MARGIN = 0.05
|
|
67
|
+
/** Marks a suggestion the batch naming flow may offer. Scores from .70 to .80 stay plain suggestions. Names nothing by itself. */
|
|
68
|
+
export const HIGH_CONFIDENCE_FLOOR = 0.8
|
|
69
|
+
export const HIGH_CONFIDENCE_MARGIN = 0.05
|
|
70
|
+
/** Never suggested, though they stay in the bank: their score still has to lose to the suggested person's. */
|
|
71
|
+
export const VOICE_SUGGESTION_NEVER_NAMES: readonly string[] = ['MU']
|
|
72
|
+
export const VOICE_SUGGESTION_DEADLINE_MS = 6 * 60_000
|
|
73
|
+
export const VOICE_SUGGESTION_MODEL_DIM = 192
|
|
74
|
+
const RESULT_CACHE_MAX = 16
|
|
75
|
+
const PLAN_YIELD_EVERY = 16
|
|
76
|
+
const PAUSE_POLL_MS = 250
|
|
77
|
+
|
|
78
|
+
export type VoiceSuggestionState = 'idle' | 'running' | 'done' | 'refused' | 'failed'
|
|
79
|
+
export type VoiceSuggestionGate = 'mixed_voice' | 'too_little_audio' | 'no_dominant_voice' | 'no_usable_audio' | 'below_rule'
|
|
80
|
+
export type VoiceSuggestionBlock = 'recording_active' | 'maintenance' | 'meeting_finalizing'
|
|
81
|
+
|
|
82
|
+
export interface VoiceSuggestion {
|
|
83
|
+
labels: string[]
|
|
84
|
+
name: string
|
|
85
|
+
score: number
|
|
86
|
+
margin: number
|
|
87
|
+
units: number
|
|
88
|
+
kept: number
|
|
89
|
+
requiresConfirmation: true
|
|
90
|
+
/** Unrounded score and margin both clear the high-confidence bar. Only a voice past every gate has a suggestion at all. */
|
|
91
|
+
highConfidence: boolean
|
|
92
|
+
}
|
|
93
|
+
export interface VoiceSuggestionUnmatched { labels: string[]; gate: VoiceSuggestionGate }
|
|
94
|
+
|
|
95
|
+
export interface VoiceSuggestionsBody {
|
|
96
|
+
state: VoiceSuggestionState
|
|
97
|
+
reason?: string
|
|
98
|
+
progress?: { done: number; total: number }
|
|
99
|
+
rule: { floor: number; margin: number }
|
|
100
|
+
modelSpace: '192d'
|
|
101
|
+
sourceRevision: string
|
|
102
|
+
suggestions?: VoiceSuggestion[]
|
|
103
|
+
unmatched?: VoiceSuggestionUnmatched[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── The bank ─────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export interface StoredVoiceProfile { name: string; embeddings: ArrayLike<number>[]; sources?: string[] }
|
|
109
|
+
export interface VoiceBankEntry { name: string; centroid: number[]; samples: number }
|
|
110
|
+
|
|
111
|
+
function toUnit(vector: ArrayLike<number>): number[] | null {
|
|
112
|
+
const out = new Array<number>(vector.length)
|
|
113
|
+
let sum = 0
|
|
114
|
+
for (let i = 0; i < vector.length; i++) {
|
|
115
|
+
const value = vector[i]
|
|
116
|
+
if (!Number.isFinite(value)) return null
|
|
117
|
+
out[i] = value
|
|
118
|
+
sum += value * value
|
|
119
|
+
}
|
|
120
|
+
if (!(sum > 0)) return null
|
|
121
|
+
const norm = Math.sqrt(sum)
|
|
122
|
+
for (let i = 0; i < out.length; i++) out[i] /= norm
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function meanDirection(vectors: number[][]): number[] | null {
|
|
127
|
+
if (vectors.length === 0) return null
|
|
128
|
+
const sum = new Array<number>(vectors[0].length).fill(0)
|
|
129
|
+
for (const vector of vectors) for (let i = 0; i < sum.length; i++) sum[i] += vector[i]
|
|
130
|
+
return toUnit(sum)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Unit-normalized mean per profile, from 192d samples only. Samples whose source contains this capture's id
|
|
135
|
+
* are dropped first, so a name given on this very call cannot vouch for itself. A profile with nothing left
|
|
136
|
+
* leaves the bank; MU stays in it.
|
|
137
|
+
*/
|
|
138
|
+
export function buildVoiceBank(profiles: readonly StoredVoiceProfile[], captureId: string): VoiceBankEntry[] {
|
|
139
|
+
const bank: VoiceBankEntry[] = []
|
|
140
|
+
for (const profile of profiles) {
|
|
141
|
+
const units: number[][] = []
|
|
142
|
+
profile.embeddings.forEach((embedding, i) => {
|
|
143
|
+
if (embedding.length !== VOICE_SUGGESTION_MODEL_DIM) return
|
|
144
|
+
if (String(profile.sources?.[i] ?? '').includes(captureId)) return
|
|
145
|
+
const unit = toUnit(embedding)
|
|
146
|
+
if (unit) units.push(unit)
|
|
147
|
+
})
|
|
148
|
+
const centroid = meanDirection(units)
|
|
149
|
+
if (centroid) bank.push({ name: profile.name, centroid, samples: units.length })
|
|
150
|
+
}
|
|
151
|
+
return bank
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function dot(a: readonly number[], b: readonly number[]): number {
|
|
155
|
+
let sum = 0
|
|
156
|
+
for (let i = 0; i < a.length; i++) sum += a[i] * b[i]
|
|
157
|
+
return sum
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── One voice's decision ─────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
export interface VoiceUnitVector { unit: number[]; sec: number }
|
|
163
|
+
|
|
164
|
+
export interface VoiceDecision {
|
|
165
|
+
labels: string[]
|
|
166
|
+
units: number
|
|
167
|
+
kept: number
|
|
168
|
+
keptSec: number
|
|
169
|
+
/** Null means this voice earned a suggestion. */
|
|
170
|
+
gate: VoiceSuggestionGate | null
|
|
171
|
+
/** Unrounded, for tests and the parity canary. Present whenever any audio survived the coherence gate. */
|
|
172
|
+
top: { name: string; score: number } | null
|
|
173
|
+
margin: number | null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const sameName = (a: string, b: string): boolean => a.normalize('NFKC').trim().toLowerCase() === b.normalize('NFKC').trim().toLowerCase()
|
|
177
|
+
|
|
178
|
+
/** MU, and whoever this Mac's owner label says the wearer is. A Mac recording has no microphone on the owner. */
|
|
179
|
+
export function neverSuggested(name: string, ownerLabel?: string): boolean {
|
|
180
|
+
return VOICE_SUGGESTION_NEVER_NAMES.some(never => sameName(never, name)) || (!!ownerLabel && sameName(ownerLabel, name))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The frozen rule, on unrounded values. */
|
|
184
|
+
export function passesVoiceSuggestionRule(score: number, margin: number): boolean {
|
|
185
|
+
return score >= VOICE_SUGGESTION_FLOOR && margin >= VOICE_SUGGESTION_MARGIN
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** On unrounded values, so a .7996 that displays as .800 is not high confidence. */
|
|
189
|
+
export function isHighConfidenceVoiceSuggestion(score: number, margin: number): boolean {
|
|
190
|
+
return score >= HIGH_CONFIDENCE_FLOOR && margin >= HIGH_CONFIDENCE_MARGIN
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Gates in the study's order, then the frozen rule. Pure. */
|
|
194
|
+
export function decideVoiceSuggestion(labels: string[], vectors: readonly VoiceUnitVector[], bank: readonly VoiceBankEntry[], ownerLabel?: string): VoiceDecision {
|
|
195
|
+
const none = (gate: VoiceSuggestionGate): VoiceDecision => ({ labels, units: vectors.length, kept: 0, keptSec: 0, gate, top: null, margin: null })
|
|
196
|
+
if (vectors.length === 0) return none('no_usable_audio')
|
|
197
|
+
const cluster = dominantCoherentCluster(vectors.map(v => Float32Array.from(v.unit)))
|
|
198
|
+
if (cluster.members.length === 0) return none('no_dominant_voice')
|
|
199
|
+
const kept = cluster.members.map(i => vectors[i])
|
|
200
|
+
const keptSec = kept.reduce((sum, v) => sum + v.sec, 0)
|
|
201
|
+
const voice = meanDirection(kept.map(v => v.unit))
|
|
202
|
+
const ranked = voice ? bank.map(entry => ({ name: entry.name, score: dot(voice, entry.centroid) })).sort((a, b) => b.score - a.score) : []
|
|
203
|
+
const top = ranked[0] ?? null
|
|
204
|
+
const margin = ranked.length >= 2 ? ranked[0].score - ranked[1].score : null
|
|
205
|
+
let gate: VoiceSuggestionGate | null = null
|
|
206
|
+
if (kept.length < vectors.length && kept.length / vectors.length < MAC_ASSIGN_MIXED_FRACTION) gate = 'mixed_voice'
|
|
207
|
+
else if (kept.length < MAC_ASSIGN_MIN_UNITS && keptSec < MAC_ASSIGN_MIN_SINGLE_SEC) gate = 'too_little_audio'
|
|
208
|
+
else if (!top || margin === null || !passesVoiceSuggestionRule(top.score, margin) || neverSuggested(top.name, ownerLabel)) gate = 'below_rule'
|
|
209
|
+
return { labels, units: vectors.length, kept: kept.length, keptSec, gate, top, margin }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Which audio ──────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
export interface VoiceSidecarEntry { chunkIndex: number; chunk: Record<string, unknown> }
|
|
215
|
+
export interface VoiceUnitRef { raw: number; slice: MacVoiceSlice | null }
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The `chunkEntries` rows that still speak for their audio. Nothing else addresses retained audio: a chunk's array
|
|
219
|
+
* position is not its WAV number. POST /relabel patches `chunks[]` and leaves these rows alone, so a row whose current
|
|
220
|
+
* chunk says otherwise is stale and is skipped for every voice: left in, a chunk renamed away from Voice 3 stays a
|
|
221
|
+
* Voice 3 unit, and what is left of Voice 3 can be suggested as the person it was just split from. A silent row has no
|
|
222
|
+
* chunk to disagree with and stays. Null when the two lists cannot be lined up: a shifted mapping would be worse.
|
|
223
|
+
*/
|
|
224
|
+
export function voiceSidecarEntries(sidecar: Record<string, unknown>): VoiceSidecarEntry[] | null {
|
|
225
|
+
const chunks = sidecar.chunks
|
|
226
|
+
if (!Array.isArray(chunks)) return null
|
|
227
|
+
const mapped = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
|
|
228
|
+
if (mapped === chunks) return null
|
|
229
|
+
const current = new Map(mapped.map(chunk => [chunk.chunkIndex, chunk.speaker]))
|
|
230
|
+
const rows = sidecar.chunkEntries as Array<Record<string, unknown>>
|
|
231
|
+
return rows
|
|
232
|
+
.filter((row): row is Record<string, unknown> & VoiceSidecarEntry => typeof row?.chunkIndex === 'number' && !!row.chunk && typeof row.chunk === 'object')
|
|
233
|
+
.filter(row => !current.has(row.chunkIndex) || current.get(row.chunkIndex) === row.chunk.speaker)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Every candidate unit of one voice, in call order, before the duration filter and the spread. */
|
|
237
|
+
export function voiceUnitCandidates(entries: readonly VoiceSidecarEntry[], label: string): VoiceUnitRef[] {
|
|
238
|
+
const out: VoiceUnitRef[] = []
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
const turns = macVoiceTurnsOf(entry.chunk)
|
|
241
|
+
if (entry.chunk.speaker !== label && !turns.some(t => t.speaker === label)) continue
|
|
242
|
+
const units = macVoiceChunkUnits(turns, label)
|
|
243
|
+
if (units === 'whole') out.push({ raw: entry.chunkIndex, slice: null })
|
|
244
|
+
else for (const slice of units) out.push({ raw: entry.chunkIndex, slice })
|
|
245
|
+
}
|
|
246
|
+
return out.sort((a, b) => a.raw - b.raw || (a.slice?.startSec ?? 0) - (b.slice?.startSec ?? 0))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Probes ───────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/** Live work that pauses a job rather than ending it. Everything else in SPEAKER_SHADOW_LIVE_LEASES ends it. */
|
|
252
|
+
export const VOICE_SUGGESTION_PAUSE_LEASES: readonly string[] = ['one_shot_transcription', 'prompt_draft_write', 'prompt_draft_warm', 'prompt_draft_finalize']
|
|
253
|
+
|
|
254
|
+
const defaultActiveByKind = (): Record<string, number> => maintenanceLifecycle.snapshot().activeByKind as Record<string, number>
|
|
255
|
+
|
|
256
|
+
export function voiceSuggestionRecordingLease(activeByKind: () => Record<string, number> = defaultActiveByKind): boolean {
|
|
257
|
+
const byKind = activeByKind()
|
|
258
|
+
return SPEAKER_SHADOW_LIVE_LEASES.some(kind => !VOICE_SUGGESTION_PAUSE_LEASES.includes(kind) && (byKind[kind] ?? 0) > 0)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function voiceSuggestionPauseLease(activeByKind: () => Record<string, number> = defaultActiveByKind): boolean {
|
|
262
|
+
const byKind = activeByKind()
|
|
263
|
+
return VOICE_SUGGESTION_PAUSE_LEASES.some(kind => (byKind[kind] ?? 0) > 0)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── The job ──────────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
export interface VoiceSuggestionDeps {
|
|
269
|
+
now: () => number
|
|
270
|
+
yieldToLoop: () => Promise<void>
|
|
271
|
+
sleep: (ms: number) => Promise<void>
|
|
272
|
+
/** The speaker runtime is up and the embedding master switch is on. */
|
|
273
|
+
available: () => boolean
|
|
274
|
+
/** Changes whenever the profile store does: delete-person, merge-profiles, any enrolment. */
|
|
275
|
+
storeFingerprint: () => string
|
|
276
|
+
extract: (audio: Buffer, unit: VoiceUnitRef) => Float32Array | null
|
|
277
|
+
readProfiles: () => { profiles: StoredVoiceProfile[] }
|
|
278
|
+
audioPath: (sessionId: string, raw: number) => string | null
|
|
279
|
+
ownerLabel: () => string | undefined
|
|
280
|
+
log: (line: string) => void
|
|
281
|
+
/** Sees each voice's unrounded decision. For the parity canary; the server passes nothing. */
|
|
282
|
+
observe?: (decision: VoiceDecision) => void
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function defaultVoiceSuggestionDeps(): VoiceSuggestionDeps {
|
|
286
|
+
return {
|
|
287
|
+
now: Date.now,
|
|
288
|
+
yieldToLoop: () => new Promise<void>(resolve => setImmediate(resolve)),
|
|
289
|
+
sleep: ms => new Promise<void>(resolve => { setTimeout(resolve, ms) }),
|
|
290
|
+
available: () => chunkEmbeddingsEnabled() && isEmbeddingAvailable(),
|
|
291
|
+
storeFingerprint: () => { try { const info = statSync(dataPath('voice-profiles.json')); return `${info.ino}:${info.size}:${info.mtimeMs}` } catch { return 'none' } },
|
|
292
|
+
extract: audio => extractEmbedding(audio),
|
|
293
|
+
readProfiles: readVoiceProfiles,
|
|
294
|
+
audioPath: meetingAudioChunkPath,
|
|
295
|
+
ownerLabel: () => { try { return getOwnerSpeakerLabel() } catch { return undefined } },
|
|
296
|
+
log: line => console.log(`[voice-suggestions] ${line}`),
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface VoiceSuggestionStartInput {
|
|
301
|
+
sessionId: string
|
|
302
|
+
sourceRevision: string
|
|
303
|
+
entries: VoiceSidecarEntry[]
|
|
304
|
+
/** Read once the job has started, so the click answers before a large review is built. */
|
|
305
|
+
loadScopes: () => Promise<string[][]>
|
|
306
|
+
/** Why a job may not start or continue right now. Called before every unit. */
|
|
307
|
+
block: () => VoiceSuggestionBlock | null
|
|
308
|
+
/** Live work a job waits out instead of ending. */
|
|
309
|
+
pause: () => boolean
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface Job {
|
|
313
|
+
sessionId: string
|
|
314
|
+
sourceRevision: string
|
|
315
|
+
storeFingerprint: string
|
|
316
|
+
state: 'running' | 'done' | 'failed'
|
|
317
|
+
reason?: string
|
|
318
|
+
progress: { done: number; total: number }
|
|
319
|
+
suggestions?: VoiceSuggestion[]
|
|
320
|
+
unmatched?: VoiceSuggestionUnmatched[]
|
|
321
|
+
cancelled: boolean
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
class JobStop extends Error {
|
|
325
|
+
constructor(readonly state: 'failed' | 'cancelled', readonly reason: string) { super(reason) }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const round3 = (value: number): number => Math.round(value * 1000) / 1000
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* What a job says, from each label's own decision. Within one scope, labels that each passed the rule for the same
|
|
332
|
+
* person become one suggestion at their lowest score and margin, high confidence only if all of them are. A label that
|
|
333
|
+
* passed for someone else gets its own suggestion. A label that did not pass is unmatched with its own gate, and is
|
|
334
|
+
* never listed on a suggestion. Pure.
|
|
335
|
+
*/
|
|
336
|
+
export function combineVoiceDecisions(scopes: readonly string[][], decisions: ReadonlyMap<string, VoiceDecision>): { suggestions: VoiceSuggestion[]; unmatched: VoiceSuggestionUnmatched[] } {
|
|
337
|
+
const suggestions: VoiceSuggestion[] = []
|
|
338
|
+
const unmatched: VoiceSuggestionUnmatched[] = []
|
|
339
|
+
for (const scope of scopes) {
|
|
340
|
+
const passed = new Map<string, Array<VoiceDecision & { top: { name: string; score: number }; margin: number }>>()
|
|
341
|
+
for (const label of scope) {
|
|
342
|
+
const decision = decisions.get(label)
|
|
343
|
+
if (!decision || decision.gate !== null || !decision.top || decision.margin === null) {
|
|
344
|
+
unmatched.push({ labels: [label], gate: decision?.gate ?? 'no_usable_audio' })
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
const name = decision.top.name
|
|
348
|
+
passed.set(name, [...(passed.get(name) ?? []), { ...decision, top: decision.top, margin: decision.margin }])
|
|
349
|
+
}
|
|
350
|
+
for (const [name, members] of passed) {
|
|
351
|
+
suggestions.push({
|
|
352
|
+
labels: members.flatMap(member => member.labels),
|
|
353
|
+
name,
|
|
354
|
+
score: round3(Math.min(...members.map(member => member.top.score))),
|
|
355
|
+
margin: round3(Math.min(...members.map(member => member.margin))),
|
|
356
|
+
units: members.reduce((sum, member) => sum + member.units, 0),
|
|
357
|
+
kept: members.reduce((sum, member) => sum + member.kept, 0),
|
|
358
|
+
requiresConfirmation: true,
|
|
359
|
+
highConfidence: members.every(member => isHighConfidenceVoiceSuggestion(member.top.score, member.margin)),
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return { suggestions, unmatched }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export class VoiceSuggestionRunner {
|
|
367
|
+
private readonly deps: VoiceSuggestionDeps
|
|
368
|
+
private readonly jobs = new Map<string, Job>()
|
|
369
|
+
private active: Job | null = null
|
|
370
|
+
|
|
371
|
+
constructor(deps: Partial<VoiceSuggestionDeps> = {}) {
|
|
372
|
+
this.deps = { ...defaultVoiceSuggestionDeps(), ...deps }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private idle(sourceRevision: string): VoiceSuggestionsBody {
|
|
376
|
+
return { state: 'idle', rule: { floor: VOICE_SUGGESTION_FLOOR, margin: VOICE_SUGGESTION_MARGIN }, modelSpace: '192d', sourceRevision }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private body(job: Job): VoiceSuggestionsBody {
|
|
380
|
+
return {
|
|
381
|
+
...this.idle(job.sourceRevision),
|
|
382
|
+
state: job.state,
|
|
383
|
+
...(job.reason ? { reason: job.reason } : {}),
|
|
384
|
+
...(job.state === 'running' || job.state === 'done' ? { progress: { ...job.progress } } : {}),
|
|
385
|
+
...(job.suggestions ? { suggestions: job.suggestions } : {}),
|
|
386
|
+
...(job.unmatched ? { unmatched: job.unmatched } : {}),
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** POST's answer when a click cannot start. Remembers nothing, so GET does not repeat it. */
|
|
391
|
+
refusal(reason: string, sourceRevision: string): VoiceSuggestionsBody {
|
|
392
|
+
return { ...this.idle(sourceRevision), state: 'refused', reason }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private remember(job: Job): void {
|
|
396
|
+
this.jobs.delete(job.sessionId)
|
|
397
|
+
this.jobs.set(job.sessionId, job)
|
|
398
|
+
while (this.jobs.size > RESULT_CACHE_MAX) this.jobs.delete(this.jobs.keys().next().value as string)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The state of a job in flight, so polling does not re-hash a large meeting. Null when none is running. */
|
|
402
|
+
running(sessionId: string): VoiceSuggestionsBody | null {
|
|
403
|
+
const job = this.jobs.get(sessionId)
|
|
404
|
+
return job?.state === 'running' && !job.cancelled ? this.body(job) : null
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** A result counts only for the revision it was computed from. Starts nothing. */
|
|
408
|
+
read(sessionId: string, sourceRevision: string): VoiceSuggestionsBody {
|
|
409
|
+
const job = this.jobs.get(sessionId)
|
|
410
|
+
if (!job || job.cancelled || job.sourceRevision !== sourceRevision) return this.idle(sourceRevision)
|
|
411
|
+
if (job.state !== 'running' && job.storeFingerprint !== this.deps.storeFingerprint()) return this.idle(sourceRevision)
|
|
412
|
+
return this.body(job)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The result of a finished job, as a copy. Null for anything else, including a job still running. */
|
|
416
|
+
finished(sessionId: string): { sourceRevision: string; suggestions: VoiceSuggestion[] } | null {
|
|
417
|
+
const job = this.jobs.get(sessionId)
|
|
418
|
+
if (job?.state !== 'done' || job.cancelled || !job.suggestions || job.storeFingerprint !== this.deps.storeFingerprint()) return null
|
|
419
|
+
return { sourceRevision: job.sourceRevision, suggestions: job.suggestions.map(suggestion => ({ ...suggestion, labels: [...suggestion.labels] })) }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Called by POST and nothing else. Returns the current job when there is one for this revision and profile store. */
|
|
423
|
+
start(input: VoiceSuggestionStartInput): VoiceSuggestionsBody {
|
|
424
|
+
const storeFingerprint = this.deps.storeFingerprint()
|
|
425
|
+
const existing = this.jobs.get(input.sessionId)
|
|
426
|
+
if (existing && !existing.cancelled && existing.sourceRevision === input.sourceRevision && existing.storeFingerprint === storeFingerprint && (existing.state === 'running' || existing.state === 'done')) return this.body(existing)
|
|
427
|
+
const refuse = (reason: string): VoiceSuggestionsBody => this.refusal(reason, input.sourceRevision)
|
|
428
|
+
const blocked = input.block()
|
|
429
|
+
if (blocked) return refuse(blocked)
|
|
430
|
+
if (!this.deps.available()) return refuse('speaker_model_unavailable')
|
|
431
|
+
if (!input.entries.some(entry => this.deps.audioPath(input.sessionId, entry.chunkIndex))) return refuse('audio_not_retained')
|
|
432
|
+
let bank: VoiceBankEntry[]
|
|
433
|
+
try { bank = buildVoiceBank(this.deps.readProfiles().profiles, input.sessionId) } catch { return refuse('profile_store_unreadable') }
|
|
434
|
+
if (bank.length < 2) return refuse('not_enough_voice_profiles')
|
|
435
|
+
// The same meeting at a newer revision or profile store replaces its own stale job; another meeting has to wait.
|
|
436
|
+
if (this.active && this.active.sessionId !== input.sessionId) return refuse('job_running_elsewhere')
|
|
437
|
+
if (this.active) this.active.cancelled = true
|
|
438
|
+
|
|
439
|
+
const job: Job = { sessionId: input.sessionId, sourceRevision: input.sourceRevision, storeFingerprint, state: 'running', progress: { done: 0, total: 0 }, cancelled: false }
|
|
440
|
+
this.active = job
|
|
441
|
+
this.remember(job)
|
|
442
|
+
void this.run(job, input, bank).catch(error => this.settle(job, 'internal_error', error))
|
|
443
|
+
return this.body(job)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private settle(job: Job, reason: string, error?: unknown): void {
|
|
447
|
+
if (job.cancelled) return
|
|
448
|
+
job.state = 'failed'
|
|
449
|
+
job.reason = reason
|
|
450
|
+
if (this.active === job) this.active = null
|
|
451
|
+
this.deps.log(`failed ${job.sessionId} (${reason})${error ? `: ${error instanceof Error ? error.message : String(error)}` : ''}`)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private async run(job: Job, input: VoiceSuggestionStartInput, bank: VoiceBankEntry[]): Promise<void> {
|
|
455
|
+
const { now, yieldToLoop, sleep } = this.deps
|
|
456
|
+
const started = now()
|
|
457
|
+
const deadline = started + VOICE_SUGGESTION_DEADLINE_MS
|
|
458
|
+
/** Between units: end on anything that says stop, wait out dictation, then let the server breathe. */
|
|
459
|
+
const checkpoint = async (): Promise<void> => {
|
|
460
|
+
for (;;) {
|
|
461
|
+
if (job.cancelled) throw new JobStop('cancelled', 'superseded')
|
|
462
|
+
if (now() >= deadline) throw new JobStop('failed', 'deadline')
|
|
463
|
+
const blocked = input.block()
|
|
464
|
+
if (blocked) throw new JobStop('failed', blocked)
|
|
465
|
+
if (!input.pause()) break
|
|
466
|
+
await sleep(PAUSE_POLL_MS)
|
|
467
|
+
}
|
|
468
|
+
await yieldToLoop()
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
await yieldToLoop()
|
|
472
|
+
const scopes = await input.loadScopes()
|
|
473
|
+
const ownerLabel = this.deps.ownerLabel()
|
|
474
|
+
|
|
475
|
+
// Plan first: each label's 40 units are spread over audio that can actually be used, as in the study.
|
|
476
|
+
const planned: Array<{ label: string; units: Array<VoiceUnitRef & { sec: number }> }> = []
|
|
477
|
+
let reads = 0
|
|
478
|
+
for (const label of new Set(scopes.flat())) {
|
|
479
|
+
const usable: Array<VoiceUnitRef & { sec: number }> = []
|
|
480
|
+
for (const unit of voiceUnitCandidates(input.entries, label)) {
|
|
481
|
+
if (++reads % PLAN_YIELD_EVERY === 0) await checkpoint()
|
|
482
|
+
const path = this.deps.audioPath(input.sessionId, unit.raw)
|
|
483
|
+
if (!path) continue
|
|
484
|
+
let cut: { sec: number } | null = null
|
|
485
|
+
try { cut = macVoiceUnitAudio(readFileSync(path), unit.slice) } catch { continue }
|
|
486
|
+
if (cut && cut.sec >= VOICEPRINT_MIN_AUDIO_SEC) usable.push({ ...unit, sec: cut.sec })
|
|
487
|
+
}
|
|
488
|
+
planned.push({ label, units: spreadMacVoiceUnits(usable, MAC_ASSIGN_MAX_UNITS) })
|
|
489
|
+
}
|
|
490
|
+
job.progress.total = planned.reduce((sum, plan) => sum + plan.units.length, 0)
|
|
491
|
+
|
|
492
|
+
const decisions = new Map<string, VoiceDecision>()
|
|
493
|
+
for (const plan of planned) {
|
|
494
|
+
const vectors: VoiceUnitVector[] = []
|
|
495
|
+
for (const unit of plan.units) {
|
|
496
|
+
await checkpoint()
|
|
497
|
+
const path = this.deps.audioPath(input.sessionId, unit.raw)
|
|
498
|
+
let embedding: Float32Array | null = null
|
|
499
|
+
if (path) {
|
|
500
|
+
let cut: { audio: Buffer; sec: number } | null = null
|
|
501
|
+
try { cut = macVoiceUnitAudio(readFileSync(path), unit.slice) } catch { /* evicted mid-run */ }
|
|
502
|
+
if (cut) embedding = this.deps.extract(cut.audio, { raw: unit.raw, slice: unit.slice })
|
|
503
|
+
}
|
|
504
|
+
job.progress.done += 1
|
|
505
|
+
if (embedding?.length !== VOICE_SUGGESTION_MODEL_DIM) continue
|
|
506
|
+
const asUnit = toUnit(embedding)
|
|
507
|
+
if (asUnit) vectors.push({ unit: asUnit, sec: unit.sec })
|
|
508
|
+
}
|
|
509
|
+
const decision = decideVoiceSuggestion([plan.label], vectors, bank, ownerLabel)
|
|
510
|
+
this.deps.observe?.(decision)
|
|
511
|
+
decisions.set(plan.label, decision)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (job.cancelled) throw new JobStop('cancelled', 'superseded')
|
|
515
|
+
const result = combineVoiceDecisions(scopes, decisions)
|
|
516
|
+
job.suggestions = result.suggestions
|
|
517
|
+
job.unmatched = result.unmatched
|
|
518
|
+
job.state = 'done'
|
|
519
|
+
if (this.active === job) this.active = null
|
|
520
|
+
this.deps.log(`done ${job.sessionId} in ${now() - started} ms: units=${job.progress.done} suggestions=${job.suggestions.length} unmatched=${job.unmatched.length}`)
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (error instanceof JobStop) {
|
|
523
|
+
if (error.state === 'cancelled') { if (this.active === job) this.active = null; return }
|
|
524
|
+
this.settle(job, error.reason)
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
throw error
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let shared: VoiceSuggestionRunner | null = null
|
|
533
|
+
/** One per server process, which is what makes it one job at a time machine-wide. */
|
|
534
|
+
export function getVoiceSuggestionRunner(): VoiceSuggestionRunner {
|
|
535
|
+
shared ??= new VoiceSuggestionRunner()
|
|
536
|
+
return shared
|
|
537
|
+
}
|
|
538
|
+
export function resetVoiceSuggestionRunnerForTests(runner: VoiceSuggestionRunner | null = null): void {
|
|
539
|
+
shared = runner
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The in-memory result of this session's finished job, or null. Batch naming reads this to confirm each item is a
|
|
544
|
+
* current high-confidence suggestion; it must still compare `sourceRevision` with the meeting as it is now.
|
|
545
|
+
*/
|
|
546
|
+
export function currentVoiceSuggestions(sessionId: string): { sourceRevision: string; suggestions: VoiceSuggestion[] } | null {
|
|
547
|
+
return shared?.finished(sessionId) ?? null
|
|
548
|
+
}
|
|
@@ -623,7 +623,8 @@ export class MediaStore {
|
|
|
623
623
|
: prepared.mime === 'application/pdf' ? 'pdf'
|
|
624
624
|
: prepared.mime === 'text/markdown' ? 'md'
|
|
625
625
|
: prepared.mime === 'text/csv' ? 'csv'
|
|
626
|
-
: prepared.mime === 'application/json' ? 'json'
|
|
626
|
+
: prepared.mime === 'application/json' ? 'json'
|
|
627
|
+
: prepared.mime === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ? 'xlsx' : 'txt'
|
|
627
628
|
const derivatives = prepared.category === 'video' ? prepared.frames : prepared.pageImages
|
|
628
629
|
const ref: MediaAttachmentRef = {
|
|
629
630
|
id,
|