Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Tests use MSTest. Generator output is emitted to `Semantics.Quantities/Generated
| `Semantics.Strings` | Strongly-typed string wrappers (`SemanticString<T>`) 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<TSelf, T>`, `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<double>`. |
Expand Down
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<double>`.
- **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<T>`).

Expand Down Expand Up @@ -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<string> roman = prog.RomanNumerals(key); // "ii7", "V7", "Imaj7"
IReadOnlyList<HarmonicFunction> fns = prog.Functions(key); // Predominant, Dominant, Tonic
IReadOnlyList<CadenceInstance> cadences = prog.Cadences(key); // Authentic (V→I) at the resolution

// Chromatic analysis: secondary dominants, borrowed chords, Neapolitan
IReadOnlyList<ChromaticAnalysis> 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
Expand Down
44 changes: 44 additions & 0 deletions Semantics.Music/Arrangement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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;

/// <summary>The ordered realization of a piece: a sequence of sections in performance order.</summary>
public sealed record Arrangement
{
/// <summary>Gets the home key used for whole-piece analysis; a section may override it locally.</summary>
public Key Key { get; private init; } = Key.Create(PitchClass.Create(0), Mode.Major);

/// <summary>Gets the sections in performance order; repetition is expressed by repeated entries.</summary>
public IReadOnlyList<Section> Sections { get; private init; } = [];

/// <summary>Gets the total length of the arrangement in bars.</summary>
public double TotalBars => Sections.Sum(section => section.Bars);

/// <summary>Gets the structural form of the arrangement.</summary>
public Form Form => Form.Of(this);

/// <summary>Creates an arrangement.</summary>
/// <param name="key">The home key.</param>
/// <param name="sections">The sections in performance order; must be non-empty.</param>
/// <returns>A new arrangement.</returns>
/// <exception cref="ArgumentNullException">Thrown when an argument is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="sections"/> is empty.</exception>
public static Arrangement Create(Key key, IEnumerable<Section> sections)
{
Ensure.NotNull(key);
Ensure.NotNull(sections);
List<Section> 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 };
}
}
21 changes: 21 additions & 0 deletions Semantics.Music/Cadence.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.Semantics.Music;

/// <summary>A harmonic cadence type, classified by the scale-degree motion into the final chord.</summary>
public enum Cadence
{
/// <summary>Authentic cadence: V to I.</summary>
Authentic,

/// <summary>Plagal cadence: IV to I.</summary>
Plagal,

/// <summary>Half cadence: any chord arriving on V.</summary>
Half,

/// <summary>Deceptive cadence: V to vi.</summary>
Deceptive,
}
32 changes: 32 additions & 0 deletions Semantics.Music/CadenceInstance.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.Semantics.Music;

using System;

/// <summary>A cadence located within a progression at the position of its resolution chord.</summary>
public sealed record CadenceInstance
{
/// <summary>Gets the index of the resolution (second) chord of the cadence.</summary>
public int Index { get; private init; }

/// <summary>Gets the cadence type.</summary>
public Cadence Type { get; private init; }

/// <summary>Creates a cadence instance.</summary>
/// <param name="index">The zero-based index of the resolution chord; must be non-negative.</param>
/// <param name="type">The cadence type.</param>
/// <returns>A new cadence instance.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="index"/> is negative.</exception>
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 };
}
}
29 changes: 29 additions & 0 deletions Semantics.Music/ChordEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.Semantics.Music;

/// <summary>
/// A harmonic event: a chord sounding for a rhythmic duration (the harmonic rhythm).
/// </summary>
public sealed record ChordEvent : IMusicalEvent
{
/// <summary>Gets the chord that sounds.</summary>
public Chord Chord { get; private init; } = new();

/// <summary>Gets the rhythmic duration for which the chord sounds.</summary>
public Duration Duration { get; private init; } = Duration.Quarter;

/// <summary>Creates a chord event from a chord and a duration.</summary>
/// <param name="chord">The chord.</param>
/// <param name="duration">The rhythmic duration.</param>
/// <returns>A new chord event.</returns>
/// <exception cref="ArgumentNullException">Thrown when an argument is null.</exception>
public static ChordEvent Create(Chord chord, Duration duration)
{
Ensure.NotNull(chord);
Ensure.NotNull(duration);
return new() { Chord = chord, Duration = duration };
}
}
36 changes: 36 additions & 0 deletions Semantics.Music/ChromaticAnalysis.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.Semantics.Music;

using System;

/// <summary>The chromatic classification of a chord at a position within a progression.</summary>
public sealed record ChromaticAnalysis
{
/// <summary>Gets the zero-based index of the chord within the progression.</summary>
public int Index { get; private init; }

/// <summary>Gets the chromatic category.</summary>
public ChromaticKind Kind { get; private init; }

/// <summary>Gets an optional human-readable detail (e.g. "V/V", "bII"), or null.</summary>
public string? Detail { get; private init; }

/// <summary>Creates a chromatic analysis result.</summary>
/// <param name="index">The zero-based chord index; must be non-negative.</param>
/// <param name="kind">The chromatic category.</param>
/// <param name="detail">An optional descriptive detail.</param>
/// <returns>A new chromatic analysis result.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="index"/> is negative.</exception>
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 };
}
}
27 changes: 27 additions & 0 deletions Semantics.Music/ChromaticKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.Semantics.Music;

/// <summary>The category of a non-diatonic (chromatic) chord within a key.</summary>
public enum ChromaticKind
{
/// <summary>A secondary dominant: a dominant-quality chord tonicizing a diatonic degree.</summary>
SecondaryDominant,

/// <summary>A borrowed chord: diatonic to the parallel mode of the same tonic (modal interchange).</summary>
BorrowedChord,

/// <summary>A Neapolitan: a major triad on the lowered second degree.</summary>
Neapolitan,

/// <summary>
/// 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.
/// </summary>
AugmentedSixth,

/// <summary>A chromatic chord with no more specific classification.</summary>
Chromatic,
}
135 changes: 135 additions & 0 deletions Semantics.Music/Form.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>The structural form of a piece: its section letter-pattern and any recognized named form.</summary>
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],
];

/// <summary>Gets the section letter-pattern (e.g. "AABA").</summary>
public string Pattern { get; private init; } = "";

/// <summary>Gets the per-section letters, aligned to arrangement order.</summary>
public IReadOnlyList<char> Letters { get; private init; } = [];

/// <summary>Gets the recognized named form, or null.</summary>
public NamedForm? Name { get; private init; }

/// <summary>Derives the form of an arrangement from its sections.</summary>
/// <param name="arrangement">The arrangement to analyze.</param>
/// <returns>The derived form.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="arrangement"/> is null.</exception>
public static Form Of(Arrangement arrangement)
{
Ensure.NotNull(arrangement);
IReadOnlyList<Section> sections = arrangement.Sections;

List<char> letters = [];
List<Section> 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 };
}

/// <summary>Builds a form directly from a letter-pattern string, applying letter recognition only.</summary>
/// <param name="pattern">A pattern of letters A-Z (e.g. "ABACA").</param>
/// <returns>The form for the pattern.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="pattern"/> is null.</exception>
/// <exception cref="FormatException">Thrown when the pattern is empty or contains a non-letter.</exception>
public static Form FromPattern(string pattern)
{
Ensure.NotNull(pattern);
if (pattern.Length == 0)
{
throw new FormatException("Form pattern is empty.");
}

if (!pattern.All(c => c is >= 'A' and <= 'Z'))
{
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<ChordEvent> 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));
}
}
Loading
Loading