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.
132 lines
4.5 KiB
Go
132 lines
4.5 KiB
Go
//go:build windows
|
||
|
||
package audio
|
||
|
||
import (
|
||
"math"
|
||
"os"
|
||
|
||
"github.com/braheezy/shine-mp3/pkg/mp3"
|
||
)
|
||
|
||
// mp3Rate is the encode sample rate. The capture pipeline is 16 kHz, but the
|
||
// Shine encoder emits broken "free-format" frames at MPEG-2 rates (16/22/24
|
||
// kHz) that most players reject. Encoding at an MPEG-1 rate (we upsample ×2 to
|
||
// 32 kHz) produces standard, universally-playable MP3s.
|
||
const mp3Rate = sampleRate * 2 // 32000
|
||
|
||
// writeMP3 encodes 16 kHz mono 16-bit PCM to a standard MP3 file using the
|
||
// pure-Go Shine encoder (no CGO). Two quirks are worked around:
|
||
// - 16 kHz (MPEG-2) yields broken free-format frames → upsample ×2 to 32 kHz.
|
||
// - Shine's Write only encodes half the samples for MONO input (its loop
|
||
// advances by samples_per_pass*2). Feeding STEREO interleaved data (the
|
||
// encoder reads samples_per_pass*channels per pass) encodes everything, so
|
||
// we duplicate mono → L=R stereo.
|
||
func writeMP3(path string, pcm []byte) error {
|
||
f, err := os.Create(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer f.Close()
|
||
mono32 := upsample2(bytesToInt16(pcm)) // 16 kHz → 32 kHz mono
|
||
stereo := make([]int16, len(mono32)*2) // L=R interleaved
|
||
for i, v := range mono32 {
|
||
stereo[2*i], stereo[2*i+1] = v, v
|
||
}
|
||
// Shine's Write() reads a WHOLE frame (samplesPerPass × channels = 1152 × 2 =
|
||
// 2304 interleaved samples for MPEG-1) per pass via unsafe pointer arithmetic,
|
||
// regardless of how short the trailing chunk is. If the buffer isn't an exact
|
||
// multiple of a frame, the final pass reads past the slice and the process
|
||
// dies with an access violation (0xc0000005) inside windowFilterSubband.
|
||
// Pad with trailing silence to a whole number of frames so no partial pass
|
||
// exists. (~36 ms of silence at most — inaudible.)
|
||
const frameInterleaved = 1152 * 2 // samplesPerPass(MPEG-1) × 2 channels
|
||
if rem := len(stereo) % frameInterleaved; rem != 0 {
|
||
stereo = append(stereo, make([]int16, frameInterleaved-rem)...)
|
||
}
|
||
if len(stereo) == 0 {
|
||
return nil // nothing to encode (empty recording)
|
||
}
|
||
enc := mp3.NewEncoder(mp3Rate, 2)
|
||
return enc.Write(f, stereo)
|
||
}
|
||
|
||
// upsample2 doubles the sample rate, 16 kHz → 32 kHz, WITH the interpolation
|
||
// filter that makes the operation legitimate.
|
||
//
|
||
// It used to interpolate linearly — each new sample the average of its
|
||
// neighbours. That is an upsampler in name only: every component at f is
|
||
// mirrored to 16 kHz − f, measured just 7 dB down (see the test). On speech it
|
||
// adds brightness; on receiver noise, which is broadband, it lays a second
|
||
// noise floor across the top of the band. Operators heard it as a hiss louder
|
||
// than the voice, present in the MP3 and absent from the WAV of the very same
|
||
// audio.
|
||
//
|
||
// Zero-stuffing followed by a windowed-sinc low-pass at the old Nyquist is the
|
||
// textbook answer, and it puts the image below 40 dB. 63 taps is a few hundred
|
||
// microseconds of work on an eight-second recording — nothing, next to the MP3
|
||
// encode that follows.
|
||
func upsample2(in []int16) []int16 {
|
||
if len(in) == 0 {
|
||
return in
|
||
}
|
||
out := make([]int16, len(in)*2)
|
||
half := len(upsampleFIR) / 2
|
||
for i := range out {
|
||
var acc float64
|
||
// The zero-stuffed signal is non-zero only at even indices, so only
|
||
// every other tap contributes — the loop skips the zeros rather than
|
||
// multiplying by them.
|
||
start := (i - half + 1) &^ 1 // first even index in the window
|
||
for j := start; j <= i+half; j += 2 {
|
||
k := i - j + half
|
||
if k < 0 || k >= len(upsampleFIR) {
|
||
continue
|
||
}
|
||
src := j / 2
|
||
if src < 0 || src >= len(in) {
|
||
continue
|
||
}
|
||
acc += float64(in[src]) * upsampleFIR[k]
|
||
}
|
||
// ×2 for the energy lost to zero-stuffing, then clamp.
|
||
v := acc * 2
|
||
if v > 32767 {
|
||
v = 32767
|
||
} else if v < -32768 {
|
||
v = -32768
|
||
}
|
||
out[i] = int16(v)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// upsampleFIR is a 63-tap Hamming-windowed sinc, cut off at a quarter of the
|
||
// NEW rate (8 kHz at 32 kHz) — exactly the old Nyquist, which is where the
|
||
// images begin.
|
||
var upsampleFIR = func() []float64 {
|
||
const n = 63
|
||
const fc = 0.25 // cycles/sample at the new rate
|
||
h := make([]float64, n)
|
||
mid := (n - 1) / 2
|
||
var sum float64
|
||
for i := 0; i < n; i++ {
|
||
m := float64(i - mid)
|
||
var v float64
|
||
if m == 0 {
|
||
v = 2 * fc
|
||
} else {
|
||
v = math.Sin(2*math.Pi*fc*m) / (math.Pi * m)
|
||
}
|
||
// Hamming window: a rectangular one would ring and put the stopband
|
||
// only ~21 dB down, which is not enough to be worth the filter.
|
||
v *= 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(n-1))
|
||
h[i] = v
|
||
sum += v
|
||
}
|
||
for i := range h {
|
||
h[i] /= sum // unity gain at DC
|
||
}
|
||
return h
|
||
}()
|