From 77fa52f0304f487ffcaa26a9bd3ff9e030e20737 Mon Sep 17 00:00:00 2001 From: Maxim Dobroselsky Date: Sat, 20 Jul 2024 14:29:13 +0300 Subject: [PATCH] Added PatternBuilder.PianoRoll method --- .../PatternBuilderTests.PianoRoll.cs | 249 ++++++++++++++++++ .../TestUtilities/PatternTestUtilities.cs | 2 +- DryWetMidi/Common/ThrowIfArgument.cs | 6 + .../Composing/PatternBuilder.PianoRoll.cs | 134 ++++++++++ DryWetMidi/Composing/PianoRollSettings.cs | 97 +++++++ 5 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 DryWetMidi.Tests/Composing/PatternBuilder/PatternBuilderTests.PianoRoll.cs create mode 100644 DryWetMidi/Composing/PatternBuilder.PianoRoll.cs create mode 100644 DryWetMidi/Composing/PianoRollSettings.cs diff --git a/DryWetMidi.Tests/Composing/PatternBuilder/PatternBuilderTests.PianoRoll.cs b/DryWetMidi.Tests/Composing/PatternBuilder/PatternBuilderTests.PianoRoll.cs new file mode 100644 index 000000000..ed1c1d604 --- /dev/null +++ b/DryWetMidi.Tests/Composing/PatternBuilder/PatternBuilderTests.PianoRoll.cs @@ -0,0 +1,249 @@ +using Melanchall.DryWetMidi.Common; +using Melanchall.DryWetMidi.Composing; +using Melanchall.DryWetMidi.Interaction; +using Melanchall.DryWetMidi.MusicTheory; +using NUnit.Framework; +using System; +using System.Collections.Generic; + +namespace Melanchall.DryWetMidi.Tests.Composing +{ + [TestFixture] + public sealed partial class PatternBuilderTests + { + #region Test methods + + [Test] + public void PianoRoll_1() + { + var step = MusicalTimeSpan.Sixteenth; + var velocity = (SevenBitNumber)70; + + var pattern = new PatternBuilder() + .SetNoteLength(step) + .SetVelocity(velocity) + .PianoRoll(@"A#5 --|--|--[==]--[....]") + .Build(); + + PatternTestUtilities.TestNotes(pattern, new[] + { + new NoteInfo(NoteName.ASharp, 5, step * 2, step, velocity), + new NoteInfo(NoteName.ASharp, 5, step * 5, step, velocity), + new NoteInfo(NoteName.ASharp, 5, step * 8, step * 4, velocity), + new NoteInfo(NoteName.ASharp, 5, step * 14, step * 6, velocity), + }); + } + + [Test] + public void PianoRoll_2() + { + var step = MusicalTimeSpan.Sixteenth; + var velocity = (SevenBitNumber)90; + + var pattern = new PatternBuilder() + .SetNoteLength(step) + .SetVelocity(velocity) + .PianoRoll(@" + A3 ---- ---| + B2 --|- --|- + G#2 |--- |--| + ") + .Build(); + + PatternTestUtilities.TestNotes(pattern, new[] + { + new NoteInfo(NoteName.GSharp, 2, step * 0, step, velocity), + new NoteInfo(NoteName.B, 2, step * 2, step, velocity), + new NoteInfo(NoteName.GSharp, 2, step * 4, step, velocity), + new NoteInfo(NoteName.B, 2, step * 6, step, velocity), + new NoteInfo(NoteName.A, 3, step * 7, step, velocity), + new NoteInfo(NoteName.GSharp, 2, step * 7, step, velocity), + }); + } + + [Test] + public void PianoRoll_CustomSymbols() + { + var step = MusicalTimeSpan.Sixteenth; + var velocity = (SevenBitNumber)90; + + var pattern = new PatternBuilder() + .SetNoteLength(step) + .SetVelocity(velocity) + .PianoRoll(@" + A3 ---- ---+ <--->--- + B2 --+- --+- --<--->- + G#2 +--- +--+ ---<---> + ", new PianoRollSettings + { + SingleCellNoteSymbol = '+', + MultiCellNoteStartSymbol = '<', + MultiCellNoteEndSymbol = '>', + }) + .Build(); + + PatternTestUtilities.TestNotes(pattern, new[] + { + new NoteInfo(NoteName.GSharp, 2, step * 0, step, velocity), + new NoteInfo(NoteName.B, 2, step * 2, step, velocity), + new NoteInfo(NoteName.GSharp, 2, step * 4, step, velocity), + new NoteInfo(NoteName.B, 2, step * 6, step, velocity), + new NoteInfo(NoteName.A, 3, step * 7, step, velocity), + new NoteInfo(NoteName.GSharp, 2, step * 7, step, velocity), + + new NoteInfo(NoteName.A, 3, step * 8, step * 5, velocity), + new NoteInfo(NoteName.B, 2, step * 10, step * 5, velocity), + new NoteInfo(NoteName.GSharp, 2, step * 11, step * 5, velocity), + }); + } + + [Test] + public void PianoRoll_CustomSymbols_SingleCellNoteSymbolIsSpace() => Assert.Throws( + () => new PatternBuilder().PianoRoll(@"AH3 ----", new PianoRollSettings + { + SingleCellNoteSymbol = ' ', + })); + + [Test] + public void PianoRoll_CustomSymbols_MultiCellNoteStartSymbolIsSpace() => Assert.Throws( + () => new PatternBuilder().PianoRoll(@"AH3 ----", new PianoRollSettings + { + MultiCellNoteStartSymbol = ' ', + })); + + [Test] + public void PianoRoll_CustomSymbols_MultiCellNoteEndSymbolIsSpace() => Assert.Throws( + () => new PatternBuilder().PianoRoll(@"AH3 ----", new PianoRollSettings + { + MultiCellNoteEndSymbol = ' ', + })); + + [Test] + public void PianoRoll_CustomActions() + { + var step = MusicalTimeSpan.Sixteenth; + var velocity = (SevenBitNumber)90; + + var pattern = new PatternBuilder() + .SetNoteLength(step) + .SetVelocity(velocity) + .PianoRoll(@" + B2 --/- --#- + G#2 +--- +--- + ", new PianoRollSettings + { + SingleCellNoteSymbol = '+', + CustomActions = new Dictionary> + { + ['/'] = (note, builder) => builder + .StepBack(MusicalTimeSpan.ThirtySecond) + .Note(note, MusicalTimeSpan.ThirtySecond, (SevenBitNumber)(builder.Velocity * 0.5)) + .Note(note), + ['#'] = (note, builder) => builder + .StepBack(MusicalTimeSpan.ThirtySecond) + .Note(note, new MusicalTimeSpan(1, 64), (SevenBitNumber)70) + .Note(note, new MusicalTimeSpan(1, 64), (SevenBitNumber)50) + .Note(note), + }, + }) + .Build(); + + PatternTestUtilities.TestNotes(pattern, new[] + { + new NoteInfo(NoteName.GSharp, 2, step * 0, step, velocity), + + new NoteInfo(NoteName.B, 2, step * 2 - MusicalTimeSpan.ThirtySecond, MusicalTimeSpan.ThirtySecond, (SevenBitNumber)45), + new NoteInfo(NoteName.B, 2, step * 2, step, velocity), + + new NoteInfo(NoteName.GSharp, 2, step * 4, step, velocity), + + new NoteInfo(NoteName.B, 2, step * 6 - MusicalTimeSpan.ThirtySecond, new MusicalTimeSpan(1, 64), (SevenBitNumber)70), + new NoteInfo(NoteName.B, 2, step * 6 - new MusicalTimeSpan(1, 64), new MusicalTimeSpan(1, 64), (SevenBitNumber)50), + new NoteInfo(NoteName.B, 2, step * 6, step, velocity), + }); + } + + [Test] + public void PianoRoll_CustomActions_ContainsSingleCellNoteSymbol() => Assert.Throws( + () => new PatternBuilder().PianoRoll(@"AH3 ----", new PianoRollSettings + { + CustomActions = new Dictionary> + { + ['|'] = (note, builder) => { }, + } + })); + + [Test] + public void PianoRoll_CustomActions_ContainsMultiCellNoteStartSymbol() => Assert.Throws( + () => new PatternBuilder().PianoRoll(@"AH3 ----", new PianoRollSettings + { + CustomActions = new Dictionary> + { + ['['] = (note, builder) => { }, + } + })); + + [Test] + public void PianoRoll_CustomActions_ContainsMultiCellNoteEndSymbol() => Assert.Throws( + () => new PatternBuilder().PianoRoll(@"AH3 ----", new PianoRollSettings + { + CustomActions = new Dictionary> + { + [']'] = (note, builder) => { }, + } + })); + + [Test] + public void PianoRoll_Repeat() + { + var step = MusicalTimeSpan.Sixteenth; + var velocity = (SevenBitNumber)90; + + var pattern = new PatternBuilder() + .SetNoteLength(step) + .SetVelocity(velocity) + .PianoRoll(@" + A3 ---- ---| + B2 --|- --|- + G#2 |--- |--| + ") + .Repeat(1) + .Build(); + + PatternTestUtilities.TestNotes(pattern, new[] + { + new NoteInfo(NoteName.GSharp, 2, step * 0, step, velocity), + new NoteInfo(NoteName.B, 2, step * 2, step, velocity), + new NoteInfo(NoteName.GSharp, 2, step * 4, step, velocity), + new NoteInfo(NoteName.B, 2, step * 6, step, velocity), + new NoteInfo(NoteName.A, 3, step * 7, step, velocity), + new NoteInfo(NoteName.GSharp, 2, step * 7, step, velocity), + + new NoteInfo(NoteName.GSharp, 2, step * 8, step, velocity), + new NoteInfo(NoteName.B, 2, step * 10, step, velocity), + new NoteInfo(NoteName.GSharp, 2, step * 12, step, velocity), + new NoteInfo(NoteName.B, 2, step * 14, step, velocity), + new NoteInfo(NoteName.A, 3, step * 15, step, velocity), + new NoteInfo(NoteName.GSharp, 2, step * 15, step, velocity), + }); + } + + [Test] + public void PianoRoll_FailedToParseNote() => Assert.Throws( + () => new PatternBuilder().PianoRoll(@"AH3 ----")); + + [Test] + public void PianoRoll_SingleCellNoteInMultiCellOne() => Assert.Throws( + () => new PatternBuilder().PianoRoll(@"A3 -[-|-]")); + + [Test] + public void PianoRoll_NoteStartedWithPreviousNotEnded() => Assert.Throws( + () => new PatternBuilder().PianoRoll(@"A3 -[-[-]]")); + + [Test] + public void PianoRoll_NoteNotStarted() => Assert.Throws( + () => new PatternBuilder().PianoRoll(@"A3 -[]--]--")); + + #endregion + } +} diff --git a/DryWetMidi.Tests/Composing/TestUtilities/PatternTestUtilities.cs b/DryWetMidi.Tests/Composing/TestUtilities/PatternTestUtilities.cs index 5bd03aaa9..5a70d11db 100644 --- a/DryWetMidi.Tests/Composing/TestUtilities/PatternTestUtilities.cs +++ b/DryWetMidi.Tests/Composing/TestUtilities/PatternTestUtilities.cs @@ -40,7 +40,7 @@ public static MidiFile TestNotes(Pattern pattern, ICollection expected var expectedTime = TimeConverter.ConvertFrom(i.Time ?? new MetricTimeSpan(), tempoMap); var expectedLength = LengthConverter.ConvertFrom(i.Length, expectedTime, tempoMap); - return new DryWetMidi.Interaction.Note(i.NoteNumber, expectedLength, expectedTime) + return new Note(i.NoteNumber, expectedLength, expectedTime) { Velocity = i.Velocity, Channel = Channel diff --git a/DryWetMidi/Common/ThrowIfArgument.cs b/DryWetMidi/Common/ThrowIfArgument.cs index 14da4ca02..2a8545438 100644 --- a/DryWetMidi/Common/ThrowIfArgument.cs +++ b/DryWetMidi/Common/ThrowIfArgument.cs @@ -16,6 +16,12 @@ internal static class ThrowIfArgument #region Methods + internal static void IsProhibitedValue(string parameterName, char argument, char invalidValue) + { + if (argument == invalidValue) + throw new ArgumentException($"'{invalidValue}' is the prohibted value for this parameter.", parameterName); + } + internal static void IsNull(string parameterName, object argument) { if (argument == null) diff --git a/DryWetMidi/Composing/PatternBuilder.PianoRoll.cs b/DryWetMidi/Composing/PatternBuilder.PianoRoll.cs new file mode 100644 index 000000000..b40c03268 --- /dev/null +++ b/DryWetMidi/Composing/PatternBuilder.PianoRoll.cs @@ -0,0 +1,134 @@ +using Melanchall.DryWetMidi.Common; +using Melanchall.DryWetMidi.Interaction; +using System; +using System.Linq; + +namespace Melanchall.DryWetMidi.Composing +{ + public sealed partial class PatternBuilder + { + #region Constants + + private static readonly char[] Digits = "0123456789".ToCharArray(); + + #endregion + + #region Methods + + public PatternBuilder PianoRoll( + string pianoRoll, + PianoRollSettings settings = null) + { + ThrowIfArgument.IsNullOrEmptyString(nameof(pianoRoll), pianoRoll, "Piano roll"); + + var pattern = BuildPatternFromPianoRoll( + pianoRoll, + this, + settings ?? new PianoRollSettings()); + + return AddAction(new AddPatternAction(pattern)); + } + + private static Pattern BuildPatternFromPianoRoll( + string pianoRoll, + PatternBuilder parentPatternBuilder, + PianoRollSettings settings) + { + var pianoRollStartSnchor = new object(); + var patternBuilder = new PatternBuilder() + .SetNoteLength(parentPatternBuilder.NoteLength) + .SetStep(parentPatternBuilder.NoteLength) + .SetVelocity(parentPatternBuilder.Velocity) + .Anchor(pianoRollStartSnchor); + + var lines = GetPianoRollLines(pianoRoll); + + foreach (var line in lines) + { + patternBuilder.MoveToLastAnchor(pianoRollStartSnchor); + + int dataStartIndex; + var note = IdentifyLineNote(line, out dataStartIndex); + + ProcessLine(patternBuilder, settings, line, note, dataStartIndex); + } + + return patternBuilder.Build(); + } + + private static MusicTheory.Note IdentifyLineNote( + string line, + out int dataStartIndex) + { + var digitIndex = line.IndexOfAny(Digits); + var notePart = line.Substring(0, digitIndex + 1); + + MusicTheory.Note note; + if (!MusicTheory.Note.TryParse(notePart, out note)) + throw new InvalidOperationException($"Failed to parse note from '{notePart}'."); + + dataStartIndex = digitIndex + 1; + + return note; + } + + private static void ProcessLine( + PatternBuilder patternBuilder, + PianoRollSettings settings, + string line, + MusicTheory.Note note, + int dataStartIndex) + { + var noteStartIndex = 0; + var isNoteBuilding = false; + + for (var i = dataStartIndex; i < line.Length; i++) + { + var symbol = line[i]; + + if (symbol == settings.SingleCellNoteSymbol) + { + if (isNoteBuilding) + throw new InvalidOperationException("Single-cell note can't be placed inside a multi-cell one."); + + patternBuilder.Note(note); + } + else if (symbol == settings.MultiCellNoteStartSymbol) + { + if (isNoteBuilding) + throw new InvalidOperationException("Note can't be started while a previous one is not ended."); + + isNoteBuilding = true; + noteStartIndex = i; + } + else if (symbol == settings.MultiCellNoteEndSymbol) + { + if (!isNoteBuilding) + throw new InvalidOperationException("Note is not started."); + + patternBuilder.Note(note, patternBuilder.NoteLength.Multiply(i - noteStartIndex + 1)); + isNoteBuilding = false; + } + else + { + Action customAction; + if (settings.CustomActions?.TryGetValue(symbol, out customAction) == true) + customAction(note, patternBuilder); + else if (!isNoteBuilding) + patternBuilder.StepForward(); + } + } + } + + private static string[] GetPianoRollLines(string pianoRoll) + { + return pianoRoll + .Split('\n', '\r') + .Select(l => l.Trim().Replace(" ", string.Empty)) + .Where(l => !string.IsNullOrWhiteSpace(l)) + .ToArray(); + } + + #endregion + } +} diff --git a/DryWetMidi/Composing/PianoRollSettings.cs b/DryWetMidi/Composing/PianoRollSettings.cs new file mode 100644 index 000000000..45e74fc49 --- /dev/null +++ b/DryWetMidi/Composing/PianoRollSettings.cs @@ -0,0 +1,97 @@ +using Melanchall.DryWetMidi.Common; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Melanchall.DryWetMidi.Composing +{ + public sealed class PianoRollSettings + { + #region Constants + + private const char ProhibitedSymbol = ' '; + + private const char DefaultSingleCellNoteSymbol = '|'; + private const char DefaultMultiCellNoteStartSymbol = '['; + private const char DefaultMultiCellNoteEndSymbol = ']'; + + #endregion + + #region Fields + + private char _singleCellNoteSymbol = DefaultSingleCellNoteSymbol; + private char _multiCellNoteStartSymbol = DefaultMultiCellNoteStartSymbol; + private char _multiCellNoteEndSymbol = DefaultMultiCellNoteEndSymbol; + + private Dictionary> _customActions; + + #endregion + + #region Properties + + public char SingleCellNoteSymbol + { + get { return _singleCellNoteSymbol; } + set + { + ThrowIfArgument.IsProhibitedValue(nameof(value), value, ProhibitedSymbol); + + _singleCellNoteSymbol = value; + } + } + + public char MultiCellNoteStartSymbol + { + get { return _multiCellNoteStartSymbol; } + set + { + ThrowIfArgument.IsProhibitedValue(nameof(value), value, ProhibitedSymbol); + + _multiCellNoteStartSymbol = value; + } + } + + public char MultiCellNoteEndSymbol + { + get { return _multiCellNoteEndSymbol; } + set + { + ThrowIfArgument.IsProhibitedValue(nameof(value), value, ProhibitedSymbol); + + _multiCellNoteEndSymbol = value; + } + } + + public Dictionary> CustomActions + { + get { return _customActions; } + set + { + if (value != null) + { + ThrowIfArgument.DoesntSatisfyCondition( + nameof(value), + value, + v => !v.Keys.Contains(SingleCellNoteSymbol), + $"Actions keys contain the symbol defined by the {nameof(SingleCellNoteSymbol)} ('{SingleCellNoteSymbol}') property which is prohibited."); + + ThrowIfArgument.DoesntSatisfyCondition( + nameof(value), + value, + v => !v.Keys.Contains(MultiCellNoteStartSymbol), + $"Actions keys contain the symbol defined by the {nameof(MultiCellNoteStartSymbol)} ('{MultiCellNoteStartSymbol}') property which is prohibited."); + + ThrowIfArgument.DoesntSatisfyCondition( + nameof(value), + value, + v => !v.Keys.Contains(MultiCellNoteEndSymbol), + $"Actions keys contain the symbol defined by the {nameof(MultiCellNoteEndSymbol)} ('{MultiCellNoteEndSymbol}') property which is prohibited."); + } + + _customActions = value; + } + } + + #endregion + } +}