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
113 changes: 113 additions & 0 deletions AudioQualityEnhancer.Tests/AudioQualityThresholdsTests.cs
Original file line number Diff line number Diff line change
@@ -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));
}

/// <summary>A lossless source is never judged by its bitrate.</summary>
[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));
}

/// <summary>
/// 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.
/// </summary>
[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));
}
}
23 changes: 9 additions & 14 deletions Services/AudioAnalysisInsightService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
21 changes: 5 additions & 16 deletions Services/AudioProfileAdvisorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ public AudioProfileAdvice BuildAdvice(AudioInfo? info, AudioDiagnostics? diagnos
}

var suggestions = new Dictionary<string, AudioProfileSuggestion>(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)
Expand Down Expand Up @@ -159,17 +159,6 @@ private static void AddSuggestion(IDictionary<string, AudioProfileSuggestion> 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);
Expand Down
77 changes: 77 additions & 0 deletions Services/AudioQualityThresholds.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using AudioQualityEnhancer.Models;

namespace AudioQualityEnhancer.Services;

/// <summary>
/// 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.
/// </summary>
public static class AudioQualityThresholds
{
/// <summary>A mono source carries one channel, so it holds up at a lower bitrate.</summary>
public const int LowBitrateMonoBitsPerSecond = 96_000;

public const int LowBitrateMultiChannelBitsPerSecond = 128_000;

public const int LowSampleRateHz = 32_000;

/// <summary>Peak level from which a re-encode is likely to push samples over full scale.</summary>
public const double LowHeadroomPeakDb = -1.0;

public const double VeryQuietLufs = -28;

public const double AlreadyLoudLufs = -9;

/// <summary>A lossy source below the bitrate its channel count needs to hold up.</summary>
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;
}

/// <summary>
/// Little headroom left below full scale. Actual clipping is reported on its own and
/// would otherwise be reported twice, so it is excluded here.
/// </summary>
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;
}

/// <summary>Mutually exclusive with <see cref="IsVeryQuiet"/>, the bands do not overlap.</summary>
public static bool IsAlreadyLoud(AudioDiagnostics? diagnostics)
{
return diagnostics?.IntegratedLoudnessLufs is > AlreadyLoudLufs;
}

public static bool HasProblematicLoudness(AudioDiagnostics? diagnostics)
{
return IsVeryQuiet(diagnostics) || IsAlreadyLoud(diagnostics);
}
}
5 changes: 2 additions & 3 deletions Services/AudioValidationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
20 changes: 8 additions & 12 deletions ViewModels/MainViewModel.Insights.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,37 +52,33 @@ private void UpdateAnalysisWarnings()
{
var warnings = new List<string>();

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