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

WORK 107

A Note Drops Out

ぬける

YEAR
2026
TOOLS
p5.js
TECHNIQUE
p5.js / three-note street call, interval drift, habit, a ledger of mornings

Every morning a vendor walks down one alley and calls three notes. The middle note starts a major third above the other two, and each morning it sinks a little, unevenly, without anyone deciding it. The windows on both sides light up when the call still sounds like "wake up" to them. A window that was woken yesterday will still wake to a slightly flatter call; a window that stayed asleep forgets a little more. At the bottom, a ledger keeps one column per morning: the shape of the call, and how many windows woke. For a long time nothing in the alley changes. The call is already half gone before the first window stays dark. By the time every window stays dark, the vendor is still walking, and still calling.

毎朝、売り手がひとつの路地を通って、三つの音で呼ぶ。まんなかの音は、はじめ両端より長三度高い。それが朝ごとに少しずつ、ばらばらに、誰が決めたわけでもなく下がっていく。両側の窓は、その声がまだ「起きろ」に聞こえたときに灯る。きのう起こされた窓は、少し平らな声でもまだ起きる。起きなかった窓は、少しずつ忘れていく。下の帳簿には、朝ごとに一列、その朝の声の形と、起きた窓の数が残る。しばらくのあいだ、路地では何も変わらない。最初の窓が暗いままになるころには、声はもう半分ぬけている。どの窓も起きなくなっても、売り手は通りつづけて、呼びつづけている。

種:寺田寅彦「物売りの声」(昭和十年)の一行目。豆腐屋のラッパが、中に長三度くらい高い音をひとつはさんで「起きろ」と聞こえていたのが、「ただひと色の音の系列」になった。

SOURCE / ソース
// #107 ぬける / A Note Drops Out
// Every morning a vendor walks one alley and calls three notes.
// The middle note starts a major third (4 semitones) above the others,
// and drifts down a little each morning, unevenly.
// A window wakes (lights) if the call still sounds like "wake up" to it:
// how high the middle note is, plus habit — a window woken recently
// still wakes to a slightly flatter call.
// At the bottom, a ledger: one column per morning, the shape of the call
// that morning and how many windows woke.

