diff --git a/Semantics.Music/Accidental.cs b/Semantics.Music/Accidental.cs
new file mode 100644
index 0000000..a86b9f9
--- /dev/null
+++ b/Semantics.Music/Accidental.cs
@@ -0,0 +1,24 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Music;
+
+/// A pitch alteration. The underlying value is the semitone offset.
+public enum Accidental
+{
+ /// Double flat (−2 semitones).
+ DoubleFlat = -2,
+
+ /// Flat (−1 semitone).
+ Flat = -1,
+
+ /// Natural (no alteration).
+ Natural = 0,
+
+ /// Sharp (+1 semitone).
+ Sharp = 1,
+
+ /// Double sharp (+2 semitones).
+ DoubleSharp = 2,
+}
diff --git a/Semantics.Music/Arrangement.cs b/Semantics.Music/Arrangement.cs
index 00d891b..1bda73d 100644
--- a/Semantics.Music/Arrangement.cs
+++ b/Semantics.Music/Arrangement.cs
@@ -6,7 +6,9 @@ namespace ktsu.Semantics.Music;
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
+using System.Text;
/// The ordered realization of a piece: a sequence of sections in performance order.
public sealed record Arrangement
@@ -17,6 +19,18 @@ public sealed record Arrangement
/// Gets the sections in performance order; repetition is expressed by repeated entries.
public IReadOnlyList Sections { get; private init; } = [];
+ /// Determines structural equality: the home key and ordered sections all match.
+ /// The arrangement to compare.
+ /// when the arrangements are value-equal.
+ public bool Equals(Arrangement? other) =>
+ other is not null
+ && Key == other.Key
+ && Sections.SequenceEqual(other.Sections);
+
+ /// Returns a hash code consistent with .
+ /// The hash code.
+ public override int GetHashCode() => (Key, Sections.Count).GetHashCode();
+
/// Gets the total length of the arrangement in bars.
public double TotalBars => Sections.Sum(section => section.Bars);
@@ -41,4 +55,64 @@ public static Arrangement Create(Key key, IEnumerable sections)
return new() { Key = key, Sections = list };
}
+
+ /// Returns the chart: a home-key line then blank-line-separated section blocks.
+ /// The canonical arrangement text.
+ public override string ToString()
+ {
+ StringBuilder sb = new();
+ _ = sb.Append(Key);
+ foreach (Section section in Sections)
+ {
+ _ = sb.Append("\n\n").Append(section);
+ }
+
+ return sb.ToString();
+ }
+
+ /// Parses a chart-style arrangement.
+ /// The arrangement text.
+ /// The parsed arrangement.
+ /// Thrown when is null.
+ /// Thrown when the text cannot be parsed.
+ public static Arrangement Parse(string text)
+ {
+ Ensure.NotNull(text);
+ return TryParse(text, out Arrangement? result)
+ ? result
+ : throw new FormatException($"Invalid arrangement '{text}'.");
+ }
+
+ /// Tries to parse a chart-style arrangement.
+ /// The text to parse.
+ /// The parsed arrangement, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [NotNullWhen(true)] out Arrangement? result)
+ {
+ result = null;
+ if (text is null)
+ {
+ return false;
+ }
+
+ string[] blocks = text.Replace("\r", string.Empty).Split(["\n\n"], System.StringSplitOptions.RemoveEmptyEntries);
+ if (blocks.Length < 2 || !Key.TryParse(blocks[0].Trim(), out Key? key))
+ {
+ return false;
+ }
+
+ List sections = [];
+ for (int i = 1; i < blocks.Length; i++)
+ {
+ if (!Section.TryParse(blocks[i], out Section? section))
+ {
+ return false;
+ }
+
+ sections.Add(section);
+ }
+
+ result = Create(key, sections);
+ return true;
+ }
}
diff --git a/Semantics.Music/Chord.cs b/Semantics.Music/Chord.cs
index 8d9ce71..cec8c1c 100644
--- a/Semantics.Music/Chord.cs
+++ b/Semantics.Music/Chord.cs
@@ -43,26 +43,29 @@ public sealed record Chord
public static Chord Parse(string symbol)
{
Ensure.NotNull(symbol);
- if (symbol.Length == 0)
+ return TryParse(symbol, out Chord? result)
+ ? result
+ : throw new FormatException($"Invalid chord symbol '{symbol}'.");
+ }
+
+ /// Tries to parse a chord symbol.
+ /// The text to parse.
+ /// The parsed chord, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? symbol, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Chord? result)
+ {
+ result = null;
+ if (string.IsNullOrEmpty(symbol))
{
- throw new FormatException("Chord symbol is empty.");
+ return false;
}
- // Split off a slash bass first.
- PitchClass? bass = null;
- string head = symbol;
- int slash = symbol.IndexOf('/');
- if (slash >= 0)
+ if (!TryReadRoot(symbol!, out PitchClass? bass, out string head, out int index, out PitchClass? root))
{
- int bassIndex = 0;
- bass = ParseRoot(symbol[(slash + 1)..], ref bassIndex);
- head = symbol[..slash];
+ return false;
}
- int index = 0;
- PitchClass root = ParseRoot(head, ref index);
string body = new([.. head[index..].Where(c => c is not ('(' or ')'))]);
-
ChordModifiers modifiers = ConsumeModifiers(ref body);
ChordQuality quality = DetermineQuality(body, modifiers.FifthAlteration);
SeventhType seventh = DetermineSeventh(body, quality);
@@ -76,7 +79,7 @@ public static Chord Parse(string symbol)
ChordTension tensions = modifiers.Tensions;
ApplyExtensions(ref body, modifiers.HasAdd9, ref seventh, ref tensions);
- return new Chord
+ result = new Chord
{
Root = root,
Quality = quality,
@@ -86,36 +89,44 @@ public static Chord Parse(string symbol)
Omissions = modifiers.Omissions,
Bass = bass,
};
+ return true;
}
- private static PitchClass ParseRoot(string symbol, ref int index)
+ private static bool TryReadRoot(string symbol, out PitchClass? bass, out string head, out int index, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out PitchClass? root)
{
- if (index >= symbol.Length)
+ bass = null;
+ head = symbol;
+ index = 0;
+ root = null;
+
+ int slash = symbol.IndexOf('/');
+ if (slash >= 0)
{
- throw new FormatException($"Missing chord root in '{symbol}'.");
+ int bassIndex = 0;
+ if (!TryParseRoot(symbol[(slash + 1)..], ref bassIndex, out PitchClass? parsedBass))
+ {
+ return false;
+ }
+
+ bass = parsedBass;
+ head = symbol[..slash];
}
- int letterPc = char.ToUpperInvariant(symbol[index]) switch
- {
- 'C' => 0,
- 'D' => 2,
- 'E' => 4,
- 'F' => 5,
- 'G' => 7,
- 'A' => 9,
- 'B' => 11,
- _ => throw new FormatException($"Invalid chord root in '{symbol}'."),
- };
- index++;
+ return TryParseRoot(head, ref index, out root);
+ }
- int accidental = 0;
- while (index < symbol.Length && (symbol[index] is '#' or 'b' or '♯' or '♭'))
+ private static bool TryParseRoot(string symbol, ref int index, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out PitchClass? root)
+ {
+ root = null;
+ if (index >= symbol.Length || !Notation.TryReadNoteLetter(symbol[index], out NoteLetter letter))
{
- accidental += symbol[index] is '#' or '♯' ? 1 : -1;
- index++;
+ return false;
}
- return PitchClass.Create(letterPc + accidental);
+ index++;
+ int accidental = Notation.ReadAccidentalOffset(symbol, ref index);
+ root = PitchClass.Create((int)letter + accidental);
+ return true;
}
/// The fifth alteration and the upper-structure modifiers consumed from a chord body.
@@ -374,18 +385,143 @@ public IReadOnlyList Voice(int octave, int inversion)
tones.Sort();
}
- Pitch rootPitch = Pitch.FromName(Root.Name + octave.ToString(CultureInfo.InvariantCulture));
+ Pitch rootPitch = Pitch.Parse(Root.Name + octave.ToString(CultureInfo.InvariantCulture));
List pitches = [.. tones.Select(offset => rootPitch.Transpose(offset))];
if (Bass is not null)
{
- Pitch bassPitch = Pitch.FromName(Bass.Name + (octave - 1).ToString(CultureInfo.InvariantCulture));
+ Pitch bassPitch = Pitch.Parse(Bass.Name + (octave - 1).ToString(CultureInfo.InvariantCulture));
pitches.Insert(0, bassPitch);
}
return pitches;
}
+ /// Returns the canonical chord symbol. The formatter is the inverse of over the parseable corpus.
+ /// The canonical chord symbol (e.g. "Cmaj7", "C/G").
+ public override string ToString()
+ {
+ System.Text.StringBuilder sb = new();
+ _ = sb.Append(Root.Name);
+ AppendQualityAndSeventh(sb);
+ AppendSixth(sb);
+ AppendTensions(sb);
+ AppendOmissions(sb);
+ if (Bass is not null)
+ {
+ _ = sb.Append('/').Append(Bass.Name);
+ }
+
+ return sb.ToString();
+ }
+
+ private void AppendQualityAndSeventh(System.Text.StringBuilder sb)
+ {
+ switch (Quality)
+ {
+ case ChordQuality.Sus2:
+ _ = sb.Append("sus2");
+ AppendPlainSeventh(sb);
+ break;
+ case ChordQuality.Sus4:
+ _ = sb.Append("sus4");
+ AppendPlainSeventh(sb);
+ break;
+ case ChordQuality.Power:
+ _ = sb.Append('5');
+ break;
+ case ChordQuality.Augmented:
+ _ = sb.Append("aug");
+ AppendPlainSeventh(sb);
+ break;
+ case ChordQuality.Minor:
+ _ = sb.Append('m');
+ AppendPlainSeventh(sb);
+ break;
+ case ChordQuality.Diminished:
+ AppendDiminished(sb);
+ break;
+ default:
+ AppendPlainSeventh(sb);
+ break;
+ }
+ }
+
+ private void AppendPlainSeventh(System.Text.StringBuilder sb) => _ = sb.Append(Seventh switch
+ {
+ SeventhType.Major => "maj7",
+ SeventhType.Dominant => "7",
+ SeventhType.Diminished => "7",
+ _ => "",
+ });
+
+ private void AppendDiminished(System.Text.StringBuilder sb) => _ = Seventh switch
+ {
+ SeventhType.Diminished => sb.Append("dim7"),
+ SeventhType.Dominant => sb.Append("m7b5"),
+ SeventhType.Major => sb.Append("dimmaj7"),
+ _ => sb.Append("dim"),
+ };
+
+ private void AppendSixth(System.Text.StringBuilder sb) => _ = Sixth switch
+ {
+ SixthType.Natural => sb.Append('6'),
+ SixthType.Flat => sb.Append("b6"),
+ _ => sb,
+ };
+
+ private void AppendTensions(System.Text.StringBuilder sb)
+ {
+ // Natural extension stack: 13 implies 9+11+13, 11 implies 9+11. A bare 9 with no
+ // seventh must be written "add9" so it does not imply a dominant seventh on reparse.
+ bool hasSeventh = Seventh != SeventhType.None;
+ if (Tensions.HasFlag(ChordTension.Thirteen))
+ {
+ _ = sb.Append("13");
+ }
+ else if (Tensions.HasFlag(ChordTension.Eleven))
+ {
+ _ = sb.Append("11");
+ }
+ else if (Tensions.HasFlag(ChordTension.Nine))
+ {
+ _ = sb.Append(hasSeventh ? "9" : "add9");
+ }
+
+ if (Tensions.HasFlag(ChordTension.FlatNine))
+ {
+ _ = sb.Append("b9");
+ }
+
+ if (Tensions.HasFlag(ChordTension.SharpNine))
+ {
+ _ = sb.Append("#9");
+ }
+
+ if (Tensions.HasFlag(ChordTension.SharpEleven))
+ {
+ _ = sb.Append("#11");
+ }
+
+ if (Tensions.HasFlag(ChordTension.FlatThirteen))
+ {
+ _ = sb.Append("b13");
+ }
+ }
+
+ private void AppendOmissions(System.Text.StringBuilder sb)
+ {
+ if (Omissions.HasFlag(ChordOmission.Third))
+ {
+ _ = sb.Append("no3");
+ }
+
+ if (Omissions.HasFlag(ChordOmission.Fifth))
+ {
+ _ = sb.Append("no5");
+ }
+ }
+
private void AddTension(SortedSet offsets, ChordTension flag, int semitones)
{
if (Tensions.HasFlag(flag))
diff --git a/Semantics.Music/ChordEvent.cs b/Semantics.Music/ChordEvent.cs
index a021d9e..819ff4a 100644
--- a/Semantics.Music/ChordEvent.cs
+++ b/Semantics.Music/ChordEvent.cs
@@ -4,6 +4,8 @@
namespace ktsu.Semantics.Music;
+using System;
+
///
/// A harmonic event: a chord sounding for a rhythmic duration (the harmonic rhythm).
///
@@ -26,4 +28,49 @@ public static ChordEvent Create(Chord chord, Duration duration)
Ensure.NotNull(duration);
return new() { Chord = chord, Duration = duration };
}
+
+ /// Parses a chord event "{chord}:{duration}" (e.g. "Cmaj7:1/4").
+ /// The chord-event text.
+ /// The parsed chord event.
+ /// Thrown when is null.
+ /// Thrown when the text cannot be parsed.
+ public static ChordEvent Parse(string text)
+ {
+ Ensure.NotNull(text);
+ return TryParse(text, out ChordEvent? result)
+ ? result
+ : throw new FormatException($"Invalid chord event '{text}'.");
+ }
+
+ /// Tries to parse a chord event "{chord}:{duration}".
+ /// The text to parse.
+ /// The parsed chord event, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ChordEvent? result)
+ {
+ result = null;
+ if (text is null)
+ {
+ return false;
+ }
+
+ int colon = text.IndexOf(':');
+ if (colon <= 0 || colon == text.Length - 1)
+ {
+ return false;
+ }
+
+ if (!Chord.TryParse(text[..colon], out Chord? chord)
+ || !Duration.TryParse(text[(colon + 1)..], out Duration? duration))
+ {
+ return false;
+ }
+
+ result = Create(chord, duration);
+ return true;
+ }
+
+ /// Returns "{chord}:{duration}" (e.g. "Cmaj7:1/4").
+ /// The canonical chord-event text.
+ public override string ToString() => $"{Chord}:{Duration}";
}
diff --git a/Semantics.Music/Duration.cs b/Semantics.Music/Duration.cs
index b31d4b3..71e4a14 100644
--- a/Semantics.Music/Duration.cs
+++ b/Semantics.Music/Duration.cs
@@ -5,6 +5,7 @@
namespace ktsu.Semantics.Music;
using System;
+using System.Globalization;
///
/// A note duration expressed as an exact rational fraction of a whole note.
@@ -74,6 +75,53 @@ public Duration Add(Duration other)
/// The dotted duration.
public Duration Dotted() => Create(Numerator * 3, Denominator * 2);
+ /// Parses a fraction "n/d".
+ /// The fraction text.
+ /// The reduced duration.
+ /// Thrown when is null.
+ /// Thrown when the text is not a valid non-zero-denominator fraction.
+ public static Duration Parse(string text)
+ {
+ Ensure.NotNull(text);
+ return TryParse(text, out Duration? result)
+ ? result
+ : throw new FormatException($"Invalid duration '{text}'.");
+ }
+
+ /// Tries to parse a fraction "n/d".
+ /// The text to parse.
+ /// The reduced duration, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Duration? result)
+ {
+ result = null;
+ if (text is null)
+ {
+ return false;
+ }
+
+ int slash = text.IndexOf('/');
+ if (slash <= 0 || slash == text.Length - 1)
+ {
+ return false;
+ }
+
+ if (!int.TryParse(text[..slash], NumberStyles.Integer | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out int numerator)
+ || !int.TryParse(text[(slash + 1)..], NumberStyles.Integer | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out int denominator)
+ || denominator == 0)
+ {
+ return false;
+ }
+
+ result = Create(numerator, denominator);
+ return true;
+ }
+
+ /// Returns the reduced fraction (e.g. "1/4").
+ /// The canonical duration text.
+ public override string ToString() =>
+ $"{Numerator.ToString(CultureInfo.InvariantCulture)}/{Denominator.ToString(CultureInfo.InvariantCulture)}";
+
private static int Gcd(int a, int b)
{
while (b != 0)
diff --git a/Semantics.Music/Form.cs b/Semantics.Music/Form.cs
index d906d6d..f0c746e 100644
--- a/Semantics.Music/Form.cs
+++ b/Semantics.Music/Form.cs
@@ -28,6 +28,19 @@ public sealed record Form
/// Gets the recognized named form, or null.
public NamedForm? Name { get; private init; }
+ /// Determines structural equality: the pattern, named form, and ordered letters all match.
+ /// The form to compare.
+ /// when the forms are value-equal.
+ public bool Equals(Form? other) =>
+ other is not null
+ && Pattern == other.Pattern
+ && Name == other.Name
+ && Letters.SequenceEqual(other.Letters);
+
+ /// Returns a hash code consistent with .
+ /// The hash code.
+ public override int GetHashCode() => (Pattern, Name).GetHashCode();
+
/// Derives the form of an arrangement from its sections.
/// The arrangement to analyze.
/// The derived form.
@@ -59,27 +72,39 @@ public static Form Of(Arrangement arrangement)
return new() { Pattern = pattern, Letters = letters, Name = name };
}
- /// Builds a form directly from a letter-pattern string, applying letter recognition only.
- /// A pattern of letters A-Z (e.g. "ABACA").
+ /// Parses a form from a letter-pattern string.
+ /// A pattern of upper-case letters A-Z (e.g. "ABACA").
/// The form for the pattern.
/// Thrown when is null.
- /// Thrown when the pattern is empty or contains a non-letter.
- public static Form FromPattern(string pattern)
+ /// Thrown when the pattern is empty or contains a non-upper-case-letter.
+ public static Form Parse(string pattern)
{
Ensure.NotNull(pattern);
- if (pattern.Length == 0)
- {
- throw new FormatException("Form pattern is empty.");
- }
+ return TryParse(pattern, out Form? result)
+ ? result
+ : throw new FormatException($"Invalid form pattern '{pattern}'.");
+ }
- if (!pattern.All(c => c is >= 'A' and <= 'Z'))
+ /// Tries to parse a form from a letter-pattern string.
+ /// The text to parse.
+ /// The parsed form, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? pattern, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Form? result)
+ {
+ result = null;
+ if (pattern is null || pattern.Length == 0 || !pattern.All(c => c is >= 'A' and <= 'Z'))
{
- throw new FormatException($"Form pattern '{pattern}' must contain only upper-case letters.");
+ return false;
}
- return new() { Pattern = pattern, Letters = [.. pattern], Name = RecognizePattern(pattern) };
+ result = new() { Pattern = pattern, Letters = [.. pattern], Name = RecognizePattern(pattern) };
+ return true;
}
+ /// Returns the letter-pattern (e.g. "AABA").
+ /// The canonical form text.
+ public override string ToString() => Pattern;
+
private static NamedForm RecognizePattern(string pattern) => pattern switch
{
"AABA" => NamedForm.ThirtyTwoBarAABA,
diff --git a/Semantics.Music/Interval.cs b/Semantics.Music/Interval.cs
index 58c85c4..aa4e93f 100644
--- a/Semantics.Music/Interval.cs
+++ b/Semantics.Music/Interval.cs
@@ -5,6 +5,7 @@
namespace ktsu.Semantics.Music;
using System;
+using System.Globalization;
///
/// Represents a melodic/harmonic interval measured in signed semitones.
@@ -39,4 +40,37 @@ public static Interval Between(Pitch from, Pitch to)
Ensure.NotNull(to);
return Create(to.Midi - from.Midi);
}
+
+ /// Parses a signed semitone count.
+ /// The signed integer text.
+ /// The parsed interval.
+ /// Thrown when is null.
+ /// Thrown when the text is not an integer.
+ public static Interval Parse(string text)
+ {
+ Ensure.NotNull(text);
+ return TryParse(text, out Interval? result)
+ ? result
+ : throw new FormatException($"Invalid interval '{text}'.");
+ }
+
+ /// Tries to parse a signed semitone count.
+ /// The text to parse.
+ /// The parsed interval, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Interval? result)
+ {
+ if (text is not null && int.TryParse(text, NumberStyles.Integer | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out int semitones))
+ {
+ result = Create(semitones);
+ return true;
+ }
+
+ result = null;
+ return false;
+ }
+
+ /// Returns the signed semitone count.
+ /// The canonical interval text.
+ public override string ToString() => Semitones.ToString(CultureInfo.InvariantCulture);
}
diff --git a/Semantics.Music/Key.cs b/Semantics.Music/Key.cs
index fa748f9..affea0e 100644
--- a/Semantics.Music/Key.cs
+++ b/Semantics.Music/Key.cs
@@ -36,6 +36,51 @@ public static Key Create(PitchClass tonic, Mode mode)
return new() { Tonic = tonic, Mode = mode };
}
+ /// Parses a key "{tonic} {mode}" (e.g. "C major", "A aeolian").
+ /// The key text.
+ /// The parsed key.
+ /// Thrown when is null.
+ /// Thrown when the text cannot be parsed.
+ public static Key Parse(string text)
+ {
+ Ensure.NotNull(text);
+ return TryParse(text, out Key? result)
+ ? result
+ : throw new FormatException($"Invalid key '{text}'.");
+ }
+
+ /// Tries to parse a key "{tonic} {mode}".
+ /// The text to parse.
+ /// The parsed key, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Key? result)
+ {
+ result = null;
+ if (text is null)
+ {
+ return false;
+ }
+
+ int space = text.IndexOf(' ');
+ if (space <= 0 || space == text.Length - 1)
+ {
+ return false;
+ }
+
+ if (!PitchClass.TryParse(text[..space], out PitchClass? tonic)
+ || !Mode.TryParse(text[(space + 1)..], out Mode? mode))
+ {
+ return false;
+ }
+
+ result = Create(tonic, mode);
+ return true;
+ }
+
+ /// Returns "{tonic} {mode}" (e.g. "C major").
+ /// The canonical key text.
+ public override string ToString() => $"{Tonic} {Mode}";
+
/// Returns the key transposed by a number of semitones (the mode is unchanged).
/// The signed semitone offset.
/// The transposed key.
@@ -100,12 +145,7 @@ public Chord ChordFromRomanNumeral(string numeral)
}
int index = 0;
- int alteration = 0;
- while (index < text.Length && (text[index] is 'b' or '#' or '♭' or '♯'))
- {
- alteration += text[index] is '#' or '♯' ? 1 : -1;
- index++;
- }
+ int alteration = Notation.ReadAccidentalOffset(text, ref index);
(int degree, int length, bool upper) = ParseNumeral(text, index);
if (length == 0)
diff --git a/Semantics.Music/Mode.cs b/Semantics.Music/Mode.cs
index a0627d9..9cfa323 100644
--- a/Semantics.Music/Mode.cs
+++ b/Semantics.Music/Mode.cs
@@ -156,16 +156,36 @@ public sealed record Mode
/// The major blues scale (major pentatonic plus the ♭3 blue note).
public static Mode BluesMajor => new() { Name = "blues_major" };
- /// Looks up a mode by name, case-insensitively.
+ /// Parses a mode by name, case-insensitively.
/// The mode name (e.g. "aeolian", "harmonic_minor", "octatonic_hw").
/// The matching mode.
/// Thrown when is null.
- /// Thrown when the name is not a known mode.
- public static Mode FromName(string name)
+ /// Thrown when the name is not a known mode.
+ public static Mode Parse(string name)
{
Ensure.NotNull(name);
- return Shapes.TryGetValue(name, out _)
- ? new() { Name = name.ToLowerInvariant() }
- : throw new ArgumentException($"Unknown mode '{name}'.", nameof(name));
+ return TryParse(name, out Mode? result)
+ ? result
+ : throw new FormatException($"Unknown mode '{name}'.");
}
+
+ /// Tries to parse a mode by name, case-insensitively.
+ /// The mode name.
+ /// The matching mode, or null on failure.
+ /// when the name is a known mode.
+ public static bool TryParse(string? name, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Mode? result)
+ {
+ if (name is not null && Shapes.ContainsKey(name))
+ {
+ result = new() { Name = name.ToLowerInvariant() };
+ return true;
+ }
+
+ result = null;
+ return false;
+ }
+
+ /// Returns the canonical lower-case mode name.
+ /// The mode name.
+ public override string ToString() => Name;
}
diff --git a/Semantics.Music/Notation.cs b/Semantics.Music/Notation.cs
new file mode 100644
index 0000000..12e0b03
--- /dev/null
+++ b/Semantics.Music/Notation.cs
@@ -0,0 +1,47 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Music;
+
+/// Shared note-letter and accidental scanning used by pitch, chord, and roman-numeral parsing.
+internal static class Notation
+{
+ /// Maps a note-letter character (case-insensitive) to a .
+ /// The character to read.
+ /// The parsed note letter, or default on failure.
+ /// when the character is a note letter A-G.
+ internal static bool TryReadNoteLetter(char c, out NoteLetter letter)
+ {
+ NoteLetter? mapped = char.ToUpperInvariant(c) switch
+ {
+ 'C' => NoteLetter.C,
+ 'D' => NoteLetter.D,
+ 'E' => NoteLetter.E,
+ 'F' => NoteLetter.F,
+ 'G' => NoteLetter.G,
+ 'A' => NoteLetter.A,
+ 'B' => NoteLetter.B,
+ _ => null,
+ };
+
+ letter = mapped ?? default;
+ return mapped is not null;
+ }
+
+ /// Consumes any run of accidental characters (# b ♯ ♭) from , returning the net semitone offset.
+ /// The text being scanned.
+ /// The current index; advanced past any accidental characters.
+ /// The net semitone offset of the consumed accidentals.
+ internal static int ReadAccidentalOffset(string text, ref int index)
+ {
+ int offset = 0;
+ while (index < text.Length && (text[index] is '#' or 'b' or '♯' or '♭'))
+ {
+ offset += text[index] is '#' or '♯' ? 1 : -1;
+ index++;
+ }
+
+ return offset;
+ }
+}
diff --git a/Semantics.Music/Note.cs b/Semantics.Music/Note.cs
index 93c7111..eb72d9f 100644
--- a/Semantics.Music/Note.cs
+++ b/Semantics.Music/Note.cs
@@ -4,6 +4,8 @@
namespace ktsu.Semantics.Music;
+using System;
+
///
/// A sounding note: a pitch with a rhythmic duration and a velocity.
///
@@ -41,4 +43,50 @@ public double Seconds(Tempo tempo)
Ensure.NotNull(tempo);
return tempo.Seconds(Duration);
}
+
+ /// Parses a note "{pitch}:{duration}:v{velocity}" (e.g. "C4:1/4:v80").
+ /// The note text.
+ /// The parsed note.
+ /// Thrown when is null.
+ /// Thrown when the text cannot be parsed.
+ public static Note Parse(string text)
+ {
+ Ensure.NotNull(text);
+ return TryParse(text, out Note? result)
+ ? result
+ : throw new FormatException($"Invalid note '{text}'.");
+ }
+
+ /// Tries to parse a note "{pitch}:{duration}:v{velocity}".
+ /// The text to parse.
+ /// The parsed note, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Note? result)
+ {
+ result = null;
+ if (text is null)
+ {
+ return false;
+ }
+
+ string[] parts = text.Split(':');
+ if (parts.Length != 3 || parts[2].Length < 2 || parts[2][0] != 'v')
+ {
+ return false;
+ }
+
+ if (!Pitch.TryParse(parts[0], out Pitch? pitch)
+ || !Duration.TryParse(parts[1], out Duration? duration)
+ || !Velocity.TryParse(parts[2][1..], out Velocity? velocity))
+ {
+ return false;
+ }
+
+ result = Create(pitch, duration, velocity);
+ return true;
+ }
+
+ /// Returns "{pitch}:{duration}:v{velocity}" (e.g. "C4:1/4:v80").
+ /// The canonical note text.
+ public override string ToString() => $"{Pitch}:{Duration}:v{Velocity}";
}
diff --git a/Semantics.Music/NoteLetter.cs b/Semantics.Music/NoteLetter.cs
new file mode 100644
index 0000000..4d86446
--- /dev/null
+++ b/Semantics.Music/NoteLetter.cs
@@ -0,0 +1,30 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Music;
+
+/// The seven natural note letters. The underlying value is the natural pitch class.
+public enum NoteLetter
+{
+ /// C (pitch class 0).
+ C = 0,
+
+ /// D (pitch class 2).
+ D = 2,
+
+ /// E (pitch class 4).
+ E = 4,
+
+ /// F (pitch class 5).
+ F = 5,
+
+ /// G (pitch class 7).
+ G = 7,
+
+ /// A (pitch class 9).
+ A = 9,
+
+ /// B (pitch class 11).
+ B = 11,
+}
diff --git a/Semantics.Music/Pitch.cs b/Semantics.Music/Pitch.cs
index e130230..631f47b 100644
--- a/Semantics.Music/Pitch.cs
+++ b/Semantics.Music/Pitch.cs
@@ -38,45 +38,67 @@ public static Pitch Create(int midi)
return new() { Midi = midi };
}
+ /// Creates a pitch from a note letter, accidental, and octave (MIDI 60 = C4).
+ /// The natural note letter.
+ /// The accidental.
+ /// The octave number.
+ /// A new pitch.
+ /// Thrown when the resulting MIDI number is outside 0..127.
+ public static Pitch Create(NoteLetter letter, Accidental accidental, int octave) =>
+ Create(((octave + 1) * 12) + (int)letter + (int)accidental);
+
/// Parses a note name such as "C4", "F#3", or "Bb5".
- /// The note name: a letter A-G, optional # or b, then an octave integer.
+ /// A letter A-G, optional accidentals, then an octave integer.
/// The parsed pitch.
/// Thrown when is null.
/// Thrown when the name cannot be parsed.
- public static Pitch FromName(string name)
+ public static Pitch Parse(string name)
{
Ensure.NotNull(name);
+ return TryParse(name, out Pitch? result)
+ ? result
+ : throw new FormatException($"Invalid note name '{name}'.");
+ }
- int index = 0;
- int letterPc = char.ToUpperInvariant(name[index]) switch
+ /// Tries to parse a note name.
+ /// The text to parse.
+ /// The parsed pitch, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? name, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Pitch? result)
+ {
+ result = null;
+ if (string.IsNullOrEmpty(name))
{
- 'C' => 0,
- 'D' => 2,
- 'E' => 4,
- 'F' => 5,
- 'G' => 7,
- 'A' => 9,
- 'B' => 11,
- _ => throw new FormatException($"Invalid note letter in '{name}'."),
- };
- index++;
+ return false;
+ }
- int accidental = 0;
- while (index < name.Length && (name[index] is '#' or 'b' or '♯' or '♭'))
+ int index = 0;
+ if (!Notation.TryReadNoteLetter(name![index], out NoteLetter letter))
{
- accidental += name[index] is '#' or '♯' ? 1 : -1;
- index++;
+ return false;
}
+ index++;
+ int accidental = Notation.ReadAccidentalOffset(name, ref index);
if (!int.TryParse(name[index..], NumberStyles.Integer, CultureInfo.InvariantCulture, out int octave))
{
- throw new FormatException($"Missing or invalid octave in '{name}'.");
+ return false;
}
- int midi = ((octave + 1) * 12) + letterPc + accidental;
- return Create(midi);
+ int midi = ((octave + 1) * 12) + (int)letter + accidental;
+ if (midi is < 0 or > 127)
+ {
+ return false;
+ }
+
+ result = new() { Midi = midi };
+ return true;
}
+ /// Returns the canonical note name (e.g. "C4").
+ /// The note name.
+ public override string ToString() => Name;
+
/// Returns a pitch transposed by a number of semitones.
/// The signed semitone offset.
/// The transposed pitch.
diff --git a/Semantics.Music/PitchClass.cs b/Semantics.Music/PitchClass.cs
index 488dfa3..4bf6853 100644
--- a/Semantics.Music/PitchClass.cs
+++ b/Semantics.Music/PitchClass.cs
@@ -4,6 +4,9 @@
namespace ktsu.Semantics.Music;
+using System;
+using System.Diagnostics.CodeAnalysis;
+
///
/// Represents one of the twelve pitch classes (C..B), octave-folded to 0..11.
///
@@ -22,4 +25,57 @@ public sealed record PitchClass
/// Any integer; reduced modulo 12.
/// A pitch class in 0..11.
public static PitchClass Create(int value) => new() { Value = ((value % 12) + 12) % 12 };
+
+ /// Creates a pitch class from a note letter and accidental.
+ /// The natural note letter.
+ /// The accidental.
+ /// The folded pitch class.
+ public static PitchClass Create(NoteLetter letter, Accidental accidental) =>
+ Create((int)letter + (int)accidental);
+
+ /// Parses a pitch-class name such as "C", "F#", or "Bb".
+ /// A note letter A-G with optional accidentals; no octave.
+ /// The parsed pitch class.
+ /// Thrown when is null.
+ /// Thrown when the text cannot be parsed.
+ public static PitchClass Parse(string text)
+ {
+ Ensure.NotNull(text);
+ return TryParse(text, out PitchClass? result)
+ ? result
+ : throw new FormatException($"Invalid pitch class '{text}'.");
+ }
+
+ /// Tries to parse a pitch-class name.
+ /// The text to parse.
+ /// The parsed pitch class, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [NotNullWhen(true)] out PitchClass? result)
+ {
+ result = null;
+ if (string.IsNullOrEmpty(text))
+ {
+ return false;
+ }
+
+ int index = 0;
+ if (!Notation.TryReadNoteLetter(text![index], out NoteLetter letter))
+ {
+ return false;
+ }
+
+ index++;
+ int accidental = Notation.ReadAccidentalOffset(text, ref index);
+ if (index != text.Length)
+ {
+ return false;
+ }
+
+ result = Create((int)letter + accidental);
+ return true;
+ }
+
+ /// Returns the sharp-spelled pitch-class name.
+ /// The canonical name (e.g. "C#").
+ public override string ToString() => Name;
}
diff --git a/Semantics.Music/Progression.Parse.cs b/Semantics.Music/Progression.Parse.cs
index 0ef0c27..f408e97 100644
--- a/Semantics.Music/Progression.Parse.cs
+++ b/Semantics.Music/Progression.Parse.cs
@@ -6,61 +6,170 @@ namespace ktsu.Semantics.Music;
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
public sealed partial record Progression
{
- /// Parses a bar-delimited chord progression in 4/4.
- /// Chord symbols with | as a barline and spaces separating chords in a bar.
- /// The parsed progression.
- /// Thrown when is null.
- /// Thrown when the text is empty or a bar is empty.
- public static Progression Parse(string text) => Parse(text, TimeSignature.Create(4, 4));
+ /// Returns the chart-style progression line, e.g. "4/4 Dm7 / G7 / | Cmaj7 / / /".
+ /// The canonical progression text.
+ public override string ToString()
+ {
+ StringBuilder sb = new();
+ _ = sb.Append(TimeSignature).Append(" ");
+
+ int beatUnit = TimeSignature.BeatUnit;
+ int beatsPerBar = TimeSignature.Beats;
+ int beatCursor = 0;
+ bool first = true;
+
+ foreach (ChordEvent ev in Chords)
+ {
+ if (!first && beatCursor % beatsPerBar == 0)
+ {
+ _ = sb.Append("| ");
+ }
+
+ first = false;
+
+ double beatsExact = ev.Duration.AsWholeNotes * beatUnit;
+ int beats = (int)System.Math.Round(beatsExact);
+ bool wholeBeats = beats >= 1 && System.Math.Abs(beatsExact - beats) < 1e-9;
+
+ if (wholeBeats)
+ {
+ _ = sb.Append(ev.Chord);
+ for (int i = 1; i < beats; i++)
+ {
+ _ = sb.Append(" /");
+ }
- /// Parses a bar-delimited chord progression with an explicit time signature.
- /// Chord symbols with | as a barline and spaces separating chords in a bar.
- /// The time signature; a bar's chords split its bar duration evenly.
+ beatCursor += beats;
+ }
+ else
+ {
+ _ = sb.Append(ev.Chord).Append('@').Append(ev.Duration);
+ beatCursor = 0; // sub-beat durations reset bar tracking (cosmetic only)
+ }
+
+ _ = sb.Append(' ');
+ }
+
+ return sb.ToString().TrimEnd();
+ }
+
+ /// Parses a chart-style progression line.
+ /// The progression text.
/// The parsed progression.
- /// Thrown when an argument is null.
- /// Thrown when the text is empty or a bar is empty.
- public static Progression Parse(string text, TimeSignature timeSignature)
+ /// Thrown when is null.
+ /// Thrown when the text cannot be parsed.
+ public static Progression Parse(string text)
{
Ensure.NotNull(text);
- Ensure.NotNull(timeSignature);
+ return TryParse(text, out Progression? result)
+ ? result
+ : throw new FormatException($"Invalid progression '{text}'.");
+ }
- string trimmed = text.Trim();
- if (trimmed.Length == 0)
+ /// Tries to parse a chart-style progression line.
+ /// The text to parse.
+ /// The parsed progression, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [NotNullWhen(true)] out Progression? result)
+ {
+ result = null;
+ if (string.IsNullOrWhiteSpace(text))
{
- throw new FormatException("Progression is empty.");
+ return false;
}
- // Strip one leading and one trailing barline so "| C | G |" parses cleanly.
- if (trimmed[0] == '|')
+ string[] tokens = text!.Split((char[]?)null, System.StringSplitOptions.RemoveEmptyEntries);
+ if (tokens.Length < 2 || !TimeSignature.TryParse(tokens[0], out TimeSignature? ts))
{
- trimmed = trimmed[1..];
+ return false;
}
- if (trimmed.Length > 0 && trimmed[^1] == '|')
+ if (!TryReadChordEvents(tokens, ts.BeatUnit, out List events))
{
- trimmed = trimmed[..^1];
+ return false;
}
- Duration barDuration = timeSignature.BarDuration;
- List events = [];
- foreach (string bar in trimmed.Split('|'))
+ result = Create(events, ts);
+ return true;
+ }
+
+ // Reads the chord/slash/bar tokens (everything after the leading time signature) into timed events.
+ private static bool TryReadChordEvents(string[] tokens, int beatUnit, out List events)
+ {
+ events = [];
+ Chord? current = null;
+ int currentBeats = 0;
+ Duration? explicitDuration = null;
+
+ for (int i = 1; i < tokens.Length; i++)
{
- string[] tokens = bar.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
- if (tokens.Length == 0)
+ string token = tokens[i];
+ if (token == "|")
+ {
+ continue;
+ }
+
+ if (token == "/")
{
- throw new FormatException("Progression has an empty bar.");
+ if (current is null)
+ {
+ return false;
+ }
+
+ currentBeats++;
+ continue;
}
- Duration each = Duration.Create(barDuration.Numerator, barDuration.Denominator * tokens.Length);
- foreach (string token in tokens)
+ FlushChord(events, ref current, ref currentBeats, ref explicitDuration, beatUnit);
+ if (!TryReadChordCell(token, out current, out explicitDuration, out currentBeats))
{
- events.Add(ChordEvent.Create(Chord.Parse(token), each));
+ return false;
}
}
- return Create(events, timeSignature);
+ FlushChord(events, ref current, ref currentBeats, ref explicitDuration, beatUnit);
+ return events.Count > 0;
+ }
+
+ // Reads one chord token, which is either a bare chord (one beat) or "chord@n/d" (an explicit duration).
+ private static bool TryReadChordCell(string token, out Chord? chord, out Duration? explicitDuration, out int beats)
+ {
+ chord = null;
+ explicitDuration = null;
+ beats = 0;
+
+ int at = token.IndexOf('@');
+ if (at >= 0)
+ {
+ return Chord.TryParse(token[..at], out chord) && Duration.TryParse(token[(at + 1)..], out explicitDuration);
+ }
+
+ if (!Chord.TryParse(token, out chord))
+ {
+ return false;
+ }
+
+ beats = 1;
+ return true;
+ }
+
+ // Appends the pending chord (if any) as an event, then clears the running state.
+ private static void FlushChord(List events, ref Chord? current, ref int currentBeats, ref Duration? explicitDuration, int beatUnit)
+ {
+ if (current is null)
+ {
+ return;
+ }
+
+ Duration duration = explicitDuration ?? Duration.Create(currentBeats, beatUnit);
+ events.Add(ChordEvent.Create(current, duration));
+ current = null;
+ currentBeats = 0;
+ explicitDuration = null;
}
}
diff --git a/Semantics.Music/Progression.cs b/Semantics.Music/Progression.cs
index dd49f87..9489450 100644
--- a/Semantics.Music/Progression.cs
+++ b/Semantics.Music/Progression.cs
@@ -19,6 +19,18 @@ public sealed partial record Progression
/// Gets the time signature used to interpret durations as bars and beats.
public TimeSignature TimeSignature { get; private init; } = TimeSignature.Create(4, 4);
+ /// Determines structural equality: the time signature and ordered chord events all match.
+ /// The progression to compare.
+ /// when the progressions are value-equal.
+ public bool Equals(Progression? other) =>
+ other is not null
+ && TimeSignature == other.TimeSignature
+ && Chords.SequenceEqual(other.Chords);
+
+ /// Returns a hash code consistent with .
+ /// The hash code.
+ public override int GetHashCode() => (TimeSignature, Chords.Count).GetHashCode();
+
/// Gets the total rhythmic length as a fraction of a whole note.
public Duration TotalDuration =>
Chords.Aggregate(Duration.Create(0, 1), (sum, chordEvent) => sum.Add(chordEvent.Duration));
diff --git a/Semantics.Music/README.md b/Semantics.Music/README.md
index 6483173..4de505c 100644
--- a/Semantics.Music/README.md
+++ b/Semantics.Music/README.md
@@ -55,10 +55,11 @@ dotnet add package ktsu.Semantics.Music
```csharp
using ktsu.Semantics.Music;
-Pitch middleC = Pitch.FromName("C4"); // MIDI 60
+Pitch middleC = Pitch.Parse("C4"); // MIDI 60
+Pitch cSharp4 = Pitch.Create(NoteLetter.C, Accidental.Sharp, octave: 4); // type-safe, no parsing
Pitch g4 = middleC.Transpose(7); // a perfect fifth up
Interval fifth = Interval.Between(middleC, g4); // +7 semitones
-double hz = Pitch.FromName("A4").FrequencyHz; // 440.0
+double hz = Pitch.Parse("A4").FrequencyHz; // 440.0
Scale dDorian = Scale.Create(PitchClass.Create(2), Mode.Dorian);
bool hasF = dDorian.Contains(PitchClass.Create(5)); // true
@@ -76,7 +77,7 @@ Chord up = cmaj7.Transpose(2); // Dmaj7
Tempo tempo = Tempo.Create(120.0); // quarter = 120 bpm
double halfNoteSeconds = tempo.Seconds(Duration.Half); // 1.0 s
-Note a4 = Note.Create(Pitch.FromName("A4"), Duration.Quarter, Velocity.Forte);
+Note a4 = Note.Create(Pitch.Parse("A4"), Duration.Quarter, Velocity.Forte);
double noteSeconds = a4.Seconds(tempo); // 0.5 s
```
@@ -96,8 +97,9 @@ Chord five = cMajor.ChordFromRomanNumeral("V7"); // G7
```csharp
using ktsu.Semantics.Music;
-// "|" is a barline; spaces separate chords within a bar
-Progression prog = Progression.Parse("| Dm7 | G7 | Cmaj7 | Cmaj7 |");
+// Chart style: a leading time signature, bars separated by "|", and a beat slash "/"
+// extends the preceding chord by one beat (so "Cmaj7 / / /" is a whole bar of 4/4).
+Progression prog = Progression.Parse("4/4 Dm7 / / / | G7 / / / | Cmaj7 / / / | Cmaj7 / / /");
Key key = prog.InferKey()!; // C major (quality-weighted fit)
IReadOnlyList roman = prog.RomanNumerals(key); // ii7, V7, Imaj7, Imaj7
@@ -106,11 +108,11 @@ IReadOnlyList cadences = prog.Cadences(key); // Authentic at th
// Chromatic analysis: secondary dominants, borrowed chords, Neapolitan
IReadOnlyList chromatic =
- Progression.Parse("| C | A7 | Dm | G7 |").ChromaticChords(key); // A7 -> secondary dominant
+ Progression.Parse("4/4 C / / / | A7 / / / | Dm / / / | G7 / / /").ChromaticChords(key); // A7 -> secondary dominant
// Structure: sections -> arrangement -> form
-Progression verse = Progression.Parse("| C | G | Am | F |");
-Progression bridge = Progression.Parse("| F | C | G | C |");
+Progression verse = Progression.Parse("4/4 C / / / | G / / / | Am / / / | F / / /");
+Progression bridge = Progression.Parse("4/4 F / / / | C / / / | G / / / | C / / /");
Arrangement song = Arrangement.Create(key,
[
Section.Create(SectionType.Verse, verse, "Verse 1"),
@@ -121,33 +123,53 @@ Arrangement song = Arrangement.Create(key,
Form form = song.Form; // Pattern "AABA", Name NamedForm.ThirtyTwoBarAABA
```
+### Type-safe construction and round-trippable text
+
+```csharp
+using ktsu.Semantics.Music;
+
+// Compiler-enforced construction — no strings to mistype
+Pitch p = Pitch.Create(NoteLetter.C, Accidental.Sharp, octave: 4); // C#4
+
+// Every in-scope type has a canonical ToString() and a Parse/TryParse that inverts it
+Chord c = Chord.Parse("Cmaj7");
+Chord same = Chord.Parse(c.ToString()); // c == same
+
+// Progressions read like a lead sheet and round-trip through their chart form
+Progression prog = Progression.Parse("4/4 Dm7 / G7 / | Cmaj7 / / /");
+Progression reparsed = Progression.Parse(prog.ToString()); // prog == reparsed
+
+// TryParse never throws; Parse throws FormatException on malformed text
+bool ok = Interval.TryParse("-5", out Interval? descendingFourth);
+```
+
## API Reference
### Core value types
| Type | Description | Key factories / members |
|------|-------------|-------------------------|
-| `PitchClass` | One of twelve pitch classes, folded to 0..11. | `Create(int)`, `Value`, `Name` |
-| `Pitch` | MIDI pitch 0..127 (60 = C4). | `Create(int)`, `FromName(string)`, `FromFrequency(double)`, `Transpose(int)`, `FrequencyHz`, `Octave` |
-| `Interval` | Signed interval in semitones. | `Create(int)`, `Between(Pitch, Pitch)`, `Semitones`, `Cents`, `Folded` |
-| `Mode` | Scale shape by semitone offsets. | `FromName(string)`, presets (`Major`, `Dorian`, `HarmonicMinor`, `WholeTone`, `MajorPentatonic`, ...), `Intervals` |
-| `Scale` | A mode rooted at a pitch class. | `Create(PitchClass, Mode)`, `Contains`, `DegreeOf`, `Transpose` |
-| `Chord` | Chord parsed from a symbol. | `Parse(string)`, `ChordTones()`, `Voice(octave)`, `Voice(octave, inversion)`, `Transpose` |
-| `Key` | Tonic in a mode, resolves function. | `Create(PitchClass, Mode)`, `RomanNumeralOf(Chord)`, `ChordFromRomanNumeral(string)`, `FunctionOf`, `Scale` |
-| `Duration` | Exact rational fraction of a whole note. | `Create(int, int)`, presets (`Whole`..`Sixteenth`), `Dotted()`, `Add`, `Multiply`, `AsWholeNotes` |
-| `TimeSignature` | Bar and beat lengths. | `Create(int, int)`, `BarDuration`, `BeatDuration` |
-| `Note` / `Rest` / `ChordEvent` | Timed musical events (`IMusicalEvent`). | `Create(...)`, `Duration`, `Seconds(Tempo)` |
-| `Velocity` | MIDI velocity 0..127. | `Create(int)`, presets (`Piano`..`Fortissimo`) |
-| `Tempo` | Beats per minute with a beat unit. | `Create(double)`, `Seconds(Duration)`, `SecondsPerBeat` |
+| `PitchClass` | One of twelve pitch classes, folded to 0..11. | `Create(int)`, `Create(NoteLetter, Accidental)`, `Parse`/`TryParse`, `Value`, `Name` |
+| `Pitch` | MIDI pitch 0..127 (60 = C4). | `Create(int)`, `Create(NoteLetter, Accidental, int)`, `Parse`/`TryParse`, `FromFrequency(double)`, `Transpose(int)`, `FrequencyHz`, `Octave` |
+| `Interval` | Signed interval in semitones. | `Create(int)`, `Between(Pitch, Pitch)`, `Parse`/`TryParse`, `Semitones`, `Cents`, `Folded` |
+| `Mode` | Scale shape by semitone offsets. | `Parse`/`TryParse`, presets (`Major`, `Dorian`, `HarmonicMinor`, `WholeTone`, `MajorPentatonic`, ...), `Intervals` |
+| `Scale` | A mode rooted at a pitch class. | `Create(PitchClass, Mode)`, `Parse`/`TryParse`, `Contains`, `DegreeOf`, `Transpose` |
+| `Chord` | Chord parsed from a symbol. | `Parse`/`TryParse`, `ChordTones()`, `Voice(octave)`, `Voice(octave, inversion)`, `Transpose` |
+| `Key` | Tonic in a mode, resolves function. | `Create(PitchClass, Mode)`, `Parse`/`TryParse`, `RomanNumeralOf(Chord)`, `ChordFromRomanNumeral(string)`, `FunctionOf`, `Scale` |
+| `Duration` | Exact rational fraction of a whole note. | `Create(int, int)`, `Parse`/`TryParse`, presets (`Whole`..`Sixteenth`), `Dotted()`, `Add`, `Multiply`, `AsWholeNotes` |
+| `TimeSignature` | Bar and beat lengths. | `Create(int, int)`, `Parse`/`TryParse`, `BarDuration`, `BeatDuration` |
+| `Note` / `Rest` / `ChordEvent` | Timed musical events (`IMusicalEvent`). | `Create(...)`, `Parse`/`TryParse`, `Duration`, `Seconds(Tempo)` |
+| `Velocity` | MIDI velocity 0..127. | `Create(int)`, `Parse`/`TryParse`, presets (`Piano`..`Fortissimo`) |
+| `Tempo` | Beats per minute with a beat unit. | `Create(double)`, `Parse`/`TryParse`, `Seconds(Duration)`, `SecondsPerBeat` |
### Analysis layer
| Type | Description | Key members |
|------|-------------|-------------|
| `Progression` | Chord sequence with bar-based harmonic rhythm. | `Parse(string)`, `Create(...)`, `RomanNumerals(Key)`, `Functions(Key)`, `Cadences(Key)`, `InferKey()`, `InferKeys()`, `ChromaticChords(Key)` |
-| `Section` | Labeled structural unit. | `Create(SectionType, Progression, label?, key?)`, `IsSameStructure(Section)` |
-| `Arrangement` | Sections in performance order. | `Create(Key, IEnumerable)`, `Form`, `TotalBars` |
-| `Form` | Structural pattern and its name. | `Of(Arrangement)`, `FromPattern(string)`, `Pattern`, `Name` |
+| `Section` | Labeled structural unit. | `Create(SectionType, Progression, label?, key?)`, `Parse`/`TryParse`, `IsSameStructure(Section)` |
+| `Arrangement` | Sections in performance order. | `Create(Key, IEnumerable)`, `Parse`/`TryParse`, `Form`, `TotalBars` |
+| `Form` | Structural pattern and its name. | `Of(Arrangement)`, `Parse`/`TryParse`, `Pattern`, `Name` |
| `HarmonicFunction` | enum: Tonic, Predominant, Dominant, Chromatic. | |
| `CadenceInstance` | A cadence at a resolution index. | `Index`, `Type` (`Cadence` enum: Authentic, Plagal, Half, Deceptive) |
| `ChromaticAnalysis` | A non-diatonic chord classification. | `Index`, `Kind` (`ChromaticKind`), `Detail` |
diff --git a/Semantics.Music/Rest.cs b/Semantics.Music/Rest.cs
index b290e88..ce1e02e 100644
--- a/Semantics.Music/Rest.cs
+++ b/Semantics.Music/Rest.cs
@@ -4,6 +4,8 @@
namespace ktsu.Semantics.Music;
+using System;
+
///
/// A rest: a span of silence with a rhythmic duration.
///
@@ -31,4 +33,42 @@ public double Seconds(Tempo tempo)
Ensure.NotNull(tempo);
return tempo.Seconds(Duration);
}
+
+ /// Parses a rest "R:{duration}" (e.g. "R:1/4").
+ /// The rest text.
+ /// The parsed rest.
+ /// Thrown when is null.
+ /// Thrown when the text cannot be parsed.
+ public static Rest Parse(string text)
+ {
+ Ensure.NotNull(text);
+ return TryParse(text, out Rest? result)
+ ? result
+ : throw new FormatException($"Invalid rest '{text}'.");
+ }
+
+ /// Tries to parse a rest "R:{duration}".
+ /// The text to parse.
+ /// The parsed rest, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Rest? result)
+ {
+ result = null;
+ if (text is null || !text.StartsWith("R:", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (!Duration.TryParse(text[2..], out Duration? duration))
+ {
+ return false;
+ }
+
+ result = Create(duration);
+ return true;
+ }
+
+ /// Returns "R:{duration}" (e.g. "R:1/4").
+ /// The canonical rest text.
+ public override string ToString() => $"R:{Duration}";
}
diff --git a/Semantics.Music/Scale.cs b/Semantics.Music/Scale.cs
index 5b58ad4..2c48611 100644
--- a/Semantics.Music/Scale.cs
+++ b/Semantics.Music/Scale.cs
@@ -104,4 +104,49 @@ public ScaleDegree DegreeOf(PitchClass pitchClass)
? new ScaleDegree(flatDegree, flatAlteration)
: new ScaleDegree(sharpDegree, sharpAlteration);
}
+
+ /// Parses a scale "{root} {mode}" (e.g. "C dorian").
+ /// The scale text.
+ /// The parsed scale.
+ /// Thrown when is null.
+ /// Thrown when the text cannot be parsed.
+ public static Scale Parse(string text)
+ {
+ Ensure.NotNull(text);
+ return TryParse(text, out Scale? result)
+ ? result
+ : throw new FormatException($"Invalid scale '{text}'.");
+ }
+
+ /// Tries to parse a scale "{root} {mode}".
+ /// The text to parse.
+ /// The parsed scale, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Scale? result)
+ {
+ result = null;
+ if (text is null)
+ {
+ return false;
+ }
+
+ int space = text.IndexOf(' ');
+ if (space <= 0 || space == text.Length - 1)
+ {
+ return false;
+ }
+
+ if (!PitchClass.TryParse(text[..space], out PitchClass? root)
+ || !Mode.TryParse(text[(space + 1)..], out Mode? mode))
+ {
+ return false;
+ }
+
+ result = Create(root, mode);
+ return true;
+ }
+
+ /// Returns "{root} {mode}" (e.g. "C dorian").
+ /// The canonical scale text.
+ public override string ToString() => $"{Root} {Mode}";
}
diff --git a/Semantics.Music/Section.cs b/Semantics.Music/Section.cs
index cc5f712..4ef80bf 100644
--- a/Semantics.Music/Section.cs
+++ b/Semantics.Music/Section.cs
@@ -4,7 +4,10 @@
namespace ktsu.Semantics.Music;
+using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
/// A labeled structural unit of a piece: a role plus its harmonic content.
public sealed record Section
@@ -66,4 +69,100 @@ public bool IsSameStructure(Section other)
return true;
}
+
+ /// Returns the chart-style section block: a bracket header then the progression line.
+ /// The canonical section text.
+ public override string ToString()
+ {
+ StringBuilder sb = new();
+ _ = sb.Append('[').Append(Type).Append(']');
+ if (Label is not null)
+ {
+ _ = sb.Append(" \"").Append(Label).Append('"');
+ }
+
+ if (Key is not null)
+ {
+ _ = sb.Append(" (").Append(Key).Append(')');
+ }
+
+ _ = sb.Append('\n').Append(Progression);
+ return sb.ToString();
+ }
+
+ /// Parses a chart-style section block.
+ /// The section text.
+ /// The parsed section.
+ /// Thrown when is null.
+ /// Thrown when the text cannot be parsed.
+ public static Section Parse(string text)
+ {
+ Ensure.NotNull(text);
+ return TryParse(text, out Section? result)
+ ? result
+ : throw new FormatException($"Invalid section '{text}'.");
+ }
+
+ /// Tries to parse a chart-style section block.
+ /// The text to parse.
+ /// The parsed section, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [NotNullWhen(true)] out Section? result)
+ {
+ result = null;
+ if (text is null)
+ {
+ return false;
+ }
+
+ string[] lines = text.Replace("\r", string.Empty).Split('\n');
+ if (lines.Length < 2 || lines[0].Length == 0 || lines[0][0] != '[')
+ {
+ return false;
+ }
+
+ string header = lines[0];
+ int close = header.IndexOf(']');
+ if (close < 1)
+ {
+ return false;
+ }
+
+ if (!Enum.TryParse(header[1..close], out SectionType type))
+ {
+ return false;
+ }
+
+ string? label = null;
+ int quote = header.IndexOf('"', close);
+ if (quote >= 0)
+ {
+ int endQuote = header.IndexOf('"', quote + 1);
+ if (endQuote < 0)
+ {
+ return false;
+ }
+
+ label = header[(quote + 1)..endQuote];
+ }
+
+ Key? key = null;
+ int open = header.IndexOf('(', close);
+ if (open >= 0)
+ {
+ int endParen = header.IndexOf(')', open + 1);
+ if (endParen < 0 || !Key.TryParse(header[(open + 1)..endParen], out key))
+ {
+ return false;
+ }
+ }
+
+ if (!Progression.TryParse(lines[1], out Progression? progression))
+ {
+ return false;
+ }
+
+ result = Create(type, progression, label, key);
+ return true;
+ }
}
diff --git a/Semantics.Music/Tempo.cs b/Semantics.Music/Tempo.cs
index 62afce6..3fb5b0d 100644
--- a/Semantics.Music/Tempo.cs
+++ b/Semantics.Music/Tempo.cs
@@ -5,6 +5,7 @@
namespace ktsu.Semantics.Music;
using System;
+using System.Globalization;
///
/// A tempo in beats per minute, where one beat is a chosen note duration (quarter note by default).
@@ -53,4 +54,51 @@ public double Seconds(Duration duration)
Ensure.NotNull(duration);
return duration.AsWholeNotes / Beat.AsWholeNotes * SecondsPerBeat;
}
+
+ /// Parses a tempo "{bpm}bpm@{beat}" (e.g. "120bpm@1/4").
+ /// The tempo text.
+ /// The parsed tempo.
+ /// Thrown when is null.
+ /// Thrown when the text cannot be parsed.
+ public static Tempo Parse(string text)
+ {
+ Ensure.NotNull(text);
+ return TryParse(text, out Tempo? result)
+ ? result
+ : throw new FormatException($"Invalid tempo '{text}'.");
+ }
+
+ /// Tries to parse a tempo "{bpm}bpm@{beat}".
+ /// The text to parse.
+ /// The parsed tempo, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Tempo? result)
+ {
+ result = null;
+ if (text is null)
+ {
+ return false;
+ }
+
+ int marker = text.IndexOf("bpm@", StringComparison.Ordinal);
+ if (marker <= 0)
+ {
+ return false;
+ }
+
+ if (!double.TryParse(text[..marker], NumberStyles.Float, CultureInfo.InvariantCulture, out double bpm)
+ || bpm <= 0.0
+ || !Duration.TryParse(text[(marker + 4)..], out Duration? beat))
+ {
+ return false;
+ }
+
+ result = Create(bpm, beat);
+ return true;
+ }
+
+ /// Returns "{bpm}bpm@{beat}" (e.g. "120bpm@1/4").
+ /// The canonical tempo text.
+ public override string ToString() =>
+ $"{BeatsPerMinute.ToString("R", CultureInfo.InvariantCulture)}bpm@{Beat}";
}
diff --git a/Semantics.Music/TimeSignature.cs b/Semantics.Music/TimeSignature.cs
index 341821b..78cae74 100644
--- a/Semantics.Music/TimeSignature.cs
+++ b/Semantics.Music/TimeSignature.cs
@@ -5,6 +5,7 @@
namespace ktsu.Semantics.Music;
using System;
+using System.Globalization;
///
/// A musical time signature, with bar and beat lengths in whole-note fractions.
@@ -42,4 +43,51 @@ public static TimeSignature Create(int beats, int beatUnit)
return new() { Beats = beats, BeatUnit = beatUnit };
}
+
+ /// Parses a time signature "beats/unit".
+ /// The time-signature text.
+ /// The parsed time signature.
+ /// Thrown when is null.
+ /// Thrown when the text is not a valid positive "beats/unit".
+ public static TimeSignature Parse(string text)
+ {
+ Ensure.NotNull(text);
+ return TryParse(text, out TimeSignature? result)
+ ? result
+ : throw new FormatException($"Invalid time signature '{text}'.");
+ }
+
+ /// Tries to parse a time signature "beats/unit".
+ /// The text to parse.
+ /// The parsed time signature, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out TimeSignature? result)
+ {
+ result = null;
+ if (text is null)
+ {
+ return false;
+ }
+
+ int slash = text.IndexOf('/');
+ if (slash <= 0 || slash == text.Length - 1)
+ {
+ return false;
+ }
+
+ if (!int.TryParse(text[..slash], NumberStyles.Integer, CultureInfo.InvariantCulture, out int beats)
+ || !int.TryParse(text[(slash + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out int beatUnit)
+ || beats <= 0 || beatUnit <= 0)
+ {
+ return false;
+ }
+
+ result = Create(beats, beatUnit);
+ return true;
+ }
+
+ /// Returns "beats/unit" (e.g. "4/4").
+ /// The canonical time-signature text.
+ public override string ToString() =>
+ $"{Beats.ToString(CultureInfo.InvariantCulture)}/{BeatUnit.ToString(CultureInfo.InvariantCulture)}";
}
diff --git a/Semantics.Music/Velocity.cs b/Semantics.Music/Velocity.cs
index 16dc1fb..640fbe8 100644
--- a/Semantics.Music/Velocity.cs
+++ b/Semantics.Music/Velocity.cs
@@ -5,6 +5,7 @@
namespace ktsu.Semantics.Music;
using System;
+using System.Globalization;
///
/// A MIDI note velocity (0..127), modelling dynamic intensity.
@@ -45,4 +46,39 @@ public static Velocity Create(int value)
return new() { Value = value };
}
+
+ /// Parses a velocity value.
+ /// The integer text (0..127).
+ /// The parsed velocity.
+ /// Thrown when is null.
+ /// Thrown when the text is not an integer in 0..127.
+ public static Velocity Parse(string text)
+ {
+ Ensure.NotNull(text);
+ return TryParse(text, out Velocity? result)
+ ? result
+ : throw new FormatException($"Invalid velocity '{text}'.");
+ }
+
+ /// Tries to parse a velocity value.
+ /// The text to parse.
+ /// The parsed velocity, or null on failure.
+ /// when parsing succeeds.
+ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Velocity? result)
+ {
+ if (text is not null
+ && int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
+ && value is >= 0 and <= 127)
+ {
+ result = Create(value);
+ return true;
+ }
+
+ result = null;
+ return false;
+ }
+
+ /// Returns the velocity value as an integer string.
+ /// The canonical velocity text.
+ public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
}
diff --git a/Semantics.Test/Music/ArrangementRoundTripTests.cs b/Semantics.Test/Music/ArrangementRoundTripTests.cs
new file mode 100644
index 0000000..946d940
--- /dev/null
+++ b/Semantics.Test/Music/ArrangementRoundTripTests.cs
@@ -0,0 +1,38 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Music;
+
+using ktsu.Semantics.Music;
+
+[TestClass]
+public class ArrangementRoundTripTests
+{
+ private static Arrangement Sample()
+ {
+ Key key = Key.Create(PitchClass.Create(NoteLetter.A, Accidental.Natural), Mode.Aeolian);
+ Progression intro = Progression.Create([ChordEvent.Create(Chord.Parse("Am"), Duration.Whole)]);
+ Progression verse = Progression.Create([Chord.Parse("Am7"), Chord.Parse("Dm7")], Duration.Half);
+ return Arrangement.Create(key,
+ [
+ Section.Create(SectionType.Intro, intro),
+ Section.Create(SectionType.Verse, verse, "Verse 1"),
+ ]);
+ }
+
+ [TestMethod]
+ public void ToStringHasKeyLineThenSections()
+ {
+ Assert.AreEqual(
+ "A aeolian\n\n[Intro]\n4/4 Am / / /\n\n[Verse] \"Verse 1\"\n4/4 Am7 / Dm7 /",
+ Sample().ToString());
+ }
+
+ [TestMethod]
+ public void RoundTrip()
+ {
+ Arrangement a = Sample();
+ Assert.AreEqual(a, Arrangement.Parse(a.ToString()));
+ }
+}
diff --git a/Semantics.Test/Music/ArrangementTests.cs b/Semantics.Test/Music/ArrangementTests.cs
index 02ecf62..aef8a52 100644
--- a/Semantics.Test/Music/ArrangementTests.cs
+++ b/Semantics.Test/Music/ArrangementTests.cs
@@ -14,8 +14,8 @@ public class ArrangementTests
[TestMethod]
public void Arrangement_OrdersSectionsAndSumsBars()
{
- Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"));
- Section chorus = Section.Create(SectionType.Chorus, Progression.Parse("F | G | C | C"));
+ Section verse = Section.Create(SectionType.Verse, Progression.Parse("4/4 C / / / | Am / / / | F / / / | G / / /"));
+ Section chorus = Section.Create(SectionType.Chorus, Progression.Parse("4/4 F / / / | G / / / | C / / / | C / / /"));
Arrangement arrangement = Arrangement.Create(CMajor, [verse, chorus, verse]);
Assert.AreEqual(3, arrangement.Sections.Count);
Assert.AreEqual(12.0, arrangement.TotalBars, 1e-9);
@@ -32,5 +32,5 @@ public void Arrangement_RejectsEmpty()
[TestMethod]
public void Arrangement_RejectsNullKey() =>
_ = Assert.ThrowsExactly(
- () => Arrangement.Create(null!, [Section.Create(SectionType.Verse, Progression.Parse("C"))]));
+ () => Arrangement.Create(null!, [Section.Create(SectionType.Verse, Progression.Parse("4/4 C / / /"))]));
}
diff --git a/Semantics.Test/Music/CadenceTests.cs b/Semantics.Test/Music/CadenceTests.cs
index 1015878..dc52c7c 100644
--- a/Semantics.Test/Music/CadenceTests.cs
+++ b/Semantics.Test/Music/CadenceTests.cs
@@ -15,7 +15,7 @@ public class CadenceTests
public void Cadences_DetectsAuthentic()
{
System.Collections.Generic.IReadOnlyList cadences =
- Progression.Parse("G | C").Cadences(CMajor);
+ Progression.Parse("4/4 G | C").Cadences(CMajor);
Assert.AreEqual(1, cadences.Count);
Assert.AreEqual(Cadence.Authentic, cadences[0].Type);
Assert.AreEqual(1, cadences[0].Index);
@@ -24,16 +24,16 @@ public void Cadences_DetectsAuthentic()
[TestMethod]
public void Cadences_DetectsPlagalHalfAndDeceptive()
{
- Assert.AreEqual(Cadence.Plagal, Progression.Parse("F | C").Cadences(CMajor)[0].Type);
- Assert.AreEqual(Cadence.Half, Progression.Parse("C | G").Cadences(CMajor)[0].Type);
- Assert.AreEqual(Cadence.Deceptive, Progression.Parse("G | Am").Cadences(CMajor)[0].Type);
+ Assert.AreEqual(Cadence.Plagal, Progression.Parse("4/4 F | C").Cadences(CMajor)[0].Type);
+ Assert.AreEqual(Cadence.Half, Progression.Parse("4/4 C | G").Cadences(CMajor)[0].Type);
+ Assert.AreEqual(Cadence.Deceptive, Progression.Parse("4/4 G | Am").Cadences(CMajor)[0].Type);
}
[TestMethod]
public void Cadences_ReportsNoneForNonCadentialMotion()
{
System.Collections.Generic.IReadOnlyList cadences =
- Progression.Parse("C | Am").Cadences(CMajor);
+ Progression.Parse("4/4 C | Am").Cadences(CMajor);
Assert.AreEqual(0, cadences.Count);
}
diff --git a/Semantics.Test/Music/ChordEventTests.cs b/Semantics.Test/Music/ChordEventTests.cs
new file mode 100644
index 0000000..b5eb32f
--- /dev/null
+++ b/Semantics.Test/Music/ChordEventTests.cs
@@ -0,0 +1,32 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Music;
+
+using ktsu.Semantics.Music;
+
+[TestClass]
+public class ChordEventTests
+{
+ [TestMethod]
+ public void ToStringIsChordColonDuration()
+ {
+ ChordEvent e = ChordEvent.Create(Chord.Parse("Cmaj7"), Duration.Quarter);
+ Assert.AreEqual("Cmaj7:1/4", e.ToString());
+ }
+
+ [TestMethod]
+ public void RoundTripWithSlashChord()
+ {
+ ChordEvent e = ChordEvent.Create(Chord.Parse("C/G"), Duration.Half);
+ Assert.AreEqual(e, ChordEvent.Parse(e.ToString()));
+ }
+
+ [TestMethod]
+ public void TryParseFailsWithoutColon()
+ {
+ Assert.IsFalse(ChordEvent.TryParse("Cmaj7", out ChordEvent? result));
+ Assert.IsNull(result);
+ }
+}
diff --git a/Semantics.Test/Music/ChordRoundTripTests.cs b/Semantics.Test/Music/ChordRoundTripTests.cs
new file mode 100644
index 0000000..f972138
--- /dev/null
+++ b/Semantics.Test/Music/ChordRoundTripTests.cs
@@ -0,0 +1,38 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Music;
+
+using ktsu.Semantics.Music;
+
+[TestClass]
+public class ChordRoundTripTests
+{
+ private static readonly string[] Corpus =
+ [
+ "C", "Cm", "Cdim", "Caug", "Csus2", "Csus4", "C5",
+ "Cmaj7", "C7", "Cm7", "Cdim7", "Cm7b5", "CmMaj7",
+ "C6", "Cm6", "C9", "Cm9", "C11", "C13",
+ "C7b9", "C7#9", "C7#11", "C7b13", "Cadd9",
+ "C/G", "Dm7/G", "F#m7b5", "Bbmaj7",
+ ];
+
+ [TestMethod]
+ public void CanonicalOutputRoundTrips()
+ {
+ foreach (string symbol in Corpus)
+ {
+ Chord chord = Chord.Parse(symbol);
+ Chord reparsed = Chord.Parse(chord.ToString());
+ Assert.AreEqual(chord, reparsed, $"round-trip failed for '{symbol}' -> '{chord}'");
+ }
+ }
+
+ [TestMethod]
+ public void TryParseReturnsFalseOnEmpty()
+ {
+ Assert.IsFalse(Chord.TryParse("", out Chord? result));
+ Assert.IsNull(result);
+ }
+}
diff --git a/Semantics.Test/Music/ChromaticAnalysisTests.cs b/Semantics.Test/Music/ChromaticAnalysisTests.cs
index 9737265..21314e0 100644
--- a/Semantics.Test/Music/ChromaticAnalysisTests.cs
+++ b/Semantics.Test/Music/ChromaticAnalysisTests.cs
@@ -15,7 +15,7 @@ public class ChromaticAnalysisTests
public void ChromaticChords_SkipsDiatonicChords()
{
System.Collections.Generic.IReadOnlyList analyses =
- Progression.Parse("C | Dm | G7 | C").ChromaticChords(CMajor);
+ Progression.Parse("4/4 C | Dm | G7 | C").ChromaticChords(CMajor);
Assert.AreEqual(0, analyses.Count);
}
@@ -24,7 +24,7 @@ public void ChromaticChords_DetectsSecondaryDominantOfDominant()
{
// D7 in C is V/V (resolving toward G, the dominant).
System.Collections.Generic.IReadOnlyList analyses =
- Progression.Parse("C | D7 | G7 | C").ChromaticChords(CMajor);
+ Progression.Parse("4/4 C | D7 | G7 | C").ChromaticChords(CMajor);
Assert.AreEqual(1, analyses.Count);
Assert.AreEqual(ChromaticKind.SecondaryDominant, analyses[0].Kind);
Assert.AreEqual("V/V", analyses[0].Detail);
@@ -35,7 +35,7 @@ public void ChromaticChords_DetectsSecondaryDominantOfDominant()
public void ChromaticChords_DetectsNeapolitan()
{
System.Collections.Generic.IReadOnlyList analyses =
- Progression.Parse("C | Db | G7").ChromaticChords(CMajor);
+ Progression.Parse("4/4 C | Db | G7").ChromaticChords(CMajor);
Assert.AreEqual(ChromaticKind.Neapolitan, analyses[0].Kind);
Assert.AreEqual("bII", analyses[0].Detail);
}
@@ -45,7 +45,7 @@ public void ChromaticChords_DetectsBorrowedMinorSubdominant()
{
// Fm in C major is iv borrowed from the parallel minor.
System.Collections.Generic.IReadOnlyList analyses =
- Progression.Parse("C | Fm | C").ChromaticChords(CMajor);
+ Progression.Parse("4/4 C | Fm | C").ChromaticChords(CMajor);
Assert.AreEqual(ChromaticKind.BorrowedChord, analyses[0].Kind);
Assert.AreEqual("from parallel minor", analyses[0].Detail);
}
diff --git a/Semantics.Test/Music/DurationTests.cs b/Semantics.Test/Music/DurationTests.cs
index 2a8ea1e..1e2cf8b 100644
--- a/Semantics.Test/Music/DurationTests.cs
+++ b/Semantics.Test/Music/DurationTests.cs
@@ -49,4 +49,27 @@ public void Create_RejectsZeroDenominator()
{
_ = Assert.ThrowsExactly(() => Duration.Create(1, 0));
}
+
+ [TestMethod]
+ public void ToStringIsReducedFraction()
+ {
+ Assert.AreEqual("1/4", Duration.Quarter.ToString());
+ Assert.AreEqual("1/4", Duration.Create(2, 8).ToString());
+ }
+
+ [TestMethod]
+ public void RoundTripOfCanonicalOutput()
+ {
+ Duration d = Duration.Create(2, 8);
+ Assert.AreEqual(d, Duration.Parse(d.ToString()));
+ }
+
+ [TestMethod]
+ public void TryParseFailsOnMissingSlashOrZeroDenominator()
+ {
+ Assert.IsFalse(Duration.TryParse("4", out Duration? a));
+ Assert.IsNull(a);
+ Assert.IsFalse(Duration.TryParse("1/0", out Duration? b));
+ Assert.IsNull(b);
+ }
}
diff --git a/Semantics.Test/Music/EnumInvariantTests.cs b/Semantics.Test/Music/EnumInvariantTests.cs
new file mode 100644
index 0000000..f139ef2
--- /dev/null
+++ b/Semantics.Test/Music/EnumInvariantTests.cs
@@ -0,0 +1,27 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Music;
+
+using ktsu.Semantics.Music;
+
+[TestClass]
+public class EnumInvariantTests
+{
+ [TestMethod]
+ public void NoteLetterValuesAreNaturalPitchClasses()
+ {
+ Assert.AreEqual(0, (int)NoteLetter.C);
+ Assert.AreEqual(4, (int)NoteLetter.E);
+ Assert.AreEqual(11, (int)NoteLetter.B);
+ }
+
+ [TestMethod]
+ public void AccidentalValuesAreSemitoneOffsets()
+ {
+ Assert.AreEqual(-1, (int)Accidental.Flat);
+ Assert.AreEqual(0, (int)Accidental.Natural);
+ Assert.AreEqual(2, (int)Accidental.DoubleSharp);
+ }
+}
diff --git a/Semantics.Test/Music/FormTests.cs b/Semantics.Test/Music/FormTests.cs
index 022e99a..9a09d74 100644
--- a/Semantics.Test/Music/FormTests.cs
+++ b/Semantics.Test/Music/FormTests.cs
@@ -12,7 +12,7 @@ public class FormTests
private static Key CMajor => Key.Create(PitchClass.Create(0), Mode.Major);
private static Section Section(SectionType type, string chords) =>
- ktsu.Semantics.Music.Section.Create(type, Progression.Parse(chords));
+ ktsu.Semantics.Music.Section.Create(type, Progression.Parse("4/4 " + chords));
[TestMethod]
public void Of_ExtractsAABAPattern_AndNamesThirtyTwoBarForm()
@@ -43,14 +43,29 @@ public void Of_RecognizesTwelveBarBlues()
}
[TestMethod]
- public void FromPattern_AppliesLetterRecognition()
+ public void Parse_AppliesLetterRecognition()
{
- Form form = Form.FromPattern("ABACA");
+ Form form = Form.Parse("ABACA");
Assert.AreEqual(NamedForm.Rondo, form.Name);
Assert.AreEqual(5, form.Letters.Count);
}
[TestMethod]
- public void FromPattern_RejectsNonLetters() =>
- _ = Assert.ThrowsExactly(() => Form.FromPattern("A1B"));
+ public void Parse_RejectsNonLetters() =>
+ _ = Assert.ThrowsExactly(() => Form.Parse("A1B"));
+
+ [TestMethod]
+ public void ToStringRoundTripsPattern()
+ {
+ Form form = Form.Parse("AABA");
+ Assert.AreEqual("AABA", form.ToString());
+ Assert.AreEqual(form, Form.Parse(form.ToString()));
+ }
+
+ [TestMethod]
+ public void TryParseFailsOnLowercase()
+ {
+ Assert.IsFalse(Form.TryParse("aaba", out Form? result));
+ Assert.IsNull(result);
+ }
}
diff --git a/Semantics.Test/Music/FrequencyBridgeTests.cs b/Semantics.Test/Music/FrequencyBridgeTests.cs
index c4a4aa8..15fa6bb 100644
--- a/Semantics.Test/Music/FrequencyBridgeTests.cs
+++ b/Semantics.Test/Music/FrequencyBridgeTests.cs
@@ -12,7 +12,7 @@ public class FrequencyBridgeTests
[TestMethod]
public void A4_Is440Hz()
{
- Assert.AreEqual(440.0, Pitch.FromName("A4").FrequencyHz, 1e-9);
+ Assert.AreEqual(440.0, Pitch.Parse("A4").FrequencyHz, 1e-9);
}
[TestMethod]
diff --git a/Semantics.Test/Music/HarmonicFunctionTests.cs b/Semantics.Test/Music/HarmonicFunctionTests.cs
index 47e1025..01508be 100644
--- a/Semantics.Test/Music/HarmonicFunctionTests.cs
+++ b/Semantics.Test/Music/HarmonicFunctionTests.cs
@@ -14,7 +14,7 @@ public class HarmonicFunctionTests
[TestMethod]
public void RomanNumerals_LabelsTwoFiveOne()
{
- Progression progression = Progression.Parse("Dm7 | G7 | Cmaj7");
+ Progression progression = Progression.Parse("4/4 Dm7 | G7 | Cmaj7");
System.Collections.Generic.IReadOnlyList numerals = progression.RomanNumerals(CMajor);
string[] expected = ["ii7", "V7", "Imaj7"];
string[] actual = [.. numerals];
@@ -24,7 +24,7 @@ public void RomanNumerals_LabelsTwoFiveOne()
[TestMethod]
public void Functions_ClassifyDiatonicChords()
{
- Progression progression = Progression.Parse("C | Dm | G | Am");
+ Progression progression = Progression.Parse("4/4 C | Dm | G | Am");
System.Collections.Generic.IReadOnlyList functions = progression.Functions(CMajor);
Assert.AreEqual(HarmonicFunction.Tonic, functions[0]); // I
Assert.AreEqual(HarmonicFunction.Predominant, functions[1]); // ii
@@ -35,7 +35,7 @@ public void Functions_ClassifyDiatonicChords()
[TestMethod]
public void Functions_MarksChromaticRoot()
{
- Progression progression = Progression.Parse("C | Db");
+ Progression progression = Progression.Parse("4/4 C | Db");
System.Collections.Generic.IReadOnlyList functions = progression.Functions(CMajor);
Assert.AreEqual(HarmonicFunction.Chromatic, functions[1]);
}
diff --git a/Semantics.Test/Music/IntervalTests.cs b/Semantics.Test/Music/IntervalTests.cs
index 81ddb17..f4c7fc8 100644
--- a/Semantics.Test/Music/IntervalTests.cs
+++ b/Semantics.Test/Music/IntervalTests.cs
@@ -34,4 +34,28 @@ public void Direction_IsSign()
Assert.AreEqual(-1, Interval.Create(-3).Direction);
Assert.AreEqual(0, Interval.Create(0).Direction);
}
+
+ [TestMethod]
+ public void ToStringIsSignedSemitones()
+ {
+ Assert.AreEqual("7", Interval.Create(7).ToString());
+ Assert.AreEqual("-5", Interval.Create(-5).ToString());
+ }
+
+ [TestMethod]
+ public void RoundTrip()
+ {
+ foreach (int s in new[] { 0, 7, -5, 14 })
+ {
+ Interval i = Interval.Create(s);
+ Assert.AreEqual(i, Interval.Parse(i.ToString()));
+ }
+ }
+
+ [TestMethod]
+ public void TryParseFailsOnGarbage()
+ {
+ Assert.IsFalse(Interval.TryParse("x", out Interval? result));
+ Assert.IsNull(result);
+ }
}
diff --git a/Semantics.Test/Music/KeyInferenceTests.cs b/Semantics.Test/Music/KeyInferenceTests.cs
index a813c69..d1f85a3 100644
--- a/Semantics.Test/Music/KeyInferenceTests.cs
+++ b/Semantics.Test/Music/KeyInferenceTests.cs
@@ -12,7 +12,7 @@ public class KeyInferenceTests
[TestMethod]
public void InferKey_TwoFiveOne_IsCMajor()
{
- Key? key = Progression.Parse("Dm7 | G7 | Cmaj7").InferKey();
+ Key? key = Progression.Parse("4/4 Dm7 | G7 | Cmaj7").InferKey();
Assert.IsNotNull(key);
Assert.AreEqual(0, key.Tonic.Value);
Assert.AreEqual(Mode.Major, key.Mode);
@@ -21,7 +21,7 @@ public void InferKey_TwoFiveOne_IsCMajor()
[TestMethod]
public void InferKey_DiatonicMinorProgression_IsAMinor()
{
- Key? key = Progression.Parse("Am | Dm | Em | Am").InferKey();
+ Key? key = Progression.Parse("4/4 Am | Dm | Em | Am").InferKey();
Assert.IsNotNull(key);
Assert.AreEqual(9, key.Tonic.Value);
Assert.AreEqual(Mode.Aeolian, key.Mode);
@@ -31,7 +31,7 @@ public void InferKey_DiatonicMinorProgression_IsAMinor()
public void InferKeys_RanksBestFirst_WithTwentyFourCandidates()
{
System.Collections.Generic.IReadOnlyList matches =
- Progression.Parse("Dm7 | G7 | Cmaj7").InferKeys();
+ Progression.Parse("4/4 Dm7 | G7 | Cmaj7").InferKeys();
Assert.AreEqual(24, matches.Count);
Assert.IsTrue(matches[0].Score >= matches[1].Score);
}
diff --git a/Semantics.Test/Music/KeyTests.cs b/Semantics.Test/Music/KeyTests.cs
index c55176c..95e6788 100644
--- a/Semantics.Test/Music/KeyTests.cs
+++ b/Semantics.Test/Music/KeyTests.cs
@@ -45,4 +45,25 @@ public void FunctionOf_ReturnsScaleDegree()
Assert.AreEqual(5, fifth.Degree);
Assert.AreEqual(0, fifth.Alteration);
}
+
+ [TestMethod]
+ public void ToStringIsTonicSpaceMode()
+ {
+ Key k = Key.Create(PitchClass.Create(NoteLetter.A, Accidental.Natural), Mode.Aeolian);
+ Assert.AreEqual("A aeolian", k.ToString());
+ }
+
+ [TestMethod]
+ public void RoundTrip()
+ {
+ Key k = Key.Create(PitchClass.Create(NoteLetter.E, Accidental.Flat), Mode.Major);
+ Assert.AreEqual(k, Key.Parse(k.ToString()));
+ }
+
+ [TestMethod]
+ public void TryParseFailsOnUnknownMode()
+ {
+ Assert.IsFalse(Key.TryParse("C bogus", out Key? result));
+ Assert.IsNull(result);
+ }
}
diff --git a/Semantics.Test/Music/ModeTests.cs b/Semantics.Test/Music/ModeTests.cs
index 1f788c3..510e73e 100644
--- a/Semantics.Test/Music/ModeTests.cs
+++ b/Semantics.Test/Music/ModeTests.cs
@@ -24,15 +24,33 @@ public void HarmonicMinor_HasRaisedSeventh()
}
[TestMethod]
- public void FromName_IsCaseInsensitive_AndEqualsStaticInstance()
+ public void Parse_IsCaseInsensitive_AndEqualsStaticInstance()
{
- Assert.AreEqual(Mode.Lydian, Mode.FromName("LYDIAN"));
+ Assert.AreEqual(Mode.Lydian, Mode.Parse("LYDIAN"));
}
[TestMethod]
- public void FromName_RejectsUnknown()
+ public void Parse_RejectsUnknown()
{
- _ = Assert.ThrowsExactly(() => Mode.FromName("bebop"));
+ _ = Assert.ThrowsExactly(() => Mode.Parse("bebop"));
+ }
+
+ [TestMethod]
+ public void ToStringRoundTripsForAllShapes()
+ {
+ foreach (string name in new[] { "major", "dorian", "harmonic_minor", "octatonic_hw", "blues_major" })
+ {
+ Mode mode = Mode.Parse(name);
+ Assert.AreEqual(name, mode.ToString());
+ Assert.AreEqual(mode, Mode.Parse(mode.ToString()));
+ }
+ }
+
+ [TestMethod]
+ public void TryParseFailsOnUnknownMode()
+ {
+ Assert.IsFalse(Mode.TryParse("bogus", out Mode? result));
+ Assert.IsNull(result);
}
[TestMethod]
@@ -80,7 +98,7 @@ public void Altered_IsSuperLocrian()
{
int[] expected = [0, 1, 3, 4, 6, 8, 10];
CollectionAssert.AreEqual(expected, Mode.Altered.Intervals.ToArray());
- Assert.AreEqual(Mode.Altered, Mode.FromName("altered"));
+ Assert.AreEqual(Mode.Altered, Mode.Parse("altered"));
}
[TestMethod]
diff --git a/Semantics.Test/Music/NoteTests.cs b/Semantics.Test/Music/NoteTests.cs
new file mode 100644
index 0000000..e47f56d
--- /dev/null
+++ b/Semantics.Test/Music/NoteTests.cs
@@ -0,0 +1,32 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Music;
+
+using ktsu.Semantics.Music;
+
+[TestClass]
+public class NoteTests
+{
+ [TestMethod]
+ public void ToStringEncodesPitchDurationVelocity()
+ {
+ Note n = Note.Create(Pitch.Parse("C4"), Duration.Quarter, Velocity.Create(80));
+ Assert.AreEqual("C4:1/4:v80", n.ToString());
+ }
+
+ [TestMethod]
+ public void RoundTrip()
+ {
+ Note n = Note.Create(Pitch.Parse("F#3"), Duration.Eighth, Velocity.Create(100));
+ Assert.AreEqual(n, Note.Parse(n.ToString()));
+ }
+
+ [TestMethod]
+ public void TryParseFailsOnMissingVelocityMarker()
+ {
+ Assert.IsFalse(Note.TryParse("C4:1/4:80", out Note? result));
+ Assert.IsNull(result);
+ }
+}
diff --git a/Semantics.Test/Music/PitchClassTests.cs b/Semantics.Test/Music/PitchClassTests.cs
index 99c1720..7d9405f 100644
--- a/Semantics.Test/Music/PitchClassTests.cs
+++ b/Semantics.Test/Music/PitchClassTests.cs
@@ -30,4 +30,43 @@ public void Equality_IsByValue()
{
Assert.AreEqual(PitchClass.Create(13), PitchClass.Create(1));
}
+
+ [TestMethod]
+ public void TypedCreateComposesLetterAndAccidental()
+ {
+ PitchClass cSharp = PitchClass.Create(NoteLetter.C, Accidental.Sharp);
+ Assert.AreEqual(1, cSharp.Value);
+ }
+
+ [TestMethod]
+ public void ToStringIsSharpSpelled()
+ {
+ Assert.AreEqual("C#", PitchClass.Create(NoteLetter.C, Accidental.Sharp).ToString());
+ }
+
+ [TestMethod]
+ public void ParseAcceptsFlatsAndNormalisesToSharp()
+ {
+ PitchClass dFlat = PitchClass.Parse("Db");
+ Assert.AreEqual(1, dFlat.Value);
+ Assert.AreEqual("C#", dFlat.ToString());
+ }
+
+ [TestMethod]
+ public void RoundTripOfCanonicalOutput()
+ {
+ PitchClass parsed = PitchClass.Parse("Db");
+ Assert.AreEqual(parsed, PitchClass.Parse(parsed.ToString()));
+ }
+
+ [TestMethod]
+ public void ParseThrowsFormatExceptionOnGarbage() =>
+ _ = Assert.ThrowsExactly(() => PitchClass.Parse("H"));
+
+ [TestMethod]
+ public void TryParseReturnsFalseOnGarbage()
+ {
+ Assert.IsFalse(PitchClass.TryParse("H", out PitchClass? result));
+ Assert.IsNull(result);
+ }
}
diff --git a/Semantics.Test/Music/PitchTests.cs b/Semantics.Test/Music/PitchTests.cs
index 8397068..7566b9d 100644
--- a/Semantics.Test/Music/PitchTests.cs
+++ b/Semantics.Test/Music/PitchTests.cs
@@ -26,12 +26,12 @@ public void Create_RejectsOutOfRange()
}
[TestMethod]
- public void FromName_ParsesSharpsAndFlats()
+ public void Parse_ParsesSharpsAndFlats()
{
- Assert.AreEqual(60, Pitch.FromName("C4").Midi);
- Assert.AreEqual(61, Pitch.FromName("C#4").Midi);
- Assert.AreEqual(61, Pitch.FromName("Db4").Midi);
- Assert.AreEqual(58, Pitch.FromName("Bb3").Midi);
+ Assert.AreEqual(60, Pitch.Parse("C4").Midi);
+ Assert.AreEqual(61, Pitch.Parse("C#4").Midi);
+ Assert.AreEqual(61, Pitch.Parse("Db4").Midi);
+ Assert.AreEqual(58, Pitch.Parse("Bb3").Midi);
}
[TestMethod]
@@ -40,4 +40,23 @@ public void Transpose_MovesBySemitones()
Assert.AreEqual(67, Pitch.Create(60).Transpose(7).Midi);
Assert.AreEqual(53, Pitch.Create(60).Transpose(-7).Midi);
}
+
+ [TestMethod]
+ public void TypedCreateMatchesParse() =>
+ Assert.AreEqual(Pitch.Parse("C#4"), Pitch.Create(NoteLetter.C, Accidental.Sharp, 4));
+
+ [TestMethod]
+ public void ToStringRoundTrips()
+ {
+ Pitch p = Pitch.Parse("F#3");
+ Assert.AreEqual("F#3", p.ToString());
+ Assert.AreEqual(p, Pitch.Parse(p.ToString()));
+ }
+
+ [TestMethod]
+ public void TryParseFailsOnBadOctave()
+ {
+ Assert.IsFalse(Pitch.TryParse("Cx", out Pitch? result));
+ Assert.IsNull(result);
+ }
}
diff --git a/Semantics.Test/Music/ProgressionRoundTripTests.cs b/Semantics.Test/Music/ProgressionRoundTripTests.cs
new file mode 100644
index 0000000..b3c99ea
--- /dev/null
+++ b/Semantics.Test/Music/ProgressionRoundTripTests.cs
@@ -0,0 +1,38 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Music;
+
+using ktsu.Semantics.Music;
+
+[TestClass]
+public class ProgressionRoundTripTests
+{
+ [TestMethod]
+ public void ToStringRendersBeatSlashesAndBarLines()
+ {
+ Progression p = Progression.Create(
+ [
+ ChordEvent.Create(Chord.Parse("Dm7"), Duration.Half),
+ ChordEvent.Create(Chord.Parse("G7"), Duration.Half),
+ ChordEvent.Create(Chord.Parse("Cmaj7"), Duration.Whole),
+ ]);
+ Assert.AreEqual("4/4 Dm7 / G7 / | Cmaj7 / / /", p.ToString());
+ }
+
+ [TestMethod]
+ public void RoundTripWholeBeatDurations()
+ {
+ Progression p = Progression.Create([Chord.Parse("Am7"), Chord.Parse("D7")], Duration.Quarter);
+ Assert.AreEqual(p, Progression.Parse(p.ToString()));
+ }
+
+ [TestMethod]
+ public void RoundTripSubBeatDurationUsesEscape()
+ {
+ Progression p = Progression.Create([ChordEvent.Create(Chord.Parse("C"), Duration.Eighth)]);
+ Assert.AreEqual("4/4 C@1/8", p.ToString());
+ Assert.AreEqual(p, Progression.Parse(p.ToString()));
+ }
+}
diff --git a/Semantics.Test/Music/ProgressionTests.cs b/Semantics.Test/Music/ProgressionTests.cs
index a0b9d3b..1db61d9 100644
--- a/Semantics.Test/Music/ProgressionTests.cs
+++ b/Semantics.Test/Music/ProgressionTests.cs
@@ -51,33 +51,33 @@ public void Progression_RejectsEmpty()
}
[TestMethod]
- public void Parse_OneChordPerBar_FillsWholeBars()
+ public void Parse_WholeBarChords_UseBeatSlashes()
{
- Progression progression = Progression.Parse("Dm7 | G7 | Cmaj7");
+ Progression progression = Progression.Parse("4/4 Dm7 / / / | G7 / / / | Cmaj7 / / /");
Assert.AreEqual(3, progression.Chords.Count);
Assert.AreEqual(Duration.Whole, progression.Chords[0].Duration);
Assert.AreEqual(3.0, progression.TotalBars, 1e-9);
}
[TestMethod]
- public void Parse_TwoChordsPerBar_SplitBarEvenly()
+ public void Parse_TwoChordsPerBar_UseBeatSlashes()
{
- Progression progression = Progression.Parse("C G | Am F");
+ Progression progression = Progression.Parse("4/4 C / Am / | F / G /");
Assert.AreEqual(4, progression.Chords.Count);
Assert.AreEqual(Duration.Half, progression.Chords[0].Duration);
Assert.AreEqual(2.0, progression.TotalBars, 1e-9);
}
[TestMethod]
- public void Parse_ToleratesLeadingAndTrailingBarlines()
+ public void Parse_ToleratesBarlines()
{
- Progression progression = Progression.Parse("| C | G |");
+ Progression progression = Progression.Parse("4/4 | C | G |");
Assert.AreEqual(2, progression.Chords.Count);
}
[TestMethod]
- public void Parse_RejectsEmptyBar() =>
- _ = Assert.ThrowsExactly(() => Progression.Parse("C || G"));
+ public void Parse_RejectsMissingTimeSignature() =>
+ _ = Assert.ThrowsExactly(() => Progression.Parse("C | G"));
[TestMethod]
public void Parse_RejectsEmptyText() =>
diff --git a/Semantics.Test/Music/RestTests.cs b/Semantics.Test/Music/RestTests.cs
new file mode 100644
index 0000000..ba6eacb
--- /dev/null
+++ b/Semantics.Test/Music/RestTests.cs
@@ -0,0 +1,31 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Music;
+
+using ktsu.Semantics.Music;
+
+[TestClass]
+public class RestTests
+{
+ [TestMethod]
+ public void ToStringIsRColonDuration()
+ {
+ Assert.AreEqual("R:1/4", Rest.Create(Duration.Quarter).ToString());
+ }
+
+ [TestMethod]
+ public void RoundTrip()
+ {
+ Rest r = Rest.Create(Duration.Half);
+ Assert.AreEqual(r, Rest.Parse(r.ToString()));
+ }
+
+ [TestMethod]
+ public void TryParseFailsWithoutRPrefix()
+ {
+ Assert.IsFalse(Rest.TryParse("1/4", out Rest? result));
+ Assert.IsNull(result);
+ }
+}
diff --git a/Semantics.Test/Music/ScaleTests.cs b/Semantics.Test/Music/ScaleTests.cs
index 2892bc5..14cefc4 100644
--- a/Semantics.Test/Music/ScaleTests.cs
+++ b/Semantics.Test/Music/ScaleTests.cs
@@ -51,4 +51,25 @@ public void DegreeOf_SharpFourIsCloserToFourthRaised()
Assert.AreEqual(5, tritone.Degree);
Assert.AreEqual(-1, tritone.Alteration);
}
+
+ [TestMethod]
+ public void ToStringIsRootSpaceMode()
+ {
+ Scale s = Scale.Create(PitchClass.Create(NoteLetter.C, Accidental.Natural), Mode.Dorian);
+ Assert.AreEqual("C dorian", s.ToString());
+ }
+
+ [TestMethod]
+ public void RoundTrip()
+ {
+ Scale s = Scale.Create(PitchClass.Create(NoteLetter.F, Accidental.Sharp), Mode.HarmonicMinor);
+ Assert.AreEqual(s, Scale.Parse(s.ToString()));
+ }
+
+ [TestMethod]
+ public void TryParseFailsWithoutSpace()
+ {
+ Assert.IsFalse(Scale.TryParse("Cdorian", out Scale? result));
+ Assert.IsNull(result);
+ }
}
diff --git a/Semantics.Test/Music/ScorePrimitivesTests.cs b/Semantics.Test/Music/ScorePrimitivesTests.cs
index 5065bd2..b9d1e50 100644
--- a/Semantics.Test/Music/ScorePrimitivesTests.cs
+++ b/Semantics.Test/Music/ScorePrimitivesTests.cs
@@ -49,7 +49,7 @@ public void Tempo_RejectsNonPositive()
[TestMethod]
public void Note_BundlesPitchDurationVelocity_AndTimesInSeconds()
{
- Note note = Note.Create(Pitch.FromName("A4"), Duration.Half, Velocity.Forte);
+ Note note = Note.Create(Pitch.Parse("A4"), Duration.Half, Velocity.Forte);
Assert.AreEqual(69, note.Pitch.Midi);
Assert.AreEqual(Duration.Half, note.Duration);
Assert.AreEqual(96, note.Velocity.Value);
diff --git a/Semantics.Test/Music/SectionRoundTripTests.cs b/Semantics.Test/Music/SectionRoundTripTests.cs
new file mode 100644
index 0000000..bb64a15
--- /dev/null
+++ b/Semantics.Test/Music/SectionRoundTripTests.cs
@@ -0,0 +1,35 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Music;
+
+using ktsu.Semantics.Music;
+
+[TestClass]
+public class SectionRoundTripTests
+{
+ private static Progression SampleProgression() =>
+ Progression.Create([Chord.Parse("Dm7"), Chord.Parse("G7")], Duration.Half);
+
+ [TestMethod]
+ public void ToStringWithLabelAndKey()
+ {
+ Section s = Section.Create(SectionType.Verse, SampleProgression(), "Verse 1", Key.Create(PitchClass.Create(NoteLetter.C, Accidental.Natural), Mode.Major));
+ Assert.AreEqual("[Verse] \"Verse 1\" (C major)\n4/4 Dm7 / G7 /", s.ToString());
+ }
+
+ [TestMethod]
+ public void RoundTripMinimal()
+ {
+ Section s = Section.Create(SectionType.Intro, SampleProgression());
+ Assert.AreEqual(s, Section.Parse(s.ToString()));
+ }
+
+ [TestMethod]
+ public void RoundTripWithLabelAndKey()
+ {
+ Section s = Section.Create(SectionType.Chorus, SampleProgression(), "Chorus", Key.Create(PitchClass.Create(NoteLetter.A, Accidental.Natural), Mode.Aeolian));
+ Assert.AreEqual(s, Section.Parse(s.ToString()));
+ }
+}
diff --git a/Semantics.Test/Music/SectionTests.cs b/Semantics.Test/Music/SectionTests.cs
index 9243757..e922d1c 100644
--- a/Semantics.Test/Music/SectionTests.cs
+++ b/Semantics.Test/Music/SectionTests.cs
@@ -12,7 +12,7 @@ public class SectionTests
[TestMethod]
public void Section_ExposesTypeProgressionAndBars()
{
- Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"), "Verse 1");
+ Section verse = Section.Create(SectionType.Verse, Progression.Parse("4/4 C / / / | Am / / / | F / / / | G / / /"), "Verse 1");
Assert.AreEqual(SectionType.Verse, verse.Type);
Assert.AreEqual("Verse 1", verse.Label);
Assert.AreEqual(4.0, verse.Bars, 1e-9);
@@ -21,17 +21,17 @@ public void Section_ExposesTypeProgressionAndBars()
[TestMethod]
public void IsSameStructure_IgnoresLabelAndKey()
{
- Section a = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"), "Verse 1");
- Section b = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"), "Verse 2");
+ Section a = Section.Create(SectionType.Verse, Progression.Parse("4/4 C / / / | Am / / / | F / / / | G / / /"), "Verse 1");
+ Section b = Section.Create(SectionType.Verse, Progression.Parse("4/4 C / / / | Am / / / | F / / / | G / / /"), "Verse 2");
Assert.IsTrue(a.IsSameStructure(b));
}
[TestMethod]
public void IsSameStructure_FalseForDifferentTypeOrChords()
{
- Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"));
- Section chorus = Section.Create(SectionType.Chorus, Progression.Parse("C | Am | F | G"));
- Section other = Section.Create(SectionType.Verse, Progression.Parse("F | G | C | C"));
+ Section verse = Section.Create(SectionType.Verse, Progression.Parse("4/4 C / / / | Am / / / | F / / / | G / / /"));
+ Section chorus = Section.Create(SectionType.Chorus, Progression.Parse("4/4 C / / / | Am / / / | F / / / | G / / /"));
+ Section other = Section.Create(SectionType.Verse, Progression.Parse("4/4 F / / / | G / / / | C / / / | C / / /"));
Assert.IsFalse(verse.IsSameStructure(chorus));
Assert.IsFalse(verse.IsSameStructure(other));
}
diff --git a/Semantics.Test/Music/TempoTests.cs b/Semantics.Test/Music/TempoTests.cs
new file mode 100644
index 0000000..b383c43
--- /dev/null
+++ b/Semantics.Test/Music/TempoTests.cs
@@ -0,0 +1,32 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Music;
+
+using ktsu.Semantics.Music;
+
+[TestClass]
+public class TempoTests
+{
+ [TestMethod]
+ public void ToStringEncodesBpmAndBeat()
+ {
+ Assert.AreEqual("120bpm@1/4", Tempo.Create(120).ToString());
+ Assert.AreEqual("90bpm@1/8", Tempo.Create(90, Duration.Eighth).ToString());
+ }
+
+ [TestMethod]
+ public void RoundTrip()
+ {
+ Tempo t = Tempo.Create(132.5, Duration.Eighth);
+ Assert.AreEqual(t, Tempo.Parse(t.ToString()));
+ }
+
+ [TestMethod]
+ public void TryParseFailsWhenMarkerMissing()
+ {
+ Assert.IsFalse(Tempo.TryParse("120", out Tempo? result));
+ Assert.IsNull(result);
+ }
+}
diff --git a/Semantics.Test/Music/TimeSignatureTests.cs b/Semantics.Test/Music/TimeSignatureTests.cs
index 44dd4c1..cb1d91c 100644
--- a/Semantics.Test/Music/TimeSignatureTests.cs
+++ b/Semantics.Test/Music/TimeSignatureTests.cs
@@ -33,4 +33,24 @@ public void Create_RejectsNonPositive()
_ = Assert.ThrowsExactly(() => TimeSignature.Create(0, 4));
_ = Assert.ThrowsExactly(() => TimeSignature.Create(4, 0));
}
+
+ [TestMethod]
+ public void ToStringIsBeatsOverUnit()
+ {
+ Assert.AreEqual("6/8", TimeSignature.Create(6, 8).ToString());
+ }
+
+ [TestMethod]
+ public void RoundTrip()
+ {
+ TimeSignature ts = TimeSignature.Create(7, 8);
+ Assert.AreEqual(ts, TimeSignature.Parse(ts.ToString()));
+ }
+
+ [TestMethod]
+ public void TryParseFailsOnNonPositive()
+ {
+ Assert.IsFalse(TimeSignature.TryParse("0/4", out TimeSignature? result));
+ Assert.IsNull(result);
+ }
}
diff --git a/Semantics.Test/Music/VelocityTests.cs b/Semantics.Test/Music/VelocityTests.cs
new file mode 100644
index 0000000..fb89c50
--- /dev/null
+++ b/Semantics.Test/Music/VelocityTests.cs
@@ -0,0 +1,31 @@
+// Copyright (c) ktsu.dev
+// All rights reserved.
+// Licensed under the MIT license.
+
+namespace ktsu.Semantics.Test.Music;
+
+using ktsu.Semantics.Music;
+
+[TestClass]
+public class VelocityTests
+{
+ [TestMethod]
+ public void ToStringIsInteger()
+ {
+ Assert.AreEqual("96", Velocity.Create(96).ToString());
+ }
+
+ [TestMethod]
+ public void RoundTrip()
+ {
+ Velocity v = Velocity.Create(100);
+ Assert.AreEqual(v, Velocity.Parse(v.ToString()));
+ }
+
+ [TestMethod]
+ public void TryParseFailsOutOfRange()
+ {
+ Assert.IsFalse(Velocity.TryParse("200", out Velocity? result));
+ Assert.IsNull(result);
+ }
+}
diff --git a/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md b/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md
index eb1a14d..90c5480 100644
--- a/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md
+++ b/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md
@@ -1,5 +1,7 @@
# Music Analysis Aggregate Layer Implementation Plan
+> **Superseded (2026-07-02):** The `Pitch.FromName`, `Mode.FromName`, and `Form.FromPattern` entry points referenced below were renamed to `Parse`/`TryParse`, and the bar-delimited `Progression.Parse` format was replaced with a chart-style format, by the type-safe-factories work — see `2026-07-02-music-type-safe-factories.md`. Snippets below record the API as it was when this plan was executed.
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an analysis-oriented aggregate layer to `Semantics.Music` — `Progression` (chords + functional-harmony analysis), `Section`, `Arrangement`, and `Form` — building only on the existing primitives.