Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 1 addition & 17 deletions internal/processor/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,6 @@ func AnalyzeAudio(filename string, config *FilterChainConfig, progressCallback f
const intervalDuration = 250 * time.Millisecond
var intervals []IntervalSample
var intervalAcc intervalAccumulator
intervalAcc.reset() // Initialize with proper defaults
var intervalStartTime time.Duration

// Track input frame time (before filter graph, which upsamples to 192kHz)
Expand Down Expand Up @@ -437,22 +436,7 @@ func AnalyzeAudio(filename string, config *FilterChainConfig, progressCallback f
}

// Calculate average spectral statistics from aspectralstats
if acc.spectralFrameCount > 0 {
spectralFrameCountF := float64(acc.spectralFrameCount)
measurements.SpectralMean = acc.spectralMeanSum / spectralFrameCountF
measurements.SpectralVariance = acc.spectralVarianceSum / spectralFrameCountF
measurements.SpectralCentroid = acc.spectralCentroidSum / spectralFrameCountF
measurements.SpectralSpread = acc.spectralSpreadSum / spectralFrameCountF
measurements.SpectralSkewness = acc.spectralSkewnessSum / spectralFrameCountF
measurements.SpectralKurtosis = acc.spectralKurtosisSum / spectralFrameCountF
measurements.SpectralEntropy = acc.spectralEntropySum / spectralFrameCountF
measurements.SpectralFlatness = acc.spectralFlatnessSum / spectralFrameCountF
measurements.SpectralCrest = acc.spectralCrestSum / spectralFrameCountF
measurements.SpectralFlux = acc.spectralFluxSum / spectralFrameCountF
measurements.SpectralSlope = acc.spectralSlopeSum / spectralFrameCountF
measurements.SpectralDecrease = acc.spectralDecreaseSum / spectralFrameCountF
measurements.SpectralRolloff = acc.spectralRolloffSum / spectralFrameCountF
}
acc.finalizeSpectral().writeSpectralTo(&measurements.BaseMeasurements)

// Store astats measurements (if captured)
if acc.astatsFound {
Expand Down
88 changes: 45 additions & 43 deletions internal/processor/analyzer_metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,33 +301,10 @@ func (a *intervalAccumulator) finalize(timestamp time.Duration) IntervalSample {

// reset clears the accumulator for the next interval.
func (a *intervalAccumulator) reset() {
a.frameCount = 0

// Raw sample RMS and peak
a.rawSumSquares = 0
a.rawSampleCount = 0
a.rawPeakAbs = 0

// aspectralstats
a.spectralMeanSum = 0
a.spectralVarianceSum = 0
a.spectralCentroidSum = 0
a.spectralSpreadSum = 0
a.spectralSkewnessSum = 0
a.spectralKurtosisSum = 0
a.spectralEntropySum = 0
a.spectralFlatnessSum = 0
a.spectralCrestSum = 0
a.spectralFluxSum = 0
a.spectralSlopeSum = 0
a.spectralDecreaseSum = 0
a.spectralRolloffSum = 0

// ebur128
a.momentaryLUFSSum = 0
a.shortTermLUFSSum = 0
a.truePeakMax = -120.0
a.samplePeakMax = -120.0
*a = intervalAccumulator{
truePeakMax: -120.0,
samplePeakMax: -120.0,
}
}

// Cached metadata keys for frame extraction - avoids per-frame C string allocations
Expand Down Expand Up @@ -453,6 +430,29 @@ func (b *baseMetadataAccumulators) accumulateSpectral(spectral SpectralMetrics)
b.spectralFrameCount++
}

// finalizeSpectral returns averaged spectral metrics from the accumulated sums.
// Returns zero-value SpectralMetrics when no spectral frames were accumulated.
func (b *baseMetadataAccumulators) finalizeSpectral() SpectralMetrics {
if b.spectralFrameCount == 0 {
return SpectralMetrics{}
}
return SpectralMetrics{
Mean: b.spectralMeanSum,
Variance: b.spectralVarianceSum,
Centroid: b.spectralCentroidSum,
Spread: b.spectralSpreadSum,
Skewness: b.spectralSkewnessSum,
Kurtosis: b.spectralKurtosisSum,
Entropy: b.spectralEntropySum,
Flatness: b.spectralFlatnessSum,
Crest: b.spectralCrestSum,
Flux: b.spectralFluxSum,
Slope: b.spectralSlopeSum,
Decrease: b.spectralDecreaseSum,
Rolloff: b.spectralRolloffSum,
}.average(float64(b.spectralFrameCount))
}

// extractAstatsMetadata extracts all astats measurements from FFmpeg metadata.
// These are cumulative values, so we keep the latest from each frame.
// Includes conversions: linearRatioToDB for CrestFactor, linearSampleToDBFS for MinLevel/MaxLevel.
Expand Down Expand Up @@ -658,6 +658,23 @@ func (m SpectralMetrics) average(n float64) SpectralMetrics {
}
}

// writeSpectralTo maps all 13 spectral fields to the corresponding BaseMeasurements fields.
func (m SpectralMetrics) writeSpectralTo(b *BaseMeasurements) {
b.SpectralMean = m.Mean
b.SpectralVariance = m.Variance
b.SpectralCentroid = m.Centroid
b.SpectralSpread = m.Spread
b.SpectralSkewness = m.Skewness
b.SpectralKurtosis = m.Kurtosis
b.SpectralEntropy = m.Entropy
b.SpectralFlatness = m.Flatness
b.SpectralCrest = m.Crest
b.SpectralFlux = m.Flux
b.SpectralSlope = m.Slope
b.SpectralDecrease = m.Decrease
b.SpectralRolloff = m.Rolloff
}

// extractSpectralMetrics extracts all 13 aspectralstats measurements from FFmpeg metadata.
// Returns a SpectralMetrics struct with Found=true if at least one metric was extracted.
func extractSpectralMetrics(metadata *ffmpeg.AVDictionary) SpectralMetrics {
Expand Down Expand Up @@ -916,22 +933,7 @@ func finalizeOutputMeasurements(acc *outputMetadataAccumulators) *OutputMeasurem
}

// Calculate average spectral statistics from aspectralstats
if acc.spectralFrameCount > 0 {
frameCount := float64(acc.spectralFrameCount)
m.SpectralMean = acc.spectralMeanSum / frameCount
m.SpectralVariance = acc.spectralVarianceSum / frameCount
m.SpectralCentroid = acc.spectralCentroidSum / frameCount
m.SpectralSpread = acc.spectralSpreadSum / frameCount
m.SpectralSkewness = acc.spectralSkewnessSum / frameCount
m.SpectralKurtosis = acc.spectralKurtosisSum / frameCount
m.SpectralEntropy = acc.spectralEntropySum / frameCount
m.SpectralFlatness = acc.spectralFlatnessSum / frameCount
m.SpectralCrest = acc.spectralCrestSum / frameCount
m.SpectralFlux = acc.spectralFluxSum / frameCount
m.SpectralSlope = acc.spectralSlopeSum / frameCount
m.SpectralDecrease = acc.spectralDecreaseSum / frameCount
m.SpectralRolloff = acc.spectralRolloffSum / frameCount
}
acc.finalizeSpectral().writeSpectralTo(&m.BaseMeasurements)

return m
}
156 changes: 156 additions & 0 deletions internal/processor/analyzer_metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package processor

import (
"math"
"testing"
)

const spectralTestEpsilon = 1e-9

func TestFinalizeSpectral_ZeroFrameCount(t *testing.T) {
acc := &baseMetadataAccumulators{}
result := acc.finalizeSpectral()

if result != (SpectralMetrics{}) {
t.Errorf("expected zero-value SpectralMetrics, got %+v", result)
}
}

func TestFinalizeSpectral_AveragesCorrectly(t *testing.T) {
acc := &baseMetadataAccumulators{
spectralMeanSum: 10.0,
spectralVarianceSum: 20.0,
spectralCentroidSum: 3000.0,
spectralSpreadSum: 600.0,
spectralSkewnessSum: 4.0,
spectralKurtosisSum: 8.0,
spectralEntropySum: 1.5,
spectralFlatnessSum: 0.5,
spectralCrestSum: 6.0,
spectralFluxSum: 2.0,
spectralSlopeSum: -0.02,
spectralDecreaseSum: 0.4,
spectralRolloffSum: 8000.0,
spectralFrameCount: 2,
}

result := acc.finalizeSpectral()

checks := []struct {
name string
got float64
want float64
}{
{"Mean", result.Mean, 5.0},
{"Variance", result.Variance, 10.0},
{"Centroid", result.Centroid, 1500.0},
{"Spread", result.Spread, 300.0},
{"Skewness", result.Skewness, 2.0},
{"Kurtosis", result.Kurtosis, 4.0},
{"Entropy", result.Entropy, 0.75},
{"Flatness", result.Flatness, 0.25},
{"Crest", result.Crest, 3.0},
{"Flux", result.Flux, 1.0},
{"Slope", result.Slope, -0.01},
{"Decrease", result.Decrease, 0.2},
{"Rolloff", result.Rolloff, 4000.0},
}
for _, c := range checks {
if math.Abs(c.got-c.want) > spectralTestEpsilon {
t.Errorf("%s: got %v, want %v", c.name, c.got, c.want)
}
}
}

