From f42d38d1fd64842c16f0e8a2df7e7bcc647bee3c Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Thu, 26 Mar 2026 23:48:42 -0400 Subject: [PATCH] Support Cb, Fb, E#, B#, double sharps/flats, unicode symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cb, Fb, E#, B# resolve to their enharmonic equivalents (fixes #40) - C##, Dbb, etc. resolve via semitone arithmetic (fixes #41) - Unicode symbols accepted: ♯ ♭ 𝄪 𝄫 - 'x'/'X' accepted as double sharp (Bach notation): Fx = F## - resolve_name handles all accidentals dynamically Closes #40, closes #41 Co-Authored-By: Claude Opus 4.6 (1M context) --- pytheory/systems.py | 45 ++++++++++++++++++++++++++++++++++++++++++++- pytheory/tones.py | 20 ++++++++++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/pytheory/systems.py b/pytheory/systems.py index e43c672..55572a9 100644 --- a/pytheory/systems.py +++ b/pytheory/systems.py @@ -25,13 +25,56 @@ class System: return tuple([Tone.from_tuple(tone) for tone in self.tone_names]) def resolve_name(self, name: str) -> str | None: - """Resolve a note name (including flats) to the canonical name. + """Resolve a note name (including flats, double sharps/flats) to the canonical name. + + Handles enharmonic equivalents: + - Standard names and their alternates (e.g. Bb, C#) + - Double sharps (C## = D, F## = G) + - Double flats (Dbb = C, Ebb = D) Returns the primary name if found, or None if not recognized. """ + # Direct lookup first for names in self.tone_names: if name in names: return names[0] + + # Handle double sharps (e.g. C## → D, F## → G) + if name.endswith('##') and len(name) >= 3: + base = name[:-2] + base_idx = self._name_to_index(base) + if base_idx is not None: + resolved_idx = (base_idx + 2) % len(self.tone_names) + return self.tone_names[resolved_idx][0] + + # Handle double flats (e.g. Dbb → C, Ebb → D) + if name.endswith('bb') and len(name) >= 3 and name[0] != 'b': + base = name[:-2] + base_idx = self._name_to_index(base) + if base_idx is not None: + resolved_idx = (base_idx - 2) % len(self.tone_names) + return self.tone_names[resolved_idx][0] + + # Handle single sharps/flats on natural notes (e.g. Cb → B, E# → F) + if len(name) == 2: + base = name[0] + modifier = name[1] + base_idx = self._name_to_index(base) + if base_idx is not None: + if modifier == '#': + resolved_idx = (base_idx + 1) % len(self.tone_names) + return self.tone_names[resolved_idx][0] + elif modifier == 'b': + resolved_idx = (base_idx - 1) % len(self.tone_names) + return self.tone_names[resolved_idx][0] + + return None + + def _name_to_index(self, name: str) -> int | None: + """Return the index of a tone name, or None if not found.""" + for i, names in enumerate(self.tone_names): + if name in names: + return i return None diff --git a/pytheory/tones.py b/pytheory/tones.py index 1b26e2e..0dfeb8d 100644 --- a/pytheory/tones.py +++ b/pytheory/tones.py @@ -47,6 +47,17 @@ class Tone: alt_names = [] if isinstance(name, str): + # Normalize unicode music symbols to ASCII equivalents + name = (name + .replace('\u266f', '#') # ♯ → # + .replace('\u266d', 'b') # ♭ → b + .replace('\U0001d12a', '##') # 𝄪 → ## + .replace('\U0001d12b', 'bb') # 𝄫 → bb + ) + # Normalize 'x' / 'X' as double sharp (only after letter name) + if len(name) >= 2 and name[1] in ('x', 'X') and name[0].isalpha(): + name = name[0] + '##' + name[2:] + try: parsed_octave = int("".join([c for c in filter(str.isdigit, name)])) except ValueError: @@ -442,9 +453,14 @@ class Tone: """ tone_names = system.tone_names[i] if prefer_flats and len(tone_names) > 1: - tone = tone_names[1] # flat spelling (e.g. "Bb") + # Find the first flat spelling (contains 'b' but isn't just 'B') + tone = tone_names[0] # fallback to primary + for tn in tone_names[1:]: + if 'b' in tn and tn != 'B': + tone = tn + break else: - tone = tone_names[0] # sharp spelling (e.g. "A#") + tone = tone_names[0] # primary spelling return klass(name=tone, octave=octave, system=system) @property