Skip to content

Sequencing.kt

src/jvmMain/kotlin/afield/audio/synth/Sequencing.kt · 866 lines

@file:afield.runtime.completion.CompletionCategory("Patterns")
package afield.audio.synth

import afield.runtime.lifecycle.GeneratorScope
import afield.runtime.lifecycle.RateLimiter
import afield.ui.visualization.ToHTML
import afield.ui.output._printHere
import afield.runtime.lifecycle.generator
import afield.ui.output.locationForTextStringOnStack
import afield.document.graph.Marker
import java.util.Locale
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToInt

Notes with times on them, and a lazy player for a stream of them. ScheduledNote is the event, noteProcess { … } turns a suspending block into a NoteProcess, and a NoteProcess draws itself as a piano roll.

Time is in seconds and pitch is a MIDI number, fractional wherever you want it, with midiToFrequency and frequencyToMidi to cross between the two. Inside the block the receiver is a NoteSequenceScope carrying a cursor, so consecutive note(…) calls lay themselves out end to end and rest(…) leaves a gap.

The block is a generator rather than a list, which is what makes while (true) a legitimate way to write a sequence: a NoteProcess pulls from it only as far as the window it is asked about, keeps a few seconds of what has already gone past, and drops the rest. Moving the playhead is what unrolls more.

Nothing here makes a sound. A NoteProcess is a structure to read notes out of and to look at; TidalPatterns.kt is the other way to write one, and the synthesis category is what plays it.

private const val PIANO_ROLL_BLUE = "#0af"
private const val PIANO_ROLL_ORANGE = "#f80"
private const val PIANO_ROLL_BACKGROUND = "#000000"
private const val PIANO_ROLL_FONT = "\"Iosevka Web\", \"IBM Plex Mono\", monospace"

data class ScheduledNote

One note: when it starts, how long it lasts, what pitch it is, and how hard.

start and duration are in seconds, pitch is a MIDI number with frequencyHz to convert, and label and color exist for the piano roll rather than for the sound. Every number is checked for being finite at construction and the duration for not being negative, so a note built out of arithmetic that went wrong fails where it was made instead of where it is drawn. note(…) is the shorter spelling and takes any Number.

data class ScheduledNote(
    val start: Double,
    val duration: Double,
    val pitch: Float,
    val velocity: Float = 1f,
    val label: String? = null,
    val color: String? = null
) {

init

    init {
        require(start.isFinite()) { "ScheduledNote.start must be finite" }
        require(duration.isFinite()) { "ScheduledNote.duration must be finite" }
        require(duration >= 0.0) { "ScheduledNote.duration must be >= 0" }
        require(pitch.isFinite()) { "ScheduledNote.pitch must be finite" }
        require(velocity.isFinite()) { "ScheduledNote.velocity must be finite" }
    }

val end: Double

    val end: Double
        get() = start + duration

val frequencyHz: Double

    val frequencyHz: Double
        get() = midiToFrequency(pitch.toDouble())

fun intersects(windowStartSeconds: Double, windowEndSeconds: Double): Boolean

    fun intersects(windowStartSeconds: Double, windowEndSeconds: Double): Boolean =
        end >= windowStartSeconds && start <= windowEndSeconds

override fun toString(): String

    override fun toString(): String = buildString {
        append("ScheduledNote(")
        append("start=${start.prettySeconds()}, duration=${duration.prettySeconds()}, pitch=${pitch.prettyPitch()}")
        if (velocity != 1f) append(", velocity=${velocity.prettyPitch()}")
        if (label != null) append(", label=\"${label.prettyText(24)}\"")
        if (color != null) append(", color=$color")
        append(")")
    }
}

fun note

fun note(
    pitch: Number,
    duration: Number,
    start: Number = 0.0,
    velocity: Number = 1.0,
    label: String? = null,
    color: String? = null
): ScheduledNote = ScheduledNote(
    start = start.toDouble(),
    duration = duration.toDouble(),
    pitch = pitch.toFloat(),
    velocity = velocity.toFloat(),
    label = label,
    color = color
)

fun midiToFrequency(midi: Number): Double = 440.0 * 2.0.pow((midi.toDouble() - 69.0) / 12.0)

fun frequencyToMidi(freqHz: Number): Double

fun frequencyToMidi(freqHz: Number): Double = 69.0 + 12.0 * (ln(freqHz.toDouble() / 440.0) / ln(2.0))

