Skip to content

Commit 996f3e2

Browse files
Merge pull request #110 from ktsu-dev/feat/music-analysis-aggregate
feat(music): analysis aggregate layer — progressions, harmony, forms [minor]
2 parents caaa5ae + 231bed1 commit 996f3e2

30 files changed

Lines changed: 3574 additions & 1 deletion

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Tests use MSTest. Generator output is emitted to `Semantics.Quantities/Generated
2222
| `Semantics.Strings` | Strongly-typed string wrappers (`SemanticString<T>`) and validation attributes/strategies. |
2323
| `Semantics.Strings.Identifiers` | Concrete identifier string types (`Uuid`, `Ulid`, `Iban`, `Isbn`, `CreditCardNumber`, `JwtToken`) built on the `Semantics.Strings` framework. |
2424
| `Semantics.Paths` | Polymorphic file system path types (`IPath`, `IFilePath`, `IDirectoryPath`, …). |
25+
| `Semantics.Music` | Immutable musical value types (`Pitch`, `Interval`, `Scale`, `Chord`, `Key`, `Duration`, `TimeSignature`) plus an analysis aggregate layer (`Progression`, `Section`, `Arrangement`, `Form`) computing roman numerals, cadences, key inference, chromatic identification, and named forms. Targets `net8.0``net10.0` + `netstandard2.0`/`netstandard2.1`. |
2526
| `Semantics.Quantities` | Hand-written runtime types (`PhysicalQuantity<TSelf, T>`, `IVector0`..`IVector4`, `UnitSystem`) plus generator output under `Generated/`. |
2627
| `Semantics.SourceGenerators` | Roslyn incremental generators that emit quantity types, units, conversions, magnitudes, physical constants, and storage-type helpers from metadata. |
2728
| `Semantics.Quantities.{Double,Float,Decimal}` | Props-only satellite packages. Each ships a `buildTransitive` props file (generated by `scripts/Generate-AliasProps.ps1`) that injects global-using aliases binding every quantity to one storage type, so consumers write `Mass` instead of `Mass<double>`. |

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ A .NET library for replacing primitive obsession with strongly-typed, self-valid
1313
- **Semantic Strings** — type-safe wrappers like `EmailAddress`, `UserId`, `BlogSlug` with attribute-driven validation, plus a batteries-included `Semantics.Strings.Identifiers` package (`Uuid`, `Ulid`, `Iban`, `Isbn`, `CreditCardNumber`, `JwtToken`).
1414
- **Semantic Paths** — polymorphic `IPath` hierarchy for files, directories, absolute, relative, and combinations.
1515
- **Semantic Quantities** — a metadata-generated, type-safe quantity system with a unified `IVector0..IVector4` model covering 60+ physical dimensions and 200+ generated types. Optional per-storage-type alias packages let you write `Mass` instead of `Mass<double>`.
16-
- **Semantic Music** — immutable musical value types: `PitchClass`, `Pitch`, `Interval`, `Mode`/`Scale`, `Chord`, `Key`, rational `Duration`, and `TimeSignature`, with chord-symbol parsing and voicing.
16+
- **Semantic Music** — immutable musical value types: `PitchClass`, `Pitch`, `Interval`, `Mode`/`Scale`, `Chord`, `Key`, rational `Duration`, and `TimeSignature`, with chord-symbol parsing and voicing, plus a harmonic/structural **analysis** layer (progressions, cadences, key inference, sections, forms).
1717

1818
Targets `net8.0``net10.0`. Semantic Strings, Paths, and Music additionally target `netstandard2.0`/`netstandard2.1`; Semantic Quantities is `net8.0`+ (it requires `INumber<T>`).
1919

@@ -234,6 +234,34 @@ double hz = a4.Pitch.FrequencyHz; // 440.0
234234

235235
The chord parser covers triads, sixths, sevenths (including half-diminished `m7b5` and minor-major `mmaj7`), extensions and altered tensions (`9`/`11`/`13`, `b9`/`#9`/`#11`/`b13`), suspensions, power chords, omit voicings (`no3`/`no5`), and slash bass. Beyond the value types there are score primitives (`Note`, `Rest`, `Velocity`, `Tempo`) that convert rhythm to real time, equal-tempered `Pitch`↔frequency (A440), chord inversions, `Transpose` on `Chord`/`Scale`/`Key`, and roman-numeral parsing as the inverse of `RomanNumeralOf`.
236236

