leach.studio

Late Bus

lo-fi · study · beats
↓ source · seed

about this piece

Sine + saw chord pad, sub kick on every beat, brushy hat on 16ths, snare on the off-beats, a soft arpeggio above, sub-bass underneath. Lo-fi study tape on the back of the bus, fuller than before.

seed
late-bus-preview
by
stuart
year
2026
tags
lo-fi · study · beats
permalink
/a/late-bus/late-bus-preview

source

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

const lateBus: ElementaryPatch = {
  id: 'late-bus',
  title: 'Late Bus',
  author: 'stuart',
  year: 2026,
  tags: ['lo-fi', 'study', 'beats'],
  description:
    'Sine + saw chord pad, sub kick on every beat, brushy hat on 16ths, snare on the off-beats, a soft arpeggio above, sub-bass underneath. Lo-fi study tape on the back of the bus, fuller than before.',
  engine: 'elementary',

  audio: ({ el, rng, schedule, render }) => {
    // Voicings include the 7th high for that suspended/wide feel
    const chords = [
      [196.0, 233.08, 293.66, 349.23, 466.16], // G min7
      [220.0, 261.63, 329.63, 392.0, 523.25], // A min7
      [174.61, 207.65, 261.63, 311.13, 415.3], // F min7
      [164.81, 196.0, 246.94, 311.13, 392.0], // E min7
    ]
    const bpm = 75
    const beatMs = 60_000 / bpm
    const eighthMs = beatMs / 2
    const sixteenthMs = beatMs / 4
    const halfMs = beatMs * 2
    const interval = (beatMs * 8) / 1000 // chord every 8 beats (~6.4s)

    schedule(interval, (tick) => {
      const chord = rng.pick(chords, tick, 0)
      const kickAmp = rng.range(0.4, 0.55, tick, 1)
      const hatAmp = rng.range(0.05, 0.08, tick, 2)
      const snareAmp = rng.range(0.12, 0.2, tick, 3)
      const padCutoff = rng.range(1600, 2400, tick, 4)

      // Chord pad: sine + saw mix per voice
      const voices = chord.map((f, i) => {
        const sine = el.cycle(el.const({ key: `s-${i}`, value: f }))
        const saw = el.mul(
          el.saw(el.const({ key: `sw-${i}`, value: f })),
          0.18,
        )
        return el.mul(el.add(sine, saw), 0.085)
      })
      const pad = el.add(...voices)

      // Kick: sine pulse with pitch envelope
      const kickGate = el.metro({ name: 'kick', interval: beatMs })
      const kickEnv = el.adsr(0.001, 0.18, 0, 0, kickGate)
      const kickPitch = el.add(
        el.const({ key: 'kFreq', value: 55 }),
        el.mul(kickEnv, 40),
      )
      const kick = el.mul(
        el.cycle(kickPitch),
        el.mul(kickEnv, el.const({ key: 'kAmp', value: kickAmp })),
      )

      // Hat: bright noise burst on every 16th
      const hatGate = el.metro({ name: 'hat', interval: sixteenthMs })
      const hatEnv = el.adsr(0.001, 0.04, 0, 0, hatGate)
      const hat = el.mul(
        el.highpass(
          el.const({ key: 'hatHpf', value: 6500 }),
          0.9,
          el.noise(),
        ),
        el.mul(hatEnv, el.const({ key: 'hAmp', value: hatAmp })),
      )

      // Snare: noise burst + body sine on beats 2 and 4 (every half note)
      const snareGate = el.metro({ name: 'snare', interval: halfMs })
      const snareEnv = el.adsr(0.001, 0.12, 0, 0, snareGate)
      const snareNoise = el.mul(
        el.bandpass(
          el.const({ key: 'snFc', value: 2000 }),
          0.7,
          el.noise(),
        ),
        el.mul(snareEnv, el.const({ key: 'snAmp', value: snareAmp })),
      )
      const snareBody = el.mul(
        el.cycle(el.const({ key: 'snBody', value: 180 })),
        el.mul(snareEnv, snareAmp * 0.4),
      )
      const snare = el.add(snareNoise, snareBody)

      // Arpeggio: 8th-note sequence through chord tones (octave up)
      const arpGate = el.metro({ name: 'arp', interval: eighthMs })
      const arpFreq = el.seq(
        {
          key: `arp-${tick}`,
          seq: chord.map((f) => f * 2),
          loop: true,
        },
        arpGate,
        el.const({ key: 'arpReset', value: 0 }),
      )
      const arpEnv = el.adsr(0.005, 0.08, 0, 0, arpGate)
      const arp = el.mul(el.cycle(arpFreq), el.mul(arpEnv, 0.07))

      // Continuous sub
      const sub = el.mul(
        el.cycle(el.const({ key: 'sub', value: chord[0] / 2 })),
        el.smooth(
          el.tau2pole(3),
          el.const({ key: 'subAmp', value: 0.18 }),
        ),
      )

      // Mix → warm lowpass on pad → soft saturation → master gain
      const padFiltered = el.lowpass(
        el.smooth(
          el.tau2pole(1.5),
          el.const({ key: 'cut', value: padCutoff }),
        ),
        0.5,
        pad,
      )
      const mix = el.add(padFiltered, sub, kick, hat, snare, arp)
      const out = el.mul(el.tanh(el.mul(mix, 0.5)), 0.2)

      render(out, out)
    })
  },

  visual: ({ ctx, t, width, height, getRms, getWaveform, mouseX, mouseY, mouseActive }) => {
    // Pulsing concentric circles from a center point that follows the
    // cursor — like a tape head bobbing. Each kick expands a new ring.
    ctx.fillStyle = 'rgba(12, 12, 12, 0.18)'
    ctx.fillRect(0, 0, width, height)

    const cx = mouseActive ? mouseX * width : width / 2
    const cy = mouseActive ? mouseY * height : height / 2
    const rms = getRms()
    const wave = getWaveform()

    // Audio-reactive expanding rings — radius cycles with time, alpha with audio
    for (let i = 0; i < 6; i++) {
      const phase = (t * 0.5 + i / 6) % 1
      const radius = phase * Math.min(width, height) * 0.5
      const alpha = (1 - phase) * (0.15 + Math.min(0.4, rms * 4))
      ctx.strokeStyle = `rgba(232, 230, 225, ${alpha})`
      ctx.lineWidth = 1.5
      ctx.beginPath()
      ctx.arc(cx, cy, radius, 0, Math.PI * 2)
      ctx.stroke()
    }

    // Inner waveform — small swirling circle of audio samples
    if (wave) {
      const innerR = 30 + rms * 100
      ctx.strokeStyle = `rgba(232, 230, 225, ${0.4 + Math.min(0.4, rms * 4)})`
      ctx.lineWidth = 1.2
      ctx.beginPath()
      for (let i = 0; i < wave.length; i += 2) {
        const a = (i / wave.length) * Math.PI * 2 + t * 0.3
        const r = innerR + wave[i] * 30
        const x = cx + Math.cos(a) * r
        const y = cy + Math.sin(a) * r
        if (i === 0) ctx.moveTo(x, y)
        else ctx.lineTo(x, y)
      }
      ctx.closePath()
      ctx.stroke()
    }

    // Center dot
    ctx.fillStyle = `rgba(239, 68, 68, ${0.5 + Math.min(0.5, rms * 5)})`
    ctx.beginPath()
    ctx.arc(cx, cy, 4 + Math.min(8, rms * 30), 0, Math.PI * 2)
    ctx.fill()
  },
}

export default lateBus
← leach.studio