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