237+
### Analysis: progressions, sections, and forms
238+
239+
Above the single-event types is an analysis layer that models harmony nested inside structure. A `Progression` is an ordered chord sequence with bar-based harmonic rhythm; it computes roman numerals, functional roles, cadences, an inferred key, and chromatic classifications. `Section`s group progressions into labelled units, an `Arrangement` orders them into a piece, and `Form` extracts the structural letter-pattern and names it.
240+
241+
```csharp
242+
using ktsu.Semantics.Music;
243+
244+
// A chord progression with bar-based harmonic rhythm ("|" = barline)
245+
Progression prog = Progression.Parse("Dm7 | G7 | Cmaj7");
246+
Key key = prog.InferKey()!; // C major (quality-weighted fit)
247+
248+
IReadOnlyList<string> roman = prog.RomanNumerals(key); // "ii7", "V7", "Imaj7"
249+
IReadOnlyList<HarmonicFunction> fns = prog.Functions(key); // Predominant, Dominant, Tonic
250+
IReadOnlyList<CadenceInstance> cadences = prog.Cadences(key); // Authentic (V→I) at the resolution
251+
252+
// Chromatic analysis: secondary dominants, borrowed chords, Neapolitan
253+
IReadOnlyList<ChromaticAnalysis> chromatic =
254+
Progression.Parse("C | D7 | G7 | C").ChromaticChords(key); // D7 → "V/V" (SecondaryDominant)
255+
256+
// Structure: sections → arrangement → form
257+
Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"));
258+
Section bridge = Section.Create(SectionType.Bridge, Progression.Parse("F | G | Em | Am"));
259+
Arrangement song = Arrangement.Create(key, [verse, verse, bridge, verse]);
260+
Form form = song.Form; // Pattern "AABA", Name ThirtyTwoBarAABA
261+
```
262+
263+
Cadences are classified by scale-degree motion (authentic, plagal, half, deceptive); key inference is quality-weighted so it distinguishes a key from its relative/parallel neighbours; `Form` recognizes named forms including AABA, ternary, binary, rondo, strophic, and the 12-bar blues progression template.
264+
237265
## Dependency injection
238266