private fun Double.prettySeconds(): String = String.format(Locale.US, "%.4fs", this).replace(".0000s", "s")

private fun Float.prettyPitch(): String = String.format(Locale.US, "%.3f", this).trimEnd('0').trimEnd('.')

private fun String.prettyText(limit: Int): String = if (length <= limit) this else take(limit - 3) + "..."

data class NoteWindowSnapshot

The notes visible in one span of time, taken around the playhead they were asked for.

notes is everything intersecting windowStartSeconds to windowEndSeconds, so a note that began before the window and is still sounding inside it is included, and the piano roll can draw it running off the left edge. exhausted says the sequence behind the window has run out, and generatedUntilSeconds how far it has been unrolled so far. It renders as a piano roll, so returning one from a box draws it.

data class NoteWindowSnapshot(
    val notes: List<ScheduledNote>,
    val playheadSeconds: Double,
    val windowStartSeconds: Double,
    val windowEndSeconds: Double,
    val exhausted: Boolean = false,
    val generatedUntilSeconds: Double = windowEndSeconds
) : ToHTML {

init

    init {
        require(windowStartSeconds <= windowEndSeconds) { "windowStartSeconds must be <= windowEndSeconds" }
    }

override fun toHTML(): String = pianoRollSvg()

override fun toString(): String

    override fun toString(): String = synthPrettyObject(
        this,
        "notes" to notes.take(4),
        "playheadSeconds" to playheadSeconds,
        "windowStartSeconds" to windowStartSeconds,
        "windowEndSeconds" to windowEndSeconds,
        "exhausted" to exhausted,
        "generatedUntilSeconds" to generatedUntilSeconds
    )
}

class NoteSequenceScope internal constructor

The receiver inside noteProcess { … }, and the cursor that lets a sequence read in the order it is played.

note and chord place what they are given at cursorSeconds and then move the cursor to the end of it, so consecutive calls run end to end and rest leaves a gap. Passing an explicit start overrides the cursor for that one note and leaves it where it was if the note ends earlier, which is how a counter-melody is written against a line already laid down. emit does the same for a ScheduledNote you built elsewhere.

class NoteSequenceScope internal constructor(
    private val scope: GeneratorScope<Unit, ScheduledNote>
) {

var cursorSeconds: Double = 0.0

suspend fun note

    suspend fun note(
        pitch: Number,
        duration: Number,
        velocity: Number = 1.0,
        start: Number? = null,
        label: String? = null,
        color: String? = null
    ): ScheduledNote {
        val scheduled = ScheduledNote(
            start = start?.toDouble() ?: cursorSeconds,
            duration = duration.toDouble(),
            pitch = pitch.toFloat(),
            velocity = velocity.toFloat(),
            label = label,
            color = color
        )
        emit(scheduled)
        cursorSeconds = max(cursorSeconds, scheduled.end)
        return scheduled
    }

suspend fun chord

    suspend fun chord(
        vararg pitches: Number,
        durationSeconds: Number,
        velocity: Number = 1.0,
        startSeconds: Number? = null,
        label: String? = null,
        color: String? = null
    ) {
        val start = startSeconds?.toDouble() ?: cursorSeconds
        pitches.forEach { pitch ->
            emit(
                ScheduledNote(
                    start = start,
                    duration = durationSeconds.toDouble(),
                    pitch = pitch.toFloat(),
                    velocity = velocity.toFloat(),
                    label = label,
                    color = color
                )
            )
        }
        cursorSeconds = max(cursorSeconds, start + durationSeconds.toDouble())
    }

fun rest(durationSeconds: Number)

    fun rest(durationSeconds: Number) {
        cursorSeconds += durationSeconds.toDouble()
    }

fun advance(durationSeconds: Number)

    fun advance(durationSeconds: Number) {
        rest(durationSeconds)
    }

suspend fun emit(note: ScheduledNote)

    suspend fun emit(note: ScheduledNote) {
        scope.yield(note)
        cursorSeconds = max(cursorSeconds, note.end)
    }
}

class NoteProcess internal constructor

A sequence of notes pulled from a generator on demand, with a playhead and a window onto it.

Nothing is materialised until it is asked for, which is what makes an endless sequence practical. at moves the playhead and unrolls defaultFutureSeconds past it; historyRetentionSeconds of what has already gone by is kept for the roll to draw and the rest is dropped, so a while (true) block does not grow without bound. exhausted is how a finite sequence reports that it is done.

