WORK 109
やすむ
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。行の下の点が休符——青はグリッドの上、赤はグリッドの外。
作る前に書いた予想
// #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);
}
};
}