Skip to content

Tune Sequencer with Two Terminators

Store a phrase as timed events, distinguish looping from stopping, and trace the player separately from the hardware that makes the sound.

Code status: illustrative code. Adapt the listing to your program and check it on your target machine. This page links no assembly or execution record for the listing itself.

Overview

A title tune repeats until we start playing. A victory phrase reaches its end and stops. We can describe both as data: notes, rests, and an instruction at the end. The player remembers its position and advances when an event’s duration expires.

Start with Four Ways to Make a Sound. We need to understand how our chosen machine starts and ends one sound before asking a table to organise several. Here we compare the players in Flock 17, Flock 18, Starfield 17 and Dash 17. They share an idea, not an identical format or driver.

Which clock counts the notes?

A player update advances musical state. Sound generation happens on another clock: SID oscillators, NES channel timers or Paula sample DMA continue between player calls. Software still chooses notes, handles effects and owns the channels. A CPU-driven Spectrum beeper needs software to generate its output edges too; putting notes in a table does not make that waveform work happen in the background. A blocking beeper routine pauses other work. Interleaving work requires a different, carefully timed implementation.

Our examples use frame-paced game loops. That is a scheduling choice, not a rule for all music players. Six player intervals last 120 ms at nominal 50 updates per second, or 100 ms at nominal 60: unchanged event spacing plays 20% faster at 60. Pitch is separate, although regional hardware clocks can also change tuning. Count the actual calls and countdown order before interpreting a stored duration. A missed update can delay the next event while hardware continues its current state.

An explicit countdown contract

This schematic pseudocode defines a small alternative we can trace. It is not a transcription of any of the games. Rows have a named kind (NOTE, REST, LOOP or STOP); notes and rests also have a positive whole-number duration. Notes carry machine-specific sound data. Named fields describe the format here, not a required binary layout. The table and player state remain valid for the whole phrase.

We start with active = TRUE, index = 1 and remaining = 0. A duration of two means two player intervals between this event’s start and the next event’s start. start_note and rest_voice act only on the resource owned by this player. stop_player ends ownership according to the driver’s agreed mute or release policy and sets active = FALSE. data_error reports a useful development error and calls stop_player; it does not silently turn malformed music into a valid phrase.

Code
DEFINE player_update
    IF NOT active THEN RETURN
    IF remaining > 0 THEN
        LET remaining = remaining - 1
        IF remaining > 0 THEN RETURN
    END

    # At most one LOOP followed by one timed event or STOP.
    FOR attempt = 1 TO 2
        IF index < 1 OR index > LENGTH(rows) THEN
            CALL data_error("Missing ending or invalid position")
            RETURN
        END
        LET row = rows[index]
        IF row.kind = STOP THEN
            CALL stop_player
            RETURN
        END
        IF row.kind = LOOP THEN
            LET index = 1
        ELSE
            IF row.kind <> NOTE AND row.kind <> REST THEN
                CALL data_error("Unknown event")
                RETURN
            END
            IF row.duration < 1 OR row.duration > MAX_DURATION THEN
                CALL data_error("Invalid duration")
                RETURN
            END
            LET index = index + 1
            LET remaining = row.duration
            IF row.kind = REST THEN
                CALL rest_voice
            ELSE
                CALL start_note(row.sound)
            END
            RETURN
        END
    END
    CALL data_error("Loop without a timed event or stop")
END

Markers are interpreted before duration or pitch fields. A one-word marker must not make us read a nonexistent second word. On a target machine we choose MAX_DURATION to fit the counter and reserved marker values, and validate pitch, sample references and table bounds as well. A small authoring check can reject bad built-in data before assembly. Runtime checking is especially useful for loaded data; it has a cost we should account for.

The two-row limit prevents an empty loop from trapping the game inside its music update. It deliberately supports only this simple phrase format. Adding jumps or commands requires a new bounded-work contract.

Predict the next event

Trace NOTE A, 2; REST, 1; NOTE B, 1; STOP with the contract above. Which calls start A, the rest and B? When does STOP run? Then replace the whole table with LOOP. Can that update return?

Compare the trace
Call Event or countdown Remaining afterwards
1 Start A 2
2 Count down 1
3 Count down to zero; start rest 1
4 Count down to zero; start B 1
5 Count down to zero; stop 0

The LOOP-only table consumes both allowed row inspections, then reports a data error and stops. An empty table fails its bounds check immediately. A zero-duration note is rejected rather than wrapping an unsigned counter.

