@joewinke/jatui 0.12.0 → 0.14.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,187 @@
1
+ /**
2
+ * Loudness matching — the pure half (mos-5lzns.31). Ported from
3
+ * extension-library `library.js` §Loudness matching (audioLevel / audioTick)
4
+ * and FEATURES.md §Loudness matching; every number below is the extension's.
5
+ *
6
+ * Shape: a gated RMS estimate of the programme's LOUD level drives a slow,
7
+ * asymmetric gain rider toward a target. The graph (./graph.ts) feeds this
8
+ * module the analyser's time-domain buffer every AUDIO_TICK_MS and lands the
9
+ * returned ride on the gain param; nothing here touches WebAudio.
10
+ *
11
+ * Not true LUFS — no K-weighting, no channel weighting. The thing being
12
+ * corrected is "this video is louder than the last one", and RMS answers that
13
+ * honestly at a fraction of the cost.
14
+ */
15
+ /** How often the rider reads the meter. fftSize 2048 is ~46ms at 44.1kHz. */
16
+ export const AUDIO_TICK_MS = 200;
17
+ export const AUDIO_FFT_SIZE = 2048;
18
+ /** Where matching aims, dBFS RMS. At −20 an ordinary video is left at ~1.0×. */
19
+ export const AUDIO_TARGET_DEFAULT = -20;
20
+ export const AUDIO_TARGET_MIN = -30;
21
+ export const AUDIO_TARGET_MAX = -12;
22
+ /** Below this RMS a window is silence — it carries no vote about level. */
23
+ export const SILENCE_RMS = 1e-5;
24
+ /**
25
+ * The gate is the whole difference between working and shouting. A 46ms
26
+ * window of speech runs ~−15 dB, the pause between sentences ~−40; driving
27
+ * gain off the instantaneous level climbs through every gap and blares the
28
+ * next word. So the estimate only listens to material within GATE_DB of what
29
+ * it already believes the loud level to be (EBU R128's relative-gate idea).
30
+ */
31
+ export const GATE_DB = 12;
32
+ /** Rise toward a louder reading quickly … */
33
+ export const LOUD_RISE = 0.08;
34
+ /** … and forget a quiet one very slowly (a new video does NOT reset it). */
35
+ export const LOUD_FORGET = 0.01;
36
+ /**
37
+ * The clamp. 1.35 is a deliberate ceiling, not a round number: measured
38
+ * across the extension's library, target −20 leaves normal material at 1.0×,
39
+ * lifts a quiet podcast by the full +2.6 dB and pulls a hot one down 10 dB.
40
+ * Matching may rescue a quiet mix by a few dB; it may NOT become an amplifier.
41
+ */
42
+ export const GAIN_MIN = 0.3;
43
+ export const GAIN_MAX = 1.35;
44
+ /**
45
+ * Asymmetric ride — the opposite way round from night dimming: coming DOWN
46
+ * off something too loud is quick, going up is slow enough to be inaudible.
47
+ */
48
+ export const RIDE_DOWN = 0.15;
49
+ export const RIDE_UP = 0.02;
50
+ /** setTargetAtTime constant the ride lands with, so the param smooths between ticks. */
51
+ export const RIDE_TAU = 0.25;
52
+ /**
53
+ * The limiter — the fast half. High ratio, knee 0, fast attack: it does
54
+ * nothing until something actually peaks. Off moves the threshold to 0 dBFS
55
+ * rather than rewiring the graph (rewiring a live graph clicks); the move is
56
+ * RAMPED (LIMITER_TAU), never a `.value` write.
57
+ *
58
+ * Not a straight wire on its own: DynamicsCompressorNode applies automatic
59
+ * makeup gain (see `compressorMakeupDb`) — ~+1.7 dB at −3 dBFS / 20:1 — so the
60
+ * graph follows the limiter with a trim GainNode at the inverse. Without the
61
+ * trim, "limiter on" is a 1.7 dB level jump, which is exactly the kind of
62
+ * trace a feature must not leave.
63
+ */
64
+ export const LIMITER = Object.freeze({
65
+ threshold: -3,
66
+ knee: 0,
67
+ ratio: 20,
68
+ attack: 0.003,
69
+ release: 0.25
70
+ });
71
+ export const LIMITER_OFF_THRESHOLD = 0;
72
+ /** Ramp constant for limiter threshold/trim moves — under an event, over a click. */
73
+ export const LIMITER_TAU = 0.15;
74
+ /**
75
+ * Silence watchdog: a routed element whose analyser reads EXACT zeros for this
76
+ * long while it is playing, audible, its clock advancing AND with positive
77
+ * evidence that decoded audio is flowing is reported as silent — the
78
+ * signature of a source that became CORS-tainted after routing (the node
79
+ * outputs zeros, never noise). Without that evidence (no audio track, or a
80
+ * browser that can't tell us) it never fires. Exact digital silence in a real
81
+ * track (a muted-mic screen recording) can still trip it, which is why the
82
+ * report asks for a notice, never a remount.
83
+ */
84
+ export const SILENT_AFTER_MS = 4000;
85
+ const finite = (n) => typeof n === 'number' && Number.isFinite(n);
86
+ /**
87
+ * Short-term RMS of a time-domain buffer, in dBFS. `null` for silence (below
88
+ * SILENCE_RMS), an empty buffer or junk — never −Infinity, never NaN.
89
+ */
90
+ export function rmsDb(buf) {
91
+ if (!buf || !(buf.length > 0))
92
+ return null;
93
+ let sum = 0;
94
+ for (let i = 0; i < buf.length; i++) {
95
+ const s = buf[i];
96
+ if (!finite(s))
97
+ return null;
98
+ sum += s * s;
99
+ }
100
+ const rms = Math.sqrt(sum / buf.length);
101
+ if (!(rms >= SILENCE_RMS))
102
+ return null;
103
+ return 20 * Math.log10(rms);
104
+ }
105
+ /**
106
+ * One step of the gated loud-level estimate. `loud` null means "no estimate
107
+ * yet" — the first real reading seeds it. A null/junk `level` (silence) leaves
108
+ * the estimate exactly where it was.
109
+ */
110
+ export function gateLoud(loud, level) {
111
+ if (!finite(level))
112
+ return finite(loud) ? loud : null;
113
+ if (!finite(loud))
114
+ return level;
115
+ if (level > loud - GATE_DB) {
116
+ return loud + (level - loud) * (level > loud ? LOUD_RISE : LOUD_FORGET);
117
+ }
118
+ return loud;
119
+ }
120
+ /** Clamp a target to the declared range; junk falls back to the default. */
121
+ export function clampTarget(target) {
122
+ if (!finite(target))
123
+ return AUDIO_TARGET_DEFAULT;
124
+ return Math.min(AUDIO_TARGET_MAX, Math.max(AUDIO_TARGET_MIN, target));
125
+ }
126
+ /**
127
+ * The gain that would put the measured loud level at `target`, clamped to
128
+ * [GAIN_MIN, GAIN_MAX]. No estimate yet ⇒ 1 (neutral): we never guess.
129
+ */
130
+ export function wantGain(loud, target) {
131
+ if (!finite(loud))
132
+ return 1;
133
+ const want = Math.pow(10, (clampTarget(target) - loud) / 20);
134
+ return Math.min(GAIN_MAX, Math.max(GAIN_MIN, want));
135
+ }
136
+ /** One asymmetric step of the ride toward `want`. Junk in ⇒ neutral 1. */
137
+ export function rideStep(ride, want) {
138
+ const r = finite(ride) ? ride : 1;
139
+ if (!finite(want))
140
+ return r;
141
+ return r + (want - r) * (want < r ? RIDE_DOWN : RIDE_UP);
142
+ }
143
+ /** A ride as dB — for readouts. 1 ⇒ 0 dB; junk ⇒ 0. */
144
+ export function gainDb(ride) {
145
+ return finite(ride) && ride > 0 ? 20 * Math.log10(ride) : 0;
146
+ }
147
+ /**
148
+ * DynamicsCompressorNode's automatic makeup gain, dB, for knee 0 — the Web
149
+ * Audio spec's "makeup gain" step (as Chromium/WebKit/Gecko implement it):
150
+ * fullRangeGain = the curve's output for a 0 dBFS input, makeup =
151
+ * (1 / fullRangeGain) ^ 0.6. For knee 0 the curve at 0 dBFS is
152
+ * threshold + (0 − threshold) / ratio, so the makeup is
153
+ * 0.6 × (−threshold) × (1 − 1/ratio): +1.71 dB at −3 dBFS / 20:1, 0 at a
154
+ * 0 dBFS threshold. Junk ⇒ 0.
155
+ */
156
+ export function compressorMakeupDb(threshold, ratio) {
157
+ if (!finite(threshold) || !finite(ratio) || ratio < 1 || threshold >= 0)
158
+ return 0;
159
+ return 0.6 * -threshold * (1 - 1 / ratio);
160
+ }
161
+ /** The trim gain (linear) that cancels a limiter's makeup at this threshold. */
162
+ export function limiterTrim(threshold) {
163
+ return Math.pow(10, -compressorMakeupDb(threshold, LIMITER.ratio) / 20);
164
+ }
165
+ /**
166
+ * Can this element's audio be routed through WebAudio without going SILENT?
167
+ *
168
+ * A media response that isn't CORS-clean makes a MediaElementAudioSourceNode
169
+ * output zeros — and routing is a one-way door, so that is silence with no
170
+ * way back. A same-origin URL is NOT enough: it may 302 to a signed storage
171
+ * URL on another origin (MardukOS file routes do exactly that), and without
172
+ * `crossorigin` the redirected response is opaque. So:
173
+ *
174
+ * · 'ok' — blob:/data: (never leave the page's origin), or the element asked
175
+ * for a CORS load (`crossorigin` set — the load is then CORS-clean
176
+ * or it fails outright, never silently).
177
+ * · 'wait' — no source yet; nothing to decide.
178
+ * · 'cors' — anything else: refuse, play unprocessed.
179
+ */
180
+ export function routeSafety(mediaUrl, crossorigin) {
181
+ if (typeof mediaUrl !== 'string' || mediaUrl === '')
182
+ return 'wait';
183
+ if (crossorigin === 'anonymous' || crossorigin === 'use-credentials' || crossorigin === '') {
184
+ return 'ok';
185
+ }
186
+ return /^(blob|data):/i.test(mediaUrl.trim()) ? 'ok' : 'cors';
187
+ }
@@ -1,29 +1,40 @@
1
1
  /**
2
- * Audio module — CONTRACT ONLY (mos-5lzns.28). Implemented by mos-5lzns.31
3
- * ("audio graph — loudness matching, quiet listening, limiter").
2
+ * Audio module (mos-5lzns.31) — loudness matching, quiet listening, limiter.
3
+ * Ported from extension-library FEATURES.md §Loudness matching, §Quiet
4
+ * listening (library.js audioEnsure / audioTick / applyAudioSettings).
4
5
  *
5
- * What lands here (extension-library FEATURES.md §Loudness matching,
6
- * §Quiet listening), as ADVISORS + an audio graph builder:
6
+ * Layout:
7
7
  *
8
- * · loudness matching — a gated RMS meter drives a slow gain rider toward a
9
- * target (~−20 dB). The gate forgets slowly so a new video doesn't reset
10
- * the meter mid-lesson; the correction is measured, not a strength slider.
11
- * · quiet listening — loudness-compensated tone (two shelves + presence
12
- * peak + gentle compressor), amount rising as volume drops, a floor after
13
- * dark. Amount 0 rebuilds nothing: splicing a live graph mid-playback
14
- * clicks — a constant few ms of silence is the trade.
15
- * · limiter — threshold −3 dBFS, catching what the slow rider cannot.
8
+ * · audio/rider.ts — pure, node-testable: short-term RMS in dBFS, the gated
9
+ * loud-level estimate (12 dB relative gate, rise 0.08 / forget 0.01), the
10
+ * clamped want-gain (0.3–1.35), the asymmetric ride (down 0.15 / up 0.02),
11
+ * the limiter constants + `compressorMakeupDb`/`limiterTrim` (the
12
+ * compressor's automatic makeup gain, cancelled by a trim node), and
13
+ * `routeSafety` (only blob:/data: or a `crossorigin` load may be routed).
14
+ * · audio/graph.ts — `createAudioSession`, the graph builder + its state
15
+ * machine: source → gain → quiet chain (advisors/quiet-listening) →
16
+ * limiter → trim → destination, analyser tapping the source. Context
17
+ * first, route last: the element is taken only once a feature is on, the
18
+ * source is route-safe AND the context is `running`; gestures retry. After
19
+ * routing, an unsafe src or bit-exact silence is reported `broken`. A
20
+ * construction failure is a permanent `broken` that still plays plain
21
+ * audio. Disabled = no trace on the param timeline.
16
22
  *
17
23
  * Module rules (README): no <video> access, no settings reads, no DOM. The
18
- * graph builder is the one exception that may touch AudioContext — it takes
19
- * a MediaElementAudioSourceNode the component creates, never the element.
20
- * Disabled ⇒ the graph is bypassed entirely: no residual filter, no half
21
- * ramp, no stale param (cancelScheduledValues before restoring). Off must be
22
- * indistinguishable from never having run.
24
+ * graph is the one exception that touches AudioContext — and even it never
25
+ * sees the element: the component passes a `context()` factory and a
26
+ * `route(ctx)` closure that calls createMediaElementSource.
27
+ *
28
+ * ⚠ CORS: WebAudio outputs SILENCE for a source that isn't CORS-clean — and
29
+ * a same-origin URL may redirect to one. The host must serve the media with
30
+ * Access-Control-Allow-Origin and pass `crossorigin="anonymous"` (or use a
31
+ * blob:/data: src); otherwise the player refuses to route (status 'cors')
32
+ * and plays the audio unprocessed.
23
33
  */
