equalizer

[github]

spectrum bars bouncing to a beat, peak caps falling slowly

data 62×16 30 fps 6.1 kB

use it

html

<script type="module" src="https://ascii.rest/ascii.js"></script>

<ascii-art piece="equalizer" options='{"bpm":120}'></ascii-art>

No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.

react

// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { equalizer } from "ascii.rest/pieces";

<Ascii piece={equalizer} options={{"bpm":120}} />

A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="equalizer", to fetch the piece only when it mounts.

astro

---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---

<Ascii piece="equalizer" options={{"bpm":120}} />

Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.

typescript

// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { equalizer } from "ascii.rest/pieces";

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, equalizer, {"bpm":120});

mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.

equalizer.ts

[js] [github]
/*
 * equalizer: a spectrum analyser over a short drum and bass loop. Bars jump to
 * each band's level and sink back, and peak caps hold, then drop slowly.
 */
import type { Frame, Meta } from "../types.ts";

export interface EqualizerOptions {
  [key: string]: unknown;
  /** Tempo, held to 60 to 180. */
  bpm: number;
}

export const meta = {
  name: "equalizer",
  category: "data",
  note: "spectrum bars bouncing to a beat, peak caps falling slowly",
  cols: 62,
  rows: 16,
  fps: 30,
  options: { bpm: 120 },
} satisfies Meta<EqualizerOptions>;

