DrawnScoreDocs.kt
src/jvmMain/kotlin/afield/audio/synth/DrawnScoreDocs.kt · 366 lines
@file:afield.runtime.completion.CompletionCategory("Patterns")
package afield.audio.synth
import afield.document.graph.Box
import afield.ui.notation.MarkType
import afield.ui.notation.NotationBuilder
import afield.ui.notation.NotationSpace
import afield.app.protocol.Rect
import afield.app.server.SessionInformation
import afield.audio.engine.WavFloatArrayWriter
import afield.ui.notation.notation
import afield.math.linalg.Vec2
import afield.math.linalg.vec
import afield.app.server.thisSessionInfo
import java.io.File
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
FIELD_TEST id: synth.drawn.score runner: jvm-main entrypoint: afield.audio.synth.DrawnScoreDocs tags: [smoke, synth, pattern, notation, audio, wav, analysis, cpu] timeoutSec: 180 expect: exitCode: 0 stdoutContains: - "drawn score heard"
A drawn score, heard — the harness behind public/docs/chain_drawn_score.md.
The route is three parts of Field that were written without each other in mind. A box draws a
score into the document's drawing with notation { drawnScore() }: a frame, a piano roll of
rectangles, a glissando drawn as a pen stroke, a held note drawn as a level line, and a text
label. notation.space("score", x = 0.0..8.0, y = 84.0..48.0) reads that drawing back as eight
beats of MIDI pitch, interpret decides what each event means as notes (the loop the page
shows), patTimed makes those a PatPattern, and patRenderStereo plays the pattern offline
into a .wav. pentatonic is the page's second reading of the same drawing.
Then the sound is taken back apart. listen finds the onsets in the rendered audio with PatSoundDocs.onsetPeaks and the pitch of each note with YIN (patF0Frames), and holds both against the notes read from the drawing — so the claim "the audio plays the score" is a measurement with an error in milliseconds and in semitones, not a caption.
renderWav needs no Field window and no browser: it opens an ephemeral document of its own,
draws the score into its drawing with the same notation { } code a box uses, and reads it
back through the same NotationSpace. As the synth.drawn.score FIELD_TEST everything renders
to a temporary directory and nothing committed is written.
object DrawnScoreDocs
Renders and measures the drawn-score page's audio; see the file overview.
object DrawnScoreDocs {
const val SR: Int = PatSoundDocs.SR
const val LEFT: Double = 260.0
The score's frame on the canvas, in world units, and what its axes mean.
const val TOP: Double = 40.0
const val WIDTH: Double = 800.0
const val HEIGHT: Double = 400.0
const val BEATS: Double = 8.0
const val HIGH: Double = 84.0
const val LOW: Double = 48.0
const val CPS: Double = 0.25
One cycle is the whole score, so 0.25 cycles a second is eight beats in four seconds.
const val CYCLES: Int = 2
val VOICE: PatVoiceSpec = patVoice(wave = PatWave.TRIANGLE, decay = 0.004, sustain = 0.4)
The voice. Detached on purpose: each note holds for 40% of its length and its tail is gone 16 ms later, so even the glissando's 62 ms steps are separated by true silence. With a longer tail PatSoundDocs.onsetPeaks loses steps — it re-arms against the level of the window that triggered it, which on a steep attack is a point on the rising edge rather than the note's peak, so a dip to a few percent between steps is not always deep enough.
const val MIN_HZ: Double = 220.0
The register YIN is told about. The score spans MIDI 60..79, about 262..784 Hz.
const val MAX_HZ: Double = 900.0
fun at(beat: Double, midi: Double): Vec2
Where beat beat and pitch midi are on the canvas.
fun at(beat: Double, midi: Double): Vec2 =
vec(LEFT + beat / BEATS * WIDTH, TOP + (HIGH - midi) / (HIGH - LOW) * HEIGHT)
val ROLL: List<Triple<Double, Double, Double>>
The piano-roll notes as drawn: start beat, end beat, MIDI pitch.
val ROLL: List<Triple<Double, Double, Double>> = listOf(
Triple(0.0, 1.0, 67.0), Triple(1.0, 1.5, 71.0), Triple(1.5, 2.0, 74.0),
Triple(2.0, 3.0, 72.0), Triple(3.0, 3.5, 71.0), Triple(3.5, 4.0, 67.0),
Triple(7.5, 8.0, 72.0),
)
const val GLISS_FROM: Double = 60.0
The glissando's ends: beat 4 at MIDI 60 up to beat 6 at MIDI 79, eased in and out.
const val GLISS_TO: Double = 79.0
const val HELD: Double = 76.0
The held note: beats 6..7.5 at MIDI 76, drawn with a wobble a hand would give it.
fun space(box: Box): NotationSpace = box.notation.space("score", x = 0.0..BEATS, y = HIGH..LOW)
A space over the score frame, with the axes the page uses.
the two readings, exactly as the page writes them
fun interpret(score: NotationSpace, glideStep: Double = 0.125): List<TimedNote>
The score's events as notes: the loop chain_drawn_score.md shows in its box, word for word
between val notes and the loop's closing brace — DrawnScorePageTest compares the two, so
the published .wav is made by the code the page teaches. A rectangle or a level stroke is
one note at its pitch; a moving stroke is cut with vertical every glideStep beats, with
pen pressure as gain; texts, frames and arrows are skipped.
fun interpret(score: NotationSpace, glideStep: Double = 0.125): List<TimedNote> {
val notes = mutableListOf<TimedNote>()
for (e in score.events) {
val kind = e.mark.kind
if (kind == MarkType.TEXT || kind == MarkType.FRAME || kind == MarkType.ARROW) continue // labels, not notes
if (e.end <= e.start) continue // no width, no time
val moving = (kind == MarkType.STROKE || kind == MarkType.LINE) && kotlin.math.abs(e.endValue - e.value) > 0.5
if (!moving) { // a rectangle, or a level line: one note
notes += TimedNote(e.start, e.end, e.value)
continue
}
var t = e.start // a glissando: cut it every glideStep
while (t < e.end - 1e-9) {
val hit = score.vertical(t).firstOrNull { it.mark.id == e.mark.id && !it.isSpan }
if (hit != null) notes += TimedNote(t, minOf(t + glideStep, e.end), hit.value, hit.pressure ?: 1.0)
t += glideStep
}
}
return notes
}
fun pentatonic(notes: List<TimedNote>): List<TimedNote>
The page's second reading: every note snapped to C minor pentatonic, and a step that lands on the pitch the previous one did held rather than struck again. Word for word the page's loop.
fun pentatonic(notes: List<TimedNote>): List<TimedNote> {
val minorPentatonic = setOf(0, 3, 5, 7, 10) // C Eb F G Bb
val inScale = (24..108).filter { it % 12 in minorPentatonic }
val snap = { midi: Double -> inScale.minBy { kotlin.math.abs(it - midi) }.toDouble() }
val tuned = mutableListOf<TimedNote>()
for (n in notes.map { it.copy(note = snap(it.note)) }) {
val last = tuned.lastOrNull()
if (last != null && last.note == n.note && last.end == n.start) tuned[tuned.size - 1] = last.copy(end = n.end) // same pitch: hold it
else tuned += n
}
return tuned
}
val MINOR_PENTATONIC: Set<Int> = setOf(0, 3, 5, 7, 10)
The pitch classes pentatonic snaps to.
rendering, from a drawing or headlessly
fun render(notes: List<TimedNote>, cycles: Int = CYCLES, cps: Double = CPS): PatSoundDocs.Stereo
notes played through VOICE, as a BEATS-beat cycle.
fun render(notes: List<TimedNote>, cycles: Int = CYCLES, cps: Double = CPS): PatSoundDocs.Stereo =
patRenderStereo(patTimed(notes, beats = BEATS), cycles = cycles, cps = cps, voice = VOICE)
fun <T> headless(block: (Box) -> T): T {
Runs block with a box in an ephemeral document of its own, so a drawing can be written and
read with no Field window. The thread's current document is restored afterwards.
fun <T> headless(block: (Box) -> T): T {
val previous = thisSessionInfo.get()
val root = Box("root")
val session = SessionInformation(filename = "<drawn-score>", documentName = "drawn-score", root = root, ephemeral = true)
root.inside = session
session.loading = false
thisSessionInfo.set(session)
try {
val box = Box("drawn-score").also {
it.inside = session
session.boxes[it.id] = it
it.frame = Rect(20.0, 40.0, 200.0, 60.0)
}
box.notation { drawnScore() }
return block(box)
} finally {
if (previous == null) thisSessionInfo.remove() else thisSessionInfo.set(previous)
}
}
fun sceneNotes(name: String): List<TimedNote>
The notes each published clip plays, read from a headless drawing.
fun sceneNotes(name: String): List<TimedNote> = headless { box ->
val notes = interpret(space(box))
when (name) {
"drawn-score" -> notes
"drawn-score-pentatonic" -> pentatonic(notes)
else -> error("unknown DrawnScoreDocs scene '$name'; known: [drawn-score, drawn-score-pentatonic]")
}
}
fun renderWav(name: String, outPath: String)
A published clip: the score drawn headlessly, read back with interpret, and rendered.
fun renderWav(name: String, outPath: String) {
val audio = render(sceneNotes(name))
require(audio.left.any { it != 0f }) { "the drawn score rendered silence" }
WavFloatArrayWriter.writeStereo16(outPath, audio.left, audio.right, SR, normalizePeak = 0.9f)
}
listening back
class Heard
One note of the score against what the audio did there.
class Heard(
val note: TimedNote,
/** When the note should strike, in seconds from the start of the render. */
val scheduledSeconds: Double,
/** The detected onset nearest to it, in seconds; NaN when none was within 30 ms. */
val onsetSeconds: Double,
/** Median YIN pitch over the note's held part, MIDI; NaN when no frame there was voiced. */
val heardMidi: Double,
) {
val onsetErrorMs: Double get() = (onsetSeconds - scheduledSeconds) * 1000.0
val pitchError: Double get() = heardMidi - note.note
class Listening
What listen measured: one Heard per scheduled note, plus the detector's own totals.
class Listening(
val heard: List<Heard>,
val onsetsDetected: Int,
val frames: List<PatF0Frame>,
) {
val scheduled: Int get() = heard.size
val matchedOnsets: Int get() = heard.count { it.onsetSeconds.isFinite() }
val maxOnsetErrorMs: Double get()
val maxOnsetErrorMs: Double get() = heard.filter { it.onsetSeconds.isFinite() }.maxOfOrNull { abs(it.onsetErrorMs) } ?: Double.NaN
val pitched: Int get() = heard.count { it.heardMidi.isFinite() }
val maxPitchError: Double get()
val maxPitchError: Double get() = heard.filter { it.heardMidi.isFinite() }.maxOfOrNull { abs(it.pitchError) } ?: Double.NaN
fun trace(): List<Triple<Double, Double, Double>>
The voiced frames as (seconds, heard MIDI, drawn MIDI of the note sounding then).
fun trace(): List<Triple<Double, Double, Double>> = frames.filter { it.voiced }.mapNotNull { f ->
val t = f.centerSample.toDouble() / SR
val n = heard.lastOrNull { it.scheduledSeconds <= t } ?: return@mapNotNull null
Triple(t, f.midi, n.note.note)
}
fun describe(): String
fun describe(): String =
"$onsetsDetected onsets detected for $scheduled scheduled, $matchedOnsets matched within 30 ms, " +
"worst ${"%.1f".format(maxOnsetErrorMs)} ms; $pitched notes pitched, worst ${"%.3f".format(maxPitchError)} semitones"
}
fun listen
Measures audio against notes: an onset for every strike, and a pitch for every note.
notes are in beats of a BEATS-beat cycle played at cps for cycles cycles, by a voice
that holds each note for sustain of its length.
fun listen(
audio: PatSoundDocs.Stereo, notes: List<TimedNote>,
cycles: Int = CYCLES, cps: Double = CPS, sustain: Double = VOICE.sustain,
): Listening {
val mono = audio.mono
val secondsPerBeat = 1.0 / cps / BEATS
val onsets = PatSoundDocs.onsetPeaks(mono, threshold = 0.05).map { it.start.toDouble() / SR }
val frames = patF0Frames(mono, SR, minHz = MIN_HZ, maxHz = MAX_HZ)
val heard = (0 until cycles).flatMap { c ->
notes.map { n ->
val start = (c * BEATS + n.start) * secondsPerBeat
val hold = (n.end - n.start) * secondsPerBeat * sustain
val onset = onsets.minByOrNull { abs(it - start) }?.takeIf { abs(it - start) <= 0.030 } ?: Double.NaN
// Frames wholly inside the held part, a little in from each end.
val inside = frames.filter { f ->
val a = f.startSample.toDouble() / SR
val b = (f.startSample + f.spanSamples).toDouble() / SR
f.voiced && a >= start + 0.004 && b <= start + hold - 0.002
}.map { it.midi }.sorted()
val midi = if (inside.isEmpty()) Double.NaN else inside[inside.size / 2]
Heard(n, start, onset, midi)
}
}
return Listening(heard, onsets.size, frames)
}
private fun checkHeard(label: String, heard: Listening) {
println("$label: ${heard.describe()}")
check(heard.onsetsDetected == heard.scheduled) {
"$label: detected ${heard.onsetsDetected} onsets for ${heard.scheduled} notes; unmatched: " +
heard.heard.filter { !it.onsetSeconds.isFinite() }.joinToString { "%.3fs %s".format(it.scheduledSeconds, it.note) }
}
check(heard.matchedOnsets == heard.scheduled) { "$label: only ${heard.matchedOnsets} of ${heard.scheduled} onsets matched" }
check(heard.maxOnsetErrorMs <= 12.0) { "$label: an onset is ${heard.maxOnsetErrorMs} ms off" }
check(heard.pitched == heard.scheduled) { "$label: only ${heard.pitched} of ${heard.scheduled} notes had a voiced frame" }
check(heard.maxPitchError <= 0.25) { "$label: a note is ${heard.maxPitchError} semitones off" }
}
FIELD_TEST entry point
fun main(args: Array<String>)
body — 47 lines, from line 290
@JvmStatic
fun main(args: Array<String>) {
val dir = File(args.getOrNull(0) ?: File(System.getProperty("java.io.tmpdir"), "drawn-score").path)
dir.mkdirs()
val notes = headless { box ->
val events = space(box).events
// The reading, checked against the drawing before anything is played.
check(events.count { it.mark.kind == MarkType.RECTANGLE } == ROLL.size) { "expected ${ROLL.size} rectangles, read $events" }
check(events.none { it.mark.kind == MarkType.FRAME }) { "the frame that defines the space was read as an event" }
interpret(space(box))
}
check(notes == sceneNotes("drawn-score")) { "the published clip does not play interpret()'s notes" }
ROLL.forEach { (a, b, midi) ->
check(notes.count { abs(it.start - a) < 1e-6 && abs(it.end - b) < 1e-6 && abs(it.note - midi) < 1e-6 } == 1) {
"the rectangle $a..$b at $midi was not read as one note: $notes"
}
}
val gliss = notes.filter { it.start >= 4.0 - 1e-9 && it.start < 6.0 - 1e-9 }
check(gliss.size == 16) { "expected the two-beat glissando as 16 eighth-beat steps, got ${gliss.size}" }
check(gliss.zipWithNext().all { (x, y) -> y.note > x.note }) { "the glissando does not rise: $gliss" }
check(abs(gliss.first().note - GLISS_FROM) < 0.05) { "the glissando starts at ${gliss.first().note}" }
val held = notes.single { abs(it.start - 6.0) < 1e-9 }
check(abs(held.note - HELD) < 0.5 && abs(held.end - 7.5) < 1e-9) { "the held note reads $held" }
check(notes.size == ROLL.size + 16 + 1) { "expected ${ROLL.size + 17} notes (the label is not one), read ${notes.size}" }
println("read ${notes.size} notes: ${ROLL.size} from rectangles, ${gliss.size} glissando steps, 1 held")
val tuned = pentatonic(notes)
check(tuned == sceneNotes("drawn-score-pentatonic")) { "the published pentatonic clip does not play pentatonic()'s notes" }
check(tuned.all { it.note == Math.rint(it.note) && Math.floorMod(it.note.toInt(), 12) in MINOR_PENTATONIC }) { "a note is off the scale: $tuned" }
check(tuned.zipWithNext().none { (x, y) -> x.note == y.note && x.end == y.start }) { "a repeated step was struck twice: $tuned" }
check(tuned.size < notes.size) { "snapping merged nothing" }
println("pentatonic: ${tuned.size} notes, on ${tuned.map { it.note.toInt() }.distinct().sorted()}")
for ((name, played) in listOf("drawn-score" to notes, "drawn-score-pentatonic" to tuned)) {
val wav = File(dir, "$name.wav")
renderWav(name, wav.path)
val audio = render(played)
val bytes = wav.readBytes()
check(String(bytes, 0, 4, Charsets.US_ASCII) == "RIFF" && bytes.size == 44 + audio.frames * 4) { "bad WAV at $wav" }
println(" wrote ${wav.absolutePath} (${bytes.size} bytes)")
checkHeard(name, listen(audio, played))
}
println("drawn score heard")
}
}
fun NotationBuilder.drawnScore()
Draws the score DrawnScoreDocs and chain_drawn_score.md read: a frame named "score", a piano
roll of rectangles, a glissando drawn as a pen stroke, a held note drawn as a level line with a
hand's wobble in it, and a text label. Call it inside notation { }.
fun NotationBuilder.drawnScore() {
val d = DrawnScoreDocs
stroke = "#868e96"
frame("score", d.LEFT, d.TOP, d.WIDTH, d.HEIGHT)
fill = "#1971c2"
stroke = "#1971c2"
d.ROLL.forEachIndexed { i, (a, b, midi) ->
val topLeft = d.at(a, midi + 0.5)
val bottomRight = d.at(b, midi - 0.5)
rect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y, name = "note ${i + 1}")
}
fill = "transparent"
// Eased, as a hand draws one: slow off the start, fast through the middle, slow into the top.
val glide = (0..64).map { k ->
val u = k / 64.0
d.at(4.0 + 2.0 * u, d.GLISS_FROM + (d.GLISS_TO - d.GLISS_FROM) * (1 - cos(PI * u)) / 2)
}
stroke(glide, name = "gliss", stroke = "#e03131")
val level = (0..30).map { k -> d.at(6.0 + 1.5 * k / 30.0, d.HELD + 0.2 * sin(k * 1.7)) }
stroke(level, name = "held", stroke = "#2f9e44")
val label = d.at(5.2, 66.0)
text("gliss.", label.x, label.y, size = 18, color = "#e03131")
}