snapshot takes a NoteWindowSnapshot around the playhead and notesBetween answers about any span, unrolling as far as it has to; both can be called while the playhead is being moved elsewhere. Construct one with noteProcess { … } or notes { … }, which are the same function, or from a pattern with PatPattern.notes(…) in TidalPatterns.kt.

class NoteProcess internal constructor(
    private val iteratorFactory: () -> Iterator<ScheduledNote>,
    historyRetentionSeconds: Double = 8.0,
    defaultPastSeconds: Double = 1.0,
    defaultFutureSeconds: Double = 6.0
) : ToHTML {

    private val lock = Any()
    private var cachedNotes = ArrayDeque<ScheduledNote>()
    private var sortedNotes = emptyList<ScheduledNote>()
    private var iterator: Iterator<ScheduledNote> = iteratorFactory()
    private var nextNote: ScheduledNote? = null
    private var stateVersion = 0L
    private var lastGeneratedStartSeconds = Double.NEGATIVE_INFINITY
    private var renderCacheKey: PianoRollRenderKey? = null
    private var renderCacheValue: String? = null

var historyRetentionSeconds: Double

    var historyRetentionSeconds: Double = historyRetentionSeconds.coerceAtLeast(0.0)
        set(value) {
            synchronized(lock) {
                field = value.coerceAtLeast(0.0)
                pruneLocked()
            }
        }

var defaultPastSeconds: Double

    var defaultPastSeconds: Double = defaultPastSeconds.coerceAtLeast(0.0)
        set(value) {
            synchronized(lock) {
                field = value.coerceAtLeast(0.0)
            }
        }

var defaultFutureSeconds: Double

    var defaultFutureSeconds: Double = defaultFutureSeconds.coerceAtLeast(0.0)
        set(value) {
            synchronized(lock) {
                field = value.coerceAtLeast(0.0)
            }
        }

var playheadSeconds: Double

    @Volatile
    var playheadSeconds: Double = 0.0
        private set

var generatedUntilSeconds: Double

    @Volatile
    var generatedUntilSeconds: Double = Double.NEGATIVE_INFINITY
        private set

var exhausted: Boolean

    @Volatile
    var exhausted: Boolean = false
        private set

override fun toHTML(): String = pianoRollSvg()

fun reset(playheadSeconds: Number = this.playheadSeconds): NoteProcess

    fun reset(playheadSeconds: Number = this.playheadSeconds): NoteProcess = apply {
        synchronized(lock) {
            cachedNotes = ArrayDeque()
            sortedNotes = emptyList()
            iterator = iteratorFactory()
            nextNote = null
            stateVersion = 0L
            lastGeneratedStartSeconds = Double.NEGATIVE_INFINITY
            renderCacheKey = null
            renderCacheValue = null
            generatedUntilSeconds = Double.NEGATIVE_INFINITY
            exhausted = false
            this.playheadSeconds = playheadSeconds.toDouble()
            primeLocked()
            generatedUntilSeconds = this.playheadSeconds
            touchLocked()
        }
        unrollTo(this.playheadSeconds + defaultFutureSeconds)
        prune()
    }

fun at(timeSeconds: Number): NoteProcess

    fun at(timeSeconds: Number): NoteProcess = apply {
        synchronized(lock) {
            playheadSeconds = timeSeconds.toDouble()
            touchLocked()
            generatedUntilSeconds = max(generatedUntilSeconds, playheadSeconds)
        }
        unrollTo(timeSeconds.toDouble() + defaultFutureSeconds)
        prune()
    }

fun advanceTo(timeSeconds: Number): NoteProcess = at(timeSeconds)

fun advanceBy(deltaSeconds: Number): NoteProcess = at(playheadSeconds + deltaSeconds.toDouble())

fun unrollTo(timeSeconds: Number): NoteProcess

    fun unrollTo(timeSeconds: Number): NoteProcess = apply {
        synchronized(lock) {
            val target = timeSeconds.toDouble()
            var changed = false
            while (true) {
                primeLocked()
                val candidate = nextNote ?: break
                if (candidate.start > target + 1e-9) break
                cachedNotes.addLast(candidate)
                lastGeneratedStartSeconds = candidate.start
                nextNote = null
                changed = true
            }
            generatedUntilSeconds = max(generatedUntilSeconds, target)
            if (changed) {
                sortedNotes = cachedNotes.toList()
                touchLocked()
            }
        }
    }

override fun toString(): String

    override fun toString(): String = synthPrettyObject(
        this,
        "playheadSeconds" to playheadSeconds,
        "generatedUntilSeconds" to generatedUntilSeconds,
        "cachedNotes" to synchronized(lock) { cachedNotes.size },
        "exhausted" to exhausted,
        "historyRetentionSeconds" to historyRetentionSeconds
    )

fun prune(): NoteProcess

    fun prune(): NoteProcess = apply {
        synchronized(lock) {
            pruneLocked()
        }
    }

fun notesBetween(startSeconds: Number, endSeconds: Number): List<ScheduledNote>

    fun notesBetween(startSeconds: Number, endSeconds: Number): List<ScheduledNote> {
        val windowStart = min(startSeconds.toDouble(), endSeconds.toDouble())
        val windowEnd = max(startSeconds.toDouble(), endSeconds.toDouble())
        unrollTo(windowEnd)
        synchronized(lock) {
            return sortedNotes.filter { it.intersects(windowStart, windowEnd) }
        }
    }

fun snapshot

    fun snapshot(
        pastSeconds: Number = defaultPastSeconds,
        futureSeconds: Number = defaultFutureSeconds
    ): NoteWindowSnapshot {
        val past = pastSeconds.toDouble().coerceAtLeast(0.0)
        val future = futureSeconds.toDouble().coerceAtLeast(0.0)
        val windowStart = playheadSeconds - past
        val windowEnd = playheadSeconds + future

        unrollTo(windowEnd)
        prune()

        synchronized(lock) {
            val notes = sortedNotes.filter { it.intersects(windowStart, windowEnd) }
            return NoteWindowSnapshot(
                notes = notes,
                playheadSeconds = playheadSeconds,
                windowStartSeconds = windowStart,
                windowEndSeconds = windowEnd,
                exhausted = exhausted,
                generatedUntilSeconds = generatedUntilSeconds
            )
        }
    }

fun pianoRollSvg

body — 91 lines, from line 396
    fun pianoRollSvg(
        widthPx: Int = 480,
        heightPx: Int = 96,
        pastSeconds: Number = defaultPastSeconds,
        futureSeconds: Number = defaultFutureSeconds,
        title: String = "Piano Roll",
        background: String = PIANO_ROLL_BACKGROUND,
        primaryColor: String = PIANO_ROLL_BLUE,
        secondaryColor: String = PIANO_ROLL_ORANGE,
        fontFamily: String = PIANO_ROLL_FONT
    ): String {
        val snapshot = snapshot(pastSeconds, futureSeconds)
        val version = synchronized(lock) { stateVersion }
        val key = PianoRollRenderKey(
            version = version,
            widthPx = widthPx,
            heightPx = heightPx,
            title = title,
            background = background,
            primaryColor = primaryColor,
            secondaryColor = secondaryColor,
            fontFamily = fontFamily,
            pastMillis = (pastSeconds.toDouble() * 1000.0).roundToInt(),
            futureMillis = (futureSeconds.toDouble() * 1000.0).roundToInt()
        )
        synchronized(lock) {
            if (renderCacheKey == key && renderCacheValue != null) return renderCacheValue!!
        }
        val svg = snapshot.pianoRollSvg(
            widthPx = widthPx,
            heightPx = heightPx,
            title = title,
            background = background,
            primaryColor = primaryColor,
            secondaryColor = secondaryColor,
            fontFamily = fontFamily
        )
        synchronized(lock) {
            renderCacheKey = key
            renderCacheValue = svg
        }
        return svg
    }

    private fun pruneLocked() {
        val retainFrom = playheadSeconds - max(historyRetentionSeconds, defaultPastSeconds)
        var changed = false
        while (cachedNotes.isNotEmpty() && cachedNotes.first().end < retainFrom) {
            cachedNotes.removeFirst()
            changed = true
        }
        if (changed) {
            sortedNotes = cachedNotes.toList()
            touchLocked()
        }
    }

    private fun touchLocked() {
        stateVersion++
        renderCacheKey = null
        renderCacheValue = null
    }

    private fun primeLocked() {
        if (nextNote != null || exhausted) return
        if (!iterator.hasNext()) {
            exhausted = true
            return
        }
        val candidate = iterator.next()
        if (candidate.start + 1e-9 < lastGeneratedStartSeconds) {
            throw IllegalArgumentException(
                "NoteProcess requires nondecreasing start times; saw ${candidate.start} after $lastGeneratedStartSeconds"
            )
        }
        nextNote = candidate
    }

    private data class PianoRollRenderKey(
        val version: Long,
        val widthPx: Int,
        val heightPx: Int,
        val title: String,
        val background: String,
        val primaryColor: String,
        val secondaryColor: String,
        val fontFamily: String,
        val pastMillis: Int,
        val futureMillis: Int
    )
}

