diff --git a/Semantics.Music/Form.cs b/Semantics.Music/Form.cs index eb30ec0d..2ff3301f 100644 --- a/Semantics.Music/Form.cs +++ b/Semantics.Music/Form.cs @@ -9,6 +9,8 @@ namespace ktsu.Semantics.Music; /// The structural form of a piece: its section letter-pattern and any recognized named form. public sealed record Form { + private const int BluesBars = 12; + private static readonly int[][] BluesTemplates = [ [1, 1, 1, 1, 4, 4, 1, 1, 5, 4, 1, 1], @@ -133,26 +135,41 @@ private static bool IsRondo(string pattern) return true; } + // A twelve-bar blues is judged bar by bar, not chord by chord: the section must last exactly twelve bars, and each + // bar is represented by the chord sounding on its downbeat. A chord held across several bars counts for each of + // them, and a chord that starts after a downbeat does not count for that bar. private static bool IsTwelveBarBlues(Section section, Key key) { - IReadOnlyList chords = section.Progression.Chords; - if (chords.Count != 12) + Progression progression = section.Progression; + Duration bar = progression.TimeSignature.BarDuration; + if (progression.TotalDuration != bar.Multiply(BluesBars)) { return false; } - int[] degrees = new int[12]; - for (int i = 0; i < 12; i++) + int[] degrees = new int[BluesBars]; + int barIndex = 0; + Duration end = Duration.Create(0, 1); + foreach (ChordEvent chordEvent in progression.Chords) { - ScaleDegree degree = key.FunctionOf(chords[i].Chord.Root); - if (degree.Alteration != 0) + end = end.Add(chordEvent.Duration); + + // Every downbeat before this chord ends that an earlier chord has not already claimed falls within this chord. + while (barIndex < BluesBars && IsBefore(bar.Multiply(barIndex), end)) { - return false; - } + ScaleDegree degree = key.FunctionOf(chordEvent.Chord.Root); + if (degree.Alteration != 0) + { + return false; + } - degrees[i] = degree.Degree; + degrees[barIndex++] = degree.Degree; + } } return BluesTemplates.Any(template => template.SequenceEqual(degrees)); } + + private static bool IsBefore(Duration left, Duration right) => + (long)left.Numerator * right.Denominator < (long)right.Numerator * left.Denominator; } diff --git a/Semantics.Test/Music/FormTests.cs b/Semantics.Test/Music/FormTests.cs index a4221874..ffacc9bf 100644 --- a/Semantics.Test/Music/FormTests.cs +++ b/Semantics.Test/Music/FormTests.cs @@ -35,7 +35,7 @@ public void Of_RecognizesTernaryAndBinary() [TestMethod] public void Of_RecognizesTwelveBarBlues() { - Section blues = Section(SectionType.Verse, "C7 | C7 | C7 | C7 | F7 | F7 | C7 | C7 | G7 | F7 | C7 | C7"); + Section blues = Section(SectionType.Verse, "C7 / / / | C7 / / / | C7 / / / | C7 / / / | F7 / / / | F7 / / / | C7 / / / | C7 / / / | G7 / / / | F7 / / / | C7 / / / | C7 / / /"); Arrangement arrangement = Arrangement.Create(CMajor, [blues]); Assert.AreEqual(NamedForm.TwelveBarBlues, arrangement.Form.Name); } @@ -66,4 +66,29 @@ public void TryParseFailsOnLowercase() Assert.IsFalse(Form.TryParse("aaba", out Form? result)); Assert.IsNull(result); } + + [TestMethod] + public void Of_RecognizesTwelveBarBlues_WithHeldChords() + { + Section blues = Section( + SectionType.Verse, + "C7 / / / / / / / / / / / / / / / | F7 / / / / / / / | C7 / / / / / / / | G7 / / / | F7 / / / | C7 / / / / / / /"); + Assert.AreEqual(12, blues.Bars); + Assert.AreEqual(NamedForm.TwelveBarBlues, Arrangement.Create(CMajor, [blues]).Form.Name); + } + + [TestMethod] + public void Of_RecognizesTwelveBarBlues_ByDownbeatChord() + { + Section blues = Section(SectionType.Verse, "C7 / / / | C7 / / / | C7 / / / | C7 / G7 / | F7 / / / | F7 / / / | C7 / / / | C7 / / / | G7 / / / | F7 / / / | C7 / / / | C7 / G7 /"); + Assert.AreEqual(NamedForm.TwelveBarBlues, Arrangement.Create(CMajor, [blues]).Form.Name); + } + + [TestMethod] + public void Of_DoesNotCallTwelveQuarterNoteChordsABlues() + { + Section shortSection = Section(SectionType.Verse, "C7 C7 C7 C7 | F7 F7 C7 C7 | G7 F7 C7 C7"); + Assert.AreEqual(3, shortSection.Bars); + Assert.AreNotEqual(NamedForm.TwelveBarBlues, Arrangement.Create(CMajor, [shortSection]).Form.Name); + } }