Spaces:
Sleeping
Sleeping
| import copy | |
| from collections import OrderedDict | |
| import numpy as np | |
| from .basic import HumanPitchName | |
| from .internal import Chord, Key, Meter, Note, Tempo | |
| _THEORYTAB_SCALE_NAME_TO_PITCH_INTERVALS = { | |
| "major": (2, 2, 1, 2, 2, 2), | |
| "dorian": (2, 1, 2, 2, 2, 1), | |
| "phrygian": (1, 2, 2, 2, 1, 2), | |
| "lydian": (2, 2, 2, 1, 2, 2), | |
| "mixolydian": (2, 2, 1, 2, 2, 1), | |
| "minor": (2, 1, 2, 2, 1, 2), | |
| "locrian": (1, 2, 2, 1, 2, 2), | |
| "harmonicMinor": (2, 1, 2, 2, 1, 3), | |
| "phrygianDominant": (1, 3, 1, 2, 1, 2), | |
| } | |
| _THEORYTAB_ACCIDENTAL_STR_TO_NUM_SEMITONES = {"bb": -2, "b": -1, "": 0, "#": 1, "##": 2} | |
| _THEORYTAB_ACCIDENTAL_STR_TO_NUM_SEMITONES_LEGACY_BUG = { | |
| "bb": -1, | |
| "b": -1, | |
| "": 0, | |
| "#": 1, | |
| "##": 1, | |
| } | |
| # NOTE: Rules written down manually from Hookpad | |
| _THEORYTAB_CHORD_TYPE_TO_ALLOWED_OPTIONS = { | |
| 5: { | |
| "inversions": [0, 1, 2], | |
| "suspensions": [2, 4], | |
| "adds": [9, 4, 6], | |
| "omits": [3, 5], | |
| "alterations": ["b5", "#5"], | |
| }, | |
| 7: { | |
| "inversions": [0, 1, 2, 3], | |
| "suspensions": [2, 4], | |
| "adds": [4, 6], | |
| "omits": [3, 5], | |
| "alterations": ["b5", "#5", "b9", "#9", "#11", "b13"], | |
| }, | |
| 9: { | |
| "inversions": [0], | |
| "suspensions": [4], | |
| "adds": [6], | |
| "omits": [3, 5], | |
| "alterations": ["b5", "#5", "#11", "b13"], | |
| }, | |
| 11: { | |
| "inversions": [0], | |
| "suspensions": [2], | |
| "adds": [], | |
| "omits": [3, 5], | |
| "alterations": ["b5", "#5", "b9", "#9", "b13"], | |
| }, | |
| 13: { | |
| "inversions": [0], | |
| "suspensions": [], | |
| "adds": [], | |
| "omits": [3, 5], | |
| "alterations": ["b5", "#5", "b9", "#9", "#11"], | |
| }, | |
| } | |
| class _TheorytabDict(dict): | |
| _FIELDS = [] | |
| def _check_values(self): | |
| if "beat" in self: | |
| min_beat = 0 if "isRest" in self and self["isRest"] else 1 | |
| if self["beat"] < min_beat: | |
| raise TheorytabValueError("beat") | |
| def __init__(self, *args, _skip_value_checks=False, **kwargs): | |
| super().__init__(*args, **kwargs) | |
| for f in self._FIELDS: | |
| if f not in self: | |
| raise TheorytabValueError(f"Missing field '{f}'") | |
| for f in self.keys(): | |
| if f not in self._FIELDS: | |
| raise TheorytabValueError(f"Unknown field '{f}'") | |
| if not _skip_value_checks: | |
| self._check_values() | |
| class TheorytabValueError(ValueError): | |
| pass | |
| class TheorytabMeter(_TheorytabDict): | |
| _FIELDS = ["beat", "numBeats", "beatUnit"] | |
| def _check_values(self): | |
| super()._check_values() | |
| if self["numBeats"] not in [2, 3, 4, 5, 6, 9, 12]: | |
| raise TheorytabValueError("numBeats") | |
| if self["beatUnit"] not in [1, 3]: | |
| raise TheorytabValueError("beatUnit") | |
| if self["beatUnit"] == 3 and self["numBeats"] not in [3, 6, 9, 12]: | |
| raise TheorytabValueError("numBeats,beatUnit") | |
| def as_meter(self): | |
| assert self["numBeats"] % self["beatUnit"] == 0 | |
| return Meter( | |
| self["numBeats"] // self["beatUnit"], | |
| 3 if self["beatUnit"] == 3 else 2, | |
| 4 if self["beatUnit"] == 3 else 2, | |
| ) | |
| class TheorytabTempo(_TheorytabDict): | |
| _FIELDS = ["beat", "bpm", "swingFactor", "swingBeat"] | |
| def _check_values(self): | |
| super()._check_values() | |
| if self["bpm"] is None or self["bpm"] < 30 or self["bpm"] > 300: | |
| raise TheorytabValueError("bpm") | |
| if isinstance(self["swingFactor"], int): | |
| if self["swingFactor"] != 0: | |
| raise TheorytabValueError("swingFactor") | |
| else: | |
| if self["swingFactor"] < 0.5 or self["swingFactor"] > 0.75: | |
| raise TheorytabValueError("swingFactor") | |
| if self["swingBeat"] not in [0.5, 0.25]: | |
| raise TheorytabValueError("swingBeat") | |
| def as_tempo(self): | |
| return Tempo(round(self["bpm"])) | |
| class TheorytabKey(_TheorytabDict): | |
| _FIELDS = ["beat", "scale", "tonic"] | |
| def _check_values(self): | |
| super()._check_values() | |
| if self["scale"] not in _THEORYTAB_SCALE_NAME_TO_PITCH_INTERVALS: | |
| raise TheorytabValueError("scale") | |
| try: | |
| HumanPitchName(self["tonic"]) | |
| except ValueError: | |
| raise TheorytabValueError("tonic") | |
| def as_key(self): | |
| return Key( | |
| HumanPitchName(self["tonic"]).as_pitch_class(), | |
| _THEORYTAB_SCALE_NAME_TO_PITCH_INTERVALS[self["scale"]], | |
| ) | |
| class _TheorytabNoteOrChord(_TheorytabDict): | |
| def _check_values(self, eps=1e-8): | |
| super()._check_values() | |
| assert "isRest" in self | |
| assert "duration" in self | |
| min_duration = None if self["isRest"] else eps | |
| if min_duration is not None and self["duration"] < min_duration: | |
| raise TheorytabValueError("duration") | |
| def will_sound(self, min_duration=1e-8): | |
| return ( | |
| self["beat"] >= 1 and self["duration"] > min_duration and not self["isRest"] | |
| ) | |
| class TheorytabNote(_TheorytabNoteOrChord): | |
| _FIELDS = ["sd", "octave", "beat", "duration", "isRest", "recordingEndBeat"] | |
| def _check_values(self): | |
| super()._check_values() | |
| sd = self["sd"] | |
| if len(sd) not in [1, 2, 3]: | |
| raise TheorytabValueError("sd") | |
| accidental_str = sd[:-1] | |
| if accidental_str not in _THEORYTAB_ACCIDENTAL_STR_TO_NUM_SEMITONES: | |
| raise TheorytabValueError("sd") | |
| sd = int(sd[-1]) - 1 | |
| if sd < 0 or sd >= 7: | |
| raise TheorytabValueError("sd") | |
| if abs(self["octave"]) > 4: | |
| raise TheorytabValueError("octave") | |
| def as_note(self, key, legacy_behavior=False): | |
| if isinstance(key, dict) and not isinstance(key, TheorytabKey): | |
| key = TheorytabKey(key) | |
| if isinstance(key, TheorytabKey): | |
| key = key.as_key() | |
| if not isinstance(key, Key): | |
| raise TypeError() | |
| result = None | |
| if self.will_sound(**({"min_duration": 0} if legacy_behavior else {})): | |
| key_tonic_pc, key_scale_intervals = key | |
| sd = self["sd"] | |
| accidental_str = sd[:-1] | |
| if legacy_behavior: | |
| accidental = _THEORYTAB_ACCIDENTAL_STR_TO_NUM_SEMITONES_LEGACY_BUG[ | |
| accidental_str | |
| ] | |
| else: | |
| accidental = _THEORYTAB_ACCIDENTAL_STR_TO_NUM_SEMITONES[accidental_str] | |
| sd = int(sd[-1]) - 1 | |
| pitch = 12 * self["octave"] | |
| pitch += key_tonic_pc | |
| pitch += sum(key_scale_intervals[:sd]) | |
| pitch += accidental | |
| result = Note(pitch % 12, pitch // 12) | |
| return result | |
| class TheorytabChord(_TheorytabNoteOrChord): | |
| _FIELDS = [ | |
| "root", | |
| "beat", | |
| "duration", | |
| "type", | |
| "inversion", | |
| "applied", | |
| "adds", | |
| "omits", | |
| "alterations", | |
| "suspensions", | |
| "pedal", | |
| "alternate", | |
| "borrowed", | |
| "isRest", | |
| "recordingEndBeat", | |
| ] | |
| def _check_values(self): | |
| super()._check_values() | |
| # Check 'root' | |
| if self["root"] <= 0: | |
| if self.will_sound(): | |
| raise TheorytabValueError("root") | |
| else: | |
| if self["root"] not in [1, 2, 3, 4, 5, 6, 7]: | |
| raise TheorytabValueError("root") | |
| # Check 'type' | |
| if self["type"] not in [5, 7, 9, 11, 13]: | |
| raise TheorytabValueError("type") | |
| # Check 'inversion' | |
| if self["inversion"] not in [0, 1, 2, 3]: | |
| raise TheorytabValueError("inversion") | |
| # Check 'applied' | |
| if self["applied"] not in [0, 1, 2, 3, 4, 5, 6, 7]: | |
| raise TheorytabValueError("applied") | |
| # Check 'adds' | |
| if any(a not in [9, 4, 6] for a in self["adds"]): | |
| raise TheorytabValueError("adds") | |
| if len(self["adds"]) != len(set(self["adds"])): | |
| raise TheorytabValueError("adds") | |
| # Check 'omits' | |
| if any(o not in [3, 5] for o in self["omits"]): | |
| raise TheorytabValueError("omits") | |
| if len(self["omits"]) != len(set(self["omits"])): | |
| raise TheorytabValueError("omits") | |
| # Check 'alterations' | |
| if any( | |
| a not in ["b5", "#5", "b9", "#9", "#11", "b13"] for a in self["alterations"] | |
| ): | |
| raise TheorytabValueError("alterations") | |
| if len(self["alterations"]) != len(set(self["alterations"])): | |
| raise TheorytabValueError("alterations") | |
| # Check 'suspensions' | |
| if any(s not in [2, 4] for s in self["suspensions"]): | |
| raise TheorytabValueError("suspensions") | |
| if len(self["suspensions"]) != len(set(self["suspensions"])): | |
| raise TheorytabValueError("suspensions") | |
| # Check 'pedal' | |
| if self["pedal"] is not None: | |
| raise TheorytabValueError("pedal") | |
| # Check 'alternate' | |
| if self["alternate"] != "": | |
| raise TheorytabValueError("alternate") | |
| # Check 'borrowed' | |
| if not ( | |
| self["borrowed"] == "" | |
| or self["borrowed"] is None | |
| or (isinstance(self["borrowed"], list) and len(self["borrowed"]) == 7) | |
| or self["borrowed"] in _THEORYTAB_SCALE_NAME_TO_PITCH_INTERVALS | |
| ): | |
| raise TheorytabValueError("borrowed") | |
| # Check 'type' against others | |
| for name, allowed_options in _THEORYTAB_CHORD_TYPE_TO_ALLOWED_OPTIONS[ | |
| self["type"] | |
| ].items(): | |
| if name == "inversions": | |
| if self["inversion"] not in allowed_options: | |
| raise TheorytabValueError("type,inversion") | |
| else: | |
| if any(o not in allowed_options for o in self[name]): | |
| raise TheorytabValueError(f"type,{name}") | |
| # Check 'inversion,omits' | |
| if self["inversion"] == 1 and 3 in self["omits"]: | |
| raise TheorytabValueError("inversion,omits") | |
| if self["inversion"] == 2 and 5 in self["omits"]: | |
| raise TheorytabValueError("inversion,omits") | |
| # Check 'adds,alterations' | |
| for alt in self["alterations"]: | |
| if int(alt[-1]) in self["adds"]: | |
| raise TheorytabValueError("adds,alterations") | |
| # Check 'adds,suspensions' | |
| if 2 in self["suspensions"] and 9 in self["adds"]: | |
| raise TheorytabValueError("adds,suspensions") | |
| if 4 in self["suspensions"] and 4 in self["adds"]: | |
| raise TheorytabValueError("adds,suspensions") | |
| # Check 'omits,alterations' | |
| if 5 in self["omits"] and ( | |
| "b5" in self["alterations"] or "#5" in self["alterations"] | |
| ): | |
| raise TheorytabValueError("omits,alterations") | |
| # Check 'omits,suspensions' | |
| if 3 in self["omits"] and len(self["suspensions"]) > 0: | |
| raise TheorytabValueError("omits,suspensions") | |
| def as_chord(self, key, root_position=False): | |
| if isinstance(key, dict) and not isinstance(key, TheorytabKey): | |
| key = TheorytabKey(key) | |
| if isinstance(key, TheorytabKey): | |
| key = key.as_key() | |
| if not isinstance(key, Key): | |
| raise TypeError() | |
| result = None | |
| if self.will_sound(): | |
| chord = copy.deepcopy(self) | |
| # Unsupported | |
| # TODO: | |
| if not root_position and chord["inversion"] != 0: | |
| raise NotImplementedError("inversion") | |
| # Build chord scale degrees | |
| chord_degrees = set(range(1, chord["type"] + 1, 2)) | |
| # Apply suspensions | |
| for i, d in enumerate(chord["suspensions"]): | |
| if i == 0: | |
| assert 3 in chord_degrees | |
| chord_degrees.remove(3) | |
| assert d not in chord_degrees | |
| chord_degrees.add(d) | |
| # Apply adds | |
| for d in chord["adds"]: | |
| if d in [4, 6]: | |
| d += 7 | |
| chord_degrees.add(d) | |
| # Apply omits | |
| for d in chord["omits"]: | |
| assert d in [3, 5] | |
| assert d in chord_degrees | |
| chord_degrees.remove(d) | |
| # Apply alterations | |
| for d in chord["alterations"]: | |
| d = int(d[1:]) | |
| chord_degrees.add(d) | |
| # Convert to list | |
| chord_degrees = sorted(list(chord_degrees)) | |
| # Find scale intervals | |
| key_tonic_pc, key_scale_intervals = key | |
| # Apply borrow (changes intervals) | |
| if isinstance(chord["borrowed"], list): | |
| key_scale_intervals = chord["borrowed"] | |
| else: | |
| if chord["borrowed"] in _THEORYTAB_SCALE_NAME_TO_PITCH_INTERVALS: | |
| key_scale_intervals = _THEORYTAB_SCALE_NAME_TO_PITCH_INTERVALS[ | |
| chord["borrowed"] | |
| ] | |
| key_scale_intervals = [0] + np.cumsum(key_scale_intervals).tolist() | |
| assert len(key_scale_intervals) == 7 | |
| # Apply secondary (changes tonic and intervals) | |
| major_scale_intervals = _THEORYTAB_SCALE_NAME_TO_PITCH_INTERVALS["major"] | |
| major_scale_intervals = [0] + np.cumsum(major_scale_intervals).tolist() | |
| if chord["applied"] > 0: | |
| key_tonic_pc = ( | |
| key_tonic_pc + key_scale_intervals[chord["root"] - 1] | |
| ) % 12 | |
| chord["root"] = chord["applied"] | |
| key_scale_intervals = major_scale_intervals | |
| # Convert scale degrees to pitch offsets | |
| chord_degree_to_interval = OrderedDict() | |
| for d in chord_degrees: | |
| d_abs = (chord["root"] - 1) + (d - 1) | |
| interval = key_scale_intervals[d_abs % 7] | |
| interval += 12 * (d_abs // 7) | |
| chord_degree_to_interval[d] = interval | |
| # NOTE: Not sure if this is a bug in Hookpad or what? | |
| if d == 7 and chord["applied"] == 7: | |
| chord_degree_to_interval[d] -= 1 | |
| # Apply alterations | |
| for alt in chord["alterations"]: | |
| d = int(alt[1:]) | |
| assert d in chord_degree_to_interval | |
| chord_degree_to_interval[d] += -1 if alt[0] == "b" else 1 | |
| # Create final chord | |
| result = [key_tonic_pc + v for _, v in chord_degree_to_interval.items()] | |
| result = Chord(result[0] % 12, tuple(np.diff(result).tolist())) | |
| return result | |