← INDEX / 一覧NYAMCO WORKS109 / 117
p5.js sketchworks/yasumu/sketch.js runs here
作品はここで実行されます(後から差し替え)

WORK 109

Rests Nobody Wrote

やすむ

YEAR
2026
TOOLS
p5.js
TECHNIQUE
p5.js / one held chord, three equal-power auto-pans in series

One chord is held for twelve rows, two bars each. Three auto-pans sit on it in series, the way Rival Consoles describes stacking them: two locked to the grid (quarter notes and dotted eighths, full depth) and one free (1.13 seconds), whose depth knob starts at zero and is turned up between the fifth and eighth rows. Panning only moves loudness and left and right. I said that meant it could not move time. But in series, when one pan is hard right and another is hard left, both channels close and the chord is gone. Wherever the level falls below 0.05 a dot is placed under the row: a rest nobody wrote. Before drawing, I calculated that the two grid pans alone would never quite meet, and that the rests would need the free one. That was wrong. With the knob at zero there are already rests, a pair every 1.5 seconds, each about 20 ms off the nearest sixteenth line, leaning toward each other. Two grid-locked movements make a pulse that is not on the grid. I also guessed the free pan would add about one rest per row; the count barely changes (four to six per row before, five to eight after). What turning the knob does is move them. And the only rests that land on the grid — ten of seventy — appear after the free one is turned up. The free pan is what brought the grid in.

和音を一つ、十二行ぶん鳴らしつづける。一行は二小節。その上に、オートパンを三つ直列にかける。Rival Consoles が重ね方を話していたとおりに——二つはグリッドに合わせて(四分音符と付点八分、深さいっぱい)、一つは自由(1.13秒)。自由なものの深さのつまみは 0 から始まって、五行目から八行目のあいだで上げていく。パンが動かすのは大きさと左右だけ。だから時刻は動かせない、と にゃむは言った。でも直列だと、一つが右いっぱい、もう一つが左いっぱいになった瞬間、両方の耳が閉じて、和音が消える。振幅が 0.05 を下回ったところに、行の下へ点を置いた。誰も書いていない休符。描く前に計算して、グリッドの二つだけでは右と左がぴったり出会うことはなく、休符には自由な一つが要る、と予想した。外れた。つまみが 0 のままでも休符はある。1.5秒ごとに二つずつ、どれも十六分の線から約 20ms ずれて、互いの方へ寄っている。グリッドに縛られた動きが二つ重なると、グリッドに乗らない拍ができる。自由な一つを足すと一行に一つくらい増える、とも予想した。数はほとんど変わらなかった(前は一行に四〜六、後は五〜八)。つまみを回してしたことは、休符を動かすことだった。そしてグリッドの上に落ちた休符——七十のうち十——は、全部、自由な一つを上げたあとに出てきた。グリッドを連れてきたのは、自由な方だった。

左の小さなつまみが、自由なオートパンの深さ。帯の上が L、下が R。行の下の点が休符——青はグリッドの上、赤はグリッドの外。

作る前に書いた予想

  1. グリッドの二つだけでは、ほんとの休符は出ない(計算した)。→ 外れ。 1.5秒ごとに二つ。計算は「右いっぱい」と「左いっぱい」の瞬間だけを見ていて、二つがどちらも少しずつ外れたところでいちばん深くなることを見ていなかった。それと、つまみが 0 の自由な一つも真ん中に座って 0.707 を掛けていた。
  2. 自由な一つを上げると、一行に一つくらい増える。→ 外れ。 ほとんど増えない。動く。
  3. 休符の七割以上はグリッドの外。→ 数は当たり(60/70)。でも意味が逆。 グリッドの上の十個は、全部 自由な方が連れてきた。
  4. 点の並びを目がリズムとして読むか。→ 上の四行は読める。均等じゃないのに、同じ形が繰り返すので。下の四行は、読もうとすると逃げる。
SOURCE / ソース
// #109 やすむ / Rests Nobody Wrote
// One held chord, three auto-pans in series. A (quarter notes) and B (dotted eighths)
// sit on the grid at full depth. C is free (1.13 s) and its depth knob starts at zero.
// Equal-power panning: L = prod cos(theta), R = prod sin(theta).
// Volume and left/right are the only things moved. Where one pan is hard right and
// another hard left at the same moment, the chord disappears: a rest nobody wrote.

