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 carts/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.
Techniques — getting range out of three square waves
Section titled “Techniques — getting range out of three square waves”The YM2149 gives you three square-wave voices, one shared noise generator, one shared envelope generator, and no filter. Compared to the C64’s SID (four waveforms per voice, per-voice ADSR, ring modulation, hard sync, and a resonant sweepable filter) that is a much smaller instrument, and a track written as “three sustained square notes” will sound like it.
The AY/YM scene solved this with three techniques. They are the KN-86’s sanctioned way to get expression out of the part, and the reason a filter is deliberately absent from the design: see ADR-0054 for the audio chain. Reference listening for the ceiling here is Tim Follin’s ZX Spectrum 128 work and the Atari ST YM catalogue, both of which run this same chip family.
1. Arpeggio — fake a chord on one voice
Section titled “1. Arpeggio — fake a chord on one voice”Three voices cannot carry a bass line, a melody, and a chord at once. Instead, cycle one voice through the chord tones at roughly frame rate (~50-60 Hz), one note per tick. The ear fuses the strum into a chord and the other two voices stay free.
This is the single highest-leverage technique on the chip. A track without arpeggios is using a third of the instrument.
2. Software envelopes — per-voice ADSR the chip does not have
Section titled “2. Software envelopes — per-voice ADSR the chip does not have”The hardware has one envelope generator shared across all three channels, with eight fixed contours and no ADSR. So attack and decay are done by the player rewriting the 4-bit volume register every tick from a per-instrument table.
A pluck is 15 14 12 10 8 6 4 3 2 1 0. A swell is 2 5 8 10 12 13.
A sustained note with tremolo loops the tail. This is what AY/YM
trackers call an instrument, and it is the difference between notes
that sound played and notes that sound switched on.
3. Buzzer — the only non-square timbre available
Section titled “3. Buzzer — the only non-square timbre available”Drive a channel’s amplitude from the hardware envelope generator
(amplitude register bit 4) and set the envelope period so the ramp
repeats at audio rate. The envelope contour becomes the waveform, so a
repeating downward ramp (shape 0x08) yields a sawtooth-ish tone out
of a square-only chip. Mute the tone generator on that channel and what
you hear is the envelope.
The envelope frequency is:
f_env = master_clock / (256 * envelope_period) = 7812.5 / envelope_period (at the 2 MHz master clock)So to buzz at a given note frequency, envelope_period = 7812.5 / freq.
At period = 1 the envelope runs at ~7.8 kHz; at period = 95 it sits
on E2. Buzzer works best below about C3, which makes it the natural
choice for bass.
Two properties of psg.c matter when you use this:
- The envelope is 5-bit (32 steps), the YM2149 depth, not the
AY-3-8910’s 16. A full sweep takes
32 * periodticks of the 250 kHz tone clock. - Writing register 13 (shape) restarts the envelope; writing registers 11/12 (period) does not. So you can glide a buzz note’s pitch by rewriting the period every tick without retriggering it.
Because there is only one envelope generator, only one channel can buzz at a time. In practice that is fine: the bass buzzes, the other two voices use software envelopes.
Channel stealing
Section titled “Channel stealing”With three voices and no drum channel, percussion comes from temporarily taking a voice away from its part, usually the bass. A noise burst with a fast software-envelope decay is a hi-hat or snare. Constant handoff between bass and drums on the same channel is what makes a three-voice track sound like it has four parts.
Auditioning
Section titled “Auditioning”runtime/tools/kn86psg/ renders a text tune (.knm) through the real
psg.c on your laptop, with all four techniques expressible directly
in the format (tick, inst, +n arpeggio offsets, @instrument,
and the = buzzer suffix). See that tool’s README for the grammar and
runtime/tools/kn86psg/tunes/ascent.knm for a worked example using all
of them. The synthesis is identical to the device; only the speaker
coloration differs.
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”runtime/src/music.h/music.c— engine + serializer.runtime/src/nosh_lisp_bridge.c— Lisp primitive bindings.runtime/tests/test_music.c— 22-case test suite.carts/carts/neongrid.lsp— “Grid Drift” sample composition.