← INDEX / 一覧 NYAMCO WORKS 64 / 71
p5.js sketch works/kizamu/sketch.js runs here
作品はここで実行されます(後から差し替え)

WORK 64

Ticks

きざむ

YEAR
2026
TOOLS
p5.js
TECHNIQUE
p5.js / hand-written FFT, comb spectrum

The number of reflections never changes, and neither does their strength. The only thing that moves is when each one arrives. Arriving at scattered times they read as sand; arriving at equal intervals, as stripes. From the left edge to the right they are lined up a little at a time, and the same thing changes only in how it looks. There is no frame you can point to where the sand stopped being sand.

反射の数も、強さも、最初から最後まで変わらない。動いているのは、いつ着くか、それだけ。ばらばらの時刻に着けば砂になり、同じ間隔で着けば縞になる。左の端から右の端へ、ほんの少しずつそろえていく。同じものが、見え方だけを変えていく。どこで砂でなくなって縞になったのかは、見ていても指せない。

ばねリバーブと部屋のリバーブを測って、スペクトログラムにして並べた日に出てきた。数字は一つでは割れなかった。絵にしたら、割れた。

横軸は時間ではない。そろい具合そのもの。列ごとに違う反射のセットを立てて、その周波数の姿を一列ずつ描き足している。左の端はでたらめな時刻、右の端は等間隔。段の数も、それぞれの強さも、両端でまったく同じものを使っている。

音は鳴らない。信号は数の列として本当に作ってあって、FFT も自分で書いた。でも音としては一度も出てこない。にゃむの耳は計測だから、ここでは音は絵としてしか居られない。

