Skip to content

Music Authoring — PSG-Only Ambient Tracks

mixing (parent task GWP-173) stays gated on the Pico 2 audio chain bring-up and is not addressed here.

This page covers how to compose a track, register it with the runtime, and trigger playback from cartridge Lisp.


A music track is a sequence of register-write rows that the engine emits at a fixed cadence. Each row addresses a YM2149 register (or a per-channel slot), waits a configurable dwell, then the engine advances to the next row. When the cursor reaches the end of the track, it jumps back to a header-defined loop_start row and keeps emitting. Tracks loop indefinitely until the cart calls music-fade or music-stop.

The engine is single-track at v0.1 — at most one track plays at a time. There are 4 registry slots (MUSIC_MAX_TRACKS) so a cart can preload alternates and switch between them; calling music-play with a different handle stops the current track and starts the new one without any crossfade. Use music-fade first if you want a graceful handoff.


(music-define HANDLE LOOP-START TICKS-PER-ROW-MS ROWS)
; Build a track from ROWS and register it under HANDLE (0..3).
; LOOP-START is the row index to jump back to after the last row.
; TICKS-PER-ROW-MS is the global dwell scaling factor (typical: 50–80).
; ROWS is a list of (channel reg val dwell) lists.
; Returns HANDLE on success, nil if rejected.
(music-play HANDLE)
; Start playback of the track at HANDLE. Replaces any current track
; without fade. Returns HANDLE, or nil if the slot is empty.
(music-fade MS)
; Begin fading out over MS milliseconds. Music keeps ticking during
; the fade; when it reaches zero, playback stops and a silence frame
; emits. MS=0 stops immediately. Returns t.
(music-stop)
; Stop playback immediately and emit a silence frame. Returns t.
(music-current)
; Returns the active handle (0..3) when playing, or nil when idle.

Each row is a 4-tuple:

(channel reg val dwell)
FieldRangeMeaning
channel0, 1, 2, 2550..2 selects PSG channel A/B/C; 255 is “raw” (no per-channel remap).
reg0..13YM2149 register. Values >13 drop at emit.
val0..255Byte to write.
dwell0..255Ticks of silence after this row before advancing. 0 collapses to 1 so a row always advances at least one tick.

When channel is 0..2, a few reg values are remapped so the same row template targets the selected channel:

regMeaning when channel = 0..2
0Tone period lo for the selected channel (R0 / R2 / R4).
1Tone period hi for the selected channel (R1 / R3 / R5).
8Amplitude for the selected channel (R8 / R9 / R10).
any otherVerbatim — these regs (mixer R7, noise R6, envelope R11/R12/R13) are global.

When channel = 255 (“raw”), every reg passes through verbatim. Use this for envelope, mixer, and noise rows.

The PSG runs at a 2 MHz master clock divided by 16 for tone:

period = 2_000_000 / (16 * frequency_hz)

Useful values:

HzPeriodHexlo / hi (LE)
11011360x4700x70 0x04
2205680x2380x38 0x02
3303790x17B0x7B 0x01
4402840x11C0x1C 0x01
6601890x0BD0xBD 0x00
8801420x08E0x8E 0x00

; A 4-row arpeggio on channel A: A4 -> E5 -> hold amp -> A4 again.
; Loop body starts at row 0; ticks_per_row_ms = 50 means each dwell
; unit is 50 ms. dwell=4 -> 200 ms.
(set my-track-rows
(list
; Mixer + envelope setup (raw channel = 255)
(list 255 7 0x3E 1) ; mixer: tone A only
; Channel A tone period (440 Hz) — channel 0, reg 0/1
(list 0 0 0x1C 1)
(list 0 1 0x01 1)
; Channel A amplitude — channel 0, reg 8 -> R8
(list 0 8 0x08 4) ; hold amp 8 for 4 ticks
; Switch to E5
(list 0 0 0xBD 1)
(list 0 1 0x00 1)
(list 0 8 0x06 4)))
(set cart-init
(fn ()
(do
; ... other init ...
(music-define 0 0 50 my-track-rows) ; HANDLE 0, loop from row 0
(music-play 0))))

