fragglet/sdl-sopwith

PC speaker simulation has aliasing artifacts

Closed this issue · 7 comments

kmill commented

The original PC speaker timer chip runs at 1.19328 MHz, but the square wave in src/sdl/pcsound.c is sampled at 48 kHz. This leads to very noticeable aliasing artifacts (it's the DTMF-like sounds accompanying the square waves). This is not how it sounds on original hardware, and it's a bit unpleasant, so I think it's worth fixing.

For maximum accuracy, it should sample the square wave at 1.19328 MHz and then do downsampling to 48 kHz (that entails doing a 24 kHz low-pass filter on the 1.19328 MHz samples and then taking every nth sample at the right ratio -- it's roughly every 25th sample, and you can take averages of adjacent samples to deal with the non-integer ratio between the sampling rates).

However, for good-enough output without needing to do any of that math, instead it can oversample the square wave and take an average.

diff --git a/src/sdl/pcsound.c b/src/sdl/pcsound.c
index ee9180f..743779d 100644
--- a/src/sdl/pcsound.c
+++ b/src/sdl/pcsound.c
@@ -209,8 +209,13 @@ static void snd_callback(void *userdata, Uint8 *stream8, int len)
                if (!speaker_on) {
                        sample = 0;
                } else {
-                       sample = square_wave(current_freq * (i + lasttime)
-                                          / output_freq);
+                       // Sample the square wave at close to TIMER_FREQ and downsample using a simple low-pass filter.
+                       // This reduces aliasing artifacts.
+                       float presample = 0;
+                       for (int j = 0; j < 25; j++) {
+                               presample += square_wave(current_freq * (i + lasttime + j/25.0) / output_freq);
+                       }
+                       sample = presample / 25.0;
                }
                sample = FilterNext(&tinny_filter, sample);
                stream[i] = (signed int) (sample * VOLUME);

To my ears, 25 is overkill, and you can get away with 3.

Hi, thanks for the report. Do you have snd_tinnyfilter disabled, or you're saying you can still hear aliasing even with the normal filter turned on? I'm surprised because we already have a low-pass filter in place that should eliminate most of this.

Note that SDL now has sound rate conversion functions built now, in so my instinct is that the best approach would probably be to use that: https://wiki.libsdl.org/SDL2/SDL_BuildAudioCVT - I think it even uses libsamplerate behind the scenes if support is compiled in for it, to give the best possible quality of conversion.

kmill commented

This is an problem both with and without snd_tinnyfilter enabled. It's a fundamental issue with the square wave generator itself -- to generate square waves of the highest quality, you have to take the ideal square wave function, do the theoretical low-pass filter of it at half the sampling rate (here, 24000 Hz), and only then actually sample it. If you sample the ideal square wave first (like in pcsound.c), then you've already baked in the aliasing artifacts, and further low-pass filters will not help. This is a common issue when synthesizing square waves, and it's noticeable because they have lots of prominent high-frequency overtones that you need to silence so they don't "fold over" when sampling.

On the original hardware, the square waves were always at some integer divisor of the 1.19328 MHz sample rate, so aliasing artifacts didn't affect it.

An intuition for the problem is that the different tones are at frequencies that don't evenly divide 48000 Hz, so there's a "jitter" in the square wave's highs and lows. This jitter corresponds to adding in a bunch of noise. While lots of the noise is inaudible, but there's still a good amount of it that's in the audible range.

Oversampling and taking the average is a quick way to deal with the problem, especially since Sopwith's soundtrack isn't particularly hi-fi to begin with :-) We just need to antialias the harsh corners of the square waves a touch.

Rest assured I'm perfectly aware of what aliasing is :) I'm just surprised that you can still hear it given that I thought those issues went away when I added in the filter. But I can definitely still hear it eg. with the bomb falling sound.

kmill commented

I know it might sound farfetched that harmonics past the 100th might have any effect, but still, the harmonics beyond that account for almost 0.5% of the power of the square wave, and all of this folds over right at that first sampling step. It might be quiet, but the noise should be only about 25 dB less than the main notes (in practice there's probably a lot more of a difference because some will be outside the range of human hearing, but I'm not sure how to quickly calculate it).

Sorry if I'm over-explaining aliasing to you -- generally people don't seem to be aware of the issue. Another time I tried to point this out in an emulation project they thought I was talking about adding extra filtering to make it sound like real hardware (like the snd_tinnyfilter), and they were totally uninterested because they didn't understand that it's about eliminating artifacts caused by the Nyquist sampling theorem.

Patch now applied. Thank you!

kmill commented

Sorry to bother you with this again, but I just tried it out and the aliasing is still there. There appears to be a missing cast; it should be float t = i + lasttime + (float)j / OVERSAMPLE_FACTOR; rather than float t = i + lasttime + j / OVERSAMPLE_FACTOR; (ok, back to playing).

Whoops, now fixed. Thank you!