239267
```csharp

Semantics.Music/Arrangement.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace ktsu.Semantics.Music;
6+
7+
using System;
8+
using System.Collections.Generic;
9+
using System.Linq;
10+
11+
/// <summary>The ordered realization of a piece: a sequence of sections in performance order.</summary>
12+
public sealed record Arrangement
13+
{
14+
/// <summary>Gets the home key used for whole-piece analysis; a section may override it locally.</summary>
15+
public Key Key { get; private init; } = Key.Create(PitchClass.Create(0), Mode.Major);
16+
17+
/// <summary>Gets the sections in performance order; repetition is expressed by repeated entries.</summary>
18+
public IReadOnlyList<Section> Sections { get; private init; } = [];
19+
20+
/// <summary>Gets the total length of the arrangement in bars.</summary>
21+
public double TotalBars => Sections.Sum(section => section.Bars);
22+
23+
/// <summary>Gets the structural form of the arrangement.</summary>
24+
public Form Form => Form.Of(this);
25+
26+
/// <summary>Creates an arrangement.</summary>
27+
/// <param name="key">The home key.</param>
28+
/// <param name="sections">The sections in performance order; must be non-empty.</param>
29+
/// <returns>A new arrangement.</returns>
30+
/// <exception cref="ArgumentNullException">Thrown when an argument is null.</exception>
31+
/// <exception cref="ArgumentException">Thrown when <paramref name="sections"/> is empty.</exception>
32+
public static Arrangement Create(Key key, IEnumerable<Section> sections)
33+
{
34+
Ensure.NotNull(key);
35+
Ensure.NotNull(sections);
36+
List<Section> list = [.. sections];
37+
if (list.Count == 0)
38+
{
39+
throw new ArgumentException("An arrangement must contain at least one section.", nameof(sections));
40+
}
41+
42+
return new() { Key = key, Sections = list };
43+
}
44+
}

Semantics.Music/Cadence.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace ktsu.Semantics.Music;
6+
7+
/// <summary>A harmonic cadence type, classified by the scale-degree motion into the final chord.</summary>
8+
public enum Cadence
9+
{
10+
/// <summary>Authentic cadence: V to I.</summary>
11+
Authentic,
12+
13+
/// <summary>Plagal cadence: IV to I.</summary>
14+
Plagal,
15+
16+
/// <summary>Half cadence: any chord arriving on V.</summary>
17+
Half,
18+
19+
/// <summary>Deceptive cadence: V to vi.</summary>
20+
Deceptive,
21+
}

Semantics.Music/CadenceInstance.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace ktsu.Semantics.Music;
6+
7+
using System;
8+
9+
/// <summary>A cadence located within a progression at the position of its resolution chord.</summary>
10+
public sealed record CadenceInstance
11+
{
12+
/// <summary>Gets the index of the resolution (second) chord of the cadence.</summary>
13+
public int Index { get; private init; }
14+
15+
/// <summary>Gets the cadence type.</summary>
16+
public Cadence Type { get; private init; }
17+
18+
/// <summary>Creates a cadence instance.</summary>
19+
/// <param name="index">The zero-based index of the resolution chord; must be non-negative.</param>
20+
/// <param name="type">The cadence type.</param>
21+
/// <returns>A new cadence instance.</returns>
22+
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="index"/> is negative.</exception>
23+
public static CadenceInstance Create(int index, Cadence type)
24+
{
25+
if (index < 0)
26+
{
27+
throw new ArgumentOutOfRangeException(nameof(index), index, "Index must be non-negative.");
28+
}
29+
30+
return new() { Index = index, Type = type };
31+
}
32+
}

Semantics.Music/ChordEvent.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace ktsu.Semantics.Music;
6+
7+
/// <summary>
8+
/// A harmonic event: a chord sounding for a rhythmic duration (the harmonic rhythm).
9+
/// </summary>
10+
public sealed record ChordEvent : IMusicalEvent
11+
{
12+
/// <summary>Gets the chord that sounds.</summary>
13+
public Chord Chord { get; private init; } = new();
14+
15+
/// <summary>Gets the rhythmic duration for which the chord sounds.</summary>
16+
public Duration Duration { get; private init; } = Duration.Quarter;
17+
18+
/// <summary>Creates a chord event from a chord and a duration.</summary>
19+
/// <param name="chord">The chord.</param>
20+
/// <param name="duration">The rhythmic duration.</param>
21+
/// <returns>A new chord event.</returns>
22+
/// <exception cref="ArgumentNullException">Thrown when an argument is null.</exception>
23+
public static ChordEvent Create(Chord chord, Duration duration)
24+
{
25+
Ensure.NotNull(chord);
26+
Ensure.NotNull(duration);
27+
return new() { Chord = chord, Duration = duration };
28+
}
29+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace ktsu.Semantics.Music;
6+
7+
using System;
8+
9+
/// <summary>The chromatic classification of a chord at a position within a progression.</summary>
10+
public sealed record ChromaticAnalysis
11+
{
12+
/// <summary>Gets the zero-based index of the chord within the progression.</summary>
13+
public int Index { get; private init; }
14+
15+
/// <summary>Gets the chromatic category.</summary>
16+
public ChromaticKind Kind { get; private init; }
17+
18+
/// <summary>Gets an optional human-readable detail (e.g. "V/V", "bII"), or null.</summary>
19+
public string? Detail { get; private init; }
20+
21+
/// <summary>Creates a chromatic analysis result.</summary>
22+
/// <param name="index">The zero-based chord index; must be non-negative.</param>
23+
/// <param name="kind">The chromatic category.</param>
24+
/// <param name="detail">An optional descriptive detail.</param>
25+
/// <returns>A new chromatic analysis result.</returns>
26+
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="index"/> is negative.</exception>
27+
public static ChromaticAnalysis Create(int index, ChromaticKind kind, string? detail)
28+
{
29+
if (index < 0)
30+
{
31+
throw new ArgumentOutOfRangeException(nameof(index), index, "Index must be non-negative.");
32+
}
33+
34+
return new() { Index = index, Kind = kind, Detail = detail };
35+
}
36+
}

Semantics.Music/ChromaticKind.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace ktsu.Semantics.Music;
6+
7+
/// <summary>The category of a non-diatonic (chromatic) chord within a key.</summary>
8+
public enum ChromaticKind
9+
{
10+
/// <summary>A secondary dominant: a dominant-quality chord tonicizing a diatonic degree.</summary>
11+
SecondaryDominant,
12+
13+
/// <summary>A borrowed chord: diatonic to the parallel mode of the same tonic (modal interchange).</summary>
14+
BorrowedChord,
15+
16+
/// <summary>A Neapolitan: a major triad on the lowered second degree.</summary>
17+
Neapolitan,
18+
19+
/// <summary>
20+
/// An augmented-sixth chord. Reserved: detection is not implemented because the chord model does
21+
/// not carry the interval spelling needed to identify Italian/French/German sixths reliably.
22+
/// </summary>
23+
AugmentedSixth,
24+
25+
/// <summary>A chromatic chord with no more specific classification.</summary>
26+
Chromatic,
27+
}

Semantics.Music/Form.cs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace ktsu.Semantics.Music;
6+
7+
using System;
8+
using System.Collections.Generic;
9+
using System.Linq;
10+
11+
/// <summary>The structural form of a piece: its section letter-pattern and any recognized named form.</summary>
12+
public sealed record Form
13+
{
14+
private static readonly int[][] BluesTemplates =
15+
[
16+
[1, 1, 1, 1, 4, 4, 1, 1, 5, 4, 1, 1],
17+
[1, 1, 1, 1, 4, 4, 1, 1, 5, 4, 1, 5],
18+
[1, 4, 1, 1, 4, 4, 1, 1, 5, 4, 1, 1],
19+
[1, 4, 1, 1, 4, 4, 1, 1, 5, 4, 1, 5],
20+
];
21+
22+
/// <summary>Gets the section letter-pattern (e.g. "AABA").</summary>
23+
public string Pattern { get; private init; } = "";
24+
25+
/// <summary>Gets the per-section letters, aligned to arrangement order.</summary>
26+
public IReadOnlyList<char> Letters { get; private init; } = [];
27+
28+
/// <summary>Gets the recognized named form, or null.</summary>
29+
public NamedForm? Name { get; private init; }
30+
31+
/// <summary>Derives the form of an arrangement from its sections.</summary>
32+
/// <param name="arrangement">The arrangement to analyze.</param>
33+
/// <returns>The derived form.</returns>
34+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="arrangement"/> is null.</exception>
35+
public static Form Of(Arrangement arrangement)
36+
{
37+
Ensure.NotNull(arrangement);
38+
IReadOnlyList<Section> sections = arrangement.Sections;
39+
40+
List<char> letters = [];
41+
List<Section> representatives = [];
42+
foreach (Section section in sections)
43+
{
44+
int index = representatives.FindIndex(representative => representative.IsSameStructure(section));
45+
if (index < 0)
46+
{
47+
representatives.Add(section);
48+
index = representatives.Count - 1;
49+
}
50+
51+
letters.Add((char)('A' + index));
52+
}
53+
54+
string pattern = new([.. letters]);
55+
NamedForm name = sections.Any(section => IsTwelveBarBlues(section, section.Key ?? arrangement.Key))
56+
? NamedForm.TwelveBarBlues
57+
: RecognizePattern(pattern);
58+
59+
return new() { Pattern = pattern, Letters = letters, Name = name };
60+
}
61+
62+
/// <summary>Builds a form directly from a letter-pattern string, applying letter recognition only.</summary>
63+
/// <param name="pattern">A pattern of letters A-Z (e.g. "ABACA").</param>
64+
/// <returns>The form for the pattern.</returns>
65+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="pattern"/> is null.</exception>
66+
/// <exception cref="FormatException">Thrown when the pattern is empty or contains a non-letter.</exception>
67+
public static Form FromPattern(string pattern)
68+
{
69+
Ensure.NotNull(pattern);
70+
if (pattern.Length == 0)
71+
{
72+
throw new FormatException("Form pattern is empty.");
73+
}
74+
75+
if (!pattern.All(c => c is >= 'A' and <= 'Z'))
76+
{
77+
throw new FormatException($"Form pattern '{pattern}' must contain only upper-case letters.");
78+
}
79+
80+
return new() { Pattern = pattern, Letters = [.. pattern], Name = RecognizePattern(pattern) };
81+
}
82+
83+
private static NamedForm RecognizePattern(string pattern) => pattern switch
84+
{
85+
"AABA" => NamedForm.ThirtyTwoBarAABA,
86+
"AB" => NamedForm.Binary,
87+
"ABA" => NamedForm.Ternary,
88+
_ when IsRondo(pattern) => NamedForm.Rondo,
89+
_ when pattern.Length > 1 && pattern.All(letter => letter == pattern[0]) => NamedForm.Strophic,
90+
_ when pattern.Distinct().Count() == pattern.Length => NamedForm.ThroughComposed,
91+
_ => NamedForm.Unknown,
92+
};
93+
94+
private static bool IsRondo(string pattern)
95+
{
96+
if (pattern.Length < 5 || pattern.Length % 2 == 0)
97+
{
98+
return false;
99+
}
100+
101+
for (int i = 0; i < pattern.Length; i++)
102+
{
103+
bool refrainPosition = i % 2 == 0;
104+
if (refrainPosition != (pattern[i] == 'A'))
105+
{
106+
return false;
107+
}
108+
}
109+
110+
return true;
111+
}
112+
113+
private static bool IsTwelveBarBlues(Section section, Key key)
114+
{
115+
IReadOnlyList<ChordEvent> chords = section.Progression.Chords;
116+
if (chords.Count != 12)
117+
{
118+
return false;
119+
}
120+
121+
int[] degrees = new int[12];
122+
for (int i = 0; i < 12; i++)
123+
{
124+
ScaleDegree degree = key.FunctionOf(chords[i].Chord.Root);
125+
if (degree.Alteration != 0)
126+
{
127+
return false;
128+
}
129+
130+
degrees[i] = degree.Degree;
131+
}
132+
133+
return BluesTemplates.Any(template => template.SequenceEqual(degrees));
134+
}
135+
}

0 commit comments

Comments
 (0)