@joewinke/jatui 0.12.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 +123 -0
- package/dist/components/VideoPlayer.svelte.d.ts +42 -0
- package/dist/index.d.ts +1 -1
- package/dist/video/README.md +4 -3
- 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/settings.js +8 -1
- package/package.json +1 -1
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
export type { VideoMarker } from '../video/band/markers';
|
|
16
16
|
// mos-5lzns.33 — night dimming.
|
|
17
17
|
export type { NightMode, NightWindow } from '../video/night/clock';
|
|
18
|
+
// mos-5lzns.31 — audio graph.
|
|
19
|
+
export type { AudioState, AudioStatus, AudioBrokenReason } from '../video/audio/graph';
|
|
18
20
|
</script>
|
|
19
21
|
|
|
20
22
|
<script lang="ts">
|
|
@@ -157,6 +159,13 @@
|
|
|
157
159
|
type NightFrame,
|
|
158
160
|
type NightState
|
|
159
161
|
} from '../video/night/track';
|
|
162
|
+
import {
|
|
163
|
+
createAudioSession,
|
|
164
|
+
routeSafety,
|
|
165
|
+
AUDIO_TARGET_DEFAULT,
|
|
166
|
+
type AudioBrokenReason,
|
|
167
|
+
type AudioState
|
|
168
|
+
} from '../video/audio';
|
|
160
169
|
|
|
161
170
|
let {
|
|
162
171
|
src,
|
|
@@ -214,6 +223,12 @@
|
|
|
214
223
|
strobeGuard = true,
|
|
215
224
|
onNightModeChange = undefined,
|
|
216
225
|
onNightBlind = undefined,
|
|
226
|
+
loudness = undefined,
|
|
227
|
+
loudnessTarget = AUDIO_TARGET_DEFAULT,
|
|
228
|
+
quietListening = undefined,
|
|
229
|
+
limiter = undefined,
|
|
230
|
+
onAudioState = undefined,
|
|
231
|
+
onAudioBroken = undefined,
|
|
217
232
|
class: className = ''
|
|
218
233
|
}: {
|
|
219
234
|
src: string;
|
|
@@ -350,6 +365,46 @@
|
|
|
350
365
|
onNightModeChange?: (mode: NightMode) => void;
|
|
351
366
|
/** Frame readback was refused (CORS-tainted canvas) — night fell back to a fixed level. */
|
|
352
367
|
onNightBlind?: () => void;
|
|
368
|
+
/**
|
|
369
|
+
* Audio graph (mos-5lzns.31) — all three default OFF (absent = the
|
|
370
|
+
* feature does not exist on this player and nothing is ever routed).
|
|
371
|
+
*
|
|
372
|
+
* `loudness`: a gated RMS meter rides the gain slowly toward
|
|
373
|
+
* `loudnessTarget` (dBFS, −30…−12, default −20) so a whispered video and
|
|
374
|
+
* a blaring one arrive at the same level; clamped 0.3–1.35×, it rescues a
|
|
375
|
+
* quiet mix by a few dB and never becomes an amplifier.
|
|
376
|
+
* `quietListening`: loudness-compensated tone that rises as the volume
|
|
377
|
+
* drops (and sits a little on while night is armed).
|
|
378
|
+
* `limiter`: catches peaks above −3 dBFS that the slow rider can't.
|
|
379
|
+
*
|
|
380
|
+
* ⚠ Routing into WebAudio is permanent for the element, and a source
|
|
381
|
+
* that isn't CORS-clean routes to SILENCE. So the player routes only
|
|
382
|
+
* when the src is blob:/data: or `crossorigin` is set ("anonymous" /
|
|
383
|
+
* "use-credentials", with the server answering Access-Control-Allow-
|
|
384
|
+
* Origin) — a same-origin URL is NOT enough, since it may redirect to
|
|
385
|
+
* storage on another origin. Anything else is refused and plays
|
|
386
|
+
* unprocessed (`status: 'cors'`). It also waits for the AudioContext to
|
|
387
|
+
* be `running` (a user gesture); until then native audio plays.
|
|
388
|
+
*
|
|
389
|
+
* After routing it cannot be undone, so the player reports
|
|
390
|
+
* `status: 'broken'` and calls `onAudioBroken(reason)`:
|
|
391
|
+
* · 'unsafe-src' — the src later changed to one that isn't route-safe
|
|
392
|
+
* (or `crossorigin` was removed). Remount the player (a fresh element)
|
|
393
|
+
* for that source.
|
|
394
|
+
* · 'silent' — bit-exact silence for 4s while the video plays audibly
|
|
395
|
+
* AND the browser shows decoded audio flowing (a video with no audio
|
|
396
|
+
* track never fires). Show a notice and/or turn the audio features
|
|
397
|
+
* off for that src — do NOT remount; it would loop.
|
|
398
|
+
* · 'build-failed' — construction threw: plain audio for the session.
|
|
399
|
+
*/
|
|
400
|
+
loudness?: boolean;
|
|
401
|
+
loudnessTarget?: number;
|
|
402
|
+
quietListening?: boolean;
|
|
403
|
+
limiter?: boolean;
|
|
404
|
+
/** Graph status + the rider's live gain (≤5×/s while matching acts). */
|
|
405
|
+
onAudioState?: (state: AudioState) => void;
|
|
406
|
+
/** Entered `broken`: 'build-failed' | 'unsafe-src' | 'silent' (see above). */
|
|
407
|
+
onAudioBroken?: (reason: AudioBrokenReason) => void;
|
|
353
408
|
class?: string;
|
|
354
409
|
} = $props();
|
|
355
410
|
|
|
@@ -620,6 +675,74 @@
|
|
|
620
675
|
flashOsd(formatSpeedLabel(baseRate));
|
|
621
676
|
}
|
|
622
677
|
|
|
678
|
+
// ── Audio (video/audio/*) ───────────────────────────────────────────────
|
|
679
|
+
// The session owns the graph and its one-way door; this block only hands
|
|
680
|
+
// it the element (inside `route`) and re-applies on every input it reads.
|
|
681
|
+
// Inert until a feature is on: no context, no routing. Gestures anywhere
|
|
682
|
+
// on the page resume a suspended context and retry a pending route.
|
|
683
|
+
let audio: ReturnType<typeof createAudioSession> | null = null;
|
|
684
|
+
$effect(() => {
|
|
685
|
+
const v = videoEl;
|
|
686
|
+
if (!v) return;
|
|
687
|
+
const s = createAudioSession({
|
|
688
|
+
// The attribute is what will load next; currentSrc lags it until loadstart.
|
|
689
|
+
routable: () => routeSafety(v.getAttribute('src') || v.currentSrc, v.getAttribute('crossorigin')),
|
|
690
|
+
context: () => new AudioContext(),
|
|
691
|
+
route: (ctx) => (ctx as AudioContext).createMediaElementSource(v),
|
|
692
|
+
playback: () => {
|
|
693
|
+
const m = v as HTMLVideoElement & {
|
|
694
|
+
webkitAudioDecodedByteCount?: number;
|
|
695
|
+
mozHasAudio?: boolean;
|
|
696
|
+
audioTracks?: { length: number };
|
|
697
|
+
};
|
|
698
|
+
return {
|
|
699
|
+
playing: !v.paused && !v.ended,
|
|
700
|
+
hidden: document.hidden,
|
|
701
|
+
audible: !v.muted && v.volume > 0,
|
|
702
|
+
time: v.currentTime,
|
|
703
|
+
audioDecoded: typeof m.webkitAudioDecodedByteCount === 'number' ? m.webkitAudioDecodedByteCount : null,
|
|
704
|
+
audioPresent:
|
|
705
|
+
typeof m.mozHasAudio === 'boolean' ? m.mozHasAudio : m.audioTracks ? m.audioTracks.length > 0 : null
|
|
706
|
+
};
|
|
707
|
+
},
|
|
708
|
+
onState: (st) => onAudioState?.(st),
|
|
709
|
+
onBroken: (reason) => onAudioBroken?.(reason)
|
|
710
|
+
});
|
|
711
|
+
audio = s;
|
|
712
|
+
const sync = () => untrack(() => s.apply(audioOpts(v)));
|
|
713
|
+
const gesture = () => s.gesture();
|
|
714
|
+
const media = ['volumechange', 'loadstart', 'loadedmetadata'];
|
|
715
|
+
const gestures = ['pointerdown', 'pointerup', 'keydown', 'touchend'];
|
|
716
|
+
for (const e of media) v.addEventListener(e, sync);
|
|
717
|
+
v.addEventListener('play', gesture);
|
|
718
|
+
for (const e of gestures) window.addEventListener(e, gesture, { capture: true, passive: true });
|
|
719
|
+
return () => {
|
|
720
|
+
for (const e of media) v.removeEventListener(e, sync);
|
|
721
|
+
v.removeEventListener('play', gesture);
|
|
722
|
+
for (const e of gestures) window.removeEventListener(e, gesture, { capture: true });
|
|
723
|
+
s.dispose();
|
|
724
|
+
audio = null;
|
|
725
|
+
};
|
|
726
|
+
});
|
|
727
|
+
function audioOpts(v: HTMLVideoElement) {
|
|
728
|
+
return {
|
|
729
|
+
level: loudness === true,
|
|
730
|
+
target: loudnessTarget,
|
|
731
|
+
limiter: limiter === true,
|
|
732
|
+
quiet: quietListening === true,
|
|
733
|
+
volume: v.volume,
|
|
734
|
+
muted: v.muted,
|
|
735
|
+
night: nightArmedNow
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
$effect(() => {
|
|
739
|
+
const v = videoEl;
|
|
740
|
+
void crossorigin; // a CORS opt-in clears a 'cors' refusal; removing it after routing is 'unsafe-src'
|
|
741
|
+
void src;
|
|
742
|
+
const o = v ? audioOpts(v) : null;
|
|
743
|
+
if (o) untrack(() => audio?.apply(o));
|
|
744
|
+
});
|
|
745
|
+
|
|
623
746
|
// ── Night (video/night/*) ───────────────────────────────────────────────
|
|
624
747
|
// The modules decide; this block samples the frame and applies the answer
|
|
625
748
|
// as CSS variables that exist ONLY while night (or the strobe guard) is
|
|
@@ -3,11 +3,13 @@ export type { BandAxis, BandState } from '../video/band/axis';
|
|
|
3
3
|
export type { VideoLumaTrack } from '../video/band/luma';
|
|
4
4
|
export type { VideoMarker } from '../video/band/markers';
|
|
5
5
|
export type { NightMode, NightWindow } from '../video/night/clock';
|
|
6
|
+
export type { AudioState, AudioStatus, AudioBrokenReason } from '../video/audio/graph';
|
|
6
7
|
import type { VideoBookmark, VideoChapter, VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment, VideoWaveform } from '../video/types';
|
|
7
8
|
import { type BandState } from '../video/band/axis';
|
|
8
9
|
import { type VideoLumaTrack } from '../video/band/luma';
|
|
9
10
|
import { type VideoMarker } from '../video/band/markers';
|
|
10
11
|
import { type NightMode, type NightWindow } from '../video/night/clock';
|
|
12
|
+
import { type AudioBrokenReason, type AudioState } from '../video/audio';
|
|
11
13
|
type $$ComponentProps = {
|
|
12
14
|
src: string;
|
|
13
15
|
poster?: string | null;
|
|
@@ -138,6 +140,46 @@ type $$ComponentProps = {
|
|
|
138
140
|
onNightModeChange?: (mode: NightMode) => void;
|
|
139
141
|
/** Frame readback was refused (CORS-tainted canvas) — night fell back to a fixed level. */
|
|
140
142
|
onNightBlind?: () => void;
|
|
143
|
+
/**
|
|
144
|
+
* Audio graph (mos-5lzns.31) — all three default OFF (absent = the
|
|
145
|
+
* feature does not exist on this player and nothing is ever routed).
|
|
146
|
+
*
|
|
147
|
+
* `loudness`: a gated RMS meter rides the gain slowly toward
|
|
148
|
+
* `loudnessTarget` (dBFS, −30…−12, default −20) so a whispered video and
|
|
149
|
+
* a blaring one arrive at the same level; clamped 0.3–1.35×, it rescues a
|
|
150
|
+
* quiet mix by a few dB and never becomes an amplifier.
|
|
151
|
+
* `quietListening`: loudness-compensated tone that rises as the volume
|
|
152
|
+
* drops (and sits a little on while night is armed).
|
|
153
|
+
* `limiter`: catches peaks above −3 dBFS that the slow rider can't.
|
|
154
|
+
*
|
|
155
|
+
* ⚠ Routing into WebAudio is permanent for the element, and a source
|
|
156
|
+
* that isn't CORS-clean routes to SILENCE. So the player routes only
|
|
157
|
+
* when the src is blob:/data: or `crossorigin` is set ("anonymous" /
|
|
158
|
+
* "use-credentials", with the server answering Access-Control-Allow-
|
|
159
|
+
* Origin) — a same-origin URL is NOT enough, since it may redirect to
|
|
160
|
+
* storage on another origin. Anything else is refused and plays
|
|
161
|
+
* unprocessed (`status: 'cors'`). It also waits for the AudioContext to
|
|
162
|
+
* be `running` (a user gesture); until then native audio plays.
|
|
163
|
+
*
|
|
164
|
+
* After routing it cannot be undone, so the player reports
|
|
165
|
+
* `status: 'broken'` and calls `onAudioBroken(reason)`:
|
|
166
|
+
* · 'unsafe-src' — the src later changed to one that isn't route-safe
|
|
167
|
+
* (or `crossorigin` was removed). Remount the player (a fresh element)
|
|
168
|
+
* for that source.
|
|
169
|
+
* · 'silent' — bit-exact silence for 4s while the video plays audibly
|
|
170
|
+
* AND the browser shows decoded audio flowing (a video with no audio
|
|
171
|
+
* track never fires). Show a notice and/or turn the audio features
|
|
172
|
+
* off for that src — do NOT remount; it would loop.
|
|
173
|
+
* · 'build-failed' — construction threw: plain audio for the session.
|
|
174
|
+
*/
|
|
175
|
+
loudness?: boolean;
|
|
176
|
+
loudnessTarget?: number;
|
|
177
|
+
quietListening?: boolean;
|
|
178
|
+
limiter?: boolean;
|
|
179
|
+
/** Graph status + the rider's live gain (≤5×/s while matching acts). */
|
|
180
|
+
onAudioState?: (state: AudioState) => void;
|
|
181
|
+
/** Entered `broken`: 'build-failed' | 'unsafe-src' | 'silent' (see above). */
|
|
182
|
+
onAudioBroken?: (reason: AudioBrokenReason) => void;
|
|
141
183
|
class?: string;
|
|
142
184
|
};
|
|
143
185
|
declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
|
package/dist/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export type { ChapterBlock } from './components/replay/ChapterTimeline.svelte';
|
|
|
12
12
|
export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelte';
|
|
13
13
|
export type { TranscriptSegment, TranscriptBookmark, TranscriptChapter, TranscriptSpeakerTalkTime, } from './components/TranscriptPlayer.svelte';
|
|
14
14
|
export { default as VideoPlayer } from './components/VideoPlayer.svelte';
|
|
15
|
-
export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, NightMode, NightWindow, } from './components/VideoPlayer.svelte';
|
|
15
|
+
export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, NightMode, NightWindow, AudioState, AudioStatus, AudioBrokenReason, } from './components/VideoPlayer.svelte';
|
|
16
16
|
export { buildSilenceMap, boostAt, estimateSaving, type SilenceMap, type SilenceWave, type SilenceOpts, type BoostOpts } from './video/advisors/smart-speed';
|
|
17
17
|
export { speakerPace, suggestRates, rateAt, describeRates, type SpeakerPace, type SpeakerPaceRow, type RateMap, type RateTurn, type RateSegment, type SuggestOpts } from './video/advisors/speaker-rate';
|
|
18
18
|
export { snapResume, resumeLabel, type ResumePoint, type ResumeReason, type ResumeCtx, type ResumeOpts } from './video/advisors/resume-point';
|
package/dist/video/README.md
CHANGED
|
@@ -20,7 +20,7 @@ props + callback surfaces.
|
|
|
20
20
|
| `rate.ts` | .28 (done) — filled by **.32** | The rate composer contract (`composeRate`, deadband, clamp), the `SPEED_STEPS` staircase, `formatSpeedLabel`. .32 adds the speaker-pace and silence-map advisors — they never write `playbackRate`; they advise. |
|
|
21
21
|
| `deepLink.ts` | .28 (done) | `?t=` parse/format/read/write (replaceState, throttled, other params preserved). |
|
|
22
22
|
| `settings.ts` | shared — knobs added by .30–.34 | `coerce` (never throws, never junk) + `VIDEO_SETTINGS` registry. A knob lands WITH its reader. |
|
|
23
|
-
| `audio.ts` | **.31** |
|
|
23
|
+
| `audio.ts` + `audio/` | **.31** (done) | `audio.ts` is the barrel. `rider.ts` — pure: RMS dBFS (silence → null), the 12 dB relative-gated loud estimate (rise 0.08 / forget 0.01), want-gain clamped 0.3–1.35, the asymmetric ride (down 0.15 / up 0.02), limiter constants + `compressorMakeupDb`/`limiterTrim` (DynamicsCompressor's automatic makeup, cancelled by a trim node), `routeSafety` (only blob:/data: or a `crossorigin` load — a same-origin URL may redirect cross-origin and route to SILENCE). `graph.ts` — `createAudioSession`: source → gain → quiet chain → limiter → trim → destination, analyser on the source. Context first, route last: the element is taken only when a feature is on, the source is route-safe and `ctx.state === 'running'` (gestures resume + retry; never navigator.userActivation). After routing it watches: an unsafe src or bit-exact zeros while audibly playing ⇒ `broken` (`unsafe-src` / `silent`) via `onBroken`. A construction throw is a permanent `build-failed` (context closed if the element wasn't taken; kept with source → destination if it was). Level off cancels before it sets; limiter moves are ramped. The element never enters the module — the component passes a `route` closure. |
|
|
24
24
|
| `night.ts` + `night/` | **.33** (done) | `night.ts` is the barrel. `meter.ts` — 32×18 frame bytes → Rec.709 luma + blown share, asymmetric smoothing, bias-light cells from the same bytes. `levels.ts` — luma → brightness/saturation/warmth/vignette off `NIGHT_FLOOR` 0.34, blind fixed level, the light-theme surround. `clock.ts` — local-clock `auto` window + the `N` auto/on/off cycle (no hyprsunset). `track.ts` — prediction (only ever adds dimming), flash intervals, `composeNight` (darker at once, brighter released per tick — never a jump). |
|
|
25
25
|
| `presence.ts` | **.34** | Wind-down (Idle Detection), resume-at-sentence, FLIP open. |
|
|
26
26
|
| `advisors/` | **.27 (done)** | The five extension-library pure advisors, ported verbatim: `smart-speed` (silence map + boost), `speaker-rate` (measured wpm + suggested rates), `resume-point` (sentence-snapping + the label), `quiet-listening` (tone-only chain + `amountFor`), `qr` (ISO 18004 encoder v1–10). Each has its own README row in `advisors/README.md`; the `--self-test` suites live on as vitest in `tests/unit/advisors/` (`npm run test:advisors`). |
|
|
@@ -44,8 +44,9 @@ feature task.
|
|
|
44
44
|
the prompt).
|
|
45
45
|
3. **Measuring code is an advisor; the component decides and applies.**
|
|
46
46
|
Modules under `src/lib/video/` read no settings and touch no `<video>`
|
|
47
|
-
(the audio graph builder is the one exception
|
|
48
|
-
|
|
47
|
+
(the audio graph builder is the one exception: it touches AudioContext,
|
|
48
|
+
but takes the element only through a `route` closure the component owns
|
|
49
|
+
— never the element itself). Knobs arrive as an
|
|
49
50
|
`opts` argument; a bad argument returns a safe neutral value rather than
|
|
50
51
|
throwing. This is what keeps every module node-testable under
|
|
51
52
|
`npm run test:unit` (the advisor vitest suites run under
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** What the host asks for. Booleans are "feature on"; volume/muted/night feed quiet listening's amount. */
|
|
2
|
+
export interface AudioOpts {
|
|
3
|
+
level: boolean;
|
|
4
|
+
target?: number;
|
|
5
|
+
limiter: boolean;
|
|
6
|
+
quiet: boolean;
|
|
7
|
+
volume?: number;
|
|
8
|
+
muted?: boolean;
|
|
9
|
+
night?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* · 'off' — nothing routed (no feature on, no source yet, or the context is
|
|
13
|
+
* still waiting for a gesture to start). Native audio plays.
|
|
14
|
+
* · 'live' — routed and processing.
|
|
15
|
+
* · 'cors' — a feature is on but the source isn't route-safe: refused,
|
|
16
|
+
* audio plays unprocessed. Clears when the source does.
|
|
17
|
+
* · 'broken' — see `reason`.
|
|
18
|
+
*/
|
|
19
|
+
export type AudioStatus = 'off' | 'live' | 'cors' | 'broken';
|
|
20
|
+
/**
|
|
21
|
+
* · 'build-failed' — construction threw; plain audio for the session (permanent).
|
|
22
|
+
* · 'unsafe-src' — routed, then the src/crossorigin changed to one that is
|
|
23
|
+
* not route-safe: this element may now be SILENT.
|
|
24
|
+
* · 'silent' — routed, playing audibly, clock advancing, decoded audio
|
|
25
|
+
* evidently flowing, and the analyser reads bit-exact
|
|
26
|
+
* zeros for SILENT_AFTER_MS. Show a notice / disable the
|
|
27
|
+
* features for this src — do NOT remount (it would loop).
|
|
28
|
+
*/
|
|
29
|
+
export type AudioBrokenReason = 'build-failed' | 'unsafe-src' | 'silent';
|
|
30
|
+
export interface AudioState {
|
|
31
|
+
status: AudioStatus;
|
|
32
|
+
/** Why `status` is 'broken'; null otherwise. */
|
|
33
|
+
reason: AudioBrokenReason | null;
|
|
34
|
+
/** The rider's current gain, dB (0 when level matching is off or unmeasured). */
|
|
35
|
+
gainDb: number;
|
|
36
|
+
/** The gated loud-level estimate, dBFS — null until something has been heard. */
|
|
37
|
+
loudDb: number | null;
|
|
38
|
+
/** Quiet listening's current amount, 0..1. */
|
|
39
|
+
quiet: number;
|
|
40
|
+
}
|
|
41
|
+
interface ParamLike {
|
|
42
|
+
value: number;
|
|
43
|
+
setTargetAtTime?(v: number, t: number, tau: number): unknown;
|
|
44
|
+
setValueAtTime?(v: number, t: number): unknown;
|
|
45
|
+
cancelScheduledValues?(t: number): unknown;
|
|
46
|
+
}
|
|
47
|
+
interface NodeLike {
|
|
48
|
+
connect(dest: unknown): unknown;
|
|
49
|
+
disconnect?(): unknown;
|
|
50
|
+
gain?: ParamLike;
|
|
51
|
+
threshold?: ParamLike;
|
|
52
|
+
knee?: ParamLike;
|
|
53
|
+
ratio?: ParamLike;
|
|
54
|
+
attack?: ParamLike;
|
|
55
|
+
release?: ParamLike;
|
|
56
|
+
fftSize?: number;
|
|
57
|
+
getFloatTimeDomainData?(buf: Float32Array): void;
|
|
58
|
+
}
|
|
59
|
+
export interface AudioContextLike {
|
|
60
|
+
currentTime: number;
|
|
61
|
+
destination: unknown;
|
|
62
|
+
/** 'suspended' | 'running' | 'closed' — absent (a test double) reads as running. */
|
|
63
|
+
state?: string;
|
|
64
|
+
createGain(): NodeLike;
|
|
65
|
+
createDynamicsCompressor(): NodeLike;
|
|
66
|
+
createAnalyser(): NodeLike;
|
|
67
|
+
createBiquadFilter?(): unknown;
|
|
68
|
+
resume?(): unknown;
|
|
69
|
+
close?(): unknown;
|
|
70
|
+
addEventListener?(type: string, fn: () => void): void;
|
|
71
|
+
}
|
|
72
|
+
export interface AudioPlayback {
|
|
73
|
+
/** Not paused and not ended. */
|
|
74
|
+
playing: boolean;
|
|
75
|
+
/** Tab hidden — the rider holds its estimate. */
|
|
76
|
+
hidden: boolean;
|
|
77
|
+
/** Not muted and volume > 0 — silence would be a fault, not a choice. */
|
|
78
|
+
audible: boolean;
|
|
79
|
+
/** currentTime, seconds. */
|
|
80
|
+
time: number;
|
|
81
|
+
/**
|
|
82
|
+
* Evidence that the element HAS decoded audio. `audioDecoded`: Chromium's
|
|
83
|
+
* webkitAudioDecodedByteCount (must be RISING to count); `audioPresent`:
|
|
84
|
+
* Gecko's mozHasAudio or WebKit's audioTracks.length > 0. null = unknown.
|
|
85
|
+
* With no positive evidence the silence watchdog never fires.
|
|
86
|
+
*/
|
|
87
|
+
audioDecoded?: number | null;
|
|
88
|
+
audioPresent?: boolean | null;
|
|
89
|
+
}
|
|
90
|
+
export interface AudioSessionDeps {
|
|
91
|
+
/** Is the element's current source route-safe? (rider.ts `routeSafety`) */
|
|
92
|
+
routable(): 'ok' | 'wait' | 'cors';
|
|
93
|
+
/** Create the AudioContext. Does NOT touch the element. */
|
|
94
|
+
context(): AudioContextLike;
|
|
95
|
+
/** Take the element: createMediaElementSource. The one-way door — called at most once. */
|
|
96
|
+
route(ctx: AudioContextLike): NodeLike;
|
|
97
|
+
playback(): AudioPlayback;
|
|
98
|
+
/** State changes + each rider tick. */
|
|
99
|
+
onState?(state: AudioState): void;
|
|
100
|
+
/** Entered `broken` (or its reason changed). */
|
|
101
|
+
onBroken?(reason: AudioBrokenReason): void;
|
|
102
|
+
/** Timer seam (tests). Defaults to setInterval/clearInterval. */
|
|
103
|
+
every?(ms: number, fn: () => void): () => void;
|
|
104
|
+
}
|
|
105
|
+
export interface AudioSession {
|
|
106
|
+
apply(opts: AudioOpts): void;
|
|
107
|
+
/** A user gesture (or play): resume the context, retry a pending route. */
|
|
108
|
+
gesture(): void;
|
|
109
|
+
/** One watchdog + rider step (the session's own timer calls this; exposed for tests). */
|
|
110
|
+
tick(): void;
|
|
111
|
+
state(): AudioState;
|
|
112
|
+
dispose(): void;
|
|
113
|
+
}
|
|
114
|
+
export declare function createAudioSession(deps: AudioSessionDeps): AudioSession;
|
|
115
|
+
export {};
|
|
@@ -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';
|
|
@@ -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/settings.js
CHANGED
|
@@ -32,7 +32,14 @@
|
|
|
32
32
|
export const VIDEO_SETTINGS = [
|
|
33
33
|
// Read by VideoPlayer's `oneAxis` prop → video/band/axis.ts resolveAxis().
|
|
34
34
|
{ id: 'player.oneAxis', type: 'bool', default: true, owner: 'mos-5lzns.30', wired: true },
|
|
35
|
-
//
|
|
35
|
+
// Read by VideoPlayer's `loudness` / `loudnessTarget` / `quietListening` /
|
|
36
|
+
// `limiter` props → video/audio/* (graph.createAudioSession, rider.*). As with
|
|
37
|
+
// night, the DEFAULT is the host's opt-in: an absent prop means nothing is
|
|
38
|
+
// ever routed through WebAudio on that player.
|
|
39
|
+
{ id: 'audio.level', type: 'bool', default: true, owner: 'mos-5lzns.31', wired: true },
|
|
40
|
+
{ id: 'audio.target', type: 'range', default: -20, min: -30, max: -12, step: 2, owner: 'mos-5lzns.31', wired: true },
|
|
41
|
+
{ id: 'audio.quiet', type: 'bool', default: true, owner: 'mos-5lzns.31', wired: true },
|
|
42
|
+
{ id: 'audio.limiter', type: 'bool', default: true, owner: 'mos-5lzns.31', wired: true },
|
|
36
43
|
// { id: 'player.speakerRate', type: 'bool', default: false, owner: 'mos-5lzns.32' },
|
|
37
44
|
// Read by VideoPlayer's `night` / `nightDepth` / `biasLight` / `predictive` /
|
|
38
45
|
// `strobeGuard` props → video/night/* (clock.nightArmed, levels.nightLevels,
|