N57PNZPFM6QU5FK4SHC3473IV6IN3HRVKSPZSFIJJ5LLCAXICNIAC SIH47AMTJ7IDL6QGXR3EWT2YUZI2M3MG7HXJTBQ2TPRXEVIBZQBQC 3ETJ6KPIYI23DLXSKISNJSY3DUGHOACE6CPCPF6V7KJK4EXIADQAC NQPVZ3PPQG6EPTTAEHXOXXGK27HZCISHZCOZU6K6RKWTRTOHMY6QC KZKLAINJJWZ64T5MUZT34LJVQIKBTKZ6EJGD7C7TTSSDGCHEDPMAC P4CJMBYKB6LTJASFYF5FX4MHQW7TH7D6KLDLWBBLFKDHTWK5SZ6AC NUOFNUIQIKWOBFHMJXY2K4AJGIROGCPFK5NJHR6Y6HYHHCOJPVCAC LBWQJEDHCNUNMEJWXILGBGYZUKQI7CDAMH2BD44HULM77SVH5UYQC 2HAQZPV377VV26SMPSXSZR6CL7SS2GTNPR5COIAPN47NLJILRQGAC QVIGQOQZIEXLFMMAA7RTL7MQWI4MC3CH22R6YO6J7LGLHWLCSD4AC YVFPP5VJJSR4EVGOMB5565IZFYAVFNRH37L6AXEUFU5AP6CL7DJAC 3DVPQOKB6BX63XSBIYYCPWBL2RBG3LXZS3XPQBANJP2FWVRAOVZQC SJN7IKIVTAZX3ACEWPLFVUT7P2TLB3RQBD4PKC6PEQQ33ECXFJRQC I4CMOMXFJ3Y4AY5LPA7MDLWVHJ674IRFYLXCEXCC5ZARLCWSKCAAC package utilsimport ("math""testing")sampleRate := 48000numSamples := int(float64(sampleRate) * duration)audio := make([]float64, numSamples)for i := range audio {ts := float64(i) / float64(sampleRate)}}}}}}if len(result) != 0 {t.Errorf("expected empty result for empty input, got %d samples", len(result))}if rate != 48000 {t.Errorf("rate = %d, want 48000", rate)}}}}}for i := range audio {ts := float64(i) / float64(sampleRate)}}ratio := power / totalPowerif ratio < 0.05 {t.Errorf("shifted 48kHz→8kHz bin has only %.1f%% of total power, want > 5%%", ratio*100)}}func TestNextPowerOf2(t *testing.T) {tests := []struct {input, want int}{{0, 1},{1, 1},{2, 2},{3, 4},{5, 8},{100, 128},{1024, 1024},{1025, 2048},}for _, tt := range tests {got := nextPowerOf2(tt.input)if got != tt.want {t.Errorf("nextPowerOf2(%d) = %d, want %d", tt.input, got, tt.want)}}}n := nextPowerOf2(len(samples))padded := make([]float64, n)copy(padded, samples)power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(padded, power, scratch)bin := int(freqHz * float64(n) / float64(sampleRate))if bin >= len(power) {return 0}return power[bin]}// signalPower returns the total power of a signal.func signalPower(samples []float64) float64 {power := 0.0for _, s := range samples {power += s * s}return power}sampleRate := 250000audio := make([]float64, numSamples)for i := range audio {ts := float64(i) / float64(sampleRate)audio[i] = math.Sin(2*math.Pi*1000*ts) + math.Sin(2*math.Pi*15000*ts)}b.ResetTimer()for i := 0; i < b.N; i++ {}}BandpassShiftFilter(audio, sampleRate, 8000, 24000)numSamples := int(float64(sampleRate) * 5.0) // 5 seconds// absInt returns the absolute value of an int.func absInt(x int) int {if x < 0 {return -x}return x}func BenchmarkBandpassShiftFilter(b *testing.B) {// computeBinPowerAtFreq uses our FFT to compute power at a specific frequency.func computeBinPowerAtFreq(samples []float64, sampleRate int, freqHz float64) float64 {// 48kHz tone shifted to 8kHz (48000 - 40000)power := computeBinPowerAtFreq(filtered, newRate, 8000)totalPower := signalPower(filtered)if totalPower == 0 {t.Fatal("filtered signal has no power")filtered, newRate := BandpassShiftFilter(audio, sampleRate, 40000, 56000)if newRate != 32000 {t.Errorf("newRate = %d, want 32000", newRate)}audio[i] = math.Sin(2 * math.Pi * 48000 * ts)func TestBandpassShiftFilter_NarrowBand(t *testing.T) {// Bat vocalisations: bandpass 40000-56000 → bandwidth 16000 → newRate 32000audio := make([]float64, 5000)sampleRate := 250000// Output should be downsampled: 2500 samples at 250kHz → ~320 samples at 32kHzexpectedSamples := int(float64(len(audio)) * float64(expectedRate) / 250000)if absInt(len(filtered)-expectedSamples) > 2 {t.Errorf("output samples = %d, want ~%d", len(filtered), expectedSamples)func TestBandpassShiftFilter_DownsampleRate(t *testing.T) {// 250kHz audio, bandpass 8000-24000 → bandwidth 16000 → newRate 32000audio := make([]float64, 2500) // tiny signalfiltered, newRate := BandpassShiftFilter(audio, 250000, 8000, 24000)expectedRate := 32000if newRate != expectedRate {t.Errorf("newRate = %d, want %d", newRate, expectedRate)func TestBandpassShiftFilter_EmptyInput(t *testing.T) {result, rate := BandpassShiftFilter([]float64{}, 48000, 1000, 8000)filtered, newRate := BandpassShiftFilter(audio, sampleRate, 8000, 12000)// The 1000 Hz tone (out of band) should be removed// After shift, in-band 10kHz→2kHz, out-of-band 1kHz→-7kHz (removed)inBandPower := computeBinPowerAtFreq(filtered, newRate, 2000)outBandPower := computeBinPowerAtFreq(filtered, newRate, 7000) // 1kHz shifted = not presentif outBandPower > inBandPower*0.1 {t.Errorf("out-of-band leakage: outBand=%.6f, inBand=%.6f", outBandPower, inBandPower)audio := make([]float64, numSamples)for i := range audio {ts := float64(i) / float64(sampleRate)audio[i] = math.Sin(2*math.Pi*1000*ts) + math.Sin(2*math.Pi*10000*ts)func TestBandpassShiftFilter_RemovesOutOfBand(t *testing.T) {// Generate a signal with tones at 1000 Hz (out of band) and 10000 Hz (in band)sampleRate := 48000duration := 0.05numSamples := int(float64(sampleRate) * duration)bandwidth := 12000 - 8000 // 4000 HzexpectedRate := 2 * bandwidth // 8000 Hzif newRate != expectedRate {t.Errorf("newRate = %d, want %d", newRate, expectedRate)}// Check that the shifted tone is at 2000 Hz in the filtered signalpower := computeBinPowerAtFreq(filtered, newRate, 2000)totalPower := signalPower(filtered)if totalPower == 0 {t.Fatal("filtered signal has no power")}// The 2000 Hz bin should contain significant energyratio := power / totalPowerif ratio < 0.1 {t.Errorf("shifted 10kHz→2kHz bin has only %.1f%% of total power, want > 10%%", ratio*100)// Bandpass 8000-12000, shift to baseband// The 10000 Hz tone should shift to 2000 Hz (10000 - 8000)filtered, newRate := BandpassShiftFilter(audio, sampleRate, 8000, 12000)audio[i] = math.Sin(2 * math.Pi * 10000 * ts)duration := 0.05func TestBandpassShiftFilter_BasicShift(t *testing.T) {// Generate a signal with a tone at 10000 Hz, sample rate 48000
package utilsimport "testing"func TestFloat64ToPCM16(t *testing.T) {samples := []float64{0.0, 1.0, -1.0, 1.5, -1.5}bytes := Float64ToPCM16(samples)if len(bytes) != len(samples)*2 {t.Fatalf("expected length %d, got %d", len(samples)*2, len(bytes))}}
package utilsimport "encoding/binary"// Float64ToPCM16 converts float64 samples [-1.0, 1.0] to signed 16-bit PCM (LittleEndian) bytes.func Float64ToPCM16(samples []float64) []byte {buf := make([]byte, len(samples)*2)for i, sample := range samples {// Clamp to [-1.0, 1.0]if sample > 1.0 {sample = 1.0} else if sample < -1.0 {sample = -1.0}// Convert to 16-bit PCMbinary.LittleEndian.PutUint16(buf[i*2:], uint16(int16(sample*32767)))}return buf}
package utilsimport ("github.com/madelynnblue/go-dsp/fft")n := len(audio)if n == 0 {}padded := make([]float64, paddedLen)copy(padded, audio)}}}for i := range result {result[i] = real(filtered[i])}}}// nextPowerOf2 returns the smallest power of 2 >= n.func nextPowerOf2(n int) int {if n <= 0 {return 1}n--n |= n >> 1n |= n >> 2n |= n >> 4n |= n >> 8n |= n >> 16n |= n >> 32return n + 1}result = ResampleRate(result, sampleRate, newRate)return result, newRate// Downsample to 2 * bandwidthbandwidth := highFreq - lowFreqnewRate := int(2 * bandwidth)if newRate >= sampleRate {return result, sampleRatefiltered := fft.IFFT(shifted)result := make([]float64, n)// Conjugate symmetry: shifted[N-k] = conj(shifted[k])if k > 0 && k < paddedLen/2 {shifted[paddedLen-k] = complex(real(shifted[k]), -imag(shifted[k]))// Shift spectrum down by lowBin: bin k gets content from original bin (k + lowBin)// Keep only bins within the passband, enforce conjugate symmetry for real output.shifted := make([]complex128, paddedLen)for k := 0; k <= paddedLen/2; k++ {if k <= bandBins {srcBin := k + lowBinif srcBin >= 0 && srcBin <= paddedLen/2 {shifted[k] = spectrum[srcBin]}spectrum := fft.FFTReal(padded)lowBin := int(lowFreq * float64(paddedLen) / float64(sampleRate))highBin := int(highFreq * float64(paddedLen) / float64(sampleRate))bandBins := highBin - lowBinpaddedLen := nextPowerOf2(n)return audio, sampleRate// BandpassShiftFilter applies a bandpass filter retaining frequencies between// lowFreq and highFreq, shifts the retained band down to baseband (0 Hz),// and downsamples to 2*(highFreq-lowFreq) Hz.// Returns the processed samples and the new sample rate.//// For example, with --bandpass 8000-24000 on 250kHz audio:// - Bandpass keeps only 8-24kHz content// - Shift down by 8kHz so content is at 0-16kHz// - Downsample from 250kHz to 32kHz// - Spectrogram shows the 8-24kHz band as if it were 0-16kHzfunc BandpassShiftFilter(audio []float64, sampleRate int, lowFreq, highFreq float64) ([]float64, int) {
package utilsimport ("math""testing")func TestResampleRate(t *testing.T) {t.Run("should return same samples for same rate", func(t *testing.T) {samples := []float64{0.1, 0.2, 0.3, 0.4, 0.5}result := ResampleRate(samples, 16000, 16000)if len(result) != len(samples) {t.Errorf("length mismatch: got %d, want %d", len(result), len(samples))}for i := range samples {if result[i] != samples[i] {t.Errorf("sample %d mismatch: got %f, want %f", i, result[i], samples[i])}}})t.Run("should downsample from 250000 to 16000", func(t *testing.T) {// 250000 / 16000 = 15.625 ratiosamples := make([]float64, 2500) // 0.01 seconds at 250kHzfor i := range samples {samples[i] = float64(i) / float64(len(samples))}result := ResampleRate(samples, 250000, 16000)expectedLen := 160 // 0.01 seconds at 16kHzif len(result) != expectedLen {t.Errorf("length mismatch: got %d, want %d", len(result), expectedLen)}})t.Run("should downsample from 44100 to 16000", func(t *testing.T) {// 44100 / 16000 = 2.75625 ratiosamples := make([]float64, 441) // 0.01 seconds at 44.1kHzfor i := range samples {samples[i] = float64(i) / float64(len(samples))}result := ResampleRate(samples, 44100, 16000)expectedLen := 160 // 0.01 seconds at 16kHzif len(result) != expectedLen {t.Errorf("length mismatch: got %d, want %d", len(result), expectedLen)}})t.Run("should preserve signal shape", func(t *testing.T) {// Create a simple ramp signalsamples := []float64{0.0, 0.25, 0.5, 0.75, 1.0}result := ResampleRate(samples, 50000, 16000)// Should still be a roughly increasing signalfor i := 1; i < len(result); i++ {if result[i] < result[i-1]-0.1 {t.Errorf("signal not preserved: result[%d]=%f < result[%d]=%f", i, result[i], i-1, result[i-1])}}})t.Run("should handle empty samples", func(t *testing.T) {result := ResampleRate([]float64{}, 44100, 16000)if len(result) != 0 {t.Errorf("expected empty result, got %d samples", len(result))}})}func TestResample(t *testing.T) {t.Run("should return same samples for speed 1.0", func(t *testing.T) {samples := []float64{0.1, 0.2, 0.3, 0.4, 0.5}result := Resample(samples, 1.0)if len(result) != len(samples) {t.Errorf("length mismatch: got %d, want %d", len(result), len(samples))}for i := range samples {if result[i] != samples[i] {t.Errorf("sample %d mismatch: got %f, want %f", i, result[i], samples[i])}}})t.Run("should double samples for half speed", func(t *testing.T) {samples := []float64{0.0, 1.0, 0.0, -1.0, 0.0}result := Resample(samples, 0.5)// Half speed = 2x more samplesexpectedLen := len(samples) * 2if len(result) != expectedLen {t.Errorf("length mismatch: got %d, want %d", len(result), expectedLen)}})t.Run("should halve samples for double speed", func(t *testing.T) {samples := []float64{0.0, 0.5, 1.0, 0.5, 0.0, -0.5, -1.0, -0.5, 0.0}result := Resample(samples, 2.0)// Double speed = half the samplesexpectedLen := len(samples) / 2if len(result) != expectedLen {t.Errorf("length mismatch: got %d, want %d", len(result), expectedLen)}})t.Run("should use linear interpolation", func(t *testing.T) {// With samples [0, 1], half-speed should interpolate to [0, 0.5, 1]samples := []float64{0.0, 1.0}result := Resample(samples, 0.5)// Expected: 4 samples (2 / 0.5 = 4)if len(result) != 4 {t.Errorf("length mismatch: got %d, want 4", len(result))}// Check interpolation: index 1 should be ~0.5 (midpoint)expected := 0.5if math.Abs(result[1]-expected) > 0.01 {t.Errorf("interpolated value mismatch: got %f, want ~%f", result[1], expected)}})t.Run("should handle empty samples", func(t *testing.T) {result := Resample([]float64{}, 0.5)if len(result) != 0 {t.Errorf("expected empty result, got %d samples", len(result))}})t.Run("should handle single sample", func(t *testing.T) {samples := []float64{0.5}result := Resample(samples, 0.5)// 1 / 0.5 = 2 samplesif len(result) != 2 {t.Errorf("length mismatch: got %d, want 2", len(result))}})}func TestResampleQuality(t *testing.T) {t.Run("should preserve zero crossings", func(t *testing.T) {// Sine wave: should have zero crossings at multiples of pisampleRate := 1000samples := make([]float64, sampleRate)for i := range samples {samples[i] = math.Sin(2 * math.Pi * float64(i) / float64(sampleRate))}// Resample to half speedresult := Resample(samples, 0.5)// First sample should still be ~0 (sine at 0)if math.Abs(result[0]) > 0.01 {t.Errorf("first sample not near zero: got %f", result[0])}// Peak should still be ~1.0 (sine max)peakFound := falsefor _, s := range result {if math.Abs(s-1.0) < 0.1 {peakFound = truebreak}}if !peakFound {t.Error("peak not preserved in resampled signal")}})}
package utils// ResampleRate converts samples from one sample rate to another using linear interpolation.// This is used to downsample high sample rate audio for spectrogram visualization.// fromRate: original sample rate (e.g., 250000)// toRate: target sample rate (e.g., 16000)func ResampleRate(samples []float64, fromRate, toRate int) []float64 {if fromRate == toRate || len(samples) == 0 {return samples}// speed = fromRate/toRate: e.g. 250000/16000 = 15.625 (skip samples to downsample)return Resample(samples, float64(fromRate)/float64(toRate))}// Resample changes playback speed using linear interpolation.// speed > 1.0 = faster (fewer samples), speed < 1.0 = slower (more samples).// For half-speed playback, use speed=0.5 which doubles the sample count.func Resample(samples []float64, speed float64) []float64 {if speed == 1.0 || len(samples) == 0 {return samples}// Calculate new length: slower speed = more samplesnewLen := int(float64(len(samples)) / speed)if newLen <= 0 {return samples}result := make([]float64, newLen)for i := range newLen {// Source index in original samples (floating point)srcIdx := float64(i) * speedidx0 := int(srcIdx)idx1 := idx0 + 1// Clamp to valid rangeif idx0 >= len(samples) {idx0 = len(samples) - 1}if idx1 >= len(samples) {idx1 = len(samples) - 1}// Linear interpolation between adjacent samplesfrac := srcIdx - float64(idx0)result[i] = samples[idx0]*(1-frac) + samples[idx1]*frac}return result}
package utilsimport ("math""math/rand""testing""github.com/madelynnblue/go-dsp/fft")// referencepower computes the power spectrum using go-dsp as ground truth.func referencePower(samples []float64) []float64 {result := fft.FFTReal(samples)n := len(samples)numBins := n/2 + 1power := make([]float64, numBins)for k := range numBins {re := real(result[k])im := imag(result[k])power[k] = re*re + im*im}return power}func TestPowerSpectrumFFT_Sinusoid(t *testing.T) {// 512-point FFT of a pure 1kHz sine at 16kHz sample rate// Expected: peak at bin k = 1000 * 512 / 16000 = 32n := 512sampleRate := 16000.0freq := 1000.0samples := make([]float64, n)for i := range samples {samples[i] = math.Sin(2.0 * math.Pi * freq * float64(i) / sampleRate)}power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)// Find peak binmaxBin := 0maxVal := 0.0for k, v := range power {if v > maxVal {maxVal = vmaxBin = k}}expectedBin := int(freq * float64(n) / sampleRate)if maxBin != expectedBin {t.Errorf("peak at bin %d, expected %d", maxBin, expectedBin)}// Compare against referenceref := referencePower(samples)for k := range power {if math.Abs(power[k]-ref[k]) > 1e-6*math.Abs(ref[k])+1e-10 {t.Errorf("bin %d: got %g, ref %g", k, power[k], ref[k])}}}func TestPowerSpectrumFFT_Random(t *testing.T) {n := 512rng := rand.New(rand.NewSource(42))samples := make([]float64, n)for i := range samples {samples[i] = rng.Float64()*2 - 1}power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)ref := referencePower(samples)for k := range power {relErr := math.Abs(power[k]-ref[k]) / (math.Abs(ref[k]) + 1e-15)if relErr > 1e-8 {t.Errorf("bin %d: got %g, ref %g (relErr=%g)", k, power[k], ref[k], relErr)}}}func TestPowerSpectrumFFT_DC(t *testing.T) {n := 512samples := make([]float64, n)for i := range samples {samples[i] = 1.0}power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)ref := referencePower(samples)for k := range power {if math.Abs(power[k]-ref[k]) > 1e-6 {t.Errorf("bin %d: got %g, ref %g", k, power[k], ref[k])}}// DC bin should have all the energyif power[0] < power[1]*1000 {t.Errorf("DC bin should dominate: power[0]=%g, power[1]=%g", power[0], power[1])}}func TestPowerSpectrumFFT_Silence(t *testing.T) {n := 512samples := make([]float64, n)power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)for k, v := range power {if v != 0 {t.Errorf("bin %d: expected 0, got %g", k, v)}}}func TestPowerSpectrumFFT_Impulse(t *testing.T) {n := 512samples := make([]float64, n)samples[0] = 1.0power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)ref := referencePower(samples)for k := range power {if math.Abs(power[k]-ref[k]) > 1e-10 {t.Errorf("bin %d: got %g, ref %g", k, power[k], ref[k])}}// Impulse: flat power spectrum, all bins should be equal (= 1.0)for k, v := range power {if math.Abs(v-1.0) > 1e-10 {t.Errorf("bin %d: expected ~1.0, got %g", k, v)}}}func TestPowerSpectrumFFT_DifferentSizes(t *testing.T) {rng := rand.New(rand.NewSource(99))for _, n := range []int{2, 4, 8, 16, 64, 256, 1024} {samples := make([]float64, n)for i := range samples {samples[i] = rng.Float64()*2 - 1}power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)ref := referencePower(samples)for k := range power {relErr := math.Abs(power[k]-ref[k]) / (math.Abs(ref[k]) + 1e-15)if relErr > 1e-8 {t.Errorf("n=%d bin %d: got %g, ref %g (relErr=%g)", n, k, power[k], ref[k], relErr)}}}}func BenchmarkPowerSpectrumFFT_512(b *testing.B) {n := 512rng := rand.New(rand.NewSource(42))samples := make([]float64, n)for i := range samples {samples[i] = rng.Float64()*2 - 1}power := make([]float64, n/2+1)scratch := make([]complex128, n)b.ResetTimer()for range b.N {PowerSpectrumFFT(samples, power, scratch)}}func BenchmarkGodsFFTReal_512(b *testing.B) {n := 512rng := rand.New(rand.NewSource(42))samples := make([]float64, n)for i := range samples {samples[i] = rng.Float64()*2 - 1}b.ResetTimer()for range b.N {fft.FFTReal(samples)}}
package utilsimport ("math""sync")// FFT twiddle factors and bit-reversal tables, cached per size.var (fftCacheMu sync.RWMutexfftCache = map[int]*fftPlan{})// fftPlan holds pre-computed data for a given FFT size.type fftPlan struct {n inttwiddle []complex128 // twiddle factors: exp(-2*pi*i*k/N) for k=0..N/2-1bitrev []int // bit-reversal permutation table}// getFFFTPlan returns a cached FFT plan for the given size (must be power of 2).func getFFTPlan(n int) *fftPlan {fftCacheMu.RLock()if p, ok := fftCache[n]; ok {fftCacheMu.RUnlock()return p}fftCacheMu.RUnlock()fftCacheMu.Lock()defer fftCacheMu.Unlock()if p, ok := fftCache[n]; ok {return p}p := &fftPlan{n: n}// Compute twiddle factors: exp(-2*pi*i*k/N) for k = 0..N/2-1p.twiddle = make([]complex128, n/2)for k := range p.twiddle {angle := -2.0 * math.Pi * float64(k) / float64(n)sin, cos := math.Sincos(angle)p.twiddle[k] = complex(cos, sin)}// Compute bit-reversal permutationbits := 0for v := n; v > 1; v >>= 1 {bits++}p.bitrev = make([]int, n)for i := range p.bitrev {p.bitrev[i] = reverseBitsN(i, bits)}fftCache[n] = preturn p}// reverseBitsN reverses the lowest `bits` bits of v.func reverseBitsN(v, bits int) int {var r intfor range bits {r = (r << 1) | (v & 1)v >>= 1}return r}// PowerSpectrumFFT computes the power spectrum of a real-valued signal using radix-2 FFT.//// samples: real input of length N (must be power of 2, N >= 2)// power: output buffer of length >= N/2+1; receives |X[k]|^2 for k=0..N/2// scratch: working buffer of length >= N; contents are overwritten//// All buffers are caller-provided to enable zero-allocation across repeated calls.func PowerSpectrumFFT(samples []float64, power []float64, scratch []complex128) {n := len(samples)plan := getFFTPlan(n)// Bit-reversal copy: load real samples into scratch in bit-reversed orderfor i, j := range plan.bitrev {scratch[j] = complex(samples[i], 0)}// Iterative Cooley-Tukey butterfly (decimation-in-time)for size := 2; size <= n; size <<= 1 {half := size >> 1step := n / size // twiddle index stepfor start := 0; start < n; start += size {tw := 0for j := range half {u := scratch[start+j]v := scratch[start+j+half] * plan.twiddle[tw]scratch[start+j] = u + vscratch[start+j+half] = u - vtw += step}}}// Extract power spectrum: |X[k]|^2 = re^2 + im^2 for k = 0..N/2numBins := n/2 + 1for k := range numBins {re := real(scratch[k])im := imag(scratch[k])power[k] = re*re + im*im}}
package utilsimport ("bytes""sync""github.com/ebitengine/oto/v3")// AudioPlayer wraps oto for simple audio playback.// The oto context is created once and reused across plays.type AudioPlayer struct {ctx *oto.Contextmu sync.Mutexplayer *oto.Player}// NewAudioPlayer creates a new audio player with the given sample rate.// Only one AudioPlayer should exist per process (oto allows one context).func NewAudioPlayer(sampleRate int) (*AudioPlayer, error) {op := &oto.NewContextOptions{SampleRate: sampleRate,ChannelCount: 1,Format: oto.FormatSignedInt16LE,}ctx, readyChan, err := oto.NewContext(op)if err != nil {return nil, err}<-readyChanreturn &AudioPlayer{ctx: ctx}, nil}// Play stops any current playback and starts playing the given samples.// Samples are float64 in the range -1.0 to 1.0.// Playback is non-blocking — audio plays in the background.func (ap *AudioPlayer) Play(samples []float64, sampleRate int) {ap.PlayAtSpeed(samples, sampleRate, 1.0)}// PlayAtSpeed plays samples at the given speed (1.0 = normal, 0.5 = half speed).// Speed change is achieved by resampling the audio.// Playback is non-blocking — audio plays in the background.func (ap *AudioPlayer) PlayAtSpeed(samples []float64, sampleRate int, speed float64) {ap.mu.Lock()defer ap.mu.Unlock()// Stop previous playbackif ap.player != nil {ap.player.Pause()ap.player = nil}// Resample if speed is not normalif speed != 1.0 {samples = Resample(samples, speed)}// Convert float64 samples to signed int16 LE bytesbuf := Float64ToPCM16(samples)ap.player = ap.ctx.NewPlayer(bytes.NewReader(buf))ap.player.Play()}// IsPlaying returns true if audio is currently playing.func (ap *AudioPlayer) IsPlaying() bool {ap.mu.Lock()defer ap.mu.Unlock()return ap.player != nil && ap.player.IsPlaying()}// Stop stops any current playback.func (ap *AudioPlayer) Stop() {ap.mu.Lock()defer ap.mu.Unlock()if ap.player != nil {ap.player.Pause()ap.player = nil}}// Close stops playback. The oto context is released by the garbage collector// (oto v3 does not expose a context close method).
if sampleRate > DefaultMaxSampleRate {segSamples = ResampleRate(segSamples, sampleRate, DefaultMaxSampleRate)sampleRate = DefaultMaxSampleRate
if sampleRate > audio.DefaultMaxSampleRate {segSamples = audio.ResampleRate(segSamples, sampleRate, audio.DefaultMaxSampleRate)sampleRate = audio.DefaultMaxSampleRate
if sampleRate > utils.DefaultMaxSampleRate {segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)sampleRate = utils.DefaultMaxSampleRate
if sampleRate > audio.DefaultMaxSampleRate {segSamples = audio.ResampleRate(segSamples, sampleRate, audio.DefaultMaxSampleRate)sampleRate = audio.DefaultMaxSampleRate
if sampleRate > utils.DefaultMaxSampleRate {segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)sampleRate = utils.DefaultMaxSampleRate
if sampleRate > audio.DefaultMaxSampleRate {segSamples = audio.ResampleRate(segSamples, sampleRate, audio.DefaultMaxSampleRate)sampleRate = audio.DefaultMaxSampleRate
package audioimport ("math""testing")func TestResampleRate(t *testing.T) {t.Run("should return same samples for same rate", func(t *testing.T) {samples := []float64{0.1, 0.2, 0.3, 0.4, 0.5}result := ResampleRate(samples, 16000, 16000)if len(result) != len(samples) {t.Errorf("length mismatch: got %d, want %d", len(result), len(samples))}for i := range samples {if result[i] != samples[i] {t.Errorf("sample %d mismatch: got %f, want %f", i, result[i], samples[i])}}})t.Run("should downsample from 250000 to 16000", func(t *testing.T) {// 250000 / 16000 = 15.625 ratiosamples := make([]float64, 2500) // 0.01 seconds at 250kHzfor i := range samples {samples[i] = float64(i) / float64(len(samples))}result := ResampleRate(samples, 250000, 16000)expectedLen := 160 // 0.01 seconds at 16kHzif len(result) != expectedLen {t.Errorf("length mismatch: got %d, want %d", len(result), expectedLen)}})t.Run("should downsample from 44100 to 16000", func(t *testing.T) {// 44100 / 16000 = 2.75625 ratiosamples := make([]float64, 441) // 0.01 seconds at 44.1kHzfor i := range samples {samples[i] = float64(i) / float64(len(samples))}result := ResampleRate(samples, 44100, 16000)expectedLen := 160 // 0.01 seconds at 16kHzif len(result) != expectedLen {t.Errorf("length mismatch: got %d, want %d", len(result), expectedLen)}})t.Run("should preserve signal shape", func(t *testing.T) {// Create a simple ramp signalsamples := []float64{0.0, 0.25, 0.5, 0.75, 1.0}result := ResampleRate(samples, 50000, 16000)// Should still be a roughly increasing signalfor i := 1; i < len(result); i++ {if result[i] < result[i-1]-0.1 {t.Errorf("signal not preserved: result[%d]=%f < result[%d]=%f", i, result[i], i-1, result[i-1])}}})t.Run("should handle empty samples", func(t *testing.T) {result := ResampleRate([]float64{}, 44100, 16000)if len(result) != 0 {t.Errorf("expected empty result, got %d samples", len(result))}})}func TestResample(t *testing.T) {t.Run("should return same samples for speed 1.0", func(t *testing.T) {samples := []float64{0.1, 0.2, 0.3, 0.4, 0.5}result := Resample(samples, 1.0)if len(result) != len(samples) {t.Errorf("length mismatch: got %d, want %d", len(result), len(samples))}for i := range samples {if result[i] != samples[i] {t.Errorf("sample %d mismatch: got %f, want %f", i, result[i], samples[i])}}})t.Run("should double samples for half speed", func(t *testing.T) {samples := []float64{0.0, 1.0, 0.0, -1.0, 0.0}result := Resample(samples, 0.5)// Half speed = 2x more samplesexpectedLen := len(samples) * 2if len(result) != expectedLen {t.Errorf("length mismatch: got %d, want %d", len(result), expectedLen)}})t.Run("should halve samples for double speed", func(t *testing.T) {samples := []float64{0.0, 0.5, 1.0, 0.5, 0.0, -0.5, -1.0, -0.5, 0.0}result := Resample(samples, 2.0)// Double speed = half the samplesexpectedLen := len(samples) / 2if len(result) != expectedLen {t.Errorf("length mismatch: got %d, want %d", len(result), expectedLen)}})t.Run("should use linear interpolation", func(t *testing.T) {// With samples [0, 1], half-speed should interpolate to [0, 0.5, 1]samples := []float64{0.0, 1.0}result := Resample(samples, 0.5)// Expected: 4 samples (2 / 0.5 = 4)if len(result) != 4 {t.Errorf("length mismatch: got %d, want 4", len(result))}// Check interpolation: index 1 should be ~0.5 (midpoint)expected := 0.5if math.Abs(result[1]-expected) > 0.01 {t.Errorf("interpolated value mismatch: got %f, want ~%f", result[1], expected)}})t.Run("should handle empty samples", func(t *testing.T) {result := Resample([]float64{}, 0.5)if len(result) != 0 {t.Errorf("expected empty result, got %d samples", len(result))}})t.Run("should handle single sample", func(t *testing.T) {samples := []float64{0.5}result := Resample(samples, 0.5)// 1 / 0.5 = 2 samplesif len(result) != 2 {t.Errorf("length mismatch: got %d, want 2", len(result))}})}func TestResampleQuality(t *testing.T) {t.Run("should preserve zero crossings", func(t *testing.T) {// Sine wave: should have zero crossings at multiples of pisampleRate := 1000samples := make([]float64, sampleRate)for i := range samples {samples[i] = math.Sin(2 * math.Pi * float64(i) / float64(sampleRate))}// Resample to half speedresult := Resample(samples, 0.5)// First sample should still be ~0 (sine at 0)if math.Abs(result[0]) > 0.01 {t.Errorf("first sample not near zero: got %f", result[0])}// Peak should still be ~1.0 (sine max)peakFound := falsefor _, s := range result {if math.Abs(s-1.0) < 0.1 {peakFound = truebreak}}if !peakFound {t.Error("peak not preserved in resampled signal")}})}
package audio// DefaultMaxSampleRate is the maximum sample rate for spectrograms.const DefaultMaxSampleRate = 16000// ResampleRate converts samples from one sample rate to another using linear interpolation.// This is used to downsample high sample rate audio for spectrogram visualization.// fromRate: original sample rate (e.g., 250000)// toRate: target sample rate (e.g., 16000)func ResampleRate(samples []float64, fromRate, toRate int) []float64 {if fromRate == toRate || len(samples) == 0 {return samples}// speed = fromRate/toRate: e.g. 250000/16000 = 15.625 (skip samples to downsample)return Resample(samples, float64(fromRate)/float64(toRate))}// Resample changes playback speed using linear interpolation.// speed > 1.0 = faster (fewer samples), speed < 1.0 = slower (more samples).// For half-speed playback, use speed=0.5 which doubles the sample count.func Resample(samples []float64, speed float64) []float64 {if speed == 1.0 || len(samples) == 0 {return samples}// Calculate new length: slower speed = more samplesnewLen := int(float64(len(samples)) / speed)if newLen <= 0 {return samples}result := make([]float64, newLen)for i := range newLen {// Source index in original samples (floating point)srcIdx := float64(i) * speedidx0 := int(srcIdx)idx1 := idx0 + 1// Clamp to valid rangeif idx0 >= len(samples) {idx0 = len(samples) - 1}if idx1 >= len(samples) {idx1 = len(samples) - 1}// Linear interpolation between adjacent samplesfrac := srcIdx - float64(idx0)result[i] = samples[idx0]*(1-frac) + samples[idx1]*frac}return result}
package audioimport ("math""math/rand""testing""github.com/madelynnblue/go-dsp/fft")// referencepower computes the power spectrum using go-dsp as ground truth.func referencePower(samples []float64) []float64 {result := fft.FFTReal(samples)n := len(samples)numBins := n/2 + 1power := make([]float64, numBins)for k := range numBins {re := real(result[k])im := imag(result[k])power[k] = re*re + im*im}return power}func TestPowerSpectrumFFT_Sinusoid(t *testing.T) {// 512-point FFT of a pure 1kHz sine at 16kHz sample rate// Expected: peak at bin k = 1000 * 512 / 16000 = 32n := 512sampleRate := 16000.0freq := 1000.0samples := make([]float64, n)for i := range samples {samples[i] = math.Sin(2.0 * math.Pi * freq * float64(i) / sampleRate)}power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)// Find peak binmaxBin := 0maxVal := 0.0for k, v := range power {if v > maxVal {maxVal = vmaxBin = k}}expectedBin := int(freq * float64(n) / sampleRate)if maxBin != expectedBin {t.Errorf("peak at bin %d, expected %d", maxBin, expectedBin)}// Compare against referenceref := referencePower(samples)for k := range power {if math.Abs(power[k]-ref[k]) > 1e-6*math.Abs(ref[k])+1e-10 {t.Errorf("bin %d: got %g, ref %g", k, power[k], ref[k])}}}func TestPowerSpectrumFFT_Random(t *testing.T) {n := 512rng := rand.New(rand.NewSource(42))samples := make([]float64, n)for i := range samples {samples[i] = rng.Float64()*2 - 1}power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)ref := referencePower(samples)for k := range power {relErr := math.Abs(power[k]-ref[k]) / (math.Abs(ref[k]) + 1e-15)if relErr > 1e-8 {t.Errorf("bin %d: got %g, ref %g (relErr=%g)", k, power[k], ref[k], relErr)}}}func TestPowerSpectrumFFT_DC(t *testing.T) {n := 512samples := make([]float64, n)for i := range samples {samples[i] = 1.0}power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)ref := referencePower(samples)for k := range power {if math.Abs(power[k]-ref[k]) > 1e-6 {t.Errorf("bin %d: got %g, ref %g", k, power[k], ref[k])}}// DC bin should have all the energyif power[0] < power[1]*1000 {t.Errorf("DC bin should dominate: power[0]=%g, power[1]=%g", power[0], power[1])}}func TestPowerSpectrumFFT_Silence(t *testing.T) {n := 512samples := make([]float64, n)power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)for k, v := range power {if v != 0 {t.Errorf("bin %d: expected 0, got %g", k, v)}}}func TestPowerSpectrumFFT_Impulse(t *testing.T) {n := 512samples := make([]float64, n)samples[0] = 1.0power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)ref := referencePower(samples)for k := range power {if math.Abs(power[k]-ref[k]) > 1e-10 {t.Errorf("bin %d: got %g, ref %g", k, power[k], ref[k])}}// Impulse: flat power spectrum, all bins should be equal (= 1.0)for k, v := range power {if math.Abs(v-1.0) > 1e-10 {t.Errorf("bin %d: expected ~1.0, got %g", k, v)}}}func TestPowerSpectrumFFT_DifferentSizes(t *testing.T) {rng := rand.New(rand.NewSource(99))for _, n := range []int{2, 4, 8, 16, 64, 256, 1024} {samples := make([]float64, n)for i := range samples {samples[i] = rng.Float64()*2 - 1}power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)ref := referencePower(samples)for k := range power {relErr := math.Abs(power[k]-ref[k]) / (math.Abs(ref[k]) + 1e-15)if relErr > 1e-8 {t.Errorf("n=%d bin %d: got %g, ref %g (relErr=%g)", n, k, power[k], ref[k], relErr)}}}}func BenchmarkPowerSpectrumFFT_512(b *testing.B) {n := 512rng := rand.New(rand.NewSource(42))samples := make([]float64, n)for i := range samples {samples[i] = rng.Float64()*2 - 1}power := make([]float64, n/2+1)scratch := make([]complex128, n)b.ResetTimer()for range b.N {PowerSpectrumFFT(samples, power, scratch)}}func BenchmarkGodsFFTReal_512(b *testing.B) {n := 512rng := rand.New(rand.NewSource(42))samples := make([]float64, n)for i := range samples {samples[i] = rng.Float64()*2 - 1}b.ResetTimer()for range b.N {fft.FFTReal(samples)}}
package audioimport ("math""sync")// FFT twiddle factors and bit-reversal tables, cached per size.var (fftCacheMu sync.RWMutexfftCache = map[int]*fftPlan{})// fftPlan holds pre-computed data for a given FFT size.type fftPlan struct {n inttwiddle []complex128 // twiddle factors: exp(-2*pi*i*k/N) for k=0..N/2-1bitrev []int // bit-reversal permutation table}// getFFTPlan returns a cached FFT plan for the given size (must be power of 2).func getFFTPlan(n int) *fftPlan {fftCacheMu.RLock()if p, ok := fftCache[n]; ok {fftCacheMu.RUnlock()return p}fftCacheMu.RUnlock()fftCacheMu.Lock()defer fftCacheMu.Unlock()if p, ok := fftCache[n]; ok {return p}p := &fftPlan{n: n}// Compute twiddle factors: exp(-2*pi*i*k/N) for k = 0..N/2-1p.twiddle = make([]complex128, n/2)for k := range p.twiddle {angle := -2.0 * math.Pi * float64(k) / float64(n)sin, cos := math.Sincos(angle)p.twiddle[k] = complex(cos, sin)}// Compute bit-reversal permutationbits := 0for v := n; v > 1; v >>= 1 {bits++}p.bitrev = make([]int, n)for i := range p.bitrev {p.bitrev[i] = reverseBitsN(i, bits)}fftCache[n] = preturn p}// reverseBitsN reverses the lowest `bits` bits of v.func reverseBitsN(v, bits int) int {var r intfor range bits {r = (r << 1) | (v & 1)v >>= 1}return r}// PowerSpectrumFFT computes the power spectrum of a real-valued signal using radix-2 FFT.//// samples: real input of length N (must be power of 2, N >= 2)// power: output buffer of length >= N/2+1; receives |X[k]|^2 for k=0..N/2// scratch: working buffer of length >= N; contents are overwritten//// All buffers are caller-provided to enable zero-allocation across repeated calls.func PowerSpectrumFFT(samples []float64, power []float64, scratch []complex128) {n := len(samples)plan := getFFTPlan(n)// Bit-reversal copy: load real samples into scratch in bit-reversed orderfor i, j := range plan.bitrev {scratch[j] = complex(samples[i], 0)}// Iterative Cooley-Tukey butterfly (decimation-in-time)for size := 2; size <= n; size <<= 1 {half := size >> 1step := n / size // twiddle index stepfor start := 0; start < n; start += size {tw := 0for j := range half {u := scratch[start+j]v := scratch[start+j+half] * plan.twiddle[tw]scratch[start+j] = u + vscratch[start+j+half] = u - vtw += step}}}// Extract power spectrum: |X[k]|^2 = re^2 + im^2 for k = 0..N/2numBins := n/2 + 1for k := range numBins {re := real(scratch[k])im := imag(scratch[k])power[k] = re*re + im*im}}
package audioimport ("math""testing")func TestBandpassShiftFilter_BasicShift(t *testing.T) {// Generate a signal with a tone at 10000 Hz, sample rate 48000sampleRate := 48000duration := 0.05numSamples := int(float64(sampleRate) * duration)audio := make([]float64, numSamples)for i := range audio {ts := float64(i) / float64(sampleRate)audio[i] = math.Sin(2 * math.Pi * 10000 * ts)}// Bandpass 8000-12000, shift to baseband// The 10000 Hz tone should shift to 2000 Hz (10000 - 8000)filtered, newRate := BandpassShiftFilter(audio, sampleRate, 8000, 12000)bandwidth := 12000 - 8000 // 4000 HzexpectedRate := 2 * bandwidth // 8000 Hzif newRate != expectedRate {t.Errorf("newRate = %d, want %d", newRate, expectedRate)}// Check that the shifted tone is at 2000 Hz in the filtered signalpower := computeBinPowerAtFreq(filtered, newRate, 2000)totalPower := signalPower(filtered)if totalPower == 0 {t.Fatal("filtered signal has no power")}// The 2000 Hz bin should contain significant energyratio := power / totalPowerif ratio < 0.1 {t.Errorf("shifted 10kHz→2kHz bin has only %.1f%% of total power, want > 10%%", ratio*100)}}func TestBandpassShiftFilter_RemovesOutOfBand(t *testing.T) {// Generate a signal with tones at 1000 Hz (out of band) and 10000 Hz (in band)sampleRate := 48000duration := 0.05numSamples := int(float64(sampleRate) * duration)audio := make([]float64, numSamples)for i := range audio {ts := float64(i) / float64(sampleRate)audio[i] = math.Sin(2*math.Pi*1000*ts) + math.Sin(2*math.Pi*10000*ts)}filtered, newRate := BandpassShiftFilter(audio, sampleRate, 8000, 12000)// The 1000 Hz tone (out of band) should be removed// After shift, in-band 10kHz→2kHz, out-of-band 1kHz→-7kHz (removed)inBandPower := computeBinPowerAtFreq(filtered, newRate, 2000)outBandPower := computeBinPowerAtFreq(filtered, newRate, 7000) // 1kHz shifted = not presentif outBandPower > inBandPower*0.1 {t.Errorf("out-of-band leakage: outBand=%.6f, inBand=%.6f", outBandPower, inBandPower)}}func TestBandpassShiftFilter_EmptyInput(t *testing.T) {result, rate := BandpassShiftFilter([]float64{}, 48000, 1000, 8000)if len(result) != 0 {t.Errorf("expected empty result for empty input, got %d samples", len(result))}if rate != 48000 {t.Errorf("rate = %d, want 48000", rate)}}func TestBandpassShiftFilter_DownsampleRate(t *testing.T) {// 250kHz audio, bandpass 8000-24000 → bandwidth 16000 → newRate 32000audio := make([]float64, 2500) // tiny signalfiltered, newRate := BandpassShiftFilter(audio, 250000, 8000, 24000)expectedRate := 32000if newRate != expectedRate {t.Errorf("newRate = %d, want %d", newRate, expectedRate)}// Output should be downsampled: 2500 samples at 250kHz → ~320 samples at 32kHzexpectedSamples := int(float64(len(audio)) * float64(expectedRate) / 250000)if absInt(len(filtered)-expectedSamples) > 2 {t.Errorf("output samples = %d, want ~%d", len(filtered), expectedSamples)}}func TestBandpassShiftFilter_NarrowBand(t *testing.T) {// Bat vocalisations: bandpass 40000-56000 → bandwidth 16000 → newRate 32000audio := make([]float64, 5000)sampleRate := 250000for i := range audio {ts := float64(i) / float64(sampleRate)audio[i] = math.Sin(2 * math.Pi * 48000 * ts)}filtered, newRate := BandpassShiftFilter(audio, sampleRate, 40000, 56000)if newRate != 32000 {t.Errorf("newRate = %d, want 32000", newRate)}// 48kHz tone shifted to 8kHz (48000 - 40000)power := computeBinPowerAtFreq(filtered, newRate, 8000)totalPower := signalPower(filtered)if totalPower == 0 {t.Fatal("filtered signal has no power")}ratio := power / totalPowerif ratio < 0.05 {t.Errorf("shifted 48kHz→8kHz bin has only %.1f%% of total power, want > 5%%", ratio*100)}}func TestNextPowerOf2(t *testing.T) {tests := []struct {input, want int}{{0, 1},{1, 1},{2, 2},{3, 4},{5, 8},{100, 128},{1024, 1024},{1025, 2048},}for _, tt := range tests {got := nextPowerOf2(tt.input)if got != tt.want {t.Errorf("nextPowerOf2(%d) = %d, want %d", tt.input, got, tt.want)}}}// computeBinPowerAtFreq uses our FFT to compute power at a specific frequency.func computeBinPowerAtFreq(samples []float64, sampleRate int, freqHz float64) float64 {n := nextPowerOf2(len(samples))padded := make([]float64, n)copy(padded, samples)power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(padded, power, scratch)bin := int(freqHz * float64(n) / float64(sampleRate))if bin >= len(power) {return 0}return power[bin]}// signalPower returns the total power of a signal.func signalPower(samples []float64) float64 {power := 0.0for _, s := range samples {power += s * s}return power}// absInt returns the absolute value of an int.func absInt(x int) int {if x < 0 {return -x}return x}func BenchmarkBandpassShiftFilter(b *testing.B) {sampleRate := 250000numSamples := int(float64(sampleRate) * 5.0) // 5 secondsaudio := make([]float64, numSamples)for i := range audio {ts := float64(i) / float64(sampleRate)audio[i] = math.Sin(2*math.Pi*1000*ts) + math.Sin(2*math.Pi*15000*ts)}b.ResetTimer()for i := 0; i < b.N; i++ {BandpassShiftFilter(audio, sampleRate, 8000, 24000)}}
package audioimport ("github.com/madelynnblue/go-dsp/fft")// BandpassShiftFilter applies a bandpass filter retaining frequencies between// lowFreq and highFreq, shifts the retained band down to baseband (0 Hz),// and downsamples to 2*(highFreq-lowFreq) Hz.// Returns the processed samples and the new sample rate.//// For example, with --bandpass 8000-24000 on 250kHz audio:// - Bandpass keeps only 8-24kHz content// - Shift down by 8kHz so content is at 0-16kHz// - Downsample from 250kHz to 32kHz// - Spectrogram shows the 8-24kHz band as if it were 0-16kHzfunc BandpassShiftFilter(audio []float64, sampleRate int, lowFreq, highFreq float64) ([]float64, int) {n := len(audio)if n == 0 {return audio, sampleRate}paddedLen := nextPowerOf2(n)padded := make([]float64, paddedLen)copy(padded, audio)spectrum := fft.FFTReal(padded)lowBin := int(lowFreq * float64(paddedLen) / float64(sampleRate))highBin := int(highFreq * float64(paddedLen) / float64(sampleRate))bandBins := highBin - lowBin// Shift spectrum down by lowBin: bin k gets content from original bin (k + lowBin)// Keep only bins within the passband, enforce conjugate symmetry for real output.shifted := make([]complex128, paddedLen)for k := 0; k <= paddedLen/2; k++ {if k <= bandBins {srcBin := k + lowBinif srcBin >= 0 && srcBin <= paddedLen/2 {shifted[k] = spectrum[srcBin]}}// Conjugate symmetry: shifted[N-k] = conj(shifted[k])if k > 0 && k < paddedLen/2 {shifted[paddedLen-k] = complex(real(shifted[k]), -imag(shifted[k]))}}filtered := fft.IFFT(shifted)result := make([]float64, n)for i := range result {result[i] = real(filtered[i])}// Downsample to 2 * bandwidthbandwidth := highFreq - lowFreqnewRate := int(2 * bandwidth)if newRate >= sampleRate {return result, sampleRate}result = ResampleRate(result, sampleRate, newRate)return result, newRate}// nextPowerOf2 returns the smallest power of 2 >= n.func nextPowerOf2(n int) int {if n <= 0 {return 1}n--n |= n >> 1n |= n >> 2n |= n >> 4n |= n >> 8n |= n >> 16n |= n >> 32return n + 1}
package audioimport ("bytes""sync""github.com/ebitengine/oto/v3")// AudioPlayer wraps oto for simple audio playback.// The oto context is created once and reused across plays.type AudioPlayer struct {ctx *oto.Contextmu sync.Mutexplayer *oto.Player}// NewAudioPlayer creates a new audio player with the given sample rate.// Only one AudioPlayer should exist per process (oto allows one context).func NewAudioPlayer(sampleRate int) (*AudioPlayer, error) {op := &oto.NewContextOptions{SampleRate: sampleRate,ChannelCount: 1,Format: oto.FormatSignedInt16LE,}ctx, readyChan, err := oto.NewContext(op)if err != nil {return nil, err}<-readyChanreturn &AudioPlayer{ctx: ctx}, nil}// Play stops any current playback and starts playing the given samples.// Samples are float64 in the range -1.0 to 1.0.// Playback is non-blocking — audio plays in the background.func (ap *AudioPlayer) Play(samples []float64, sampleRate int) {ap.PlayAtSpeed(samples, sampleRate, 1.0)}// PlayAtSpeed plays samples at the given speed (1.0 = normal, 0.5 = half speed).// Speed change is achieved by resampling the audio.// Playback is non-blocking — audio plays in the background.func (ap *AudioPlayer) PlayAtSpeed(samples []float64, sampleRate int, speed float64) {ap.mu.Lock()defer ap.mu.Unlock()// Stop previous playbackif ap.player != nil {ap.player.Pause()ap.player = nil}// Resample if speed is not normalif speed != 1.0 {samples = Resample(samples, speed)}// Convert float64 samples to signed int16 LE bytesbuf := Float64ToPCM16(samples)ap.player = ap.ctx.NewPlayer(bytes.NewReader(buf))ap.player.Play()}// IsPlaying returns true if audio is currently playing.func (ap *AudioPlayer) IsPlaying() bool {ap.mu.Lock()defer ap.mu.Unlock()return ap.player != nil && ap.player.IsPlaying()}// Stop stops any current playback.func (ap *AudioPlayer) Stop() {ap.mu.Lock()defer ap.mu.Unlock()if ap.player != nil {ap.player.Pause()ap.player = nil}}// Close stops playback. The oto context is released by the garbage collector// (oto v3 does not expose a context close method).
package audioimport "testing"func TestFloat64ToPCM16(t *testing.T) {samples := []float64{0.0, 1.0, -1.0, 1.5, -1.5}bytes := Float64ToPCM16(samples)if len(bytes) != len(samples)*2 {t.Fatalf("expected length %d, got %d", len(samples)*2, len(bytes))}}
package audioimport "encoding/binary"// Float64ToPCM16 converts float64 samples [-1.0, 1.0] to signed 16-bit PCM (LittleEndian) bytes.func Float64ToPCM16(samples []float64) []byte {buf := make([]byte, len(samples)*2)for i, sample := range samples {// Clamp to [-1.0, 1.0]if sample > 1.0 {sample = 1.0} else if sample < -1.0 {sample = -1.0}// Convert to 16-bit PCMbinary.LittleEndian.PutUint16(buf[i*2:], uint16(int16(sample*32767)))}return buf}
## [2026-05-19] Extract audio/ package (Phase 2)- **Created `audio/` package** with 5 files from `utils/`:- `audio_convert.go` — `Float64ToPCM16`- `audio_player.go` — `AudioPlayer`, `NewAudioPlayer`- `bandpass.go` — `BandpassShiftFilter`- `fft.go` — `PowerSpectrumFFT`- `resample.go` — `Resample`, `ResampleRate`, `DefaultMaxSampleRate` (moved from `spectrogram.go`)- Moved 4 test files to `audio/`- Updated callers: `tui/update.go`, `tools/calls/calls_classify.go`, `tools/calls/calls_clip.go`, `tools/calls/calls_clip_bench_test.go`- Updated `utils/spectrogram.go` — imports `audio/` for `PowerSpectrumFFT`, `ResampleRate`, `DefaultMaxSampleRate`- Updated `utils/wav_writer.go` — imports `audio/` for `Float64ToPCM16`- `utils/` shrinks from 3,259 → 2,914 LOC (24 → 19 non-test files)