chord-romanizer 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chord_romanizer-0.1.1/LICENSE +21 -0
- chord_romanizer-0.1.1/MANIFEST.in +2 -0
- chord_romanizer-0.1.1/PKG-INFO +83 -0
- chord_romanizer-0.1.1/README.md +70 -0
- chord_romanizer-0.1.1/chord_romanizer/__init__.py +13 -0
- chord_romanizer-0.1.1/chord_romanizer/chord_interpreter.py +228 -0
- chord_romanizer-0.1.1/chord_romanizer/chord_parser.py +114 -0
- chord_romanizer-0.1.1/chord_romanizer/chord_structure.py +126 -0
- chord_romanizer-0.1.1/chord_romanizer/note_speller.py +80 -0
- chord_romanizer-0.1.1/chord_romanizer/romanizer.py +564 -0
- chord_romanizer-0.1.1/chord_romanizer.egg-info/PKG-INFO +83 -0
- chord_romanizer-0.1.1/chord_romanizer.egg-info/SOURCES.txt +14 -0
- chord_romanizer-0.1.1/chord_romanizer.egg-info/dependency_links.txt +1 -0
- chord_romanizer-0.1.1/chord_romanizer.egg-info/top_level.txt +1 -0
- chord_romanizer-0.1.1/setup.cfg +4 -0
- chord_romanizer-0.1.1/setup.py +18 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 anime-song
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: chord_romanizer
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A library to analyze chord progressions and convert them to Roman numeral analysis.
|
|
5
|
+
Home-page: https://github.com/anime-song/chord-romanizer
|
|
6
|
+
Author: anime-song
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.7
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
|
|
14
|
+
# Chord Romanizer
|
|
15
|
+
|
|
16
|
+
Chord Romanizerは、コード進行の解析とディグリーネーム(ローマ数字)表記への変換を行うPythonライブラリです。
|
|
17
|
+
複雑なコードや転回形、ハイブリッドコード(分数コード)にも対応しており、文脈(前後のコード)を考慮した解析を行います。
|
|
18
|
+
|
|
19
|
+
## 特徴
|
|
20
|
+
- **文脈考慮**: キーと前後のコードから最適な度数表記を判定(例: 半音進行や解決先を考慮)。
|
|
21
|
+
- **分数コード対応**: 転回形 (`C/E`)、ハイブリッドコード (`F/G`)、UST (`upper structure triad`) などを識別し、適切に表記 (`I/3`, `V9sus4`, etc.)。
|
|
22
|
+
- **柔軟な入力**: 一般的なコードシンボル表記 (`Cm7`, `F#aug`, `Bb/C` など) をパース可能。
|
|
23
|
+
|
|
24
|
+
## インストール
|
|
25
|
+
|
|
26
|
+
現在のところ、PyPIには公開されていません。リポジトリをクローンしてプロジェクトに含めてください。
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/your-username/chord-romanizer.git
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## クイックスタート
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from chord_romanizer import Romanizer, ChordParser
|
|
36
|
+
|
|
37
|
+
# 1. インスタンス生成 (デフォルトキーを指定可能)
|
|
38
|
+
romanizer = Romanizer(default_tonic="C")
|
|
39
|
+
|
|
40
|
+
# 2. コード進行の準備 (文字列からParsedChordへ変換)
|
|
41
|
+
progression_str = ["Dm7", "G7", "Cmaj7", "A7(b9)", "Dm7"]
|
|
42
|
+
chords = [ChordParser.parse(c) for c in progression_str if c]
|
|
43
|
+
|
|
44
|
+
# 3. 解析 (annotate_progression)
|
|
45
|
+
# 結果は RomanizedChord オブジェクトのリスト
|
|
46
|
+
results = romanizer.annotate_progression(chords)
|
|
47
|
+
|
|
48
|
+
# 4. 結果の表示
|
|
49
|
+
for res in results:
|
|
50
|
+
input_symbol = res.chord.symbol
|
|
51
|
+
roman = res.roman
|
|
52
|
+
key = romanizer.default_tonic
|
|
53
|
+
print(f"{input_symbol} (Key: {key}) -> {roman}")
|
|
54
|
+
|
|
55
|
+
# 出力例:
|
|
56
|
+
# Dm7 (Key: C) -> IIm7
|
|
57
|
+
# G7 (Key: C) -> V7
|
|
58
|
+
# Cmaj7 (Key: C) -> IM7
|
|
59
|
+
# A7(b9) (Key: C) -> VI7(b9)
|
|
60
|
+
# Dm7 (Key: C) -> IIm7
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 主なクラスと機能
|
|
64
|
+
|
|
65
|
+
### `Romanizer`
|
|
66
|
+
解析のメインクラスです。
|
|
67
|
+
- `annotate_progression(progression)`: コードのリストを受け取り、解析結果 (`RomanizedChord`) のリストを返します。リストの各要素は `ParsedChord` または `(ParsedChord, Key)` のタプルを受け付けます(転調に対応する場合)。
|
|
68
|
+
|
|
69
|
+
### `ChordParser`
|
|
70
|
+
コードシンボル文字列を解析用オブジェクトに変換します。
|
|
71
|
+
- `parse(symbol_str)`: 文字列をパースして `ParsedChord` を返します。
|
|
72
|
+
|
|
73
|
+
### `RomanizedChord`
|
|
74
|
+
解析結果を保持するデータクラスです。
|
|
75
|
+
- `roman`: 最終的なローマ数字表記(例: `IIm7`, `V7/VI`)。
|
|
76
|
+
- `degree_root`: ルートの度数(例: `II`, `V`)。
|
|
77
|
+
- `degree_bass`: ベース音の度数(転回形の場合)。
|
|
78
|
+
- `is_hybrid`: ハイブリッドコード(機能的な分数コード)として解釈されたかフラグ。
|
|
79
|
+
- `is_ii_v_start`: ツーファイブ進行の起点(II)と判定されたか。
|
|
80
|
+
- `is_resolution_target`: ドミナントモーションの解決先(I)と判定されたか。
|
|
81
|
+
|
|
82
|
+
## ライセンス
|
|
83
|
+
[MIT License](LICENSE)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Chord Romanizer
|
|
2
|
+
|
|
3
|
+
Chord Romanizerは、コード進行の解析とディグリーネーム(ローマ数字)表記への変換を行うPythonライブラリです。
|
|
4
|
+
複雑なコードや転回形、ハイブリッドコード(分数コード)にも対応しており、文脈(前後のコード)を考慮した解析を行います。
|
|
5
|
+
|
|
6
|
+
## 特徴
|
|
7
|
+
- **文脈考慮**: キーと前後のコードから最適な度数表記を判定(例: 半音進行や解決先を考慮)。
|
|
8
|
+
- **分数コード対応**: 転回形 (`C/E`)、ハイブリッドコード (`F/G`)、UST (`upper structure triad`) などを識別し、適切に表記 (`I/3`, `V9sus4`, etc.)。
|
|
9
|
+
- **柔軟な入力**: 一般的なコードシンボル表記 (`Cm7`, `F#aug`, `Bb/C` など) をパース可能。
|
|
10
|
+
|
|
11
|
+
## インストール
|
|
12
|
+
|
|
13
|
+
現在のところ、PyPIには公開されていません。リポジトリをクローンしてプロジェクトに含めてください。
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
git clone https://github.com/your-username/chord-romanizer.git
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## クイックスタート
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from chord_romanizer import Romanizer, ChordParser
|
|
23
|
+
|
|
24
|
+
# 1. インスタンス生成 (デフォルトキーを指定可能)
|
|
25
|
+
romanizer = Romanizer(default_tonic="C")
|
|
26
|
+
|
|
27
|
+
# 2. コード進行の準備 (文字列からParsedChordへ変換)
|
|
28
|
+
progression_str = ["Dm7", "G7", "Cmaj7", "A7(b9)", "Dm7"]
|
|
29
|
+
chords = [ChordParser.parse(c) for c in progression_str if c]
|
|
30
|
+
|
|
31
|
+
# 3. 解析 (annotate_progression)
|
|
32
|
+
# 結果は RomanizedChord オブジェクトのリスト
|
|
33
|
+
results = romanizer.annotate_progression(chords)
|
|
34
|
+
|
|
35
|
+
# 4. 結果の表示
|
|
36
|
+
for res in results:
|
|
37
|
+
input_symbol = res.chord.symbol
|
|
38
|
+
roman = res.roman
|
|
39
|
+
key = romanizer.default_tonic
|
|
40
|
+
print(f"{input_symbol} (Key: {key}) -> {roman}")
|
|
41
|
+
|
|
42
|
+
# 出力例:
|
|
43
|
+
# Dm7 (Key: C) -> IIm7
|
|
44
|
+
# G7 (Key: C) -> V7
|
|
45
|
+
# Cmaj7 (Key: C) -> IM7
|
|
46
|
+
# A7(b9) (Key: C) -> VI7(b9)
|
|
47
|
+
# Dm7 (Key: C) -> IIm7
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 主なクラスと機能
|
|
51
|
+
|
|
52
|
+
### `Romanizer`
|
|
53
|
+
解析のメインクラスです。
|
|
54
|
+
- `annotate_progression(progression)`: コードのリストを受け取り、解析結果 (`RomanizedChord`) のリストを返します。リストの各要素は `ParsedChord` または `(ParsedChord, Key)` のタプルを受け付けます(転調に対応する場合)。
|
|
55
|
+
|
|
56
|
+
### `ChordParser`
|
|
57
|
+
コードシンボル文字列を解析用オブジェクトに変換します。
|
|
58
|
+
- `parse(symbol_str)`: 文字列をパースして `ParsedChord` を返します。
|
|
59
|
+
|
|
60
|
+
### `RomanizedChord`
|
|
61
|
+
解析結果を保持するデータクラスです。
|
|
62
|
+
- `roman`: 最終的なローマ数字表記(例: `IIm7`, `V7/VI`)。
|
|
63
|
+
- `degree_root`: ルートの度数(例: `II`, `V`)。
|
|
64
|
+
- `degree_bass`: ベース音の度数(転回形の場合)。
|
|
65
|
+
- `is_hybrid`: ハイブリッドコード(機能的な分数コード)として解釈されたかフラグ。
|
|
66
|
+
- `is_ii_v_start`: ツーファイブ進行の起点(II)と判定されたか。
|
|
67
|
+
- `is_resolution_target`: ドミナントモーションの解決先(I)と判定されたか。
|
|
68
|
+
|
|
69
|
+
## ライセンス
|
|
70
|
+
[MIT License](LICENSE)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .romanizer import Romanizer, RomanizedChord
|
|
2
|
+
from .chord_parser import ChordParser, ParsedChord
|
|
3
|
+
from .chord_interpreter import ChordInterpreter, HybridAnalysis, HybridKind
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Romanizer",
|
|
7
|
+
"RomanizedChord",
|
|
8
|
+
"ChordParser",
|
|
9
|
+
"ParsedChord",
|
|
10
|
+
"ChordInterpreter",
|
|
11
|
+
"HybridAnalysis",
|
|
12
|
+
"HybridKind",
|
|
13
|
+
]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from .chord_parser import ParsedChord
|
|
6
|
+
from .note_speller import NoteSpeller
|
|
7
|
+
from .chord_structure import ChordStructure
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HybridKind(str, Enum):
|
|
11
|
+
"""ハイブリッドコードの分類ID"""
|
|
12
|
+
|
|
13
|
+
NONE = "none"
|
|
14
|
+
BLACKADDER = "blackadder"
|
|
15
|
+
SEC_DOM_3_IN_BASS = "sec_dom_3inbass"
|
|
16
|
+
HALFDIM_9 = "halfdim9"
|
|
17
|
+
SUS4_9 = "9sus4"
|
|
18
|
+
SUS4_7_B9 = "7sus4(b9)"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class HybridAnalysis:
|
|
23
|
+
"""ハイブリッド/スラッシュコードの解析結果"""
|
|
24
|
+
|
|
25
|
+
is_hybrid: bool = False
|
|
26
|
+
alter: Optional[str] = None # "Gb7(9,#11)" など
|
|
27
|
+
bass_preference: Optional[bool] = None # Bassの#b優先度
|
|
28
|
+
root_override: Optional[str] = None # Blackadder時のアンカーなど
|
|
29
|
+
kind: HybridKind = HybridKind.NONE # "blackadder", "halfdim9" など
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ChordInterpreter:
|
|
33
|
+
"""
|
|
34
|
+
複雑なコード(AugSlash, Blackadderなど)の機能的な解釈を行うクラス。
|
|
35
|
+
Romanizerから複雑な条件分岐を隠蔽する。
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# --- Scoring Configuration ---
|
|
39
|
+
SCORE_SEMITONE_RESOLUTION = 3.0 # 半音解決
|
|
40
|
+
SCORE_BACKDOOR_RESOLUTION = 5.0 # 裏コード/Backdoor解決
|
|
41
|
+
SCORE_STRONG_RESOLUTION = 6.0 # 強進行 (V->I)
|
|
42
|
+
SCORE_WEAK_RESOLUTION = 2.0 # 弱進行 (Dominantへの解決など)
|
|
43
|
+
SCORE_BASE_BIAS = 0.5 # 同点時の下駄
|
|
44
|
+
|
|
45
|
+
# -----------------------------
|
|
46
|
+
# public
|
|
47
|
+
# -----------------------------
|
|
48
|
+
def analyze_slash_chord(
|
|
49
|
+
self,
|
|
50
|
+
chord: ParsedChord,
|
|
51
|
+
next_chord: Optional[ParsedChord],
|
|
52
|
+
) -> HybridAnalysis:
|
|
53
|
+
if not chord.bass:
|
|
54
|
+
return HybridAnalysis(is_hybrid=False)
|
|
55
|
+
|
|
56
|
+
# 1. 転回形 (Structureに委譲)
|
|
57
|
+
if ChordStructure.is_inversion(chord.root, chord.bass, chord.quality):
|
|
58
|
+
return HybridAnalysis(is_hybrid=False)
|
|
59
|
+
|
|
60
|
+
# 2. Aug slash (文脈解釈)
|
|
61
|
+
if ChordStructure.is_aug_quality(chord.quality):
|
|
62
|
+
aug_result = self._infer_aug_slash(chord, next_chord)
|
|
63
|
+
if aug_result:
|
|
64
|
+
return aug_result
|
|
65
|
+
|
|
66
|
+
# 3. 通常の hybrid (簡易定義に基づく)
|
|
67
|
+
alter, kind = self._infer_normal_hybrid(chord)
|
|
68
|
+
if alter:
|
|
69
|
+
return HybridAnalysis(is_hybrid=True, alter=alter, kind=kind)
|
|
70
|
+
|
|
71
|
+
return HybridAnalysis(is_hybrid=True, kind=HybridKind.NONE)
|
|
72
|
+
|
|
73
|
+
# --- Internal Logic ---
|
|
74
|
+
|
|
75
|
+
def _infer_aug_slash(
|
|
76
|
+
self, chord: ParsedChord, next_chord: Optional[ParsedChord]
|
|
77
|
+
) -> Optional[HybridAnalysis]:
|
|
78
|
+
candidates: List[Tuple[float, HybridAnalysis]] = []
|
|
79
|
+
|
|
80
|
+
blackadder = self._check_blackadder(chord, next_chord)
|
|
81
|
+
if blackadder:
|
|
82
|
+
candidates.append(blackadder)
|
|
83
|
+
|
|
84
|
+
halfdim = self._check_halfdim(chord, next_chord)
|
|
85
|
+
if halfdim:
|
|
86
|
+
candidates.append(halfdim)
|
|
87
|
+
|
|
88
|
+
if not candidates:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
# スコア最大を採用
|
|
92
|
+
return max(candidates, key=lambda x: x[0])[1]
|
|
93
|
+
|
|
94
|
+
def _check_blackadder(
|
|
95
|
+
self, chord: ParsedChord, next_chord: Optional[ParsedChord]
|
|
96
|
+
) -> Optional[Tuple[float, HybridAnalysis]]:
|
|
97
|
+
bass_pitch_class = NoteSpeller.pitch_class_of(chord.bass)
|
|
98
|
+
triad_pitch_classes = ChordStructure.get_aug_triad_pitch_classes(chord.root)
|
|
99
|
+
|
|
100
|
+
if bass_pitch_class is None or triad_pitch_classes is None:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# Anchor check: bass + 6 semitones が構成音に含まれるか
|
|
104
|
+
anchor_pitch_class = (bass_pitch_class + 6) % 12
|
|
105
|
+
if anchor_pitch_class not in triad_pitch_classes:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
# --- Context Analysis ---
|
|
109
|
+
score = 0.0
|
|
110
|
+
bass_to_next = (
|
|
111
|
+
NoteSpeller.semitone_distance(next_chord.root, chord.bass)
|
|
112
|
+
if next_chord
|
|
113
|
+
else None
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Determine Bass Preference (Sharp or Flat)
|
|
117
|
+
bass_preference = self._bass_pref_from_resolution(chord.bass, next_chord) or False
|
|
118
|
+
bass_fixed = NoteSpeller.name_of_pitch_class(bass_pitch_class, bass_preference)
|
|
119
|
+
anchor_name = NoteSpeller.name_of_pitch_class(anchor_pitch_class) # Canonical
|
|
120
|
+
|
|
121
|
+
# Default Interpretation
|
|
122
|
+
alter = f"{bass_fixed}7(9,#11)"
|
|
123
|
+
kind = HybridKind.BLACKADDER
|
|
124
|
+
|
|
125
|
+
# Rule 1: 半音解決 (Tritone substitution behavior)
|
|
126
|
+
if bass_to_next in (1, 11):
|
|
127
|
+
score += self.SCORE_SEMITONE_RESOLUTION
|
|
128
|
+
|
|
129
|
+
# Rule 2: Backdoor (bVII -> I)
|
|
130
|
+
if (
|
|
131
|
+
next_chord
|
|
132
|
+
and bass_to_next == 2
|
|
133
|
+
and ChordStructure.is_tonic_quality(next_chord.quality)
|
|
134
|
+
):
|
|
135
|
+
score += self.SCORE_BACKDOOR_RESOLUTION
|
|
136
|
+
|
|
137
|
+
# Rule 3: 3rd in Bass Secondary Dominant (e.g., Faug/B -> C)
|
|
138
|
+
# Anchor(F) -> Dominant(G) -> Target(C)
|
|
139
|
+
anchor_parsed = NoteSpeller.parse_note(anchor_name)
|
|
140
|
+
if anchor_parsed and next_chord:
|
|
141
|
+
dominant_letter = NoteSpeller.shift_letter(anchor_parsed[0], 1)
|
|
142
|
+
dominant_pc = (anchor_pitch_class + 2) % 12
|
|
143
|
+
dominant_name = NoteSpeller.spell_pitch_class(dominant_letter, dominant_pc)
|
|
144
|
+
|
|
145
|
+
dominant_to_next = NoteSpeller.semitone_distance(next_chord.root, dominant_name)
|
|
146
|
+
# Dominant resolution check (V->I)
|
|
147
|
+
if dominant_to_next in (5, 7) and ChordStructure.is_tonic_quality(
|
|
148
|
+
next_chord.quality
|
|
149
|
+
):
|
|
150
|
+
alter = f"{dominant_name}7(9,#11)/{bass_fixed}"
|
|
151
|
+
kind = HybridKind.SEC_DOM_3_IN_BASS
|
|
152
|
+
score += self.SCORE_STRONG_RESOLUTION
|
|
153
|
+
|
|
154
|
+
return score, HybridAnalysis(
|
|
155
|
+
is_hybrid=True,
|
|
156
|
+
kind=kind,
|
|
157
|
+
alter=alter,
|
|
158
|
+
bass_preference=bass_preference,
|
|
159
|
+
root_override=anchor_name,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _check_halfdim(
|
|
163
|
+
self, chord: ParsedChord, next_chord: Optional[ParsedChord]
|
|
164
|
+
) -> Optional[Tuple[float, HybridAnalysis]]:
|
|
165
|
+
bass_pitch_class = NoteSpeller.pitch_class_of(chord.bass)
|
|
166
|
+
triad_pitch_classes = ChordStructure.get_aug_triad_pitch_classes(chord.root)
|
|
167
|
+
if bass_pitch_class is None or triad_pitch_classes is None:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
# Half-dim check: relative intervals {2, 6, 10}
|
|
171
|
+
relative_intervals = {(pc - bass_pitch_class) % 12 for pc in triad_pitch_classes}
|
|
172
|
+
if not {2, 6, 10}.issubset(relative_intervals):
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
score = self.SCORE_BASE_BIAS
|
|
176
|
+
bass_preference = (
|
|
177
|
+
True if "#" in chord.bass else (False if "b" in chord.bass else None)
|
|
178
|
+
)
|
|
179
|
+
bass_fixed = NoteSpeller.name_of_pitch_class(bass_pitch_class, bass_preference)
|
|
180
|
+
|
|
181
|
+
if next_chord:
|
|
182
|
+
bass_to_next = NoteSpeller.semitone_distance(next_chord.root, chord.bass)
|
|
183
|
+
next_is_dominant = ChordStructure.is_dominant_quality(next_chord.quality)
|
|
184
|
+
|
|
185
|
+
# iiø -> V (Strong)
|
|
186
|
+
if bass_to_next in (5, 7) and next_is_dominant:
|
|
187
|
+
score += self.SCORE_STRONG_RESOLUTION
|
|
188
|
+
# Any resolution to Dominant (Weak)
|
|
189
|
+
elif next_is_dominant:
|
|
190
|
+
score += self.SCORE_WEAK_RESOLUTION
|
|
191
|
+
|
|
192
|
+
return score, HybridAnalysis(
|
|
193
|
+
is_hybrid=True,
|
|
194
|
+
kind=HybridKind.HALFDIM_9,
|
|
195
|
+
alter=f"{bass_fixed}m7-5(9)",
|
|
196
|
+
bass_preference=bass_preference,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def _infer_normal_hybrid(
|
|
200
|
+
self, chord: ParsedChord
|
|
201
|
+
) -> Tuple[Optional[str], HybridKind]:
|
|
202
|
+
dist = NoteSpeller.semitone_distance(chord.root, chord.bass)
|
|
203
|
+
if dist is None:
|
|
204
|
+
return None, HybridKind.NONE
|
|
205
|
+
|
|
206
|
+
intervals = ChordStructure.get_intervals(chord.quality)
|
|
207
|
+
relative_intervals = {(i + dist) % 12 for i in intervals}
|
|
208
|
+
|
|
209
|
+
has_third = (3 in relative_intervals) or (4 in relative_intervals)
|
|
210
|
+
if not has_third:
|
|
211
|
+
if {2, 5, 10}.issubset(relative_intervals):
|
|
212
|
+
return f"{chord.bass}9sus4", HybridKind.SUS4_9
|
|
213
|
+
if {1, 5, 10}.issubset(relative_intervals):
|
|
214
|
+
return f"{chord.bass}7sus4(b9)", HybridKind.SUS4_7_B9
|
|
215
|
+
return None, HybridKind.NONE
|
|
216
|
+
|
|
217
|
+
# Helper: Bass preference logic
|
|
218
|
+
def _bass_pref_from_resolution(
|
|
219
|
+
self, bass: str, next_chord: Optional[ParsedChord]
|
|
220
|
+
) -> Optional[bool]:
|
|
221
|
+
if not next_chord:
|
|
222
|
+
return None
|
|
223
|
+
bass_to_next = NoteSpeller.semitone_distance(next_chord.root, bass)
|
|
224
|
+
if bass_to_next == 1:
|
|
225
|
+
return True # Ascending -> Sharp
|
|
226
|
+
if bass_to_next == 11:
|
|
227
|
+
return False # Descending -> Flat
|
|
228
|
+
return None
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
# Pitch-class canonical names (all sharps)
|
|
5
|
+
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
|
6
|
+
|
|
7
|
+
# Aliases for normalization (flats, H, etc.)
|
|
8
|
+
NOTE_ALIASES = {
|
|
9
|
+
"CB": "B",
|
|
10
|
+
"B#": "C",
|
|
11
|
+
"DB": "C#",
|
|
12
|
+
"EB": "D#",
|
|
13
|
+
"E#": "F",
|
|
14
|
+
"FB": "E",
|
|
15
|
+
"GB": "F#",
|
|
16
|
+
"AB": "G#",
|
|
17
|
+
"BB": "A#",
|
|
18
|
+
"HB": "B",
|
|
19
|
+
"H": "B",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize_note_pc(note: str) -> Optional[str]:
|
|
24
|
+
"""Normalize pitch-class spelling to canonical sharp-based representation."""
|
|
25
|
+
up = note.strip().upper()
|
|
26
|
+
# Handle double sharp x
|
|
27
|
+
if up.endswith("X"):
|
|
28
|
+
base = up[:-1]
|
|
29
|
+
if base in NOTE_NAMES or base in NOTE_ALIASES:
|
|
30
|
+
# Recursively normalize base and add 2 semitones
|
|
31
|
+
base_pc = normalize_note_pc(base)
|
|
32
|
+
if base_pc:
|
|
33
|
+
idx = NOTE_NAMES.index(base_pc)
|
|
34
|
+
return NOTE_NAMES[(idx + 2) % 12]
|
|
35
|
+
|
|
36
|
+
if up in NOTE_ALIASES:
|
|
37
|
+
return NOTE_ALIASES[up]
|
|
38
|
+
if up in NOTE_NAMES:
|
|
39
|
+
return up
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_spelling(token: str) -> str:
|
|
44
|
+
"""Keep user spelling but capitalize first letter (Db -> Db, c# -> C#)."""
|
|
45
|
+
token = token.strip()
|
|
46
|
+
if not token:
|
|
47
|
+
return token
|
|
48
|
+
return token[0].upper() + token[1:]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ParsedChord:
|
|
53
|
+
symbol: str
|
|
54
|
+
root: str # user spelling (e.g., Db / C#)
|
|
55
|
+
quality: str
|
|
56
|
+
bass: Optional[str] = None # user spelling (e.g., F#, Gb)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ChordParser:
|
|
60
|
+
@staticmethod
|
|
61
|
+
def parse(symbol: str) -> Optional[ParsedChord]:
|
|
62
|
+
"""Parse chord symbol like 'C#m7/G#' into components."""
|
|
63
|
+
if not symbol:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
text = symbol.strip()
|
|
67
|
+
|
|
68
|
+
# Allow no-chord markers
|
|
69
|
+
normalized_nc = text.replace(".", "").replace(" ", "").upper()
|
|
70
|
+
if normalized_nc in {"NC", "NOCHORD"}:
|
|
71
|
+
return ParsedChord(symbol=text, root="NC", quality="", bass=None)
|
|
72
|
+
|
|
73
|
+
# Split slash bass if present
|
|
74
|
+
if "/" in text:
|
|
75
|
+
body, bass = text.split("/", 1)
|
|
76
|
+
bass_token = bass.strip()
|
|
77
|
+
if normalize_note_pc(bass_token) is None:
|
|
78
|
+
return None
|
|
79
|
+
else:
|
|
80
|
+
body, bass_token = text, None
|
|
81
|
+
|
|
82
|
+
body = body.strip()
|
|
83
|
+
if not body:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
# Root token: A-G plus optional accidentals (#, b, x, bb)
|
|
87
|
+
# Check for 2-char accidentals first (bb) or just double #?
|
|
88
|
+
# Actually user input might be "Cbb". body="Cbb".
|
|
89
|
+
# Or "Cx". body="Cx".
|
|
90
|
+
|
|
91
|
+
# Simple parser approach:
|
|
92
|
+
# Take 1 char. Check next chars for accidentals.
|
|
93
|
+
root_len = 1
|
|
94
|
+
if len(body) > 1 and body[1] in ("#", "b", "B", "x", "X"):
|
|
95
|
+
root_len = 2
|
|
96
|
+
# Check for double accidentals like "bb" or "##"
|
|
97
|
+
if len(body) > 2 and body[2] == body[1] and body[1] in ("#", "b", "B"):
|
|
98
|
+
root_len = 3
|
|
99
|
+
|
|
100
|
+
root_token = body[:root_len]
|
|
101
|
+
rest = body[root_len:]
|
|
102
|
+
|
|
103
|
+
# Validate root
|
|
104
|
+
if normalize_note_pc(root_token) is None:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
root = normalize_spelling(root_token)
|
|
108
|
+
quality = rest or ""
|
|
109
|
+
|
|
110
|
+
bass: Optional[str] = None
|
|
111
|
+
if bass_token:
|
|
112
|
+
bass = normalize_spelling(bass_token)
|
|
113
|
+
|
|
114
|
+
return ParsedChord(symbol=text, root=root, quality=quality, bass=bass)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from typing import Dict, Optional, Set, Tuple
|
|
2
|
+
|
|
3
|
+
from .chord_parser import NOTE_NAMES
|
|
4
|
+
from .note_speller import NATURAL_PITCH_CLASS, NoteSpeller
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ChordStructure:
|
|
8
|
+
"""
|
|
9
|
+
コードの「静的な定義」のみを扱う。
|
|
10
|
+
「文脈によってこう解釈する」というロジックは Interpreter へ移動。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def get_intervals(quality: str) -> Set[int]:
|
|
15
|
+
if "M7" in quality:
|
|
16
|
+
return {0, 4, 7, 11}
|
|
17
|
+
|
|
18
|
+
quality_lower = (quality or "").lower()
|
|
19
|
+
if ("m7-5" in quality_lower) or ("m7b5" in quality_lower):
|
|
20
|
+
return {0, 3, 6, 10}
|
|
21
|
+
if "dim" in quality_lower or "o" in quality_lower:
|
|
22
|
+
return {0, 3, 6}
|
|
23
|
+
if ("maj7" in quality_lower) or ("ma7" in quality_lower):
|
|
24
|
+
return {0, 4, 7, 11}
|
|
25
|
+
if "m7" in quality_lower:
|
|
26
|
+
return {0, 3, 7, 10}
|
|
27
|
+
if "7" in quality_lower:
|
|
28
|
+
return {0, 4, 7, 10}
|
|
29
|
+
if "m" in quality_lower:
|
|
30
|
+
return {0, 3, 7}
|
|
31
|
+
return {0, 4, 7}
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def is_inversion(root: str, bass: str, quality: str) -> bool:
|
|
35
|
+
dist = NoteSpeller.semitone_distance(bass, root)
|
|
36
|
+
if dist is None:
|
|
37
|
+
return False
|
|
38
|
+
return dist in ChordStructure.get_intervals(quality)
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def get_spelled_tones(root: str, quality: str) -> Dict[int, str]:
|
|
42
|
+
root_parsed = NoteSpeller.parse_note(root)
|
|
43
|
+
if root_parsed is None:
|
|
44
|
+
return {}
|
|
45
|
+
root_letter, root_accidental = root_parsed
|
|
46
|
+
root_pitch_class = (NATURAL_PITCH_CLASS[root_letter] + root_accidental) % 12
|
|
47
|
+
|
|
48
|
+
quality_lower = quality.lower()
|
|
49
|
+
if "M7" in quality:
|
|
50
|
+
definition = ([(0, 0), (4, 2), (7, 4)], (11, 6))
|
|
51
|
+
elif "m7" in quality_lower:
|
|
52
|
+
definition = ([(0, 0), (3, 2), (7, 4)], (10, 6))
|
|
53
|
+
elif "maj7" in quality_lower:
|
|
54
|
+
definition = ([(0, 0), (4, 2), (7, 4)], (11, 6))
|
|
55
|
+
elif "7" in quality_lower:
|
|
56
|
+
definition = ([(0, 0), (4, 2), (7, 4)], (10, 6))
|
|
57
|
+
else:
|
|
58
|
+
definition = ([(0, 0), (4, 2), (7, 4)], None)
|
|
59
|
+
|
|
60
|
+
triad, seventh = definition
|
|
61
|
+
tones = {}
|
|
62
|
+
for semitone, step in triad:
|
|
63
|
+
target_letter = NoteSpeller.shift_letter(root_letter, step)
|
|
64
|
+
current_pitch_class = (root_pitch_class + semitone) % 12
|
|
65
|
+
tones[current_pitch_class] = NoteSpeller.spell_pitch_class(
|
|
66
|
+
target_letter, current_pitch_class
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if seventh:
|
|
70
|
+
semitone, step = seventh
|
|
71
|
+
target_letter = NoteSpeller.shift_letter(root_letter, step)
|
|
72
|
+
current_pitch_class = (root_pitch_class + semitone) % 12
|
|
73
|
+
tones[current_pitch_class] = NoteSpeller.spell_pitch_class(
|
|
74
|
+
target_letter, current_pitch_class
|
|
75
|
+
)
|
|
76
|
+
return tones
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def is_aug_quality(quality: str) -> bool:
|
|
80
|
+
quality_lower = (quality or "").lower()
|
|
81
|
+
return ("aug" in quality_lower) or ("+" in (quality or ""))
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def get_aug_triad_pitch_classes(root: str) -> Optional[Set[int]]:
|
|
85
|
+
root_pc = NoteSpeller.pitch_class_of(root)
|
|
86
|
+
if root_pc is None:
|
|
87
|
+
return None
|
|
88
|
+
return {(root_pc + 0) % 12, (root_pc + 4) % 12, (root_pc + 8) % 12}
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def is_dominant_quality(quality: str) -> bool:
|
|
92
|
+
"""機能的にドミナントになり得るQualityか判定"""
|
|
93
|
+
# "M7" は Major 7th なのでドミナントではない (case-sensitive check)
|
|
94
|
+
if "M7" in quality:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
quality_lower = (quality or "").lower()
|
|
98
|
+
# maj7 は除外、7を含むものをドミナント候補とする
|
|
99
|
+
if ("maj7" in quality_lower) or ("ma7" in quality_lower):
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
# マイナー系 (m7 など) はドミナントではない (ただし dim7 はドミナント機能を持つことがあるので除外しない)
|
|
103
|
+
if ("m" in quality_lower) and ("dim" not in quality_lower):
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
return "7" in quality_lower
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def is_tonic_quality(quality: str) -> bool:
|
|
110
|
+
"""機能的にトニックになり得るQualityか判定"""
|
|
111
|
+
if ChordStructure.is_dominant_quality(quality):
|
|
112
|
+
return False
|
|
113
|
+
# m, maj7, M7, 6, または無印(Triad)など
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def is_minor_quality(quality: str) -> bool:
|
|
118
|
+
"""機能的にマイナーになり得るQualityか判定(m, m7, m9など)"""
|
|
119
|
+
# "M7" は Major 7th -> Not Minor
|
|
120
|
+
if "M7" in quality:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
quality_lower = (quality or "").lower()
|
|
124
|
+
if ("m" in quality_lower) and ("maj" not in quality_lower) and ("dim" not in quality_lower):
|
|
125
|
+
return True
|
|
126
|
+
return False
|