Support Cb, Fb, E#, B#, double sharps/flats, unicode symbols

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 23:48:42 -04:00
parent 5a4122d61f
commit f42d38d1fd
2 changed files with 62 additions and 3 deletions
+44 -1
View File
@@ -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
+18 -2
View File
@@ -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