# 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.
