second phase of utils refactor, audio/

quietlight
May 18, 2026, 10:46 PM
N57PNZPFM6QU5FK4SHC3473IV6IN3HRVKSPZSFIJJ5LLCAXICNIAC

Dependencies

  • [2] SIH47AMT ignore
  • [3] 3ETJ6KPI refactor of tui/ second iteration
  • [4] NQPVZ3PP first phase of utils refactor, all realted to db interfaces
  • [5] 2Y5U3QPU added gosymdb
  • [6] VU3KBTQ6 more tests
  • [7] TUC452XH new util shared by 3 cmd's needing location
  • [8] ZKLAOPUR fix event logging
  • [9] I4CMOMXF dot files
  • [10] HCOBJB6W ck 4
  • [11] 43TMU2JO more tests, glm much better than claude
  • [12] 3DVPQOKB big tidy up of tools/
  • [13] A6MCX2V6 emptied audio/ and moved files into testdata folders
  • [14] LBWQJEDH minor refactor and more tests for utils/
  • [15] Q4JPMGET fixed tests
  • [16] 2HAQZPV3 more refactoring with glm
  • [17] FCCJNYCV more tests for utils/
  • [18] JZRF7OBJ refactor to get db omports out of utils, but still have failing tests, may need updating
  • [19] YVFPP5VJ refactor of tui/ first iteration
  • [20] QVIGQOQZ more work on utils/ with glm
  • [21] P4CJMBYK added first version of --bandpass flag to calls classify, work to do
  • [22] KZKLAINJ run out of space on nest, cleaned out
  • [23] NUOFNUIQ simplified --bandpass
  • [*] SJN7IKIV

Change contents

  • file deletion: bandpass_test.go (----------)
    [5.1][5.4996:5036](),[5.5036][5.1:1]()
    package utils
    import (
    "math"
    "testing"
    )
    sampleRate := 48000
    numSamples := 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 / totalPower
    if 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.0
    for _, s := range samples {
    power += s * s
    }
    return power
    }
    sampleRate := 250000
    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*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 32000
    audio := make([]float64, 5000)
    sampleRate := 250000
    // Output should be downsampled: 2500 samples at 250kHz → ~320 samples at 32kHz
    expectedSamples := 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 32000
    audio := make([]float64, 2500) // tiny signal
    filtered, newRate := BandpassShiftFilter(audio, 250000, 8000, 24000)
    expectedRate := 32000
    if 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 present
    if 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 := 48000
    duration := 0.05
    numSamples := int(float64(sampleRate) * duration)
    bandwidth := 12000 - 8000 // 4000 Hz
    expectedRate := 2 * bandwidth // 8000 Hz
    if newRate != expectedRate {
    t.Errorf("newRate = %d, want %d", newRate, expectedRate)
    }
    // Check that the shifted tone is at 2000 Hz in the filtered signal
    power := computeBinPowerAtFreq(filtered, newRate, 2000)
    totalPower := signalPower(filtered)
    if totalPower == 0 {
    t.Fatal("filtered signal has no power")
    }
    // The 2000 Hz bin should contain significant energy
    ratio := power / totalPower
    if 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.05
    func TestBandpassShiftFilter_BasicShift(t *testing.T) {
    // Generate a signal with a tone at 10000 Hz, sample rate 48000
  • file deletion: audio_convert_test.go (----------)
    [5.1][5.6491:6536](),[5.6536][5.6223:6223]()
    package utils
    import "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))
    }
    }
  • file deletion: audio_convert.go (----------)
    [5.1][5.7025:7065](),[5.7065][5.6538:6538]()
    package utils
    import "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 PCM
    binary.LittleEndian.PutUint16(buf[i*2:], uint16(int16(sample*32767)))
    }
    return buf
    }
  • file deletion: bandpass.go (----------)
    [5.1][5.8186:8221](),[5.8221][5.5038:5038]()
    package utils
    import (
    "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 >> 1
    n |= n >> 2
    n |= n >> 4
    n |= n >> 8
    n |= n >> 16
    n |= n >> 32
    return n + 1
    }
    result = ResampleRate(result, sampleRate, newRate)
    return result, newRate
    // Downsample to 2 * bandwidth
    bandwidth := highFreq - lowFreq
    newRate := int(2 * bandwidth)
    if newRate >= sampleRate {
    return result, sampleRate
    filtered := 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 + lowBin
    if 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 - lowBin
    paddedLen := 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-16kHz
    func BandpassShiftFilter(audio []float64, sampleRate int, lowFreq, highFreq float64) ([]float64, int) {
  • file deletion: resample_test.go (----------)
    [5.1][5.74212:74252](),[5.74252][5.69214:69214]()
    package utils
    import (
    "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 ratio
    samples := make([]float64, 2500) // 0.01 seconds at 250kHz
    for i := range samples {
    samples[i] = float64(i) / float64(len(samples))
    }
    result := ResampleRate(samples, 250000, 16000)
    expectedLen := 160 // 0.01 seconds at 16kHz
    if 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 ratio
    samples := make([]float64, 441) // 0.01 seconds at 44.1kHz
    for i := range samples {
    samples[i] = float64(i) / float64(len(samples))
    }
    result := ResampleRate(samples, 44100, 16000)
    expectedLen := 160 // 0.01 seconds at 16kHz
    if 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 signal
    samples := []float64{0.0, 0.25, 0.5, 0.75, 1.0}
    result := ResampleRate(samples, 50000, 16000)
    // Should still be a roughly increasing signal
    for 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 samples
    expectedLen := len(samples) * 2
    if 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 samples
    expectedLen := len(samples) / 2
    if 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.5
    if 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 samples
    if 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 pi
    sampleRate := 1000
    samples := make([]float64, sampleRate)
    for i := range samples {
    samples[i] = math.Sin(2 * math.Pi * float64(i) / float64(sampleRate))
    }
    // Resample to half speed
    result := 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 := false
    for _, s := range result {
    if math.Abs(s-1.0) < 0.1 {
    peakFound = true
    break
    }
    }
    if !peakFound {
    t.Error("peak not preserved in resampled signal")
    }
    })
    }
  • file deletion: resample.go (----------)
    [5.1][5.76346:76381](),[5.76381][5.74254:74254]()
    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 samples
    newLen := 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) * speed
    idx0 := int(srcIdx)
    idx1 := idx0 + 1
    // Clamp to valid range
    if idx0 >= len(samples) {
    idx0 = len(samples) - 1
    }
    if idx1 >= len(samples) {
    idx1 = len(samples) - 1
    }
    // Linear interpolation between adjacent samples
    frac := srcIdx - float64(idx0)
    result[i] = samples[idx0]*(1-frac) + samples[idx1]*frac
    }
    return result
    }
  • file deletion: fft_test.go (----------)
    [5.1][5.143860:143895](),[5.143895][5.139270:139270]()
    package utils
    import (
    "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 + 1
    power := 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 = 32
    n := 512
    sampleRate := 16000.0
    freq := 1000.0
    samples := 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 bin
    maxBin := 0
    maxVal := 0.0
    for k, v := range power {
    if v > maxVal {
    maxVal = v
    maxBin = k
    }
    }
    expectedBin := int(freq * float64(n) / sampleRate)
    if maxBin != expectedBin {
    t.Errorf("peak at bin %d, expected %d", maxBin, expectedBin)
    }
    // Compare against reference
    ref := 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 := 512
    rng := 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 := 512
    samples := 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 energy
    if 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 := 512
    samples := 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 := 512
    samples := make([]float64, n)
    samples[0] = 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-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 := 512
    rng := 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 := 512
    rng := 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)
    }
    }
  • file deletion: fft.go (----------)
    [5.1][5.146547:146577](),[5.146577][5.143897:143897]()
    package utils
    import (
    "math"
    "sync"
    )
    // FFT twiddle factors and bit-reversal tables, cached per size.
    var (
    fftCacheMu sync.RWMutex
    fftCache = map[int]*fftPlan{}
    )
    // fftPlan holds pre-computed data for a given FFT size.
    type fftPlan struct {
    n int
    twiddle []complex128 // twiddle factors: exp(-2*pi*i*k/N) for k=0..N/2-1
    bitrev []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-1
    p.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 permutation
    bits := 0
    for 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] = p
    return p
    }
    // reverseBitsN reverses the lowest `bits` bits of v.
    func reverseBitsN(v, bits int) int {
    var r int
    for 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 order
    for 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 >> 1
    step := n / size // twiddle index step
    for start := 0; start < n; start += size {
    tw := 0
    for j := range half {
    u := scratch[start+j]
    v := scratch[start+j+half] * plan.twiddle[tw]
    scratch[start+j] = u + v
    scratch[start+j+half] = u - v
    tw += step
    }
    }
    }
    // Extract power spectrum: |X[k]|^2 = re^2 + im^2 for k = 0..N/2
    numBins := n/2 + 1
    for k := range numBins {
    re := real(scratch[k])
    im := imag(scratch[k])
    power[k] = re*re + im*im
    }
    }
  • file deletion: audio_player.go (----------)
    [5.1][5.216299:216338](),[5.216338][5.213885:213885]()
    package utils
    import (
    "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.Context
    mu sync.Mutex
    player *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
    }
    <-readyChan
    return &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 playback
    if ap.player != nil {
    ap.player.Pause()
    ap.player = nil
    }
    // Resample if speed is not normal
    if speed != 1.0 {
    samples = Resample(samples, speed)
    }
    // Convert float64 samples to signed int16 LE bytes
    buf := 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).
  • edit in utils/wav_writer.go at line 8
    [5.4573]
    [5.4573]
    "skraak/audio"
  • replacement in utils/wav_writer.go at line 59
    [5.6306][5.7070:7103]()
    buf := Float64ToPCM16(samples)
    [5.6306]
    [5.6563]
    buf := audio.Float64ToPCM16(samples)
  • edit in utils/spectrogram.go at line 13
    [5.62826]
    [5.62826]
    "skraak/audio"
  • edit in utils/spectrogram.go at line 42
    [5.63380][5.63380:63563]()
    // DefaultMaxSampleRate is the maximum sample rate for spectrograms.
    // Higher sample rates are downsampled to this rate for better visualization.
    const DefaultMaxSampleRate = 16000
  • replacement in utils/spectrogram.go at line 97
    [5.65324][5.65324:65375]()
    PowerSpectrumFFT(frameData, framePower, scratch)
    [5.65324]
    [5.65375]
    audio.PowerSpectrumFFT(frameData, framePower, scratch)
  • replacement in utils/spectrogram.go at line 238
    [5.68494][5.207:321](),[5.321][3.4036:4072]()
    if sampleRate > DefaultMaxSampleRate {
    segSamples = ResampleRate(segSamples, sampleRate, DefaultMaxSampleRate)
    sampleRate = DefaultMaxSampleRate
    [5.206]
    [5.68649]
    if sampleRate > audio.DefaultMaxSampleRate {
    segSamples = audio.ResampleRate(segSamples, sampleRate, audio.DefaultMaxSampleRate)
    sampleRate = audio.DefaultMaxSampleRate
  • edit in tui/update.go at line 12
    [5.3950]
    [5.3950]
    "skraak/audio"
  • replacement in tui/update.go at line 457
    [3.8824][3.8824:8952]()
    samples, sampleRate = utils.BandpassShiftFilter(samples, sampleRate, m.state.Config.BandpassLow, m.state.Config.BandpassHigh)
    [3.8824]
    [3.8952]
    samples, sampleRate = audio.BandpassShiftFilter(samples, sampleRate, m.state.Config.BandpassLow, m.state.Config.BandpassHigh)
  • edit in tools/calls/calls_clip_bench_test.go at line 9
    [5.248141]
    [5.248141]
    "skraak/audio"
  • replacement in tools/calls/calls_clip_bench_test.go at line 77
    [5.249996][5.249996:250040]()
    utils.ResampleRate(samples, 48000, 16000)
    [5.249996]
    [5.250040]
    audio.ResampleRate(samples, 48000, 16000)
  • replacement in tools/calls/calls_clip_bench_test.go at line 87
    [5.250266][5.250266:250311]()
    utils.ResampleRate(samples, 250000, 16000)
    [5.250266]
    [5.250311]
    audio.ResampleRate(samples, 250000, 16000)
  • replacement in tools/calls/calls_clip_bench_test.go at line 120
    [5.251222][5.251222:251274]()
    utils.PowerSpectrumFFT(frameData, power, scratch)
    [5.251222]
    [5.251274]
    audio.PowerSpectrumFFT(frameData, power, scratch)
  • replacement in tools/calls/calls_clip_bench_test.go at line 263
    [5.255410][5.255410:255468]()
    segSamples = utils.ResampleRate(segSamples, sr, 16000)
    [5.255410]
    [5.255468]
    segSamples = audio.ResampleRate(segSamples, sr, 16000)
  • replacement in tools/calls/calls_clip_bench_test.go at line 288
    [5.256151][5.256151:256209]()
    segSamples = utils.ResampleRate(segSamples, sr, 16000)
    [5.256151]
    [5.256209]
    segSamples = audio.ResampleRate(segSamples, sr, 16000)
  • edit in tools/calls/calls_clip.go at line 11
    [5.257989]
    [5.257989]
    "skraak/audio"
  • replacement in tools/calls/calls_clip.go at line 349
    [3.9548][5.269358:269490](),[5.269358][5.269358:269490](),[5.269490][3.9549:9591]()
    if sampleRate > utils.DefaultMaxSampleRate {
    segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)
    sampleRate = utils.DefaultMaxSampleRate
    [3.9548]
    [5.269538]
    if sampleRate > audio.DefaultMaxSampleRate {
    segSamples = audio.ResampleRate(segSamples, sampleRate, audio.DefaultMaxSampleRate)
    sampleRate = audio.DefaultMaxSampleRate
  • edit in tools/calls/calls_classify.go at line 13
    [5.292173]
    [5.292173]
    "skraak/audio"
  • replacement in tools/calls/calls_classify.go at line 62
    [5.293844][5.293844:293882]()
    Player *utils.AudioPlayer
    [5.293844]
    [5.293882]
    Player *audio.AudioPlayer
  • replacement in tools/calls/calls_classify.go at line 723
    [3.10642][3.10642:10764]()
    segSamples, sampleRate = utils.BandpassShiftFilter(segSamples, sampleRate, s.Config.BandpassLow, s.Config.BandpassHigh)
    [3.10642]
    [3.10764]
    segSamples, sampleRate = audio.BandpassShiftFilter(segSamples, sampleRate, s.Config.BandpassLow, s.Config.BandpassHigh)
  • replacement in tools/calls/calls_classify.go at line 728
    [3.10864][3.10864:11038]()
    if sampleRate > utils.DefaultMaxSampleRate {
    segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)
    sampleRate = utils.DefaultMaxSampleRate
    [3.10864]
    [3.11038]
    if sampleRate > audio.DefaultMaxSampleRate {
    segSamples = audio.ResampleRate(segSamples, sampleRate, audio.DefaultMaxSampleRate)
    sampleRate = audio.DefaultMaxSampleRate
  • replacement in tools/calls/calls_classify.go at line 788
    [3.12734][3.12734:12788]()
    player, err := utils.NewAudioPlayer(playSampleRate)
    [3.12734]
    [3.12788]
    player, err := audio.NewAudioPlayer(playSampleRate)
  • file addition: audio (d--r------)
    [25.1]
  • file addition: resample_test.go (----------)
    [0.1]
    package audio
    import (
    "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 ratio
    samples := make([]float64, 2500) // 0.01 seconds at 250kHz
    for i := range samples {
    samples[i] = float64(i) / float64(len(samples))
    }
    result := ResampleRate(samples, 250000, 16000)
    expectedLen := 160 // 0.01 seconds at 16kHz
    if 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 ratio
    samples := make([]float64, 441) // 0.01 seconds at 44.1kHz
    for i := range samples {
    samples[i] = float64(i) / float64(len(samples))
    }
    result := ResampleRate(samples, 44100, 16000)
    expectedLen := 160 // 0.01 seconds at 16kHz
    if 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 signal
    samples := []float64{0.0, 0.25, 0.5, 0.75, 1.0}
    result := ResampleRate(samples, 50000, 16000)
    // Should still be a roughly increasing signal
    for 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 samples
    expectedLen := len(samples) * 2
    if 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 samples
    expectedLen := len(samples) / 2
    if 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.5
    if 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 samples
    if 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 pi
    sampleRate := 1000
    samples := make([]float64, sampleRate)
    for i := range samples {
    samples[i] = math.Sin(2 * math.Pi * float64(i) / float64(sampleRate))
    }
    // Resample to half speed
    result := 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 := false
    for _, s := range result {
    if math.Abs(s-1.0) < 0.1 {
    peakFound = true
    break
    }
    }
    if !peakFound {
    t.Error("peak not preserved in resampled signal")
    }
    })
    }
  • file addition: resample.go (----------)
    [0.1]
    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 samples
    newLen := 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) * speed
    idx0 := int(srcIdx)
    idx1 := idx0 + 1
    // Clamp to valid range
    if idx0 >= len(samples) {
    idx0 = len(samples) - 1
    }
    if idx1 >= len(samples) {
    idx1 = len(samples) - 1
    }
    // Linear interpolation between adjacent samples
    frac := srcIdx - float64(idx0)
    result[i] = samples[idx0]*(1-frac) + samples[idx1]*frac
    }
    return result
    }
  • file addition: fft_test.go (----------)
    [0.1]
    package audio
    import (
    "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 + 1
    power := 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 = 32
    n := 512
    sampleRate := 16000.0
    freq := 1000.0
    samples := 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 bin
    maxBin := 0
    maxVal := 0.0
    for k, v := range power {
    if v > maxVal {
    maxVal = v
    maxBin = k
    }
    }
    expectedBin := int(freq * float64(n) / sampleRate)
    if maxBin != expectedBin {
    t.Errorf("peak at bin %d, expected %d", maxBin, expectedBin)
    }
    // Compare against reference
    ref := 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 := 512
    rng := 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 := 512
    samples := 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 energy
    if 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 := 512
    samples := 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 := 512
    samples := make([]float64, n)
    samples[0] = 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-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 := 512
    rng := 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 := 512
    rng := 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)
    }
    }
  • file addition: fft.go (----------)
    [0.1]
    package audio
    import (
    "math"
    "sync"
    )
    // FFT twiddle factors and bit-reversal tables, cached per size.
    var (
    fftCacheMu sync.RWMutex
    fftCache = map[int]*fftPlan{}
    )
    // fftPlan holds pre-computed data for a given FFT size.
    type fftPlan struct {
    n int
    twiddle []complex128 // twiddle factors: exp(-2*pi*i*k/N) for k=0..N/2-1
    bitrev []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-1
    p.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 permutation
    bits := 0
    for 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] = p
    return p
    }
    // reverseBitsN reverses the lowest `bits` bits of v.
    func reverseBitsN(v, bits int) int {
    var r int
    for 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 order
    for 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 >> 1
    step := n / size // twiddle index step
    for start := 0; start < n; start += size {
    tw := 0
    for j := range half {
    u := scratch[start+j]
    v := scratch[start+j+half] * plan.twiddle[tw]
    scratch[start+j] = u + v
    scratch[start+j+half] = u - v
    tw += step
    }
    }
    }
    // Extract power spectrum: |X[k]|^2 = re^2 + im^2 for k = 0..N/2
    numBins := n/2 + 1
    for k := range numBins {
    re := real(scratch[k])
    im := imag(scratch[k])
    power[k] = re*re + im*im
    }
    }
  • file addition: bandpass_test.go (----------)
    [0.1]
    package audio
    import (
    "math"
    "testing"
    )
    func TestBandpassShiftFilter_BasicShift(t *testing.T) {
    // Generate a signal with a tone at 10000 Hz, sample rate 48000
    sampleRate := 48000
    duration := 0.05
    numSamples := 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 Hz
    expectedRate := 2 * bandwidth // 8000 Hz
    if newRate != expectedRate {
    t.Errorf("newRate = %d, want %d", newRate, expectedRate)
    }
    // Check that the shifted tone is at 2000 Hz in the filtered signal
    power := computeBinPowerAtFreq(filtered, newRate, 2000)
    totalPower := signalPower(filtered)
    if totalPower == 0 {
    t.Fatal("filtered signal has no power")
    }
    // The 2000 Hz bin should contain significant energy
    ratio := power / totalPower
    if 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 := 48000
    duration := 0.05
    numSamples := 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 present
    if 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 32000
    audio := make([]float64, 2500) // tiny signal
    filtered, newRate := BandpassShiftFilter(audio, 250000, 8000, 24000)
    expectedRate := 32000
    if newRate != expectedRate {
    t.Errorf("newRate = %d, want %d", newRate, expectedRate)
    }
    // Output should be downsampled: 2500 samples at 250kHz → ~320 samples at 32kHz
    expectedSamples := 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 32000
    audio := make([]float64, 5000)
    sampleRate := 250000
    for 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 / totalPower
    if 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.0
    for _, 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 := 250000
    numSamples := int(float64(sampleRate) * 5.0) // 5 seconds
    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*15000*ts)
    }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
    BandpassShiftFilter(audio, sampleRate, 8000, 24000)
    }
    }
  • file addition: bandpass.go (----------)
    [0.1]
    package audio
    import (
    "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-16kHz
    func 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 + lowBin
    if 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 * bandwidth
    bandwidth := highFreq - lowFreq
    newRate := 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 >> 1
    n |= n >> 2
    n |= n >> 4
    n |= n >> 8
    n |= n >> 16
    n |= n >> 32
    return n + 1
    }
  • file addition: audio_player.go (----------)
    [0.1]
    package audio
    import (
    "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.Context
    mu sync.Mutex
    player *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
    }
    <-readyChan
    return &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 playback
    if ap.player != nil {
    ap.player.Pause()
    ap.player = nil
    }
    // Resample if speed is not normal
    if speed != 1.0 {
    samples = Resample(samples, speed)
    }
    // Convert float64 samples to signed int16 LE bytes
    buf := 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).
  • file addition: audio_convert_test.go (----------)
    [0.1]
    package audio
    import "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))
    }
    }
  • file addition: audio_convert.go (----------)
    [0.1]
    package audio
    import "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 PCM
    binary.LittleEndian.PutUint16(buf[i*2:], uint16(int16(sample*32767)))
    }
    return buf
    }
  • edit in CHANGELOG.md at line 4
    [5.1198010]
    [4.53953]
    ## [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)
  • edit in .ignore at line 8
    [2.176][5.49:56](),[5.49][5.49:56]()
    audio/