A Drawn Morph, Heard
Draw a circle and a heart, then move the points of one towards the other. The changing outline is a morph; measure its size as it moves and you can turn that measurement into a tune. You'll hear the shape change as well as see it.
Here notation reads two strokes from the drawing layer. resampleByLength spaces their points
evenly, and matchPoints pairs the circle's points with the heart's. The example draws its strokes
in code, but you can draw your own and name them "from" and "to".
Two strokes, paired
morphShapes() draws the circle in blue, starting on its right and going round anticlockwise, and
the heart in red, starting at its bottom point and going clockwise. The circle is drawn the way a
hand draws one, slowly off the start and into the end, so its points are bunched there. The box
reads both strokes back by name, resamples them, and matches them:
notation { morphShapes() }
val drawnFrom = notation.named("from")!!.points
val drawnTo = notation.named("to")!!.points
val from = drawnFrom.resampleByLength(240, closed = true)
val to = drawnTo.resampleByLength(240, closed = true)
val match = matchPoints(from, to)
val partner = match.reorder(to)
:::fold draw the pairs both ways, and the table
fun chord(a: Vec2, b: Vec2, dx: Double) = FLine().moveTo(a.x + dx, a.y).lineTo(b.x + dx, b.y).also {
it.thickness = 0.8
it.color = okhsl(atan2(a.y - 260.0, a.x - 520.0) * 180 / PI + 360, 0.85, 0.6, 0.9)
}
lines(
from.indices.map { chord(from[it], partner[it], -220.0) } + from.indices.map { chord(from[it], to[it], 220.0) },
w = 760.0,
h = 380.0,
fitToSize = true,
)
val byIndex =
from.indices.sumOf { (from[it].x - to[it].x).toDouble().pow(2) + (from[it].y - to[it].y).toDouble().pow(2) }
markdown(
"travel",
"""
| | |
|---|---|
| points drawn, circle and heart | ${drawnFrom.size} and ${drawnTo.size} |
| points after resampling | ${from.size} and ${to.size} |
| squared travel, matched (left) | ${match.squaredCost.f0} |
| squared travel, by list index (right) | ${byIndex.f0} |
""",
width = 420,
)
:::endfold

The matched pairs mostly stay close together and fan out. Pair the points by index instead and the circle's first point goes to the heart's first point on the other side of the figure; the other pairs follow it around. The readout totals the squared distances the points must travel for each pairing.
Resampling is what makes the two strokes comparable. Excalidraw keeps a stroke as the points the
pen produced, so the two strokes have different numbers of points, and the circle's are crowded at
its start. resampleByLength(n) walks the stroke by arc length and places exactly n points along
it at equal distances; closed = true treats the stroke as a loop, so the last point is not a
repeat of the first.
The morph
morphBetween(from, partner, t) puts each point a fraction t of the way to its partner. Five
steps of each pairing, top matched and bottom by index:
notation { morphShapes() }
val from = notation.named("from")!!.points.resampleByLength(240, closed = true)
val to = notation.named("to")!!.points.resampleByLength(240, closed = true)
val partner = matchPoints(from, to).reorder(to)
:::fold draw five steps of each
fun dot(p: Vec2, dx: Double, dy: Double, colour: Vec4) =
FLine().also { f ->
val x = (p.x - 520.0) * 0.5 + dx
val y = (p.y - 260.0) * 0.5 + dy
f.rect(x - 2, y - 2, 4, 4)
f.color = colour
f.filled = true
f.stroked = false
}
val steps = [0.0, 0.25, 0.5, 0.75, 1.0]
lines(
steps.indices.flatMap { s ->
morphBetween(from, partner, steps[s]).map { dot(it, s * 200.0, 0.0, vec(0.1, 0.45, 0.85, 1.0)) } +
morphBetween(from, to, steps[s]).map { dot(it, s * 200.0, 200.0, vec(0.88, 0.2, 0.2, 1.0)) }
},
w = 1000.0,
h = 400.0,
fitToSize = true,
)
:::endfold

Paired by index, the points cross the middle of the figure to reach their partners, so halfway through the cloud is folded in on itself and smaller than either shape. Matched, the outline stays an outline, except for the few points that the first figure shows as long chords on its left: the matching sends them from the top of the circle down into the cleft between the heart's two lobes, and they cross the inside of the shape to get there.
The morph, as a number and as sound
spreadOf(points) is how big a cloud is: the root-mean-square distance of its points from their
centre. Follow it from t = 0 to t = 1 for both pairings:
notation { morphShapes() }
val from = notation.named("from")!!.points.resampleByLength(240, closed = true)
val to = notation.named("to")!!.points.resampleByLength(240, closed = true)
val partner = matchPoints(from, to).reorder(to)
notation.clear() // read: take the strokes down so they do not lie over the plot
val ts = (0..50).map { it / 50.0 }
showSignals(
"matched" to ts.map { spreadOf(morphBetween(from, partner, it)) },
"by list index" to ts.map { spreadOf(morphBetween(from, to, it)) },
x = ts,
title = "how big the cloud is, through the morph",
xTitle = "t",
yTitle = "spread",
channel = "spread",
)

To hear it, the morph goes out to the heart and back in 32 steps, a quarter of a beat each. Each
step is a note: MIDI 72 when the cloud is the circle's size, an octave lower for every halving of
it. patTimed makes the notes a pattern of eight beats, and patRenderStereo plays it through
patVoice, a simple triangle-wave synth voice:
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)
}
val audio = patRenderStereo(
patTimed(notes, beats = 8.0),
cycles = 2,
cps = 0.25,
voice = patVoice(wave = PatWave.TRIANGLE, decay = 0.004, sustain = 0.6),
)
The matched morph, twice through:
The same loop with to in place of partner, the points paired by index:
The heart is smaller than the circle, so the first tune falls to the heart's pitch and climbs back. The second falls about twice as far, then climbs before it reaches the heart: you can see that fold in the bottom row of the morph above.
The pitch the box computes, step by step, for both:
notation { morphShapes() }
val from = notation.named("from")!!.points.resampleByLength(240, closed = true)
val to = notation.named("to")!!.points.resampleByLength(240, closed = true)
val matched = DrawnMorphDocs.morphNotes(from, matchPoints(from, to).reorder(to)) // the loop above
val byIndex = DrawnMorphDocs.morphNotes(from, to)
notation.clear()
showSignals(
"matched" to matched.map { it.note },
"by list index" to byIndex.map { it.note },
title = "pitch per step",
xTitle = "step",
yTitle = "MIDI",
channel = "pitch",
)
markdown(
"dips",
"lowest note, matched: %.2f \nlowest note, by list index: %.2f"
.format(matched.minOf { it.note }, byIndex.minOf { it.note }),
width = 400,
)

Drawing your own
Open drawn_morph from the welcome screen's starting points and run shapes, then paired,
morph and play. To morph your own shapes, press alt/option-d and draw two closed strokes with the
pen (p), then replace morphShapes() in shapes with a line that names the two strokes you drew
last:
val mine = notation.strokes.filter { it.handDrawn }.takeLast(2)
notation.edit(mine[0]) { name = "from" }
notation.edit(mine[1]) { name = "to" }
Run the boxes after shapes again and they morph what you drew. A shape with corners, or two
shapes of very different sizes, makes the index-order fold worse.
Where each piece is documented
- Drawing and Notation for the drawing layer and
notation. - A Drawn Score, Heard is the nearest chain: the same drawing layer read as a score instead of as shapes.
- Patterns & Rhythm for
patTimedand the voice.