DirtRender.kt
src/jvmMain/kotlin/afield/audio/synth/DirtRender.kt · 348 lines
@file:afield.runtime.completion.CompletionCategory("Audio")
package afield.audio.synth
import afield.document.graph.Box
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.isDirectory
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlin.math.sqrt
Offline rendering of a pattern through the real Dirt sample library.
The other renderer in this package, patRenderStereo, goes through patVoice: a sine for bd,
filtered white noise for sd and hh. That stand-in needs no assets, so it works in CI and on
a clean checkout. It also sounds like bursts of white noise, because it is bursts of white
noise. It is not the voice behind most of the published audio figures either:
DocsAudioProvenance resolves each published clip to the producer that rendered it,
DocsAudioProvenanceGuardTest pins the resulting corpus by count and by family, and
public/docs/sound.md states the mix in prose. The largest family comes from
DoughDspSoundDocs, which never reaches the stand-in voice at all.
The genuine Tidal sample set was already playable, but only live: playDirt drives a
polyphonic DirtSamplePlayer through respondAhead, a clock and an audio device, none of which
exist inside a docs build or a headless box run. Here the same library, loaded by libraryOrNull
from wherever dirtSampleRoot finds it, is mixed straight into a buffer, so a transcript, a docs
capture or a println-and-listen box can sound like actual drums with no audio device anywhere.
Fallback happens at two different granularities:
- No library on this machine — the whole render falls back to patRenderStereo. Nothing
breaks, the figures still generate, and DirtRenderResult.usedSamples is
false, so a caller can say which one you are hearing. - Library present, token missing from it — only that event falls back to the synth voice. A pattern of note numbers, or one naming a drum Dirt has never heard of, must not go silent just because the library loaded.
Both are for a box, where something audible beats an error. A published clip wants the
opposite: strict = true turns either case into an exception, and the docs renderers pass
DirtRender.requirePinnedLibrary, the checksummed subset ./gradlew fetchDocsDirtSamples
downloads, so a clip is made from the same bytes on every machine or not made at all.
object DirtRender
Where the Dirt sample library is, whether it loaded, and what a finished render was made of.
Discovery is dirtSampleRoot and libraryOrNull; DirtRenderResult carries the bookkeeping back to the caller.
object DirtRender {
const val ENV_VAR = "FIELD_DIRT_SAMPLES"
Where the sample set lives, if it lives anywhere. Probed once; see dirtSampleRoot.
The standard install locations, in probe order.
Dirt-Samples ships as a SuperCollider quark, so anyone who has run Tidal already has it at one of these — which is the whole reason to auto-discover rather than ask. It is ~400MB of third-party audio with mixed per-folder licensing, so it is emphatically not vendored here.
private fun candidateRoots(): List<Path> = buildList {
System.getenv(ENV_VAR)?.takeIf { it.isNotBlank() }?.let { add(Paths.get(it)) }
val home = System.getProperty("user.home") ?: return@buildList
add(Paths.get(home, "Library", "Application Support", "SuperCollider", "downloaded-quarks", "Dirt-Samples"))
add(Paths.get(home, ".local", "share", "SuperCollider", "downloaded-quarks", "Dirt-Samples"))
add(Paths.get(home, "Dirt-Samples"))
// Last, because it holds only the few folders the docs use: a full Tidal install should
// win for live playing. See [pinnedSampleRoot].
runCatching { pinnedCacheDir() }.getOrNull()?.let { add(it) }
}
the pinned subset the docs render from
The manifest tools/fetch_dirt_samples.py downloads from; one copy, read by both sides.
private const val MANIFEST_RESOURCE = "/afield/four/synth/dirt-samples-pinned.json"
class PinnedFile(val path: String, val bytes: Long, val sha256: String)
One pinned file: its path inside the library, its size and its SHA-256.
class PinnedManifest(val commit: String, val folders: List<String>, val files: List<PinnedFile>)
The pinned subset: which commit of tidalcycles/Dirt-Samples, which folders, which files.
val pinnedManifest: PinnedManifest by lazy
The manifest off the classpath.
val pinnedManifest: PinnedManifest by lazy {
val text = DirtRender::class.java.getResourceAsStream(MANIFEST_RESOURCE)
?.use { it.readBytes().toString(Charsets.UTF_8) }
?: error("$MANIFEST_RESOURCE is missing from the classpath")
val root = kotlinx.serialization.json.Json.parseToJsonElement(text).jsonObject
PinnedManifest(
commit = root.getValue("commit").jsonPrimitive.content,
folders = root.getValue("folders").jsonArray.map { it.jsonPrimitive.content },
files = root.getValue("files").jsonArray.map {
val o = it.jsonObject
PinnedFile(
o.getValue("path").jsonPrimitive.content,
o.getValue("bytes").jsonPrimitive.long,
o.getValue("sha256").jsonPrimitive.content,
)
},
)
}
fun pinnedCacheDir(): Path
Where tools/fetch_dirt_samples.py puts the pinned subset, whether or not it is there.
The per-user native download cache of tools/native_download_cache.py, computed the same
way: AFIELD_NATIVE_DOWNLOAD_CACHE first, then the platform's cache convention.
fun pinnedCacheDir(): Path {
val override = System.getenv("AFIELD_NATIVE_DOWNLOAD_CACHE")?.trim().orEmpty()
val home = System.getProperty("user.home") ?: "."
val os = System.getProperty("os.name").orEmpty().lowercase()
val cache = when {
override.isNotEmpty() -> Paths.get(override.replaceFirst(Regex("^~"), home))
os.contains("windows") ->
Paths.get(System.getenv("LOCALAPPDATA") ?: Paths.get(home, "AppData", "Local").toString(), "Field4", "Cache")
os.contains("mac") -> Paths.get(home, "Library", "Caches", "Field4")
else -> Paths.get(System.getenv("XDG_CACHE_HOME") ?: Paths.get(home, ".cache").toString(), "field4")
}
return cache.resolve("native-downloads").resolve("dirt-samples").resolve(pinnedManifest.commit)
}
private val verifiedRoots = java.util.concurrent.ConcurrentHashMap<Path, List<String>>()
fun pinnedProblems(root: Path = pinnedCacheDir()): List<String>
What is wrong with the pinned subset at root: missing files and wrong digests. Empty when it is whole.
fun pinnedProblems(root: Path = pinnedCacheDir()): List<String> = verifiedRoots.computeIfAbsent(root.toAbsolutePath().normalize()) {
val digest = java.security.MessageDigest.getInstance("SHA-256")
pinnedManifest.files.mapNotNull { file ->
val path = root.resolve(file.path)
when {
!Files.isRegularFile(path) -> "missing ${file.path}"
Files.size(path) != file.bytes -> "wrong size ${file.path}"
digest.digest(Files.readAllBytes(path)).joinToString("") { "%02x".format(it) } != file.sha256 ->
"wrong digest ${file.path}"
else -> null
}
}
}
fun pinnedSampleRoot(): Path?
The pinned, checksummed Dirt-Samples subset, or null when it is not on this machine intact.
Verified once per process: every file in the manifest present at its pinned SHA-256.
fun pinnedSampleRoot(): Path? = pinnedCacheDir().takeIf { it.isDirectory() && pinnedProblems(it).isEmpty() }
const val PINNED_REMEDY
How to get the pinned subset; quoted by every failure that needs it.
const val PINNED_REMEDY = "run `./gradlew fetchDocsDirtSamples` (or `python3 tools/fetch_dirt_samples.py`), " +
"which downloads the pinned, checksummed Dirt-Samples folders into the shared per-user cache"
fun requirePinnedLibrary(): DirtSampleLibrary
The library a published clip is rendered from: the pinned subset, and nothing else.
Not libraryOrNull, which takes whatever Tidal install it finds first. Two machines with two different installs would publish two different clips, and the provenance audit compares bytes. Throws, naming the remedy, when the subset is missing or any file is wrong.
fun requirePinnedLibrary(): DirtSampleLibrary {
val dir = pinnedCacheDir()
val problems = if (dir.isDirectory()) pinnedProblems(dir) else listOf("no directory at $dir")
check(problems.isEmpty()) {
"the pinned Dirt-Samples subset (tidalcycles/Dirt-Samples@${pinnedManifest.commit.take(12)}: " +
"${pinnedManifest.folders.joinToString(" ")}) is not usable at $dir — " +
"${problems.size} problem(s), first: ${problems.first()}. To fix it, $PINNED_REMEDY. " +
"There is no synth fallback for a published clip."
}
return DirtSampleLibrary.shared(dir, preload = false)
}
fun dirtSampleRoot(): Path?
The Dirt sample root on this machine, or null.
Not cached: the env var is the documented override, and a caller that sets it and then renders in the same session should get what they asked for.
fun dirtSampleRoot(): Path? = candidateRoots().firstOrNull { it.isDirectory() && Files.exists(it) }
fun libraryOrNull(preload: Boolean = false): DirtSampleLibrary?
The shared library for dirtSampleRoot, or null when there is nothing to load.
fun libraryOrNull(preload: Boolean = false): DirtSampleLibrary? =
dirtSampleRoot()?.let { runCatching { DirtSampleLibrary.shared(it, preload = preload) }.getOrNull() }
class DirtRenderResult
A rendered buffer, plus what it was actually made of.
class DirtRenderResult(
val audio: PatSoundDocs.Stereo,
/** False when no library was found and the whole thing came from [patVoice]. */
val usedSamples: Boolean,
/** Distinct tokens played from real samples. */
val matched: Set<String>,
/** Distinct tokens the library had nothing for, which fell back to the synth voice. */
val unmatched: Set<String>,
/** Number of onset events actually mixed from a Dirt sample. */
val renderedEvents: Int,
) {
fun describe(): String
One line fit to print in a box, so a demo can say which voice you are hearing.
fun describe(): String = when {
!usedSamples -> "synth voice (no Dirt-Samples found; set ${ENV_VAR} to use real samples)"
unmatched.isEmpty() -> "Dirt samples: ${matched.sorted().joinToString(" ")}"
else -> "Dirt samples: ${matched.sorted().joinToString(" ")}" +
" — synth voice for ${unmatched.sorted().joinToString(" ")}"
}
}
}
fun patRenderStereoDirt
Schedules pattern over cycles cycles at cps and mixes it from real Dirt samples.
Samples are one-shots: a hit plays to its natural end rather than being gated by the length of the arc that triggered it, which is what SuperDirt does and the reason a snare sounds like a snare instead of a click. Only events with an onset trigger — a fragment carried over from a previous arc is a continuation, and re-triggering it would flam.
Honours the same gain, pan and speed annotations as patRenderStereo, so the parameter
algebra applies unchanged.
With strict, a missing library or a token it has no sample for is an exception rather than
the synth voice; that is the mode every published clip uses.
body — 99 lines, from line 223
fun patRenderStereoDirt(
pattern: PatPattern<String>,
cycles: Int,
cps: Double = 1.0,
library: DirtSampleLibrary? = DirtRender.libraryOrNull(),
voice: PatVoiceSpec = patVoice(),
sampleRate: Int = PatSoundDocs.SR,
strict: Boolean = false,
): DirtRender.DirtRenderResult {
require(cycles > 0) { "cycles must be > 0" }
require(cps > 0.0) { "cps must be > 0" }
check(!strict || library != null) {
"a strict Dirt render needs a sample library and none was found; ${DirtRender.PINNED_REMEDY}, " +
"or set ${DirtRender.ENV_VAR} to a Dirt-Samples directory"
}
if (library == null) {
return DirtRender.DirtRenderResult(
audio = patRenderStereo(pattern, cycles, cps, voice, sampleRate),
usedSamples = false,
matched = emptySet(),
unmatched = emptySet(),
renderedEvents = 0,
)
}
val secondsPerCycle = 1.0 / cps
val frames = (cycles * secondsPerCycle * sampleRate).toInt()
val left = FloatArray(frames)
val right = FloatArray(frames)
val matched = linkedSetOf<String>()
val unmatched = linkedSetOf<String>()
var renderedEvents = 0
val leftovers = mutableListOf<PatEvent<String>>()
val events = pattern.events(PatArc(PatQ.ZERO, PatQ.of(cycles)))
.filter { it.hasOnset }
.sortedWith(compareBy({ it.arc.start }, { it.arc.end }))
for (event in events) {
val token = event.value.trim()
val sample = runCatching { library.sample(token) }.getOrNull()
if (sample == null) {
check(!strict) {
"a strict Dirt render met '$token', which ${library.root} has no sample for " +
"(it has ${library.families.keys.sorted().joinToString(" ")})"
}
unmatched += token
leftovers += event
continue
}
matched += token
val startFrame = (event.arc.start.toDouble() * secondsPerCycle * sampleRate).toInt()
if (startFrame >= frames) continue
renderedEvents++
val gain = (event.annotations["gain"]?.toDoubleOrNull() ?: 1.0).coerceIn(0.0, 4.0)
val pan = (event.annotations["pan"]?.toDoubleOrNull() ?: 0.5).coerceIn(0.0, 1.0)
val speed = (event.annotations["speed"]?.toDoubleOrNull() ?: 1.0).let {
if (it == 0.0) 1.0 else it
}
val leftGain = (gain * sqrt(1.0 - pan)).toFloat()
val rightGain = (gain * sqrt(pan)).toFloat()
// One step through the source per output frame, accounting for both a sample recorded at a
// different rate and an explicit `speed`.
val step = (sample.sampleRate / sampleRate) * speed
var position = 0.0
var frame = startFrame
while (frame < frames && position < sample.lengthSamples - 1) {
left[frame] += sample.readLeft(position) * leftGain
right[frame] += sample.readRight(position) * rightGain
position += step
frame++
}
}
// Whatever the library had no sample for still gets a voice, mixed on top.
if (leftovers.isNotEmpty()) {
val rest = patRenderStereo(
PatPattern { arc -> leftovers.filter { it.arc.start >= arc.start && it.arc.start < arc.end } },
cycles, cps, voice, sampleRate,
)
for (i in 0 until minOf(frames, rest.left.size)) {
left[i] += rest.left[i]
right[i] += rest.right[i]
}
}
return DirtRender.DirtRenderResult(
audio = PatSoundDocs.Stereo(left, right),
usedSamples = true,
matched = matched,
unmatched = unmatched,
renderedEvents = renderedEvents,
)
}
fun Box.showDirtAudio
Renders pattern through the real Dirt samples and puts an audio player in this box's output.
The sample-backed twin of showPatAudio: same call shape, same annotations, but it sounds like
the drum machine the token names instead of a stand-in for one. Falls back to the synth voice
when the library is absent, and returns the result so a box can print DirtRender.DirtRenderResult.describe
and say which one played.
fun Box.showDirtAudio(
pattern: PatPattern<String>,
cycles: Int = 4,
cps: Double = 2.0,
library: DirtSampleLibrary? = DirtRender.libraryOrNull(),
voice: PatVoiceSpec = patVoice(),
channel: String = "main",
): DirtRender.DirtRenderResult {
val result = patRenderStereoDirt(pattern, cycles = cycles, cps = cps, library = library, voice = voice)
display(
channel,
afield.audio.engine.WavFloatArrayWriter.encodeStereo16(
result.audio.left, result.audio.right, PatSoundDocs.SR, normalizePeak = 0.9f
)
)
return result
}