added first version of --bandpass flag to calls classify, work to do

quietlight
May 17, 2026, 10:59 PM
P4CJMBYKB6LTJASFYF5FX4MHQW7TH7D6KLDLWBBLFKDHTWK5SZ6AC

Dependencies

  • [2] ZDZDASRT complexity over 12 now gone, but have some lint fails
  • [3] JAT3DXOL cyclo over 15
  • [4] ZOSYO3IB ck 3
  • [5] LQLC7S3A trying gemini: Inconsistent Standards in @utils/ refactoring
  • [6] 3DVPQOKB big tidy up of tools/
  • [7] KZKLAINJ run out of space on nest, cleaned out
  • [*] DD3LCTLZ tidy up lat lng timezone api for calls classify and push certainty
  • [*] QFPEKXL5 ck 6
  • [*] NS4TDPLN cyclomatic complexity

Change contents

  • replacement in utils/spectrogram.go at line 194
    [3.67903][3.67903:68032]()
    func GenerateSegmentSpectrogram(dataFilePath string, startTime, endTime float64, color bool, imgSize int) (image.Image, error) {
    [3.67903]
    [3.68032]
    // 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) {
  • edit in utils/spectrogram.go at line 207
    [3.68397]
    [3.68397]
    }
    // Apply bandpass filter if specified
    if bandpassLow > 0 || bandpassHigh > 0 {
    segSamples = BandpassFilter(segSamples, sampleRate, bandpassLow, bandpassHigh)
  • replacement in utils/spectrogram.go at line 214
    [3.68401][3.68401:68463]()
    // For spectrograms, downsample if sample rate exceeds 16kHz
    [3.68401]
    [3.68463]
    // Determine max sample rate for spectrogram display
    maxRate := BandpassMaxSampleRate(sampleRate, bandpassHigh)
    // For spectrograms, downsample if sample rate exceeds max
  • replacement in utils/spectrogram.go at line 219
    [3.68494][3.68494:68649]()
    if sampleRate > DefaultMaxSampleRate {
    segSamples = ResampleRate(segSamples, sampleRate, DefaultMaxSampleRate)
    spectSampleRate = DefaultMaxSampleRate
    [3.68494]
    [3.68649]
    if sampleRate > maxRate {
    segSamples = ResampleRate(segSamples, sampleRate, maxRate)
    spectSampleRate = maxRate
  • file addition: bandpass_test.go (----------)
    [3.1]
    package utils
    import (
    "math"
    "testing"
    )
    func TestBandpassFilter_BasicFiltering(t *testing.T) {
    // Generate a signal with two frequency components: 1000 Hz and 10000 Hz
    sampleRate := 48000
    duration := 0.1 // 100ms
    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)
    }
    // Bandpass to keep only 8000-12000 Hz — should remove the 1000 Hz component
    filtered := 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 suppressed
    origPower := 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 energy
    ratioHigh := filtPowerHigh / origPowerHigh
    if ratioHigh < 0.3 {
    t.Errorf("10000 Hz bin retained only %.1f%% of energy, want > 30%%", ratioHigh*100)
    }
    // 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)
    }
    }
    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 := 44100
    audio := 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 passband
    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 * 5000 * ts) // 5kHz, within 4-6kHz passband
    }
    filtered := BandpassFilter(audio, sampleRate, 4000, 6000)
    // The signal should be largely preserved
    origPower := signalPower(audio)
    filtPower := signalPower(filtered)
    // 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)
    }
    }
    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 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)
    }
    })
    }
    }
    // 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.0
    for _, s := range samples {
    power += s * s
    }
    return power
    }
    func BenchmarkBandpassFilter(b *testing.B) {
    sampleRate := 250000
    duration := 5.0 // 5 seconds
    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*15000*ts)
    }
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
    BandpassFilter(audio, sampleRate, 8000, 24000)
    }
    }
  • file addition: bandpass.go (----------)
    [3.1]
    package utils
    import (
    "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 / 2
    for i := range spectrum {
    freq := float64(i) * sampleRate / n
    if 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 / 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
    }
    }
    return max
    }
    // 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
    }
    // 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 568
    [3.240944][3.240944:240965](),[3.240965][3.3183:3236](),[3.3236][3.240965:241024](),[3.240965][3.240965:241024]()
    // Read WAV samples
    wavPath := strings.TrimSuffix(df.FilePath, ".data")
    samples, sampleRate, err := utils.ReadWAVSamples(wavPath)
    [3.240944]
    [3.241024]
    segSamples, outputSampleRate, err := loadFilteredSegment(state, df, seg)
  • replacement in tui/classify.go at line 570
    [3.241041][3.241041:241288]()
    return fmt.Errorf("failed to read WAV: %w", err)
    }
    // Extract segment samples
    segSamples := utils.ExtractSegmentSamples(samples, sampleRate, seg.StartTime, seg.EndTime)
    if len(segSamples) == 0 {
    return fmt.Errorf("no samples in segment")
    [3.241041]
    [3.241288]
    return err
  • edit in tui/classify.go at line 573
    [3.241292][3.241292:241565]()
    // Determine output sample rate (downsample if > 16kHz)
    outputSampleRate := sampleRate
    if sampleRate > utils.DefaultMaxSampleRate {
    segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)
    outputSampleRate = utils.DefaultMaxSampleRate
    }
  • edit in tui/classify.go at line 588
    [3.3616]
    [3.3616]
    }
    // 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 configured
    if state.Config.BandpassLow > 0 || state.Config.BandpassHigh > 0 {
    segSamples = utils.BandpassFilter(segSamples, sampleRate, state.Config.BandpassLow, state.Config.BandpassHigh)
    }
    // Downsample if sample rate exceeds max
    maxRate := utils.BandpassMaxSampleRate(sampleRate, state.Config.BandpassHigh)
    if sampleRate > maxRate {
    segSamples = utils.ResampleRate(segSamples, sampleRate, maxRate)
    sampleRate = maxRate
    }
    return segSamples, sampleRate, nil
  • replacement in tui/classify.go at line 689
    [3.242931][3.242931:243043]()
    wavPath := strings.TrimSuffix(df.FilePath, ".data")
    samples, sampleRate, err := utils.ReadWAVSamples(wavPath)
    [3.242931]
    [3.243043]
    segSamples, playSampleRate, err := loadFilteredSegment(state, df, seg)
  • replacement in tui/classify.go at line 696
    [3.243172][3.243172:243222]()
    player, err := utils.NewAudioPlayer(sampleRate)
    [3.243172]
    [3.243222]
    player, err := utils.NewAudioPlayer(playSampleRate)
  • edit in tui/classify.go at line 703
    [3.243317][3.243317:243409]()
    segSamples := utils.ExtractSegmentSamples(samples, sampleRate, seg.StartTime, seg.EndTime)
  • replacement in tui/classify.go at line 705
    [3.243465][3.243465:243523]()
    state.Player.PlayAtSpeed(segSamples, sampleRate, speed)
    [3.243465]
    [3.243523]
    state.Player.PlayAtSpeed(segSamples, playSampleRate, speed)
  • replacement in tui/classify.go at line 851
    [3.247435][3.247435:247548]()
    img, err := utils.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, state.Config.Color, imgSize)
    [3.247435]
    [3.247548]
    img, err := utils.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, state.Config.Color, imgSize, state.Config.BandpassLow, state.Config.BandpassHigh)
  • replacement in tools/calls/calls_show_images.go at line 76
    [3.85150][3.85150:85267]()
    img, err := utils.GenerateSegmentSpectrogram(input.DataFilePath, seg.StartTime, seg.EndTime, input.Color, imgSize)
    [3.85150]
    [3.85267]
    img, err := utils.GenerateSegmentSpectrogram(input.DataFilePath, seg.StartTime, seg.EndTime, input.Color, imgSize, 0, 0)
  • edit in tools/calls/calls_classify.go at line 43
    [3.293337]
    [3.293337]
    BandpassLow float64 // bandpass filter low frequency (Hz), 0 = no filter
    BandpassHigh float64 // bandpass filter high frequency (Hz), 0 = no filter
  • edit in cmd/calls_classify.go at line 46
    [9.2308]
    [3.1144090]
    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")
  • edit in cmd/calls_classify.go at line 82
    [9.2327]
    [2.18788]
    bandpass string // "8000-24000" format
  • edit in cmd/calls_classify.go at line 103
    [2.20000]
    [2.20000]
    "--bandpass": {func(v string, a *classifyArgs) { a.bandpass = classifyUniqueSet(v, "--bandpass", a.bandpass) }, false},
  • edit in cmd/calls_classify.go at line 165
    [2.20970]
    [2.20970]
    return err
    }
    if err := a.validateBandpass(); err != nil {
  • edit in cmd/calls_classify.go at line 198
    [10.8341]
    [11.11170]
    }
    func (a classifyArgs) validateBandpass() error {
    if a.bandpass == "" {
    return nil
    }
    _, _, err := parseBandpass(a.bandpass)
    return err
  • edit in cmd/calls_classify.go at line 208
    [3.1149871]
    [11.11173]
    // 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
    }
  • edit in cmd/calls_classify.go at line 309
    [9.2810]
    [2.21996]
    var bandpassLow, bandpassHigh float64
    if a.bandpass != "" {
    var err error
    bandpassLow, bandpassHigh, err = parseBandpass(a.bandpass)
    if err != nil {
    return calls.ClassifyConfig{}, err
    }
    }
  • edit in cmd/calls_classify.go at line 334
    [3.1152214]
    [11.12514]
    BandpassLow: bandpassLow,
    BandpassHigh: bandpassHigh,
  • edit in cmd/calls_classify.go at line 377
    [3.1152878]
    [3.1152878]
    if config.BandpassLow > 0 || config.BandpassHigh > 0 {
    fmt.Fprintf(os.Stderr, "Bandpass: %.0f-%.0f Hz\n", config.BandpassLow, config.BandpassHigh)
    }
  • edit in CHANGELOG.md at line 4
    [3.1198010]
    [2.22401]
    ## [2026-05-18] Add --bandpass flag to calls classify TUI
    Added a `--bandpass <low-high>` flag to `skraak calls classify` that applies
    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.
  • edit in CHANGELOG.md at line 12
    [2.22402]
    [2.22402]
    ### Added
    - `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.
    - `--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 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).