SonicPiPatterns.kt
src/jvmMain/kotlin/afield/audio/synth/SonicPiPatterns.kt · 1139 lines
@file:afield.runtime.completion.CompletionCategory("Audio")
Field's pattern algebra as a sequencer for Sonic Pi's engine.
pat("bd [hh hh] sd") and the algebra around it (TidalPatterns.kt, PatAlgebra.kt,
PatComposition.kt) keep musical structure exact in rational cycle space and can transform it
live. SonicPi.kt owns a real Sonic Pi 5: 66 synths, 42 effects and 206 samples in the installed
vocabulary, and a scheduler better than Field's own. This file is how a pattern reaches that engine
without going through Sonic Pi's own language in a string literal, which the pattern algebra cannot
see into.
There are two routes, good at different things, and both are here.
compile — turn a pattern into a Sonic Pi program.
sonicPiProgram(
"drums" to pat("bd [hh hh] sd hh"),
"bass" to pat("<e1 g1> ~ e2 ~").every(4) { it.rev() } on "tb303",
cps = 0.5,
cycles = 4
).source
gives you a live_loop per layer, with sleeps that are the pattern's own rational cycle
positions. The result is ordinary Sonic Pi source: paste it into a #sonicpi region and edit it by
hand, or hand it to Box.sonicPiPattern and hear it. Field's clock is not involved once it is
running — Sonic Pi's scheduler is, which is the accurate one.
The tempo mapping that makes this readable is SonicPiProgram.bpm: use_bpm 60 * cps makes one Sonic
Pi beat exactly one Field cycle, so every sleep in the emitted program is literally the cycle
fraction the pattern computed. A third stays a third — Rational(1, 3) is emitted where a decimal
would round, and a decimal where it would not.
drive — Field's clock sequences, Sonic Pi sounds.
pat("bd*4, [~ cp]*2").sonicPiLive(cps = 0.5)
One /run-code per event, dispatched by the same PatResponseSpec machinery that drives every
other live pattern in Field, so .every, .degradeBy and a signal on gain(...) stay live while
you edit. Costs a network hop of jitter per note; buys you a pattern you can change mid-bar.
Both return something a box can be running: compile returns a SonicPiLaunch, drive returns a
PatResponseSpec. Either way the box stays lit while the music plays and stopping the box stops it.
Atoms become instruments through SonicPiInstruments, which resolves in this order: an explicit
override, then a real sample name, then a real synth name, then a note (c4, e2, 60), then the
drum-machine aliases (bd, sd, hh, cp, …). "Real" means in the installed vocabulary —
sonicpi-vocabulary.json, extracted from the app by tools/generate_sonic_pi_vocabulary.py. An atom
that resolves to nothing is reported in SonicPiProgram.unresolved rather than dropped, and a
mistyped alias is a test failure rather than silence (synth.sonic-pi.patterns).
Parameters come from PatAlgebra's annotations, so with(gain(sine().range(0.2, 0.8))) works
unchanged. Three of them are not pass-throughs and the difference matters:
gain→amp:, unchanged.pan→pan:, rescaled: Field's pan is 0..1, Sonic Pi's is -1..1.cutoff→cutoff:(synths) orlpf:(samples), converted: Field's cutoff is Hz, Sonic Pi's is a MIDI note number.
Any other annotation is passed through only if the vocabulary says the target accepts it — cutoff
on :beep is not a Sonic Pi opt, so it is dropped and recorded in SonicPiProgram.droppedOpts
instead of being sent for Sonic Pi to ignore.
Notes are emitted as MIDI numbers, not as :c4. Field's patNoteNumber defaults a bare c to
octave 5 and Sonic Pi's :c is octave 4; emitting the number the pattern actually meant removes a
whole class of off-by-an-octave that neither side would report.
package afield.audio.synth
import afield.document.graph.Box
import com.fasterxml.jackson.databind.ObjectMapper
import java.math.BigDecimal
import java.math.BigInteger
import kotlin.math.ln
import kotlin.math.roundToInt
the installed vocabulary
object SonicPiVocabulary
What the installed Sonic Pi can actually be asked to do.
Read from the same sonicpi-vocabulary.json the editor completes against, via
SonicPiEditorExtension.vocabularyJson, so there is one extracted copy rather than a second
hand-written one that rots at the next Sonic Pi release. Everything this file emits is checked
against it before it goes out.
object SonicPiVocabulary {
private val root by lazy {
ObjectMapper().readTree(SonicPiEditorExtension.vocabularyJson())
}
val version: String by lazy { root["sonicPiVersion"]?.asText() ?: "unknown" }
The Sonic Pi the vocabulary was extracted from.
val synths: Set<String> by lazy
val synths: Set<String> by lazy {
root["synths"].mapNotNull { it["name"]?.asText() }.toSet()
}
val fx: Set<String> by lazy
val fx: Set<String> by lazy {
root["fx"].mapNotNull { it["name"]?.asText() }.toSet()
}
val sampleGroups: Map<String, List<String>> by lazy
Sample group name (bd, drum, loop, …) to the samples in it.
val sampleGroups: Map<String, List<String>> by lazy {
root["sampleGroups"].properties().associate { (group, node) ->
group to (node["samples"]?.map { it.asText() } ?: emptyList())
}
}
val samples: Set<String> by lazy { sampleGroups.values.flatten().toSet() }
private val synthOptsByName: Map<String, Set<String>> by lazy {
root["synths"].associate { node ->
(node["name"]?.asText() ?: "") to (node["opts"]?.map { it.asText() }?.toSet() ?: emptySet())
}
}
private val fnOptsByName: Map<String, Set<String>> by lazy {
root["fns"].associate { node ->
(node["name"]?.asText() ?: "") to (node["opts"]?.map { it.asText() }?.toSet() ?: emptySet())
}
}
val sampleOpts: Set<String> by lazy { fnOptsByName["sample"] ?: emptySet() }
The opts sample accepts — amp, rate, lpf, hpf, beat_stretch, and forty more.
val commonPlayOpts: Set<String> by lazy { fnOptsByName["play"] ?: emptySet() }
The opts every synth accepts, whichever one is in force.
fun synthOpts(synth: String): Set<String> = synthOptsByName[synth] ?: commonPlayOpts
The opts synth accepts.
Per-synth, because :tb303 has cutoff and res, :beep has neither, and sending
cutoff: to :beep is silently ignored by Sonic Pi rather than reported.
private val fxOptsByName: Map<String, Set<String>> by lazy {
root["fx"].associate { node ->
(node["name"]?.asText() ?: "") to (node["opts"]?.map { it.asText() }?.toSet() ?: emptySet())
}
}
fun fxOpts(name: String): Set<String> = fxOptsByName[name] ?: emptySet()
The opts FX name accepts — :reverb has room and damp, :echo has phase and decay.
fun isSample(name: String): Boolean = name in samples
fun isSynth(name: String): Boolean = name in synths
fun isFx(name: String): Boolean = name in fx
override fun toString(): String
A one-line description, for a box that wants to prove the vocabulary loaded.
override fun toString(): String =
"SonicPiVocabulary(version=$version, synths=${synths.size}, fx=${fx.size}, " +
"samples=${samples.size} in ${sampleGroups.size} groups)"
}
atoms become instruments
sealed class SonicPiVoice
What one pattern atom turns into when Sonic Pi plays it.
sealed class SonicPiVoice {
data class Sample(val name: String) : SonicPiVoice()
sample :bd_haus — a name from the installed sample library.
override fun toString() = "sample :$name"
data class Note(val note: Int, val synth: String? = null) : SonicPiVoice()
play 60 — a MIDI note, optionally after switching synth.
synth null means "whatever synth is in force", which is what makes use_synth at the top
of a live_loop do the obvious thing.
override fun toString() = if (synth == null) "play $note" else "play $note on :$synth"
object Silent : SonicPiVoice()
Nothing to play. Distinct from unresolved: this atom is a rest, and says so.
override fun toString() = "silent"
val SONIC_PI_DRUM_ALIASES: Map<String, SonicPiVoice>
The drum-machine names, resolved to real samples in the installed library.
These are the Tidal/Dirt-ish shorthand that anyone writing pat("bd sd") expects to hear, and
every target here is asserted to be in sonicpi-vocabulary.json by synth.sonic-pi.patterns —
a typo becomes a failing test rather than a live_loop that plays two of its four voices.
val SONIC_PI_DRUM_ALIASES: Map<String, SonicPiVoice> = buildMap {
fun s(alias: String, sample: String) = put(alias, SonicPiVoice.Sample(sample))
s("bd", "bd_haus"); s("kick", "bd_haus"); s("bass drum", "bd_haus")
s("bd808", "bd_808"); s("808", "bd_808")
s("sd", "sn_dolf"); s("sn", "sn_dolf"); s("snare", "sn_dolf")
s("sd2", "drum_snare_hard")
s("hh", "drum_cymbal_closed"); s("ch", "drum_cymbal_closed"); s("hat", "drum_cymbal_closed")
s("oh", "drum_cymbal_open"); s("openhat", "drum_cymbal_open")
s("pedal", "drum_cymbal_pedal")
s("cp", "perc_snap"); s("clap", "perc_snap")
s("rim", "perc_snap2")
s("cr", "drum_splash_hard"); s("crash", "drum_splash_hard")
s("rd", "ride_tri"); s("ride", "ride_tri")
s("lt", "drum_tom_lo_hard"); s("mt", "drum_tom_mid_hard"); s("ht", "drum_tom_hi_hard")
s("cb", "drum_cowbell"); s("cowbell", "drum_cowbell")
s("tabla", "tabla_ghe1")
s("amen", "loop_amen"); s("break", "loop_breakbeat")
s("vinyl", "vinyl_hiss"); s("scratch", "vinyl_scratch")
s("glitch", "glitch_perc1")
s("choir", "ambi_choir"); s("drone", "ambi_drone")
s("blip", "elec_blip"); s("tick", "elec_tick"); s("ping", "elec_ping"); s("pop", "elec_pop")
s("bell", "perc_bell")
// Two synth aliases, for the atoms people write when they mean a pitched voice rather than a
// drum. Both play at a fixed note; use `note(...)` or a pitched atom for anything else.
put("bass", SonicPiVoice.Note(note = 40, synth = "tb303"))
put("lead", SonicPiVoice.Note(note = 64, synth = "prophet"))
put("~", SonicPiVoice.Silent)
put("-", SonicPiVoice.Silent)
}
data class SonicPiInstruments
How an atom becomes a voice.
Resolution is ordered, and the order is the interesting part:
overrides— whatever you said, without argument.- a real sample name (
bd_haus,loop_amen) — the library, exactly as installed. - a real synth name (
tb303,prophet) — played atdefaultNote. - a note:
c4,ef3,60, viapatNoteNumber, played on the ambient synth. - aliases — the drum shorthand.
Samples and synths come before notes: fm is a synth and would otherwise be read as
F♯-something, and e2 really is a note that no sample is called.
An atom that matches nothing resolves to null, which becomes an entry in
SonicPiProgram.unresolved. Silently skipping it is the one behaviour that is never right — a
pattern that plays three of its four voices with no complaint is very hard to debug from the
sound.
data class SonicPiInstruments(
val overrides: Map<String, SonicPiVoice> = emptyMap(),
val aliases: Map<String, SonicPiVoice> = SONIC_PI_DRUM_ALIASES,
/** The synth a bare note plays on, emitted as `use_synth` at the top of each `live_loop`. */
val defaultSynth: String = "beep",
/** The note a bare synth-name atom plays. */
val defaultNote: Int = 60
) {
init
init {
require(SonicPiVocabulary.isSynth(defaultSynth)) {
"defaultSynth :$defaultSynth is not a synth in Sonic Pi ${SonicPiVocabulary.version}"
}
}
fun resolve(atom: String): SonicPiVoice?
Resolve one atom, or null if nothing in the installed vocabulary matches it.
fun resolve(atom: String): SonicPiVoice? {
val text = atom.trim()
if (text.isEmpty()) return SonicPiVoice.Silent
// `bd:3` — the sample-index suffix the mini-notation allows. The index selects among a
// group in Tidal; Sonic Pi names its samples individually, so the base name is what counts.
val base = text.substringBefore(':').trim().ifEmpty { text }
overrides[text]?.let { return it }
overrides[base]?.let { return it }
if (SonicPiVocabulary.isSample(base)) return SonicPiVoice.Sample(base)
if (SonicPiVocabulary.isSynth(base)) return SonicPiVoice.Note(defaultNote, base)
patNoteNumber(base)?.let { return SonicPiVoice.Note(it, null) }
aliases[base]?.let { return it }
return null
}
fun mapping(vararg pairs: Pair<String, SonicPiVoice>): SonicPiInstruments
Add or replace one atom's meaning: instruments.mapping("kick" to sonicPiSample("bd_tek")).
fun mapping(vararg pairs: Pair<String, SonicPiVoice>): SonicPiInstruments =
copy(overrides = overrides + pairs)
}
fun sonicPiSample(name: String): SonicPiVoice.Sample
sonicPiSample("bd_tek") — a checked sample name, for SonicPiInstruments.mapping.
fun sonicPiSample(name: String): SonicPiVoice.Sample {
require(SonicPiVocabulary.isSample(name)) {
"'$name' is not a sample in Sonic Pi ${SonicPiVocabulary.version}"
}
return SonicPiVoice.Sample(name)
}
fun sonicPiNote(note: Int, synth: String? = null): SonicPiVoice.Note
sonicPiNote(60, "tb303") — a checked synth name, for SonicPiInstruments.mapping.
fun sonicPiNote(note: Int, synth: String? = null): SonicPiVoice.Note {
if (synth != null) {
require(SonicPiVocabulary.isSynth(synth)) {
"'$synth' is not a synth in Sonic Pi ${SonicPiVocabulary.version}"
}
}
return SonicPiVoice.Note(note, synth)
}
the compiled program
data class SonicPiPatternEvent
One thing Sonic Pi is asked to do, at one exact position in cycle space.
data class SonicPiPatternEvent(
/** The `live_loop` this belongs to. */
val layer: String,
/** Cycles from the start of the loop body. Exact — this is the pattern's own number. */
val at: PatQ,
val voice: SonicPiVoice,
/** Rendered Sonic Pi opts, in emission order. */
val opts: List<Pair<String, String>>,
/** The atom this came from, for pointing back at the pattern text. */
val atom: String
)
data class SonicPiDroppedOpt(val layer: String, val atom: String, val key: String, val why: String)
An annotation the target could not accept, kept so a dropped parameter is visible.
data class SonicPiDroppedOpt(val layer: String, val atom: String, val key: String, val why: String)
data class SonicPiUnresolvedAtom(val layer: String, val atom: String, val count: Int)
An atom no part of the installed vocabulary matched.
data class SonicPiProgram
A Field pattern, lowered to a Sonic Pi program.
source is the whole point: ordinary Sonic Pi that a person can read, paste into a #sonicpi
region and edit. The rest is what the lowering knows and the text cannot say — where every event
landed in exact cycle space (events), what it could not resolve (unresolved) and what it had
to throw away (droppedOpts).
data class SonicPiProgram(
val source: String,
val events: List<SonicPiPatternEvent>,
val cps: Double,
val cycles: Int,
val layers: List<String>,
val unresolved: List<SonicPiUnresolvedAtom> = emptyList(),
val droppedOpts: List<SonicPiDroppedOpt> = emptyList()
) {
val bpm: Double get() = 60.0 * cps
Sonic Pi beats per minute such that one beat is one Field cycle.
val secondsPerCycle: Double get() = 1.0 / cps
Seconds per cycle, which is what cps means at the other boundary.
fun onsetSeconds(): List<Double> = events.map { it.at.toDouble() * secondsPerCycle }
Event onsets in seconds from the start of the loop, the way scheduled(cps) reports them.
override fun toString(): String
override fun toString(): String = synthPrettyObject(
this,
"layers" to layers,
"events" to events.size,
"cps" to cps,
"cycles" to cycles,
"bpm" to bpm,
"unresolved" to unresolved.map { it.atom },
"droppedOpts" to droppedOpts.size
)
}
the lowering
private const val SONIC_PI_MAX_EVENTS = 20_000
fun PatPattern<String>.toSonicPi
Lower one pattern to a single-live_loop Sonic Pi program.
println(pat("bd [hh hh] sd hh").toSonicPi(cps = 0.5))
cycles is the length of the emitted loop in cycles. Left out, it is the pattern's own period:
pat("<a b c>") gets three, .every(4) four, up to 16 for a pattern that does not repeat sooner.
Given shorter than that, the program's header says which layers it cuts short.
fun PatPattern<String>.toSonicPi(
cps: Number = 1.0,
cycles: Int? = null,
name: String = "field",
instruments: SonicPiInstruments = SonicPiInstruments()
): String = sonicPiProgram(name to this, cps = cps, cycles = cycles, instruments = instruments).source
fun PatPattern<String>.toSonicPiProgram
Lower one pattern and keep everything the lowering learned — see SonicPiProgram.
Use this over toSonicPi when you want to check SonicPiProgram.unresolved before you play it.
fun PatPattern<String>.toSonicPiProgram(
cps: Number = 1.0,
cycles: Int? = null,
name: String = "field",
instruments: SonicPiInstruments = SonicPiInstruments()
): SonicPiProgram = sonicPiProgram(name to this, cps = cps, cycles = cycles, instruments = instruments)
fun sonicPiProgram
Lower several named patterns to one Sonic Pi program, a live_loop per layer.
val program = sonicPiProgram(
"drums" to pat("bd [hh hh] sd hh"),
"bass" to pat("<e1 g1> ~ e2 ~") with gain(0.6),
cps = 0.5,
cycles = 4
)
Layers rather than stack because stack merges its arguments into one pattern and there is no
way back out: the emitted program should have the shape the person wrote, and a live_loop per
voice is both what Sonic Pi is good at and what is editable afterwards. stack still works
inside a layer — it just lands as several notes at the same sleep position, which is right.
Name a layer's synth with on when its notes should not play on the program's default one —
"bass" to pat("e1 ~ e2 ~") on "tb303" — which is also what makes cutoff reach it, since
:beep has no such opt and the lowering will not send one that the target does not accept.
fun sonicPiProgram(
vararg layers: Pair<String, PatPattern<String>>,
cps: Number = 1.0,
cycles: Int? = null,
instruments: SonicPiInstruments = SonicPiInstruments()
): SonicPiProgram = sonicPiProgram(
layers = layers.map { SonicPiLayer(it.first, it.second) }.toTypedArray(),
cps = cps,
cycles = cycles,
instruments = instruments
)
data class SonicPiLayer
One live_loop: a name, a pattern, and optionally its own instrument set.
The per-layer instruments is the whole reason this exists rather than a Pair. Drums want
samples and a bass line wants :tb303, and use_synth is emitted per live_loop anyway, so
"which synth" is naturally a property of the layer rather than of the program.
data class SonicPiLayer(
val name: String,
val pattern: PatPattern<String>,
val instruments: SonicPiInstruments? = null,
/** FX to wrap this loop's body in, outermost first. See [sonicPiFx]. */
val fx: List<SonicPiFx> = emptyList()
)
data class SonicPiFx(val name: String, val opts: Map<String, Double> = emptyMap())
One with_fx around a layer's body.
Build these with sonicPiFx rather than the constructor: the name and every opt are checked
against the installed vocabulary there, and Sonic Pi's response to with_fx :revreb is a runtime
error on the loop's own thread minutes into a performance — the exact failure Box.sonicPi's
error reporting exists to make visible, and which is nicer still not to have.
override fun toString(): String
override fun toString(): String =
if (opts.isEmpty()) "with_fx :$name"
else "with_fx :$name, " + opts.entries.joinToString(", ") { "${it.key}: ${it.value}" }
}
fun sonicPiFx(name: String, vararg opts: Pair<String, Number>): SonicPiFx
A checked with_fx: sonicPiFx("reverb", "room" to 0.8, "mix" to 0.4).
sonicPiLayer("bass", pat("e3 ~ e4 ~"), synth = "tb303", fx = listOf(sonicPiFx("reverb", "room" to 0.6)))
fun sonicPiFx(name: String, vararg opts: Pair<String, Number>): SonicPiFx {
require(SonicPiVocabulary.isFx(name)) {
"'$name' is not an FX in Sonic Pi ${SonicPiVocabulary.version}"
}
val accepted = SonicPiVocabulary.fxOpts(name)
for ((key, _) in opts) {
require(key in accepted) {
"with_fx :$name has no opt '$key' — it accepts ${accepted.sorted()}"
}
}
return SonicPiFx(name, opts.associate { it.first to it.second.toDouble() })
}
infix fun SonicPiLayer.through(effect: SonicPiFx): SonicPiLayer = copy(fx = fx + effect)
pat("bd sd") on "beep" through sonicPiFx("echo") — add FX to a layer.
fun sonicPiLayer
A layer, spelled out.
Kotlin will not mix a Pair and a SonicPiLayer in one vararg, so a program in which any
layer names its own synth has to be written entirely in one form or the other. This is that form:
sonicPiProgram(
sonicPiLayer("drums", pat("bd ~ [bd bd bd] bd")),
sonicPiLayer("bass", pat("<e1 g1> ~ e2 ~") with cutoff(700.0), synth = "tb303"),
cps = 0.5
)
fun sonicPiLayer(
name: String,
pattern: PatPattern<String>,
synth: String? = null,
instruments: SonicPiInstruments? = null,
fx: List<SonicPiFx> = emptyList()
): SonicPiLayer = SonicPiLayer(
name = name,
pattern = pattern,
instruments = instruments ?: synth?.let { SonicPiInstruments(defaultSynth = it) },
fx = fx
)
infix fun Pair<String, PatPattern<String>>.on(synth: String): SonicPiLayer
"bass" to pat("e1 ~ e2 ~") on "tb303" — this layer's notes play on synth.
infix fun Pair<String, PatPattern<String>>.on(synth: String): SonicPiLayer =
SonicPiLayer(first, second, SonicPiInstruments(defaultSynth = synth))
infix fun Pair<String, PatPattern<String>>.using(instruments: SonicPiInstruments): SonicPiLayer
"drums" to pat("bd sd") using myKit — this layer resolves its atoms with instruments.
infix fun Pair<String, PatPattern<String>>.using(instruments: SonicPiInstruments): SonicPiLayer =
SonicPiLayer(first, second, instruments)
fun sonicPiProgram
Lower named layers, each free to name its own synth. See sonicPiProgram and on.
body — 62 lines, from line 512
fun sonicPiProgram(
vararg layers: SonicPiLayer,
cps: Number = 1.0,
cycles: Int? = null,
instruments: SonicPiInstruments = SonicPiInstruments()
): SonicPiProgram {
require(layers.isNotEmpty()) { "a Sonic Pi program needs at least one layer" }
require(cycles == null || cycles >= 1) { "cycles must be at least 1, was $cycles" }
// Every layer loops over the same number of cycles, so the program needs the longest period.
val periods = layers.associate { rubyLoopName(it.name) to patternPeriod(it.pattern) }
val needed = periods.values.maxOf { it ?: SONIC_PI_PERIOD_SEARCH }
// A dense pattern that never repeats could run past the event budget over 16 cycles; a default
// must not throw where an explicit 1 would not, so it shrinks until every layer fits.
val cycles = cycles ?: (needed downTo 1).first { c ->
c == 1 || layers.all { layer -> runCatching { patternEventsForCycles(layer.pattern, c) }.isSuccess }
}
val tooShort = periods.filter { (_, p) -> (p ?: SONIC_PI_PERIOD_SEARCH) > cycles }
val cpsValue = cps.toDouble()
require(cpsValue.isFinite() && cpsValue > 0.0) { "cps must be finite and > 0, was $cps" }
val allEvents = mutableListOf<SonicPiPatternEvent>()
val unresolved = mutableListOf<SonicPiUnresolvedAtom>()
val dropped = mutableListOf<SonicPiDroppedOpt>()
val bodies = mutableListOf<String>()
for (spec in layers) {
val (rawName, pattern) = spec.name to spec.pattern
val kit = spec.instruments ?: instruments
val layer = rubyLoopName(rawName)
val missing = LinkedHashMap<String, Int>()
val lowered = mutableListOf<SonicPiPatternEvent>()
for (event in patternEventsForCycles(pattern, cycles)) {
val atom = event.value
val voice = kit.resolve(atom)
if (voice == null) {
missing[atom] = (missing[atom] ?: 0) + 1
continue
}
if (voice is SonicPiVoice.Silent) continue
val opts = sonicPiOptsFor(voice, event.annotations, layer, atom, kit.defaultSynth, dropped)
lowered += SonicPiPatternEvent(layer, event.arc.start, voice, opts, atom)
}
missing.forEach { (atom, count) -> unresolved += SonicPiUnresolvedAtom(layer, atom, count) }
allEvents += lowered
bodies += renderLiveLoop(layer, lowered, cycles, cpsValue, kit.defaultSynth, spec.fx)
}
val header = renderHeader(layers.map { it.name }, cpsValue, cycles, unresolved, dropped, tooShort)
val source = (listOf(header) + bodies).joinToString("\n")
return SonicPiProgram(
source = source,
events = allEvents,
cps = cpsValue,
cycles = cycles,
layers = layers.map { rubyLoopName(it.name) },
unresolved = unresolved,
droppedOpts = dropped
)
}
internal const val SONIC_PI_PERIOD_SEARCH = 16
How far patternPeriod looks: a pattern that has not repeated by then is treated as not repeating.
internal fun <A> patternPeriod(pattern: PatPattern<A>): Int?
The smallest number of cycles after which pattern repeats exactly, looking at its first
SONIC_PI_PERIOD_SEARCH cycles; null when it does not repeat within them (a ? or | draws
afresh each cycle, so one usually does not). <a b> gives 2, .every(4) gives 4.
internal fun <A> patternPeriod(pattern: PatPattern<A>): Int? {
val events = runCatching { patternEventsForCycles(pattern, SONIC_PI_PERIOD_SEARCH) }.getOrNull() ?: return null
val perCycle = Array(SONIC_PI_PERIOD_SEARCH) { mutableListOf<String>() }
for (event in events) {
val cycle = kotlin.math.floor(event.arc.start.toDouble()).toInt().coerceIn(0, SONIC_PI_PERIOD_SEARCH - 1)
val offset = PatQ.of(cycle)
perCycle[cycle] += "${event.arc.start - offset}|${event.arc.end - offset}|${event.value}|${event.annotations}"
}
for (p in 1 until SONIC_PI_PERIOD_SEARCH) {
if ((p until SONIC_PI_PERIOD_SEARCH).all { k -> perCycle[k] == perCycle[k % p] }) return p
}
return null
}
internal fun <A> patternEventsForCycles(pattern: PatPattern<A>, cycles: Int): List<PatEvent<A>>
The events of pattern over the first cycles cycles, in playing order.
The single enumeration both the emitter and the piano roll use, so the two cannot disagree about
which fragment of a note crossing a bar line is the one that strikes. eventSequence already
answers that (only the onset fragment, widened back to its whole); this only bounds it.
body — 96 lines, from line 604
internal fun <A> patternEventsForCycles(pattern: PatPattern<A>, cycles: Int): List<PatEvent<A>> {
val limit = PatQ.of(cycles)
val out = mutableListOf<PatEvent<A>>()
for (event in pattern.eventSequence(0)) {
if (event.arc.start >= limit) break
out += event
require(out.size <= SONIC_PI_MAX_EVENTS) {
"pattern produced more than $SONIC_PI_MAX_EVENTS events in $cycles cycle(s); " +
"lower `cycles`, or slow the pattern down"
}
}
return out
}
private fun renderHeader(
layerNames: List<String>,
cps: Double,
cycles: Int,
unresolved: List<SonicPiUnresolvedAtom>,
dropped: List<SonicPiDroppedOpt>,
tooShort: Map<String, Int?> = emptyMap(),
): String = buildString {
appendLine("#sonicpi")
appendLine("# Generated from a Field pattern. Ordinary Sonic Pi from here on — edit it freely.")
appendLine("#")
layerNames.forEach { appendLine("# layer ${rubyLoopName(it)}") }
appendLine("#")
appendLine("# use_bpm makes one beat exactly one Field cycle, so every sleep below is the cycle")
appendLine("# fraction the pattern computed. Rational(n, d) appears where a decimal would round.")
appendLine("# ${formatRubyNumber(cps)} cycles/second, ${cycles} cycle(s) per loop.")
if (tooShort.isNotEmpty()) {
appendLine("#")
appendLine("# SHORTER THAN THE PATTERN — these layers vary over more cycles than the loop holds,")
appendLine("# so the loop repeats only their first $cycles:")
tooShort.forEach { (layer, period) ->
appendLine("# $layer: " + (period?.let { "repeats every $it cycles" }
?: "does not repeat within $SONIC_PI_PERIOD_SEARCH cycles"))
}
}
if (unresolved.isNotEmpty()) {
appendLine("#")
appendLine("# NOT PLAYED — no sample, synth, note or alias matched these atoms:")
unresolved.forEach { appendLine("# ${it.layer}: '${it.atom}' x${it.count}") }
}
if (dropped.isNotEmpty()) {
appendLine("#")
appendLine("# DROPPED OPTS — the target does not accept these:")
dropped.distinctBy { it.layer to it.key }.forEach {
appendLine("# ${it.layer}: ${it.key} — ${it.why}")
}
}
}
private fun renderLiveLoop(
layer: String,
events: List<SonicPiPatternEvent>,
cycles: Int,
cps: Double,
defaultSynth: String,
fx: List<SonicPiFx> = emptyList()
): String = buildString {
appendLine()
appendLine("live_loop :$layer do")
// `60 * cps` rather than the product, so the emitted program says why its tempo is what it is.
appendLine(" use_bpm 60 * ${formatRubyNumber(cps)}")
appendLine(" use_synth :$defaultSynth")
// FX wrap the body, not the `use_bpm`: `with_fx` re-enters its block on every iteration, and a
// sleep inside one is still a sleep, so the loop's period is unaffected by how deep it is.
fx.forEachIndexed { depth, effect ->
appendLine(" " + " ".repeat(depth) + renderWithFx(effect) + " do")
}
val pad = " " + " ".repeat(fx.size)
var cursor = PatQ.ZERO
var currentSynth = defaultSynth
for (event in events) {
val delta = event.at - cursor
if (delta > PatQ.ZERO) appendLine("${pad}sleep ${patQToRuby(delta)}")
cursor = event.at
when (val voice = event.voice) {
is SonicPiVoice.Sample -> appendLine("${pad}sample :${voice.name}${renderOpts(event.opts)}")
is SonicPiVoice.Note -> {
if (voice.synth != null && voice.synth != currentSynth) {
appendLine("${pad}use_synth :${voice.synth}")
currentSynth = voice.synth
}
appendLine("${pad}play ${voice.note}${renderOpts(event.opts)}")
}
SonicPiVoice.Silent -> Unit
}
}
val tail = PatQ.of(cycles) - cursor
if (tail > PatQ.ZERO) appendLine("${pad}sleep ${patQToRuby(tail)}")
for (depth in fx.indices.reversed()) appendLine(" " + " ".repeat(depth) + "end")
appendLine("end")
}
with_fx :reverb, room: 0.8 — opts already checked by sonicPiFx.
private fun renderWithFx(effect: SonicPiFx): String =
"with_fx :${effect.name}" + effect.opts.entries.sortedBy { it.key }
.joinToString("") { ", ${it.key}: ${formatRubyNumber(it.value)}" }
private fun renderOpts(opts: List<Pair<String, String>>): String =
if (opts.isEmpty()) "" else opts.joinToString(", ", prefix = ", ") { "${it.first}: ${it.second}" }
parameters
Field's PatAlgebra annotations, translated into opts the target actually accepts.
Three are conversions rather than renames, and they are the ones that would otherwise be wrong in a way you can hear but not see: pan's range, and cutoff's unit.
source — 61 lines, from line 717
private fun sonicPiOptsFor(
voice: SonicPiVoice,
annotations: Map<String, String>,
layer: String,
atom: String,
ambientSynth: String,
dropped: MutableList<SonicPiDroppedOpt>
): List<Pair<String, String>> {
if (annotations.isEmpty()) return emptyList()
val isSample = voice is SonicPiVoice.Sample
// A note with no synth of its own plays on the layer's `use_synth`, so that is the synth whose
// opts decide what may be sent. Asking `play`'s common opts instead would drop `cutoff` from a
// `:tb303` layer — correct for `:beep`, and silently wrong for the case anyone would write.
val accepted: Set<String> = when (voice) {
is SonicPiVoice.Sample -> SonicPiVocabulary.sampleOpts
is SonicPiVoice.Note -> SonicPiVocabulary.synthOpts(voice.synth ?: ambientSynth)
SonicPiVoice.Silent -> emptySet()
}
val out = mutableListOf<Pair<String, String>>()
fun emit(key: String, value: String, why: String) {
if (key in accepted) out += key to value
else dropped += SonicPiDroppedOpt(layer, atom, key, why)
}
// Sorted, so the emitted line is a function of the annotations and not of the order a map
// happened to be built in — a generated program that differs run to run cannot be diffed.
for ((key, raw) in annotations.entries.sortedBy { it.key }.map { it.key to it.value }) {
val number = raw.trim().toDoubleOrNull()
when {
key == "gain" && number != null ->
emit("amp", formatRubyNumber(number), "no amp on this target")
// Field's pan is 0..1 with 0.5 centred; Sonic Pi's is -1..1 with 0 centred.
key == "pan" && number != null ->
emit("pan", formatRubyNumber((number * 2.0 - 1.0).coerceIn(-1.0, 1.0)), "no pan on this target")
// Field's cutoff is Hz; Sonic Pi's cutoff/lpf are MIDI note numbers.
key == "cutoff" && number != null -> {
val midi = formatRubyNumber(hzToMidiNote(number))
if (isSample) emit("lpf", midi, "no lpf on sample")
else emit("cutoff", midi, "synth :${(voice as? SonicPiVoice.Note)?.synth ?: ambientSynth} has no cutoff opt")
}
key == "resonance" && number != null ->
emit("res", formatRubyNumber(number), "target has no res opt")
key == "speed" && number != null && isSample ->
emit("rate", formatRubyNumber(number), "no rate on this target")
// The mini-notation's own sample-index suffix; Sonic Pi selects by name, so it is noise.
key == "n" || key == "sampleIndex" -> Unit
number != null -> emit(key, formatRubyNumber(number), "not an opt of this target")
else -> Unit
}
}
return out
}
internal fun hzToMidiNote(hz: Double): Double
Hz to a MIDI note number, which is the unit Sonic Pi's cutoff: and lpf: are in.
internal fun hzToMidiNote(hz: Double): Double {
if (!(hz > 0.0) || !hz.isFinite()) return 0.0
return (69.0 + 12.0 * (ln(hz / 440.0) / ln(2.0))).coerceIn(0.0, 130.0)
}
rendering numbers so that a third stays a third
internal fun patQToRuby(q: PatQ): String
A PatQ as Ruby, exactly.
A decimal when the denominator is a product of 2s and 5s, because then it terminates and reads
the way a musician expects. Rational(n, d) otherwise — a triplet emitted as 0.333333 drifts
by a whole beat inside a minute, and a live_loop runs for a lot longer than a minute.
internal fun patQToRuby(q: PatQ): String {
if (q.d == BigInteger.ONE) return q.n.toString()
var d = q.d
while (d.mod(BigInteger.TWO) == BigInteger.ZERO) d = d.divide(BigInteger.TWO)
while (d.mod(FIVE) == BigInteger.ZERO) d = d.divide(FIVE)
if (d == BigInteger.ONE) {
// Terminating: an exact decimal exists and BigDecimal will find it.
val exact = runCatching {
BigDecimal(q.n).divide(BigDecimal(q.d)).stripTrailingZeros().toPlainString()
}.getOrNull()
if (exact != null) return exact
}
return "Rational(${q.n}, ${q.d})"
}
private val FIVE: BigInteger = BigInteger.valueOf(5)
internal fun formatRubyNumber(value: Double): String
A double as compact Ruby: no exponent, no trailing zeros, no more precision than is meant.
internal fun formatRubyNumber(value: Double): String {
if (!value.isFinite()) return "0"
if (value == value.roundToInt().toDouble() && kotlin.math.abs(value) < 1e9) {
return value.roundToInt().toString()
}
// Four places: enough for a rate or a gain curve, few enough that a MIDI cutoff derived from
// Hz reads as a number rather than as an artefact of the conversion that produced it.
return BigDecimal(value).setScale(4, java.math.RoundingMode.HALF_UP)
.stripTrailingZeros().toPlainString()
}
internal fun rubyLoopName(name: String): String
A Ruby symbol that live_loop :x do will accept, from whatever the layer was called.
internal fun rubyLoopName(name: String): String {
val cleaned = name.trim().lowercase()
.map { if (it.isLetterOrDigit() || it == '_') it else '_' }
.joinToString("")
.trim('_')
val safe = cleaned.ifEmpty { "field" }
return if (safe.first().isDigit()) "l_$safe" else safe
}
reading a program back
data class SonicPiReadEvent
One thing a Sonic Pi program was read as doing, and when.
data class SonicPiReadEvent(
val layer: String,
/** Beats from the top of the loop body — which, at `use_bpm 60 * cps`, is cycles. */
val at: PatQ,
val kind: String,
/** Sample name for `sample`, the synth in force for `play`. */
val name: String,
val note: Int? = null,
val opts: Map<String, String> = emptyMap()
)
data class SonicPiReading
What SonicPiProgramReader made of a program: its events, and anything it could not read.
data class SonicPiReading(
val events: List<SonicPiReadEvent>,
/** Total sleep per loop, which for a well-formed loop is its period in cycles. */
val loopPeriods: Map<String, PatQ>,
/** Lines that were neither blank, a comment, nor part of the emitted subset. */
val unparsed: List<String>,
/** The `with_fx` blocks each loop opened, outermost first. */
val fx: Map<String, List<SonicPiFx>> = emptyMap()
)
object SonicPiProgramReader
Reads a generated program's text back into an event lattice.
The reason this exists rather than the tests reading SonicPiProgram.events directly: a test that
compares the emitter's internal list against the pattern only proves the emitter can enumerate.
Everything that can actually go wrong — a rational rendered as a rounded decimal, a sleep delta
that does not accumulate to the position it was derived from, a missing pad at the end of the
loop, an opt on the wrong line — lives in the gap between that list and the text. So the oracle
parses the text, exactly as Sonic Pi would, and the comparison is against the pattern's own
PatQ positions.
It understands only the subset sonicPiProgram emits. Anything else lands in
SonicPiReading.unparsed, so widening the emitter without widening this shows up as a failure
rather than as an event the oracle stopped checking without saying so.
object SonicPiProgramReader {
private val LIVE_LOOP = Regex("""^live_loop\s+:(\w+)\s+do$""")
private val USE_SYNTH = Regex("""^use_synth\s+:(\w+)$""")
private val USE_BPM = Regex("""^use_bpm\s+(.+)$""")
private val WITH_FX = Regex("""^with_fx\s+:(\w+)\s*(?:,\s*(.*?))?\s+do$""")
private val SLEEP = Regex("""^sleep\s+(.+)$""")
private val SAMPLE = Regex("""^sample\s+:(\w+)\s*(?:,\s*(.*))?$""")
private val PLAY = Regex("""^play\s+(-?[\d.]+)\s*(?:,\s*(.*))?$""")
private val RATIONAL = Regex("""^Rational\(\s*(-?\d+)\s*,\s*(\d+)\s*\)$""")
private val R_LITERAL = Regex("""^(-?\d+)r\s*/\s*(\d+)$""")
private val DECIMAL = Regex("""^-?\d*\.\d+$""")
fun read(source: String): SonicPiReading
body — 71 lines, from line 886
fun read(source: String): SonicPiReading {
val events = mutableListOf<SonicPiReadEvent>()
val periods = LinkedHashMap<String, PatQ>()
val unparsed = mutableListOf<String>()
val fxSeen = LinkedHashMap<String, MutableList<SonicPiFx>>()
var layer: String? = null
var at = PatQ.ZERO
var synth = "?"
// `with_fx ... do` opens a block inside the loop, so its `end` must not be mistaken for the
// loop's. Counted rather than parsed: nothing about a timing lattice depends on the nesting.
var openBlocks = 0
for (rawLine in source.lineSequence()) {
val line = rawLine.substringBefore('#').trim()
if (line.isEmpty()) continue
val loopStart = LIVE_LOOP.matchEntire(line)
val withFx = WITH_FX.matchEntire(line)
val useSynth = USE_SYNTH.matchEntire(line)
val sleepFor = SLEEP.matchEntire(line)
val sampleAt = SAMPLE.matchEntire(line)
val playAt = PLAY.matchEntire(line)
when {
loopStart != null -> {
layer = loopStart.groupValues[1]
at = PatQ.ZERO
synth = "?"
openBlocks = 0
}
withFx != null -> {
openBlocks++
fxSeen.getOrPut(layer ?: "<toplevel>") { mutableListOf() } +=
SonicPiFx(withFx.groupValues[1], parseOpts(withFx.groupValues.getOrNull(2))
.mapNotNull { (k, v) -> v.toDoubleOrNull()?.let { k to it } }.toMap())
}
line == "end" -> {
if (openBlocks > 0) {
openBlocks--
} else {
layer?.let { periods[it] = at }
layer = null
}
}
USE_BPM.matches(line) -> Unit
useSynth != null -> synth = useSynth.groupValues[1]
sleepFor != null -> {
val amount = parseRubyNumber(sleepFor.groupValues[1])
if (amount == null) unparsed += rawLine else at += amount
}
sampleAt != null -> events += SonicPiReadEvent(
layer = layer ?: "<toplevel>",
at = at,
kind = "sample",
name = sampleAt.groupValues[1],
opts = parseOpts(sampleAt.groupValues.getOrNull(2))
)
playAt != null -> events += SonicPiReadEvent(
layer = layer ?: "<toplevel>",
at = at,
kind = "play",
name = synth,
note = playAt.groupValues[1].toDoubleOrNull()?.roundToInt(),
opts = parseOpts(playAt.groupValues.getOrNull(2))
)
else -> unparsed += rawLine
}
}
return SonicPiReading(events, periods, unparsed, fxSeen)
}
fun parseRubyNumber(text: String): PatQ?
A Ruby numeric literal from the emitted subset, exactly.
Exactly, meaning "0.25" becomes 25/100 and not PatQ.fromDouble(0.25) — the whole point of
the oracle is that no comparison goes through a double.
fun parseRubyNumber(text: String): PatQ? {
val t = text.trim()
RATIONAL.matchEntire(t)?.let { return PatQ.of(it.groupValues[1].toLong(), it.groupValues[2].toLong()) }
R_LITERAL.matchEntire(t)?.let { return PatQ.of(it.groupValues[1].toLong(), it.groupValues[2].toLong()) }
t.toLongOrNull()?.let { return PatQ.of(it) }
if (!DECIMAL.matches(t)) return null
val negative = t.startsWith("-")
val (whole, frac) = t.removePrefix("-").split(".")
val denom = BigInteger.TEN.pow(frac.length)
val numer = BigInteger(whole.ifEmpty { "0" }) * denom + BigInteger(frac)
val q = normalisePatQ(PatQ(numer, denom))
return if (negative) -q else q
}
private fun parseOpts(text: String?): Map<String, String> {
if (text.isNullOrBlank()) return emptyMap()
return text.split(",").mapNotNull { part ->
val idx = part.indexOf(':')
if (idx <= 0) null else part.substring(0, idx).trim() to part.substring(idx + 1).trim()
}.toMap()
}
}
internal fun normalisePatQ(q: PatQ): PatQ = PatQ.ZERO + q
PatQ's constructor does not reduce; its factories do.
Building one positionally leaves 2/4 unreduced, and PatQ is a data class, so 2/4 != 1/2 by
equals even though compareTo says they are equal. Every comparison in the oracle is by value,
so everything that reaches it goes through here first.
playing it
fun Box.sonicPiPattern
Compile this pattern to Sonic Pi and play it, tied to the box.
The compile-mode entry point. The box stays lit while the live_loops run and stopping the box
stops them, exactly as Box.sonicPi does — this is Box.sonicPi, with the Ruby generated
instead of typed. Sonic Pi's scheduler is doing the timing, so this is the accurate mode; the
cost is that changing the Kotlin means re-running the box, which redefines the named live_loop
in place rather than starting a second one.
pat("bd [hh hh] sd hh").sonicPiPattern(cps = 0.5, cycles = 4)
Prints the program's unresolved atoms if there are any: an atom that plays nothing is otherwise indistinguishable from a rest.
fun Box.sonicPiPattern(
pattern: PatPattern<String>,
cps: Number = 1.0,
cycles: Int? = null,
name: String? = null,
instruments: SonicPiInstruments = SonicPiInstruments()
): SonicPiLaunch = sonicPiPattern(
(name ?: this.name.ifBlank { "field" }) to pattern,
cps = cps,
cycles = cycles,
instruments = instruments
)
fun Box.sonicPiPattern
Compile several named layers and play them, tied to the box. See sonicPiProgram.
fun Box.sonicPiPattern(
vararg layers: Pair<String, PatPattern<String>>,
cps: Number = 1.0,
cycles: Int? = null,
instruments: SonicPiInstruments = SonicPiInstruments()
): SonicPiLaunch = sonicPiPattern(
layers = layers.map { SonicPiLayer(it.first, it.second) }.toTypedArray(),
cps = cps,
cycles = cycles,
instruments = instruments
)
fun Box.sonicPiPattern
Compile layers that may each name their own synth, and play them, tied to the box.
sonicPiPattern(
"drums" to pat("bd ~ [bd bd bd] bd"),
"bass" to (pat("<e1 g1> ~ e2 ~") with cutoff(700.0)) on "tb303",
cps = 0.5,
cycles = 4
)
fun Box.sonicPiPattern(
vararg layers: SonicPiLayer,
cps: Number = 1.0,
cycles: Int? = null,
instruments: SonicPiInstruments = SonicPiInstruments()
): SonicPiLaunch {
val program = sonicPiProgram(*layers, cps = cps, cycles = cycles, instruments = instruments)
program.unresolved.forEach {
println("sonicPiPattern: '${it.atom}' (x${it.count}) in :${it.layer} matched no sample, synth, note or alias — it will not play")
}
// lineOffset 0 rather than inferred: the Ruby was generated, so there is no literal in this
// box's source for Sonic Pi's line numbers to be measured against.
return sonicPi(program.source, lineOffset = 0)
}
fun PatPattern<String>.sonicPiSource
The Sonic Pi source for a pattern, ready to paste into a #sonicpi region.
fun PatPattern<String>.sonicPiSource(
cps: Number = 1.0,
cycles: Int? = null,
name: String = "field",
instruments: SonicPiInstruments = SonicPiInstruments()
): String = sonicPiProgram(name to this, cps = cps, cycles = cycles, instruments = instruments).source
fun PatPattern<String>.sonicPiLive
Drive Sonic Pi from Field's clock, one /run-code per event.
The live mode. Field is the sequencer and Sonic Pi is the instrument, so every pattern transform
stays live — edit the Kotlin, re-run, and the next event goes out changed, without redefining a
live_loop. Control signals work the way they do everywhere else in PatAlgebra:
(pat("bd*4, [~ cp]*2") with gain(sine().range(0.3, 0.9))).sonicPiLive(cps = 0.5)
The trade against Box.sonicPiPattern is timing. Each event is a UDP message plus Sonic Pi's own
scheduling latency, so onsets carry a roughly constant offset and a small amount of jitter, where
a compiled live_loop is sample-accurate. A constant offset is not a problem for a sequencer —
sonicpi.pattern.timing measures the jitter, which is.
lead sends each event that many seconds early, if you want to trade Field's dispatch latency
away and can tolerate the pattern being that much less live.
fun PatPattern<String>.sonicPiLive(
cps: Number = 1.0,
instruments: SonicPiInstruments = SonicPiInstruments(),
lead: Number = 0.0,
clock: PatClock? = defaultPatClock(),
onSend: ((String) -> Unit)? = null
): PatResponseSpec<String> {
val sp = SonicPi.shared()
sp.awaitReady()
return respondAhead(ahead = lead, cps = cps, dispatchOnMain = false, clock = clock) { event ->
val voice = instruments.resolve(event.value)
if (voice == null) {
println("sonicPiLive: '${event.value}' matched no sample, synth, note or alias")
return@respondAhead
}
if (voice is SonicPiVoice.Silent) return@respondAhead
val code = sonicPiOneLiner(voice, event.annotations, instruments.defaultSynth)
onSend?.invoke(code)
sp.run(code, workspace = SONIC_PI_LIVE_WORKSPACE, silent = true)
}
}
const val SONIC_PI_LIVE_WORKSPACE: String = "field-pat-live"
The workspace live-driven one-shots run in, kept off the compile-mode workspaces.
internal fun sonicPiOneLiner
One event as a standalone line of Sonic Pi.
use_synth has to be on the same line as the note rather than a separate message: each
/run-code is its own Sonic Pi run on its own thread, so a use_synth sent separately would set
a thread-local on a thread that has already finished.
internal fun sonicPiOneLiner(
voice: SonicPiVoice,
annotations: Map<String, String>,
defaultSynth: String
): String {
val dropped = mutableListOf<SonicPiDroppedOpt>()
val opts = renderOpts(sonicPiOptsFor(voice, annotations, "live", "-", defaultSynth, dropped))
return when (voice) {
is SonicPiVoice.Sample -> "sample :${voice.name}$opts"
is SonicPiVoice.Note -> "use_synth :${voice.synth ?: defaultSynth}; play ${voice.note}$opts"
SonicPiVoice.Silent -> "nil"
}
}