Removes the widgets-only Audio React Pixaroma node (PixaromaAudioReact) and renames Audio Pulse Pixaroma's display label to 'AudioReact Pixaroma'. Why: the editor-driven node fully supersedes the widgets-only one. Shipping two nodes that share an engine but differ only in UX is more confusing than helpful. Safe to delete since nothing was published with the basic node. What changed (user-facing): - 'Audio Pulse' renamed to 'AudioReact' everywhere users look (display name, button label, editor title, modal text, error messages, README, CLAUDE.md docs, parity harness). - Old basic 'Audio React Pixaroma' node removed. What stayed for compatibility: - Internal Python class name PixaromaAudioStudio (workflow class_type). - File paths js/audio_studio/, nodes/node_audio_studio.py. - Server route /pixaroma/api/audio_studio/, on-disk audio_studio/<id>/. - DOM id pixaroma-audio-studio-editor + CSS prefix pix-as-. - Engine file _audio_react_engine.py (its name happens to match the new product name now). - node.properties.audioStudioState cfg key. REQUIRES COMFYUI RESTART to pick up the deleted module + new display name.
13 KiB
Audio React / AudioReact — Math Reference
Single source of truth for every formula in the Pixaroma audio-reactive
nodes (Audio React, AudioReact). Both the Python engine
(nodes/_audio_react_engine.py) and the WebGL preview shaders
(js/audio_studio/shaders.mjs — landed in Milestone E) implement the
formulas defined here.
When you change a formula, change it here FIRST, then update both implementations and re-run the parity check.
1. Audio envelope pipeline
Input: AUDIO dict {waveform: [B, C, S] tensor, sample_rate: int},
fps: int, audio_band: str, smoothing: int.
Output: Float32Array (Python: torch.Tensor) of length total_frames = floor(audio_duration * fps), values in [0, 1].
Pipeline:
- Mono-mix: if
C > 1, average channels. - Bandpass FFT — see §3.
- Resample: split waveform into
total_framesnon-overlapping chunks ofsamples_per_frame = max(1, sample_rate // fps)samples each. If the waveform is shorter thantotal_frames * samples_per_frame, repeat-pad. - Per-frame RMS:
rms[t] = sqrt(mean(chunk_t ** 2)). - Min-max normalize:
rms = (rms - rms.min()) / (rms.max() - rms.min())(returns zeros ifrms.max() == rms.min()). - Moving-average smoothing: convolve
rmswith akernel = ones(sw) / swwhereswissmoothingrounded UP to the nearest odd integer (so the kernel is centered).replicate-pad bysw // 2on each side first. - (Loop-safe ramp — applied separately by the caller, not part of the envelope helper. See §4.)
2. Onset track
Input: envelope: Float32Array[T] in [0, 1].
Output: Float32Array[T] in [0, 1].
Algorithm:
diff[t] = max(0, env[t] - env[t-1])fort >= 1;diff[0] = 0.thresh = max(0.05, quantile(diff, 0.75)).spikes[t] = diff[t] if diff[t] > thresh else 0.- Sequential decay:
out[0] = spikes[0]; out[t] = max(spikes[t], out[t-1] * 0.85). - Peak-normalize:
out = out / out.max()(returns zeros ifout.max() == 0).
The decay rate 0.85 is hand-tuned. Faster decay (lower) makes onsets
spikier; slower (higher) makes them smear into envelopes. Don't change
without re-running parity.
3. Bandpass FFT
Input: waveform: Tensor[..., S], sample_rate: int,
low_hz: float | None, high_hz: float | None.
Output: Tensor[..., S] (real).
spec = rfft(waveform, dim=-1)
freqs = rfftfreq(S, d=1/sample_rate)
mask = ones_like(freqs)
if low_hz is not None: mask *= (freqs >= low_hz)
if high_hz is not None: mask *= (freqs <= high_hz)
spec *= mask
return irfft(spec, n=S, dim=-1)
AUDIO_BANDS_HZ:
full: (None, None) — no bandpassbass: (20, 250)mids: (250, 4000)treble: (4000, 20000)
The JS implementation must use the same boundaries. The JS FFT is a small
inlined real-FFT (radix-2 Cooley-Tukey on real input), zero-padding to the
next power-of-2; trim back to S after irfft.
4. Loop-safe ramp
If loop_safe == True AND total_frames >= 4:
fade_n = max(2, min(int(fps * 0.5), total_frames // 2)).loop_ramp = linspace(0.0, 1.0, fade_n)— note: starts AT 0.envelope[:fade_n] *= loop_ramp(envelope[0] becomes 0).envelope[-fade_n:] *= flip(loop_ramp)(envelope[-1] becomes 0).
The linspace(0, 1, fade_n) deliberately starts at 0 — that's what makes
the loop seamless (motion is fully frozen at boundaries).
5. Base sampling grid
Normalized [-1, 1] grid, [1, H, W, 2] with x-first ordering (ready for
F.grid_sample):
y, x = meshgrid(linspace(-1, 1, H), linspace(-1, 1, W), indexing="ij")
base_grid = stack([x, y], dim=-1).unsqueeze(0) # [1, H, W, 2]
6. Motion modes
All 8 motion functions accept the canonical MotionContext (or its
shader-uniform equivalent) and return a transformed sampling grid in the
same shape as base_grid. The renderer then samples the source image
through the grid (F.grid_sample in Python with mode="bilinear",
padding_mode="border", align_corners=False; texture() on a clamp-to-edge
texture in GLSL).
6.1 scale_pulse
s = env_t * intensity * 0.15
grid' = grid * (1.0 - s)
Maximum zoom: intensity=2.0, env_t=1.0 → s=0.3 → 30% zoom-in.
6.2 zoom_punch
s = onset_t * intensity * 0.30
grid' = grid * (1.0 - s)
Same shape as scale_pulse but driven by onset_t instead of env_t,
and with double the multiplier.
6.3 shake
# Pre-render: build cumulative random walk for the whole clip
gen = Generator().manual_seed(0)
dx_raw = randn(total_frames, gen=gen) * onset_array
dy_raw = randn(total_frames, gen=gen) * onset_array
decay = 0.7
dx[0] = dx_raw[0]; dy[0] = dy_raw[0]
dx[k] = dx[k-1] * decay + dx_raw[k] * (1 - decay)
dy[k] = dy[k-1] * decay + dy_raw[k] * (1 - decay)
# Per-frame:
amp = intensity * 0.04
grid' = grid - (dx[i] * amp, dy[i] * amp)
Approximation note (shader): the WebGL preview cannot bit-match this —
torch Generator(seed=0) produces a specific sequence that JS cannot
reproduce, and float-precision drift over hundreds of frames diverges
trajectories. Shader uses a deterministic same-seeded JS RNG (e.g.
mulberry32(0)) approximating the same characteristic motion. Final
MP4 output is authoritative; preview is approximate for shake only.
6.4 drift
sway = sin(2π * motion_speed * t)
bob = cos(2π * motion_speed * t)
amp = env_t * intensity * 0.04
grid' = grid - (sway * amp, bob * amp)
t in seconds since clip start.
6.5 rotate_pulse
aspect = W / H
sway = sin(2π * motion_speed * t)
angle = sway * env_t * intensity * (π / 12) # max ±15°
xs = grid[..., 0] * aspect
ys = grid[..., 1]
new_x = (xs * cos(angle) - ys * sin(angle)) / aspect
new_y = xs * sin(angle) + ys * cos(angle)
Aspect correction prevents non-square frames from producing visually elliptical rotation.
6.6 swirl
aspect = W / H
xs = grid[..., 0] * aspect
ys = grid[..., 1]
r = sqrt(xs² + ys²)
θ = atan2(ys, xs)
twist = env_t * intensity * (π / 2) * max(0, 1 - r)
θ' = θ + twist
new_x = r * cos(θ') / aspect
new_y = r * sin(θ')
Outside the unit disk (r >= 1), twist = 0 so the corners are stationary.
6.7 ripple
aspect = W / H
ys = linspace(-1, 1, H) (broadcast)
xs = linspace(-1, 1, W) (broadcast)
r = sqrt((xs * aspect)² + ys²)
k = 6π
omega = 2π * max(motion_speed * 4.0, 0.5)
A = env_t * intensity * 0.015 * 2.0 # normalized [-1,1] grid units
dr = A * sin(k * r - omega * t)
r_safe = max(r, 1e-3)
dx = dr * (xs * aspect) / r_safe / aspect
dy = dr * ys / r_safe
grid' = grid + (dx, dy)
6.8 slit_scan
ys = linspace(-1, 1, H) (broadcast across W)
k = 4π
omega = 2π * max(motion_speed * 2.0, 0.4)
A = env_t * intensity * 0.04
dy = A * sin(k * ys - omega * t)
dx = A * 0.5 * cos(k * ys - omega * t)
grid' = grid + (dx, dy)
The horizontal displacement at half-amplitude with cos phase keeps the distortion from looking like a single 1D wave.
7. Overlays
All 4 overlays read frame: [H, W, 3] in [0, 1] and return the modified
frame. Each is gated by its strength uniform — if strength <= 0: skip.
7.1 glitch
if onset_t <= 0.001 or strength <= 0: return frame
max_px = max(1, int(onset_t * strength * 0.012 * min(H, W)))
# Per-channel R/G/B horizontal offsets (random sign, ±max_px), seeded
# deterministically by the onset value for reproducibility.
seed = int(onset_t * 1e6) & 0xFFFF
gen = Generator().manual_seed(seed)
signs = randint(0, 2, (3,), gen=gen) * 2 - 1 # ±1 per channel
offsets = signs * max_px
for c in 0..2:
ox = offsets[c]
if ox > 0: shift channel c right by ox px (replicate left edge)
elif ox < 0: shift left by |ox| (replicate right edge)
# Big-spike scanline tear:
if onset_t * strength > 0.7:
n_swap = max(1, H // 20)
pick n_swap random row indices, swap row[i] with row[i+1].
GLSL implementation samples each channel from a horizontally-shifted UV coord. Scanline tear can be approximated by per-row pseudo-random amplitude added to the UV.x — a row that's "swapped" effectively reads from the row above or below.
7.2 bloom
if env_t <= 0.001 or strength <= 0: return frame
weight = env_t * strength * 0.6
# Downsample 4x → 9-tap separable Gaussian blur (sigma=2.0) → upsample
small = downsample(frame, 0.25)
small = blur_horizontal(small, kernel=gauss_9, sigma=2.0)
small = blur_vertical(small, kernel=gauss_9, sigma=2.0)
big = upsample(small, frame.size)
bloom_layer = clip(big * weight, 0, 1)
# Screen blend
out = 1 - (1 - frame) * (1 - bloom_layer)
return clip(out, 0, 1)
GLSL: same multi-pass — downsample to a 1/4-size FBO, two blur passes,
upsample, screen-blend. The 9-tap separable Gaussian: weights g[i] = exp(-(i - 4)² / 8), normalized to sum 1.
7.3 vignette
if env_t <= 0.001 or strength <= 0: return frame
ys = linspace(-1, 1, H); xs = linspace(-1, 1, W)
r = sqrt(xs² + ys²).clip(0, 1.4)
v = (r / 1.414).clip(0, 1)
mask = 1 - v * env_t * strength * 0.5
return frame * mask
GLSL: compute r from gl_FragCoord normalized to [-1, 1], same formula.
7.4 hue_shift
if env_t <= 0.001 or strength <= 0: return frame
angle = env_t * strength * (30° in radians)
c, s = cos(angle), sin(angle)
m = [
[0.299 + 0.701*c + 0.168*s, 0.587 - 0.587*c + 0.330*s, 0.114 - 0.114*c - 0.497*s],
[0.299 - 0.299*c - 0.328*s, 0.587 + 0.413*c + 0.035*s, 0.114 - 0.114*c + 0.292*s],
[0.299 - 0.299*c + 1.250*s, 0.587 - 0.587*c - 1.050*s, 0.114 + 0.886*c - 0.203*s],
]
return clip(frame @ m^T, 0, 1)
The 0.299 / 0.587 / 0.114 luma triples MUST be exact in every row — typos drift the gray axis and produce a tint on neutrals at high angles.
8. Constants reference
| Constant | Value | Used by | Rationale |
|---|---|---|---|
scale_pulse multiplier |
0.15 | scale_pulse | 30% max zoom feels punchy without distortion |
zoom_punch multiplier |
0.30 | zoom_punch | 2× scale_pulse for spike feel |
shake amplitude |
0.04 | shake | 4% half-frame at intensity=1, raw=±1 |
shake decay |
0.7 | shake | smooths random walk into believable motion |
drift amplitude |
0.04 | drift | matches shake amplitude |
rotate_pulse max angle |
π/12 (15°) | rotate_pulse | bigger angles look gimmicky |
ripple k |
6π | ripple | 6 full radial cycles within unit disk |
ripple omega coefficient |
max(motion_speed*4, 0.5) | ripple | floor prevents stalling at 0 motion_speed |
ripple amplitude |
0.015 * 2.0 | ripple | 1.5% of grid range = 1.5% pixel displacement |
slit_scan k |
4π | slit_scan | 2 full vertical cycles |
slit_scan omega |
max(motion_speed*2, 0.4) | slit_scan | floor matches ripple's rationale |
slit_scan amplitude |
0.04 | slit_scan | matches shake/drift |
glitch px coefficient |
0.012 | glitch | 720p → 9px max, 4K → 26px max |
glitch tear threshold |
0.7 | glitch | only big spikes tear |
glitch tear row count |
H // 20 | glitch | 5% of rows |
bloom weight coefficient |
0.6 | bloom | screen-blend weight at full env*strength |
bloom blur sigma |
2.0 | bloom | matches 9-tap kernel coverage |
bloom downsample |
0.25 | bloom | 1/16 area, 16× compute saving |
vignette weight |
0.5 | vignette | 50% darken at corners on full env*strength |
hue_shift max angle |
30° | hue_shift | bigger looks gimmicky |
onset decay |
0.85 | onset_track | balances spike vs smear |
onset thresh floor |
0.05 | onset_track | minimum sensitivity for quiet music |
loop_safe fade |
0.5s | loop_safe ramp | invisible on clips ≥ 5s |
9. Where preview is approximate
Listed here so users / engineers know what to expect.
shake— JS RNG cannot reproduce torch'sGenerator(seed=0). Browser usesmulberry32(0)as a deterministic-but-different stand-in. Final MP4 output (Python) is authoritative.bloom— Python uses downsample → 2-pass separable Gaussian blur → upsample → screen blend (3 framebuffer passes). Browser approximates with a single in-shader 9-tap radial blur — visually similar but the falloff shape and strength are not bit-exact. Final MP4 (Python) is authoritative.
All other modes are exact-bit-pattern bound (within shader-precision
rounding — highp floats throughout).
10. Parity check
Two layers of test:
- Python parity (
scripts/audio_parity_check.py) — renders 64 reference frames from the engine, diffs against committed PNGs. Tolerance: pixel RMSE ≤ 1.0 (covers PyTorch float-precision drift across releases). Run before each release. - Browser parity harness (
assets/audio_studio_parity/index.html, landing in Milestone I) — loads the same goldens, runs the WebGL pipeline against the same source, computes per-pair ΔE in JS. Mean ΔE ≤ 5.0 for non-shake / non-bloom tests. Run manually in Chrome / Firefox / Safari before release.