@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,400 @@
1
+ /**
2
+ * The audio graph (mos-5lzns.31) — the one module under src/lib/video/ that
3
+ * touches WebAudio. Ported from extension-library `library.js`
4
+ * (audioEnsure / audioTick / applyAudioSettings / applyQuietAmount).
5
+ *
6
+ * source ─┬─► gain ─► quiet.input … quiet.output ─► limiter ─► trim ─► destination
7
+ * └─► analyser (taps the SOURCE: measure the material, not our
8
+ * own correction, or the loop chases its own tail)
9
+ *
10
+ * Quiet listening sits between level and limiter: it shapes TONE, so it acts
11
+ * on the signal at the level you'll hear, and the limiter is last by
12
+ * definition. `trim` cancels the limiter's automatic makeup gain (rider.ts
13
+ * `compressorMakeupDb`), so toggling the limiter moves no level. If the quiet
14
+ * chain can't build, the graph splices around it — tone is optional; sound
15
+ * is not.
16
+ *
17
+ * `createMediaElementSource` is a ONE-WAY DOOR — the spec has no way back once
18
+ * an element is routed. The contract is "never a silent player":
19
+ *
20
+ * 1. CONTEXT FIRST, ROUTE LAST. The context is created when a feature is on,
21
+ * `resume()`d on every gesture, and the element is routed only once
22
+ * `ctx.state === 'running'`. A suspended context on a routed element is
23
+ * silence; a suspended context on an UNrouted element is nothing at all —
24
+ * native audio keeps playing, and the next gesture retries. We never
25
+ * consult navigator.userActivation: the context's own state is the truth.
26
+ * 2. ROUTE ONLY A CORS-CLEAN SOURCE (rider.ts `routeSafety`): blob:/data:, or
27
+ * the element has `crossorigin`. A same-origin URL may 302 to storage on
28
+ * another origin, and an opaque response routes to zeros.
29
+ * 3. AFTER ROUTING, WATCH. A later src that is not route-safe, or an analyser
30
+ * reading bit-exact zeros for SILENT_AFTER_MS while the element plays
31
+ * audibly AND there is positive evidence decoded audio is flowing (a
32
+ * rising webkitAudioDecodedByteCount, mozHasAudio, audioTracks), is
33
+ * reported as `broken` (`unsafe-src` / `silent`) via `onBroken`. A video
34
+ * with no audio track never reads as silent. Both clear if the condition
35
+ * clears. Remounting is right for `unsafe-src` (a fresh element); for
36
+ * `silent` the host should show a notice and/or turn the audio features
37
+ * off for that src — remounting would just loop.
38
+ * 4. A construction throw is a permanent `broken` (`build-failed`). If it
39
+ * happened before the element was taken, the context is closed; if after,
40
+ * the source is wired straight to the destination and the context KEPT —
41
+ * closing it would silence the routed element.
42
+ *
43
+ * Disabled leaves no trace: level off CANCELS scheduled values before setting
44
+ * 1; the limiter's threshold ramps back to 0 dBFS with its trim to unity; the
45
+ * quiet chain rests at the identity (amount 0). A graph is never built just
46
+ * to neutralise it.
47
+ *
48
+ * The element itself never enters this module: the component passes `route`,
49
+ * a closure that calls createMediaElementSource on it.
50
+ */
51
+ import { amountFor, createQuietChain } from '../advisors/quiet-listening';
52
+ import { AUDIO_FFT_SIZE, AUDIO_TICK_MS, LIMITER, LIMITER_OFF_THRESHOLD, LIMITER_TAU, RIDE_TAU, SILENT_AFTER_MS, clampTarget, gainDb, gateLoud, limiterTrim, rideStep, rmsDb, wantGain } from './rider';
53
+ const defaultEvery = (ms, fn) => {
54
+ const id = setInterval(fn, ms);
55
+ return () => clearInterval(id);
56
+ };
57
+ const safely = (fn) => {
58
+ try {
59
+ fn();
60
+ }
61
+ catch {
62
+ /* never throw out of the audio path */
63
+ }
64
+ };
65
+ export function createAudioSession(deps) {
66
+ let ctx = null;
67
+ let routed = false;
68
+ let buildFailed = false;
69
+ let unsafeSrc = false;
70
+ let silent = false;
71
+ let refusedCors = false;
72
+ let gain = null;
73
+ let limiter = null;
74
+ let trim = null;
75
+ let analyser = null;
76
+ let quiet = null;
77
+ let buf = null;
78
+ let ride = 1;
79
+ let loud = null;
80
+ let amount = 0;
81
+ let levelOn = false;
82
+ let limiterOn = false;
83
+ let target = clampTarget(undefined);
84
+ let stopTimer = null;
85
+ let lastOpts = null;
86
+ let anyOnLast = false;
87
+ let zeroMs = 0;
88
+ let lastDecoded = null;
89
+ let lastTime = Number.NaN;
90
+ let disposed = false;
91
+ let lastSent = '';
92
+ let lastBroken = null;
93
+ const reason = () => buildFailed ? 'build-failed' : routed && unsafeSrc ? 'unsafe-src' : routed && silent ? 'silent' : null;
94
+ const status = () => reason() ? 'broken' : routed ? 'live' : refusedCors ? 'cors' : 'off';
95
+ const state = () => ({
96
+ status: status(),
97
+ reason: reason(),
98
+ gainDb: levelOn ? gainDb(ride) : 0,
99
+ loudDb: loud,
100
+ quiet: amount
101
+ });
102
+ const emit = () => {
103
+ const s = state();
104
+ if (s.reason !== lastBroken) {
105
+ lastBroken = s.reason;
106
+ if (s.reason)
107
+ safely(() => deps.onBroken?.(s.reason));
108
+ }
109
+ const key = `${s.status}|${s.reason}|${s.gainDb.toFixed(2)}|${s.loudDb?.toFixed(1)}|${s.quiet.toFixed(2)}`;
110
+ if (key === lastSent)
111
+ return;
112
+ lastSent = key;
113
+ safely(() => deps.onState?.(s));
114
+ };
115
+ const now = () => (ctx && Number.isFinite(ctx.currentTime) ? ctx.currentTime : 0);
116
+ const running = () => !!ctx && (ctx.state === undefined || ctx.state === 'running');
117
+ const routable = () => {
118
+ try {
119
+ return deps.routable();
120
+ }
121
+ catch {
122
+ return 'wait';
123
+ }
124
+ };
125
+ /** Ask the context to start; on success retry whatever is pending. Never loops. */
126
+ function kick() {
127
+ if (!ctx || running())
128
+ return;
129
+ try {
130
+ const p = ctx.resume?.();
131
+ p?.then?.(() => {
132
+ if (!disposed && running() && lastOpts)
133
+ apply(lastOpts);
134
+ }, () => { });
135
+ }
136
+ catch {
137
+ /* a refused resume just waits for the next gesture */
138
+ }
139
+ }
140
+ function closeCtx() {
141
+ const c = ctx;
142
+ ctx = null;
143
+ safely(() => {
144
+ const r = c?.close?.();
145
+ r?.catch?.(() => { });
146
+ });
147
+ }
148
+ /** Route the element and wire the graph. Only ever called with a running context. */
149
+ function build() {
150
+ const c = ctx;
151
+ let source = null;
152
+ try {
153
+ source = deps.route(c);
154
+ routed = true;
155
+ gain = c.createGain();
156
+ limiter = c.createDynamicsCompressor();
157
+ // Built at rest: threshold 0 dBFS, trim unity. configure() ramps them in.
158
+ limiter.threshold.value = LIMITER_OFF_THRESHOLD;
159
+ limiter.knee.value = LIMITER.knee;
160
+ limiter.ratio.value = LIMITER.ratio;
161
+ limiter.attack.value = LIMITER.attack;
162
+ limiter.release.value = LIMITER.release;
163
+ trim = c.createGain();
164
+ trim.gain.value = 1;
165
+ analyser = c.createAnalyser();
166
+ analyser.fftSize = AUDIO_FFT_SIZE;
167
+ buf = new Float32Array(AUDIO_FFT_SIZE);
168
+ // The quiet chain degrades to a unity wire or {input:null} on a context
169
+ // it doesn't recognise — splice around the null case instead of throwing
170
+ // into the catch below, where the element is already taken.
171
+ const q = createQuietChain(c);
172
+ quiet = q.input && q.output ? q : null;
173
+ source.connect(analyser);
174
+ source.connect(gain);
175
+ if (quiet) {
176
+ gain.connect(quiet.input);
177
+ quiet.output.connect(limiter);
178
+ }
179
+ else {
180
+ gain.connect(limiter);
181
+ }
182
+ limiter.connect(trim);
183
+ trim.connect(c.destination);
184
+ stopTimer = (deps.every ?? defaultEvery)(AUDIO_TICK_MS, tick);
185
+ return true;
186
+ }
187
+ catch {
188
+ buildFailed = true;
189
+ for (const n of [gain, limiter, trim, analyser])
190
+ safely(() => n?.disconnect?.());
191
+ quiet?.dispose();
192
+ quiet = gain = limiter = trim = analyser = null;
193
+ if (source) {
194
+ // The door closed behind us: a partly-built graph is silence. Wire the
195
+ // source straight out and KEEP the context — plain audio is all
196
+ // "broken" may mean.
197
+ safely(() => {
198
+ source.disconnect?.();
199
+ source.connect(c.destination);
200
+ });
201
+ }
202
+ else {
203
+ // Never took the element: nothing depends on this context.
204
+ routed = false;
205
+ closeCtx();
206
+ }
207
+ return false;
208
+ }
209
+ }
210
+ function setLevel(on) {
211
+ if (on === levelOn)
212
+ return;
213
+ levelOn = on;
214
+ if (on)
215
+ return;
216
+ // Hand the level back exactly as it was — cancel first, then set. A ramp
217
+ // toward 1 would leave a half-faded trace on the param timeline. The
218
+ // estimate resets too, so re-enabling starts clean, not stale.
219
+ ride = 1;
220
+ loud = null;
221
+ const p = gain?.gain;
222
+ if (!p)
223
+ return;
224
+ try {
225
+ p.cancelScheduledValues?.(now());
226
+ if (p.setValueAtTime)
227
+ p.setValueAtTime(1, now());
228
+ else
229
+ p.value = 1;
230
+ }
231
+ catch {
232
+ p.value = 1;
233
+ }
234
+ }
235
+ function setLimiter(on) {
236
+ if (on === limiterOn || !limiter?.threshold)
237
+ return;
238
+ limiterOn = on;
239
+ const th = on ? LIMITER.threshold : LIMITER_OFF_THRESHOLD;
240
+ // Ramped, never a .value write, and the trim moves with it so the
241
+ // compressor's makeup gain never reaches the output.
242
+ const ramp = (p, v) => {
243
+ if (!p)
244
+ return;
245
+ try {
246
+ if (p.setTargetAtTime)
247
+ p.setTargetAtTime(v, now(), LIMITER_TAU);
248
+ else
249
+ p.value = v;
250
+ }
251
+ catch {
252
+ p.value = v;
253
+ }
254
+ };
255
+ ramp(limiter.threshold, th);
256
+ ramp(trim?.gain, limiterTrim(th));
257
+ }
258
+ function tick() {
259
+ if (disposed || !routed || buildFailed || !analyser)
260
+ return;
261
+ let pb;
262
+ try {
263
+ pb = deps.playback();
264
+ }
265
+ catch {
266
+ return;
267
+ }
268
+ if (!pb.playing) {
269
+ lastTime = Number.NaN;
270
+ return;
271
+ }
272
+ try {
273
+ analyser.getFloatTimeDomainData?.(buf);
274
+ }
275
+ catch {
276
+ return;
277
+ }
278
+ // Watchdog: bit-exact zeros while audible, the clock advances AND there is
279
+ // positive evidence that decoded audio exists and is flowing. A video
280
+ // with no audio track reads zeros forever and must never be called
281
+ // "silent" — no evidence, no verdict.
282
+ const advanced = Number.isFinite(lastTime) && pb.time > lastTime;
283
+ lastTime = pb.time;
284
+ const decoded = Number.isFinite(pb.audioDecoded) ? pb.audioDecoded : null;
285
+ const flowing = decoded !== null
286
+ ? lastDecoded !== null && decoded > lastDecoded // Chromium: bytes still decoding
287
+ : pb.audioPresent === true; // Gecko mozHasAudio / WebKit audioTracks
288
+ lastDecoded = decoded;
289
+ if (!flowing) {
290
+ zeroMs = 0;
291
+ if (silent) {
292
+ silent = false;
293
+ emit();
294
+ }
295
+ }
296
+ else if (pb.audible && advanced) {
297
+ let zero = true;
298
+ for (let i = 0; i < buf.length; i++) {
299
+ if (buf[i] !== 0) {
300
+ zero = false;
301
+ break;
302
+ }
303
+ }
304
+ zeroMs = zero ? zeroMs + AUDIO_TICK_MS : 0;
305
+ const s = zeroMs >= SILENT_AFTER_MS;
306
+ if (s !== silent) {
307
+ silent = s;
308
+ emit();
309
+ }
310
+ }
311
+ // Rider. Hidden: the estimate holds.
312
+ if (!levelOn || pb.hidden || !gain?.gain)
313
+ return;
314
+ const level = rmsDb(buf);
315
+ if (level == null)
316
+ return; // silence carries no vote
317
+ loud = gateLoud(loud, level);
318
+ ride = rideStep(ride, wantGain(loud, target));
319
+ safely(() => gain.gain.setTargetAtTime?.(ride, now(), RIDE_TAU));
320
+ emit();
321
+ }
322
+ function configure(o, q) {
323
+ setLevel(o.level === true);
324
+ setLimiter(o.limiter === true);
325
+ amount = q && quiet ? amountFor(o.muted ? 0 : o.volume, !!o.night, {}) : 0;
326
+ quiet?.setAmount(amount);
327
+ }
328
+ function apply(opts) {
329
+ if (disposed)
330
+ return;
331
+ const o = opts ?? {};
332
+ lastOpts = o;
333
+ target = clampTarget(o.target);
334
+ const q = o.quiet === true;
335
+ const anyOn = o.level === true || o.limiter === true || q;
336
+ anyOnLast = anyOn;
337
+ if (buildFailed)
338
+ return emit();
339
+ if (routed) {
340
+ // The door is shut behind us; all we can do is watch and report.
341
+ unsafeSrc = routable() === 'cors';
342
+ configure(o, q);
343
+ return emit();
344
+ }
345
+ // Never create a context — let alone route — just to neutralise it.
346
+ if (!anyOn) {
347
+ refusedCors = false;
348
+ return emit();
349
+ }
350
+ const r = routable();
351
+ refusedCors = r === 'cors';
352
+ if (r !== 'ok')
353
+ return emit();
354
+ if (!ctx) {
355
+ try {
356
+ ctx = deps.context();
357
+ const c = ctx;
358
+ c.addEventListener?.('statechange', () => {
359
+ if (!disposed && ctx === c && running() && !routed && lastOpts)
360
+ apply(lastOpts);
361
+ });
362
+ }
363
+ catch {
364
+ ctx = null;
365
+ buildFailed = true;
366
+ return emit();
367
+ }
368
+ }
369
+ if (!running()) {
370
+ // Suspended: do NOT route. Native audio keeps playing; the resume below
371
+ // (or the next gesture's) retries.
372
+ kick();
373
+ return emit();
374
+ }
375
+ if (!build())
376
+ return emit();
377
+ configure(o, q);
378
+ emit();
379
+ }
380
+ function gesture() {
381
+ // All features off: a click is not our business (no context, no apply).
382
+ if (disposed || buildFailed || !anyOnLast)
383
+ return;
384
+ if (ctx && !running())
385
+ kick();
386
+ if (!routed && lastOpts)
387
+ apply(lastOpts);
388
+ }
389
+ function dispose() {
390
+ if (disposed)
391
+ return;
392
+ disposed = true;
393
+ stopTimer?.();
394
+ stopTimer = null;
395
+ quiet?.dispose();
396
+ closeCtx();
397
+ gain = limiter = trim = analyser = quiet = null;
398
+ }
399
+ return { apply, gesture, tick, state, dispose };
400
+ }
@@ -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';