Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 21 additions & 8 deletions Semantics.Music/Chord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ namespace ktsu.Semantics.Music;

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

/// <summary>
Expand Down Expand Up @@ -135,32 +134,41 @@ public Chord Transpose(int semitones) => this with
/// <summary>Voices the chord at the given octave and inversion.</summary>
/// <param name="octave">The octave for the root (e.g. 4 places the root at C4 for a C chord).</param>
/// <param name="inversion">
/// The inversion: 0 root position, 1 first inversion, and so on. Each step raises the next-lowest
/// chord tone by an octave; the value wraps modulo the number of chord tones.
/// The inversion: 0 root position, 1 first inversion, and so on. Inversion <c>k</c> puts the
/// <c>k</c>-th entry of <see cref="ChordTones"/> lowest, raising every tone below it by whole octaves
/// until it sits above that bass; the value wraps modulo the number of chord tones.
/// </param>
/// <returns>The pitches, lowest first; a slash bass (if any) sounds one octave below the root.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="octave"/> puts any pitch of the voicing outside MIDI 0..127.
/// </exception>
public IReadOnlyList<Pitch> Voice(int octave, int inversion)
{
List<int> tones = [.. ChordTones()];
int count = tones.Count;
if (count > 0)
{
// Raising only the tones before the bass by one octave is not enough once tensions are
// involved: a raised root at 12 still sits below a ninth at 14, and would sort back under it.
int rotation = ((inversion % count) + count) % count;
int bass = tones[rotation];
for (int i = 0; i < rotation; i++)
{
tones[i] += 12;
while (tones[i] <= bass)
{
tones[i] += 12;
}
}

tones.Sort();
}

Pitch rootPitch = Pitch.Parse(Root.Name + octave.ToString(CultureInfo.InvariantCulture));
List<Pitch> pitches = [.. tones.Select(offset => rootPitch.Transpose(offset))];
long rootMidi = (((long)octave + 1) * 12) + Root.Value;
List<Pitch> pitches = [.. tones.Select(offset => PitchAt(rootMidi + offset, octave))];

if (Bass is not null)
{
Pitch bassPitch = Pitch.Parse(Bass.Name + (octave - 1).ToString(CultureInfo.InvariantCulture));
pitches.Insert(0, bassPitch);
pitches.Insert(0, PitchAt(((long)octave * 12) + Bass.Value, octave));
}

return pitches;
Expand All @@ -171,6 +179,11 @@ public IReadOnlyList<Pitch> Voice(int octave, int inversion)
/// <remarks><c>Parse(ToString()) == this</c> for every expressible chord.</remarks>
public override string ToString() => ChordSymbolWriter.Format(this);

private static Pitch PitchAt(long midi, int octave) =>
midi is >= 0 and <= 127
? Pitch.Create((int)midi)
: throw new ArgumentOutOfRangeException(nameof(octave), octave, "The voicing would leave MIDI 0..127.");

private void AddTension(SortedSet<int> offsets, ChordTensions flag, int semitones)
{
if (Tensions.HasFlag(flag))
Expand Down
105 changes: 93 additions & 12 deletions Semantics.Music/ChordSymbolReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
{
private readonly string text = text;
private int index;
private int groupDepth;

internal static bool TryRead(string text, out Chord? chord)
{
Expand All @@ -23,7 +24,7 @@
return false;
}

if (reader.index != text.Length || !builder.TryBuild(root!, bass, out chord))

Check warning on line 27 in Semantics.Music/ChordSymbolReader.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 27 in Semantics.Music/ChordSymbolReader.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 27 in Semantics.Music/ChordSymbolReader.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 27 in Semantics.Music/ChordSymbolReader.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.
{
chord = null;
return false;
Expand All @@ -32,6 +33,27 @@
return true;
}

/// <summary>Reads a chord body, with no root and no slash bass, against a root already resolved.</summary>
/// <remarks>
/// A roman numeral resolves its own root, so its suffix has to be read as a body. Gluing the suffix
/// onto the root's name instead would let a leading "b" or "#" become the root's accidental: "Vb9"
/// in C major would read as F#9 rather than G with a flat nine.
/// </remarks>
internal static bool TryReadBody(PitchClass root, string body, out Chord? chord)
{
ChordSymbolReader reader = new(body);
chord = null;
if (!reader.TryReadBody(out ChordBuilder builder)
|| reader.index != body.Length
|| !builder.TryBuild(root, null, out chord))
{
chord = null;
return false;
}

return true;
}

private bool TryReadRoot([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out PitchClass? root)
{
root = null;
Expand Down Expand Up @@ -109,7 +131,10 @@

private void ReadAugmentedQuality(ChordBuilder builder)
{
if (TryTake("aug") || (Peek('+') && !IsDigit(PeekNext()) && TryTake("+")))
// At the start of the body a "+" is the augmented mark even before a number, so "C+7" is an
// augmented seventh. Only "+5" is left to the modifiers, where it reads as a raised fifth and
// means the same chord; anywhere later a "+" before a number is a sharp ("C7+9" is C7#9).
if (TryTake("aug") || (Peek('+') && PeekNext() != '5' && TryTake("+")))
{
builder.Augmented = true;
}
Expand Down Expand Up @@ -256,21 +281,29 @@
}

bool any = false;
while (!Peek(')'))
groupDepth++;
try
{
if (!TryReadModifier(builder))
while (!Peek(')'))
{
index = start;
return false;
}
if (!TryReadModifier(builder))
{
index = start;
return false;
}

any = true;
if (TryTake(",") && Peek(')'))
{
index = start;
return false;
any = true;
if (TryTake(",") && Peek(')'))
{
index = start;
return false;
}
}
}
finally
{
groupDepth--;
}

if (!any)
{
Expand Down Expand Up @@ -333,6 +366,53 @@
}

index = start;
if (groupDepth > 0 && TryReadGroupedTone(builder))
{
return true;
}

index = start;
return false;
}

/// <summary>Reads the lead-sheet spellings only a parenthesised group allows.</summary>
/// <remarks>
/// A bare tension in parentheses is an added tone, as on a lead sheet: "C7(13)" is C7add13, not C13.
/// A major-seventh mark in parentheses supplies the seventh a body without a number lacks, so
/// "Cm(maj7)" is Cmmaj7; after a number there is already a seventh, and "C7(maj7)" is refused.
/// </remarks>
private bool TryReadGroupedTone(ChordBuilder builder)
{
if (TryTakeEither("maj7", "Maj7") || TryTakeEither("M7", "Δ7"))
{
if (builder.ExtendedNumber != 0 || builder.MajorMarker || builder.Sixth != SixthType.None)
{
return false;
}

builder.MajorMarker = true;
builder.ExtendedNumber = 7;
return true;
}

if (TryTake("9"))
{
return builder.ExtendedNumber < 9
&& builder.SetTension(ChordTensions.Nine, ChordTensions.FlatNine | ChordTensions.SharpNine);
}

if (TryTake("11"))
{
return builder.ExtendedNumber < 11
&& builder.SetTension(ChordTensions.Eleven, ChordTensions.SharpEleven);
}

if (TryTake("13"))
{
return builder.ExtendedNumber < 13
&& builder.SetTension(ChordTensions.Thirteen, ChordTensions.FlatThirteen);
}

return false;
}

Expand Down Expand Up @@ -612,7 +692,8 @@
&& !(FifthAlteration < 0 && Augmented);

private bool HasInvalidSuspension() =>
Sus is not null && (Minor || Diminished || Augmented || MajorMarker || Power || FifthAlteration != 0);
Sus is not null && (Minor || Diminished || Augmented || Power || FifthAlteration != 0
|| (MajorMarker && !DeltaMarker && (ExtendedNumber is 0 or 6)));

private bool HasInvalidMajorMarker() =>
(MajorMarker && !DeltaMarker && ExtendedNumber == 0 && (Minor || Diminished || Augmented))
Expand Down
5 changes: 0 additions & 5 deletions Semantics.Music/ChordSymbolWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,6 @@ private static bool HasSupportedStructure(Chord chord)
return false;
}

if ((chord.Quality is ChordQuality.Sus2 or ChordQuality.Sus4) && chord.Seventh == SeventhType.Major)
{
return false;
}

return chord.Quality != ChordQuality.Power
|| (chord.Seventh == SeventhType.None
&& chord.Sixth == SixthType.None
Expand Down
6 changes: 4 additions & 2 deletions Semantics.Music/ChromaticKind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ public enum ChromaticKind
Neapolitan,

/// <summary>
/// An augmented-sixth chord. Reserved: detection is not implemented because the chord model does
/// not carry the interval spelling needed to identify Italian/French/German sixths reliably.
/// An augmented-sixth chord on the lowered sixth degree: Italian ("It+6", the shape of a dominant
/// seventh with no fifth), French ("Fr+6", a dominant seventh with a flat fifth) or German ("Ger+6",
/// a dominant seventh, reported only when it resolves to the dominant or to a cadential six-four,
/// since otherwise it is indistinguishable from the dominant seventh it sounds like).
/// </summary>
AugmentedSixth,

Expand Down
87 changes: 55 additions & 32 deletions Semantics.Music/Key.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ktsu.Semantics.Music;

using System;
using System.Linq;
using System.Text;

/// <summary>
Expand Down Expand Up @@ -84,6 +85,31 @@ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNu
/// <returns>The transposed key.</returns>
public Key Transpose(int semitones) => Create(PitchClass.Create(Tonic.Value + semitones), Mode);

/// <summary>
/// Gets the seven-degree scale that roman numerals, functions and cadences are spelled against:
/// the key's own scale when it has seven degrees, otherwise major or natural minor on the same
/// tonic, chosen by the mode's third.
/// </summary>
/// <remarks>
/// A pentatonic, hexatonic or whole-tone mode's own degree index is not a harmonic degree: in C
/// major pentatonic G is the fourth note, yet it is the dominant. Spelling against the heptatonic
/// reference makes G "V" there, and C "III" in A minor pentatonic. Modes with more than seven
/// degrees keep their own scale, so <see cref="RomanNumeralOf"/> still refuses their eighth degree.
/// </remarks>
internal Scale AnalysisScale
{
get
{
if (Mode.DegreeCount >= 7)
{
return Scale;
}

bool minor = Mode.Intervals.Contains(3) && !Mode.Intervals.Contains(4);
return Scale.Create(Tonic, minor ? Mode.Aeolian : Mode.Major);
}
}

/// <summary>Resolves a pitch class to its scale-degree function in this key.</summary>
/// <param name="root">The pitch class to resolve.</param>
/// <returns>The scale degree with any chromatic alteration.</returns>
Expand Down Expand Up @@ -111,7 +137,7 @@ public string RomanNumeralOf(Chord chord)
{
Ensure.NotNull(chord);

ScaleDegree degree = Scale.DegreeOf(chord.Root);
ScaleDegree degree = AnalysisScale.DegreeOf(chord.Root);

// Wrapping the degree onto the seven-entry table would hand two distinct degrees the same
// numeral, silently. Refuse instead, which is also what ChordFromRomanNumeral already does
Expand Down Expand Up @@ -171,7 +197,7 @@ public Chord ChordFromRomanNumeral(string numeral)

index += length;

System.Collections.Generic.IReadOnlyList<PitchClass> pitchClasses = Scale.PitchClasses;
System.Collections.Generic.IReadOnlyList<PitchClass> pitchClasses = AnalysisScale.PitchClasses;
if (degree > pitchClasses.Count)
{
throw new FormatException($"Degree {degree} is out of range for this key.");
Expand Down Expand Up @@ -213,7 +239,9 @@ public Chord ChordFromRomanNumeral(string numeral)
quality = upper ? string.Empty : "m";
}

return Chord.Parse(root.Name + quality + suffix);
return ChordSymbolReader.TryReadBody(root, quality + suffix, out Chord? chord)
? chord!
: throw new FormatException($"Invalid roman numeral '{numeral}'.");
}

private static (int Degree, int Length, bool Upper) ParseNumeral(string text, int start)
Expand All @@ -234,37 +262,32 @@ private static (int Degree, int Length, bool Upper) ParseNumeral(string text, in
return (0, 0, false);
}

/// <summary>Writes the chord's body in roman-numeral form: the chord symbol after its root, with the
/// quality carried by the numeral's case or by °, ø and +.</summary>
/// <remarks>
/// The body is the chord symbol's own, so sixths, tensions and suspensions survive the trip and
/// <see cref="ChordFromRomanNumeral"/> reads back the chord it was given. The slash bass is dropped:
/// "I6" already means an added sixth, so figured-bass inversions would collide with it.
/// </remarks>
private static string QualitySuffix(Chord chord)
{
string seventh = chord.Seventh switch
{
SeventhType.Major => "maj7",
SeventhType.Dominant or SeventhType.Diminished => "7",
_ => "",
};

// A diminished triad under a minor seventh is half-diminished (viiø7), which has to be told
// apart from the fully diminished seventh (vii°7): ChordFromRomanNumeral reads ° as dim, so
// spelling both with ° would turn a half-diminished chord into a fully diminished one.
bool halfDiminished = chord.Quality == ChordQuality.Diminished && chord.Seventh == SeventhType.Dominant;

string quality = chord.Quality switch
string body = ChordSymbolWriter.Format(chord with { Bass = null })[chord.Root.Name.Length..];
switch (chord.Quality)
{
ChordQuality.Diminished => halfDiminished ? "ø" : "°",
ChordQuality.Augmented => "+",
ChordQuality.Sus2 => "sus2",
ChordQuality.Sus4 => "sus4",
ChordQuality.Power => "5",
_ => "",
};

string fifth = chord.Quality switch
{
ChordQuality.MajorFlatFive => "b5",
ChordQuality.MinorSharpFive => "#5",
_ => "",
};

return quality + seventh + fifth;
case ChordQuality.Minor:
case ChordQuality.MinorSharpFive:
return body[1..];
case ChordQuality.Diminished when chord.Seventh == SeventhType.Dominant:
// The writer spells a half-diminished chord "m7b5"; the numeral says "ø7", which
// ChordFromRomanNumeral passes through, rather than "°7", which it reads as fully diminished.
int flatFive = body.IndexOf("b5", StringComparison.Ordinal);
return "ø" + body[1..flatFive] + body[(flatFive + 2)..];
case ChordQuality.Diminished:
return "°" + body["dim".Length..];
case ChordQuality.Augmented:
return "+" + body["aug".Length..];
default:
return body;
}
}
}
2 changes: 1 addition & 1 deletion Semantics.Music/Progression.Analysis.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public IReadOnlyList<HarmonicFunction> Functions(Key key)
/// <summary>Classifies a single chord's function in a key by its root scale degree.</summary>
private static HarmonicFunction FunctionOf(Key key, Chord chord)
{
ScaleDegree degree = key.FunctionOf(chord.Root);
ScaleDegree degree = key.AnalysisScale.DegreeOf(chord.Root);
if (degree.Alteration != 0)
{
return HarmonicFunction.Chromatic;
Expand Down
Loading
Loading