Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c6453d1
feat(music): add NoteLetter and Accidental enums
matt-edmondson Jul 2, 2026
5671895
feat(music): typed PitchClass factory, Parse/TryParse, canonical ToSt…
matt-edmondson Jul 2, 2026
868abad
feat(music): typed Pitch factory, rename FromName to Parse/TryParse, …
matt-edmondson Jul 2, 2026
bd47dd1
feat(music): rename Mode.FromName to Parse/TryParse, canonical ToString
matt-edmondson Jul 2, 2026
344cd03
feat(music): Interval canonical ToString + Parse/TryParse
matt-edmondson Jul 2, 2026
f44c4e3
feat(music): Duration canonical ToString + Parse/TryParse
matt-edmondson Jul 2, 2026
f4c9897
feat(music): TimeSignature canonical ToString + Parse/TryParse
matt-edmondson Jul 2, 2026
6453493
feat(music): Velocity canonical ToString + Parse/TryParse
matt-edmondson Jul 2, 2026
bc2ed6d
feat(music): Tempo canonical ToString + Parse/TryParse
matt-edmondson Jul 2, 2026
ec48914
feat(music): Scale canonical ToString + Parse/TryParse
matt-edmondson Jul 2, 2026
43c7983
feat(music): Key canonical ToString + Parse/TryParse; roman-numeral a…
matt-edmondson Jul 2, 2026
acf616f
feat(music): canonical Chord ToString, TryParse, ParseRoot via Notation
matt-edmondson Jul 2, 2026
4e96171
feat(music): ChordEvent canonical ToString + Parse/TryParse
matt-edmondson Jul 2, 2026
c17ea4a
feat(music): Note canonical ToString + Parse/TryParse
matt-edmondson Jul 2, 2026
10760d0
feat(music): Rest canonical ToString + Parse/TryParse
matt-edmondson Jul 2, 2026
9ead25b
feat(music): rename Form.FromPattern to Parse/TryParse, canonical ToS…
matt-edmondson Jul 2, 2026
b4056a3
feat(music)!: replace bar-delimited Progression.Parse with chart-styl…
matt-edmondson Jul 2, 2026
b22f9a7
feat(music): chart-style Section ToString + Parse/TryParse
matt-edmondson Jul 2, 2026
aedd86d
feat(music): chart-style Arrangement ToString + Parse/TryParse + stru…
matt-edmondson Jul 2, 2026
13bda87
docs(music): update examples and references for Parse/TryParse rename…
matt-edmondson Jul 2, 2026
d4855de
refactor(music): split Progression.TryParse into helpers to cut cogni…
matt-edmondson Jul 2, 2026
9d4eb64
fix(music): use ValueTuple.GetHashCode instead of System.HashCode for…
matt-edmondson Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Semantics.Music/Accidental.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.Semantics.Music;

/// <summary>A pitch alteration. The underlying value is the semitone offset.</summary>
public enum Accidental
{
/// <summary>Double flat (−2 semitones).</summary>
DoubleFlat = -2,

/// <summary>Flat (−1 semitone).</summary>
Flat = -1,

/// <summary>Natural (no alteration).</summary>
Natural = 0,

/// <summary>Sharp (+1 semitone).</summary>
Sharp = 1,

/// <summary>Double sharp (+2 semitones).</summary>
DoubleSharp = 2,
}
74 changes: 74 additions & 0 deletions Semantics.Music/Arrangement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ namespace ktsu.Semantics.Music;

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;

/// <summary>The ordered realization of a piece: a sequence of sections in performance order.</summary>
public sealed record Arrangement
Expand All @@ -17,6 +19,18 @@ public sealed record Arrangement
/// <summary>Gets the sections in performance order; repetition is expressed by repeated entries.</summary>
public IReadOnlyList<Section> Sections { get; private init; } = [];

/// <summary>Determines structural equality: the home key and ordered sections all match.</summary>
/// <param name="other">The arrangement to compare.</param>
/// <returns><see langword="true"/> when the arrangements are value-equal.</returns>
public bool Equals(Arrangement? other) =>
other is not null
&& Key == other.Key
&& Sections.SequenceEqual(other.Sections);

/// <summary>Returns a hash code consistent with <see cref="Equals(Arrangement)"/>.</summary>
/// <returns>The hash code.</returns>
public override int GetHashCode() => (Key, Sections.Count).GetHashCode();

/// <summary>Gets the total length of the arrangement in bars.</summary>
public double TotalBars => Sections.Sum(section => section.Bars);

Expand All @@ -41,4 +55,64 @@ public static Arrangement Create(Key key, IEnumerable<Section> sections)

return new() { Key = key, Sections = list };
}

/// <summary>Returns the chart: a home-key line then blank-line-separated section blocks.</summary>
/// <returns>The canonical arrangement text.</returns>
public override string ToString()
{
StringBuilder sb = new();
_ = sb.Append(Key);
foreach (Section section in Sections)
{
_ = sb.Append("\n\n").Append(section);
}

return sb.ToString();
}

/// <summary>Parses a chart-style arrangement.</summary>
/// <param name="text">The arrangement text.</param>
/// <returns>The parsed arrangement.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="text"/> is null.</exception>
/// <exception cref="FormatException">Thrown when the text cannot be parsed.</exception>
public static Arrangement Parse(string text)
{
Ensure.NotNull(text);
return TryParse(text, out Arrangement? result)
? result
: throw new FormatException($"Invalid arrangement '{text}'.");
}

/// <summary>Tries to parse a chart-style arrangement.</summary>
/// <param name="text">The text to parse.</param>
/// <param name="result">The parsed arrangement, or null on failure.</param>
/// <returns><see langword="true"/> when parsing succeeds.</returns>
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<Section> 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;
}
}
208 changes: 172 additions & 36 deletions Semantics.Music/Chord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}'.");
}

/// <summary>Tries to parse a chord symbol.</summary>
/// <param name="symbol">The text to parse.</param>
/// <param name="result">The parsed chord, or null on failure.</param>
/// <returns><see langword="true"/> when parsing succeeds.</returns>
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);
Expand All @@ -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,
Expand All @@ -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;
}

/// <summary>The fifth alteration and the upper-structure modifiers consumed from a chord body.</summary>
Expand Down Expand Up @@ -374,18 +385,143 @@ public IReadOnlyList<Pitch> 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<Pitch> 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;
}

/// <summary>Returns the canonical chord symbol. The formatter is the inverse of <see cref="Parse"/> over the parseable corpus.</summary>
/// <returns>The canonical chord symbol (e.g. "Cmaj7", "C/G").</returns>
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<int> offsets, ChordTension flag, int semitones)
{
if (Tensions.HasFlag(flag))
Expand Down
Loading
Loading