diff --git a/docs/audacity.md b/docs/audacity.md
new file mode 100644
index 0000000..ab39644
--- /dev/null
+++ b/docs/audacity.md
@@ -0,0 +1,115 @@
+# Audacity
+
+[Audacity](https://www.audacityteam.org/)
+is "free, open source, cross-platform audio software -
+an easy-to-use, multi-track audio editor and recorder for
+Windows, macOS, GNU/Linux and other operating systems."
+
+Audacity runs on desktops and laptops, and has a point and click graphical interface and keyboard shortcuts.
+
+Egret can use [Audacity Project](https://manual.audacityteam.org/man/audacity_projects.html) files as input and output.
+
+*As input*: Audacity Project files can contain labels in [label tracks](https://manual.audacityteam.org/man/label_tracks.html).
+The labels can be used as expectations for tests in a test suite.
+
+
+*As output*: Results from running tools and comparing tool output to the test expectations can be saved as Audacity Project files.
+
+
+## Benefits
+
+Audacity may be helpful for [defining test expectations](#usageDefineExpectations) using the graphical interface.
+
+While there are many programs that can visualise audio,
+Audacity has built-in tools to define areas of interest in the audio.
+The areas of interest are defined by a start time and an end time, and can include a high and low frequency range as well.
+The areas can be defined by clicking and dragging and can be edited in a table layout.
+These areas of interest can be saved to Audacity Project files and used by Egret as test expectations.
+
+Audacity may be helpful when [reviewing results](#usageReviewResults) to
+look at the visualisation of the audio and labelled areas of interest.
+
+It can be difficult to create reliable audio recognition tools.
+Egret makes it easy to assess the actual output of tools compared to the expected output.
+Output formats such as CSV or JSON allow automated processing to assess the results.
+Sometimes it is useful to manually assess any problems with the actual output not matching expected output.
+Egret results saved as Audacity Project files can be opened in Audacity, along with the audio file.
+The Audacity interface provides a way to manually evaluate test results compared to test expectations.
+
+
+## Usage for defining test expectations
+
+Open Audacity (version 2 or 3) , and
+[import the audio file](https://manual.audacityteam.org/man/importing_audio.html)
+that contains the sounds to be recognised.
+
+Define Labels either in Label Tracks using the
+[label track tools](https://manual.audacityteam.org/man/label_tracks.html) or use the
+[Label Editor](https://manual.audacityteam.org/man/labels_editor.html).
+
+Save your work as an [Audacity Project](https://manual.audacityteam.org/man/file_menu_save_project.html).
+Either "Save Project" or "Save Project As...". This will save your tracks as an Audacity Project file.
+Depending on the Audacity version, this might:
+
+- create a file with the extension `.aup` and a folder named the same as the Audacity project with the extension `_data` (version 2); or
+- create a file with the extension `.aup3` (version 3).
+
+Egret can import both `.aup` and `.aup3` files.
+
+To import the labels, Egret only needs the `.aup` or `.aup3` file, not the directory.
+The directory contains the audio imported into Audacity. You may want to keep the directory if you want to change the labels.
+
+Make sure you keep the original audio file you imported into Audacity.
+Egret requires this original audio file to run the analysis tools and get results.
+
+The Audacity project file and the audio file must have the same name, and be in the same folder.
+
+The original audio file is found by Egret by finding a file that has the same file "stem" as the Audacity project file.
+The "stem" is the filename before to the last dot `.` (the extension, e.g. `.wav`).
+Egret finds the original audio file by removing the file extension and comparing the "stem" of the audio file and the Audacity project file.
+
+For example, the Audacity project file `/123/abc.aup` or `/123/abc.aup3` might have an original audio file
+at the path `/123/abc.wav` or `/123/abc.mp3`.
+
+
+## Usage for reviewing results
+
+When running Egret, there is an option to save the results as Audacity Project files using the `--audacity` command line argument.
+
+This will create one Audacity Project file for each combination of test suite, tool, and audio file.
+For example, if you have one test suite with two audio files, and two tools,
+then the output results will be stored in four Audacity Projects.
+
+Egret saves Audacity Project results as `.aup` files, which can be opened by Audacity version 2 or 3.
+
+Open an Audacity Project file. It will contain a number of Label Tracks.
+I won't have any Audio tracks. The audio file that was used by Egret needs to be imported to show the audio track.
+
+Once the audio file is imported, then the Label Tracks can be used to investigate the results from the analysis tools run using Egret.
+
+
+## Use different Audacity Projects for defining expectations and reviewing results
+
+We recommend using separate, dedicated Audacity Projects for defining expectations and reviewing results.
+
+It should be possible to open two or more Audacity Project files at the same time using
+[File > New](https://manual.audacityteam.org/man/file_menu.html#new).
+
+As you review the results, you can use any insights to make changes in the other Audacity window that contains the test expectations.
+
+
+## Frequently Asked Questions
+
+### Why can't Egret read the audio files saved as part of the Audacity Project file?
+
+An Audacity project file may include zero, one, or more audio files.
+However, the files are stored in Sun AU format, split into smaller files.
+
+The goal is to be able to load expectations and save results to easily view labels against audio files.
+It is tricky to run tools and match test expectations when the original audio is spilt into parts,
+and in a format that some tools have trouble reading.
+
+Egret is a tool runner, and does not try to convert or combine the audio files.
+
+Instead, there is the convention that one Audacity project file (`.aup` or `.aup3`) references one original audio file.
+The Audacity project file and the audio file must have the same name, and be in the same folder.
diff --git a/src/Egret.Cli/Commands/TestCommand.cs b/src/Egret.Cli/Commands/TestCommand.cs
index d80db61..6f0efde 100644
--- a/src/Egret.Cli/Commands/TestCommand.cs
+++ b/src/Egret.Cli/Commands/TestCommand.cs
@@ -43,6 +43,8 @@ public class TestCommandOptions
public bool Csv { get; set; } = false;
+ public bool Audacity { get; set; } = false;
+
public bool Sequential { get; set; } = false;
}
diff --git a/src/Egret.Cli/Egret.Cli.csproj b/src/Egret.Cli/Egret.Cli.csproj
index b818670..182439b 100644
--- a/src/Egret.Cli/Egret.Cli.csproj
+++ b/src/Egret.Cli/Egret.Cli.csproj
@@ -24,9 +24,11 @@
-
-
-
+
+
+
+
+
diff --git a/src/Egret.Cli/Formatters/AudacityResultFormatter.cs b/src/Egret.Cli/Formatters/AudacityResultFormatter.cs
new file mode 100644
index 0000000..d55c6f5
--- /dev/null
+++ b/src/Egret.Cli/Formatters/AudacityResultFormatter.cs
@@ -0,0 +1,152 @@
+namespace Egret.Cli.Formatters
+{
+ using Models.Audacity;
+ using Models.Results;
+ using Processing;
+ using Serialization.Audacity;
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Linq;
+ using System.Threading.Tasks;
+
+ ///
+ /// Result formatter that stores output in Audacity (2.x) project files.
+ ///
+ public class AudacityResultFormatter : IResultFormatter
+ {
+ private readonly OutputFile outputFile;
+ private readonly AudacitySerializer serializer;
+
+ public AudacityResultFormatter(OutputFile outputFile, AudacitySerializer serializer)
+ {
+ this.outputFile = outputFile;
+ this.serializer = serializer;
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ // no op
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask WriteResultsHeader()
+ {
+ // no op
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask WriteResult(int index, TestCaseResult result)
+ {
+ (FileInfo currentFile, Project project) = this.BuildProject(index, result);
+ this.serializer.Serialize(currentFile.FullName, project);
+
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask WriteResultsFooter(FinalResults finalResults)
+ {
+ // TODO: what to do with the stats?
+ // for now, just ignore the footer data
+ return ValueTask.CompletedTask;
+ }
+
+ private (FileInfo, Project) BuildProject(int index, TestCaseResult result)
+ {
+ var sourceName = Path.GetFileNameWithoutExtension(result.Context.SourceName);
+ var resultFile = outputFile.GetOutputFile(".aup", sourceName);
+
+ // store the outcome and context
+ var outcome = result.Success ? "All expectations passed" : "One or more expectations failed";
+ var tags = new List
+ {
+ new("OverallOutcome", outcome),
+ new("ResultIndex", index.ToString()),
+ new(nameof(result.Context.SourceName), result.Context.SourceName),
+ new(nameof(result.Context.SuiteName), result.Context.SuiteName),
+ new(nameof(result.Context.TestName), result.Context.TestName),
+ new(nameof(result.Context.ToolName), result.Context.ToolName),
+ new(nameof(result.Context.ToolVersion), result.Context.ToolVersion),
+ new(nameof(result.Context.CaseTracker), result.Context.CaseTracker.ToString()),
+ new(nameof(result.Context.ExecutionIndex), result.Context.ExecutionIndex.ToString()),
+ new(nameof(result.Context.ExecutionTime), result.Context.ExecutionTime.ToString()),
+ };
+
+ // record any errors
+ tags.AddRange(result.Errors.Select((error, errorIndex) =>
+ new Tag($"Error{errorIndex:D2}", error))
+ );
+
+ // convert results to tracks and labels
+ var eventTruePositives = new List();
+ var eventFalsePositives = new List();
+ var eventTrueNegatives = new List();
+ var eventFalseNegatives = new List();
+
+ foreach (var expectationResult in result.Results)
+ {
+ // TODO: add event expectation result to TP, FP, TN, FN label list
+ // TODO: add segment-level result as a tag
+
+ var isSuccessful = expectationResult.Successful;
+ var isSegment = expectationResult.IsSegmentResult;
+
+ var subject = expectationResult.Subject;
+ var subjectName = subject.Name ?? String.Empty;
+ var isPositiveAssertion = subject.IsPositiveAssertion;
+
+ var target = expectationResult.Target;
+
+ var truePositive = expectationResult.Contingency == Contingency.TruePositive;
+ var falsePositive = expectationResult.Contingency == Contingency.FalsePositive;
+ var trueNegative = expectationResult.Contingency == Contingency.TrueNegative;
+ var falseNegative = expectationResult.Contingency == Contingency.FalseNegative;
+
+ foreach (var assertion in expectationResult.Assertions)
+ {
+ switch (assertion)
+ {
+ case SuccessfulAssertion successfulAssertion:
+
+ break;
+ case ErrorAssertion errorAssertion:
+
+ break;
+ case FailedAssertion failedAssertion:
+
+ break;
+ default:
+ throw new NotImplementedException(
+ $"Unable to process assertion of type '{assertion.GetType()}'.");
+ }
+ }
+ }
+
+ // create 4 tracks for TP, FP, TN, FN labels
+ var tracks = new List
+ {
+ new("EvTP", 1, 100, 0, eventTruePositives.ToArray()),
+ new("EvFP", 1, 100, 0, eventFalsePositives.ToArray()),
+ new("EvTN", 1, 100, 0, eventTrueNegatives.ToArray()),
+ new("EvFN", 1, 100, 0, eventFalseNegatives.ToArray()),
+ };
+
+ // build the audacity project
+ var project = new Project
+ {
+ Tags = tags.ToArray(),
+ Tracks = tracks.ToArray(),
+ ProjectName = $"{sourceName}_data",
+ Version = "1.3.0",
+ AudacityVersion = "2.4.2",
+ Rate = 44100,
+ SnapTo = "off",
+ SelectionFormat = "hh:mm:ss + milliseconds",
+ FrequencyFormat = "Hz",
+ BandwidthFormat = "octaves"
+ };
+
+ return (resultFile, project);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Egret.Cli/Formatters/MetaFormatter.cs b/src/Egret.Cli/Formatters/MetaFormatter.cs
index c950508..693b573 100644
--- a/src/Egret.Cli/Formatters/MetaFormatter.cs
+++ b/src/Egret.Cli/Formatters/MetaFormatter.cs
@@ -37,11 +37,16 @@ public MetaFormatter(ILogger logger, TestCommandOptions options, IS
formatters.Add(provider.GetRequiredService());
}
- if (logger.PassThrough(options.Csv, "Using HTML result formatter: {yesNo}", LogLevel.Debug))
+ if (logger.PassThrough(options.Csv, "Using CSV result formatter: {yesNo}", LogLevel.Debug))
{
formatters.Add(provider.GetRequiredService());
}
+ if (logger.PassThrough(options.Audacity, "Using Audacity result formatter: {yesNo}", LogLevel.Debug))
+ {
+ formatters.Add(provider.GetRequiredService());
+ }
+
this.formatters = formatters;
}
diff --git a/src/Egret.Cli/Hosting/HostingSetup.cs b/src/Egret.Cli/Hosting/HostingSetup.cs
index 7050ddc..02658e7 100644
--- a/src/Egret.Cli/Hosting/HostingSetup.cs
+++ b/src/Egret.Cli/Hosting/HostingSetup.cs
@@ -17,9 +17,12 @@
using YamlDotNet.Serialization.NamingConventions;
using Egret.Cli.Serialization.Egret;
using System.IO.Abstractions;
+using Egret.Cli.Serialization.Audacity;
namespace Egret.Cli.Hosting
{
+ using Serialization.Xml;
+
public class HostingSetup
{
private static readonly string FilterEgretConsoleName = typeof(EgretConsole).FullName;
@@ -64,8 +67,11 @@ private void ConfigureServices(HostBuilderContext context, IServiceCollection se
services.AddSingleton(_ => UnderscoredNamingConvention.Instance);
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
services.AddTransient();
services.AddTransient();
@@ -81,14 +87,17 @@ private void ConfigureServices(HostBuilderContext context, IServiceCollection se
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton((provider) => new ITestCaseImporter[] {
provider.GetRequiredService(),
provider.GetRequiredService(),
provider.GetRequiredService(),
+ provider.GetRequiredService(),
});
services.AddSingleton();
}
diff --git a/src/Egret.Cli/Models/Audacity/Label.cs b/src/Egret.Cli/Models/Audacity/Label.cs
new file mode 100644
index 0000000..520d556
--- /dev/null
+++ b/src/Egret.Cli/Models/Audacity/Label.cs
@@ -0,0 +1,55 @@
+namespace Egret.Cli.Models.Audacity
+{
+ using System;
+ using System.Xml.Serialization;
+
+ public record Label
+ {
+ [XmlAttribute(AttributeName = "title")]
+ public string Title { get; init; }
+
+ [XmlAttribute(AttributeName = "t")]
+ public double TimeStart { get; init; }
+
+ [XmlAttribute(AttributeName = "t1")]
+ public double TimeEnd { get; init; }
+
+ [XmlAttribute(AttributeName = "selLow")]
+ public double SelLow { get; init; }
+
+ [XmlAttribute(AttributeName = "selHigh")]
+ public double SelHigh { get; init; }
+
+ ///
+ /// Is this label for a point in time?
+ /// If false, this label covers a time span equal to or greater than 0.01.
+ ///
+ public bool IsTimePoint => Math.Abs(this.TimeStart - this.TimeEnd) < 0.01;
+
+ ///
+ /// Is this label for a frequency point?
+ /// If false, this label covers a frequency range equal to or greater than 0.01.
+ ///
+ public bool IsSelPoint => Math.Abs(this.SelLow - this.SelHigh) < 0.01;
+
+ public Label()
+ {
+ }
+
+ public Label(string title, double timeStart, double timeEnd)
+ {
+ Title = title;
+ TimeStart = timeStart;
+ TimeEnd = timeEnd;
+ }
+
+ public Label(string title, double timeStart, double timeEnd, double selLow, double selHigh)
+ {
+ Title = title;
+ TimeStart = timeStart;
+ TimeEnd = timeEnd;
+ SelLow = selLow;
+ SelHigh = selHigh;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Egret.Cli/Models/Audacity/LabelTrack.cs b/src/Egret.Cli/Models/Audacity/LabelTrack.cs
new file mode 100644
index 0000000..36a7441
--- /dev/null
+++ b/src/Egret.Cli/Models/Audacity/LabelTrack.cs
@@ -0,0 +1,44 @@
+namespace Egret.Cli.Models.Audacity
+{
+ using System.Xml.Serialization;
+
+ public record LabelTrack
+ {
+ [XmlAttribute(AttributeName = "name")]
+ public string Name { get; init; }
+
+ [XmlAttribute(AttributeName = "isSelected")]
+ public int IsSelected { get; init; }
+
+ [XmlAttribute(AttributeName = "height")]
+ public int Height { get; init; } = 100;
+
+ [XmlAttribute(AttributeName = "minimized")]
+ public int Minimized { get; init; }
+
+ [XmlElement(ElementName = "label")]
+ public Label[] Labels { get; init; }
+
+ [XmlAttribute(AttributeName = "numlabels")]
+ public int NumLabels
+ {
+ get => Labels.Length;
+
+ // discard value here, instead of throw or no setter, to satisfy the XML serializer
+ init => _ = value;
+ }
+
+ public LabelTrack()
+ {
+ }
+
+ public LabelTrack(string name, int isSelected, int height, int minimized, Label[] labels)
+ {
+ Name = name;
+ IsSelected = isSelected;
+ Height = height;
+ Minimized = minimized;
+ Labels = labels;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Egret.Cli/Models/Audacity/Project.cs b/src/Egret.Cli/Models/Audacity/Project.cs
new file mode 100644
index 0000000..1cbf134
--- /dev/null
+++ b/src/Egret.Cli/Models/Audacity/Project.cs
@@ -0,0 +1,65 @@
+namespace Egret.Cli.Models.Audacity
+{
+ using Models;
+ using Serialization.Yaml;
+ using System.Xml.Serialization;
+
+ [XmlRoot(Namespace = "http://audacity.sourceforge.net/xml/", ElementName = "project")]
+ public record Project : ISourceInfo
+ {
+ [XmlAttribute(AttributeName = "snapto")]
+ public string SnapTo { get; init; }
+
+ [XmlAttribute(AttributeName = "projname")]
+ public string ProjectName { get; init; }
+
+ [XmlArray(ElementName = "tags")]
+ [XmlArrayItem(ElementName = "tag")]
+ public Tag[] Tags { get; init; }
+
+ [XmlElement(ElementName = "labeltrack")]
+ public LabelTrack[] Tracks { get; init; }
+
+ [XmlAttribute(AttributeName = "sel0")]
+ public double Sel0 { get; init; }
+
+ [XmlAttribute(AttributeName = "sel1")]
+ public double Sel1 { get; init; }
+
+ [XmlAttribute(AttributeName = "selLow")]
+ public double SelLow { get; init; }
+
+ [XmlAttribute(AttributeName = "selHigh")]
+ public double SelHigh { get; init; }
+
+ [XmlAttribute(AttributeName = "vpos")]
+ public double VPos { get; init; }
+
+ [XmlAttribute(AttributeName = "h")]
+ public double HVal { get; init; }
+
+ [XmlAttribute(AttributeName = "zoom")]
+ public double Zoom { get; init; }
+
+ [XmlAttribute(AttributeName = "rate")]
+ public double Rate { get; init; }
+
+ [XmlAttribute(AttributeName = "version")]
+ public string Version { get; init; }
+
+ [XmlAttribute(AttributeName = "audacityversion")]
+ public string AudacityVersion { get; init; }
+
+ [XmlAttribute(AttributeName = "selectionformat")]
+ public string SelectionFormat { get; init; }
+
+ [XmlAttribute(AttributeName = "frequencyformat")]
+ public string FrequencyFormat { get; init; }
+
+ [XmlAttribute(AttributeName = "bandwidthformat")]
+ public string BandwidthFormat { get; init; }
+
+ [XmlIgnore]
+ public SourceInfo SourceInfo { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/Egret.Cli/Models/Audacity/Tag.cs b/src/Egret.Cli/Models/Audacity/Tag.cs
new file mode 100644
index 0000000..df62121
--- /dev/null
+++ b/src/Egret.Cli/Models/Audacity/Tag.cs
@@ -0,0 +1,58 @@
+namespace Egret.Cli.Models.Audacity
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Xml.Serialization;
+
+ ///
+ /// Audacity project file tag.
+ /// A tag is a key value pair.
+ ///
+ public record Tag
+ {
+ [XmlAttribute(AttributeName = "name")]
+ public string Name { get; init; }
+
+ [XmlAttribute(AttributeName = "value")]
+ public string Value { get; init; }
+
+ public Tag()
+ {
+ }
+
+ public Tag(string name, string value)
+ {
+ Name = name;
+ Value = value;
+ }
+
+ public static implicit operator KeyValuePair(Tag tag)
+ {
+ return new(tag.Name, tag.Value);
+ }
+
+ public static implicit operator Tuple(Tag tag)
+ {
+ return new(tag.Name, tag.Value);
+ }
+ public static implicit operator ValueTuple(Tag tag)
+ {
+ return new(tag.Name, tag.Value);
+ }
+
+ public static explicit operator Tag(KeyValuePair pair)
+ {
+ return new(pair.Key, pair.Value);
+ }
+
+ public static explicit operator Tag(Tuple pair)
+ {
+ return new(pair.Item1, pair.Item2);
+ }
+
+ public static explicit operator Tag(ValueTuple pair)
+ {
+ return new(pair.Item1, pair.Item2);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Egret.Cli/Models/Bounds.cs b/src/Egret.Cli/Models/Bounds.cs
index 991175b..904eb76 100644
--- a/src/Egret.Cli/Models/Bounds.cs
+++ b/src/Egret.Cli/Models/Bounds.cs
@@ -8,8 +8,8 @@ namespace Egret.Cli.Models
{
///
/// Represents a rectangular event.
- // Coordinates are encoded as four intervals to allow fuzzy matching.
- ///
+ /// Coordinates are encoded as four intervals to allow fuzzy matching.
+ ///
public struct Bounds : IYamlConvertible
{
public Bounds(Interval startSeconds, Interval endSeconds, Interval lowHertz, Interval highHertz)
diff --git a/src/Egret.Cli/Processing/MultiGlob/MultiGlob.cs b/src/Egret.Cli/Processing/MultiGlob/MultiGlob.cs
index a5b64cb..4a74864 100644
--- a/src/Egret.Cli/Processing/MultiGlob/MultiGlob.cs
+++ b/src/Egret.Cli/Processing/MultiGlob/MultiGlob.cs
@@ -18,7 +18,7 @@ namespace Egret.Cli.Processing
/// glob = standard_blob
///
/// For standard glob syntax see docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.filesystemglobbing.matcher?view=dotnet-plat-ext-5.0
- ///
+ ///
public class MultiGlob : Matcher
{
diff --git a/src/Egret.Cli/Processing/OutputFile.cs b/src/Egret.Cli/Processing/OutputFile.cs
index 02053b1..2ff4338 100644
--- a/src/Egret.Cli/Processing/OutputFile.cs
+++ b/src/Egret.Cli/Processing/OutputFile.cs
@@ -1,15 +1,17 @@
-using Egret.Cli.Commands;
-using Egret.Cli.Extensions;
-using Egret.Cli.Hosting;
-using System.IO;
-
namespace Egret.Cli.Processing
{
+ using Commands;
+ using Extensions;
+ using Hosting;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Linq;
+
public class OutputFile
{
- private readonly TestCommandOptions options;
- private readonly string filestem;
private readonly string dateStamp;
+ private readonly string filestem;
+ private readonly TestCommandOptions options;
public OutputFile(TestCommandOptions options, RunInfo runInfo)
{
@@ -18,14 +20,32 @@ public OutputFile(TestCommandOptions options, RunInfo runInfo)
dateStamp = runInfo.StartedAt.ToString("yyyyMMdd-HHmmss");
}
- public string GetOutputPath(string extension)
+ public string GetOutputPath(string extension, params string[] nameParts)
{
- return Path.Combine(options.Output.FullName, $"{dateStamp}_{filestem}_results.{extension}");
+ string name = BuildOutputFileName(extension, nameParts);
+ return Path.Combine(options.Output.FullName, name);
}
- public FileInfo GetOutputFile(string extension)
+ public FileInfo GetOutputFile(string extension, params string[] nameParts)
{
- return options.Output.Combine($"{dateStamp}_{filestem}_results.{extension}");
+ string name = BuildOutputFileName(extension, nameParts);
+ return options.Output.Combine(name);
+ }
+
+ private string BuildOutputFileName(string extension, IReadOnlyCollection nameParts)
+ {
+ var parts = new List {dateStamp, filestem};
+
+ if (nameParts != null && nameParts.Count > 0)
+ {
+ parts.AddRange(nameParts);
+ }
+
+ parts.Add("results");
+
+ var validParts = parts.Where(i => !string.IsNullOrWhiteSpace(i));
+ string name = string.Join('_', validParts);
+ return $"{name}.{extension.Trim('.')}";
}
}
}
\ No newline at end of file
diff --git a/src/Egret.Cli/Program.cs b/src/Egret.Cli/Program.cs
index 8597000..cd0b89d 100644
--- a/src/Egret.Cli/Program.cs
+++ b/src/Egret.Cli/Program.cs
@@ -51,6 +51,7 @@ private static CommandLineBuilder BuildCommandLine()
new Option("--csv", description: "Output results to a CSV file").WithAlias("-c"),
new Option("--no-console", description: "Do not output results in the console").WithAlias("-q"),
new Option("--html", description: "Output results to a HTML file").WithAlias("-h"),
+ new Option("--audacity", description: "Output results to Audacity project files").WithAlias("-a"),
new Option("--sequential", description: "Disable parallel execution").WithAlias("-s"),
},
new Command("watch", "Runs egret tests every time a change is found")
diff --git a/src/Egret.Cli/Serialization/Audacity/Audacity3Serializer.cs b/src/Egret.Cli/Serialization/Audacity/Audacity3Serializer.cs
new file mode 100644
index 0000000..6f56b20
--- /dev/null
+++ b/src/Egret.Cli/Serialization/Audacity/Audacity3Serializer.cs
@@ -0,0 +1,367 @@
+namespace Egret.Cli.Serialization.Audacity
+{
+ using Microsoft.Data.Sqlite;
+ using Microsoft.Extensions.Logging;
+ using Models.Audacity;
+ using MoreLinq;
+ using System;
+ using System.Collections.Generic;
+ using System.Globalization;
+ using System.IO;
+ using System.IO.Abstractions;
+ using System.Linq;
+ using System.Text;
+ using System.Xml;
+
+ ///
+ /// A class to deserialize Audacity 3 Project files.
+ ///
+ ///
+ /// NOTE: Serialization is not implemented.
+ /// Serialize to an Audacity 2 Project file instead.
+ ///
+ public class Audacity3Serializer
+ {
+ private readonly ILogger logger;
+ private readonly AudacitySerializer audacitySerializer;
+
+ public Audacity3Serializer(ILogger logger, AudacitySerializer audacitySerializer)
+ {
+ this.logger = logger;
+ this.audacitySerializer = audacitySerializer;
+ }
+
+ public Project Deserialize(IFileInfo fileInfo)
+ {
+ var info = GetAudacityFormatInfo(fileInfo);
+ var project = GetProject(fileInfo);
+
+ return project;
+ }
+
+ private string BuildConnectionString(IFileInfo fileInfo)
+ {
+ var connectionString = new SqliteConnectionStringBuilder
+ {
+ Mode = SqliteOpenMode.ReadWriteCreate, DataSource = fileInfo.FullName,
+ };
+ return connectionString.ToString();
+ }
+
+ private void ExecuteReader(IFileInfo fileInfo, string commandText,
+ Dictionary parameters = null,
+ Action action = null)
+ {
+ var connectionString = this.BuildConnectionString(fileInfo);
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+
+ var command = connection.CreateCommand();
+ command.CommandText = commandText;
+ parameters?.ForEach(pair => command.Parameters.AddWithValue(pair.Key, pair.Value));
+
+ using var reader = command.ExecuteReader();
+ action?.Invoke(connection, command, reader);
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// Implemented from reverse engineering and learning from code at
+ /// https://github.com/audacity/audacity/blob/2c47dc5ba1a91c87a457ffba6caf8b1baf706279/src/ProjectFileIO.cpp
+ ///
+ ///
+ private Dictionary GetAudacityFormatInfo(IFileInfo fileInfo)
+ {
+ string applicationIdRaw = null;
+ string userVersionRaw = null;
+
+ ExecuteReader(fileInfo, "PRAGMA application_id; PRAGMA user_version;", action: (conn, comm, reader) =>
+ {
+ while (reader.Read())
+ {
+ applicationIdRaw = reader.GetString(0);
+ }
+
+ reader.NextResult();
+ while (reader.Read())
+ {
+ userVersionRaw = reader.GetString(0);
+ }
+ });
+
+ var applicationIdInt = Convert.ToInt32(applicationIdRaw);
+ var applicationIdBytes = BitConverter.GetBytes(applicationIdInt);
+ var applicationIdString = Encoding.UTF8.GetString(applicationIdBytes).Reverse();
+ var applicationId = string.Join("", applicationIdString);
+
+ var userVersionInt = Convert.ToInt32(userVersionRaw);
+ var userVersionBytes = BitConverter.GetBytes(userVersionInt);
+ var userVersionNumbers = userVersionBytes.Reverse();
+ var userVersion = string.Join(".", userVersionNumbers);
+
+ return new Dictionary {{"applicationId", applicationId}, {"userVersion", userVersion},};
+ }
+
+ ///
+ ///
+ ///
+ ///
+ /// Implemented from reverse engineering and learning from code at
+ /// https://github.com/audacity/audacity/blob/2c47dc5ba1a91c87a457ffba6caf8b1baf706279/src/ProjectFileIO.cpp
+ /// and
+ /// https://github.com/audacity/audacity/blob/2c47dc5ba1a91c87a457ffba6caf8b1baf706279/src/ProjectSerializer.cpp
+ ///
+ ///
+ private Project GetProject(IFileInfo fileInfo)
+ {
+ var culture = CultureInfo.InvariantCulture;
+ using var scope = logger.BeginScope("Processing Audacity 3 project file {path}", fileInfo.Name);
+ Project project = null;
+ ExecuteReader(fileInfo, "SELECT dict, doc FROM project WHERE id = 1;", action: (conn, comm, reader) =>
+ {
+ while (reader.Read())
+ {
+ if (project != null)
+ {
+ throw new InvalidOperationException("Audacity 3 project has already been loaded.");
+ }
+
+ // read the dict and doc from the sqlite file
+ var bufferDict = (byte[])reader.GetValue(0);
+ var bufferDoc = (byte[])reader.GetValue(1);
+ var buffer = bufferDict.Concat(bufferDoc).ToArray();
+
+ // create a memory stream and binary reader as each have benefits when reading the buffer
+ using var inStream = new MemoryStream(buffer);
+ using var inBinary = new BinaryReader(inStream);
+
+ // create the xml stream to write to
+ using var contentWriter = new StringWriter();
+ var settings = new XmlWriterSettings
+ {
+ Encoding = Encoding.UTF8, Indent = true, OmitXmlDeclaration = true
+ };
+ using var contentXml = XmlWriter.Create(contentWriter, settings);
+
+ // implement the "stack of dictionaries" used in the custom XML serialization
+ var currentIds = new Dictionary();
+ var allIds = new Stack>();
+
+ // need an initial encoding
+ Encoding encoding = Encoding.Default;
+
+ // read the first value, which is a value indicating the type of data stored in the current 'block'
+ int currentValue = inBinary.Read();
+
+ // read the data in a loop - first the dict of xml keys and values, then the xml doc
+ while (currentValue > -1)
+ {
+ ushort id;
+ int count;
+ string name;
+ string text;
+
+ switch (currentValue)
+ {
+ case (int)FieldTypes.Push:
+ allIds.Push(currentIds);
+ currentIds = new Dictionary();
+ logger.LogTrace("Pushed new id dictionary on to stack.");
+ break;
+
+ case (int)FieldTypes.Pop:
+ currentIds = allIds.Pop();
+ logger.LogTrace("Popped id dictionary from stack.");
+ break;
+
+ case (int)FieldTypes.Name:
+ id = inBinary.ReadUInt16();
+ count = inBinary.ReadUInt16();
+ text = ReadString(inBinary, count, encoding);
+ currentIds[id] = text;
+ logger.LogTrace($"Added {id}={text} to dictionary.");
+ break;
+
+ case (int)FieldTypes.StartTag:
+ id = inBinary.ReadUInt16();
+ name = GetName(currentIds, id);
+ contentXml.WriteStartElement(name, AudacitySerializer.XmlNs);
+ logger.LogTrace($"Wrote tag start {name}.");
+ break;
+
+ case (int)FieldTypes.EndTag:
+ id = inBinary.ReadUInt16();
+ name = GetName(currentIds, id);
+ contentXml.WriteEndElement();
+ logger.LogTrace($"Wrote tag end {name}.");
+ break;
+
+ case (int)FieldTypes.String:
+ id = inBinary.ReadUInt16();
+ count = inBinary.ReadInt32();
+ name = GetName(currentIds, id);
+ text = ReadString(inBinary, count, encoding);
+ contentXml.WriteAttributeString(name, text);
+ logger.LogTrace($"Wrote attribute string {name}={text}.");
+ break;
+
+ case (int)FieldTypes.Float:
+ id = inBinary.ReadUInt16();
+ var floatValue = inBinary.ReadSingle();
+ var floatDigits = inBinary.ReadInt32();
+ name = GetName(currentIds, id);
+ contentXml.WriteAttributeString(name, floatValue.ToString(culture));
+ logger.LogTrace($"Wrote attribute float {name}={floatValue}.");
+ break;
+
+ case (int)FieldTypes.Double:
+ id = inBinary.ReadUInt16();
+ var doubleValue = inBinary.ReadDouble();
+ var doubleDigits = inBinary.ReadInt32();
+ name = GetName(currentIds, id);
+ contentXml.WriteAttributeString(name, doubleValue.ToString(culture));
+ logger.LogTrace($"Wrote attribute double {name}={doubleValue}.");
+ break;
+
+ case (int)FieldTypes.Int:
+ id = inBinary.ReadUInt16();
+ var intValue = inBinary.ReadUInt32();
+ name = GetName(currentIds, id);
+ contentXml.WriteAttributeString(name, intValue.ToString(culture));
+ logger.LogTrace($"Wrote attribute int {name}={intValue}.");
+ break;
+
+ case (int)FieldTypes.Bool:
+ id = inBinary.ReadUInt16();
+ var boolValue = inBinary.ReadBoolean();
+ name = GetName(currentIds, id);
+ contentXml.WriteAttributeString(name, boolValue ? "1" : "0");
+ logger.LogTrace($"Wrote attribute bool {name}={boolValue}.");
+ break;
+
+ case (int)FieldTypes.Long:
+ id = inBinary.ReadUInt16();
+ var longValue = inBinary.ReadInt32();
+ name = GetName(currentIds, id);
+ contentXml.WriteAttributeString(name, longValue.ToString(culture));
+ logger.LogTrace($"Wrote attribute long {name}={longValue}.");
+ break;
+
+ case (int)FieldTypes.LongLong:
+ id = inBinary.ReadUInt16();
+ // check C++ to C# type equivalence:
+ // https://www.tangiblesoftwaresolutions.com/articles/csharp_equivalent_to_cplus_types.html
+ var longLongValue = inBinary.ReadInt64();
+ name = GetName(currentIds, id);
+ contentXml.WriteAttributeString(name, longLongValue.ToString(culture));
+ logger.LogTrace($"Wrote attribute long long {name}={longLongValue}.");
+ break;
+
+ case (int)FieldTypes.SizeT:
+ id = inBinary.ReadUInt16();
+ var ulongValue = inBinary.ReadUInt32();
+ name = GetName(currentIds, id);
+ contentXml.WriteAttributeString(name, ulongValue.ToString(culture));
+ logger.LogTrace($"Wrote attribute sizet {name}={ulongValue}.");
+ break;
+
+ case (int)FieldTypes.Data:
+ count = inBinary.ReadInt32();
+ text = ReadString(inBinary, count, encoding);
+ contentXml.WriteString(text);
+ logger.LogTrace($"Wrote text '{text}'.");
+ break;
+
+ case (int)FieldTypes.Raw:
+ count = inBinary.ReadInt32();
+ text = ReadString(inBinary, count, encoding);
+ contentXml.WriteRaw(text);
+ logger.LogTrace($"Wrote raw '{text}'.");
+ break;
+
+ case (int)FieldTypes.CharSize:
+ int readByte = inStream.ReadByte();
+ encoding = readByte switch
+ {
+ 1 => Encoding.UTF8,
+ 2 => Encoding.Unicode,
+ 4 => Encoding.UTF32,
+ _ => throw new InvalidOperationException(
+ $"Invalid encoding identifier '{readByte}'.")
+ };
+
+ logger.LogTrace($"Set encoding '{encoding}'.");
+ break;
+
+ default:
+ var msg = $"Unknown field type id {currentValue}.";
+ logger.LogError(msg);
+ throw new InvalidOperationException(msg);
+ }
+
+ // read the value indicating the next type of data
+ currentValue = inBinary.Read();
+ }
+
+ // ensure all xml content has been written
+ contentXml.Flush();
+
+ // get the xml as a string
+ var content = contentWriter.ToString();
+
+ // write the content to a stream writer that can be used by the audacity serializer
+ using var outStream = new MemoryStream();
+ using var outWriter = new StreamWriter(outStream);
+ outWriter.Write(content);
+ outWriter.Flush();
+ outStream.Position = 0;
+ project = this.audacitySerializer.Deserialize(outStream, fileInfo.FullName);
+ }
+ });
+ return project;
+ }
+
+ ///
+ ///
+ ///
+ ///
+ /// Based on https://github.com/audacity/audacity/blob/2c47dc5ba1a91c87a457ffba6caf8b1baf706279/src/ProjectSerializer.cpp
+ ///
+ private enum FieldTypes
+ {
+ CharSize,
+ StartTag,
+ EndTag,
+ String,
+ Int,
+ Bool,
+ Long,
+ LongLong,
+ SizeT,
+ Float,
+ Double,
+ Data,
+ Raw,
+ Push,
+ Pop,
+ Name,
+ };
+
+ private string ReadString(BinaryReader reader, int count, Encoding encoding)
+ {
+ var bytes = new byte[count];
+ var readCount = reader.Read(bytes, 0, count);
+ var text = encoding.GetString(bytes);
+ return text;
+ }
+
+ private string GetName(Dictionary dict, ushort id)
+ {
+ var value = dict[id];
+ return value;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Egret.Cli/Serialization/Audacity/AudacityImporter.cs b/src/Egret.Cli/Serialization/Audacity/AudacityImporter.cs
new file mode 100644
index 0000000..d05c27a
--- /dev/null
+++ b/src/Egret.Cli/Serialization/Audacity/AudacityImporter.cs
@@ -0,0 +1,204 @@
+namespace Egret.Cli.Serialization.Audacity
+{
+ using LanguageExt;
+ using LanguageExt.Common;
+ using Microsoft.Extensions.FileSystemGlobbing;
+ using Microsoft.Extensions.Logging;
+ using Microsoft.Extensions.Options;
+ using Models;
+ using Models.Audacity;
+ using Models.Expectations;
+ using Processing;
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.IO.Abstractions;
+ using System.Linq;
+ using static LanguageExt.Prelude;
+
+ public class AudacityImporter : ITestCaseImporter
+ {
+ private const string ProjectFileExtension = ".aup";
+ private const string Project3FileExtension = ".aup3";
+
+ private readonly double defaultTolerance;
+ private readonly IFileSystem fileSystem;
+ private readonly ILogger logger;
+ private readonly AudacitySerializer serializer;
+ private readonly Audacity3Serializer serializer3;
+
+ public AudacityImporter(
+ ILogger logger,
+ IFileSystem fileSystem,
+ AudacitySerializer serializer,
+ Audacity3Serializer serializer3,
+ IOptions settings)
+ {
+ this.logger = logger;
+ this.fileSystem = fileSystem;
+ this.serializer = serializer;
+ this.serializer3 = serializer3;
+ defaultTolerance = settings.Value.DefaultThreshold;
+ }
+
+ public Validation>> CanProcess(string matcher, Config config)
+ {
+ (IEnumerable errors, IEnumerable results) = PathResolver
+ .ResolvePathOrGlob(fileSystem, matcher, config.Location.DirectoryName)
+ .Partition();
+
+ if (errors.Any())
+ {
+ return errors.ToSeq();
+ }
+
+ return results.Any() && results.All(p =>
+ Path.GetExtension(p) == ProjectFileExtension || Path.GetExtension(p) == Project3FileExtension)
+ ? Some(results)
+ : None;
+ }
+
+ public async IAsyncEnumerable Load(
+ IEnumerable resolvedSpecifications,
+ ImporterContext context)
+ {
+ string filter = context.Include.Filter;
+ if (filter is not null)
+ {
+ logger.LogDebug($"Filtering Audacity tracks by name using filter '{filter}'.");
+ }
+
+ double temporalTolerance = context.Include.TemporalTolerance ?? defaultTolerance;
+ double spectralTolerance = context.Include.SpectralTolerance ?? defaultTolerance;
+ Override overrideBounds = context.Include.Override;
+
+ foreach (string path in resolvedSpecifications)
+ {
+ var ext = Path.GetExtension(path);
+ Project dataFile;
+ switch (ext)
+ {
+ case ProjectFileExtension:
+ {
+ logger.LogTrace("Loading Audacity data file: {file}", path);
+ await using Stream stream = fileSystem.File.OpenRead(path);
+ dataFile = serializer.Deserialize(stream, path);
+ break;
+ }
+ case Project3FileExtension:
+ logger.LogTrace("Loading Audacity 3 data file: {file}", path);
+ dataFile = serializer3.Deserialize((FileInfoBase)new FileInfo(path));
+ break;
+ default:
+ throw new ArgumentException(
+ $"Could not load Audacity data file. Is this an Audacity project file at path '{path}'?");
+ }
+
+ int filteredCount = 0;
+ int availableCount = 0;
+ Arr expectations = dataFile.Tracks
+ .SelectMany((track, trackIndex) =>
+ track.Labels
+ .Where(label =>
+ {
+ availableCount += 1;
+
+ bool included = filter == null || track.Name.Contains(filter);
+ if (included)
+ {
+ logger.LogTrace(
+ "Included Audacity project label '{label}' " +
+ "because the track '{track}' matched filter '{filter}'.",
+ label, track.Name, filter);
+ }
+ else
+ {
+ logger.LogTrace(
+ "Discarded Audacity project label '{label}' " +
+ "because the track '{track}' did not match filter '{filter}'.",
+ label, track.Name, filter);
+ filteredCount += 1;
+ }
+
+ return included;
+ })
+ .Select((label, labelIndex) =>
+ {
+ string name =
+ $"Audacity Label {label.Title} in track {track.Name} ({trackIndex}:{labelIndex})";
+
+ return label.IsSelPoint
+ ? (IExpectation)new TemporalExpectation(label)
+ {
+ Time = new TimeRange(
+ label.TimeStart.WithTolerance(temporalTolerance),
+ label.TimeEnd.WithTolerance(temporalTolerance)),
+ AnyLabel = new[] {label.Title},
+ Name = name
+ }
+ : (IExpectation)new BoundedExpectation(label)
+ {
+ Bounds = new Bounds(
+ overrideBounds?.Start ?? label.TimeStart.WithTolerance(temporalTolerance),
+ overrideBounds?.End ?? label.TimeEnd.WithTolerance(temporalTolerance),
+ overrideBounds?.Low ?? label.SelLow.WithTolerance(spectralTolerance),
+ overrideBounds?.High ?? label.SelHigh.WithTolerance(spectralTolerance)),
+ AnyLabel = new[] {label.Title},
+ Name = name
+ };
+ }))
+ .ToArr();
+
+ if (expectations.IsEmpty)
+ {
+ logger.LogWarning("No annotations found in {path}, producing a no events expectation", path);
+ expectations = new Arr {new NoEvents()};
+ }
+
+ if (context.Include.Exhaustive is bool exhaustive)
+ {
+ expectations.Add(new NoExtraResultsExpectation {Match = exhaustive});
+ }
+
+ if (filteredCount > 0)
+ {
+ logger.LogDebug(
+ $"Found {availableCount} and filtered out {filteredCount} keeping {expectations.Count} expectations.");
+ }
+
+ logger.LogTrace("Data file converted to expectations: {@expectations}", expectations);
+
+ // find the associated audio file
+ var pathDir = Path.GetDirectoryName(path);
+ var audioFileMatcher = new Matcher();
+ audioFileMatcher.AddInclude(Path.GetFileNameWithoutExtension(path) + ".*");
+
+ var knownAudioExts = new[] {".wav", ".mp3", ".ogg", ".flac", ".wv", ".webm", ".aiff", ".wma", ".m4a"};
+ var matches = audioFileMatcher
+ .GetResultsInFullPath(fileSystem, pathDir)
+ .Where(p => knownAudioExts.Contains(Path.GetExtension(p)))
+ .ToArr();
+
+ if (matches.Count != 1)
+ {
+ var foundFiles = matches
+ .Select(Path.GetFileName)
+ .OrderBy(i => i)
+ .ToArr();
+
+ var foundFileString = string.Join(", ", foundFiles.IsEmpty ? new string[] {"No audio files found"} : foundFiles);
+ var extString = string.Join(", ", knownAudioExts);
+ throw new FileNotFoundException(
+ $"Could not find the one audio file (with extensions {extString}) for Audacity project file '{path}': {foundFileString}.");
+ }
+
+ yield return new TestCase
+ {
+ SourceInfo = dataFile.SourceInfo,
+ Expect = expectations.ToArray(),
+ File = Path.GetRelativePath(pathDir, matches.First()),
+ };
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Egret.Cli/Serialization/Audacity/AudacitySerializer.cs b/src/Egret.Cli/Serialization/Audacity/AudacitySerializer.cs
new file mode 100644
index 0000000..e85127a
--- /dev/null
+++ b/src/Egret.Cli/Serialization/Audacity/AudacitySerializer.cs
@@ -0,0 +1,92 @@
+namespace Egret.Cli.Serialization.Audacity
+{
+ using Microsoft.Extensions.Logging;
+ using Models;
+ using Models.Audacity;
+ using System;
+ using System.IO;
+ using System.IO.Abstractions;
+ using System.Threading.Tasks;
+ using System.Xml.Serialization;
+ using Xml;
+
+ public class AudacitySerializer
+ {
+ private readonly ILogger logger;
+ private readonly DefaultXmlSerializer serializer;
+
+ public static string XmlDtd = "http://audacity.sourceforge.net/xml/audacityproject-1.3.0.dtd";
+ public static string XmlName = "project";
+ public static string XmlNs = "http://audacity.sourceforge.net/xml/";
+ public static string XmlPubId = "-//audacityproject-1.3.0//DTD//EN";
+
+ public AudacitySerializer(ILogger logger, DefaultXmlSerializer serializer)
+ {
+ this.logger = logger;
+ this.serializer = serializer;
+ }
+
+ public async Task Deserialize(IFileInfo fileInfo)
+ {
+ Project result = await serializer.Deserialize(fileInfo,
+ (sender, args) => LogDeserializerIssue(sender, args, fileInfo.FullName),
+ (sender, args) => LogDeserializerIssue(sender, args, fileInfo.FullName),
+ (sender, args) => LogDeserializerIssue(sender, args, fileInfo.FullName),
+ (sender, args) => LogDeserializerIssue(sender, args, fileInfo.FullName)
+ );
+ result.SourceInfo = new SourceInfo(fileInfo.FullName);
+ return result;
+ }
+
+ public Project Deserialize(Stream stream, string path)
+ {
+ Project result = serializer.Deserialize(stream,
+ (sender, args) => LogDeserializerIssue(sender, args, path),
+ (sender, args) => LogDeserializerIssue(sender, args, path),
+ (sender, args) => LogDeserializerIssue(sender, args, path),
+ (sender, args) => LogDeserializerIssue(sender, args, path)
+ );
+ result.SourceInfo = new SourceInfo(path);
+ return result;
+ }
+
+ public void Serialize(string path, Project project)
+ {
+ serializer.Serialize(path, project, XmlNs,
+ XmlName, XmlPubId, XmlDtd);
+
+ // create an empty folder so Audacity can open the .aup file
+ Directory.CreateDirectory(Path.Combine(Path.GetDirectoryName(path), project.ProjectName));
+ }
+
+ private void LogDeserializerIssue(object sender, EventArgs args, string path)
+ {
+ switch (args)
+ {
+ case XmlAttributeEventArgs a:
+ logger.LogWarning(
+ $"Sender '{sender}' found an unknown XML attribute '{a.Attr.Name}' " +
+ $"in '{path}' at '{a.LineNumber}:{a.LinePosition}'.");
+ break;
+ case XmlElementEventArgs a:
+ logger.LogWarning(
+ $"Sender '{sender}' found an unknown XML element '{a.Element.Name}' " +
+ $"in '{path}' at '{a.LineNumber}:{a.LinePosition}'.");
+ break;
+ case XmlNodeEventArgs a:
+ logger.LogWarning(
+ $"Sender '{sender}' found an unknown XML node '{a.Name}' " +
+ $"in '{path}' at '{a.LineNumber}:{a.LinePosition}'.");
+ break;
+ case UnreferencedObjectEventArgs a:
+ logger.LogWarning(
+ $"Found an unreferenced object '{a.UnreferencedId}' " +
+ $"{a.UnreferencedObject?.GetType().Name} in '{path}'.");
+ break;
+ default:
+ logger.LogWarning($"Found an unknown issue in '{path}'.");
+ break;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Egret.Cli/Serialization/Xml/XmlSerializer.cs b/src/Egret.Cli/Serialization/Xml/XmlSerializer.cs
new file mode 100644
index 0000000..ef76966
--- /dev/null
+++ b/src/Egret.Cli/Serialization/Xml/XmlSerializer.cs
@@ -0,0 +1,178 @@
+namespace Egret.Cli.Serialization.Xml
+{
+ using System;
+ using System.IO;
+ using System.IO.Abstractions;
+ using System.Text;
+ using System.Threading.Tasks;
+ using System.Xml;
+ using System.Xml.Serialization;
+
+ ///
+ /// The default XML serializer.
+ ///
+ public class DefaultXmlSerializer
+ {
+ ///
+ /// Deserialize a file to the specified type.
+ ///
+ /// The source file.
+ /// Action to take when an unknown attribute is found.
+ /// Action to take when an unknown element is found.
+ /// Action to take when an unknown node is found.
+ /// Action to take when a known but unreferenced object is found.
+ /// Deserialize to this type.
+ ///
+ public async Task Deserialize(IFileInfo fileInfo,
+ Action onUnknownAttribute = null,
+ Action onUnknownElement = null,
+ Action onUnknownNode = null,
+ Action onUnreferencedObject = null)
+ {
+ await using Stream stream = File.OpenRead(fileInfo.FullName);
+ return Deserialize(stream, onUnknownAttribute, onUnknownElement, onUnknownNode, onUnreferencedObject);
+ }
+
+ ///
+ /// Deserialize a file to the specified type.
+ ///
+ /// The source stream.
+ /// Action to take when an unknown attribute is found.
+ /// Action to take when an unknown element is found.
+ /// Action to take when an unknown node is found.
+ /// Action to take when a known but unreferenced object is found.
+ /// Deserialize to this type.
+ ///
+ public T Deserialize(Stream stream,
+ Action onUnknownAttribute = null,
+ Action onUnknownElement = null,
+ Action onUnknownNode = null,
+ Action onUnreferencedObject = null
+ )
+ {
+ XmlSerializer serializer = new(typeof(T));
+ if (onUnknownAttribute != null)
+ {
+ serializer.UnknownAttribute += (sender, args) => onUnknownAttribute(sender, args);
+ }
+ else
+ {
+ serializer.UnknownAttribute += (_, args) =>
+ throw new InvalidOperationException(
+ $"Unknown attribute {args.Attr} at {args.LineNumber}:{args.LinePosition}.");
+ }
+
+ if (onUnknownElement != null)
+ {
+ serializer.UnknownElement += (sender, args) => onUnknownElement(sender, args);
+ }
+ else
+ {
+ serializer.UnknownElement += (_, args) =>
+ throw new InvalidOperationException(
+ $"Unknown element {args.Element.Name} at {args.LineNumber}:{args.LinePosition}.");
+ }
+
+ if (onUnknownNode != null)
+ {
+ serializer.UnknownNode += (sender, args) => onUnknownNode(sender, args);
+ }
+ else
+ {
+ serializer.UnknownNode += (_, args) =>
+ throw new InvalidOperationException(
+ $"Unknown node {args.Name} at {args.LineNumber}:{args.LinePosition}.");
+ }
+
+ if (onUnreferencedObject != null)
+ {
+ serializer.UnreferencedObject += (sender, args) => onUnreferencedObject(sender, args);
+ }
+ else
+ {
+ serializer.UnreferencedObject += (_, args) =>
+ throw new InvalidOperationException(
+ $"Unreferenced object '{args.UnreferencedId}' {args.UnreferencedObject?.GetType().Name}.");
+ }
+
+ T result = (T)serializer.Deserialize(stream);
+ return result;
+ }
+
+ ///
+ /// Serialize data to a file.
+ ///
+ /// The path to write the serialized data.
+ /// The data to serialize.
+ /// The XML namespace.
+ /// The name of the root element.
+ /// The identifier of the document.
+ /// The url to the dtd document.
+ /// Internal subset declarations.
+ /// The type of the data to serialize.
+ public async void Serialize(string path, T data, string xmlNamespace = null,
+ string xmlRootName = null, string xmlPubId = null, string xmlDtd = null, string xmlSubset = null)
+ {
+ await using FileStream stream = File.OpenWrite(path);
+ var settings = new XmlWriterSettings
+ {
+ Async = true,
+ Encoding = new UTF8Encoding(false), // do not include the XML UTF BOM
+ NamespaceHandling = NamespaceHandling.OmitDuplicates,
+ Indent = true,
+ OmitXmlDeclaration = false,
+ CheckCharacters = true,
+ };
+ Serialize(stream, data, settings, xmlNamespace, xmlRootName, xmlPubId, xmlDtd, xmlSubset);
+ }
+
+ ///
+ /// Serialize data to a file.
+ ///
+ /// The stream to write the serialized data.
+ /// The data to serialize.
+ /// The XML writer settings.
+ /// The XML namespace.
+ /// The name of the root element.
+ /// The identifier of the document.
+ /// The url to the dtd document.
+ /// Internal subset declarations.
+ /// The type of the data to serialize.
+ public async void Serialize(Stream stream, T data, XmlWriterSettings settings = null,
+ string xmlNamespace = null,
+ string xmlRootName = null, string xmlPubId = null, string xmlDtd = null, string xmlSubset = null)
+ {
+ // use the namespace if available
+ var serializer = xmlNamespace == null
+ ? new XmlSerializer(typeof(T))
+ : new XmlSerializer(typeof(T), xmlNamespace);
+
+ // use the settings if available
+ await using var xmlWriter = settings == null
+ ? XmlWriter.Create(stream)
+ : XmlWriter.Create(stream, settings);
+
+ // write the DOCTYPE if available
+ if (xmlRootName != null)
+ {
+ await xmlWriter.WriteDocTypeAsync(xmlRootName, xmlPubId, xmlDtd, xmlSubset);
+ }
+ else if (xmlPubId != null || xmlDtd != null || xmlSubset != null)
+ {
+ throw new ArgumentException("The XML pubid, dtd, or subset all require an xml root name.");
+ }
+
+ // set the non-prefixed namespace if available
+ if (xmlNamespace != null)
+ {
+ var ns = new XmlSerializerNamespaces();
+ ns.Add("", xmlNamespace);
+ serializer.Serialize(xmlWriter, data, ns);
+ }
+ else
+ {
+ serializer.Serialize(xmlWriter, data);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Egret.Tests/Egret.Tests.csproj b/tests/Egret.Tests/Egret.Tests.csproj
index 927d3d4..3530c6b 100644
--- a/tests/Egret.Tests/Egret.Tests.csproj
+++ b/tests/Egret.Tests/Egret.Tests.csproj
@@ -22,5 +22,4 @@
-
diff --git a/tests/Egret.Tests/Formatters/AudacityResultFormatterTests.cs b/tests/Egret.Tests/Formatters/AudacityResultFormatterTests.cs
new file mode 100644
index 0000000..a7309fe
--- /dev/null
+++ b/tests/Egret.Tests/Formatters/AudacityResultFormatterTests.cs
@@ -0,0 +1,83 @@
+namespace Egret.Tests.Formatters
+{
+ using Cli.Commands;
+ using Cli.Formatters;
+ using Cli.Hosting;
+ using Cli.Models.Results;
+ using Cli.Processing;
+ using Support;
+ using System;
+ using System.IO;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ public class AudacityResultFormatterTests : TestBase
+ {
+ public AudacityResultFormatterTests(ITestOutputHelper output) : base(output)
+ {
+ }
+
+ [Fact]
+ public async void TestNoResults()
+ {
+ // arrange
+ var formatter = GetFormatter();
+ var finalResults = GetFinalResults();
+
+ // act
+ await formatter.WriteResultsHeader();
+ await formatter.WriteResultsFooter(finalResults);
+
+ // assert
+ // TODO
+ }
+
+ [Fact]
+ public async void TestWriteResults()
+ {
+ // arrange
+ var formatter = GetFormatter();
+ var results = new TestCaseResult[]
+ {
+ new(Array.Empty(), Array.Empty(), new TestContext(null,
+ null,
+ null,
+ null,
+ null,
+ 0,
+ new CaseExecutor.CaseTracker(),
+ TimeSpan.Zero))
+ };
+ var finalResults = GetFinalResults();
+
+
+ // act
+ await formatter.WriteResultsHeader();
+ for (int i = 0; i < results.Length; i++)
+ {
+ await formatter.WriteResult(i,results[i]);
+ }
+
+ await formatter.WriteResultsFooter(finalResults);
+
+ // assert
+ // TODO
+ }
+
+ private AudacityResultFormatter GetFormatter()
+ {
+ var runInfo = new RunInfo(DateTime.Now);
+ var options = new TestCommandOptions
+ {
+ Configuration = new FileInfo(this.TempFactory.GetTempFile("config.yml", true).FullName)
+ };
+ var outputFile = new OutputFile(options, runInfo);
+ return new AudacityResultFormatter(outputFile, this.AudacitySerializer);
+ }
+
+ private FinalResults GetFinalResults()
+ {
+ return new(null, null, TimeSpan.Zero);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Egret.Tests/Serialization/Audacity/Audacity3Tests.cs b/tests/Egret.Tests/Serialization/Audacity/Audacity3Tests.cs
new file mode 100644
index 0000000..42864d9
--- /dev/null
+++ b/tests/Egret.Tests/Serialization/Audacity/Audacity3Tests.cs
@@ -0,0 +1,123 @@
+namespace Egret.Tests.Serialization.Audacity
+{
+ using Cli.Models;
+ using Cli.Models.Audacity;
+ using Cli.Serialization;
+ using Cli.Serialization.Audacity;
+ using FluentAssertions;
+ using LanguageExt;
+ using LanguageExt.Common;
+ using Support;
+ using System.IO;
+ using System.IO.Abstractions;
+ using System.Threading.Tasks;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ public class Audacity3Tests : TestBase
+ {
+ private readonly Audacity3Serializer audacity3Serializer;
+ private readonly ConfigDeserializer configDeserializer;
+
+ public static TheoryData Example2 => new() {AudacityExamples.Example2Instance()};
+
+
+ public Audacity3Tests(ITestOutputHelper output) : base(output)
+ {
+ this.audacity3Serializer = this.Audacity3Serializer;
+ this.configDeserializer = BuildConfigDeserializer();
+ }
+
+ [Theory]
+ [FileData(AudacityExamples.Example2File)]
+ public void TestAudacity3Deserialize(string filePath)
+ {
+ // arrange
+ var sourceFile = (FileInfoBase)new FileInfo(filePath);
+ var projectExpected = AudacityExamples.Example2Instance();
+
+ // act
+ var projectActual = audacity3Serializer.Deserialize(sourceFile);
+
+ // assert
+ AudacityExamples.Compare(projectActual, projectExpected);
+ }
+
+ [Theory]
+ [FileData(AudacityExamples.Example2File)]
+ public async Task TestConfigDeserializer(string filePath)
+ {
+ var resolvedPath = Path.GetFullPath(filePath);
+ var audioPath = Path.ChangeExtension(filePath, ".wav");
+ TestFiles.AddFile(audioPath, "");
+
+ // There are two file systems being used here,
+ // because SqliteConnection doesn't seem to be able to use the mock file system.
+ // The mock file system contains a placeholder file at placeholderPath so the file is found.
+ // The placeholder path is changed in the host config content and the guest config path so they match the real file.
+ // Then the real path is given to SqliteConnection,
+ // and the real file exists and the mock file exists with placeholder content (which is not read).
+ var placeholderPath = "/abc/example2.aup3";
+ var hostConfig = AudacityExamples.Host3Config;
+ var newHostConfig = (hostConfig.Path, hostConfig.Contents.Replace(placeholderPath, resolvedPath));
+ TestFiles.AddFile(newHostConfig);
+
+ var guestConfig = AudacityExamples.Guest3Config;
+ var newGuestConfig = (resolvedPath, guestConfig.Contents);
+ TestFiles.AddFile(newGuestConfig);
+
+ (Config config, Seq errors) = await this.configDeserializer.Deserialize(
+ TestFiles.FileInfo.FromFileName(AudacityExamples.HostConfig.Path)
+ );
+
+ errors.Should().BeEmpty();
+ config.Should().NotBeNull();
+
+ config.TestSuites.Should().HaveCount(1);
+
+ var testSuit = config.TestSuites["host_suite"];
+ testSuit.IncludeTests.ToList().Should().HaveCount(1);
+
+ var includeTests = testSuit.IncludeTests[0];
+ includeTests.From.Should().Be(resolvedPath);
+
+ var testsCases = includeTests.Tests;
+ testsCases.ToList().Should().HaveCount(1);
+
+ var expectations = testsCases[0].Expect;
+
+ expectations[0].Should().BeOfType();
+ expectations[1].Should().BeOfType();
+ expectations[2].Should().BeOfType();
+
+ var temporalTolerance = includeTests.TemporalTolerance ?? 0.5;
+ var spectralTolerance = includeTests.SpectralTolerance ?? 0.5;
+
+ var expected = AudacityExamples.Example2Instance();
+
+ var expectation1 = (BoundedExpectation)expectations[0];
+ expectation1.Name.Should().Be($"Audacity Label label 3 in track Label Track (0:0)");
+ var expected1Label = expected.Tracks[0].Labels[0];
+ expectation1.Bounds.StartSeconds.Should().Be(expected1Label.TimeStart.WithTolerance(temporalTolerance));
+ expectation1.Bounds.EndSeconds.Should().Be(expected1Label.TimeEnd.WithTolerance(temporalTolerance));
+ expectation1.Bounds.HighHertz.Should().Be(expected1Label.SelHigh.WithTolerance(spectralTolerance));
+ expectation1.Bounds.LowHertz.Should().Be(expected1Label.SelLow.WithTolerance(spectralTolerance));
+
+ var expectation2 = (BoundedExpectation)expectations[1];
+ expectation2.Name.Should().Be($"Audacity Label label 1 in track Label Track (0:1)");
+ var expected2Label = expected.Tracks[0].Labels[1];
+ expectation2.Bounds.StartSeconds.Should().Be(expected2Label.TimeStart.WithTolerance(temporalTolerance));
+ expectation2.Bounds.EndSeconds.Should().Be(expected2Label.TimeEnd.WithTolerance(temporalTolerance));
+ expectation2.Bounds.HighHertz.Should().Be(expected2Label.SelHigh.WithTolerance(spectralTolerance));
+ expectation2.Bounds.LowHertz.Should().Be(expected2Label.SelLow.WithTolerance(spectralTolerance));
+
+ var expectation3 = (BoundedExpectation)expectations[2];
+ expectation3.Name.Should().Be($"Audacity Label label 2 in track Label Track (0:2)");
+ var expected3Label = expected.Tracks[0].Labels[2];
+ expectation3.Bounds.StartSeconds.Should().Be(expected3Label.TimeStart.WithTolerance(temporalTolerance));
+ expectation3.Bounds.EndSeconds.Should().Be(expected3Label.TimeEnd.WithTolerance(temporalTolerance));
+ expectation3.Bounds.HighHertz.Should().Be(expected3Label.SelHigh.WithTolerance(spectralTolerance));
+ expectation3.Bounds.LowHertz.Should().Be(expected3Label.SelLow.WithTolerance(spectralTolerance));
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Egret.Tests/Serialization/Audacity/AudacityExamples.cs b/tests/Egret.Tests/Serialization/Audacity/AudacityExamples.cs
new file mode 100644
index 0000000..2afe1c8
--- /dev/null
+++ b/tests/Egret.Tests/Serialization/Audacity/AudacityExamples.cs
@@ -0,0 +1,209 @@
+namespace Egret.Tests.Serialization.Audacity
+{
+ using Cli.Models;
+ using Cli.Models.Audacity;
+ using FluentAssertions;
+ using MoreLinq;
+ using Support;
+ using System.IO;
+
+ public static class AudacityExamples
+ {
+ public const string Example1File = @"..\..\..\..\Fixtures\Audacity\audacity-example1.aup";
+ public const string Example2File = @"..\..\..\..\Fixtures\Audacity\audacity-example2.aup3";
+ public const string Example2V2File = @"..\..\..\..\Fixtures\Audacity\audacity-example2.aup";
+
+ public static readonly TestFile HostConfig = ("/abc/host.egret.yaml", @"
+test_suites:
+ host_suite:
+ include_tests:
+ - from: /abc/example1.aup
+");
+
+ public static readonly TestFile GuestConfig = (@"/abc/example1.aup", File.ReadAllText(Example1File));
+
+ public static readonly TestFile Host3Config = ("/abc/host.egret.yaml", @"
+test_suites:
+ host_suite:
+ include_tests:
+ - from: /abc/example2.aup3
+");
+
+ // Note that this file is not actually used by the Audacity3Serializer,
+ // as SqliteConnection doesn't seem to be able to use the mock file system.
+ // This is here to ensure that the file exists in the mock filesystem.
+ // NOTE: the file contains placeholder data on purpose
+ public static readonly TestFile Guest3Config = (@"/abc/example2.aup3", "some placeholder content");
+
+
+ public static Project Example1Instance()
+ {
+ return new()
+ {
+ ProjectName = "example1_data",
+ Version = "1.3.0",
+ AudacityVersion = "2.4.2",
+ Sel0 = 0.0,
+ Sel1 = 0.0,
+ SelLow = 0,
+ SelHigh = 0,
+ VPos = 0,
+ HVal = 0.0000000000,
+ Zoom = 71.6039279869,
+ Rate = 44100.0,
+ SnapTo = "off",
+ SelectionFormat = "hh:mm:ss + milliseconds",
+ FrequencyFormat = "Hz",
+ BandwidthFormat = "octaves",
+ SourceInfo = new SourceInfo(BuildFullPath(Example1File)),
+ Tags =
+ new Tag[]
+ {
+ new() {Name = "ARTIST", Value = "artist"},
+ new() {Name = "TITLE", Value = "track"},
+ new() {Name = "COMMENTS", Value = "comments"},
+ new() {Name = "ALBUM", Value = "album"},
+ new() {Name = "YEAR", Value = "year"},
+ new() {Name = "TRACKNUMBER", Value = "track number"},
+ new() {Name = "GENRE", Value = "genre"},
+ new() {Name = "Custom", Value = "Custom metadata tag"},
+ },
+ Tracks = new LabelTrack[]
+ {
+ new()
+ {
+ Name = "Track 2",
+ IsSelected = 1,
+ Height = 206,
+ Minimized = 0,
+ NumLabels = 2,
+ Labels = new Label[]
+ {
+ new()
+ {
+ TimeStart = 4.0000000000,
+ TimeEnd = 7.2446258503,
+ SelLow = 1.0000000000,
+ SelHigh = 10.0000000000,
+ Title = "test 1"
+ },
+ new()
+ {
+ TimeStart = 15.9714285714,
+ TimeEnd = 24.4400000000,
+ SelLow = 10.0000000000,
+ SelHigh = 10000.0000000000,
+ Title = "test 3"
+ },
+ }
+ },
+ new()
+ {
+ Name = "Track 1",
+ IsSelected = 1,
+ Height = 90,
+ Minimized = 0,
+ NumLabels = 1,
+ Labels = new[]
+ {
+ new Label {TimeStart = 8.0228571429, TimeEnd = 18.9800000000, Title = "test 2"},
+ }
+ },
+ },
+ };
+ }
+
+ public static Project Example2Instance()
+ {
+ return new()
+ {
+ Version = "1.3.0",
+ AudacityVersion = "3.0.0",
+ Sel0 = 0.18857142857142856,
+ Sel1 = 2.477142857142857,
+ SelLow = 1230.769287109375,
+ SelHigh = 7160.8388671875,
+ VPos = 0,
+ HVal = 0,
+ Zoom = 116.66666666666667,
+ Rate = 22050,
+ SnapTo = "off",
+ SelectionFormat = "hh:mm:ss + milliseconds",
+ FrequencyFormat = "Hz",
+ BandwidthFormat = "octaves",
+ SourceInfo = new SourceInfo(BuildFullPath(Example1File)),
+ Tags =
+ new Tag[] {new() {Name = "encoder", Value = "Lavc58.35.100 libvorbis"}},
+ Tracks = new LabelTrack[]
+ {
+ new()
+ {
+ Name = "Label Track",
+ IsSelected = 1,
+ Height = 73,
+ Minimized = 0,
+ NumLabels = 3,
+ Labels = new Label[]
+ {
+ new()
+ {
+ TimeStart = 0.18857142857142856,
+ TimeEnd = 2.477142857142857,
+ SelLow = 1230.769287109375,
+ SelHigh = 7160.8388671875,
+ Title = "label 3"
+ },
+ new()
+ {
+ TimeStart = 1.2257142857142858,
+ TimeEnd = 2.52,
+ SelLow = 2237.76220703125,
+ SelHigh = 5874.1259765625,
+ Title = "label 1"
+ },
+ new()
+ {
+ TimeStart = 5.854285714285714,
+ TimeEnd = 7.808571428571428,
+ SelLow = 1342.6573486328125,
+ SelHigh = 7440.5595703125,
+ Title = "label 2"
+ },
+ }
+ },
+ },
+ };
+ }
+
+ public static string BuildFullPath(string relativePath)
+ {
+ var basePath = Directory.GetCurrentDirectory();
+ var relativePathNormalised = relativePath.TrimStart(Path.PathSeparator);
+ return Path.GetFullPath(relativePathNormalised, basePath);
+ }
+
+ public static void Compare(Project actual, Project expected)
+ {
+ // TODO: compare projects
+ // actual.Should().BeEquivalentTo(expected,
+ // options => options
+ // .Excluding(p => p.Tags)
+ // .Excluding(p => p.Tracks));
+
+ actual.Tags.Should()
+ .BeEquivalentTo(expected.Tags, options => options.ComparingByMembers());
+
+ actual.Tracks.Should().BeEquivalentTo(expected.Tracks,
+ options => options.ComparingByMembers());
+
+ actual.Tracks.ForEach((track, trackIndex) =>
+ {
+ track.Labels.ForEach((label, labelIndex) =>
+ {
+ label.Should().BeEquivalentTo(expected.Tracks[trackIndex].Labels[labelIndex],
+ options => options.ComparingByMembers());
+ });
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Egret.Tests/Serialization/Audacity/AudacityTests.cs b/tests/Egret.Tests/Serialization/Audacity/AudacityTests.cs
new file mode 100644
index 0000000..0a9523a
--- /dev/null
+++ b/tests/Egret.Tests/Serialization/Audacity/AudacityTests.cs
@@ -0,0 +1,129 @@
+namespace Egret.Tests.Serialization.Audacity
+{
+ using Cli.Models;
+ using Cli.Models.Audacity;
+ using Cli.Serialization;
+ using Cli.Serialization.Audacity;
+ using FluentAssertions;
+ using LanguageExt;
+ using LanguageExt.Common;
+ using Support;
+ using System.IO;
+ using System.IO.Abstractions;
+ using System.Linq;
+ using System.Threading.Tasks;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ public class AudacityTests : TestBase
+ {
+ private readonly AudacitySerializer audacitySerializer;
+ private readonly ConfigDeserializer configDeserializer;
+
+ public static TheoryData Example1 => new() {AudacityExamples.Example1Instance()};
+
+ public AudacityTests(ITestOutputHelper output) : base(output)
+ {
+ this.audacitySerializer = this.AudacitySerializer;
+ this.configDeserializer = BuildConfigDeserializer();
+ }
+
+ [Theory]
+ [FileData(AudacityExamples.Example1File)]
+ public async Task TestAudacityDeserialize(string filePath)
+ {
+ // arrange
+ var sourceFile = (FileInfoBase)new FileInfo(filePath);
+ var projectExpected = AudacityExamples.Example1Instance();
+
+ // act
+ var projectActual = await audacitySerializer.Deserialize(sourceFile);
+
+ // assert
+ AudacityExamples.Compare(projectActual, projectExpected);
+ }
+
+ [Theory]
+ [MemberData(nameof(Example1))]
+ public async Task TestAudacitySerialize(Project project)
+ {
+ // arrange
+ var tempDir = Path.GetTempPath();
+ var tempFileName = Path.GetRandomFileName();
+ var tempFile = Path.Combine(tempDir, Path.ChangeExtension(tempFileName, "aup"));
+ var expectedFile = AudacityExamples.BuildFullPath(AudacityExamples.Example1File);
+
+ // act
+ audacitySerializer.Serialize(tempFile, project);
+
+ var projectActual = await audacitySerializer.Deserialize((FileInfoBase)new FileInfo(tempFile));
+ var projectExpected = await audacitySerializer.Deserialize((FileInfoBase)new FileInfo(expectedFile));
+
+ // assert
+ tempFile.Should().EndWith(".aup");
+ AudacityExamples.Compare(projectActual, projectExpected);
+ }
+
+ [Fact]
+ public async Task TestConfigDeserializer()
+ {
+ TestFiles.AddFile("/abc/example1.wav", "");
+
+ TestFiles.AddFile(AudacityExamples.HostConfig);
+ TestFiles.AddFile(AudacityExamples.GuestConfig);
+
+ (Config config, Seq errors) = await this.configDeserializer.Deserialize(
+ TestFiles.FileInfo.FromFileName(AudacityExamples.HostConfig.Path)
+ );
+
+ errors.Should().BeEmpty();
+ config.Should().NotBeNull();
+
+ config.TestSuites.Should().HaveCount(1);
+
+ var testSuit = config.TestSuites["host_suite"];
+ testSuit.IncludeTests.ToList().Should().HaveCount(1);
+
+ var includeTests = testSuit.IncludeTests[0];
+ includeTests.From.Should().Be(@"/abc/example1.aup");
+
+ var testsCases = includeTests.Tests;
+ testsCases.ToList().Should().HaveCount(1);
+
+ var expectations = testsCases[0].Expect;
+
+ expectations[0].Should().BeOfType();
+ expectations[1].Should().BeOfType();
+ expectations[2].Should().BeOfType();
+
+ var temporalTolerance = includeTests.TemporalTolerance ?? 0.5;
+ var spectralTolerance = includeTests.SpectralTolerance ?? 0.5;
+
+ var expected = AudacityExamples.Example1Instance();
+
+ var expectation1 = (BoundedExpectation)expectations[0];
+ expectation1.Name.Should().Be($"Audacity Label test 1 in track Track 2 (0:0)");
+ var expected1Label = expected.Tracks[0].Labels[0];
+ expectation1.Bounds.StartSeconds.Should().Be(expected1Label.TimeStart.WithTolerance(temporalTolerance));
+ expectation1.Bounds.EndSeconds.Should().Be(expected1Label.TimeEnd.WithTolerance(temporalTolerance));
+ expectation1.Bounds.HighHertz.Should().Be(expected1Label.SelHigh.WithTolerance(spectralTolerance));
+ expectation1.Bounds.LowHertz.Should().Be(expected1Label.SelLow.WithTolerance(spectralTolerance));
+
+ var expectation2 = (BoundedExpectation)expectations[1];
+ expectation2.Name.Should().Be($"Audacity Label test 3 in track Track 2 (0:1)");
+ var expected2Label = expected.Tracks[0].Labels[1];
+ expectation2.Bounds.StartSeconds.Should().Be(expected2Label.TimeStart.WithTolerance(temporalTolerance));
+ expectation2.Bounds.EndSeconds.Should().Be(expected2Label.TimeEnd.WithTolerance(temporalTolerance));
+ expectation2.Bounds.HighHertz.Should().Be(expected2Label.SelHigh.WithTolerance(spectralTolerance));
+ expectation2.Bounds.LowHertz.Should().Be(expected2Label.SelLow.WithTolerance(spectralTolerance));
+
+ var expectation3 = (TemporalExpectation)expectations[2];
+ expectation3.Name.Should().Be($"Audacity Label test 2 in track Track 1 (1:0)");
+ var expected3Label = expected.Tracks[1].Labels[0];
+ expectation3.Time.StartSeconds.Should().Be(expected3Label.TimeStart.WithTolerance(temporalTolerance));
+ expectation3.Time.EndSeconds.Should().Be(expected3Label.TimeEnd.WithTolerance(temporalTolerance));
+
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Egret.Tests/Support/FileDataAttribute.cs b/tests/Egret.Tests/Support/FileDataAttribute.cs
new file mode 100644
index 0000000..03cd725
--- /dev/null
+++ b/tests/Egret.Tests/Support/FileDataAttribute.cs
@@ -0,0 +1,54 @@
+namespace Egret.Tests.Support
+{
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Reflection;
+ using System.Runtime.InteropServices;
+ using Xunit.Sdk;
+
+ ///
+ /// A xUnit Data Attribute for loading data from a file.
+ ///
+ public class FileDataAttribute : DataAttribute
+ {
+ private readonly string filePath;
+
+ ///
+ /// Create a new File Data Attribute.
+ /// Loads data from a file.
+ ///
+ /// The path to the file.
+ public FileDataAttribute(string filePath)
+ {
+ this.filePath = filePath;
+ }
+
+ public override IEnumerable GetData(MethodInfo testMethod)
+ {
+ if (testMethod == null)
+ {
+ throw new ArgumentNullException(nameof(testMethod));
+ }
+
+ // Get the absolute path to the file
+ var isWindows = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+ var isAbsolutePath = isWindows
+ ? Path.IsPathFullyQualified(this.filePath)
+ : Path.IsPathRooted(this.filePath);
+ var path = isAbsolutePath
+ ? this.filePath
+ : Path.Combine(Directory.GetCurrentDirectory(), this.filePath.TrimStart(Path.PathSeparator));
+
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException($"Could not find file at path: {path}");
+ }
+
+ // The return data is an enumerable of object array.
+ // The array is the test method parameters. Each item in the enumerable is one call of the test method.
+ // return one item in the enumerable, one item in the object array, which is the path to the file
+ return new[] {new[] {path}};
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Egret.Tests/Support/TestBase.cs b/tests/Egret.Tests/Support/TestBase.cs
index 8b8ed2a..d4d3976 100644
--- a/tests/Egret.Tests/Support/TestBase.cs
+++ b/tests/Egret.Tests/Support/TestBase.cs
@@ -14,6 +14,8 @@
using Xunit.Abstractions;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
+using Egret.Cli.Serialization.Audacity;
+using Egret.Cli.Serialization.Xml;
namespace Egret.Tests.Support
{
@@ -43,13 +45,17 @@ protected ILogger BuildLogger()
protected AvianzDeserializer AvianzDeserializer => new(new DefaultJsonSerializer());
+ protected AudacitySerializer AudacitySerializer => new(BuildLogger(), new DefaultXmlSerializer());
+ protected Audacity3Serializer Audacity3Serializer => new(BuildLogger(), AudacitySerializer);
+
protected ConfigDeserializer BuildConfigDeserializer()
{
var egret = new EgretImporter(BuildLogger(), TestFiles);
var shared = new SharedImporter(BuildLogger(), Helpers.DefaultNamingConvention);
var avianz = new AvianzImporter(BuildLogger(), TestFiles, AvianzDeserializer, Helpers.DefaultAppSettings);
+ var audacity = new AudacityImporter(BuildLogger(), TestFiles, AudacitySerializer, Audacity3Serializer, Helpers.DefaultAppSettings);
var importer = new TestCaseImporter(BuildLogger(), new ITestCaseImporter[] {
- egret, shared, avianz
+ egret, shared, avianz, audacity
});
return new ConfigDeserializer(
BuildLogger(),
diff --git a/tests/Fixtures/Audacity/audacity-example1.aup b/tests/Fixtures/Audacity/audacity-example1.aup
new file mode 100644
index 0000000..0a9cbd1
--- /dev/null
+++ b/tests/Fixtures/Audacity/audacity-example1.aup
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Fixtures/Audacity/audacity-example2.aup b/tests/Fixtures/Audacity/audacity-example2.aup
new file mode 100644
index 0000000..98f3a46
--- /dev/null
+++ b/tests/Fixtures/Audacity/audacity-example2.aup
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Fixtures/Audacity/audacity-example2.aup3 b/tests/Fixtures/Audacity/audacity-example2.aup3
new file mode 100644
index 0000000..59c9bca
Binary files /dev/null and b/tests/Fixtures/Audacity/audacity-example2.aup3 differ