The loop_start = 0 means the track loops from the top. To skip an intro section on subsequent loops, declare a higher loop_start:

(music-define 0 4 80 my-track-rows) ; loop body is rows 4..end

See kn86-emulator/carts/neongrid.lsp for a 224-row real composition (“Grid Drift”) with three voices, intro section, and 7 phrases.


When a cart fires (sfx-keyclick), (sfx-select), etc. while music is playing, the SFX register writes hit the same PSG. The result:

  1. SFX writes preempt music writes for the channel the SFX targets (typically channel A for keyclicks). Music’s row writes on that channel are temporarily masked while the SFX envelope plays out.
  2. Music keeps ticking through the SFX. The engine does not query channel state — its row sequence advances on schedule.
  3. Music resumes audibly on the next row that targets that channel, when its register write overwrites the SFX residual.

In practice the audible result is “the keyclick/confirm chirp plays over the music; music returns within ~50–200 ms depending on track density on that channel.” No manual ducking required.

If a cart needs explicit control (e.g., duck the music for a multi-second event), call music-fade before the event and music-play HANDLE again afterward.


Tracks are serialized as a 16-byte header plus N × 4-byte rows:

Header (16 bytes, little-endian):
u32 magic = 0x4D55534B ('MUSK')
u16 version = 1
u16 row_count
u16 loop_start
u16 ticks_per_row_ms
u8[4] reserved (zero)
Each row (4 bytes):
u8 channel
u8 reg
u8 val
u8 dwell

The format lives alongside the cart’s existing static data (no new container — option (b) per the design pack). Cart authors normally build tracks via (music-define ...) from a Lisp s-expression list; the engine’s serializer is exposed in C for tooling that wants to emit .mus files directly. Round-tripping a track through serialize

  • deserialize is byte-for-byte stable (test_music.c covers this).

  • The engine emits rows directly to nosh_psg_write with no buffering layer between the row table and the PSG register file.
  • One global MusicEngine (music_engine_global()) holds all track data; MUSIC_MAX_TRACKS = 4, MUSIC_MAX_ROWS = 512. Total static footprint is ~8 KB and lives in BSS — no malloc.
  • Tick cadence is whatever the audio callback runs at: at 1024 samples / 44.1 kHz, the buffer is ~23 ms, so music_tick(23) fires every 23 ms. Row dwells should be at least ~50 ms to avoid skipping rows on a single tick.
  • music_tick is O(rows-advanced-this-tick) — for typical ticks_per_row_ms ≥ 50 and 23 ms audio buffers, that’s at most one row per tick.

What’s not in scope (gated on Pico bring-up)

Section titled “What’s not in scope (gated on Pico bring-up)”
  • PCM mixing — sample playback, voice barks, drum loops. This is the parent GWP-173 work and stays deferred until ADR-0017’s audio chain validates on hardware.
  • Multi-track layering — playing two tracks simultaneously and mixing them in software. Out of scope for the PSG-only path; the YM2149 only has 3 tone channels and they’re all addressable from a single track.
  • Tempo sync to gameplay events — phase advance changing music, threat-level escalation, etc. The current engine has no mission / phase awareness; that’s a follow-up once gameplay design weighs in.

  • kn86-emulator/src/music.h / music.c — engine + serializer.
  • kn86-emulator/src/nosh_lisp_bridge.c — Lisp primitive bindings.
  • kn86-emulator/tests/test_music.c — 22-case test suite.
  • kn86-emulator/carts/neongrid.lsp — “Grid Drift” sample composition.
  • docs/plans/sprints/2026-04-27-sprint4-gwp-173-design.md — original triage that scoped this PSG-only spinoff out of GWP-173.