diff --git a/Semantics.Music/Arrangement.cs b/Semantics.Music/Arrangement.cs
index f778c1ea..1a14c682 100644
--- a/Semantics.Music/Arrangement.cs
+++ b/Semantics.Music/Arrangement.cs
@@ -33,6 +33,9 @@ other is not null
public double TotalBars => Sections.Sum(section => section.Bars);
/// Gets the structural form of the arrangement.
+ ///
+ /// Thrown when the arrangement has more than structurally distinct sections.
+ ///
public Form Form => Form.Of(this);
/// Creates an arrangement.
diff --git a/Semantics.Music/Form.cs b/Semantics.Music/Form.cs
index eb30ec0d..a5abdc94 100644
--- a/Semantics.Music/Form.cs
+++ b/Semantics.Music/Form.cs
@@ -17,6 +17,9 @@ public sealed record Form
[1, 4, 1, 1, 4, 4, 1, 1, 5, 4, 1, 5],
];
+ /// The number of structurally distinct sections a form can label, one per letter A-Z.
+ public const int MaxDistinctSections = 26;
+
/// Gets the section letter-pattern (e.g. "AABA").
public string Pattern { get; private init; } = "";
@@ -42,7 +45,14 @@ other is not null
/// Derives the form of an arrangement from its sections.
/// The arrangement to analyze.
/// The derived form.
+ ///
+ /// Each structurally distinct section takes the next letter from A to Z, so an arrangement can have at most
+ /// distinct sections. Beyond that there is no letter accepts.
+ ///
/// Thrown when is null.
+ ///
+ /// Thrown when has more than structurally distinct sections.
+ ///
public static Form Of(Arrangement arrangement)
{
Ensure.NotNull(arrangement);
@@ -55,6 +65,13 @@ public static Form Of(Arrangement arrangement)
int index = representatives.FindIndex(representative => representative.IsSameStructure(section));
if (index < 0)
{
+ if (representatives.Count == MaxDistinctSections)
+ {
+ throw new ArgumentException(
+ $"A form can label at most {MaxDistinctSections} structurally distinct sections (A-Z).",
+ nameof(arrangement));
+ }
+
representatives.Add(section);
index = representatives.Count - 1;
}
diff --git a/Semantics.Test/Music/FormTests.cs b/Semantics.Test/Music/FormTests.cs
index a4221874..242f47ad 100644
--- a/Semantics.Test/Music/FormTests.cs
+++ b/Semantics.Test/Music/FormTests.cs
@@ -40,6 +40,35 @@ public void Of_RecognizesTwelveBarBlues()
Assert.AreEqual(NamedForm.TwelveBarBlues, arrangement.Form.Name);
}
+ [TestMethod]
+ public void Of_TwentySixDistinctSections_RoundTripsThroughParse()
+ {
+ Arrangement arrangement = Arrangement.Create(CMajor, DistinctSections(Form.MaxDistinctSections));
+ Form form = arrangement.Form;
+ Assert.AreEqual("ABCDEFGHIJKLMNOPQRSTUVWXYZ", form.Pattern);
+ Assert.AreEqual(form, Form.Parse(form.ToString()));
+ }
+
+ [TestMethod]
+ public void Of_MoreDistinctSectionsThanLetters_Throws()
+ {
+ Arrangement arrangement = Arrangement.Create(CMajor, DistinctSections(Form.MaxDistinctSections + 1));
+ _ = Assert.ThrowsExactly(() => Form.Of(arrangement));
+ }
+
+ [TestMethod]
+ public void Of_RepeatsBeyondTwentySixSections_StillLabelsByStructure()
+ {
+ List sections = DistinctSections(Form.MaxDistinctSections);
+ sections.Add(sections[0]);
+ Form form = Arrangement.Create(CMajor, sections).Form;
+ Assert.AreEqual("ABCDEFGHIJKLMNOPQRSTUVWXYZA", form.Pattern);
+ }
+
+ // Sections with different chord counts never share a structure, so section i has i + 1 bars of C.
+ private static List DistinctSections(int count) =>
+ [.. Enumerable.Range(1, count).Select(bars => Section(SectionType.Verse, string.Join(" | ", Enumerable.Repeat("C", bars))))];
+
[TestMethod]
public void Parse_AppliesLetterRecognition()
{