[32X/GBA] add additional GBA interpolation options, and improve 32X PWM resampling quality

for GBA, added cubic Hermite option as well as an option to low-pass PSG output when using enhanced interpolation

I really do not know what I was thinking when I originally wrote this cubic resampling code
This commit is contained in:
jsgroth 2025-11-01 16:04:52 -05:00
parent 339183c9a5
commit 59ffc61d53
10 changed files with 250 additions and 86 deletions

View File

@ -29,6 +29,7 @@ use thiserror::Error;
#[derive(Debug, Clone, Copy, Encode, Decode, ConfigDisplay)]
pub struct GbaAudioConfig {
pub audio_interpolation: GbaAudioInterpolation,
pub psg_low_pass: bool,
pub pulse_1_enabled: bool,
pub pulse_2_enabled: bool,
pub wavetable_enabled: bool,
@ -41,6 +42,7 @@ impl Default for GbaAudioConfig {
fn default() -> Self {
Self {
audio_interpolation: GbaAudioInterpolation::default(),
psg_low_pass: false,
pulse_1_enabled: true,
pulse_2_enabled: true,
wavetable_enabled: true,

View File

@ -214,9 +214,14 @@ impl AudioResampler {
GbaAudioInterpolation::NearestNeighbor => {
Self::Basic(Box::new(BasicResampler::new(clock_shift, output_frequency)))
}
GbaAudioInterpolation::WindowedSinc => Self::Interpolating(Box::new(
InterpolatingResampler::new(output_frequency, pcm_frequencies),
)),
GbaAudioInterpolation::CubicHermite | GbaAudioInterpolation::WindowedSinc => {
Self::Interpolating(Box::new(InterpolatingResampler::new(
config.audio_interpolation,
config.psg_low_pass,
output_frequency,
pcm_frequencies,
)))
}
}
}
@ -363,7 +368,7 @@ impl Apu {
self.resampler.push_mixed_sample([sample_l, sample_r]);
}
}
GbaAudioInterpolation::WindowedSinc => {
GbaAudioInterpolation::CubicHermite | GbaAudioInterpolation::WindowedSinc => {
for _ in 0..pwm_samples_elapsed * psg_ticks {
self.psg.tick_1mhz(self.enabled);
self.resampler.push_psg(self.psg.sample(self.config.psg_channels_enabled()));
@ -750,6 +755,8 @@ impl Apu {
self.timer_frequencies[self.pcm_b.timer as usize],
],
);
} else if let AudioResampler::Interpolating(resampler) = &mut self.resampler {
resampler.update_psg_low_pass(config.psg_low_pass);
}
}

View File

@ -2,9 +2,14 @@
use crate::apu::PwmClockShift;
use bincode::{Decode, Encode};
use dsp::design::FilterType;
use dsp::iir::SecondOrderIirFilter;
use dsp::sinc::{PerformanceSincResampler, QualitySincResampler};
use gba_config::GbaAudioInterpolation;
use jgenesis_common::audio::CubicResampler;
use jgenesis_common::frontend::AudioOutput;
use std::array;
use std::cmp::Ordering;
#[derive(Debug, Clone, Encode, Decode)]
pub struct BasicResampler {
@ -45,29 +50,144 @@ impl BasicResampler {
}
}
#[derive(Debug, Clone, Encode, Decode)]
enum EnhancedResampler {
Cubic(CubicResampler<1>),
Sinc(QualitySincResampler<1>),
}
impl EnhancedResampler {
fn new(
interpolation: GbaAudioInterpolation,
source_frequency: f64,
output_frequency: u64,
) -> Self {
match interpolation {
GbaAudioInterpolation::WindowedSinc => {
Self::Sinc(QualitySincResampler::new(source_frequency, output_frequency as f64))
}
_ => Self::Cubic(CubicResampler::new(source_frequency, output_frequency)),
}
}
fn collect(&mut self, sample: f64) {
match self {
Self::Cubic(resampler) => resampler.collect_sample([sample]),
Self::Sinc(resampler) => resampler.collect([sample]),
}
}
fn update_source_frequency(&mut self, source_frequency: f64) {
match self {
Self::Cubic(resampler) => resampler.update_source_frequency(source_frequency),
Self::Sinc(resampler) => resampler.update_source_frequency(source_frequency),
}
}
fn update_output_frequency(&mut self, output_frequency: u64) {
match self {
Self::Cubic(resampler) => resampler.update_output_frequency(output_frequency),
Self::Sinc(resampler) => resampler.update_output_frequency(output_frequency as f64),
}
}
fn output_buffer_pop_front(&mut self) -> Option<[f64; 1]> {
match self {
Self::Cubic(resampler) => resampler.output_buffer_pop_front(),
Self::Sinc(resampler) => resampler.output_buffer_pop_front(),
}
}
}
// PSG source frequency is actually 2 MHz (2^21), but this implementation only runs it at 1 MHz
const PSG_SOURCE_FREQUENCY: f64 = 1_048_576.0;
#[derive(Debug, Clone, Encode, Decode)]
struct PsgLowPassFilter {
max_pcm_frequency: Option<f64>,
psg_low_pass: bool,
filters: Option<(SecondOrderIirFilter, SecondOrderIirFilter)>,
}
impl PsgLowPassFilter {
fn new(psg_low_pass: bool, pcm_frequencies: [Option<f64>; 2]) -> Self {
if !psg_low_pass {
return Self { max_pcm_frequency: None, psg_low_pass, filters: None };
}
let max_pcm_frequency = f64_option_max(pcm_frequencies);
let filters = max_pcm_frequency.map(|max_pcm_frequency| {
let lpf_cutoff = max_pcm_frequency * 0.45;
(Self::new_filter(lpf_cutoff), Self::new_filter(lpf_cutoff))
});
Self { max_pcm_frequency, psg_low_pass, filters }
}
fn new_filter(cutoff: f64) -> SecondOrderIirFilter {
dsp::design::butterworth(cutoff, PSG_SOURCE_FREQUENCY, FilterType::LowPass)
}
fn filter(&mut self, sample: [f64; 2]) -> [f64; 2] {
self.filters
.as_mut()
.map(|(filter_l, filter_r)| [filter_l.filter(sample[0]), filter_r.filter(sample[1])])
.unwrap_or(sample)
}
fn reload(&mut self, psg_low_pass: bool, pcm_frequencies: [Option<f64>; 2]) {
if psg_low_pass != self.psg_low_pass
|| f64_option_max(pcm_frequencies) != self.max_pcm_frequency
{
*self = Self::new(psg_low_pass, pcm_frequencies);
}
}
}
fn f64_option_max(values: impl IntoIterator<Item = Option<f64>>) -> Option<f64> {
// Less is arbitrary here - if there's ever a NaN frequency, there are bigger problems
values.into_iter().flatten().max_by(|&a, &b| a.partial_cmp(&b).unwrap_or(Ordering::Less))
}
#[derive(Debug, Clone, Encode, Decode)]
pub struct InterpolatingResampler {
interpolation: GbaAudioInterpolation,
psg_low_pass: bool,
pcm_frequencies: [Option<f64>; 2],
pcm_resamplers: [Option<QualitySincResampler<1>>; 2],
pcm_resamplers: [Option<EnhancedResampler>; 2],
pcm_samples: [i8; 2],
psg_lpf: PsgLowPassFilter,
psg_resampler: PerformanceSincResampler<2>,
output_frequency: f64,
}
impl InterpolatingResampler {
pub fn new(output_frequency: u64, pcm_frequencies: [Option<f64>; 2]) -> Self {
pub fn new(
interpolation: GbaAudioInterpolation,
psg_low_pass: bool,
output_frequency: u64,
pcm_frequencies: [Option<f64>; 2],
) -> Self {
Self {
interpolation,
psg_low_pass,
pcm_frequencies,
pcm_resamplers: pcm_frequencies.map(|frequency| {
frequency
.map(|frequency| QualitySincResampler::new(frequency, output_frequency as f64))
frequency.map(|frequency| {
EnhancedResampler::new(interpolation, frequency, output_frequency)
})
}),
pcm_samples: array::from_fn(|_| 0),
psg_lpf: PsgLowPassFilter::new(psg_low_pass, pcm_frequencies),
psg_resampler: PerformanceSincResampler::new((1 << 20).into(), output_frequency as f64),
output_frequency: output_frequency as f64,
}
}
pub fn update_psg_low_pass(&mut self, psg_low_pass: bool) {
self.psg_lpf.reload(psg_low_pass, self.pcm_frequencies);
}
pub fn push_pcm_a(&mut self, sample: i8) {
self.push_pcm(0, sample);
}
@ -80,7 +200,7 @@ impl InterpolatingResampler {
self.pcm_samples[i] = sample;
if let Some(resampler) = &mut self.pcm_resamplers[i] {
resampler.collect([f64::from(sample) / 128.0]);
resampler.collect(f64::from(sample) / 128.0);
}
}
@ -93,7 +213,10 @@ impl InterpolatingResampler {
}
pub fn push_psg(&mut self, sample: (i16, i16)) {
self.psg_resampler.collect([f64::from(sample.0) / 512.0, f64::from(sample.1) / 512.0]);
let sample =
self.psg_lpf.filter([f64::from(sample.0) / 512.0, f64::from(sample.1) / 512.0]);
self.psg_resampler.collect(sample);
}
pub fn update_pcm_a_frequency(&mut self, frequency: Option<f64>) {
@ -112,9 +235,24 @@ impl InterpolatingResampler {
return;
}
// TODO correct mid-sample position
self.pcm_resamplers[i] =
frequency.map(|frequency| QualitySincResampler::new(frequency, self.output_frequency));
match frequency {
Some(frequency) => {
self.pcm_resamplers[i]
.get_or_insert_with(|| {
EnhancedResampler::new(
self.interpolation,
frequency,
self.output_frequency as u64,
)
})
.update_source_frequency(frequency);
}
None => {
self.pcm_resamplers[i] = None;
}
}
self.psg_lpf.reload(self.psg_low_pass, self.pcm_frequencies);
}
pub fn update_output_frequency(&mut self, output_frequency: u64) {
@ -123,7 +261,7 @@ impl InterpolatingResampler {
self.psg_resampler.update_output_frequency(self.output_frequency);
for resampler in self.pcm_resamplers.iter_mut().flatten() {
resampler.update_output_frequency(self.output_frequency);
resampler.update_output_frequency(output_frequency);
}
}
@ -143,11 +281,11 @@ impl InterpolatingResampler {
let mut pcm_a = self.pcm_resamplers[0]
.as_mut()
.and_then(QualitySincResampler::output_buffer_pop_front)
.and_then(EnhancedResampler::output_buffer_pop_front)
.unwrap_or_else(|| [f64::from(self.pcm_samples[0]) / 128.0])[0];
let mut pcm_b = self.pcm_resamplers[1]
.as_mut()
.and_then(QualitySincResampler::output_buffer_pop_front)
.and_then(EnhancedResampler::output_buffer_pop_front)
.unwrap_or_else(|| [f64::from(self.pcm_samples[1]) / 128.0])[0];
if pcm_volume_shifts[0] {

View File

@ -99,7 +99,7 @@ impl PwmAudioFilter {
#[derive(Debug, Clone, Encode, Decode)]
pub struct PwmResampler {
filter: PwmAudioFilter,
resampler: CubicResampler,
resampler: CubicResampler<2>,
output: VecDeque<(f64, f64)>,
}
@ -107,14 +107,14 @@ impl PwmResampler {
pub fn new(config: &Sega32XEmulatorConfig, output_frequency: u64) -> Self {
Self {
filter: PwmAudioFilter::new(config, output_frequency),
resampler: CubicResampler::new(22000.0),
resampler: CubicResampler::new(22000.0, output_frequency),
output: VecDeque::with_capacity(48000 / 30),
}
}
pub fn collect_sample(&mut self, sample_l: f64, sample_r: f64) {
self.resampler.collect_sample(sample_l, sample_r);
while let Some((output_l, output_r)) = self.resampler.output_buffer_pop_front() {
self.resampler.collect_sample([sample_l, sample_r]);
while let Some([output_l, output_r]) = self.resampler.output_buffer_pop_front() {
let (output_l, output_r) = self.filter.filter((output_l, output_r));
self.output.push_back((output_l, output_r));
}

View File

@ -1,84 +1,72 @@
use crate::audio::{
DEFAULT_OUTPUT_FREQUENCY, RESAMPLE_SCALING_FACTOR, interpolate_cubic_hermite_6p,
};
use crate::audio::{RESAMPLE_SCALING_FACTOR, interpolate_cubic_hermite_6p};
use bincode::{Decode, Encode};
use std::array;
use std::collections::VecDeque;
const BUFFER_LEN: usize = 6;
#[derive(Debug, Clone, Encode, Decode)]
pub struct CubicResampler {
pub struct CubicResampler<const CHANNELS: usize> {
scaled_source_frequency: u64,
output_frequency: u64,
cycle_counter_product: u64,
scaled_x_counter: u64,
input_samples_l: VecDeque<f64>,
input_samples_r: VecDeque<f64>,
output_samples: VecDeque<(f64, f64)>,
input_samples: VecDeque<[f64; CHANNELS]>,
output_samples: VecDeque<[f64; CHANNELS]>,
}
impl CubicResampler {
impl<const CHANNELS: usize> CubicResampler<CHANNELS> {
#[must_use]
pub fn new(source_frequency: f64) -> Self {
pub fn new(source_frequency: f64, output_frequency: u64) -> Self {
let scaled_source_frequency = scale_source_frequency(source_frequency);
let mut resampler = Self {
scaled_source_frequency,
output_frequency: DEFAULT_OUTPUT_FREQUENCY,
output_frequency,
cycle_counter_product: 0,
scaled_x_counter: 0,
input_samples_l: VecDeque::with_capacity(2 * BUFFER_LEN),
input_samples_r: VecDeque::with_capacity(2 * BUFFER_LEN),
input_samples: VecDeque::with_capacity(2 * BUFFER_LEN),
output_samples: VecDeque::with_capacity(48000 / 60 * 2),
};
resampler.input_samples_l.extend([0.0; BUFFER_LEN]);
resampler.input_samples_r.extend([0.0; BUFFER_LEN]);
resampler.input_samples.extend([[0.0; CHANNELS]; BUFFER_LEN]);
resampler
}
pub fn collect_sample(&mut self, sample_l: f64, sample_r: f64) {
self.input_samples_l.push_back(sample_l);
self.input_samples_r.push_back(sample_r);
pub fn collect_sample(&mut self, samples: [f64; CHANNELS]) {
self.input_samples.push_back(samples);
let scaled_output_frequency = self.output_frequency * RESAMPLE_SCALING_FACTOR;
self.cycle_counter_product += scaled_output_frequency;
while self.cycle_counter_product >= self.scaled_source_frequency {
self.cycle_counter_product -= self.scaled_source_frequency;
// Having fewer than N samples in the buffers _shouldn't_ happen, but don't crash if it does
while self.input_samples.len() < BUFFER_LEN {
self.input_samples
.push_front(self.input_samples.front().copied().unwrap_or([0.0; CHANNELS]));
}
let x = (self.scaled_x_counter as f64) / (scaled_output_frequency as f64);
let output: [f64; CHANNELS] = array::from_fn(|channel| {
let samples: [f64; 6] = array::from_fn(|i| self.input_samples[i][channel]);
interpolate_cubic_hermite_6p(samples, x).clamp(-1.0, 1.0)
});
self.output_samples.push_back(output);
self.scaled_x_counter += self.scaled_source_frequency;
while self.scaled_x_counter >= scaled_output_frequency {
self.scaled_x_counter -= scaled_output_frequency;
self.input_samples_l.pop_front();
self.input_samples_r.pop_front();
self.input_samples.pop_front();
}
// Having fewer than N samples in the buffers _shouldn't_ happen, but don't crash if it does
while self.input_samples_l.len() < BUFFER_LEN {
self.input_samples_l.push_back(0.0);
}
while self.input_samples_r.len() < BUFFER_LEN {
self.input_samples_r.push_back(0.0);
}
let x = (self.scaled_x_counter as f64) / (scaled_output_frequency as f64);
let output_l =
interpolate_cubic_hermite_6p(first_six_samples(&self.input_samples_l), x)
.clamp(-1.0, 1.0);
let output_r =
interpolate_cubic_hermite_6p(first_six_samples(&self.input_samples_r), x)
.clamp(-1.0, 1.0);
self.output_samples.push_back((output_l, output_r));
}
// Having more than N+1 samples in the buffers here also _shouldn't_ happen, but do something reasonable if it does
while self.input_samples_l.len() > BUFFER_LEN + 1 {
self.input_samples_l.pop_front();
}
while self.input_samples_r.len() > BUFFER_LEN + 1 {
self.input_samples_r.pop_front();
while self.input_samples.len() > BUFFER_LEN + 1 {
self.input_samples.pop_front();
}
}
@ -88,7 +76,7 @@ impl CubicResampler {
}
#[must_use]
pub fn output_buffer_pop_front(&mut self) -> Option<(f64, f64)> {
pub fn output_buffer_pop_front(&mut self) -> Option<[f64; CHANNELS]> {
self.output_samples.pop_front()
}
@ -120,10 +108,6 @@ impl CubicResampler {
}
}
fn first_six_samples(buffer: &VecDeque<f64>) -> [f64; 6] {
[buffer[0], buffer[1], buffer[2], buffer[3], buffer[4], buffer[5]]
}
fn scale_source_frequency(source_frequency: f64) -> u64 {
(source_frequency * RESAMPLE_SCALING_FACTOR as f64).round() as u64
}

View File

@ -193,5 +193,6 @@ impl GbaSaveMemory {
pub enum GbaAudioInterpolation {
#[default]
NearestNeighbor,
CubicHermite,
WindowedSinc,
}

View File

@ -175,26 +175,46 @@ impl App {
let mut open = true;
Window::new("GBA Audio Settings").open(&mut open).resizable(false).show(ctx, |ui| {
let rect = ui
.group(|ui| {
ui.label("Audio interpolation");
ui.group(|ui| {
let rect = ui
.scope(|ui| {
ui.label("Audio interpolation");
ui.radio_value(
&mut self.config.game_boy_advance.audio_interpolation,
GbaAudioInterpolation::NearestNeighbor,
"Nearest neighbor (Native)",
);
ui.radio_value(
&mut self.config.game_boy_advance.audio_interpolation,
GbaAudioInterpolation::WindowedSinc,
"Windowed sinc (Anti-aliased)",
);
})
.response
.interact_rect;
if ui.rect_contains_pointer(rect) {
self.state.help_text.insert(WINDOW, helptext::AUDIO_INTERPOLATION);
}
for (value, label) in [
(GbaAudioInterpolation::NearestNeighbor, "Nearest neighbor (Native)"),
(GbaAudioInterpolation::CubicHermite, "Cubic Hermite"),
(GbaAudioInterpolation::WindowedSinc, "Windowed sinc"),
] {
ui.radio_value(
&mut self.config.game_boy_advance.audio_interpolation,
value,
label,
);
}
})
.response
.interact_rect;
if ui.rect_contains_pointer(rect) {
self.state.help_text.insert(WINDOW, helptext::AUDIO_INTERPOLATION);
}
let rect = ui
.add_enabled_ui(
self.config.game_boy_advance.audio_interpolation
!= GbaAudioInterpolation::NearestNeighbor,
|ui| {
ui.checkbox(
&mut self.config.game_boy_advance.psg_low_pass,
"Apply low-pass filter to PSG",
);
},
)
.response
.interact_rect;
if ui.rect_contains_pointer(rect) {
self.state.help_text.insert(WINDOW, helptext::PSG_LPF);
}
});
let rect = ui
.group(|ui| {

View File

@ -46,7 +46,16 @@ pub const AUDIO_INTERPOLATION: HelpText = HelpText {
heading: "Audio Interpolation",
text: &[
"Optionally perform much higher quality audio interpolation than actual hardware does.",
"Sinc interpolation significantly reduces audio aliasing and noise, but it can also make audio sound more muffled.",
"Sinc interpolation reduces audio aliasing and noise more than cubic interpolation does, but it also usually sounds more muffled.",
],
};
pub const PSG_LPF: HelpText = HelpText {
heading: "PSG Low-Pass Filter",
text: &[
"Optionally apply a low-pass filter to PSG audio output when using enhanced interpolation.",
"In games that use both the PCM and PSG channels, this makes the PSG channels stand out less.",
"The LPF cutoff frequency is set dynamically based on the PCM sample rate.",
],
};

View File

@ -20,6 +20,8 @@ pub struct GameBoyAdvanceAppConfig {
pub forced_save_memory_type: Option<GbaSaveMemory>,
#[serde(default)]
pub audio_interpolation: GbaAudioInterpolation,
#[serde(default)]
pub psg_low_pass: bool,
#[serde(default = "true_fn")]
pub pulse_1_enabled: bool,
#[serde(default = "true_fn")]

View File

@ -593,6 +593,7 @@ impl AppConfigExt for AppConfig {
forced_save_memory_type: self.game_boy_advance.forced_save_memory_type,
audio: GbaAudioConfig {
audio_interpolation: self.game_boy_advance.audio_interpolation,
psg_low_pass: self.game_boy_advance.psg_low_pass,
pulse_1_enabled: self.game_boy_advance.pulse_1_enabled,
pulse_2_enabled: self.game_boy_advance.pulse_2_enabled,
wavetable_enabled: self.game_boy_advance.wavetable_enabled,