From 153c588b1b981fcf1d30abfbbf8e8fc898ebeb23 Mon Sep 17 00:00:00 2001 From: Kentaro Hakase Date: Wed, 12 Aug 2026 02:18:15 +0200 Subject: [PATCH] Give the audio quality rules a single home The numbers that decide when audio counts as weak were written out four times: in the analysis scoring, in the profile advice, in the result validation and in the view model warnings. Each copy carried the same bitrate bounds, the same sample rate bound, the same headroom limit and the same loudness band. Changing one of them would have made the interface contradict itself - the warning strip saying one thing, the analysis report another - with nothing to catch it. They move into AudioQualityThresholds as named constants with the checks built on them, and the four call sites now ask that type instead of repeating the comparison. The rules themselves are unchanged, which the existing analysis, advice and validation tests confirm; the new tests pin the boundaries so a later change to a number is a deliberate one. Two details the copies had already agreed on are now stated once: low headroom excludes actual clipping, because clipping is reported on its own and would otherwise appear twice, and the two loudness bands cannot overlap. --- .../AudioQualityThresholdsTests.cs | 113 ++++++++++++++++++ Services/AudioAnalysisInsightService.cs | 23 ++-- Services/AudioProfileAdvisorService.cs | 21 +--- Services/AudioQualityThresholds.cs | 77 ++++++++++++ Services/AudioValidationService.cs | 5 +- ViewModels/MainViewModel.Insights.cs | 20 ++-- 6 files changed, 214 insertions(+), 45 deletions(-) create mode 100644 AudioQualityEnhancer.Tests/AudioQualityThresholdsTests.cs create mode 100644 Services/AudioQualityThresholds.cs diff --git a/AudioQualityEnhancer.Tests/AudioQualityThresholdsTests.cs b/AudioQualityEnhancer.Tests/AudioQualityThresholdsTests.cs new file mode 100644 index 0000000..b84ad84 --- /dev/null +++ b/AudioQualityEnhancer.Tests/AudioQualityThresholdsTests.cs @@ -0,0 +1,113 @@ +using AudioQualityEnhancer.Models; +using AudioQualityEnhancer.Services; + +namespace AudioQualityEnhancer.Tests; + +public sealed class AudioQualityThresholdsTests +{ + [Theory] + [InlineData(true, 1, 95_000, true)] + [InlineData(true, 1, 96_000, false)] + [InlineData(true, 2, 127_000, true)] + [InlineData(true, 2, 128_000, false)] + [InlineData(true, 2, 96_000, true)] + [InlineData(false, 2, 64_000, false)] + [InlineData(true, 2, 0, false)] + public void HasLowBitrate_UsesAChannelDependentThreshold(bool isLossy, int channels, int bitRate, bool expected) + { + using var info = new AudioInfo + { + IsLikelyLossy = isLossy, + Channels = channels, + BitRate = bitRate + }; + + Assert.Equal(expected, AudioQualityThresholds.HasLowBitrate(info)); + } + + /// A lossless source is never judged by its bitrate. + [Fact] + public void HasLowBitrate_IgnoresALosslessSource() + { + using var info = new AudioInfo { IsLikelyLossy = false, Channels = 2, BitRate = 1_000 }; + + Assert.False(AudioQualityThresholds.HasLowBitrate(info)); + } + + [Fact] + public void HasLowBitrate_IsFalseWithoutInfo() + { + Assert.False(AudioQualityThresholds.HasLowBitrate(null)); + } + + [Theory] + [InlineData(0, false)] + [InlineData(22_050, true)] + [InlineData(31_999, true)] + [InlineData(32_000, false)] + [InlineData(48_000, false)] + public void HasLowSampleRate_UsesTheDocumentedBoundary(int sampleRate, bool expected) + { + using var info = new AudioInfo { SampleRate = sampleRate }; + + Assert.Equal(expected, AudioQualityThresholds.HasLowSampleRate(info)); + } + + [Theory] + [InlineData(-2.0, false)] + [InlineData(-1.1, false)] + [InlineData(-1.0, true)] + [InlineData(-0.5, true)] + public void HasLowHeadroom_UsesTheDocumentedBoundary(double truePeakDb, bool expected) + { + using var diagnostics = new AudioDiagnostics { TruePeakDb = truePeakDb }; + + Assert.Equal(expected, AudioQualityThresholds.HasLowHeadroom(diagnostics)); + } + + [Fact] + public void HasLowHeadroom_FallsBackToTheMeasuredMaximum() + { + using var diagnostics = new AudioDiagnostics { TruePeakDb = null, MaxVolumeDb = -0.2 }; + + Assert.True(AudioQualityThresholds.HasLowHeadroom(diagnostics)); + } + + /// + /// Clipping is reported on its own, so it must not also count as low headroom or the + /// same problem would appear twice in the findings. + /// + [Fact] + public void HasLowHeadroom_ExcludesActualClipping() + { + using var diagnostics = new AudioDiagnostics { TruePeakDb = 0.5, MaxVolumeDb = 0.5 }; + + Assert.True(AudioQualityThresholds.HasPotentialClipping(diagnostics)); + Assert.False(AudioQualityThresholds.HasLowHeadroom(diagnostics)); + } + + [Theory] + [InlineData(-30.0, true, false)] + [InlineData(-28.0, false, false)] + [InlineData(-16.0, false, false)] + [InlineData(-9.0, false, false)] + [InlineData(-5.0, false, true)] + public void LoudnessBands_DoNotOverlap(double lufs, bool veryQuiet, bool alreadyLoud) + { + using var diagnostics = new AudioDiagnostics { IntegratedLoudnessLufs = lufs }; + + Assert.Equal(veryQuiet, AudioQualityThresholds.IsVeryQuiet(diagnostics)); + Assert.Equal(alreadyLoud, AudioQualityThresholds.IsAlreadyLoud(diagnostics)); + Assert.Equal(veryQuiet || alreadyLoud, AudioQualityThresholds.HasProblematicLoudness(diagnostics)); + } + + [Fact] + public void AllChecks_AreFalseWithoutDiagnostics() + { + Assert.False(AudioQualityThresholds.HasPotentialClipping(null)); + Assert.False(AudioQualityThresholds.HasLowHeadroom(null)); + Assert.False(AudioQualityThresholds.IsVeryQuiet(null)); + Assert.False(AudioQualityThresholds.IsAlreadyLoud(null)); + Assert.False(AudioQualityThresholds.HasProblematicLoudness(null)); + } +} diff --git a/Services/AudioAnalysisInsightService.cs b/Services/AudioAnalysisInsightService.cs index e53ffb3..5e7950d 100644 --- a/Services/AudioAnalysisInsightService.cs +++ b/Services/AudioAnalysisInsightService.cs @@ -19,18 +19,14 @@ public AudioAnalysisReport BuildReport(AudioInfo info, AudioDiagnostics? diagnos AddRecommendation(recommendations, AudioAnalysisFindingKind.LossyTranscodingRisk); } - if (info.IsLikelyLossy && info.BitRate is > 0) + if (AudioQualityThresholds.HasLowBitrate(info)) { - var lowBitrateThreshold = info.Channels == 1 ? 96_000 : 128_000; - if (info.BitRate.Value < lowBitrateThreshold) - { - score -= 15; - AddFinding(findings, AudioAnalysisFindingKind.LowBitrate, AudioInsightSeverity.Warning); - AddRecommendation(recommendations, AudioAnalysisFindingKind.LowBitrate); - } + score -= 15; + AddFinding(findings, AudioAnalysisFindingKind.LowBitrate, AudioInsightSeverity.Warning); + AddRecommendation(recommendations, AudioAnalysisFindingKind.LowBitrate); } - if (info.SampleRate is > 0 and < 32000) + if (AudioQualityThresholds.HasLowSampleRate(info)) { score -= 10; AddFinding(findings, AudioAnalysisFindingKind.LowSampleRate, AudioInsightSeverity.Warning); @@ -56,27 +52,26 @@ public AudioAnalysisReport BuildReport(AudioInfo info, AudioDiagnostics? diagnos } else { - var peak = diagnostics.TruePeakDb ?? diagnostics.MaxVolumeDb; - if (diagnostics.HasPotentialClipping) + if (AudioQualityThresholds.HasPotentialClipping(diagnostics)) { score -= 25; AddFinding(findings, AudioAnalysisFindingKind.PotentialClipping, AudioInsightSeverity.Critical); AddRecommendation(recommendations, AudioAnalysisFindingKind.PotentialClipping); } - else if (peak is >= -1.0) + else if (AudioQualityThresholds.HasLowHeadroom(diagnostics)) { score -= 10; AddFinding(findings, AudioAnalysisFindingKind.LowHeadroom, AudioInsightSeverity.Warning); AddRecommendation(recommendations, AudioAnalysisFindingKind.LowHeadroom); } - if (diagnostics.IntegratedLoudnessLufs is < -28) + if (AudioQualityThresholds.IsVeryQuiet(diagnostics)) { score -= 10; AddFinding(findings, AudioAnalysisFindingKind.VeryQuiet, AudioInsightSeverity.Warning); AddRecommendation(recommendations, AudioAnalysisFindingKind.VeryQuiet); } - else if (diagnostics.IntegratedLoudnessLufs is > -9) + else if (AudioQualityThresholds.IsAlreadyLoud(diagnostics)) { score -= 10; AddFinding(findings, AudioAnalysisFindingKind.AlreadyLoud, AudioInsightSeverity.Warning); diff --git a/Services/AudioProfileAdvisorService.cs b/Services/AudioProfileAdvisorService.cs index e386280..e4d6e54 100644 --- a/Services/AudioProfileAdvisorService.cs +++ b/Services/AudioProfileAdvisorService.cs @@ -19,14 +19,14 @@ public AudioProfileAdvice BuildAdvice(AudioInfo? info, AudioDiagnostics? diagnos } var suggestions = new Dictionary(StringComparer.Ordinal); - var hasLowBitrate = HasLowBitrate(info); - var hasLowSampleRate = info.SampleRate is > 0 and < 32000; + var hasLowBitrate = AudioQualityThresholds.HasLowBitrate(info); + var hasLowSampleRate = AudioQualityThresholds.HasLowSampleRate(info); var looksSpeechLike = info.Channels == 1 || hasLowBitrate || hasLowSampleRate; var isVideoSource = IsVideoSource(info); var hasAdvancedDiagnostics = diagnostics is not null; - var hasPotentialClipping = diagnostics?.HasPotentialClipping == true; - var hasLowHeadroom = !hasPotentialClipping && (diagnostics?.TruePeakDb ?? diagnostics?.MaxVolumeDb) is >= -1.0; - var hasProblematicLoudness = diagnostics?.IntegratedLoudnessLufs is < -28 or > -9; + var hasPotentialClipping = AudioQualityThresholds.HasPotentialClipping(diagnostics); + var hasLowHeadroom = AudioQualityThresholds.HasLowHeadroom(diagnostics); + var hasProblematicLoudness = AudioQualityThresholds.HasProblematicLoudness(diagnostics); var hasTechnicalWarnings = info.IsLikelyLossy || hasLowBitrate || hasLowSampleRate || hasPotentialClipping || hasLowHeadroom || hasProblematicLoudness; if (looksSpeechLike) @@ -159,17 +159,6 @@ private static void AddSuggestion(IDictionary su } } - private static bool HasLowBitrate(AudioInfo info) - { - if (!info.IsLikelyLossy || info.BitRate is not > 0) - { - return false; - } - - var threshold = info.Channels == 1 ? 96_000 : 128_000; - return info.BitRate.Value < threshold; - } - private static bool IsVideoSource(AudioInfo info) { var extension = Path.GetExtension(info.SourcePath); diff --git a/Services/AudioQualityThresholds.cs b/Services/AudioQualityThresholds.cs new file mode 100644 index 0000000..9dce407 --- /dev/null +++ b/Services/AudioQualityThresholds.cs @@ -0,0 +1,77 @@ +using AudioQualityEnhancer.Models; + +namespace AudioQualityEnhancer.Services; + +/// +/// The rules that decide when source or output audio counts as weak. They were written +/// out four times - in the analysis scoring, the profile advice, the result validation +/// and the view model warnings - with the same numbers in each place, so a change to one +/// of them would have made the interface contradict itself with nothing to catch it. +/// +public static class AudioQualityThresholds +{ + /// A mono source carries one channel, so it holds up at a lower bitrate. + public const int LowBitrateMonoBitsPerSecond = 96_000; + + public const int LowBitrateMultiChannelBitsPerSecond = 128_000; + + public const int LowSampleRateHz = 32_000; + + /// Peak level from which a re-encode is likely to push samples over full scale. + public const double LowHeadroomPeakDb = -1.0; + + public const double VeryQuietLufs = -28; + + public const double AlreadyLoudLufs = -9; + + /// A lossy source below the bitrate its channel count needs to hold up. + public static bool HasLowBitrate(AudioInfo? info) + { + if (info is null || !info.IsLikelyLossy || info.BitRate is not > 0) + { + return false; + } + + var threshold = info.Channels == 1 + ? LowBitrateMonoBitsPerSecond + : LowBitrateMultiChannelBitsPerSecond; + + return info.BitRate.Value < threshold; + } + + public static bool HasLowSampleRate(AudioInfo? info) + { + return info?.SampleRate is > 0 and < LowSampleRateHz; + } + + public static bool HasPotentialClipping(AudioDiagnostics? diagnostics) + { + return diagnostics?.HasPotentialClipping == true; + } + + /// + /// Little headroom left below full scale. Actual clipping is reported on its own and + /// would otherwise be reported twice, so it is excluded here. + /// + public static bool HasLowHeadroom(AudioDiagnostics? diagnostics) + { + return !HasPotentialClipping(diagnostics) && + (diagnostics?.TruePeakDb ?? diagnostics?.MaxVolumeDb) is >= LowHeadroomPeakDb; + } + + public static bool IsVeryQuiet(AudioDiagnostics? diagnostics) + { + return diagnostics?.IntegratedLoudnessLufs is < VeryQuietLufs; + } + + /// Mutually exclusive with , the bands do not overlap. + public static bool IsAlreadyLoud(AudioDiagnostics? diagnostics) + { + return diagnostics?.IntegratedLoudnessLufs is > AlreadyLoudLufs; + } + + public static bool HasProblematicLoudness(AudioDiagnostics? diagnostics) + { + return IsVeryQuiet(diagnostics) || IsAlreadyLoud(diagnostics); + } +} diff --git a/Services/AudioValidationService.cs b/Services/AudioValidationService.cs index aa352bb..69ea1bc 100644 --- a/Services/AudioValidationService.cs +++ b/Services/AudioValidationService.cs @@ -312,12 +312,11 @@ private static void AddPeakFindings(AudioDiagnostics? outputDiagnostics, ICollec return; } - var peak = outputDiagnostics.TruePeakDb ?? outputDiagnostics.MaxVolumeDb; - if (outputDiagnostics.HasPotentialClipping) + if (AudioQualityThresholds.HasPotentialClipping(outputDiagnostics)) { AddFinding(findings, AudioComparisonFindingKind.PotentialClipping, AudioInsightSeverity.Critical); } - else if (peak is >= -1.0) + else if (AudioQualityThresholds.HasLowHeadroom(outputDiagnostics)) { AddFinding(findings, AudioComparisonFindingKind.LowHeadroom, AudioInsightSeverity.Warning); } diff --git a/ViewModels/MainViewModel.Insights.cs b/ViewModels/MainViewModel.Insights.cs index d4f1e61..ee60d5a 100644 --- a/ViewModels/MainViewModel.Insights.cs +++ b/ViewModels/MainViewModel.Insights.cs @@ -52,37 +52,33 @@ private void UpdateAnalysisWarnings() { var warnings = new List(); - if (AudioInfo?.IsLikelyLossy == true && AudioInfo.BitRate is > 0) + if (AudioQualityThresholds.HasLowBitrate(AudioInfo)) { - var lowBitrateThreshold = AudioInfo.Channels == 1 ? 96_000 : 128_000; - if (AudioInfo.BitRate.Value < lowBitrateThreshold) - { - warnings.Add(LocalizationService.Instance.Format("Warning_LowBitrateFormat", AudioInfo.BitRateDisplay)); - } + warnings.Add(LocalizationService.Instance.Format("Warning_LowBitrateFormat", AudioInfo!.BitRateDisplay)); } - if (AudioInfo?.SampleRate is > 0 and < 32000) + if (AudioQualityThresholds.HasLowSampleRate(AudioInfo)) { - warnings.Add(LocalizationService.Instance.Format("Warning_LowSampleRateFormat", AudioInfo.SampleRateDisplay)); + warnings.Add(LocalizationService.Instance.Format("Warning_LowSampleRateFormat", AudioInfo!.SampleRateDisplay)); } var diagnostics = AudioDiagnostics; if (diagnostics is not null) { - if (diagnostics.HasPotentialClipping) + if (AudioQualityThresholds.HasPotentialClipping(diagnostics)) { warnings.Add(LocalizationService.Instance["Warning_PotentialClipping"]); } - else if ((diagnostics.TruePeakDb ?? diagnostics.MaxVolumeDb) is >= -1.0) + else if (AudioQualityThresholds.HasLowHeadroom(diagnostics)) { warnings.Add(LocalizationService.Instance["Warning_LowHeadroom"]); } - if (diagnostics.IntegratedLoudnessLufs is < -28) + if (AudioQualityThresholds.IsVeryQuiet(diagnostics)) { warnings.Add(LocalizationService.Instance["Warning_VeryQuiet"]); } - else if (diagnostics.IntegratedLoudnessLufs is > -9) + else if (AudioQualityThresholds.IsAlreadyLoud(diagnostics)) { warnings.Add(LocalizationService.Instance["Warning_AlreadyLoud"]); }