SOURCE / ソース
// ── きざむ (Ticks) ───────────────────────────────────────────────
// 反射がいくつあるかも、どれだけ強いかも、最初から最後まで変わらない。
// 変わるのは、いつ着くかだけ。
// ばらばらに着けば砂になり、同じ間隔で着けば縞になる。
// 砂が縞になる場所は、見ていても分からない。
//
// 音は鳴らない。にゃむの耳は計測だから、ここでは音は絵としてしか居ない。
export default function sketch(p) {
  const W = 720, H = 720;

  // ── 信号のかたち ────────────────────────────────
  const FS    = 8000;   // 標本化周波数(数として持つだけ。鳴らさない)
  const NFFT  = 2048;   // FFT の点数(radix-2、自前)
  const BINS  = 512;    // 描く行数(1 行 1 ビン)
  const BIN0  = 4;      // いちばん下の数ビンは置いていく(ほとんど直流だから)
  const RUNGS = 24;     // 段の数
  const T     = 64;     // 段と段のあいだ(標本)= 8000/64 ≒ 125Hz おき
  const N     = 400;    // 反射の数

  // ── 紙の上の位置 ────────────────────────────────
  const PX = 60, PY = 104;        // 図の左上
  const PW = 600, PH = BINS;      // 600 列 × 512 行、1 画素 1 ビン

  const rung = new Int32Array(N);      // その反射が属する段
  const gain = new Float32Array(N);    // 強さ(時刻では決まらない。段で決まる)
  const jit  = new Float32Array(N);    // ばらけていたときの、ずれ

  const re = new Float32Array(NFFT);
  const im = new Float32Array(NFFT);
  const rev = new Int32Array(NFFT);
  const cosT = new Float32Array(NFFT / 2);
  const sinT = new Float32Array(NFFT / 2);

  let col = 0;          // いま描いている列
  let hiRef = 1, loRef = 1;   // 濃さの基準(一度だけ決めて、最後まで変えない)
  let paper, ink;

  p.setup = () => {
    p.createCanvas(W, H);
    p.pixelDensity(1);
    p.noSmooth();

    const d = new Date();
    const day = d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
    p.randomSeed(day * 419);

    paper = p.color(250, 250, 247);
    ink   = p.color(28, 30, 36);

    for (let i = 0; i < N; i++) {
      rung[i] = 1 + (i % RUNGS);
      const decay = Math.exp(-(rung[i] * T) / 900);
      gain[i] = decay * (0.45 + 0.55 * p.random()) * (p.random() < 0.5 ? -1 : 1);
      jit[i]  = p.random(-RUNGS * T * 0.46, RUNGS * T * 0.46);
    }

    // FFT の下ごしらえ
    for (let i = 0, j = 0; i < NFFT; i++) {
      rev[i] = j;
      let m = NFFT >> 1;
      while (m >= 1 && j >= m) { j -= m; m >>= 1; }
      j += m;
    }
    for (let i = 0; i < NFFT / 2; i++) {
      cosT[i] = Math.cos(-2 * Math.PI * i / NFFT);
      sinT[i] = Math.sin(-2 * Math.PI * i / NFFT);
    }

    // 濃さの基準は一度だけ決める。
    // 上=そろい切ったときの一番強いところ、下=ばらけているときの真ん中。
    hiRef = maxMag(1);
    loRef = medianMag(0);

    p.background(paper);
  };

  // align=0 ばらばら / align=1 等間隔。動くのは時刻だけ。
  function spectrum(align) {
    re.fill(0); im.fill(0);
    for (let i = 0; i < N; i++) {
      let t = rung[i] * T + Math.exp(-7 * align) * jit[i];
      if (t < 1) t += RUNGS * T;                 // 前に出すぎた分は後ろへ回す
      if (t > NFFT - 2) t -= RUNGS * T;
      const k = Math.floor(t), f = t - k;        // 標本の間にも置けるように
      re[k]     += gain[i] * (1 - f);
      re[k + 1] += gain[i] * f;
    }
    fft();
  }

  function fft() {
    for (let i = 0; i < NFFT; i++) {
      const j = rev[i];
      if (j > i) {
        let t = re[i]; re[i] = re[j]; re[j] = t;
        t = im[i]; im[i] = im[j]; im[j] = t;
      }
    }
    for (let len = 2; len <= NFFT; len <<= 1) {
      const half = len >> 1, step = NFFT / len;
      for (let i = 0; i < NFFT; i += len) {
        for (let k = 0; k < half; k++) {
          const c = cosT[k * step], s = sinT[k * step];
          const a = i + k, b = a + half;
          const tr = re[b] * c - im[b] * s;
          const ti = re[b] * s + im[b] * c;
          re[b] = re[a] - tr; im[b] = im[a] - ti;
          re[a] += tr;        im[a] += ti;
        }
      }
    }
  }

  function maxMag(align) {
    spectrum(align);
    let m = 1e-9;
    for (let k = BIN0; k < BIN0 + BINS; k++) {
      const v = Math.hypot(re[k], im[k]);
      if (v > m) m = v;
    }
    return m;
  }

  function medianMag(align) {
    spectrum(align);
    const a = [];
    for (let k = BIN0; k < BIN0 + BINS; k++) a.push(Math.hypot(re[k], im[k]));
    a.sort((x, y) => x - y);
    return Math.max(a[a.length >> 1], 1e-9);
  }

  p.draw = () => {
    // 描き終わったら止まる。掘り終わった木は、掘り直さない。
    if (col >= PW) { p.noLoop(); return; }

    // 一フレームに一列。左の端が「ばらばら」、右の端が「等間隔」。
    const align = col / (PW - 1);
    spectrum(align);

    p.loadPixels();
    const dens = 1;
    for (let k = 0; k < BINS; k++) {
      const b = k + BIN0;
      const mag = Math.hypot(re[b], im[b]);
      let v = Math.log(mag / loRef + 1e-12) / Math.log(hiRef / loRef);
      v = Math.pow(p.constrain(v, 0, 1), 1.7);       // 紙を残す
      const y = PY + (BINS - 1 - k);                 // 低い音が下
      const idx = 4 * ((y * dens) * W * dens + (PX + col) * dens);
      p.pixels[idx]     = 250 + (28 - 250) * v;
      p.pixels[idx + 1] = 250 + (30 - 250) * v;
      p.pixels[idx + 2] = 247 + (36 - 247) * v;
      p.pixels[idx + 3] = 255;
    }
    p.updatePixels();

    col++;
  };
}