From a3f4d58eaaf021da949d64305bf7a515cc7ba615 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:28:45 +0000 Subject: [PATCH 1/2] Detect a twelve-bar blues by bars, not chord count [patch] IsTwelveBarBlues required exactly twelve chord events and ignored their durations, so three bars of quarter-note chords were called a blues and a real twelve-bar blues with held chords was not. The section must now last exactly twelve bars, and each bar is represented by the chord sounding on its downbeat. The existing blues test used twelve one-beat cells, the three-bar false positive itself, so it now writes each chord as a full bar. Fixes ktsu-dev/Semantics#317 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SWJoWcEC3BuVS7pSPco7mA --- Semantics.Music/Form.cs | 35 +++++++++++++++++++++++-------- Semantics.Test/Music/FormTests.cs | 27 +++++++++++++++++++++++- 2 files changed, 52 insertions(+), 10 deletions(-) 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..af604a1c 100644 --- a/Semantics.Test/Music/FormTests.cs +++ b/Semantics.Test/Music/FormTests.cs @@ -35,11 +35,36 @@ 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); } + [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); + } + [TestMethod] public void Parse_AppliesLetterRecognition() { From 27c797b14f3fc58fbb7ade4922544854ed65a37b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:29:19 +0000 Subject: [PATCH 2/2] Move the bar-based blues tests to the end of FormTests Keeps them clear of the #314 tests so the two PRs merge independently. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SWJoWcEC3BuVS7pSPco7mA --- Semantics.Test/Music/FormTests.cs | 50 +++++++++++++++---------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/Semantics.Test/Music/FormTests.cs b/Semantics.Test/Music/FormTests.cs index af604a1c..ffacc9bf 100644 --- a/Semantics.Test/Music/FormTests.cs +++ b/Semantics.Test/Music/FormTests.cs @@ -40,31 +40,6 @@ public void Of_RecognizesTwelveBarBlues() Assert.AreEqual(NamedForm.TwelveBarBlues, arrangement.Form.Name); } - [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); - } - [TestMethod] public void Parse_AppliesLetterRecognition() { @@ -91,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); + } }