Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ jobs:
working-directory: tonalis/ts
# Approach A: the repo keeps a local `file:` link to @tonalis/music-dsl for monorepo dev;
# rewrite it to the published version range only in the publish artifact.
- run: npm pkg set 'dependencies[@tonalis/music-dsl]=^0.1.0'
- run: npm pkg set 'dependencies[@tonalis/music-dsl]=^0.1.1'
working-directory: tonalis/ts
- run: npm publish --access public
working-directory: tonalis/ts
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The same language is implemented three times — in **Python**, **TypeScript**,
three pass two shared, language-agnostic conformance suites: the **lead-sheet** suite (255 cases under
[`conformance/tonalis/`](conformance/tonalis/), specified in
[`conformance/tonalis/SPEC.md`](conformance/tonalis/SPEC.md)) and the **music-DSL scale/encode**
suite (429 cases under [`conformance/music-dsl/`](conformance/music-dsl/), specified in
suite (433 cases under [`conformance/music-dsl/`](conformance/music-dsl/), specified in
[`conformance/music-dsl/SPEC-scales.md`](conformance/music-dsl/SPEC-scales.md)).

## What it does
Expand Down
65 changes: 65 additions & 0 deletions conformance/music-dsl/cases/numeric/from_chord.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
[
{
"name": "numeric/from-chord/Csus-in-C",
"op": "numeric_from_chord",
"args": {
"key_root": "C",
"chord": "Csus",
"substitution": false
},
"expect": {
"model": {
"numeric": {
"root": "I",
"triad": "sus",
"seventh": "",
"extensions": [],
"harmonic_function": "Tonic",
"substitution": false
}
}
}
},
{
"name": "numeric/from-chord/Csus2-in-C",
"op": "numeric_from_chord",
"args": {
"key_root": "C",
"chord": "Csus2",
"substitution": false
},
"expect": {
"model": {
"numeric": {
"root": "I",
"triad": "sus2",
"seventh": "",
"extensions": [],
"harmonic_function": "Tonic",
"substitution": false
}
}
}
},
{
"name": "numeric/from-chord/Csus4-in-C",
"op": "numeric_from_chord",
"args": {
"key_root": "C",
"chord": "Csus4",
"substitution": false
},
"expect": {
"model": {
"numeric": {
"root": "I",
"triad": "sus4",
"seventh": "",
"extensions": [],
"harmonic_function": "Tonic",
"substitution": false
}
}
}
}
]
30 changes: 27 additions & 3 deletions conformance/music-dsl/cases/numeric/parse.json
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@
"model": {
"numeric": {
"numerator": {
"root": "bvii",
"root": "bVII",
"triad": "sus",
"seventh": "",
"extensions": [],
Expand All @@ -254,7 +254,9 @@
"root": "iv",
"triad": "-",
"seventh": "7",
"extensions": ["alt"],
"extensions": [
"alt"
],
"harmonic_function": "Subdominant",
"substitution": false
},
Expand All @@ -273,7 +275,7 @@
"model": {
"numeric": {
"numerator": {
"root": "iii",
"root": "III",
"triad": "sus2",
"seventh": "^7",
"extensions": [],
Expand All @@ -284,5 +286,27 @@
}
}
}
},
{
"name": "numeric/parse/mOrM-II-sus4",
"op": "parse_numeric",
"args": {
"input": "II7sus4"
},
"expect": {
"model": {
"numeric": {
"numerator": {
"root": "II",
"triad": "sus4",
"seventh": "7",
"extensions": [],
"harmonic_function": "Tonic",
"substitution": false
},
"denominator": null
}
}
}
}
]
76 changes: 50 additions & 26 deletions conformance/music-dsl/runners/python/bless_numeric.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,24 +93,24 @@ def _modulate_case(name: str, semitones: int, note: str) -> dict:

def _is_diatonic_case(name: str, root: str, scale: str, chord: str) -> dict:
"""Build an is_diatonic case."""
result = is_diatonic(Notes(root), Scales[scale], Chord(chord))
return {
"name": name,
"op": "is_diatonic",
"args": {"root": root, "scale": scale, "chord": chord},
"expect": {"value": result},
}
args = {"root": root, "scale": scale, "chord": chord}
try:
result = is_diatonic(Notes(root), Scales[scale], Chord(chord))
expect = {"value": result}
except InvalidChordStringException:
expect = {"error": True}
return {"name": name, "op": "is_diatonic", "args": args, "expect": expect}


def _hf_in_key_case(name: str, key_root: str, key_is_minor: bool, chord: str) -> dict:
"""Build a harmonic_function_in_key case."""
result = harmonic_function_in_key(Notes(key_root), key_is_minor, Chord(chord)).name
return {
"name": name,
"op": "harmonic_function_in_key",
"args": {"key_root": key_root, "key_is_minor": key_is_minor, "chord": chord},
"expect": {"value": result},
}
args = {"key_root": key_root, "key_is_minor": key_is_minor, "chord": chord}
try:
result = harmonic_function_in_key(Notes(key_root), key_is_minor, Chord(chord)).name
expect = {"value": result}
except InvalidChordStringException:
expect = {"error": True}
return {"name": name, "op": "harmonic_function_in_key", "args": args, "expect": expect}


def _chord_in_key_case(name: str, numeric: str, key_root: str) -> dict:
Expand All @@ -136,10 +136,24 @@ def _chord_in_key_case(name: str, numeric: str, key_root: str) -> dict:
("numeric/parse/bVI^7", "bVI^7"),
("numeric/parse/sharp-ivo7", "#ivo7"),
("numeric/parse/sV7", "sV7"),
("numeric/parse/mOrM-bVI-minor", "bVI-7"),
("numeric/parse/mOrM-II-minor", "II-7"),
("numeric/parse/mOrM-sII-dim", "#IIo7"),
("numeric/parse/mOrM-V-halfdim", "Vh^7"),
("numeric/parse/mOrM-bVII-sus", "bVIIsus"),
("numeric/parse/mOrM-IV-minor-alt", "IV-7alt"),
("numeric/parse/mOrM-III-sus2", "IIIsus2^7"),
("numeric/parse/mOrM-II-sus4", "II7sus4"),
]