24
34
  /**
25
35
  * Neutral advisor product — the multiplicative identity every composer and
26
- * caller must get when audio correction is off or unmeasured. Exported so
27
- * .31's rate-relevant advisors (if any) share the exact collapse value.
36
+ * caller must get when audio correction is off or unmeasured.
28
37
  */
29
38
  export declare const AUDIO_NEUTRAL = 1;
39
+ export * from './audio/rider';
40
+ export * from './audio/graph';
@@ -1,29 +1,40 @@
1
1
  /**
2
- * Audio module — CONTRACT ONLY (mos-5lzns.28). Implemented by mos-5lzns.31
3
- * ("audio graph — loudness matching, quiet listening, limiter").
2
+ * Audio module (mos-5lzns.31) — loudness matching, quiet listening, limiter.
3
+ * Ported from extension-library FEATURES.md §Loudness matching, §Quiet
4
+ * listening (library.js audioEnsure / audioTick / applyAudioSettings).
4
5
  *
5
- * What lands here (extension-library FEATURES.md §Loudness matching,
6
- * §Quiet listening), as ADVISORS + an audio graph builder:
6
+ * Layout:
7
7
  *
8
- * · loudness matching — a gated RMS meter drives a slow gain rider toward a
9
- * target (~−20 dB). The gate forgets slowly so a new video doesn't reset
10
- * the meter mid-lesson; the correction is measured, not a strength slider.
11
- * · quiet listening — loudness-compensated tone (two shelves + presence
12
- * peak + gentle compressor), amount rising as volume drops, a floor after
13
- * dark. Amount 0 rebuilds nothing: splicing a live graph mid-playback
14
- * clicks — a constant few ms of silence is the trade.
15
- * · limiter — threshold −3 dBFS, catching what the slow rider cannot.
8
+ * · audio/rider.ts — pure, node-testable: short-term RMS in dBFS, the gated
9
+ * loud-level estimate (12 dB relative gate, rise 0.08 / forget 0.01), the
10
+ * clamped want-gain (0.3–1.35), the asymmetric ride (down 0.15 / up 0.02),
11
+ * the limiter constants + `compressorMakeupDb`/`limiterTrim` (the
12
+ * compressor's automatic makeup gain, cancelled by a trim node), and
13
+ * `routeSafety` (only blob:/data: or a `crossorigin` load may be routed).
14
+ * · audio/graph.ts — `createAudioSession`, the graph builder + its state
15
+ * machine: source → gain → quiet chain (advisors/quiet-listening) →
16
+ * limiter → trim → destination, analyser tapping the source. Context
17
+ * first, route last: the element is taken only once a feature is on, the
18
+ * source is route-safe AND the context is `running`; gestures retry. After
19
+ * routing, an unsafe src or bit-exact silence is reported `broken`. A
20
+ * construction failure is a permanent `broken` that still plays plain
21
+ * audio. Disabled = no trace on the param timeline.
16
22
  *
17
23
  * Module rules (README): no <video> access, no settings reads, no DOM. The
18
- * graph builder is the one exception that may touch AudioContext — it takes
19
- * a MediaElementAudioSourceNode the component creates, never the element.
20
- * Disabled ⇒ the graph is bypassed entirely: no residual filter, no half
21
- * ramp, no stale param (cancelScheduledValues before restoring). Off must be
22
- * indistinguishable from never having run.
24
+ * graph is the one exception that touches AudioContext — and even it never
25
+ * sees the element: the component passes a `context()` factory and a
26
+ * `route(ctx)` closure that calls createMediaElementSource.
27
+ *
28
+ * ⚠ CORS: WebAudio outputs SILENCE for a source that isn't CORS-clean — and
29
+ * a same-origin URL may redirect to one. The host must serve the media with
30
+ * Access-Control-Allow-Origin and pass `crossorigin="anonymous"` (or use a
31
+ * blob:/data: src); otherwise the player refuses to route (status 'cors')
32
+ * and plays the audio unprocessed.
23
33
  */
