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.
@@ -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,2 @@
1
+ include README.md
2
+ include LICENSE
@@ -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