SonicPiActivity.kt
src/jvmMain/kotlin/afield/audio/synth/SonicPiActivity.kt · 111 lines
package afield.audio.synth
import afield.document.graph.Box
import afield.ui.output.RawDecoration
import afield.ui.output.decorationState
Sonic Pi's per-line activity, shown in the editor.
Sonic Pi tells its client which line just triggered a note — /flash, from play, synth,
sample, control, play_chord and the with_fx/live_loop block boundaries, tagged with the
caller's line. That answers "what is making sound, and where in the code", so it belongs on the
code rather than in a log.
Three things make this less trivial than painting the lines that flashed.
The rate is musical, not human. Two live_loops at a modest tempo produce something like sixteen
flashes a second and each iteration reports the loop head, the play and the end. Repainting per
flash would push a decoration set over the websocket faster than anyone can see, so
sonicPiActivityDecorations is a pure function of recency that a caller samples at a sane rate —
the launch loop's 50ms poll — rather than something the flash handler drives.
Lines have to cool off. A flash is an instant, not a duration, so a line that fired 40ms ago and one that fired 400ms ago are both "recent" and should not look identical; without a decay the display latches on and every line that has ever sounded stays lit.
And the line numbers need translating. Sonic Pi counts from line 1 of the source it was handed, which is the inside of a Kotlin triple-quoted string — see inferSonicPiLineOffset for why that offset is inferred rather than assumed to be zero.
internal enum class SonicPiHeat(val className: String, val maxAgeMs: Long, val alpha: String)
How recently a line fired, bucketed for display.
internal enum class SonicPiHeat(val className: String, val maxAgeMs: Long, val alpha: String) {
HOT("afield-sonicpi-flash-hot", 120L, "0.42"),
WARM("afield-sonicpi-flash-warm", 300L, "0.26"),
COOL("afield-sonicpi-flash-cool", 650L, "0.12");
companion object
fun forAge(ageMs: Long): SonicPiHeat? = entries.firstOrNull { ageMs <= it.maxAgeMs }
A left border rather than a full-width background: the line stays readable while it pulses, and a background wide enough to notice is a background too loud to code against.
private fun heatCss(heat: SonicPiHeat): String =
".${heat.className} { background: rgba(120, 210, 255, ${heat.alpha}); " +
"box-shadow: inset 2px 0 0 rgba(120, 210, 255, 0.9); }"
internal fun sonicPiActivityDecorations
Decorations for the lines of workspace that are currently sounding.
Pure, and it has to be: the whole display is a function of (ages, offset), so the behaviour that matters — decay, translation, coalescing — is testable without an editor, a websocket or a box.
internal fun sonicPiActivityDecorations(
ages: Map<Int, Long>,
lineOffset: Int,
columnLimit: Int = 400
): List<RawDecoration> = ages.entries
.mapNotNull { (sonicPiLine, ageMs) ->
val heat = SonicPiHeat.forAge(ageMs) ?: return@mapNotNull null
val boxLine = sonicPiLine + lineOffset
if (boxLine < 1) return@mapNotNull null
// RawDecoration is 1-based and Monaco clamps a range to the line's real end, so a generous
// end column paints the whole line without this needing to know the text.
RawDecoration(
lineStart = boxLine,
colStart = 1,
lineEnd = boxLine,
colEnd = columnLimit,
classname = heat.className,
classDef = mapOf(heat.className to heatCss(heat))
)
}
.sortedBy { it.lineStart }
internal fun applySonicPiActivity
Paint one frame of activity onto box.
Returns the decorations applied, so a caller can tell whether anything changed without reading the box back. Swallows failures: this is decoration, and a box with no live session (a headless run, a box the user has since deleted) must not take the music down with it.
internal fun applySonicPiActivity(
box: Box,
sonicPi: SonicPi,
workspace: String,
lineOffset: Int
): List<RawDecoration> {
val decorations = sonicPiActivityDecorations(sonicPi.lineAges(workspace), lineOffset)
runCatching {
val state = box.decorationState
val stale = state.keys.filter { it.startsWith(ACTIVITY_KEY_PREFIX) }
stale.forEach { state.remove(it) }
decorations.forEach { state["$ACTIVITY_KEY_PREFIX${it.lineStart}"] = it }
}
return decorations
}
internal fun clearSonicPiActivity(box: Box)
Remove every activity decoration, for when the music stops.
internal fun clearSonicPiActivity(box: Box) {
runCatching {
val state = box.decorationState
state.keys.filter { it.startsWith(ACTIVITY_KEY_PREFIX) }.forEach { state.remove(it) }
}
}
Namespaced so this shares Box.decorationState with error markers and everything else that decorates a box, and only ever clears its own entries.
private const val ACTIVITY_KEY_PREFIX = "sonicpi-activity:"