NUOFNUIQIKWOBFHMJXY2K4AJGIROGCPFK5NJHR6Y6HYHHCOJPVCAC // 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) {
if sampleRate > maxRate {segSamples = ResampleRate(segSamples, sampleRate, maxRate)spectSampleRate = maxRate
if sampleRate > DefaultMaxSampleRate {segSamples = ResampleRate(segSamples, sampleRate, DefaultMaxSampleRate)spectSampleRate = DefaultMaxSampleRate
// 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)
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)
// 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)
// 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)
// 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)
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)
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)
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
// 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")
}}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)}})
// 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 {
// 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) {
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)
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
// 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]}
// 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
// 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}
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
// 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
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)
}// 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)
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.
- `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.
- `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`.