Files
OpsLog/internal/audio/upsample_test.go
T
rouggy fddb3c45c4 fix: MP3 recordings carried a hiss the WAV of the same audio did not
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.
2026-07-31 21:27:57 +02:00

80 lines
2.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}