package classifier import ( "math" ) // ExtractFeatures computes spectral features for a signal slice. // spectrum is full-band power in dB (length fftSize). func ExtractFeatures(s SignalInput, spectrum []float64, sampleRate int, fftSize int) Features { if fftSize <= 0 { fftSize = len(spectrum) } if len(spectrum) == 0 || s.FirstBin < 0 || s.LastBin < s.FirstBin || s.FirstBin >= len(spectrum) { return Features{} } if s.LastBin >= len(spectrum) { s.LastBin = len(spectrum) - 1 } binHz := float64(sampleRate) / float64(max(1, fftSize)) // slice start := s.FirstBin end := s.LastBin peakDb := -1e9 peakIdx := start sumLin := 0.0 geoSum := 0.0 count := 0 for i := start; i <= end; i++ { db := spectrum[i] if db > peakDb { peakDb = db peakIdx = i } p := math.Pow(10, db/10.0) sumLin += p if p > 0 { geoSum += math.Log(p) } count++ } avgLin := 0.0 if count > 0 { avgLin = sumLin / float64(count) } // Peak-to-avg in dB peakToAvg := 0.0 if avgLin > 0 { peakToAvg = 10 * math.Log10(math.Pow(10, peakDb/10.0)/avgLin) } // Spectral flatness flat := 0.0 if count > 0 && avgLin > 0 { geoMean := math.Exp(geoSum / float64(count)) flat = geoMean / avgLin } // BW3dB bw3 := bwAtThreshold(spectrum, start, end, peakDb-3.0) * binHz // BW90 (90% energy) bw90 := bwEnergy(spectrum, start, end, 0.90) * binHz // Symmetry (power left/right of peak) leftSum, rightSum := 0.0, 0.0 for i := start; i <= end; i++ { p := math.Pow(10, spectrum[i]/10.0) if i <= peakIdx { leftSum += p } else { rightSum += p } } sym := 0.0 if leftSum+rightSum > 0 { sym = (rightSum - leftSum) / (rightSum + leftSum) } // Rolloff (dB/kHz) at edges leftDb := spectrum[start] rightDb := spectrum[end] leftHz := math.Max(binHz, float64(peakIdx-start)*binHz) rightHz := math.Max(binHz, float64(end-peakIdx)*binHz) rollL := (peakDb - leftDb) / (leftHz / 1e3) rollR := (peakDb - rightDb) / (rightHz / 1e3) return Features{ BW3dB: bw3, BW90: bw90, SpectralFlat: clamp01(flat), PeakToAvg: peakToAvg, Symmetry: sym, RolloffLeft: rollL, RolloffRight: rollR, } } func bwAtThreshold(spectrum []float64, start, end int, threshDb float64) float64 { left := start right := end for i := start; i <= end; i++ { if spectrum[i] >= threshDb { left = i break } } for i := end; i >= start; i-- { if spectrum[i] >= threshDb { right = i break } } if right < left { return float64(end - start + 1) } return float64(right - left + 1) } func bwEnergy(spectrum []float64, start, end int, frac float64) float64 { if frac <= 0 { return 0 } if frac > 1 { frac = 1 } powers := make([]float64, 0, end-start+1) sum := 0.0 for i := start; i <= end; i++ { p := math.Pow(10, spectrum[i]/10.0) sum += p powers = append(powers, p) } if sum == 0 { return float64(end - start + 1) } // accumulate from center outward center := (start + end) / 2 l := center r := center acc := powers[center-start] for acc/sum < frac && (l > start || r < end) { if l > start { l-- acc += powers[l-start] } if acc/sum >= frac { break } if r < end { r++ acc += powers[r-start] } } return float64(r - l + 1) } func clamp01(v float64) float64 { if v < 0 { return 0 } if v > 1 { return 1 } return v } func max(a, b int) int { if a > b { return a } return b }