parse_cases = [_numeric_case(name, input_str) for name, input_str in parse_inputs]

from_chord_cases = [
_numeric_from_chord_case("numeric/from-chord/Csus-in-C", "C", "Csus", False),
_numeric_from_chord_case("numeric/from-chord/Csus2-in-C", "C", "Csus2", False),
_numeric_from_chord_case("numeric/from-chord/Csus4-in-C", "C", "Csus4", False),
]

# ---------------------------------------------------------------------------
# NUMERIC / SLASH
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -182,28 +196,28 @@ def _chord_in_key_case(name: str, numeric: str, key_root: str) -> dict:
_ab_with_sub = next(c for c in substitution_cases if c["name"] == "substitution/Ab-m7-in-C-with-sub")
assert _ab_no_sub["expect"].get("model", {}).get("numeric", {}).get("root") == \
_ab_with_sub["expect"].get("model", {}).get("numeric", {}).get("root"), \
f"BLESS FAILED: Ab-7 sub=True root should equal sub=False (branch-c must not fire for A==4)"
"BLESS FAILED: Ab-7 sub=True root should equal sub=False (branch-c must not fire for A==4)"

print("Substitution anchor cross-checks PASSED.")

# ---------------------------------------------------------------------------
# NUMERIC / ERRORS
# ---------------------------------------------------------------------------
# IncorrectHarmonicFunctionException: chord whose harmonic function doesn't match
# the substitution branch's requirement. Branch (a) requires Subdominant;
# a Dominant-function chord at Tritone distance fires (a) and then raises.
# Actually branch (a) is: semitones(chord.root->key_root)==Tritone AND chord.hf != Subdominant
# Example: C7 in F# (F#->C ascending = 6 = Tritone; C7 is Dominant, not Subdominant) -> raises
error_cases = [
# IncorrectHarmonicFunctionException: C7 at tritone from key F# is Dominant, not Subdominant
_numeric_from_chord_case("numeric/errors/incorrect-hf-tritone-not-subdominant", "F#", "C7", True),
# InvalidChordStringException: empty numerator
_numeric_from_chord_case(
"numeric/errors/incorrect-hf-tritone-not-subdominant", "F#", "C7", True
),
_numeric_case("numeric/errors/empty-numerator", ""),
_numeric_case("numeric/errors/b9add9-merges-to-b99-invalid", "Vsusb9add9"),
_numeric_case(
"numeric/errors/b9add9-lowercase-root-invalid", "visusb9add9"
),
]

# Verify they are indeed errors
for ec in error_cases:
assert "error" in ec["expect"], f"BLESS FAILED: {ec['name']} expected error but got: {ec['expect']}"
for error_case in error_cases:
assert "error" in error_case["expect"], (
f"BLESS FAILED: {error_case['name']} expected error but got: {error_case['expect']}"
)

# ---------------------------------------------------------------------------
# TRANSACTIONS / MODULATE
Expand Down Expand Up @@ -246,6 +260,9 @@ def _chord_in_key_case(name: str, numeric: str, key_root: str) -> dict:
_is_diatonic_case("transactions/is_diatonic/C-harm-minor-Bb^7-non-diatonic", "C", "HarmonicMinor", "Bb^7"),
_is_diatonic_case("transactions/is_diatonic/G-major-D7-diatonic", "G", "Major", "D7"),
_is_diatonic_case("transactions/is_diatonic/G-major-Db7-non-diatonic", "G", "Major", "Db7"),
_is_diatonic_case(
"is_diatonic-invalid-chord-error", "G", "Major", "Eb9#9add9"
),
]

