PatSoundDocs.kt
src/jvmMain/kotlin/afield/audio/synth/PatSoundDocs.kt · 875 lines · cited by Patterns
@file:afield.runtime.completion.CompletionCategory("Patterns")
package afield.audio.synth
import afield.audio.engine.WavFloatArrayWriter
import java.io.File
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
FIELD_TEST id: synth.pat.sound runner: jvm-main entrypoint: afield.audio.synth.PatSoundDocs tags: [smoke, synth, pattern, tidal, audio, wav, capture, cpu] timeoutSec: 180 expect: exitCode: 0 stdoutContains: - "pat sound docs rendered"
Headless offline sound renderer for the pattern language: the audible companion to
PatPianoRollGallery's piano rolls, in the same harness shape as DoughDspSoundDocs.
Every scene schedules a pattern and runs its events through patRenderStereo with a
PatVoiceSpec — one oscillator, one one-pole lowpass, one exponential-decay envelope — reading
each event's own gain, pan, cutoff and sustain annotations. There is no realtime engine,
no audio device, no GPU and no Field window, and the render is deterministic, so a committed
.wav regenerates byte-stably.
A before and an after in one file
Several scenes splice two versions of one pattern together with whenCycle({ it >= 2 }): the
opening cycles play it without the operator under discussion and the closing cycles play the
same pattern with it, so the difference arrives in a single listen. Those scenes check their two
halves against each other, requiring the modulated half's level to vary by some multiple of the
flat half's, rather than against a remembered number, so retuning the voice leaves the check
meaningful.
What each render is checked against
As a FIELD_TEST (synth.pat.sound) every scene is rendered to a temp dir and asserted: a
RIFF/WAVE header whose declared channel count, sample rate and data size agree with the bytes on
disk; a duration within a tenth of a second of what the scene's own cycle count and tempo imply;
peak and RMS inside bounds that catch silence and runaway level; and then each scene's own
verify, which is where the musical claim lives. Those range from counting onsets to checking
that the filter sweep brightens, that the voice-led chords sound the notes the pattern says, and
that jux decorrelates the two channels. It writes no committed assets during the test. Pass an
output dir as args[0] (or set PAT_SOUND_DIR) to regenerate the committed captures.
Add a sound by appending one Scene to SCENES.
object PatSoundDocs
The catalogue of rendered pattern scenes, and the FIELD_TEST entry point that renders them.
object PatSoundDocs {
const val SR = 44_100
const val ONSET_WINDOW_MS = 12.0
The analysis-window length onsetPeaks uses, in milliseconds. Its timing bias, too.
scene model
class Stereo(val left: FloatArray, val right: FloatArray)
Stereo audio: two equal-length channels in roughly [-1, 1].
val frames: Int get() = left.size
val mono: FloatArray get() = FloatArray(frames) { 0.5f * (left[it] + right[it]) }
class Scene
class Scene(
val name: String,
val title: String,
/** One line describing what you hear. */
val blurb: String,
val cycles: Int,
/** Cycles per second — percussive scenes want 2, a chord scene wants room to breathe. */
val cps: Double,
val render: (Int, Double) -> Stereo,
/** Deeper correctness check on the rendered audio. */
val verify: Scene.(Stereo) -> Unit = {}
) {
val seconds: Double get() = cycles / cps
val cycleSamples: Double get() = SR / cps
Samples per cycle at this scene's tempo — the unit its timing checks are written in.
private const val CPS = 2.0
val SCENES: List<Scene>
body — 346 lines, from line 97
val SCENES: List<Scene> = listOf(
// 1 ── the parameter counterfactual, in one file: flat, then following a sine.
Scene(
name = "gain-lfo-before-after",
title = "gain(sine()) — two cycles flat, then two cycles following the LFO",
blurb = "Sixteen hats a cycle. The first two cycles are the pattern as the old surface " +
"could express it — every hat the same level. The last two are the identical pattern " +
"with `gain(sine().range(0.08, 1.0))`; you hear the level breathe once per cycle.",
cycles = 4,
cps = CPS,
render = { cycles, cps ->
val hats = pat("hh*16")
// `whenCycle` is the existing operator for "from cycle 2 on, play this instead".
// (`cat(a, b).slow(2)` looks like it would do the same and does not: `slow` is
// anchored per cycle rather than spanning them, so it doubles the density instead.)
patRenderStereo(
hats.whenCycle({ it >= 2 }) { hats with gain(sine().range(0.08, 1.0)) },
cycles = cycles, cps = cps,
voice = patVoice(wave = PatWave.NOISE, decay = 0.006, sustain = 0.05)
)
},
verify = { audio ->
val onsets = onsetPeaks(audio.mono, threshold = 0.05)
check(onsets.size == 64) { "expected 64 hats, found ${onsets.size}" }
// Per-note RMS, not peak: a noise burst's peak is stochastic even at a fixed gain.
val flat = onsets.take(32).map { it.rms }
val modulated = onsets.drop(32).map { it.rms }
val flatCv = variation(flat)
val modCv = variation(modulated)
println(
" flat half level CV=${"%.4f".format(flatCv)} (range ${"%.3f".format(spread(flat))}), " +
"modulated half CV=${"%.4f".format(modCv)} (range ${"%.3f".format(spread(modulated))})"
)
check(flatCv < 0.12) { "the 'before' half is not flat: CV $flatCv" }
check(modCv > 4.0 * max(1e-6, flatCv)) {
"the modulated half is not audibly modulated: CV $modCv vs $flatCv"
}
// and the modulation really is one sine per cycle: two humps over the two cycles.
check(humpCount(modulated) == 2) { "expected two gain humps, counted ${humpCount(modulated)}" }
}
),
// 2 ── a continuous filter parameter, note by note.
Scene(
name = "cutoff-sweep",
title = "cutoff(sine().rangex(300, 6000)) — a filter curve read per note",
blurb = "Sixteen sawtooth notes a cycle through a one-pole lowpass whose cutoff is a " +
"sine in log-frequency. Each note latches one value of the curve, so the timbre " +
"opens and closes once per cycle without the note count changing.",
cycles = 4,
cps = CPS,
render = { cycles, cps ->
patRenderStereo(
// MIDI 45 (110 Hz), not "bd": a named drum carries its own frequency and wave
// from the drum map, and this scene is about the filter, not the kit.
pat("45*16") with cutoff(sine().rangex(300, 6000)) with gain(0.7),
cycles = cycles, cps = cps,
voice = patVoice(wave = PatWave.SAW, baseHz = 110.0, decay = 0.012, sustain = 0.3)
)
},
verify = { audio ->
val onsets = onsetPeaks(audio.mono, threshold = 0.03)
check(onsets.size == 64) { "expected 64 notes, found ${onsets.size}" }
// Brightness per note must track the curve: bright where the cutoff is high.
val brightness = onsets.map { brightnessOf(audio.mono, it.start, it.end) }
val perCycle = brightness.chunked(16)
perCycle.forEachIndexed { cycle, values ->
val peak = values.indexOf(values.max())
check(peak in 2..5) { "cycle $cycle: brightness peaks at note $peak, not near the quarter" }
val trough = values.indexOf(values.min())
check(trough in 10..13) { "cycle $cycle: brightness troughs at note $trough" }
}
val ratio = brightness.max() / max(1e-9, brightness.min())
println(" brightest / dullest note = ${"%.1f".format(ratio)}x")
check(ratio > 2.0) { "the filter sweep barely changed the timbre: ${ratio}x" }
}
),
// 3 ── the pitch surface, audibly: a voice-led progression, arpeggiated.
Scene(
name = "voice-leading",
title = "chords(\"c'maj7 a'min7 d'min7 g'dom7\") — as written, then voice-led",
blurb = "The same four-chord progression twice, arpeggiated. The first two cycles are " +
"spelled from each chord's own root and the hand leaps; the last two are " +
"`voiceLead()`, which holds the common tones and moves 9 semitones instead of 83.",
cycles = 4,
// Half the tempo of the percussive scenes: each arpeggio note needs enough cycles of
// its own waveform for the pitch check below to be a real measurement.
cps = 1.0,
render = { cycles, cps ->
val progression = chords("c'maj7 a'min7 d'min7 g'dom7")
val asWritten = progression.arpeggiate().texts()
val voiceLed = progression.voiceLead(center = 60).arpeggiate().texts()
patRenderStereo(
asWritten.whenCycle({ it >= 2 }) { voiceLed },
cycles = cycles, cps = cps,
voice = patVoice(wave = PatWave.SINE, decay = 0.02, sustain = 0.45)
)
},
verify = { audio ->
val onsets = onsetPeaks(audio.mono, threshold = 0.02)
check(onsets.size == 64) { "expected 64 arpeggiated notes, found ${onsets.size}" }
// The audio must actually sound the notes the pattern says. Estimate each note's
// pitch from the signal and compare with the scheduled MIDI number.
val progression = chords("c'maj7 a'min7 d'min7 g'dom7")
val expected = (
cycleNotes(progression.arpeggiate(), 0) + cycleNotes(progression.arpeggiate(), 1) +
cycleNotes(progression.voiceLead(center = 60).arpeggiate(), 0) +
cycleNotes(progression.voiceLead(center = 60).arpeggiate(), 1)
)
check(expected.size == 64) { "expected 64 scheduled notes, the pattern gave ${expected.size}" }
var worst = 0.0
onsets.zip(expected).forEachIndexed { index, (onset, midi) ->
val hz = estimateHz(audio.mono, onset.start, onset.end)
val heard = 69.0 + 12.0 * (Math.log(hz / 440.0) / Math.log(2.0))
val error = abs(heard - midi)
worst = max(worst, error)
check(error < 0.6) { "note $index: pattern says MIDI $midi, audio sounds ${"%.2f".format(heard)} (${"%.1f".format(hz)} Hz)" }
}
println(" worst pitch error across 64 notes = ${"%.3f".format(worst)} semitones")
// and the second half really is the compressed, voice-led register.
val writtenSpan = expected.take(32).let { it.max() - it.min() }
val ledSpan = expected.drop(32).let { it.max() - it.min() }
println(" as-written spans $writtenSpan semitones, voice-led spans $ledSpan")
check(ledSpan < writtenSpan) { "voice leading did not narrow the register: $ledSpan vs $writtenSpan" }
}
),
// 4 ── jux: the stereo image is the point, so the file is really stereo.
Scene(
name = "jux-stereo",
title = "jux { rev() } — forwards in one ear, backwards in the other",
blurb = "A four-step pattern hard-panned left, and the same pattern reversed hard-panned " +
"right. The two channels carry genuinely different rhythms — the file is stereo " +
"precisely because folding it to mono would destroy what it demonstrates.",
cycles = 4,
cps = CPS,
render = { cycles, cps ->
patRenderStereo(
pat("bd ~ sd hh").jux { it.rev() } with gain(0.8),
cycles = cycles, cps = cps,
voice = patVoice(wave = PatWave.SQUARE, decay = 0.02, sustain = 0.2)
)
},
verify = { audio ->
val left = onsetPeaks(audio.left, threshold = 0.05)
val right = onsetPeaks(audio.right, threshold = 0.05)
check(left.size == 12) { "left channel should carry 3 onsets x 4 cycles, found ${left.size}" }
check(right.size == 12) { "right channel should carry 3 onsets x 4 cycles, found ${right.size}" }
// The channels must be genuinely decorrelated, not a panned copy.
val correlation = correlation(audio.left, audio.right)
println(" L/R correlation = ${"%.3f".format(correlation)}")
check(abs(correlation) < 0.5) { "jux produced near-identical channels: correlation $correlation" }
// And the reversal is visible in the onset positions. "bd ~ sd hh" fires at 0, 1/2
// and 3/4; mirrored about the cycle, those arcs land at 3/4, 1/4 and 0 — so the
// right channel's onsets are 0, 1/4, 3/4. That is the reversal, measured in audio.
// Quantise each onset to the eighth-note grid it was scheduled on. The trailing
// `% 1.0` matters: an onset detected a millisecond early rounds to a whole cycle.
fun positions(onsets: List<Onset>) = onsets
.map { (it.start.toDouble() % cycleSamples) / cycleSamples }
.map { (Math.round(it * 8.0) / 8.0) % 1.0 }
.distinct().sorted()
check(positions(left) == listOf(0.0, 0.5, 0.75)) { "left onsets: ${positions(left)}" }
check(positions(right) == listOf(0.0, 0.25, 0.75)) { "right onsets: ${positions(right)}" }
}
),
// 5 ── off: an echo displaced by an exact eighth, quieter.
Scene(
name = "off-echo",
title = "off(1/8) { gain 0.35 } — a quieter ghost an exact eighth late",
blurb = "Two onsets a cycle, each shadowed by a copy an eighth of a cycle later at just " +
"over a third the level. The displacement is exact rational time, so the echo lands " +
"on the grid rather than near it.",
cycles = 4,
cps = CPS,
render = { cycles, cps ->
patRenderStereo(
pat("bd ~ sd ~").off(1, 8) { it with gain(0.35) },
cycles = cycles, cps = cps,
voice = patVoice(wave = PatWave.SINE, baseHz = 220.0, decay = 0.012, sustain = 0.12)
)
},
verify = { audio ->
val onsets = onsetPeaks(audio.mono, threshold = 0.02)
check(onsets.size == 16) { "expected 2 onsets + 2 echoes x 4 cycles = 16, found ${onsets.size}" }
// Onsets alternate loud, quiet, loud, quiet, an eighth of a cycle apart.
val loud = onsets.filterIndexed { i, _ -> i % 2 == 0 }
val quiet = onsets.filterIndexed { i, _ -> i % 2 == 1 }
val loudMean = loud.map { it.rms }.average()
val quietMean = quiet.map { it.rms }.average()
println(" loud=${"%.3f".format(loudMean)} echo=${"%.3f".format(quietMean)} ratio=${"%.2f".format(quietMean / loudMean)}")
check(quietMean < loudMean * 0.6) { "the echo is not quieter: $quietMean vs $loudMean" }
// Each pair is an onset and its echo. The tolerance is one analysis window (12 ms,
// 0.024 cycles at this tempo): the detector timestamps an onset at the start of the
// window whose level crossed, so a loud note is reported slightly earlier than a
// quiet one. Measured gap 0.108 against the scheduled 0.125 — comfortably an eighth
// rather than a quarter or a sixteenth, which is the claim.
onsets.chunked(2).forEach { (a, b) ->
val gapCycles = (b.start - a.start).toDouble() / cycleSamples
check(abs(gapCycles - 0.125) < 0.03) { "the echo is not an eighth late: $gapCycles cycles" }
}
}
),
// 6 ── the mini-notation additions, heard: `?` degrade over a shuffled grid.
Scene(
name = "degrade-shuffle",
title = "pat(\"hh*16?0.35\").shuffle(4) — deterministic thinning, permuted quarters",
blurb = "Sixteen hats a cycle with a 35% chance of being dropped, over quarters played " +
"in a different order every cycle. Deterministic — the same text always gives the " +
"same groove — but no two cycles are alike.",
cycles = 4,
cps = CPS,
render = { cycles, cps ->
patRenderStereo(
pat("hh*16?0.35").shuffle(4) with gain(0.85),
cycles = cycles, cps = cps,
voice = patVoice(wave = PatWave.NOISE, decay = 0.006, sustain = 0.05)
)
},
verify = { audio ->
val onsets = onsetPeaks(audio.mono, threshold = 0.05)
check(onsets.size in 30..58) { "?0.35 over 64 slots should leave 30..58 hats, left ${onsets.size}" }
val perCycle = (0 until cycles).map { c ->
onsets.count { it.start >= c * cycleSamples && it.start < (c + 1) * cycleSamples }
}
println(" hats per cycle = $perCycle")
check(perCycle.all { it in 4..15 }) { "a cycle came out empty or undegraded: $perCycle" }
check(perCycle.distinct().size > 1) { "every cycle dropped the same number of hats: $perCycle" }
// Determinism: rendering again must be bit-identical.
val again = patRenderStereo(
pat("hh*16?0.35").shuffle(4) with gain(0.85),
cycles = cycles, cps = cps,
voice = patVoice(wave = PatWave.NOISE, decay = 0.006, sustain = 0.05)
)
check(again.left.contentEquals(audio.left)) { "the render is not deterministic" }
}
),
// 7 ── groove: the same eight hats, straight then swung.
Scene(
name = "swing-before-after",
title = "swing(4) — two cycles straight, then two swung",
blurb = "Eight hats a cycle. The first two cycles are dead straight; the last two are " +
"swung, every off-beat pushed exactly a third of an eighth late. It is still exactly " +
"quantised — the shift is rational time, not a nudge in milliseconds.",
cycles = 4,
cps = CPS,
render = { cycles, cps ->
val hats = pat("hh*8")
patRenderStereo(
hats.whenCycle({ it >= 2 }) { hats.swing(4) },
cycles = cycles, cps = cps,
voice = patVoice(wave = PatWave.NOISE, decay = 0.008, sustain = 0.06)
)
},
verify = { audio ->
val onsets = onsetPeaks(audio.mono)
check(onsets.size == 32) { "expected 8 hats x 4 cycles, found ${onsets.size}" }
val gaps = onsets.zipWithNext { a, b -> (b.start - a.start).toDouble() / cycleSamples }
// Skip gap 0: the very first onset sits at sample 0, so it is flagged in the first
// analysis window while every later onset is flagged a window after its true start.
// That makes only the first interval short — measured 0.104 against 0.125.
val straight = gaps.drop(1).take(14)
val swung = gaps.drop(16).take(15)
// Straight: every gap an eighth. The tolerance is the detector's one-window bias.
check(straight.all { abs(it - 0.125) < 0.02 }) { "the straight half is not even: $straight" }
// Swung: gaps alternate long, short — 1/8 + 1/24 then 1/8 - 1/24, i.e. 1/6 and 1/12.
val longs = swung.filterIndexed { i, _ -> i % 2 == 0 }
val shorts = swung.filterIndexed { i, _ -> i % 2 == 1 }
println(
" straight gap = %.4f cycles; swung long = %.4f, short = %.4f, ratio = %.2f".format(
straight.average(), longs.average(), shorts.average(), longs.average() / shorts.average()
)
)
check(longs.all { abs(it - 1.0 / 6.0) < 0.02 }) { "the swung long gaps are wrong: $longs" }
check(shorts.all { abs(it - 1.0 / 12.0) < 0.02 }) { "the swung short gaps are wrong: $shorts" }
check(longs.average() / shorts.average() > 1.6) { "the swing is inaudible" }
// and the straight half really is more even than the swung half.
check(variation(straight) < variation(swung) / 3.0) {
"the two halves are equally uneven: ${variation(straight)} vs ${variation(swung)}"
}
}
),
// 8 ── mini-notation: hear what a `.` does to five notes.
Scene(
name = "dot-grouping",
title = "`.` grouping — five even notes, then two grouped halves",
blurb = "Five notes a cycle. The first two cycles are `bd sd hh hh hh`, five equal fifths. " +
"The last two are `bd sd . hh hh hh` — the same five notes, but the dot groups them " +
"into two halves, so the first two get a quarter each and the last three a sixth each. " +
"Same notes, completely different feel.",
cycles = 4,
cps = 1.0,
render = { cycles, cps ->
patRenderStereo(
pat("bd sd hh hh hh").whenCycle({ it >= 2 }) { pat("bd sd . hh hh hh") },
cycles = cycles, cps = cps,
voice = patVoice(decay = 0.012, sustain = 0.12)
)
},
verify = { audio ->
val onsets = onsetPeaks(audio.mono)
check(onsets.size == 20) { "expected 5 notes x 4 cycles, found ${onsets.size}" }
// The detector reports the start of the window whose level crossed, so a loud onset is
// timestamped nearly a full window EARLY — measured 11 ms against the 12 ms window.
// Uncorrected, the note on a cycle boundary falls into the previous cycle's bucket.
val bias = ONSET_WINDOW_MS / 1000.0 * SR
fun positionsOfCycle(cycle: Int) = onsets
.map { it.start + bias }
.filter { it >= cycle * cycleSamples && it < (cycle + 1) * cycleSamples }
.map { (it % cycleSamples) / cycleSamples }
// Fifths: 0, 0.2, 0.4, 0.6, 0.8. The tolerance is the detector's one-window bias
// (12 ms against a 1 s cycle here).
val even = positionsOfCycle(1)
check(even.size == 5) { "cycle 1 has ${even.size} notes" }
listOf(0.0, 0.2, 0.4, 0.6, 0.8).zip(even).forEach { (want, got) ->
check(abs(got - want) < 0.03) { "even cycle: expected $want, measured $got (all: $even)" }
}
// Grouped: 0, 0.25, 0.5, 0.6667, 0.8333.
val grouped = positionsOfCycle(3)
check(grouped.size == 5) { "cycle 3 has ${grouped.size} notes" }
listOf(0.0, 0.25, 0.5, 2.0 / 3.0, 5.0 / 6.0).zip(grouped).forEach { (want, got) ->
check(abs(got - want) < 0.03) { "grouped cycle: expected $want, measured $got (all: $grouped)" }
}
// And the two are audibly different: the third note moves by a tenth of a cycle.
println(
" even = ${even.map { "%.3f".format(it) }}\n" +
" grouped = ${grouped.map { "%.3f".format(it) }}"
)
check(abs(grouped[2] - even[2]) > 0.05) { "the dot changed nothing audible" }
}
),
)
private val byName: Map<String, Scene> = SCENES.associateBy { it.name }
fun renderWav(name: String, outPath: String)
Offline-render scene name and write a stereo 16-bit PCM WAV to outPath.
fun renderWav(name: String, outPath: String) {
val scene = byName[name] ?: error("unknown PatSoundDocs scene '$name'; known: ${byName.keys.sorted()}")
val audio = scene.render(scene.cycles, scene.cps)
require(audio.frames > 0) { "scene '$name' rendered no samples" }
require(audio.left.any { it != 0f } || audio.right.any { it != 0f }) { "scene '$name' rendered silence" }
WavFloatArrayWriter.writeStereo16(outPath, audio.left, audio.right, SR, normalizePeak = 0.9f)
}
verification helpers
class Onset(val start: Int, val end: Int, val peak: Double, val rms: Double)
A detected note: where it started, where the next one starts, and how loud it was.
Both loudness measures are here because they answer different questions. peak is the
transient; rms is the level. For a noise-based voice the peak of each burst is stochastic
even at a fixed gain — measured spread 0.22 across sixteen identical hats — so any check
about level has to read rms.
fun onsetPeaks
Detects onsets as rising edges of a short-time RMS envelope.
The design is the outcome of measuring three simpler ones against patterns whose note count is known exactly:
- "any sample above a threshold" counts a single noise hat several hundred times;
- non-overlapping abs-peak buckets found 67 onsets where 64 were scheduled, because a noise burst's peak jitters bucket to bucket and fakes rising edges;
- non-overlapping RMS buckets found 9 of 64 on a 110 Hz sawtooth, because a bucket shorter than the waveform's period ripples with the waveform rather than with the envelope.
So: overlapping windows — windowMs long, hopped every hopMs — which decouples
smoothing from time resolution. The rise test compares a window against the one a full
window-length earlier (never an overlapping neighbour), the level test is relative to the
loudest window in the file so a note at gain 0.08 still registers, and the detector only
re-arms once the level falls below releaseFraction of the level of the window that
triggered the last onset. That is not the note's peak: the trigger is the first window to
clear the rise test, which on a steep attack is a point low on the rising edge, so the level
it re-arms against can be a few percent of the peak. Notes separated by less than true
silence can then be missed — DrawnScoreDocs measured a run of 62 ms notes whose gaps fell
to 5% of the peak lose sixteen strikes in one cycle and none in the next, because where the
2 ms hop happened to land against the attack differed between the two.
With the defaults this counts every scene in SCENES exactly right. It is a counter for patterns whose answer is known, not a general onset detector.
body — 56 lines, from line 492
fun onsetPeaks(
samples: FloatArray,
threshold: Double = 0.06,
riseRatio: Double = 1.7,
minGapMs: Double = 12.0,
hopMs: Double = 2.0,
windowMs: Double = ONSET_WINDOW_MS,
releaseFraction: Double = 0.5
): List<Onset> {
val hop = (hopMs / 1000.0 * SR).toInt().coerceAtLeast(1)
val window = (windowMs / 1000.0 * SR).toInt().coerceAtLeast(hop)
if (samples.size < window + hop) return emptyList()
val windows = (samples.size - window) / hop + 1
// Prefix sums of squares, so every window's RMS costs two lookups regardless of overlap.
val cumulative = DoubleArray(samples.size + 1)
for (i in samples.indices) cumulative[i + 1] = cumulative[i] + samples[i].toDouble() * samples[i]
val env = DoubleArray(windows) { k ->
val lo = k * hop
sqrt((cumulative[lo + window] - cumulative[lo]) / window)
}
val globalMax = env.max()
if (globalMax <= 0.0) return emptyList()
val level = threshold * globalMax
val minGapWindows = (minGapMs / hopMs).toInt().coerceAtLeast(1)
val lookBack = (windowMs / hopMs).toInt().coerceAtLeast(1)
val starts = mutableListOf<Int>()
var lastWindow = -minGapWindows * 2
var armed = true
var lastPeak = 0.0
for (k in 0 until windows) {
// The peak is the highest level since the trigger, not the level at the trigger: a
// window fires on the rising edge, and releasing against that edge re-arms too late
// for a sample whose tail never falls below half of it.
if (!armed) lastPeak = max(lastPeak, env[k])
if (!armed && env[k] < releaseFraction * lastPeak) armed = true
if (!armed) continue
if (env[k] < level) continue
val previous = if (k - lookBack >= 0) env[k - lookBack] else 0.0
if (env[k] < riseRatio * max(1e-12, previous)) continue
if (k - lastWindow < minGapWindows) continue
starts += k * hop
lastWindow = k
lastPeak = env[k]
armed = false
}
return starts.mapIndexed { index, start ->
val end = if (index + 1 < starts.size) starts[index + 1] else samples.size
var peak = 0.0
for (j in start until end) peak = max(peak, abs(samples[j]).toDouble())
val energy = cumulative[end] - cumulative[start]
Onset(start, end, peak, sqrt(energy / max(1, end - start)))
}
}
fun spread(values: List<Double>): Double
Peak-to-peak range of a list, relative to its mean — "does this vary at all".
fun spread(values: List<Double>): Double {
if (values.isEmpty()) return 0.0
return (values.max() - values.min()) / max(1e-9, values.average())
}
fun variation(values: List<Double>): Double
Coefficient of variation: standard deviation over mean.
The right statistic for "is this level modulated", where spread is not: a noise voice's per-note level has a long tail, so one lucky burst moves the min-to-max range by a quarter while barely moving the distribution.
fun variation(values: List<Double>): Double {
if (values.size < 2) return 0.0
val mean = values.average()
val v = values.sumOf { (it - mean) * (it - mean) } / values.size
return sqrt(v) / max(1e-9, mean)
}
fun humpCount(values: List<Double>): Int
Number of local maxima in a smoothed series — how many times a modulation went up and down.
fun humpCount(values: List<Double>): Int {
if (values.size < 5) return 0
val smoothed = values.indices.map { i ->
val lo = max(0, i - 1); val hi = min(values.size - 1, i + 1)
(lo..hi).sumOf { values[it] } / (hi - lo + 1)
}
val threshold = (smoothed.max() + smoothed.min()) / 2.0
var humps = 0
var above = false
for (v in smoothed) {
if (!above && v > threshold) { humps++; above = true }
if (above && v < threshold) above = false
}
return humps
}
fun brightnessOf(samples: FloatArray, from: Int, to: Int): Double
First-difference energy over total energy across [from, to) — a brightness proxy.
fun brightnessOf(samples: FloatArray, from: Int, to: Int): Double {
val lo = from.coerceIn(1, samples.size)
val hi = to.coerceIn(lo, samples.size)
var diff = 0.0
var total = 0.0
for (i in lo until hi) {
val d = (samples[i] - samples[i - 1]).toDouble()
diff += d * d
total += samples[i].toDouble() * samples[i]
}
return if (total < 1e-12) 0.0 else diff / total
}
fun estimateHz(samples: FloatArray, from: Int, to: Int, loHz: Double = 40.0, hiHz: Double = 2000.0): Double
Estimates the dominant frequency of [from, to) by autocorrelation over the musical range.
Used to check that the audio really sounds the MIDI note the pattern scheduled — the only
reliable way to verify a pitch claim about a .wav.
body — 42 lines, from line 606
fun estimateHz(samples: FloatArray, from: Int, to: Int, loHz: Double = 40.0, hiHz: Double = 2000.0): Double {
val lo = from.coerceIn(0, samples.size)
// Skip the attack and use a steady middle chunk.
val start = (lo + (to - lo) / 8).coerceIn(0, max(0, samples.size - 1))
val end = min(samples.size, start + (to - lo) * 3 / 4)
if (end - start < 64) return 0.0
val minLag = (SR / hiHz).toInt().coerceAtLeast(2)
val maxLag = min((SR / loHz).toInt(), (end - start) / 2)
if (maxLag <= minLag) return 0.0
var bestLag = minLag
var bestScore = -Double.MAX_VALUE
for (lag in minLag..maxLag) {
var num = 0.0
var den = 1e-12
for (i in start until end - lag) {
num += samples[i].toDouble() * samples[i + lag]
den += samples[i].toDouble() * samples[i]
}
val score = num / den
if (score > bestScore) { bestScore = score; bestLag = lag }
}
// Parabolic refinement around the best integer lag, so the estimate beats the lag grid.
fun scoreAt(lag: Int): Double {
if (lag < minLag || lag > maxLag) return -Double.MAX_VALUE
var num = 0.0
var den = 1e-12
for (i in start until end - lag) {
num += samples[i].toDouble() * samples[i + lag]
den += samples[i].toDouble() * samples[i]
}
return num / den
}
val prev = scoreAt(bestLag - 1)
val next = scoreAt(bestLag + 1)
val refined = if (prev > -1e300 && next > -1e300) {
val denom = prev - 2.0 * bestScore + next
if (abs(denom) < 1e-12) bestLag.toDouble() else bestLag + 0.5 * (prev - next) / denom
} else {
bestLag.toDouble()
}
return SR / refined
}
fun correlation(a: FloatArray, b: FloatArray): Double
Normalised cross-correlation of two channels at zero lag.
fun correlation(a: FloatArray, b: FloatArray): Double {
var num = 0.0
var da = 1e-12
var db = 1e-12
for (i in a.indices) {
num += a[i].toDouble() * b[i]
da += a[i].toDouble() * a[i]
db += b[i].toDouble() * b[i]
}
return num / sqrt(da * db)
}
fun rms(a: FloatArray): Double
RMS of a channel.
fun rms(a: FloatArray): Double {
var s = 0.0
for (v in a) s += v.toDouble() * v
return sqrt(s / max(1, a.size))
}
fun peak(a: FloatArray): Double
Peak of a channel.
fun peak(a: FloatArray): Double {
var p = 0.0
for (v in a) p = max(p, abs(v).toDouble())
return p
}
fun cycleNotes(pattern: PatPattern<Double>, index: Long): List<Double>
The MIDI numbers a numeric pattern schedules in cycle index, in time order.
fun cycleNotes(pattern: PatPattern<Double>, index: Long): List<Double> =
pattern.events(PatArc(PatQ.of(index), PatQ.of(index + 1)))
.sortedWith(compareBy({ it.arc.start }, { it.arc.end }))
.map { it.value }
FIELD_TEST entry point
fun main(args: Array<String>)
body — 54 lines, from line 684
@JvmStatic
fun main(args: Array<String>) {
val outDir = File(
args.getOrNull(0) ?: System.getenv("PAT_SOUND_DIR")
?: File(System.getProperty("java.io.tmpdir"), "pat-sound-docs").path
)
outDir.mkdirs()
SCENES.forEach { scene ->
val audio = scene.render(scene.cycles, scene.cps)
check(audio.frames > 0) { "${scene.name}: rendered no samples" }
check(audio.left.size == audio.right.size) { "${scene.name}: channel length mismatch" }
check(audio.left.all { it.isFinite() } && audio.right.all { it.isFinite() }) {
"${scene.name}: non-finite samples"
}
val expectedFrames = (scene.seconds * SR).toInt()
check(abs(audio.frames - expectedFrames) <= SR / 10) {
"${scene.name}: expected ~$expectedFrames frames, got ${audio.frames}"
}
val monoPeak = peak(audio.mono)
val monoRms = rms(audio.mono)
check(monoPeak > 0.02) { "${scene.name}: effectively silent (peak=$monoPeak)" }
// Overlapping voices legitimately sum past 1.0 before the writer normalises (the same
// allowance DoughDspSoundDocs makes for a resonant filter); 4.0 catches a real runaway.
check(monoPeak <= 4.0) { "${scene.name}: runaway level (peak=$monoPeak)" }
check(monoRms > 0.002) { "${scene.name}: RMS too low to be music (rms=$monoRms)" }
check(monoRms < monoPeak) { "${scene.name}: RMS >= peak, which is impossible" }
println("scene ${scene.name}: ${"%.2f".format(audio.frames.toDouble() / SR)}s, peak=${"%.3f".format(monoPeak)}, rms=${"%.4f".format(monoRms)}")
scene.verify(scene, audio)
val file = File(outDir, "pat-${scene.name}.wav")
WavFloatArrayWriter.writeStereo16(file, audio.left, audio.right, SR, normalizePeak = 0.9f)
// Assert a real WAV landed on disk, with an honest header.
val bytes = file.readBytes()
check(String(bytes, 0, 4, Charsets.US_ASCII) == "RIFF") { "${scene.name}: missing RIFF" }
check(String(bytes, 8, 4, Charsets.US_ASCII) == "WAVE") { "${scene.name}: missing WAVE" }
check(String(bytes, 12, 4, Charsets.US_ASCII) == "fmt ") { "${scene.name}: missing fmt chunk" }
val channels = (bytes[22].toInt() and 0xFF) or ((bytes[23].toInt() and 0xFF) shl 8)
check(channels == 2) { "${scene.name}: WAV declares $channels channels, not 2" }
val declaredSr = (bytes[24].toInt() and 0xFF) or ((bytes[25].toInt() and 0xFF) shl 8) or
((bytes[26].toInt() and 0xFF) shl 16) or ((bytes[27].toInt() and 0xFF) shl 24)
check(declaredSr == SR) { "${scene.name}: WAV declares $declaredSr Hz" }
check(bytes.size == 44 + audio.frames * 4) {
"${scene.name}: WAV data size mismatch: ${bytes.size} vs ${44 + audio.frames * 4}"
}
println(" wrote ${file.absolutePath} (${bytes.size} bytes)")
}
println("pat sound docs rendered to ${outDir.absolutePath} (${SCENES.size} scenes)")
}
}
The voice. A pattern is only a schedule; this is the smallest honest thing that turns one into sound. Top-level in afield.audio.synth (auto-imported into every box) so a docs fence or a live box can render a pattern to audio with one call.
enum class PatWave { SINE, SAW, SQUARE, TRIANGLE, NOISE }
Oscillator shapes patVoice can use. NOISE is a deterministic hash, never Random.
class PatVoiceSpec internal constructor
A tiny deterministic synth voice: one oscillator, one exponential-decay envelope, one one-pole lowpass.
Parameters read off each event, all optional: gain (0..1, default 1), pan (0 left .. 1
right, default centre), cutoff (Hz, default open), sustain (fraction of the event's arc the
note holds for, default sustain), note (MIDI, overriding the atom).
Pitch comes from the event's value when it parses as a number (a MIDI note), and otherwise from
a small drum map so that pat("bd sd hh cp") makes a recognisable kit: bd low, sd and cp
mid-band noise, hh high and short. Anything unrecognised is played at baseHz.
class PatVoiceSpec internal constructor(
val wave: PatWave,
val baseHz: Double,
val decay: Double,
val sustain: Double,
val attack: Double
)
fun patVoice
Builds a PatVoiceSpec. See PatVoiceSpec for what each event parameter does.
fun patVoice(
wave: PatWave = PatWave.SINE,
baseHz: Double = 220.0,
decay: Double = 0.08,
sustain: Double = 1.0,
attack: Double = 0.003
): PatVoiceSpec = PatVoiceSpec(wave, baseHz, decay.coerceAtLeast(1e-4), sustain.coerceIn(0.0, 1.0), attack.coerceAtLeast(1e-5))
internal fun patDrumHz(atom: String, baseHz: Double): Pair<Double, PatWave?>
The drum map behind unpitched atoms — crude, just enough to be recognisable.
internal rather than private because it is the authority the piano rolls' instrument colours
are checked against: PatPianoRollGallery.ATOM_COLORS claims to distinguish exactly the tokens
this function gives a voice of their own, and synth.pat.mini reads this map to verify it.
internal fun patDrumHz(atom: String, baseHz: Double): Pair<Double, PatWave?> = when (atom.substringBefore(':')) {
"bd", "kick" -> 62.0 to PatWave.SINE
"sd", "snare" -> 190.0 to PatWave.NOISE
"cp", "clap" -> 900.0 to PatWave.NOISE
"hh", "hat" -> 6200.0 to PatWave.NOISE
"oh" -> 4200.0 to PatWave.NOISE
else -> baseHz to null
}
fun patRenderStereo
Schedules pattern over cycles whole cycles at cps and renders it through voice.
This is the core of PatSoundDocs: nothing here knows anything about the operators. It renders
through patSynthRenderStereo, the realtime PatSynthBank path, with a voice for every note that
sounds at once, so nothing is stolen and a note lands on the same frame patSynth plays it on.
fun patRenderStereo(
pattern: PatPattern<String>,
cycles: Int,
cps: Double = 1.0,
voice: PatVoiceSpec = patVoice(),
sampleRate: Int = PatSoundDocs.SR
): PatSoundDocs.Stereo {
require(cycles > 0) { "cycles must be > 0" }
require(cps > 0.0) { "cps must be > 0" }
val notes = (0 until cycles).flatMap { cycle ->
patSynthNotesForArc(pattern, PatArc(PatQ.of(cycle), PatQ.of(cycle + 1)), cps, sampleRate, voice)
}
// The most notes sounding at once: +1 at each start, -1 at each end, ends before starts.
var sounding = 0
var voices = 1
notes.flatMap { listOf(it.startFrame to 1, it.startFrame + it.frames to -1) }
.sortedWith(compareBy({ it.first }, { it.second }))
.forEach { (_, step) -> sounding += step; voices = maxOf(voices, sounding) }
return patSynthRenderStereo(pattern, cycles, cps, voice, voices = voices, sampleRate = sampleRate)
}
fun afield.document.graph.Box.showPatAudio
Renders pattern through voice and puts an audio player in this box's output.
The one-liner behind the loadable demo: showPatAudio(pat("bd sd hh sd") with gain(sine()))
plays it. The rendered PatSoundDocs.Stereo is returned so a box can also plot or measure it.
fun afield.document.graph.Box.showPatAudio(
pattern: PatPattern<String>,
cycles: Int = 4,
cps: Double = 2.0,
voice: PatVoiceSpec = patVoice(),
channel: String = "main"
): PatSoundDocs.Stereo {
val audio = patRenderStereo(pattern, cycles = cycles, cps = cps, voice = voice)
display(
channel,
afield.audio.engine.WavFloatArrayWriter.encodeStereo16(
audio.left, audio.right, PatSoundDocs.SR, normalizePeak = 0.9f
)
)
return audio
}
fun afield.document.graph.Box.showPatSound(name: String, channel: String = "main"): PatSoundDocs.Stereo
Renders a named PatSoundDocs scene into this box's output as an audio player.
fun afield.document.graph.Box.showPatSound(name: String, channel: String = "main"): PatSoundDocs.Stereo {
val scene: PatSoundDocs.Scene = PatSoundDocs.SCENES.firstOrNull { scene -> scene.name == name }
// `kotlin.error`, not `error`: inside a Box extension the unqualified name resolves to
// Box's own `error(...)`, which returns Unit, and the elvis then widens the whole thing to Any.
?: kotlin.error("unknown PatSoundDocs scene '$name'; known: ${PatSoundDocs.SCENES.map { it.name }}")
val audio = scene.render(scene.cycles, scene.cps)
display(
channel,
afield.audio.engine.WavFloatArrayWriter.encodeStereo16(
audio.left, audio.right, PatSoundDocs.SR, normalizePeak = 0.9f
)
)
return audio
}
fun patMidiToHz(midi: Double): Double = 440.0 * Math.pow(2.0, (midi - 69.0) / 12.0)
MIDI note number to Hz, A4 = 69 = 440 Hz.
internal fun patVoiceEnvelope(t: Double, holdSec: Double, attack: Double, decay: Double): Double
Attack ramp, hold at full, then exponential decay — the whole envelope, in one expression.
internal rather than private because PatSynthBank renders the same voice block by block on the
audio seam, and a realtime voice that differed from the offline one here would make every .wav
on the site stop being evidence about what the speakers do.
internal fun patVoiceEnvelope(t: Double, holdSec: Double, attack: Double, decay: Double): Double = when {
t < attack -> t / attack
t <= holdSec -> 1.0
else -> exp(-(t - holdSec) / decay)
}