//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 }()