Skip to content

MidiPat.kt

src/jvmMain/kotlin/afield/audio/synth/MidiPat.kt · 379 lines

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

import afield.document.graph.Box
import afield.geometry.path.FLine
import afield.ui.visualization.lines
import afield.math.linalg.Vec4
import afield.graphics.render.color
import afield.graphics.render.filled
import afield.graphics.render.fline
import afield.graphics.render.rect
import afield.graphics.render.stroked
import afield.graphics.render.thickness
import afield.math.linalg.vec
import java.io.File
import kotlin.math.roundToInt
import kotlin.math.roundToLong

The bridge between the pattern algebra and MidiClip, plus a piano roll drawn as FLines.

Two vocabularies have to be reconciled, and the pattern stack's spelling wins both times.

Cycles against beats. A PatPattern is a function of cycles, measured in PatQ: a rational over BigInteger, so a subdivision is exact however fine it gets. A MIDI file is quarter notes and ticks. beatsPerCycle bridges the two, defaulting to 4 so that one cycle is one bar of 4/4, which is what the mini-notation reads like. It is an Int because the import direction divides by ppq * beatsPerCycle: an integer denominator is what lets MidiClip.toPat rebuild every onset as an exact PatQ rather than a truncated one. Export does not keep that guarantee and cannot — MidiNote holds its position as Double quarter notes.

Velocity and channel are not in the Pat vocabulary. PatAlgebra.kt names the parameters the rest of the stack reads — gain for amplitude, conventionally 0..1, and note for MIDI pitch — and there is no velocity parameter and no channel. Rather than add two more that only MIDI would ever set, export reads velocity from gain when it is present, and from an explicit velocity annotation (0..127) when a caller wants wire-level control. Import writes both, so a round trip through the algebra keeps its dynamics either way; MidiRoundTripTest.a pattern exports to MIDI and comes back as a pattern is where that is pinned.

A clip read back in becomes a finite pattern. Querying past the last note yields nothing rather than looping, so reach for slow, fast or cat to place a clip inside a longer structure.

The piano roll is FLine geometry rather than an SVG string: one filled rectangle per note over a beat-and-bar grid, with a horizontal rule at every C to give the eye an octave reference the way a real roll's keyboard gutter does. Each note line carries its pitch, velocity, start beat and track index in properties, so a caller can hit-test a click back to the note it landed on. Field's y axis grows downward, so a higher pitch draws at a smaller y; reversing that yields an upside-down roll that still looks plausible.

const val MIDI_DEFAULT_BEATS_PER_CYCLE: Int = 4

Beats per cycle used by MidiClip.toPat and PatPattern.toMidiClip when none is given.

fun <A> PatPattern<A>.toMidiClip

Render this pattern over cycles cycles into a MidiClip.

Only events with an onset in the window become notes: a PatPattern clips events to the arc it was queried with, so an event whose whole began before the window is a continuation and striking it again would double-trigger the note. That is why the filter is hasOnset and the length comes from extent, not from arc — the same rule FaustPattern.kt:90-93 follows.

Pitch is resolved in the order every other consumer in this package uses: the note annotation first, then the event's own value parsed as a note name or number by patNoteNumber. Note that patNoteNumber uses Tidal's octave convention, where c5 is MIDI 60 — not scientific pitch notation. An event whose value names no pitch (a sample name like bd) is skipped.

fun <A> PatPattern<A>.toMidiClip(
    cycles: Int = 1,
    bpm: Double = 120.0,
    beatsPerCycle: Int = MIDI_DEFAULT_BEATS_PER_CYCLE,
    velocity: Int = 100,
    channel: Int = 0,
    name: String? = null,
    ppq: Int = MIDI_DEFAULT_PPQ,
    program: Int? = null
): MidiClip {
    require(cycles > 0) { "cycles must be positive, was $cycles" }
    require(beatsPerCycle > 0) { "beatsPerCycle must be positive, was $beatsPerCycle" }

    val window = PatArc(PatQ.ZERO, PatQ.of(cycles.toLong()))
    val notes = events(window).mapNotNull { event -> event.toMidiNote(beatsPerCycle, velocity, channel) }

    return MidiClip(
        tracks = listOf(MidiTrackData(notes = notes, name = name, program = program)),
        tempoMap = listOf(0.0 to bpm),
        ppq = ppq,
        name = name,
        timeSignature = beatsPerCycle to 4
    )
}

