Architecture
This page describes the Pue JS runtime from the moment a scroll event is observed to the moment an emission reaches the audio output. Understanding the pipeline is not required to use the library, but it will help you make informed tuning decisions.
// The emission pipeline, conceptuallyscroll → sample → kinematics → tonnage → odor → voice → destinationEmitters
An emitter is the unit of composition in Pue JS. It owns:
- a binding to one scroll container (
windowor anElement), - an
AudioContext, either private or shared through theaudioContextoption, - a resolved acoustic profile,
- a lifecycle state.
Multiple emitters can coexist on a page — for instance, one per scrollable panel. When they share an AudioContext, each odor is decoded once and their output is mixed by the browser.
Lifecycle
| State | Meaning |
|---|---|
idle | Created or stopped. No listeners are attached. |
suspended | Observing, but the AudioContext is waiting for a user gesture. |
running | Observing and emitting. |
destroyed | All listeners and audio nodes released. Further calls throw E_DESTROYED. |
Transitions are reported through the statechange event.
Scroll sampling
Pue JS never performs work inside the scroll event itself. A passive listener flags the container as dirty; the position is read once per animation frame. The frame loop stops by itself 150 ms after the last movement.
Velocity
Instantaneous velocity is expressed in pixels per second and smoothed with a low-pass filter to remove trackpad jitter. Scrolls below the threshold (80 px/s by default) are considered incidental and produce no emission. Frames without movement never emit.
Throttling
The throttle option controls how frequently emissions may be triggered:
| Strategy | Behaviour |
|---|---|
"velocity" | The minimum interval shortens from 260 ms to 60 ms as tonnage increases. Default. |
"distance" | One emission every half viewport height scrolled. |
number | A fixed minimum interval, in milliseconds. |
Tonnage
Tonnage is the perceived intensity of an emission, normalized between 0 and 1. Velocity is first normalized between the threshold and 3000 px/s, then passed through a tonnage curve. Tonnage drives gain and odor selection.
Three built-in curves are provided — "linear", "logarithmic" (default) and "exponential". The logarithmic curve matches the human perception of loudness and keeps slow reading discreet while preserving headroom for aggressive flicks.
Custom curves are plain functions of the velocity:
import { createEmitter, defineTonnageCurve } from "@puejs/core"; // Silent below 200 px/s, saturates at 3000 px/sconst gated = defineTonnageCurve((velocity) => { if (velocity < 200) return 0; return Math.min(1, (velocity - 200) / 2800);}); createEmitter(window, { tonnage: gated });Acoustic profiles
An acoustic profile is the full set of parameters that shape an emission: odors, pitch range, resonance, duration, volume and tonnage curve. The presets export provides four calibrated profiles:
| Preset | Character |
|---|---|
presets.organic | Balanced, warm, moderate resonance. The default. |
presets.crisp | Short, bright transients for dense, content-heavy layouts. |
presets.deep | Low pitch, long body. Suited to immersive storytelling. |
presets.discreet | Reduced gain and duration for productivity interfaces. |
Options passed alongside preset are merged over it, so a preset can be refined without being redefined.
Odors
Odors are the samples an emitter plays. Built-in odors live in their own ES modules under @puejs/core/odors and inline their audio, so there is nothing to host.
import { createEmitter } from "@puejs/core";import { staccato, sustained } from "@puejs/core/odors"; createEmitter(window, { odors: [staccato, sustained] });When several odors are available, the runtime selects one per emission based on tonnage. Where ranges overlap, the choice is drawn from the emitter's seeded random source.
| Odor | Size (gzip) | Duration | Tonnage range | Profile | In presets |
|---|---|---|---|---|---|
staccato | 2.1 kB | 0.31 s | 0 – 0.6 | Short and dry. | Yes |
sustained | 1.7 kB | 0.21 s | 0.4 – 1 | Rounder, with more body. | Yes |
soprano | 2.3 kB | 0.37 s | 0.3 – 0.8 | High-pitched, airy release. | No |
pesante | 4.6 kB | 0.94 s | 0.5 – 1 | Heavy and dense, with a viscous texture. | No |
sforzando | 4.9 kB | 1.01 s | 0.7 – 1 | Sudden, hard accent. | No |
fortissimo | 10.9 kB | 2.52 s | 0.85 – 1 | Long and powerful. Reserved for the fastest flicks. | No |
cathedral | 9.3 kB | 2.15 s | 0.9 – 1 | Loud, with a long reverberant decay. | No |
Every odor is trimmed and peak-normalized at build time, so emissions start sounding immediately.
soprano, pesante, sforzando, fortissimo and cathedral are expressive odors: they are not referenced by any preset, so they only reach your bundle when you import them.
import { createEmitter } from "@puejs/core";import { cathedral, fortissimo, pesante, sforzando, soprano, staccato, sustained } from "@puejs/core/odors"; createEmitter(window, { odors: [staccato, sustained, soprano, pesante, sforzando, fortissimo, cathedral], // fortissimo lasts 2.5 s: raise the duration so it is not cut short. duration: 2600,});duration caps every emission. Short odors end naturally before the cap; long ones are faded out when they reach it.
Your own samples can be used with defineOdor.
Voices
Each emission is a short Web Audio graph: the sample's playback rate sets the pitch, a peaking filter around 180 Hz adds resonance, and a gain node applies the tonnage-scaled volume with a 60 ms fade-out ending at duration. At most six voices play at once; the oldest is stopped when the limit is reached.
Scheduling
Motion is detected and the emission scheduled within the same animation frame. Playback starts 5 ms later on the Web Audio clock, which absorbs scheduling jitter without being perceptible. Each emit event exposes the measured latency between detection and scheduling, so you can verify it in your own telemetry.