SonicPi.kt
src/jvmMain/kotlin/afield/audio/synth/SonicPi.kt · 1079 lines
@file:afield.runtime.completion.CompletionCategory("Audio")
package afield.audio.synth
import afield.document.graph.Box
import afield.data.query.graph.LiveGraphEdge
import afield.data.query.graph.LiveGraphInspectable
import afield.runtime.lifecycle.inMain
import com.illposed.osc.OSCMessage
import com.illposed.osc.OSCMessageListener
import com.illposed.osc.messageselector.OSCPatternAddressMessageSelector
import com.illposed.osc.transport.OSCPortIn
import com.illposed.osc.transport.OSCPortInBuilder
import com.illposed.osc.transport.OSCPortOut
import com.illposed.osc.transport.OSCPortOutBuilder
import java.io.File
import java.net.InetSocketAddress
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
Sonic Pi v5 driven from a Field box, in owned mode.
sonicPi and Box.sonicPi are the box-facing entry points, both booting the one shared SonicPi on first use. That class carries the control API — running and stopping code, the mixer, recording, and the log and error feeds that come back into the box. The types around it model what the daemon sends: SonicPiLogMessage, SonicPiError, SonicPiFlash, SonicPiPorts and SonicPiVersion.
class SonicPi private constructor
Sonic Pi v5 driven from Field, in owned mode: Field spawns the Sonic Pi daemon itself and becomes the client the daemon expects, so there is no Sonic Pi window anywhere — a Field box is the buffer, and Sonic Pi's log and error reports come back into the box.
Owned mode is not a stylistic preference, it is the only mode in which the control API is
reachable on v5. Every interesting message (/run-code, /stop-all-jobs, the mixer, recording)
is authenticated with a random 32-bit token the daemon mints at boot, and v5 prints that token
exactly once, to the daemon's stdout. The pre-v5 trick of scraping it out of daemon.log is dead:
in v5 the boot-time token log is commented out and the only surviving Util.log "Token: …" sits
inside the daemon's exit handler, so it lands in the log after the daemon has stopped. If you
did not spawn the daemon, you cannot talk to it. (An already-running Sonic Pi GUI is still
reachable, but only over the unauthenticated cue port — see cue.)
Owning the daemon comes with one obligation: SonicPiPorts.daemon must keep receiving
/daemon/keep-alive or the daemon reaps its whole process tree. That is a feature, not a
nuisance — if Field dies, Sonic Pi takes itself down rather than leaving an orphaned audio server
behind. Do not trust the "more frequently than every 3s" in Sonic Pi's own daemon.rb header,
though; v5's KillSwitch sleeps 40s before it starts watching and then gives up only after five
empty 10s windows, so the real grace period is closer to 90s. We send every 1.2s regardless,
which is what the GUI does and leaves no room for argument. close skips the wait and asks the
daemon to exit immediately.
Two implementation choices:
Nothing here touches Field's main runloop. afield.io.osc.OscServer steps its handlers as
Generators on the main thread, which is right for a box reacting to a control surface and wrong
here — Sonic Pi's log arrives in bursts, and requiring a pumped runloop would make this unusable
from a headless jvm-main smoke test. Callbacks therefore fire on the OSC receive thread; hop to
the main thread yourself with afield.runtime.lifecycle.inMain if a callback touches the scene graph.
Encoding goes through JavaOSC rather than by hand. OSC pads every string and typetag to a
multiple of four bytes, adding nothing when the length is already aligned, and getting that
wrong is invisible until it isn't: a stray four bytes after a ,is typetag shifts the arguments
so the token decodes as 0, and the only evidence is Invalid token: 0 buried in
~/.sonic-pi/log/spider.log while unauthenticated messages like /version keep working fine.
Verified against Sonic Pi v5.0.0 "SuperSonic" (bundle 5.0.0, Ruby 4.0.5).
class SonicPi private constructor(
private val install: SonicPiInstall,
private val bootTimeoutMs: Long
) : AutoCloseable, LiveGraphInspectable {
companion object
companion object {
private const val KEEP_ALIVE_INTERVAL_MS = 1_200L
@Volatile
private var sharedInstance: SonicPi? = null
internal fun daemonPidFile(): File
Where the running daemon's PID is recorded, so the next boot can find one left behind.
internal fun daemonPidFile(): File {
val cacheBase = System.getenv("XDG_CACHE_HOME")?.takeIf(String::isNotBlank)?.let(::File)
?: File(System.getProperty("user.home"), ".cache")
return File(cacheBase, "field4/sonic-pi/daemon.pid")
}
internal fun reapAbandonedDaemon
End the daemon pidFile names if it is still alive and is a Sonic Pi daemon, with its
process tree (the spider and the audio server), and say what was done; null when there
was nothing to reap. isDaemon decides from the process's command line, so a PID the OS
has since reused for something else is left alone.
internal fun reapAbandonedDaemon(
pidFile: File,
isDaemon: (ProcessHandle) -> Boolean = { h ->
h.info().commandLine().orElse("").contains("daemon.rb")
},
): String? {
val pid = runCatching { pidFile.readText().trim().toLong() }.getOrNull() ?: return null
pidFile.delete()
val handle = ProcessHandle.of(pid).orElse(null) ?: return null
if (!handle.isAlive || !isDaemon(handle)) return null
val tree = handle.descendants().toList() + handle
tree.forEach { it.destroy() }
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (tree.any { it.isAlive } && System.nanoTime() < deadline) Thread.sleep(50)
tree.filter { it.isAlive }.forEach { it.destroyForcibly() }
return "ended an abandoned Sonic Pi daemon (pid $pid, ${tree.size} process(es)) left by an earlier Field, " +
"so this boot can have the cue port"
}
fun shared
The one Sonic Pi this Field owns, booting it if necessary.
Cached the way afield.io.osc.OscServers caches sockets, and for the same reason: a box re-runs on every keystroke while you are live coding, and booting a second audio server per edit would be a disaster rather than an inconvenience.
@Synchronized
fun shared(
install: SonicPiInstall = SonicPiInstall.discover(),
bootTimeoutMs: Long = 90_000L
): SonicPi {
sharedInstance?.takeUnless { it.closed }?.let { return it }
return SonicPi(install, bootTimeoutMs).also { sharedInstance = it }
}
fun currentOrNull(): SonicPi? = sharedInstance?.takeUnless { it.closed }
fun destroyShared()
@Synchronized
fun destroyShared() {
sharedInstance?.close()
sharedInstance = null
}
}
Every log line, error and status message Sonic Pi has sent, oldest first, capped.
private val logBuffer = CopyOnWriteArrayList<SonicPiLogMessage>()
private val errorBuffer = CopyOnWriteArrayList<SonicPiError>()
private val seenAddresses = ConcurrentHashMap<String, Long>()
private val pendingAcks = ConcurrentHashMap<String, CountDownLatch>()
jobId -> workspace, learned from /run/started, so a late error can find its box.
private val jobWorkspaces = ConcurrentHashMap<Int, String>()
private val workspaceJobs = ConcurrentHashMap<String, Int>()
private val endedJobs = ConcurrentHashMap<Int, Boolean>()
private val flashBuffer = CopyOnWriteArrayList<SonicPiFlash>()
private val flashCounts = ConcurrentHashMap<String, ConcurrentHashMap<Int, Long>>()
private val flashTimes = ConcurrentHashMap<String, ConcurrentHashMap<Int, Long>>()
private val reportTargets = ConcurrentHashMap<String, ReportTarget>()
@Volatile
private var fallbackTarget: ReportTarget? = null
private data class ReportTarget(val box: Box, val lineOffset: Int)
private val readyLatch = CountDownLatch(1)
private val stopping = AtomicBoolean(false)
@Volatile
private var versionReply: SonicPiVersion? = null
var verbose: Boolean
Print Sonic Pi's log to stdout (and so into the box's output) as it arrives.
@Volatile
var verbose: Boolean = false
var reportErrors: Boolean
Print errors as they arrive. On by default — a silent syntax error is the worst outcome.
@Volatile
var reportErrors: Boolean = true
var onLog: ((SonicPiLogMessage) -> Unit)? = null
@Volatile
var onLog: ((SonicPiLogMessage) -> Unit)? = null
var onError: ((SonicPiError) -> Unit)? = null
@Volatile
var onError: ((SonicPiError) -> Unit)? = null
var onMessage: ((String, List<Any?>) -> Unit)? = null
Raw feed of everything the spider server sends, for messages this class does not model.
@Volatile
var onMessage: ((String, List<Any?>) -> Unit)? = null
var onFlash: ((SonicPiFlash) -> Unit)? = null
Called for every line Sonic Pi reports as having triggered sound.
Fires on the OSC receive thread, at the rate the music plays — two live_loops at a modest
tempo produce something like sixteen a second, and each iteration reports the loop's opening
line and its end as well as the play in between. Coalesce before drawing anything.
@Volatile
var onFlash: ((SonicPiFlash) -> Unit)? = null
var closed: Boolean
@Volatile
var closed: Boolean = false
private set
private val process: Process
val ports: SonicPiPorts
private val toSpider: OSCPortOut
private val toDaemon: OSCPortOut
private val fromSpider: OSCPortIn
private val cueClients = ConcurrentHashMap<String, OSCPortOut>()
private val keepAliveThread: Thread
private val stdoutThread: Thread
private val stderrThread: Thread
private val shutdownHook: Thread
init
body — 110 lines, from line 213
init {
require(install.ruby.canExecute()) { "Sonic Pi's bundled ruby is not executable: ${install.ruby}" }
require(install.daemon.isFile) { "Sonic Pi's daemon.rb is missing: ${install.daemon}" }
// A daemon an earlier Field started and never stopped (it crashed, or was killed) holds the
// cue port for about 90 s more, which pushes this boot onto a random one. End it first.
reapAbandonedDaemon(daemonPidFile())?.let { println("SonicPi: $it") }
val processBuilder = ProcessBuilder(install.ruby.absolutePath, install.daemon.absolutePath)
.directory(install.daemon.parentFile)
processBuilder.environment().putAll(install.environment)
process = processBuilder.start()
runCatching { daemonPidFile().apply { parentFile.mkdirs() }.writeText("${process.pid()}\n") }
// The daemon prints one line — six whitespace-separated integers — then keeps the pipe open
// for its own logging. Both streams need draining forever regardless, or a full pipe buffer
// wedges the daemon somewhere down the line.
val portLine = CountDownLatch(1)
val parsed = AtomicReference<SonicPiPorts?>(null)
val bootLog = CopyOnWriteArrayList<String>()
stdoutThread = Thread({
process.inputStream.bufferedReader().useLines { lines ->
for (line in lines) {
if (parsed.get() == null) {
bootLog += line
SonicPiPorts.parse(line)?.let {
parsed.set(it)
portLine.countDown()
}
}
if (verbose) println("[sonic-pi daemon] $line")
}
}
}, "sonic-pi-daemon-stdout").apply { isDaemon = true; start() }
stderrThread = Thread({
process.errorStream.bufferedReader().useLines { lines ->
for (line in lines) {
bootLog += line
if (verbose || parsed.get() == null) println("[sonic-pi daemon!] $line")
}
}
}, "sonic-pi-daemon-stderr").apply { isDaemon = true; start() }
// From here on the daemon is running, so every failure path has to take it down with us.
// Anything else strands an audio server that nobody owns, holding the cue port, until the
// daemon's own kill switch notices — which takes about a minute and a half, long enough to
// push the *next* boot onto a different cue port and make it look like the port moved.
var spiderOut: OSCPortOut? = null
var daemonOut: OSCPortOut? = null
var spiderIn: OSCPortIn? = null
var keepAlive: Thread? = null
var hook: Thread? = null
try {
if (!portLine.await(bootTimeoutMs, TimeUnit.MILLISECONDS) || parsed.get() == null) {
error(
"Sonic Pi's daemon never printed its port line within ${bootTimeoutMs}ms. " +
"Daemon output so far:\n" + bootLog.joinToString("\n").take(4000)
)
}
val p = parsed.get()!!
spiderOut = openPortOut(p.guiSendToSpider)
daemonOut = openPortOut(p.daemon)
// The daemon picked gui-listen-to-spider as a free port and expects its client to bind it.
val listening = OSCPortInBuilder().setLocalPort(p.guiListenToSpider).build()
spiderIn = listening
listening.dispatcher.addListener(OSCPatternAddressMessageSelector("//*"), OSCMessageListener { event ->
runCatching { dispatch(event.message.address, event.message.arguments?.toList() ?: emptyList()) }
.onFailure { println("SonicPi: listener for ${event.message.address} failed: ${it.message}") }
})
// Deliberately no connect() here. A receive-only OSCPortIn has no remote address, so
// connecting throws "Can't connect to port 0"; startListening() is what binds the local
// port. OscServer in afield.io.osc does call connect(), but inside a
// `catch (_: Throwable) {}` that hides this exact exception.
listening.startListening()
val daemonPort = daemonOut
keepAlive = Thread({
while (!stopping.get()) {
runCatching { daemonPort.send(OSCMessage("/daemon/keep-alive", listOf(p.token))) }
Thread.sleep(KEEP_ALIVE_INTERVAL_MS)
}
}, "sonic-pi-keep-alive").apply { isDaemon = true; start() }
hook = Thread({ shutdown(fromHook = true) }, "sonic-pi-shutdown")
runCatching { Runtime.getRuntime().addShutdownHook(hook) }
ports = p
toSpider = spiderOut
toDaemon = daemonOut
fromSpider = listening
keepAliveThread = keepAlive
shutdownHook = hook
} catch (t: Throwable) {
stopping.set(true)
runCatching { parsed.get()?.let { daemonOut?.send(OSCMessage("/daemon/exit", listOf(it.token))) } }
keepAlive?.interrupt()
runCatching { spiderIn?.stopListening() }
runCatching { spiderIn?.close() }
runCatching { spiderOut?.close() }
runCatching { daemonOut?.close() }
hook?.let { h -> runCatching { Runtime.getRuntime().removeShutdownHook(h) } }
process.destroyForcibly()
runCatching { process.waitFor(5, TimeUnit.SECONDS) }
throw t
}
}
val isReady: Boolean
True once the spider server has announced /spider/ready.
val isReady: Boolean
get() = readyLatch.count == 0L
val isRunning: Boolean
val isRunning: Boolean
get() = !closed && process.isAlive
fun awaitReady(timeoutMs: Long = 60_000L): Boolean
Block until Sonic Pi is ready to accept code. Returns false on timeout.
fun awaitReady(timeoutMs: Long = 60_000L): Boolean =
readyLatch.await(timeoutMs, TimeUnit.MILLISECONDS)
fun run(code: String, workspace: String? = null, silent: Boolean = false): SonicPi
Evaluate Sonic Pi code — the whole point of this class.
workspace is the buffer name Sonic Pi attributes the run to, and shows in error reports;
a Field box's name makes a good one. Fire-and-forget: errors surface asynchronously through
onError and errors, not as a thrown exception, because that is how Sonic Pi reports them.
fun run(code: String, workspace: String? = null, silent: Boolean = false): SonicPi = apply {
val args = mutableListOf<Any?>(ports.token, code)
if (workspace != null) {
args += workspace
args += if (silent) 1 else 0
}
send("/run-code", args)
}
fun stopAll(): SonicPi = apply { send("/stop-all-jobs", listOf(ports.token)) }
fun stop(jobId: Int): SonicPi = apply { send("/stop-job", listOf(ports.token, jobId)) }
fun ping(id: String = "field4", timeoutMs: Long = 5_000L): Boolean
Round-trip a /ping and wait for its /ack. The cheapest proof the token is accepted.
fun ping(id: String = "field4", timeoutMs: Long = 5_000L): Boolean {
val latch = CountDownLatch(1)
pendingAcks[id] = latch
try {
send("/ping", listOf(ports.token, id))
return latch.await(timeoutMs, TimeUnit.MILLISECONDS)
} finally {
pendingAcks.remove(id)
}
}
fun version(timeoutMs: Long = 5_000L): SonicPiVersion?
Ask Sonic Pi its version, waiting for the reply.
fun version(timeoutMs: Long = 5_000L): SonicPiVersion? {
versionReply?.let { return it }
send("/version", listOf(ports.token))
val deadline = System.nanoTime() + timeoutMs * 1_000_000
while (System.nanoTime() < deadline) {
versionReply?.let { return it }
Thread.sleep(50)
}
return versionReply
}
fun volume(value: Double, silent: Boolean = false): SonicPi
The fader after the limiter, 0.0–1.0. v5 rescaled this: v4's 0–5 range is gone.
fun volume(value: Double, silent: Boolean = false): SonicPi =
apply { send("/mixer-output-volume", listOf(ports.token, value.toFloat(), if (silent) 1 else 0)) }
fun drive(value: Double, silent: Boolean = false): SonicPi
The gain before the limiter, 0.0–1.0 (0.5 is unity). New in v5.
fun drive(value: Double, silent: Boolean = false): SonicPi =
apply { send("/mixer-drive", listOf(ports.token, value.toFloat(), if (silent) 1 else 0)) }
fun startRecording(): SonicPi = apply { send("/start-recording", listOf(ports.token)) }
fun stopRecording(): SonicPi = apply { send("/stop-recording", listOf(ports.token)) }
fun saveRecording(path: String): SonicPi
fun saveRecording(path: String): SonicPi =
apply { send("/save-recording", listOf(ports.token, path)) }
fun deleteRecording(): SonicPi = apply { send("/delete-recording", listOf(ports.token)) }
fun cue
Send an OSC cue to Sonic Pi's cue port.
Sonic Pi turns an incoming cue into a cue name of the form /osc:<host>:<port>/your/path,
which Sonic Pi code waits on with sync — in practice via its wildcard prefix rather than the
literal host and port. (That wildcard is spelled with a star, which cannot be written in a
KDoc block without ending it.)
Unlike everything else here this needs no token, so the same call reaches a Sonic Pi GUI you
launched yourself — pass host/port to aim at one. Cues are the right channel for
per-event traffic; run is for changing what the music is.
fun cue(
address: String,
args: List<Any?> = emptyList(),
host: String = "127.0.0.1",
port: Int = ports.oscCues
): SonicPi = apply {
val key = "$host:$port"
val client = cueClients.computeIfAbsent(key) {
OSCPortOutBuilder().setRemoteSocketAddress(InetSocketAddress(host, port)).build()
}
runCatching { client.send(OSCMessage(address, args)) }
.onFailure { println("SonicPi: cue $address failed: ${it.message}") }
}
fun cue(address: String, vararg args: Any?): SonicPi = cue(address, args.toList())
fun reportTo(workspace: String, box: Box, lineOffset: Int = 0): SonicPi
Route Sonic Pi's output for workspace into box.
Necessary because Sonic Pi's errors are asynchronous. run returns as soon as the OSC
message is away, and the error for a bad line arrives a second or so later on the OSC receive
thread — by which time the box has finished running. A bare println from that thread is
kotlin.io.println, not Box.println, so it goes to the process stdout Field was launched
from and never appears in the box. Errors raised inside a live_loop can arrive minutes
later, which makes this the normal case rather than an edge one.
lineOffset is the number of box lines before the Ruby source. Both Sonic Pi and Field's
editor/output protocol use 1-based lines, so a reported line maps to reportedLine +
lineOffset. The default of 0 is right for the usual shape, where the opening sonicPi("""
and the first line of Ruby share a line.
fun reportTo(workspace: String, box: Box, lineOffset: Int = 0): SonicPi = apply {
val target = ReportTarget(box, lineOffset)
reportTargets[workspace] = target
fallbackTarget = target
}
fun stopReportingTo(workspace: String): SonicPi
fun stopReportingTo(workspace: String): SonicPi = apply {
val removed = reportTargets.remove(workspace)
if (removed != null && fallbackTarget == removed) fallbackTarget = null
}
fun activity(workspace: String): Map<Int, Long> = flashCounts[workspace]?.toMap() ?: emptyMap()
Which lines have triggered sound, and how often, for workspace.
Reset when a new run starts on that workspace, so this describes the run you are listening to
rather than the whole session. This is the "what is making sound and where" signal: Sonic Pi
emits it from play, synth, sample, control, play_chord and the with_fx/live_loop
block boundaries, tagged with the caller's line.
It reports triggering, not audibility — a play at amp: 0 still creates a node and still
flashes, so a line being hot does not prove you can hear it.
val flashes: List<SonicPiFlash>
val flashes: List<SonicPiFlash>
get() = flashBuffer.toList()
fun lineAges(workspace: String, now: Long = System.nanoTime()): Map<Int, Long>
How long ago, in milliseconds, each line of workspace last triggered sound.
The basis for showing activity in the editor: recency rather than a count, because what a player wants to see is which lines are sounding now.
fun lineAges(workspace: String, now: Long = System.nanoTime()): Map<Int, Long> =
flashTimes[workspace]?.mapValues { (_, at) -> (now - at) / 1_000_000 } ?: emptyMap()
fun activeLines(workspace: String, withinMs: Long = 500L): List<Int>
The lines of workspace that triggered sound within the last withinMs.
fun activeLines(workspace: String, withinMs: Long = 500L): List<Int> =
lineAges(workspace).filterValues { it <= withinMs }.keys.sorted()
fun jobIdFor(workspace: String): Int? = workspaceJobs[workspace]
The most recent Sonic Pi job id for workspace, if one has started.
fun isJobAlive(jobId: Int): Boolean = !endedJobs.containsKey(jobId)
fun awaitJobStart(workspace: String, timeoutMs: Long = 15_000L): Int?
Wait for workspace's next job to start, returning its id.
fun awaitJobStart(workspace: String, timeoutMs: Long = 15_000L): Int? {
val deadline = System.nanoTime() + timeoutMs * 1_000_000
while (System.nanoTime() < deadline) {
workspaceJobs[workspace]?.let { return it }
Thread.sleep(20)
}
return workspaceJobs[workspace]
}
val log: List<SonicPiLogMessage>
val log: List<SonicPiLogMessage>
get() = logBuffer.toList()
val errors: List<SonicPiError>
val errors: List<SonicPiError>
get() = errorBuffer.toList()
fun messageCounts(): Map<String, Long> = seenAddresses.toMap()
Addresses seen from the spider server, with how many of each — handy when something is quiet.
fun clearLog(): SonicPi
body — 128 lines, from line 503
fun clearLog(): SonicPi = apply {
logBuffer.clear()
errorBuffer.clear()
}
private fun send(address: String, args: List<Any?>) {
if (closed) {
println("SonicPi: ignoring $address — this client is closed")
return
}
runCatching { toSpider.send(OSCMessage(address, args)) }
.onFailure { println("SonicPi: send $address failed: ${it.message}") }
}
private fun openPortOut(port: Int): OSCPortOut =
OSCPortOutBuilder()
.setRemoteSocketAddress(InetSocketAddress("127.0.0.1", port))
.build()
.also { runCatching { it.connect() } }
private fun dispatch(address: String, args: List<Any?>) {
seenAddresses.merge(address, 1L, Long::plus)
onMessage?.invoke(address, args)
when (address) {
"/spider/ready" -> readyLatch.countDown()
"/ack" -> (args.firstOrNull() as? String)?.let { pendingAcks[it]?.countDown() }
"/version" -> versionReply = SonicPiVersion(
version = args.getOrNull(0) as? String ?: "?",
versionNumber = (args.getOrNull(1) as? Number)?.toInt() ?: 0,
latestVersion = args.getOrNull(2) as? String,
platform = args.getOrNull(7) as? String
)
// `/run/started` is the only place the jobId <-> workspace pairing is stated, and it is
// what lets an error that surfaces much later still find the box that caused it.
"/run/started" -> {
val jobId = (args.getOrNull(0) as? Number)?.toInt()
val workspace = args.getOrNull(1) as? String
if (jobId != null && workspace != null) {
if (jobWorkspaces.size > MAX_TRACKED_JOBS) jobWorkspaces.clear()
jobWorkspaces[jobId] = workspace
workspaceJobs[workspace] = jobId
endedJobs.remove(jobId)
flashCounts.remove(workspace)
flashTimes.remove(workspace)
// A fresh run means the previous run's marker is stale.
reportTargets[workspace]?.let { target ->
inMain { runCatching { target.box.displayError(null, "code", -1) } }
}
}
}
"/run/ended" -> (args.getOrNull(0) as? Number)?.toInt()?.let { endedJobs[it] = true }
"/flash" -> {
val flash = SonicPiFlash(
jobId = (args.getOrNull(0) as? Number)?.toInt() ?: -1,
workspace = args.getOrNull(1) as? String ?: "",
line = (args.getOrNull(2) as? Number)?.toInt() ?: -1
)
if (flash.line >= 0) {
flashBuffer += flash
while (flashBuffer.size > MAX_FLASHES) flashBuffer.removeAt(0)
flashCounts.computeIfAbsent(flash.workspace) { ConcurrentHashMap() }
.merge(flash.line, 1L, Long::plus)
flashTimes.computeIfAbsent(flash.workspace) { ConcurrentHashMap() }[flash.line] =
System.nanoTime()
runCatching { onFlash?.invoke(flash) }
.onFailure { println("SonicPi: onFlash threw: ${it.message}") }
}
}
"/log/multi_message" -> emit(decodeMultiMessage(args))
"/log/info" -> emit(decodeLogInfo(args))
"/error" -> emit(decodeError(args, syntax = false))
"/syntax_error" -> emit(decodeError(args, syntax = true))
}
}
private fun targetFor(jobId: Int): ReportTarget? =
jobWorkspaces[jobId]?.let { reportTargets[it] } ?: fallbackTarget
private fun emit(message: SonicPiLogMessage) {
logBuffer += message
trim(logBuffer)
val target = if (message.jobId >= 0) targetFor(message.jobId) else null
if (target != null && message.text.isNotBlank()) {
// Only a job's own output goes to the box. Sonic Pi's boot chatter arrives on
// /log/info with no job, and putting "Initialised SuperSonic" in someone's box would
// be noise rather than feedback.
inMain { runCatching { target.box.println(message.text) } }
} else if (verbose) {
println(message.toString())
}
runCatching { onLog?.invoke(message) }
.onFailure { println("SonicPi: onLog threw: ${it.message}") }
}
private fun emit(error: SonicPiError) {
errorBuffer += error
trim(errorBuffer)
val target = targetFor(error.jobId)
if (target != null) {
// One error-kind output on purpose. A following normal println would make Field's
// normal-output handler clear this error immediately, leaving grey text and no marker.
// toString includes the source line as well as the diagnostic, so the red output remains
// readable without a second channel. Sonic Pi and Field both count lines from one.
inMain {
runCatching {
target.box.displayError(error.toString(), "code", sonicPiErrorBoxLine(error.line, target.lineOffset))
}
}
} else if (reportErrors) {
println(error.toString())
}
runCatching { onError?.invoke(error) }
.onFailure { println("SonicPi: onError threw: ${it.message}") }
}
private fun trim(buffer: CopyOnWriteArrayList<*>) {
while (buffer.size > MAX_BUFFERED) buffer.removeAt(0)
}
override fun liveGraphChildren(): Iterable<LiveGraphEdge>
override fun liveGraphChildren(): Iterable<LiveGraphEdge> = listOf(
LiveGraphEdge("running") { isRunning },
LiveGraphEdge("ready") { isReady },
LiveGraphEdge("ports") { ports },
LiveGraphEdge("logLines") { logBuffer.size },
LiveGraphEdge("errors") { errorBuffer.size },
LiveGraphEdge("messageCounts") { messageCounts() }
)
override fun toString(): String
override fun toString(): String = synthPrettyObject(
this,
"running" to isRunning,
"ready" to isReady,
"ports" to ports,
"logLines" to logBuffer.size,
"errors" to errorBuffer.size
)
override fun close() = shutdown(fromHook = false)
private fun shutdown(fromHook: Boolean) {
if (!stopping.compareAndSet(false, true)) return
closed = true
// Ask nicely first: /daemon/exit is authenticated with the same token and tears down the
// spider and the audio server with it. If Field died instead, the 3s keep-alive timeout
// gets us the same outcome.
runCatching { toDaemon.send(OSCMessage("/daemon/exit", listOf(ports.token))) }
runCatching { keepAliveThread.interrupt() }
runCatching { fromSpider.stopListening() }
runCatching { fromSpider.close() }
runCatching { toSpider.close() }
runCatching { toDaemon.close() }
cueClients.values.forEach { c -> runCatching { c.close() } }
cueClients.clear()
if (!process.waitFor(10, TimeUnit.SECONDS)) {
process.destroyForcibly()
process.waitFor(5, TimeUnit.SECONDS)
}
runCatching {
val file = daemonPidFile()
if (file.readText().trim() == process.pid().toString()) file.delete()
}
if (!fromHook) {
runCatching { Runtime.getRuntime().removeShutdownHook(shutdownHook) }
if (sharedInstance === this) sharedInstance = null
}
}
}
private const val MAX_BUFFERED = 5_000
private const val MAX_TRACKED_JOBS = 4_096
private const val MAX_FLASHES = 2_048
data class SonicPiFlash(val jobId: Int, val workspace: String, val line: Int)
One line reported as having triggered sound. See SonicPi.activity.
internal fun inferSonicPiLineOffset(box: Box, code: String): Int
Work out where code starts inside the box, so a line Sonic Pi reports can be pointed at.
Sonic Pi numbers its errors from line 1 of the source it was handed. That source is the inside of a
Kotlin triple-quoted string, so its line 1 is the box line the opening quote sits on — which is
line 1 only when the sonicPi( call is the first thing in the box. One import above it and every
marker is off by one, which is exactly the kind of wrongness nobody reports and everybody stops
trusting. Since the box's own text is right here, find the code in it rather than asking the caller.
Returns 0 when the code cannot be located, which is the old behaviour and right for a box whose code is built at runtime rather than written as a literal.
internal fun inferSonicPiLineOffset(box: Box, code: String): Int {
val source = runCatching { box.getPropertyRaw("code") as? String }.getOrNull() ?: return 0
if (source.isEmpty() || code.isEmpty()) return 0
// The literal appears verbatim; anchor on the longest prefix that is cheap to find.
var at = source.indexOf(code)
if (at < 0) {
// A trimIndent()-ed or otherwise reshaped literal will not match whole. Fall back to the
// first non-blank line, which is enough to locate the region's start.
val anchor = code.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: return 0
if (anchor.isEmpty()) return 0
val anchorAt = source.indexOf(anchor)
if (anchorAt < 0) return 0
// The anchor is the region's first non-blank line; step back over the blank ones.
val blankLeading = code.lineSequence().takeWhile { it.isBlank() }.count()
return (source.take(anchorAt).count { it == '\n' } - blankLeading).coerceAtLeast(0)
}
return source.take(at).count { it == '\n' }
}
internal fun decodeMultiMessage(args: List<Any?>): SonicPiLogMessage
Decode /log/multi_message: jobId, thread, runtime, count, then count flattened
(style, text) pairs.
Tolerant — the count and the payload come from separate parts of the spider server, so a message that claims more pairs than it carries decodes to the pairs that are actually there. Losing a log line is a cosmetic failure; throwing on the OSC receive thread would not be.
internal fun decodeMultiMessage(args: List<Any?>): SonicPiLogMessage {
val count = (args.getOrNull(3) as? Number)?.toInt() ?: 0
val lines = (0 until count).mapNotNull { i ->
val style = (args.getOrNull(4 + i * 2) as? Number)?.toInt() ?: return@mapNotNull null
val text = args.getOrNull(5 + i * 2) as? String ?: return@mapNotNull null
SonicPiLogLine(style, text)
}
return SonicPiLogMessage(
jobId = (args.getOrNull(0) as? Number)?.toInt() ?: -1,
// Ruby sends the thread name through `.inspect`, so it arrives already quoted.
thread = (args.getOrNull(1) as? String)?.trim('"') ?: "",
runtime = (args.getOrNull(2) as? String)?.toDoubleOrNull() ?: 0.0,
lines = lines
)
}
internal fun decodeLogInfo(args: List<Any?>): SonicPiLogMessage
Decode /log/info: style, text. Sonic Pi's own chatter rather than a job's output.
internal fun decodeLogInfo(args: List<Any?>): SonicPiLogMessage = SonicPiLogMessage(
jobId = -1,
thread = "info",
runtime = 0.0,
lines = listOf(
SonicPiLogLine(
(args.getOrNull(0) as? Number)?.toInt() ?: 0,
args.getOrNull(1) as? String ?: ""
)
)
)
internal fun decodeError(args: List<Any?>, syntax: Boolean): SonicPiError
Decode /error and /syntax_error, which do not agree on argument order.
/error is jobId, message, backtrace, line, firstCol, lastCol, sourceLine, while
/syntax_error is jobId, message, sourceLine, line, lineAsString, firstCol, lastCol — the
offending source moves from last to third, there is no backtrace, and the line number arrives
twice, once as an int and once as a string.
internal fun decodeError(args: List<Any?>, syntax: Boolean): SonicPiError = SonicPiError(
jobId = (args.getOrNull(0) as? Number)?.toInt() ?: -1,
message = args.getOrNull(1) as? String ?: "",
backtrace = if (syntax) "" else args.getOrNull(2) as? String ?: "",
line = (args.getOrNull(3) as? Number)?.toInt() ?: -1,
firstColumn = (args.getOrNull(if (syntax) 5 else 4) as? Number)?.toInt() ?: -1,
lastColumn = (args.getOrNull(if (syntax) 6 else 5) as? Number)?.toInt() ?: -1,
sourceLine = (if (syntax) args.getOrNull(2) else args.getOrNull(6)) as? String ?: "",
syntax = syntax
)
data class SonicPiPorts
The ports and token the Sonic Pi daemon allocates at boot and prints, once, to stdout.
The order is the daemon's, not ours: daemon gui-listen-to-spider gui-send-to-spider scsynth
osc-cues token. scsynth keeps its historical name in v5 even though SuperSonic has replaced
scsynth as the audio engine.
data class SonicPiPorts(
val daemon: Int,
val guiListenToSpider: Int,
val guiSendToSpider: Int,
val scsynth: Int,
val oscCues: Int,
val token: Int
) {
companion object
companion object {
Six integers alone on a line. The token is a random signed 32-bit int, so it is very
often negative — a \d+-only pattern silently never matches roughly half the time.
private val LINE = Regex("""^\s*(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s*$""")
fun parse(line: String): SonicPiPorts?
fun parse(line: String): SonicPiPorts? {
val m = LINE.matchEntire(line) ?: return null
val v = m.groupValues.drop(1).map { it.toIntOrNull() ?: return null }
return SonicPiPorts(v[0], v[1], v[2], v[3], v[4], v[5])
}
}
override fun toString(): String
override fun toString(): String = synthPrettyObject(
this,
"daemon" to daemon,
"guiListenToSpider" to guiListenToSpider,
"guiSendToSpider" to guiSendToSpider,
"scsynth" to scsynth,
"oscCues" to oscCues,
// deliberately not the token — this ends up in logs and screenshots
"token" to "<redacted>"
)
}
data class SonicPiInstall
Where Sonic Pi is installed, and the files/environment that owned mode needs.
data class SonicPiInstall(
val home: File,
val ruby: File,
val daemon: File,
/** AppImages need the same library and executable search paths that their `AppRun` sets. */
val environment: Map<String, String> = emptyMap()
) {
companion object
companion object {
fun discover(): SonicPiInstall
Find a Sonic Pi install.
-Dsonicpi.home= or SONIC_PI_HOME wins, and may point at the Contents/Resources
directory on macOS, an extracted Linux AppDir, or the AppImage itself. A Linux AppImage
in ~/Downloads is also found automatically and extracted once into Field's cache.
fun discover(): SonicPiInstall {
val explicit = System.getProperty("sonicpi.home") ?: System.getenv("SONIC_PI_HOME")
val explicitAppImage =
System.getProperty("sonicpi.appimage") ?: System.getenv("FIELD_SONIC_PI_APPIMAGE")
sequenceOf(explicitAppImage, explicit)
.filterNotNull()
.map(::File)
.filter { it.isFile && it.name.endsWith(".AppImage", ignoreCase = true) }
.forEach { return fromAppImage(it) }
val roots = buildList {
explicit?.let { File(it).takeIf(File::isDirectory)?.let(::add) }
add(File("/Applications/Sonic Pi.app/Contents/Resources"))
add(File(System.getProperty("user.home"), "Applications/Sonic Pi.app/Contents/Resources"))
add(File("/opt/sonic-pi"))
add(File("/usr/lib/sonic-pi"))
}
for (root in roots) {
of(root)?.let { return it }
}
appImageCandidates().forEach { return fromAppImage(it) }
error(
"Could not find a Sonic Pi install. Looked in:\n" +
roots.joinToString("\n") { " $it" } +
"\nAlso looked for Sonic Pi AppImages in ~/Downloads. " +
"Set -Dsonicpi.home=…, SONIC_PI_HOME, or FIELD_SONIC_PI_APPIMAGE."
)
}
fun of(root: File): SonicPiInstall?
Build an install from a candidate app root/AppDir, or null if the pieces are not there.
body — 94 lines, from line 867
fun of(root: File): SonicPiInstall? {
val homes = listOf(
root,
File(root, "Contents/Resources"),
File(root, "usr/share/sonic-pi"),
File(root, "share/sonic-pi")
).map { runCatching { it.canonicalFile }.getOrDefault(it.absoluteFile) }.distinct()
return homes.firstNotNullOfOrNull(::ofHome)
}
private fun ofHome(root: File): SonicPiInstall? {
val daemon = File(root, "app/server/ruby/bin/daemon.rb").canonicalFile
if (!daemon.isFile) return null
// Linux AppImages put Ruby beside share/, not below Sonic Pi's app/server/native/.
val appImageRuby = File(root, "../../ruby/bin/ruby").canonicalFile
val ruby = sequenceOf(
File(root, "app/server/native/ruby/bin/ruby"),
File(root, "server/native/ruby/bin/ruby"),
appImageRuby,
File("/usr/bin/ruby")
).map { runCatching { it.canonicalFile }.getOrDefault(it.absoluteFile) }
.firstOrNull { it.canExecute() } ?: return null
val environment = if (ruby == appImageRuby) appImageEnvironment(root) else emptyMap()
return SonicPiInstall(root.canonicalFile, ruby, daemon, environment)
}
private fun appImageEnvironment(home: File): Map<String, String> {
val usr = File(home, "../..").canonicalFile
val rubyBin = File(usr, "ruby/bin").absolutePath
val libraryPath = listOf(File(usr, "lib"), File(usr, "ruby/lib"))
.joinToString(File.pathSeparator) { it.absolutePath }
val currentPath = System.getenv("PATH").orEmpty()
val currentLibraries = System.getenv("LD_LIBRARY_PATH").orEmpty()
return mapOf(
"PATH" to listOf(rubyBin, currentPath).filter(String::isNotBlank).joinToString(File.pathSeparator),
"LD_LIBRARY_PATH" to listOf(libraryPath, currentLibraries)
.filter(String::isNotBlank)
.joinToString(File.pathSeparator)
)
}
private fun appImageCandidates(): List<File> {
val home = File(System.getProperty("user.home"))
return listOf(File(home, "Downloads"), File(home, "downloads"))
.flatMap { directory ->
directory.listFiles { file ->
file.isFile && file.name.contains("sonic", ignoreCase = true) &&
file.name.contains("pi", ignoreCase = true) &&
file.name.endsWith(".AppImage", ignoreCase = true)
}?.toList().orEmpty()
}
.distinctBy { it.absolutePath }
.sortedByDescending { it.lastModified() }
}
@Synchronized
private fun fromAppImage(appImage: File): SonicPiInstall {
require(appImage.canExecute()) { "Sonic Pi AppImage is not executable: $appImage" }
val cacheBase = System.getenv("XDG_CACHE_HOME")
?.takeIf(String::isNotBlank)
?.let(::File)
?: File(System.getProperty("user.home"), ".cache")
val cacheKey = buildString {
append(appImage.nameWithoutExtension.replace(Regex("[^A-Za-z0-9._-]"), "_"))
append('-').append(appImage.length())
append('-').append(appImage.lastModified())
}
val cache = File(cacheBase, "field4/sonic-pi/appimages/$cacheKey")
val extracted = File(cache, "squashfs-root")
val complete = File(cache, ".field4-extraction-complete")
if (complete.isFile) {
of(extracted)?.let { return it }
}
check(cache.mkdirs() || cache.isDirectory) { "Could not create Sonic Pi cache: $cache" }
val extractionLog = File(cache, "extract.log")
val process = ProcessBuilder(appImage.absolutePath, "--appimage-extract", "usr")
.directory(cache)
.redirectOutput(extractionLog)
.redirectErrorStream(true)
.start()
val exit = process.waitFor()
check(exit == 0) {
"Could not extract Sonic Pi AppImage (exit $exit). See $extractionLog"
}
val install = of(extracted)
?: error("Sonic Pi AppImage extracted, but its server/Ruby layout was not recognised: $extracted")
complete.writeText("${appImage.absolutePath}\n${appImage.length()}\n${appImage.lastModified()}\n")
return install
}
fun isInstalled(): Boolean = runCatching { discover() }.isSuccess
data class SonicPiLogLine(val style: Int, val text: String)
One styled line of Sonic Pi's log. Style 0 is Sonic Pi's own chatter, 1 is your puts.
data class SonicPiLogMessage
data class SonicPiLogMessage(
val jobId: Int,
val thread: String,
val runtime: Double,
val lines: List<SonicPiLogLine>
) {
val text: String get() = lines.joinToString("\n") { it.text }
override fun toString(): String
override fun toString(): String {
val where = if (jobId >= 0) "run $jobId" else thread
return "[sonic-pi $where] $text"
}
}
data class SonicPiError
data class SonicPiError(
val jobId: Int,
val message: String,
val backtrace: String,
val line: Int,
val firstColumn: Int,
val lastColumn: Int,
val sourceLine: String,
val syntax: Boolean
) {
override fun toString(): String
override fun toString(): String = buildString {
append("[sonic-pi ")
append(if (syntax) "syntax error" else "error")
if (jobId >= 0) append(" in run $jobId")
if (line >= 0) append(" at line $line")
append("] ")
append(message.trim())
if (sourceLine.isNotBlank()) {
append("\n ")
append(sourceLine.trim())
}
}
}
internal fun sonicPiErrorBoxLine(sonicPiLine: Int, lineOffset: Int): Int
Translate a 1-based Sonic Pi source line into Field's 1-based output protocol.
internal fun sonicPiErrorBoxLine(sonicPiLine: Int, lineOffset: Int): Int =
if (sonicPiLine < 1) -1 else sonicPiLine + lineOffset
data class SonicPiVersion
data class SonicPiVersion(
val version: String,
val versionNumber: Int,
val latestVersion: String?,
val platform: String?
)
fun sonicPi(): SonicPi = SonicPi.shared()
The Sonic Pi this Field owns, booting it on first use.
Booting takes a couple of seconds; this returns as soon as the daemon has handed over its ports, which is before the audio engine is up. Call SonicPi.awaitReady first if the very next thing you do is make a sound.
fun sonicPi(code: String, workspace: String? = null): SonicPiLaunch
Boot Sonic Pi if necessary and evaluate code in it.
The one-liner form, for a box that is a piece of music:
sonicPi("live_loop :field do; sample :bd_haus; sleep 0.5; end")
Re-running the box redefines the live_loop in place rather than starting a second one — that is
Sonic Pi's own semantics for a named live_loop, and the reason this pairs so well with a box.
fun sonicPi(code: String, workspace: String? = null): SonicPiLaunch {
val sp = SonicPi.shared()
sp.awaitReady()
val where = workspace ?: "field4"
val prior = sp.jobIdFor(where)
sp.run(code, where)
return SonicPiLaunch(sp, where, prior)
}
fun Box.sonicPi(code: String, workspace: String? = null, lineOffset: Int = -1): SonicPiLaunch
Run Sonic Pi code, get its errors back in this box, and tie the music to the box's lifecycle.
Returning the result as the box's last expression is what connects the two: the box stays lit for as long as the music plays, and stopping the box stops it (see SonicPiLaunch).
Prefer this over the bare sonicPi from inside a box. Sonic Pi reports errors asynchronously, so
by the time one arrives the box has finished running and there is nothing left to attribute it to
— this wires the box up as the destination first (see SonicPi.reportTo). It matters most for the
failure that looks like success: a live_loop whose body raises on its own thread, minutes into a
performance, with the run that started it long since finished.
sonicPi("""
live_loop :bikes do
...
end
""")
workspace defaults to the box's name, falling back to its id, and is the name Sonic Pi uses when
it describes where an error happened.
fun Box.sonicPi(code: String, workspace: String? = null, lineOffset: Int = -1): SonicPiLaunch {
val where = workspace ?: name.ifBlank { id }
val offset = if (lineOffset >= 0) lineOffset else inferSonicPiLineOffset(this, code)
val sp = SonicPi.shared()
sp.reportTo(where, this, offset)
sp.awaitReady()
val prior = sp.jobIdFor(where)
sp.run(code, where)
return SonicPiLaunch(sp, where, prior, offset)
}