Skip to content

SonicPiSoundDocs.kt

src/jvmMain/kotlin/afield/examples/sonicpi/SonicPiSoundDocs.kt · 83 lines

package afield.examples.sonicpi

import afield.audio.engine.WavFloatArrayLoader
import afield.audio.engine.WavFloatArrayWriter
import afield.audio.synth.SonicPi
import java.io.File
import java.nio.file.Files

object SonicPiSoundDocs

The sound on the Sonic Pi page, recorded from a real Sonic Pi.

Unlike the other *SoundDocs renderers this is not an offline render: it boots (or reuses) the shared SonicPi, runs the page's program, and records it through Sonic Pi's own recorder — the same startRecording / run / saveRecording path SonicPiPatternProbe measures. The clip is therefore what Sonic Pi actually played, choose and rrand included, and differs run to run.

Scenes: bikes, the page's first live_loop, recorded for two of its eight-second passes.

object SonicPiSoundDocs {

val BIKES

The first box on the page, as the box sends it.

    val BIKES = """
        #sonicpi
        live_loop :bikes do
          with_synth :dsaw do
            with_fx :reverb, room: 0.5, mix: 0.3 do
              start_note = chord([:b1, :e2, :b3].choose, :minor).choose
              play start_note, release: 8, cutoff: 90, detune: rrand(0, 0.2)
            end
          end
          sleep 8
        end
    """.trimIndent()

Post-fader, so not zero; the WAV is peak-normalised afterwards.

    private const val RECORD_VOLUME = 0.3

fun renderWav(name: String, outPath: String)

body — 45 lines, from line 38
    fun renderWav(name: String, outPath: String) {
        val (source, seconds) = when (name) {
            "bikes" -> BIKES to 16.0
            else -> throw IllegalArgumentException("No Sonic Pi scene '$name'; there is 'bikes'")
        }
        val sonicPi = SonicPi.shared()
        check(sonicPi.awaitReady(120_000L)) { "Sonic Pi never announced /spider/ready" }
        val raw = File(Files.createTempDirectory("sonicpi-docs-").toFile(), "$name.wav")
        try {
            sonicPi.volume(RECORD_VOLUME, silent = true)
            Thread.sleep(300)
            sonicPi.startRecording()
            Thread.sleep(300)
            sonicPi.run(source, workspace = "docs_$name", silent = true)
            Thread.sleep((seconds * 1000).toLong())
            sonicPi.stopAll()
            Thread.sleep(1_200)
            sonicPi.stopRecording()
            Thread.sleep(400)
            sonicPi.saveRecording(raw.absolutePath)
            check(awaitStableFile(raw, 30_000)) { "Sonic Pi never wrote a recording to $raw" }
        } finally {
            runCatching { sonicPi.stopAll() }
        }
        val audio = WavFloatArrayLoader.loadWavToFloatArray(raw.absolutePath)
        require(audio.samples.any { it != 0f }) { "Sonic Pi recorded silence for '$name'" }
        WavFloatArrayWriter.writeMono16(outPath, audio.samples, audio.sampleRate, normalizePeak = 0.9f)
        raw.delete()
    }

    private fun awaitStableFile(file: File, timeoutMs: Long): Boolean {
        val deadline = System.nanoTime() + timeoutMs * 1_000_000
        var lastSize = -1L
        var stableFor = 0
        while (System.nanoTime() < deadline) {
            val size = if (file.isFile) file.length() else -1L
            if (size > 0 && size == lastSize) {
                if (++stableFor >= 3) return true
            } else stableFor = 0
            lastSize = size
            Thread.sleep(300)
        }
        return file.isFile && file.length() > 0
    }
}