Making Music in Scala 3, Part 3: Interpretation, Performance, and Players

scalamusic

Following Chapter 8 of The Haskell School of Music by Paul Hudak. Part 1 built a Music[A] type and hacked it into a MIDI file. Part 2 proved twelve laws about it. This post goes back and does the MIDI conversion properly, which turns out to require an idea I didn’t expect: that interpretation is a first-class thing you can pass around.

It also produces the most useful negative result of the series so far. I tried three Scala encodings of Hudak’s Player abstraction, expected the typeclass to win, and it can’t — for a structural reason, not a stylistic one.

🔗Three layers, and why not two

Part 1 went straight from Music[Pitch] to MIDI events. That works and it’s wrong, because it collapses two genuinely different translations into one function.

Hudak’s structure:

Music[A]   ──perform──▶   Performance   ──▶   MIDI

Music[A] is the score. Symbolic, abstract, no notion of milliseconds or velocity. Modify(Phrase(List(Legato(1.1))), m) says “play this legato” — an instruction, not a sound.

Performance is what a musician actually did. A flat, time-ordered list of events, each with an onset, a duration, a pitch, a volume. Every instruction has been resolved into numbers.

MIDI is a wire format. Channels, program changes, ticks, and a 16-channel limit.

The middle layer is the one worth having, and the argument for it is one sentence: legato on a piano and legato on a violin are different physical actions. A violinist keeps the bow moving; a pianist holds the key and leans on the sustain pedal. Same instruction, different realisation. If Music → MIDI is one function, there’s nowhere for that difference to live.

🔗Context is a Reader

perform walks the tree carrying state downward — current time, current instrument, current tempo, current key, current volume:

// scala-check: skip
final case class Context[A](
  time:       Rational,
  player:     Player[A],
  instrument: Instr,
  dur:        Rational,      // duration, in seconds, of one whole note
  key:        AbsPitch,
  volume:     Int
)

final case class Event(
  time:   Rational,
  instr:  Instr,
  pitch:  AbsPitch,
  dur:    Rational,
  volume: Int,
  params: List[Double]
)

type Performance = List[Event]

Nothing flows back up. Each branch gets a modified copy and returns events. That’s a Reader, and I think naming it is worth doing even though we won’t use the type — it tells you immediately which operations are safe (any local modification is invisible to siblings) and it explains why Modify can be implemented by .copy and nothing else.

Context.dur is the one field that always confuses me. It’s not a duration in the score’s units — it’s the number of seconds one whole note lasts, i.e. the conversion factor. Which is what a metronome marking gives you:

// scala-check: skip
def metro(bpm: Int, beat: Rational): Rational = Rational(60) / (bpm * beat)

metro(96, qn) is 96 quarter notes per minute.

🔗perform, and fixing part 1’s quadratic bug

Part 1’s flattening called dur(m1) inside the Seq case, walking the left subtree a second time to find out where the right one starts — quadratic in the depth of a melody.

Hudak’s fix is the obvious one once stated: return the duration alongside the events, so the traversal computes it exactly once.

// scala-check: skip
def perform[A](pm: PlayerMap[A], c: Context[A], m: Music[A]): Performance =
  perf(pm, c, m)._1

def perf[A](pm: PlayerMap[A], c: Context[A], m: Music[A]): (Performance, Rational) =
  m match
    case Prim(Note(d, a)) =>
      (c.player.playNote(c, d, a), d * c.dur)

    case Prim(Rest(d)) =>
      (Nil, d * c.dur)

    case Seq(m1, m2) =>
      val (p1, d1) = perf(pm, c, m1)
      val (p2, d2) = perf(pm, c.copy(time = c.time + d1), m2)
      (p1 ++ p2, d1 + d2)

    case Par(m1, m2) =>
      val (p1, d1) = perf(pm, c, m1)
      val (p2, d2) = perf(pm, c, m2)
      (merge(p1, p2), d1 max d2)

    case Modify(Tempo(r), m)      => perf(pm, c.copy(dur = c.dur / r), m)
    case Modify(Transpose(p), m)  => perf(pm, c.copy(key = c.key + p), m)
    case Modify(Instrument(i), m) => perf(pm, c.copy(instrument = i), m)
    case Modify(Player(name), m)  => perf(pm, c.copy(player = pm(name)), m)
    case Modify(Phrase(attrs), m) => c.player.interpPhrase(pm, c, attrs, m)

Read the Modify cases as a column and the structure is clear: each one is a single .copy on the context, which is what “this is a Reader” buys you.

