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 CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.6.0] - 2026-10-08
11
+
12
+ ### Added
13
+ - **Sound-reactive pack** (`use-scroll-animate/components/effects`, `registerAudioEffects()`, `AUDIO_FX`, kind `background`): `spectrum-bars` (`bars`, `gap`, `mirror`), `pulse-ring` (`rings`, `color`), `wave-ring` (`color`, `amplitude`) — Canvas 2D on the 5.5 `canvasBackground()` runner (visible-only, adaptive quality); they idle gently until audio is enabled.
14
+ - `enableAudio(input)` — analyse the microphone (`'mic'`), an `<audio>` / `<video>` element or selector, or a `MediaStream` through one shared Web Audio analyser. Must be called from a user gesture (the context is resumed there). The microphone is never routed to the speakers and is released on `stop()`; media stays audible after `stop()`. Also `disableAudio()`, `getAudio()`, `sample()` → `{ level, bass, freq, wave }`.
15
+ - Beat detection: `createBeatDetector({ threshold, cooldown, history, floor })` (pure), `onBeat(cb)`, and `bindBeat(el, effect, options)` — plays **any registered effect** on every beat.
16
+ - `<usa-audio source="#track | mic" label="…">` — renders (or uses your `[data-audio-toggle]`) an `aria-pressed` toggle button; children with `data-usa-beat="effect"` (`data-usa-beat-options` JSON) play that effect on beats; events `usa-beat`, `usa-audio-error` (`data-audio-error` on the host).
17
+ - While audio runs, `--usa-audio-level` / `--usa-audio-bass` (0–1) are set on `<html>` for CSS-driven reactions.
18
+ - Showcase: **Sound-reactive backgrounds** and **Beat-triggered effects** cards.
19
+
20
+ ### Accessibility
21
+ - Audio only starts from a user gesture. Under reduced motion the visual effects are skipped, beats trigger no effects and the CSS variables stay at 0 (sound keeps playing).
22
+
10
23
  ## [5.5.0] - 2026-10-08
11
24
 
12
25
  ### Added
@@ -1115,6 +1115,332 @@ const GENERATIVE_FX = [
1115
1115
  }),
1116
1116
  ];
1117
1117
 
