simplified --bandpass
Dependencies
- [2]
P4CJMBYKadded first version of --bandpass flag to calls classify, work to do - [3]
JAT3DXOLcyclo over 15 - [4]
3DVPQOKBbig tidy up of tools/ - [5]
ZDZDASRTcomplexity over 12 now gone, but have some lint fails - [6]
KZKLAINJrun out of space on nest, cleaned out
Change contents
- replacement in utils/spectrogram.go at line 194
// 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) {func GenerateSegmentSpectrogram(dataFilePath string, startTime, endTime float64, color bool, imgSize int) (image.Image, error) { - edit in utils/spectrogram.go at line 206
}// Apply bandpass filter if specifiedif bandpassLow > 0 || bandpassHigh > 0 {segSamples = BandpassFilter(segSamples, sampleRate, bandpassLow, bandpassHigh) - replacement in utils/spectrogram.go at line 208
// Determine max sample rate for spectrogram displaymaxRate := BandpassMaxSampleRate(sampleRate, bandpassHigh)// For spectrograms, downsample if sample rate exceeds max// For spectrograms, downsample if sample rate exceeds 16kHz - replacement in utils/spectrogram.go at line 210
if sampleRate > maxRate {segSamples = ResampleRate(segSamples, sampleRate, maxRate)spectSampleRate = maxRateif sampleRate > DefaultMaxSampleRate {segSamples = ResampleRate(segSamples, sampleRate, DefaultMaxSampleRate)spectSampleRate = DefaultMaxSampleRate - replacement in utils/bandpass_test.go at line 8
func TestBandpassFilter_BasicFiltering(t *testing.T) {// Generate a signal with two frequency components: 1000 Hz and 10000 Hzfunc 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
duration := 0.1 // 100msduration := 0.05 - replacement in utils/bandpass_test.go at line 17
audio[i] = math.Sin(2*math.Pi*1000*ts) + math.Sin(2*math.Pi*10000*ts)audio[i] = math.Sin(2 * math.Pi * 10000 * ts) - replacement in utils/bandpass_test.go at line 20
// Bandpass to keep only 8000-12000 Hz — should remove the 1000 Hz componentfiltered := BandpassFilter(audio, sampleRate, 8000, 12000)// 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
if len(filtered) != len(audio) {t.Fatalf("filtered length = %d, want %d", len(filtered), len(audio))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) - edit in utils/bandpass_test.go at line 41
} - replacement in utils/bandpass_test.go at line 43
// Verify using our own FFT that the 10000 Hz component is preserved// and the 1000 Hz component is suppressedorigPower := computeBinPower(audio, sampleRate, 1000)origPowerHigh := computeBinPower(audio, sampleRate, 10000)filtPowerLow := computeBinPower(filtered, sampleRate, 1000)filtPowerHigh := computeBinPower(filtered, sampleRate, 10000)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) - replacement in utils/bandpass_test.go at line 49
// The 10000 Hz component should retain significant energyratioHigh := filtPowerHigh / origPowerHighif ratioHigh < 0.3 {t.Errorf("10000 Hz bin retained only %.1f%% of energy, want > 30%%", ratioHigh*100)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
// The 1000 Hz component should be strongly suppressedratioLow := filtPowerLow / origPowerif ratioLow > 0.1 {t.Errorf("1000 Hz bin retained %.1f%% of energy, want < 10%%", ratioLow*100)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) - replacement in utils/bandpass_test.go at line 67
func TestBandpassFilter_EmptyInput(t *testing.T) {result := BandpassFilter([]float64{}, 48000, 1000, 8000)func TestBandpassShiftFilter_EmptyInput(t *testing.T) {result, rate := BandpassShiftFilter([]float64{}, 48000, 1000, 8000) - edit in utils/bandpass_test.go at line 72
if rate != 48000 {t.Errorf("rate = %d, want 48000", rate)} - replacement in utils/bandpass_test.go at line 77
func TestBandpassFilter_PreservesLength(t *testing.T) {sampleRate := 44100audio := make([]float64, 1000)for i := range audio {audio[i] = math.Sin(2 * math.Pi * 440 * float64(i) / float64(sampleRate))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) - replacement in utils/bandpass_test.go at line 87
filtered := BandpassFilter(audio, sampleRate, 200, 2000)if len(filtered) != len(audio) {t.Errorf("filtered length = %d, want %d", len(filtered), len(audio))// 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) - replacement in utils/bandpass_test.go at line 94
func TestBandpassFilter_PassbandPreserved(t *testing.T) {// Generate a signal entirely within the passbandsampleRate := 48000duration := 0.05numSamples := int(float64(sampleRate) * duration)audio := make([]float64, numSamples)func TestBandpassShiftFilter_NarrowBand(t *testing.T) {// Bat vocalisations: bandpass 40000-56000 → bandwidth 16000 → newRate 32000audio := make([]float64, 5000)sampleRate := 250000 - replacement in utils/bandpass_test.go at line 100
audio[i] = math.Sin(2 * math.Pi * 5000 * ts) // 5kHz, within 4-6kHz passbandaudio[i] = math.Sin(2 * math.Pi * 48000 * ts) - edit in utils/bandpass_test.go at line 102
filtered := BandpassFilter(audio, sampleRate, 4000, 6000) - replacement in utils/bandpass_test.go at line 103
// The signal should be largely preservedorigPower := signalPower(audio)filtPower := signalPower(filtered)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
// Allow for some attenuation from windowing effectsif filtPower < origPower*0.2 {t.Errorf("passband signal power too low: %.6f, want > %.6f (20%% of original %.6f)",filtPower, origPower*0.2, origPower)// 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
ratio := power / totalPowerif 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
}}func TestBandpassMaxSampleRate(t *testing.T) {tests := []struct {name stringsampleRate intbandpassHigh float64want 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
// computeBinPower uses our FFT to compute the power at a specific frequency bin.func computeBinPower(samples []float64, sampleRate int, freqHz float64) float64 {// 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
func BenchmarkBandpassFilter(b *testing.B) {// 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
duration := 5.0 // 5 secondsnumSamples := int(float64(sampleRate) * duration)numSamples := int(float64(sampleRate) * 5.0) // 5 seconds - replacement in utils/bandpass_test.go at line 187
BandpassFilter(audio, sampleRate, 8000, 24000)BandpassShiftFilter(audio, sampleRate, 8000, 24000) - edit in utils/bandpass.go at line 4
"math" - edit in utils/bandpass.go at line 5
"github.com/madelynnblue/go-dsp/window" - replacement in utils/bandpass.go at line 7
// 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 {// 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) { - replacement in utils/bandpass.go at line 20
return audioreturn audio, sampleRate - edit in utils/bandpass.go at line 22
padded := padAndWindow(audio)spectrum := fft.FFTReal(padded)applyBandpassMask(spectrum, float64(sampleRate), lowFreq, highFreq, len(padded)) - replacement in utils/bandpass.go at line 23
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))paddedLen := nextPowerOf2(n) - replacement in utils/bandpass.go at line 27
win := window.Hamming(paddedLen)for i := range padded {padded[i] *= win[i]}return padded}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
// applyBandpassMask zeros out frequency bins outside [lowFreq, highFreq].func applyBandpassMask(spectrum []complex128, sampleRate, lowFreq, highFreq float64, paddedLen int) {n := float64(paddedLen)halfRate := sampleRate / 2for i := range spectrum {freq := float64(i) * sampleRate / nif freq > halfRate {freq = sampleRate - freq// 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]} - replacement in utils/bandpass.go at line 43
if freq < lowFreq || freq > highFreq {spectrum[i] = 0// 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
} - replacement in utils/bandpass.go at line 49
// extractReal takes the real part of complex samples, trimmed to origLen.func extractReal(filtered []complex128, origLen int) []float64 {result := make([]float64, origLen)filtered := fft.IFFT(shifted)result := make([]float64, n) - edit in utils/bandpass.go at line 55
return result} - replacement in utils/bandpass.go at line 56
// 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 / maxFiltfor i := range result {result[i] *= scale}}}// peakAmplitude returns the maximum absolute value in the slice.func peakAmplitude(samples []float64) float64 {max := 0.0for _, s := range samples {if abs := math.Abs(s); abs > max {max = abs}// Downsample to 2 * bandwidthbandwidth := highFreq - lowFreqnewRate := int(2 * bandwidth)if newRate >= sampleRate {return result, sampleRate - replacement in utils/bandpass.go at line 62
return maxresult = ResampleRate(result, sampleRate, newRate)return result, newRate - edit in utils/bandpass.go at line 80
// 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
// loadFilteredSegment reads, bandpass-filters, and downsamples a segment's audio.// loadFilteredSegment reads, bandpass-shifts, and downsamples a segment's audio. - replacement in tui/classify.go at line 601
// Apply bandpass filter if configured// Apply bandpass+shift+downsample if configured - replacement in tui/classify.go at line 603
segSamples = utils.BandpassFilter(segSamples, sampleRate, state.Config.BandpassLow, state.Config.BandpassHigh)segSamples, sampleRate = utils.BandpassShiftFilter(segSamples, sampleRate, state.Config.BandpassLow, state.Config.BandpassHigh)return segSamples, sampleRate, nil - replacement in tui/classify.go at line 607
// Downsample if sample rate exceeds maxmaxRate := utils.BandpassMaxSampleRate(sampleRate, state.Config.BandpassHigh)if sampleRate > maxRate {segSamples = utils.ResampleRate(segSamples, sampleRate, maxRate)sampleRate = maxRate// No bandpass: downsample if sample rate exceeds defaultif sampleRate > utils.DefaultMaxSampleRate {segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)sampleRate = utils.DefaultMaxSampleRate - replacement in tui/classify.go at line 851
img, err := utils.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, state.Config.Color, imgSize, state.Config.BandpassLow, state.Config.BandpassHigh)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
}// 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.Imageif 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
img, err := utils.GenerateSegmentSpectrogram(input.DataFilePath, seg.StartTime, seg.EndTime, input.Color, imgSize, 0, 0)img, err := utils.GenerateSegmentSpectrogram(input.DataFilePath, seg.StartTime, seg.EndTime, input.Color, imgSize) - replacement in CHANGELOG.md at line 8
a bandpass filter to audio before spectrogram display and playback. This isuseful for reviewing high sample rate recordings targeting specific frequencybands, e.g. Rock Wren at 8-24kHz from 250kHz AudioMoth recordings.a bandpass filter, shifts the retained band down to baseband, and downsamplesbefore spectrogram display and playback. This lets you inspect specific frequencybands in high sample rate recordings — e.g. Rock Wren at 8-24kHz from 250kHzAudioMoth 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
- `utils/bandpass.go`: New `BandpassFilter` function using go-dsp FFT/IFFT withHamming 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.- `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
- `utils/spectrogram.go`: `GenerateSegmentSpectrogram` now accepts bandpassparameters; applies bandpass before downsampling, uses `BandpassMaxSampleRate`instead of hardcoded `DefaultMaxSampleRate` when bandpass is active.- `tui/classify.go`: `playCurrentSegmentAtSpeed` and `saveClip` apply bandpassfiltering and use `BandpassMaxSampleRate` for downsampling.- `tui/classify.go`: `generateSpectrogramImage` passes bandpass config to`GenerateSegmentSpectrogram`.- `tui/classify.go`: `playCurrentSegmentAtSpeed` now reads only the segmentsamples (not the whole file) before bandpass filtering, improving performance.- `tools/calls/calls_show_images.go`: Updated `GenerateSegmentSpectrogram` callwith zero bandpass values (no filtering).- `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`.