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
|
|
@ -715,6 +715,107 @@ Three ways to provide asset data:
|
|||
|
||||
---
|
||||
|
||||
## Audio
|
||||
|
||||
NEScript ships with a full data-driven audio subsystem. Sound effects run on pulse channel 1 and music runs on pulse channel 2, both driven by an NMI-time tick that walks per-track data tables compiled into PRG ROM. Programs that never touch audio pay zero ROM or cycle cost — the driver and its period table are only linked in when user code contains at least one `play`, `start_music`, or `stop_music` statement.
|
||||
|
||||
### Statements
|
||||
|
||||
```
|
||||
play SfxName // trigger a one-shot sound effect
|
||||
start_music TrackName // begin looping background music
|
||||
stop_music // silence the music channel
|
||||
```
|
||||
|
||||
Each statement looks up the name in the program's user declarations first, then falls back to the builtin table. Unknown names are a hard error (E0505).
|
||||
|
||||
### SFX Declarations
|
||||
|
||||
An `sfx` block is a frame-accurate envelope for pulse 1. `pitch` latches the pulse period on trigger; `volume` runs one entry per frame, so the envelope length controls the effect duration.
|
||||
|
||||
```
|
||||
sfx Pickup {
|
||||
duty: 2 // 0-3, 2 = 50% square (default)
|
||||
pitch: [0x50, 0x50, 0x50, 0x50, 0x50] // period for each frame
|
||||
volume: [15, 12, 9, 6, 3] // 0-15, one per frame
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `pitch` and `volume` must have the same length (the frame count).
|
||||
- `volume` values are 0-15 (4-bit pulse volume).
|
||||
- `duty` is 0-3 and defaults to 2.
|
||||
- Maximum 120 frames (2 seconds at 60 fps).
|
||||
|
||||
### Music Declarations
|
||||
|
||||
A `music` block is a flat list of `(pitch, duration)` note pairs played on pulse 2. Pitch 0 is a rest; pitches 1-60 are indices into the builtin 60-note period table (C1 through B5, with middle C at index 37). Duration is in frames (so at 60 fps, `30` is half a second).
|
||||
|
||||
```
|
||||
music Theme {
|
||||
duty: 2 // 0-3 (default 2)
|
||||
volume: 10 // 0-15 (default 10)
|
||||
repeat: true // loop when track ends (default true)
|
||||
notes: [
|
||||
37, 20, // C4 for 20 frames
|
||||
41, 20, // E4
|
||||
44, 20, // G4
|
||||
49, 20, // C5
|
||||
0, 10, // rest for 10 frames
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `notes` must contain an even number of bytes (pitch + duration pairs).
|
||||
- Pitches are 0 (rest) or 1-60 (period table index).
|
||||
- Duration must be ≥ 1 frame.
|
||||
- Maximum 256 notes per track.
|
||||
|
||||
### Builtin Names
|
||||
|
||||
For programs that want classic game audio without writing data tables, NEScript provides a handful of builtin effects and tracks that can be used directly:
|
||||
|
||||
**Builtin SFX**
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `coin`, `pickup`, `collect` | Ascending high blip |
|
||||
| `jump`, `hop` | Descending arc |
|
||||
| `hit`, `damage`, `explode` | Low blast |
|
||||
| `click`, `select`, `confirm` | Sharp beep |
|
||||
| `cancel`, `back`, `error` | Low longer tone |
|
||||
| `shoot`, `laser`, `fire` | Very high pulse |
|
||||
| `step`, `footstep` | Short low thud |
|
||||
|
||||
**Builtin Music**
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `title`, `theme`, `main` | Major arpeggio (looping) |
|
||||
| `battle`, `boss` | Driving pulse (looping) |
|
||||
| `win`, `victory`, `fanfare` | Ascending burst (one-shot) |
|
||||
| `gameover`, `lose`, `fail` | Descending dirge (looping) |
|
||||
|
||||
A user-declared `sfx` or `music` block takes priority over a builtin with the same name, so `sfx coin { ... }` will shadow the default coin effect.
|
||||
|
||||
### How It Works
|
||||
|
||||
Compile time:
|
||||
|
||||
1. The resolver compiles each `sfx` into `(period_lo, period_hi, envelope[])` and each `music` into `(header, (pitch, duration)[])`, appending builtins for any referenced name that isn't user-declared.
|
||||
2. The IR codegen emits `play Name` as: write trigger bytes to `$4002`/`$4003`, load envelope pointer into `$0C/$0D`, set the sfx counter. `start_music Name` stamps a state byte into `$07`, loads the stream pointer into `$0E/$0F` (and the loop base into `$05/$06`), and primes the duration counter.
|
||||
3. The linker splices the audio tick, the 60-entry period table, and every compiled sfx/music blob into PRG ROM, all guarded on a `__audio_used` marker label so silent programs never pay the cost.
|
||||
|
||||
Runtime (every NMI, if audio is in use):
|
||||
|
||||
1. **SFX**: if the counter is nonzero, read one envelope byte through `(ZP_SFX_PTR),Y` and write it to `$4000`. A zero sentinel mutes pulse 1 and stops the tick.
|
||||
2. **Music**: if active and the note counter hits zero, read the next pitch byte. 0 = rest (mute pulse 2). 1-60 = look up the period in the table and write to `$4006`/`$4007`. `0xFF` = loop back to the base pointer (or mute if `repeat: false`). Then read the duration byte and reload the counter.
|
||||
|
||||
Total memory cost: 8 bytes of zero page, ~200 bytes for the driver body, 120 bytes for the period table, plus the data for each user-declared sfx/music.
|
||||
|
||||
---
|
||||
|
||||
## Mappers
|
||||
|
||||
The mapper determines cartridge hardware and available ROM size.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue