DrawnMorphDocs.kt
src/jvmMain/kotlin/afield/audio/synth/DrawnMorphDocs.kt · 197 lines
@file:afield.runtime.completion.CompletionCategory("Patterns")
package afield.audio.synth
import afield.document.graph.Box
import afield.ui.notation.NotationBuilder
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.geometry.spatial.matchPoints
import afield.geometry.spatial.resampleByLength
import afield.math.linalg.vec
import afield.app.server.thisSessionInfo
import java.io.File
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.log2
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
FIELD_TEST id: synth.drawn.morph runner: jvm-main entrypoint: afield.audio.synth.DrawnMorphDocs tags: [smoke, synth, pattern, notation, audio, wav, cpu] timeoutSec: 180 expect: exitCode: 0 stdoutContains: - "drawn morph heard"
The harness that renders the sound on chain_drawn_morph.md from two strokes in a document's
drawing.
DrawnMorphDocs reads a circle and a heart back through notation, pairs their points and plays
the in-between cloud's size as pitch. It runs as the synth.drawn.morph FIELD_TEST.
object DrawnMorphDocs
A drawn morph, heard — the harness behind public/docs/chain_drawn_morph.md.
Two strokes in the document's drawing (morphShapes: a circle named "from" and a heart named
"to", drawn from a different place and the other way round) are read back with
notation.named(...), resampled to N points each with resampleByLength, and paired with
matchPoints. morphBetween is the in-between cloud and spreadOf its size; morphNotes plays
that size as pitch while the cloud goes out to the heart and back, once with the matched pairs
and once with the pairs given by list index. renderWav draws the shapes into an ephemeral
document of its own, so the published .wavs come from the same reading the page's boxes do.
object DrawnMorphDocs {
const val SR: Int = PatSoundDocs.SR
const val N: Int = 240
Points per shape after resampling.
const val CX: Double = 520.0
Where both shapes are centred on the canvas, and their size, in world units.
const val CY: Double = 260.0
const val R: Double = 160.0
const val BEATS: Double = 8.0
One cycle is eight beats; at a quarter cycle a second the morph out and back takes four seconds.
const val CPS: Double = 0.25
const val CYCLES: Int = 2
val VOICE: PatVoiceSpec = patVoice(wave = PatWave.TRIANGLE, decay = 0.004, sustain = 0.6)
fun circle(): List<Vec2>
The circle, as a hand would draw it: from the right, anticlockwise on screen, bunched at the start.
fun circle(): List<Vec2> = (0..72).map { k ->
val u = k / 72.0
val a = -2 * PI * u * u * (3 - 2 * u) // eased: slow off the start and into the end
vec(CX + R * cos(a), CY + R * sin(a))
}
fun heart(): List<Vec2>
The heart, from its bottom point, clockwise on screen (y grows down).
fun heart(): List<Vec2> = (0..120).map { k ->
val a = PI + 2 * PI * k / 120.0
val x = 16 * sin(a).pow(3)
val y = 13 * cos(a) - 5 * cos(2 * a) - 2 * cos(3 * a) - cos(4 * a)
vec(CX + R / 17 * x, CY - R / 17 * y + 10)
}
fun shapes(box: Box): Pair<List<Vec2>, List<Vec2>>
The resampled pair the page's boxes read back from the drawing.
fun shapes(box: Box): Pair<List<Vec2>, List<Vec2>> {
val from = box.notation.named("from")!!.points.resampleByLength(N, closed = true)
val to = box.notation.named("to")!!.points.resampleByLength(N, closed = true)
return from to to
}
fun morphNotes(from: List<Vec2>, partner: List<Vec2>): List<TimedNote>
The morph played as pitch, word for word the loop on the page (DrawnMorphPageTest compares
them): 32 steps out to partner and back, each step a quarter beat at a pitch that is MIDI 72
when the cloud is the circle's size and falls an octave for every halving of it.
fun morphNotes(from: List<Vec2>, partner: List<Vec2>): List<TimedNote> {
val notes = mutableListOf<TimedNote>()
val spread0 = spreadOf(from)
for (k in 0 until 32) {
val t = if (k < 16) k / 15.0 else (31 - k) / 15.0 // out to the heart, then back
val midi = 72 + 12 * log2(spreadOf(morphBetween(from, partner, t)) / spread0)
notes += TimedNote(k * 0.25, k * 0.25 + 0.25, midi)
}
return notes
}
fun render(notes: List<TimedNote>): PatSoundDocs.Stereo
fun render(notes: List<TimedNote>): 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 whose drawing holds morphShapes.
fun <T> headless(block: (Box) -> T): T {
val previous = thisSessionInfo.get()
val root = Box("root")
val session = SessionInformation(filename = "<drawn-morph>", documentName = "drawn-morph", root = root, ephemeral = true)
root.inside = session
session.loading = false
thisSessionInfo.set(session)
try {
val box = Box("drawn-morph").also {
it.inside = session
session.boxes[it.id] = it
it.frame = Rect(20.0, 40.0, 200.0, 60.0)
}
box.notation { morphShapes() }
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.
fun sceneNotes(name: String): List<TimedNote> = headless { box ->
val (from, to) = shapes(box)
when (name) {
"drawn-morph-matched" -> morphNotes(from, matchPoints(from, to).reorder(to))
"drawn-morph-index" -> morphNotes(from, to)
else -> error("unknown DrawnMorphDocs scene '$name'; known: [drawn-morph-matched, drawn-morph-index]")
}
}
fun renderWav(name: String, outPath: String)
fun renderWav(name: String, outPath: String) {
val audio = render(sceneNotes(name))
require(audio.left.any { it != 0f }) { "the drawn morph rendered silence" }
WavFloatArrayWriter.writeStereo16(outPath, audio.left, audio.right, SR, normalizePeak = 0.9f)
}
fun main(args: Array<String>)
@JvmStatic
fun main(args: Array<String>) {
val dir = File(args.getOrNull(0) ?: File(System.getProperty("java.io.tmpdir"), "drawn-morph").path)
dir.mkdirs()
val (matchedCost, indexCost) = headless { box ->
val (from, to) = shapes(box)
check(from.size == N && to.size == N) { "resampled to ${from.size} and ${to.size}, not $N" }
val m = matchPoints(from, to)
val index = from.indices.sumOf { (from[it].x - to[it].x).toDouble().pow(2) + (from[it].y - to[it].y).toDouble().pow(2) }
m.squaredCost to index
}
check(matchedCost < indexCost / 2) { "matching should at least halve the index-order travel: $matchedCost vs $indexCost" }
val matched = sceneNotes("drawn-morph-matched")
val index = sceneNotes("drawn-morph-index")
val dipMatched = 72 - matched.minOf { it.note }
val dipIndex = 72 - index.minOf { it.note }
check(dipIndex > 2 * dipMatched) { "the index-order morph should collapse further: dips $dipIndex vs $dipMatched" }
println("travel matched %.0f, by index %.0f; pitch dips %.2f vs %.2f semitones".format(matchedCost, indexCost, dipMatched, dipIndex))
for (name in listOf("drawn-morph-matched", "drawn-morph-index")) {
val wav = File(dir, "$name.wav")
renderWav(name, wav.path)
val bytes = wav.readBytes()
check(String(bytes, 0, 4, Charsets.US_ASCII) == "RIFF" && bytes.size > 44) { "bad WAV at $wav" }
println(" wrote ${wav.absolutePath} (${bytes.size} bytes)")
}
println("drawn morph heard")
}
}
fun morphBetween(from: List<Vec2>, to: List<Vec2>, t: Double): List<Vec2>
The in-between cloud: point i a fraction t of the way from from[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) to to[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240).
fun morphBetween(from: List<Vec2>, to: List<Vec2>, t: Double): List<Vec2> =
List(from.size) { vec(from[it].x + (to[it].x - from[it].x) * t, from[it].y + (to[it].y - from[it].y) * t) }
fun spreadOf(points: List<Vec2>): Double
How big a cloud is: the root-mean-square distance of its points from their centroid.
fun spreadOf(points: List<Vec2>): Double {
val cx = points.sumOf { it.x.toDouble() } / points.size
val cy = points.sumOf { it.y.toDouble() } / points.size
return sqrt(points.sumOf { (it.x - cx).pow(2) + (it.y - cy).pow(2) } / points.size)
}
fun NotationBuilder.morphShapes()
The two strokes chain_drawn_morph.md reads: a circle named "from" and a heart named "to". Call it inside notation { }.
fun NotationBuilder.morphShapes() {
stroke(DrawnMorphDocs.circle(), name = "from", stroke = "#1971c2")
stroke(DrawnMorphDocs.heart(), name = "to", stroke = "#e03131")
}