mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +00:00
audio: complete the subsystem — asset pipeline, user decls, tracker-style driver
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.
This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:
## Asset pipeline (new `src/assets/audio.rs`)
- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
pulse-1 envelopes. Pitch/volume arrays must match in length; each
entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
`start_music` references and append builtin fallbacks for any
name that isn't user-declared — so `play coin` still works
without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
and tracks (theme, battle, victory, gameover) synthesize through
the same compile path as user decls — one data model, one driver.
## Runtime engine (`src/runtime/mod.rs`)
- `gen_audio_tick()` walks both channels every NMI: reads one
envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
advances ptr, mutes on zero sentinel. Music decrements the note
counter, advances to the next `(pitch, dur)` pair on zero, looks
up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
(A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
user sfx/music data can be spliced into PRG with regular labels
that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
(duty/volume/loop/active), `$0C-$0F` sfx and music pointers.
## IR codegen (`src/codegen/ir_codegen.rs`)
- `with_audio(sfx, music)` registers compile-time trigger constants
per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
`__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
with the active bit OR'd in, seeds both ptr and loop base from
`__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.
## Linker (`src/linker/mod.rs`)
- New `link_with_all_assets(user_code, sprites, sfx, music)` path
that splices driver body, period table, and each sfx/music data
blob into PRG — all guarded on the `__audio_used` marker so
silent programs pay zero ROM cost.
## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)
- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
letting the linker splice ROM data tables into a code section
and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
correctly in the same assembly pass.
## Analyzer
- `play` / `start_music` now validate the name against user decls
and builtin tables. Unknown names emit E0505 with a helpful list
of builtins — previously a typo would silently compile to no-op.
## Parser
- New `sfx_decl` / `music_decl` grammar with property-style
configuration. Strict validation: duty 0-3, volume 0-15, pitch
arrays must match volume length, music notes must come in pairs,
pitch 0-60, duration ≥ 1.
## Tests
+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
$4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
user-declared `sfx` + `music` program that verifies bytes land
in PRG ROM at the right addresses
## Docs
- New Audio section in `docs/language-guide.md` with syntax
reference, builtin tables, and an explanation of how the
driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
"status: full subsystem" with a narrower list of follow-up work
(triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
`sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
builtin fallback via `play coin`.
Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
This commit is contained in:
parent
c5c8c38a54
commit
d42540f45e
22 changed files with 2865 additions and 243 deletions
|
|
@ -163,18 +163,26 @@ These are defined in `ErrorCode` but never emitted:
|
|||
|
||||
## 12. Audio
|
||||
|
||||
**Status**: A minimal APU driver is now in place. `play SfxName` emits
|
||||
pulse-1 register writes from a builtin SFX table, `start_music` holds a
|
||||
tone on pulse 2, `stop_music` mutes pulse 2. The NMI handler gains a
|
||||
`JSR __audio_tick` splice whenever user code triggered audio so the SFX
|
||||
countdown counter can mute the tone when its frames are up. Unused
|
||||
programs pay zero ROM cost.
|
||||
**Status**: A full data-driven audio subsystem is in place. User programs
|
||||
can declare `sfx Name { duty, pitch, volume }` blocks (frame-accurate
|
||||
pulse-1 envelopes) and `music Name { duty, volume, repeat, notes }`
|
||||
blocks (pulse-2 note streams with rests and looping). The resolver
|
||||
compiles these into ROM-ready byte tables; the IR codegen emits
|
||||
trigger sequences that load pointers into ZP; the runtime NMI tick
|
||||
walks the envelope and note stream each frame, indexing into a
|
||||
builtin 60-note period table. Builtin effects (`coin`, `jump`, `hit`,
|
||||
`click`, `cancel`, `shoot`, `step`) and tracks (`theme`, `battle`,
|
||||
`victory`, `gameover`) are synthesized from the same data path so
|
||||
programs that don't declare their own audio still make sound.
|
||||
Programs that touch no audio pay zero ROM or cycle cost — the whole
|
||||
subsystem elides when the `__audio_used` marker is absent.
|
||||
|
||||
**Still TODO for a full audio solution**:
|
||||
**Still TODO for richer audio**:
|
||||
- Triangle/noise/DMC channels (currently only pulse 1 and 2 are used)
|
||||
- Multi-channel tracker playback (one `notes` list per channel)
|
||||
- `@sfx("file.nsf")` / `@music("file.ftm")` asset directives
|
||||
- FamiTracker export format parsing
|
||||
- Multi-channel music driver with note tables and envelopes
|
||||
- Per-SFX duration override in the declaration rather than the builtin table
|
||||
- Per-note pitch changes within a sfx (currently pitch is latched once)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -285,14 +293,16 @@ spills to zero-page $02 for comparisons. A proper allocator would:
|
|||
|
||||
These items were documented as future work but have since been implemented:
|
||||
|
||||
- **Minimal audio driver** — `src/runtime/mod.rs::gen_audio_tick()`
|
||||
plus codegen for `IrOp::PlaySfx` / `StartMusic` / `StopMusic`. SFX
|
||||
and music map through a builtin name table (`coin`, `jump`, `hit`,
|
||||
`theme`, etc.) to APU pulse 1/2 register writes. The NMI handler
|
||||
gains a `JSR __audio_tick` splice (via the linker's `__audio_used`
|
||||
marker lookup) that decrements a per-frame counter and mutes the
|
||||
channel when the tone's time is up. Programs without audio pay
|
||||
zero ROM cost.
|
||||
- **Full audio subsystem** — `src/runtime/mod.rs::gen_audio_tick`,
|
||||
`gen_period_table`, and `gen_data_block` implement a frame-walking
|
||||
pulse driver. `src/assets/audio.rs` compiles user `sfx`/`music`
|
||||
declarations (and builtins referenced via `play coin` etc.) into
|
||||
ROM-ready envelope and note-stream byte tables. `IrCodeGen::with_audio`
|
||||
threads the compile-time trigger constants into `play`/`start_music`,
|
||||
which emit pointer loads against per-blob labels. The linker splices
|
||||
driver body, period table, and every blob into PRG gated on the
|
||||
`__audio_used` marker so silent programs pay no cost. Full
|
||||
parser/analyzer/codegen/linker/runtime test coverage.
|
||||
- **u16 arithmetic and comparisons** — new IR ops `LoadVarHi`,
|
||||
`StoreVarHi`, `Add16`, `Sub16`, `CmpEq16` through `CmpGtEq16`. The
|
||||
lowering context tracks variable types via the analyzer's symbol
|
||||
|
|
@ -422,9 +432,10 @@ These items were documented as future work but have since been implemented:
|
|||
- **`for i in start..end { ... }` loops** — half-open range with a
|
||||
u8 index variable. Desugared in IR lowering to a while loop with
|
||||
a proper continue-edge block so `break`/`continue` work.
|
||||
- **Audio statement parsing** — `play SfxName`, `start_music`,
|
||||
`stop_music` parse and analyze as no-ops (no driver yet; users
|
||||
can wire audio via inline `asm` blocks).
|
||||
- **Audio subsystem** — full data-driven pulse driver with
|
||||
`sfx`/`music` block declarations, builtin effects, and an
|
||||
NMI-time tick that walks envelope and note-stream tables.
|
||||
See section 12 above for the full writeup.
|
||||
- **Constant expression folding** — `const B: u8 = A + 3` evaluates
|
||||
at compile time and feeds through to variable initializers too.
|
||||
- **`on scanline` codegen (minimal)** — MMC3 IRQ setup at startup
|
||||
|
|
@ -490,10 +501,12 @@ For someone picking up this codebase, the recommended order of work:
|
|||
end-to-end (load/store, +/-, comparisons all propagate through
|
||||
16-bit IR ops). Struct fields and array elements are still u8-only
|
||||
— the layout machinery needs to grow multi-byte field offsets.
|
||||
2. **Full multi-channel music driver** — the current audio engine
|
||||
holds a single tone on each of pulse 1 (SFX) and pulse 2 (music).
|
||||
A proper NSF-style driver with note tables, envelopes, and a
|
||||
multi-track stream would unblock real game music.
|
||||
2. **Triangle / noise / DMC channels** — the current audio engine
|
||||
plays sfx on pulse 1 and music on pulse 2 with a full
|
||||
data-driven tracker model (envelope walk, period table,
|
||||
`(pitch, duration)` note streams, loop-back). Wiring triangle
|
||||
and noise channels into the same model would unblock richer
|
||||
multi-part compositions.
|
||||
3. **Register allocator** — proper A/X/Y allocation to replace
|
||||
zero-page spills used by the current IR codegen. Partially
|
||||
mitigated by peephole passes + the new slot recycler, but still
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue