@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.
@@ -2,10 +2,14 @@ export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookm
2
2
  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
+ export type { NightMode, NightWindow } from '../video/night/clock';
6
+ export type { AudioState, AudioStatus, AudioBrokenReason } from '../video/audio/graph';
5
7
  import type { VideoBookmark, VideoChapter, VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment, VideoWaveform } from '../video/types';
6
8
  import { type BandState } from '../video/band/axis';
7
9
  import { type VideoLumaTrack } from '../video/band/luma';
8
10
  import { type VideoMarker } from '../video/band/markers';
11
+ import { type NightMode, type NightWindow } from '../video/night/clock';
12
+ import { type AudioBrokenReason, type AudioState } from '../video/audio';
9
13
  type $$ComponentProps = {
10
14
  src: string;
11
15
  poster?: string | null;
@@ -97,10 +101,89 @@ type $$ComponentProps = {
97
101
  onAddBookmark?: (playbackSec: number, label: string) => void;
98
102
  /** Right-click on the band. The host owns the panel; this only hit-tests. */
99
103
  onBookmarkContext?: (sec: number, existing: VideoBookmark | null, clientX: number, clientY: number) => void;
104
+ /**
105
+ * Night dimming (mos-5lzns.33) — a METER, not a preference: ~1×/s while
106
+ * playing it reads the frame's actual brightness (32×18 canvas) and pulls
107
+ * the picture down in proportion, warming it through multiply layers so
108
+ * blacks stay black. A dark scene passes through untouched.
109
+ *
110
+ * `'auto'` arms it from the local clock (20:00–07:00; 18:00–08:00 when the
111
+ * OS prefers dark), `'on'` always, `'off'` never. Omitted (default) ⇒ the
112
+ * feature does not exist on this player: no N key, no button, no layers.
113
+ * Bindable — `N` and the moon button cycle it; `onNightModeChange` reports.
114
+ *
115
+ * Frame reading needs a CORS-clean load: pass `crossorigin="anonymous"`
116
+ * for cross-origin media. A tainted canvas runs night on a fixed level
117
+ * (no meter, no bias light) and calls `onNightBlind`.
118
+ */
119
+ night?: NightMode;
120
+ /** How far night goes, 0.2–1 (default 0.7). Bindable — scrolling the moon button sets it. */
121
+ nightDepth?: number;
122
+ /** Override the auto window, local hours `{ from, to }` (wraps midnight when from > to). */
123
+ nightWindow?: NightWindow | null;
124
+ /** While night acts, spill the frame's own colours, blurred, behind the player. */
125
+ biasLight?: boolean;
126
+ /** Read the `luma` track ~1s ahead so dimming starts BEFORE a bright cut. Only ever adds dimming. */
127
+ predictive?: boolean;
128
+ /**
129
+ * Photosensitivity guard: across the `luma` track's flash intervals,
130
+ * drop the picture to about 55% brightness (instant in, released per
131
+ * tick — never a jump back up). Always at full strength: it does NOT
132
+ * follow `nightDepth`, and it acts whether night is on, off or absent,
133
+ * whenever the track carries `flashes`. `false` to disable.
134
+ *
135
+ * It REDUCES flashes; it does not make content WCAG 2.3.1-safe. Flash
136
+ * DETECTION is the host's job — the player guards only the intervals the
137
+ * host supplies in `luma.flashes`.
138
+ */
139
+ strobeGuard?: boolean;
140
+ onNightModeChange?: (mode: NightMode) => void;
141
+ /** Frame readback was refused (CORS-tainted canvas) — night fell back to a fixed level. */
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;
100
183
  class?: string;
101
184
  };
102
185
  declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
103
186
  bandState: () => BandState;
104
- }, "">;
187
+ }, "night" | "nightDepth">;
105
188
  type VideoPlayer = ReturnType<typeof VideoPlayer>;
106
189
  export default VideoPlayer;
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, } 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';
@@ -20,8 +20,8 @@ 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** | Loudness matching, quiet listening, limiter — audio-graph contract is in the file header. |
24
- | `night.ts` | **.33** | Night dimming, bias light, predictive dimming (luma track), strobe guard. |
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
+ | `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`). |
27
27
 
@@ -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, and it takes a
48
- MediaElementAudioSourceNode, never the element). Knobs arrive as an
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
+ }