Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 27. The Piano Roll: How It Works

Goal

By the end of this chapter you will understand how the piano roll example works: how a Keleusma loop program becomes music.

A real loop program

Part IV introduced the loop function, a program that never finishes and yields to a host on every cycle, and said a real one would be run in Part VIII. The piano roll is that real program. A song is a loop main, and the piano roll example is the Rust host that drives it.

Three pieces

The piano roll has three pieces, working at three different speeds.

  • The song is a Keleusma loop main(input: Word) -> Word. Each cycle of the loop is one sixteenth-note tick. On each tick the song decides which notes start or stop.
  • The host’s main thread drives the song. It resumes the song once per tick. At 120 beats per minute, a tick is 125 milliseconds.
  • The host’s audio thread produces the sound. It runs far faster, at forty-eight thousand samples per second, and it never enters the Keleusma virtual machine.

The song schedules, the host synthesizes

This is the key idea. The song does not make sound. It schedules events. When the song decides that channel 0 should sound MIDI note 60, it calls a host native:

host::play(0, 60)

That call writes into the host’s voice state. The audio thread reads that state and turns it into sound. The song’s job is the timing and the note choices. The host’s job is the synthesis. The native function calls, introduced in Chapter 16’s idea of talking to the host, are the bridge.

The song’s memory

A song remembers where it is using the data segment from Chapter 18. Every bundled song declares a data state block with the same shape: a one-shot setup flag, a loop counter, a section pointer, a few general-purpose slots, and two arrays of eight counters, one tracking each channel’s position in its note pattern and one tracking the ticks left before that channel’s next note.

The init block

The first thing a song’s loop main body does, on its very first cycle, is a one-shot setup block guarded by the state.init flag:

if state.init == 0 {
    host::song_name("C major progression, four-bar loop");
    host::set_waveform(0, 0);
    host::set_adsr(0, 5, 80, 700, 150);
    host::set_enable(0, 1);
    // ... configure the other channels ...
    state.init = 1;
};

The data segment starts zeroed, so state.init is 0 on the first cycle, the block runs, and it sets state.init to 1. On every cycle after that, the block is skipped. This is where each channel’s instrument is chosen.

The per-tick body

After the init block, the song handles each channel the same way. For a channel, if its remaining-ticks counter has reached zero, the song looks up the channel’s next note, calls host::play or host::silence, sets the counter to the new note’s duration, and advances the channel’s position. Otherwise it simply counts the counter down by one. Then the song yields, and the cycle ends.

Every tick does a fixed, small amount of work. The bounded-step guarantee from Chapter 20 holds throughout: the song cannot overrun its tick.

Swapping songs

The piano roll holds ten songs. Pressing a key swaps the running song for the next one. That swap is the hot code swap of Chapter 26, happening at a RESET boundary, made audible.

What you now know

  • A song is a loop main, and the piano roll is its host.
  • The song schedules note events with host native calls; the host’s audio thread synthesizes the sound.
  • A song keeps its position in the data segment.
  • A one-shot init block configures the instruments on the first cycle.
  • Each tick does a bounded amount of work.

The next chapter builds the piano roll on your own machine.