Two of them are not like the others. Player swaps out the thing doing the interpreting, mid-tree. And Phrase doesn’t recurse at all — it hands the entire subtree to the current player and lets it decide. Those two cases are the whole point of the chapter.

And merge, which — per part 2 — must compare whole events rather than start times, or the commutativity of :=: fails:

// scala-check: skip
def merge(a: Performance, b: Performance): Performance = (a, b) match
  case (e1 :: es1, e2 :: es2) =>
    if summon[Ordering[Event]].lt(e1, e2) then e1 :: merge(es1, b)
    else e2 :: merge(a, es2)
  case (Nil, es) => es
  case (es, Nil) => es

🔗What a Player is

Here is the idea I came for. A player is a bundle of interpretation decisions:

// scala-check: skip
final case class Player[A](
  name:         String,
  playNote:     (Context[A], Dur, A) => Performance,
  interpPhrase: (PlayerMap[A], Context[A], List[PhraseAttribute], Music[A]) => (Performance, Rational)
)

type PlayerMap[A] = String => Player[A]

playNote turns a single note into events, applying whatever note-level attributes it understands. interpPhrase handles phrase-level markings — dynamics, articulation, ornaments — by performing the subtree and then transforming the result.

The vocabulary of markings is large and open-ended:

// scala-check: skip
enum PhraseAttribute:
  case Dyn(d: Dynamic)          // Accent, Crescendo, Diminuendo, Loudness
  case Tmp(t: TempoChange)      // Ritardando, Accelerando
  case Art(a: Articulation)     // Staccato, Legato, Slurred, Pedal, Pizzicato, ...
  case Orn(o: Ornament)         // Trill, Mordent, Arpeggio, ...

A default player understands a handful and ignores the rest:

// scala-check: skip
def defaultPasHandler(pa: PhraseAttribute): Performance => Performance = pa match
  case Dyn(Accent(x))   => _.map(e => e.copy(volume = (x * e.volume).toInt))
  case Art(Staccato(x)) => _.map(e => e.copy(dur = x * e.dur))
  case Art(Legato(x))   => _.map(e => e.copy(dur = x * e.dur))
  case _                => identity

Modify(Phrase(List(Legato(Rational(5,4)))), m) stretches every note in m by 25% without changing the tempo — notes overlap slightly, which is what legato is. Four lines.

Ritardando is the one with actual arithmetic in it. You want to stretch time progressively across a phrase: no stretch at the start, maximum at the end. Given a phrase starting at t0 with total duration D, an event at time t moves to

t' = (1 + ((t - t0) / D) * x) * (t - t0) + t0

and its duration becomes

d' = (1 + ((2 * (t - t0) + d) / D) * x) * d

The second falls out of applying the first to both the start and the end of the note and subtracting. Accelerando is the same with the sign flipped. It’s the least glamorous code in the series and it’s the thing that makes a generated piece stop sounding like a machine.

🔗Three ways to encode a Player, and why the obvious one loses

Hudak derives a new player from an old one with Haskell’s record-update syntax and calls it “inheritance”:

newPlayer = defPlayer { pName = "NewPlayer"
                      , interpPhrase = defInterpPhrase myPasHandler }

In Scala there are at least three ways to express that, and they’re not equivalent. I expected to end up at the typeclass and I didn’t.

Option 1 — case class of functions, derived with .copy. The direct translation.

// scala-check: skip
val jazzPlayer = defaultPlayer.copy(
  name         = "Jazz",
  interpPhrase = defaultInterpPhrase(swingHandler)
)

Partial override is trivial and untouched fields are inherited. Players are ordinary values: put them in a Map, build them at runtime, pick one from a config file.

Option 2 — trait with overridable methods.

// scala-check: skip
trait Player[A]:
  def name: String
  def playNote(c: Context[A], d: Dur, a: A): Performance
  def interpPhrase(...): (Performance, Rational)

object DefaultPlayer extends Player[Note1]:
  ...

object JazzPlayer extends DefaultPlayer:
  override def interpPhrase(...) = ...

Reads more naturally to most Scala programmers, and override is checked. But you can only override at declaration sites — you can’t take a player you were handed and tweak one field, which is exactly what .copy gives you for free. And “derive a variant” now means declaring a new type.

Option 3 — typeclass. The instinct, given how much of this series has leaned on given/using:

// scala-check: skip
trait Player[P]:
  def playNote(...): Performance
given Player[Jazz] with { ... }

This one cannot work, and the reason is structural rather than a matter of taste.

Look at the Modify case again:

// scala-check: skip
case Modify(Player(name), m) => perf(pm, c.copy(player = pm(name)), m)

Player(name) carries a String. Which player to use is data inside the score, chosen at runtime, resolved through a PlayerMap[A] = String => Player[A]. A score loaded from a file can name a player that didn’t exist when the program was compiled.

Typeclass instances are selected by the compiler from a type. There is no type here — there’s a string. You cannot summon on a value. You’d end up building a Map[String, Player] of type-class-derived instances, at which point you have a runtime dictionary and the typeclass was ceremony around a Map.

So: option 1. Which is a small, concrete instance of a general rule worth stating — typeclasses are for dispatch the compiler can resolve; when the choice is made by data at runtime, you want a plain value. Hudak’s Haskell reaches the same conclusion by using a record of functions rather than a class, and it’s the sort of thing that’s easy to miss if you’re pattern-matching on “this looks like an interface, so: typeclass.”

🔗Swing

Hudak’s exercise 8.4, and the best demonstration that the abstraction is doing something.

Jazz eighth notes aren’t even. A pair of eighths is played long-short, roughly 2:1 — the first gets two thirds of the pair, the second gets one third. Written as straight eighths, performed as a swung triplet feel.

At the Player level:

// scala-check: skip
def swingHandler(pa: PhraseAttribute): Performance => Performance = pa match
  case Art(Swing(ratio)) => events =>
    events.map { e =>
      if !isEighth(e.dur) then e
      else if onBeat(e.time) then e.copy(dur = e.dur * ratio * 2)
      else e.copy(time = e.time + e.dur * (ratio * 2 - 1), dur = e.dur * (2 - ratio * 2))
    }
  case other => defaultPasHandler(other)

val jazzPlayer = defaultPlayer.copy(
  name = "Jazz", interpPhrase = defaultInterpPhrase(swingHandler))

Hudak flags the catch, and it’s worth repeating because it’s the honest limit of the design: to do this at the player level you have to infer things the score didn’t tell you — what counts as an eighth note, where the downbeat is, whether a given pair is even a pair. The Performance layer has thrown away the metrical structure that would make this unambiguous.

Which is a genuine design tension rather than a bug. Performance is deliberately flat because flat is what MIDI wants. Anything needing bar lines and beat positions has to either reconstruct them or be handled earlier, at the Music level where the structure still exists. I don’t think there’s a clean answer, and the fact that the book hits the same wall suggests it isn’t just my translation.

🔗MIDI, done properly

Which leaves the channel problem I skipped in part 1.

MIDI has 16 channels. Each carries one instrument at a time, and channel 10 is reserved for percussion. Part 1 assigned i % 16 per event, which is wrong in every way: it scatters one instrument across many channels, sends a PROGRAM_CHANGE per note, and cheerfully stomps on channel 10.

The fix is to allocate once, up front:

// scala-check: skip
def channelMap(p: Performance): Map[Instr, Int] =
  val instruments = p.map(_.instr).distinct
  val (perc, pitched) = instruments.partition(_.isPercussion)
  val available = (0 to 15).filterNot(_ == 9)   // channel 10, zero-indexed
  perc.map(_ -> 9).toMap ++ pitched.zip(available).toMap

Then one PROGRAM_CHANGE per channel at tick 0, and every note goes to its instrument’s channel. If there are more than fifteen pitched instruments you have a real problem and have to either drop voices or merge instruments — worth surfacing as an error rather than silently producing something wrong.

🔗Where we are

MusicPerformance → MIDI, with interpretation as a value you can swap, and the quadratic traversal from part 1 gone.

The laws from part 2 still hold, and it’s worth noticing why they survived a complete rewrite of the thing they’re defined in terms of: they’re stated over perform, and perform’s observable output didn’t change. The implementation underneath it changed entirely. That’s observational equality earning its keep for the second time in this series.

Next: Part 4 — Self-Similar Music, in which we stop transcribing music and start generating it, by feeding a four-note pattern into itself.


🔗Sources

  • Hudak, Paul. The Haskell School of Music. Cambridge University Press. Chapter 8 (Interpretation and Performance). The three-layer architecture, the Context and Event types, the Player record with playNote and interpPhrase, the phrase-attribute vocabulary, and the Ritardando time-stretch formulae are all Hudak’s.
  • The swing-feel player is Hudak’s exercise 8.4, including his caveat about the assumptions it forces.
  • javax.sound.midi is part of the JDK.

The Scala 3 translation, the comparison of the three Player encodings and the argument that a typeclass cannot express a runtime-named player, and the channel-allocation code are mine.