Capo's Own Language

• Chris Liscio

Just before I started grad school in 2022, I began picking away at the "Keyboard and MIDI Overhaul" project that was meant to improve Capo's support for MIDI controls and extend them to iOS. It was an ambitious project, and it ultimately spawned a little domain-specific language (DSL) for configuration.

Keyboard and MIDI controls are "power-user" features, and a combination of configuration files and documentation seems like a good power-user interface. But even power users are unlikely to know what specific messages their MIDI hardware spits out, and users on iOS would have a harder time writing and editing code. So I committed to building a comprehensive UI to configure keyboard and MIDI bindings in Capo. The DSL is still there, but it's used entirely behind the scenes.

The .caporemote configuration file contains a list of bindings, and each binding consists of a trigger that executes one or more commands. Here is a simple example of a keyboard binding:

keyDown(rightArrow) {
  goToNextEntry(in: bars)
}

This language is easy to read: when you press the right arrow key, Capo will go to the next bar entry. Even complex bindings remain quite readable:

controlChange(channel: 2, number: 16, value: $value) {
  setSpeed(to: $value[25:100])
}

This one triggers when Capo receives MIDI Control Change (CC) #16 messages on channel 2, and it passes the current $value to setSpeed(to:). The numbers in the square brackets define the output range: we only want values between 25% and 100%.

Compared to an XML, JSON, or YAML configuration file, this DSL isn't just easy to read—its parser can also tell you when something's wrong. For example, writing goToNextEntry(in: potato) produces an error pointing at the exact line and column, along with the expected values: bars, beats, or markers. By contrast, an XML, JSON, or YAML parser would happily accept it, leaving the user to hunt for their mistake without guidance.

I built my DSL's parser using the swift-parsing library from Point-Free. In addition to parsing, this library lets you build a Printer so that you can work in reverse. In my case, this is how the configuration gets saved to disk.1

The parsers themselves use ResultBuilder syntax. Here's a modified version of the command parser:

public struct CommandParser: ParserPrinter {
  public var body: some ParserPrinter<Substring.UTF8View, CapoRemoteCommandDescriptor> {
    OneOf {
      // ... skipped
      
      Command(
        name: "goToNextEntry", 
        converter: .case(CapoRemoteCommandDescriptor.goToNextEntry)
      ) {
        ConstantParameter<CapoRemoteCommandDescriptor.NavigableEntry>(name: "in")
      }
      
      Command(
        name: "setSpeed", 
        converter: .case(CapoRemoteCommandDescriptor.setSpeed)
      ) {
        PercentageParameter(name: "to")
      }
      
      // ... skipped
    }
  }
}

It's not important what each Command parser does, or why it takes a converter: parameter—those are boring implementation details. Instead, I want you to recognize the simplicity of this overall structure: it parses (and prints) OneOf these named Commands. The goToNextEntry command takes a named ConstantParameter of type NavigableEntry, and the setSpeed command takes a named PercentageParameter.

This CommandParser composes a collection of smaller, focused Command parsers to form the larger one. Adding a new command to Capo means adding a new Command parser to the list above.

Parsing was the easy part. The harder problem was designing the system that consumes those parsed values. First, I had to catalog every action in Capo worth exposing to a binding—that took a while. Later, I realized I needed two types for this to work.

Incoming MIDI values arrive as integers from 0–127, but the command they trigger often wants something else—a speed between 25% and 100%, say. CapoRemoteCommandDescriptor is the "recipe" that bridges the two: it describes how to produce a CapoRemoteCommand from whatever value comes in. The variable-speed example above produces a descriptor of .setSpeed(to: .variable(.percentageInRange(25...100))), which then emits commands like .setSpeed(percent: 25) or .setSpeed(percent: 30) as MIDI values stream in. For simpler bindings the two types look nearly identical—.goToNextEntry(in: .bars) vs. .goToNext(.bar)—and without variable support, having two types would just be duplication.

This was a challenging project, and I'm proud of the result. I'm also disappointed that all this work on the DSL got hidden behind the scenes—I had a grand vision of a bespoke code editor with inline error reporting, and nifty tools for learning MIDI messages. But I had to be realistic about my strengths and weaknesses—text editing and syntax highlighting are not in my wheelhouse. So I'll settle for a pat on the back for spotting that rabbit hole and stepping around it.


This piece was inspired by this month's Swift Blog Carnival on Tiny Languages. I've been trying to write more on this space lately, and this month's prompt gave me an excuse to think and write about my work on this project. Thanks for the nudge, Christian!

  1. If you're using Capo already, you can take a look at ~/Library/Containers/com.supermegaultragroovy.capo3.mac/Data/Library/Application Support/commands.caporemote to see this in action.