@gotcos/glasses-server 6.94.4 → 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 +147 -0
- package/README.md +52 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +19 -1
- 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/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 +36 -0
- package/server/lib/job-audit-caller.ts +20 -0
- 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 +44 -26
- 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-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-speaker-review.ts +182 -17
- 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/speaker-shadow-decide.ts +503 -0
- package/server/lib/speaker-shadow-models.ts +150 -0
- package/server/lib/speaker-shadow-numeric.ts +143 -0
- 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 +113 -0
- package/server/lib/speaker-shadow-timeline.ts +210 -0
- package/server/lib/speaker-shadow-worker.ts +312 -0
- package/server/lib/speaker-shadow.ts +788 -0
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +8 -4
- 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/health.ts +5 -0
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +506 -49
- package/server/routes/speaker-shadow.ts +79 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +3 -0
- 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,503 @@
|
|
|
1
|
+
// Speaker merge shadow mode: the research gates plus the Wave-1 anchor gate.
|
|
2
|
+
//
|
|
3
|
+
// SOURCE OF TRUTH. `community-1-fluidaudio-v1/scripts/hybrid.py` `decide()` (round 2) and
|
|
4
|
+
// the report assembly in `round3/propose.py`, on CPython 3.15. Production `Voice N` labels
|
|
5
|
+
// stay the base; FluidAudio's offline Community-1 clusters only PROPOSE that two of them
|
|
6
|
+
// are one person. A voice may join a merge only when all hold:
|
|
7
|
+
// (a) at least `majority` (0.8) of its FluidAudio-covered speech is on one cluster, and
|
|
8
|
+
// at least half of its speech is covered at all;
|
|
9
|
+
// (b) it has at least `floorSeconds` (15 s) of production speech;
|
|
10
|
+
// (c) its window-embedding centroid lies inside the target cluster's own spread,
|
|
11
|
+
// measured leave-fragment-out against the 95th percentile.
|
|
12
|
+
// (d) a nonanchor's centroid distance from the kept voice must be <=1.5 times
|
|
13
|
+
// that anchor's own p95 spread. Missing evidence fails closed. The anchor
|
|
14
|
+
// remains the longest voice passing (a)-(c); a rejected fragment never
|
|
15
|
+
// causes selection of another anchor.
|
|
16
|
+
// Named labels, `Ext` and every non-Voice label are frozen: never merged into or out of.
|
|
17
|
+
//
|
|
18
|
+
// LOG ONLY. Nothing here writes, and nothing calls a writer: the output is a proposal record.
|
|
19
|
+
//
|
|
20
|
+
// Order and rounding are part of the contract: Python dicts iterate in insertion order,
|
|
21
|
+
// max()/most_common(1) keep the FIRST maximum, sorted() is stable, research gates compare
|
|
22
|
+
// ROUNDED values and reason strings use Python %-formatting. Gate(d) uses the raw ratio.
|
|
23
|
+
// The research numerics remain unchanged below,
|
|
24
|
+
// with the Python-compatible numerics in speaker-shadow-numeric.ts.
|
|
25
|
+
|
|
26
|
+
import { PY_VOICE_LABEL, codePointCompare, pyClock, pyFirstInt, pyFixed, pyRound, pyRoundInt, pySum } from './speaker-shadow-numeric.js'
|
|
27
|
+
|
|
28
|
+
export const SHADOW_SAMPLE_RATE = 16_000
|
|
29
|
+
/** Existing round-2 gates plus the separately enforced Wave-1 anchor ratio. */
|
|
30
|
+
export const SHADOW_GATES = Object.freeze({ majority: 0.8, minCoveredFraction: 0.5, floorSeconds: 15, anchorRatioMax: 1.5,
|
|
31
|
+
/** Compatibility alias for older report consumers; this is now enforced. */
|
|
32
|
+
anchorRatioPostHoc: 1.5 })
|
|
33
|
+
|
|
34
|
+
export function passesShadowAnchorRatio(ratio: number | null): boolean {
|
|
35
|
+
return ratio !== null && Number.isFinite(ratio) && ratio >= 0 && ratio <= SHADOW_GATES.anchorRatioMax
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One production segment on the reconstructed timeline: [lo, hi) samples and its label. */
|
|
39
|
+
export type ShadowSegment = readonly [lo: number, hi: number, label: string]
|
|
40
|
+
/** A FluidAudio turn in seconds (float32 values widened, end clipped to the audio). */
|
|
41
|
+
export interface ShadowTurn { start: number; end: number; speaker: string }
|
|
42
|
+
/** One `--export-embeddings` row (the fields decide() reads). */
|
|
43
|
+
export interface ShadowEmbeddingRow { startTime: number; endTime: number; cluster: number; embedding256: number[] }
|
|
44
|
+
/** Where a production segment came from, for listen pointers. Same order as the segments. */
|
|
45
|
+
export interface ShadowSegmentSource { chunkIndex: number; start: number; end: number; abs: number }
|
|
46
|
+
|
|
47
|
+
export function isShadowVoiceLabel(label: string): boolean {
|
|
48
|
+
return PY_VOICE_LABEL.test(label)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Vector helpers (hybrid.py norm / mean_dir / cosdist / p95) ───────────────
|
|
52
|
+
|
|
53
|
+
export function norm(v: readonly number[]): number[] {
|
|
54
|
+
const squares = new Array<number>(v.length)
|
|
55
|
+
for (let i = 0; i < v.length; i++) squares[i] = v[i] * v[i]
|
|
56
|
+
const n = Math.sqrt(pySum(squares)) || 1.0
|
|
57
|
+
return v.map(x => x / n)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function meanDir(vs: readonly number[][]): number[] {
|
|
61
|
+
const s = new Array<number>(vs[0].length).fill(0)
|
|
62
|
+
for (const v of vs) for (let i = 0; i < v.length; i++) s[i] += v[i]
|
|
63
|
+
return norm(s)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function cosdist(a: readonly number[], b: readonly number[]): number {
|
|
67
|
+
const n = Math.min(a.length, b.length)
|
|
68
|
+
const products = new Array<number>(n)
|
|
69
|
+
for (let i = 0; i < n; i++) products[i] = a[i] * b[i]
|
|
70
|
+
return 1.0 - pySum(products)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function p95(values: readonly number[]): number {
|
|
74
|
+
const xs = [...values].sort((x, y) => x - y)
|
|
75
|
+
const k = 0.95 * (xs.length - 1)
|
|
76
|
+
const f = Math.floor(k), c = Math.ceil(k)
|
|
77
|
+
return f === c ? xs[f] : xs[f] + (xs[c] - xs[f]) * (k - f)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Interval helpers (common.py interval_samples / exact_overlap) ────────────
|
|
81
|
+
|
|
82
|
+
const KEY_SEP = '\u0000'
|
|
83
|
+
type Interval = readonly [lo: number, hi: number, label: string]
|
|
84
|
+
|
|
85
|
+
/** FluidAudio turns as sample intervals, validated like common.py. */
|
|
86
|
+
export function intervalSamples(turns: readonly ShadowTurn[], total: number): Interval[] {
|
|
87
|
+
return turns.map(t => {
|
|
88
|
+
const { start: a, end: b, speaker: label } = t
|
|
89
|
+
if (typeof label !== 'string' || !label || label.includes(KEY_SEP)) throw new Error('bad FluidAudio turn label')
|
|
90
|
+
if (!Number.isFinite(a) || !Number.isFinite(b) || !(a >= 0 && a < b && b <= total / SHADOW_SAMPLE_RATE + 1e-6)) throw new Error('bad FluidAudio turn time')
|
|
91
|
+
return [Math.max(0, pyRoundInt(a * SHADOW_SAMPLE_RATE)), Math.min(total, pyRoundInt(b * SHADOW_SAMPLE_RATE)), label] as const
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Turns sorted by start with the longest span, so a query scans only what can overlap. */
|
|
96
|
+
export class IntervalIndex {
|
|
97
|
+
private readonly rows: Interval[]
|
|
98
|
+
private readonly maxSpan: number
|
|
99
|
+
constructor(intervals: readonly Interval[]) {
|
|
100
|
+
this.rows = [...intervals].sort((x, y) => x[0] - y[0])
|
|
101
|
+
this.maxSpan = this.rows.reduce((m, [a, b]) => Math.max(m, b - a), 0)
|
|
102
|
+
}
|
|
103
|
+
/** Every interval with a < hi and b > lo (the set common.py crops to). */
|
|
104
|
+
overlapping(lo: number, hi: number): Interval[] {
|
|
105
|
+
const rows = this.rows
|
|
106
|
+
let left = 0, right = rows.length
|
|
107
|
+
const from = lo - this.maxSpan
|
|
108
|
+
while (left < right) { const mid = (left + right) >> 1; if (rows[mid][0] < from) left = mid + 1; else right = mid }
|
|
109
|
+
const out: Interval[] = []
|
|
110
|
+
for (let i = left; i < rows.length && rows[i][0] < hi; i++) if (rows[i][1] > lo) out.push(rows[i])
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Samples of [lo, hi) by active-label set, in Python dict order (first appearance as the
|
|
117
|
+
* points ascend). Keys are the sorted labels joined by NUL; '' is "no cluster active".
|
|
118
|
+
*/
|
|
119
|
+
export function exactOverlap(lo: number, hi: number, index: IntervalIndex): Map<string, number> {
|
|
120
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi) || !(lo >= 0 && lo < hi)) throw new Error('exact_overlap range')
|
|
121
|
+
const boundaries = new Set<number>([lo, hi])
|
|
122
|
+
const cropped: Array<[number, number, string]> = []
|
|
123
|
+
for (const [a0, b0, label] of index.overlapping(lo, hi)) {
|
|
124
|
+
const a = Math.max(lo, a0), b = Math.min(hi, b0)
|
|
125
|
+
if (a < b) { cropped.push([a, b, label]); boundaries.add(a); boundaries.add(b) }
|
|
126
|
+
}
|
|
127
|
+
const points = [...boundaries].sort((x, y) => x - y)
|
|
128
|
+
const counts = new Map<string, number>()
|
|
129
|
+
for (let i = 0; i + 1 < points.length; i++) {
|
|
130
|
+
const a = points[i], b = points[i + 1]
|
|
131
|
+
const active = [...new Set(cropped.filter(([x, y]) => x <= a && y >= b).map(([, , label]) => label))].sort(codePointCompare)
|
|
132
|
+
const key = active.join(KEY_SEP)
|
|
133
|
+
counts.set(key, (counts.get(key) ?? 0) + (b - a))
|
|
134
|
+
}
|
|
135
|
+
let sum = 0
|
|
136
|
+
for (const n of counts.values()) sum += n
|
|
137
|
+
if (sum !== hi - lo) throw new Error('exact_overlap accounting')
|
|
138
|
+
return counts
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const isSingle = (key: string) => key !== '' && !key.includes(KEY_SEP)
|
|
142
|
+
|
|
143
|
+
function mergeIv(iv: Array<[number, number]>): Array<[number, number]> {
|
|
144
|
+
const out: Array<[number, number]> = []
|
|
145
|
+
for (const [a, b] of [...iv].sort((x, y) => (x[0] - y[0]) || (x[1] - y[1]))) {
|
|
146
|
+
const last = out[out.length - 1]
|
|
147
|
+
if (last && a <= last[1]) last[1] = Math.max(b, last[1])
|
|
148
|
+
else out.push([a, b])
|
|
149
|
+
}
|
|
150
|
+
return out
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── decide() ─────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
export type ShadowOutlier =
|
|
156
|
+
| { pass: false; reason: string }
|
|
157
|
+
| { pass: boolean; fragmentCentroidDistance: number; clusterP95Distance: number; raw: { distance: number; p95: number } }
|
|
158
|
+
|
|
159
|
+
export interface ShadowVoiceRow {
|
|
160
|
+
seconds: number
|
|
161
|
+
coveredFraction: number
|
|
162
|
+
topCluster: string | null
|
|
163
|
+
majority: number
|
|
164
|
+
fragmentEmbeddings: number
|
|
165
|
+
otherClusterMembers: number
|
|
166
|
+
outlier: ShadowOutlier
|
|
167
|
+
/** Unrounded ratio used by gate (d); null for the anchor or missing evidence. */
|
|
168
|
+
anchorRatio: number | null
|
|
169
|
+
/** null means this voice is the anchor or is not in a proposed group. */
|
|
170
|
+
anchorRatioPass: boolean | null
|
|
171
|
+
eligible: boolean
|
|
172
|
+
reasons: string[]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface ShadowItem { vec: number[]; exportCluster: string; outputCluster: string | null; voice: string | null }
|
|
176
|
+
export interface ShadowGroup { cluster: string; voices: string[]; anchor: string | null; mergedIntoAnchor: string[] }
|
|
177
|
+
|
|
178
|
+
/** The anchor spread is independent of the fragment; do not inflate it with
|
|
179
|
+
* candidate fragments, and do not accept a missing/zero spread as agreement. */
|
|
180
|
+
function anchorRatio(fragment: readonly number[][], anchor: readonly number[][]): number | null {
|
|
181
|
+
if (fragment.length < 3 || anchor.length < 3 || [...fragment, ...anchor].some(v =>
|
|
182
|
+
v.length !== 256 || v.some(x => !Number.isFinite(x)) || !v.some(x => x !== 0))) return null
|
|
183
|
+
const centroid = meanDir(anchor)
|
|
184
|
+
const spread = p95(anchor.map(v => cosdist(v, centroid)))
|
|
185
|
+
const distance = cosdist(meanDir(fragment), centroid)
|
|
186
|
+
if (!Number.isFinite(spread) || !(spread > 0) || !Number.isFinite(distance) || distance < -1e-12) return null
|
|
187
|
+
// Unit-vector dot products can exceed one by a rounding ulp.
|
|
188
|
+
const ratio = Math.max(0, distance) / spread
|
|
189
|
+
return Number.isFinite(ratio) ? ratio : null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface ShadowDecision {
|
|
193
|
+
voices: Map<string, Array<[number, number]>>
|
|
194
|
+
rows: Map<string, ShadowVoiceRow>
|
|
195
|
+
groups: ShadowGroup[]
|
|
196
|
+
rejected: Array<{ voice: string; cluster: string; reasons: string[] }>
|
|
197
|
+
remap: Map<string, string>
|
|
198
|
+
proposed: number
|
|
199
|
+
accepted: number
|
|
200
|
+
agree: number
|
|
201
|
+
withOut: number
|
|
202
|
+
items: ShadowItem[]
|
|
203
|
+
voiceBefore: number
|
|
204
|
+
voiceAfter: number
|
|
205
|
+
index: IntervalIndex
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function decide(
|
|
209
|
+
segs: readonly ShadowSegment[],
|
|
210
|
+
total: number,
|
|
211
|
+
regularTurns: readonly ShadowTurn[],
|
|
212
|
+
emb: readonly ShadowEmbeddingRow[],
|
|
213
|
+
nSec: number = SHADOW_GATES.floorSeconds,
|
|
214
|
+
majority: number = SHADOW_GATES.majority,
|
|
215
|
+
): ShadowDecision {
|
|
216
|
+
const SR = SHADOW_SAMPLE_RATE
|
|
217
|
+
const index = new IntervalIndex(intervalSamples(regularTurns, total))
|
|
218
|
+
|
|
219
|
+
const collected = new Map<string, Array<[number, number]>>()
|
|
220
|
+
for (const [lo, hi, label] of segs) {
|
|
221
|
+
if (!isShadowVoiceLabel(label)) continue
|
|
222
|
+
const list = collected.get(label)
|
|
223
|
+
if (list) list.push([lo, hi]); else collected.set(label, [[lo, hi]])
|
|
224
|
+
}
|
|
225
|
+
const voices = new Map<string, Array<[number, number]>>()
|
|
226
|
+
for (const [v, iv] of collected) voices.set(v, mergeIv(iv))
|
|
227
|
+
|
|
228
|
+
// Embedding attribution: a window belongs to the voice holding >= 50% of its span.
|
|
229
|
+
const items: ShadowItem[] = []
|
|
230
|
+
for (const e of emb) {
|
|
231
|
+
const lo = pyRoundInt(e.startTime * SR), hi = pyRoundInt(e.endTime * SR)
|
|
232
|
+
if (hi <= lo) continue
|
|
233
|
+
if (!Number.isInteger(e.cluster)) throw new Error('embedding cluster is not an integer')
|
|
234
|
+
let best: string | null = null, bestN = 0
|
|
235
|
+
for (const [v, iv] of voices) {
|
|
236
|
+
let n = 0
|
|
237
|
+
for (const [a, b] of iv) if (a < hi && b > lo) n += Math.max(0, Math.min(hi, b) - Math.max(lo, a))
|
|
238
|
+
if (n > bestN) { best = v; bestN = n }
|
|
239
|
+
}
|
|
240
|
+
const cover = lo < total ? exactOverlap(lo, Math.min(hi, total), index) : new Map<string, number>()
|
|
241
|
+
let outLabel: string | null = null, outN = -Infinity
|
|
242
|
+
for (const [key, n] of cover) if (isSingle(key) && n > outN) { outLabel = key; outN = n }
|
|
243
|
+
items.push({ vec: norm(e.embedding256), exportCluster: `S${e.cluster + 1}`, outputCluster: outLabel, voice: best !== null && bestN >= 0.5 * (hi - lo) ? best : null })
|
|
244
|
+
}
|
|
245
|
+
let agree = 0, withOut = 0
|
|
246
|
+
for (const x of items) if (x.outputCluster) { withOut++; if (x.exportCluster === x.outputCluster) agree++ }
|
|
247
|
+
|
|
248
|
+
const rows = new Map<string, ShadowVoiceRow>()
|
|
249
|
+
for (const [v, iv] of voices) {
|
|
250
|
+
let dur = 0
|
|
251
|
+
for (const [a, b] of iv) dur += b - a
|
|
252
|
+
const counts = new Map<string, number>()
|
|
253
|
+
for (const [a, b] of iv) for (const [key, n] of exactOverlap(a, b, index)) counts.set(key, (counts.get(key) ?? 0) + n)
|
|
254
|
+
let covered = 0
|
|
255
|
+
for (const [key, n] of counts) if (key !== '') covered += n
|
|
256
|
+
let top: string | null = null, topN = 0, topSeen = false
|
|
257
|
+
for (const [key, n] of counts) if (isSingle(key) && (!topSeen || n > topN)) { top = key; topN = n; topSeen = true }
|
|
258
|
+
rows.set(v, {
|
|
259
|
+
seconds: pyRound(dur / SR, 2),
|
|
260
|
+
coveredFraction: dur ? pyRound(covered / dur, 3) : 0,
|
|
261
|
+
topCluster: top,
|
|
262
|
+
majority: covered ? pyRound(topN / covered, 3) : 0,
|
|
263
|
+
fragmentEmbeddings: 0, otherClusterMembers: 0,
|
|
264
|
+
outlier: { pass: false, reason: '' }, anchorRatio: null, anchorRatioPass: null, eligible: false, reasons: [],
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// (c) outlier check against the voice's top cluster; membership = export cluster label.
|
|
269
|
+
for (const [v, r] of rows) {
|
|
270
|
+
const k = r.topCluster
|
|
271
|
+
const frag = items.filter(x => x.voice === v).map(x => x.vec)
|
|
272
|
+
const members = items.filter(x => x.exportCluster === k && x.voice !== v).map(x => x.vec)
|
|
273
|
+
r.fragmentEmbeddings = frag.length
|
|
274
|
+
r.otherClusterMembers = members.length
|
|
275
|
+
if (k === null || frag.length < 3 || members.length < 5) {
|
|
276
|
+
r.outlier = { pass: false, reason: `insufficient embeddings (fragment ${frag.length}, others ${members.length})` }
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
const c = meanDir(members)
|
|
280
|
+
const thr = p95(members.map(m => cosdist(m, c)))
|
|
281
|
+
const d = cosdist(meanDir(frag), c)
|
|
282
|
+
r.outlier = { pass: d <= thr, fragmentCentroidDistance: pyRound(d, 4), clusterP95Distance: pyRound(thr, 4), raw: { distance: d, p95: thr } }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const r of rows.values()) {
|
|
286
|
+
const reasons: string[] = []
|
|
287
|
+
if (!(r.coveredFraction >= 0.5)) reasons.push(`(a) covered ${pyFixed(r.coveredFraction, 2)} < 0.50`)
|
|
288
|
+
if (!(r.majority >= majority)) reasons.push(`(a) majority ${pyFixed(r.majority, 2)} < ${pyFixed(majority, 2)}`)
|
|
289
|
+
if (!(r.seconds >= nSec)) reasons.push(`(b) ${pyFixed(r.seconds, 1)} s < ${pyFixed(nSec, 0)} s`)
|
|
290
|
+
if (!r.outlier.pass) {
|
|
291
|
+
const o = r.outlier
|
|
292
|
+
reasons.push('(c) ' + ('reason' in o ? o.reason : `centroid ${pyFixed(o.fragmentCentroidDistance, 3)} > p95 ${pyFixed(o.clusterP95Distance, 3)}`))
|
|
293
|
+
}
|
|
294
|
+
r.eligible = reasons.length === 0
|
|
295
|
+
r.reasons = reasons
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const byCluster = new Map<string, string[]>()
|
|
299
|
+
for (const [v, r] of rows) {
|
|
300
|
+
if (r.topCluster && r.majority > 0.5) {
|
|
301
|
+
const list = byCluster.get(r.topCluster)
|
|
302
|
+
if (list) list.push(v); else byCluster.set(r.topCluster, [v])
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const longestFirst = (vs: string[]) => [...vs].sort((a, b) => rows.get(b)!.seconds - rows.get(a)!.seconds)
|
|
306
|
+
const remap = new Map<string, string>()
|
|
307
|
+
const rejected: ShadowDecision['rejected'] = []
|
|
308
|
+
const groups: ShadowGroup[] = []
|
|
309
|
+
const vecs = new Map<string, number[][]>()
|
|
310
|
+
for (const item of items) if (item.voice) {
|
|
311
|
+
const list = vecs.get(item.voice)
|
|
312
|
+
if (list) list.push(item.vec); else vecs.set(item.voice, [item.vec])
|
|
313
|
+
}
|
|
314
|
+
let proposed = 0, accepted = 0
|
|
315
|
+
for (const [k, vs] of [...byCluster].sort((x, y) => codePointCompare(x[0], y[0]))) {
|
|
316
|
+
if (vs.length < 2) continue
|
|
317
|
+
proposed += vs.length - 1
|
|
318
|
+
const baseEligible = longestFirst(vs.filter(v => rows.get(v)!.eligible))
|
|
319
|
+
const anchor = baseEligible[0] ?? null
|
|
320
|
+
if (anchor) for (const v of vs) {
|
|
321
|
+
if (v === anchor) continue
|
|
322
|
+
const row = rows.get(v)!
|
|
323
|
+
row.anchorRatio = anchorRatio(vecs.get(v) ?? [], vecs.get(anchor) ?? [])
|
|
324
|
+
row.anchorRatioPass = passesShadowAnchorRatio(row.anchorRatio)
|
|
325
|
+
if (row.eligible && !row.anchorRatioPass) {
|
|
326
|
+
row.eligible = false
|
|
327
|
+
row.reasons.push(row.anchorRatio === null ? '(d) anchor ratio evidence unavailable'
|
|
328
|
+
: `(d) anchor ratio ${pyFixed(row.anchorRatio, 4)} > ${pyFixed(SHADOW_GATES.anchorRatioMax, 2)}`)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const ok = baseEligible.filter(v => rows.get(v)!.eligible)
|
|
332
|
+
const merged = ok.filter(v => v !== anchor)
|
|
333
|
+
accepted += merged.length
|
|
334
|
+
for (const v of merged) remap.set(v, anchor!)
|
|
335
|
+
for (const v of vs) {
|
|
336
|
+
if (!ok.includes(v)) rejected.push({ voice: v, cluster: k, reasons: rows.get(v)!.reasons })
|
|
337
|
+
else if (v === anchor && !merged.length) rejected.push({ voice: v, cluster: k, reasons: ['no other eligible voice in this cluster'] })
|
|
338
|
+
}
|
|
339
|
+
groups.push({ cluster: k, voices: longestFirst(vs), anchor, mergedIntoAnchor: merged })
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
voices, rows, groups, rejected, remap, proposed, accepted, agree, withOut, items,
|
|
344
|
+
voiceBefore: voices.size,
|
|
345
|
+
voiceAfter: new Set([...voices.keys()].map(v => remap.get(v) ?? v)).size,
|
|
346
|
+
index,
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Report assembly (round3/propose.py), log only ────────────────────────────
|
|
351
|
+
|
|
352
|
+
export interface ShadowListenPointer { chunkIndex: number; inFileStart: number; inFileEnd: number; callTime: string }
|
|
353
|
+
export interface ShadowVoiceEntry {
|
|
354
|
+
voice: string
|
|
355
|
+
seconds: number
|
|
356
|
+
coveredFraction: number
|
|
357
|
+
topCluster: string | null
|
|
358
|
+
majority: number
|
|
359
|
+
outlierPass: boolean
|
|
360
|
+
outlierDistance: number | null
|
|
361
|
+
outlierP95: number | null
|
|
362
|
+
outlierNote: string | null
|
|
363
|
+
/** Applied unrounded ratio. Optional only for older persisted proposal rows. */
|
|
364
|
+
anchorRatio?: number | null
|
|
365
|
+
/** Rounded compatibility field; gate (d) uses the row's UNROUNDED ratio. */
|
|
366
|
+
anchorRatioPostHoc: number | null
|
|
367
|
+
eligible: boolean
|
|
368
|
+
reasons: string[]
|
|
369
|
+
listen: ShadowListenPointer[]
|
|
370
|
+
}
|
|
371
|
+
/** A non-Voice label sharing the cluster, as a hint only ("cluster also holds 65 s of ..."). */
|
|
372
|
+
export interface ShadowOtherLabel { label: string; seconds: number }
|
|
373
|
+
export interface ShadowAccepted { cluster: string; keepLabel: string; merge: string[]; voices: ShadowVoiceEntry[]; otherLabelsInCluster: ShadowOtherLabel[] }
|
|
374
|
+
export type ShadowRejected = ShadowVoiceEntry & { cluster: string; wouldJoin: string | null; otherLabelsInCluster: ShadowOtherLabel[] }
|
|
375
|
+
export interface ShadowUntouched { voice: string; seconds: number; why: string }
|
|
376
|
+
|
|
377
|
+
export interface ShadowProposals {
|
|
378
|
+
productionVoices: number
|
|
379
|
+
voicesAfter: number
|
|
380
|
+
frozenOtherLabels: string[]
|
|
381
|
+
fluidAudioClusters: number
|
|
382
|
+
proposedMerges: number
|
|
383
|
+
acceptedMerges: number
|
|
384
|
+
rejectedMerges: number
|
|
385
|
+
embeddingClusterAgreement: [agree: number, withOutputCluster: number, total: number]
|
|
386
|
+
accepted: ShadowAccepted[]
|
|
387
|
+
rejected: ShadowRejected[]
|
|
388
|
+
untouched: ShadowUntouched[]
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function listenPointers(label: string, byLabel: Map<string, ShadowSegmentSource[]>, k = 3): ShadowListenPointer[] {
|
|
392
|
+
const segs = byLabel.get(label) ?? []
|
|
393
|
+
if (!segs.length) return []
|
|
394
|
+
const pick = (score: (s: ShadowSegmentSource) => number, max: boolean) => segs.reduce((best, s) => (max ? score(s) > score(best) : score(s) < score(best)) ? s : best)
|
|
395
|
+
const picks = [pick(s => s.end - s.start, true), pick(s => s.abs, false), pick(s => s.abs, true)]
|
|
396
|
+
const seen = new Set<number>(), out: ShadowListenPointer[] = []
|
|
397
|
+
for (const s of picks) {
|
|
398
|
+
if (seen.has(s.chunkIndex)) continue
|
|
399
|
+
seen.add(s.chunkIndex)
|
|
400
|
+
out.push({ chunkIndex: s.chunkIndex, inFileStart: pyRound(s.start, 2), inFileEnd: pyRound(s.end, 2), callTime: pyClock(s.abs) })
|
|
401
|
+
}
|
|
402
|
+
return out.slice(0, k)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The proposal report for one meeting, exactly as round3/propose.py assembles it (minus transcript text and file paths). */
|
|
406
|
+
export function buildShadowProposals(
|
|
407
|
+
D: ShadowDecision,
|
|
408
|
+
segs: readonly ShadowSegment[],
|
|
409
|
+
sources: readonly ShadowSegmentSource[],
|
|
410
|
+
regularTurns: readonly ShadowTurn[],
|
|
411
|
+
): ShadowProposals {
|
|
412
|
+
const SR = SHADOW_SAMPLE_RATE
|
|
413
|
+
const rows = D.rows
|
|
414
|
+
if (sources.length !== segs.length) throw new Error('segment sources out of step')
|
|
415
|
+
const byLabel = new Map<string, ShadowSegmentSource[]>()
|
|
416
|
+
segs.forEach(([, , label], i) => { const list = byLabel.get(label); if (list) list.push(sources[i]); else byLabel.set(label, [sources[i]]) })
|
|
417
|
+
|
|
418
|
+
// Named / non-Voice labels sharing each FluidAudio cluster (caution only; never merged).
|
|
419
|
+
const named = new Map<string, Map<string, number>>()
|
|
420
|
+
for (const [lo, hi, label] of segs) {
|
|
421
|
+
if (isShadowVoiceLabel(label)) continue
|
|
422
|
+
for (const [key, n] of exactOverlap(lo, hi, D.index)) {
|
|
423
|
+
if (!isSingle(key)) continue
|
|
424
|
+
let inner = named.get(key)
|
|
425
|
+
if (!inner) { inner = new Map(); named.set(key, inner) }
|
|
426
|
+
inner.set(label, (inner.get(label) ?? 0) + n)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const voiceEntry = (v: string, anchor: string): ShadowVoiceEntry => {
|
|
431
|
+
const r = rows.get(v)!
|
|
432
|
+
const o = r.outlier
|
|
433
|
+
return {
|
|
434
|
+
voice: v, seconds: r.seconds, coveredFraction: r.coveredFraction, topCluster: r.topCluster, majority: r.majority,
|
|
435
|
+
outlierPass: o.pass,
|
|
436
|
+
outlierDistance: 'reason' in o ? null : o.fragmentCentroidDistance,
|
|
437
|
+
outlierP95: 'reason' in o ? null : o.clusterP95Distance,
|
|
438
|
+
outlierNote: 'reason' in o ? o.reason : null,
|
|
439
|
+
anchorRatio: r.anchorRatio, anchorRatioPostHoc: r.anchorRatio === null ? null : pyRound(r.anchorRatio, 2), eligible: r.eligible, reasons: r.reasons,
|
|
440
|
+
listen: listenPointers(v, byLabel),
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const accepted: ShadowAccepted[] = [], rejected: ShadowRejected[] = []
|
|
445
|
+
const grouped = new Set<string>()
|
|
446
|
+
for (const g of D.groups) {
|
|
447
|
+
const anchor = g.anchor || g.voices[0]
|
|
448
|
+
for (const v of g.voices) grouped.add(v)
|
|
449
|
+
const members = g.voices.map(v => voiceEntry(v, anchor))
|
|
450
|
+
const caution: ShadowOtherLabel[] = [...(named.get(g.cluster) ?? new Map<string, number>())]
|
|
451
|
+
.sort((x, y) => y[1] - x[1])
|
|
452
|
+
.filter(([, n]) => n >= SR)
|
|
453
|
+
.map(([label, n]) => ({ label, seconds: pyRound(n / SR, 1) }))
|
|
454
|
+
if (g.mergedIntoAnchor.length) {
|
|
455
|
+
const kept = [g.anchor, ...g.mergedIntoAnchor]
|
|
456
|
+
accepted.push({ cluster: g.cluster, keepLabel: g.anchor!, merge: g.mergedIntoAnchor, voices: members.filter(m => kept.includes(m.voice)), otherLabelsInCluster: caution })
|
|
457
|
+
}
|
|
458
|
+
for (const m of members) {
|
|
459
|
+
const v = m.voice
|
|
460
|
+
if (g.mergedIntoAnchor.includes(v) || (v === g.anchor && g.mergedIntoAnchor.length)) continue
|
|
461
|
+
if (v === anchor) rejected.push({ cluster: g.cluster, wouldJoin: null, ...m, reasons: [...m.reasons, 'no other voice on this cluster passed every gate'], otherLabelsInCluster: caution })
|
|
462
|
+
else rejected.push({ cluster: g.cluster, wouldJoin: anchor, ...m, otherLabelsInCluster: caution })
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const untouched: ShadowUntouched[] = []
|
|
467
|
+
const byNumber = [...rows].sort((x, y) => (pyFirstInt(x[0]) ?? 0) - (pyFirstInt(y[0]) ?? 0))
|
|
468
|
+
for (const [v, r] of byNumber) {
|
|
469
|
+
if (grouped.has(v)) continue
|
|
470
|
+
let why: string
|
|
471
|
+
if (!r.topCluster || r.coveredFraction === 0) why = 'FluidAudio did not cover this voice'
|
|
472
|
+
else if (r.majority <= 0.5) why = `no FluidAudio cluster holds a majority of it (top ${r.topCluster} ${pyFixed(r.majority, 2)})`
|
|
473
|
+
else why = `the only voice on FluidAudio cluster ${r.topCluster}`
|
|
474
|
+
untouched.push({ voice: v, seconds: r.seconds, why })
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
productionVoices: D.voiceBefore,
|
|
479
|
+
voicesAfter: D.voiceAfter,
|
|
480
|
+
frozenOtherLabels: [...new Set(segs.map(([, , label]) => label).filter(label => !isShadowVoiceLabel(label)))].sort(codePointCompare),
|
|
481
|
+
fluidAudioClusters: new Set(regularTurns.map(t => t.speaker)).size,
|
|
482
|
+
proposedMerges: D.proposed,
|
|
483
|
+
acceptedMerges: D.accepted,
|
|
484
|
+
rejectedMerges: rejected.filter(x => x.wouldJoin).length,
|
|
485
|
+
embeddingClusterAgreement: [D.agree, D.withOut, D.items.length],
|
|
486
|
+
accepted, rejected, untouched,
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** adapt.py native_segments + to_turns: float32 times, end clipped to the audio, empty turns dropped. */
|
|
491
|
+
export function nativeTurns(segments: ReadonlyArray<{ speakerId: unknown; startTimeSeconds: unknown; endTimeSeconds: unknown }>, totalSeconds: number): { turns: ShadowTurn[]; clipped: number; dropped: number } {
|
|
492
|
+
const turns: ShadowTurn[] = []
|
|
493
|
+
let clipped = 0, dropped = 0
|
|
494
|
+
for (const s of segments) {
|
|
495
|
+
if (typeof s.speakerId !== 'string' || typeof s.startTimeSeconds !== 'number' || typeof s.endTimeSeconds !== 'number') throw new Error('bad FluidAudio segment')
|
|
496
|
+
const start = Math.fround(s.startTimeSeconds)
|
|
497
|
+
let end = Math.fround(s.endTimeSeconds)
|
|
498
|
+
if (end > totalSeconds) { end = totalSeconds; clipped++ }
|
|
499
|
+
if (!(start < end)) { dropped++; continue }
|
|
500
|
+
turns.push({ start, end, speaker: s.speakerId })
|
|
501
|
+
}
|
|
502
|
+
return { turns, clipped, dropped }
|
|
503
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Speaker shadow mode (6.95.0): the pinned FluidAudio Community-1 diarizer models.
|
|
2
|
+
//
|
|
3
|
+
// NEVER DOWNLOADED BY THE SERVER. FluidAudio's CLI fetches models from Hugging Face
|
|
4
|
+
// whenever its cache folder is missing or its revision marker is unknown, so the server
|
|
5
|
+
// runs the CLI only after every pinned file below is present and hashes to its pin, and
|
|
6
|
+
// always under sandbox-exec with network denied (a CLI that tried anyway fails closed).
|
|
7
|
+
// The models must already be on the Mac, in `<data>/speaker-shadow/models/speaker-diarization`
|
|
8
|
+
// (or COS_SPEAKER_SHADOW_MODELS): the Hugging Face repo files at the pinned revision.
|
|
9
|
+
// Anything else is skipped as `models_missing` or `models_mismatch` and logged.
|
|
10
|
+
//
|
|
11
|
+
// LAYOUT. The CLI reads `$HOME/Library/Application Support/FluidAudio/Models/speaker-diarization`.
|
|
12
|
+
// The server gives it a private HOME whose model folder holds only symlinks to the
|
|
13
|
+
// verified files plus the revision marker. The sandbox lets the CLI write in that HOME
|
|
14
|
+
// (CoreML keeps its compiled cache there) but not through a symlink, because the sandbox
|
|
15
|
+
// matches resolved paths: the verified model bytes are read-only to the CLI.
|
|
16
|
+
//
|
|
17
|
+
// License: CC-BY-4.0 scoped (NOTICE.md in the repo at this revision). Attribution is
|
|
18
|
+
// required before these models ship inside a build; the server ships none of them.
|
|
19
|
+
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
import { createReadStream, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
22
|
+
import { join, resolve } from 'node:path'
|
|
23
|
+
import { pipeline } from 'node:stream/promises'
|
|
24
|
+
import { Transform } from 'node:stream'
|
|
25
|
+
import { dataPath } from './data-dir.js'
|
|
26
|
+
|
|
27
|
+
export interface ShadowPinnedFile { path: string; size: number; sha256: string }
|
|
28
|
+
|
|
29
|
+
/** FluidInference/speaker-diarization-coreml, the revision FluidAudio 0.17.4 pins (ModelNames.swift Repo.diarizer). */
|
|
30
|
+
export const SHADOW_MODEL_REPO = 'FluidInference/speaker-diarization-coreml'
|
|
31
|
+
export const SHADOW_MODEL_REVISION = 'df2625ac79a7ac6b65ad868fee6d80f320da4232'
|
|
32
|
+
/** FluidAudio's local folder name for that repo (not the repo name: the round-1 canary lost a run to this). */
|
|
33
|
+
export const SHADOW_MODEL_FOLDER = 'speaker-diarization'
|
|
34
|
+
/** What FluidAudio writes after a download; without it the CLI deletes the folder and re-downloads. */
|
|
35
|
+
export const SHADOW_REVISION_MARKER = { path: '.fluidaudio-revision', content: `${SHADOW_MODEL_REVISION}\n`, sha256: '1f6ae38637c86e0998be2d231a1431a5a3c37010e9337b682e635cd5c147ef7a' } as const
|
|
36
|
+
|
|
37
|
+
// From the canary's model-manifest.json and the repo's file listing at the pinned revision.
|
|
38
|
+
export const SHADOW_MODEL_FILES: readonly ShadowPinnedFile[] = [
|
|
39
|
+
{ path: 'Embedding.mlmodelc/analytics/coremldata.bin', size: 243, sha256: '8d6706436639b53830b4dbe8aaf9c9a843f7f582d63e16f3cb8bb7c6ccd58682' },
|
|
40
|
+
{ path: 'Embedding.mlmodelc/coremldata.bin', size: 704, sha256: '4a705bac27d151d9642f37609296042a15602a42253039e0921dc9e75da7e004' },
|
|
41
|
+
{ path: 'Embedding.mlmodelc/metadata.json', size: 2818, sha256: '1854371eb6b438fb8aeac96afb45c999af7902581c06afdfcd7ff3cb1ce66be5' },
|
|
42
|
+
{ path: 'Embedding.mlmodelc/model.mil', size: 78432, sha256: '22fa958aef72a561c21f874a07cbdcd30fdf40ee961c0bc2fb67c119273b46d3' },
|
|
43
|
+
{ path: 'Embedding.mlmodelc/weights/weight.bin', size: 13412288, sha256: '99356b2985b8d43880a657024d941d450b38820451ccff903f76ed4e52d1868b' },
|
|
44
|
+
{ path: 'FBank.mlmodelc/analytics/coremldata.bin', size: 243, sha256: '0e8bd3a8b82ac123580989f490e4d9245127c535857630b543311268accc3f0a' },
|
|
45
|
+
{ path: 'FBank.mlmodelc/coremldata.bin', size: 853, sha256: '57ac436bb0671cbb5527a339134d695f752eb77f7a18966b93c6835335595759' },
|
|
46
|
+
{ path: 'FBank.mlmodelc/metadata.json', size: 3409, sha256: '2623785f5d186893b82d01e84aa33a7704ef763c3309e02055f22dc9d871ce9a' },
|
|
47
|
+
{ path: 'FBank.mlmodelc/model.mil', size: 15667, sha256: '27aaeb21569e81bdbe2eef87789f50a37cfea800039bd134448a9417de2f30ed' },
|
|
48
|
+
{ path: 'FBank.mlmodelc/weights/weight.bin', size: 1776896, sha256: '9e83fdd3ea78064b078069e4d9141603c61c47a27fd19e7e3142ff7476f8db36' },
|
|
49
|
+
{ path: 'PldaRho.mlmodelc/analytics/coremldata.bin', size: 243, sha256: '8940ea6044dbcbefa22da8cc41e0b485e1fb5ed89aecaf37c6e0c483a97ddcd7' },
|
|
50
|
+
{ path: 'PldaRho.mlmodelc/coremldata.bin', size: 763, sha256: '4d9741477f721c79b09fcdfe455110c4b7d4272e2de3496bf1729d966d3ee418' },
|
|
51
|
+
{ path: 'PldaRho.mlmodelc/metadata.json', size: 2749, sha256: 'b314cf25a93e46b4076883a6f5a2f8848b73c3851bd9d36074d067f35a1c7945' },
|
|
52
|
+
{ path: 'PldaRho.mlmodelc/model.mil', size: 7613, sha256: '83aee2e5310d19b5f202aea97d07a0e12102556d1b32ef3ed08b36f7f9725041' },
|
|
53
|
+
{ path: 'PldaRho.mlmodelc/weights/weight.bin', size: 200192, sha256: '80f7d229202636d372428c90596f11a91545f07da77259f07153aaf225914a36' },
|
|
54
|
+
{ path: 'Segmentation.mlmodelc/analytics/coremldata.bin', size: 243, sha256: '64265f8e7ad41a5f68d630c15288c2499cca5892ad49e20096819cdeac004cdb' },
|
|
55
|
+
{ path: 'Segmentation.mlmodelc/coremldata.bin', size: 812, sha256: 'ea51481b8bd3e496ad3cf16f066ddaa37f20e8772eaac76b3393c28de20e06bc' },
|
|
56
|
+
{ path: 'Segmentation.mlmodelc/metadata.json', size: 3410, sha256: '88dbf0b07208fe142e1729c2b4c974ad3599fcb2ae5d5f18fce782b225384124' },
|
|
57
|
+
{ path: 'Segmentation.mlmodelc/model.mil', size: 43063, sha256: 'd37e4ce30b406a6b34f765f769b9baed3178cc0c2b2e299c641daa43a052dd3f' },
|
|
58
|
+
{ path: 'Segmentation.mlmodelc/weights/weight.bin', size: 5959360, sha256: 'c3189a64946c75bc24fcb98afe89ad78c52bdbadfdf65e857fb1b81e2cc9fbb2' },
|
|
59
|
+
{ path: 'plda-parameters.json', size: 89416, sha256: '38ee28d4269c076cef254ee760bbd811f0738a92e0f01f9699ad372828c5de8f' },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
/** The top-level names the private HOME links to. Each must hold exactly its pinned files. */
|
|
63
|
+
export const SHADOW_MODEL_ENTRIES: readonly string[] = [...new Set(SHADOW_MODEL_FILES.map(f => f.path.split('/')[0]))]
|
|
64
|
+
|
|
65
|
+
export function speakerShadowRoot(): string {
|
|
66
|
+
return dataPath('speaker-shadow')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function speakerShadowModelsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
70
|
+
const override = env.COS_SPEAKER_SHADOW_MODELS?.trim()
|
|
71
|
+
return override ? resolve(override) : join(speakerShadowRoot(), 'models', SHADOW_MODEL_FOLDER)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The CLI's private HOME (CoreML compiled cache and the linked model folder). */
|
|
75
|
+
export function speakerShadowHome(): string {
|
|
76
|
+
return join(speakerShadowRoot(), 'home')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ShadowModelCheck =
|
|
80
|
+
| { ok: true; dir: string; bytes: number }
|
|
81
|
+
| { ok: false; reason: 'models_missing' | 'models_mismatch'; detail: string }
|
|
82
|
+
|
|
83
|
+
/** Cheap pre-check for the scheduler: every pinned file exists with its size. No hashing. */
|
|
84
|
+
export function shadowModelsPresent(dir: string): boolean {
|
|
85
|
+
return SHADOW_MODEL_FILES.every(f => { try { const st = statSync(join(dir, f.path)); return st.isFile() && st.size === f.size } catch { return false } })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function sha256File(path: string): Promise<string> {
|
|
89
|
+
const hash = createHash('sha256')
|
|
90
|
+
await pipeline(createReadStream(path), new Transform({ transform(chunk, _e, cb) { hash.update(chunk); cb(null) } }))
|
|
91
|
+
return hash.digest('hex')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function listFiles(root: string, rel = ''): string[] {
|
|
95
|
+
const out: string[] = []
|
|
96
|
+
for (const name of readdirSync(join(root, rel))) {
|
|
97
|
+
const path = rel ? `${rel}/${name}` : name
|
|
98
|
+
const st = lstatSync(join(root, path))
|
|
99
|
+
if (st.isSymbolicLink()) throw new Error(`symlink inside the model folder: ${path}`)
|
|
100
|
+
if (st.isDirectory()) out.push(...listFiles(root, path))
|
|
101
|
+
else out.push(path)
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Full check: every pinned file hashes to its pin, and each linked entry holds exactly the
|
|
108
|
+
* pinned files (an extra file inside a `.mlmodelc` would be read by CoreML unverified).
|
|
109
|
+
* Runs in the worker, before every diarization.
|
|
110
|
+
*/
|
|
111
|
+
export async function verifyShadowModels(dir: string): Promise<ShadowModelCheck> {
|
|
112
|
+
let real: string
|
|
113
|
+
try { real = realpathSync(dir) } catch { return { ok: false, reason: 'models_missing', detail: 'model folder not found' } }
|
|
114
|
+
let bytes = 0
|
|
115
|
+
for (const f of SHADOW_MODEL_FILES) {
|
|
116
|
+
const path = join(real, f.path)
|
|
117
|
+
let size: number
|
|
118
|
+
try {
|
|
119
|
+
const st = lstatSync(path)
|
|
120
|
+
if (!st.isFile()) return { ok: false, reason: 'models_mismatch', detail: `${f.path} is not a regular file` }
|
|
121
|
+
size = st.size
|
|
122
|
+
} catch { return { ok: false, reason: 'models_missing', detail: `${f.path} is missing` } }
|
|
123
|
+
if (size !== f.size || await sha256File(path) !== f.sha256) return { ok: false, reason: 'models_mismatch', detail: `${f.path} does not match its pinned SHA-256` }
|
|
124
|
+
bytes += size
|
|
125
|
+
}
|
|
126
|
+
const pinned = new Set(SHADOW_MODEL_FILES.map(f => f.path))
|
|
127
|
+
for (const entry of SHADOW_MODEL_ENTRIES) {
|
|
128
|
+
if (!lstatSync(join(real, entry)).isDirectory()) continue
|
|
129
|
+
let files: string[]
|
|
130
|
+
try { files = listFiles(real, entry) } catch (error) { return { ok: false, reason: 'models_mismatch', detail: error instanceof Error ? error.message : String(error) } }
|
|
131
|
+
const extra = files.filter(p => !pinned.has(p))
|
|
132
|
+
if (extra.length) return { ok: false, reason: 'models_mismatch', detail: `unpinned file in ${entry}: ${extra[0]}` }
|
|
133
|
+
}
|
|
134
|
+
return { ok: true, dir: real, bytes }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Rebuild the private HOME's model folder: symlinks to the verified entries and the
|
|
139
|
+
* revision marker. The folder only ever holds links and the marker, so rebuilding it from
|
|
140
|
+
* scratch loses nothing (fs.rm unlinks a symlink, never its target).
|
|
141
|
+
*/
|
|
142
|
+
export function prepareShadowHome(home: string, verifiedDir: string): string {
|
|
143
|
+
const folder = join(home, 'Library', 'Application Support', 'FluidAudio', 'Models', SHADOW_MODEL_FOLDER)
|
|
144
|
+
mkdirSync(join(home, 'Library', 'Application Support', 'FluidAudio', 'Models'), { recursive: true, mode: 0o700 })
|
|
145
|
+
rmSync(folder, { recursive: true, force: true })
|
|
146
|
+
mkdirSync(folder, { mode: 0o700 })
|
|
147
|
+
for (const entry of SHADOW_MODEL_ENTRIES) symlinkSync(join(verifiedDir, entry), join(folder, entry))
|
|
148
|
+
writeFileSync(join(folder, SHADOW_REVISION_MARKER.path), SHADOW_REVISION_MARKER.content, { mode: 0o600 })
|
|
149
|
+
return folder
|
|
150
|
+
}
|