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.
Big picture
Section titled “Big picture”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.
Lisp playback primitives
Section titled “Lisp playback primitives”(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.Row contract
Section titled “Row contract”Each row is a 4-tuple:
(channel reg val dwell)| Field | Range | Meaning |
|---|---|---|
channel | 0, 1, 2, 255 | 0..2 selects PSG channel A/B/C; 255 is “raw” (no per-channel remap). |
reg | 0..13 | YM2149 register. Values >13 drop at emit. |
val | 0..255 | Byte to write. |
dwell | 0..255 | Ticks of silence after this row before advancing. 0 collapses to 1 so a row always advances at least one tick. |
Channel-relative addressing
Section titled “Channel-relative addressing”When channel is 0..2, a few reg values are remapped so the same
row template targets the selected channel:
reg | Meaning when channel = 0..2 |
|---|---|
0 | Tone period lo for the selected channel (R0 / R2 / R4). |
1 | Tone period hi for the selected channel (R1 / R3 / R5). |
8 | Amplitude for the selected channel (R8 / R9 / R10). |
| any other | Verbatim — 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.
Frequency-to-period
Section titled “Frequency-to-period”The PSG runs at a 2 MHz master clock divided by 16 for tone:
period = 2_000_000 / (16 * frequency_hz)Useful values:
| Hz | Period | Hex | lo / hi (LE) |
|---|---|---|---|
| 110 | 1136 | 0x470 | 0x70 0x04 |
| 220 | 568 | 0x238 | 0x38 0x02 |
| 330 | 379 | 0x17B | 0x7B 0x01 |
| 440 | 284 | 0x11C | 0x1C 0x01 |
| 660 | 189 | 0x0BD | 0xBD 0x00 |
| 880 | 142 | 0x08E | 0x8E 0x00 |
Composing a track — minimal example
Section titled “Composing a track — minimal example”; 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..endSee kn86-emulator/carts/neongrid.lsp for a 224-row real composition
(“Grid Drift”) with three voices, intro section, and 7 phrases.
SFX coexistence
Section titled “SFX coexistence”When a cart fires (sfx-keyclick), (sfx-select), etc. while music
is playing, the SFX register writes hit the same PSG. The result:
- 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.
- Music keeps ticking through the SFX. The engine does not query channel state — its row sequence advances on schedule.
- 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.
Format on disk (option (b))
Section titled “Format on disk (option (b))”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 dwellThe 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).
Performance notes
Section titled “Performance notes”- The engine emits rows directly to
nosh_psg_writewith 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 — nomalloc. - 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_tickisO(rows-advanced-this-tick)— for typicalticks_per_row_ms ≥ 50and 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.
Reference
Section titled “Reference”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.