@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.
- package/CLAUDE.md +10 -0
- package/dist/components/VideoPlayer.svelte +504 -8
- package/dist/components/VideoPlayer.svelte.d.ts +42 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +10 -0
- package/dist/video/README.md +18 -4
- package/dist/video/advisors/README.md +20 -0
- package/dist/video/advisors/qr.d.ts +95 -0
- package/dist/video/advisors/qr.js +546 -0
- package/dist/video/advisors/quiet-listening.d.ts +73 -0
- package/dist/video/advisors/quiet-listening.js +292 -0
- package/dist/video/advisors/resume-point.d.ts +71 -0
- package/dist/video/advisors/resume-point.js +302 -0
- package/dist/video/advisors/smart-speed.d.ts +82 -0
- package/dist/video/advisors/smart-speed.js +314 -0
- package/dist/video/advisors/speaker-rate.d.ts +74 -0
- package/dist/video/advisors/speaker-rate.js +322 -0
- package/dist/video/keys.d.ts +4 -4
- package/dist/video/keys.js +4 -3
- package/dist/video/night/clock.d.ts +31 -0
- package/dist/video/night/clock.js +58 -0
- package/dist/video/night/levels.d.ts +78 -0
- package/dist/video/night/levels.js +117 -0
- package/dist/video/night/meter.d.ts +53 -0
- package/dist/video/night/meter.js +115 -0
- package/dist/video/night/track.d.ts +105 -0
- package/dist/video/night/track.js +138 -0
- package/dist/video/night.d.ts +25 -20
- package/dist/video/night.js +25 -20
- package/dist/video/settings.js +10 -1
- package/package.json +3 -2
|
@@ -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[];
|