mirror of
https://github.com/imjasonh/nescript
synced 2026-07-09 09:18:01 +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
|
|
@ -129,6 +129,8 @@ impl Parser {
|
|||
let mut sprites = Vec::new();
|
||||
let mut palettes = Vec::new();
|
||||
let mut backgrounds = Vec::new();
|
||||
let mut sfx = Vec::new();
|
||||
let mut music = Vec::new();
|
||||
let mut banks = Vec::new();
|
||||
let mut start_state = None;
|
||||
let mut on_frame = None;
|
||||
|
|
@ -169,6 +171,12 @@ impl Parser {
|
|||
TokenKind::KwBackground => {
|
||||
backgrounds.push(self.parse_background_decl()?);
|
||||
}
|
||||
TokenKind::KwSfx => {
|
||||
sfx.push(self.parse_sfx_decl()?);
|
||||
}
|
||||
TokenKind::KwMusic => {
|
||||
music.push(self.parse_music_decl()?);
|
||||
}
|
||||
TokenKind::KwBank => {
|
||||
banks.push(self.parse_bank_decl()?);
|
||||
}
|
||||
|
|
@ -237,6 +245,8 @@ impl Parser {
|
|||
sprites,
|
||||
palettes,
|
||||
backgrounds,
|
||||
sfx,
|
||||
music,
|
||||
banks,
|
||||
start_state,
|
||||
span,
|
||||
|
|
@ -762,6 +772,275 @@ impl Parser {
|
|||
})
|
||||
}
|
||||
|
||||
// ── SFX / Music declarations ──
|
||||
|
||||
/// `sfx Name { duty: N, pitch: [..], volume: [..] }`. Pitch and
|
||||
/// volume arrays must be the same length — each index is one
|
||||
/// frame of the envelope. Duty is optional, default 2 (50%).
|
||||
fn parse_sfx_decl(&mut self) -> Result<SfxDecl, Diagnostic> {
|
||||
let start = self.current_span();
|
||||
self.expect(&TokenKind::KwSfx)?;
|
||||
let (name, _) = self.expect_ident()?;
|
||||
self.expect(&TokenKind::LBrace)?;
|
||||
|
||||
let mut duty: u8 = 2;
|
||||
let mut pitch: Option<Vec<u8>> = None;
|
||||
let mut volume: Option<Vec<u8>> = None;
|
||||
|
||||
while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof {
|
||||
let (key, key_span) = self.expect_ident()?;
|
||||
self.expect(&TokenKind::Colon)?;
|
||||
match key.as_str() {
|
||||
"duty" => {
|
||||
duty = self.parse_u8_literal("duty")?;
|
||||
if duty > 3 {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("sfx 'duty' must be 0-3, got {duty}"),
|
||||
key_span,
|
||||
));
|
||||
}
|
||||
}
|
||||
"pitch" => {
|
||||
pitch = Some(self.parse_byte_array("pitch")?);
|
||||
}
|
||||
"volume" => {
|
||||
let vals = self.parse_byte_array("volume")?;
|
||||
for v in &vals {
|
||||
if *v > 15 {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("sfx 'volume' entries must be 0-15, got {v}"),
|
||||
key_span,
|
||||
));
|
||||
}
|
||||
}
|
||||
volume = Some(vals);
|
||||
}
|
||||
_ => {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("unknown sfx property '{key}'"),
|
||||
key_span,
|
||||
));
|
||||
}
|
||||
}
|
||||
if *self.peek() == TokenKind::Comma {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
self.expect(&TokenKind::RBrace)?;
|
||||
|
||||
let pitch = pitch.ok_or_else(|| {
|
||||
Diagnostic::error(ErrorCode::E0201, "sfx requires 'pitch' property", start)
|
||||
})?;
|
||||
let volume = volume.ok_or_else(|| {
|
||||
Diagnostic::error(ErrorCode::E0201, "sfx requires 'volume' property", start)
|
||||
})?;
|
||||
|
||||
if pitch.is_empty() {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
"sfx 'pitch' array must have at least one frame",
|
||||
start,
|
||||
));
|
||||
}
|
||||
if pitch.len() != volume.len() {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!(
|
||||
"sfx 'pitch' and 'volume' arrays must have the same length \
|
||||
(pitch has {}, volume has {})",
|
||||
pitch.len(),
|
||||
volume.len()
|
||||
),
|
||||
start,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(SfxDecl {
|
||||
name,
|
||||
duty,
|
||||
pitch,
|
||||
volume,
|
||||
span: Span::new(start.file_id, start.start, self.current_span().end),
|
||||
})
|
||||
}
|
||||
|
||||
/// `music Name { duty: N, volume: N, repeat: true|false, notes: [..] }`.
|
||||
/// Notes are encoded as a flat list: `[pitch1, dur1, pitch2, dur2, ...]`.
|
||||
/// Pitch 0 is a rest; nonzero pitches are indices into the builtin
|
||||
/// period table (1 = C1, 60 = B5). Duration is in frames (1-255).
|
||||
fn parse_music_decl(&mut self) -> Result<MusicDecl, Diagnostic> {
|
||||
let start = self.current_span();
|
||||
self.expect(&TokenKind::KwMusic)?;
|
||||
let (name, _) = self.expect_ident()?;
|
||||
self.expect(&TokenKind::LBrace)?;
|
||||
|
||||
let mut duty: u8 = 2;
|
||||
let mut volume: u8 = 10;
|
||||
let mut loops: bool = true;
|
||||
let mut notes: Option<Vec<MusicNote>> = None;
|
||||
|
||||
while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof {
|
||||
let (key, key_span) = self.expect_ident()?;
|
||||
self.expect(&TokenKind::Colon)?;
|
||||
match key.as_str() {
|
||||
"duty" => {
|
||||
duty = self.parse_u8_literal("duty")?;
|
||||
if duty > 3 {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("music 'duty' must be 0-3, got {duty}"),
|
||||
key_span,
|
||||
));
|
||||
}
|
||||
}
|
||||
"volume" => {
|
||||
volume = self.parse_u8_literal("volume")?;
|
||||
if volume > 15 {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("music 'volume' must be 0-15, got {volume}"),
|
||||
key_span,
|
||||
));
|
||||
}
|
||||
}
|
||||
"repeat" => match self.peek().clone() {
|
||||
TokenKind::BoolLiteral(b) => {
|
||||
self.advance();
|
||||
loops = b;
|
||||
}
|
||||
_ => {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("expected bool for 'repeat', got '{}'", self.peek()),
|
||||
key_span,
|
||||
));
|
||||
}
|
||||
},
|
||||
"notes" => {
|
||||
let flat = self.parse_byte_array("notes")?;
|
||||
if flat.len() % 2 != 0 {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
"music 'notes' must have an even number of entries \
|
||||
(pitch, duration, pitch, duration, ...)",
|
||||
key_span,
|
||||
));
|
||||
}
|
||||
let mut n = Vec::with_capacity(flat.len() / 2);
|
||||
for pair in flat.chunks(2) {
|
||||
let pitch = pair[0];
|
||||
let duration = pair[1];
|
||||
if duration == 0 {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
"music note duration must be >= 1",
|
||||
key_span,
|
||||
));
|
||||
}
|
||||
if pitch > 60 {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("music note pitch must be 0 (rest) or 1-60, got {pitch}"),
|
||||
key_span,
|
||||
));
|
||||
}
|
||||
n.push(MusicNote { pitch, duration });
|
||||
}
|
||||
notes = Some(n);
|
||||
}
|
||||
_ => {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("unknown music property '{key}'"),
|
||||
key_span,
|
||||
));
|
||||
}
|
||||
}
|
||||
if *self.peek() == TokenKind::Comma {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
self.expect(&TokenKind::RBrace)?;
|
||||
|
||||
let notes = notes.ok_or_else(|| {
|
||||
Diagnostic::error(ErrorCode::E0201, "music requires 'notes' property", start)
|
||||
})?;
|
||||
|
||||
if notes.is_empty() {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
"music 'notes' must contain at least one (pitch, duration) pair",
|
||||
start,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(MusicDecl {
|
||||
name,
|
||||
duty,
|
||||
volume,
|
||||
loops,
|
||||
notes,
|
||||
span: Span::new(start.file_id, start.start, self.current_span().end),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a `[byte, byte, ...]` array. Used by sfx/music property
|
||||
/// parsing — the main `parse_asset_source` also does this, but
|
||||
/// without the array-literal-only restriction we want here.
|
||||
fn parse_byte_array(&mut self, prop: &str) -> Result<Vec<u8>, Diagnostic> {
|
||||
self.expect(&TokenKind::LBracket)?;
|
||||
let mut out = Vec::new();
|
||||
while *self.peek() != TokenKind::RBracket && *self.peek() != TokenKind::Eof {
|
||||
if let TokenKind::IntLiteral(v) = self.peek().clone() {
|
||||
self.advance();
|
||||
if v > 0xFF {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("'{prop}' entries must fit in a u8, got {v}"),
|
||||
self.current_span(),
|
||||
));
|
||||
}
|
||||
out.push(v as u8);
|
||||
} else {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("expected byte value in '{prop}', found '{}'", self.peek()),
|
||||
self.current_span(),
|
||||
));
|
||||
}
|
||||
if *self.peek() == TokenKind::Comma {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
self.expect(&TokenKind::RBracket)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Parse a single u8 integer literal for a scalar property.
|
||||
fn parse_u8_literal(&mut self, prop: &str) -> Result<u8, Diagnostic> {
|
||||
match self.peek().clone() {
|
||||
TokenKind::IntLiteral(v) => {
|
||||
self.advance();
|
||||
if v > 0xFF {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("'{prop}' must fit in a u8, got {v}"),
|
||||
self.current_span(),
|
||||
));
|
||||
}
|
||||
Ok(v as u8)
|
||||
}
|
||||
other => Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("expected integer for '{prop}', got '{other}'"),
|
||||
self.current_span(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_asset_source(&mut self) -> Result<AssetSource, Diagnostic> {
|
||||
match self.peek() {
|
||||
TokenKind::At => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue