diff --git a/src/Acoustics.Shared/Extensions/EnumerableExtensions.cs b/src/Acoustics.Shared/Extensions/EnumerableExtensions.cs index 65ca3ae78..e559d14e5 100644 --- a/src/Acoustics.Shared/Extensions/EnumerableExtensions.cs +++ b/src/Acoustics.Shared/Extensions/EnumerableExtensions.cs @@ -315,12 +315,11 @@ public static string Join(this IEnumerable items, string delimiter = " ", var result = new StringBuilder(); foreach (var item in items) { - result.Append(item); - result.Append(delimiter); + result.AppendJoin(string.Empty, prefix, item, suffix, delimiter); } // return one delimiter length less because we always add a delimiter on the end - return result.ToString(0, result.Length - delimiter.Length); + return result.ToString(0, Math.Max(0, result.Length - delimiter.Length)); } public static string JoinFormatted(this IEnumerable items, string formatString = "{0:f2}", string delimiter = " ") => diff --git a/src/AnalysisPrograms/Recognizers/GenericRecognizer.cs b/src/AnalysisPrograms/Recognizers/GenericRecognizer.cs index cc9dcb8b1..b26872ac0 100644 --- a/src/AnalysisPrograms/Recognizers/GenericRecognizer.cs +++ b/src/AnalysisPrograms/Recognizers/GenericRecognizer.cs @@ -6,6 +6,7 @@ namespace AnalysisPrograms.Recognizers { using System; using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Reflection; @@ -72,10 +73,10 @@ public static void ValidateProfileTagsMatchAlgorithms(Dictionary { if (profile is CommonParameters c) { - var checks = c.Validate(null).Where(v => v is not null); - if (checks.Any()) + List failures = new(); + if (!Validator.TryValidateObject(c, new ValidationContext(c), failures)) { - throw new ConfigFileException(checks, file) { ProfileName = profileName }; + throw new ConfigFileException(failures, file) { ProfileName = profileName }; } } diff --git a/tests/Acoustics.Test/Acoustics.Test.csproj b/tests/Acoustics.Test/Acoustics.Test.csproj index 42d872d6d..bd01233ad 100644 --- a/tests/Acoustics.Test/Acoustics.Test.csproj +++ b/tests/Acoustics.Test/Acoustics.Test.csproj @@ -38,6 +38,7 @@ + diff --git a/tests/Acoustics.Test/AudioAnalysisTools/HarmonicAnalysis/HarmonicAlgorithmTests.cs b/tests/Acoustics.Test/AudioAnalysisTools/HarmonicAnalysis/HarmonicAlgorithmTests.cs new file mode 100644 index 000000000..d64cebec7 --- /dev/null +++ b/tests/Acoustics.Test/AudioAnalysisTools/HarmonicAnalysis/HarmonicAlgorithmTests.cs @@ -0,0 +1,126 @@ +// +// All code in this file and all associated files are the copyright and property of the QUT Ecoacoustics Research Group (formerly MQUTeR, and formerly QUT Bioacoustics Research Group). +// + +namespace Acoustics.Test.AudioAnalysisTools.HarmonicAnalysis +{ + using System; + using System.IO; + using System.Linq; + using Acoustics.Test.TestHelpers; + using global::AnalysisPrograms.Recognizers.Base; + using global::AudioAnalysisTools; + using global::AudioAnalysisTools.DSP; + using global::AudioAnalysisTools.StandardSpectrograms; + using global::AudioAnalysisTools.WavTools; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class HarmonicAlgorithmTests : OutputDirectoryTest + { + private static readonly FileInfo TestAsset = PathHelper.ResolveAsset("harmonic.wav"); + private readonly SpectrogramStandard spectrogram; + + public HarmonicAlgorithmTests() + { + var recording = new AudioRecording(TestAsset); + this.spectrogram = new SpectrogramStandard( + new SonogramConfig + { + WindowSize = 512, + WindowStep = 512, + WindowOverlap = 0, + NoiseReductionType = NoiseReductionType.None, + NoiseReductionParameter = 0.0, + Duration = recording.Duration, + SampleRate = recording.SampleRate, + }, + recording.WavReader); + } + + [TestMethod] + public void TestHarmonicsAlgorithmOn440HertzHarmonic() + { + var threshold = -80; + var parameters = new HarmonicParameters + { + MinHertz = 400, + MaxHertz = 5500, + // expected value + //MaxFormantGap = 480, + MaxFormantGap = 3500,// this is the lowest value that would produce a result, 3400 does not + MinFormantGap = 400, + MinDuration = 0.9, + MaxDuration = 1.1, + DecibelThresholds = new double?[] { threshold }, + DctThreshold = 0.5, + }; + Assert.That.IsValid(parameters); + + var (events, plots) = HarmonicParameters.GetComponentsWithHarmonics( + this.spectrogram, + parameters, + threshold, + TimeSpan.Zero, + "440_harmonic"); + + this.SaveImage( + SpectrogramTools.GetSonogramPlusCharts(this.spectrogram, events, plots, null)); + + Assert.AreEqual(1, events.Count); + Assert.IsInstanceOfType(events.First(), typeof(HarmonicEvent)); + + // first harmonic is 440Hz fundamental, with 12 harmonics, stopping at 5280 Hz + var actual = events.First() as HarmonicEvent; + Assert.AreEqual(1.0, actual.EventStartSeconds); + Assert.AreEqual(2.0, actual.EventEndSeconds); + Assert.AreEqual(400, actual.LowFrequencyHertz); + Assert.AreEqual(5400, actual.HighFrequencyHertz); + + Assert.Fail("intentionally faulty test"); + } + + [TestMethod] + public void TestHarmonicsAlgorithmOn1000HertzHarmonic() + { + var threshold = -80; + var parameters = new HarmonicParameters + { + MinHertz = 800, + MaxHertz = 5500, + // expected values + //MaxFormantGap = 1050, + MaxFormantGap = 3200, // this is the lowest value that would produce a result, 3100 does not + MinFormantGap = 950, + MinDuration = 0.9, + MaxDuration = 1.1, + DecibelThresholds = new double?[] { threshold }, + DctThreshold = 0.5, + }; + Assert.That.IsValid(parameters); + + var (events, plots) = HarmonicParameters.GetComponentsWithHarmonics( + this.spectrogram, + parameters, + threshold, + TimeSpan.Zero, + "1000_harmonic"); + + this.SaveImage( + SpectrogramTools.GetSonogramPlusCharts(this.spectrogram, events, plots, null)); + + Assert.AreEqual(1, events.Count); + Assert.IsInstanceOfType(events.First(), typeof(HarmonicEvent)); + + // second harmonic is 1000 Hz fundamental, with 4 harmonics, stopping at 5000 Hz + var actual = events.First() as HarmonicEvent; + Assert.AreEqual(3.0, actual.EventStartSeconds); + Assert.AreEqual(4.0, actual.EventEndSeconds); + Assert.AreEqual(900, actual.LowFrequencyHertz); + Assert.AreEqual(5100, actual.HighFrequencyHertz); + + Assert.Fail("intentionally faulty test"); + + } + } +} \ No newline at end of file diff --git a/tests/Acoustics.Test/Shared/Extensions/EnumerableExtensionsTests.cs b/tests/Acoustics.Test/Shared/Extensions/EnumerableExtensionsTests.cs index b72cb6df4..0d07351e4 100644 --- a/tests/Acoustics.Test/Shared/Extensions/EnumerableExtensionsTests.cs +++ b/tests/Acoustics.Test/Shared/Extensions/EnumerableExtensionsTests.cs @@ -28,6 +28,15 @@ public void TestJoinCustomDelimiter() Assert.AreEqual("0,-,1,-,2,-,3,-,4", actual); } + [TestMethod] + public void TestJoinCustomDelimiterWithPrefixAndSuffix() + { + var items = new[] { 0, 1, 2, 3, 4 }; + var actual = items.Join("/", "`", "~"); + + Assert.AreEqual("`0~/`1~/`2~/`3~/`4~", actual); + } + [TestMethod] public void TestJoinNonGeneric() { diff --git a/tests/Acoustics.Test/TestHelpers/Assertions.cs b/tests/Acoustics.Test/TestHelpers/Assertions.cs index e4e255de9..0da8a2f89 100644 --- a/tests/Acoustics.Test/TestHelpers/Assertions.cs +++ b/tests/Acoustics.Test/TestHelpers/Assertions.cs @@ -6,6 +6,7 @@ namespace Acoustics.Test.TestHelpers { using System; using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Globalization; using System.IO; @@ -154,6 +155,47 @@ public static void AreEqual( } } + public static void IsEmpty( + this CollectionAssert _, + IEnumerable actual, + bool printItems = false, + string message = "") + { + if (actual == null) + { + Assert.Fail("actual is null, expected none"); + } + + using var actualEnum = actual.GetEnumerator(); + + if (actualEnum.MoveNext()) + { + var items = printItems ? actual.FormatList() : string.Empty; + Assert.Fail($"Actual had {actual.Count()} items and we expected none.\n{items}\n{message}"); + } + } + + public static void IsValid( + this Assert _, + T actual, + ValidationContext context = null, + string message = "") + where T : IValidatableObject + { + if (actual == null) + { + Assert.Fail("actual is null, expected an object to validate"); + } + + List failures = new(); + context ??= new ValidationContext(actual); + if (!Validator.TryValidateObject(actual, context, failures)) + { + var items = failures.Select(x => x.MemberNames.Join(", ") + " => " + x.ToString()).FormatList(); + Assert.Fail($"Actual was not valid. Returned validation failures:\n{items}\n{message}"); + } + } + public static void AreEqual( this CollectionAssert collectionAssert, double[,] expected, diff --git a/tests/Acoustics.Test/TestHelpers/GeneratedImageTest.cs b/tests/Acoustics.Test/TestHelpers/GeneratedImageTest.cs index e8cb13704..d88ddbad8 100644 --- a/tests/Acoustics.Test/TestHelpers/GeneratedImageTest.cs +++ b/tests/Acoustics.Test/TestHelpers/GeneratedImageTest.cs @@ -116,19 +116,7 @@ private void SaveImage(string typeToken, Image image) { var extra = this.ExtraName.IsNullOrEmpty() ? string.Empty : "_" + this.ExtraName; - var outName = $"{this.TestContext.TestName}{extra}_{typeToken}.png"; - if (image == null) - { - this.TestContext.WriteLine($"Skipping writing expected image `{outName}` because it is null"); - return; - } - - this.SaveTestOutput(output => - { - var path = output.CombinePath(outName); - image.Save(path); - return path; - }); + this.SaveImage(image, $"{extra}_{typeToken}"); } private bool ShouldWrite(WriteTestOutput should) => diff --git a/tests/Acoustics.Test/TestHelpers/OutputDirectoryTest.cs b/tests/Acoustics.Test/TestHelpers/OutputDirectoryTest.cs index d5b268333..8782443a9 100644 --- a/tests/Acoustics.Test/TestHelpers/OutputDirectoryTest.cs +++ b/tests/Acoustics.Test/TestHelpers/OutputDirectoryTest.cs @@ -7,6 +7,8 @@ namespace Acoustics.Test.TestHelpers using System; using System.IO; using Microsoft.VisualStudio.TestTools.UnitTesting; + using SixLabors.ImageSharp; + using SixLabors.ImageSharp.PixelFormats; [TestClass] public class OutputDirectoryTest @@ -69,6 +71,25 @@ protected FileInfo SaveTestOutput(Func callback) return savedFile; } + protected void SaveImage(Image image, params string[] tokens) + { + var token = tokens.Join("_"); + + var outName = $"{this.TestContext.TestName}{token}.png"; + if (image == null) + { + this.TestContext.WriteLine($"Skipping writing expected image `{outName}` because it is null"); + return; + } + + this.SaveTestOutput(output => + { + var path = output.CombinePath(outName); + image.Save(path); + return path; + }); + } + /// /// Save a test result. /// Also saves copies of test results to daily output directories. diff --git a/tests/Fixtures/harmonic.wav b/tests/Fixtures/harmonic.wav new file mode 100644 index 000000000..41fbcba7a --- /dev/null +++ b/tests/Fixtures/harmonic.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f00906279a9a10675461cf07d09bfd2e9542e83f6b92ecc11ae4f527b9b04fe +size 304954