@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.
- package/dist/components/VideoPlayer.svelte +332 -8
- package/dist/components/VideoPlayer.svelte.d.ts +76 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -0
- package/dist/video/README.md +6 -5
- package/dist/video/audio/graph.d.ts +115 -0
- package/dist/video/audio/graph.js +400 -0
- package/dist/video/audio/rider.d.ts +134 -0
- package/dist/video/audio/rider.js +187 -0
- package/dist/video/audio.d.ts +30 -19
- package/dist/video/audio.js +30 -19
- package/dist/video/rate/ads.d.ts +36 -0
- package/dist/video/rate/ads.js +59 -0
- package/dist/video/rate/compose.d.ts +82 -0
- package/dist/video/rate/compose.js +114 -0
- package/dist/video/rate/silence.d.ts +27 -0
- package/dist/video/rate/silence.js +48 -0
- package/dist/video/rate/speaker.d.ts +54 -0
- package/dist/video/rate/speaker.js +94 -0
- package/dist/video/rate/tick.d.ts +38 -0
- package/dist/video/rate/tick.js +15 -0
- package/dist/video/rate.d.ts +20 -63
- package/dist/video/rate.js +20 -82
- package/dist/video/settings.js +16 -2
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/video/audio.d.ts
CHANGED
|
@@ -1,29 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Audio module
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
* §Quiet listening), as ADVISORS + an audio graph builder:
|
|
6
|
+
* Layout:
|
|
7
7
|
*
|
|
8
|
-
* ·
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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.
|
|
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';
|
package/dist/video/audio.js
CHANGED
|
@@ -1,29 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Audio module
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
* §Quiet listening), as ADVISORS + an audio graph builder:
|
|
6
|
+
* Layout:
|
|
7
7
|
*
|
|
8
|
-
* ·
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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.
|
|
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;
|