P4CJMBYKB6LTJASFYF5FX4MHQW7TH7D6KLDLWBBLFKDHTWK5SZ6AC ZDZDASRTTRPJRIBAMNO3TB533XFELVYJQQGAMA3WOQYY35SVAQUQC KZKLAINJJWZ64T5MUZT34LJVQIKBTKZ6EJGD7C7TTSSDGCHEDPMAC JAT3DXOLENZZGXE2NYFF3TVQAQIXMMNYO234ETKQGC2CRHJVZERQC 3DVPQOKB6BX63XSBIYYCPWBL2RBG3LXZS3XPQBANJP2FWVRAOVZQC DD3LCTLZFDIPVXXSG7RDINZCQ7NGKVG3X52OFVGVZIISD5VPF35QC QFPEKXL5OUKLT4WECMATSOHWYM24QPHKS6WZAAI5BAEQSAGAK6CQC NS4TDPLNAWJYJN37PZDYXMG6OJSAWZCMTPSPKX73JCLZZAMY25BAC func GenerateSegmentSpectrogram(dataFilePath string, startTime, endTime float64, color bool, imgSize int) (image.Image, error) {
// 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) {
if sampleRate > DefaultMaxSampleRate {segSamples = ResampleRate(segSamples, sampleRate, DefaultMaxSampleRate)spectSampleRate = DefaultMaxSampleRate
if sampleRate > maxRate {segSamples = ResampleRate(segSamples, sampleRate, maxRate)spectSampleRate = maxRate
package utilsimport ("math""testing")func TestBandpassFilter_BasicFiltering(t *testing.T) {// Generate a signal with two frequency components: 1000 Hz and 10000 HzsampleRate := 48000duration := 0.1 // 100msnumSamples := 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)}// Bandpass to keep only 8000-12000 Hz — should remove the 1000 Hz componentfiltered := BandpassFilter(audio, sampleRate, 8000, 12000)if len(filtered) != len(audio) {t.Fatalf("filtered length = %d, want %d", len(filtered), len(audio))}// 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)// 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)}// 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)}}func TestBandpassFilter_EmptyInput(t *testing.T) {result := BandpassFilter([]float64{}, 48000, 1000, 8000)if len(result) != 0 {t.Errorf("expected empty result for empty input, got %d samples", len(result))}}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))}filtered := BandpassFilter(audio, sampleRate, 200, 2000)if len(filtered) != len(audio) {t.Errorf("filtered length = %d, want %d", len(filtered), len(audio))}}func TestBandpassFilter_PassbandPreserved(t *testing.T) {// Generate a signal entirely within the passbandsampleRate := 48000duration := 0.05numSamples := int(float64(sampleRate) * duration)audio := make([]float64, numSamples)for i := range audio {ts := float64(i) / float64(sampleRate)audio[i] = math.Sin(2 * math.Pi * 5000 * ts) // 5kHz, within 4-6kHz passband}filtered := BandpassFilter(audio, sampleRate, 4000, 6000)// The signal should be largely preservedorigPower := signalPower(audio)filtPower := signalPower(filtered)// 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)}}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)}}}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 {n := nextPowerOf2(len(samples))padded := make([]float64, n)copy(padded, samples)power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(padded, power, scratch)bin := int(freqHz * float64(n) / float64(sampleRate))if bin >= len(power) {return 0}return power[bin]}// signalPower returns the total power of a signal.func signalPower(samples []float64) float64 {power := 0.0for _, s := range samples {power += s * s}return power}func BenchmarkBandpassFilter(b *testing.B) {sampleRate := 250000duration := 5.0 // 5 secondsnumSamples := 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*15000*ts)}b.ResetTimer()for i := 0; i < b.N; i++ {BandpassFilter(audio, sampleRate, 8000, 24000)}}
package utilsimport ("math""github.com/madelynnblue/go-dsp/fft""github.com/madelynnblue/go-dsp/window")// 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 {n := len(audio)if n == 0 {return audio}padded := padAndWindow(audio)spectrum := fft.FFTReal(padded)applyBandpassMask(spectrum, float64(sampleRate), lowFreq, highFreq, len(padded))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))padded := make([]float64, paddedLen)copy(padded, audio)win := window.Hamming(paddedLen)for i := range padded {padded[i] *= win[i]}return padded}// 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}if freq < lowFreq || freq > highFreq {spectrum[i] = 0}}}// extractReal takes the real part of complex samples, trimmed to origLen.func extractReal(filtered []complex128, origLen int) []float64 {result := make([]float64, origLen)for i := range result {result[i] = real(filtered[i])}return result}// 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}}return max}// nextPowerOf2 returns the smallest power of 2 >= n.func nextPowerOf2(n int) int {if n <= 0 {return 1}n--n |= n >> 1n |= n >> 2n |= n >> 4n |= n >> 8n |= n >> 16n |= n >> 32return n + 1}// 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}
// Read WAV sampleswavPath := strings.TrimSuffix(df.FilePath, ".data")samples, sampleRate, err := utils.ReadWAVSamples(wavPath)
segSamples, outputSampleRate, err := loadFilteredSegment(state, df, seg)
return fmt.Errorf("failed to read WAV: %w", err)}// Extract segment samplessegSamples := utils.ExtractSegmentSamples(samples, sampleRate, seg.StartTime, seg.EndTime)if len(segSamples) == 0 {return fmt.Errorf("no samples in segment")
return err
}// loadFilteredSegment reads, bandpass-filters, and downsamples a segment's audio.func loadFilteredSegment(state *calls.ClassifyState, df *utils.DataFile, seg *utils.Segment) ([]float64, int, error) {wavPath := strings.TrimSuffix(df.FilePath, ".data")segSamples, sampleRate, err := utils.ReadWAVSegmentSamples(wavPath, seg.StartTime, seg.EndTime)if err != nil {return nil, 0, fmt.Errorf("failed to read WAV: %w", err)}if len(segSamples) == 0 {return nil, 0, fmt.Errorf("no samples in segment")}// Apply bandpass filter if configuredif state.Config.BandpassLow > 0 || state.Config.BandpassHigh > 0 {segSamples = utils.BandpassFilter(segSamples, sampleRate, state.Config.BandpassLow, state.Config.BandpassHigh)}// Downsample if sample rate exceeds maxmaxRate := utils.BandpassMaxSampleRate(sampleRate, state.Config.BandpassHigh)if sampleRate > maxRate {segSamples = utils.ResampleRate(segSamples, sampleRate, maxRate)sampleRate = maxRate}return segSamples, sampleRate, nil
img, err := utils.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, state.Config.Color, imgSize)
img, err := utils.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, state.Config.Color, imgSize, state.Config.BandpassLow, state.Config.BandpassHigh)
fmt.Fprintf(os.Stderr, " --bandpass <low-high> Bandpass filter frequency range in Hz (e.g. 8000-24000)\n")fmt.Fprintf(os.Stderr, " Filters audio to the specified range before display/playback.\n")fmt.Fprintf(os.Stderr, " Useful for high sample rate recordings targeting specific\n")fmt.Fprintf(os.Stderr, " frequency bands (e.g. Rock Wren at 8-24kHz from 250kHz audio).\n")
// parseBandpass parses a bandpass string like "8000-24000" into low and high frequencies.func parseBandpass(s string) (float64, float64, error) {parts := strings.SplitN(s, "-", 2)if len(parts) != 2 {return 0, 0, fmt.Errorf("--bandpass format must be low-high (e.g. 8000-24000)")}low, err := strconv.ParseFloat(parts[0], 64)if err != nil {return 0, 0, fmt.Errorf("--bandpass low frequency must be a number")}high, err := strconv.ParseFloat(parts[1], 64)if err != nil {return 0, 0, fmt.Errorf("--bandpass high frequency must be a number")}if low < 0 {return 0, 0, fmt.Errorf("--bandpass low frequency must be >= 0")}if high <= low {return 0, 0, fmt.Errorf("--bandpass high frequency must be > low frequency")}return low, high, nil}
## [2026-05-18] Add --bandpass flag to calls classify TUIAdded a `--bandpass <low-high>` flag to `skraak calls classify` that appliesa 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.
### Added- `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.- `--bandpass <low-high>` flag on `skraak calls classify` (e.g. `--bandpass 8000-24000`)- `BandpassLow` and `BandpassHigh` fields on `calls.ClassifyConfig`### Changed- `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).