const BANDS = 19; // half octaves from 31 Hz to 16 kHz
const LABELS = ["31", "63", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"];
const PH = 12; // plot height in rows
const EIGHTHS = "▁▂▃▄▅▆▇";
const FALL = 2.2; // bar fall, heights per second
const HOLD = 0.5; // seconds a peak cap waits before it drops
const GRAVITY = 1.1; // how fast a dropping cap speeds up
const KICK_GAIN = 1.4, BASS_GAIN = 0.34, SNARE_GAIN = 0.95, HAT_GAIN = 0.75, CHORD_GAIN = 0.15, KNEE = 1.4;
const HEADROOM = 0.95; // the loudest a band reaches, so caps always show

// Sixteen steps a bar. The loop is four bars, the last with a fill.
const KICK = ["x.....x.x...x...", "x.....x.x.....x.", "x.....x.x...x...", "x.....x.x.x.x.xx"];
const SNARE = ["....x.......x...", "....x.......x..x", "....x.......x...", "....x..x..x.xxxx"];
const HAT = "x.x.x.xxx.x.x.xx";
const BASS = [0, 0, 2, 3, 1, 1, 3, 2, 0, 0, 2, 4, 3, 2, 1, 0]; // band per beat
const CHORDS = [[8, 10, 13], [7, 10, 12], [9, 11, 13], [8, 11, 14]];

function hash(n: number) {
  n = Math.imul(n ^ (n >>> 16), 0x7feb352d);
  n = Math.imul(n ^ (n >>> 15), 0x846ca68b);
  return ((n ^ (n >>> 16)) >>> 0) / 4294967296;
}
// Smooth flutter per band, a pure function of time.
function flutter(b: number, x: number) {
  const i = Math.floor(x), f = x - i, u = f * f * (3 - 2 * f);
  return hash(b * 7919 + i) * (1 - u) + hash(b * 7919 + i + 1) * u;
}

export default function equalizer({ bpm = meta.options.bpm }: Partial<EqualizerOptions> = {}): Frame {
  const { cols, rows } = meta;
  const STEP = 15 / Math.max(60, Math.min(180, bpm)); // seconds per sixteenth
  const X0 = Math.floor((cols - (BANDS * 3 - 1)) / 2);
  const bar = new Float32Array(BANDS), peak = new Float32Array(BANDS);
  const vel = new Float32Array(BANDS), held = new Float32Array(BANDS);
  const level = new Float32Array(BANDS);

  // Time since the last hit of a pattern, looking back up to a bar.
  const since = (t: number, pat: string | string[]) => {
    const s = Math.floor(t / STEP);
    for (let k = 0; k < 16; k++) {
      const j = s - k, which = ((Math.floor(j / 16) % 4) + 4) % 4;
      const p = typeof pat === "string" ? pat : pat[which];
      if (p[((j % 16) + 16) % 16] === "x") return t - j * STEP;
    }
    return 9;
  };

  const spectrum = (t: number) => {
    const kick = Math.exp(-since(t, KICK) / 0.14);
    const snare = Math.exp(-since(t, SNARE) / 0.11);
    const hat = Math.exp(-since(t, HAT) / 0.045);
    const beat = Math.floor(t / (STEP * 4));
    const beatFrac = t / (STEP * 4) - beat;
    const bass = BASS[((beat % 16) + 16) % 16] + 0.6;
    const chord = CHORDS[((Math.floor(beat / 4) % 4) + 4) % 4];
    // The chord ducks under each beat and swells back, as if sidechained.
    const pump = 0.5 + 0.5 * (1 - Math.exp(-beatFrac * 4));
    for (let b = 0; b < BANDS; b++) {
      const n = flutter(b, t * 9);
      let sum =
        0.03 + 0.08 * n - 0.001 * b + // the room
        KICK_GAIN * kick * Math.exp(-(((b - 1.2) / 1.4) ** 2)) +
        // The bass note and its octave, two bands up.
        BASS_GAIN * (0.45 + 0.55 * Math.exp(-beatFrac * 3)) * (Math.exp(-(((b - bass) / 0.9) ** 2)) + 0.5 * Math.exp(-(((b - bass - 2) / 1.1) ** 2))) +
        SNARE_GAIN * (0.75 + 0.25 * n) * snare * Math.exp(-(((b - 9) / 4.2) ** 2)) +
        HAT_GAIN * hat * Math.exp(-(((b - 16) / 2.2) ** 2)) * (0.6 + 0.4 * n);
      // The chord rings in the mids, each tone with a weaker overtone.
      for (const c of chord) sum += CHORD_GAIN * pump * (0.7 + 0.3 * n) * (Math.exp(-((b - c) ** 2)) + 0.5 * Math.exp(-((b - c - 2) ** 2)));
      // A soft knee, so loud bands still move instead of pinning at the top.
      level[b] = HEADROOM * (1 - Math.exp(-KNEE * sum));
    }
  };

  let last = 0;
  const step = (t: number) => {
    const dt = Math.min(0.1, Math.max(0, t - last));
    last = t;
    spectrum(t);
    for (let b = 0; b < BANDS; b++) {
      bar[b] = Math.max(level[b], bar[b] - FALL * dt);
      if (bar[b] >= peak[b]) {
        peak[b] = bar[b];
        held[b] = HOLD;
        vel[b] = 0;
      } else if ((held[b] -= dt) < 0) {
        vel[b] += GRAVITY * dt;
        peak[b] = Math.max(bar[b], peak[b] - vel[b] * dt);
      }
    }
  };
  // Play a few bars in so frame 0 is mid song, just after a kick, then count
  // time from there.
  const WARM = STEP * (16 * 3 + 6) + 0.04;
  for (let i = 0; i <= 90; i++) step((WARM * i) / 90);

  const grid = Array.from({ length: rows }, () => new Array<string>(cols).fill(" "));
  const AY = 1 + PH; // axis row
  for (let c = X0 - 1; c <= X0 + BANDS * 3 - 1; c++) grid[AY][c] = "─";
  LABELS.forEach((s, i) => {
    const c = X0 + i * 6 + 1 - Math.floor(s.length / 2);
    [...s].forEach((ch, j) => (grid[AY + 1][c + j] = ch));
  });

  return (t) => {
    if (t + WARM < last) {
      last = 0;
      bar.fill(0), peak.fill(0), vel.fill(0), held.fill(0);
      for (let i = 0; i <= 90; i++) step((WARM * i) / 90);
    }
    step(t + WARM);
    for (let r = 0; r < AY; r++) grid[r].fill(" ");
    for (let b = 0; b < BANDS; b++) {
      const x = X0 + b * 3;
      const h = Math.round(Math.min(1, bar[b]) * PH * 8);
      const full = h >> 3, part = h & 7;
      for (let r = 0; r < full; r++) grid[AY - 1 - r][x] = grid[AY - 1 - r][x + 1] = "█";
      if (part) grid[AY - 1 - full][x] = grid[AY - 1 - full][x + 1] = EIGHTHS[part - 1];
      // The cap is one thin line, drawn once a clear cell parts it from the bar.
      const pr = Math.min(PH * 8 - 1, Math.round(Math.min(1, peak[b]) * PH * 8)) >> 3;
      if (pr >= (part ? full : full - 1) + 2) grid[AY - 1 - pr][x] = grid[AY - 1 - pr][x + 1] = "▁";
    }
    return grid.map((row) => row.join("")).join("\n");
  };
}