From 95d90309e476677d83ecd59b49f5843201a731a7 Mon Sep 17 00:00:00 2001 From: Martin Wimpress Date: Fri, 6 Feb 2026 16:43:50 +0000 Subject: [PATCH] feat(logging): normalise final spectral metrics for gain comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Propagate NormalisationResult into report generation and pass to noise-floor and speech-region tables - Compute effective normalisation gain (dB) and enable gain-normalise mode when available - Compensate final spectral metrics (mean, variance, flux, slope) by dividing out applied gain using scaling power per-metric - Add normaliseForGain helper to internal/logging/table.go - Add tests for normaliseForGain and for table output: - verify † markers and footnote appear when normalisation present - verify no markers/footnote when normalisation absent Impact: final spectral metrics in reports are now gain-normalised for reliable cross-stage comparisons; reports include a † marker and a footnote showing the effective gain when applied. Signed-off-by: Martin Wimpress --- internal/logging/report.go | 106 +++++++++++++++--- internal/logging/table.go | 13 +++ internal/logging/table_test.go | 189 +++++++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+), 12 deletions(-) diff --git a/internal/logging/report.go b/internal/logging/report.go index 7fdd5e0..7f898b4 100644 --- a/internal/logging/report.go +++ b/internal/logging/report.go @@ -341,11 +341,17 @@ func GenerateReport(data ReportData) error { // Loudness Measurements Table (Input → Filtered → Final) writeLoudnessTable(f, inputMeasurements, filteredMeasurements, finalMeasurements) + // Extract normalisation result for gain-dependent metric compensation + var normResult *processor.NormalisationResult + if data.Result != nil { + normResult = data.Result.NormResult + } + // Noise Floor Analysis Table - writeNoiseFloorTable(f, inputMeasurements, filteredMeasurements, getFinalMeasurements(data.Result)) + writeNoiseFloorTable(f, inputMeasurements, filteredMeasurements, getFinalMeasurements(data.Result), normResult) // Speech Region Analysis Table - writeSpeechRegionTable(f, inputMeasurements, filteredMeasurements, getFinalMeasurements(data.Result)) + writeSpeechRegionTable(f, inputMeasurements, filteredMeasurements, getFinalMeasurements(data.Result), normResult) return nil } @@ -1118,7 +1124,7 @@ func writeLoudnessTable(f *os.File, input *processor.AudioMeasurements, filtered // writeNoiseFloorTable outputs a three-column comparison table for noise floor metrics. // Columns: Input (Pass 1 elected silence candidate), Filtered (Pass 2 SilenceSample), Final (Pass 4 SilenceSample) -func writeNoiseFloorTable(f *os.File, inputMeasurements *processor.AudioMeasurements, filteredMeasurements *processor.OutputMeasurements, finalMeasurements *processor.OutputMeasurements) { +func writeNoiseFloorTable(f *os.File, inputMeasurements *processor.AudioMeasurements, filteredMeasurements *processor.OutputMeasurements, finalMeasurements *processor.OutputMeasurements, normResult *processor.NormalisationResult) { writeSection(f, "Noise Floor Analysis") // Skip if no input measurements or noise profile @@ -1128,6 +1134,13 @@ func writeNoiseFloorTable(f *os.File, inputMeasurements *processor.AudioMeasurem return } + // Compute effective normalisation gain for spectral metric compensation + var effectiveGainDB float64 + if normResult != nil && !normResult.Skipped { + effectiveGainDB = normResult.OutputLUFS - normResult.InputLUFS + } + gainNormalise := effectiveGainDB != 0 + // Find the elected silence candidate in SilenceCandidates by matching Region.Start to NoiseProfile.Start // NoiseProfile only has ~10 fields, but we need the full 20+ field SilenceCandidateMetrics var inputNoise *processor.SilenceCandidateMetrics @@ -1298,7 +1311,14 @@ func writeNoiseFloorTable(f *os.File, inputMeasurements *processor.AudioMeasurem if finalNoise != nil { finalMean = finalNoise.SpectralMean } - table.AddRow("Spectral Mean", + if gainNormalise && !finalIsDigitalSilence { + finalMean = normaliseForGain(finalMean, effectiveGainDB, 1) + } + meanLabel := "Spectral Mean" + if gainNormalise { + meanLabel = "Spectral Mean †" + } + table.AddRow(meanLabel, []string{ formatMetric(inputMean, 6), formatMetricSpectral(filteredMean, 6, filteredIsDigitalSilence), @@ -1318,7 +1338,14 @@ func writeNoiseFloorTable(f *os.File, inputMeasurements *processor.AudioMeasurem if finalNoise != nil { finalVar = finalNoise.SpectralVariance } - table.AddRow("Spectral Variance", + if gainNormalise && !finalIsDigitalSilence { + finalVar = normaliseForGain(finalVar, effectiveGainDB, 2) + } + varLabel := "Spectral Variance" + if gainNormalise { + varLabel = "Spectral Variance †" + } + table.AddRow(varLabel, []string{ formatMetric(inputVar, 6), formatMetricSpectral(filteredVar, 6, filteredIsDigitalSilence), @@ -1480,7 +1507,14 @@ func writeNoiseFloorTable(f *os.File, inputMeasurements *processor.AudioMeasurem if finalNoise != nil { finalFlux = finalNoise.SpectralFlux } - table.AddRow("Spectral Flux", + if gainNormalise && !finalIsDigitalSilence { + finalFlux = normaliseForGain(finalFlux, effectiveGainDB, 2) + } + fluxLabel := "Spectral Flux" + if gainNormalise { + fluxLabel = "Spectral Flux †" + } + table.AddRow(fluxLabel, []string{ formatMetric(inputFlux, 6), formatMetricSpectral(filteredFlux, 6, filteredIsDigitalSilence), @@ -1500,7 +1534,14 @@ func writeNoiseFloorTable(f *os.File, inputMeasurements *processor.AudioMeasurem if finalNoise != nil { finalSlope = finalNoise.SpectralSlope } - table.AddRow("Spectral Slope", + if gainNormalise && !finalIsDigitalSilence { + finalSlope = normaliseForGain(finalSlope, effectiveGainDB, 1) + } + slopeLabel := "Spectral Slope" + if gainNormalise { + slopeLabel = "Spectral Slope †" + } + table.AddRow(slopeLabel, []string{ formatMetric(inputSlope, 9), formatMetricSpectral(filteredSlope, 9, filteredIsDigitalSilence), @@ -1659,12 +1700,15 @@ func writeNoiseFloorTable(f *os.File, inputMeasurements *processor.AudioMeasurem table.AddRow("Character", []string{inputChar, filteredChar, finalChar}, "", "") fmt.Fprint(f, table.String()) + if gainNormalise { + fmt.Fprintf(f, "† Final values gain-normalised (÷ %.1f dB) for cross-stage comparison\n", effectiveGainDB) + } fmt.Fprintln(f, "") } // writeSpeechRegionTable outputs a three-column comparison table for speech region metrics. // Columns: Input (Pass 1 speech profile), Filtered (Pass 2 SpeechSample), Final (Pass 4 SpeechSample) -func writeSpeechRegionTable(f *os.File, inputMeasurements *processor.AudioMeasurements, filteredMeasurements *processor.OutputMeasurements, finalMeasurements *processor.OutputMeasurements) { +func writeSpeechRegionTable(f *os.File, inputMeasurements *processor.AudioMeasurements, filteredMeasurements *processor.OutputMeasurements, finalMeasurements *processor.OutputMeasurements, normResult *processor.NormalisationResult) { writeSection(f, "Speech Region Analysis") // Skip if no input measurements or speech profile @@ -1674,6 +1718,13 @@ func writeSpeechRegionTable(f *os.File, inputMeasurements *processor.AudioMeasur return } + // Compute effective normalisation gain for spectral metric compensation + var effectiveGainDB float64 + if normResult != nil && !normResult.Skipped { + effectiveGainDB = normResult.OutputLUFS - normResult.InputLUFS + } + gainNormalise := effectiveGainDB != 0 + // Extract speech samples inputSpeech := inputMeasurements.SpeechProfile var filteredSpeech *processor.SpeechCandidateMetrics @@ -1749,7 +1800,14 @@ func writeSpeechRegionTable(f *os.File, inputMeasurements *processor.AudioMeasur if finalSpeech != nil { finalMean = finalSpeech.SpectralMean } - table.AddMetricRow("Spectral Mean", inputMean, filteredMean, finalMean, 6, "", "") + if gainNormalise { + finalMean = normaliseForGain(finalMean, effectiveGainDB, 1) + } + meanLabel := "Spectral Mean" + if gainNormalise { + meanLabel = "Spectral Mean †" + } + table.AddMetricRow(meanLabel, inputMean, filteredMean, finalMean, 6, "", "") // Spectral Variance inputVar := math.NaN() @@ -1764,7 +1822,14 @@ func writeSpeechRegionTable(f *os.File, inputMeasurements *processor.AudioMeasur if finalSpeech != nil { finalVar = finalSpeech.SpectralVariance } - table.AddMetricRow("Spectral Variance", inputVar, filteredVar, finalVar, 6, "", "") + if gainNormalise { + finalVar = normaliseForGain(finalVar, effectiveGainDB, 2) + } + varLabel := "Spectral Variance" + if gainNormalise { + varLabel = "Spectral Variance †" + } + table.AddMetricRow(varLabel, inputVar, filteredVar, finalVar, 6, "", "") // Spectral Centroid inputCentroid := math.NaN() @@ -1884,7 +1949,14 @@ func writeSpeechRegionTable(f *os.File, inputMeasurements *processor.AudioMeasur if finalSpeech != nil { finalFlux = finalSpeech.SpectralFlux } - table.AddMetricRow("Spectral Flux", inputFlux, filteredFlux, finalFlux, 6, "", interpretFlux(finalFlux)) + if gainNormalise { + finalFlux = normaliseForGain(finalFlux, effectiveGainDB, 2) + } + fluxLabel := "Spectral Flux" + if gainNormalise { + fluxLabel = "Spectral Flux †" + } + table.AddMetricRow(fluxLabel, inputFlux, filteredFlux, finalFlux, 6, "", interpretFlux(finalFlux)) // Spectral Slope inputSlope := math.NaN() @@ -1899,7 +1971,14 @@ func writeSpeechRegionTable(f *os.File, inputMeasurements *processor.AudioMeasur if finalSpeech != nil { finalSlope = finalSpeech.SpectralSlope } - table.AddMetricRow("Spectral Slope", inputSlope, filteredSlope, finalSlope, 9, "", interpretSlope(finalSlope)) + if gainNormalise { + finalSlope = normaliseForGain(finalSlope, effectiveGainDB, 1) + } + slopeLabel := "Spectral Slope" + if gainNormalise { + slopeLabel = "Spectral Slope †" + } + table.AddMetricRow(slopeLabel, inputSlope, filteredSlope, finalSlope, 9, "", interpretSlope(finalSlope)) // Spectral Decrease inputDecrease := math.NaN() @@ -2025,6 +2104,9 @@ func writeSpeechRegionTable(f *os.File, inputMeasurements *processor.AudioMeasur table.AddRow("Character", []string{inputSpeechChar, filteredSpeechChar, finalSpeechChar}, "", "") fmt.Fprint(f, table.String()) + if gainNormalise { + fmt.Fprintf(f, "† Final values gain-normalised (÷ %.1f dB) for cross-stage comparison\n", effectiveGainDB) + } fmt.Fprintln(f, "") } diff --git a/internal/logging/table.go b/internal/logging/table.go index 6755166..6a15f3b 100644 --- a/internal/logging/table.go +++ b/internal/logging/table.go @@ -284,3 +284,16 @@ func (t *MetricTable) AddMetricRow(label string, input, filtered, final float64, Interpretation: interpretation, }) } + +// normaliseForGain compensates a spectral metric value for the gain applied during normalisation. +// scalingPower is 1 for metrics that scale linearly with gain (Mean, Slope) +// or 2 for metrics that scale with gain squared (Variance, Flux). +// Returns math.NaN() if rawValue is NaN, gain is NaN, or gainDB is 0 +// (a zero gain means no normalisation occurred and the caller should use the raw value). +func normaliseForGain(rawValue, gainDB float64, scalingPower int) float64 { + if math.IsNaN(rawValue) || math.IsNaN(gainDB) || gainDB == 0 { + return math.NaN() + } + divisor := math.Pow(10, gainDB*float64(scalingPower)/20.0) + return rawValue / divisor +} diff --git a/internal/logging/table_test.go b/internal/logging/table_test.go index 9f0b03d..bca55ef 100644 --- a/internal/logging/table_test.go +++ b/internal/logging/table_test.go @@ -2,8 +2,11 @@ package logging import ( "math" + "os" "strings" "testing" + + "github.com/linuxmatters/jivetalking/internal/processor" ) func TestFormatMetric(t *testing.T) { @@ -304,3 +307,189 @@ func TestFormatMetricPeak(t *testing.T) { }) } } + +func TestNormaliseForGain(t *testing.T) { + tests := []struct { + name string + rawValue float64 + gainDB float64 + scalingPower int + wantNaN bool + wantApprox float64 // only checked if wantNaN is false + tolerance float64 // relative tolerance for comparison + }{ + { + name: "+18 dB gain on xG metric", + rawValue: 0.004812, + gainDB: 18.0, + scalingPower: 1, + wantApprox: 0.000606, // 0.004812 / 10^(18/20) = 0.004812 / 7.943 + tolerance: 0.001, + }, + { + name: "+18 dB gain on xG2 metric", + rawValue: 0.018876, + gainDB: 18.0, + scalingPower: 2, + wantApprox: 0.000299, // 0.018876 / 10^(36/20) = 0.018876 / 63.096 + tolerance: 0.001, + }, + { + name: "0 dB gain returns NaN", + rawValue: 0.005, + gainDB: 0.0, + scalingPower: 1, + wantNaN: true, + }, + { + name: "NaN input returns NaN", + rawValue: math.NaN(), + gainDB: 18.0, + scalingPower: 1, + wantNaN: true, + }, + { + name: "NaN gain returns NaN", + rawValue: 0.005, + gainDB: math.NaN(), + scalingPower: 1, + wantNaN: true, + }, + { + name: "negative gain (attenuation)", + rawValue: 0.001, + gainDB: -6.0, + scalingPower: 1, + wantApprox: 0.001995, // 0.001 / 10^(-6/20) = 0.001 / 0.5012 + tolerance: 0.001, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normaliseForGain(tt.rawValue, tt.gainDB, tt.scalingPower) + if tt.wantNaN { + if !math.IsNaN(got) { + t.Errorf("normaliseForGain() = %v, want NaN", got) + } + return + } + if math.IsNaN(got) { + t.Errorf("normaliseForGain() = NaN, want %v", tt.wantApprox) + return + } + // Check relative error + relErr := math.Abs(got-tt.wantApprox) / math.Abs(tt.wantApprox) + if relErr > tt.tolerance { + t.Errorf("normaliseForGain() = %v, want approx %v (relative error %.4f > %.4f)", got, tt.wantApprox, relErr, tt.tolerance) + } + }) + } +} + +func TestWriteSpeechRegionTableGainNormalisation(t *testing.T) { + // Helper to create minimal speech metrics with plausible values + makeSpeechMetrics := func() *processor.SpeechCandidateMetrics { + return &processor.SpeechCandidateMetrics{ + RMSLevel: -24.0, + PeakLevel: -12.0, + CrestFactor: 12.0, + SpectralMean: 0.004812, + SpectralVariance: 0.018876, + SpectralCentroid: 1500.0, + SpectralSpread: 800.0, + SpectralSkewness: 2.5, + SpectralKurtosis: 8.0, + SpectralEntropy: 0.65, + SpectralFlatness: 0.15, + SpectralCrest: 12.0, + SpectralFlux: 0.003200, + SpectralSlope: -0.000045, + SpectralDecrease: -0.00012, + SpectralRolloff: 4500.0, + } + } + + t.Run("with_normalisation_result", func(t *testing.T) { + tmpFile, err := os.CreateTemp("", "speech-table-test-*.txt") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + + input := &processor.AudioMeasurements{ + SpeechProfile: makeSpeechMetrics(), + } + filtered := &processor.OutputMeasurements{ + SpeechSample: makeSpeechMetrics(), + } + final := &processor.OutputMeasurements{ + SpeechSample: makeSpeechMetrics(), + } + normResult := &processor.NormalisationResult{ + OutputLUFS: -18.0, + InputLUFS: -36.0, + Skipped: false, + } + + writeSpeechRegionTable(tmpFile, input, filtered, final, normResult) + tmpFile.Close() + + data, err := os.ReadFile(tmpFile.Name()) + if err != nil { + t.Fatal(err) + } + output := string(data) + + // Verify † markers appear on the 4 gain-dependent metrics + for _, label := range []string{"Spectral Mean †", "Spectral Variance †", "Spectral Flux †", "Spectral Slope †"} { + if !strings.Contains(output, label) { + t.Errorf("expected gain-normalised label %q in output", label) + } + } + + // Verify footnote appears + if !strings.Contains(output, "† Final values gain-normalised") { + t.Error("expected gain-normalisation footnote in output") + } + if !strings.Contains(output, "18.0 dB") { + t.Error("expected effective gain value '18.0 dB' in footnote") + } + }) + + t.Run("without_normalisation_result", func(t *testing.T) { + tmpFile, err := os.CreateTemp("", "speech-table-test-*.txt") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + + input := &processor.AudioMeasurements{ + SpeechProfile: makeSpeechMetrics(), + } + filtered := &processor.OutputMeasurements{ + SpeechSample: makeSpeechMetrics(), + } + final := &processor.OutputMeasurements{ + SpeechSample: makeSpeechMetrics(), + } + + writeSpeechRegionTable(tmpFile, input, filtered, final, nil) + tmpFile.Close() + + data, err := os.ReadFile(tmpFile.Name()) + if err != nil { + t.Fatal(err) + } + output := string(data) + + // Verify NO † markers appear + if strings.Contains(output, "†") { + t.Error("expected no † markers when normalisation result is nil") + } + + // Verify NO footnote + if strings.Contains(output, "gain-normalised") { + t.Error("expected no gain-normalisation footnote when normalisation result is nil") + } + }) +}