Now change only the update rate. The event order stays the same; the time between events changes. That observation establishes a scheduling result, not whether a particular instrument sounds good or releases instantly.

What the existing players actually do

Amiga: two timers and a repeating waveform

Flock 17 stores normal rows as two words, period, duration: four bytes per row. Period zero is a rest; a single negative word marks the ending. Unit 17 loops; unit 18 distinguishes -1 (return to the title jingle) from -2 (stop advancing). Its pointer stays on the stop marker. The marker itself does not mute the channel: soundtick completes the previously timed sound.

tunetick starts with a zero timer. It tests for zero before decrementing; a non-zero timer is decremented and the routine returns even when it becomes zero. A row loaded with duration D therefore starts its successor D + 1 player calls later. A loop marker also uses a call to reset the pointer before a later call loads the first note. This is different from the schematic contract above.

playsound points Paula channel 0 at an eight-byte square waveform, sets a four-word sample length and enables DMA. The waveform repeats; it is not a one-shot recording. sndtimer and soundtick determine when playback stops. On the title path, tunetick runs before soundtick, so a newly loaded sound timer is decremented in that same game update. Trace both routines when explaining the note’s sounding length and the gap before its successor. A rest disables channel DMA. Music and effects share this channel; transitions must decide who may write it.

C64: the title borrows the laser’s voice

Starfield 17 uses three parallel byte tables: frequency low, frequency high and duration. Its high byte alone determines the marker: zero means rest, $FF loops. Consequently this particular format cannot express a sounding frequency with a zero high byte. It has no STOP marker; leaving the title stops music updates.

enter_title sets the timer to one. jingle_tick decrements first, so the first note loads on the next call; a positive duration D gives D calls between starts. A normal note writes four SID registers: frequency high, frequency low, gate off, gate on. A rest writes gate off. The countdown still costs CPU instructions on calls with no SID writes.

The loop path loads the first row in the same call, assuming it is a valid timed row. The built-in tables must have matching lengths, positive durations and a valid first row. This is not a general malformed-data parser. enter_title installs the music envelope; enter_game gates the voice off and restores the laser’s envelope. Gate-off requests release, not an immediate reset to silence. A quick off/on pair does not promise the same attack from every previous envelope state. Listen to repeated notes as well as isolated ones.

NES: duration bytes also carry commands

Dash 17 stores period_low, period_high, duration, three bytes per row. Duration $FF loops and $FE stops; ordinary durations must be 1–253. Both period bytes zero mean rest. start_phrase initialises the timer to one, then tune_tick decrements before testing. A zero timer would wrap to 255 rather than immediately load the next row.

The routine checks the duration markers before playing the row. LOOP immediately reads again from the start; a LOOP-only table would never return. STOP silences pulse 1 by selecting constant volume zero and clears the active pointer flag. Validate the built-in tables, including the byte index: each complete three-byte row must fit before that index wraps. The active flag also assumes the phrase address has a non-zero high byte.

A sounding row writes four APU registers: volume/duty, sweep and two timer bytes. The timer-high write resets pulse phase and starts the envelope process; Dash selects constant volume, so this is not an audible envelope attack. Its sweep setting is chosen for its existing period range, not every possible pulse note. The player runs in the main loop after an NMI notification, not inside NMI. The one-bit notification does not queue every missed frame.

Choosing and measuring the result

We can preserve one event idea without requiring identical note starts, rests or release tails. Does the phrase articulate repeated notes clearly? Does its ending leave room for the next cue? If a sound effect takes the channel, decide whether the interrupted musical voice resumes, restarts or stays silent. Keep that policy visible in the game’s state transitions.

Measure the countdown-only path, a new note and the loop boundary separately. Include the driver’s writes, any effect updates and DMA costs where applicable. Hardware-generated sound saves waveform work; it does not guarantee negligible player cost or punctual updates. Avoid changing countdown order merely to make the listings alike: it changes an existing tune’s rhythm and needs a new listening check.

A single-voice phrase is a useful first solution for title music or a short cue. Multiple voices, instrument state and effects deserve later additions when a game needs them. They do not require one universal tracker architecture. Transposition also depends on representation: SID frequency values and Paula/NES periods move in opposite directions, and rounding and valid ranges matter.

Hardware references

The game descriptions above follow their maintained sources. The schematic trace checks countdown and marker logic; it does not establish audible output or original hardware behaviour.