From ca311b2b6d5eb6dd43d02df2338f6870275a3505 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 17:25:23 +1000 Subject: [PATCH 01/16] docs(music): design spec for analysis aggregate layer Phase 1 reframed around analysis: Progression + functional-harmony analysis (roman numerals, function, cadences, key inference, chromatic identification) nested inside Section -> Arrangement, with Form as the extracted structural pattern. Added to Semantics.Music; playback/notation concerns explicitly out of scope. --- ...6-07-01-music-analysis-aggregate-design.md | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-music-analysis-aggregate-design.md diff --git a/docs/superpowers/specs/2026-07-01-music-analysis-aggregate-design.md b/docs/superpowers/specs/2026-07-01-music-analysis-aggregate-design.md new file mode 100644 index 0000000..7d6701f --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-music-analysis-aggregate-design.md @@ -0,0 +1,294 @@ +# Music analysis aggregate layer — design + +Status: approved (2026-07-01). Phase 1 of `docs/roadmap-semantic-domains.md`, scoped to **analysis**. +Read alongside the existing `Semantics.Music` primitives and `CLAUDE.md`. + +## Motivation + +`Semantics.Music` ships a deep set of primitives — `Pitch`, `PitchClass`, `Interval`, `Scale`/`Mode`, +`Key` (with roman-numeral ↔ chord resolution), `Chord` (rich parse/voice/transpose), `Duration`, +`TimeSignature`, `Tempo`, `Velocity`, and `Note`/`Rest` as `IMusicalEvent`s — but nothing above the +single event. There is no way to hold a chord *progression*, label the *sections* of a piece, describe +its *arrangement*, or name its *form*. + +The roadmap's Phase 1 sketched a playback/notation container layer (`Score → Track → Measure → Voice`, +plus `Tuning`, `Clef`, `Articulation`, `Dynamic`). This spec deliberately reframes Phase 1 around +**analysis** instead: the container layer models *harmony nested inside structure* and computes +functional-harmony and formal analysis. Playback and notation concerns (tempo/seconds timing, tuning +systems, clefs, articulations, dynamics) are **out of scope** for this work. + +## Scope + +In scope — four container types plus their analyses, added to `Semantics.Music`: + +``` +Arrangement the piece's roadmap: an ordered realization of sections (repeats allowed) + └─ Section a labeled unit — Intro / Verse / Chorus / Bridge / Solo / Coda … + └─ Progression the harmonic content: an ordered chord sequence with harmonic rhythm +Form the abstract pattern extracted from the sections — AABA, 12-bar blues, ternary … +``` + +The five analyses live on `Progression`: + +1. Batch **roman-numeral** labeling relative to a `Key`. +2. **Functional classification** (tonic / predominant / dominant / chromatic). +3. **Cadence detection** (authentic / plagal / half / deceptive). +4. **Key inference** (rank candidate keys by diatonic fit). +5. **Chromatic identification** (secondary dominant / borrowed / Neapolitan / chromatic). + +Out of scope: real-time (tempo/seconds) timing, tuning/temperament systems, clefs, key signatures as +engraving objects, articulations, dynamics, `Score`/`Track`/`Measure`/`Voice` performance containers, +and any MIDI/MusicXML I/O. + +## Conventions + +- All types added to the existing `Semantics.Music` package (namespace `ktsu.Semantics.Music`); the + target matrix is unchanged (`net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1`). No new package, + no new dependency — the analysis layer builds only on the existing primitives. +- Every new type is an immutable `sealed record` with static `Create`/`Parse` factories and + `Ensure.NotNull` guards, matching the existing Music primitives. +- Error handling follows the local Music convention: `FormatException` for parse failures, + `ArgumentOutOfRangeException` for out-of-range values, `ArgumentException` for other validation. + Empty collections are rejected (a `Progression`, `Section`, and `Arrangement` must each hold at + least one element). +- File headers, XML documentation on all public APIs, tabs, CRLF + UTF-8-no-BOM + trailing newline. +- Tests follow repo conventions: MSTest, `Assert.ThrowsExactly`, explicit types (no `var`), no + `using System;`/MSTest usings in test files (global usings cover them), semantic asserts. + +## Layer 1 — `Progression` + +### `ChordEvent` + +```csharp +public sealed record ChordEvent : IMusicalEvent +{ + public Chord Chord { get; } + public Duration Duration { get; } // harmonic rhythm — how long this chord sounds + public static ChordEvent Create(Chord chord, Duration duration); +} +``` + +Implements the existing `IMusicalEvent` (which requires only `Duration`), so a chord change slots into +the same rhythmic contract as `Note`/`Rest`. + +### `Progression` + +```csharp +public sealed record Progression +{ + public IReadOnlyList Chords { get; } + public TimeSignature TimeSignature { get; } // default 4/4 — interprets durations as bars/beats + public Duration TotalDuration { get; } + public double TotalBars { get; } + + public static Progression Create(IEnumerable chords); + public static Progression Create(IEnumerable chords, TimeSignature timeSignature); + public static Progression Create(IEnumerable chords, Duration each); // uniform harmonic rhythm + public static Progression Parse(string text); // "Dm7 | G7 | Cmaj7" + public static Progression Parse(string text, TimeSignature timeSignature); +} +``` + +`Parse` is the ergonomic analysis entry point: `|` is a barline, spaces separate chords within a bar, +and a bar's chords split its `BarDuration` evenly. Each token is parsed by the existing `Chord.Parse`. +`"Dm7 | G7 | Cmaj7"` in 4/4 yields three whole-bar chords; `"C G | Am F"` yields four half-bar chords. + +### Analysis API on `Progression` + +```csharp +public IReadOnlyList RomanNumerals(Key key); +public IReadOnlyList Functions(Key key); +public IReadOnlyList Cadences(Key key); +public IReadOnlyList InferKeys(); +public Key? InferKey(); +public IReadOnlyList ChromaticChords(Key key); +``` + +**Roman numerals** — one per `ChordEvent`, delegating to the existing `Key.RomanNumeralOf(Chord)`. + +**Functional classification** + +```csharp +public enum HarmonicFunction { Tonic, Predominant, Dominant, Chromatic } +``` + +Assigned by the chord root's scale degree in `key` (via `Key.FunctionOf`): + +| Degree (diatonic) | Function | +|---|---| +| I, iii, vi | Tonic | +| ii, IV | Predominant | +| V, vii° | Dominant | +| non-diatonic root | Chromatic | + +**Cadence detection** + +```csharp +public enum Cadence { Authentic, Plagal, Half, Deceptive } +public sealed record CadenceInstance // Index = position of the resolution chord +{ + public int Index { get; } + public Cadence Type { get; } +} +``` + +Classified from the functional motion of each adjacent chord pair (degree-based, so it does not depend +on seventh/extension colour): + +| Motion (root degrees) | Cadence | +|---|---| +| V → I | Authentic | +| IV → I | Plagal | +| any → V (as the phrase target) | Half | +| V → vi | Deceptive | + +`Half` is reported when the pair *ends* on V (a dominant arrival); the four types are mutually +exclusive per pair, tested in the order Authentic, Deceptive, Plagal, Half. PAC/IAC are **not** +distinguished — that needs soprano/voice-leading information the model does not carry. + +**Key inference** + +```csharp +public sealed record KeyMatch { public Key Key { get; } public double Score { get; } } +``` + +Scores every candidate `Key` — 12 tonics × {major (`Mode.Major`), natural minor (`Mode.Aeolian`)} — by +the fraction of chords whose root is diatonic to that key (ties broken by preferring a tonic-rooted +first/last chord, then major over minor). `InferKeys()` returns matches ranked by descending score; +`InferKey()` returns the single best (or `null` for a degenerate all-chromatic input). + +**Chromatic identification** + +```csharp +public enum ChromaticKind { SecondaryDominant, BorrowedChord, Neapolitan, AugmentedSixth, Chromatic } +public sealed record ChromaticAnalysis // Index = chord position +{ + public int Index { get; } + public ChromaticKind Kind { get; } + public string? Detail { get; } // e.g. "V/V" for a secondary dominant of the dominant +} +``` + +Only chords **not** diatonic to `key` are analyzed. Classification, in priority order: + +- **SecondaryDominant** — a dominant-quality chord (major triad or dominant 7th) whose root is a + perfect fifth above a diatonic degree of `key`; `Detail` names the target (e.g. `"V/V"`, `"V/ii"`). +- **Neapolitan** — a major triad on the lowered second degree (`bII`). +- **BorrowedChord** — otherwise diatonic to the *parallel* mode (major↔minor) of the same tonic; + `Detail` names the source (e.g. `"iv (from parallel minor)"`). +- **Chromatic** — anything else (the honest fallback). + +`AugmentedSixth` is declared in the enum for completeness but detection is a **stretch goal**: the +current `Chord` model stores pitch classes and quality, not the specific interval spelling +(e.g. the augmented sixth against the bass) needed to identify It/Fr/Ger sixths reliably. The initial +implementation will not emit `AugmentedSixth`; such chords fall through to `Chromatic`. This limitation +is documented rather than faked. + +## Layer 2 — `Section` + +```csharp +public enum SectionType +{ + Intro, Verse, PreChorus, Chorus, PostChorus, Bridge, Solo, Interlude, Refrain, Outro, Coda, Other +} + +public sealed record Section +{ + public SectionType Type { get; } + public string? Label { get; } // human label distinguishing repeats, e.g. "Verse 1" + public Progression Progression { get; } + public Key? Key { get; } // optional local key for a section that modulates + public double Bars { get; } // derived from Progression.TotalBars + + public static Section Create(SectionType type, Progression progression, string? label = null, Key? key = null); + + public bool IsSameStructure(Section other); // Type + progression harmonic content; ignores Label/Key +} +``` + +`IsSameStructure` is what `Form` groups on: two sections share a form-letter when they have the same +`Type` and the same ordered chord content (ignoring `Label` and local `Key`). Record value-equality +still exists but includes `Label`/`Key`, so it is not used for form grouping. + +## Layer 3 — `Arrangement` + +```csharp +public sealed record Arrangement +{ + public Key Key { get; } // home key for whole-piece analysis + public IReadOnlyList
Sections { get; } // in performance order; repeats = repeated entries + public double TotalBars { get; } + public Form Form { get; } // => Form.Of(this) + + public static Arrangement Create(Key key, IEnumerable
sections); +} +``` + +The `Arrangement` is the top-level analysis container. Repetition and song-map semantics are expressed +by repeating `Section` entries in order (e.g. Verse, Chorus, Verse, Chorus, Bridge, Chorus). Each +section analyzes against its own `Key` if set, otherwise the arrangement `Key`. + +## Layer 4 — `Form` + +```csharp +public enum NamedForm +{ + ThirtyTwoBarAABA, TwelveBarBlues, VerseChorus, Binary, Ternary, Rondo, Strophic, ThroughComposed, Unknown +} + +public sealed record Form +{ + public string Pattern { get; } // e.g. "AABA" + public IReadOnlyList Letters { get; } // per-section, aligned to arrangement order + public NamedForm? Name { get; } + + public static Form Of(Arrangement arrangement); + public static Form FromPattern(string pattern); +} +``` + +`Pattern` is assigned by walking the arrangement's sections in order and giving each distinct +`IsSameStructure` group the next unused letter (A, B, C…). `NamedForm` recognition combines two kinds +of template: + +- **Section-letter patterns** — `Binary` = `AB`, `Ternary` = `ABA`, `ThirtyTwoBarAABA` = `AABA` + (with the four sections each ~8 bars), `Rondo` = `ABACA`/`ABACABA`, `Strophic` = all identical + (`AAA…`), `ThroughComposed` = all distinct (no letter repeats), else `Unknown`. +- **Progression-template match** — `TwelveBarBlues` is recognized when a section's progression matches + the 12-bar blues roman-numeral template (I–I–I–I–IV–IV–I–I–V–IV–I–V, allowing common variants) over + 12 bars; this is a *progression* form, so it is detected from a section's harmonic content rather + than the section-letter pattern. + +`FromPattern` builds a `Form` directly from a letter string (for callers who have a pattern but no +arrangement) and applies the section-letter recognition only. + +## Testing + +New test files under `Semantics.Test/Music/`, each with concrete musical examples: + +- `ProgressionTests` — construction, `Parse` bar syntax, `TotalBars`, empty rejection. +- `HarmonicFunctionTests` — I/ii/V/vi function mapping in major and minor. +- `CadenceTests` — authentic (V–I), plagal (IV–I), half (→V), deceptive (V–vi) in C major. +- `KeyInferenceTests` — ii–V–I in C infers C major; a diatonic minor progression infers the minor key. +- `ChromaticAnalysisTests` — secondary dominant (D7→G7 in C = V/V), borrowed iv (Fm in C), Neapolitan + (Db major = bII in C), and a chromatic fallback. +- `SectionTests` — `IsSameStructure` equivalence ignoring `Label`/`Key`; bar length. +- `ArrangementTests` — ordered sections, per-section key override, `TotalBars`. +- `FormTests` — `AABA` pattern + `ThirtyTwoBarAABA`, `ABA` ternary, `AB` binary, strophic, 12-bar blues + progression-template recognition, `FromPattern`. + +## Documentation + +Finish with the `update-docs` skill: extend the README Music section with the aggregate/analysis layer, +add a CLAUDE.md project-layout note that `Semantics.Music` now includes the analysis containers, and +refresh DESCRIPTION/TAGS as needed. + +## Implementation sequencing + +One spec, one branch, milestoned in the plan: + +1. `Progression` + `ChordEvent` + roman numerals + functional classification + cadence detection. +2. Key inference + chromatic identification. +3. `Section` + `Arrangement` + `Form` (section-letter recognition, then the 12-bar-blues template). + +Each milestone lands with its tests green before the next begins. From 5e004cbd2be4fc5fea2eac3766de28d375068054 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 17:35:39 +1000 Subject: [PATCH 02/16] docs(music): implementation plan for analysis aggregate layer 11 tasks across 3 milestones (Progression+roman/function/cadence, key inference+chromatic ID, Section/Arrangement/Form), TDD steps with complete code and per-task commits. --- .../2026-07-01-music-analysis-aggregate.md | 1838 +++++++++++++++++ 1 file changed, 1838 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md diff --git a/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md b/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md new file mode 100644 index 0000000..be77fd8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md @@ -0,0 +1,1838 @@ +# Music Analysis Aggregate Layer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an analysis-oriented aggregate layer to `Semantics.Music` — `Progression` (chords + functional-harmony analysis), `Section`, `Arrangement`, and `Form` — building only on the existing primitives. + +**Architecture:** Four immutable `sealed record` container types in the existing `ktsu.Semantics.Music` namespace. `Progression` is a partial record whose analyses (roman numerals, functional classification, cadences, key inference, chromatic identification) are split across focused partial files. `Section` wraps a `Progression` with a role; `Arrangement` orders sections; `Form` derives the structural letter-pattern and recognizes named forms. No new package, no new dependency. + +**Tech Stack:** C# (LangVersion ≥ 12 — collection expressions and list patterns are already in use), MSTest, `Ensure.NotNull` from Polyfill. Multi-target `net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1`. + +## Global Constraints + +- All types added to the existing `Semantics.Music/` project; namespace `ktsu.Semantics.Music`. No `.csproj` changes; the test project already references `Semantics.Music`. +- File header on every source file: + ```csharp + // Copyright (c) ktsu.dev + // All rights reserved. + // Licensed under the MIT license. + ``` +- File-scoped namespace; `using` directives **inside** the namespace (match existing Music files). +- Tabs for indentation; CRLF line endings; UTF-8 **no BOM**; trailing newline. (The Write tool emits LF — after writing any `.cs` file, verify it is CRLF + no-BOM: first two bytes `2f 2f`, last two bytes `0d 0a`.) +- Immutable `sealed record` with static `Create`/`Parse` factories; `Ensure.NotNull` on reference parameters; no `this.` qualifiers; explicit accessibility; nullable enabled; warnings-as-errors. +- Error handling: `FormatException` for parse failures, `ArgumentOutOfRangeException` for out-of-range values, `ArgumentException` for other validation. A `Progression`, `Section`, and `Arrangement` must each be non-empty. +- Tests: MSTest, `Assert.ThrowsExactly` (never `Assert.ThrowsException`), explicit types (no `var`), **no** `using System;` or MSTest usings in test files (global usings cover them), only `using ktsu.Semantics.Music;`. Use semantic asserts (`Assert.AreEqual`, `CollectionAssert`) not `Assert.IsTrue` on equality. +- Build after each task with `dotnet build` and run tests with `dotnet test`. Multi-target builds are slow — allow generous timeouts. + +--- + +## Milestone 1 — Progression core + roman numerals, functions, cadences + +### Task 1: `ChordEvent` + +**Files:** +- Create: `Semantics.Music/ChordEvent.cs` +- Test: `Semantics.Test/Music/ProgressionTests.cs` (created here, extended in later tasks) + +**Interfaces:** +- Consumes: existing `Chord`, `Duration`, `IMusicalEvent`. +- Produces: `ChordEvent.Create(Chord chord, Duration duration) -> ChordEvent`; properties `Chord Chord`, `Duration Duration`. + +- [ ] **Step 1: Write the failing test** + +```csharp +// Semantics.Test/Music/ProgressionTests.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class ProgressionTests +{ + [TestMethod] + public void ChordEvent_BundlesChordAndDuration_AndIsMusicalEvent() + { + ChordEvent chordEvent = ChordEvent.Create(Chord.Parse("Cmaj7"), Duration.Half); + Assert.AreEqual(0, chordEvent.Chord.Root.Value); + Assert.AreEqual(Duration.Half, chordEvent.Duration); + IMusicalEvent asEvent = chordEvent; + Assert.AreEqual(Duration.Half, asEvent.Duration); + } + + [TestMethod] + public void ChordEvent_RejectsNullChord() => + _ = Assert.ThrowsExactly(() => ChordEvent.Create(null!, Duration.Half)); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test --filter "FullyQualifiedName~ProgressionTests"` +Expected: FAIL — `ChordEvent` does not exist (compile error). + +- [ ] **Step 3: Write minimal implementation** + +```csharp +// Semantics.Music/ChordEvent.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// +/// A harmonic event: a chord sounding for a rhythmic duration (the harmonic rhythm). +/// +public sealed record ChordEvent : IMusicalEvent +{ + /// Gets the chord that sounds. + public Chord Chord { get; private init; } = new(); + + /// Gets the rhythmic duration for which the chord sounds. + public Duration Duration { get; private init; } = Duration.Quarter; + + /// Creates a chord event from a chord and a duration. + /// The chord. + /// The rhythmic duration. + /// A new chord event. + /// Thrown when an argument is null. + public static ChordEvent Create(Chord chord, Duration duration) + { + Ensure.NotNull(chord); + Ensure.NotNull(duration); + return new() { Chord = chord, Duration = duration }; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test --filter "FullyQualifiedName~ProgressionTests"` +Expected: PASS. + +- [ ] **Step 5: Verify line endings, then commit** + +Confirm `Semantics.Music/ChordEvent.cs` is CRLF + no-BOM (first bytes `2f 2f`, last `0d 0a`). + +```bash +git add Semantics.Music/ChordEvent.cs Semantics.Test/Music/ProgressionTests.cs +git commit -m "feat(music): add ChordEvent harmonic event type" +``` + +--- + +### Task 2: `Progression` core (construction, totals, empty rejection) + +**Files:** +- Create: `Semantics.Music/Progression.cs` +- Test: `Semantics.Test/Music/ProgressionTests.cs` (extend) + +**Interfaces:** +- Consumes: `ChordEvent`, `Chord`, `Duration`, `TimeSignature`. +- Produces (partial record `Progression`): + - `IReadOnlyList Chords`, `TimeSignature TimeSignature`, `Duration TotalDuration`, `double TotalBars`. + - `Create(IEnumerable) -> Progression` + - `Create(IEnumerable, TimeSignature) -> Progression` + - `Create(IEnumerable, Duration each) -> Progression` + +- [ ] **Step 1: Write the failing test** (append inside `ProgressionTests`) + +```csharp + [TestMethod] + public void Progression_TotalBars_SumsHarmonicRhythmAgainstTimeSignature() + { + Progression progression = Progression.Create( + [ + ChordEvent.Create(Chord.Parse("C"), Duration.Whole), + ChordEvent.Create(Chord.Parse("G"), Duration.Whole), + ]); + Assert.AreEqual(2.0, progression.TotalBars, 1e-9); + Assert.AreEqual(2, progression.Chords.Count); + } + + [TestMethod] + public void Progression_Create_FromChordsWithUniformRhythm() + { + Progression progression = Progression.Create( + [Chord.Parse("Dm7"), Chord.Parse("G7"), Chord.Parse("Cmaj7")], Duration.Whole); + Assert.AreEqual(3.0, progression.TotalBars, 1e-9); + } + + [TestMethod] + public void Progression_RejectsEmpty() => + _ = Assert.ThrowsExactly(() => Progression.Create(System.Array.Empty())); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test --filter "FullyQualifiedName~ProgressionTests"` +Expected: FAIL — `Progression` does not exist. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +// Semantics.Music/Progression.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// +/// An ordered chord sequence with a harmonic rhythm, plus functional-harmony analysis. +/// +public sealed partial record Progression +{ + /// Gets the ordered chord events (the harmonic content). + public IReadOnlyList Chords { get; private init; } = []; + + /// Gets the time signature used to interpret durations as bars and beats. + public TimeSignature TimeSignature { get; private init; } = TimeSignature.Create(4, 4); + + /// Gets the total rhythmic length as a fraction of a whole note. + public Duration TotalDuration => + Chords.Aggregate(Duration.Create(0, 1), (sum, chordEvent) => sum.Add(chordEvent.Duration)); + + /// Gets the total length measured in bars of the time signature. + public double TotalBars => TotalDuration.AsWholeNotes / TimeSignature.BarDuration.AsWholeNotes; + + /// Creates a progression in 4/4 from chord events. + /// The ordered chord events; must be non-empty. + /// A new progression. + /// Thrown when is null. + /// Thrown when is empty. + public static Progression Create(IEnumerable chords) => Create(chords, TimeSignature.Create(4, 4)); + + /// Creates a progression from chord events with an explicit time signature. + /// The ordered chord events; must be non-empty. + /// The time signature. + /// A new progression. + /// Thrown when an argument is null. + /// Thrown when is empty. + public static Progression Create(IEnumerable chords, TimeSignature timeSignature) + { + Ensure.NotNull(chords); + Ensure.NotNull(timeSignature); + List list = [.. chords]; + if (list.Count == 0) + { + throw new ArgumentException("A progression must contain at least one chord.", nameof(chords)); + } + + return new() { Chords = list, TimeSignature = timeSignature }; + } + + /// Creates a progression from chords that all share one rhythmic duration. + /// The ordered chords; must be non-empty. + /// The duration applied to every chord. + /// A new progression in 4/4. + /// Thrown when an argument is null. + /// Thrown when is empty. + public static Progression Create(IEnumerable chords, Duration each) + { + Ensure.NotNull(chords); + Ensure.NotNull(each); + return Create([.. chords.Select(chord => ChordEvent.Create(chord, each))]); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test --filter "FullyQualifiedName~ProgressionTests"` +Expected: PASS. + +- [ ] **Step 5: Verify line endings, then commit** + +```bash +git add Semantics.Music/Progression.cs Semantics.Test/Music/ProgressionTests.cs +git commit -m "feat(music): add Progression core (construction, totals, empty rejection)" +``` + +--- + +### Task 3: `Progression.Parse` (bar-delimited chord syntax) + +**Files:** +- Create: `Semantics.Music/Progression.Parse.cs` +- Test: `Semantics.Test/Music/ProgressionTests.cs` (extend) + +**Interfaces:** +- Consumes: `Progression.Create`, `ChordEvent.Create`, `Chord.Parse`, `TimeSignature`, `Duration`. +- Produces: `Progression.Parse(string) -> Progression`; `Progression.Parse(string, TimeSignature) -> Progression`. + +- [ ] **Step 1: Write the failing test** (append inside `ProgressionTests`) + +```csharp + [TestMethod] + public void Parse_OneChordPerBar_FillsWholeBars() + { + Progression progression = Progression.Parse("Dm7 | G7 | Cmaj7"); + Assert.AreEqual(3, progression.Chords.Count); + Assert.AreEqual(Duration.Whole, progression.Chords[0].Duration); + Assert.AreEqual(3.0, progression.TotalBars, 1e-9); + } + + [TestMethod] + public void Parse_TwoChordsPerBar_SplitBarEvenly() + { + Progression progression = Progression.Parse("C G | Am F"); + Assert.AreEqual(4, progression.Chords.Count); + Assert.AreEqual(Duration.Half, progression.Chords[0].Duration); + Assert.AreEqual(2.0, progression.TotalBars, 1e-9); + } + + [TestMethod] + public void Parse_ToleratesLeadingAndTrailingBarlines() + { + Progression progression = Progression.Parse("| C | G |"); + Assert.AreEqual(2, progression.Chords.Count); + } + + [TestMethod] + public void Parse_RejectsEmptyBar() => + _ = Assert.ThrowsExactly(() => Progression.Parse("C || G")); + + [TestMethod] + public void Parse_RejectsEmptyText() => + _ = Assert.ThrowsExactly(() => Progression.Parse(" ")); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test --filter "FullyQualifiedName~ProgressionTests"` +Expected: FAIL — `Parse` not defined. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +// Semantics.Music/Progression.Parse.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System; +using System.Collections.Generic; + +public sealed partial record Progression +{ + /// Parses a bar-delimited chord progression in 4/4. + /// Chord symbols with | as a barline and spaces separating chords in a bar. + /// The parsed progression. + /// Thrown when is null. + /// Thrown when the text is empty or a bar is empty. + public static Progression Parse(string text) => Parse(text, TimeSignature.Create(4, 4)); + + /// Parses a bar-delimited chord progression with an explicit time signature. + /// Chord symbols with | as a barline and spaces separating chords in a bar. + /// The time signature; a bar's chords split its bar duration evenly. + /// The parsed progression. + /// Thrown when an argument is null. + /// Thrown when the text is empty or a bar is empty. + public static Progression Parse(string text, TimeSignature timeSignature) + { + Ensure.NotNull(text); + Ensure.NotNull(timeSignature); + + string trimmed = text.Trim(); + if (trimmed.Length == 0) + { + throw new FormatException("Progression is empty."); + } + + // Strip one leading and one trailing barline so "| C | G |" parses cleanly. + if (trimmed[0] == '|') + { + trimmed = trimmed[1..]; + } + + if (trimmed.Length > 0 && trimmed[^1] == '|') + { + trimmed = trimmed[..^1]; + } + + Duration barDuration = timeSignature.BarDuration; + List events = []; + foreach (string bar in trimmed.Split('|')) + { + string[] tokens = bar.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length == 0) + { + throw new FormatException("Progression has an empty bar."); + } + + Duration each = Duration.Create(barDuration.Numerator, barDuration.Denominator * tokens.Length); + foreach (string token in tokens) + { + events.Add(ChordEvent.Create(Chord.Parse(token), each)); + } + } + + return Create(events, timeSignature); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test --filter "FullyQualifiedName~ProgressionTests"` +Expected: PASS. + +- [ ] **Step 5: Verify line endings, then commit** + +```bash +git add Semantics.Music/Progression.Parse.cs Semantics.Test/Music/ProgressionTests.cs +git commit -m "feat(music): add Progression.Parse bar-delimited chord syntax" +``` + +--- + +### Task 4: Roman numerals + functional classification + +**Files:** +- Create: `Semantics.Music/HarmonicFunction.cs` +- Create: `Semantics.Music/Progression.Analysis.cs` +- Test: `Semantics.Test/Music/HarmonicFunctionTests.cs` + +**Interfaces:** +- Consumes: `Key.RomanNumeralOf(Chord)`, `Key.FunctionOf(PitchClass) -> ScaleDegree`, `ScaleDegree.Degree`, `ScaleDegree.Alteration`. +- Produces: + - `enum HarmonicFunction { Tonic, Predominant, Dominant, Chromatic }` + - `Progression.RomanNumerals(Key key) -> IReadOnlyList` + - `Progression.Functions(Key key) -> IReadOnlyList` + +- [ ] **Step 1: Write the failing test** + +```csharp +// Semantics.Test/Music/HarmonicFunctionTests.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class HarmonicFunctionTests +{ + private static Key CMajor => Key.Create(PitchClass.Create(0), Mode.Major); + + [TestMethod] + public void RomanNumerals_LabelsTwoFiveOne() + { + Progression progression = Progression.Parse("Dm7 | G7 | Cmaj7"); + System.Collections.Generic.IReadOnlyList numerals = progression.RomanNumerals(CMajor); + CollectionAssert.AreEqual(new[] { "ii7", "V7", "Imaj7" }, (System.Collections.Generic.List)[.. numerals]); + } + + [TestMethod] + public void Functions_ClassifyDiatonicChords() + { + Progression progression = Progression.Parse("C | Dm | G | Am"); + System.Collections.Generic.IReadOnlyList functions = progression.Functions(CMajor); + Assert.AreEqual(HarmonicFunction.Tonic, functions[0]); // I + Assert.AreEqual(HarmonicFunction.Predominant, functions[1]); // ii + Assert.AreEqual(HarmonicFunction.Dominant, functions[2]); // V + Assert.AreEqual(HarmonicFunction.Tonic, functions[3]); // vi + } + + [TestMethod] + public void Functions_MarksChromaticRoot() + { + Progression progression = Progression.Parse("C | Db"); + System.Collections.Generic.IReadOnlyList functions = progression.Functions(CMajor); + Assert.AreEqual(HarmonicFunction.Chromatic, functions[1]); + } +``` + +Close the class: + +```csharp +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test --filter "FullyQualifiedName~HarmonicFunctionTests"` +Expected: FAIL — `HarmonicFunction`/`Functions`/`RomanNumerals` not defined. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +// Semantics.Music/HarmonicFunction.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// The functional role a chord plays within a key. +public enum HarmonicFunction +{ + /// Tonic function (scale degrees I, iii, vi). + Tonic, + + /// Predominant / subdominant function (scale degrees ii, IV). + Predominant, + + /// Dominant function (scale degrees V, vii). + Dominant, + + /// A chromatic (non-diatonic) chord with no diatonic function. + Chromatic, +} +``` + +```csharp +// Semantics.Music/Progression.Analysis.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System.Collections.Generic; +using System.Linq; + +public sealed partial record Progression +{ + /// Returns the roman-numeral function of each chord relative to a key. + /// The key to analyze against. + /// One roman numeral per chord, in order. + /// Thrown when is null. + public IReadOnlyList RomanNumerals(Key key) + { + Ensure.NotNull(key); + return [.. Chords.Select(chordEvent => key.RomanNumeralOf(chordEvent.Chord))]; + } + + /// Returns the harmonic function of each chord relative to a key. + /// The key to analyze against. + /// One function per chord, in order. + /// Thrown when is null. + public IReadOnlyList Functions(Key key) + { + Ensure.NotNull(key); + return [.. Chords.Select(chordEvent => FunctionOf(key, chordEvent.Chord))]; + } + + /// Classifies a single chord's function in a key by its root scale degree. + private static HarmonicFunction FunctionOf(Key key, Chord chord) + { + ScaleDegree degree = key.FunctionOf(chord.Root); + if (degree.Alteration != 0) + { + return HarmonicFunction.Chromatic; + } + + return degree.Degree switch + { + 1 or 3 or 6 => HarmonicFunction.Tonic, + 2 or 4 => HarmonicFunction.Predominant, + 5 or 7 => HarmonicFunction.Dominant, + _ => HarmonicFunction.Chromatic, + }; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test --filter "FullyQualifiedName~HarmonicFunctionTests"` +Expected: PASS. + +- [ ] **Step 5: Verify line endings, then commit** + +```bash +git add Semantics.Music/HarmonicFunction.cs Semantics.Music/Progression.Analysis.cs Semantics.Test/Music/HarmonicFunctionTests.cs +git commit -m "feat(music): add roman-numeral labeling and functional classification" +``` + +--- + +### Task 5: Cadence detection + +**Files:** +- Create: `Semantics.Music/Cadence.cs` +- Create: `Semantics.Music/CadenceInstance.cs` +- Create: `Semantics.Music/Progression.Cadences.cs` +- Test: `Semantics.Test/Music/CadenceTests.cs` + +**Interfaces:** +- Consumes: `Key.FunctionOf(PitchClass) -> ScaleDegree`. +- Produces: + - `enum Cadence { Authentic, Plagal, Half, Deceptive }` + - `CadenceInstance.Create(int index, Cadence type) -> CadenceInstance`; properties `int Index`, `Cadence Type`. + - `Progression.Cadences(Key key) -> IReadOnlyList` + +- [ ] **Step 1: Write the failing test** + +```csharp +// Semantics.Test/Music/CadenceTests.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class CadenceTests +{ + private static Key CMajor => Key.Create(PitchClass.Create(0), Mode.Major); + + [TestMethod] + public void Cadences_DetectsAuthentic() + { + System.Collections.Generic.IReadOnlyList cadences = + Progression.Parse("G | C").Cadences(CMajor); + Assert.AreEqual(1, cadences.Count); + Assert.AreEqual(Cadence.Authentic, cadences[0].Type); + Assert.AreEqual(1, cadences[0].Index); + } + + [TestMethod] + public void Cadences_DetectsPlagalHalfAndDeceptive() + { + Assert.AreEqual(Cadence.Plagal, Progression.Parse("F | C").Cadences(CMajor)[0].Type); + Assert.AreEqual(Cadence.Half, Progression.Parse("C | G").Cadences(CMajor)[0].Type); + Assert.AreEqual(Cadence.Deceptive, Progression.Parse("G | Am").Cadences(CMajor)[0].Type); + } + + [TestMethod] + public void Cadences_ReportsNoneForNonCadentialMotion() + { + System.Collections.Generic.IReadOnlyList cadences = + Progression.Parse("C | Am").Cadences(CMajor); + Assert.AreEqual(0, cadences.Count); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test --filter "FullyQualifiedName~CadenceTests"` +Expected: FAIL — `Cadence`/`CadenceInstance`/`Cadences` not defined. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +// Semantics.Music/Cadence.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// A harmonic cadence type, classified by the scale-degree motion into the final chord. +public enum Cadence +{ + /// Authentic cadence: V to I. + Authentic, + + /// Plagal cadence: IV to I. + Plagal, + + /// Half cadence: any chord arriving on V. + Half, + + /// Deceptive cadence: V to vi. + Deceptive, +} +``` + +```csharp +// Semantics.Music/CadenceInstance.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System; + +/// A cadence located within a progression at the position of its resolution chord. +public sealed record CadenceInstance +{ + /// Gets the index of the resolution (second) chord of the cadence. + public int Index { get; private init; } + + /// Gets the cadence type. + public Cadence Type { get; private init; } + + /// Creates a cadence instance. + /// The zero-based index of the resolution chord; must be non-negative. + /// The cadence type. + /// A new cadence instance. + /// Thrown when is negative. + public static CadenceInstance Create(int index, Cadence type) + { + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(index), index, "Index must be non-negative."); + } + + return new() { Index = index, Type = type }; + } +} +``` + +```csharp +// Semantics.Music/Progression.Cadences.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System.Collections.Generic; + +public sealed partial record Progression +{ + /// Detects cadences from the scale-degree motion of each adjacent chord pair in a key. + /// The key to analyze against. + /// The cadences found, in order; empty when none are present. + /// Thrown when is null. + public IReadOnlyList Cadences(Key key) + { + Ensure.NotNull(key); + List result = []; + for (int i = 0; i + 1 < Chords.Count; i++) + { + ScaleDegree from = key.FunctionOf(Chords[i].Chord.Root); + ScaleDegree to = key.FunctionOf(Chords[i + 1].Chord.Root); + if (from.Alteration != 0 || to.Alteration != 0) + { + continue; + } + + Cadence? cadence = Classify(from.Degree, to.Degree); + if (cadence is Cadence value) + { + result.Add(CadenceInstance.Create(i + 1, value)); + } + } + + return result; + } + + private static Cadence? Classify(int from, int to) => (from, to) switch + { + (5, 1) => Cadence.Authentic, + (5, 6) => Cadence.Deceptive, + (4, 1) => Cadence.Plagal, + (_, 5) => Cadence.Half, + _ => null, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test --filter "FullyQualifiedName~CadenceTests"` +Expected: PASS. + +- [ ] **Step 5: Verify line endings, then commit** + +```bash +git add Semantics.Music/Cadence.cs Semantics.Music/CadenceInstance.cs Semantics.Music/Progression.Cadences.cs Semantics.Test/Music/CadenceTests.cs +git commit -m "feat(music): add cadence detection" +``` + +- [ ] **Step 6: Milestone 1 checkpoint — full build and test** + +Run: `dotnet build` then `dotnet test` +Expected: solution builds clean across all targets; all tests pass. + +--- + +## Milestone 2 — Key inference + chromatic identification + +### Task 6: Key inference + +**Files:** +- Create: `Semantics.Music/KeyMatch.cs` +- Create: `Semantics.Music/Progression.KeyInference.cs` +- Test: `Semantics.Test/Music/KeyInferenceTests.cs` + +**Interfaces:** +- Consumes: `Key.Create(PitchClass, Mode)`, `Key.Scale`, `Scale.Contains(PitchClass)`, `Key.Tonic`, `Key.Mode`, `Mode.Major`, `Mode.Aeolian`, `PitchClass.Create(int)`. +- Produces: + - `KeyMatch.Create(Key key, double score) -> KeyMatch`; properties `Key Key`, `double Score`. + - `Progression.InferKeys() -> IReadOnlyList` (ranked, best first) + - `Progression.InferKey() -> Key?` + +- [ ] **Step 1: Write the failing test** + +```csharp +// Semantics.Test/Music/KeyInferenceTests.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class KeyInferenceTests +{ + [TestMethod] + public void InferKey_TwoFiveOne_IsCMajor() + { + Key? key = Progression.Parse("Dm7 | G7 | Cmaj7").InferKey(); + Assert.IsNotNull(key); + Assert.AreEqual(0, key.Tonic.Value); + Assert.AreEqual(Mode.Major, key.Mode); + } + + [TestMethod] + public void InferKey_DiatonicMinorProgression_IsAMinor() + { + Key? key = Progression.Parse("Am | Dm | Em | Am").InferKey(); + Assert.IsNotNull(key); + Assert.AreEqual(9, key.Tonic.Value); + Assert.AreEqual(Mode.Aeolian, key.Mode); + } + + [TestMethod] + public void InferKeys_RanksBestFirst_WithTwentyFourCandidates() + { + System.Collections.Generic.IReadOnlyList matches = + Progression.Parse("Dm7 | G7 | Cmaj7").InferKeys(); + Assert.AreEqual(24, matches.Count); + Assert.IsTrue(matches[0].Score >= matches[1].Score); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test --filter "FullyQualifiedName~KeyInferenceTests"` +Expected: FAIL — `KeyMatch`/`InferKey`/`InferKeys` not defined. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +// Semantics.Music/KeyMatch.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// A candidate key paired with its diatonic-fit score for a progression. +public sealed record KeyMatch +{ + /// Gets the candidate key. + public Key Key { get; private init; } = Key.Create(PitchClass.Create(0), Mode.Major); + + /// Gets the fit score in 0..1: the fraction of chords whose root is diatonic to the key. + public double Score { get; private init; } + + /// Creates a key match. + /// The candidate key. + /// The fit score. + /// A new key match. + /// Thrown when is null. + public static KeyMatch Create(Key key, double score) + { + Ensure.NotNull(key); + return new() { Key = key, Score = score }; + } +} +``` + +```csharp +// Semantics.Music/Progression.KeyInference.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System.Collections.Generic; +using System.Linq; + +public sealed partial record Progression +{ + /// Ranks candidate keys (12 tonics in major and natural minor) by diatonic fit. + /// All 24 candidates, best first; ties break toward a tonic-rooted last then first chord, then major. + public IReadOnlyList InferKeys() + { + PitchClass firstRoot = Chords[0].Chord.Root; + PitchClass lastRoot = Chords[^1].Chord.Root; + List matches = []; + foreach (Mode mode in new[] { Mode.Major, Mode.Aeolian }) + { + for (int tonic = 0; tonic < 12; tonic++) + { + Key key = Key.Create(PitchClass.Create(tonic), mode); + Scale scale = key.Scale; + int diatonic = Chords.Count(chordEvent => scale.Contains(chordEvent.Chord.Root)); + matches.Add(KeyMatch.Create(key, (double)diatonic / Chords.Count)); + } + } + + return + [ + .. matches + .OrderByDescending(match => match.Score) + .ThenByDescending(match => match.Key.Tonic.Value == lastRoot.Value) + .ThenByDescending(match => match.Key.Tonic.Value == firstRoot.Value) + .ThenByDescending(match => match.Key.Mode == Mode.Major) + .ThenBy(match => match.Key.Tonic.Value), + ]; + } + + /// Returns the single best-fitting key, or null when no chord root is diatonic to any candidate. + /// The best key, or null for a degenerate all-chromatic input. + public Key? InferKey() + { + KeyMatch best = InferKeys()[0]; + return best.Score > 0.0 ? best.Key : null; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test --filter "FullyQualifiedName~KeyInferenceTests"` +Expected: PASS. + +- [ ] **Step 5: Verify line endings, then commit** + +```bash +git add Semantics.Music/KeyMatch.cs Semantics.Music/Progression.KeyInference.cs Semantics.Test/Music/KeyInferenceTests.cs +git commit -m "feat(music): add key inference by diatonic fit" +``` + +--- + +### Task 7: Chromatic identification + +**Files:** +- Create: `Semantics.Music/ChromaticKind.cs` +- Create: `Semantics.Music/ChromaticAnalysis.cs` +- Create: `Semantics.Music/Progression.Chromatic.cs` +- Test: `Semantics.Test/Music/ChromaticAnalysisTests.cs` + +**Interfaces:** +- Consumes: `Chord.ChordTones() -> IReadOnlyList`, `Chord.Root`, `Chord.Quality` (`ChordQuality.Major`), `Chord.Seventh` (`SeventhType.None`/`SeventhType.Dominant`), `Key.Scale`, `Key.Tonic`, `Key.Mode`, `Scale.Create`, `Scale.Contains`, `Scale.DegreeOf`. +- Produces: + - `enum ChromaticKind { SecondaryDominant, BorrowedChord, Neapolitan, AugmentedSixth, Chromatic }` + - `ChromaticAnalysis.Create(int index, ChromaticKind kind, string? detail) -> ChromaticAnalysis`; properties `int Index`, `ChromaticKind Kind`, `string? Detail`. + - `Progression.ChromaticChords(Key key) -> IReadOnlyList` + +- [ ] **Step 1: Write the failing test** + +```csharp +// Semantics.Test/Music/ChromaticAnalysisTests.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class ChromaticAnalysisTests +{ + private static Key CMajor => Key.Create(PitchClass.Create(0), Mode.Major); + + [TestMethod] + public void ChromaticChords_SkipsDiatonicChords() + { + System.Collections.Generic.IReadOnlyList analyses = + Progression.Parse("C | Dm | G7 | C").ChromaticChords(CMajor); + Assert.AreEqual(0, analyses.Count); + } + + [TestMethod] + public void ChromaticChords_DetectsSecondaryDominantOfDominant() + { + // D7 in C is V/V (resolving toward G, the dominant). + System.Collections.Generic.IReadOnlyList analyses = + Progression.Parse("C | D7 | G7 | C").ChromaticChords(CMajor); + Assert.AreEqual(1, analyses.Count); + Assert.AreEqual(ChromaticKind.SecondaryDominant, analyses[0].Kind); + Assert.AreEqual("V/V", analyses[0].Detail); + Assert.AreEqual(1, analyses[0].Index); + } + + [TestMethod] + public void ChromaticChords_DetectsNeapolitan() + { + System.Collections.Generic.IReadOnlyList analyses = + Progression.Parse("C | Db | G7").ChromaticChords(CMajor); + Assert.AreEqual(ChromaticKind.Neapolitan, analyses[0].Kind); + Assert.AreEqual("bII", analyses[0].Detail); + } + + [TestMethod] + public void ChromaticChords_DetectsBorrowedMinorSubdominant() + { + // Fm in C major is iv borrowed from the parallel minor. + System.Collections.Generic.IReadOnlyList analyses = + Progression.Parse("C | Fm | C").ChromaticChords(CMajor); + Assert.AreEqual(ChromaticKind.BorrowedChord, analyses[0].Kind); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test --filter "FullyQualifiedName~ChromaticAnalysisTests"` +Expected: FAIL — chromatic types/method not defined. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +// Semantics.Music/ChromaticKind.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// The category of a non-diatonic (chromatic) chord within a key. +public enum ChromaticKind +{ + /// A secondary dominant: a dominant-quality chord tonicizing a diatonic degree. + SecondaryDominant, + + /// A borrowed chord: diatonic to the parallel mode of the same tonic (modal interchange). + BorrowedChord, + + /// A Neapolitan: a major triad on the lowered second degree. + Neapolitan, + + /// + /// 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. + /// + AugmentedSixth, + + /// A chromatic chord with no more specific classification. + Chromatic, +} +``` + +```csharp +// Semantics.Music/ChromaticAnalysis.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System; + +/// The chromatic classification of a chord at a position within a progression. +public sealed record ChromaticAnalysis +{ + /// Gets the zero-based index of the chord within the progression. + public int Index { get; private init; } + + /// Gets the chromatic category. + public ChromaticKind Kind { get; private init; } + + /// Gets an optional human-readable detail (e.g. "V/V", "bII"), or null. + public string? Detail { get; private init; } + + /// Creates a chromatic analysis result. + /// The zero-based chord index; must be non-negative. + /// The chromatic category. + /// An optional descriptive detail. + /// A new chromatic analysis result. + /// Thrown when is negative. + public static ChromaticAnalysis Create(int index, ChromaticKind kind, string? detail) + { + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(index), index, "Index must be non-negative."); + } + + return new() { Index = index, Kind = kind, Detail = detail }; + } +} +``` + +```csharp +// Semantics.Music/Progression.Chromatic.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System.Collections.Generic; +using System.Linq; + +public sealed partial record Progression +{ + private static readonly string[] MajorDegreeRomans = ["I", "ii", "iii", "IV", "V", "vi", "vii°"]; + private static readonly string[] MinorDegreeRomans = ["i", "ii°", "III", "iv", "v", "VI", "VII"]; + + /// Classifies each non-diatonic chord in the progression relative to a key. + /// The key to analyze against. + /// One entry per non-diatonic chord; diatonic chords are omitted. + /// Thrown when is null. + public IReadOnlyList ChromaticChords(Key key) + { + Ensure.NotNull(key); + List result = []; + for (int i = 0; i < Chords.Count; i++) + { + Chord chord = Chords[i].Chord; + if (IsDiatonic(key, chord)) + { + continue; + } + + (ChromaticKind kind, string? detail) = ClassifyChromatic(key, chord); + result.Add(ChromaticAnalysis.Create(i, kind, detail)); + } + + return result; + } + + private static bool IsDiatonic(Key key, Chord chord) + { + Scale scale = key.Scale; + return chord.ChordTones().All(offset => scale.Contains(PitchClass.Create(chord.Root.Value + offset))); + } + + private static (ChromaticKind Kind, string? Detail) ClassifyChromatic(Key key, Chord chord) + { + // Secondary dominant: a major triad or dominant seventh a perfect fifth above a diatonic degree. + bool dominantQuality = chord.Quality == ChordQuality.Major + && chord.Seventh is SeventhType.None or SeventhType.Dominant; + if (dominantQuality) + { + ScaleDegree target = key.Scale.DegreeOf(PitchClass.Create(chord.Root.Value - 7)); + if (target.Alteration == 0 && target.Degree != 1) + { + string[] table = key.Mode == Mode.Major ? MajorDegreeRomans : MinorDegreeRomans; + return (ChromaticKind.SecondaryDominant, "V/" + table[(target.Degree - 1) % 7]); + } + } + + // Neapolitan: a major triad on the lowered second degree. + if (chord.Quality == ChordQuality.Major + && chord.Root.Value == PitchClass.Create(key.Tonic.Value + 1).Value) + { + return (ChromaticKind.Neapolitan, "bII"); + } + + // Borrowed: diatonic to the parallel mode of the same tonic. + Mode parallelMode = key.Mode == Mode.Major ? Mode.Aeolian : Mode.Major; + Scale parallel = Scale.Create(key.Tonic, parallelMode); + if (chord.ChordTones().All(offset => parallel.Contains(PitchClass.Create(chord.Root.Value + offset)))) + { + string source = parallelMode == Mode.Aeolian ? "parallel minor" : "parallel major"; + return (ChromaticKind.BorrowedChord, "from " + source); + } + + return (ChromaticKind.Chromatic, null); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test --filter "FullyQualifiedName~ChromaticAnalysisTests"` +Expected: PASS. + +- [ ] **Step 5: Verify line endings, then commit** + +```bash +git add Semantics.Music/ChromaticKind.cs Semantics.Music/ChromaticAnalysis.cs Semantics.Music/Progression.Chromatic.cs Semantics.Test/Music/ChromaticAnalysisTests.cs +git commit -m "feat(music): add chromatic chord identification" +``` + +- [ ] **Step 6: Milestone 2 checkpoint — full build and test** + +Run: `dotnet build` then `dotnet test` +Expected: builds clean; all tests pass. + +--- + +## Milestone 3 — Section, Arrangement, Form + +### Task 8: `Section` + +**Files:** +- Create: `Semantics.Music/SectionType.cs` +- Create: `Semantics.Music/Section.cs` +- Test: `Semantics.Test/Music/SectionTests.cs` + +**Interfaces:** +- Consumes: `Progression` (with `Chords`, `TotalBars`), `ChordEvent.Chord`, `Chord` equality, `Key`. +- Produces: + - `enum SectionType { Intro, Verse, PreChorus, Chorus, PostChorus, Bridge, Solo, Interlude, Refrain, Outro, Coda, Other }` + - `Section.Create(SectionType type, Progression progression, string? label = null, Key? key = null) -> Section`; properties `SectionType Type`, `string? Label`, `Progression Progression`, `Key? Key`, `double Bars`. + - `Section.IsSameStructure(Section other) -> bool` + +- [ ] **Step 1: Write the failing test** + +```csharp +// Semantics.Test/Music/SectionTests.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class SectionTests +{ + [TestMethod] + public void Section_ExposesTypeProgressionAndBars() + { + Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"), "Verse 1"); + Assert.AreEqual(SectionType.Verse, verse.Type); + Assert.AreEqual("Verse 1", verse.Label); + Assert.AreEqual(4.0, verse.Bars, 1e-9); + } + + [TestMethod] + public void IsSameStructure_IgnoresLabelAndKey() + { + Section a = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"), "Verse 1"); + Section b = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"), "Verse 2"); + Assert.IsTrue(a.IsSameStructure(b)); + } + + [TestMethod] + public void IsSameStructure_FalseForDifferentTypeOrChords() + { + Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G")); + Section chorus = Section.Create(SectionType.Chorus, Progression.Parse("C | Am | F | G")); + Section other = Section.Create(SectionType.Verse, Progression.Parse("F | G | C | C")); + Assert.IsFalse(verse.IsSameStructure(chorus)); + Assert.IsFalse(verse.IsSameStructure(other)); + } + + [TestMethod] + public void Section_RejectsNullProgression() => + _ = Assert.ThrowsExactly(() => Section.Create(SectionType.Verse, null!)); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test --filter "FullyQualifiedName~SectionTests"` +Expected: FAIL — `SectionType`/`Section` not defined. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +// Semantics.Music/SectionType.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// The structural role of a section within a piece. +public enum SectionType +{ + /// An introduction. + Intro, + + /// A verse. + Verse, + + /// A pre-chorus leading into the chorus. + PreChorus, + + /// A chorus / refrain hook. + Chorus, + + /// A post-chorus following the chorus. + PostChorus, + + /// A bridge providing contrast. + Bridge, + + /// A solo section. + Solo, + + /// An instrumental interlude. + Interlude, + + /// A recurring refrain. + Refrain, + + /// An outro / ending section. + Outro, + + /// A coda / tail. + Coda, + + /// Any other or unspecified role. + Other, +} +``` + +```csharp +// Semantics.Music/Section.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System.Collections.Generic; + +/// A labeled structural unit of a piece: a role plus its harmonic content. +public sealed record Section +{ + /// Gets the structural role of the section. + public SectionType Type { get; private init; } = SectionType.Other; + + /// Gets an optional human label distinguishing repeated instances (e.g. "Verse 1"). + public string? Label { get; private init; } + + /// Gets the harmonic content of the section. + public Progression Progression { get; private init; } = new(); + + /// Gets an optional local key for a section that modulates; null inherits the arrangement key. + public Key? Key { get; private init; } + + /// Gets the length of the section in bars, derived from its progression. + public double Bars => Progression.TotalBars; + + /// Creates a section. + /// The structural role. + /// The harmonic content. + /// An optional human label. + /// An optional local key. + /// A new section. + /// Thrown when is null. + public static Section Create(SectionType type, Progression progression, string? label = null, Key? key = null) + { + Ensure.NotNull(progression); + return new() { Type = type, Progression = progression, Label = label, Key = key }; + } + + /// Returns whether another section has the same role and ordered chord content. + /// The section to compare. + /// when the type and chord sequence match; label and key are ignored. + /// Thrown when is null. + public bool IsSameStructure(Section other) + { + Ensure.NotNull(other); + if (Type != other.Type) + { + return false; + } + + IReadOnlyList mine = Progression.Chords; + IReadOnlyList theirs = other.Progression.Chords; + if (mine.Count != theirs.Count) + { + return false; + } + + for (int i = 0; i < mine.Count; i++) + { + if (mine[i].Chord != theirs[i].Chord) + { + return false; + } + } + + return true; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test --filter "FullyQualifiedName~SectionTests"` +Expected: PASS. + +- [ ] **Step 5: Verify line endings, then commit** + +```bash +git add Semantics.Music/SectionType.cs Semantics.Music/Section.cs Semantics.Test/Music/SectionTests.cs +git commit -m "feat(music): add Section structural unit" +``` + +--- + +### Task 9: `Arrangement` (core, without Form) + +**Files:** +- Create: `Semantics.Music/Arrangement.cs` +- Test: `Semantics.Test/Music/ArrangementTests.cs` + +**Interfaces:** +- Consumes: `Section` (with `Bars`), `Key`. +- Produces: `Arrangement.Create(Key key, IEnumerable
sections) -> Arrangement`; properties `Key Key`, `IReadOnlyList
Sections`, `double TotalBars`. (The `Form Form` property is added in Task 10.) + +- [ ] **Step 1: Write the failing test** + +```csharp +// Semantics.Test/Music/ArrangementTests.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class ArrangementTests +{ + private static Key CMajor => Key.Create(PitchClass.Create(0), Mode.Major); + + [TestMethod] + public void Arrangement_OrdersSectionsAndSumsBars() + { + Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G")); + Section chorus = Section.Create(SectionType.Chorus, Progression.Parse("F | G | C | C")); + Arrangement arrangement = Arrangement.Create(CMajor, [verse, chorus, verse]); + Assert.AreEqual(3, arrangement.Sections.Count); + Assert.AreEqual(12.0, arrangement.TotalBars, 1e-9); + Assert.AreEqual(SectionType.Chorus, arrangement.Sections[1].Type); + } + + [TestMethod] + public void Arrangement_RejectsEmpty() => + _ = Assert.ThrowsExactly(() => Arrangement.Create(CMajor, System.Array.Empty
())); + + [TestMethod] + public void Arrangement_RejectsNullKey() => + _ = Assert.ThrowsExactly( + () => Arrangement.Create(null!, [Section.Create(SectionType.Verse, Progression.Parse("C"))])); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test --filter "FullyQualifiedName~ArrangementTests"` +Expected: FAIL — `Arrangement` not defined. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +// Semantics.Music/Arrangement.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// The ordered realization of a piece: a sequence of sections in performance order. +public sealed record Arrangement +{ + /// Gets the home key used for whole-piece analysis; a section may override it locally. + public Key Key { get; private init; } = Key.Create(PitchClass.Create(0), Mode.Major); + + /// Gets the sections in performance order; repetition is expressed by repeated entries. + public IReadOnlyList
Sections { get; private init; } = []; + + /// Gets the total length of the arrangement in bars. + public double TotalBars => Sections.Sum(section => section.Bars); + + /// Creates an arrangement. + /// The home key. + /// The sections in performance order; must be non-empty. + /// A new arrangement. + /// Thrown when an argument is null. + /// Thrown when is empty. + public static Arrangement Create(Key key, IEnumerable
sections) + { + Ensure.NotNull(key); + Ensure.NotNull(sections); + List
list = [.. sections]; + if (list.Count == 0) + { + throw new ArgumentException("An arrangement must contain at least one section.", nameof(sections)); + } + + return new() { Key = key, Sections = list }; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test --filter "FullyQualifiedName~ArrangementTests"` +Expected: PASS. + +- [ ] **Step 5: Verify line endings, then commit** + +```bash +git add Semantics.Music/Arrangement.cs Semantics.Test/Music/ArrangementTests.cs +git commit -m "feat(music): add Arrangement container" +``` + +--- + +### Task 10: `Form` (pattern extraction + named-form recognition) and wire `Arrangement.Form` + +**Files:** +- Create: `Semantics.Music/NamedForm.cs` +- Create: `Semantics.Music/Form.cs` +- Modify: `Semantics.Music/Arrangement.cs` (add `Form Form` property) +- Test: `Semantics.Test/Music/FormTests.cs` + +**Interfaces:** +- Consumes: `Arrangement` (`Sections`, `Key`), `Section.IsSameStructure`, `Section.Bars`, `Section.Key`, `Section.Progression.Chords`, `Key.FunctionOf`, `ScaleDegree`. +- Produces: + - `enum NamedForm { ThirtyTwoBarAABA, TwelveBarBlues, VerseChorus, Binary, Ternary, Rondo, Strophic, ThroughComposed, Unknown }` + - `Form.Of(Arrangement arrangement) -> Form`; `Form.FromPattern(string pattern) -> Form`; properties `string Pattern`, `IReadOnlyList Letters`, `NamedForm? Name`. + - `Arrangement.Form` computed property returning `Form.Of(this)`. + +**Design note (deviation from spec):** `ThirtyTwoBarAABA` is recognized from the `AABA` **letter pattern** alone; the "each section ~8 bars" length is treated as the conventional realization and is not enforced, so `Form.Of` and `Form.FromPattern` agree. `TwelveBarBlues` remains the one progression-template form (detected only in `Form.Of`, which has access to progressions and the key). + +- [ ] **Step 1: Write the failing test** + +```csharp +// Semantics.Test/Music/FormTests.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class FormTests +{ + private static Key CMajor => Key.Create(PitchClass.Create(0), Mode.Major); + + private static Section Section(SectionType type, string chords) => + ktsu.Semantics.Music.Section.Create(type, Progression.Parse(chords)); + + [TestMethod] + public void Of_ExtractsAABAPattern_AndNamesThirtyTwoBarForm() + { + Section a = Section(SectionType.Verse, "C | Am | F | G"); + Section b = Section(SectionType.Bridge, "F | G | Em | Am"); + Arrangement arrangement = Arrangement.Create(CMajor, [a, a, b, a]); + Form form = arrangement.Form; + Assert.AreEqual("AABA", form.Pattern); + Assert.AreEqual(NamedForm.ThirtyTwoBarAABA, form.Name); + } + + [TestMethod] + public void Of_RecognizesTernaryAndBinary() + { + Section a = Section(SectionType.Verse, "C | G"); + Section b = Section(SectionType.Chorus, "F | C"); + Assert.AreEqual(NamedForm.Ternary, Arrangement.Create(CMajor, [a, b, a]).Form.Name); + Assert.AreEqual(NamedForm.Binary, Arrangement.Create(CMajor, [a, b]).Form.Name); + } + + [TestMethod] + public void Of_RecognizesTwelveBarBlues() + { + 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 FromPattern_AppliesLetterRecognition() + { + Form form = Form.FromPattern("ABACA"); + Assert.AreEqual(NamedForm.Rondo, form.Name); + Assert.AreEqual(5, form.Letters.Count); + } + + [TestMethod] + public void FromPattern_RejectsNonLetters() => + _ = Assert.ThrowsExactly(() => Form.FromPattern("A1B")); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test --filter "FullyQualifiedName~FormTests"` +Expected: FAIL — `NamedForm`/`Form`/`Arrangement.Form` not defined. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +// Semantics.Music/NamedForm.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// A recognized named musical form. +public enum NamedForm +{ + /// 32-bar AABA song form (recognized by the AABA section pattern). + ThirtyTwoBarAABA, + + /// 12-bar blues (recognized by a section's I-IV-V progression template). + TwelveBarBlues, + + /// Verse-chorus form. + VerseChorus, + + /// Binary form (AB). + Binary, + + /// Ternary form (ABA). + Ternary, + + /// Rondo form (ABACA / ABACABA). + Rondo, + + /// Strophic form (a single repeated section). + Strophic, + + /// Through-composed (no section repeats). + ThroughComposed, + + /// An unrecognized pattern. + Unknown, +} +``` + +```csharp +// Semantics.Music/Form.cs +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// The structural form of a piece: its section letter-pattern and any recognized named form. +public sealed record Form +{ + private static readonly int[][] BluesTemplates = + [ + [1, 1, 1, 1, 4, 4, 1, 1, 5, 4, 1, 1], + [1, 1, 1, 1, 4, 4, 1, 1, 5, 4, 1, 5], + [1, 4, 1, 1, 4, 4, 1, 1, 5, 4, 1, 1], + [1, 4, 1, 1, 4, 4, 1, 1, 5, 4, 1, 5], + ]; + + /// Gets the section letter-pattern (e.g. "AABA"). + public string Pattern { get; private init; } = ""; + + /// Gets the per-section letters, aligned to arrangement order. + public IReadOnlyList Letters { get; private init; } = []; + + /// Gets the recognized named form, or null. + public NamedForm? Name { get; private init; } + + /// Derives the form of an arrangement from its sections. + /// The arrangement to analyze. + /// The derived form. + /// Thrown when is null. + public static Form Of(Arrangement arrangement) + { + Ensure.NotNull(arrangement); + IReadOnlyList
sections = arrangement.Sections; + + List letters = []; + List
representatives = []; + foreach (Section section in sections) + { + int index = representatives.FindIndex(representative => representative.IsSameStructure(section)); + if (index < 0) + { + representatives.Add(section); + index = representatives.Count - 1; + } + + letters.Add((char)('A' + index)); + } + + string pattern = new([.. letters]); + NamedForm name = sections.Any(section => IsTwelveBarBlues(section, section.Key ?? arrangement.Key)) + ? NamedForm.TwelveBarBlues + : RecognizePattern(pattern); + + return new() { Pattern = pattern, Letters = letters, Name = name }; + } + + /// Builds a form directly from a letter-pattern string, applying letter recognition only. + /// A pattern of letters A-Z (e.g. "ABACA"). + /// The form for the pattern. + /// Thrown when is null. + /// Thrown when the pattern is empty or contains a non-letter. + public static Form FromPattern(string pattern) + { + Ensure.NotNull(pattern); + if (pattern.Length == 0) + { + throw new FormatException("Form pattern is empty."); + } + + if (!pattern.All(char.IsUpper)) + { + throw new FormatException($"Form pattern '{pattern}' must contain only upper-case letters."); + } + + return new() { Pattern = pattern, Letters = [.. pattern], Name = RecognizePattern(pattern) }; + } + + private static NamedForm RecognizePattern(string pattern) => pattern switch + { + "AABA" => NamedForm.ThirtyTwoBarAABA, + "AB" => NamedForm.Binary, + "ABA" => NamedForm.Ternary, + _ when IsRondo(pattern) => NamedForm.Rondo, + _ when pattern.Length > 1 && pattern.All(letter => letter == pattern[0]) => NamedForm.Strophic, + _ when pattern.Distinct().Count() == pattern.Length => NamedForm.ThroughComposed, + _ => NamedForm.Unknown, + }; + + private static bool IsRondo(string pattern) + { + if (pattern.Length < 5 || pattern.Length % 2 == 0) + { + return false; + } + + for (int i = 0; i < pattern.Length; i++) + { + bool refrainPosition = i % 2 == 0; + if (refrainPosition != (pattern[i] == 'A')) + { + return false; + } + } + + return true; + } + + private static bool IsTwelveBarBlues(Section section, Key key) + { + IReadOnlyList chords = section.Progression.Chords; + if (chords.Count != 12) + { + return false; + } + + int[] degrees = new int[12]; + for (int i = 0; i < 12; i++) + { + ScaleDegree degree = key.FunctionOf(chords[i].Chord.Root); + if (degree.Alteration != 0) + { + return false; + } + + degrees[i] = degree.Degree; + } + + return BluesTemplates.Any(template => template.SequenceEqual(degrees)); + } +} +``` + +Then modify `Semantics.Music/Arrangement.cs` — add this property after `TotalBars`: + +```csharp + /// Gets the structural form of the arrangement. + public Form Form => Form.Of(this); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test --filter "FullyQualifiedName~FormTests"` +Expected: PASS. + +- [ ] **Step 5: Verify line endings, then commit** + +```bash +git add Semantics.Music/NamedForm.cs Semantics.Music/Form.cs Semantics.Music/Arrangement.cs Semantics.Test/Music/FormTests.cs +git commit -m "feat(music): add Form pattern extraction and named-form recognition" +``` + +--- + +### Task 11: Documentation and final verification + +**Files:** +- Modify: `README.md` (Music section), `CLAUDE.md` (project-layout row for `Semantics.Music`), and DESCRIPTION/TAGS if present — via the `update-docs` skill. + +- [ ] **Step 1: Full solution build and test** + +Run: `dotnet build` +Expected: clean across `net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1`. + +Run: `dotnet test` +Expected: all tests pass (the ~944 pre-existing plus the new analysis tests). + +- [ ] **Step 2: Update documentation** + +Invoke the `update-docs` skill to extend the README Music section with the aggregate/analysis layer (`Progression`, `Section`, `Arrangement`, `Form` and the analyses), add a note in `CLAUDE.md` that `Semantics.Music` now includes the analysis containers, and refresh DESCRIPTION/TAGS as needed. Show a usage snippet, e.g.: + +```csharp +Progression prog = Progression.Parse("Dm7 | G7 | Cmaj7"); +Key key = prog.InferKey()!; // C major +IReadOnlyList roman = prog.RomanNumerals(key); // ii7, V7, Imaj7 +IReadOnlyList cadences = prog.Cadences(key); +``` + +- [ ] **Step 3: Commit documentation** + +```bash +git add README.md CLAUDE.md +git commit -m "docs(music): document the analysis aggregate layer" +``` + +- [ ] **Step 4: Update project memory** + +Append a memory file noting the analysis layer shipped (types, the AABA-by-pattern deviation, the deferred `AugmentedSixth` detection) and add its pointer to `MEMORY.md`. + +--- + +## Self-Review + +**Spec coverage:** +- Progression + ChordEvent + harmonic rhythm → Tasks 1–3. ✓ +- Roman numerals → Task 4. ✓ Functional classification → Task 4. ✓ +- Cadence detection → Task 5. ✓ +- Key inference → Task 6. ✓ +- Chromatic identification (secondary dominant / borrowed / Neapolitan / chromatic; AugmentedSixth reserved) → Task 7. ✓ +- Section (+ `IsSameStructure`, `SectionType`) → Task 8. ✓ +- Arrangement → Task 9. ✓ +- Form (pattern + `NamedForm` recognition; 12-bar blues template; `FromPattern`) → Task 10. ✓ +- Testing (per-type test files with concrete examples) → each task. ✓ +- Docs (README/CLAUDE.md/DESCRIPTION/TAGS via update-docs) → Task 11. ✓ +- Milestone sequencing (Progression/roman/function/cadence → key/chromatic → Section/Arrangement/Form) → Milestones 1–3. ✓ + +**Deviation recorded:** `ThirtyTwoBarAABA` recognized by the AABA letter pattern without enforcing 8-bar sections (documented in Task 10) so `Of`/`FromPattern` agree — a deliberate refinement of the spec's "~8 bars each" note. + +**Placeholder scan:** no TBD/TODO; every code step has complete code; no "handle edge cases" hand-waving. + +**Type consistency:** `Progression` is `partial record` across all six files; analysis method names (`RomanNumerals`, `Functions`, `Cadences`, `InferKey`/`InferKeys`, `ChromaticChords`) are consistent between the interface blocks and the implementations; `ChordEvent.Chord`/`Duration`, `CadenceInstance.Index/Type`, `KeyMatch.Key/Score`, `ChromaticAnalysis.Index/Kind/Detail`, `Section.IsSameStructure`, `Form.Of`/`FromPattern`/`Pattern`/`Letters`/`Name`, and `Arrangement.Form` all match across tasks. From f60366190249e72770df9410e2c8981033aeef92 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 18:33:29 +1000 Subject: [PATCH 03/16] feat(music): add ChordEvent harmonic event type --- Semantics.Music/ChordEvent.cs | 29 ++++++++++++++++++++++++ Semantics.Test/Music/ProgressionTests.cs | 27 ++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 Semantics.Music/ChordEvent.cs create mode 100644 Semantics.Test/Music/ProgressionTests.cs diff --git a/Semantics.Music/ChordEvent.cs b/Semantics.Music/ChordEvent.cs new file mode 100644 index 0000000..a021d9e --- /dev/null +++ b/Semantics.Music/ChordEvent.cs @@ -0,0 +1,29 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// +/// A harmonic event: a chord sounding for a rhythmic duration (the harmonic rhythm). +/// +public sealed record ChordEvent : IMusicalEvent +{ + /// Gets the chord that sounds. + public Chord Chord { get; private init; } = new(); + + /// Gets the rhythmic duration for which the chord sounds. + public Duration Duration { get; private init; } = Duration.Quarter; + + /// Creates a chord event from a chord and a duration. + /// The chord. + /// The rhythmic duration. + /// A new chord event. + /// Thrown when an argument is null. + public static ChordEvent Create(Chord chord, Duration duration) + { + Ensure.NotNull(chord); + Ensure.NotNull(duration); + return new() { Chord = chord, Duration = duration }; + } +} diff --git a/Semantics.Test/Music/ProgressionTests.cs b/Semantics.Test/Music/ProgressionTests.cs new file mode 100644 index 0000000..8ab9d87 --- /dev/null +++ b/Semantics.Test/Music/ProgressionTests.cs @@ -0,0 +1,27 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class ProgressionTests +{ + [TestMethod] + public void ChordEvent_BundlesChordAndDuration_AndIsMusicalEvent() + { + ChordEvent chordEvent = ChordEvent.Create(Chord.Parse("Cmaj7"), Duration.Half); + Assert.AreEqual(0, chordEvent.Chord.Root.Value); + Assert.AreEqual(Duration.Half, chordEvent.Duration); +#pragma warning disable CA1859 // Intentionally testing interface contract via IMusicalEvent reference + IMusicalEvent asEvent = chordEvent; +#pragma warning restore CA1859 + Assert.AreEqual(Duration.Half, asEvent.Duration); + } + + [TestMethod] + public void ChordEvent_RejectsNullChord() => + _ = Assert.ThrowsExactly(() => ChordEvent.Create(null!, Duration.Half)); +} From d7b82a35597d6d3a94500955d60bf6c56889237e Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 18:55:53 +1000 Subject: [PATCH 04/16] fix(music): drop CA1859 pragma; use IMusicalEvent helper in ChordEvent test --- Semantics.Test/Music/ProgressionTests.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Semantics.Test/Music/ProgressionTests.cs b/Semantics.Test/Music/ProgressionTests.cs index 8ab9d87..2ba27c9 100644 --- a/Semantics.Test/Music/ProgressionTests.cs +++ b/Semantics.Test/Music/ProgressionTests.cs @@ -15,10 +15,8 @@ public void ChordEvent_BundlesChordAndDuration_AndIsMusicalEvent() ChordEvent chordEvent = ChordEvent.Create(Chord.Parse("Cmaj7"), Duration.Half); Assert.AreEqual(0, chordEvent.Chord.Root.Value); Assert.AreEqual(Duration.Half, chordEvent.Duration); -#pragma warning disable CA1859 // Intentionally testing interface contract via IMusicalEvent reference - IMusicalEvent asEvent = chordEvent; -#pragma warning restore CA1859 - Assert.AreEqual(Duration.Half, asEvent.Duration); + static Duration DurationOf(IMusicalEvent musicalEvent) => musicalEvent.Duration; + Assert.AreEqual(Duration.Half, DurationOf(chordEvent)); } [TestMethod] From 10bce70da5b3c6b886aa74468c738db06099a506 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 19:14:21 +1000 Subject: [PATCH 05/16] feat(music): add Progression core (construction, totals, empty rejection) --- Semantics.Music/Progression.cs | 67 ++++++++++++++++++++++++ Semantics.Test/Music/ProgressionTests.cs | 27 ++++++++++ 2 files changed, 94 insertions(+) create mode 100644 Semantics.Music/Progression.cs diff --git a/Semantics.Music/Progression.cs b/Semantics.Music/Progression.cs new file mode 100644 index 0000000..dd49f87 --- /dev/null +++ b/Semantics.Music/Progression.cs @@ -0,0 +1,67 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// +/// An ordered chord sequence with a harmonic rhythm, plus functional-harmony analysis. +/// +public sealed partial record Progression +{ + /// Gets the ordered chord events (the harmonic content). + public IReadOnlyList Chords { get; private init; } = []; + + /// Gets the time signature used to interpret durations as bars and beats. + public TimeSignature TimeSignature { get; private init; } = TimeSignature.Create(4, 4); + + /// Gets the total rhythmic length as a fraction of a whole note. + public Duration TotalDuration => + Chords.Aggregate(Duration.Create(0, 1), (sum, chordEvent) => sum.Add(chordEvent.Duration)); + + /// Gets the total length measured in bars of the time signature. + public double TotalBars => TotalDuration.AsWholeNotes / TimeSignature.BarDuration.AsWholeNotes; + + /// Creates a progression in 4/4 from chord events. + /// The ordered chord events; must be non-empty. + /// A new progression. + /// Thrown when is null. + /// Thrown when is empty. + public static Progression Create(IEnumerable chords) => Create(chords, TimeSignature.Create(4, 4)); + + /// Creates a progression from chord events with an explicit time signature. + /// The ordered chord events; must be non-empty. + /// The time signature. + /// A new progression. + /// Thrown when an argument is null. + /// Thrown when is empty. + public static Progression Create(IEnumerable chords, TimeSignature timeSignature) + { + Ensure.NotNull(chords); + Ensure.NotNull(timeSignature); + List list = [.. chords]; + if (list.Count == 0) + { + throw new ArgumentException("A progression must contain at least one chord.", nameof(chords)); + } + + return new() { Chords = list, TimeSignature = timeSignature }; + } + + /// Creates a progression from chords that all share one rhythmic duration. + /// The ordered chords; must be non-empty. + /// The duration applied to every chord. + /// A new progression in 4/4. + /// Thrown when an argument is null. + /// Thrown when is empty. + public static Progression Create(IEnumerable chords, Duration each) + { + Ensure.NotNull(chords); + Ensure.NotNull(each); + return Create([.. chords.Select(chord => ChordEvent.Create(chord, each))]); + } +} diff --git a/Semantics.Test/Music/ProgressionTests.cs b/Semantics.Test/Music/ProgressionTests.cs index 2ba27c9..6de4b73 100644 --- a/Semantics.Test/Music/ProgressionTests.cs +++ b/Semantics.Test/Music/ProgressionTests.cs @@ -22,4 +22,31 @@ public void ChordEvent_BundlesChordAndDuration_AndIsMusicalEvent() [TestMethod] public void ChordEvent_RejectsNullChord() => _ = Assert.ThrowsExactly(() => ChordEvent.Create(null!, Duration.Half)); + + [TestMethod] + public void Progression_TotalBars_SumsHarmonicRhythmAgainstTimeSignature() + { + Progression progression = Progression.Create( + [ + ChordEvent.Create(Chord.Parse("C"), Duration.Whole), + ChordEvent.Create(Chord.Parse("G"), Duration.Whole), + ]); + Assert.AreEqual(2.0, progression.TotalBars, 1e-9); + Assert.AreEqual(2, progression.Chords.Count); + } + + [TestMethod] + public void Progression_Create_FromChordsWithUniformRhythm() + { + Progression progression = Progression.Create( + [Chord.Parse("Dm7"), Chord.Parse("G7"), Chord.Parse("Cmaj7")], Duration.Whole); + Assert.AreEqual(3.0, progression.TotalBars, 1e-9); + } + + [TestMethod] + public void Progression_RejectsEmpty() + { + ChordEvent[] empty = []; + _ = Assert.ThrowsExactly(() => Progression.Create(empty)); + } } From 787f3adef69336407b3cdcce78fc0c611c0c9e44 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 19:25:22 +1000 Subject: [PATCH 06/16] feat(music): add Progression.Parse bar-delimited chord syntax --- Semantics.Music/Progression.Parse.cs | 66 ++++++++++++++++++++++++ Semantics.Test/Music/ProgressionTests.cs | 33 ++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 Semantics.Music/Progression.Parse.cs diff --git a/Semantics.Music/Progression.Parse.cs b/Semantics.Music/Progression.Parse.cs new file mode 100644 index 0000000..0ef0c27 --- /dev/null +++ b/Semantics.Music/Progression.Parse.cs @@ -0,0 +1,66 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System; +using System.Collections.Generic; + +public sealed partial record Progression +{ + /// Parses a bar-delimited chord progression in 4/4. + /// Chord symbols with | as a barline and spaces separating chords in a bar. + /// The parsed progression. + /// Thrown when is null. + /// Thrown when the text is empty or a bar is empty. + public static Progression Parse(string text) => Parse(text, TimeSignature.Create(4, 4)); + + /// Parses a bar-delimited chord progression with an explicit time signature. + /// Chord symbols with | as a barline and spaces separating chords in a bar. + /// The time signature; a bar's chords split its bar duration evenly. + /// The parsed progression. + /// Thrown when an argument is null. + /// Thrown when the text is empty or a bar is empty. + public static Progression Parse(string text, TimeSignature timeSignature) + { + Ensure.NotNull(text); + Ensure.NotNull(timeSignature); + + string trimmed = text.Trim(); + if (trimmed.Length == 0) + { + throw new FormatException("Progression is empty."); + } + + // Strip one leading and one trailing barline so "| C | G |" parses cleanly. + if (trimmed[0] == '|') + { + trimmed = trimmed[1..]; + } + + if (trimmed.Length > 0 && trimmed[^1] == '|') + { + trimmed = trimmed[..^1]; + } + + Duration barDuration = timeSignature.BarDuration; + List events = []; + foreach (string bar in trimmed.Split('|')) + { + string[] tokens = bar.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length == 0) + { + throw new FormatException("Progression has an empty bar."); + } + + Duration each = Duration.Create(barDuration.Numerator, barDuration.Denominator * tokens.Length); + foreach (string token in tokens) + { + events.Add(ChordEvent.Create(Chord.Parse(token), each)); + } + } + + return Create(events, timeSignature); + } +} diff --git a/Semantics.Test/Music/ProgressionTests.cs b/Semantics.Test/Music/ProgressionTests.cs index 6de4b73..9bfa9d3 100644 --- a/Semantics.Test/Music/ProgressionTests.cs +++ b/Semantics.Test/Music/ProgressionTests.cs @@ -49,4 +49,37 @@ public void Progression_RejectsEmpty() ChordEvent[] empty = []; _ = Assert.ThrowsExactly(() => Progression.Create(empty)); } + + [TestMethod] + public void Parse_OneChordPerBar_FillsWholeBars() + { + Progression progression = Progression.Parse("Dm7 | G7 | Cmaj7"); + Assert.AreEqual(3, progression.Chords.Count); + Assert.AreEqual(Duration.Whole, progression.Chords[0].Duration); + Assert.AreEqual(3.0, progression.TotalBars, 1e-9); + } + + [TestMethod] + public void Parse_TwoChordsPerBar_SplitBarEvenly() + { + Progression progression = Progression.Parse("C G | Am F"); + Assert.AreEqual(4, progression.Chords.Count); + Assert.AreEqual(Duration.Half, progression.Chords[0].Duration); + Assert.AreEqual(2.0, progression.TotalBars, 1e-9); + } + + [TestMethod] + public void Parse_ToleratesLeadingAndTrailingBarlines() + { + Progression progression = Progression.Parse("| C | G |"); + Assert.AreEqual(2, progression.Chords.Count); + } + + [TestMethod] + public void Parse_RejectsEmptyBar() => + _ = Assert.ThrowsExactly(() => Progression.Parse("C || G")); + + [TestMethod] + public void Parse_RejectsEmptyText() => + _ = Assert.ThrowsExactly(() => Progression.Parse(" ")); } From 5306a165f40212d6f10ff7ab3f202470228eaa3b Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 19:37:11 +1000 Subject: [PATCH 07/16] feat(music): add roman-numeral labeling and functional classification --- Semantics.Music/HarmonicFunction.cs | 21 ++++++++ Semantics.Music/Progression.Analysis.cs | 49 +++++++++++++++++++ Semantics.Test/Music/HarmonicFunctionTests.cs | 42 ++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 Semantics.Music/HarmonicFunction.cs create mode 100644 Semantics.Music/Progression.Analysis.cs create mode 100644 Semantics.Test/Music/HarmonicFunctionTests.cs diff --git a/Semantics.Music/HarmonicFunction.cs b/Semantics.Music/HarmonicFunction.cs new file mode 100644 index 0000000..bebd910 --- /dev/null +++ b/Semantics.Music/HarmonicFunction.cs @@ -0,0 +1,21 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// The functional role a chord plays within a key. +public enum HarmonicFunction +{ + /// Tonic function (scale degrees I, iii, vi). + Tonic, + + /// Predominant / subdominant function (scale degrees ii, IV). + Predominant, + + /// Dominant function (scale degrees V, vii). + Dominant, + + /// A chromatic (non-diatonic) chord with no diatonic function. + Chromatic, +} diff --git a/Semantics.Music/Progression.Analysis.cs b/Semantics.Music/Progression.Analysis.cs new file mode 100644 index 0000000..4c4e4b8 --- /dev/null +++ b/Semantics.Music/Progression.Analysis.cs @@ -0,0 +1,49 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System.Collections.Generic; +using System.Linq; + +public sealed partial record Progression +{ + /// Returns the roman-numeral function of each chord relative to a key. + /// The key to analyze against. + /// One roman numeral per chord, in order. + /// Thrown when is null. + public IReadOnlyList RomanNumerals(Key key) + { + Ensure.NotNull(key); + return [.. Chords.Select(chordEvent => key.RomanNumeralOf(chordEvent.Chord))]; + } + + /// Returns the harmonic function of each chord relative to a key. + /// The key to analyze against. + /// One function per chord, in order. + /// Thrown when is null. + public IReadOnlyList Functions(Key key) + { + Ensure.NotNull(key); + return [.. Chords.Select(chordEvent => FunctionOf(key, chordEvent.Chord))]; + } + + /// Classifies a single chord's function in a key by its root scale degree. + private static HarmonicFunction FunctionOf(Key key, Chord chord) + { + ScaleDegree degree = key.FunctionOf(chord.Root); + if (degree.Alteration != 0) + { + return HarmonicFunction.Chromatic; + } + + return degree.Degree switch + { + 1 or 3 or 6 => HarmonicFunction.Tonic, + 2 or 4 => HarmonicFunction.Predominant, + 5 or 7 => HarmonicFunction.Dominant, + _ => HarmonicFunction.Chromatic, + }; + } +} diff --git a/Semantics.Test/Music/HarmonicFunctionTests.cs b/Semantics.Test/Music/HarmonicFunctionTests.cs new file mode 100644 index 0000000..47e1025 --- /dev/null +++ b/Semantics.Test/Music/HarmonicFunctionTests.cs @@ -0,0 +1,42 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class HarmonicFunctionTests +{ + private static Key CMajor => Key.Create(PitchClass.Create(0), Mode.Major); + + [TestMethod] + public void RomanNumerals_LabelsTwoFiveOne() + { + Progression progression = Progression.Parse("Dm7 | G7 | Cmaj7"); + System.Collections.Generic.IReadOnlyList numerals = progression.RomanNumerals(CMajor); + string[] expected = ["ii7", "V7", "Imaj7"]; + string[] actual = [.. numerals]; + CollectionAssert.AreEqual(expected, actual); + } + + [TestMethod] + public void Functions_ClassifyDiatonicChords() + { + Progression progression = Progression.Parse("C | Dm | G | Am"); + System.Collections.Generic.IReadOnlyList functions = progression.Functions(CMajor); + Assert.AreEqual(HarmonicFunction.Tonic, functions[0]); // I + Assert.AreEqual(HarmonicFunction.Predominant, functions[1]); // ii + Assert.AreEqual(HarmonicFunction.Dominant, functions[2]); // V + Assert.AreEqual(HarmonicFunction.Tonic, functions[3]); // vi + } + + [TestMethod] + public void Functions_MarksChromaticRoot() + { + Progression progression = Progression.Parse("C | Db"); + System.Collections.Generic.IReadOnlyList functions = progression.Functions(CMajor); + Assert.AreEqual(HarmonicFunction.Chromatic, functions[1]); + } +} From 11fe6910e1755f64cc900ffb096855172661ee77 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 19:46:19 +1000 Subject: [PATCH 08/16] feat(music): add cadence detection --- Semantics.Music/Cadence.cs | 21 +++++++++++ Semantics.Music/CadenceInstance.cs | 32 +++++++++++++++++ Semantics.Music/Progression.Cadences.cs | 46 +++++++++++++++++++++++++ Semantics.Test/Music/CadenceTests.cs | 39 +++++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 Semantics.Music/Cadence.cs create mode 100644 Semantics.Music/CadenceInstance.cs create mode 100644 Semantics.Music/Progression.Cadences.cs create mode 100644 Semantics.Test/Music/CadenceTests.cs diff --git a/Semantics.Music/Cadence.cs b/Semantics.Music/Cadence.cs new file mode 100644 index 0000000..7f95386 --- /dev/null +++ b/Semantics.Music/Cadence.cs @@ -0,0 +1,21 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// A harmonic cadence type, classified by the scale-degree motion into the final chord. +public enum Cadence +{ + /// Authentic cadence: V to I. + Authentic, + + /// Plagal cadence: IV to I. + Plagal, + + /// Half cadence: any chord arriving on V. + Half, + + /// Deceptive cadence: V to vi. + Deceptive, +} diff --git a/Semantics.Music/CadenceInstance.cs b/Semantics.Music/CadenceInstance.cs new file mode 100644 index 0000000..5b7b5f8 --- /dev/null +++ b/Semantics.Music/CadenceInstance.cs @@ -0,0 +1,32 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System; + +/// A cadence located within a progression at the position of its resolution chord. +public sealed record CadenceInstance +{ + /// Gets the index of the resolution (second) chord of the cadence. + public int Index { get; private init; } + + /// Gets the cadence type. + public Cadence Type { get; private init; } + + /// Creates a cadence instance. + /// The zero-based index of the resolution chord; must be non-negative. + /// The cadence type. + /// A new cadence instance. + /// Thrown when is negative. + public static CadenceInstance Create(int index, Cadence type) + { + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(index), index, "Index must be non-negative."); + } + + return new() { Index = index, Type = type }; + } +} diff --git a/Semantics.Music/Progression.Cadences.cs b/Semantics.Music/Progression.Cadences.cs new file mode 100644 index 0000000..43508fe --- /dev/null +++ b/Semantics.Music/Progression.Cadences.cs @@ -0,0 +1,46 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System.Collections.Generic; + +public sealed partial record Progression +{ + /// Detects cadences from the scale-degree motion of each adjacent chord pair in a key. + /// The key to analyze against. + /// The cadences found, in order; empty when none are present. + /// Thrown when is null. + public IReadOnlyList Cadences(Key key) + { + Ensure.NotNull(key); + List result = []; + for (int i = 0; i + 1 < Chords.Count; i++) + { + ScaleDegree from = key.FunctionOf(Chords[i].Chord.Root); + ScaleDegree to = key.FunctionOf(Chords[i + 1].Chord.Root); + if (from.Alteration != 0 || to.Alteration != 0) + { + continue; + } + + Cadence? cadence = Classify(from.Degree, to.Degree); + if (cadence is Cadence value) + { + result.Add(CadenceInstance.Create(i + 1, value)); + } + } + + return result; + } + + private static Cadence? Classify(int from, int to) => (from, to) switch + { + (5, 1) => Cadence.Authentic, + (5, 6) => Cadence.Deceptive, + (4, 1) => Cadence.Plagal, + (_, 5) => Cadence.Half, + _ => null, + }; +} diff --git a/Semantics.Test/Music/CadenceTests.cs b/Semantics.Test/Music/CadenceTests.cs new file mode 100644 index 0000000..839fc4f --- /dev/null +++ b/Semantics.Test/Music/CadenceTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class CadenceTests +{ + private static Key CMajor => Key.Create(PitchClass.Create(0), Mode.Major); + + [TestMethod] + public void Cadences_DetectsAuthentic() + { + System.Collections.Generic.IReadOnlyList cadences = + Progression.Parse("G | C").Cadences(CMajor); + Assert.AreEqual(1, cadences.Count); + Assert.AreEqual(Cadence.Authentic, cadences[0].Type); + Assert.AreEqual(1, cadences[0].Index); + } + + [TestMethod] + public void Cadences_DetectsPlagalHalfAndDeceptive() + { + Assert.AreEqual(Cadence.Plagal, Progression.Parse("F | C").Cadences(CMajor)[0].Type); + Assert.AreEqual(Cadence.Half, Progression.Parse("C | G").Cadences(CMajor)[0].Type); + Assert.AreEqual(Cadence.Deceptive, Progression.Parse("G | Am").Cadences(CMajor)[0].Type); + } + + [TestMethod] + public void Cadences_ReportsNoneForNonCadentialMotion() + { + System.Collections.Generic.IReadOnlyList cadences = + Progression.Parse("C | Am").Cadences(CMajor); + Assert.AreEqual(0, cadences.Count); + } +} From c337719bf08b197d0cbd4b19982d70d29ad9f1ae Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 20:03:29 +1000 Subject: [PATCH 09/16] docs(music): switch key inference to quality-weighted scoring Root-only scoring cannot separate a key from its relative/parallel neighbours (Am Dm Em Am scores 1.0 for A major, A aeolian, and C major alike), so the prefer-major tie-break wrongly picked A major. Weight each chord by whether its triad quality matches the diatonic triad at that degree. Discovered during Task 6 implementation (implementer BLOCKED). --- .../2026-07-01-music-analysis-aggregate.md | 72 +++++++++++++++++-- ...6-07-01-music-analysis-aggregate-design.md | 8 ++- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md b/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md index be77fd8..eb1a14d 100644 --- a/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md +++ b/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md @@ -768,7 +768,7 @@ Expected: solution builds clean across all targets; all tests pass. - Test: `Semantics.Test/Music/KeyInferenceTests.cs` **Interfaces:** -- Consumes: `Key.Create(PitchClass, Mode)`, `Key.Scale`, `Scale.Contains(PitchClass)`, `Key.Tonic`, `Key.Mode`, `Mode.Major`, `Mode.Aeolian`, `PitchClass.Create(int)`. +- Consumes: `Key.Create(PitchClass, Mode)`, `Key.Scale`, `Scale.Contains(PitchClass)`, `Scale.PitchClasses`, `Key.Tonic`, `Key.Mode`, `Mode.Major`, `Mode.Aeolian`, `PitchClass.Create(int)`, `PitchClass.Value`, `Chord.Root`, `Chord.Quality`, `ChordQuality` (`Major`/`Minor`/`Diminished`/`Augmented`). - Produces: - `KeyMatch.Create(Key key, double score) -> KeyMatch`; properties `Key Key`, `double Score`. - `Progression.InferKeys() -> IReadOnlyList` (ranked, best first) @@ -839,7 +839,7 @@ public sealed record KeyMatch /// Gets the candidate key. public Key Key { get; private init; } = Key.Create(PitchClass.Create(0), Mode.Major); - /// Gets the fit score in 0..1: the fraction of chords whose root is diatonic to the key. + /// Gets the quality-weighted diatonic-fit score in 0..1 (1.0 = every chord is diatonic with matching triad quality). public double Score { get; private init; } /// Creates a key match. @@ -868,7 +868,7 @@ using System.Linq; public sealed partial record Progression { - /// Ranks candidate keys (12 tonics in major and natural minor) by diatonic fit. + /// Ranks candidate keys (12 tonics in major and natural minor) by quality-weighted diatonic fit. /// All 24 candidates, best first; ties break toward a tonic-rooted last then first chord, then major. public IReadOnlyList InferKeys() { @@ -880,9 +880,13 @@ public sealed partial record Progression for (int tonic = 0; tonic < 12; tonic++) { Key key = Key.Create(PitchClass.Create(tonic), mode); - Scale scale = key.Scale; - int diatonic = Chords.Count(chordEvent => scale.Contains(chordEvent.Chord.Root)); - matches.Add(KeyMatch.Create(key, (double)diatonic / Chords.Count)); + double total = 0.0; + foreach (ChordEvent chordEvent in Chords) + { + total += ScoreChord(key, chordEvent.Chord); + } + + matches.Add(KeyMatch.Create(key, total / Chords.Count)); } } @@ -897,6 +901,62 @@ public sealed partial record Progression ]; } + // Scores one chord's fit to a key: 1.0 when its root is diatonic AND its triad quality matches the + // diatonic triad at that degree, 0.5 when the root is diatonic but the quality differs (or has no + // comparable third), 0.0 when the root is not in the scale. Quality-weighting is what distinguishes + // a key from its relative/parallel neighbours, which share the same chord roots. + private static double ScoreChord(Key key, Chord chord) + { + Scale scale = key.Scale; + if (!scale.Contains(chord.Root)) + { + return 0.0; + } + + ChordQuality? diatonic = DiatonicTriadQuality(scale, chord.Root); + bool comparable = chord.Quality is ChordQuality.Major or ChordQuality.Minor + or ChordQuality.Diminished or ChordQuality.Augmented; + if (!comparable || diatonic is null) + { + return 1.0; + } + + return chord.Quality == diatonic.Value ? 1.0 : 0.5; + } + + // Returns the diatonic triad quality built on a scale degree by stacking scale thirds, or null when + // the third/fifth do not form a recognised triad. + private static ChordQuality? DiatonicTriadQuality(Scale scale, PitchClass root) + { + IReadOnlyList pitchClasses = scale.PitchClasses; + int index = -1; + for (int i = 0; i < pitchClasses.Count; i++) + { + if (pitchClasses[i].Value == root.Value) + { + index = i; + break; + } + } + + if (index < 0) + { + return null; + } + + int count = pitchClasses.Count; + int third = ((((pitchClasses[(index + 2) % count].Value - root.Value) % 12) + 12) % 12); + int fifth = ((((pitchClasses[(index + 4) % count].Value - root.Value) % 12) + 12) % 12); + return (third, fifth) switch + { + (4, 7) => ChordQuality.Major, + (3, 7) => ChordQuality.Minor, + (3, 6) => ChordQuality.Diminished, + (4, 8) => ChordQuality.Augmented, + _ => null, + }; + } + /// Returns the single best-fitting key, or null when no chord root is diatonic to any candidate. /// The best key, or null for a degenerate all-chromatic input. public Key? InferKey() diff --git a/docs/superpowers/specs/2026-07-01-music-analysis-aggregate-design.md b/docs/superpowers/specs/2026-07-01-music-analysis-aggregate-design.md index 7d6701f..4d7dd87 100644 --- a/docs/superpowers/specs/2026-07-01-music-analysis-aggregate-design.md +++ b/docs/superpowers/specs/2026-07-01-music-analysis-aggregate-design.md @@ -153,8 +153,12 @@ public sealed record KeyMatch { public Key Key { get; } public double Score { ge ``` Scores every candidate `Key` — 12 tonics × {major (`Mode.Major`), natural minor (`Mode.Aeolian`)} — by -the fraction of chords whose root is diatonic to that key (ties broken by preferring a tonic-rooted -first/last chord, then major over minor). `InferKeys()` returns matches ranked by descending score; +a **quality-weighted** diatonic fit: each chord contributes 1.0 when its root is diatonic *and* its +triad quality matches the diatonic triad built on that scale degree, 0.5 when the root is diatonic but +the quality differs (or the chord has no comparable third), and 0.0 when the root is not in the scale. +Root-only scoring cannot separate a key from its relative/parallel neighbours (which share the same +chord roots), so quality-weighting is required; ties are then broken by preferring a tonic-rooted +first/last chord, then major over minor. `InferKeys()` returns matches ranked by descending score; `InferKey()` returns the single best (or `null` for a degenerate all-chromatic input). **Chromatic identification** From 3264b8e8e7103b89ee7b56f3c207f71bc099ffda Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 20:19:41 +1000 Subject: [PATCH 10/16] feat(music): add key inference by diatonic fit --- Semantics.Music/KeyMatch.cs | 26 +++++ Semantics.Music/Progression.KeyInference.cs | 108 ++++++++++++++++++++ Semantics.Test/Music/KeyInferenceTests.cs | 38 +++++++ 3 files changed, 172 insertions(+) create mode 100644 Semantics.Music/KeyMatch.cs create mode 100644 Semantics.Music/Progression.KeyInference.cs create mode 100644 Semantics.Test/Music/KeyInferenceTests.cs diff --git a/Semantics.Music/KeyMatch.cs b/Semantics.Music/KeyMatch.cs new file mode 100644 index 0000000..c69d845 --- /dev/null +++ b/Semantics.Music/KeyMatch.cs @@ -0,0 +1,26 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// A candidate key paired with its diatonic-fit score for a progression. +public sealed record KeyMatch +{ + /// Gets the candidate key. + public Key Key { get; private init; } = Key.Create(PitchClass.Create(0), Mode.Major); + + /// Gets the quality-weighted diatonic-fit score in 0..1 (1.0 = every chord is diatonic with matching triad quality). + public double Score { get; private init; } + + /// Creates a key match. + /// The candidate key. + /// The fit score. + /// A new key match. + /// Thrown when is null. + public static KeyMatch Create(Key key, double score) + { + Ensure.NotNull(key); + return new() { Key = key, Score = score }; + } +} diff --git a/Semantics.Music/Progression.KeyInference.cs b/Semantics.Music/Progression.KeyInference.cs new file mode 100644 index 0000000..d9510bd --- /dev/null +++ b/Semantics.Music/Progression.KeyInference.cs @@ -0,0 +1,108 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System.Collections.Generic; +using System.Linq; + +public sealed partial record Progression +{ + /// Ranks candidate keys (12 tonics in major and natural minor) by quality-weighted diatonic fit. + /// All 24 candidates, best first; ties break toward a tonic-rooted last then first chord, then major. + public IReadOnlyList InferKeys() + { + PitchClass firstRoot = Chords[0].Chord.Root; + PitchClass lastRoot = Chords[^1].Chord.Root; + List matches = []; + foreach (Mode mode in new[] { Mode.Major, Mode.Aeolian }) + { + for (int tonic = 0; tonic < 12; tonic++) + { + Key key = Key.Create(PitchClass.Create(tonic), mode); + double total = 0.0; + foreach (ChordEvent chordEvent in Chords) + { + total += ScoreChord(key, chordEvent.Chord); + } + + matches.Add(KeyMatch.Create(key, total / Chords.Count)); + } + } + + return + [ + .. matches + .OrderByDescending(match => match.Score) + .ThenByDescending(match => match.Key.Tonic.Value == lastRoot.Value) + .ThenByDescending(match => match.Key.Tonic.Value == firstRoot.Value) + .ThenByDescending(match => match.Key.Mode == Mode.Major) + .ThenBy(match => match.Key.Tonic.Value), + ]; + } + + // Scores one chord's fit to a key: 1.0 when its root is diatonic AND its triad quality matches the + // diatonic triad at that degree, 0.5 when the root is diatonic but the quality differs (or has no + // comparable third), 0.0 when the root is not in the scale. Quality-weighting is what distinguishes + // a key from its relative/parallel neighbours, which share the same chord roots. + private static double ScoreChord(Key key, Chord chord) + { + Scale scale = key.Scale; + if (!scale.Contains(chord.Root)) + { + return 0.0; + } + + ChordQuality? diatonic = DiatonicTriadQuality(scale, chord.Root); + bool comparable = chord.Quality is ChordQuality.Major or ChordQuality.Minor + or ChordQuality.Diminished or ChordQuality.Augmented; + if (!comparable || diatonic is null) + { + return 1.0; + } + + return chord.Quality == diatonic.Value ? 1.0 : 0.5; + } + + // Returns the diatonic triad quality built on a scale degree by stacking scale thirds, or null when + // the third/fifth do not form a recognised triad. + private static ChordQuality? DiatonicTriadQuality(Scale scale, PitchClass root) + { + IReadOnlyList pitchClasses = scale.PitchClasses; + int index = -1; + for (int i = 0; i < pitchClasses.Count; i++) + { + if (pitchClasses[i].Value == root.Value) + { + index = i; + break; + } + } + + if (index < 0) + { + return null; + } + + int count = pitchClasses.Count; + int third = (((pitchClasses[(index + 2) % count].Value - root.Value) % 12) + 12) % 12; + int fifth = (((pitchClasses[(index + 4) % count].Value - root.Value) % 12) + 12) % 12; + return (third, fifth) switch + { + (4, 7) => ChordQuality.Major, + (3, 7) => ChordQuality.Minor, + (3, 6) => ChordQuality.Diminished, + (4, 8) => ChordQuality.Augmented, + _ => null, + }; + } + + /// Returns the single best-fitting key, or null when no chord root is diatonic to any candidate. + /// The best key, or null for a degenerate all-chromatic input. + public Key? InferKey() + { + KeyMatch best = InferKeys()[0]; + return best.Score > 0.0 ? best.Key : null; + } +} diff --git a/Semantics.Test/Music/KeyInferenceTests.cs b/Semantics.Test/Music/KeyInferenceTests.cs new file mode 100644 index 0000000..a813c69 --- /dev/null +++ b/Semantics.Test/Music/KeyInferenceTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class KeyInferenceTests +{ + [TestMethod] + public void InferKey_TwoFiveOne_IsCMajor() + { + Key? key = Progression.Parse("Dm7 | G7 | Cmaj7").InferKey(); + Assert.IsNotNull(key); + Assert.AreEqual(0, key.Tonic.Value); + Assert.AreEqual(Mode.Major, key.Mode); + } + + [TestMethod] + public void InferKey_DiatonicMinorProgression_IsAMinor() + { + Key? key = Progression.Parse("Am | Dm | Em | Am").InferKey(); + Assert.IsNotNull(key); + Assert.AreEqual(9, key.Tonic.Value); + Assert.AreEqual(Mode.Aeolian, key.Mode); + } + + [TestMethod] + public void InferKeys_RanksBestFirst_WithTwentyFourCandidates() + { + System.Collections.Generic.IReadOnlyList matches = + Progression.Parse("Dm7 | G7 | Cmaj7").InferKeys(); + Assert.AreEqual(24, matches.Count); + Assert.IsTrue(matches[0].Score >= matches[1].Score); + } +} From 68b9c9d3f982c79a40aff1e475d7d8791c1d0ce9 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 20:45:55 +1000 Subject: [PATCH 11/16] feat(music): add chromatic chord identification --- Semantics.Music/ChromaticAnalysis.cs | 36 +++++++++ Semantics.Music/ChromaticKind.cs | 27 +++++++ Semantics.Music/Progression.Chromatic.cs | 77 +++++++++++++++++++ .../Music/ChromaticAnalysisTests.cs | 51 ++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 Semantics.Music/ChromaticAnalysis.cs create mode 100644 Semantics.Music/ChromaticKind.cs create mode 100644 Semantics.Music/Progression.Chromatic.cs create mode 100644 Semantics.Test/Music/ChromaticAnalysisTests.cs diff --git a/Semantics.Music/ChromaticAnalysis.cs b/Semantics.Music/ChromaticAnalysis.cs new file mode 100644 index 0000000..df78b2b --- /dev/null +++ b/Semantics.Music/ChromaticAnalysis.cs @@ -0,0 +1,36 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System; + +/// The chromatic classification of a chord at a position within a progression. +public sealed record ChromaticAnalysis +{ + /// Gets the zero-based index of the chord within the progression. + public int Index { get; private init; } + + /// Gets the chromatic category. + public ChromaticKind Kind { get; private init; } + + /// Gets an optional human-readable detail (e.g. "V/V", "bII"), or null. + public string? Detail { get; private init; } + + /// Creates a chromatic analysis result. + /// The zero-based chord index; must be non-negative. + /// The chromatic category. + /// An optional descriptive detail. + /// A new chromatic analysis result. + /// Thrown when is negative. + public static ChromaticAnalysis Create(int index, ChromaticKind kind, string? detail) + { + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(index), index, "Index must be non-negative."); + } + + return new() { Index = index, Kind = kind, Detail = detail }; + } +} diff --git a/Semantics.Music/ChromaticKind.cs b/Semantics.Music/ChromaticKind.cs new file mode 100644 index 0000000..afb4c25 --- /dev/null +++ b/Semantics.Music/ChromaticKind.cs @@ -0,0 +1,27 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// The category of a non-diatonic (chromatic) chord within a key. +public enum ChromaticKind +{ + /// A secondary dominant: a dominant-quality chord tonicizing a diatonic degree. + SecondaryDominant, + + /// A borrowed chord: diatonic to the parallel mode of the same tonic (modal interchange). + BorrowedChord, + + /// A Neapolitan: a major triad on the lowered second degree. + Neapolitan, + + /// + /// 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. + /// + AugmentedSixth, + + /// A chromatic chord with no more specific classification. + Chromatic, +} diff --git a/Semantics.Music/Progression.Chromatic.cs b/Semantics.Music/Progression.Chromatic.cs new file mode 100644 index 0000000..6380bd9 --- /dev/null +++ b/Semantics.Music/Progression.Chromatic.cs @@ -0,0 +1,77 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System.Collections.Generic; +using System.Linq; + +public sealed partial record Progression +{ + private static readonly string[] MajorDegreeRomans = ["I", "ii", "iii", "IV", "V", "vi", "vii°"]; + private static readonly string[] MinorDegreeRomans = ["i", "ii°", "III", "iv", "v", "VI", "VII"]; + + /// Classifies each non-diatonic chord in the progression relative to a key. + /// The key to analyze against. + /// One entry per non-diatonic chord; diatonic chords are omitted. + /// Thrown when is null. + public IReadOnlyList ChromaticChords(Key key) + { + Ensure.NotNull(key); + List result = []; + for (int i = 0; i < Chords.Count; i++) + { + Chord chord = Chords[i].Chord; + if (IsDiatonic(key, chord)) + { + continue; + } + + (ChromaticKind kind, string? detail) = ClassifyChromatic(key, chord); + result.Add(ChromaticAnalysis.Create(i, kind, detail)); + } + + return result; + } + + private static bool IsDiatonic(Key key, Chord chord) + { + Scale scale = key.Scale; + return chord.ChordTones().All(offset => scale.Contains(PitchClass.Create(chord.Root.Value + offset))); + } + + private static (ChromaticKind Kind, string? Detail) ClassifyChromatic(Key key, Chord chord) + { + // Secondary dominant: a major triad or dominant seventh a perfect fifth above a diatonic degree. + bool dominantQuality = chord.Quality == ChordQuality.Major + && chord.Seventh is SeventhType.None or SeventhType.Dominant; + if (dominantQuality) + { + ScaleDegree target = key.Scale.DegreeOf(PitchClass.Create(chord.Root.Value - 7)); + if (target.Alteration == 0 && target.Degree != 1) + { + string[] table = key.Mode == Mode.Major ? MajorDegreeRomans : MinorDegreeRomans; + return (ChromaticKind.SecondaryDominant, "V/" + table[(target.Degree - 1) % 7]); + } + } + + // Neapolitan: a major triad on the lowered second degree. + if (chord.Quality == ChordQuality.Major + && chord.Root.Value == PitchClass.Create(key.Tonic.Value + 1).Value) + { + return (ChromaticKind.Neapolitan, "bII"); + } + + // Borrowed: diatonic to the parallel mode of the same tonic. + Mode parallelMode = key.Mode == Mode.Major ? Mode.Aeolian : Mode.Major; + Scale parallel = Scale.Create(key.Tonic, parallelMode); + if (chord.ChordTones().All(offset => parallel.Contains(PitchClass.Create(chord.Root.Value + offset)))) + { + string source = parallelMode == Mode.Aeolian ? "parallel minor" : "parallel major"; + return (ChromaticKind.BorrowedChord, "from " + source); + } + + return (ChromaticKind.Chromatic, null); + } +} diff --git a/Semantics.Test/Music/ChromaticAnalysisTests.cs b/Semantics.Test/Music/ChromaticAnalysisTests.cs new file mode 100644 index 0000000..0de8203 --- /dev/null +++ b/Semantics.Test/Music/ChromaticAnalysisTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class ChromaticAnalysisTests +{ + private static Key CMajor => Key.Create(PitchClass.Create(0), Mode.Major); + + [TestMethod] + public void ChromaticChords_SkipsDiatonicChords() + { + System.Collections.Generic.IReadOnlyList analyses = + Progression.Parse("C | Dm | G7 | C").ChromaticChords(CMajor); + Assert.AreEqual(0, analyses.Count); + } + + [TestMethod] + public void ChromaticChords_DetectsSecondaryDominantOfDominant() + { + // D7 in C is V/V (resolving toward G, the dominant). + System.Collections.Generic.IReadOnlyList analyses = + Progression.Parse("C | D7 | G7 | C").ChromaticChords(CMajor); + Assert.AreEqual(1, analyses.Count); + Assert.AreEqual(ChromaticKind.SecondaryDominant, analyses[0].Kind); + Assert.AreEqual("V/V", analyses[0].Detail); + Assert.AreEqual(1, analyses[0].Index); + } + + [TestMethod] + public void ChromaticChords_DetectsNeapolitan() + { + System.Collections.Generic.IReadOnlyList analyses = + Progression.Parse("C | Db | G7").ChromaticChords(CMajor); + Assert.AreEqual(ChromaticKind.Neapolitan, analyses[0].Kind); + Assert.AreEqual("bII", analyses[0].Detail); + } + + [TestMethod] + public void ChromaticChords_DetectsBorrowedMinorSubdominant() + { + // Fm in C major is iv borrowed from the parallel minor. + System.Collections.Generic.IReadOnlyList analyses = + Progression.Parse("C | Fm | C").ChromaticChords(CMajor); + Assert.AreEqual(ChromaticKind.BorrowedChord, analyses[0].Kind); + } +} From df4bc6815ae99ac523b002b898ee6a1510a2d246 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 21:07:26 +1000 Subject: [PATCH 12/16] feat(music): add Section structural unit --- Semantics.Music/Section.cs | 69 ++++++++++++++++++++++++++++ Semantics.Music/SectionType.cs | 45 ++++++++++++++++++ Semantics.Test/Music/SectionTests.cs | 42 +++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 Semantics.Music/Section.cs create mode 100644 Semantics.Music/SectionType.cs create mode 100644 Semantics.Test/Music/SectionTests.cs diff --git a/Semantics.Music/Section.cs b/Semantics.Music/Section.cs new file mode 100644 index 0000000..cc5f712 --- /dev/null +++ b/Semantics.Music/Section.cs @@ -0,0 +1,69 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System.Collections.Generic; + +/// A labeled structural unit of a piece: a role plus its harmonic content. +public sealed record Section +{ + /// Gets the structural role of the section. + public SectionType Type { get; private init; } = SectionType.Other; + + /// Gets an optional human label distinguishing repeated instances (e.g. "Verse 1"). + public string? Label { get; private init; } + + /// Gets the harmonic content of the section. + public Progression Progression { get; private init; } = new(); + + /// Gets an optional local key for a section that modulates; null inherits the arrangement key. + public Key? Key { get; private init; } + + /// Gets the length of the section in bars, derived from its progression. + public double Bars => Progression.TotalBars; + + /// Creates a section. + /// The structural role. + /// The harmonic content. + /// An optional human label. + /// An optional local key. + /// A new section. + /// Thrown when is null. + public static Section Create(SectionType type, Progression progression, string? label = null, Key? key = null) + { + Ensure.NotNull(progression); + return new() { Type = type, Progression = progression, Label = label, Key = key }; + } + + /// Returns whether another section has the same role and ordered chord content. + /// The section to compare. + /// when the type and chord sequence match; label and key are ignored. + /// Thrown when is null. + public bool IsSameStructure(Section other) + { + Ensure.NotNull(other); + if (Type != other.Type) + { + return false; + } + + IReadOnlyList mine = Progression.Chords; + IReadOnlyList theirs = other.Progression.Chords; + if (mine.Count != theirs.Count) + { + return false; + } + + for (int i = 0; i < mine.Count; i++) + { + if (mine[i].Chord != theirs[i].Chord) + { + return false; + } + } + + return true; + } +} diff --git a/Semantics.Music/SectionType.cs b/Semantics.Music/SectionType.cs new file mode 100644 index 0000000..4ea80fe --- /dev/null +++ b/Semantics.Music/SectionType.cs @@ -0,0 +1,45 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// The structural role of a section within a piece. +public enum SectionType +{ + /// An introduction. + Intro, + + /// A verse. + Verse, + + /// A pre-chorus leading into the chorus. + PreChorus, + + /// A chorus / refrain hook. + Chorus, + + /// A post-chorus following the chorus. + PostChorus, + + /// A bridge providing contrast. + Bridge, + + /// A solo section. + Solo, + + /// An instrumental interlude. + Interlude, + + /// A recurring refrain. + Refrain, + + /// An outro / ending section. + Outro, + + /// A coda / tail. + Coda, + + /// Any other or unspecified role. + Other, +} diff --git a/Semantics.Test/Music/SectionTests.cs b/Semantics.Test/Music/SectionTests.cs new file mode 100644 index 0000000..9243757 --- /dev/null +++ b/Semantics.Test/Music/SectionTests.cs @@ -0,0 +1,42 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class SectionTests +{ + [TestMethod] + public void Section_ExposesTypeProgressionAndBars() + { + Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"), "Verse 1"); + Assert.AreEqual(SectionType.Verse, verse.Type); + Assert.AreEqual("Verse 1", verse.Label); + Assert.AreEqual(4.0, verse.Bars, 1e-9); + } + + [TestMethod] + public void IsSameStructure_IgnoresLabelAndKey() + { + Section a = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"), "Verse 1"); + Section b = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"), "Verse 2"); + Assert.IsTrue(a.IsSameStructure(b)); + } + + [TestMethod] + public void IsSameStructure_FalseForDifferentTypeOrChords() + { + Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G")); + Section chorus = Section.Create(SectionType.Chorus, Progression.Parse("C | Am | F | G")); + Section other = Section.Create(SectionType.Verse, Progression.Parse("F | G | C | C")); + Assert.IsFalse(verse.IsSameStructure(chorus)); + Assert.IsFalse(verse.IsSameStructure(other)); + } + + [TestMethod] + public void Section_RejectsNullProgression() => + _ = Assert.ThrowsExactly(() => Section.Create(SectionType.Verse, null!)); +} From 88623e52a2215d99c6136cbbc768e0da1fe64b85 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 21:30:52 +1000 Subject: [PATCH 13/16] feat(music): add Arrangement container --- Semantics.Music/Arrangement.cs | 41 ++++++++++++++++++++++++ Semantics.Test/Music/ArrangementTests.cs | 36 +++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 Semantics.Music/Arrangement.cs create mode 100644 Semantics.Test/Music/ArrangementTests.cs diff --git a/Semantics.Music/Arrangement.cs b/Semantics.Music/Arrangement.cs new file mode 100644 index 0000000..84279c1 --- /dev/null +++ b/Semantics.Music/Arrangement.cs @@ -0,0 +1,41 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// The ordered realization of a piece: a sequence of sections in performance order. +public sealed record Arrangement +{ + /// Gets the home key used for whole-piece analysis; a section may override it locally. + public Key Key { get; private init; } = Key.Create(PitchClass.Create(0), Mode.Major); + + /// Gets the sections in performance order; repetition is expressed by repeated entries. + public IReadOnlyList
Sections { get; private init; } = []; + + /// Gets the total length of the arrangement in bars. + public double TotalBars => Sections.Sum(section => section.Bars); + + /// Creates an arrangement. + /// The home key. + /// The sections in performance order; must be non-empty. + /// A new arrangement. + /// Thrown when an argument is null. + /// Thrown when is empty. + public static Arrangement Create(Key key, IEnumerable
sections) + { + Ensure.NotNull(key); + Ensure.NotNull(sections); + List
list = [.. sections]; + if (list.Count == 0) + { + throw new ArgumentException("An arrangement must contain at least one section.", nameof(sections)); + } + + return new() { Key = key, Sections = list }; + } +} diff --git a/Semantics.Test/Music/ArrangementTests.cs b/Semantics.Test/Music/ArrangementTests.cs new file mode 100644 index 0000000..02ecf62 --- /dev/null +++ b/Semantics.Test/Music/ArrangementTests.cs @@ -0,0 +1,36 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class ArrangementTests +{ + private static Key CMajor => Key.Create(PitchClass.Create(0), Mode.Major); + + [TestMethod] + public void Arrangement_OrdersSectionsAndSumsBars() + { + Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G")); + Section chorus = Section.Create(SectionType.Chorus, Progression.Parse("F | G | C | C")); + Arrangement arrangement = Arrangement.Create(CMajor, [verse, chorus, verse]); + Assert.AreEqual(3, arrangement.Sections.Count); + Assert.AreEqual(12.0, arrangement.TotalBars, 1e-9); + Assert.AreEqual(SectionType.Chorus, arrangement.Sections[1].Type); + } + + [TestMethod] + public void Arrangement_RejectsEmpty() + { + Section[] empty = []; + _ = Assert.ThrowsExactly(() => Arrangement.Create(CMajor, empty)); + } + + [TestMethod] + public void Arrangement_RejectsNullKey() => + _ = Assert.ThrowsExactly( + () => Arrangement.Create(null!, [Section.Create(SectionType.Verse, Progression.Parse("C"))])); +} From 7299c9934a4456c684360e3b1d7d8b06547ec4da Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 21:45:59 +1000 Subject: [PATCH 14/16] feat(music): add Form pattern extraction and named-form recognition --- Semantics.Music/Arrangement.cs | 3 + Semantics.Music/Form.cs | 135 ++++++++++++++++++++++++++++++ Semantics.Music/NamedForm.cs | 36 ++++++++ Semantics.Test/Music/FormTests.cs | 56 +++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 Semantics.Music/Form.cs create mode 100644 Semantics.Music/NamedForm.cs create mode 100644 Semantics.Test/Music/FormTests.cs diff --git a/Semantics.Music/Arrangement.cs b/Semantics.Music/Arrangement.cs index 84279c1..00d891b 100644 --- a/Semantics.Music/Arrangement.cs +++ b/Semantics.Music/Arrangement.cs @@ -20,6 +20,9 @@ public sealed record Arrangement /// Gets the total length of the arrangement in bars. public double TotalBars => Sections.Sum(section => section.Bars); + /// Gets the structural form of the arrangement. + public Form Form => Form.Of(this); + /// Creates an arrangement. /// The home key. /// The sections in performance order; must be non-empty. diff --git a/Semantics.Music/Form.cs b/Semantics.Music/Form.cs new file mode 100644 index 0000000..470c754 --- /dev/null +++ b/Semantics.Music/Form.cs @@ -0,0 +1,135 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// The structural form of a piece: its section letter-pattern and any recognized named form. +public sealed record Form +{ + private static readonly int[][] BluesTemplates = + [ + [1, 1, 1, 1, 4, 4, 1, 1, 5, 4, 1, 1], + [1, 1, 1, 1, 4, 4, 1, 1, 5, 4, 1, 5], + [1, 4, 1, 1, 4, 4, 1, 1, 5, 4, 1, 1], + [1, 4, 1, 1, 4, 4, 1, 1, 5, 4, 1, 5], + ]; + + /// Gets the section letter-pattern (e.g. "AABA"). + public string Pattern { get; private init; } = ""; + + /// Gets the per-section letters, aligned to arrangement order. + public IReadOnlyList Letters { get; private init; } = []; + + /// Gets the recognized named form, or null. + public NamedForm? Name { get; private init; } + + /// Derives the form of an arrangement from its sections. + /// The arrangement to analyze. + /// The derived form. + /// Thrown when is null. + public static Form Of(Arrangement arrangement) + { + Ensure.NotNull(arrangement); + IReadOnlyList
sections = arrangement.Sections; + + List letters = []; + List
representatives = []; + foreach (Section section in sections) + { + int index = representatives.FindIndex(representative => representative.IsSameStructure(section)); + if (index < 0) + { + representatives.Add(section); + index = representatives.Count - 1; + } + + letters.Add((char)('A' + index)); + } + + string pattern = new([.. letters]); + NamedForm name = sections.Any(section => IsTwelveBarBlues(section, section.Key ?? arrangement.Key)) + ? NamedForm.TwelveBarBlues + : RecognizePattern(pattern); + + return new() { Pattern = pattern, Letters = letters, Name = name }; + } + + /// Builds a form directly from a letter-pattern string, applying letter recognition only. + /// A pattern of letters A-Z (e.g. "ABACA"). + /// The form for the pattern. + /// Thrown when is null. + /// Thrown when the pattern is empty or contains a non-letter. + public static Form FromPattern(string pattern) + { + Ensure.NotNull(pattern); + if (pattern.Length == 0) + { + throw new FormatException("Form pattern is empty."); + } + + if (!pattern.All(char.IsUpper)) + { + throw new FormatException($"Form pattern '{pattern}' must contain only upper-case letters."); + } + + return new() { Pattern = pattern, Letters = [.. pattern], Name = RecognizePattern(pattern) }; + } + + private static NamedForm RecognizePattern(string pattern) => pattern switch + { + "AABA" => NamedForm.ThirtyTwoBarAABA, + "AB" => NamedForm.Binary, + "ABA" => NamedForm.Ternary, + _ when IsRondo(pattern) => NamedForm.Rondo, + _ when pattern.Length > 1 && pattern.All(letter => letter == pattern[0]) => NamedForm.Strophic, + _ when pattern.Distinct().Count() == pattern.Length => NamedForm.ThroughComposed, + _ => NamedForm.Unknown, + }; + + private static bool IsRondo(string pattern) + { + if (pattern.Length < 5 || pattern.Length % 2 == 0) + { + return false; + } + + for (int i = 0; i < pattern.Length; i++) + { + bool refrainPosition = i % 2 == 0; + if (refrainPosition != (pattern[i] == 'A')) + { + return false; + } + } + + return true; + } + + private static bool IsTwelveBarBlues(Section section, Key key) + { + IReadOnlyList chords = section.Progression.Chords; + if (chords.Count != 12) + { + return false; + } + + int[] degrees = new int[12]; + for (int i = 0; i < 12; i++) + { + ScaleDegree degree = key.FunctionOf(chords[i].Chord.Root); + if (degree.Alteration != 0) + { + return false; + } + + degrees[i] = degree.Degree; + } + + return BluesTemplates.Any(template => template.SequenceEqual(degrees)); + } +} diff --git a/Semantics.Music/NamedForm.cs b/Semantics.Music/NamedForm.cs new file mode 100644 index 0000000..c6b9431 --- /dev/null +++ b/Semantics.Music/NamedForm.cs @@ -0,0 +1,36 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// A recognized named musical form. +public enum NamedForm +{ + /// 32-bar AABA song form (recognized by the AABA section pattern). + ThirtyTwoBarAABA, + + /// 12-bar blues (recognized by a section's I-IV-V progression template). + TwelveBarBlues, + + /// Verse-chorus form. + VerseChorus, + + /// Binary form (AB). + Binary, + + /// Ternary form (ABA). + Ternary, + + /// Rondo form (ABACA / ABACABA). + Rondo, + + /// Strophic form (a single repeated section). + Strophic, + + /// Through-composed (no section repeats). + ThroughComposed, + + /// An unrecognized pattern. + Unknown, +} diff --git a/Semantics.Test/Music/FormTests.cs b/Semantics.Test/Music/FormTests.cs new file mode 100644 index 0000000..022e99a --- /dev/null +++ b/Semantics.Test/Music/FormTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class FormTests +{ + private static Key CMajor => Key.Create(PitchClass.Create(0), Mode.Major); + + private static Section Section(SectionType type, string chords) => + ktsu.Semantics.Music.Section.Create(type, Progression.Parse(chords)); + + [TestMethod] + public void Of_ExtractsAABAPattern_AndNamesThirtyTwoBarForm() + { + Section a = Section(SectionType.Verse, "C | Am | F | G"); + Section b = Section(SectionType.Bridge, "F | G | Em | Am"); + Arrangement arrangement = Arrangement.Create(CMajor, [a, a, b, a]); + Form form = arrangement.Form; + Assert.AreEqual("AABA", form.Pattern); + Assert.AreEqual(NamedForm.ThirtyTwoBarAABA, form.Name); + } + + [TestMethod] + public void Of_RecognizesTernaryAndBinary() + { + Section a = Section(SectionType.Verse, "C | G"); + Section b = Section(SectionType.Chorus, "F | C"); + Assert.AreEqual(NamedForm.Ternary, Arrangement.Create(CMajor, [a, b, a]).Form.Name); + Assert.AreEqual(NamedForm.Binary, Arrangement.Create(CMajor, [a, b]).Form.Name); + } + + [TestMethod] + public void Of_RecognizesTwelveBarBlues() + { + 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 FromPattern_AppliesLetterRecognition() + { + Form form = Form.FromPattern("ABACA"); + Assert.AreEqual(NamedForm.Rondo, form.Name); + Assert.AreEqual(5, form.Letters.Count); + } + + [TestMethod] + public void FromPattern_RejectsNonLetters() => + _ = Assert.ThrowsExactly(() => Form.FromPattern("A1B")); +} From d8d7ac5713c54ee54dd468ac1ea2fa1fd0d9cf1b Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 22:13:39 +1000 Subject: [PATCH 15/16] test(music): lock guard/coverage contracts; dedupe chromatic scale check --- Semantics.Music/Form.cs | 2 +- Semantics.Music/Progression.Chromatic.cs | 7 +++++-- Semantics.Test/Music/CadenceTests.cs | 4 ++++ Semantics.Test/Music/ChromaticAnalysisTests.cs | 5 +++++ Semantics.Test/Music/ProgressionTests.cs | 13 +++++++++++++ 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/Semantics.Music/Form.cs b/Semantics.Music/Form.cs index 470c754..d906d6d 100644 --- a/Semantics.Music/Form.cs +++ b/Semantics.Music/Form.cs @@ -72,7 +72,7 @@ public static Form FromPattern(string pattern) throw new FormatException("Form pattern is empty."); } - if (!pattern.All(char.IsUpper)) + if (!pattern.All(c => c is >= 'A' and <= 'Z')) { throw new FormatException($"Form pattern '{pattern}' must contain only upper-case letters."); } diff --git a/Semantics.Music/Progression.Chromatic.cs b/Semantics.Music/Progression.Chromatic.cs index 6380bd9..cb74416 100644 --- a/Semantics.Music/Progression.Chromatic.cs +++ b/Semantics.Music/Progression.Chromatic.cs @@ -35,10 +35,13 @@ public IReadOnlyList ChromaticChords(Key key) return result; } + private static bool AllTonesInScale(Chord chord, Scale scale) => + chord.ChordTones().All(offset => scale.Contains(PitchClass.Create(chord.Root.Value + offset))); + private static bool IsDiatonic(Key key, Chord chord) { Scale scale = key.Scale; - return chord.ChordTones().All(offset => scale.Contains(PitchClass.Create(chord.Root.Value + offset))); + return AllTonesInScale(chord, scale); } private static (ChromaticKind Kind, string? Detail) ClassifyChromatic(Key key, Chord chord) @@ -66,7 +69,7 @@ private static (ChromaticKind Kind, string? Detail) ClassifyChromatic(Key key, C // Borrowed: diatonic to the parallel mode of the same tonic. Mode parallelMode = key.Mode == Mode.Major ? Mode.Aeolian : Mode.Major; Scale parallel = Scale.Create(key.Tonic, parallelMode); - if (chord.ChordTones().All(offset => parallel.Contains(PitchClass.Create(chord.Root.Value + offset)))) + if (AllTonesInScale(chord, parallel)) { string source = parallelMode == Mode.Aeolian ? "parallel minor" : "parallel major"; return (ChromaticKind.BorrowedChord, "from " + source); diff --git a/Semantics.Test/Music/CadenceTests.cs b/Semantics.Test/Music/CadenceTests.cs index 839fc4f..1015878 100644 --- a/Semantics.Test/Music/CadenceTests.cs +++ b/Semantics.Test/Music/CadenceTests.cs @@ -36,4 +36,8 @@ public void Cadences_ReportsNoneForNonCadentialMotion() Progression.Parse("C | Am").Cadences(CMajor); Assert.AreEqual(0, cadences.Count); } + + [TestMethod] + public void CadenceInstance_Create_RejectsNegativeIndex() => + _ = Assert.ThrowsExactly(() => CadenceInstance.Create(-1, Cadence.Authentic)); } diff --git a/Semantics.Test/Music/ChromaticAnalysisTests.cs b/Semantics.Test/Music/ChromaticAnalysisTests.cs index 0de8203..9737265 100644 --- a/Semantics.Test/Music/ChromaticAnalysisTests.cs +++ b/Semantics.Test/Music/ChromaticAnalysisTests.cs @@ -47,5 +47,10 @@ public void ChromaticChords_DetectsBorrowedMinorSubdominant() System.Collections.Generic.IReadOnlyList analyses = Progression.Parse("C | Fm | C").ChromaticChords(CMajor); Assert.AreEqual(ChromaticKind.BorrowedChord, analyses[0].Kind); + Assert.AreEqual("from parallel minor", analyses[0].Detail); } + + [TestMethod] + public void ChromaticAnalysis_Create_RejectsNegativeIndex() => + _ = Assert.ThrowsExactly(() => ChromaticAnalysis.Create(-1, ChromaticKind.Chromatic, null)); } diff --git a/Semantics.Test/Music/ProgressionTests.cs b/Semantics.Test/Music/ProgressionTests.cs index 9bfa9d3..a0b9d3b 100644 --- a/Semantics.Test/Music/ProgressionTests.cs +++ b/Semantics.Test/Music/ProgressionTests.cs @@ -82,4 +82,17 @@ public void Parse_RejectsEmptyBar() => [TestMethod] public void Parse_RejectsEmptyText() => _ = Assert.ThrowsExactly(() => Progression.Parse(" ")); + + [TestMethod] + public void Progression_TotalBars_RespectsNonFourFourTimeSignature() + { + Progression waltz = Progression.Create( + [ + ChordEvent.Create(Chord.Parse("C"), Duration.Quarter), + ChordEvent.Create(Chord.Parse("F"), Duration.Quarter), + ChordEvent.Create(Chord.Parse("G"), Duration.Quarter), + ], + TimeSignature.Create(3, 4)); + Assert.AreEqual(1.0, waltz.TotalBars, 1e-9); + } } From 231bed19581e3374605d1051dedb765486f7b243 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 1 Jul 2026 22:18:12 +1000 Subject: [PATCH 16/16] docs(music): document the analysis aggregate layer --- CLAUDE.md | 1 + README.md | 30 +++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index cb56060..fc3e521 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ Tests use MSTest. Generator output is emitted to `Semantics.Quantities/Generated | `Semantics.Strings` | Strongly-typed string wrappers (`SemanticString`) and validation attributes/strategies. | | `Semantics.Strings.Identifiers` | Concrete identifier string types (`Uuid`, `Ulid`, `Iban`, `Isbn`, `CreditCardNumber`, `JwtToken`) built on the `Semantics.Strings` framework. | | `Semantics.Paths` | Polymorphic file system path types (`IPath`, `IFilePath`, `IDirectoryPath`, …). | +| `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`. | | `Semantics.Quantities` | Hand-written runtime types (`PhysicalQuantity`, `IVector0`..`IVector4`, `UnitSystem`) plus generator output under `Generated/`. | | `Semantics.SourceGenerators` | Roslyn incremental generators that emit quantity types, units, conversions, magnitudes, physical constants, and storage-type helpers from metadata. | | `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`. | diff --git a/README.md b/README.md index 294df55..978d7a5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A .NET library for replacing primitive obsession with strongly-typed, self-valid - **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`). - **Semantic Paths** — polymorphic `IPath` hierarchy for files, directories, absolute, relative, and combinations. - **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`. -- **Semantic Music** — immutable musical value types: `PitchClass`, `Pitch`, `Interval`, `Mode`/`Scale`, `Chord`, `Key`, rational `Duration`, and `TimeSignature`, with chord-symbol parsing and voicing. +- **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). Targets `net8.0`–`net10.0`. Semantic Strings, Paths, and Music additionally target `netstandard2.0`/`netstandard2.1`; Semantic Quantities is `net8.0`+ (it requires `INumber`). @@ -234,6 +234,34 @@ double hz = a4.Pitch.FrequencyHz; // 440.0 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`. +### Analysis: progressions, sections, and forms + +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. + +```csharp +using ktsu.Semantics.Music; + +// A chord progression with bar-based harmonic rhythm ("|" = barline) +Progression prog = Progression.Parse("Dm7 | G7 | Cmaj7"); +Key key = prog.InferKey()!; // C major (quality-weighted fit) + +IReadOnlyList roman = prog.RomanNumerals(key); // "ii7", "V7", "Imaj7" +IReadOnlyList fns = prog.Functions(key); // Predominant, Dominant, Tonic +IReadOnlyList cadences = prog.Cadences(key); // Authentic (V→I) at the resolution + +// Chromatic analysis: secondary dominants, borrowed chords, Neapolitan +IReadOnlyList chromatic = + Progression.Parse("C | D7 | G7 | C").ChromaticChords(key); // D7 → "V/V" (SecondaryDominant) + +// Structure: sections → arrangement → form +Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G")); +Section bridge = Section.Create(SectionType.Bridge, Progression.Parse("F | G | Em | Am")); +Arrangement song = Arrangement.Create(key, [verse, verse, bridge, verse]); +Form form = song.Form; // Pattern "AABA", Name ThirtyTwoBarAABA +``` + +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. + ## Dependency injection ```csharp