@joewinke/jatui 0.11.0 → 0.13.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 +627 -8
- package/dist/components/VideoPlayer.svelte.d.ts +84 -1
- package/dist/index.d.ts +1 -1
- package/dist/video/README.md +5 -4
- 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/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 +18 -2
- package/package.json +1 -1
|
@@ -0,0 +1,134 @@
|
|
|
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 declare const AUDIO_TICK_MS = 200;
|
|
17
|
+
export declare const AUDIO_FFT_SIZE = 2048;
|
|
18
|
+
/** Where matching aims, dBFS RMS. At −20 an ordinary video is left at ~1.0×. */
|
|
19
|
+
export declare const AUDIO_TARGET_DEFAULT = -20;
|
|
20
|
+
export declare const AUDIO_TARGET_MIN = -30;
|
|
21
|
+
export declare const AUDIO_TARGET_MAX = -12;
|
|
22
|
+
/** Below this RMS a window is silence — it carries no vote about level. */
|
|
23
|
+
export declare const SILENCE_RMS = 0.00001;
|
|
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 declare const GATE_DB = 12;
|
|
32
|
+
/** Rise toward a louder reading quickly … */
|
|
33
|
+
export declare const LOUD_RISE = 0.08;
|
|
34
|
+
/** … and forget a quiet one very slowly (a new video does NOT reset it). */
|
|
35
|
+
export declare 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 declare const GAIN_MIN = 0.3;
|
|
43
|
+
export declare 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 declare const RIDE_DOWN = 0.15;
|
|
49
|
+
export declare const RIDE_UP = 0.02;
|
|
50
|
+
/** setTargetAtTime constant the ride lands with, so the param smooths between ticks. */
|
|
51
|
+
export declare 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 declare const LIMITER: Readonly<{
|
|
65
|
+
threshold: -3;
|
|
66
|
+
knee: 0;
|
|
67
|
+
ratio: 20;
|
|
68
|
+
attack: 0.003;
|
|
69
|
+
release: 0.25;
|
|
70
|
+
}>;
|
|
71
|
+
export declare const LIMITER_OFF_THRESHOLD = 0;
|
|
72
|
+
/** Ramp constant for limiter threshold/trim moves — under an event, over a click. */
|
|
73
|
+
export declare 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 declare const SILENT_AFTER_MS = 4000;
|
|
85
|
+
/**
|
|
86
|
+
* Short-term RMS of a time-domain buffer, in dBFS. `null` for silence (below
|
|
87
|
+
* SILENCE_RMS), an empty buffer or junk — never −Infinity, never NaN.
|
|
88
|
+
*/
|
|
89
|
+
export declare function rmsDb(buf: ArrayLike<number> | null | undefined): number | null;
|
|
90
|
+
/**
|
|
91
|
+
* One step of the gated loud-level estimate. `loud` null means "no estimate
|
|
92
|
+
* yet" — the first real reading seeds it. A null/junk `level` (silence) leaves
|
|
93
|
+
* the estimate exactly where it was.
|
|
94
|
+
*/
|
|
95
|
+
export declare function gateLoud(loud: number | null, level: number | null): number | null;
|
|
96
|
+
/** Clamp a target to the declared range; junk falls back to the default. */
|
|
97
|
+
export declare function clampTarget(target: unknown): number;
|
|
98
|
+
/**
|
|
99
|
+
* The gain that would put the measured loud level at `target`, clamped to
|
|
100
|
+
* [GAIN_MIN, GAIN_MAX]. No estimate yet ⇒ 1 (neutral): we never guess.
|
|
101
|
+
*/
|
|
102
|
+
export declare function wantGain(loud: number | null, target: unknown): number;
|
|
103
|
+
/** One asymmetric step of the ride toward `want`. Junk in ⇒ neutral 1. */
|
|
104
|
+
export declare function rideStep(ride: number, want: number): number;
|
|
105
|
+
/** A ride as dB — for readouts. 1 ⇒ 0 dB; junk ⇒ 0. */
|
|
106
|
+
export declare function gainDb(ride: number): number;
|
|
107
|
+
/**
|
|
108
|
+
* DynamicsCompressorNode's automatic makeup gain, dB, for knee 0 — the Web
|
|
109
|
+
* Audio spec's "makeup gain" step (as Chromium/WebKit/Gecko implement it):
|
|
110
|
+
* fullRangeGain = the curve's output for a 0 dBFS input, makeup =
|
|
111
|
+
* (1 / fullRangeGain) ^ 0.6. For knee 0 the curve at 0 dBFS is
|
|
112
|
+
* threshold + (0 − threshold) / ratio, so the makeup is
|
|
113
|
+
* 0.6 × (−threshold) × (1 − 1/ratio): +1.71 dB at −3 dBFS / 20:1, 0 at a
|
|
114
|
+
* 0 dBFS threshold. Junk ⇒ 0.
|
|
115
|
+
*/
|
|
116
|
+
export declare function compressorMakeupDb(threshold: unknown, ratio: unknown): number;
|
|
117
|
+
/** The trim gain (linear) that cancels a limiter's makeup at this threshold. */
|
|
118
|
+
export declare function limiterTrim(threshold: number): number;
|
|
119
|
+
/**
|
|
120
|
+
* Can this element's audio be routed through WebAudio without going SILENT?
|
|
121
|
+
*
|
|
122
|
+
* A media response that isn't CORS-clean makes a MediaElementAudioSourceNode
|
|
123
|
+
* output zeros — and routing is a one-way door, so that is silence with no
|
|
124
|
+
* way back. A same-origin URL is NOT enough: it may 302 to a signed storage
|
|
125
|
+
* URL on another origin (MardukOS file routes do exactly that), and without
|
|
126
|
+
* `crossorigin` the redirected response is opaque. So:
|
|
127
|
+
*
|
|
128
|
+
* · 'ok' — blob:/data: (never leave the page's origin), or the element asked
|
|
129
|
+
* for a CORS load (`crossorigin` set — the load is then CORS-clean
|
|
130
|
+
* or it fails outright, never silently).
|
|
131
|
+
* · 'wait' — no source yet; nothing to decide.
|
|
132
|
+
* · 'cors' — anything else: refuse, play unprocessed.
|
|
133
|
+
*/
|
|
134
|
+
export declare function routeSafety(mediaUrl: unknown, crossorigin: unknown): 'ok' | 'wait' | 'cors';
|
|
@@ -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';
|
package/dist/video/keys.d.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
8
|
* speed, </> frame step, [/] chapter jumps, B bookmark, T transcript, ?
|
|
9
9
|
* shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
|
|
10
|
-
* library-app verbs, not player verbs),
|
|
11
|
-
* until mos-5lzns.33; add it THERE, with its own row here), `,` (settings —
|
|
10
|
+
* library-app verbs, not player verbs), `,` (settings —
|
|
12
11
|
* jatui is prop-driven; a settings module arrives with the features that need
|
|
13
|
-
* one), `/` (transcript search — the pane has none yet).
|
|
12
|
+
* one), `/` (transcript search — the pane has none yet). N (night cycle)
|
|
13
|
+
* landed with the night module (mos-5lzns.33), gated on the host opting in.
|
|
14
14
|
*/
|
|
15
15
|
export interface PlayerKeyRow {
|
|
16
16
|
/** Keycap label as shown in the help overlay — `/`-separated alternates. */
|
|
@@ -20,7 +20,7 @@ export interface PlayerKeyRow {
|
|
|
20
20
|
* Availability gate: the row is only advertised (and the key only bound)
|
|
21
21
|
* when the feature's data exists. Undefined ⇒ always active.
|
|
22
22
|
*/
|
|
23
|
-
when?: 'chapters' | 'bookmarks' | 'transcript' | 'comments';
|
|
23
|
+
when?: 'chapters' | 'bookmarks' | 'transcript' | 'comments' | 'night';
|
|
24
24
|
}
|
|
25
25
|
/** Order follows the help overlay: transport, seek, volume, speed, structure. */
|
|
26
26
|
export declare const PLAYER_KEYS: PlayerKeyRow[];
|
package/dist/video/keys.js
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
8
|
* speed, </> frame step, [/] chapter jumps, B bookmark, T transcript, ?
|
|
9
9
|
* shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
|
|
10
|
-
* library-app verbs, not player verbs),
|
|
11
|
-
* until mos-5lzns.33; add it THERE, with its own row here), `,` (settings —
|
|
10
|
+
* library-app verbs, not player verbs), `,` (settings —
|
|
12
11
|
* jatui is prop-driven; a settings module arrives with the features that need
|
|
13
|
-
* one), `/` (transcript search — the pane has none yet).
|
|
12
|
+
* one), `/` (transcript search — the pane has none yet). N (night cycle)
|
|
13
|
+
* landed with the night module (mos-5lzns.33), gated on the host opting in.
|
|
14
14
|
*/
|
|
15
15
|
/** Order follows the help overlay: transport, seek, volume, speed, structure. */
|
|
16
16
|
export const PLAYER_KEYS = [
|
|
@@ -28,6 +28,7 @@ export const PLAYER_KEYS = [
|
|
|
28
28
|
{ keys: '{ / }', description: 'Previous / next comment', when: 'comments' },
|
|
29
29
|
{ keys: 'B', description: 'Bookmark at the current time', when: 'bookmarks' },
|
|
30
30
|
{ keys: 'T', description: 'Show / hide the transcript', when: 'transcript' },
|
|
31
|
+
{ keys: 'N', description: 'Night dimming: auto / on / off', when: 'night' },
|
|
31
32
|
{ keys: 'Home / End', description: 'Jump to start / end' },
|
|
32
33
|
{ keys: '?', description: 'Toggle this help' }
|
|
33
34
|
];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What arms night dimming (mos-5lzns.33).
|
|
3
|
+
*
|
|
4
|
+
* The extension asked the desktop (hyprsunset's colour temperature) and fell
|
|
5
|
+
* back to the clock. A component in a web app has no desktop to ask, so the
|
|
6
|
+
* signal is re-based (docs/videos-module-viability.md §6): the LOCAL clock,
|
|
7
|
+
* plus a manual toggle. `prefers-color-scheme: dark` can only WIDEN the
|
|
8
|
+
* window (18:00–08:00 instead of 20:00–07:00), never be the trigger — a lot
|
|
9
|
+
* of desktops report dark around the clock.
|
|
10
|
+
*
|
|
11
|
+
* `N` cycles auto → on → off. `auto` follows the clock; `on` ignores it;
|
|
12
|
+
* `off` leaves no trace.
|
|
13
|
+
*/
|
|
14
|
+
export type NightMode = 'auto' | 'on' | 'off';
|
|
15
|
+
export declare const NIGHT_MODES: readonly NightMode[];
|
|
16
|
+
export interface NightWindow {
|
|
17
|
+
/** Hour (0–24, fractional ok) the night starts. */
|
|
18
|
+
from: number;
|
|
19
|
+
/** Hour the night ends (next morning when < from). */
|
|
20
|
+
to: number;
|
|
21
|
+
}
|
|
22
|
+
export declare const NIGHT_WINDOW: Readonly<NightWindow>;
|
|
23
|
+
export declare const NIGHT_WINDOW_DARK: Readonly<NightWindow>;
|
|
24
|
+
/** Any unknown mode is treated as off — a bad value must never switch dimming ON. */
|
|
25
|
+
export declare function coerceNightMode(mode: unknown): NightMode;
|
|
26
|
+
export declare function nextNightMode(mode: unknown): NightMode;
|
|
27
|
+
/** Is `date` inside the night window? Handles windows that wrap midnight (and ones that don't). */
|
|
28
|
+
export declare function inNightWindow(date: Date, prefersDark?: boolean, win?: NightWindow | null): boolean;
|
|
29
|
+
export declare function nightArmed(mode: unknown, date: Date, prefersDark?: boolean, win?: NightWindow | null): boolean;
|
|
30
|
+
/** The OSD / aria text for a mode, as the user will read it. */
|
|
31
|
+
export declare function nightModeLabel(mode: unknown, armed: boolean): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What arms night dimming (mos-5lzns.33).
|
|
3
|
+
*
|
|
4
|
+
* The extension asked the desktop (hyprsunset's colour temperature) and fell
|
|
5
|
+
* back to the clock. A component in a web app has no desktop to ask, so the
|
|
6
|
+
* signal is re-based (docs/videos-module-viability.md §6): the LOCAL clock,
|
|
7
|
+
* plus a manual toggle. `prefers-color-scheme: dark` can only WIDEN the
|
|
8
|
+
* window (18:00–08:00 instead of 20:00–07:00), never be the trigger — a lot
|
|
9
|
+
* of desktops report dark around the clock.
|
|
10
|
+
*
|
|
11
|
+
* `N` cycles auto → on → off. `auto` follows the clock; `on` ignores it;
|
|
12
|
+
* `off` leaves no trace.
|
|
13
|
+
*/
|
|
14
|
+
export const NIGHT_MODES = ['auto', 'on', 'off'];
|
|
15
|
+
export const NIGHT_WINDOW = Object.freeze({ from: 20, to: 7 });
|
|
16
|
+
export const NIGHT_WINDOW_DARK = Object.freeze({ from: 18, to: 8 });
|
|
17
|
+
/** Any unknown mode is treated as off — a bad value must never switch dimming ON. */
|
|
18
|
+
export function coerceNightMode(mode) {
|
|
19
|
+
return mode === 'auto' || mode === 'on' || mode === 'off' ? mode : 'off';
|
|
20
|
+
}
|
|
21
|
+
export function nextNightMode(mode) {
|
|
22
|
+
const m = coerceNightMode(mode);
|
|
23
|
+
return NIGHT_MODES[(NIGHT_MODES.indexOf(m) + 1) % NIGHT_MODES.length];
|
|
24
|
+
}
|
|
25
|
+
/** Is `date` inside the night window? Handles windows that wrap midnight (and ones that don't). */
|
|
26
|
+
export function inNightWindow(date, prefersDark = false, win) {
|
|
27
|
+
const t = date instanceof Date ? date.getTime() : NaN;
|
|
28
|
+
if (!Number.isFinite(t))
|
|
29
|
+
return false;
|
|
30
|
+
const w = validWindow(win) ?? (prefersDark ? NIGHT_WINDOW_DARK : NIGHT_WINDOW);
|
|
31
|
+
const h = date.getHours() + date.getMinutes() / 60;
|
|
32
|
+
if (w.from === w.to)
|
|
33
|
+
return false;
|
|
34
|
+
return w.from > w.to ? h >= w.from || h < w.to : h >= w.from && h < w.to;
|
|
35
|
+
}
|
|
36
|
+
export function nightArmed(mode, date, prefersDark = false, win) {
|
|
37
|
+
const m = coerceNightMode(mode);
|
|
38
|
+
if (m === 'on')
|
|
39
|
+
return true;
|
|
40
|
+
if (m === 'off')
|
|
41
|
+
return false;
|
|
42
|
+
return inNightWindow(date, prefersDark, win);
|
|
43
|
+
}
|
|
44
|
+
/** The OSD / aria text for a mode, as the user will read it. */
|
|
45
|
+
export function nightModeLabel(mode, armed) {
|
|
46
|
+
const m = coerceNightMode(mode);
|
|
47
|
+
if (m === 'on')
|
|
48
|
+
return 'Night · always on';
|
|
49
|
+
if (m === 'off')
|
|
50
|
+
return 'Night · off';
|
|
51
|
+
return armed ? 'Night · auto, dimming now' : 'Night · auto, until dark';
|
|
52
|
+
}
|
|
53
|
+
function validWindow(w) {
|
|
54
|
+
if (!w)
|
|
55
|
+
return null;
|
|
56
|
+
const ok = (h) => typeof h === 'number' && Number.isFinite(h) && h >= 0 && h <= 24;
|
|
57
|
+
return ok(w.from) && ok(w.to) ? { from: w.from, to: w.to } : null;
|
|
58
|
+
}
|