The operator split it in one test: WAV is fine, MP3 is not. The MP3 path is the only one that resamples — 16 kHz up to 32 kHz, because the encoder emits broken frames at MPEG-2 rates — and it did so by averaging neighbouring samples. That is an upsampler in name only. Every component at f is mirrored to 16 kHz − f, and the test measures the mirror just 7 dB below the wanted signal: a second copy of the whole spectrum. On speech it adds brightness; on receiver noise, which is broadband, it lays a second noise floor over the top of the band. Hence "a hiss that is not the QRM, louder than the voice" — and hence its absence from the WAV, which resamples nothing. Zero-stuffing plus a 63-tap windowed sinc at the old Nyquist puts the image 56.8 dB down instead of 7, at a cost measured in microseconds against the MP3 encode that follows. Two tests pin it: the image suppression, and that the level is unchanged — a fix that quietly halved every recording would be a second surprise on top of the one being repaired.
80 lines
2.5 KiB
Go
80 lines
2.5 KiB
Go
package audio
|
||
|
||
import (
|
||
"math"
|
||
"testing"
|
||
)
|
||
|
||
// goertzel returns the magnitude of one frequency bin, so a test can ask "how
|
||
// much energy is at 10 kHz" without pulling in an FFT.
|
||
func goertzel(x []int16, fs, freq float64) float64 {
|
||
w := 2 * math.Pi * freq / fs
|
||
c := 2 * math.Cos(w)
|
||
var s1, s2 float64
|
||
for _, v := range x {
|
||
s := float64(v) + c*s1 - s2
|
||
s2, s1 = s1, s
|
||
}
|
||
return math.Hypot(s1-s2*math.Cos(w), s2*math.Sin(w))
|
||
}
|
||
|
||
// Upsampling 16 kHz → 32 kHz must not create an audible mirror image.
|
||
//
|
||
// MP3 recordings came back with a hiss that is not on the air and is not in the
|
||
// WAV of the same audio — "louder than the voice", from a station that hears the
|
||
// voice well clear of the noise. The MP3 path is the only one that resamples,
|
||
// and it did so by linear interpolation: every component at f is mirrored to
|
||
// 16 kHz − f, which for broadband receiver noise means a second noise floor
|
||
// spread across the top of the band.
|
||
func TestUpsampleSuppressesTheImage(t *testing.T) {
|
||
const fs = 16000.0
|
||
const tone = 6000.0 // its image lands at 16000 − 6000 = 10 kHz
|
||
|
||
in := make([]int16, 4096)
|
||
for i := range in {
|
||
in[i] = int16(12000 * math.Sin(2*math.Pi*tone*float64(i)/fs))
|
||
}
|
||
|
||
out := upsample2(in)
|
||
if len(out) != 2*len(in) {
|
||
t.Fatalf("upsample2 returned %d samples for %d", len(out), len(in))
|
||
}
|
||
|
||
wanted := goertzel(out, 2*fs, tone)
|
||
image := goertzel(out, 2*fs, 2*fs-tone-16000) // = 10 kHz
|
||
if wanted == 0 {
|
||
t.Fatal("the tone itself did not survive upsampling")
|
||
}
|
||
db := 20 * math.Log10(image/wanted)
|
||
t.Logf("image at 10 kHz is %.1f dB below the 6 kHz tone", db)
|
||
if db > -35 {
|
||
t.Errorf("image only %.1f dB down — it is audible as added hiss; want at least 35 dB", db)
|
||
}
|
||
}
|
||
|
||
// The filter must not change the level, or every existing recording would come
|
||
// back quieter (or clipped) after the fix — a second surprise on top of the one
|
||
// being repaired.
|
||
func TestUpsampleKeepsTheLevel(t *testing.T) {
|
||
const fs = 16000.0
|
||
in := make([]int16, 4096)
|
||
for i := range in {
|
||
in[i] = int16(10000 * math.Sin(2*math.Pi*1000*float64(i)/fs))
|
||
}
|
||
rms := func(x []int16) float64 {
|
||
var acc float64
|
||
for _, v := range x {
|
||
acc += float64(v) * float64(v)
|
||
}
|
||
return math.Sqrt(acc / float64(len(x)))
|
||
}
|
||
// Skip the filter's warm-up and tail, where the window is only partly fed.
|
||
out := upsample2(in)
|
||
got, want := rms(out[200:len(out)-200]), rms(in[100:len(in)-100])
|
||
ratio := got / want
|
||
t.Logf("level after upsampling: ×%.3f", ratio)
|
||
if ratio < 0.9 || ratio > 1.1 {
|
||
t.Errorf("level changed by ×%.3f — want within 10%%", ratio)
|
||
}
|
||
}
|