Files
pixaroma-ComfyUI-Pixaroma/docs/audio-react-math.md
T
pixaroma 327cf5f425 consolidate: drop basic Audio React node, rename Audio Pulse -> AudioReact Pixaroma
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.
2026-04-27 22:00:42 +03:00

13 KiB
Raw Blame History

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:

  1. Mono-mix: if C > 1, average channels.
  2. Bandpass FFT — see §3.
  3. Resample: split waveform into total_frames non-overlapping chunks of samples_per_frame = max(1, sample_rate // fps) samples each. If the waveform is shorter than total_frames * samples_per_frame, repeat-pad.
  4. Per-frame RMS: rms[t] = sqrt(mean(chunk_t ** 2)).
  5. Min-max normalize: rms = (rms - rms.min()) / (rms.max() - rms.min()) (returns zeros if rms.max() == rms.min()).
  6. Moving-average smoothing: convolve rms with a kernel = ones(sw) / sw where sw is smoothing rounded UP to the nearest odd integer (so the kernel is centered). replicate-pad by sw // 2 on each side first.
  7. (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:

  1. diff[t] = max(0, env[t] - env[t-1]) for t >= 1; diff[0] = 0.
  2. thresh = max(0.05, quantile(diff, 0.75)).
  3. spikes[t] = diff[t] if diff[t] > thresh else 0.
  4. Sequential decay: out[0] = spikes[0]; out[t] = max(spikes[t], out[t-1] * 0.85).
  5. Peak-normalize: out = out / out.max() (returns zeros if out.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 bandpass
  • bass: (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:

  1. fade_n = max(2, min(int(fps * 0.5), total_frames // 2)).
  2. loop_ramp = linspace(0.0, 1.0, fade_n) — note: starts AT 0.
  3. envelope[:fade_n] *= loop_ramp (envelope[0] becomes 0).
  4. 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's Generator(seed=0). Browser uses mulberry32(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:

  1. 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.
  2. 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.