1118
+ let actx = null;
1119
+ let current = null;
1120
+ const mediaSources = new WeakMap();
1121
+ /** The running analyser, if `enableAudio()` was called. */
1122
+ const getAudio = () => current;
1123
+ function audioContext() {
1124
+ const AC = globalThis.AudioContext || globalThis.webkitAudioContext;
1125
+ if (!AC)
1126
+ throw new Error('[use-scroll-animate] Web Audio is not available');
1127
+ if (!actx || actx.state === 'closed')
1128
+ actx = new AC();
1129
+ return actx;
1130
+ }
1131
+ /**
1132
+ * Start analysing `input` and make it the source of every sound-reactive
1133
+ * effect. Call from a user gesture. Replaces a previous source.
1134
+ */
1135
+ async function enableAudio(input = 'mic', opts = {}) {
1136
+ current?.stop();
1137
+ const ac = audioContext();
1138
+ if (ac.state === 'suspended')
1139
+ await ac.resume().catch(() => undefined);
1140
+ const analyser = ac.createAnalyser();
1141
+ analyser.fftSize = opts.fftSize || 512;
1142
+ analyser.smoothingTimeConstant = opts.smoothing ?? 0.8;
1143
+ let src;
1144
+ let release = () => { };
1145
+ const media = typeof input === 'string' && input !== 'mic' ? document.querySelector(input) : input;
1146
+ if (media === 'mic') {
1147
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1148
+ src = ac.createMediaStreamSource(stream);
1149
+ src.connect(analyser); // never to the speakers: no feedback
1150
+ release = () => stream.getTracks().forEach((t) => t.stop());
1151
+ }
1152
+ else if (media && typeof media.play === 'function') {
1153
+ const m = media;
1154
+ let node = mediaSources.get(m);
1155
+ if (!node)
1156
+ mediaSources.set(m, (node = ac.createMediaElementSource(m)));
1157
+ try {
1158
+ node.disconnect();
1159
+ }
1160
+ catch {
1161
+ /* not connected */
1162
+ }
1163
+ src = node;
1164
+ src.connect(analyser);
1165
+ analyser.connect(ac.destination);
1166
+ release = () => {
1167
+ try {
1168
+ node.disconnect();
1169
+ }
1170
+ catch {
1171
+ /* already */
1172
+ }
1173
+ node.connect(ac.destination); // keep the media audible
1174
+ };
1175
+ if (m.paused)
1176
+ await m.play()?.catch?.(() => undefined);
1177
+ }
1178
+ else if (media && typeof media.getTracks === 'function') {
1179
+ src = ac.createMediaStreamSource(media);
1180
+ src.connect(analyser);
1181
+ }
1182
+ else
1183
+ throw new Error(`[use-scroll-animate] enableAudio: no audio source for ${String(input)}`);
1184
+ const freq = new Uint8Array(analyser.frequencyBinCount);
1185
+ const wave = new Uint8Array(analyser.fftSize);
1186
+ const self = {
1187
+ context: ac,
1188
+ analyser,
1189
+ sample: () => {
1190
+ analyser.getByteFrequencyData(freq);
1191
+ analyser.getByteTimeDomainData(wave);
1192
+ let sq = 0;
1193
+ for (let i = 0; i < wave.length; i++)
1194
+ sq += ((wave[i] - 128) / 128) ** 2;
1195
+ const n = Math.max(1, Math.round(freq.length * 0.08));
1196
+ let b = 0;
1197
+ for (let i = 0; i < n; i++)
1198
+ b += freq[i];
1199
+ return { level: Math.min(1, Math.sqrt(sq / wave.length) * 2), bass: b / n / 255, freq, wave };
1200
+ },
1201
+ stop: () => {
1202
+ if (current === self) {
1203
+ current = null;
1204
+ if (raf && typeof cancelAnimationFrame === 'function')
1205
+ cancelAnimationFrame(raf);
1206
+ raf = 0;
1207
+ }
1208
+ try {
1209
+ src.disconnect();
1210
+ analyser.disconnect();
1211
+ }
1212
+ catch {
1213
+ /* already */
1214
+ }
1215
+ release();
1216
+ setVars(0, 0);
1217
+ },
1218
+ };
1219
+ current = self;
1220
+ kick();
1221
+ return self;
1222
+ }
1223
+ /** Stop the current audio source (if any). */
1224
+ function disableAudio() {
1225
+ current?.stop();
1226
+ }
1227
+ /** A pure beat detector: feed it energy (0–1) and a timestamp per frame; it answers "beat?". */
1228
+ function createBeatDetector(o = {}) {
1229
+ const { threshold = 1.35, cooldown = 250, history = 43, floor = 0.08 } = o;
1230
+ const hist = [];
1231
+ let last = -Infinity;
1232
+ return (energy, now) => {
1233
+ const avg = hist.length ? hist.reduce((a, b) => a + b, 0) / hist.length : energy;
1234
+ const ready = hist.length >= 8;
1235
+ hist.push(energy);
1236
+ if (hist.length > history)
1237
+ hist.shift();
1238
+ if (ready && energy > floor && energy > avg * threshold && now - last >= cooldown) {
1239
+ last = now;
1240
+ return true;
1241
+ }
1242
+ return false;
1243
+ };
1244
+ }
1245
+ const listeners = new Set();
1246
+ let raf = 0;
1247
+ function setVars(level, bass) {
1248
+ if (typeof document === 'undefined')
1249
+ return;
1250
+ const s = document.documentElement.style;
1251
+ s.setProperty('--usa-audio-level', level.toFixed(3));
1252
+ s.setProperty('--usa-audio-bass', bass.toFixed(3));
1253
+ }
1254
+ function loop(now) {
1255
+ raf = 0;
1256
+ if (!current)
1257
+ return;
1258
+ const { level, bass } = current.sample();
1259
+ const reduced = base.prefersReducedMotion();
1260
+ setVars(reduced ? 0 : level, reduced ? 0 : bass);
1261
+ for (const l of Array.from(listeners))
1262
+ if (l.detect(bass, now))
1263
+ l.cb({ energy: bass, time: now });
1264
+ kick();
1265
+ }
1266
+ function kick() {
1267
+ if (!raf && current && typeof requestAnimationFrame === 'function')
1268
+ raf = requestAnimationFrame(loop);
1269
+ }
1270
+ /** Call `cb` on every detected beat of the current audio source. Returns an unsubscribe. */
1271
+ function onBeat(cb, o = {}) {
1272
+ const l = { detect: createBeatDetector(o), cb };
1273
+ listeners.add(l);
1274
+ kick();
1275
+ return () => void listeners.delete(l);
1276
+ }
1277
+ /** Play the registered effect `name` on `el` at every beat (not under reduced motion). Returns an unbind. */
1278
+ function bindBeat(el, name, options = {}) {
1279
+ const { threshold, cooldown, history, floor, ...fx } = options;
1280
+ return onBeat(() => {
1281
+ if (!base.prefersReducedMotion())
1282
+ components_fx.playEffect(el, name, fx).catch(() => undefined);
1283
+ }, { threshold, cooldown, history, floor });
1284
+ }
1285
+ // --- visual effects ----------------------------------------------------------
1286
+ /** The current sample, or a gentle synthetic one while no audio is enabled. */
1287
+ function read(t, bins) {
1288
+ if (current)
1289
+ return { ...current.sample(), idle: false };
1290
+ const freq = new Uint8Array(bins);
1291
+ const wave = new Uint8Array(bins * 2);
1292
+ for (let i = 0; i < bins; i++)
1293
+ freq[i] = 70 + 50 * Math.sin(t * 2 + i * 0.35) * Math.cos(t * 0.7 + i * 0.11) * (1 - i / bins);
1294
+ for (let i = 0; i < wave.length; i++)
1295
+ wave[i] = 128 + 22 * Math.sin(t * 3 + i * 0.12);
1296
+ return { level: 0.25 + 0.1 * Math.sin(t * 2), bass: 0.35 + 0.2 * Math.sin(t * 2.4), freq, wave, idle: true };
1297
+ }
1298
+ const audioFx = (name, description, defaults, spec) => ({
1299
+ name,
1300
+ kind: 'background',
1301
+ description,
1302
+ reduced: 'skip',
1303
+ defaults: { colors: PALETTE, background: '#0b0d12', speed: 1, quality: 1, ...defaults },
1304
+ run: (el, o, ctx) => canvasBackground(el, ctx, spec, o),
1305
+ });
1306
+ const AUDIO_FX = [
1307
+ audioFx('spectrum-bars', 'Frequency bars dance to the audio (mirror them with `mirror: true`).', { bars: 48, gap: 2, mirror: false }, {
1308
+ draw: ({ ctx, w, h, t, o }) => {
1309
+ ctx.fillStyle = o.background;
1310
+ ctx.fillRect(0, 0, w, h);
1311
+ const s = read(t, o.bars);
1312
+ const per = Math.max(1, Math.floor((s.freq.length * 0.7) / o.bars));
1313
+ const bw = w / o.bars;
1314
+ for (let i = 0; i < o.bars; i++) {
1315
+ let v = 0;
1316
+ for (let k = 0; k < per; k++)
1317
+ v += s.freq[Math.min(s.freq.length - 1, i * per + k)];
1318
+ const bh = (v / per / 255) * h * (o.mirror ? 0.5 : 0.9);
1319
+ ctx.fillStyle = o.colors[i % o.colors.length];
1320
+ 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);
1321
+ }
1322
+ },
1323
+ }),
1324
+ audioFx('pulse-ring', 'Glowing rings pulse with the bass.', { rings: 3, color: '#7c5cff' }, {
1325
+ draw: ({ ctx, w, h, t, o }) => {
1326
+ ctx.fillStyle = o.background;
1327
+ ctx.fillRect(0, 0, w, h);
1328
+ const s = read(t, 32);
1329
+ const [r, g, b] = hexRgb(o.color);
1330
+ const base = Math.min(w, h) * 0.18;
1331
+ for (let i = 0; i < o.rings; i++) {
1332
+ const rad = base * (1 + i * 0.55) * (1 + s.bass * 0.6);
1333
+ ctx.strokeStyle = `rgba(${r},${g},${b},${(0.9 - i * 0.25) * (0.4 + s.level)})`;
1334
+ ctx.lineWidth = 3 + s.bass * 10 - i;
1335
+ ctx.beginPath();
1336
+ ctx.arc(w / 2, h / 2, rad, 0, Math.PI * 2);
1337
+ ctx.stroke();
1338
+ }
1339
+ },
1340
+ }),
1341
+ audioFx('wave-ring', 'The live waveform wrapped into a circle.', { color: '#22d3ee', amplitude: 0.35 }, {
1342
+ draw: ({ ctx, w, h, t, o }) => {
1343
+ ctx.fillStyle = o.background;
1344
+ ctx.fillRect(0, 0, w, h);
1345
+ const s = read(t, 64);
1346
+ const n = s.wave.length;
1347
+ const base = Math.min(w, h) * 0.28;
1348
+ ctx.strokeStyle = o.color;
1349
+ ctx.lineWidth = 2;
1350
+ ctx.beginPath();
1351
+ for (let i = 0; i <= n; i++) {
1352
+ const a = (i / n) * Math.PI * 2;
1353
+ const rad = base * (1 + ((s.wave[i % n] - 128) / 128) * o.amplitude * 2);
1354
+ const x = w / 2 + Math.cos(a) * rad;
1355
+ const y = h / 2 + Math.sin(a) * rad;
1356
+ if (i)
1357
+ ctx.lineTo(x, y);
1358
+ else
1359
+ ctx.moveTo(x, y);
1360
+ }
1361
+ ctx.closePath();
1362
+ ctx.stroke();
1363
+ },
1364
+ }),
1365
+ ];
1366
+ /**
1367
+ * `<usa-audio source="#track | mic" label="…">` — a toggle button (yours, as
1368
+ * `[data-audio-toggle]`, or one it renders) that enables the audio source on
1369
+ * click; children with `data-usa-beat="effect"` play that effect on every
1370
+ * beat (`data-usa-beat-options` JSON; `threshold` / `cooldown` attributes).
1371
+ * Emits `usa-beat` and `usa-audio-error`.
1372
+ */
1373
+ function defineAudio(tag = 'usa-audio') {
1374
+ return base.defineElement(tag, (Base) => class UsaAudio extends Base {
1375
+ constructor() {
1376
+ super(...arguments);
1377
+ this.active = false;
1378
+ this.audio = null;
1379
+ this.off = null;
1380
+ }
1381
+ static get observedAttributes() {
1382
+ return ['source'];
1383
+ }
1384
+ button() {
1385
+ let b = this.querySelector('[data-audio-toggle]');
1386
+ if (!b) {
1387
+ b = document.createElement('button');
1388
+ b.type = 'button';
1389
+ b.setAttribute('data-audio-toggle', '');
1390
+ b.textContent = this.str('label', '🔊 Enable sound-reactive effects');
1391
+ this.prepend(b);
1392
+ this.onCleanup(() => b.remove());
1393
+ }
1394
+ return b;
1395
+ }
1396
+ async toggle() {
1397
+ const b = this.button();
1398
+ if (this.active)
1399
+ return this.stop();
1400
+ try {
1401
+ this.audio = await enableAudio(this.str('source', 'mic'));
1402
+ this.active = true;
1403
+ this.removeAttribute('data-audio-error');
1404
+ b.setAttribute('aria-pressed', 'true');
1405
+ this.off = onBeat((d) => {
1406
+ this.dispatchEvent(new CustomEvent('usa-beat', { detail: d, bubbles: true }));
1407
+ if (base.prefersReducedMotion())
1408
+ return;
1409
+ this.querySelectorAll('[data-usa-beat]').forEach((el) => {
1410
+ let opts = {};
1411
+ try {
1412
+ opts = JSON.parse(el.dataset.usaBeatOptions || '{}') || {};
1413
+ }
1414
+ catch {
1415
+ /* ignore bad JSON */
1416
+ }
1417
+ components_fx.playEffect(el, el.dataset.usaBeat || 'pulse', opts).catch(() => undefined);
1418
+ });
1419
+ }, { threshold: this.num('threshold', 1.35), cooldown: this.num('cooldown', 250) });
1420
+ }
1421
+ catch (err) {
1422
+ this.setAttribute('data-audio-error', '');
1423
+ this.dispatchEvent(new CustomEvent('usa-audio-error', { detail: { error: err }, bubbles: true }));
1424
+ }
1425
+ }
1426
+ stop() {
1427
+ this.off?.();
1428
+ this.off = null;
1429
+ if (this.audio && current === this.audio)
1430
+ this.audio.stop();
1431
+ this.audio = null;
1432
+ this.active = false;
1433
+ this.querySelector('[data-audio-toggle]')?.setAttribute('aria-pressed', 'false');
1434
+ }
1435
+ mount() {
1436
+ const b = this.button();
1437
+ b.setAttribute('aria-pressed', 'false');
1438
+ this.listen(b, 'click', () => void this.toggle());
1439
+ this.onCleanup(() => this.stop());
1440
+ }
1441
+ }, { id: 'usa-audio', text: 'usa-audio{display:block}usa-audio[data-audio-error] [data-audio-toggle]{outline:2px solid #ff5c8a}' });
1442
+ }
1443
+
1118
1444
  /**
1119
1445
  * use-scroll-animate/components/effects — the 5.x effect packs, all
1120
1446
  * registered through `registerEffect()` (5.0) and playable with
@@ -1133,6 +1459,7 @@ const EFFECT_PACKS = {
1133
1459
  physics: PHYSICS_FX,
1134
1460
  page: PAGE_FX,
1135
1461
  generative: GENERATIVE_FX,
1462
+ audio: AUDIO_FX,
1136
1463
  };
1137
1464
  /** 5.1: card & click effects 2.0. */
