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"]); }