fun noteProcess

fun noteProcess(
    historyRetentionSeconds: Double = 8.0,
    defaultPastSeconds: Double = 1.0,
    defaultFutureSeconds: Double = 6.0,
    block: suspend NoteSequenceScope.() -> Unit
): NoteProcess = NoteProcess(
    iteratorFactory = {
        noteIterator(block)
    },
    historyRetentionSeconds = historyRetentionSeconds,
    defaultPastSeconds = defaultPastSeconds,
    defaultFutureSeconds = defaultFutureSeconds
).reset()

fun notes

fun notes(
    historyRetentionSeconds: Double = 8.0,
    defaultPastSeconds: Double = 1.0,
    defaultFutureSeconds: Double = 6.0,
    block: suspend NoteSequenceScope.() -> Unit
): NoteProcess = noteProcess(
    historyRetentionSeconds = historyRetentionSeconds,
    defaultPastSeconds = defaultPastSeconds,
    defaultFutureSeconds = defaultFutureSeconds,
    block = block
)

fun Iterable<ScheduledNote>.pianoRollSvg

fun Iterable<ScheduledNote>.pianoRollSvg(
    nowSeconds: Number = 0.0,
    pastSeconds: Number = 1.0,
    futureSeconds: Number = 6.0,
    widthPx: Int = 480,
    heightPx: Int = 96,
    title: String = "Piano Roll",
    background: String = PIANO_ROLL_BACKGROUND,
    primaryColor: String = PIANO_ROLL_BLUE,
    secondaryColor: String = PIANO_ROLL_ORANGE,
    fontFamily: String = PIANO_ROLL_FONT
): String {
    val playhead = nowSeconds.toDouble()
    val past = pastSeconds.toDouble().coerceAtLeast(0.0)
    val future = futureSeconds.toDouble().coerceAtLeast(0.0)
    return NoteWindowSnapshot(
        notes = this.filter { it.intersects(playhead - past, playhead + future) }.sortedBy { it.start },
        playheadSeconds = playhead,
        windowStartSeconds = playhead - past,
        windowEndSeconds = playhead + future
    ).pianoRollSvg(
        widthPx = widthPx,
        heightPx = heightPx,
        title = title,
        background = background,
        primaryColor = primaryColor,
        secondaryColor = secondaryColor,
        fontFamily = fontFamily
    )
}

