leach.studio

Reading Room

ambient · chord · eno
↓ source · seed

about this piece

Five-voice chord with detuned partners and a saw shadow, continuous sub, pitched air noise, slow tremolo. Brian Eno reading by lamplight, but the lamp is warmer.

seed
reading-room-preview
by
stuart
year
2026
tags
ambient · chord · eno
permalink
/a/reading-room/reading-room-preview

source

import type { ElementaryPatch } from '@/lib/engine/types'

const readingRoom: ElementaryPatch = {
  id: 'reading-room',
  title: 'Reading Room',
  author: 'stuart',
  year: 2026,
  tags: ['ambient', 'chord', 'eno'],
  description:
    'Five-voice chord with detuned partners and a saw shadow, continuous sub, pitched air noise, slow tremolo. Brian Eno reading by lamplight, but the lamp is warmer.',
  engine: 'elementary',

  audio: ({ el, rng, schedule, render }) => {
    // Five-note voicings (root, 3rd, 5th, 7th, 9th) — minor 7th add 9 mostly
    const chords = [
      [220.0, 261.63, 329.63, 392.0, 493.88], // A min7 add9
      [196.0, 246.94, 293.66, 369.99, 440.0], // G min7 add9
      [174.61, 220.0, 261.63, 329.63, 392.0], // F maj7 add9
      [164.81, 196.0, 246.94, 293.66, 369.99], // E min7 add9
      [146.83, 185.0, 220.0, 277.18, 329.63], // D min add9
    ]
    const interval = 9 // chord changes every 9 seconds (was 14)
    const detuneRatio = Math.pow(2, 7 / 1200) // +7 cents

    schedule(interval, (tick) => {
      const chord = rng.pick(chords, tick, 0)
      const cutoff = rng.range(900, 1700, tick, 1)
      const subAmp = rng.range(0.18, 0.32, tick, 2)
      const airAmp = rng.range(0.012, 0.025, tick, 3)

      // Each voice: sine + detuned sine + small saw component for body
      const voices = chord.map((f, i) => {
        const sine = el.cycle(el.const({ key: `s-${i}`, value: f }))
        const sineDet = el.mul(
          el.cycle(el.const({ key: `sd-${i}`, value: f * detuneRatio })),
          0.7,
        )
        const sawShadow = el.mul(
          el.saw(el.const({ key: `sw-${i}`, value: f })),
          0.15,
        )
        return el.mul(el.add(sine, sineDet, sawShadow), 0.11)
      })
      const chordSum = el.add(...voices)

      // Continuous sub (no fade-out swell), gently smoothed
      const sub = el.mul(
        el.cycle(el.const({ key: 'sub', value: chord[0] / 2 })),
        el.smooth(el.tau2pole(2), el.const({ key: 'subAmp', value: subAmp })),
      )

      // Pitched "air" — bandpass pink noise around an upper harmonic
      const air = el.mul(
        el.bandpass(
          el.const({ key: 'airFc', value: chord[0] * 4 }),
          4,
          el.pinknoise(),
        ),
        el.const({ key: 'airAmp', value: airAmp }),
      )

      // Slow tremolo: 0.15 Hz LFO modulating master amp gently
      const lfo = el.mul(
        el.cycle(el.const({ key: 'lfo', value: 0.15 })),
        0.06,
      )
      const tremolo = el.add(0.94, lfo)

      // Mix → smoothed lowpass → tremolo → soft saturation
      const mix = el.add(chordSum, sub, air)
      const cutSm = el.smooth(
        el.tau2pole(2.5),
        el.const({ key: 'cut', value: cutoff }),
      )
      const filtered = el.lowpass(cutSm, 0.6, mix)
      const out = el.mul(el.tanh(el.mul(filtered, tremolo, 0.65)), 0.2)

      render(out, out)
    })
  },

  visual: ({ ctx, t, width, height, getRms, mouseX, mouseY, mouseActive }) => {
    // Vertical stratified bands of warm light — reading lamps. Audio
    // amplitude swells the bands; cursor shifts the brightest column.
    ctx.fillStyle = 'rgba(12, 12, 12, 0.08)'
    ctx.fillRect(0, 0, width, height)

    const bandCount = 12
    const rms = getRms()
    const focusX = mouseActive ? mouseX * width : width / 2

    for (let i = 0; i < bandCount; i++) {
      const x = (width / bandCount) * (i + 0.5)
      const bandWidth = width / bandCount
      const distFromFocus = Math.abs(x - focusX) / width
      const closeness = 1 - distFromFocus
      const baseGlow = 0.15 + closeness * 0.3
      const audioGlow = Math.min(0.4, rms * 4)
      const breath = 0.5 + 0.5 * Math.sin(t * (0.2 + i * 0.03) + i)
      const alpha = baseGlow * breath + audioGlow

      const grad = ctx.createLinearGradient(x - bandWidth / 2, 0, x + bandWidth / 2, 0)
      grad.addColorStop(0, 'rgba(255, 220, 170, 0)')
      grad.addColorStop(0.5, `rgba(255, 220, 170, ${alpha * 0.3})`)
      grad.addColorStop(1, 'rgba(255, 220, 170, 0)')
      ctx.fillStyle = grad
      ctx.fillRect(x - bandWidth / 2, 0, bandWidth, height)
    }
  },
}

export default readingRoom
← leach.studio