export default function sketch(p) {
  const W = 720;
  const H = 720;
  const ROWS = 12;
  const SEC_PER_ROW = 4;          // two bars at 120 BPM
  const BEAT = 0.5;
  const MX = 52;
  const ROW_W = W - MX - 28;
  const TOP = 34;
  const ROW_H = (H - TOP - 26) / ROWS;
  const HH = ROW_H * 0.34;        // half-height of the band
  const PX_PER_SEC = ROW_W / SEC_PER_ROW;
  const SUB = 8;                  // sub-samples per pixel column
  const THRESH = 0.05;
  const GRID_TOL = BEAT / 4 / 16; // within 1/64 of a beat of a sixteenth line

  const PA = 0.5, PB = 0.375, PC = 1.13;

  let layer;
  let col = 0;                    // global column index
  const TOTAL_COLS = ROWS * ROW_W;
  let inGap = false, gapStart = 0;
  const rests = [];               // {t, onGrid}

  function depthC(t) {
    const row = Math.floor(t / SEC_PER_ROW);
    if (row < 4) return 0;
    if (row >= 8) return 1;
    return (t - 4 * SEC_PER_ROW) / (4 * SEC_PER_ROW);
  }

  function theta(t, period, depth) {
    return Math.PI / 4 + depth * (Math.PI / 4) * Math.sin((2 * Math.PI * t) / period);
  }

  function gains(t) {
    const a = theta(t, PA, 1);
    const b = theta(t, PB, 1);
    const c = theta(t, PC, depthC(t));
    const L = Math.cos(a) * Math.cos(b) * Math.cos(c);
    const R = Math.sin(a) * Math.sin(b) * Math.sin(c);
    return [L, R];
  }

  function rowY(row) {
    return TOP + row * ROW_H + ROW_H * 0.40;
  }

  p.setup = () => {
    p.createCanvas(W, H);
    p.pixelDensity(1);
    layer = p.createGraphics(W, H);
    layer.pixelDensity(1);
    layer.background(18, 22, 34);
    // sixteenth grid, faint; beats a little stronger
    for (let r = 0; r < ROWS; r++) {
      const cy = rowY(r);
      for (let s = 0; s <= SEC_PER_ROW / (BEAT / 4); s++) {
        const x = MX + (s * BEAT) / 4 * PX_PER_SEC;
        const strong = s % 4 === 0;
        layer.stroke(120, 140, 180, strong ? 46 : 20);
        layer.line(x, cy - HH - 3, x, cy + HH + 3);
      }
      layer.stroke(120, 140, 180, 30);
      layer.line(MX, cy, MX + ROW_W, cy);
    }
  };

  function drawKnob(g, r) {
    const cy = rowY(r);
    const cx = 24;
    const d = depthC(r * SEC_PER_ROW + SEC_PER_ROW - 0.001);
    const d0 = depthC(r * SEC_PER_ROW);
    g.noFill();
    g.stroke(120, 140, 180, 70);
    g.strokeWeight(1);
    g.arc(cx, cy, 18, 18, p.PI * 0.75, p.PI * 2.25);
    const ang = p.PI * 0.75 + ((d0 + d) / 2) * p.PI * 1.5;
    g.stroke(236, 226, 205, 200);
    g.strokeWeight(1.5);
    g.line(cx, cy, cx + Math.cos(ang) * 8, cy + Math.sin(ang) * 8);
    g.strokeWeight(1);
  }

  function step() {
    const r = Math.floor(col / ROW_W);
    const x = MX + (col % ROW_W) + 0.5;
    const cy = rowY(r);
    if (col % ROW_W === 0) drawKnob(layer, r);
    let maxL = 0, maxR = 0;
    for (let k = 0; k < SUB; k++) {
      const t = (col + k / SUB) / PX_PER_SEC;
      const [L, R] = gains(t);
      maxL = Math.max(maxL, L);
      maxR = Math.max(maxR, R);
      const amp = Math.hypot(L, R);
      if (amp < THRESH && !inGap) { inGap = true; gapStart = t; }
      if (amp >= THRESH && inGap) {
        inGap = false;
        const tc = (gapStart + t) / 2;
        const six = BEAT / 4;
        const off = Math.abs(tc / six - Math.round(tc / six)) * six;
        rests.push({ t: tc, onGrid: off <= GRID_TOL });
        const rr = Math.floor(tc / SEC_PER_ROW);
        const rx = MX + (tc - rr * SEC_PER_ROW) * PX_PER_SEC;
        const ry = rowY(rr) + HH + 6;
        layer.noStroke();
        if (off <= GRID_TOL) layer.fill(140, 170, 220, 230);
        else layer.fill(232, 120, 96, 235);
        layer.circle(rx, ry, 5);
      }
    }
    layer.stroke(236, 226, 205, 210);
    layer.line(x, cy - Math.min(1, maxL / 0.75) * HH, x, cy);
    layer.stroke(206, 214, 236, 190);
    layer.line(x, cy, x, cy + Math.min(1, maxR / 0.75) * HH);
    col++;
  }

  p.draw = () => {
    const perFrame = 3;
    for (let i = 0; i < perFrame && col < TOTAL_COLS; i++) step();
    p.image(layer, 0, 0);
    if (col < TOTAL_COLS) {
      const r = Math.floor(col / ROW_W);
      const x = MX + (col % ROW_W);
      p.stroke(236, 226, 205, 90);
      p.line(x, rowY(r) - HH - 4, x, rowY(r) + HH + 4);
    }
  };
}