fun NoteWindowSnapshot.pianoRollSvg

body — 174 lines, from line 545
fun NoteWindowSnapshot.pianoRollSvg(
    widthPx: Int = 480,
    heightPx: Int = 96,
    title: String = "Piano Roll",
    background: String = PIANO_ROLL_BACKGROUND,
    primaryColor: String = PIANO_ROLL_BLUE,
    secondaryColor: String = PIANO_ROLL_ORANGE,
    fontFamily: String = PIANO_ROLL_FONT
): String {
    val spanSeconds = (windowEndSeconds - windowStartSeconds).coerceAtLeast(0.001)
    val w = widthPx.toDouble()
    val h = heightPx.toDouble()
    val padLeft = 34.0
    val padRight = 8.0
    val padTop = 18.0
    val padBottom = 12.0
    val innerW = (w - padLeft - padRight).coerceAtLeast(1.0)
    val innerH = (h - padTop - padBottom).coerceAtLeast(1.0)
    val x0 = padLeft
    val y0 = padTop

    if (notes.isEmpty()) {
        return """
            <svg xmlns="http://www.w3.org/2000/svg" width="$widthPx" height="$heightPx" viewBox="0 0 $widthPx $heightPx">
              <rect x="0" y="0" width="$widthPx" height="$heightPx" fill="$background"/>
              <text x="${x0.f2()}" y="13" font-family="$fontFamily" font-size="10" fill="$primaryColor">$title</text>
              ${buildRollBadges(
                  listOf(
                      RollBadge("now ${formatSeconds(playheadSeconds)}s"),
                      RollBadge("${formatSeconds(spanSeconds)}s span")
                  ),
                  widthPx = widthPx,
                  y = 13,
                  fontFamily = fontFamily
              )}
              <line x1="${xForTime(playheadSeconds, windowStartSeconds, spanSeconds, x0, innerW).f2()}" y1="${y0.f2()}" x2="${xForTime(playheadSeconds, windowStartSeconds, spanSeconds, x0, innerW).f2()}" y2="${(y0 + innerH).f2()}" stroke="$secondaryColor" stroke-width="1.4" opacity="0.85"/>
              <text x="${(widthPx / 2.0).f2()}" y="${(heightPx / 2.0).f2()}" text-anchor="middle" dominant-baseline="middle" font-family="$fontFamily" font-size="11" fill="$primaryColor" opacity="0.7">Waiting for notes</text>
            </svg>
        """.trimIndent()
    }

    val minPitchRaw = floor((notes.minOfOrNull { it.pitch.toDouble() } ?: 60.0) - 1.0)
    val maxPitchRaw = ceil((notes.maxOfOrNull { it.pitch.toDouble() } ?: 72.0) + 1.0)
    val paddedMinPitch = minPitchRaw
    val paddedMaxPitch = max(maxPitchRaw, paddedMinPitch + 6.0)
    val pitchSpan = (paddedMaxPitch - paddedMinPitch).coerceAtLeast(1.0)
    val laneHeight = max(4.0, innerH / (pitchSpan + 1.0))

    fun yForPitch(pitch: Double): Double {
        val t = ((pitch - paddedMinPitch) / pitchSpan).coerceIn(0.0, 1.0)
        return y0 + innerH - t * innerH
    }

    val horizontalGrid = buildString {
        val startPitch = paddedMinPitch.toInt()
        val endPitch = paddedMaxPitch.toInt()
        for (pitch in startPitch..endPitch) {
            val top = yForPitch(pitch + 0.5)
            val bottom = yForPitch(pitch - 0.5)
            val height = (bottom - top).coerceAtLeast(0.5)
            val noteClass = ((pitch % 12) + 12) % 12
            val opacity = when {
                noteClass == 0 -> 0.08
                noteClass in setOf(1, 3, 6, 8, 10) -> 0.045
                else -> 0.02
            }
            append(
                """<rect x="${x0.f2()}" y="${top.f2()}" width="${innerW.f2()}" height="${height.f2()}" fill="$primaryColor" opacity="${opacity.f3()}"/>"""
            )
            append('\n')
            if (noteClass == 0) {
                append(
                    """<line x1="${x0.f2()}" y1="${top.f2()}" x2="${(x0 + innerW).f2()}" y2="${top.f2()}" stroke="$primaryColor" opacity="0.12" stroke-width="1"/>"""
                )
                append('\n')
            }
        }
    }

    val timeStep = selectTimeDivision(spanSeconds)
    val verticalGrid = buildString {
        var gridTime = floor(windowStartSeconds / timeStep) * timeStep
        while (gridTime <= windowEndSeconds + 1e-9) {
            val x = xForTime(gridTime, windowStartSeconds, spanSeconds, x0, innerW)
            val rel = gridTime - playheadSeconds
            val isNow = abs(rel) < timeStep * 0.18
            val color = if (isNow) secondaryColor else primaryColor
            val opacity = if (isNow) 0.55 else 0.14
            val dash = if (isNow) "" else " stroke-dasharray=\"2,3\""
            append(
                """<line x1="${x.f2()}" y1="${y0.f2()}" x2="${x.f2()}" y2="${(y0 + innerH).f2()}" stroke="$color" stroke-width="${if (isNow) "1.4" else "1"}" opacity="$opacity"$dash/>"""
            )
            append('\n')
            val label = if (isNow) "now" else formatRelativeSeconds(rel)
            append(
                """<text x="${x.f2()}" y="${(y0 + innerH + 10).f2()}" text-anchor="middle" font-family="$fontFamily" font-size="9" fill="$color" opacity="${if (isNow) "0.92" else "0.7"}">${escapeRollXml(label)}</text>"""
            )
            append('\n')
            gridTime += timeStep
        }
    }

    val noteRects = buildString {
        notes.sortedWith(compareBy<ScheduledNote> { it.start }.thenByDescending { it.pitch }).forEach { note ->
            val clampedStart = note.start.coerceAtLeast(windowStartSeconds)
            val clampedEnd = note.end.coerceAtMost(windowEndSeconds)
            val x = xForTime(clampedStart, windowStartSeconds, spanSeconds, x0, innerW)
            val endX = xForTime(clampedEnd, windowStartSeconds, spanSeconds, x0, innerW)
            val width = max(2.0, endX - x)
            val y = yForPitch(note.pitch.toDouble()) - laneHeight * 0.42
            val fill = note.color ?: primaryColor
            val velocity = note.velocity.coerceIn(0f, 1f)
            val active = note.start <= playheadSeconds && note.end > playheadSeconds
            val past = note.end <= playheadSeconds
            val opacity = when {
                active -> 0.92
                past -> 0.28 + velocity * 0.18
                else -> 0.52 + velocity * 0.38
            }.coerceIn(0.18, 0.98)
            val stroke = if (active) secondaryColor else fill
            val strokeOpacity = if (active) 0.95 else 0.45
            append(
                """<rect x="${x.f2()}" y="${y.f2()}" width="${width.f2()}" height="${max(3.0, laneHeight * 0.84).f2()}" rx="2" fill="$fill" opacity="${opacity.f3()}" stroke="$stroke" stroke-opacity="${strokeOpacity.f3()}" stroke-width="${if (active) "1.2" else "0.7"}"/>"""
            )
            append('\n')
            append(
                """<line x1="${x.f2()}" y1="${y.f2()}" x2="${x.f2()}" y2="${(y + max(3.0, laneHeight * 0.84)).f2()}" stroke="${if (active) secondaryColor else fill}" stroke-width="1" opacity="0.95"/>"""
            )
            append('\n')
            if (!note.label.isNullOrBlank() && width >= 28.0) {
                append(
                    """<text x="${(x + 4).f2()}" y="${(y + max(9.0, laneHeight * 0.62)).f2()}" font-family="$fontFamily" font-size="8" fill="$background" opacity="0.95">${escapeRollXml(note.label)}</text>"""
                )
                append('\n')
            }
        }
    }

    val rangeMid = ((paddedMinPitch + paddedMaxPitch) * 0.5).roundToInt().toFloat()
    val pitchLabels = listOf(
        paddedMaxPitch.roundToInt().toFloat(),
        rangeMid,
        paddedMinPitch.roundToInt().toFloat()
    ).distinct()
        .joinToString("\n") { pitch ->
            val y = yForPitch(pitch.toDouble())
            """<text x="${(x0 - 4).f2()}" y="${(y + 3).f2()}" text-anchor="end" font-family="$fontFamily" font-size="9" fill="$primaryColor" opacity="0.75">${escapeRollXml(formatPitchName(pitch))}</text>"""
        }

    val badges = buildRollBadges(
        listOf(
            RollBadge("now ${formatSeconds(playheadSeconds)}s"),
            RollBadge("${notes.count { it.start <= playheadSeconds && it.end > playheadSeconds }} on", secondaryColor),
            RollBadge("${notes.size} notes"),
            RollBadge("${formatPitchName(paddedMinPitch.toFloat())}-${formatPitchName(paddedMaxPitch.toFloat())}")
        ),
        widthPx = widthPx,
        y = 13,
        fontFamily = fontFamily
    )

    return """
        <svg xmlns="http://www.w3.org/2000/svg" width="$widthPx" height="$heightPx" viewBox="0 0 $widthPx $heightPx">
          <rect x="0" y="0" width="$widthPx" height="$heightPx" fill="$background"/>
          <text x="${x0.f2()}" y="13" font-family="$fontFamily" font-size="10" fill="$primaryColor">$title</text>
          $badges
          $horizontalGrid
          $verticalGrid
          $noteRects
          <line x1="${xForTime(playheadSeconds, windowStartSeconds, spanSeconds, x0, innerW).f2()}" y1="${y0.f2()}" x2="${xForTime(playheadSeconds, windowStartSeconds, spanSeconds, x0, innerW).f2()}" y2="${(y0 + innerH).f2()}" stroke="$secondaryColor" stroke-width="1.5" opacity="0.95"/>
          $pitchLabels
        </svg>
    """.trimIndent()
}

