diff --git a/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs b/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs new file mode 100644 index 0000000000..e978af5e49 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs @@ -0,0 +1,117 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ManagedBass; +using Moq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; +using osuTK.Audio; +using FileInfo = osu.Game.IO.FileInfo; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckTooShortAudioFilesTest + { + private CheckTooShortAudioFiles check; + private IBeatmap beatmap; + + [SetUp] + public void Setup() + { + check = new CheckTooShortAudioFiles(); + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + BeatmapSet = new BeatmapSetInfo + { + Files = new List(new[] + { + new BeatmapSetFileInfo + { + Filename = "abc123.wav", + FileInfo = new FileInfo { Hash = "abcdef" } + } + }) + } + } + }; + + // 0 = No output device. This still allows decoding. + if (!Bass.Init(0) && Bass.LastError != Errors.Already) + throw new AudioException("Could not initialize Bass."); + } + + [Test] + public void TestDifferentExtension() + { + beatmap.BeatmapInfo.BeatmapSet.Files.Clear(); + beatmap.BeatmapInfo.BeatmapSet.Files.Add(new BeatmapSetFileInfo + { + Filename = "abc123.jpg", + FileInfo = new FileInfo { Hash = "abcdef" } + }); + + // Should fail to load, but not produce an error due to the extension not being expected to load. + Assert.IsEmpty(check.Run(getContext(null, allowMissing: true))); + } + + [Test] + public void TestRegularAudioFile() + { + Assert.IsEmpty(check.Run(getContext("Samples/test-sample.mp3"))); + } + + [Test] + public void TestBlankAudioFile() + { + // This is a 0 ms duration audio file, commonly used to silence sliderslides/ticks, and so should be fine. + Assert.IsEmpty(check.Run(getContext("Samples/blank.wav"))); + } + + [Test] + public void TestTooShortAudioFile() + { + var issues = check.Run(getContext("Samples/test-sample-cut.mp3")).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckTooShortAudioFiles.IssueTemplateTooShort); + } + + [Test] + public void TestMissingAudioFile() + { + Assert.IsEmpty(check.Run(getContext("Samples/missing.mp3", allowMissing: true))); + } + + [Test] + public void TestCorruptAudioFile() + { + var issues = check.Run(getContext("Samples/corrupt.wav")).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckTooShortAudioFiles.IssueTemplateBadFormat); + } + + private BeatmapVerifierContext getContext(string resourceName, bool allowMissing = false) + { + Stream resourceStream = string.IsNullOrEmpty(resourceName) ? null : TestResources.OpenResource(resourceName); + if (!allowMissing && resourceStream == null) + throw new FileNotFoundException($"The requested test resource \"{resourceName}\" does not exist."); + + var mockWorkingBeatmap = new Mock(beatmap, null, null); + mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); + + return new BeatmapVerifierContext(beatmap, mockWorkingBeatmap.Object); + } + } +} diff --git a/osu.Game.Tests/Resources/Samples/blank.wav b/osu.Game.Tests/Resources/Samples/blank.wav new file mode 100644 index 0000000000..878bf23cea Binary files /dev/null and b/osu.Game.Tests/Resources/Samples/blank.wav differ diff --git a/osu.Game.Tests/Resources/Samples/corrupt.wav b/osu.Game.Tests/Resources/Samples/corrupt.wav new file mode 100644 index 0000000000..87c7de4b7b Binary files /dev/null and b/osu.Game.Tests/Resources/Samples/corrupt.wav differ diff --git a/osu.Game.Tests/Resources/Samples/test-sample-cut.mp3 b/osu.Game.Tests/Resources/Samples/test-sample-cut.mp3 new file mode 100644 index 0000000000..003fe23dca Binary files /dev/null and b/osu.Game.Tests/Resources/Samples/test-sample-cut.mp3 differ diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs index 545fee6264..768ab3545f 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs @@ -24,6 +24,7 @@ namespace osu.Game.Rulesets.Edit new CheckAudioQuality(), new CheckMutedObjects(), new CheckFewHitsounds(), + new CheckTooShortAudioFiles(), // Files new CheckZeroByteFiles(), diff --git a/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs b/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs new file mode 100644 index 0000000000..9a2c714c06 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs @@ -0,0 +1,83 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ManagedBass; +using osu.Framework.Audio.Callbacks; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckTooShortAudioFiles : ICheck + { + private const int ms_threshold = 25; + private const int min_bytes_threshold = 100; + + private readonly string[] audioExtensions = { "mp3", "ogg", "wav" }; + + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Too short audio files"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateTooShort(this), + new IssueTemplateBadFormat(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var beatmapSet = context.Beatmap.BeatmapInfo.BeatmapSet; + + foreach (var file in beatmapSet.Files) + { + Stream data = context.WorkingBeatmap.GetStream(file.FileInfo.StoragePath); + if (data == null) + continue; + + var fileCallbacks = new FileCallbacks(new DataStreamFileProcedures(data)); + int decodeStream = Bass.CreateStream(StreamSystem.NoBuffer, BassFlags.Decode, fileCallbacks.Callbacks, fileCallbacks.Handle); + + if (decodeStream == 0) + { + // If the file is not likely to be properly parsed by Bass, we don't produce Error issues about it. + // Image files and audio files devoid of audio data both fail, for example, but neither would be issues in this check. + if (hasAudioExtension(file.Filename) && probablyHasAudioData(data)) + yield return new IssueTemplateBadFormat(this).Create(file.Filename); + + continue; + } + + long length = Bass.ChannelGetLength(decodeStream); + double ms = Bass.ChannelBytes2Seconds(decodeStream, length) * 1000; + + // Extremely short audio files do not play on some soundcards, resulting in nothing being heard in-game for some users. + if (ms > 0 && ms < ms_threshold) + yield return new IssueTemplateTooShort(this).Create(file.Filename, ms); + } + } + + private bool hasAudioExtension(string filename) => audioExtensions.Any(filename.ToLower().EndsWith); + private bool probablyHasAudioData(Stream data) => data.Length > min_bytes_threshold; + + public class IssueTemplateTooShort : IssueTemplate + { + public IssueTemplateTooShort(ICheck check) + : base(check, IssueType.Problem, "\"{0}\" is too short ({1:0f} ms), should be at least {2:0f} ms.") + { + } + + public Issue Create(string filename, double ms) => new Issue(this, filename, ms, ms_threshold); + } + + public class IssueTemplateBadFormat : IssueTemplate + { + public IssueTemplateBadFormat(ICheck check) + : base(check, IssueType.Error, "Could not check whether \"{0}\" is too short (code \"{1}\").") + { + } + + public Issue Create(string filename) => new Issue(this, filename, Bass.LastError); + } + } +}