1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00
nescript/examples/audio_demo.ne
Claude d42540f45e
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
2026-04-13 01:10:21 +00:00

122 lines
3.6 KiB
Text

// Audio Demo — showcases the full audio subsystem.
//
// NEScript has three audio statements:
// play Name — trigger an SFX on pulse 1 (one-shot)
// start_music Name — play a background music track on pulse 2
// stop_music — silence the music channel
//
// SFX and music tracks are compiled into PRG ROM data tables and
// walked one byte per NMI by the builtin audio driver. You can
// either use the builtin names (coin, jump, theme, ...) or declare
// your own via `sfx Name { ... }` / `music Name { ... }` blocks.
//
// Builtin SFX names:
// coin, pickup, collect — high ascending 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 — low thud
//
// Builtin music names:
// title, theme, main — major arpeggio (looping)
// battle, boss — driving pulse (looping)
// win, victory, fanfare — ascending burst (one-shot)
// gameover, lose, fail — descending dirge (looping)
//
// Build: cargo run -- build examples/audio_demo.ne
// Output: examples/audio_demo.nes
game "Audio Demo" {
mapper: NROM
}
// ── User-declared sound effects ──
//
// An `sfx` block is a frame-accurate envelope for pulse 1. `pitch`
// latches the pulse period once on trigger; `volume` runs one entry
// per frame, so the envelope length controls the effect duration.
// `duty` (0-3) picks the pulse waveform shape (2 = 50% square).
// A gentle rising chirp, longer than the builtin coin.
sfx LongCoin {
duty: 2
pitch: [0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50, 0x50]
volume: [15, 14, 13, 12, 11, 9, 7, 5, 3, 1]
}
// A sharp two-part zap — quick high spike into silence.
sfx Zap {
duty: 3
pitch: [0x20, 0x20, 0x20, 0x20, 0x20]
volume: [15, 12, 8, 4, 1]
}
// ── User-declared music tracks ──
//
// A `music` block is a flat list of `(pitch, duration)` note pairs.
// Pitch 0 = rest; 1-60 = period table index (C1..B5, middle C = 37).
// Duration is in frames (so at 60 fps, 30 = half second per note).
// Music loops by default; set `repeat: false` for one-shot cues.
// A cheerful four-note looping theme.
music Theme {
duty: 2
volume: 10
repeat: true
notes: [
37, 20, // C4
41, 20, // E4
44, 20, // G4
49, 20, // C5
44, 20, // G4
41, 20, // E4
]
}
// ── Game state ──
var px: u8 = 128
var py: u8 = 120
var timer: u8 = 0
var music_on: bool = false
on frame {
// Move the smiley so you can see the game is running.
if button.right { px += 1 }
if button.left { px -= 1 }
if button.down { py += 1 }
if button.up { py -= 1 }
// Input-triggered SFX.
if button.a { play LongCoin }
if button.b { play Zap }
// Start/stop the user-declared theme.
if button.start { start_music Theme }
if button.select { stop_music }
// Auto-play a builtin coin SFX every 60 frames so the e2e
// harness (which runs headless without simulated input) can
// capture a non-silent audio hash. Also toggles the music
// every 120 frames to exercise start/stop paths.
timer += 1
if timer == 30 {
play coin
}
if timer == 120 {
timer = 0
if music_on {
stop_music
music_on = false
} else {
start_music Theme
music_on = true
}
}
draw Smiley at: (px, py)
}
start Main