One pattern event as a MidiNote, or null if the event names no pitch or occupies no time.

Velocity precedence: an explicit velocity annotation (0..127) beats gain (0..1, scaled), which beats fallbackVelocity. Duration is the event's extent scaled by its sustain annotation, which the pattern stack defines as a fraction of the arc rather than an absolute length (see FaustPattern.kt:105-107).

private fun <A> PatEvent<A>.toMidiNote(
    beatsPerCycle: Int,
    fallbackVelocity: Int,
    fallbackChannel: Int
): MidiNote? {
    if (!hasOnset) return null

    val pitch = annotations["note"]?.trim()?.toDoubleOrNull()
        ?: patNoteNumber(value.toString())?.toDouble()
        ?: return null

    val span = extent
    val startBeats = span.start.toDouble() * beatsPerCycle
    val fullBeats = span.dur.toDouble() * beatsPerCycle
    if (fullBeats <= 0.0) return null

    val sustain = annotations["sustain"]?.trim()?.toDoubleOrNull()?.coerceIn(0.0, 1.0) ?: 1.0
    val durationBeats = fullBeats * sustain
    if (durationBeats <= 0.0) return null

    val explicit = annotations["velocity"]?.trim()?.toDoubleOrNull()
    val gain = annotations["gain"]?.trim()?.toDoubleOrNull()
    val resolvedVelocity = when {
        explicit != null -> explicit.roundToInt()
        gain != null -> (gain.coerceIn(0.0, 1.0) * 127.0).roundToInt()
        else -> fallbackVelocity
    }

    val resolvedChannel = annotations["channel"]?.trim()?.toIntOrNull() ?: fallbackChannel

    return MidiNote(
        pitch = pitch.roundToInt().coerceIn(0, 127),
        startBeats = startBeats,
        durationBeats = durationBeats,
        velocity = resolvedVelocity.coerceIn(1, 127),
        channel = resolvedChannel.coerceIn(0, 15)
    )
}

fun <A> PatPattern<A>.writeMidi

Write this pattern to file as a Standard MIDI File. Returns file.

fun <A> PatPattern<A>.writeMidi(
    file: File,
    cycles: Int = 1,
    bpm: Double = 120.0,
    beatsPerCycle: Int = MIDI_DEFAULT_BEATS_PER_CYCLE,
    name: String? = null
): File = toMidiClip(cycles = cycles, bpm = bpm, beatsPerCycle = beatsPerCycle, name = name).writeTo(file)

fun MidiClip.toPat(beatsPerCycle: Int = MIDI_DEFAULT_BEATS_PER_CYCLE): PatPattern<String>

This clip as a pattern, so a recorded or imported performance can be composed with the algebra.

Each note becomes an event whose whole is its exact extent in cycles and whose value is its pitch as a decimal string — the spelling patNoteNumber parses back, so toPat().toMidiClip() is a round trip. note, velocity, gain and channel annotations are all set: gain because that is the key the rest of the pattern stack reads for amplitude, velocity because it is lossless.

Times stay exact. The conversion is the rational tick / (ppq * beatsPerCycle), built with PatQ.of rather than PatQ.fromDouble — the latter truncates, which would drift a long clip.

The pattern is finite: it renders events only where notes exist, so querying past the end of the clip yields nothing rather than looping. Use slow/fast/cat to place it in a longer structure.

fun MidiClip.toPat(beatsPerCycle: Int = MIDI_DEFAULT_BEATS_PER_CYCLE): PatPattern<String> {
    require(beatsPerCycle > 0) { "beatsPerCycle must be positive, was $beatsPerCycle" }
    val denominator = ppq.toLong() * beatsPerCycle

    // Precompute each note's exact cycle-space extent once, outside the render lambda: the lambda
    // runs on every query, and a pattern is queried per cycle for as long as it plays.
    val prepared = notes.mapNotNull { note ->
        val startTick = (note.startBeats * ppq).roundToLong()
        val endTick = (note.endBeats * ppq).roundToLong()
        if (endTick <= startTick) return@mapNotNull null
        val whole = PatArc(PatQ.of(startTick, denominator), PatQ.of(endTick, denominator))
        whole to note
    }

    return PatPattern { absArc ->
        prepared.mapNotNull { (whole, note) ->
            val part = whole.intersect(absArc) ?: return@mapNotNull null
            PatEvent(
                arc = part,
                value = note.pitch.toString(),
                annotations = mapOf(
                    "note" to note.pitch.toString(),
                    "velocity" to note.velocity.toString(),
                    "gain" to patFormatNumber(note.velocity / 127.0),
                    "channel" to note.channel.toString()
                ),
                whole = whole
            )
        }
    }
}

fun readMidiPat(file: File, beatsPerCycle: Int = MIDI_DEFAULT_BEATS_PER_CYCLE): PatPattern<String>

Read file as a Standard MIDI File and return it as a pattern.

fun readMidiPat(file: File, beatsPerCycle: Int = MIDI_DEFAULT_BEATS_PER_CYCLE): PatPattern<String> =
    readMidiClip(file).toPat(beatsPerCycle)

fun PatTranscription.toMidiClip

The notes a transcription found, as a MidiClip — the bridge PatTranscription did not have.

PatTranscription turns audio into PatDetectedNotes and its only existing export is miniNotation, a quantized string that cannot express an off-grid onset or a fractional pitch (PatTranscription.kt:399-405 drops centsOffset outright). This route keeps the detected times exactly as detected: it converts seconds to beats at bpm and never touches the grid.

Velocity carries the detector's confidence, scaled to 1..127. That is not a dynamic marking — nothing in the pitch tracker measures loudness — so it is documented here rather than silently implied: a quiet, confidently-detected note comes out loud.

fun PatTranscription.toMidiClip(
    bpm: Double = 120.0,
    name: String? = "transcription",
    ppq: Int = MIDI_DEFAULT_PPQ
): MidiClip {
    val beatsPerSecond = bpm / 60.0
    val midiNotes = notes.mapNotNull { detected ->
        val startBeats = detected.startSeconds * beatsPerSecond
        val durationBeats = (detected.endSeconds - detected.startSeconds) * beatsPerSecond
        if (durationBeats <= 0.0) return@mapNotNull null
        MidiNote(
            pitch = detected.midi.coerceIn(0, 127),
            startBeats = startBeats,
            durationBeats = durationBeats,
            velocity = (detected.confidence.coerceIn(0.0, 1.0) * 126.0 + 1.0).roundToInt().coerceIn(1, 127),
            channel = 0
        )
    }
    return MidiClip(
        tracks = listOf(MidiTrackData(notes = midiNotes, name = name)),
        tempoMap = listOf(0.0 to bpm),
        ppq = ppq,
        name = name
    )
}

fun PatTranscription.writeMidi(file: File, bpm: Double = 120.0): File

Write this transcription straight to a Standard MIDI File another DAW can open. Returns file.

fun PatTranscription.writeMidi(file: File, bpm: Double = 120.0): File =
    toMidiClip(bpm = bpm).writeTo(file)

Piano roll, drawn as FLines.

The house rendering order is Plotly, then FLine, then raster. A piano roll is geometry, so it is FLines: real drawable lines with real colours, not an SVG string like PatPianoRollGallery.svgForLanes or Iterable<ScheduledNote>.pianoRollSvg produce.

Note that Field's y axis grows downward, so a higher pitch gets a smaller y. Getting that backwards silently produces an upside-down roll that still looks plausible.

data class MidiRollOptions

Layout for MidiClip.toFLines. Sizes are in Field units, which for a box output read as pixels.

data class MidiRollOptions(
    val pixelsPerBeat: Double = 44.0,
    val semitoneHeight: Double = 7.0,
    val originX: Double = 0.0,
    val originY: Double = 0.0,
    /** Draw a vertical line per beat, and a brighter one per bar. */
    val showGrid: Boolean = true,
    val beatsPerBar: Int = MIDI_DEFAULT_BEATS_PER_CYCLE,
    /** Colour notes by velocity rather than by track. */
    val colorByVelocity: Boolean = true,
    val minimumNoteWidth: Double = 2.0
)

fun MidiClip.toFLines(options: MidiRollOptions = MidiRollOptions()): List<FLine>

This clip as a list of FLines: one filled rectangle per note, plus a beat/bar grid and a horizontal rule at each C.

Each note line carries properties["midi.pitch"], ["midi.velocity"], ["midi.startBeats"] and ["midi.track"], following the identity-tagging pattern AbletonSetDebugDrawing.kt:241 established, so a caller can hit-test back to the note.

body — 86 lines, from line 276
fun MidiClip.toFLines(options: MidiRollOptions = MidiRollOptions()): List<FLine> {
    val all = notes
    if (all.isEmpty()) return emptyList()

    val lowest = all.minOf { it.pitch }
    val highest = all.maxOf { it.pitch }
    // Two semitones of air above and below, so the extreme notes are not flush against the edge.
    val top = highest + 2
    val bottom = lowest - 2
    val out = mutableListOf<FLine>()

    fun yOf(pitch: Int): Double = options.originY + (top - pitch) * options.semitoneHeight
    fun xOf(beats: Double): Double = options.originX + beats * options.pixelsPerBeat

    val totalBeats = durationBeats
    val rollHeight = (top - bottom) * options.semitoneHeight

    if (options.showGrid) {
        var beat = 0
        while (beat <= totalBeats.toInt() + 1) {
            val isBar = beat % options.beatsPerBar == 0
            out += tick(
                xOf(beat.toDouble()), options.originY,
                xOf(beat.toDouble()), options.originY + rollHeight,
                if (isBar) vec(0.62, 0.66, 0.72, 0.55) else vec(0.42, 0.45, 0.50, 0.28)
            )
            beat++
        }
        // A rule at every C gives the eye an octave reference, the way a real piano roll's
        // keyboard gutter does.
        for (pitch in bottom..top) {
            if (pitch % 12 != 0) continue
            val y = yOf(pitch) + options.semitoneHeight
            out += tick(xOf(0.0), y, xOf(totalBeats + 1.0), y, vec(0.55, 0.58, 0.64, 0.34))
        }
    }

    tracks.forEachIndexed { trackIndex, track ->
        track.notes.forEach { note ->
            val x = xOf(note.startBeats)
            val w = (note.durationBeats * options.pixelsPerBeat).coerceAtLeast(options.minimumNoteWidth)
            val y = yOf(note.pitch)
            val h = options.semitoneHeight - 1.0

            val color = if (options.colorByVelocity) {
                // Velocity to brightness: a soft note is dim blue, a hard one bright cyan-white.
                val t = (note.velocity / 127.0).coerceIn(0.0, 1.0)
                vec(0.25 + 0.55 * t, 0.55 + 0.40 * t, 0.85 + 0.15 * t, 0.92)
            } else {
                TRACK_COLORS[trackIndex % TRACK_COLORS.size]
            }

            out += fline {
                rect(x, y, w, h)
                this.color = color
                this.filled = true
                this.stroked = true
                thickness = 1.0
            }.also {
                it.properties["midi.pitch"] = note.pitch
                it.properties["midi.velocity"] = note.velocity
                it.properties["midi.startBeats"] = note.startBeats
                it.properties["midi.track"] = trackIndex
            }
        }
    }
    return out
}

private fun tick(x0: Double, y0: Double, x1: Double, y1: Double, lineColor: Vec4): FLine =
    fline {
        moveTo(x0, y0)
        lineTo(x1, y1)
        this.color = lineColor
        this.stroked = true
        this.filled = false
        thickness = 1.0
    }

private val TRACK_COLORS: List<Vec4> = listOf(
    vec(0.31, 0.76, 0.97, 0.92),
    vec(0.98, 0.68, 0.32, 0.92),
    vec(0.56, 0.89, 0.52, 0.92),
    vec(0.93, 0.51, 0.75, 0.92),
    vec(0.78, 0.70, 0.98, 0.92)
)

fun Box.showMidiRoll

Draw this clip's piano roll into channel of this box.

fitToSize defaults to true because a roll's natural extent is "however many beats long the clip is", which is almost never the panel's aspect ratio.

fun Box.showMidiRoll(
    clip: MidiClip,
    channel: String = "lines",
    options: MidiRollOptions = MidiRollOptions(),
    w: Double = 900.0,
    h: Double = 320.0,
    fitToSize: Boolean = true
) {
    lines(clip.toFLines(options), channel = channel, w = w, h = h, fitToSize = fitToSize)
}