From afc0557eef7620e84163ac41099e5f12927bd2b4 Mon Sep 17 00:00:00 2001 From: Justin LeFebvre Date: Sun, 12 Jul 2026 23:03:31 -0400 Subject: [PATCH 1/3] First stab at translating the auto_downcharter --- AutoDownchartCli/AutoDownchartCli.csproj | 16 + AutoDownchartCli/Program.cs | 294 ++++++++ .../Chart/FiveFretDownchartGeneratorTests.cs | 247 +++++++ YARG.Core.sln | 22 + .../FiveFretDownchartGenerator.cs | 641 ++++++++++++++++++ .../AutoGeneration/MidiDownchartExporter.cs | 307 +++++++++ YARG.Core/Chart/SongChart.AutoGeneration.cs | 23 +- YARG.Core/Chart/SongChart.cs | 2 + .../Chart/Tracks/InstrumentDifficulty.cs | 26 +- .../Song/Entries/Ini/SongEntry.IniBase.cs | 4 +- YARG.Core/Song/Entries/SongEntry.Scanning.cs | 23 +- 11 files changed, 1598 insertions(+), 7 deletions(-) create mode 100644 AutoDownchartCli/AutoDownchartCli.csproj create mode 100644 AutoDownchartCli/Program.cs create mode 100644 YARG.Core.UnitTests/Chart/FiveFretDownchartGeneratorTests.cs create mode 100644 YARG.Core/Chart/AutoGeneration/FiveFretDownchartGenerator.cs create mode 100644 YARG.Core/Chart/AutoGeneration/MidiDownchartExporter.cs diff --git a/AutoDownchartCli/AutoDownchartCli.csproj b/AutoDownchartCli/AutoDownchartCli.csproj new file mode 100644 index 000000000..3d7d3f346 --- /dev/null +++ b/AutoDownchartCli/AutoDownchartCli.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + diff --git a/AutoDownchartCli/Program.cs b/AutoDownchartCli/Program.cs new file mode 100644 index 000000000..ea719d806 --- /dev/null +++ b/AutoDownchartCli/Program.cs @@ -0,0 +1,294 @@ +using System.CommandLine; +using Melanchall.DryWetMidi.Core; +using MoonscraperChartEditor.Song.IO; +using YARG.Core; +using YARG.Core.Chart.AutoGeneration; + +namespace AutoDownchartCli; + +internal static class Program +{ + private static readonly Dictionary Instruments = new(StringComparer.OrdinalIgnoreCase) + { + { "guitar", Instrument.FiveFretGuitar }, + { "coop", Instrument.FiveFretCoopGuitar }, + { "bass", Instrument.FiveFretBass }, + { "rhythm", Instrument.FiveFretRhythm }, + { "keys", Instrument.Keys }, + }; + + public static int Main(string[] args) + { + var inputArgument = new Argument("input") + { + Description = "Path to a .mid file or a directory containing notes.mid files", + }; + + var outputOption = new Option("--output", "-o") + { + Description = "Output file, or output root for directory mode", + }; + + var intensityOption = new Option("--intensity") + { + Description = "Reduction intensity (0.0-1.0, default: 1.0)", + DefaultValueFactory = _ => 1.0, + }; + + var instrumentOption = new Option("--instrument", "-i") + { + Description = "Comma-separated: guitar,coop,bass,rhythm,keys", + }; + + var replaceOption = new Option("--replace-existing") + { + Description = "Replace authored Hard, Medium, and Easy charts", + }; + + var inPlaceOption = new Option("--in-place") + { + Description = "Atomically replace each source MIDI", + }; + + var overwriteOption = new Option("--overwrite") + { + Description = "Replace existing output files", + }; + + var root = new RootCommand("Generate reduced MIDI difficulties from Expert five-fret charts") + { + inputArgument, + outputOption, + intensityOption, + instrumentOption, + replaceOption, + inPlaceOption, + overwriteOption, + }; + + root.Validators.Add(result => + { + double intensity = result.GetValue(intensityOption); + if (double.IsNaN(intensity) || double.IsInfinity(intensity) || + intensity < 0 || intensity > 1) + { + result.AddError("Intensity must be a number between 0 and 1."); + } + + if (result.GetValue(inPlaceOption) && result.GetValue(outputOption) is not null) + { + result.AddError("--in-place cannot be combined with --output."); + } + + string inputPath = result.GetValue(inputArgument)!; + string? outputPath = result.GetValue(outputOption); + if (outputPath is not null && File.Exists(inputPath)) + { + var pathComparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (string.Equals(Path.GetFullPath(inputPath), Path.GetFullPath(outputPath), pathComparison)) + { + result.AddError("--output cannot be the input file. Use --in-place instead."); + } + } + + string? instrumentValue = result.GetValue(instrumentOption); + if (instrumentValue is not null) + { + string[] instrumentNames = instrumentValue.Split(',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (instrumentNames.Length == 0) + { + result.AddError("--instrument must contain at least one instrument name."); + } + + foreach (string name in instrumentNames) + { + if (!Instruments.TryGetValue(name, out _)) + { + result.AddError( + $"Unknown instrument '{name}'. Must be guitar, coop, bass, rhythm, or keys."); + } + } + } + }); + + root.SetAction(parseResult => + { + var options = new CliOptions + { + InputPath = parseResult.GetValue(inputArgument)!, + OutputPath = parseResult.GetValue(outputOption), + Intensity = parseResult.GetValue(intensityOption), + ReplaceExisting = parseResult.GetValue(replaceOption), + InPlace = parseResult.GetValue(inPlaceOption), + Overwrite = parseResult.GetValue(overwriteOption), + }; + + string? instrumentValue = parseResult.GetValue(instrumentOption); + if (instrumentValue is not null) + { + foreach (string name in instrumentValue.Split(',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + options.Instruments.Add(Instruments[name]); + } + } + + return Run(options); + }); + + var parseResult = root.Parse(args); + return parseResult.Invoke(); + } + + private static int Run(CliOptions options) + { + try + { + var inputs = GetInputs(options.InputPath); + if (inputs.Count == 0) + { + Console.Error.WriteLine("No MIDI files were found."); + return 1; + } + + int failures = 0; + int generated = 0; + int skipped = 0; + foreach (string input in inputs) + { + try + { + var result = ProcessFile(input, options); + generated += result.GeneratedDifficultyCount; + skipped += result.SkippedDifficultyCount; + } + catch (Exception exception) + { + failures++; + Console.Error.WriteLine($"ERROR: {input}: {exception.Message}"); + } + } + + Console.WriteLine($"Completed {inputs.Count - failures}/{inputs.Count} file(s): " + + $"{generated} generated, {skipped} skipped, {failures} failed."); + return failures == 0 ? 0 : 1; + } + catch (Exception exception) + { + Console.Error.WriteLine($"ERROR: {exception.Message}"); + return 1; + } + } + + private static MidiDownchartExportResult ProcessFile(string inputPath, CliOptions options) + { + var source = MidFileLoader.LoadMidiFile(inputPath); + var result = MidiDownchartExporter.Generate(source, new MidiDownchartExportOptions + { + Intensity = options.Intensity, + ReplaceExisting = options.ReplaceExisting, + Instruments = options.Instruments.Count == 0 ? null : options.Instruments, + }); + + if (result.GeneratedDifficultyCount == 0) + { + Console.WriteLine($"Skipped {inputPath}: no difficulties needed generation."); + return result; + } + + string outputPath = GetOutputPath(inputPath, options); + if (!options.InPlace && File.Exists(outputPath) && !options.Overwrite) + { + throw new IOException($"Output already exists: {outputPath}. Use --overwrite to replace it."); + } + + string? directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + if (options.InPlace) + { + string temporaryPath = Path.Combine( + directory ?? ".", + $".{Path.GetFileName(inputPath)}.{Guid.NewGuid():N}.tmp"); + try + { + result.Midi.Write(temporaryPath, true); + File.Move(temporaryPath, inputPath, true); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + else + { + result.Midi.Write(outputPath, options.Overwrite); + } + + Console.WriteLine($"{inputPath} -> {outputPath}: " + + $"{result.GeneratedDifficultyCount} generated, {result.SkippedDifficultyCount} skipped."); + return result; + } + + private static List GetInputs(string inputPath) + { + if (File.Exists(inputPath)) + { + return [Path.GetFullPath(inputPath)]; + } + + if (Directory.Exists(inputPath)) + { + return Directory.EnumerateFiles(inputPath, "notes.mid", SearchOption.AllDirectories) + .Select(Path.GetFullPath) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + } + + throw new FileNotFoundException($"Input path does not exist: {inputPath}"); + } + + private static string GetOutputPath(string inputPath, CliOptions options) + { + if (options.InPlace) + { + return inputPath; + } + + if (!string.IsNullOrEmpty(options.OutputPath)) + { + if (File.Exists(options.InputPath)) + { + return Path.GetFullPath(options.OutputPath); + } + + string relative = Path.GetRelativePath(Path.GetFullPath(options.InputPath), inputPath); + string relativeDirectory = Path.GetDirectoryName(relative) ?? ""; + return Path.Combine(Path.GetFullPath(options.OutputPath), relativeDirectory, "notes.generated.mid"); + } + + string directory = Path.GetDirectoryName(inputPath) ?? "."; + string fileName = Path.GetFileNameWithoutExtension(inputPath); + return Path.Combine(directory, $"{fileName}.generated.mid"); + } + + private sealed class CliOptions + { + public string InputPath { get; init; } = ""; + public string? OutputPath { get; init; } + public double Intensity { get; init; } = 1.0; + public bool ReplaceExisting { get; init; } + public bool InPlace { get; init; } + public bool Overwrite { get; init; } + public HashSet Instruments { get; } = []; + } +} \ No newline at end of file diff --git a/YARG.Core.UnitTests/Chart/FiveFretDownchartGeneratorTests.cs b/YARG.Core.UnitTests/Chart/FiveFretDownchartGeneratorTests.cs new file mode 100644 index 000000000..0aad06b87 --- /dev/null +++ b/YARG.Core.UnitTests/Chart/FiveFretDownchartGeneratorTests.cs @@ -0,0 +1,247 @@ +using Melanchall.DryWetMidi.Common; +using Melanchall.DryWetMidi.Core; +using NUnit.Framework; +using YARG.Core.Chart; +using YARG.Core.Chart.AutoGeneration; + +namespace YARG.Core.UnitTests.Chart; + +public class FiveFretDownchartGeneratorTests +{ + private const uint RESOLUTION = 480; + + [Test] + public void Generate_CreatesIndependentGeneratedDifficulty() + { + var sync = MakeSyncTrack(); + var source = MakeExpert( + MakeNote(sync, 0, FiveFretGuitarFret.Green, FiveFretGuitarFret.Red, FiveFretGuitarFret.Yellow), + MakeNote(sync, 120, FiveFretGuitarFret.Blue), + MakeNote(sync, 240, FiveFretGuitarFret.Orange)); + + var generated = FiveFretDownchartGenerator.Generate(source, Difficulty.Hard, sync); + var clone = generated.Clone(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(generated.Difficulty, Is.EqualTo(Difficulty.Hard)); + Assert.That(generated.IsGenerated, Is.True); + Assert.That(clone.IsGenerated, Is.True); + Assert.That(generated.Notes[0].ChildNotes, Has.Count.EqualTo(1)); + Assert.That(source.Notes[0].ChildNotes, Has.Count.EqualTo(2)); + Assert.That(generated.Notes[0], Is.Not.SameAs(source.Notes[0])); + Assert.That(generated.Notes[0].NextNote, Is.SameAs(generated.Notes[1])); + Assert.That(generated.Notes[1].PreviousNote, Is.SameAs(generated.Notes[0])); + } + } + + [Test] + public void SongChartGenerateFiveFretDownchart_UsesExpertDifficultyAndSyncTrack() + { + var sync = MakeSyncTrack(); + var expert = MakeExpert( + MakeNote(sync, 0, FiveFretGuitarFret.Green), + MakeNote(sync, 120, FiveFretGuitarFret.Red), + MakeNote(sync, 240, FiveFretGuitarFret.Yellow)); + var chart = new SongChart(RESOLUTION) + { + SyncTrack = sync, + FiveFretGuitar = new InstrumentTrack(Instrument.FiveFretGuitar, + new Dictionary> + { + { Difficulty.Expert, expert }, + }), + }; + + var generated = chart.GenerateFiveFretDownchart( + Instrument.FiveFretGuitar, + Difficulty.Hard); + + using (Assert.EnterMultipleScope()) + { + Assert.That(generated.Instrument, Is.EqualTo(Instrument.FiveFretGuitar)); + Assert.That(generated.Difficulty, Is.EqualTo(Difficulty.Hard)); + Assert.That(generated.IsGenerated, Is.True); + } + } + + [Test] + public void GenerateMissing_PreservesAuthoredDifficulty() + { + var sync = MakeSyncTrack(); + var expert = MakeExpert( + MakeNote(sync, 0, FiveFretGuitarFret.Green), + MakeNote(sync, 120, FiveFretGuitarFret.Red), + MakeNote(sync, 240, FiveFretGuitarFret.Yellow)); + var authoredMedium = new InstrumentDifficulty( + Instrument.FiveFretGuitar, + Difficulty.Medium, + new List { MakeNote(sync, 0, FiveFretGuitarFret.Orange) }, + new List(), + new List()); + var track = new InstrumentTrack(Instrument.FiveFretGuitar, + new Dictionary> + { + { Difficulty.Expert, expert }, + { Difficulty.Medium, authoredMedium }, + }); + + int generated = FiveFretDownchartGenerator.GenerateMissing(track, sync); + + using (Assert.EnterMultipleScope()) + { + Assert.That(generated, Is.EqualTo(2)); + Assert.That(track.GetDifficulty(Difficulty.Medium), Is.SameAs(authoredMedium)); + Assert.That(track.GetDifficulty(Difficulty.Medium).IsGenerated, Is.False); + Assert.That(track.GetDifficulty(Difficulty.Hard).IsGenerated, Is.True); + Assert.That(track.GetDifficulty(Difficulty.Easy).IsGenerated, Is.True); + Assert.That(track.TryGetDifficulty(Difficulty.Beginner, out _), Is.False); + } + } + + [Test] + public void Generate_RejectsInvalidArguments() + { + var sync = MakeSyncTrack(); + var source = MakeExpert(MakeNote(sync, 0, FiveFretGuitarFret.Green)); + + Assert.That( + () => FiveFretDownchartGenerator.Generate(source, Difficulty.Beginner, sync), + Throws.TypeOf()); + Assert.That( + () => FiveFretDownchartGenerator.Generate(source, Difficulty.Easy, sync, 1.1), + Throws.TypeOf()); + } + + [Test] + public void SongChartFromMidi_GeneratesMissingDifficulties() + { + var chart = SongChart.FromMidi(ParseSettings.Default_Midi, MakeExpertMidi()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Expert).IsGenerated, Is.False); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Hard).IsGenerated, Is.True); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Medium).IsGenerated, Is.True); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Easy).IsGenerated, Is.True); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Beginner).Notes, Is.Empty); + } + } + + [Test] + public void MidiExporter_PreservesExpertAndWritesGeneratedDifficulties() + { + var source = MakeExpertMidi(); + var result = MidiDownchartExporter.Generate(source); + var chart = SongChart.FromMidi(ParseSettings.Default_Midi, result.Midi); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.GeneratedDifficultyCount, Is.EqualTo(3)); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Expert).Notes, Has.Count.EqualTo(4)); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Hard).Notes, Is.Not.Empty); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Medium).Notes, Is.Not.Empty); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Easy).Notes, Is.Not.Empty); + Assert.That(source.Chunks.OfType().Single(track => GetTrackName(track) == "PART GUITAR") + .Events.OfType().Count(), Is.EqualTo(4)); + } + } + + private static InstrumentDifficulty MakeExpert(params GuitarNote[] notes) + { + for (int i = 0; i < notes.Length; i++) + { + notes[i].PreviousNote = i > 0 ? notes[i - 1] : null; + notes[i].NextNote = i + 1 < notes.Length ? notes[i + 1] : null; + } + + return new InstrumentDifficulty( + Instrument.FiveFretGuitar, + Difficulty.Expert, + notes.ToList(), + new List(), + new List()); + } + + private static GuitarNote MakeNote( + SyncTrack sync, + uint tick, + FiveFretGuitarFret fret, + params FiveFretGuitarFret[] children) + { + double time = sync.TickToTime(tick); + var note = new GuitarNote(fret, GuitarNoteType.Strum, GuitarNoteFlags.None, NoteFlags.None, + time, 0, tick, 0); + foreach (var childFret in children) + { + note.AddChildNote(new GuitarNote(childFret, GuitarNoteType.Strum, GuitarNoteFlags.None, + NoteFlags.None, time, 0, tick, 0)); + } + + return note; + } + + private static SyncTrack MakeSyncTrack() + { + return new SyncTrack( + RESOLUTION, + new List { new(120, 0, 0) }, + new List(), + new List()); + } + + private static MidiFile MakeExpertMidi() + { + var syncEvents = new List<(long Tick, MidiEvent Event)> + { + (0, new SequenceTrackNameEvent("TEMPO")), + (0, new SetTempoEvent(500000)), + }; + var guitarEvents = new List<(long Tick, MidiEvent Event)> + { + (0, new SequenceTrackNameEvent("PART GUITAR")), + }; + + int[] frets = { 96, 97, 98, 99 }; + for (int i = 0; i < frets.Length; i++) + { + long tick = i * 120; + guitarEvents.Add((tick, new NoteOnEvent + { + NoteNumber = (SevenBitNumber) frets[i], + Velocity = (SevenBitNumber) 100, + })); + guitarEvents.Add((tick + 30, new NoteOffEvent + { + NoteNumber = (SevenBitNumber) frets[i], + Velocity = (SevenBitNumber) 0, + })); + } + + return new MidiFile(MakeTrack(syncEvents), MakeTrack(guitarEvents)) + { + TimeDivision = new TicksPerQuarterNoteTimeDivision((short) RESOLUTION), + }; + } + + private static TrackChunk MakeTrack(List<(long Tick, MidiEvent Event)> events) + { + var ordered = events + .OrderBy(item => item.Tick) + .ThenBy(item => item.Event is NoteOffEvent ? 0 : item.Event is NoteOnEvent ? 2 : 1) + .ToList(); + long previousTick = 0; + foreach (var item in ordered) + { + item.Event.DeltaTime = item.Tick - previousTick; + previousTick = item.Tick; + } + + return new TrackChunk(ordered.Select(item => item.Event)); + } + + private static string GetTrackName(TrackChunk track) + { + return track.Events.OfType().FirstOrDefault()?.Text ?? ""; + } +} \ No newline at end of file diff --git a/YARG.Core.sln b/YARG.Core.sln index f400207ba..2e942baee 100644 --- a/YARG.Core.sln +++ b/YARG.Core.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReplayCli", "ReplayCli\Repl EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YARG.Core.Benchmarks", "YARG.Core.Benchmarks\YARG.Core.Benchmarks.csproj", "{D26697B2-1739-4327-9B34-1501FE0E619F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutoDownchartCli", "AutoDownchartCli\AutoDownchartCli.csproj", "{54325EDF-1721-4C6D-851B-C57125098CDF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -127,6 +129,26 @@ Global {D26697B2-1739-4327-9B34-1501FE0E619F}.Release|x64.Build.0 = Release|Any CPU {D26697B2-1739-4327-9B34-1501FE0E619F}.Release|x86.ActiveCfg = Release|Any CPU {D26697B2-1739-4327-9B34-1501FE0E619F}.Release|x86.Build.0 = Release|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Debug|ARM.ActiveCfg = Debug|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Debug|ARM.Build.0 = Debug|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Debug|ARM64.Build.0 = Debug|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Debug|x64.ActiveCfg = Debug|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Debug|x64.Build.0 = Debug|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Debug|x86.ActiveCfg = Debug|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Debug|x86.Build.0 = Debug|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Release|Any CPU.Build.0 = Release|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Release|ARM.ActiveCfg = Release|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Release|ARM.Build.0 = Release|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Release|ARM64.ActiveCfg = Release|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Release|ARM64.Build.0 = Release|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Release|x64.ActiveCfg = Release|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Release|x64.Build.0 = Release|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Release|x86.ActiveCfg = Release|Any CPU + {54325EDF-1721-4C6D-851B-C57125098CDF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/YARG.Core/Chart/AutoGeneration/FiveFretDownchartGenerator.cs b/YARG.Core/Chart/AutoGeneration/FiveFretDownchartGenerator.cs new file mode 100644 index 000000000..8b659b910 --- /dev/null +++ b/YARG.Core/Chart/AutoGeneration/FiveFretDownchartGenerator.cs @@ -0,0 +1,641 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace YARG.Core.Chart.AutoGeneration +{ + public static class FiveFretDownchartGenerator + { + private const double MIN_CAPABILITY = 0.8; + private const double CAPABILITY_PRECISION = 0.005; + private const double HAND_INDEPENDENCE = 2; + private const double EPSILON = 0.0000001; + private const double ROCK_METER_SIZE = 42; + private static readonly Instrument[] FiveFretInstruments = GameMode.FiveFretGuitar.PossibleInstruments(); + + public static InstrumentDifficulty Generate( + InstrumentDifficulty source, + Difficulty targetDifficulty, + SyncTrack syncTrack, + double intensity = 1.0) + { + ValidateArguments(source, targetDifficulty, intensity); + + var sourceChords = CreateSourceChords(source, syncTrack.Resolution); + return GenerateDifficulty(source, targetDifficulty, syncTrack, intensity, sourceChords, + FindMinimumPassingIntensity(sourceChords)); + } + + public static IReadOnlyDictionary> GenerateAll( + InstrumentDifficulty source, + SyncTrack syncTrack, + double intensity = 1.0) + { + ValidateArguments(source, Difficulty.Hard, intensity); + + var sourceChords = CreateSourceChords(source, syncTrack.Resolution); + double minimumPassingIntensity = FindMinimumPassingIntensity(sourceChords); + var generated = new Dictionary>(); + foreach (var target in new[] { Difficulty.Hard, Difficulty.Medium, Difficulty.Easy }) + { + generated.Add(target, GenerateDifficulty(source, target, syncTrack, intensity, sourceChords, + minimumPassingIntensity)); + } + + return generated; + } + + private static InstrumentDifficulty GenerateDifficulty( + InstrumentDifficulty source, + Difficulty targetDifficulty, + SyncTrack syncTrack, + double intensity, + List sourceChords, + double minimumPassingIntensity) + { + var chords = sourceChords.Select(chord => chord.Clone()).ToList(); + + switch (targetDifficulty) + { + case Difficulty.Hard: + ReduceChords(chords, Difficulty.Expert); + ReduceChart(chords, Math.Max(1, minimumPassingIntensity * intensity)); + break; + case Difficulty.Medium: + ReduceChords(chords, Difficulty.Expert); + ReduceChords(chords, Difficulty.Hard); + ReduceRange(chords, Difficulty.Medium, false); + ReduceChart(chords, Math.Max(1, minimumPassingIntensity * intensity * 0.7)); + break; + case Difficulty.Easy: + ReduceChords(chords, Difficulty.Expert); + ReduceChords(chords, Difficulty.Hard); + ReduceRange(chords, Difficulty.Medium, true); + ReduceChords(chords, Difficulty.Medium); + ReduceRange(chords, Difficulty.Easy, false); + ReduceChart(chords, Math.Max(1, minimumPassingIntensity * intensity * 0.4)); + break; + } + + var phrases = source.Phrases + .Where(phrase => phrase.Type is not PhraseType.TremoloLane and not PhraseType.TrillLane) + .Select(phrase => phrase.Clone()) + .ToList(); + var textEvents = source.TextEvents.Select(text => text.Clone()).ToList(); + var notes = BuildNotes(chords, syncTrack); + + ApplyPhraseFlags(notes, phrases); + + return new InstrumentDifficulty( + source.Instrument, targetDifficulty, notes, phrases, textEvents, true); + } + + public static int GenerateMissing( + InstrumentTrack track, + SyncTrack syncTrack, + double intensity = 1.0) + { + ValidateInstrument(track.Instrument); + ValidateIntensity(intensity); + + if (!track.TryGetDifficulty(Difficulty.Expert, out var expert) || expert.Notes.Count == 0) + { + return 0; + } + + Difficulty[] targets = { Difficulty.Hard, Difficulty.Medium, Difficulty.Easy }; + var missingTargets = targets.Where(target => + !track.TryGetDifficulty(target, out var existing) || existing.Notes.Count == 0).ToList(); + if (missingTargets.Count == 0) + { + return 0; + } + + var generatedDifficulties = GenerateAll(expert, syncTrack, intensity); + foreach (var target in missingTargets) + { + track.TryGetDifficulty(target, out var existing); + if (existing != null) + { + track.RemoveDifficulty(target); + } + + var generatedDifficulty = generatedDifficulties[target]; + PreserveAuthoredMetadata(existing, generatedDifficulty); + track.AddDifficulty(target, generatedDifficulty); + } + + return missingTargets.Count; + } + + private static List CreateSourceChords( + InstrumentDifficulty source, + uint resolution) + { + return source.Notes.Select(note => DownchartChord.FromNote(note, resolution)).ToList(); + } + + private static void PreserveAuthoredMetadata( + InstrumentDifficulty? existing, + InstrumentDifficulty generated) + { + if (existing == null || existing.IsGenerated) + { + return; + } + + if (existing.Phrases.Count > 0) + { + generated.Phrases.Clear(); + generated.Phrases.AddRange(existing.Phrases.Select(phrase => phrase.Clone())); + ApplyPhraseFlags(generated.Notes, generated.Phrases); + } + + if (existing.TextEvents.Count > 0) + { + generated.TextEvents.Clear(); + generated.TextEvents.AddRange(existing.TextEvents.Select(text => text.Clone())); + } + } + + private static void ValidateArguments( + InstrumentDifficulty source, + Difficulty targetDifficulty, + double intensity) + { + ValidateInstrument(source.Instrument); + ValidateIntensity(intensity); + + if (source.Difficulty != Difficulty.Expert) + { + throw new ArgumentException("The source difficulty must be Expert.", nameof(source)); + } + + if (targetDifficulty is not Difficulty.Easy and not Difficulty.Medium and not Difficulty.Hard) + { + throw new ArgumentOutOfRangeException(nameof(targetDifficulty), + "The target difficulty must be Easy, Medium, or Hard."); + } + } + + private static void ValidateInstrument(Instrument instrument) + { + if (!FiveFretInstruments.Contains(instrument)) + { + throw new ArgumentException($"Instrument {instrument} is not a five-fret instrument.", nameof(instrument)); + } + } + + private static void ValidateIntensity(double intensity) + { + if (double.IsNaN(intensity) || double.IsInfinity(intensity) || intensity < 0 || intensity > 1) + { + throw new ArgumentOutOfRangeException(nameof(intensity), "Intensity must be between 0 and 1."); + } + } + + private static double FindMinimumPassingIntensity(List chords) + { + if (chords.Count < 3) + { + return MIN_CAPABILITY; + } + + var intensities = new List(chords.Count - 2); + for (int i = 1; i < chords.Count - 1; i++) + { + intensities.Add(GetGapIntensity(chords[i], chords[i - 1])); + } + + double left = MIN_CAPABILITY; + double right = intensities.Max(); + while (right - left > CAPABILITY_PRECISION) + { + double capability = (left + right) / 2; + if (SimulateRun(intensities, capability)) + { + right = capability; + } + else + { + left = capability; + } + } + + return right; + } + + private static bool SimulateRun(List intensities, double capability) + { + double meter = ROCK_METER_SIZE * 5 / 6; + foreach (double intensity in intensities) + { + double hitProbability = 1 / Math.Max(intensity / capability, 1); + double expectedChange = hitProbability * 0.25 + (1 - hitProbability) * -2; + meter = Math.Min(ROCK_METER_SIZE, meter + expectedChange); + if (meter < 0) + { + return false; + } + } + + return true; + } + + private static double GetGapIntensity(DownchartChord chord, DownchartChord previous) + { + double elapsed = chord.Time - previous.Time; + if (elapsed <= 0) + { + return 1; + } + + double leftHandVelocity = 1 / elapsed; + double rightHandVelocity = leftHandVelocity; + int previousFrets = previous.Shape >> 1; + int currentFrets = chord.Shape >> 1; + int lifts = CountFrets(previousFrets & ~currentFrets); + int presses = CountFrets(currentFrets & ~previousFrets); + double leftHandActions = 0.5 * (Math.Max(1, lifts) + presses); + int rightHandActions = IsStrum(chord) || chord.Shape == previous.Shape ? 1 : 0; + + double leftIntensity = Math.Max(leftHandVelocity * leftHandActions, EPSILON); + double rightIntensity = Math.Max(rightHandVelocity * rightHandActions, EPSILON); + return Math.Max(1, Math.Pow( + Math.Pow(leftIntensity, HAND_INDEPENDENCE) + + Math.Pow(rightIntensity, HAND_INDEPENDENCE), + 1 / HAND_INDEPENDENCE)); + } + + private static bool IsStrum(DownchartChord chord) + { + return chord.Type == GuitarNoteType.Strum; + } + + private static void ReduceChart(List chords, double intensityThreshold) + { + int index = 1; + while (index < chords.Count - 1) + { + var current = chords[index]; + var previous = chords[index - 1]; + if (GetGapIntensity(current, previous) <= intensityThreshold) + { + index++; + continue; + } + + bool joinChords = false; + if (!IsStrum(current) && current.Type != GuitarNoteType.Tap) + { + joinChords = index <= 1 || IsStrum(previous); + if (current.TimeLength <= 0.2) + { + joinChords = false; + } + } + + if (joinChords) + { + previous.TickLength = current.TickEnd - previous.Tick; + previous.TimeLength = current.TimeEnd - previous.Time; + chords.RemoveAt(index); + } + else if (current.GetScore() > previous.GetScore()) + { + chords.RemoveAt(index - 1); + if (index > 1 && index - 1 < chords.Count && + chords[index - 1].Type == GuitarNoteType.Hopo && + chords[index - 1].Tick - chords[index - 2].Tick > + 17 * chords[index - 1].Resolution / 24) + { + chords[index - 1].Type = GuitarNoteType.Strum; + } + } + else + { + chords.RemoveAt(index); + } + } + } + + private static void ReduceChords(List chords, Difficulty difficulty) + { + foreach (var chord in chords) + { + chord.Shape = GetReducedShape(chord.Shape, difficulty); + } + } + + private static int GetReducedShape(int shape, Difficulty difficulty) + { + return (difficulty, shape) switch + { + (Difficulty.Expert, 0b_00111_0) => 0b_00011_0, + (Difficulty.Expert, 0b_01011_0) => 0b_00101_0, + (Difficulty.Expert, 0b_01101_0) => 0b_00110_0, + (Difficulty.Expert, 0b_01110_0) => 0b_01010_0, + (Difficulty.Expert, 0b_10011_0) => 0b_01001_0, + (Difficulty.Expert, 0b_10101_0) => 0b_01010_0, + (Difficulty.Expert, 0b_10110_0) => 0b_01100_0, + (Difficulty.Expert, 0b_11001_0) => 0b_10010_0, + (Difficulty.Expert, 0b_11010_0) => 0b_10100_0, + (Difficulty.Expert, 0b_11100_0) => 0b_11000_0, + (Difficulty.Hard, 0b_01001_0) => 0b_00110_0, + (Difficulty.Hard, 0b_10010_0) => 0b_01100_0, + (Difficulty.Medium, 0b_00011_0) => 0b_00001_0, + (Difficulty.Medium, 0b_00101_0) => 0b_00010_0, + (Difficulty.Medium, 0b_00110_0) => 0b_00100_0, + (Difficulty.Medium, 0b_01010_0) => 0b_01000_0, + (Difficulty.Medium, 0b_01100_0) => 0b_10000_0, + _ => shape, + }; + } + + private static void ReduceRange(List chords, Difficulty difficulty, bool chordsOnly) + { + int leftMask; + int rightMask; + int shift; + if (difficulty == Difficulty.Medium) + { + leftMask = 0b_00001_0; + rightMask = 0b_10000_0; + shift = 1; + } + else + { + leftMask = 0b_00011_0; + rightMask = 0b_11000_0; + shift = 2; + } + + bool shifted = false; + foreach (var chord in chords) + { + if ((leftMask & chord.Shape) != 0) + { + shifted = false; + } + else if ((rightMask & chord.Shape) != 0) + { + shifted = true; + } + + if (shifted && chord.Shape != 0b_00000_1 && (!chordsOnly || !IsSingleNote(chord.Shape))) + { + chord.Shape >>= shift; + } + } + } + + private static List BuildNotes(List chords, SyncTrack syncTrack) + { + var notes = new List(chords.Count); + GuitarNote? previous = null; + foreach (var chord in chords) + { + var frets = GetFrets(chord.Shape); + if (frets.Count == 0) + { + continue; + } + + var time = syncTrack.TickToTime(chord.Tick); + var timeEnd = syncTrack.TickToTime(chord.TickEnd); + var flags = chord.Flags & ~(NoteFlags.StarPower | NoteFlags.StarPowerStart | + NoteFlags.StarPowerEnd | NoteFlags.Solo | NoteFlags.SoloStart | NoteFlags.SoloEnd | + NoteFlags.Tremolo | NoteFlags.Trill | NoteFlags.LaneStart | NoteFlags.LaneEnd | + NoteFlags.BigRockEnding | NoteFlags.CodaStart | NoteFlags.CodaEnd); + + var parent = new GuitarNote(frets[0], chord.Type, chord.GuitarFlags, flags, + time, timeEnd - time, chord.Tick, chord.TickLength); + for (int i = 1; i < frets.Count; i++) + { + parent.AddChildNote(new GuitarNote(frets[i], chord.Type, chord.GuitarFlags, flags, + time, timeEnd - time, chord.Tick, chord.TickLength)); + } + + parent.PreviousNote = previous; + foreach (var child in parent.ChildNotes) + { + child.PreviousNote = previous; + } + + if (previous != null) + { + previous.NextNote = parent; + foreach (var child in previous.ChildNotes) + { + child.NextNote = parent; + } + } + + notes.Add(parent); + previous = parent; + } + + return notes; + } + + private static void ApplyPhraseFlags(List notes, List phrases) + { + const NoteFlags phraseFlags = NoteFlags.StarPower | NoteFlags.StarPowerStart | + NoteFlags.StarPowerEnd | NoteFlags.Solo | NoteFlags.SoloStart | NoteFlags.SoloEnd | + NoteFlags.BigRockEnding | NoteFlags.CodaStart | NoteFlags.CodaEnd; + foreach (var note in notes) + { + note.Flags &= ~phraseFlags; + foreach (var child in note.ChildNotes) + { + child.Flags &= ~phraseFlags; + } + } + + foreach (var phrase in phrases) + { + NoteFlags activeFlag; + NoteFlags startFlag; + NoteFlags endFlag; + bool inclusiveEnd; + switch (phrase.Type) + { + case PhraseType.StarPower: + activeFlag = NoteFlags.StarPower; + startFlag = NoteFlags.StarPowerStart; + endFlag = NoteFlags.StarPowerEnd; + inclusiveEnd = false; + break; + case PhraseType.Solo: + activeFlag = NoteFlags.Solo; + startFlag = NoteFlags.SoloStart; + endFlag = NoteFlags.SoloEnd; + inclusiveEnd = true; + break; + case PhraseType.BigRockEnding: + activeFlag = NoteFlags.BigRockEnding; + startFlag = NoteFlags.None; + endFlag = NoteFlags.None; + inclusiveEnd = true; + break; + case PhraseType.Coda: + activeFlag = NoteFlags.None; + startFlag = NoteFlags.CodaStart; + endFlag = NoteFlags.CodaEnd; + inclusiveEnd = true; + break; + default: + continue; + } + + var phraseNotes = notes.Where(note => + note.Tick >= phrase.Tick && + (inclusiveEnd ? note.Tick <= phrase.TickEnd : note.Tick < phrase.TickEnd)).ToList(); + if (phraseNotes.Count == 0) + { + continue; + } + + foreach (var note in phraseNotes) + { + ActivateFlagOnChord(note, activeFlag); + } + + ActivateFlagOnChord(phraseNotes[0], startFlag); + ActivateFlagOnChord(phraseNotes[^1], endFlag); + } + } + + private static void ActivateFlagOnChord(GuitarNote note, NoteFlags flag) + { + note.Flags |= flag; + foreach (var child in note.ChildNotes) + { + child.Flags |= flag; + } + } + + private static List GetFrets(int shape) + { + var frets = new List(5); + if ((shape & 1) != 0) + { + frets.Add(FiveFretGuitarFret.Open); + } + + for (int fret = 1; fret <= 5; fret++) + { + if ((shape & (1 << fret)) != 0) + { + frets.Add((FiveFretGuitarFret) fret); + } + } + + return frets; + } + + private static bool IsSingleNote(int shape) + { + return shape > 0 && (shape & (shape - 1)) == 0; + } + + private static int CountFrets(int shape) + { + int count = 0; + while (shape != 0) + { + shape &= shape - 1; + count++; + } + + return count; + } + + private sealed class DownchartChord + { + public double Time; + public double TimeLength; + public uint Tick; + public uint TickLength; + public uint Resolution; + public int Shape; + public GuitarNoteType Type; + public GuitarNoteFlags GuitarFlags; + public NoteFlags Flags; + + public double TimeEnd => Time + TimeLength; + public uint TickEnd => Tick + TickLength; + + public static DownchartChord FromNote(GuitarNote note, uint resolution) + { + int shape = 0; + uint tickEnd = note.TickEnd; + double timeEnd = note.TimeEnd; + var guitarFlags = GuitarNoteFlags.None; + foreach (var gem in note.AllNotes) + { + shape |= gem.Fret == (int) FiveFretGuitarFret.Open ? 1 : 1 << gem.Fret; + tickEnd = Math.Max(tickEnd, gem.TickEnd); + timeEnd = Math.Max(timeEnd, gem.TimeEnd); + guitarFlags |= gem.GuitarFlags; + } + + return new DownchartChord + { + Time = note.Time, + TimeLength = timeEnd - note.Time, + Tick = note.Tick, + TickLength = tickEnd - note.Tick, + Resolution = resolution, + Shape = shape, + Type = note.Type, + GuitarFlags = guitarFlags, + Flags = note.Flags, + }; + } + + public double GetScore() + { + double score = 0; + if (TimeLength >= 0.2) + { + score += 100 * Math.Pow((double) TickLength / Resolution, 2); + } + + if (Shape != 1) + { + score += 100 + CountFrets(Shape) * 100; + } + + score += 100.0 * GreatestCommonDivisor(Tick, Resolution) / Resolution; + return score; + } + + public DownchartChord Clone() + { + return new DownchartChord + { + Time = Time, + TimeLength = TimeLength, + Tick = Tick, + TickLength = TickLength, + Resolution = Resolution, + Shape = Shape, + Type = Type, + GuitarFlags = GuitarFlags, + Flags = Flags, + }; + } + + private static uint GreatestCommonDivisor(uint left, uint right) + { + while (right != 0) + { + (left, right) = (right, left % right); + } + + return left; + } + } + } +} \ No newline at end of file diff --git a/YARG.Core/Chart/AutoGeneration/MidiDownchartExporter.cs b/YARG.Core/Chart/AutoGeneration/MidiDownchartExporter.cs new file mode 100644 index 000000000..c4a12ddf2 --- /dev/null +++ b/YARG.Core/Chart/AutoGeneration/MidiDownchartExporter.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Melanchall.DryWetMidi.Common; +using Melanchall.DryWetMidi.Core; +using MoonscraperChartEditor.Song.IO; +using YARG.Core.Extensions; + +namespace YARG.Core.Chart.AutoGeneration +{ + public sealed class MidiDownchartExportOptions + { + public double Intensity { get; set; } = 1.0; + public bool ReplaceExisting { get; set; } + public IReadOnlyCollection? Instruments { get; set; } + } + + public sealed class MidiDownchartExportResult + { + public MidiFile Midi { get; } + public int GeneratedDifficultyCount { get; } + public int SkippedDifficultyCount { get; } + + internal MidiDownchartExportResult(MidiFile midi, int generatedDifficultyCount, int skippedDifficultyCount) + { + Midi = midi; + GeneratedDifficultyCount = generatedDifficultyCount; + SkippedDifficultyCount = skippedDifficultyCount; + } + } + + public static class MidiDownchartExporter + { + private static readonly Instrument[] AllInstruments = GameMode.FiveFretGuitar.PossibleInstruments(); + + private static readonly Dictionary TrackInstruments = new() + { + { MidIOHelper.GUITAR_TRACK, Instrument.FiveFretGuitar }, + { MidIOHelper.GH1_GUITAR_TRACK, Instrument.FiveFretGuitar }, + { MidIOHelper.GUITAR_COOP_TRACK, Instrument.FiveFretCoopGuitar }, + { MidIOHelper.BASS_TRACK, Instrument.FiveFretBass }, + { MidIOHelper.RHYTHM_TRACK, Instrument.FiveFretRhythm }, + { MidIOHelper.KEYS_TRACK, Instrument.Keys }, + }; + + public static MidiDownchartExportResult Generate( + MidiFile source, + MidiDownchartExportOptions? options = null) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + options ??= new MidiDownchartExportOptions(); + ValidateOptions(options); + + var selectedInstruments = new HashSet(options.Instruments ?? AllInstruments); + var settings = ParseSettings.Default_Midi; + var chart = SongChart.FromMidi(settings, source); + var output = source.Clone(); + + int generatedCount = 0; + int skippedCount = 0; + for (int chunkIndex = 0; chunkIndex < output.Chunks.Count; chunkIndex++) + { + if (output.Chunks[chunkIndex] is not TrackChunk track || + !TrackInstruments.TryGetValue(track.GetTrackName(), out var instrument) || + !selectedInstruments.Contains(instrument)) + { + continue; + } + + var instrumentTrack = chart.GetFiveFretTrack(instrument); + if (!instrumentTrack.TryGetDifficulty(Difficulty.Expert, out var expert) || + expert.Notes.Count == 0) + { + skippedCount += 3; + continue; + } + + var targets = new List(); + foreach (var target in new[] { Difficulty.Hard, Difficulty.Medium, Difficulty.Easy }) + { + if (!options.ReplaceExisting && + instrumentTrack.TryGetDifficulty(target, out var existing) && + !existing.IsGenerated) + { + skippedCount++; + continue; + } + + targets.Add(target); + } + + if (targets.Count > 0) + { + var allGenerated = FiveFretDownchartGenerator.GenerateAll( + expert, chart.SyncTrack, options.Intensity); + var generated = targets.ToDictionary(target => target, target => allGenerated[target]); + generatedCount += generated.Count; + output.Chunks[chunkIndex] = RewriteTrack(track, generated); + } + } + + return new MidiDownchartExportResult(output, generatedCount, skippedCount); + } + + private static void ValidateOptions(MidiDownchartExportOptions options) + { + if (double.IsNaN(options.Intensity) || + double.IsInfinity(options.Intensity) || + options.Intensity < 0 || options.Intensity > 1) + { + throw new ArgumentOutOfRangeException(nameof(options.Intensity), "Intensity must be between 0 and 1."); + } + + if (options.Instruments == null) + { + return; + } + + foreach (var instrument in options.Instruments) + { + if (!AllInstruments.Contains(instrument)) + { + throw new ArgumentException($"Instrument {instrument} is not a supported five-fret instrument.", + nameof(options.Instruments)); + } + } + } + + private static TrackChunk RewriteTrack( + TrackChunk source, + Dictionary> generated) + { + var events = new List(); + long absoluteTick = 0; + int sequence = 0; + foreach (var midiEvent in source.Events) + { + absoluteTick += midiEvent.DeltaTime; + if (ShouldRemove(midiEvent, generated.Keys)) + { + continue; + } + + var clone = midiEvent.Clone(); + clone.DeltaTime = 0; + events.Add(new TimedEvent(absoluteTick, clone, sequence++)); + } + + foreach (var (difficulty, chart) in generated) + { + AddDifficultyEvents(events, chart, difficulty, ref sequence); + } + + events.Sort(CompareEvents); + long previousTick = 0; + foreach (var timedEvent in events) + { + timedEvent.Event.DeltaTime = timedEvent.Tick - previousTick; + previousTick = timedEvent.Tick; + } + + var result = new TrackChunk(); + result.Events.AddRange(events.Select(timed => timed.Event)); + return result; + } + + private static bool ShouldRemove(MidiEvent midiEvent, IEnumerable generated) + { + int noteNumber = midiEvent switch + { + NoteOnEvent noteOn => noteOn.NoteNumber, + NoteOffEvent noteOff => noteOff.NoteNumber, + _ => -1, + }; + + foreach (var difficulty in generated) + { + int baseNote = GetBaseNote(difficulty); + if (noteNumber >= baseNote - 1 && noteNumber <= baseNote + 6) + { + return true; + } + } + + return false; + } + + private static void AddDifficultyEvents( + List events, + InstrumentDifficulty chart, + Difficulty difficulty, + ref int sequence) + { + int baseNote = GetBaseNote(difficulty); + int hopoMarker = baseNote + 5; + int strumMarker = baseNote + 6; + + foreach (var guitarNote in chart.Notes) + { + uint markerLength = Math.Max(guitarNote.TickLength, 1); + foreach (var note in guitarNote.AllNotes) + { + int noteNumber = note.Fret == (int) FiveFretGuitarFret.Open + ? baseNote - 1 + : baseNote + note.Fret - 1; + AddNote(events, note.Tick, Math.Max(note.TickLength, 1), noteNumber, ref sequence); + } + + if (guitarNote.Type == GuitarNoteType.Hopo) + { + AddNote(events, guitarNote.Tick, markerLength, hopoMarker, ref sequence); + } + else if (guitarNote.Type == GuitarNoteType.Strum) + { + AddNote(events, guitarNote.Tick, markerLength, strumMarker, ref sequence); + } + } + } + + private static void AddNote( + List events, + uint tick, + uint length, + int noteNumber, + ref int sequence) + { + events.Add(new TimedEvent(tick, new NoteOnEvent + { + NoteNumber = (SevenBitNumber) noteNumber, + Velocity = (SevenBitNumber) MidIOHelper.VELOCITY, + }, sequence++)); + events.Add(new TimedEvent(tick + length, new NoteOffEvent + { + NoteNumber = (SevenBitNumber) noteNumber, + Velocity = (SevenBitNumber) 0, + }, sequence++)); + } + + private static int GetBaseNote(Difficulty difficulty) + { + return difficulty switch + { + Difficulty.Easy => 60, + Difficulty.Medium => 72, + Difficulty.Hard => 84, + _ => throw new ArgumentOutOfRangeException(nameof(difficulty)), + }; + } + + private static int CompareEvents(TimedEvent left, TimedEvent right) + { + int comparison = left.Tick.CompareTo(right.Tick); + if (comparison != 0) + { + return comparison; + } + + comparison = EventPriority(left.Event).CompareTo(EventPriority(right.Event)); + if (comparison != 0) + { + return comparison; + } + + comparison = GetNoteNumber(left.Event).CompareTo(GetNoteNumber(right.Event)); + return comparison != 0 ? comparison : left.Sequence.CompareTo(right.Sequence); + } + + private static int EventPriority(MidiEvent midiEvent) + { + return midiEvent switch + { + NoteOffEvent => 0, + NoteOnEvent noteOn when noteOn.Velocity == (SevenBitNumber) 0 => 0, + NoteOnEvent => 2, + _ => 1, + }; + } + + private static int GetNoteNumber(MidiEvent midiEvent) + { + return midiEvent switch + { + NoteOnEvent noteOn => noteOn.NoteNumber, + NoteOffEvent noteOff => noteOff.NoteNumber, + _ => -1, + }; + } + + private sealed class TimedEvent + { + public long Tick { get; } + public MidiEvent Event { get; } + public int Sequence { get; } + + public TimedEvent(long tick, MidiEvent midiEvent, int sequence) + { + Tick = tick; + Event = midiEvent; + Sequence = sequence; + } + } + } +} \ No newline at end of file diff --git a/YARG.Core/Chart/SongChart.AutoGeneration.cs b/YARG.Core/Chart/SongChart.AutoGeneration.cs index 7f8bb833e..0cd4d840f 100644 --- a/YARG.Core/Chart/SongChart.AutoGeneration.cs +++ b/YARG.Core/Chart/SongChart.AutoGeneration.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using YARG.Core.Chart.AutoGeneration; using YARG.Core.IO; using YARG.Core.Logging; using YARG.Core.Parsing; @@ -13,6 +14,26 @@ namespace YARG.Core.Chart /// public partial class SongChart { + /// + /// Generates a five-fret difficulty from the instrument's Expert chart. + /// + public InstrumentDifficulty GenerateFiveFretDownchart( + Instrument instrument, + Difficulty targetDifficulty, + double intensity = 1.0) + { + var expert = GetFiveFretTrack(instrument).GetDifficulty(Difficulty.Expert); + return FiveFretDownchartGenerator.Generate(expert, targetDifficulty, SyncTrack, intensity); + } + + private void GenerateFiveFretDowncharts() + { + foreach (var track in FiveFretTracks) + { + FiveFretDownchartGenerator.GenerateMissing(track, SyncTrack); + } + } + private void PostProcessSections() { uint lastTick = GetLastTick(); @@ -528,7 +549,7 @@ public static void LoadLipsyncFromMilo(SongChart songChart, SongEntry songEntry) miloLipsync.Load(); songChart.LipsyncEvents.AddRange(miloLipsync.LipsyncEvents); - + // Generate lipsync from vocals if no lipsync data was found if (songChart.LipsyncEvents.Count == 0) { diff --git a/YARG.Core/Chart/SongChart.cs b/YARG.Core/Chart/SongChart.cs index ec9e14c1c..f21d00f87 100644 --- a/YARG.Core/Chart/SongChart.cs +++ b/YARG.Core/Chart/SongChart.cs @@ -157,6 +157,8 @@ internal SongChart(ISongLoader loader) // Ensure beatlines are present SyncTrack.FinishLoading(GetLastTick()); + // Generate any missing 5 fret charts + GenerateFiveFretDowncharts(); // Use beatlines to place auto-generated drum activation phrases for charts without manually authored phrases CreateDrumActivationPhrases(); // Add range shift phrases, done here since they are parsed from text events diff --git a/YARG.Core/Chart/Tracks/InstrumentDifficulty.cs b/YARG.Core/Chart/Tracks/InstrumentDifficulty.cs index 5544c8f95..74da0aeaa 100644 --- a/YARG.Core/Chart/Tracks/InstrumentDifficulty.cs +++ b/YARG.Core/Chart/Tracks/InstrumentDifficulty.cs @@ -12,6 +12,7 @@ public class InstrumentDifficulty : ICloneable Notes { get; } = new(); public List Phrases { get; } = new(); @@ -28,14 +29,26 @@ public class InstrumentDifficulty : ICloneable Notes.Count == 0 && Phrases.Count == 0 && TextEvents.Count == 0; public InstrumentDifficulty(Instrument instrument, Difficulty difficulty) + : this(instrument, difficulty, false) + { + } + + internal InstrumentDifficulty(Instrument instrument, Difficulty difficulty, bool isGenerated) { Instrument = instrument; Difficulty = difficulty; + IsGenerated = isGenerated; } public InstrumentDifficulty(Instrument instrument, Difficulty difficulty, List notes, List phrases, List text) - : this(instrument, difficulty) + : this(instrument, difficulty, notes, phrases, text, false) + { + } + + internal InstrumentDifficulty(Instrument instrument, Difficulty difficulty, + List notes, List phrases, List text, bool isGenerated) + : this(instrument, difficulty, isGenerated) { Notes = notes; Phrases = phrases; @@ -45,7 +58,14 @@ public InstrumentDifficulty(Instrument instrument, Difficulty difficulty, public InstrumentDifficulty(Instrument instrument, Difficulty difficulty, List notes, List phrases, List text, List shifts) - : this(instrument, difficulty) + : this(instrument, difficulty, notes, phrases, text, shifts, false) + { + } + + internal InstrumentDifficulty(Instrument instrument, Difficulty difficulty, + List notes, List phrases, List text, List shifts, + bool isGenerated) + : this(instrument, difficulty, isGenerated) { Notes = notes; Phrases = phrases; @@ -55,7 +75,7 @@ public InstrumentDifficulty(Instrument instrument, Difficulty difficulty, public InstrumentDifficulty(InstrumentDifficulty other) : this(other.Instrument, other.Difficulty, other.Notes.DuplicateNotes(), other.Phrases.Duplicate(), - other.TextEvents.Duplicate(), other.RangeShiftEvents.Duplicate()) + other.TextEvents.Duplicate(), other.RangeShiftEvents.Duplicate(), other.IsGenerated) { } diff --git a/YARG.Core/Song/Entries/Ini/SongEntry.IniBase.cs b/YARG.Core/Song/Entries/Ini/SongEntry.IniBase.cs index 84d179b96..1b0fe6dae 100644 --- a/YARG.Core/Song/Entries/Ini/SongEntry.IniBase.cs +++ b/YARG.Core/Song/Entries/Ini/SongEntry.IniBase.cs @@ -7,7 +7,6 @@ using YARG.Core.Extensions; using YARG.Core.IO; using YARG.Core.IO.Ini; -using YARG.Core.Logging; namespace YARG.Core.Song { @@ -182,6 +181,7 @@ protected internal static ScanResult ScanChart(IniSubEntry entry, FixedArray ParseMidi(FixedArray file, ref parts.HarmonyVocals.ActivateSubtrack(2); } } + + AddGeneratedFiveFretDifficulties(ref parts); return midiFile.Resolution; } + private protected static void AddGeneratedFiveFretDifficulties(ref AvailableParts parts) + { + AddGeneratedDownchartDifficulties(ref parts.FiveFretGuitar); + AddGeneratedDownchartDifficulties(ref parts.FiveFretBass); + AddGeneratedDownchartDifficulties(ref parts.FiveFretRhythm); + AddGeneratedDownchartDifficulties(ref parts.FiveFretCoopGuitar); + AddGeneratedDownchartDifficulties(ref parts.Keys); + } + + private static void AddGeneratedDownchartDifficulties(ref PartValues part) + { + if ((part.Difficulties & DifficultyMask.Expert) == 0) + { + return; + } + + part.Difficulties |= DifficultyMask.Easy | DifficultyMask.Medium | DifficultyMask.Hard; + } + protected static void FinalizeDrums(ref AvailableParts parts, DrumsType drumsType) { if (!drumsType.Has(DrumsType.FourLane)) @@ -129,4 +150,4 @@ protected static bool IsValid(in AvailableParts parts) || parts.HarmonyVocals.IsActive(); } } -} +} \ No newline at end of file From 188fd8b5e6571ef88ac13e5a9c9f91671a6e4d8c Mon Sep 17 00:00:00 2001 From: Justin LeFebvre Date: Mon, 13 Jul 2026 00:05:53 -0400 Subject: [PATCH 2/3] Add a test with a known midi file for regression --- .../Chart/FiveFretDownchartGeneratorTests.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/YARG.Core.UnitTests/Chart/FiveFretDownchartGeneratorTests.cs b/YARG.Core.UnitTests/Chart/FiveFretDownchartGeneratorTests.cs index 0aad06b87..fbcb4282e 100644 --- a/YARG.Core.UnitTests/Chart/FiveFretDownchartGeneratorTests.cs +++ b/YARG.Core.UnitTests/Chart/FiveFretDownchartGeneratorTests.cs @@ -147,6 +147,31 @@ public void MidiExporter_PreservesExpertAndWritesGeneratedDifficulties() } } + [Test] + public void MidiExporter_DrawntotheflameProducesStableGuitarDowncharts() + { + string projectDirectory = Directory.GetParent(Environment.CurrentDirectory)! + .Parent!.Parent!.Parent!.FullName; + string midiPath = Path.Combine(projectDirectory, "Engine", "Test Charts", "drawntotheflame.mid"); + var source = MidiFile.Read(midiPath); + + var result = MidiDownchartExporter.Generate(source, new MidiDownchartExportOptions + { + ReplaceExisting = true, + Instruments = [Instrument.FiveFretGuitar], + }); + var chart = SongChart.FromMidi(ParseSettings.Default_Midi, result.Midi); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.GeneratedDifficultyCount, Is.EqualTo(3)); + Assert.That(result.SkippedDifficultyCount, Is.Zero); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Hard).Notes, Has.Count.EqualTo(965)); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Medium).Notes, Has.Count.EqualTo(635)); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Easy).Notes, Has.Count.EqualTo(516)); + } + } + private static InstrumentDifficulty MakeExpert(params GuitarNote[] notes) { for (int i = 0; i < notes.Length; i++) From 8a13506b7b761e7e4432306834e81d3c41f8f18c Mon Sep 17 00:00:00 2001 From: Justin LeFebvre Date: Mon, 13 Jul 2026 00:34:31 -0400 Subject: [PATCH 3/3] Only generate down charts when requested --- .../Chart/FiveFretDownchartGeneratorTests.cs | 20 +++++++++++++++-- .../AutoGeneration/MidiDownchartExporter.cs | 3 ++- YARG.Core/Chart/ParsingProperties.cs | 14 ++++++++++++ YARG.Core/Chart/SongChart.UltraStar.cs | 4 ++-- YARG.Core/Chart/SongChart.cs | 21 +++++++++++++----- .../Song/Entries/Ini/SongEntry.IniBase.cs | 3 +-- YARG.Core/Song/Entries/SongEntry.Scanning.cs | 22 +------------------ 7 files changed, 53 insertions(+), 34 deletions(-) diff --git a/YARG.Core.UnitTests/Chart/FiveFretDownchartGeneratorTests.cs b/YARG.Core.UnitTests/Chart/FiveFretDownchartGeneratorTests.cs index fbcb4282e..6cccdbf82 100644 --- a/YARG.Core.UnitTests/Chart/FiveFretDownchartGeneratorTests.cs +++ b/YARG.Core.UnitTests/Chart/FiveFretDownchartGeneratorTests.cs @@ -116,7 +116,9 @@ public void Generate_RejectsInvalidArguments() [Test] public void SongChartFromMidi_GeneratesMissingDifficulties() { - var chart = SongChart.FromMidi(ParseSettings.Default_Midi, MakeExpertMidi()); + var settings = ParseSettings.Default_Midi; + settings.DownchartGeneration = DownchartGenerationMode.MissingDifficulties; + var chart = SongChart.FromMidi(settings, MakeExpertMidi()); using (Assert.EnterMultipleScope()) { @@ -128,6 +130,20 @@ public void SongChartFromMidi_GeneratesMissingDifficulties() } } + [Test] + public void SongChartFromMidi_DoesNotGenerateDifficultiesByDefault() + { + var chart = SongChart.FromMidi(ParseSettings.Default_Midi, MakeExpertMidi()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Expert).Notes, Is.Not.Empty); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Hard).Notes, Is.Empty); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Medium).Notes, Is.Empty); + Assert.That(chart.FiveFretGuitar.GetDifficulty(Difficulty.Easy).Notes, Is.Empty); + } + } + [Test] public void MidiExporter_PreservesExpertAndWritesGeneratedDifficulties() { @@ -269,4 +285,4 @@ private static string GetTrackName(TrackChunk track) { return track.Events.OfType().FirstOrDefault()?.Text ?? ""; } -} \ No newline at end of file +} diff --git a/YARG.Core/Chart/AutoGeneration/MidiDownchartExporter.cs b/YARG.Core/Chart/AutoGeneration/MidiDownchartExporter.cs index c4a12ddf2..8b7c035e9 100644 --- a/YARG.Core/Chart/AutoGeneration/MidiDownchartExporter.cs +++ b/YARG.Core/Chart/AutoGeneration/MidiDownchartExporter.cs @@ -84,6 +84,7 @@ public static MidiDownchartExportResult Generate( { if (!options.ReplaceExisting && instrumentTrack.TryGetDifficulty(target, out var existing) && + existing.Notes.Count > 0 && !existing.IsGenerated) { skippedCount++; @@ -304,4 +305,4 @@ public TimedEvent(long tick, MidiEvent midiEvent, int sequence) } } } -} \ No newline at end of file +} diff --git a/YARG.Core/Chart/ParsingProperties.cs b/YARG.Core/Chart/ParsingProperties.cs index 2249d5f7f..1ebd22010 100644 --- a/YARG.Core/Chart/ParsingProperties.cs +++ b/YARG.Core/Chart/ParsingProperties.cs @@ -27,6 +27,12 @@ public static bool Has(this DrumsType type, DrumsType value) } } + public enum DownchartGenerationMode + { + Disabled, + MissingDifficulties, + } + /// /// Settings used when parsing charts. /// @@ -44,6 +50,7 @@ public struct ParseSettings StarPowerNote = SETTING_DEFAULT, NoteSnapThreshold = 0, TuningOffsetCents = 0, + DownchartGeneration = DownchartGenerationMode.Disabled, }; public static readonly ParseSettings Default_Chart = new() @@ -55,6 +62,7 @@ public struct ParseSettings StarPowerNote = SETTING_DEFAULT, NoteSnapThreshold = 0, TuningOffsetCents = 0, + DownchartGeneration = DownchartGenerationMode.Disabled, }; public static readonly ParseSettings Default_Midi = new() @@ -66,6 +74,7 @@ public struct ParseSettings StarPowerNote = 116, NoteSnapThreshold = 0, TuningOffsetCents = 0, + DownchartGeneration = DownchartGenerationMode.Disabled, }; /// @@ -127,5 +136,10 @@ public struct ParseSettings /// Defaults to 0. Should never go beyond the [-50,50] range, but can still be honored if it does. /// public int TuningOffsetCents; + + /// + /// Controls whether missing difficulties are generated after parsing. + /// + public DownchartGenerationMode DownchartGeneration; } } \ No newline at end of file diff --git a/YARG.Core/Chart/SongChart.UltraStar.cs b/YARG.Core/Chart/SongChart.UltraStar.cs index 896992056..2edeab259 100644 --- a/YARG.Core/Chart/SongChart.UltraStar.cs +++ b/YARG.Core/Chart/SongChart.UltraStar.cs @@ -14,14 +14,14 @@ public partial class SongChart public static SongChart FromUltraStarFile(in ParseSettings settings, string filePath) { var loader = MoonSongLoader.LoadUltraStar(settings, filePath); - return new SongChart(loader); + return FromLoader(loader, settings); } /// Loads a SongChart from raw UltraStar .txt bytes. public static SongChart FromUltraStarBytes(in ParseSettings settings, ReadOnlySpan data) { var loader = MoonSongLoader.LoadUltraStar(settings, data.ToArray()); - return new SongChart(loader); + return FromLoader(loader, settings); } } } diff --git a/YARG.Core/Chart/SongChart.cs b/YARG.Core/Chart/SongChart.cs index f21d00f87..ab76e1e0b 100644 --- a/YARG.Core/Chart/SongChart.cs +++ b/YARG.Core/Chart/SongChart.cs @@ -157,8 +157,6 @@ internal SongChart(ISongLoader loader) // Ensure beatlines are present SyncTrack.FinishLoading(GetLastTick()); - // Generate any missing 5 fret charts - GenerateFiveFretDowncharts(); // Use beatlines to place auto-generated drum activation phrases for charts without manually authored phrases CreateDrumActivationPhrases(); // Add range shift phrases, done here since they are parsed from text events @@ -233,19 +231,30 @@ public void Append(SongChart song) public static SongChart FromFile(in ParseSettings settings, string filePath) { var loader = MoonSongLoader.LoadSong(settings, filePath); - return new(loader); + return FromLoader(loader, settings); } public static SongChart FromMidi(in ParseSettings settings, MidiFile midi) { var loader = MoonSongLoader.LoadMidi(settings, midi); - return new(loader); + return FromLoader(loader, settings); } public static SongChart FromDotChart(in ParseSettings settings, ReadOnlySpan chartText) { var loader = MoonSongLoader.LoadDotChart(settings, chartText); - return new(loader); + return FromLoader(loader, settings); + } + + private static SongChart FromLoader(ISongLoader loader, in ParseSettings settings) + { + var chart = new SongChart(loader); + if (settings.DownchartGeneration == DownchartGenerationMode.MissingDifficulties) + { + chart.GenerateFiveFretDowncharts(); + } + + return chart; } public InstrumentTrack GetFiveFretTrack(Instrument instrument) @@ -575,4 +584,4 @@ static uint VoxMax(IEnumerable tracks) return null; } } -} \ No newline at end of file +} diff --git a/YARG.Core/Song/Entries/Ini/SongEntry.IniBase.cs b/YARG.Core/Song/Entries/Ini/SongEntry.IniBase.cs index 1b0fe6dae..27bc8e520 100644 --- a/YARG.Core/Song/Entries/Ini/SongEntry.IniBase.cs +++ b/YARG.Core/Song/Entries/Ini/SongEntry.IniBase.cs @@ -181,7 +181,6 @@ protected internal static ScanResult ScanChart(IniSubEntry entry, FixedArray ParseMidi(FixedArray file, ref } } - AddGeneratedFiveFretDifficulties(ref parts); return midiFile.Resolution; } - private protected static void AddGeneratedFiveFretDifficulties(ref AvailableParts parts) - { - AddGeneratedDownchartDifficulties(ref parts.FiveFretGuitar); - AddGeneratedDownchartDifficulties(ref parts.FiveFretBass); - AddGeneratedDownchartDifficulties(ref parts.FiveFretRhythm); - AddGeneratedDownchartDifficulties(ref parts.FiveFretCoopGuitar); - AddGeneratedDownchartDifficulties(ref parts.Keys); - } - - private static void AddGeneratedDownchartDifficulties(ref PartValues part) - { - if ((part.Difficulties & DifficultyMask.Expert) == 0) - { - return; - } - - part.Difficulties |= DifficultyMask.Easy | DifficultyMask.Medium | DifficultyMask.Hard; - } - protected static void FinalizeDrums(ref AvailableParts parts, DrumsType drumsType) { if (!drumsType.Has(DrumsType.FourLane)) @@ -150,4 +130,4 @@ protected static bool IsValid(in AvailableParts parts) || parts.HarmonyVocals.IsActive(); } } -} \ No newline at end of file +}