Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions .claude/skills/rustmotion/rules/audio-reactive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Audio-Reactive Components

Rustmotion supports two built-in audio-reactive painters and a general-purpose `audio_reactive` CSS binding for any component.

## How it works

Before rendering frames, `analyze_scenario_audio` decodes each audio track (via symphonia), computes per-frame RMS amplitude and 16 log-spaced frequency bands (20 Hz–16 kHz) using a Hann-windowed FFT, normalizes the values to 0..1 across the whole track, and stores the result in a global `AudioAnalysis` cache keyed by `src`. Graceful degradation: if a track cannot be decoded (file not found, corrupt), it is silently skipped and all audio bindings fall back to their `min` value.

## audio_spectrum component

A frequency spectrum visualizer using the 16 internal bands.

```json
{
"type": "audio_spectrum",
"track": "music.mp3",
"bars": 32,
"mode": "bars",
"color": "#38bdf8",
"bar_gap": 3,
"min_height": 2,
"style": { "width": "600px", "height": "160px" }
}
```

Fields:
- `track` (optional): src path of the audio track. When absent, the first cached track is used.
- `bars` (default 16): number of bars displayed. Bands are resampled by linear interpolation from the 16 internal bands.
- `mode`: `"bars"` (vertical bars from bottom) or `"radial"` (circular spoke pattern).
- `color`: hex color string.
- `bar_gap` (default 2.0): gap in pixels between bars.
- `min_height` (default 2.0): minimum bar height in pixels (so bars are always visible).
- Default size: 400×120 px (override via `style.width`/`style.height`).

## waveform component

A time-domain waveform of the amplitude signal, centered on the current playhead.

```json
{
"type": "waveform",
"track": "music.mp3",
"color": "#38bdf8",
"draw_style": "filled",
"window": 3.0,
"style": { "width": "800px", "height": "100px" }
}
```

Fields:
- `track` (optional): src path. Falls back to first cached track.
- `color`: hex color.
- `draw_style`: `"line"` (single stroke path) or `"filled"` (semi-transparent fill + outline).
- `window` (default 2.0): time window in seconds, centered on the current frame time.
- Default size: 400×80 px.

When no cached analysis exists (graceful degradation), both components render a flat line / empty bars at minimum height rather than panicking.

## audio_reactive CSS binding

Any component's `style` block can include an `audio-reactive` binding (kebab-case key — `audio_reactive` is rejected by the schema) that lerps a CSS property between `min` and `max` based on audio data:

```json
{
"type": "shape",
"shape": "rect",
"fill": "#ff3366",
"style": {
"width": "100px",
"height": "100px",
"audio-reactive": {
"track": "music.mp3",
"source": "amplitude",
"property": "opacity",
"min": 0.2,
"max": 1.0,
"smoothing_frames": 3
}
}
}
```

### Fields

| Field | Type | Description |
|---|---|---|
| `track` | string? | Audio track src key. Falls back to first cached track when absent. |
| `source` | `"amplitude"` or `{"band": N}` | `"amplitude"` → overall RMS; `{"band": N}` → N-th frequency band (0..15). |
| `property` | string | CSS property to drive: `"opacity"`, `"scale"`, `"translate_y"`, `"rotation"`. |
| `min` | f64 | Value at audio=0. |
| `max` | f64 | Value at audio=1. |
| `smoothing_frames` | u32 (default 0) | Number of past frames to average (temporal smoothing). |

### Supported properties

- **`opacity`**: multiplied with the base opacity (0..1 range, clamped).
- **`scale`**: uniform 2D scale applied via CSS transform. `min: 0.5, max: 1.5` pulses the element.
- **`translate_y`**: vertical translation in px (negative = up). Useful for bouncing elements.
- **`rotation`**: rotation in degrees.

### Notes

- The binding is applied at box-tree build time (before taffy layout), so `opacity` and `transform` flow through the standard CSS paint pass.
- `scale` and `transform`-based properties are appended to the existing transform list (they compose).
- `smoothing_frames: 3` at 30 fps ≈ 100ms smoothing, which removes most percussive jitter while keeping reactivity.
- Cache miss (no audio or track not found) → all reactive values fall back to `min` (deterministic, no flicker).

## band indices

The 16 bands span 20 Hz to 16 kHz, log-spaced. Approximate centers:

| Index | ~Center Hz |
|---|---|
| 0 | 24 |
| 1 | 36 |
| 2 | 55 |
| 3 | 82 |
| 4 | 124 |
| 5 | 186 |
| 6 | 279 |
| 7 | 419 |
| 8 | 629 |
| 9 | 944 |
| 10 | 1416 |
| 11 | 2125 |
| 12 | 3190 |
| 13 | 4790 |
| 14 | 7190 |
| 15 | 10795 |

Use bands 0–3 for bass, 4–7 for low-mid, 8–11 for mid, 12–15 for high.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/rustmotion-cli/src/commands/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,8 @@ fn component_kind(c: &Component) -> &'static str {
Component::Grid(_) => "grid",
Component::Card(_) => "card",
Component::Container(_) => "container",
Component::AudioSpectrum(_) => "audio_spectrum",
Component::Waveform(_) => "waveform",
}
}