func TestWriteSpectralTo_MapsAllFields(t *testing.T) {
sm := SpectralMetrics{
Mean: 1.0,
Variance: 2.0,
Centroid: 3.0,
Spread: 4.0,
Skewness: 5.0,
Kurtosis: 6.0,
Entropy: 7.0,
Flatness: 8.0,
Crest: 9.0,
Flux: 10.0,
Slope: 11.0,
Decrease: 12.0,
Rolloff: 13.0,
}

var bm BaseMeasurements
sm.writeSpectralTo(&bm)

checks := []struct {
name string
got float64
want float64
}{
{"SpectralMean", bm.SpectralMean, 1.0},
{"SpectralVariance", bm.SpectralVariance, 2.0},
{"SpectralCentroid", bm.SpectralCentroid, 3.0},
{"SpectralSpread", bm.SpectralSpread, 4.0},
{"SpectralSkewness", bm.SpectralSkewness, 5.0},
{"SpectralKurtosis", bm.SpectralKurtosis, 6.0},
{"SpectralEntropy", bm.SpectralEntropy, 7.0},
{"SpectralFlatness", bm.SpectralFlatness, 8.0},
{"SpectralCrest", bm.SpectralCrest, 9.0},
{"SpectralFlux", bm.SpectralFlux, 10.0},
{"SpectralSlope", bm.SpectralSlope, 11.0},
{"SpectralDecrease", bm.SpectralDecrease, 12.0},
{"SpectralRolloff", bm.SpectralRolloff, 13.0},
}
for _, c := range checks {
if math.Abs(c.got-c.want) > spectralTestEpsilon {
t.Errorf("%s: got %v, want %v", c.name, c.got, c.want)
}
}
}

func TestFinalizeSpectral_WriteSpectralTo_Chained(t *testing.T) {
acc := &baseMetadataAccumulators{
spectralMeanSum: 30.0,
spectralVarianceSum: 60.0,
spectralCentroidSum: 9000.0,
spectralSpreadSum: 1500.0,
spectralSkewnessSum: 6.0,
spectralKurtosisSum: 12.0,
spectralEntropySum: 2.1,
spectralFlatnessSum: 0.9,
spectralCrestSum: 15.0,
spectralFluxSum: 3.0,
spectralSlopeSum: -0.06,
spectralDecreaseSum: 1.2,
spectralRolloffSum: 24000.0,
spectralFrameCount: 3,
}

var bm BaseMeasurements
acc.finalizeSpectral().writeSpectralTo(&bm)

checks := []struct {
name string
got float64
want float64
}{
{"SpectralMean", bm.SpectralMean, 10.0},
{"SpectralVariance", bm.SpectralVariance, 20.0},
{"SpectralCentroid", bm.SpectralCentroid, 3000.0},
{"SpectralSpread", bm.SpectralSpread, 500.0},
{"SpectralSkewness", bm.SpectralSkewness, 2.0},
{"SpectralKurtosis", bm.SpectralKurtosis, 4.0},
{"SpectralEntropy", bm.SpectralEntropy, 0.7},
{"SpectralFlatness", bm.SpectralFlatness, 0.3},
{"SpectralCrest", bm.SpectralCrest, 5.0},
{"SpectralFlux", bm.SpectralFlux, 1.0},
{"SpectralSlope", bm.SpectralSlope, -0.02},
{"SpectralDecrease", bm.SpectralDecrease, 0.4},
{"SpectralRolloff", bm.SpectralRolloff, 8000.0},
}
for _, c := range checks {
if math.Abs(c.got-c.want) > spectralTestEpsilon {
t.Errorf("%s: got %v, want %v", c.name, c.got, c.want)
}
}
}
22 changes: 22 additions & 0 deletions internal/processor/analyzer_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,28 @@ func measureOutputSilenceRegionFromReader(reader *audio.Reader, region SilenceRe
}, nil
}

// extractRegionPair builds optional SilenceRegion and SpeechRegion pointers
// from AudioMeasurements profiles. Returns (nil, nil) when both profiles are absent.
func extractRegionPair(m *AudioMeasurements) (*SilenceRegion, *SpeechRegion) {
var silRegion *SilenceRegion
var spRegion *SpeechRegion
if m.NoiseProfile != nil {
silRegion = &SilenceRegion{
Start: m.NoiseProfile.Start,
End: m.NoiseProfile.Start + m.NoiseProfile.Duration,
Duration: m.NoiseProfile.Duration,
}
}
if m.SpeechProfile != nil {
spRegion = &SpeechRegion{
Start: m.SpeechProfile.Region.Start,
End: m.SpeechProfile.Region.End,
Duration: m.SpeechProfile.Region.Duration,
}
}
return silRegion, spRegion
}

// MeasureOutputRegions measures both silence and speech regions from the same
// output file in a single open/close cycle. This avoids redundant file opens,
// demuxing, and decoding that would occur if silence and speech regions were
Expand Down
Loading