UE4TOOVXPEWPYVE5UEC6OTKYDUDVBTRV22HNOZ4XES2PWCBT24YAC 3QTZPFWEDJVI4FKNCJFOAEHTI5C7P2NQANRIW7D54RHXKETNEYLQC XEFQ73KDMQJ5YP5UBGZM3Z2GZUVRCVWMTIADZQSUIJKVDTBODP4AC 3JA7HYRMHV57SIMGMGPDOMKQ3NBQS2SKOX3EKDHRBQRP7ZPZGFTQC WMOV7RM3GZV3I4W5IJ7DCNK3IYNIMK76JPSU7E3NA6E3HWA5HWCAC TLLVARZXOP2M3B5VTLF4SYDGMIBPHABE6LJFG77IU53QTSYGTKWAC IFVRAERTCCDICNTYTG3TX2WASB6RXQQEJWWXQMQZJSQDQ3HLE5OQC SB4FZEB6ZLUHQNM3M76OGNNJY6THOF55S6JO6Q7IGXWE7OA7INFAC GQ2FYLG2VGDDKQ7TJTCFM2XPLJOAX2N737OZWPRCNKSFIH7YJ6EQC TTJZWSWFENRMFQGSEEC4DKCLPFTDWJR5XHUGKEE34HT2RDGJV52AC package utilsimport ("math""math/rand""testing""github.com/madelynnblue/go-dsp/fft")// referencepower computes the power spectrum using go-dsp as ground truth.func referencePower(samples []float64) []float64 {result := fft.FFTReal(samples)n := len(samples)numBins := n/2 + 1power := make([]float64, numBins)for k := 0; k < numBins; k++ {re := real(result[k])im := imag(result[k])power[k] = re*re + im*im}return power}func TestPowerSpectrumFFT_Sinusoid(t *testing.T) {// 512-point FFT of a pure 1kHz sine at 16kHz sample rate// Expected: peak at bin k = 1000 * 512 / 16000 = 32n := 512sampleRate := 16000.0freq := 1000.0samples := make([]float64, n)for i := range samples {samples[i] = math.Sin(2.0 * math.Pi * freq * float64(i) / sampleRate)}power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)// Find peak binmaxBin := 0maxVal := 0.0for k, v := range power {if v > maxVal {maxVal = vmaxBin = k}}expectedBin := int(freq * float64(n) / sampleRate)if maxBin != expectedBin {t.Errorf("peak at bin %d, expected %d", maxBin, expectedBin)}// Compare against referenceref := referencePower(samples)for k := range power {if math.Abs(power[k]-ref[k]) > 1e-6*math.Abs(ref[k])+1e-10 {t.Errorf("bin %d: got %g, ref %g", k, power[k], ref[k])}}}func TestPowerSpectrumFFT_Random(t *testing.T) {n := 512rng := rand.New(rand.NewSource(42))samples := make([]float64, n)for i := range samples {samples[i] = rng.Float64()*2 - 1}power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)ref := referencePower(samples)for k := range power {relErr := math.Abs(power[k]-ref[k]) / (math.Abs(ref[k]) + 1e-15)if relErr > 1e-8 {t.Errorf("bin %d: got %g, ref %g (relErr=%g)", k, power[k], ref[k], relErr)}}}func TestPowerSpectrumFFT_DC(t *testing.T) {n := 512samples := make([]float64, n)for i := range samples {samples[i] = 1.0}power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)ref := referencePower(samples)for k := range power {if math.Abs(power[k]-ref[k]) > 1e-6 {t.Errorf("bin %d: got %g, ref %g", k, power[k], ref[k])}}// DC bin should have all the energyif power[0] < power[1]*1000 {t.Errorf("DC bin should dominate: power[0]=%g, power[1]=%g", power[0], power[1])}}func TestPowerSpectrumFFT_Silence(t *testing.T) {n := 512samples := make([]float64, n)power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)for k, v := range power {if v != 0 {t.Errorf("bin %d: expected 0, got %g", k, v)}}}func TestPowerSpectrumFFT_Impulse(t *testing.T) {n := 512samples := make([]float64, n)samples[0] = 1.0power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)ref := referencePower(samples)for k := range power {if math.Abs(power[k]-ref[k]) > 1e-10 {t.Errorf("bin %d: got %g, ref %g", k, power[k], ref[k])}}// Impulse: flat power spectrum, all bins should be equal (= 1.0)for k, v := range power {if math.Abs(v-1.0) > 1e-10 {t.Errorf("bin %d: expected ~1.0, got %g", k, v)}}}func TestPowerSpectrumFFT_DifferentSizes(t *testing.T) {rng := rand.New(rand.NewSource(99))for _, n := range []int{2, 4, 8, 16, 64, 256, 1024} {samples := make([]float64, n)for i := range samples {samples[i] = rng.Float64()*2 - 1}power := make([]float64, n/2+1)scratch := make([]complex128, n)PowerSpectrumFFT(samples, power, scratch)ref := referencePower(samples)for k := range power {relErr := math.Abs(power[k]-ref[k]) / (math.Abs(ref[k]) + 1e-15)if relErr > 1e-8 {t.Errorf("n=%d bin %d: got %g, ref %g (relErr=%g)", n, k, power[k], ref[k], relErr)}}}}func BenchmarkPowerSpectrumFFT_512(b *testing.B) {n := 512rng := rand.New(rand.NewSource(42))samples := make([]float64, n)for i := range samples {samples[i] = rng.Float64()*2 - 1}power := make([]float64, n/2+1)scratch := make([]complex128, n)b.ResetTimer()for range b.N {PowerSpectrumFFT(samples, power, scratch)}}func BenchmarkGodsFFTReal_512(b *testing.B) {n := 512rng := rand.New(rand.NewSource(42))samples := make([]float64, n)for i := range samples {samples[i] = rng.Float64()*2 - 1}b.ResetTimer()for range b.N {fft.FFTReal(samples)}}
package utilsimport ("math""sync")// FFT twiddle factors and bit-reversal tables, cached per size.var (fftCacheMu sync.RWMutexfftCache = map[int]*fftPlan{})// fftPlan holds pre-computed data for a given FFT size.type fftPlan struct {n inttwiddle []complex128 // twiddle factors: exp(-2*pi*i*k/N) for k=0..N/2-1bitrev []int // bit-reversal permutation table}// getFFFTPlan returns a cached FFT plan for the given size (must be power of 2).func getFFTPlan(n int) *fftPlan {fftCacheMu.RLock()if p, ok := fftCache[n]; ok {fftCacheMu.RUnlock()return p}fftCacheMu.RUnlock()fftCacheMu.Lock()defer fftCacheMu.Unlock()if p, ok := fftCache[n]; ok {return p}p := &fftPlan{n: n}// Compute twiddle factors: exp(-2*pi*i*k/N) for k = 0..N/2-1p.twiddle = make([]complex128, n/2)for k := range p.twiddle {angle := -2.0 * math.Pi * float64(k) / float64(n)sin, cos := math.Sincos(angle)p.twiddle[k] = complex(cos, sin)}// Compute bit-reversal permutationbits := 0for v := n; v > 1; v >>= 1 {bits++}p.bitrev = make([]int, n)for i := range p.bitrev {p.bitrev[i] = reverseBitsN(i, bits)}fftCache[n] = preturn p}// reverseBitsN reverses the lowest `bits` bits of v.func reverseBitsN(v, bits int) int {var r intfor i := 0; i < bits; i++ {r = (r << 1) | (v & 1)v >>= 1}return r}// PowerSpectrumFFT computes the power spectrum of a real-valued signal using radix-2 FFT.//// samples: real input of length N (must be power of 2, N >= 2)// power: output buffer of length >= N/2+1; receives |X[k]|^2 for k=0..N/2// scratch: working buffer of length >= N; contents are overwritten//// All buffers are caller-provided to enable zero-allocation across repeated calls.func PowerSpectrumFFT(samples []float64, power []float64, scratch []complex128) {n := len(samples)plan := getFFTPlan(n)// Bit-reversal copy: load real samples into scratch in bit-reversed orderfor i, j := range plan.bitrev {scratch[j] = complex(samples[i], 0)}// Iterative Cooley-Tukey butterfly (decimation-in-time)for size := 2; size <= n; size <<= 1 {half := size >> 1step := n / size // twiddle index stepfor start := 0; start < n; start += size {tw := 0for j := 0; j < half; j++ {u := scratch[start+j]v := scratch[start+j+half] * plan.twiddle[tw]scratch[start+j] = u + vscratch[start+j+half] = u - vtw += step}}}// Extract power spectrum: |X[k]|^2 = re^2 + im^2 for k = 0..N/2numBins := n/2 + 1for k := 0; k < numBins; k++ {re := real(scratch[k])im := imag(scratch[k])power[k] = re*re + im*im}}
clips, errs := processFile(dataPath, input.Output, input.Prefix, input.Filter, speciesName, callType, input.Certainty, imgSize, input.Color)
clips, errs := processFile(dataPath, input.Output, input.Prefix, input.Filter, speciesName, callType, input.Certainty, imgSize, input.Color, input.WavOnly)
clips, errs := processFile(dataPath, input.Output, input.Prefix, input.Filter, speciesName, callType, input.Certainty, imgSize, input.Color)
clips, errs := processFile(dataPath, input.Output, input.Prefix, input.Filter, speciesName, callType, input.Certainty, imgSize, input.Color, input.WavOnly)
func processFile(dataPath, outputDir, prefix, filter, speciesName, callType string, certainty, imgSize int, color bool) ([]string, []string) {
func processFile(dataPath, outputDir, prefix, filter, speciesName, callType string, certainty, imgSize int, color, wavOnly bool) ([]string, []string) {
clipFiles, err := generateClip(samples, sampleRate, outputDir, prefix, basename, seg.StartTime, seg.EndTime, imgSize, color)
clipFiles, err := generateClip(samples, sampleRate, outputDir, prefix, basename, seg.StartTime, seg.EndTime, imgSize, color, wavOnly)
clipFiles, err := generateClip(samples, sampleRate, outputDir, prefix, basename, seg.StartTime, seg.EndTime, imgSize, color)
clipFiles, err := generateClip(samples, sampleRate, outputDir, prefix, basename, seg.StartTime, seg.EndTime, imgSize, color, wavOnly)
func generateClip(samples []float64, sampleRate int, outputDir, prefix, basename string, startTime, endTime float64, imgSize int, color bool) ([]string, error) {
func generateClip(samples []float64, sampleRate int, outputDir, prefix, basename string, startTime, endTime float64, imgSize int, color, wavOnly bool) ([]string, error) {
// Generate spectrogramspectSampleRate := outputSampleRateconfig := utils.DefaultSpectrogramConfig(spectSampleRate)spectrogram := utils.GenerateSpectrogram(segSamples, config)if spectrogram == nil {return nil, fmt.Errorf("failed to generate spectrogram")}
// Generate spectrogram and PNG unless --wav-onlyif !wavOnly {pngPath := filepath.Join(outputDir, baseName+".png")
// Create image (grayscale or color)var img image.Imageif color {colorData := utils.ApplyL4Colormap(spectrogram)img = utils.CreateRGBImage(colorData)} else {img = utils.CreateGrayscaleImage(spectrogram)}if img == nil {return nil, fmt.Errorf("failed to create image")}
spectSampleRate := outputSampleRateconfig := utils.DefaultSpectrogramConfig(spectSampleRate)spectrogram := utils.GenerateSpectrogram(segSamples, config)if spectrogram == nil {return nil, fmt.Errorf("failed to generate spectrogram")}
resized := utils.ResizeImage(img, imgSize, imgSize)
// Create image (grayscale or color)var img image.Imageif color {colorData := utils.ApplyL4Colormap(spectrogram)img = utils.CreateRGBImage(colorData)} else {img = utils.CreateGrayscaleImage(spectrogram)}if img == nil {return nil, fmt.Errorf("failed to create image")}resized := utils.ResizeImage(img, imgSize, imgSize)
// Write PNG (O_EXCL fails atomically if file exists, replacing os.Stat check)pngFile, err := os.OpenFile(pngPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)if err != nil {if os.IsExist(err) {return nil, fmt.Errorf("file already exists: %s", pngPath)
// Write PNG (O_EXCL fails atomically if file exists)pngFile, err := os.OpenFile(pngPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)if err != nil {if os.IsExist(err) {return nil, fmt.Errorf("file already exists: %s", pngPath)}return nil, fmt.Errorf("failed to create PNG: %w", err)}if err := utils.WritePNG(resized, pngFile); err != nil {pngFile.Close()return nil, fmt.Errorf("failed to write PNG: %w", err)