fun NoteProcess.pianoRollHtml

fun NoteProcess.pianoRollHtml(
    widthPx: Int = 480,
    heightPx: Int = 96,
    pastSeconds: Number = defaultPastSeconds,
    futureSeconds: Number = defaultFutureSeconds
): String = pianoRollSvg(
    widthPx = widthPx,
    heightPx = heightPx,
    pastSeconds = pastSeconds,
    futureSeconds = futureSeconds
)

private class InlinePianoRollState {
    val outputRateLimiter = RateLimiter(10.0, 0.2)
}

private fun Marker.inlinePianoRollState(): InlinePianoRollState =
    properties.computeIfAbsent("__piano_roll__") { InlinePianoRollState() } as InlinePianoRollState

fun Marker.pianoRoll

body — 127 lines, from line 739
fun Marker.pianoRoll(
    value: NoteProcess,
    pastSeconds: Number = value.defaultPastSeconds,
    futureSeconds: Number = value.defaultFutureSeconds,
    widthPx: Int = 480,
    heightPx: Int = 96
): NoteProcess {
    val location = locationForTextStringOnStack(".pianoRoll(", this.box, end = true, offset = 1)
    val state = inlinePianoRollState()
    state.outputRateLimiter {
        location?.let {
            this.box._printHere(
                it.line,
                value.pianoRollSvg(
                    widthPx = widthPx,
                    heightPx = heightPx,
                    pastSeconds = pastSeconds,
                    futureSeconds = futureSeconds
                )
            )
        }
    }
    return value
}

