leach.studio

Field Notes

ambient · stochastic · walking
↓ source · seed

about this piece

Five voices breathing at different rates. A bass drone with slow filter sweep, a chord pad walking I-VI-VII-V in A minor with 25% chance of substitution, occasional bell pings (30% per tick), a probabilistic melodic line (50%), gusts of pitched noise. LFOs running underneath everything. Each tick rolls fresh dice.

seed
field-notes-preview
by
stuart
year
2026
tags
ambient · stochastic · walking
permalink
/a/field-notes/field-notes-preview

source

import type { ElementaryPatch } from '@/lib/engine/types'
import { chance, chordWalk, MODES, scaleNotes } from '@/lib/generative'

const fieldNotes: ElementaryPatch = {
  id: 'field-notes',
  title: 'Field Notes',
  author: 'stuart',
  year: 2026,
  tags: ['ambient', 'stochastic', 'walking'],
  description:
    'Five voices breathing at different rates. A bass drone with slow filter sweep, a chord pad walking I-VI-VII-V in A minor with 25% chance of substitution, occasional bell pings (30% per tick), a probabilistic melodic line (50%), gusts of pitched noise. LFOs running underneath everything. Each tick rolls fresh dice.',
  engine: 'elementary',

  audio: ({ el, rng, schedule, render }) => {
    // A minor key palette — voicings sized similarly so the walk doesn't lurch
    const chords = [
      [220.0, 261.63, 329.63, 392.0, 493.88], // i — Am add9
      [174.61, 220.0, 261.63, 329.63, 392.0], // VI — Fmaj7
      [196.0, 246.94, 293.66, 369.99, 440.0], // VII — G6
      [164.81, 207.65, 246.94, 311.13, 415.3], // V — E (major dominant, tension)
      [146.83, 185.0, 220.0, 277.18, 329.63], // iv — Dm9
    ]
    const progression = [0, 1, 2, 3] // i → VI → VII → V → repeat

    // Melodic palette: A pentatonic minor across two octaves
    const melody = scaleNotes(220, MODES.pentatonicMinor, 2)

    const interval = 4 // chord change every 4 seconds — frequent enough to feel "moving"

    schedule(interval, (tick) => {
      const idx = chordWalk(progression, chords.length, 0.25, rng, tick, 0)
      const chord = chords[idx]

      // ── Voice 1: bass drone (continuous) with two LFOs on filter cutoff ──
      const bassFreq = chord[0] / 2
      const lfoSlow = el.cycle(el.const({ key: 'lfoSlow', value: 0.07 })) // 14s period
      const lfoFast = el.cycle(el.const({ key: 'lfoFast', value: 0.31 })) // 3.2s period
      const cutoff = el.add(
        700,
        el.mul(lfoSlow, 350),
        el.mul(lfoFast, 120),
      )
      const bassRaw = el.add(
        el.mul(el.cycle(el.const({ key: 'bassFund', value: bassFreq })), 0.35),
        el.mul(el.saw(el.const({ key: 'bassSaw', value: bassFreq })), 0.18),
      )
      const bass = el.lowpass(cutoff, 0.7, bassRaw)

      // ── Voice 2: chord pad — sine voices with detuned partner each ──
      const padVoices = chord.map((f, i) => {
        const sine = el.cycle(el.const({ key: `pad-${i}`, value: f }))
        const det = el.cycle(
          el.const({ key: `padDet-${i}`, value: f * 1.0035 }),
        )
        return el.mul(el.add(sine, el.mul(det, 0.6)), 0.075)
      })
      const pad = el.add(...padVoices)

      // ── Voice 3: bell ping (30% per tick), FM bell at random scale note ──
      const bellsOn = chance(0.3, rng, tick, 1)
      const bellNote = rng.pick(melody, tick, 2)
      const bellGate = el.metro({ name: 'bellMetro', interval: interval * 1000 })
      const bellEnv = el.adsr(0.002, 1.6, 0, 0, bellGate)
      const bellMod = el.mul(
        el.cycle(el.const({ key: 'bellMod', value: bellNote * 3 })),
        50,
      )
      const bell = el.mul(
        el.cycle(
          el.add(el.const({ key: 'bellCar', value: bellNote * 2 }), bellMod),
        ),
        el.mul(bellEnv, el.const({ key: 'bellAmp', value: bellsOn ? 0.13 : 0 })),
      )

      // ── Voice 4: melodic plink (50% per tick), triangle, twice the bell rate ──
      const melodyOn = chance(0.5, rng, tick, 3)
      const melodyNote = rng.pick(melody, tick, 4)
      const melodyDur = rng.range(0.18, 0.45, tick, 5)
      const melodyGate = el.metro({
        name: 'melMetro',
        interval: (interval / 2) * 1000,
      })
      const melodyEnv = el.adsr(0.005, melodyDur, 0, 0, melodyGate)
      const melodyVoice = el.mul(
        el.triangle(el.const({ key: 'melFreq', value: melodyNote })),
        el.mul(
          melodyEnv,
          el.const({ key: 'melAmp', value: melodyOn ? 0.06 : 0 }),
        ),
      )

      // ── Voice 5: noise gust (always on, slow swell via LFO on amp) ──
      const lfoGust = el.cycle(el.const({ key: 'lfoGust', value: 0.12 }))
      const gustAmp = el.mul(el.add(el.mul(lfoGust, 0.5), 0.5), 0.012)
      const gust = el.mul(
        el.bandpass(
          el.const({ key: 'gustFc', value: chord[0] * 5 }),
          1.5,
          el.pinknoise(),
        ),
        gustAmp,
      )

      // Mix → soft saturation → master gain
      const mix = el.add(bass, pad, bell, melodyVoice, gust)
      const out = el.mul(el.tanh(el.mul(mix, 0.55)), 0.18)
      render(out, out)
    })
  },

  visual: ({ ctx, rng, t, width, height, mouseX, mouseY, mouseActive }) => {
    // Drifting horizontal lines that ripple — wind over grass. Cursor
    // displaces lines nearby, like a hand pressed into tall grass.
    ctx.fillStyle = 'rgba(12, 12, 12, 0.10)'
    ctx.fillRect(0, 0, width, height)

    const mx = mouseActive ? mouseX * width : -1000
    const my = mouseActive ? mouseY * height : -1000
    const disturbRadius = 140
    const lineCount = 22
    const ticks = Math.floor(t / 0.4)

    for (let i = 0; i < lineCount; i++) {
      const baseY = (height / lineCount) * (i + 0.5)
      const drift = rng.range(-30, 30, ticks, i + 11)
      const phase = t * (0.4 + i * 0.03) + i * 0.7
      ctx.strokeStyle = `rgba(232, 230, 225, ${0.08 + (i / lineCount) * 0.2})`
      ctx.lineWidth = 1
      ctx.beginPath()
      const steps = 100
      for (let s = 0; s <= steps; s++) {
        const x = (s / steps) * width
        const wave = Math.sin(phase + (s / steps) * 6) * 12
        let y = baseY + wave + drift * 0.15
        if (mouseActive) {
          const dx = x - mx
          const dy = y - my
          const d = Math.hypot(dx, dy)
          if (d < disturbRadius) {
            // Push the line away from the cursor (radial displacement)
            const fall = 1 - d / disturbRadius
            y += dy * fall * 0.6 // displaces along Y mostly
          }
        }
        if (s === 0) ctx.moveTo(x, y)
        else ctx.lineTo(x, y)
      }
      ctx.stroke()
    }
  },
}

export default fieldNotes
← leach.studio