1138
1465
  function registerCardClickEffects() {
@@ -1150,9 +1477,14 @@ function registerPageEffects() {
1150
1477
  function registerGenerativeEffects() {
1151
1478
  components_fx.registerEffects(EFFECT_PACKS.generative);
1152
1479
  }
1480
+ /** 5.6: sound-reactive (Web Audio) backgrounds. */
1481
+ function registerAudioEffects() {
1482
+ components_fx.registerEffects(EFFECT_PACKS.audio);
1483
+ }
1153
1484
  /** Define the 5.x elements of this entry (`<usa-story>`, …) under their default tags. */
1154
1485
  function defineEffectElements() {
1155
1486
  defineStory();
1487
+ defineAudio();
1156
1488
  }
1157
1489
  /** Register the built-ins and every pack (idempotent). */
1158
1490
  function registerAllEffects() {
@@ -1161,6 +1493,7 @@ function registerAllEffects() {
1161
1493
  components_fx.registerEffects(defs);
1162
1494
  }
1163
1495
 
1496
+ exports.AUDIO_FX = AUDIO_FX;
1164
1497
  exports.CARD_FX = CARD_FX;
1165
1498
  exports.CLICK_FX = CLICK_FX;
1166
1499
  exports.EFFECT_PACKS = EFFECT_PACKS;
@@ -1168,15 +1501,23 @@ exports.GENERATIVE_FX = GENERATIVE_FX;
1168
1501
  exports.PAGE_FX = PAGE_FX;
1169
1502
  exports.PHYSICS_FX = PHYSICS_FX;
1170
1503
  exports.STORY_TEMPLATES = STORY_TEMPLATES;
1504
+ exports.bindBeat = bindBeat;
1171
1505
  exports.bounceKeyframes = bounceKeyframes;
1172
1506
  exports.canvasBackground = canvasBackground;
1507
+ exports.createBeatDetector = createBeatDetector;
1508
+ exports.defineAudio = defineAudio;
1173
1509
  exports.defineEffectElements = defineEffectElements;
1174
1510
  exports.defineStory = defineStory;
1511
+ exports.disableAudio = disableAudio;
1512
+ exports.enableAudio = enableAudio;
1175
1513
  exports.formatCount = formatCount;
1176
1514
  exports.fxLayer = fxLayer;
1515
+ exports.getAudio = getAudio;
1177
1516
  exports.hexRgb = hexRgb;
1178
1517
  exports.noise2 = noise2;
1518
+ exports.onBeat = onBeat;
1179
1519
  exports.registerAllEffects = registerAllEffects;
1520
+ exports.registerAudioEffects = registerAudioEffects;
1180
1521
  exports.registerCardClickEffects = registerCardClickEffects;
1181
1522
  exports.registerGenerativeEffects = registerGenerativeEffects;
1182
1523
  exports.registerPageEffects = registerPageEffects;