Files
furumi_tui/src/visualizations/pulsing_sphere.rhai
T

394 lines
14 KiB
Plaintext

// name: Pulsing sphere
// protocol: furumi-visualizer-v2
// bundle-version: 5
//
// A second bundled example with a very different structure from
// scope_spectrum.rhai: instead of a waveform-first visual, it renders a
// hollow terminal "sphere" whose radius, halo and palette respond to audio.
//
// Script entry point:
// fn render(input) -> Array<Map>
//
// Input map fields supplied by Furumi:
// width, height terminal size in cells
// time, position, progress animation time and playback progress
// volume, paused player state
// energy, bass, mid, treble smoothed audio bands in the 0.0..1.0 range
// beat transient pulse estimate in the 0.0..1.0 range
// seed stable per-track number in the 0.0..1.0 range
// samples recent mono waveform samples in the -1.0..1.0 range
// show_clock, clock user setting and formatted clock text
// track_title, track_artist metadata strings
//
// Useful numeric helpers available to scripts:
// to_int, to_float Rhai conversions
// sin, cos, tan, sqrt, abs, pow
// Scripts cannot import modules; keep every visualization self-contained.
//
// Draw command maps returned from render(input):
// #{ op: "clear", bg: 0x000000 }
// #{ op: "cell", x: 10, y: 4, ch: "*", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "hline", x: 0, y: 4, w: 20, ch: "-", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "vline", x: 10, y: 0, h: 12, ch: "|", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "rect", x: 10, y: 6, w: 2, h: 4, ch: "#", fg: 0x33ffee, bg: 0x000000 }
// #{ op: "trace", x: 0, ys: [4, 5, 3], ch: "*", line_ch: "|", fg: 0xffffff, line_fg: 0x558888, bg: 0x000000 }
// #{ op: "text", x: 2, y: 1, text: "hello", fg: 0xffffff, bg: 0x000000 }
//
// This script intentionally uses every command type. The sphere itself stays
// hollow: trace commands draw its spectral outline and orbit, hline draws
// latitudes, rect/vline draw the small energy meter, cells create stars and
// burst nodes, and labels use text.
fn clamp(value, low, high) {
if value < low {
low
} else if value > high {
high
} else {
value
}
}
fn min(left, right) {
if left < right { left } else { right }
}
fn max(left, right) {
if left > right { left } else { right }
}
fn rgb(r, g, b) {
let rr = to_int(clamp(r, 0, 255));
let gg = to_int(clamp(g, 0, 255));
let bb = to_int(clamp(b, 0, 255));
rr * 65536 + gg * 256 + bb
}
fn lerp(left, right, mix) {
left * (1.0 - mix) + right * mix
}
fn sample_at(samples, nx) {
let len = samples.len();
if len <= 0 {
0.0
} else {
let pos = clamp(nx, 0.0, 1.0) * (len - 1);
let left = to_int(pos);
let right = if left + 1 < len { left + 1 } else { left };
let mix = pos - to_float(left);
lerp(samples[left], samples[right], mix)
}
}
fn cell(x, y, ch, fg, bg) {
#{ op: "cell", x: x, y: y, ch: ch, fg: fg, bg: bg }
}
fn hline(x, y, w, ch, fg, bg) {
#{ op: "hline", x: x, y: y, w: w, ch: ch, fg: fg, bg: bg }
}
fn vline(x, y, h, ch, fg, bg) {
#{ op: "vline", x: x, y: y, h: h, ch: ch, fg: fg, bg: bg }
}
fn rect(x, y, w, h, ch, fg, bg) {
#{ op: "rect", x: x, y: y, w: w, h: h, ch: ch, fg: fg, bg: bg }
}
fn trace(x, ys, ch, line_ch, fg, line_fg, bg) {
#{ op: "trace", x: x, ys: ys, ch: ch, line_ch: line_ch, fg: fg, line_fg: line_fg, bg: bg }
}
fn text(x, y, value, fg, bg) {
#{ op: "text", x: x, y: y, text: value, fg: fg, bg: bg }
}
// A time-varying RGB palette. "shade" is usually 0.0..1.0; larger values are
// allowed and clamped by rgb(). Audio bands shift the hue without Rust knowing
// anything about the visual.
fn palette(input, shade, phase) {
let t = input.time + input.seed * 8.0 + phase;
let bass_push = input.bass * 70.0;
let treble_push = input.treble * 65.0;
rgb(
50 + shade * 130 + sin(t * 0.90) * 55 + bass_push,
70 + shade * 115 + sin(t * 0.63 + 2.1) * 55 + input.mid * 55,
95 + shade * 145 + cos(t * 0.72 + 0.7) * 55 + treble_push
)
}
fn wrap01(value) {
let out = value;
while out < 0.0 {
out += 1.0;
}
while out > 1.0 {
out -= 1.0;
}
out
}
fn spectral_band(input, phase) {
if phase < 0.34 {
input.bass
} else if phase < 0.68 {
input.mid
} else {
input.treble
}
}
// Treat the waveform as if it was wrapped around the sphere. The returned
// value is a radial spike amount: low frequencies push broad parts of the
// contour, raw samples add sharp teeth, and beat makes the edge jump outward.
fn edge_spectrum(input, phase) {
let p = wrap01(phase);
let wave = abs(sample_at(input.samples, p));
let neighbor = abs(sample_at(input.samples, wrap01(p + 0.021)));
let band = spectral_band(input, p);
let flutter = abs(sin(input.time * (5.0 + p * 7.0) + p * 38.0 + input.seed * 9.0));
let fine = abs(cos(input.time * (8.0 + p * 4.0) - p * 71.0));
clamp(
wave * (0.62 + input.energy * 0.36)
+ neighbor * 0.24
+ band * 0.46
+ input.beat * (0.30 + flutter * 0.64)
+ fine * input.energy * 0.18,
0.0,
1.55
)
}
fn push_starfield(cmds, input, bg) {
let stars = 34;
let index = 0;
while index < stars {
let x = (index * 37 + to_int(input.seed * 1000.0)) % max(input.width, 1);
let y = (index * 17 + to_int(input.time * 2.0)) % max(input.height, 1);
let speed = 0.7 + to_float(index % 5) * 0.15;
let twinkle = abs(sin(input.time * speed + to_float(index)));
let fg = rgb(
45 + twinkle * 120 + input.treble * 80,
75 + twinkle * 120,
105 + twinkle * 130
);
let ch = if twinkle + input.beat > 1.25 { "*" } else { "." };
cmds.push(cell(x, y, ch, fg, bg));
index += 1;
}
cmds
}
fn push_orbit(cmds, input, cx, cy, rx, ry, bg) {
let width = max(rx * 2 + 9, 3);
let start_x = max(cx - width / 2, 0);
let max_w = input.width - start_x;
let actual_w = min(width, max_w);
let back = [];
let front = [];
let i = 0;
while i < actual_w {
let nx = to_float(i) / to_float(max(actual_w - 1, 1));
let angle = nx * 6.28318 + input.time * 0.85;
let sample = sample_at(input.samples, nx);
let wobble = (sin(angle + input.bass * 2.0) + sample * 0.28)
* to_float(max(ry, 1))
* (0.28 + input.energy * 0.10);
let y_front = to_int(clamp(
to_float(cy) + wobble + input.beat * 1.5,
0.0,
to_float(input.height - 1)
));
let y_back = to_int(clamp(
to_float(cy) - wobble - input.beat * 1.5,
0.0,
to_float(input.height - 1)
));
front.push(y_front);
back.push(y_back);
i += 1;
}
let back_fg = palette(input, 0.18, 3.4);
let front_fg = palette(input, 0.65 + input.beat * 0.25, 0.5);
cmds.push(trace(start_x, back, ".", ".", back_fg, back_fg, bg));
cmds.push(trace(start_x, front, "*", ".", front_fg, front_fg, bg));
cmds
}
fn push_pulse_ring(cmds, input, cx, cy, rx, ry, bg) {
let ring_rx = rx + 2 + to_int(input.beat * 5.0 + input.energy * 2.0);
let ring_ry = ry + 1 + to_int(input.beat * 3.0 + input.bass * 2.0);
let left = max(cx - ring_rx, 0);
let right = min(cx + ring_rx, input.width - 1);
let width = right - left + 1;
if width <= 2 {
return cmds;
}
let top = [];
let bottom = [];
let i = 0;
while i < width {
let nx = if width > 1 { to_float(i) / to_float(width - 1) * 2.0 - 1.0 } else { 0.0 };
let phase = to_float(i) / to_float(max(width - 1, 1));
let inside = max(1.0 - nx * nx, 0.0);
let spike = edge_spectrum(input, phase) * (1.0 + input.energy * 3.0 + input.beat * 2.0);
let y = sqrt(inside) * (to_float(ring_ry) + spike);
top.push(to_int(clamp(to_float(cy) - y, 0.0, to_float(input.height - 1))));
bottom.push(to_int(clamp(to_float(cy) + y, 0.0, to_float(input.height - 1))));
i += 1;
}
let color = palette(input, 0.14 + input.beat * 0.35, 4.8);
cmds.push(trace(left, top, ".", ".", color, color, bg));
cmds.push(trace(left, bottom, ".", ".", color, color, bg));
cmds
}
fn push_sphere(cmds, input, cx, cy, rx, ry, bg) {
let left = max(cx - rx, 0);
let right = min(cx + rx, input.width - 1);
let width = right - left + 1;
if width <= 2 {
return cmds;
}
let top = [];
let bottom = [];
let glow_top = [];
let glow_bottom = [];
let i = 0;
while i < width {
let nx = if width > 1 { to_float(i) / to_float(width - 1) * 2.0 - 1.0 } else { 0.0 };
let inside = max(1.0 - nx * nx, 0.0);
let phase = to_float(i) / to_float(max(width - 1, 1));
let top_spike = edge_spectrum(input, phase) * (1.0 + input.energy * 3.4 + input.beat * 3.0);
let bottom_spike = edge_spectrum(input, 1.0 - phase) * (1.0 + input.energy * 3.4 + input.beat * 3.0);
let edge = sqrt(inside) * to_float(ry);
let top_y = to_int(clamp(to_float(cy) - edge - top_spike, 0.0, to_float(input.height - 1)));
let bottom_y = to_int(clamp(to_float(cy) + edge + bottom_spike, 0.0, to_float(input.height - 1)));
let glow = 1 + to_int(max(top_spike, bottom_spike) * 0.35 + input.beat * 2.0);
top.push(top_y);
bottom.push(bottom_y);
glow_top.push(clamp(top_y - glow, 0, input.height - 1));
glow_bottom.push(clamp(bottom_y + glow, 0, input.height - 1));
i += 1;
}
let glow = palette(input, 0.22 + input.energy * 0.22, 3.1);
let outline = palette(input, 0.82 + input.beat * 0.45, 0.3);
let bridge = palette(input, 0.42 + input.mid * 0.25, 2.2);
cmds.push(trace(left, glow_top, ".", ".", glow, glow, bg));
cmds.push(trace(left, glow_bottom, ".", ".", glow, glow, bg));
cmds.push(trace(left, top, "*", ".", outline, bridge, bg));
cmds.push(trace(left, bottom, "*", ".", outline, bridge, bg));
let equator = palette(input, 0.50 + input.bass * 0.25, 5.6);
cmds.push(hline(left, cy, width, "-", equator, bg));
if ry > 4 {
let latitude = palette(input, 0.35 + input.treble * 0.20, 1.8);
let span = max(width - width / 3, 3);
let lat_x = left + (width - span) / 2;
let lat_y = max(cy - ry / 2, 0);
cmds.push(hline(lat_x, lat_y, span, ".", latitude, bg));
cmds.push(hline(lat_x, min(cy + ry / 2, input.height - 1), span, ".", latitude, bg));
}
let nodes = 42;
let node = 0;
while node < nodes {
let phase = to_float(node) / to_float(nodes);
let angle = phase * 6.28318 + input.time * (0.10 + input.treble * 0.08);
let spectrum = edge_spectrum(input, phase);
let burst = spectrum * (1.4 + input.energy * 4.2 + input.beat * 3.5);
let x = to_int(clamp(to_float(cx) + cos(angle) * (to_float(rx) + burst), 0.0, to_float(input.width - 1)));
let y = to_int(clamp(to_float(cy) + sin(angle) * (to_float(ry) + burst * 0.58), 0.0, to_float(input.height - 1)));
let fg = palette(input, 0.48 + spectrum * 0.36 + input.beat * 0.30, angle);
let ch = if spectrum > 1.05 { "*" } else if spectrum > 0.72 { "+" } else { "." };
cmds.push(cell(x, y, ch, fg, bg));
if spectrum > 1.18 {
let spike_x = to_int(clamp(to_float(cx) + cos(angle) * (to_float(rx) + burst + 1.5), 0.0, to_float(input.width - 1)));
let spike_y = to_int(clamp(to_float(cy) + sin(angle) * (to_float(ry) + (burst + 1.5) * 0.58), 0.0, to_float(input.height - 1)));
cmds.push(cell(spike_x, spike_y, ".", fg, bg));
}
node += 1;
}
cmds
}
fn push_meter(cmds, input, bg) {
if input.height < 5 || input.width < 20 {
return cmds;
}
let y = input.height - 2;
let w = min(input.width - 4, 48);
let x = 2;
let base = rgb(18, 34, 42);
let fill = palette(input, 0.65 + input.energy * 0.35, 5.2);
let filled = to_int(clamp(input.energy * to_float(w), 1.0, to_float(w)));
cmds.push(hline(x, y, w, "-", base, bg));
cmds.push(rect(x, y, filled, 1, " ", fill, fill));
let progress = to_int(clamp(input.progress * to_float(w), 0.0, to_float(w)));
if progress > 0 {
cmds.push(hline(x, y - 1, progress, ".", fill, bg));
cmds.push(vline(min(x + progress, x + w - 1), y - 1, 2, "|", fill, bg));
}
cmds
}
fn push_labels(cmds, input, bg) {
let fg = rgb(180 + input.treble * 60, 230, 245);
let dim = rgb(70, 120 + input.energy * 80, 130 + input.energy * 80);
if input.show_clock {
cmds.push(text(1, 0, " " + input.clock + " ", fg, rgb(2, 18, 24)));
}
if input.track_title != "" && input.height > 4 {
cmds.push(text(2, input.height - 1, input.track_title, fg, bg));
}
if input.track_artist != "" && input.height > 5 {
cmds.push(text(2, input.height - 3, input.track_artist, dim, bg));
}
if input.paused {
cmds.push(text(2, 1, "paused", dim, bg));
}
cmds
}
fn render(input) {
let cmds = [];
let bg = rgb(1, 4, 10);
cmds.push(#{ op: "clear", bg: bg });
if input.width <= 8 || input.height <= 6 {
return cmds;
}
cmds = push_starfield(cmds, input, bg);
let cx = input.width / 2;
let cy = input.height / 2;
let max_ry = max((input.height - 6) / 2, 2);
let max_rx = max(input.width / 3, 4);
let base = min(to_float(max_ry), to_float(max_rx) / 2.0) * 0.78;
let activity = clamp(input.energy * 0.72 + input.bass * 0.50 + input.beat * 1.00, 0.0, 1.18);
let pulse = 0.34
+ activity * 0.58
+ input.volume * 0.04
+ input.beat * 0.18
+ abs(sin(input.time * 2.8 + input.position * 0.07)) * (0.03 + input.energy * 0.07);
let ry = max(to_int(base * pulse), 2);
let rx = max(to_int(to_float(ry) * (1.85 + input.treble * 0.28)), 4);
cmds = push_pulse_ring(cmds, input, cx, cy, rx, ry, bg);
cmds = push_orbit(cmds, input, cx, cy, rx, ry, bg);
cmds = push_sphere(cmds, input, cx, cy, rx, ry, bg);
cmds = push_meter(cmds, input, bg);
cmds = push_labels(cmds, input, bg);
cmds
}