use-scroll-animate 5.5.0 → 5.6.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/CHANGELOG.md +13 -0
- package/dist/components/effects.cjs +341 -0
- package/dist/components/effects.cjs.map +1 -1
- package/dist/components/effects.d.cts +92 -2
- package/dist/components/effects.d.ts +92 -2
- package/dist/components/effects.js +335 -3
- package/dist/components/effects.js.map +1 -1
- package/dist/components.umd.js +2 -2
- package/dist/components.umd.js.map +1 -1
- package/docs/ROADMAP.md +1 -1
- package/docs/components.md +21 -0
- package/package.json +1 -1
|
@@ -147,6 +147,94 @@ declare function hexRgb(hex: string): [number, number, number];
|
|
|
147
147
|
declare function canvasBackground(el: HTMLElement, fx: EffectContext, spec: GenerativeSpec, o: any): () => void;
|
|
148
148
|
declare const GENERATIVE_FX: EffectDefinition[];
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* 5.6 — sound-reactive effects (Web Audio).
|
|
152
|
+
*
|
|
153
|
+
* - `enableAudio(input)` — start analysing the microphone (`'mic'`), an
|
|
154
|
+
* `<audio>` / `<video>` element (or a selector for one) or a `MediaStream`.
|
|
155
|
+
* Browsers only allow an `AudioContext` to start inside a user gesture, so
|
|
156
|
+
* call it from a click handler (or use `<usa-audio>`, which renders the
|
|
157
|
+
* toggle button for you).
|
|
158
|
+
* - Background effects (kind `background`, Canvas 2D through
|
|
159
|
+
* `canvasBackground()`): `spectrum-bars`, `pulse-ring`, `wave-ring`. Before
|
|
160
|
+
* audio is enabled they idle gently.
|
|
161
|
+
* - Beat detection: `createBeatDetector()` (pure: energy → beat?), `onBeat(cb)`
|
|
162
|
+
* and `bindBeat(el, effect, options)`, which plays any registered effect on
|
|
163
|
+
* every beat. `<usa-audio>` does the same for `[data-usa-beat="effect"]`
|
|
164
|
+
* children and emits `usa-beat`.
|
|
165
|
+
* - While audio runs, `--usa-audio-level` and `--usa-audio-bass` (0–1) are set
|
|
166
|
+
* on `<html>` for CSS-driven reactions.
|
|
167
|
+
*
|
|
168
|
+
* Reduced motion: the visual effects are skipped, beats play no effects and
|
|
169
|
+
* the CSS variables stay at 0 — audio itself keeps playing.
|
|
170
|
+
*/
|
|
171
|
+
|
|
172
|
+
type AudioInput = 'mic' | HTMLMediaElement | MediaStream | string;
|
|
173
|
+
interface AudioSample {
|
|
174
|
+
/** Overall loudness (RMS of the waveform), 0–1. */
|
|
175
|
+
level: number;
|
|
176
|
+
/** Low-frequency energy (first ~8 % of the spectrum), 0–1. */
|
|
177
|
+
bass: number;
|
|
178
|
+
/** Frequency bins, 0–255 each. */
|
|
179
|
+
freq: Uint8Array;
|
|
180
|
+
/** Time-domain waveform, 0–255 (128 = silence). */
|
|
181
|
+
wave: Uint8Array;
|
|
182
|
+
}
|
|
183
|
+
interface AudioReactive {
|
|
184
|
+
readonly context: AudioContext;
|
|
185
|
+
readonly analyser: AnalyserNode;
|
|
186
|
+
/** Read the analyser now. */
|
|
187
|
+
sample(): AudioSample;
|
|
188
|
+
/** Stop analysing (media keeps playing; the microphone is released). */
|
|
189
|
+
stop(): void;
|
|
190
|
+
}
|
|
191
|
+
interface BeatOptions {
|
|
192
|
+
/** A beat is energy above `threshold` × the recent average (default 1.35). */
|
|
193
|
+
threshold?: number;
|
|
194
|
+
/** Minimum ms between beats (default 250). */
|
|
195
|
+
cooldown?: number;
|
|
196
|
+
/** Frames of history for the average (default 43 ≈ 0.7 s). */
|
|
197
|
+
history?: number;
|
|
198
|
+
/** Ignore energy below this floor, 0–1 (default 0.08). */
|
|
199
|
+
floor?: number;
|
|
200
|
+
}
|
|
201
|
+
/** The running analyser, if `enableAudio()` was called. */
|
|
202
|
+
declare const getAudio: () => AudioReactive | null;
|
|
203
|
+
/**
|
|
204
|
+
* Start analysing `input` and make it the source of every sound-reactive
|
|
205
|
+
* effect. Call from a user gesture. Replaces a previous source.
|
|
206
|
+
*/
|
|
207
|
+
declare function enableAudio(input?: AudioInput, opts?: {
|
|
208
|
+
fftSize?: number;
|
|
209
|
+
smoothing?: number;
|
|
210
|
+
}): Promise<AudioReactive>;
|
|
211
|
+
/** Stop the current audio source (if any). */
|
|
212
|
+
declare function disableAudio(): void;
|
|
213
|
+
/** A pure beat detector: feed it energy (0–1) and a timestamp per frame; it answers "beat?". */
|
|
214
|
+
declare function createBeatDetector(o?: BeatOptions): (energy: number, now: number) => boolean;
|
|
215
|
+
/** Call `cb` on every detected beat of the current audio source. Returns an unsubscribe. */
|
|
216
|
+
declare function onBeat(cb: (detail: {
|
|
217
|
+
energy: number;
|
|
218
|
+
time: number;
|
|
219
|
+
}) => void, o?: BeatOptions): () => void;
|
|
220
|
+
/** Play the registered effect `name` on `el` at every beat (not under reduced motion). Returns an unbind. */
|
|
221
|
+
declare function bindBeat(el: HTMLElement, name: string, options?: Record<string, unknown> & BeatOptions): () => void;
|
|
222
|
+
declare const AUDIO_FX: EffectDefinition[];
|
|
223
|
+
interface UsaAudioElement extends UsaElement {
|
|
224
|
+
/** `true` while this element's audio source is being analysed. */
|
|
225
|
+
readonly active: boolean;
|
|
226
|
+
/** Start (from a user gesture) or stop analysing. */
|
|
227
|
+
toggle(): Promise<void>;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* `<usa-audio source="#track | mic" label="…">` — a toggle button (yours, as
|
|
231
|
+
* `[data-audio-toggle]`, or one it renders) that enables the audio source on
|
|
232
|
+
* click; children with `data-usa-beat="effect"` play that effect on every
|
|
233
|
+
* beat (`data-usa-beat-options` JSON; `threshold` / `cooldown` attributes).
|
|
234
|
+
* Emits `usa-beat` and `usa-audio-error`.
|
|
235
|
+
*/
|
|
236
|
+
declare function defineAudio(tag?: string): CustomElementConstructor | undefined;
|
|
237
|
+
|
|
150
238
|
/**
|
|
151
239
|
* 5.4 — `<usa-story template="…">` scroll-storytelling templates.
|
|
152
240
|
*
|
|
@@ -199,10 +287,12 @@ declare function registerPhysicsEffects(): void;
|
|
|
199
287
|
declare function registerPageEffects(): void;
|
|
200
288
|
/** 5.5: generative Canvas 2D backgrounds. */
|
|
201
289
|
declare function registerGenerativeEffects(): void;
|
|
290
|
+
/** 5.6: sound-reactive (Web Audio) backgrounds. */
|
|
291
|
+
declare function registerAudioEffects(): void;
|
|
202
292
|
/** Define the 5.x elements of this entry (`<usa-story>`, …) under their default tags. */
|
|
203
293
|
declare function defineEffectElements(): void;
|
|
204
294
|
/** Register the built-ins and every pack (idempotent). */
|
|
205
295
|
declare function registerAllEffects(): void;
|
|
206
296
|
|
|
207
|
-
export { CARD_FX, CLICK_FX, EFFECT_PACKS, GENERATIVE_FX, PAGE_FX, PHYSICS_FX, STORY_TEMPLATES, bounceKeyframes, canvasBackground, defineEffectElements, defineStory, formatCount, fxLayer, hexRgb, noise2, registerAllEffects, registerCardClickEffects, registerGenerativeEffects, registerPageEffects, registerPhysicsEffects, solveSpring, springKeyframes, storyProgress };
|
|
208
|
-
export type { GenFrame, GenerativeSpec, SpringOptions, StoryTemplate, UsaStoryElement };
|
|
297
|
+
export { AUDIO_FX, CARD_FX, CLICK_FX, EFFECT_PACKS, GENERATIVE_FX, PAGE_FX, PHYSICS_FX, STORY_TEMPLATES, bindBeat, bounceKeyframes, canvasBackground, createBeatDetector, defineAudio, defineEffectElements, defineStory, disableAudio, enableAudio, formatCount, fxLayer, getAudio, hexRgb, noise2, onBeat, registerAllEffects, registerAudioEffects, registerCardClickEffects, registerGenerativeEffects, registerPageEffects, registerPhysicsEffects, solveSpring, springKeyframes, storyProgress };
|
|
298
|
+
export type { AudioInput, AudioReactive, AudioSample, BeatOptions, GenFrame, GenerativeSpec, SpringOptions, StoryTemplate, UsaAudioElement, UsaStoryElement };
|
|
@@ -147,6 +147,94 @@ declare function hexRgb(hex: string): [number, number, number];
|
|
|
147
147
|
declare function canvasBackground(el: HTMLElement, fx: EffectContext, spec: GenerativeSpec, o: any): () => void;
|
|
148
148
|
declare const GENERATIVE_FX: EffectDefinition[];
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* 5.6 — sound-reactive effects (Web Audio).
|
|
152
|
+
*
|
|
153
|
+
* - `enableAudio(input)` — start analysing the microphone (`'mic'`), an
|
|
154
|
+
* `<audio>` / `<video>` element (or a selector for one) or a `MediaStream`.
|
|
155
|
+
* Browsers only allow an `AudioContext` to start inside a user gesture, so
|
|
156
|
+
* call it from a click handler (or use `<usa-audio>`, which renders the
|
|
157
|
+
* toggle button for you).
|
|
158
|
+
* - Background effects (kind `background`, Canvas 2D through
|
|
159
|
+
* `canvasBackground()`): `spectrum-bars`, `pulse-ring`, `wave-ring`. Before
|
|
160
|
+
* audio is enabled they idle gently.
|
|
161
|
+
* - Beat detection: `createBeatDetector()` (pure: energy → beat?), `onBeat(cb)`
|
|
162
|
+
* and `bindBeat(el, effect, options)`, which plays any registered effect on
|
|
163
|
+
* every beat. `<usa-audio>` does the same for `[data-usa-beat="effect"]`
|
|
164
|
+
* children and emits `usa-beat`.
|
|
165
|
+
* - While audio runs, `--usa-audio-level` and `--usa-audio-bass` (0–1) are set
|
|
166
|
+
* on `<html>` for CSS-driven reactions.
|
|
167
|
+
*
|
|
168
|
+
* Reduced motion: the visual effects are skipped, beats play no effects and
|
|
169
|
+
* the CSS variables stay at 0 — audio itself keeps playing.
|
|
170
|
+
*/
|
|
171
|
+
|
|
172
|
+
type AudioInput = 'mic' | HTMLMediaElement | MediaStream | string;
|
|
173
|
+
interface AudioSample {
|
|
174
|
+
/** Overall loudness (RMS of the waveform), 0–1. */
|
|
175
|
+
level: number;
|
|
176
|
+
/** Low-frequency energy (first ~8 % of the spectrum), 0–1. */
|
|
177
|
+
bass: number;
|
|
178
|
+
/** Frequency bins, 0–255 each. */
|
|
179
|
+
freq: Uint8Array;
|
|
180
|
+
/** Time-domain waveform, 0–255 (128 = silence). */
|
|
181
|
+
wave: Uint8Array;
|
|
182
|
+
}
|
|
183
|
+
interface AudioReactive {
|
|
184
|
+
readonly context: AudioContext;
|
|
185
|
+
readonly analyser: AnalyserNode;
|
|
186
|
+
/** Read the analyser now. */
|
|
187
|
+
sample(): AudioSample;
|
|
188
|
+
/** Stop analysing (media keeps playing; the microphone is released). */
|
|
189
|
+
stop(): void;
|
|
190
|
+
}
|
|
191
|
+
interface BeatOptions {
|
|
192
|
+
/** A beat is energy above `threshold` × the recent average (default 1.35). */
|
|
193
|
+
threshold?: number;
|
|
194
|
+
/** Minimum ms between beats (default 250). */
|
|
195
|
+
cooldown?: number;
|
|
196
|
+
/** Frames of history for the average (default 43 ≈ 0.7 s). */
|
|
197
|
+
history?: number;
|
|
198
|
+
/** Ignore energy below this floor, 0–1 (default 0.08). */
|
|
199
|
+
floor?: number;
|
|
200
|
+
}
|
|
201
|
+
/** The running analyser, if `enableAudio()` was called. */
|
|
202
|
+
declare const getAudio: () => AudioReactive | null;
|
|
203
|
+
/**
|
|
204
|
+
* Start analysing `input` and make it the source of every sound-reactive
|
|
205
|
+
* effect. Call from a user gesture. Replaces a previous source.
|
|
206
|
+
*/
|
|
207
|
+
declare function enableAudio(input?: AudioInput, opts?: {
|
|
208
|
+
fftSize?: number;
|
|
209
|
+
smoothing?: number;
|
|
210
|
+
}): Promise<AudioReactive>;
|
|
211
|
+
/** Stop the current audio source (if any). */
|
|
212
|
+
declare function disableAudio(): void;
|
|
213
|
+
/** A pure beat detector: feed it energy (0–1) and a timestamp per frame; it answers "beat?". */
|
|
214
|
+
declare function createBeatDetector(o?: BeatOptions): (energy: number, now: number) => boolean;
|
|
215
|
+
/** Call `cb` on every detected beat of the current audio source. Returns an unsubscribe. */
|
|
216
|
+
declare function onBeat(cb: (detail: {
|
|
217
|
+
energy: number;
|
|
218
|
+
time: number;
|
|
219
|
+
}) => void, o?: BeatOptions): () => void;
|
|
220
|
+
/** Play the registered effect `name` on `el` at every beat (not under reduced motion). Returns an unbind. */
|
|
221
|
+
declare function bindBeat(el: HTMLElement, name: string, options?: Record<string, unknown> & BeatOptions): () => void;
|
|
222
|
+
declare const AUDIO_FX: EffectDefinition[];
|
|
223
|
+
interface UsaAudioElement extends UsaElement {
|
|
224
|
+
/** `true` while this element's audio source is being analysed. */
|
|
225
|
+
readonly active: boolean;
|
|
226
|
+
/** Start (from a user gesture) or stop analysing. */
|
|
227
|
+
toggle(): Promise<void>;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* `<usa-audio source="#track | mic" label="…">` — a toggle button (yours, as
|
|
231
|
+
* `[data-audio-toggle]`, or one it renders) that enables the audio source on
|
|
232
|
+
* click; children with `data-usa-beat="effect"` play that effect on every
|
|
233
|
+
* beat (`data-usa-beat-options` JSON; `threshold` / `cooldown` attributes).
|
|
234
|
+
* Emits `usa-beat` and `usa-audio-error`.
|
|
235
|
+
*/
|
|
236
|
+
declare function defineAudio(tag?: string): CustomElementConstructor | undefined;
|
|
237
|
+
|
|
150
238
|
/**
|
|
151
239
|
* 5.4 — `<usa-story template="…">` scroll-storytelling templates.
|
|
152
240
|
*
|
|
@@ -199,10 +287,12 @@ declare function registerPhysicsEffects(): void;
|
|
|
199
287
|
declare function registerPageEffects(): void;
|
|
200
288
|
/** 5.5: generative Canvas 2D backgrounds. */
|
|
201
289
|
declare function registerGenerativeEffects(): void;
|
|
290
|
+
/** 5.6: sound-reactive (Web Audio) backgrounds. */
|
|
291
|
+
declare function registerAudioEffects(): void;
|
|
202
292
|
/** Define the 5.x elements of this entry (`<usa-story>`, …) under their default tags. */
|
|
203
293
|
declare function defineEffectElements(): void;
|
|
204
294
|
/** Register the built-ins and every pack (idempotent). */
|
|
205
295
|
declare function registerAllEffects(): void;
|
|
206
296
|
|
|
207
|
-
export { CARD_FX, CLICK_FX, EFFECT_PACKS, GENERATIVE_FX, PAGE_FX, PHYSICS_FX, STORY_TEMPLATES, bounceKeyframes, canvasBackground, defineEffectElements, defineStory, formatCount, fxLayer, hexRgb, noise2, registerAllEffects, registerCardClickEffects, registerGenerativeEffects, registerPageEffects, registerPhysicsEffects, solveSpring, springKeyframes, storyProgress };
|
|
208
|
-
export type { GenFrame, GenerativeSpec, SpringOptions, StoryTemplate, UsaStoryElement };
|
|
297
|
+
export { AUDIO_FX, CARD_FX, CLICK_FX, EFFECT_PACKS, GENERATIVE_FX, PAGE_FX, PHYSICS_FX, STORY_TEMPLATES, bindBeat, bounceKeyframes, canvasBackground, createBeatDetector, defineAudio, defineEffectElements, defineStory, disableAudio, enableAudio, formatCount, fxLayer, getAudio, hexRgb, noise2, onBeat, registerAllEffects, registerAudioEffects, registerCardClickEffects, registerGenerativeEffects, registerPageEffects, registerPhysicsEffects, solveSpring, springKeyframes, storyProgress };
|
|
298
|
+
export type { AudioInput, AudioReactive, AudioSample, BeatOptions, GenFrame, GenerativeSpec, SpringOptions, StoryTemplate, UsaAudioElement, UsaStoryElement };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { registerBuiltinEffects, registerEffects } from './fx.js';
|
|
2
|
-
import { x as defineElement } from '../chunks/base-C3Sw9sAO.js';
|
|
1
|
+
import { playEffect, registerBuiltinEffects, registerEffects } from './fx.js';
|
|
2
|
+
import { x as defineElement, p as prefersReducedMotion } from '../chunks/base-C3Sw9sAO.js';
|
|
3
3
|
import '../chunks/core-DN3hHbHh.js';
|
|
4
4
|
import './tokens.js';
|
|
5
5
|
import '../chunks/fx-ChjxrMBo.js';
|
|
@@ -1113,6 +1113,332 @@ const GENERATIVE_FX = [
|
|
|
1113
1113
|
}),
|
|
1114
1114
|
];
|
|
1115
1115
|
|
|
1116
|
+
let actx = null;
|
|
1117
|
+
let current = null;
|
|
1118
|
+
const mediaSources = new WeakMap();
|
|
1119
|
+
/** The running analyser, if `enableAudio()` was called. */
|
|
1120
|
+
const getAudio = () => current;
|
|
1121
|
+
function audioContext() {
|
|
1122
|
+
const AC = globalThis.AudioContext || globalThis.webkitAudioContext;
|
|
1123
|
+
if (!AC)
|
|
1124
|
+
throw new Error('[use-scroll-animate] Web Audio is not available');
|
|
1125
|
+
if (!actx || actx.state === 'closed')
|
|
1126
|
+
actx = new AC();
|
|
1127
|
+
return actx;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Start analysing `input` and make it the source of every sound-reactive
|
|
1131
|
+
* effect. Call from a user gesture. Replaces a previous source.
|
|
1132
|
+
*/
|
|
1133
|
+
async function enableAudio(input = 'mic', opts = {}) {
|
|
1134
|
+
current?.stop();
|
|
1135
|
+
const ac = audioContext();
|
|
1136
|
+
if (ac.state === 'suspended')
|
|
1137
|
+
await ac.resume().catch(() => undefined);
|
|
1138
|
+
const analyser = ac.createAnalyser();
|
|
1139
|
+
analyser.fftSize = opts.fftSize || 512;
|
|
1140
|
+
analyser.smoothingTimeConstant = opts.smoothing ?? 0.8;
|
|
1141
|
+
let src;
|
|
1142
|
+
let release = () => { };
|
|
1143
|
+
const media = typeof input === 'string' && input !== 'mic' ? document.querySelector(input) : input;
|
|
1144
|
+
if (media === 'mic') {
|
|
1145
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
1146
|
+
src = ac.createMediaStreamSource(stream);
|
|
1147
|
+
src.connect(analyser); // never to the speakers: no feedback
|
|
1148
|
+
release = () => stream.getTracks().forEach((t) => t.stop());
|
|
1149
|
+
}
|
|
1150
|
+
else if (media && typeof media.play === 'function') {
|
|
1151
|
+
const m = media;
|
|
1152
|
+
let node = mediaSources.get(m);
|
|
1153
|
+
if (!node)
|
|
1154
|
+
mediaSources.set(m, (node = ac.createMediaElementSource(m)));
|
|
1155
|
+
try {
|
|
1156
|
+
node.disconnect();
|
|
1157
|
+
}
|
|
1158
|
+
catch {
|
|
1159
|
+
/* not connected */
|
|
1160
|
+
}
|
|
1161
|
+
src = node;
|
|
1162
|
+
src.connect(analyser);
|
|
1163
|
+
analyser.connect(ac.destination);
|
|
1164
|
+
release = () => {
|
|
1165
|
+
try {
|
|
1166
|
+
node.disconnect();
|
|
1167
|
+
}
|
|
1168
|
+
catch {
|
|
1169
|
+
/* already */
|
|
1170
|
+
}
|
|
1171
|
+
node.connect(ac.destination); // keep the media audible
|
|
1172
|
+
};
|
|
1173
|
+
if (m.paused)
|
|
1174
|
+
await m.play()?.catch?.(() => undefined);
|
|
1175
|
+
}
|
|
1176
|
+
else if (media && typeof media.getTracks === 'function') {
|
|
1177
|
+
src = ac.createMediaStreamSource(media);
|
|
1178
|
+
src.connect(analyser);
|
|
1179
|
+
}
|
|
1180
|
+
else
|
|
1181
|
+
throw new Error(`[use-scroll-animate] enableAudio: no audio source for ${String(input)}`);
|
|
1182
|
+
const freq = new Uint8Array(analyser.frequencyBinCount);
|
|
1183
|
+
const wave = new Uint8Array(analyser.fftSize);
|
|
1184
|
+
const self = {
|
|
1185
|
+
context: ac,
|
|
1186
|
+
analyser,
|
|
1187
|
+
sample: () => {
|
|
1188
|
+
analyser.getByteFrequencyData(freq);
|
|
1189
|
+
analyser.getByteTimeDomainData(wave);
|
|
1190
|
+
let sq = 0;
|
|
1191
|
+
for (let i = 0; i < wave.length; i++)
|
|
1192
|
+
sq += ((wave[i] - 128) / 128) ** 2;
|
|
1193
|
+
const n = Math.max(1, Math.round(freq.length * 0.08));
|
|
1194
|
+
let b = 0;
|
|
1195
|
+
for (let i = 0; i < n; i++)
|
|
1196
|
+
b += freq[i];
|
|
1197
|
+
return { level: Math.min(1, Math.sqrt(sq / wave.length) * 2), bass: b / n / 255, freq, wave };
|
|
1198
|
+
},
|
|
1199
|
+
stop: () => {
|
|
1200
|
+
if (current === self) {
|
|
1201
|
+
current = null;
|
|
1202
|
+
if (raf && typeof cancelAnimationFrame === 'function')
|
|
1203
|
+
cancelAnimationFrame(raf);
|
|
1204
|
+
raf = 0;
|
|
1205
|
+
}
|
|
1206
|
+
try {
|
|
1207
|
+
src.disconnect();
|
|
1208
|
+
analyser.disconnect();
|
|
1209
|
+
}
|
|
1210
|
+
catch {
|
|
1211
|
+
/* already */
|
|
1212
|
+
}
|
|
1213
|
+
release();
|
|
1214
|
+
setVars(0, 0);
|
|
1215
|
+
},
|
|
1216
|
+
};
|
|
1217
|
+
current = self;
|
|
1218
|
+
kick();
|
|
1219
|
+
return self;
|
|
1220
|
+
}
|
|
1221
|
+
/** Stop the current audio source (if any). */
|
|
1222
|
+
function disableAudio() {
|
|
1223
|
+
current?.stop();
|
|
1224
|
+
}
|
|
1225
|
+
/** A pure beat detector: feed it energy (0–1) and a timestamp per frame; it answers "beat?". */
|
|
1226
|
+
function createBeatDetector(o = {}) {
|
|
1227
|
+
const { threshold = 1.35, cooldown = 250, history = 43, floor = 0.08 } = o;
|
|
1228
|
+
const hist = [];
|
|
1229
|
+
let last = -Infinity;
|
|
1230
|
+
return (energy, now) => {
|
|
1231
|
+
const avg = hist.length ? hist.reduce((a, b) => a + b, 0) / hist.length : energy;
|
|
1232
|
+
const ready = hist.length >= 8;
|
|
1233
|
+
hist.push(energy);
|
|
1234
|
+
if (hist.length > history)
|
|
1235
|
+
hist.shift();
|
|
1236
|
+
if (ready && energy > floor && energy > avg * threshold && now - last >= cooldown) {
|
|
1237
|
+
last = now;
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1240
|
+
return false;
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
const listeners = new Set();
|
|
1244
|
+
let raf = 0;
|
|
1245
|
+
function setVars(level, bass) {
|
|
1246
|
+
if (typeof document === 'undefined')
|
|
1247
|
+
return;
|
|
1248
|
+
const s = document.documentElement.style;
|
|
1249
|
+
s.setProperty('--usa-audio-level', level.toFixed(3));
|
|
1250
|
+
s.setProperty('--usa-audio-bass', bass.toFixed(3));
|
|
1251
|
+
}
|
|
1252
|
+
function loop(now) {
|
|
1253
|
+
raf = 0;
|
|
1254
|
+
if (!current)
|
|
1255
|
+
return;
|
|
1256
|
+
const { level, bass } = current.sample();
|
|
1257
|
+
const reduced = prefersReducedMotion();
|
|
1258
|
+
setVars(reduced ? 0 : level, reduced ? 0 : bass);
|
|
1259
|
+
for (const l of Array.from(listeners))
|
|
1260
|
+
if (l.detect(bass, now))
|
|
1261
|
+
l.cb({ energy: bass, time: now });
|
|
1262
|
+
kick();
|
|
1263
|
+
}
|
|
1264
|
+
function kick() {
|
|
1265
|
+
if (!raf && current && typeof requestAnimationFrame === 'function')
|
|
1266
|
+
raf = requestAnimationFrame(loop);
|
|
1267
|
+
}
|
|
1268
|
+
/** Call `cb` on every detected beat of the current audio source. Returns an unsubscribe. */
|
|
1269
|
+
function onBeat(cb, o = {}) {
|
|
1270
|
+
const l = { detect: createBeatDetector(o), cb };
|
|
1271
|
+
listeners.add(l);
|
|
1272
|
+
kick();
|
|
1273
|
+
return () => void listeners.delete(l);
|
|
1274
|
+
}
|
|
1275
|
+
/** Play the registered effect `name` on `el` at every beat (not under reduced motion). Returns an unbind. */
|
|
1276
|
+
function bindBeat(el, name, options = {}) {
|
|
1277
|
+
const { threshold, cooldown, history, floor, ...fx } = options;
|
|
1278
|
+
return onBeat(() => {
|
|
1279
|
+
if (!prefersReducedMotion())
|
|
1280
|
+
playEffect(el, name, fx).catch(() => undefined);
|
|
1281
|
+
}, { threshold, cooldown, history, floor });
|
|
1282
|
+
}
|
|
1283
|
+
// --- visual effects ----------------------------------------------------------
|
|
1284
|
+
/** The current sample, or a gentle synthetic one while no audio is enabled. */
|
|
1285
|
+
function read(t, bins) {
|
|
1286
|
+
if (current)
|
|
1287
|
+
return { ...current.sample(), idle: false };
|
|
1288
|
+
const freq = new Uint8Array(bins);
|
|
1289
|
+
const wave = new Uint8Array(bins * 2);
|
|
1290
|
+
for (let i = 0; i < bins; i++)
|
|
1291
|
+
freq[i] = 70 + 50 * Math.sin(t * 2 + i * 0.35) * Math.cos(t * 0.7 + i * 0.11) * (1 - i / bins);
|
|
1292
|
+
for (let i = 0; i < wave.length; i++)
|
|
1293
|
+
wave[i] = 128 + 22 * Math.sin(t * 3 + i * 0.12);
|
|
1294
|
+
return { level: 0.25 + 0.1 * Math.sin(t * 2), bass: 0.35 + 0.2 * Math.sin(t * 2.4), freq, wave, idle: true };
|
|
1295
|
+
}
|
|
1296
|
+
const audioFx = (name, description, defaults, spec) => ({
|
|
1297
|
+
name,
|
|
1298
|
+
kind: 'background',
|
|
1299
|
+
description,
|
|
1300
|
+
reduced: 'skip',
|
|
1301
|
+
defaults: { colors: PALETTE, background: '#0b0d12', speed: 1, quality: 1, ...defaults },
|
|
1302
|
+
run: (el, o, ctx) => canvasBackground(el, ctx, spec, o),
|
|
1303
|
+
});
|
|
1304
|
+
const AUDIO_FX = [
|
|
1305
|
+
audioFx('spectrum-bars', 'Frequency bars dance to the audio (mirror them with `mirror: true`).', { bars: 48, gap: 2, mirror: false }, {
|
|
1306
|
+
draw: ({ ctx, w, h, t, o }) => {
|
|
1307
|
+
ctx.fillStyle = o.background;
|
|
1308
|
+
ctx.fillRect(0, 0, w, h);
|
|
1309
|
+
const s = read(t, o.bars);
|
|
1310
|
+
const per = Math.max(1, Math.floor((s.freq.length * 0.7) / o.bars));
|
|
1311
|
+
const bw = w / o.bars;
|
|
1312
|
+
for (let i = 0; i < o.bars; i++) {
|
|
1313
|
+
let v = 0;
|
|
1314
|
+
for (let k = 0; k < per; k++)
|
|
1315
|
+
v += s.freq[Math.min(s.freq.length - 1, i * per + k)];
|
|
1316
|
+
const bh = (v / per / 255) * h * (o.mirror ? 0.5 : 0.9);
|
|
1317
|
+
ctx.fillStyle = o.colors[i % o.colors.length];
|
|
1318
|
+
ctx.fillRect(i * bw + o.gap / 2, o.mirror ? h / 2 - bh : h - bh, Math.max(1, bw - o.gap), o.mirror ? bh * 2 : bh);
|
|
1319
|
+
}
|
|
1320
|
+
},
|
|
1321
|
+
}),
|
|
1322
|
+
audioFx('pulse-ring', 'Glowing rings pulse with the bass.', { rings: 3, color: '#7c5cff' }, {
|
|
1323
|
+
draw: ({ ctx, w, h, t, o }) => {
|
|
1324
|
+
ctx.fillStyle = o.background;
|
|
1325
|
+
ctx.fillRect(0, 0, w, h);
|
|
1326
|
+
const s = read(t, 32);
|
|
1327
|
+
const [r, g, b] = hexRgb(o.color);
|
|
1328
|
+
const base = Math.min(w, h) * 0.18;
|
|
1329
|
+
for (let i = 0; i < o.rings; i++) {
|
|
1330
|
+
const rad = base * (1 + i * 0.55) * (1 + s.bass * 0.6);
|
|
1331
|
+
ctx.strokeStyle = `rgba(${r},${g},${b},${(0.9 - i * 0.25) * (0.4 + s.level)})`;
|
|
1332
|
+
ctx.lineWidth = 3 + s.bass * 10 - i;
|
|
1333
|
+
ctx.beginPath();
|
|
1334
|
+
ctx.arc(w / 2, h / 2, rad, 0, Math.PI * 2);
|
|
1335
|
+
ctx.stroke();
|
|
1336
|
+
}
|
|
1337
|
+
},
|
|
1338
|
+
}),
|
|
1339
|
+
audioFx('wave-ring', 'The live waveform wrapped into a circle.', { color: '#22d3ee', amplitude: 0.35 }, {
|
|
1340
|
+
draw: ({ ctx, w, h, t, o }) => {
|
|
1341
|
+
ctx.fillStyle = o.background;
|
|
1342
|
+
ctx.fillRect(0, 0, w, h);
|
|
1343
|
+
const s = read(t, 64);
|
|
1344
|
+
const n = s.wave.length;
|
|
1345
|
+
const base = Math.min(w, h) * 0.28;
|
|
1346
|
+
ctx.strokeStyle = o.color;
|
|
1347
|
+
ctx.lineWidth = 2;
|
|
1348
|
+
ctx.beginPath();
|
|
1349
|
+
for (let i = 0; i <= n; i++) {
|
|
1350
|
+
const a = (i / n) * Math.PI * 2;
|
|
1351
|
+
const rad = base * (1 + ((s.wave[i % n] - 128) / 128) * o.amplitude * 2);
|
|
1352
|
+
const x = w / 2 + Math.cos(a) * rad;
|
|
1353
|
+
const y = h / 2 + Math.sin(a) * rad;
|
|
1354
|
+
if (i)
|
|
1355
|
+
ctx.lineTo(x, y);
|
|
1356
|
+
else
|
|
1357
|
+
ctx.moveTo(x, y);
|
|
1358
|
+
}
|
|
1359
|
+
ctx.closePath();
|
|
1360
|
+
ctx.stroke();
|
|
1361
|
+
},
|
|
1362
|
+
}),
|
|
1363
|
+
];
|
|
1364
|
+
/**
|
|
1365
|
+
* `<usa-audio source="#track | mic" label="…">` — a toggle button (yours, as
|
|
1366
|
+
* `[data-audio-toggle]`, or one it renders) that enables the audio source on
|
|
1367
|
+
* click; children with `data-usa-beat="effect"` play that effect on every
|
|
1368
|
+
* beat (`data-usa-beat-options` JSON; `threshold` / `cooldown` attributes).
|
|
1369
|
+
* Emits `usa-beat` and `usa-audio-error`.
|
|
1370
|
+
*/
|
|
1371
|
+
function defineAudio(tag = 'usa-audio') {
|
|
1372
|
+
return defineElement(tag, (Base) => class UsaAudio extends Base {
|
|
1373
|
+
constructor() {
|
|
1374
|
+
super(...arguments);
|
|
1375
|
+
this.active = false;
|
|
1376
|
+
this.audio = null;
|
|
1377
|
+
this.off = null;
|
|
1378
|
+
}
|
|
1379
|
+
static get observedAttributes() {
|
|
1380
|
+
return ['source'];
|
|
1381
|
+
}
|
|
1382
|
+
button() {
|
|
1383
|
+
let b = this.querySelector('[data-audio-toggle]');
|
|
1384
|
+
if (!b) {
|
|
1385
|
+
b = document.createElement('button');
|
|
1386
|
+
b.type = 'button';
|
|
1387
|
+
b.setAttribute('data-audio-toggle', '');
|
|
1388
|
+
b.textContent = this.str('label', '🔊 Enable sound-reactive effects');
|
|
1389
|
+
this.prepend(b);
|
|
1390
|
+
this.onCleanup(() => b.remove());
|
|
1391
|
+
}
|
|
1392
|
+
return b;
|
|
1393
|
+
}
|
|
1394
|
+
async toggle() {
|
|
1395
|
+
const b = this.button();
|
|
1396
|
+
if (this.active)
|
|
1397
|
+
return this.stop();
|
|
1398
|
+
try {
|
|
1399
|
+
this.audio = await enableAudio(this.str('source', 'mic'));
|
|
1400
|
+
this.active = true;
|
|
1401
|
+
this.removeAttribute('data-audio-error');
|
|
1402
|
+
b.setAttribute('aria-pressed', 'true');
|
|
1403
|
+
this.off = onBeat((d) => {
|
|
1404
|
+
this.dispatchEvent(new CustomEvent('usa-beat', { detail: d, bubbles: true }));
|
|
1405
|
+
if (prefersReducedMotion())
|
|
1406
|
+
return;
|
|
1407
|
+
this.querySelectorAll('[data-usa-beat]').forEach((el) => {
|
|
1408
|
+
let opts = {};
|
|
1409
|
+
try {
|
|
1410
|
+
opts = JSON.parse(el.dataset.usaBeatOptions || '{}') || {};
|
|
1411
|
+
}
|
|
1412
|
+
catch {
|
|
1413
|
+
/* ignore bad JSON */
|
|
1414
|
+
}
|
|
1415
|
+
playEffect(el, el.dataset.usaBeat || 'pulse', opts).catch(() => undefined);
|
|
1416
|
+
});
|
|
1417
|
+
}, { threshold: this.num('threshold', 1.35), cooldown: this.num('cooldown', 250) });
|
|
1418
|
+
}
|
|
1419
|
+
catch (err) {
|
|
1420
|
+
this.setAttribute('data-audio-error', '');
|
|
1421
|
+
this.dispatchEvent(new CustomEvent('usa-audio-error', { detail: { error: err }, bubbles: true }));
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
stop() {
|
|
1425
|
+
this.off?.();
|
|
1426
|
+
this.off = null;
|
|
1427
|
+
if (this.audio && current === this.audio)
|
|
1428
|
+
this.audio.stop();
|
|
1429
|
+
this.audio = null;
|
|
1430
|
+
this.active = false;
|
|
1431
|
+
this.querySelector('[data-audio-toggle]')?.setAttribute('aria-pressed', 'false');
|
|
1432
|
+
}
|
|
1433
|
+
mount() {
|
|
1434
|
+
const b = this.button();
|
|
1435
|
+
b.setAttribute('aria-pressed', 'false');
|
|
1436
|
+
this.listen(b, 'click', () => void this.toggle());
|
|
1437
|
+
this.onCleanup(() => this.stop());
|
|
1438
|
+
}
|
|
1439
|
+
}, { id: 'usa-audio', text: 'usa-audio{display:block}usa-audio[data-audio-error] [data-audio-toggle]{outline:2px solid #ff5c8a}' });
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1116
1442
|
/**
|
|
1117
1443
|
* use-scroll-animate/components/effects — the 5.x effect packs, all
|
|
1118
1444
|
* registered through `registerEffect()` (5.0) and playable with
|
|
@@ -1131,6 +1457,7 @@ const EFFECT_PACKS = {
|
|
|
1131
1457
|
physics: PHYSICS_FX,
|
|
1132
1458
|
page: PAGE_FX,
|
|
1133
1459
|
generative: GENERATIVE_FX,
|
|
1460
|
+
audio: AUDIO_FX,
|
|
1134
1461
|
};
|
|
1135
1462
|
/** 5.1: card & click effects 2.0. */
|
|
1136
1463
|
function registerCardClickEffects() {
|
|
@@ -1148,9 +1475,14 @@ function registerPageEffects() {
|
|
|
1148
1475
|
function registerGenerativeEffects() {
|
|
1149
1476
|
registerEffects(EFFECT_PACKS.generative);
|
|
1150
1477
|
}
|
|
1478
|
+
/** 5.6: sound-reactive (Web Audio) backgrounds. */
|
|
1479
|
+
function registerAudioEffects() {
|
|
1480
|
+
registerEffects(EFFECT_PACKS.audio);
|
|
1481
|
+
}
|
|
1151
1482
|
/** Define the 5.x elements of this entry (`<usa-story>`, …) under their default tags. */
|
|
1152
1483
|
function defineEffectElements() {
|
|
1153
1484
|
defineStory();
|
|
1485
|
+
defineAudio();
|
|
1154
1486
|
}
|
|
1155
1487
|
/** Register the built-ins and every pack (idempotent). */
|
|
1156
1488
|
function registerAllEffects() {
|
|
@@ -1159,5 +1491,5 @@ function registerAllEffects() {
|
|
|
1159
1491
|
registerEffects(defs);
|
|
1160
1492
|
}
|
|
1161
1493
|
|
|
1162
|
-
export { CARD_FX, CLICK_FX, EFFECT_PACKS, GENERATIVE_FX, PAGE_FX, PHYSICS_FX, STORY_TEMPLATES, bounceKeyframes, canvasBackground, defineEffectElements, defineStory, formatCount, fxLayer, hexRgb, noise2, registerAllEffects, registerCardClickEffects, registerGenerativeEffects, registerPageEffects, registerPhysicsEffects, solveSpring, springKeyframes, storyProgress };
|
|
1494
|
+
export { AUDIO_FX, CARD_FX, CLICK_FX, EFFECT_PACKS, GENERATIVE_FX, PAGE_FX, PHYSICS_FX, STORY_TEMPLATES, bindBeat, bounceKeyframes, canvasBackground, createBeatDetector, defineAudio, defineEffectElements, defineStory, disableAudio, enableAudio, formatCount, fxLayer, getAudio, hexRgb, noise2, onBeat, registerAllEffects, registerAudioEffects, registerCardClickEffects, registerGenerativeEffects, registerPageEffects, registerPhysicsEffects, solveSpring, springKeyframes, storyProgress };
|
|
1163
1495
|
//# sourceMappingURL=effects.js.map
|