# ---------------------------------------------------------------------------
Expand All @@ -264,6 +281,12 @@ def _chord_in_key_case(name: str, numeric: str, key_root: str) -> dict:
_hf_in_key_case("transactions/hf_in_key/C-major-subdominant-D-7", "C", False, "D-7"),
# Different root: ii-7 in G = Subdominant
_hf_in_key_case("transactions/hf_in_key/G-major-subdominant-A-7", "G", False, "A-7"),
_hf_in_key_case(
"transactions/hf_in_key/invalid-chord-error",
"C",
False,
"Eb9#9add9",
),
]

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -295,6 +318,7 @@ def _chord_in_key_case(name: str, numeric: str, key_root: str) -> dict:

files = [
(numeric_dir / "parse.json", parse_cases),
(numeric_dir / "from_chord.json", from_chord_cases),
(numeric_dir / "slash.json", slash_cases),
(numeric_dir / "substitution.json", substitution_cases),
(numeric_dir / "errors.json", error_cases),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

RUN_PY = Path(__file__).resolve().parent / "run.py"
CASES = Path(__file__).resolve().parents[2] / "cases"
EXPECTED_CASE_COUNT = 429 # 427 + 2 DRY-423 bebop-minor scale_value (BebopDorian, BebopMinor)
EXPECTED_CASE_COUNT = 433 # 429 baseline + 4 no-third suspension casing cases


def test_musicdsl_conformance_passes():
Expand Down
2 changes: 1 addition & 1 deletion conformance/music-dsl/runners/ts/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function discover(dir: string): Array<{ relpath: string; case: Case }> {
return out;
}

const EXPECTED_CASE_COUNT = 429; // keep in sync with the Python runner's frozen count
const EXPECTED_CASE_COUNT = 433; // keep in sync with the Python runner's frozen count

const cases = discover(CASES_DIR);

Expand Down
2 changes: 1 addition & 1 deletion music-dsl/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Pure music-theory domain library: notes, intervals, scale degrees, chord
qualities, scales, and chord encoding. Zero runtime dependencies. This is the
Python reference implementation; TypeScript (`@tonalis/music-dsl` on npm) and
Rust (`tonalis-music-dsl` on crates.io) ports conform to the same spec and a shared
429-case conformance suite plus a seeded three-way differential fuzzer.
433-case conformance suite plus a seeded three-way differential fuzzer.

`music-dsl` is the theory core underneath [`tonalis`](https://pypi.org/project/tonalis/),
the format-agnostic lead-sheet DSL, and is fully usable on its own.
Expand Down
12 changes: 9 additions & 3 deletions music-dsl/python/music_dsl/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,15 @@


def m_or_M_scaledegree(root: ScaleDegree, triad) -> ScaleDegree:
# Major-third triads (major, augmented) and sus4 keep the major scale degree;
# everything else (minor, dim, half-dim) lowers it.
if triad and triad not in (Triad.Major, Triad.Augmented, Triad.Sus4):
# Chords without a defining third retain an uppercase degree; all others that
# are not major-third triads lower it.
if triad and triad not in (
Triad.Major,
Triad.Augmented,
Triad.Sus,
Triad.Sus2,
Triad.Sus4,
):
return root.to_minor()
return root

Expand Down
2 changes: 1 addition & 1 deletion music-dsl/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "tonalis-music-dsl"
version = "0.1.0"
version = "0.1.1"
description = "Music-theory domain: notes, chords, intervals, scales, harmonic function."
requires-python = ">=3.11"
readme = "README.md"
Expand Down
19 changes: 19 additions & 0 deletions music-dsl/python/tests/unit/domain/chords/test_sus_shorthand.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@

from music_dsl.domain.chords.chord import Chord
from music_dsl.domain.static import Seventh, Triad
from music_dsl.domain.chords.numeric_chord import NumericChord


@pytest.mark.parametrize(
"token",
["Isus", "Isus2", "Isus4", "I7sus", "I7sus2", "I7sus4"],
)
def test_suspended_numeric_chords_keep_uppercase(token):
assert repr(NumericChord.from_chord_string(token)) == token


def test_bare_sus_is_the_sus4_pitch_set_but_sus2_is_distinct():
bare = Chord("Csus")
two = Chord("Csus2")
four = Chord("Csus4")
assert bare.encoding == four.encoding
assert two.encoding != four.encoding




@pytest.mark.parametrize("root", ["C", "G", "A", "D", "E", "F", "B"])
Expand Down
2 changes: 1 addition & 1 deletion music-dsl/rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion music-dsl/rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tonalis-music-dsl"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
description = "MusicDSL — music-theory domain (notes, intervals, scale-degrees, chords, encoding). Port of the Python music_dsl reference."
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion music-dsl/rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Pure music-theory domain library: notes, intervals, scale degrees, chord
qualities, scales, and chord encoding. No runtime dependencies. Port of the
Python `music-dsl` reference implementation — all three ports (Python /
TypeScript / Rust) conform to the same spec via a shared 429-case conformance
TypeScript / Rust) conform to the same spec via a shared 433-case conformance
suite plus a seeded three-way differential fuzzer.

The crate is named `tonalis-music-dsl`; the library target (import path) is
Expand Down
Loading