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.
field-notes-previewimport 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