DirtSamples.kt
src/jvmMain/kotlin/afield/audio/synth/DirtSamples.kt · 661 lines
@file:afield.runtime.completion.CompletionCategory("Audio")
package afield.audio.synth
import afield.data.query.graph.LiveGraphEdge
import afield.data.query.graph.LiveGraphInspectable
import afield.document.graph.Box
import afield.document.properties.Inheritance
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.ThreadFactory
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import kotlin.io.path.extension
import kotlin.io.path.isDirectory
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
The TidalCycles Dirt-Samples collection loaded, indexed, and played from Field.
dirt attaches a DirtSampleLibrary to the current box context and dirtPlayer builds a
polyphonic DirtSamplePlayer from it; PatPattern.playDirt drives one from a pattern of
family:variant tokens. The library scans a directory into DirtSampleFamily groups of
DirtSampleDescriptor, each resolving to a StereoAudioSample.
var Box.dirtSamples by Inheritance<DirtSampleLibrary>()
The DirtSampleLibrary attached to this box, available to child boxes.
fun dirt(path: String, preload: Boolean = true): DirtSampleLibrary
Loads or reuses a shared Dirt sample library and stores it on the current root box.
fun dirt(path: String, preload: Boolean = true): DirtSampleLibrary {
val library = DirtSampleLibrary.shared(path, preload = preload)
Box.context.get()?.root?.dirtSamples = library
return library
}
fun dirt(): DirtSampleLibrary
Returns the Dirt sample library installed in the current box context.
Call dirt("/path/to/Dirt-Samples") first to initialize this context value.
fun dirt(): DirtSampleLibrary =
Box.context.get()?.dirtSamples ?: error("No DirtSampleLibrary in box context. Call dirt(\"/path/to/Dirt-Samples\") first.")
fun dirtPlayer
Creates a polyphonic sample player for a Dirt sample library.
fun dirtPlayer(
voices: Int = 32,
library: DirtSampleLibrary = dirt(),
gain: Param = Param(1f)
): DirtSamplePlayer = DirtSamplePlayer(library = library, maxVoices = voices, masterGain = gain)
fun PatPattern<String>.playDirt
fun PatPattern<String>.playDirt(
player: DirtSamplePlayer,
cps: Number = 1.0,
ahead: Number = 0.02,
dispatchOnMain: Boolean = true,
clock: PatClock? = Box.context.get()?.patClock ?: musicClock(),
speed: (PatScheduledEvent<String>) -> Float = { 1f },
gain: (PatScheduledEvent<String>) -> Float = { 1f },
pan: (PatScheduledEvent<String>) -> Float = { 0f }
): PatResponseSpec<String> = respondAhead(
ahead = ahead,
cps = cps,
dispatchOnMain = dispatchOnMain,
clock = clock
) { event ->
player.trigger(
token = event.value,
speed = speed(event),
gain = gain(event),
pan = pan(event)
)
}
class StereoAudioSample
class StereoAudioSample(
val left: FloatArray,
val right: FloatArray = left,
val sampleRate: Float = 44_100f
) {
init
init {
require(left.isNotEmpty()) { "StereoAudioSample must contain at least one sample" }
require(right.size == left.size) { "StereoAudioSample channels must have equal length" }
}
val lengthSamples: Int
val lengthSamples: Int
get() = left.size
val lengthSeconds: Float
val lengthSeconds: Float
get() = left.size / sampleRate
val isMono: Boolean
val isMono: Boolean
get() = left === right
fun readLeft(position: Double): Float = interpolate(left, position)
fun readRight(position: Double): Float = interpolate(right, position)
companion object
fun fromMono(sample: AudioSample): StereoAudioSample
fun fromMono(sample: AudioSample): StereoAudioSample =
StereoAudioSample(sample.data, sample.data, sample.sampleRate)
}
override fun toString(): String
override fun toString(): String = synthPrettyObject(
this,
"lengthSamples" to lengthSamples,
"lengthSeconds" to lengthSeconds,
"sampleRate" to sampleRate,
"isMono" to isMono
)
}
data class DirtSampleToken
data class DirtSampleToken(
val family: String,
val variant: Int = 0
) {
companion object
fun parse(token: String): DirtSampleToken?
fun parse(token: String): DirtSampleToken? {
val trimmed = token.trim()
if (trimmed.isEmpty()) return null
val colon = trimmed.lastIndexOf(':')
if (colon <= 0 || colon >= trimmed.lastIndex) return DirtSampleToken(trimmed, 0)
val suffix = trimmed.substring(colon + 1)
if (!suffix.all(Char::isDigit)) return DirtSampleToken(trimmed, 0)
return DirtSampleToken(trimmed.substring(0, colon), suffix.toInt())
}
}
override fun toString(): String = if (variant == 0) family else "$family:$variant"
class DirtSampleDescriptor internal constructor
class DirtSampleDescriptor internal constructor(
val family: String,
val variant: Int,
val path: Path,
val relativePath: String,
private val onFirstLoad: () -> Unit
) : LiveGraphInspectable {
@Volatile
private var cached: StereoAudioSample? = null
@Volatile
private var failure: Throwable? = null
fun peekLoaded(): StereoAudioSample? = cached
fun getOrLoad(): StereoAudioSample
fun getOrLoad(): StereoAudioSample {
cached?.let { return it }
failure?.let { throw IllegalStateException("Failed to load Dirt sample $path", it) }
synchronized(this) {
cached?.let { return it }
failure?.let { throw IllegalStateException("Failed to load Dirt sample $path", it) }
return try {
StereoWavSampleLoader.load(path).also {
cached = it
onFirstLoad()
}
} catch (t: Throwable) {
failure = t
throw t
}
}
}
override fun liveGraphChildren(): Iterable<LiveGraphEdge>
override fun liveGraphChildren(): Iterable<LiveGraphEdge> = listOf(
LiveGraphEdge("family") { family },
LiveGraphEdge("variant") { variant },
LiveGraphEdge("path") { path.toString() },
LiveGraphEdge("relativePath") { relativePath },
LiveGraphEdge("loaded") { cached != null }
)
override fun toString(): String
override fun toString(): String = synthPrettyObject(
this,
"token" to DirtSampleToken(family, variant),
"relativePath" to relativePath,
"loaded" to (cached != null)
)
}
class DirtSampleFamily internal constructor
class DirtSampleFamily internal constructor(
val name: String,
val entries: List<DirtSampleDescriptor>
) {
val size: Int
val size: Int
get() = entries.size
fun descriptor(index: Int = 0): DirtSampleDescriptor?
fun descriptor(index: Int = 0): DirtSampleDescriptor? {
if (entries.isEmpty()) return null
return entries[floorMod(index, entries.size)]
}
fun sample(index: Int = 0): StereoAudioSample? = descriptor(index)?.getOrLoad()
override fun toString(): String
override fun toString(): String = synthPrettyObject(
this,
"name" to name,
"size" to size,
"entries" to entries.take(3)
)
}
class DirtSampleLibrary private constructor
class DirtSampleLibrary private constructor(
val root: Path,
preload: Boolean
) : LiveGraphInspectable, AutoCloseable {
companion object
companion object {
private val sharedLibraries = ConcurrentHashMap<Path, DirtSampleLibrary>()
fun shared(path: String, preload: Boolean = true): DirtSampleLibrary
fun shared(path: String, preload: Boolean = true): DirtSampleLibrary =
shared(Paths.get(path), preload)
fun shared(path: Path, preload: Boolean = true): DirtSampleLibrary
fun shared(path: Path, preload: Boolean = true): DirtSampleLibrary {
val normalized = path.toAbsolutePath().normalize()
return sharedLibraries.compute(normalized) { _, existing ->
when {
existing == null -> DirtSampleLibrary(normalized, preload)
preload -> {
existing.preloadAllAsync()
existing
}
else -> existing
}
}!!
}
}
private val loadedCount = AtomicInteger(0)
private val preloadStarted = AtomicBoolean(false)
private val preloadFuture = CompletableFuture<Int>()
private val executor = Executors.newSingleThreadExecutor(DirtThreadFactory(root.fileName?.toString() ?: "dirt"))
private val closed = AtomicBoolean(false)
private val descriptorsByFamilyName: Map<String, DirtSampleFamily>
private val descriptorsInOrder: List<DirtSampleDescriptor>
init
init {
require(root.isDirectory()) { "Dirt sample root is not a directory: $root" }
val (families, descriptors) = scanFamilies(root)
descriptorsByFamilyName = families
descriptorsInOrder = descriptors
if (preload) preloadAllAsync()
}
val families: Map<String, DirtSampleFamily>
val families: Map<String, DirtSampleFamily>
get() = descriptorsByFamilyName
val totalSamples: Int
val totalSamples: Int
get() = descriptorsInOrder.size
val ready: Boolean
val ready: Boolean
get() = preloadFuture.isDone
val loadedSamples: Int
val loadedSamples: Int
get() = loadedCount.get()
fun family(name: String): DirtSampleFamily?
fun family(name: String): DirtSampleFamily? {
val exact = descriptorsByFamilyName[name]
if (exact != null) return exact
return descriptorsByFamilyName[name.lowercase()]
}
fun descriptor(token: String): DirtSampleDescriptor?
fun descriptor(token: String): DirtSampleDescriptor? {
val parsed = DirtSampleToken.parse(token) ?: return null
return family(parsed.family)?.descriptor(parsed.variant)
}
fun sample(token: String): StereoAudioSample? = descriptor(token)?.getOrLoad()
fun preloadAllAsync(): CompletableFuture<Int>
fun preloadAllAsync(): CompletableFuture<Int> {
if (preloadStarted.compareAndSet(false, true)) {
executor.execute {
try {
descriptorsInOrder.forEach { descriptor ->
try {
descriptor.getOrLoad()
} catch (_: Throwable) {
// Keep preloading other files; missing or malformed assets
// should not stop the rest of the library from becoming usable.
}
}
preloadFuture.complete(loadedCount.get())
} catch (t: Throwable) {
preloadFuture.completeExceptionally(t)
}
}
}
return preloadFuture
}
override fun close()
override fun close() {
if (closed.compareAndSet(false, true)) {
executor.shutdown()
}
}
override fun liveGraphChildren(): Iterable<LiveGraphEdge>
override fun liveGraphChildren(): Iterable<LiveGraphEdge> = listOf(
LiveGraphEdge("root") { root.toString() },
LiveGraphEdge("familyCount") { families.size },
LiveGraphEdge("totalSamples") { totalSamples },
LiveGraphEdge("loadedSamples") { loadedSamples },
LiveGraphEdge("ready") { ready }
)
private fun scanFamilies(root: Path): Pair<Map<String, DirtSampleFamily>, List<DirtSampleDescriptor>> {
val allDescriptors = mutableListOf<DirtSampleDescriptor>()
val families = mutableMapOf<String, DirtSampleFamily>()
Files.list(root).use { children ->
children.filter { it.isDirectory() }.sorted(compareBy { it.fileName.toString() }).forEach { familyDir ->
val familyName = familyDir.fileName.toString()
val files = Files.walk(familyDir).use { walk ->
walk.filter { Files.isRegularFile(it) && it.extension.equals("wav", ignoreCase = true) }
.sorted(compareBy { familyDir.relativize(it).toString() })
.toList()
}
val descriptors = files.mapIndexed { index, file ->
DirtSampleDescriptor(
family = familyName,
variant = index,
path = file,
relativePath = familyDir.relativize(file).toString(),
onFirstLoad = { loadedCount.incrementAndGet() }
)
}
families[familyName] = DirtSampleFamily(familyName, descriptors)
allDescriptors += descriptors
}
}
return families.toMap() to allDescriptors
}
override fun toString(): String
override fun toString(): String = synthPrettyObject(
this,
"root" to root,
"families" to families.keys.sorted().take(6),
"totalSamples" to totalSamples,
"loadedSamples" to loadedSamples,
"ready" to ready
)
}
class DirtSamplePlayer
class DirtSamplePlayer(
val library: DirtSampleLibrary,
val maxVoices: Int = 32,
val masterGain: Param = Param(1f)
) : StereoNode(), LiveGraphInspectable {
private data class Voice(
var active: Boolean = false,
var sample: StereoAudioSample? = null,
var position: Double = 0.0,
var start: Double = 0.0,
var end: Double = 0.0,
var speed: Double = 1.0,
var gainL: Float = 1f,
var gainR: Float = 1f,
var triggerId: Long = 0L
)
private val voices = Array(maxVoices.coerceAtLeast(1)) { Voice() }
private val triggerIds = AtomicLong(0L)
private val lock = Any()
private var gainBuf = FloatArray(0)
fun play
fun play(
token: String,
speed: Number = 1.0,
gain: Number = 1.0,
pan: Number = 0.0,
start: Number = 0.0,
end: Number = 1.0
): Boolean = trigger(token, speed, gain, pan, start, end)
fun trigger
fun trigger(
token: String,
speed: Number = 1.0,
gain: Number = 1.0,
pan: Number = 0.0,
start: Number = 0.0,
end: Number = 1.0
): Boolean {
val sample = library.sample(token) ?: return false
return trigger(sample, speed, gain, pan, start, end)
}
fun trigger
fun trigger(
sample: StereoAudioSample,
speed: Number = 1.0,
gain: Number = 1.0,
pan: Number = 0.0,
start: Number = 0.0,
end: Number = 1.0
): Boolean {
val playbackSpeed = speed.toDouble()
if (!playbackSpeed.isFinite() || playbackSpeed == 0.0) return false
val sliceA = start.toDouble().coerceIn(0.0, 1.0)
val sliceB = end.toDouble().coerceIn(0.0, 1.0)
val startNorm = min(sliceA, sliceB)
val endNorm = max(sliceA, sliceB)
val startSample = sample.lengthSamples * startNorm
val endSample = sample.lengthSamples * endNorm
if (abs(endSample - startSample) < 1.0) return false
val (leftGain, rightGain) = equalPowerPan(
gain = gain.toFloat(),
pan = pan.toFloat()
)
val reverse = playbackSpeed < 0.0
val triggerId = triggerIds.incrementAndGet()
synchronized(lock) {
val target = voices.firstOrNull { !it.active } ?: voices.minByOrNull { it.triggerId } ?: return false
target.active = true
target.sample = sample
target.start = startSample
target.end = endSample
target.position = if (reverse) endSample else startSample
target.speed = playbackSpeed
target.gainL = leftGain
target.gainR = rightGain
target.triggerId = triggerId
}
return true
}
fun stopAll()
fun stopAll() {
synchronized(lock) {
voices.forEach { voice ->
voice.active = false
voice.sample = null
}
}
}
override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext)
override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) {
if (gainBuf.size < numFrames) gainBuf = FloatArray(numFrames)
masterGain.render(gainBuf, numFrames, ctx)
synchronized(lock) {
for (i in 0 until numFrames) {
var left = 0f
var right = 0f
voices.forEach { voice ->
if (!voice.active) return@forEach
val sample = voice.sample ?: run {
voice.active = false
return@forEach
}
if (!voice.insideBounds()) {
voice.active = false
return@forEach
}
val sampleL = sample.readLeft(voice.position) * voice.gainL
val sampleR = sample.readRight(voice.position) * voice.gainR
left += sampleL
right += sampleR
voice.position += voice.speed * sample.sampleRate / ctx.sampleRate
if (!voice.insideBounds()) {
voice.active = false
}
}
outL[i] = left * gainBuf[i]
outR[i] = right * gainBuf[i]
}
}
}
override fun liveGraphChildren(): Iterable<LiveGraphEdge>
override fun liveGraphChildren(): Iterable<LiveGraphEdge> = listOf(
LiveGraphEdge("library") { library },
LiveGraphEdge("masterGain") { masterGain },
LiveGraphEdge("maxVoices") { maxVoices },
LiveGraphEdge("activeVoices") { synchronized(lock) { voices.count { it.active } } }
)
private fun Voice.insideBounds(): Boolean {
return if (speed >= 0.0) {
position < end
} else {
position > start
}
}
override fun toString(): String
body — 162 lines, from line 499
override fun toString(): String = synthPrettyObject(
this,
"library" to library,
"maxVoices" to maxVoices,
"activeVoices" to synchronized(lock) { voices.count { it.active } },
"masterGain" to masterGain
)
}
private object StereoWavSampleLoader {
fun load(path: Path): StereoAudioSample {
require(Files.exists(path)) { "WAV file not found: $path" }
val bytes = Files.readAllBytes(path)
val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
fun readFourCC(): String {
val id = ByteArray(4)
buf.get(id)
return String(id, Charsets.US_ASCII)
}
fun skipChunkPadding(size: Int) {
if (size % 2 == 1 && buf.remaining() > 0) {
buf.position(buf.position() + 1)
}
}
val riff = readFourCC()
require(riff == "RIFF") { "Invalid WAV (missing RIFF header): $path" }
buf.int
val wave = readFourCC()
require(wave == "WAVE") { "Invalid WAV (missing WAVE header): $path" }
var fmtFound = false
var dataFound = false
var audioFormat = 0
var channels = 0
var sampleRate = 0
var bitsPerSample = 0
var dataOffset = 0
var dataSize = 0
while (buf.remaining() >= 8) {
val chunkId = readFourCC()
val chunkSize = buf.int
val chunkStart = buf.position()
when (chunkId) {
"fmt " -> {
audioFormat = buf.short.toInt() and 0xFFFF
channels = buf.short.toInt() and 0xFFFF
sampleRate = buf.int
buf.int
buf.short
bitsPerSample = buf.short.toInt() and 0xFFFF
fmtFound = true
}
"data" -> {
dataOffset = chunkStart
dataSize = chunkSize
dataFound = true
}
}
buf.position(chunkStart + chunkSize)
skipChunkPadding(chunkSize)
if (fmtFound && dataFound) break
}
require(fmtFound) { "Invalid WAV (missing fmt chunk): $path" }
require(dataFound) { "Invalid WAV (missing data chunk): $path" }
require(audioFormat == 1 || audioFormat == 3) { "Unsupported WAV format (only PCM and IEEE Float supported): $path" }
require(bitsPerSample == 8 || bitsPerSample == 16 || bitsPerSample == 32 || bitsPerSample == 64) {
"Unsupported WAV bit depth: $bitsPerSample"
}
require(channels == 1 || channels == 2) { "Unsupported WAV channels: $channels" }
require(dataOffset >= 0 && dataSize >= 0 && dataOffset + dataSize <= bytes.size) {
"Invalid WAV data chunk bounds: offset=$dataOffset size=$dataSize total=${bytes.size}"
}
val raw = bytes.copyOfRange(dataOffset, dataOffset + dataSize)
val bytesPerSample = bitsPerSample / 8
val frameSize = channels * bytesPerSample
val frameCount = raw.size / frameSize
val left = FloatArray(frameCount)
val right = FloatArray(frameCount)
val rawBuf = ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN)
when {
audioFormat == 3 && bitsPerSample == 32 -> {
for (i in 0 until frameCount) {
left[i] = rawBuf.float
right[i] = if (channels == 2) rawBuf.float else left[i]
}
}
audioFormat == 3 && bitsPerSample == 64 -> {
for (i in 0 until frameCount) {
left[i] = rawBuf.double.toFloat()
right[i] = if (channels == 2) rawBuf.double.toFloat() else left[i]
}
}
audioFormat == 1 && bitsPerSample == 16 -> {
for (i in 0 until frameCount) {
left[i] = rawBuf.short.toInt() / 32768.0f
right[i] = if (channels == 2) rawBuf.short.toInt() / 32768.0f else left[i]
}
}
audioFormat == 1 && bitsPerSample == 8 -> {
for (i in 0 until frameCount) {
left[i] = ((rawBuf.get().toInt() and 0xFF) - 128) / 128.0f
right[i] = if (channels == 2) ((rawBuf.get().toInt() and 0xFF) - 128) / 128.0f else left[i]
}
}
else -> error("Unsupported WAV combination format=$audioFormat bits=$bitsPerSample")
}
return if (channels == 1) {
StereoAudioSample(left, left, sampleRate.toFloat())
} else {
StereoAudioSample(left, right, sampleRate.toFloat())
}
}
}
private class DirtThreadFactory(private val label: String) : ThreadFactory {
override fun newThread(runnable: Runnable): Thread = Thread(runnable, "DirtSampleLibrary-$label").apply {
isDaemon = true
}
}
private fun equalPowerPan(gain: Float, pan: Float): Pair<Float, Float> {
val piOver4 = (Math.PI / 4.0).toFloat()
val clamped = pan.coerceIn(-1f, 1f)
val angle = clamped * piOver4
return (gain * kotlin.math.cos(angle + piOver4)) to (gain * kotlin.math.sin(angle + piOver4))
}
private fun interpolate(samples: FloatArray, position: Double): Float {
if (samples.isEmpty()) return 0f
val clamped = position.coerceIn(0.0, (samples.lastIndex).toDouble())
val idx = clamped.toInt()
val frac = (clamped - idx).toFloat()
val s0 = samples[(idx - 1).coerceAtLeast(0)]
val s1 = samples[idx]
val s2 = samples[(idx + 1).coerceAtMost(samples.lastIndex)]
val s3 = samples[(idx + 2).coerceAtMost(samples.lastIndex)]
return hermite4(frac, s0, s1, s2, s3)
}
private fun hermite4(frac: Float, s0: Float, s1: Float, s2: Float, s3: Float): Float {
val c0 = s1
val c1 = 0.5f * (s2 - s0)
val c2 = s0 - 2.5f * s1 + 2f * s2 - 0.5f * s3
val c3 = 0.5f * (s3 - s0) + 1.5f * (s1 - s2)
return ((c3 * frac + c2) * frac + c1) * frac + c0
}
private fun floorMod(value: Int, modulus: Int): Int {
require(modulus > 0) { "Modulus must be positive" }
val mod = value % modulus
return if (mod < 0) mod + modulus else mod
}