diff --git a/crates/gba-core/src/audio.rs b/crates/gba-core/src/audio.rs index af340e2..c57150a 100644 --- a/crates/gba-core/src/audio.rs +++ b/crates/gba-core/src/audio.rs @@ -1,5 +1,6 @@ //! Nominal Direct Sound and PSG pulse channels 1–2. No host audio output. +mod modulation; mod pulse; use pulse::Pulse; @@ -144,8 +145,8 @@ impl Audio { SOUNDCNT_H => self.control, SOUNDCNT_X => { (u16::from(self.enabled) << 7) - | u16::from(self.pulses[0].active) - | (u16::from(self.pulses[1].active) << 1) + | u16::from(self.pulses[0].modulation.active) + | (u16::from(self.pulses[1].modulation.active) << 1) } SOUNDBIAS => self.bias, 0x0400_0086 | 0x0400_008a => 0, diff --git a/crates/gba-core/src/audio/modulation.rs b/crates/gba-core/src/audio/modulation.rs new file mode 100644 index 0000000..7bc70ad --- /dev/null +++ b/crates/gba-core/src/audio/modulation.rs @@ -0,0 +1,98 @@ +//! Shared nominal PSG length, envelope, activity, and logical DAC gate. + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(super) struct Modulation { + pub(super) active: bool, + pub(super) envelope: u8, + pub(super) length_enabled: bool, + pub(super) length: u8, + pub(super) volume: u8, + envelope_ticks: u8, + envelope_running: bool, +} + +impl Modulation { + pub(super) fn write_length(&mut self, value: u8) { + self.length = 64 - (value & 63); + } + + fn dac_enabled(&self) -> bool { + self.envelope & 0xf8 != 0 + } + + fn envelope_can_run(&self) -> bool { + self.envelope & 7 != 0 + && if self.envelope & 8 != 0 { + self.volume < 15 + } else { + self.volume > 0 + } + } + + pub(super) fn write_envelope(&mut self, value: u8) { + let was_running = self.envelope_running; + self.envelope = value; + if !self.dac_enabled() { + self.active = false; + } + self.envelope_running = self.envelope_can_run(); + if !was_running && self.envelope_running { + self.envelope_ticks = value & 7; + } + } + + /// Apply length-enable edge rules and restart modulation when bit 7 is set. + /// Return the trigger strobe even with the logical DAC gate disabled. + pub(super) fn write_control(&mut self, value: u8, next_step: u8) -> bool { + let extra_length_clock = next_step & 1 != 0; + if !self.length_enabled && value & 0x40 != 0 && extra_length_clock { + self.clock_length(); + } + self.length_enabled = value & 0x40 != 0; + if value & 0x80 == 0 { + return false; + } + self.active = self.dac_enabled(); + if self.length == 0 { + self.length = 64; + if self.length_enabled && extra_length_clock { + self.length -= 1; + } + } + self.volume = self.envelope >> 4; + self.envelope_ticks = if self.envelope & 7 == 0 { + 8 + } else { + self.envelope & 7 + }; + self.envelope_running = self.envelope_can_run(); + true + } + + fn clock_length(&mut self) { + if self.length != 0 { + self.length -= 1; + if self.length == 0 { + self.active = false; + } + } + } + + pub(super) fn clock(&mut self, step: u8) { + if step & 1 == 0 && self.length_enabled { + self.clock_length(); + } + if step == 7 && self.active && self.envelope_running { + self.envelope_ticks -= 1; + if self.envelope_ticks == 0 { + if self.envelope & 8 != 0 { + self.volume += 1; + } else { + self.volume -= 1; + } + self.envelope_ticks = self.envelope & 7; + self.envelope_running = self.envelope_can_run(); + } + } + } +} diff --git a/crates/gba-core/src/audio/pulse.rs b/crates/gba-core/src/audio/pulse.rs index f853ddb..965f95b 100644 --- a/crates/gba-core/src/audio/pulse.rs +++ b/crates/gba-core/src/audio/pulse.rs @@ -1,20 +1,15 @@ -//! Shared nominal pulse oscillator and modulation units. No host-clock dependency. +//! Shared nominal pulse oscillator and sweep unit. No host-clock dependency. //! Channel 2 never receives sweep-register writes, so its sweep remains disabled. +use super::modulation::Modulation; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub(super) struct Pulse { + pub(super) modulation: Modulation, sweep: u8, duty: u8, - envelope: u8, frequency: u16, - length_enabled: bool, - length: u8, - pub(super) active: bool, phase: u8, remaining: u32, - volume: u8, - envelope_ticks: u8, - envelope_running: bool, sweep_shadow: u16, sweep_ticks: u8, sweep_running: bool, @@ -26,8 +21,8 @@ impl Pulse { match offset { 0 => self.sweep, 2 => self.duty << 6, - 3 => self.envelope, - 5 => u8::from(self.length_enabled) << 6, + 3 => self.modulation.envelope, + 5 => u8::from(self.modulation.length_enabled) << 6, _ => 0, } } @@ -36,19 +31,6 @@ impl Pulse { 16 * (2048 - u32::from(self.frequency)) } - fn dac_enabled(&self) -> bool { - self.envelope & 0xf8 != 0 - } - - fn envelope_can_run(&self) -> bool { - self.envelope & 7 != 0 - && if self.envelope & 8 != 0 { - self.volume < 15 - } else { - self.volume > 0 - } - } - fn sweep_period(&self) -> u8 { let period = (self.sweep >> 4) & 7; if period == 0 { @@ -72,57 +54,28 @@ impl Pulse { match offset { 0 => { if self.subtracted && self.sweep & 8 != 0 && value & 8 == 0 { - self.active = false; + self.modulation.active = false; } self.sweep = value & 0x7f; } 2 => { self.duty = value >> 6; - self.length = 64 - (value & 63); - } - 3 => { - let was_running = self.envelope_running; - self.envelope = value; - if !self.dac_enabled() { - self.active = false; - } - self.envelope_running = self.envelope_can_run(); - if !was_running && self.envelope_running { - self.envelope_ticks = value & 7; - } + self.modulation.write_length(value); } + 3 => self.modulation.write_envelope(value), 4 => self.frequency = (self.frequency & 0x700) | u16::from(value), 5 => { self.frequency = (self.frequency & 255) | (u16::from(value & 7) << 8); - let extra_length_clock = next_step & 1 != 0; - if !self.length_enabled && value & 0x40 != 0 && extra_length_clock { - self.clock_length(); - } - self.length_enabled = value & 0x40 != 0; - if value & 0x80 != 0 { - self.active = self.dac_enabled(); - if self.length == 0 { - self.length = 64; - if self.length_enabled && extra_length_clock { - self.length -= 1; - } - } + if self.modulation.write_control(value, next_step) { // Retrigger reloads the oscillator timer but preserves duty position. // Sub-divider alignment and hardware startup latency are not modeled. self.remaining = self.period(); - self.volume = self.envelope >> 4; - self.envelope_ticks = if self.envelope & 7 == 0 { - 8 - } else { - self.envelope & 7 - }; - self.envelope_running = self.envelope_can_run(); self.sweep_shadow = self.frequency; self.sweep_ticks = self.sweep_period(); self.sweep_running = self.sweep & 0x77 != 0; self.subtracted = false; if self.sweep & 7 != 0 && self.calculate_sweep() > 2047 { - self.active = false; + self.modulation.active = false; } } } @@ -132,7 +85,7 @@ impl Pulse { /// Batch oscillator edges until the next modulation event or bus access. pub(super) fn advance(&mut self, cycles: u32) { - if !self.active || cycles == 0 { + if !self.modulation.active || cycles == 0 { return; } if cycles < self.remaining { @@ -145,19 +98,8 @@ impl Pulse { self.remaining = period - rest % period; } - fn clock_length(&mut self) { - if self.length != 0 { - self.length -= 1; - if self.length == 0 { - self.active = false; - } - } - } - pub(super) fn clock(&mut self, step: u8) { - if step & 1 == 0 && self.length_enabled { - self.clock_length(); - } + self.modulation.clock(step); if step & 3 == 2 && self.sweep_running { self.sweep_ticks -= 1; if self.sweep_ticks == 0 { @@ -165,41 +107,29 @@ impl Pulse { if self.sweep & 0x70 != 0 { let next = self.calculate_sweep(); if next > 2047 { - self.active = false; + self.modulation.active = false; } else if self.sweep & 7 != 0 { self.frequency = next; self.sweep_shadow = next; if self.calculate_sweep() > 2047 { - self.active = false; + self.modulation.active = false; } } } } } - if step == 7 && self.active && self.envelope_running { - self.envelope_ticks -= 1; - if self.envelope_ticks == 0 { - if self.envelope & 8 != 0 { - self.volume += 1; - } else { - self.volume -= 1; - } - self.envelope_ticks = self.envelope & 7; - self.envelope_running = self.envelope_can_run(); - } - } } /// Signed pulse amplitude before PSG master volume and stereo routing. pub(super) fn sample(&self) -> i16 { - if !self.active { + if !self.modulation.active { return 0; } let high = [0x80u8, 0x81, 0xe1, 0x7e][usize::from(self.duty)] & (1 << self.phase) != 0; if high { - i16::from(self.volume) + i16::from(self.modulation.volume) } else { - -i16::from(self.volume) + -i16::from(self.modulation.volume) } } } diff --git a/crates/gba-core/src/audio/pulse/tests.rs b/crates/gba-core/src/audio/pulse/tests.rs index 5fbcdee..154754f 100644 --- a/crates/gba-core/src/audio/pulse/tests.rs +++ b/crates/gba-core/src/audio/pulse/tests.rs @@ -59,23 +59,23 @@ fn length_load_enable_extra_clock_expiry_and_zero_reload() { let mut pulse = start(1000, 2, 0xf0, 0); pulse.write(2, 63, 0); // One remaining length tick. pulse.clock(0); // Disabled length does not expire. - assert!(pulse.active); + assert!(pulse.modulation.active); pulse.write(5, 0x43, 1); // Enable before a step that does not clock length. - assert_eq!(pulse.length, 0); - assert!(!pulse.active); + assert_eq!(pulse.modulation.length, 0); + assert!(!pulse.modulation.active); pulse.write(5, 0xc3, 1); // Trigger with empty length reloads 63 in this phase. - assert_eq!(pulse.length, 63); - assert!(pulse.active); + assert_eq!(pulse.modulation.length, 63); + assert!(pulse.modulation.active); for _ in 0..62 { pulse.clock(0); } - assert!(pulse.active); + assert!(pulse.modulation.active); pulse.clock(0); - assert!(!pulse.active); + assert!(!pulse.modulation.active); pulse.clock(0); - assert_eq!(pulse.length, 0); + assert_eq!(pulse.modulation.length, 0); pulse.write(5, 0xc3, 0); - assert_eq!(pulse.length, 64); + assert_eq!(pulse.modulation.length, 64); } #[test] @@ -83,11 +83,11 @@ fn simultaneous_length_enable_and_trigger_reload_after_extra_expiry() { let mut pulse = start(1000, 0, 0xf0, 0); pulse.write(2, 63, 0); pulse.write(5, 0xc3, 1); - assert_eq!(pulse.length, 63); - assert!(pulse.active); + assert_eq!(pulse.modulation.length, 63); + assert!(pulse.modulation.active); pulse.write(2, 62, 0); pulse.write(5, 0xc3, 1); // Already enabled: do not add another extra decrement. - assert_eq!(pulse.length, 2); + assert_eq!(pulse.modulation.length, 2); } #[test] @@ -101,23 +101,23 @@ fn envelopes_step_at_selected_period_saturate_and_do_not_clear_status() { } else { 12u8.saturating_sub(tick) }; - assert_eq!(pulse.volume, expected); - assert!(pulse.active); + assert_eq!(pulse.modulation.volume, expected); + assert!(pulse.modulation.active); } } let mut pulse = start(0, 1, 0x53, 0); pulse.clock(7); pulse.clock(7); - assert_eq!(pulse.volume, 5); + assert_eq!(pulse.modulation.volume, 5); pulse.clock(7); - assert_eq!(pulse.volume, 4); + assert_eq!(pulse.modulation.volume, 4); pulse.write(3, 0xa0, 0); // Period zero holds current volume until retrigger. for _ in 0..16 { pulse.clock(7); } - assert_eq!(pulse.volume, 4); + assert_eq!(pulse.modulation.volume, 4); pulse.write(5, 0x80, 0); - assert_eq!(pulse.volume, 10); + assert_eq!(pulse.modulation.volume, 10); } #[test] @@ -125,31 +125,31 @@ fn live_envelope_configuration_retains_volume_and_dac_off_requires_retrigger() { let mut pulse = start(0, 1, 0x52, 0); pulse.clock(7); pulse.write(3, 0xa9, 0); // Change direction/period, not current volume or remaining tick. - assert_eq!(pulse.volume, 5); + assert_eq!(pulse.modulation.volume, 5); pulse.clock(7); - assert_eq!(pulse.volume, 6); + assert_eq!(pulse.modulation.volume, 6); pulse.write(3, 0, 0); - assert!(!pulse.active); + assert!(!pulse.modulation.active); assert_eq!(pulse.sample(), 0); pulse.write(3, 0x08, 0); // Rising envelope with initial zero has DAC enabled. - assert!(!pulse.active); + assert!(!pulse.modulation.active); pulse.write(5, 0x80, 0); - assert!(pulse.active); - assert_eq!(pulse.volume, 0); + assert!(pulse.modulation.active); + assert_eq!(pulse.modulation.volume, 0); pulse.write(3, 7, 0); // Period alone cannot enable DAC. pulse.write(5, 0x80, 0); - assert!(!pulse.active); + assert!(!pulse.modulation.active); } #[test] fn sweep_checks_trigger_overflow_and_second_calculation_after_update() { - assert!(!start(1500, 1, 0xf0, 0x11).active); - assert!(!start(1500, 1, 0xf0, 1).active); // Time zero still checks at trigger. + assert!(!start(1500, 1, 0xf0, 0x11).modulation.active); + assert!(!start(1500, 1, 0xf0, 1).modulation.active); // Time zero still checks at trigger. let mut pulse = start(1000, 1, 0xf0, 0x11); - assert!(pulse.active); + assert!(pulse.modulation.active); pulse.clock(2); assert_eq!(pulse.frequency, 1500); - assert!(!pulse.active); // Next calculated 2250 disables before another sweep tick. + assert!(!pulse.modulation.active); // Next calculated 2250 disables before another sweep tick. } #[test] @@ -173,17 +173,17 @@ fn sweep_zero_period_shift_zero_and_subtraction_direction_rules() { } assert_eq!(pulse.frequency, 500); // Time zero suppresses periodic changes. let mut pulse = start(1200, 1, 0xf0, 0x10); - assert!(pulse.active); // Shift zero skips trigger-time overflow check. + assert!(pulse.modulation.active); // Shift zero skips trigger-time overflow check. pulse.clock(2); - assert!(!pulse.active); // Timed overflow check still applies. + assert!(!pulse.modulation.active); // Timed overflow check still applies. let mut pulse = start(1000, 1, 0xf0, 0x19); pulse.clock(2); assert_eq!(pulse.frequency, 500); pulse.write(0, 0x11, 0); - assert!(!pulse.active); // Clearing negate after subtraction disables the channel. + assert!(!pulse.modulation.active); // Clearing negate after subtraction disables the channel. let mut pulse = start(1000, 1, 0xf0, 0x18); pulse.write(0, 0x10, 0); // No subtraction calculation has happened. - assert!(pulse.active); + assert!(pulse.modulation.active); } #[test] diff --git a/docs/architecture.md b/docs/architecture.md index a916a95..2f92cfb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,8 @@ The repository is a Cargo workspace with three crates: | `crates/gba-core/src/cpu/fetch_boundary_tests.rs` | Actual fetch-region snapshots, split refills, IWRAM lane requirements, ROM-window boundaries, and nominal timing | | `crates/gba-core/src/io.rs` | I/O registers, timers, HALT/STOP wake-up, next-event bounds, and IRQ latches | | `crates/gba-core/src/audio.rs` | Direct Sound state, PSG sequencer, idle channel/wave banks, and instantaneous stereo mixing | -| `crates/gba-core/src/audio/pulse.rs` | Shared pulse oscillator, length, envelope, and status; sweep is mapped only for channel 1 | +| `crates/gba-core/src/audio/pulse.rs` | Shared pulse oscillator; sweep is mapped only for channel 1 | +| `crates/gba-core/src/audio/modulation.rs` | Shared PSG length, envelope, logical DAC gate, and activity state | | `crates/gba-core/src/audio/pulse/tests.rs` | Exhaustive frequency/duty arithmetic and original modulation edge tests | | `crates/gba-core/tests/pulse_sound.rs` | Register masks, stereo mixing, sequencer clocks, idle modes, batching, and explicit limits | | `crates/gba-core/tests/pulse_sound/channel2.rs` | Channel 2 mapping, register gaps, independent state, shared clocks, and sum-before-rounding mixing |