Pulse-channel sfx with a multi-byte `pitch:` array used to silently
ignore everything past the first byte — the runtime audio tick
latched the period at trigger time and never updated it. Programs
that wanted a frequency sweep had no way to express it.
The compiler now compiles a per-frame pitch envelope blob alongside
the existing volume envelope when `decl.pitch` has more than one
distinct value. The blob is padded (or truncated) to the volume
envelope's length and ends in a zero sentinel so the runtime
walker stops both pointers on the same NMI. Sfx with a single
scalar pitch (or an array where every byte is the same) keep their
historical "no pitch blob, latch once" path and emit byte-identical
ROM bytes.
The runtime gains two new pieces, both gated on a new
`__sfx_pitch_used` codegen marker so programs without varying-pitch
sfx pay zero bytes:
1. `gen_audio_tick` emits a per-frame pitch update block inside
the SFX tick: read a byte through `(AUDIO_SFX_PITCH_PTR),Y`,
write it to `$4002` (pulse-1 period low), advance the pointer.
The block bails on a zero high-byte pointer so a single
program can mix scalar-pitch and varying-pitch sfx without
one clobbering the other.
2. `emit_play_pulse` seeds `AUDIO_SFX_PITCH_PTR_LO/HI` with the
pitch-blob label for varying-pitch sfx and zeros it for
scalar-pitch sfx. The per-call branch is skipped entirely
when the program has no varying-pitch sfx anywhere.
The new `examples/sfx_pitch_envelope.ne` exercises the path with
a 16-frame siren sweep. Triangle and noise per-frame pitch are
deferred — they share the same data shape but the runtime ticks
for those channels still write only their volume registers, see
docs/future-work.md for the gap.
https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB