@joewinke/jatui 0.10.0 → 0.11.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.
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Per-speaker playback rate, measured rather than guessed. Ported verbatim
3
+ * from extension-library `lib/speaker-rate.js` (mos-5lzns.27).
4
+ *
5
+ * The manual version of this feature is a speed slider, and a speed slider is
6
+ * a chore: you nudge it up for the slow host, forget, and then the guest who
7
+ * talks at 200 wpm arrives sounding auctioned. The premise here is the one
8
+ * behind loudness matching, one sense over — the app can MEASURE the thing you
9
+ * were about to compensate for. Words per minute falls straight out of a
10
+ * diarised transcript (words in a segment, seconds of that segment, attributed
11
+ * to whoever was talking), so it can propose the rates that put everyone at one
12
+ * pace and let you keep or discard them. Nobody has to guess "is 1.2 too much
13
+ * for this one".
14
+ *
15
+ * Pure advisor, deliberately (src/lib/video/README.md doctrine): no DOM, no
16
+ * <video>, no settings reads, no motion (so nothing here for
17
+ * prefers-reduced-motion to reduce). It answers "how fast is each person
18
+ * talking, and what would even them out" and stops there — the component
19
+ * decides whether to apply any of it, and every function returns a safe
20
+ * neutral value rather than throwing when the data is missing or malformed,
21
+ * because half this library's videos have no diarisation at all.
22
+ *
23
+ * speakerPace(transcript, speakers)
24
+ * -> { bySpeaker: { name: { wpm, words, seconds } }, medianWpm }
25
+ * suggestRates(pace, { targetWpm, min, max, step }) -> { name: rate }
26
+ * rateAt(t, turns, rates, fallback) -> multiplier at time t
27
+ * describeRates(pace, rates) -> ["Darren 168 wpm → 0.9x", …]
28
+ *
29
+ * Input shapes: segments/turns are the same duration-bearing objects the
30
+ * component already holds (VideoTranscriptSegment / VideoSpeakerTurn read
31
+ * structurally — `start`/`end` seconds, `text`, `speaker`/`name`, `ad`), so
32
+ * meeting and scribe data flows straight through. Field names are read with
33
+ * the extension's spellings (`start`/`end`/`text`/`speaker`/`name`/`ad`);
34
+ * mos-5lzns.32's wiring maps the component's `Sec`-suffixed props at the call
35
+ * site if they ever differ.
36
+ */
37
+ // Under this many words a WPM is an artefact of segment boundaries rather than
38
+ // a speaking style: "Right." alone on a four-second segment reads as 15 wpm,
39
+ // and a three-word interjection inside someone else's sentence reads as 400.
40
+ // Twenty words is roughly eight seconds of continuous speech — the shortest
41
+ // sample that has ever looked like a pace instead of a coincidence.
42
+ const MIN_WORDS = 20;
43
+ // Within ±7% of target, do nothing at all. 1.05 is audible on music and
44
+ // essentially inaudible on speech, so a rate that close buys nothing — and
45
+ // since turns hand off every few seconds, a hair-trigger would have the pitch
46
+ // stepping on every handoff. A rate that flickers 1.0 → 1.05 → 1.0 is worse
47
+ // than no feature; leaving someone alone is a real answer.
48
+ const DEADZONE = 0.07;
49
+ const DEFAULTS = { min: 0.75, max: 1.5, step: 0.05 };
50
+ // finite-or-null: the single gate every external number passes through, so
51
+ // "0.9", undefined, NaN and {} all collapse to one thing callers can test
52
+ function num(v) {
53
+ const n = typeof v === "number" ? v : parseFloat(v);
54
+ return Number.isFinite(n) ? n : null;
55
+ }
56
+ function labelOf(v) {
57
+ if (typeof v !== "string")
58
+ return null;
59
+ const s = v.trim();
60
+ return s || null;
61
+ }
62
+ // Whitespace tokens, and nothing cleverer. Stripping punctuation or filtering
63
+ // "um" would make the number prettier and less true — WPM is only useful here
64
+ // as a RATIO between two speakers counted the same way, and both of them say um.
65
+ function countWords(text) {
66
+ if (typeof text !== "string")
67
+ return 0;
68
+ const s = text.trim();
69
+ return s ? s.split(/\s+/).length : 0;
70
+ }
71
+ function flattenTurns(turns) {
72
+ const list = [];
73
+ for (const t of turns) {
74
+ if (!t || typeof t !== "object")
75
+ continue;
76
+ const start = num(t.start);
77
+ const end = num(t.end);
78
+ if (start == null || end == null || end <= start)
79
+ continue;
80
+ list.push({
81
+ start,
82
+ end,
83
+ name: t.ad ? null : labelOf(t.name),
84
+ });
85
+ }
86
+ if (!list.length)
87
+ return [];
88
+ // hosts promise sorted input; sorting anyway costs O(n log n) once per array
89
+ // and takes the promise out of the contract, which is cheaper than a support
90
+ // thread about rates being off by one speaker
91
+ list.sort((a, b) => a.start - b.start);
92
+ const cuts = [];
93
+ for (const t of list)
94
+ cuts.push(t.start, t.end);
95
+ cuts.sort((a, b) => a - b);
96
+ const flat = [];
97
+ let ti = 0;
98
+ let open = [];
99
+ for (let i = 0; i < cuts.length - 1; i++) {
100
+ const a = cuts[i];
101
+ const b = cuts[i + 1];
102
+ if (b <= a)
103
+ continue; // duplicate boundary — zero-width slice, nothing to own
104
+ while (ti < list.length && list[ti].start <= a)
105
+ open.push(list[ti++]);
106
+ open = open.filter((t) => t.end > a); // bounded by overlap depth, not by n
107
+ if (!open.length)
108
+ continue;
109
+ let win = open[0];
110
+ for (const t of open)
111
+ if (t.start >= win.start)
112
+ win = t;
113
+ const prev = flat[flat.length - 1];
114
+ // merge across cut points: the interesting boundaries are handoffs, and a
115
+ // speaker interrupted-then-resumed should read as one stretch, not three
116
+ if (prev && prev.name === win.name && prev.end === a)
117
+ prev.end = b;
118
+ else
119
+ flat.push({ start: a, end: b, name: win.name });
120
+ }
121
+ return flat;
122
+ }
123
+ // rateAt runs on every timeupdate, so the flatten is memoised against the array
124
+ // identity the host handed us. Fingerprinting length + endpoints catches the
125
+ // realistic staleness (a fresh array from setSpeakers, a re-diarise); an
126
+ // in-place edit that preserves both would go unseen, which is why the host
127
+ // replaces this array rather than mutating it.
128
+ const flatCache = new WeakMap();
129
+ function prepared(turns) {
130
+ if (!Array.isArray(turns) || !turns.length)
131
+ return [];
132
+ const mark = turns.length +
133
+ ":" +
134
+ turns[0]?.start +
135
+ ":" +
136
+ turns[turns.length - 1]?.end;
137
+ const hit = flatCache.get(turns);
138
+ if (hit && hit.mark === mark)
139
+ return hit.flat;
140
+ const flat = flattenTurns(turns);
141
+ flatCache.set(turns, { mark, flat });
142
+ return flat;
143
+ }
144
+ // last slice starting at or before sec, then a coverage check — [start, end),
145
+ // so a time landing exactly on a handoff belongs to the person taking over
146
+ function segAt(flat, sec) {
147
+ let lo = 0;
148
+ let hi = flat.length - 1;
149
+ let found = -1;
150
+ while (lo <= hi) {
151
+ const mid = (lo + hi) >> 1;
152
+ if (flat[mid].start <= sec) {
153
+ found = mid;
154
+ lo = mid + 1;
155
+ }
156
+ else
157
+ hi = mid - 1;
158
+ }
159
+ if (found < 0)
160
+ return null;
161
+ const seg = flat[found];
162
+ return sec < seg.end ? seg : null;
163
+ }
164
+ // Weighted by talk time, not a plain median of the speakers. Two speakers with
165
+ // a plain median land on the midpoint and BOTH get resampled — the worst of the
166
+ // three options, since the voice you hear most is the one you're least likely
167
+ // to want altered. Weighted, the dominant speaker sits at the target and stays
168
+ // at 1x, and the occasional guest is the one who moves. An exact tie splits the
169
+ // difference so a two-hander doesn't arbitrarily crown whoever is slower.
170
+ function weightedMedian(rows) {
171
+ const list = rows.slice().sort((a, b) => a.wpm - b.wpm);
172
+ const total = list.reduce((s, r) => s + r.seconds, 0);
173
+ if (!(total > 0))
174
+ return 0;
175
+ const half = total / 2;
176
+ let acc = 0;
177
+ for (let i = 0; i < list.length; i++) {
178
+ acc += list[i].seconds;
179
+ if (acc > half)
180
+ return list[i].wpm;
181
+ if (acc === half) {
182
+ const next = list[i + 1];
183
+ return next ? (list[i].wpm + next.wpm) / 2 : list[i].wpm;
184
+ }
185
+ }
186
+ return list[list.length - 1].wpm;
187
+ }
188
+ const round1 = (n) => Math.round(n * 10) / 10;
189
+ export function speakerPace(transcript, speakers) {
190
+ const empty = { bySpeaker: Object.create(null), medianWpm: 0 };
191
+ if (!Array.isArray(transcript) || !transcript.length)
192
+ return empty;
193
+ const flat = prepared(speakers);
194
+ const acc = new Map();
195
+ for (const seg of transcript) {
196
+ if (!seg || typeof seg !== "object")
197
+ continue;
198
+ const start = num(seg.start);
199
+ const end = num(seg.end);
200
+ if (start == null || end == null || end <= start)
201
+ continue;
202
+ const words = countWords(seg.text);
203
+ if (!words)
204
+ continue;
205
+ // Midpoint, not start: a segment straddling a handoff belongs to whoever
206
+ // did most of the talking in it, and with no per-word timings the midpoint
207
+ // is the cheapest honest proxy. Turns win over segment.speaker when both
208
+ // exist — the diarisation pass that produced the turns is the one that
209
+ // resolved LABEL → display name and marked the ads.
210
+ const covering = flat.length ? segAt(flat, (start + end) / 2) : null;
211
+ // covering with a null name is an ad read: known, and deliberately not a
212
+ // person. Falling through to seg.speaker here would file the sponsor's
213
+ // words under the host.
214
+ const name = covering
215
+ ? covering.name
216
+ : labelOf(seg.speaker);
217
+ if (!name)
218
+ continue;
219
+ const row = acc.get(name) || { words: 0, seconds: 0 };
220
+ row.words += words;
221
+ row.seconds += end - start;
222
+ acc.set(name, row);
223
+ }
224
+ // null-prototype: a speaker literally named "__proto__" (names are
225
+ // user-editable — the rename menu accepts anything) would otherwise assign
226
+ // to the object's prototype slot instead of creating a key, leaving
227
+ // bySpeaker self-inconsistent: readable by name, invisible to Object.keys
228
+ // (the empty return above is null-prototype for the same reason — both
229
+ // Object.create(null) sites are deliberate, not style)
230
+ const bySpeaker = Object.create(null);
231
+ const rows = [];
232
+ for (const [name, row] of acc) {
233
+ if (row.words < MIN_WORDS || !(row.seconds > 0))
234
+ continue;
235
+ const wpm = round1((row.words / row.seconds) * 60);
236
+ bySpeaker[name] = {
237
+ wpm,
238
+ words: row.words,
239
+ seconds: Math.round(row.seconds * 100) / 100,
240
+ };
241
+ rows.push({ wpm, seconds: row.seconds });
242
+ }
243
+ return {
244
+ bySpeaker,
245
+ medianWpm: rows.length ? round1(weightedMedian(rows)) : 0,
246
+ };
247
+ }
248
+ export function suggestRates(pace, opts) {
249
+ const out = {};
250
+ const by = pace && typeof pace === "object" ? pace.bySpeaker : null;
251
+ if (!by || typeof by !== "object")
252
+ return out;
253
+ const o = opts && typeof opts === "object" ? opts : {};
254
+ const target = num(o.targetWpm) ?? num(pace.medianWpm);
255
+ if (!(target !== null && target > 0))
256
+ return out;
257
+ const a = num(o.min) ?? DEFAULTS.min;
258
+ const b = num(o.max) ?? DEFAULTS.max;
259
+ const lo = Math.min(a, b);
260
+ const hi = Math.max(a, b); // an inverted range is a typo, not an instruction to return nothing
261
+ const step = num(o.step) ?? DEFAULTS.step;
262
+ for (const [name, p] of Object.entries(by)) {
263
+ const wpm = num(p && p.wpm);
264
+ if (wpm == null || wpm <= 0)
265
+ continue;
266
+ const raw = target / wpm;
267
+ if (Math.abs(raw - 1) <= DEADZONE && raw >= lo && raw <= hi) {
268
+ // exactly 1, not 0.9999 — the host compares against 1. Gated on the
269
+ // range: an unmodified 1 is only the right answer if 1 is a rate the
270
+ // caller actually allows. With min 1.2 it isn't, and returning it anyway
271
+ // silently broke the one promise this function makes about its output.
272
+ out[name] = 1;
273
+ continue;
274
+ }
275
+ // snap first, then clamp: snapping a clamped value can walk it back over
276
+ // the limit, and the limit is the promise the user actually set
277
+ const snapped = step > 0 ? Math.round(raw / step) * step : raw;
278
+ const rate = Math.round(Math.min(hi, Math.max(lo, snapped)) * 1000) / 1000;
279
+ out[name] = Math.abs(rate - 1) < 1e-9 ? 1 : rate;
280
+ }
281
+ return out;
282
+ }
283
+ export function rateAt(t, turns, rates, fallback = 1) {
284
+ const fb = num(fallback) ?? 1;
285
+ const sec = num(t);
286
+ if (sec == null)
287
+ return fb;
288
+ const flat = prepared(turns);
289
+ if (!flat.length)
290
+ return fb;
291
+ const seg = segAt(flat, sec);
292
+ // no turn covering t, or an ad read (name === null): nobody to match, so
293
+ // normal speed. Silence between turns is the one place a rate change would
294
+ // be inaudible anyway, and holding the last speaker's rate through it makes
295
+ // the next handoff land late.
296
+ if (!seg || !seg.name)
297
+ return fb;
298
+ const r = rates && typeof rates === "object"
299
+ ? num(rates[seg.name])
300
+ : null;
301
+ return r != null && r > 0 ? r : fb;
302
+ }
303
+ // Sorted by talk time because that's the order you think about the people in:
304
+ // the host first, the guest who said four sentences last. Someone already on
305
+ // pace still gets a line — seeing that they were measured and left alone is
306
+ // the point, and a list that only shows the changed speakers reads like the
307
+ // others were missed.
308
+ export function describeRates(pace, rates) {
309
+ const by = pace && typeof pace === "object" ? pace.bySpeaker : null;
310
+ if (!by || typeof by !== "object")
311
+ return [];
312
+ const rows = Object.entries(by).filter(([, p]) => p && num(p.wpm) != null);
313
+ rows.sort((x, y) => (num(y[1].seconds) ?? 0) - (num(x[1].seconds) ?? 0) ||
314
+ x[0].localeCompare(y[0]));
315
+ return rows.map(([name, p]) => {
316
+ const wpm = Math.round(num(p.wpm));
317
+ const r = rates && typeof rates === "object" ? num(rates[name]) : null;
318
+ if (r == null || Math.abs(r - 1) < 1e-9)
319
+ return `${name} ${wpm} wpm · on pace`;
320
+ return `${name} ${wpm} wpm → ${+r.toFixed(2)}x`;
321
+ });
322
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",
@@ -76,7 +76,8 @@
76
76
  "check:demos": "node scripts/check-demo-coverage.mjs",
77
77
  "guard": "node scripts/check-demo-coverage.mjs",
78
78
  "test:dist": "svelte-kit sync && vitest run --config vitest.dist.config.ts",
79
- "test:unit": "node --import ./tests/unit/register.mjs --test tests/unit/",
79
+ "test:unit": "node --import ./tests/unit/register.mjs --test tests/unit/*.test.ts",
80
+ "test:advisors": "svelte-kit sync && vitest run --config vitest.unit.config.ts",
80
81
  "prepublishOnly": "node scripts/guard-ci-publish.mjs && npm run package"
81
82
  },
82
83
  "peerDependencies": {