mirror of
https://github.com/imjasonh/nescript
synced 2026-07-10 17:52:51 +00:00
audio: per-frame pitch envelopes for pulse SFX
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
This commit is contained in:
parent
db3a4adc57
commit
9878b7d87d
12 changed files with 449 additions and 31 deletions
|
|
@ -131,6 +131,16 @@ pub const AUDIO_NOISE_COUNTER: u16 = 0x07F2;
|
|||
pub const AUDIO_TRIANGLE_PTR_LO: u16 = 0x07F3;
|
||||
pub const AUDIO_TRIANGLE_PTR_HI: u16 = 0x07F4;
|
||||
pub const AUDIO_TRIANGLE_COUNTER: u16 = 0x07F5;
|
||||
/// Pulse-1 sfx per-frame pitch envelope pointer. Only populated
|
||||
/// (and only read by the audio tick) in programs that declare at
|
||||
/// least one sfx with a varying-pitch `pitch:` array; programs
|
||||
/// that stick to scalar `pitch:` keep their byte-for-byte
|
||||
/// pre-pitch-envelope ROM output. The tick treats a zero
|
||||
/// high-byte as "no pitch update for the currently-playing sfx",
|
||||
/// which lets a single program mix sfx with and without pitch
|
||||
/// envelopes.
|
||||
pub const AUDIO_SFX_PITCH_PTR_LO: u16 = 0x07F6;
|
||||
pub const AUDIO_SFX_PITCH_PTR_HI: u16 = 0x07F7;
|
||||
|
||||
/// Generate the NES hardware initialization sequence.
|
||||
/// This runs at RESET and sets up the hardware before user code.
|
||||
|
|
@ -778,7 +788,11 @@ pub fn gen_multiply() -> Vec<Instruction> {
|
|||
/// instruction stream as before. The linker decides whether to
|
||||
/// enable each by scanning for the `__noise_used` and
|
||||
/// `__triangle_used` marker labels emitted by the IR codegen.
|
||||
pub fn gen_audio_tick(has_noise: bool, has_triangle: bool) -> Vec<Instruction> {
|
||||
pub fn gen_audio_tick(
|
||||
has_noise: bool,
|
||||
has_triangle: bool,
|
||||
has_sfx_pitch: bool,
|
||||
) -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
out.push(Instruction::new(NOP, AM::Label("__audio_tick".into())));
|
||||
|
|
@ -818,6 +832,61 @@ pub fn gen_audio_tick(has_noise: bool, has_triangle: bool) -> Vec<Instruction> {
|
|||
NOP,
|
||||
AM::Label("__audio_sfx_ptr_ok".into()),
|
||||
));
|
||||
// Optional per-frame pitch update. Only emitted in programs
|
||||
// that declare at least one varying-pitch sfx, gated on the
|
||||
// `__sfx_pitch_used` codegen marker. The block reads a byte
|
||||
// through (AUDIO_SFX_PITCH_PTR),Y, writes it to `$4002`
|
||||
// (pulse-1 period low), then advances the pointer in
|
||||
// lockstep with the volume envelope above. A zero high byte
|
||||
// in the pointer is treated as "no pitch envelope on the
|
||||
// currently-playing sfx" so a program can mix scalar-pitch
|
||||
// and varying-pitch sfx without the latter clobbering the
|
||||
// former when it isn't playing.
|
||||
//
|
||||
// The pointer lives in main RAM (no zero-page slot pressure)
|
||||
// and is copied into ZP scratch $02/$03 for the indirect
|
||||
// read because the 6502 has no `(abs),Y` mode — the same
|
||||
// technique used by `gen_noise_tick` / `gen_triangle_tick`.
|
||||
if has_sfx_pitch {
|
||||
// Bail out if the high byte is zero — sentinel for "the
|
||||
// currently-playing sfx has no pitch envelope".
|
||||
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI)));
|
||||
out.push(Instruction::new(
|
||||
BEQ,
|
||||
AM::LabelRelative("__audio_sfx_pitch_done".into()),
|
||||
));
|
||||
// Stash main-RAM pointer in ZP scratch $02/$03.
|
||||
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_SFX_PITCH_PTR_LO)));
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
|
||||
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI)));
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
|
||||
out.push(Instruction::new(LDY, AM::Immediate(0)));
|
||||
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
|
||||
// Zero pitch byte? Treat it as the matching mute sentinel
|
||||
// (volume envelope's zero will already mute on the next
|
||||
// tick) and skip the period write so we don't yank pulse-1
|
||||
// to a 0 period for one frame before muting.
|
||||
out.push(Instruction::new(
|
||||
BEQ,
|
||||
AM::LabelRelative("__audio_sfx_pitch_advance".into()),
|
||||
));
|
||||
out.push(Instruction::new(STA, AM::Absolute(0x4002)));
|
||||
out.push(Instruction::new(
|
||||
NOP,
|
||||
AM::Label("__audio_sfx_pitch_advance".into()),
|
||||
));
|
||||
// Advance the main-RAM pointer 1 byte.
|
||||
out.push(Instruction::new(INC, AM::Absolute(AUDIO_SFX_PITCH_PTR_LO)));
|
||||
out.push(Instruction::new(
|
||||
BNE,
|
||||
AM::LabelRelative("__audio_sfx_pitch_done".into()),
|
||||
));
|
||||
out.push(Instruction::new(INC, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI)));
|
||||
out.push(Instruction::new(
|
||||
NOP,
|
||||
AM::Label("__audio_sfx_pitch_done".into()),
|
||||
));
|
||||
}
|
||||
out.push(Instruction::new(NOP, AM::Label("__audio_sfx_done".into())));
|
||||
|
||||
// ── Music tick ──
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue