simplified --bandpass

quietlight
May 17, 2026, 11:20 PM
NUOFNUIQIKWOBFHMJXY2K4AJGIROGCPFK5NJHR6Y6HYHHCOJPVCAC

Dependencies

  • [2] P4CJMBYK added first version of --bandpass flag to calls classify, work to do
  • [3] JAT3DXOL cyclo over 15
  • [4] 3DVPQOKB big tidy up of tools/
  • [5] ZDZDASRT complexity over 12 now gone, but have some lint fails
  • [6] KZKLAINJ run out of space on nest, cleaned out

Change contents

  • replacement in utils/spectrogram.go at line 194
    [3.67903][2.8236:8497]()
    // bandpassLow and bandpassHigh specify an optional bandpass filter range in Hz (0 = no filter).
    func GenerateSegmentSpectrogram(dataFilePath string, startTime, endTime float64, color bool, imgSize int, bandpassLow, bandpassHigh float64) (image.Image, error) {
    [3.67903]
    [3.68032]
    func GenerateSegmentSpectrogram(dataFilePath string, startTime, endTime float64, color bool, imgSize int) (image.Image, error) {
  • edit in utils/spectrogram.go at line 206
    [3.68397][2.8498:8664]()
    }
    // Apply bandpass filter if specified
    if bandpassLow > 0 || bandpassHigh > 0 {
    segSamples = BandpassFilter(segSamples, sampleRate, bandpassLow, bandpassHigh)
  • replacement in utils/spectrogram.go at line 208
    [3.68401][2.8665:8840]()
    // Determine max sample rate for spectrogram display
    maxRate := BandpassMaxSampleRate(sampleRate, bandpassHigh)
    // For spectrograms, downsample if sample rate exceeds max
    [3.68401]
    [3.68463]
    // For spectrograms, downsample if sample rate exceeds 16kHz
  • replacement in utils/spectrogram.go at line 210
    [3.68494][2.8841:8957]()
    if sampleRate > maxRate {
    segSamples = ResampleRate(segSamples, sampleRate, maxRate)
    spectSampleRate = maxRate
    [3.68494]
    [3.68649]
    if sampleRate > DefaultMaxSampleRate {
    segSamples = ResampleRate(segSamples, sampleRate, DefaultMaxSampleRate)
    spectSampleRate = DefaultMaxSampleRate
  • replacement in utils/bandpass_test.go at line 8
    [2.48][2.48:177]()
    func TestBandpassFilter_BasicFiltering(t *testing.T) {
    // Generate a signal with two frequency components: 1000 Hz and 10000 Hz
    [2.48]
    [2.177]
    func TestBandpassShiftFilter_BasicShift(t *testing.T) {
    // Generate a signal with a tone at 10000 Hz, sample rate 48000
  • replacement in utils/bandpass_test.go at line 11
    [2.198][2.198:224]()
    duration := 0.1 // 100ms
    [2.198]
    [2.224]
    duration := 0.05
  • replacement in utils/bandpass_test.go at line 17
    [2.379][2.379:451]()
    audio[i] = math.Sin(2*math.Pi*1000*ts) + math.Sin(2*math.Pi*10000*ts)
    [2.379]
    [2.451]
    audio[i] = math.Sin(2 * math.Pi * 10000 * ts)
  • replacement in utils/bandpass_test.go at line 20
    [2.455][2.455:595]()
    // Bandpass to keep only 8000-12000 Hz — should remove the 1000 Hz component
    filtered := BandpassFilter(audio, sampleRate, 8000, 12000)
    [2.455]
    [2.595]
    // Bandpass 8000-12000, shift to baseband
    // The 10000 Hz tone should shift to 2000 Hz (10000 - 8000)
    filtered, newRate := BandpassShiftFilter(audio, sampleRate, 8000, 12000)
  • replacement in utils/bandpass_test.go at line 24
    [2.596][2.596:701]()
    if len(filtered) != len(audio) {
    t.Fatalf("filtered length = %d, want %d", len(filtered), len(audio))
    [2.596]
    [2.701]
    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)
  • edit in utils/bandpass_test.go at line 41
    [2.704]
    [2.704]
    }
  • replacement in utils/bandpass_test.go at line 43
    [2.705][2.705:1058]()
    // Verify using our own FFT that the 10000 Hz component is preserved
    // and the 1000 Hz component is suppressed
    origPower := computeBinPower(audio, sampleRate, 1000)
    origPowerHigh := computeBinPower(audio, sampleRate, 10000)
    filtPowerLow := computeBinPower(filtered, sampleRate, 1000)
    filtPowerHigh := computeBinPower(filtered, sampleRate, 10000)
    [2.705]
    [2.1058]
    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)
  • replacement in utils/bandpass_test.go at line 49
    [2.1059][2.1059:1271]()
    // The 10000 Hz component should retain significant energy
    ratioHigh := filtPowerHigh / origPowerHigh
    if ratioHigh < 0.3 {
    t.Errorf("10000 Hz bin retained only %.1f%% of energy, want > 30%%", ratioHigh*100)
    [2.1059]
    [2.1271]
    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)
  • replacement in utils/bandpass_test.go at line 55
    [2.1275][2.1275:1469]()
    // The 1000 Hz component should be strongly suppressed
    ratioLow := filtPowerLow / origPower
    if ratioLow > 0.1 {
    t.Errorf("1000 Hz bin retained %.1f%% of energy, want < 10%%", ratioLow*100)
    [2.1275]
    [2.1469]
    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)
  • replacement in utils/bandpass_test.go at line 67
    [2.1475][2.1475:1584]()
    func TestBandpassFilter_EmptyInput(t *testing.T) {
    result := BandpassFilter([]float64{}, 48000, 1000, 8000)
    [2.1475]
    [2.1584]
    func TestBandpassShiftFilter_EmptyInput(t *testing.T) {
    result, rate := BandpassShiftFilter([]float64{}, 48000, 1000, 8000)
  • edit in utils/bandpass_test.go at line 72
    [2.1691]
    [2.1691]
    if rate != 48000 {
    t.Errorf("rate = %d, want 48000", rate)
    }
  • replacement in utils/bandpass_test.go at line 77
    [2.1694][2.1694:1903]()
    func TestBandpassFilter_PreservesLength(t *testing.T) {
    sampleRate := 44100
    audio := make([]float64, 1000)
    for i := range audio {
    audio[i] = math.Sin(2 * math.Pi * 440 * float64(i) / float64(sampleRate))
    [2.1694]
    [2.1903]
    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)
  • replacement in utils/bandpass_test.go at line 87
    [2.1907][2.1907:2070]()
    filtered := BandpassFilter(audio, sampleRate, 200, 2000)
    if len(filtered) != len(audio) {
    t.Errorf("filtered length = %d, want %d", len(filtered), len(audio))
    [2.1907]
    [2.2070]
    // 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)
  • replacement in utils/bandpass_test.go at line 94
    [2.2076][2.2076:2314]()
    func TestBandpassFilter_PassbandPreserved(t *testing.T) {
    // Generate a signal entirely within the passband
    sampleRate := 48000
    duration := 0.05
    numSamples := int(float64(sampleRate) * duration)
    audio := make([]float64, numSamples)
    [2.2076]
    [2.2314]
    func TestBandpassShiftFilter_NarrowBand(t *testing.T) {
    // Bat vocalisations: bandpass 40000-56000 → bandwidth 16000 → newRate 32000
    audio := make([]float64, 5000)
    sampleRate := 250000
  • replacement in utils/bandpass_test.go at line 100
    [2.2379][2.2379:2458]()
    audio[i] = math.Sin(2 * math.Pi * 5000 * ts) // 5kHz, within 4-6kHz passband
    [2.2379]
    [2.2458]
    audio[i] = math.Sin(2 * math.Pi * 48000 * ts)
  • edit in utils/bandpass_test.go at line 102
    [2.2461][2.2461:2521]()
    filtered := BandpassFilter(audio, sampleRate, 4000, 6000)
  • replacement in utils/bandpass_test.go at line 103
    [2.2522][2.2522:2634]()
    // The signal should be largely preserved
    origPower := signalPower(audio)
    filtPower := signalPower(filtered)
    [2.2522]
    [2.2634]
    filtered, newRate := BandpassShiftFilter(audio, sampleRate, 40000, 56000)
    if newRate != 32000 {
    t.Errorf("newRate = %d, want 32000", newRate)
    }
  • replacement in utils/bandpass_test.go at line 108
    [2.2635][2.2635:2848]()
    // Allow for some attenuation from windowing effects
    if filtPower < origPower*0.2 {
    t.Errorf("passband signal power too low: %.6f, want > %.6f (20%% of original %.6f)",
    filtPower, origPower*0.2, origPower)
    [2.2635]
    [2.2848]
    // 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")
  • edit in utils/bandpass_test.go at line 114
    [2.2851]
    [2.2851]
    ratio := power / totalPower
    if ratio < 0.05 {
    t.Errorf("shifted 48kHz→8kHz bin has only %.1f%% of total power, want > 5%%", ratio*100)
    }
  • edit in utils/bandpass_test.go at line 138
    [2.3190][2.3190:3909]()
    }
    }
    func TestBandpassMaxSampleRate(t *testing.T) {
    tests := []struct {
    name string
    sampleRate int
    bandpassHigh float64
    want int
    }{
    {"no bandpass, high rate", 250000, 0, DefaultMaxSampleRate},
    {"no bandpass, low rate", 8000, 0, 8000},
    {"bandpass 24kHz", 250000, 24000, 48000},
    {"bandpass below default max", 48000, 6000, DefaultMaxSampleRate},
    {"bandpass exceeds original rate", 16000, 24000, 16000},
    }
    for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
    got := BandpassMaxSampleRate(tt.sampleRate, tt.bandpassHigh)
    if got != tt.want {
    t.Errorf("BandpassMaxSampleRate(%d, %.0f) = %d, want %d",
    tt.sampleRate, tt.bandpassHigh, got, tt.want)
    }
    })
  • replacement in utils/bandpass_test.go at line 141
    [2.3915][2.3915:4079]()
    // computeBinPower uses our FFT to compute the power at a specific frequency bin.
    func computeBinPower(samples []float64, sampleRate int, freqHz float64) float64 {
    [2.3915]
    [2.4079]
    // computeBinPowerAtFreq uses our FFT to compute power at a specific frequency.
    func computeBinPowerAtFreq(samples []float64, sampleRate int, freqHz float64) float64 {
  • replacement in utils/bandpass_test.go at line 167
    [2.4569][2.4569:4614]()
    func BenchmarkBandpassFilter(b *testing.B) {
    [2.4569]
    [2.4614]
    // 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) {
  • replacement in utils/bandpass_test.go at line 177
    [2.4636][2.4636:4717]()
    duration := 5.0 // 5 seconds
    numSamples := int(float64(sampleRate) * duration)
    [2.4636]
    [2.4717]
    numSamples := int(float64(sampleRate) * 5.0) // 5 seconds
  • replacement in utils/bandpass_test.go at line 187
    [2.4941][2.4941:4990]()
    BandpassFilter(audio, sampleRate, 8000, 24000)
    [2.4941]
    [2.4990]
    BandpassShiftFilter(audio, sampleRate, 8000, 24000)
  • edit in utils/bandpass.go at line 4
    [2.5063][2.5063:5072]()
    "math"
  • edit in utils/bandpass.go at line 5
    [2.5110][2.5110:5151]()
    "github.com/madelynnblue/go-dsp/window"
  • replacement in utils/bandpass.go at line 7
    [2.5154][2.5154:5460]()
    // BandpassFilter applies a bandpass filter to audio samples using FFT.
    // It retains frequencies between lowFreq and highFreq (Hz) at the given sampleRate.
    // Returns filtered samples of the same length as input.
    func BandpassFilter(audio []float64, sampleRate int, lowFreq, highFreq float64) []float64 {
    [2.5154]
    [2.5460]
    // 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) {
  • replacement in utils/bandpass.go at line 20
    [2.5490][2.5490:5505]()
    return audio
    [2.5490]
    [2.5505]
    return audio, sampleRate
  • edit in utils/bandpass.go at line 22
    [2.5508][2.5508:5655]()
    padded := padAndWindow(audio)
    spectrum := fft.FFTReal(padded)
    applyBandpassMask(spectrum, float64(sampleRate), lowFreq, highFreq, len(padded))
  • replacement in utils/bandpass.go at line 23
    [2.5656][2.5656:5944]()
    filtered := fft.IFFT(spectrum)
    result := extractReal(filtered, n)
    normalizeAmplitude(result, audio)
    return result
    }
    // padAndWindow zero-pads audio to next power of 2 and applies a Hamming window.
    func padAndWindow(audio []float64) []float64 {
    paddedLen := nextPowerOf2(len(audio))
    [2.5656]
    [2.5944]
    paddedLen := nextPowerOf2(n)
  • replacement in utils/bandpass.go at line 27
    [2.6004][2.6004:6105]()
    win := window.Hamming(paddedLen)
    for i := range padded {
    padded[i] *= win[i]
    }
    return padded
    }
    [2.6004]
    [2.6105]
    spectrum := fft.FFTReal(padded)
    lowBin := int(lowFreq * float64(paddedLen) / float64(sampleRate))
    highBin := int(highFreq * float64(paddedLen) / float64(sampleRate))
    bandBins := highBin - lowBin
  • replacement in utils/bandpass.go at line 33
    [2.6106][2.6106:6452]()
    // applyBandpassMask zeros out frequency bins outside [lowFreq, highFreq].
    func applyBandpassMask(spectrum []complex128, sampleRate, lowFreq, highFreq float64, paddedLen int) {
    n := float64(paddedLen)
    halfRate := sampleRate / 2
    for i := range spectrum {
    freq := float64(i) * sampleRate / n
    if freq > halfRate {
    freq = sampleRate - freq
    [2.6106]
    [2.6452]
    // 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]
    }
  • replacement in utils/bandpass.go at line 43
    [2.6456][2.6456:6516]()
    if freq < lowFreq || freq > highFreq {
    spectrum[i] = 0
    [2.6456]
    [2.6516]
    // Conjugate symmetry: shifted[N-k] = conj(shifted[k])
    if k > 0 && k < paddedLen/2 {
    shifted[paddedLen-k] = complex(real(shifted[k]), -imag(shifted[k]))
  • edit in utils/bandpass.go at line 48
    [2.6523][2.6523:6525]()
    }
  • replacement in utils/bandpass.go at line 49
    [2.6526][2.6526:6702]()
    // extractReal takes the real part of complex samples, trimmed to origLen.
    func extractReal(filtered []complex128, origLen int) []float64 {
    result := make([]float64, origLen)
    [2.6526]
    [2.6702]
    filtered := fft.IFFT(shifted)
    result := make([]float64, n)
  • edit in utils/bandpass.go at line 55
    [2.6762][2.6762:6779]()
    return result
    }
  • replacement in utils/bandpass.go at line 56
    [2.6780][2.6780:7310]()
    // normalizeAmplitude scales result to match the peak amplitude of original.
    func normalizeAmplitude(result, original []float64) {
    maxOrig := peakAmplitude(original)
    maxFilt := peakAmplitude(result)
    if maxFilt > 0 && maxOrig > 0 {
    scale := maxOrig / maxFilt
    for i := range result {
    result[i] *= scale
    }
    }
    }
    // peakAmplitude returns the maximum absolute value in the slice.
    func peakAmplitude(samples []float64) float64 {
    max := 0.0
    for _, s := range samples {
    if abs := math.Abs(s); abs > max {
    max = abs
    }
    [2.6780]
    [2.7310]
    // Downsample to 2 * bandwidth
    bandwidth := highFreq - lowFreq
    newRate := int(2 * bandwidth)
    if newRate >= sampleRate {
    return result, sampleRate
  • replacement in utils/bandpass.go at line 62
    [2.7313][2.7313:7325]()
    return max
    [2.7313]
    [2.7325]
    result = ResampleRate(result, sampleRate, newRate)
    return result, newRate
  • edit in utils/bandpass.go at line 80
    [2.7541][2.7541:8185]()
    // BandpassMaxSampleRate returns the sample rate to use for downsampling
    // after bandpass filtering. It ensures the bandpass range is captured
    // by using 2 * highFreq as the target, or the original rate if lower.
    // Falls back to DefaultMaxSampleRate when no bandpass is active.
    func BandpassMaxSampleRate(sampleRate int, bandpassHigh float64) int {
    if bandpassHigh <= 0 {
    if sampleRate > DefaultMaxSampleRate {
    return DefaultMaxSampleRate
    }
    return sampleRate
    }
    target := int(2 * bandpassHigh)
    if target > sampleRate {
    return sampleRate
    }
    if target < DefaultMaxSampleRate {
    return DefaultMaxSampleRate
    }
    return target
    }
  • replacement in tui/classify.go at line 590
    [2.9093][2.9093:9176]()
    // loadFilteredSegment reads, bandpass-filters, and downsamples a segment's audio.
    [2.9093]
    [2.9176]
    // loadFilteredSegment reads, bandpass-shifts, and downsamples a segment's audio.
  • replacement in tui/classify.go at line 601
    [2.9608][2.9608:9648]()
    // Apply bandpass filter if configured
    [2.9608]
    [2.9648]
    // Apply bandpass+shift+downsample if configured
  • replacement in tui/classify.go at line 603
    [2.9716][2.9716:9829]()
    segSamples = utils.BandpassFilter(segSamples, sampleRate, state.Config.BandpassLow, state.Config.BandpassHigh)
    [2.9716]
    [2.9829]
    segSamples, sampleRate = utils.BandpassShiftFilter(segSamples, sampleRate, state.Config.BandpassLow, state.Config.BandpassHigh)
    return segSamples, sampleRate, nil
  • replacement in tui/classify.go at line 607
    [2.9833][2.9833:10071]()
    // Downsample if sample rate exceeds max
    maxRate := utils.BandpassMaxSampleRate(sampleRate, state.Config.BandpassHigh)
    if sampleRate > maxRate {
    segSamples = utils.ResampleRate(segSamples, sampleRate, maxRate)
    sampleRate = maxRate
    [2.9833]
    [2.10071]
    // No bandpass: downsample if sample rate exceeds default
    if sampleRate > utils.DefaultMaxSampleRate {
    segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)
    sampleRate = utils.DefaultMaxSampleRate
  • replacement in tui/classify.go at line 851
    [3.247435][2.10302:10468]()
    img, err := utils.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, state.Config.Color, imgSize, state.Config.BandpassLow, state.Config.BandpassHigh)
    [3.247435]
    [3.247548]
    if state.Config.BandpassLow > 0 || state.Config.BandpassHigh > 0 {
    return generateBandpassSpectrogram(state, dataPath, seg, imgSize)
    }
    img, err := utils.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, state.Config.Color, imgSize)
  • edit in tui/classify.go at line 861
    [3.247593]
    [3.247593]
    }
    // generateBandpassSpectrogram generates a spectrogram from bandpass-shifted audio.
    // Reads the segment, applies bandpass+shift+downsample, then generates spectrogram
    // from the processed samples.
    func generateBandpassSpectrogram(state *calls.ClassifyState, dataPath string, seg *utils.Segment, imgSize int) image.Image {
    wavPath := strings.TrimSuffix(dataPath, ".data")
    samples, sampleRate, err := utils.ReadWAVSegmentSamples(wavPath, seg.StartTime, seg.EndTime)
    if err != nil || len(samples) == 0 {
    return nil
    }
    samples, sampleRate = utils.BandpassShiftFilter(samples, sampleRate, state.Config.BandpassLow, state.Config.BandpassHigh)
    config := utils.DefaultSpectrogramConfig(sampleRate)
    spectrogram := utils.GenerateSpectrogram(samples, config)
    if spectrogram == nil {
    return nil
    }
    var img image.Image
    if state.Config.Color {
    colorData := utils.ApplyL4Colormap(spectrogram)
    img = utils.CreateRGBImage(colorData)
    } else {
    img = utils.CreateGrayscaleImage(spectrogram)
    }
    if img == nil {
    return nil
    }
    imgSize = utils.ClampImageSize(imgSize)
    return utils.ResizeImage(img, imgSize, imgSize)
  • replacement in tools/calls/calls_show_images.go at line 76
    [3.85150][2.10500:10623]()
    img, err := utils.GenerateSegmentSpectrogram(input.DataFilePath, seg.StartTime, seg.EndTime, input.Color, imgSize, 0, 0)
    [3.85150]
    [3.85267]
    img, err := utils.GenerateSegmentSpectrogram(input.DataFilePath, seg.StartTime, seg.EndTime, input.Color, imgSize)
  • replacement in CHANGELOG.md at line 8
    [2.13065][2.13065:13286]()
    a bandpass filter to audio before spectrogram display and playback. This is
    useful for reviewing high sample rate recordings targeting specific frequency
    bands, e.g. Rock Wren at 8-24kHz from 250kHz AudioMoth recordings.
    [2.13065]
    [3.22401]
    a bandpass filter, shifts the retained band down to baseband, and downsamples
    before spectrogram display and playback. This lets you inspect specific frequency
    bands in high sample rate recordings — e.g. Rock Wren at 8-24kHz from 250kHz
    AudioMoth data, or bat vocalisations at 40-56kHz.
    The band `low-high` Hz is shifted to baseband (0 Hz) and downsampled to
    `2 × (high - low)` Hz, then fed into the existing spectrogram/playback pipeline.
    The Y axis shows 0 to bandwidth/2 — you know what real frequencies those represent.
  • replacement in CHANGELOG.md at line 18
    [2.13297][2.13297:13667]()
    - `utils/bandpass.go`: New `BandpassFilter` function using go-dsp FFT/IFFT with
    Hamming window, zero-padding to power of 2, and amplitude normalization.
    Also `BandpassMaxSampleRate` to determine appropriate downsampling rate.
    - `utils/bandpass_test.go`: Unit tests for bandpass filtering, nextPowerOf2,
    and BandpassMaxSampleRate; benchmark for 5s/250kHz segments.
    [2.13297]
    [2.13667]
    - `utils/bandpass.go`: `BandpassShiftFilter` — FFT-based bandpass + baseband shift
    + downsample. Returns filtered samples and new sample rate.
    - `utils/bandpass_test.go`: Unit tests for shift correctness, out-of-band rejection,
    downsample rate, and narrow-band (bat) scenario; benchmark.
  • replacement in CHANGELOG.md at line 26
    [2.13837][2.13837:14593]()
    - `utils/spectrogram.go`: `GenerateSegmentSpectrogram` now accepts bandpass
    parameters; applies bandpass before downsampling, uses `BandpassMaxSampleRate`
    instead of hardcoded `DefaultMaxSampleRate` when bandpass is active.
    - `tui/classify.go`: `playCurrentSegmentAtSpeed` and `saveClip` apply bandpass
    filtering and use `BandpassMaxSampleRate` for downsampling.
    - `tui/classify.go`: `generateSpectrogramImage` passes bandpass config to
    `GenerateSegmentSpectrogram`.
    - `tui/classify.go`: `playCurrentSegmentAtSpeed` now reads only the segment
    samples (not the whole file) before bandpass filtering, improving performance.
    - `tools/calls/calls_show_images.go`: Updated `GenerateSegmentSpectrogram` call
    with zero bandpass values (no filtering).
    [2.13837]
    [2.14593]
    - `tui/classify.go`: `generateSpectrogramImage` branches to `generateBandpassSpectrogram`
    when bandpass is active, which reads the segment, applies `BandpassShiftFilter`,
    then generates spectrogram from the processed samples.
    - `tui/classify.go`: `loadFilteredSegment` uses `BandpassShiftFilter` for playback/clips.
    - `tui/classify.go`: `playCurrentSegmentAtSpeed` now reads only the segment samples
    (not the whole file) via `loadFilteredSegment`.