@joewinke/jatui 0.10.0 → 0.12.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,82 @@
1
+ /**
2
+ * Silence-aware playback speed. Overcast's trick, done off the waveform we
3
+ * already have. Ported verbatim from extension-library `lib/smart-speed.js`
4
+ * (mos-5lzns.27) — behaviour identical, including the tuned constants; see
5
+ * VIDEO-PLAYER-GAP-AUDIT.md ("lift the CONSTANTS AND THE REASONS").
6
+ *
7
+ * The problem with a flat 1.5x is that it charges the same tax on everything:
8
+ * the pauses AND the words. Speech is barely compressible before it turns to
9
+ * chipmunk, but the gaps between sentences are pure dead time — a lecture is
10
+ * routinely 15-20% silence, and NONE of that silence carries meaning. So spend
11
+ * the speed where the meaning isn't: hold 1x through dense speech, and run the
12
+ * gaps at 2.2x where nobody can hear the difference because there is nothing
13
+ * to hear.
14
+ *
15
+ * Why the waveform and not the transcript: the envelope is already computed for
16
+ * every video (~600 bins, drawn on the band), it exists before diarization
17
+ * finishes, and it's honest about pauses WITHIN a transcript segment — segment
18
+ * boundaries are where the ASR chose to break, not where the speaker actually
19
+ * stopped. Rejected: analysing the audio live through an AnalyserNode. That
20
+ * only ever knows the present, so it reacts a beat after each gap opens and a
21
+ * beat after it closes — exactly the two moments that must be precise.
22
+ *
23
+ * This module is a pure ADVISOR (src/lib/video/README.md doctrine). It never
24
+ * touches a video element and never sets playbackRate; it hands back a
25
+ * multiplier and the component decides what to do with it (multiplied against
26
+ * the user's base rate and any per-speaker rate, via the rate composer in
27
+ * `../rate` — mos-5lzns.32 owns that wiring). Keeping it dumb is what makes it
28
+ * testable under node with no DOM at all.
29
+ *
30
+ * buildSilenceMap(wave, opts) -> { ranges: [[start,end],...], covered, duration }
31
+ * boostAt(t, map, opts) -> multiplier (1 outside gaps, boost inside)
32
+ * estimateSaving(map, opts) -> seconds saved, for UI copy
33
+ *
34
+ * prefers-reduced-motion is deliberately NOT consulted here, and that is a
35
+ * decision rather than an oversight: reduced motion is about things moving in
36
+ * the viewport, and rate is neither motion nor decoration — muting it would
37
+ * silently disable an accessibility-adjacent feature (getting through material
38
+ * faster) for people who asked for calmer animation. The host owns any visual
39
+ * indicator of the rate change, and that indicator is where the media query
40
+ * belongs.
41
+ */
42
+ /** The waveform envelope the component already holds (`VideoWaveform`). */
43
+ export interface SilenceWave {
44
+ /** Seconds the peaks span. */
45
+ duration?: number | null;
46
+ /** Peak per bin, on any scale — normalised against their own max. */
47
+ peaks?: ArrayLike<number> | null;
48
+ }
49
+ /** A qualified, padded silent run: `[startSec, endSec)`. */
50
+ export type SilenceRange = [number, number];
51
+ export interface SilenceMap {
52
+ ranges: SilenceRange[];
53
+ /** Total seconds inside ranges — the naive figure, before lead-trimming. */
54
+ covered: number;
55
+ duration: number;
56
+ }
57
+ export interface SilenceOpts {
58
+ floor?: number;
59
+ minGap?: number;
60
+ mergeGap?: number;
61
+ pad?: number;
62
+ }
63
+ export interface BoostOpts {
64
+ boost?: number;
65
+ lead?: number;
66
+ }
67
+ /**
68
+ * wave = { duration: seconds, peaks: [0..99] } (~600 bins, evenly spaced)
69
+ * opts = { floor, minGap, mergeGap, pad }
70
+ */
71
+ export declare function buildSilenceMap(wave: SilenceWave | null | undefined, opts?: SilenceOpts | null): SilenceMap;
72
+ /**
73
+ * Multiplier at time t: 1 outside the gaps, `boost` inside.
74
+ * opts = { boost, lead }
75
+ */
76
+ export declare function boostAt(t: unknown, map: unknown, opts?: BoostOpts | null): number;
77
+ /**
78
+ * Seconds saved across the whole track at this boost — for UI copy
79
+ * ("about 6 minutes shorter"), not for scheduling.
80
+ * opts = { boost, lead }
81
+ */
82
+ export declare function estimateSaving(map: unknown, opts?: BoostOpts | null): number;
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Silence-aware playback speed. Overcast's trick, done off the waveform we
3
+ * already have. Ported verbatim from extension-library `lib/smart-speed.js`
4
+ * (mos-5lzns.27) — behaviour identical, including the tuned constants; see
5
+ * VIDEO-PLAYER-GAP-AUDIT.md ("lift the CONSTANTS AND THE REASONS").
6
+ *
7
+ * The problem with a flat 1.5x is that it charges the same tax on everything:
8
+ * the pauses AND the words. Speech is barely compressible before it turns to
9
+ * chipmunk, but the gaps between sentences are pure dead time — a lecture is
10
+ * routinely 15-20% silence, and NONE of that silence carries meaning. So spend
11
+ * the speed where the meaning isn't: hold 1x through dense speech, and run the
12
+ * gaps at 2.2x where nobody can hear the difference because there is nothing
13
+ * to hear.
14
+ *
15
+ * Why the waveform and not the transcript: the envelope is already computed for
16
+ * every video (~600 bins, drawn on the band), it exists before diarization
17
+ * finishes, and it's honest about pauses WITHIN a transcript segment — segment
18
+ * boundaries are where the ASR chose to break, not where the speaker actually
19
+ * stopped. Rejected: analysing the audio live through an AnalyserNode. That
20
+ * only ever knows the present, so it reacts a beat after each gap opens and a
21
+ * beat after it closes — exactly the two moments that must be precise.
22
+ *
23
+ * This module is a pure ADVISOR (src/lib/video/README.md doctrine). It never
24
+ * touches a video element and never sets playbackRate; it hands back a
25
+ * multiplier and the component decides what to do with it (multiplied against
26
+ * the user's base rate and any per-speaker rate, via the rate composer in
27
+ * `../rate` — mos-5lzns.32 owns that wiring). Keeping it dumb is what makes it
28
+ * testable under node with no DOM at all.
29
+ *
30
+ * buildSilenceMap(wave, opts) -> { ranges: [[start,end],...], covered, duration }
31
+ * boostAt(t, map, opts) -> multiplier (1 outside gaps, boost inside)
32
+ * estimateSaving(map, opts) -> seconds saved, for UI copy
33
+ *
34
+ * prefers-reduced-motion is deliberately NOT consulted here, and that is a
35
+ * decision rather than an oversight: reduced motion is about things moving in
36
+ * the viewport, and rate is neither motion nor decoration — muting it would
37
+ * silently disable an accessibility-adjacent feature (getting through material
38
+ * faster) for people who asked for calmer animation. The host owns any visual
39
+ * indicator of the rate change, and that indicator is where the media query
40
+ * belongs.
41
+ */
42
+ const DEFAULTS = {
43
+ floor: 0.12, // share of the track's OWN peak that still counts as silence
44
+ // minGap and mergeGap used to be the same number doing two jobs: minGap also
45
+ // stood in for "how close together do two gaps have to be before they're
46
+ // treated as one". That meant turning minGap UP — read by anyone as "be more
47
+ // conservative, speed through fewer gaps" — silently ALSO merged more
48
+ // aggressively across the loud material between gaps, the opposite of
49
+ // conservative. Split into two knobs that each move behaviour in the
50
+ // direction their name promises:
51
+ minGap: 0.35, // a run shorter than this is natural pacing, not dead air worth speeding through
52
+ // mergeGap has no default of its OWN — buildSilenceMap defaults it to minGap
53
+ // when the caller doesn't pass one, so `buildSilenceMap(wave, {})` (the only
54
+ // way the host calls this) behaves exactly as it always has.
55
+ pad: 0.08, // trimmed off both ends so an onset is never clipped
56
+ boost: 2.2,
57
+ lead: 0.15, // boost starts late / ends early by this much
58
+ };
59
+ // Anything unparseable becomes the fallback rather than propagating NaN through
60
+ // arithmetic — a NaN duration silently produces a map full of NaN ranges, and
61
+ // NaN comparisons are all false, so the bug shows up as "smart speed just does
62
+ // nothing" three files away instead of here.
63
+ function num(v, fallback) {
64
+ const n = typeof v === "number" ? v : parseFloat(v);
65
+ return Number.isFinite(n) ? n : fallback;
66
+ }
67
+ function clamp(v, lo, hi) {
68
+ return v < lo ? lo : v > hi ? hi : v;
69
+ }
70
+ // Accept anything peaks[i]-indexable with a numeric length: a plain Array, but
71
+ // also Float32Array/Uint8Array — the waveform pipeline hands back typed arrays
72
+ // as often as plain ones, and Array.isArray(typedArray) is false, so that check
73
+ // used to reject perfectly good data and fall through to the neutral map with
74
+ // no error and no hint why. Strings are explicitly excluded even though they
75
+ // have a numeric .length and are index-accessible — a string isn't a sequence
76
+ // of numbers, it's text that happens to overlap the shape, and letting it
77
+ // through would mean silently treating every character code as a peak.
78
+ function indexable(v) {
79
+ // Number.isSafeInteger, not just "is a number": a crafted {length: Infinity}
80
+ // satisfies every other check here and then hangs the bin loop forever. A
81
+ // waveform arrives as parsed JSON from a sidecar on disk, so this is defence
82
+ // against a corrupted file rather than an attacker — but a hang is the one
83
+ // failure mode this module must never have, since it runs on the UI thread.
84
+ return (v != null &&
85
+ typeof v !== "string" &&
86
+ Number.isSafeInteger(v.length) &&
87
+ v.length >= 0);
88
+ }
89
+ // ── the map ─────────────────────────────────────────────────────────────────
90
+ /**
91
+ * wave = { duration: seconds, peaks: [0..99] } (~600 bins, evenly spaced)
92
+ * opts = { floor, minGap, mergeGap, pad }
93
+ */
94
+ export function buildSilenceMap(wave, opts) {
95
+ const duration = num(wave && wave.duration, 0);
96
+ const peaks = wave && indexable(wave.peaks) ? wave.peaks : null;
97
+ // A map with no ranges is a perfectly valid answer — it means "advise 1x
98
+ // everywhere", which is what every guard below wants to say.
99
+ const none = {
100
+ ranges: [],
101
+ covered: 0,
102
+ duration: duration > 0 ? duration : 0,
103
+ };
104
+ if (!(duration > 0) || !peaks || peaks.length === 0)
105
+ return none;
106
+ const floor = clamp(num(opts && opts.floor, DEFAULTS.floor), 0, 1);
107
+ const minGap = Math.max(0, num(opts && opts.minGap, DEFAULTS.minGap));
108
+ // mergeGap falls back to minGap, not to DEFAULTS.minGap — so a caller who
109
+ // customizes minGap and never mentions mergeGap still gets the pre-split
110
+ // behaviour (one number governing both jobs) rather than a mix of their
111
+ // minGap and a hardcoded merge distance.
112
+ const mergeGap = Math.max(0, num(opts && opts.mergeGap, minGap));
113
+ const pad = Math.max(0, num(opts && opts.pad, DEFAULTS.pad));
114
+ // Normalize against the track's own maximum, never against the nominal 0..99
115
+ // scale. A quietly-recorded lecture that peaks at 22 has silence somewhere
116
+ // around 3, and an absolute 0.12*99 threshold would declare the entire video
117
+ // silent — the failure mode is speeding through the whole thing at 2.2x,
118
+ // which is precisely the chipmunk outcome this feature exists to avoid.
119
+ let max = 0;
120
+ for (let i = 0; i < peaks.length; i++) {
121
+ const v = num(peaks[i], NaN);
122
+ if (Number.isFinite(v) && v > max)
123
+ max = v;
124
+ }
125
+ // No signal at all (all zeros, all garbage): there is nothing to be confident
126
+ // about, so claim nothing.
127
+ if (!(max > 0))
128
+ return { ranges: [], covered: 0, duration };
129
+ const level = max * floor;
130
+ const bin = duration / peaks.length;
131
+ // Pass 1 — raw silent runs, in bin units promoted to seconds. A non-finite
132
+ // bin counts as LOUD: unreadable data is not evidence of silence, and the
133
+ // asymmetry matters (a missed gap costs a little time, a wrong gap eats a
134
+ // word).
135
+ const runs = [];
136
+ let open = -1;
137
+ for (let i = 0; i < peaks.length; i++) {
138
+ const v = num(peaks[i], NaN);
139
+ const silent = Number.isFinite(v) && v <= level;
140
+ if (silent) {
141
+ if (open < 0)
142
+ open = i;
143
+ }
144
+ else if (open >= 0) {
145
+ runs.push([open * bin, i * bin]);
146
+ open = -1;
147
+ }
148
+ }
149
+ if (open >= 0)
150
+ runs.push([open * bin, peaks.length * bin]);
151
+ // Pass 2 — coalesce. Two gaps separated by less than mergeGap of speech are
152
+ // one gap with a blip in it (a breath, a click, a single loud bin of room
153
+ // tone); treating them separately means the rate drops back to 1x for a
154
+ // fifth of a second and immediately climbs again, which is audible as a
155
+ // wobble even though each individual decision was defensible. Deliberately
156
+ // NOT minGap here (see DEFAULTS) — merging and qualifying are different
157
+ // questions and default to the same number only because that's today's
158
+ // behaviour, not because they're actually the same idea.
159
+ const merged = [];
160
+ for (const r of runs) {
161
+ const last = merged[merged.length - 1];
162
+ if (last && r[0] - last[1] < mergeGap)
163
+ last[1] = r[1];
164
+ else
165
+ merged.push([r[0], r[1]]);
166
+ }
167
+ // Pass 3 — qualify by length, THEN pad. Order matters: minGap is a statement
168
+ // about the pause itself ("is this dead air or is it just how people talk"),
169
+ // so it has to be judged on the real gap, before pad shaves it. Judging after
170
+ // padding would silently require minGap + 2*pad of silence and make the two
171
+ // knobs interfere.
172
+ const ranges = [];
173
+ let covered = 0;
174
+ for (const [a, b] of merged) {
175
+ if (b - a < minGap)
176
+ continue;
177
+ const start = clamp(a + pad, 0, duration);
178
+ const end = clamp(b - pad, 0, duration);
179
+ if (end - start <= 0)
180
+ continue; // padded out of existence
181
+ ranges.push([start, end]);
182
+ covered += end - start;
183
+ }
184
+ return { ranges, covered, duration };
185
+ }
186
+ // ── lookup ──────────────────────────────────────────────────────────────────
187
+ // boostAt runs on every timeupdate (~4/s per video, and the theater can have a
188
+ // preview element too), so it binary-searches. It also has to survive a map it
189
+ // didn't build — an older cached map, something round-tripped through JSON,
190
+ // ranges a caller assembled by hand — hence one normalize-and-sort pass whose
191
+ // result is cached against the map object. Cost is O(n) once, O(log n) forever
192
+ // after, which is the only way to be both defensive AND cheap in a hot path.
193
+ //
194
+ // Cache key is the map object; the guard is the ranges array's IDENTITY, so a
195
+ // rebuilt map invalidates itself for free. The one thing that fools it is
196
+ // mutating an existing ranges array in place — don't; assign a new one, which
197
+ // is what buildSilenceMap returns anyway.
198
+ const normalized = new WeakMap();
199
+ function rangesOf(map) {
200
+ if (!map || typeof map !== "object")
201
+ return null;
202
+ const raw = map.ranges;
203
+ if (!Array.isArray(raw) || raw.length === 0)
204
+ return null;
205
+ const hit = normalized.get(map);
206
+ if (hit && hit.src === raw)
207
+ return hit.out;
208
+ const clean = [];
209
+ for (const r of raw) {
210
+ if (!Array.isArray(r) || r.length < 2)
211
+ continue;
212
+ const a = num(r[0], NaN);
213
+ const b = num(r[1], NaN);
214
+ if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a)
215
+ continue;
216
+ clean.push([a, b]);
217
+ }
218
+ clean.sort((x, y) => x[0] - y[0]);
219
+ // Coalesce overlaps so the array is strictly disjoint and ascending. That is
220
+ // what makes "rightmost range whose start <= t" a correct containment test
221
+ // rather than an approximation — with overlaps, a later-starting shorter
222
+ // range can shadow the one actually containing t.
223
+ const out = [];
224
+ for (const r of clean) {
225
+ const last = out[out.length - 1];
226
+ if (last && r[0] <= last[1]) {
227
+ if (r[1] > last[1])
228
+ last[1] = r[1];
229
+ }
230
+ else
231
+ out.push(r);
232
+ }
233
+ // Cache the SAME value we're about to return, not the raw (possibly empty)
234
+ // `out` array. Caching `out` unconditionally used to mean an all-invalid map
235
+ // returned null on the first call (out.length is 0) but a truthy empty array
236
+ // on every call after, from the cache — harmless in practice (boostAt's
237
+ // binary search over a zero-length array also lands on "no match"), but it's
238
+ // one value on the first call and a different one on the second for no
239
+ // reason, which is exactly the kind of thing that stops being harmless the
240
+ // next time someone adds a `if (!ranges)` check downstream.
241
+ const result = out.length ? out : null;
242
+ try {
243
+ normalized.set(map, { src: raw, out: result });
244
+ }
245
+ catch {
246
+ // a frozen host object can't carry the cache — every call re-normalizes,
247
+ // which is correct, only slower
248
+ }
249
+ return result;
250
+ }
251
+ /**
252
+ * Multiplier at time t: 1 outside the gaps, `boost` inside.
253
+ * opts = { boost, lead }
254
+ */
255
+ export function boostAt(t, map, opts) {
256
+ const time = num(t, NaN);
257
+ if (!Number.isFinite(time))
258
+ return 1;
259
+ const boost = Math.max(1, num(opts && opts.boost, DEFAULTS.boost));
260
+ if (boost === 1)
261
+ return 1;
262
+ const lead = Math.max(0, num(opts && opts.lead, DEFAULTS.lead));
263
+ const ranges = rangesOf(map);
264
+ if (!ranges)
265
+ return 1;
266
+ let lo = 0;
267
+ let hi = ranges.length - 1;
268
+ let found = -1;
269
+ while (lo <= hi) {
270
+ const mid = (lo + hi) >> 1;
271
+ if (ranges[mid][0] <= time) {
272
+ found = mid;
273
+ lo = mid + 1;
274
+ }
275
+ else
276
+ hi = mid - 1;
277
+ }
278
+ if (found < 0)
279
+ return 1;
280
+ // lead applied symmetrically: come up to speed only after the speaker has
281
+ // definitely stopped, and be back at 1x before they start again. The end is
282
+ // the side that actually matters — arriving at a word already doing 2.2x
283
+ // clips its onset, and a clipped consonant is the single most noticeable
284
+ // artifact this whole feature can produce.
285
+ const [start, end] = ranges[found];
286
+ return time >= start + lead && time < end - lead ? boost : 1;
287
+ }
288
+ /**
289
+ * Seconds saved across the whole track at this boost — for UI copy
290
+ * ("about 6 minutes shorter"), not for scheduling.
291
+ * opts = { boost, lead }
292
+ */
293
+ export function estimateSaving(map, opts) {
294
+ const boost = Math.max(1, num(opts && opts.boost, DEFAULTS.boost));
295
+ if (boost <= 1)
296
+ return 0;
297
+ const lead = Math.max(0, num(opts && opts.lead, DEFAULTS.lead));
298
+ const ranges = rangesOf(map);
299
+ if (!ranges)
300
+ return 0;
301
+ // Measured off the SAME lead-trimmed windows boostAt actually boosts, so the
302
+ // number the user is shown is the number they get. Summing map.covered and
303
+ // multiplying would over-promise by 2*lead per gap — with a lot of short
304
+ // gaps that is most of the claim.
305
+ let sped = 0;
306
+ for (const [a, b] of ranges) {
307
+ const span = b - a - 2 * lead;
308
+ if (span > 0)
309
+ sped += span;
310
+ }
311
+ // t seconds of material played at `boost` takes t/boost — the saving is the
312
+ // remainder, never the naive sped*boost.
313
+ return sped * (1 - 1 / boost);
314
+ }
@@ -0,0 +1,74 @@
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
+ /** A diarized turn: seconds, a resolved name, and the ad flag. */
38
+ export interface RateTurn {
39
+ start?: unknown;
40
+ end?: unknown;
41
+ /** Resolved display name (raw SPEAKER_NN labels are the caller's problem). */
42
+ name?: string | null;
43
+ /** Ad read — never attributed to a person, never a landing point. */
44
+ ad?: boolean;
45
+ }
46
+ /** A transcript segment: seconds, text, and a fallback speaker label. */
47
+ export interface RateSegment {
48
+ start?: unknown;
49
+ end?: unknown;
50
+ text?: unknown;
51
+ speaker?: string | null;
52
+ }
53
+ export interface SpeakerPaceRow {
54
+ wpm: number;
55
+ words: number;
56
+ seconds: number;
57
+ }
58
+ export interface SpeakerPace {
59
+ /** Null-prototype map — a speaker literally named "__proto__" stays a key. */
60
+ bySpeaker: Record<string, SpeakerPaceRow>;
61
+ medianWpm: number;
62
+ }
63
+ export interface SuggestOpts {
64
+ targetWpm?: number;
65
+ min?: number;
66
+ max?: number;
67
+ step?: number;
68
+ }
69
+ /** name → rate multiplier. */
70
+ export type RateMap = Record<string, number>;
71
+ export declare function speakerPace(transcript: unknown, speakers: unknown): SpeakerPace;
72
+ export declare function suggestRates(pace: unknown, opts?: SuggestOpts | null): RateMap;
73
+ export declare function rateAt(t: unknown, turns: unknown, rates: unknown, fallback?: unknown): number;
74
+ export declare function describeRates(pace: unknown, rates: unknown): string[];