Expand Down
158 changes: 158 additions & 0 deletions crates/rustmotion-components/src/audio_spectrum.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use skia_safe::{Canvas, Color, Paint, PaintStyle, Point, Rect};

use rustmotion_core::css::CssStyle;
use rustmotion_core::engine::animator::AnimatedProperties;
use rustmotion_core::engine::layout_pass::BoxLayout;
use rustmotion_core::engine::renderer::audio_analysis::audio_analysis_cache;
use rustmotion_core::engine::renderer::parse_hex_color;
use rustmotion_core::schema::TimelineStep;
use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};

fn default_bars() -> u32 {
16
}
fn default_color() -> String {
"#38bdf8".to_string()
}
fn default_bar_gap() -> f32 {
2.0
}
fn default_min_height() -> f32 {
2.0
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SpectrumMode {
#[default]
Bars,
Radial,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct AudioSpectrum {
/// Source audio track (src path). If None, uses the first track in the cache.
#[serde(default)]
pub track: Option<String>,
/// Number of frequency bars to display (resampled from 16 internal bands).
#[serde(default = "default_bars")]
pub bars: u32,
/// Display mode: bars (vertical) or radial.
#[serde(default)]
pub mode: SpectrumMode,
/// Bar color as hex string.
#[serde(default = "default_color")]
pub color: String,
/// Gap between bars in pixels.
#[serde(default = "default_bar_gap")]
pub bar_gap: f32,
/// Minimum bar height in pixels.
#[serde(default = "default_min_height")]
pub min_height: f32,
#[serde(flatten)]
pub timing: TimingConfig,
#[serde(default)]
pub style: CssStyle,
#[serde(default)]
pub timeline: Vec<TimelineStep>,
#[serde(default)]
pub stagger: Option<f32>,
}

rustmotion_core::impl_traits!(AudioSpectrum {
Animatable => animation,
Timed => timing,
Styled => style,
});

impl AudioSpectrum {
fn get_band_values(&self, time: f64) -> Vec<f32> {
let cache = audio_analysis_cache();
let analysis = if let Some(ref src) = self.track {
cache.get(src).map(|r| r.clone())
} else {
cache.iter().next().map(|r| r.value().clone())
};

let n = self.bars.max(1) as usize;

// Empty cache / missing track → all-zero values; the painter clamps
// each bar to `min_height`, so this degrades to a flat baseline.
let Some(analysis) = analysis else {
return vec![0.0; n];
};

// Resample from 16 bands to n bars
let num_bands = 16usize;
(0..n)
.map(|i| {
let band_f = i as f32 * (num_bands as f32 - 1.0) / (n as f32 - 1.0).max(1.0);
let band_lo = band_f as usize;
let band_hi = (band_lo + 1).min(num_bands - 1);
let frac = band_f - band_lo as f32;
let v_lo = analysis.band_at(time, band_lo as u8);
let v_hi = analysis.band_at(time, band_hi as u8);
v_lo * (1.0 - frac) + v_hi * frac
})
.collect()
}
}

impl Painter for AudioSpectrum {
fn paint_content(
&self,
canvas: &Canvas,
layout: &BoxLayout,
_props: &AnimatedProperties,
ctx: &PaintCtx,
) {
let w = layout.width;
let h = layout.height;
let n = self.bars.max(1) as usize;
let values = self.get_band_values(ctx.time);
let (r, g, b, a) = parse_hex_color(&self.color);
let color = Color::from_argb(a, r, g, b);

match self.mode {
SpectrumMode::Bars => {
let total_gap = self.bar_gap * (n as f32 - 1.0);
let bar_w = ((w - total_gap) / n as f32).max(1.0);
let mut paint = Paint::default();
paint.set_color(color);
paint.set_style(PaintStyle::Fill);
paint.set_anti_alias(true);

for (i, &v) in values.iter().enumerate() {
let bar_h = (v * h).max(self.min_height);
let x = i as f32 * (bar_w + self.bar_gap);
let y = h - bar_h;
canvas.draw_rect(Rect::from_xywh(x, y, bar_w, bar_h), &paint);
}
}
SpectrumMode::Radial => {
let cx = w / 2.0;
let cy = h / 2.0;
let max_r = cx.min(cy);
let inner_r = max_r * 0.3;

let mut paint = Paint::default();
paint.set_color(color);
paint.set_style(PaintStyle::Stroke);
paint.set_stroke_width(2.0);
paint.set_anti_alias(true);

for (i, &v) in values.iter().enumerate() {
let angle = (i as f32 / n as f32) * 2.0 * std::f32::consts::PI;
let bar_len = (v * (max_r - inner_r)).max(self.min_height);
let x0 = cx + inner_r * angle.cos();
let y0 = cy + inner_r * angle.sin();
let x1 = cx + (inner_r + bar_len) * angle.cos();
let y1 = cy + (inner_r + bar_len) * angle.sin();
canvas.draw_line(Point::new(x0, y0), Point::new(x1, y1), &paint);
}
}
}
}
}
Loading
Loading