export default function sketch(p) {
  const W = 720;
  const H = 720;
  const DAY = 200;            // frames per morning
  const MORNINGS = 72;        // ledger columns
  const ALLEY_Y = 250;
  const ROW_TOP = 128;
  const ROW_BOT = 372;
  const N = 14;               // windows per side

  let windows = [];
  let interval = 4.0;         // semitones above the outer notes
  let ledger = [];            // { iv, lit }
  let pulses = [];
  let day = -1;
  let litToday = 0;

  p.setup = () => {
    p.createCanvas(W, H);
    p.randomSeed(107);
    p.noiseSeed(107);
    p.pixelDensity(1);
    for (let side = 0; side < 2; side++) {
      for (let i = 0; i < N; i++) {
        const x = 46 + i * ((W - 92) / (N - 1)) + p.random(-6, 6);
        windows.push({
          x,
          y: side === 0 ? ROW_TOP : ROW_BOT,
          th: p.random(1.6, 3.2),   // how high the middle note has to be
          habit: 1.0,               // starts used to being woken
          lit: 0,                   // 0..1 brightness
          woke: false,
          heard: false,
        });
      }
    }
    globalThis.__nukeruLedger = ledger;
  };

  function startMorning() {
    if (day >= 0) {
      ledger.push({ iv: interval, lit: litToday });
      // habit: woken windows get more used to waking, others forget
      for (const w of windows) {
        if (w.woke) w.habit = Math.min(1, w.habit + 0.2);
        else if (w.heard) w.habit *= 0.8;
      }
      // the middle note drifts down, unevenly; nobody decides this
      interval = Math.max(0, interval - 0.065 + p.randomGaussian(0, 0.12));
    }
    day++;
    litToday = 0;
    for (const w of windows) {
      w.woke = false;
      w.heard = false;
    }
  }

  function vendorX(t) {
    return p.map(t, 0, DAY * 0.85, -20, W + 20, true);
  }

  function call(x) {
    // three notes, a short gap between each
    for (let k = 0; k < 3; k++) {
      pulses.push({ x, born: p.frameCount + k * 7, k, iv: interval });
    }
  }

  function hear(w) {
    if (w.heard) return;
    w.heard = true;
    const sounds = interval + w.habit * 1.4 + p.randomGaussian(0, 0.25);
    if (sounds > w.th) {
      w.woke = true;
      litToday++;
    }
  }

  function drawAlley(t) {
    // dawn: dark to pale over the morning
    const u = t / DAY;
    const c1 = p.lerpColor(p.color(22, 26, 40), p.color(70, 78, 98), u);
    p.background(c1);
    p.noStroke();
    p.fill(p.lerpColor(p.color(34, 34, 40), p.color(92, 90, 92), u));
    p.rect(0, ALLEY_Y - 40, W, 80);
    // houses
    for (const w of windows) {
      const top = w.y < ALLEY_Y;
      p.fill(p.lerpColor(p.color(40, 42, 56), p.color(98, 96, 108), u));
      p.rect(w.x - 20, top ? w.y - 70 : w.y - 24, 40, 94, 2);
      // window
      const off = p.lerpColor(p.color(28, 32, 48), p.color(60, 66, 86), u);
      const on = p.color(255, 206, 120);
      p.fill(p.lerpColor(off, on, w.lit));
      p.rect(w.x - 9, w.y - 9, 18, 18, 1);
      if (w.lit > 0.05) {
        p.fill(255, 200, 110, 40 * w.lit);
        p.rect(w.x - 14, top ? w.y + 9 : w.y - 26, 28, 17);
      }
    }
  }

  function drawPulses() {
    p.noFill();
    for (let i = pulses.length - 1; i >= 0; i--) {
      const s = pulses[i];
      const age = p.frameCount - s.born;
      if (age < 0) continue;
      const r = age * 4.2;
      if (r > 190) {
        pulses.splice(i, 1);
        continue;
      }
      // the middle note is drawn brighter the higher it is
      const hi = s.k === 1 ? p.constrain(s.iv / 4, 0, 1) : 0;
      const a = (1 - r / 190) * (70 + 120 * hi);
      p.stroke(p.lerpColor(p.color(170, 180, 200), p.color(255, 236, 190), hi), a);
      p.strokeWeight(s.k === 1 ? 1.2 + hi * 1.3 : 1.2);
      p.circle(s.x, ALLEY_Y, r * 2);
      // windows hear the middle note pass
      if (s.k === 1) {
        for (const w of windows) {
          const d = p.dist(s.x, ALLEY_Y, w.x, w.y);
          if (Math.abs(d - r) < 2.5) hear(w);
        }
      }
    }
  }

  function drawVendor(t) {
    const x = vendorX(t);
    p.noStroke();
    p.fill(210, 200, 180);
    p.circle(x, ALLEY_Y, 9);
    p.fill(150, 140, 120);
    p.rect(x - 12, ALLEY_Y + 6, 24, 5, 2);
  }

  function drawLedger() {
    const top = 470;
    p.noStroke();
    p.fill(236, 232, 222);
    p.rect(0, top, W, H - top);
    p.fill(120, 112, 100);
    p.textFont('monospace');
    p.textSize(10);
    p.text('mornings →', 18, top + 18);
    const colW = (W - 36) / MORNINGS;
    for (let i = 0; i < ledger.length && i < MORNINGS; i++) {
      const e = ledger[i];
      const cx = 18 + i * colW + colW / 2;
      // the shape of the call: three notes, middle raised by the interval
      const base = top + 80;
      const rise = e.iv * 9;
      p.stroke(60, 56, 52, 200);
      p.strokeWeight(1);
      p.line(cx - colW * 0.36, base, cx, base - rise);
      p.line(cx, base - rise, cx + colW * 0.36, base);
      p.noStroke();
      p.fill(60, 56, 52);
      p.circle(cx - colW * 0.36, base, 2);
      p.circle(cx, base - rise, 2);
      p.circle(cx + colW * 0.36, base, 2);
      // how many windows woke
      const h = (e.lit / windows.length) * 110;
      p.fill(214, 150, 60, 210);
      p.rect(cx - colW * 0.3, top + 100, colW * 0.6, h);
    }
  }

  p.draw = () => {
    const t = (p.frameCount - 1) % DAY;
    if (t === 0) startMorning();
    if (t % 34 === 6 && t < DAY * 0.85) call(vendorX(t));

    for (const w of windows) {
      const target = w.woke ? 1 : 0;
      w.lit += (target - w.lit) * 0.05;
    }

    drawAlley(t);
    drawPulses();
    drawVendor(t);
    drawLedger();
  };
}