private data class RollBadge(
    val text: String,
    val color: String = PIANO_ROLL_BLUE
)

private fun noteIterator(block: suspend NoteSequenceScope.() -> Unit): Iterator<ScheduledNote> {
    val generator = generator<ScheduledNote> {
        NoteSequenceScope(this).block()
    }
    return object : Iterator<ScheduledNote> {
        private var started = false
        private var finished = false
        private var nextValue: ScheduledNote? = null

        override fun hasNext(): Boolean {
            if (finished) return false
            if (!started) {
                nextValue = generator.start()
                started = true
                if (nextValue == null) finished = true
            }
            return !finished
        }

        override fun next(): ScheduledNote {
            if (!hasNext()) throw NoSuchElementException("No more scheduled notes")
            val out = nextValue ?: throw NoSuchElementException("No more scheduled notes")
            nextValue = generator.next(Unit)
            if (nextValue == null) finished = true
            return out
        }
    }
}

private fun buildRollBadges(
    badges: List<RollBadge>,
    widthPx: Int,
    y: Int,
    fontFamily: String
): String {
    if (badges.isEmpty()) return ""

    var cursor = widthPx - 8.0
    val parts = mutableListOf<String>()
    badges.asReversed().forEach { badge ->
        val estimatedWidth = badge.text.length * 6.2 + 8.0
        cursor -= estimatedWidth
        if (cursor < widthPx * 0.34) return@forEach
        parts += """<text x="${cursor.f2()}" y="$y" font-family="$fontFamily" font-size="10" fill="${badge.color}" opacity="0.92">${escapeRollXml(badge.text)}</text>"""
        cursor -= 8.0
    }
    return parts.joinToString("\n")
}