24
34
  /**
25
35
  * Neutral advisor product — the multiplicative identity every composer and
26
- * caller must get when audio correction is off or unmeasured. Exported so
27
- * .31's rate-relevant advisors (if any) share the exact collapse value.
36
+ * caller must get when audio correction is off or unmeasured.
28
37
  */
29
38
  export const AUDIO_NEUTRAL = 1;
39
+ export * from './audio/rider';
40
+ export * from './audio/graph';
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Skip ad reads (mos-5lzns.32).
3
+ *
4
+ * Ad spans come from two places, merged: the host's `adSpans` prop (MOS fills
5
+ * it from its enrichment pass) and any speaker turn flagged `ad: true` — the
6
+ * band already paints those as not-a-person, so a player that paints an ad
7
+ * and then plays it through with skipping on would contradict itself.
8
+ *
9
+ * THE ONE RULE: only PLAYBACK that runs into an ad is skipped. A seek that
10
+ * lands inside one is a person asking to watch it, and is honoured. The
11
+ * component forgets its previous sample on every `seeking` event; this module
12
+ * additionally refuses any step larger than `maxStep` media-seconds, so even
13
+ * a seek that raced the event can't be mistaken for crossing the start.
14
+ *
15
+ * Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
16
+ */
17
+ import type { VideoSpeakerTurn } from '../types';
18
+ /** A host-detected ad read, `[startSec, endSec)`. */
19
+ export interface VideoAdSpan {
20
+ startSec: number;
21
+ endSec: number;
22
+ }
23
+ /** Sorted, merged, finite `[start, end)` pairs. Touching/overlapping spans become one. */
24
+ export declare function normalizeAdSpans(spans: readonly VideoAdSpan[] | null | undefined, turns?: readonly VideoSpeakerTurn[] | null): [number, number][];
25
+ /**
26
+ * Where to jump, or null. Skips when playback moved forward from `prev`
27
+ * (outside, before the span) to `now` (inside it) in one step of at most
28
+ * `maxStep` seconds. `prev === null` means "no trustworthy previous sample"
29
+ * (just seeked, just started, new source) and never skips.
30
+ */
31
+ export declare function adSkipTarget(prev: number | null, now: number, spans: readonly [number, number][], maxStep: number): {
32
+ from: number;
33
+ to: number;
34
+ } | null;
35
+ /** Wall-clock tick → the largest media step that still counts as "playing through". */
36
+ export declare function adMaxStep(elapsedMs: number, rate: number): number;
@@ -0,0 +1,59 @@
1
+ /** Sorted, merged, finite `[start, end)` pairs. Touching/overlapping spans become one. */
2
+ export function normalizeAdSpans(spans, turns) {
3
+ const raw = [];
4
+ const push = (a, b) => {
5
+ if (typeof a !== 'number' || typeof b !== 'number')
6
+ return;
7
+ if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a)
8
+ return;
9
+ raw.push([Math.max(0, a), b]);
10
+ };
11
+ if (Array.isArray(spans))
12
+ for (const s of spans)
13
+ if (s)
14
+ push(s.startSec, s.endSec);
15
+ if (Array.isArray(turns))
16
+ for (const t of turns)
17
+ if (t && t.ad)
18
+ push(t.startSec, t.endSec);
19
+ if (!raw.length)
20
+ return raw;
21
+ raw.sort((x, y) => x[0] - y[0]);
22
+ const out = [[raw[0][0], raw[0][1]]];
23
+ for (let i = 1; i < raw.length; i++) {
24
+ const last = out[out.length - 1];
25
+ const [a, b] = raw[i];
26
+ if (a <= last[1])
27
+ last[1] = Math.max(last[1], b);
28
+ else
29
+ out.push([a, b]);
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * Where to jump, or null. Skips when playback moved forward from `prev`
35
+ * (outside, before the span) to `now` (inside it) in one step of at most
36
+ * `maxStep` seconds. `prev === null` means "no trustworthy previous sample"
37
+ * (just seeked, just started, new source) and never skips.
38
+ */
39
+ export function adSkipTarget(prev, now, spans, maxStep) {
40
+ if (prev == null || !Number.isFinite(prev) || !Number.isFinite(now))
41
+ return null;
42
+ if (now <= prev || now - prev > maxStep)
43
+ return null;
44
+ for (const [a, b] of spans) {
45
+ if (a > now)
46
+ break;
47
+ if (now < b && prev < a)
48
+ return { from: a, to: b };
49
+ }
50
+ return null;
51
+ }
52
+ /** Wall-clock tick → the largest media step that still counts as "playing through". */
53
+ export function adMaxStep(elapsedMs, rate) {
54
+ const ms = Number.isFinite(elapsedMs) && elapsedMs > 0 ? elapsedMs : 0;
55
+ const r = Number.isFinite(rate) && rate > 0 ? rate : 1;
56
+ // 1.5× slack for timer jitter, plus a floor so a background-throttled tab
57
+ // (setInterval clamped to ≥1s) still recognises its own playback.
58
+ return (ms / 1000) * r * 1.5 + 0.5;
59
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Playback-rate arbitration for VideoPlayer (mos-5lzns.28 internal split;
3
+ * moved under rate/ by mos-5lzns.32, which fills the advisors).
4
+ *
5
+ * THE RATE COMPOSER CONTRACT (extension-library FEATURES.md — "The rate
6
+ * composer") — this is the one non-obvious rule, stated up front because it
7
+ * looks like a bug from the outside:
8
+ *
9
+ * Three features want `playbackRate`, and NONE of them writes it:
10
+ * · you — the speed keys (−/=/Backspace, alt+wheel)
11
+ * · speaker — "this person talks fast" (mos-5lzns.32 fills)
12
+ * · silence — "nothing is being said here" (mos-5lzns.32 fills)
13
+ *
14
+ * They contribute to a product: rate = base × speaker × silence
15
+ *
16
+ * `base` is the only one a human chose, and therefore the only one the UI
17
+ * reports. The speed readout must keep saying `1×` while a gap is being taken
18
+ * at 2.2×, or the label becomes a strobe.
19
+ *
20
+ * 1. Both advisors collapse to `1` the moment their setting is off or their
21
+ * data has not arrived — `1` is the multiplicative identity, so the
22
+ * composer runs identically on a video with no transcript, no waveform
23
+ * and no speakers. There is no "feature absent" branch anywhere.
24
+ * 2. `base === 0` is a state, not a level (the frozen-frame end of the
25
+ * staircase) — it returns early, before any multiplication, because no
26
+ * advisor gets to un-pause the video by multiplying its way out of zero.
27
+ * 3. Only real changes are written. `playbackRate` is a live audio
28
+ * parameter: writing a *slightly* different value is audible as a wobble.
29
+ * Hence the 0.001 deadband.
30
+ * 4. The clamp is [0.0625, 16] — the range HTML media elements actually
31
+ * support. A product of three multipliers can leave it; a single one
32
+ * cannot.
33
+ *
34
+ * The SPEED_STEPS staircase and formatSpeedLabel are ported from the
35
+ * extension's theater (linear 0.1 steps below 1×, all the way down to 0 —
36
+ * playbackRate=0 is a genuine frozen frame; above 1× the familiar jumps).
37
+ */
38
+ /** The speed staircase — one step per press of −/=, Backspace resets to 1. */
39
+ export declare const SPEED_STEPS: number[];
40
+ /**
41
+ * "A quarter speed" reads faster as "−.75" than "0.25×" — below 1× the label
42
+ * is the delta, not the multiplier. The 0 step is the frozen-frame end of the
43
+ * staircase — a state, not a level — and the label says so ("0×"), because a
44
+ * frozen frame labeled "1×" lies. Garbage degrades to `1×`.
45
+ */
46
+ export declare function formatSpeedLabel(rate: number): string;
47
+ /**
48
+ * One step along the staircase. A rate OFF the staircase (1.25 from the
49
+ * native controls' speed menu, say) steps to its nearest staircase neighbour
50
+ * in the pressed direction — never back to 1×, which from 1.25 would make
51
+ * "faster" slower. Past either end there is no further step: the rate holds.
52
+ * Garbage (NaN, negative) is treated as 1×.
53
+ */
54
+ export declare function stepRate(current: number, direction: 1 | -1): number;
55
+ /** The clamp the composer applies — the range HTML media elements actually support. */
56
+ export declare const RATE_MIN = 0.0625;
57
+ export declare const RATE_MAX = 16;
58
+ /** Writes only on real change — a slightly-different write is audible as a wobble. */
59
+ export declare const RATE_DEADBAND = 0.001;
60
+ export interface ComposedRate {
61
+ rate: number;
62
+ /** False when the deadband swallowed the write (keep the previous rate). */
63
+ changed: boolean;
64
+ }
65
+ /**
66
+ * The whole arbitration. `speaker` and `silence` are advisor products in
67
+ * [0.0625, 16] that COLLAPSE TO 1 when their feature is off or its data has
68
+ * not arrived (rate/speaker.ts, rate/silence.ts — mos-5lzns.32); with both
69
+ * at 1 this is exactly `base`.
70
+ *
71
+ * `base === 0` returns early: a frozen frame is a state, not a level.
72
+ */
73
+ export declare function composeRate(base: number, speaker: number, silence: number): ComposedRate;
74
+ /**
75
+ * Should the component write `next` over the element's `current` rate?
76
+ * Only on a real change (> RATE_DEADBAND) — a slightly-different write is
77
+ * audible as a wobble. The one exception is landing EXACTLY on `base`: when
78
+ * every advisor has collapsed to 1 the applied rate must be the human's rate
79
+ * to the last bit (doctrine 1 — an off feature leaves no trace), so a residue
80
+ * inside the deadband is still cleared.
81
+ */
82
+ export declare function shouldWriteRate(current: number, next: number, base: number): boolean;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Playback-rate arbitration for VideoPlayer (mos-5lzns.28 internal split;
3
+ * moved under rate/ by mos-5lzns.32, which fills the advisors).
4
+ *
5
+ * THE RATE COMPOSER CONTRACT (extension-library FEATURES.md — "The rate
6
+ * composer") — this is the one non-obvious rule, stated up front because it
7
+ * looks like a bug from the outside:
8
+ *
9
+ * Three features want `playbackRate`, and NONE of them writes it:
10
+ * · you — the speed keys (−/=/Backspace, alt+wheel)
11
+ * · speaker — "this person talks fast" (mos-5lzns.32 fills)
12
+ * · silence — "nothing is being said here" (mos-5lzns.32 fills)
13
+ *
14
+ * They contribute to a product: rate = base × speaker × silence
15
+ *
16
+ * `base` is the only one a human chose, and therefore the only one the UI
17
+ * reports. The speed readout must keep saying `1×` while a gap is being taken
18
+ * at 2.2×, or the label becomes a strobe.
19
+ *
20
+ * 1. Both advisors collapse to `1` the moment their setting is off or their
21
+ * data has not arrived — `1` is the multiplicative identity, so the
22
+ * composer runs identically on a video with no transcript, no waveform
23
+ * and no speakers. There is no "feature absent" branch anywhere.
24
+ * 2. `base === 0` is a state, not a level (the frozen-frame end of the
25
+ * staircase) — it returns early, before any multiplication, because no
26
+ * advisor gets to un-pause the video by multiplying its way out of zero.
27
+ * 3. Only real changes are written. `playbackRate` is a live audio
28
+ * parameter: writing a *slightly* different value is audible as a wobble.
29
+ * Hence the 0.001 deadband.
30
+ * 4. The clamp is [0.0625, 16] — the range HTML media elements actually
31
+ * support. A product of three multipliers can leave it; a single one
32
+ * cannot.
33
+ *
34
+ * The SPEED_STEPS staircase and formatSpeedLabel are ported from the
35
+ * extension's theater (linear 0.1 steps below 1×, all the way down to 0 —
36
+ * playbackRate=0 is a genuine frozen frame; above 1× the familiar jumps).
37
+ */
38
+ /** The speed staircase — one step per press of −/=, Backspace resets to 1. */
39
+ export const SPEED_STEPS = [
40
+ 0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.75, 2, 2.5, 5, 10
41
+ ];
42
+ /**
43
+ * "A quarter speed" reads faster as "−.75" than "0.25×" — below 1× the label
44
+ * is the delta, not the multiplier. The 0 step is the frozen-frame end of the
45
+ * staircase — a state, not a level — and the label says so ("0×"), because a
46
+ * frozen frame labeled "1×" lies. Garbage degrades to `1×`.
47
+ */
48
+ export function formatSpeedLabel(rate) {
49
+ if (!Number.isFinite(rate) || rate < 0)
50
+ return '1×';
51
+ if (rate === 0)
52
+ return '0×';
53
+ if (rate < 1) {
54
+ return (rate - 1).toFixed(2).replace(/\.?0+$/, '').replace(/^-0\./, '-.') + '×';
55
+ }
56
+ return rate.toFixed(2).replace(/\.?0+$/, '') + '×';
57
+ }
58
+ /**
59
+ * One step along the staircase. A rate OFF the staircase (1.25 from the
60
+ * native controls' speed menu, say) steps to its nearest staircase neighbour
61
+ * in the pressed direction — never back to 1×, which from 1.25 would make
62
+ * "faster" slower. Past either end there is no further step: the rate holds.
63
+ * Garbage (NaN, negative) is treated as 1×.
64
+ */
65
+ export function stepRate(current, direction) {
66
+ const cur = Number.isFinite(current) && current >= 0 ? current : 1;
67
+ const i = SPEED_STEPS.indexOf(cur);
68
+ if (i !== -1)
69
+ return SPEED_STEPS[Math.min(SPEED_STEPS.length - 1, Math.max(0, i + direction))];
70
+ if (direction > 0)
71
+ return SPEED_STEPS.find((s) => s > cur) ?? cur;
72
+ for (let j = SPEED_STEPS.length - 1; j >= 0; j--)
73
+ if (SPEED_STEPS[j] < cur)
74
+ return SPEED_STEPS[j];
75
+ return cur;
76
+ }
77
+ /** The clamp the composer applies — the range HTML media elements actually support. */
78
+ export const RATE_MIN = 0.0625;
79
+ export const RATE_MAX = 16;
80
+ /** Writes only on real change — a slightly-different write is audible as a wobble. */
81
+ export const RATE_DEADBAND = 0.001;
82
+ /**
83
+ * The whole arbitration. `speaker` and `silence` are advisor products in
84
+ * [0.0625, 16] that COLLAPSE TO 1 when their feature is off or its data has
85
+ * not arrived (rate/speaker.ts, rate/silence.ts — mos-5lzns.32); with both
86
+ * at 1 this is exactly `base`.
87
+ *
88
+ * `base === 0` returns early: a frozen frame is a state, not a level.
89
+ */
90
+ export function composeRate(base, speaker, silence) {
91
+ if (!Number.isFinite(base) || base <= 0) {
92
+ return { rate: Math.max(0, Number.isFinite(base) ? base : 0), changed: false };
93
+ }
94
+ const sp = Number.isFinite(speaker) && speaker > 0 ? speaker : 1;
95
+ const si = Number.isFinite(silence) && silence > 0 ? silence : 1;
96
+ const rate = Math.min(RATE_MAX, Math.max(RATE_MIN, base * sp * si));
97
+ const changed = Math.abs(rate - base) > RATE_DEADBAND;
98
+ return { rate: changed ? rate : base, changed };
99
+ }
100
+ /**
101
+ * Should the component write `next` over the element's `current` rate?
102
+ * Only on a real change (> RATE_DEADBAND) — a slightly-different write is
103
+ * audible as a wobble. The one exception is landing EXACTLY on `base`: when
104
+ * every advisor has collapsed to 1 the applied rate must be the human's rate
105
+ * to the last bit (doctrine 1 — an off feature leaves no trace), so a residue
106
+ * inside the deadband is still cleared.
107
+ */
108
+ export function shouldWriteRate(current, next, base) {
109
+ if (!Number.isFinite(next) || current === next)
110
+ return false;
111
+ if (!Number.isFinite(current))
112
+ return true;
113
+ return Math.abs(current - next) > RATE_DEADBAND || next === base;
114
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Smart speed — the `silence` factor of the rate composer (mos-5lzns.32).
3
+ *
4
+ * The measuring is `../advisors/smart-speed` (ported verbatim by .27): hold
5
+ * 1× through speech, run the gaps between sentences at `boost` (2.2× by
6
+ * default), found off the waveform envelope the band already draws. This
7
+ * file adapts the component's `VideoWaveform` prop and collapses to 1 when
8
+ * the feature is off or the envelope has not arrived.
9
+ *
10
+ * Pure: no DOM, no <video>, no settings reads (src/lib/video/README.md).
11
+ */
12
+ import { type BoostOpts, type SilenceMap } from '../advisors/smart-speed';
13
+ import type { VideoWaveform } from '../types';
14
+ /**
15
+ * The silence map for a waveform, or null when there is nothing to map (no
16
+ * peaks, no known duration, or no qualifying gap at all — a map with zero
17
+ * ranges can never boost, so the caller may as well not hold one).
18
+ */
19
+ export declare function silenceMapFor(waveform: VideoWaveform | null | undefined, durationSec: number): SilenceMap | null;
20
+ /** The silence factor at time t: `boost` inside a gap, 1 everywhere else. */
21
+ export declare function silenceFactor(t: number, map: SilenceMap | null, opts?: BoostOpts | null): number;
22
+ /**
23
+ * "Smart speed · about 6 min shorter" — the visible surface for an automatic
24
+ * correction (doctrine 2). Empty when the saving would round to nothing, so
25
+ * the caller never flashes a claim it can't back.
26
+ */
27
+ export declare function smartSpeedLabel(map: SilenceMap | null, opts?: BoostOpts | null): string;