private fun xForTime(timeSeconds: Double, windowStartSeconds: Double, spanSeconds: Double, x0: Double, innerW: Double): Double {
    val t = ((timeSeconds - windowStartSeconds) / spanSeconds).coerceIn(0.0, 1.0)
    return x0 + t * innerW
}

private fun selectTimeDivision(spanSeconds: Double): Double {
    val candidates = doubleArrayOf(0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0)
    val target = spanSeconds / 6.0
    return candidates.firstOrNull { it >= target } ?: candidates.last()
}

private fun formatSeconds(value: Double): String {
    val absValue = abs(value)
    val decimals = when {
        absValue >= 10.0 -> 1
        absValue >= 1.0 -> 2
        else -> 3
    }
    return String.format(Locale.US, "%.${decimals}f", value)
}

private fun formatRelativeSeconds(value: Double): String =
    when {
        abs(value) < 0.0005 -> "now"
        value > 0.0 -> "+${formatSeconds(value)}"
        else -> formatSeconds(value)
    }

private fun formatPitchName(pitch: Float): String {
    val rounded = pitch.roundToInt()
    val cents = ((pitch - rounded) * 100f).roundToInt()
    val name = noteNames[(rounded % 12 + 12) % 12]
    val octave = (rounded / 12) - 1
    return if (cents == 0) "$name$octave" else "$name$octave${if (cents > 0) "+" else ""}$cents"
}

private fun escapeRollXml(text: String): String = text
    .replace("&", "&amp;")
    .replace("<", "&lt;")
    .replace(">", "&gt;")
    .replace("\"", "&quot;")
    .replace("'", "&#39;")

private fun Double.f2(): String = String.format(Locale.US, "%.2f", this)

private fun Double.f3(): String = String.format(Locale.US, "%.3f", this)

private val noteNames = arrayOf("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")