From dff12678ba51d4fc9840ed8635772d89e208d0b4 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Sun, 22 Mar 2026 05:34:11 -0400 Subject: [PATCH] Fix music theory accuracy and core bugs across the library - Fix Tone.__init__ overwriting explicit octave kwarg when parsing name - Fix Tone.__eq__ calling names as attribute instead of method, add __hash__ - Fix octave arithmetic to use C-based boundaries (scientific pitch notation) - Fix pitch() to account for octave (was ignoring it entirely) - Fix modal scale generation: modes were overwritten due to DEGREES loop bug - Fix modal offset rotation off-by-one in generate_scale - Fix scales._scales property being called as function - Fix chord intervals: major/minor thirds were swapped, added missing chord tones - Remove broken duplicate NamedChord class in chords.py - Expand test suite from 11 to 83 tests covering tones, scales, modes, chords, pitches, fingerings, and intervals Co-Authored-By: Claude Opus 4.6 (1M context) --- pytheory/_statics.py | 21 +- pytheory/charts.py | 70 +++--- pytheory/chords.py | 6 - pytheory/play.py | 4 +- pytheory/scales.py | 2 +- pytheory/systems.py | 2 +- pytheory/tones.py | 67 +++-- test_pytheory.py | 584 +++++++++++++++++++++++++++++++++++++++++-- 8 files changed, 677 insertions(+), 79 deletions(-) diff --git a/pytheory/_statics.py b/pytheory/_statics.py index 6e39f4b..cff0ed6 100644 --- a/pytheory/_statics.py +++ b/pytheory/_statics.py @@ -71,8 +71,19 @@ SCALES = { SYSTEMS = NotImplemented -for i, (degree_name, modes) in enumerate(DEGREES["western"]): - for mode in modes: - SCALES[12]["heptatonic"][1].update( - {mode: {"major": True, "hemitonic": True, "offset": i}} - ) +# Modes are rotations of the major scale pattern. +# Each mode's offset is its position in the major scale. +_MODES = { + "ionian": 0, + "dorian": 1, + "phrygian": 2, + "lydian": 3, + "mixolydian": 4, + "aeolian": 5, + "locrian": 6, +} + +for mode_name, offset in _MODES.items(): + SCALES[12]["heptatonic"][1][mode_name] = { + "major": True, "hemitonic": True, "offset": offset + } diff --git a/pytheory/charts.py b/pytheory/charts.py index 8f53294..eeccfed 100644 --- a/pytheory/charts.py +++ b/pytheory/charts.py @@ -35,45 +35,53 @@ class NamedChord: def acceptable_tones(self): acceptable = [self.tone] - # Major third. if self.quality == "maj": - acceptable += [self.tone.add(3)] - - # Minor third. - elif self.quality == "m": - acceptable += [self.tone.add(4)] - - # Perfect fifth. - elif self.quality == "5": - acceptable += [self.tone.add(5)] - - elif self.quality == "7": - acceptable += [self.tone.add(7)] - - elif self.quality == "9": - acceptable += [self.tone.add(9)] - - elif self.quality == "dim": - acceptable += [self.tone.add(4), self.tone.add(8)] - - elif self.quality == "m6": - acceptable += [self.tone.add(4), self.tone.add(6)] - - elif self.quality == "m7": + # Major triad: root, major 3rd, perfect 5th acceptable += [self.tone.add(4), self.tone.add(7)] - elif self.quality == "m9": - acceptable += [self.tone.add(4), self.tone.add(9)] - - elif self.quality == "maj7": + elif self.quality == "m": + # Minor triad: root, minor 3rd, perfect 5th acceptable += [self.tone.add(3), self.tone.add(7)] + elif self.quality == "5": + # Power chord: root, perfect 5th + acceptable += [self.tone.add(7)] + + elif self.quality == "7": + # Dominant 7th: root, major 3rd, perfect 5th, minor 7th + acceptable += [self.tone.add(4), self.tone.add(7), self.tone.add(10)] + + elif self.quality == "9": + # Dominant 9th: root, major 3rd, perfect 5th, minor 7th, major 9th + acceptable += [self.tone.add(4), self.tone.add(7), self.tone.add(10), self.tone.add(2)] + + elif self.quality == "dim": + # Diminished: root, minor 3rd, diminished 5th + acceptable += [self.tone.add(3), self.tone.add(6)] + + elif self.quality == "m6": + # Minor 6th: root, minor 3rd, perfect 5th, major 6th + acceptable += [self.tone.add(3), self.tone.add(7), self.tone.add(9)] + + elif self.quality == "m7": + # Minor 7th: root, minor 3rd, perfect 5th, minor 7th + acceptable += [self.tone.add(3), self.tone.add(7), self.tone.add(10)] + + elif self.quality == "m9": + # Minor 9th: root, minor 3rd, perfect 5th, minor 7th, major 9th + acceptable += [self.tone.add(3), self.tone.add(7), self.tone.add(10), self.tone.add(2)] + + elif self.quality == "maj7": + # Major 7th: root, major 3rd, perfect 5th, major 7th + acceptable += [self.tone.add(4), self.tone.add(7), self.tone.add(11)] + elif self.quality == "maj9": - acceptable += [self.tone.add(3), self.tone.add(9)] + # Major 9th: root, major 3rd, perfect 5th, major 7th, major 9th + acceptable += [self.tone.add(4), self.tone.add(7), self.tone.add(11), self.tone.add(2)] else: - acceptable += [self.tone.add(5)] - acceptable += [self.tone.subtract(5)] + # Default (no quality): major triad + acceptable += [self.tone.add(4), self.tone.add(7)] return tuple(acceptable) diff --git a/pytheory/chords.py b/pytheory/chords.py index 3ad3d25..147b3bd 100644 --- a/pytheory/chords.py +++ b/pytheory/chords.py @@ -79,12 +79,6 @@ class Chord: return Chord(tones=tones) -class NamedChord: - def __init__(self, *, name, system): - self.name - self.system - - class Fretboard: def __init__(self, *, tones): self.tones = tones diff --git a/pytheory/play.py b/pytheory/play.py index acae0b1..66e7a6b 100644 --- a/pytheory/play.py +++ b/pytheory/play.py @@ -69,10 +69,10 @@ def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000): """Play a tone or chord.""" if isinstance(tone_or_chord, Tone): - chord = [synth(tone_or_chord.pitch(temperament=temperament, symbolic=True))] + chord = [synth(tone_or_chord.pitch(temperament=temperament))] else: chord = [ - synth(tone.pitch(temperament=temperament, symbolic=True)) + synth(tone.pitch(temperament=temperament)) for tone in tone_or_chord.tones ] diff --git a/pytheory/scales.py b/pytheory/scales.py index 0767bdd..2f999a0 100644 --- a/pytheory/scales.py +++ b/pytheory/scales.py @@ -108,7 +108,7 @@ class TonedScale: @property def scales(self): - return tuple(self._scales().keys()) + return tuple(self._scales.keys()) @property def _scales(self): diff --git a/pytheory/systems.py b/pytheory/systems.py index 8c2f7f3..65428b8 100644 --- a/pytheory/systems.py +++ b/pytheory/systems.py @@ -111,7 +111,7 @@ class System: ] if offset: - scale = scale[offset - 1 :] + scale[: offset - 1] + scale = scale[offset:] + scale[:offset] # descending goes in meta? return {"intervals": scale, "hemitonic": hemitonic, "meta": {}} diff --git a/pytheory/tones.py b/pytheory/tones.py index d307630..029a1d6 100644 --- a/pytheory/tones.py +++ b/pytheory/tones.py @@ -9,11 +9,14 @@ class Tone: if isinstance(name, str): try: - octave = int("".join([c for c in filter(str.isdigit, name)])) + parsed_octave = int("".join([c for c in filter(str.isdigit, name)])) except ValueError: - octave = None + parsed_octave = None - name = name.replace(str(octave), "") if octave else name + if parsed_octave is not None: + name = name.replace(str(parsed_octave), "") + if octave is None: + octave = parsed_octave self.name = name self.octave = octave @@ -57,16 +60,21 @@ class Tone: def __eq__(self, other): # Comparing string literals. - if self.name == other: - return True + if isinstance(other, str): + return self.name == other # Comparing against other Tones. try: - if (self.name in other.names) and (self.octave == other.octave): + if (self.name in other.names()) and (self.octave == other.octave): return True except AttributeError: pass + return False + + def __hash__(self): + return hash((self.name, self.octave)) + @classmethod def from_string(klass, s, system=None): try: @@ -103,7 +111,11 @@ class Tone: raise ValueError("Tone index cannot be referenced without a system!") def _math(self, interval): - """Returns (new index, new octave).""" + """Returns (new index, new octave). + + Octave boundaries follow scientific pitch notation, where the + octave number increments at C (index 3 in the Western system). + """ octave = self.octave or 0 @@ -113,10 +125,20 @@ class Tone: raise ValueError( "Tone math can only be computed with an associated system!" ) - result = self._index + interval - index = result % mod - octave = result // mod + octave - return (index, octave) + + # C is at index 3 in the Western tone list (A=0, A#=1, B=2, C=3, ...) + # Scientific pitch notation changes octave at C, not A. + c_index = 3 + + # Convert to absolute semitones from C0 + note_from_c0 = ((self._index - c_index) % mod) + (octave * mod) + note_from_c0 += interval + + new_octave = note_from_c0 // mod + relative = note_from_c0 % mod + new_index = (relative + c_index) % mod + + return (new_index, new_octave) def add(self, interval): index, octave = self._math(interval) @@ -137,9 +159,26 @@ class Tone: tones = len(self.system.tones) except AttributeError: raise ValueError("Pitches can only be computed with an associated system!") + pitch_scale = TEMPERAMENTS[temperament](tones) - pitch = pitch_scale[self._index] + octave = self.octave or 4 + + # C is at index 3; convert to semitones from C0 for both + # this note and the reference A4. + c_index = 3 + note_from_c0 = ((self._index - c_index) % tones) + (octave * tones) + a4_from_c0 = ((0 - c_index) % tones) + (4 * tones) # A4 + + diff = note_from_c0 - a4_from_c0 + octave_shift = diff // tones + within_octave = diff % tones + + ratio = pitch_scale[within_octave] * (2 ** octave_shift) + if symbolic: - return reference_pitch * pitch + return reference_pitch * ratio else: - return reference_pitch * pitch.evalf(precision) + result = reference_pitch * ratio + if precision: + return float(result.evalf(precision)) + return float(result) diff --git a/test_pytheory.py b/test_pytheory.py index 94fe876..e94bf5f 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -1,48 +1,57 @@ import pytest import pytheory -from pytheory import Tone, TonedScale, Tone, Fretboard, Chord +from pytheory import Tone, TonedScale, Fretboard, Chord +from pytheory.charts import CHARTS, NamedChord +# ── Tone basics ────────────────────────────────────────────────────────────── + def test_tone_from_string(): c4 = Tone.from_string("C4") assert c4.name == "C" assert c4.octave == 4 +def test_tone_from_string_sharp(): + cs4 = Tone.from_string("C#4") + assert cs4.name == "C#" + assert cs4.octave == 4 + + +def test_tone_from_string_no_octave(): + d = Tone.from_string("D") + assert d.name == "D" + assert d.octave is None + + def test_tone_initialization(): c4 = Tone(name="C", octave=4) assert c4.name == "C" assert c4.octave == 4 -def test_tone_addition(): - assert ( - Tone.from_string("C4", system=pytheory.SYSTEMS["western"]).add(12).full_name - == "C5" - ) - - -def test_tone_subtraction(): - assert ( - Tone.from_string("C5", system=pytheory.SYSTEMS["western"]) - .subtract(12) - .full_name - == "C4" - ) +def test_tone_initialization_with_octave_in_name_and_kwarg(): + # When name has digits, explicit octave kwarg is kept + t = Tone(name="C4", octave=5) + assert t.name == "C" + assert t.octave == 5 # explicit kwarg preserved def test_tone_full_name(): c4 = Tone(name="C", octave=4) d = Tone(name="D", octave=None) - assert c4.full_name == "C4" assert d.full_name == "D" +def test_tone_repr(): + c4 = Tone(name="C", octave=4) + assert repr(c4) == "" + + def test_tone_system(): c4 = Tone(name="C", octave=4, system="western") - assert c4.system_name == "western" assert c4.system == pytheory.SYSTEMS["western"] @@ -50,10 +59,271 @@ def test_tone_system(): def test_tone_exists(): c4 = Tone(name="C", octave=4, system="western") invalid_tone = Tone(name="H", octave=4, system="western") + assert c4.exists is True + assert invalid_tone.exists is False - assert c4.exists == True - assert invalid_tone.exists == False +def test_tone_names_method(): + t = Tone(name="C#", alt_names=["Db"], octave=4) + assert t.names() == ["C#", "Db"] + + +# ── Tone equality ──────────────────────────────────────────────────────────── + +def test_tone_eq_string(): + c4 = Tone(name="C", octave=4) + assert c4 == "C" + assert not (c4 == "D") + + +def test_tone_eq_tone(): + a = Tone(name="C", octave=4) + b = Tone(name="C", octave=4) + assert a == b + + +def test_tone_eq_different_octave(): + a = Tone(name="C", octave=4) + b = Tone(name="C", octave=5) + assert not (a == b) + + +def test_tone_eq_alt_name(): + a = Tone(name="C#", alt_names=["Db"], octave=4) + b = Tone(name="Db", alt_names=["C#"], octave=4) + assert a == b # b.name "Db" is in a.names(), and vice versa + + +def test_tone_hash(): + a = Tone(name="C", octave=4) + b = Tone(name="C", octave=4) + assert hash(a) == hash(b) + s = {a, b} + assert len(s) == 1 + + +# ── Tone arithmetic ───────────────────────────────────────────────────────── + +def test_tone_addition(): + t = Tone.from_string("C4", system=pytheory.SYSTEMS["western"]) + assert t.add(12).full_name == "C5" + + +def test_tone_subtraction(): + t = Tone.from_string("C5", system=pytheory.SYSTEMS["western"]) + assert t.subtract(12).full_name == "C4" + + +def test_tone_add_semitone(): + t = Tone.from_string("C4", system=pytheory.SYSTEMS["western"]) + assert t.add(1).name == "C#" + assert t.add(1).octave == 4 + + +def test_tone_add_across_octave_boundary(): + """B4 + 1 semitone = C5 (octave changes at C).""" + t = Tone.from_string("B4", system=pytheory.SYSTEMS["western"]) + result = t.add(1) + assert result.name == "C" + assert result.octave == 5 + + +def test_tone_subtract_across_octave_boundary(): + """C4 - 1 semitone = B3.""" + t = Tone.from_string("C4", system=pytheory.SYSTEMS["western"]) + result = t.subtract(1) + assert result.name == "B" + assert result.octave == 3 + + +def test_tone_add_within_octave_no_wrap(): + """A4 + 2 = B4 (no octave change, A and B are in same octave).""" + t = Tone.from_string("A4", system=pytheory.SYSTEMS["western"]) + result = t.add(2) + assert result.name == "B" + assert result.octave == 4 + + +def test_tone_octave_correct_for_chromatic_walk(): + """Walk all 12 semitones from C4 to C5 and verify octave numbers.""" + t = Tone.from_string("C4", system=pytheory.SYSTEMS["western"]) + expected = [ + ("C", 4), ("C#", 4), ("D", 4), ("D#", 4), + ("E", 4), ("F", 4), ("F#", 4), ("G", 4), + ("G#", 4), ("A", 4), ("A#", 4), ("B", 4), ("C", 5), + ] + for i, (name, octave) in enumerate(expected): + result = t.add(i) + assert result.name == name, f"step {i}: expected {name}, got {result.name}" + assert result.octave == octave, f"step {i}: expected octave {octave}, got {result.octave}" + + +# ── Pitch frequencies ──────────────────────────────────────────────────────── + +def test_pitch_a4_is_440(): + t = Tone.from_string("A4", system="western") + assert abs(t.pitch() - 440.0) < 0.01 + + +def test_pitch_a3_is_220(): + t = Tone.from_string("A3", system="western") + assert abs(t.pitch() - 220.0) < 0.01 + + +def test_pitch_c4_middle_c(): + t = Tone.from_string("C4", system="western") + assert abs(t.pitch() - 261.63) < 0.01 + + +def test_pitch_c5(): + t = Tone.from_string("C5", system="western") + assert abs(t.pitch() - 523.25) < 0.01 + + +def test_pitch_e4(): + t = Tone.from_string("E4", system="western") + assert abs(t.pitch() - 329.63) < 0.01 + + +def test_pitch_octave_doubles_frequency(): + t1 = Tone.from_string("C4", system="western") + t2 = Tone.from_string("C5", system="western") + ratio = t2.pitch() / t1.pitch() + assert abs(ratio - 2.0) < 0.001 + + +def test_pitch_symbolic(): + t = Tone.from_string("A4", system="western") + sym_pitch = t.pitch(symbolic=True) + assert float(sym_pitch) == 440.0 + + +# ── Scales ─────────────────────────────────────────────────────────────────── + +def test_c_major_scale(): + c = TonedScale(tonic="C4") + major = c["major"] + names = [t.name for t in major.tones] + assert names == ["C", "D", "E", "F", "G", "A", "B", "C"] + + +def test_c_major_scale_octaves(): + c = TonedScale(tonic="C4") + major = c["major"] + octaves = [t.octave for t in major.tones] + assert octaves == [4, 4, 4, 4, 4, 4, 4, 5] + + +def test_c_minor_scale(): + c = TonedScale(tonic="C4") + minor = c["minor"] + names = [t.name for t in minor.tones] + # C D Eb F G Ab Bb C (using sharps: D#, G#, A#) + assert names == ["C", "D", "D#", "F", "G", "G#", "A#", "C"] + + +def test_c_harmonic_minor_scale(): + c = TonedScale(tonic="C4") + hminor = c["harmonic minor"] + names = [t.name for t in hminor.tones] + # C D Eb F G Ab B C (raised 7th) + assert names == ["C", "D", "D#", "F", "G", "G#", "B", "C"] + + +def test_g_major_scale(): + g = TonedScale(tonic="G4") + major = g["major"] + names = [t.name for t in major.tones] + assert names == ["G", "A", "B", "C", "D", "E", "F#", "G"] + + +def test_available_scales(): + c = TonedScale(tonic="C4") + scales = c.scales + assert "major" in scales + assert "minor" in scales + assert "harmonic minor" in scales + assert "ionian" in scales + assert "dorian" in scales + + +def test_scale_degree_by_roman_numeral(): + c = TonedScale(tonic="C4") + major = c["major"] + assert major["I"].name == "C" + assert major["V"].name == "G" + + +def test_scale_degree_by_index(): + c = TonedScale(tonic="C4") + major = c["major"] + assert major[0].name == "C" + assert major[4].name == "G" + + +def test_scale_invalid_key(): + c = TonedScale(tonic="C4") + with pytest.raises(KeyError): + c["nonexistent"] + + +# ── Modes ──────────────────────────────────────────────────────────────────── + +def test_ionian_equals_major(): + c = TonedScale(tonic="C4") + major_names = [t.name for t in c["major"].tones] + ionian_names = [t.name for t in c["ionian"].tones] + assert major_names == ionian_names + + +def test_aeolian_equals_minor(): + c = TonedScale(tonic="C4") + minor_names = [t.name for t in c["minor"].tones] + aeolian_names = [t.name for t in c["aeolian"].tones] + assert minor_names == aeolian_names + + +def test_c_dorian(): + c = TonedScale(tonic="C4") + dorian = c["dorian"] + names = [t.name for t in dorian.tones] + # Dorian: W H W W W H W → C D Eb F G A Bb C + assert names == ["C", "D", "D#", "F", "G", "A", "A#", "C"] + + +def test_c_phrygian(): + c = TonedScale(tonic="C4") + phrygian = c["phrygian"] + names = [t.name for t in phrygian.tones] + # Phrygian: H W W W H W W → C Db Eb F G Ab Bb C + assert names == ["C", "C#", "D#", "F", "G", "G#", "A#", "C"] + + +def test_c_lydian(): + c = TonedScale(tonic="C4") + lydian = c["lydian"] + names = [t.name for t in lydian.tones] + # Lydian: W W W H W W H → C D E F# G A B C + assert names == ["C", "D", "E", "F#", "G", "A", "B", "C"] + + +def test_c_mixolydian(): + c = TonedScale(tonic="C4") + mixolydian = c["mixolydian"] + names = [t.name for t in mixolydian.tones] + # Mixolydian: W W H W W H W → C D E F G A Bb C + assert names == ["C", "D", "E", "F", "G", "A", "A#", "C"] + + +def test_c_locrian(): + c = TonedScale(tonic="C4") + locrian = c["locrian"] + names = [t.name for t in locrian.tones] + # Locrian: H W W H W W W → C Db Eb F Gb Ab Bb C + assert names == ["C", "C#", "D#", "F", "F#", "G#", "A#", "C"] + + +# ── Chords ─────────────────────────────────────────────────────────────────── def test_chord_creation(): c_major = Chord( @@ -91,6 +361,91 @@ def test_chord_dissonance(): assert c_major.dissonance > 0 +def test_chord_single_tone(): + single = Chord(tones=[Tone(name="C", octave=4)]) + assert single.harmony == 0 + assert single.dissonance == 0 + assert single.beat_pulse == 0 + assert single.intervals == [] + + +def test_chord_repr(): + c = Chord(tones=[Tone(name="C", octave=4), Tone(name="E", octave=4)]) + assert "C4" in repr(c) + assert "E4" in repr(c) + + +# ── Named chords (acceptable tones / music theory) ────────────────────────── + +def test_named_chord_c_major_tones(): + c = NamedChord(tone_name="C", quality="") + names = c.acceptable_tone_names + assert "C" in names + assert "E" in names + assert "G" in names + + +def test_named_chord_c_major_explicit_tones(): + c = NamedChord(tone_name="C", quality="maj") + names = c.acceptable_tone_names + assert "C" in names + assert "E" in names + assert "G" in names + + +def test_named_chord_c_minor_tones(): + cm = NamedChord(tone_name="C", quality="m") + names = cm.acceptable_tone_names + assert "C" in names + assert "D#" in names # Eb enharmonic + assert "G" in names + + +def test_named_chord_power_chord(): + c5 = NamedChord(tone_name="C", quality="5") + names = c5.acceptable_tone_names + assert "C" in names + assert "G" in names + assert len(names) == 2 + + +def test_named_chord_dominant_7th(): + c7 = NamedChord(tone_name="C", quality="7") + names = c7.acceptable_tone_names + assert "C" in names + assert "E" in names # major 3rd + assert "G" in names # perfect 5th + assert "A#" in names # minor 7th (Bb) + + +def test_named_chord_diminished(): + cdim = NamedChord(tone_name="C", quality="dim") + names = cdim.acceptable_tone_names + assert "C" in names + assert "D#" in names # minor 3rd (Eb) + assert "F#" in names # diminished 5th (Gb) + + +def test_named_chord_minor_7th(): + cm7 = NamedChord(tone_name="C", quality="m7") + names = cm7.acceptable_tone_names + assert "C" in names + assert "D#" in names # minor 3rd + assert "G" in names # perfect 5th + assert "A#" in names # minor 7th + + +def test_named_chord_major_7th(): + cmaj7 = NamedChord(tone_name="C", quality="maj7") + names = cmaj7.acceptable_tone_names + assert "C" in names + assert "E" in names # major 3rd + assert "G" in names # perfect 5th + assert "B" in names # major 7th + + +# ── Fretboard ──────────────────────────────────────────────────────────────── + def test_fretboard_creation(): standard_tuning = [ Tone(name="E", octave=4), @@ -104,3 +459,194 @@ def test_fretboard_creation(): assert len(fretboard.tones) == 6 assert fretboard.tones[0].full_name == "E4" assert fretboard.tones[-1].full_name == "E2" + + +def test_fretboard_repr(): + fretboard = Fretboard(tones=[Tone(name="E", octave=4)]) + assert "E4" in repr(fretboard) + + +# ── Chord fingerings ───────────────────────────────────────────────────────── + +@pytest.fixture +def guitar_fretboard(): + tuning = [ + Tone.from_string("E4"), + Tone.from_string("B3"), + Tone.from_string("G3"), + Tone.from_string("D3"), + Tone.from_string("A2"), + Tone.from_string("E2"), + ] + return Fretboard(tones=tuning) + + +def test_chord_fingering_c(guitar_fretboard): + c = CHARTS["western"]["C"] + fingering = c.fingering(fretboard=guitar_fretboard) + assert len(fingering) == 6 + # All fret values should be small integers or None + for f in fingering: + assert f is None or (isinstance(f, int) and f >= 0) + + +def test_chord_fingering_am(guitar_fretboard): + am = CHARTS["western"]["Am"] + fingering = am.fingering(fretboard=guitar_fretboard) + assert len(fingering) == 6 + + +def test_chord_fingering_em(guitar_fretboard): + em = CHARTS["western"]["Em"] + fingering = em.fingering(fretboard=guitar_fretboard) + assert len(fingering) == 6 + # Em should be very open (lots of 0s) + zeros = sum(1 for f in fingering if f == 0) + assert zeros >= 3 + + +def test_chord_fingering_all_western_chords(guitar_fretboard): + """Every chord in the western chart should produce a valid fingering.""" + for name, chord in CHARTS["western"].items(): + fingering = chord.fingering(fretboard=guitar_fretboard) + assert len(fingering) == 6, f"{name} produced wrong number of positions" + + +def test_chord_fingering_multiple(guitar_fretboard): + c = CHARTS["western"]["C"] + fingerings = c.fingering(fretboard=guitar_fretboard, multiple=True) + assert len(fingerings) >= 1 + assert all(len(f) == 6 for f in fingerings) + + +# ── Charts ─────────────────────────────────────────────────────────────────── + +def test_charts_western_exists(): + assert "western" in CHARTS + assert len(CHARTS["western"]) > 0 + + +def test_charts_has_basic_chords(): + chart = CHARTS["western"] + for name in ["C", "D", "E", "F", "G", "A", "B"]: + assert name in chart, f"Missing chord: {name}" + + +def test_charts_has_minor_chords(): + chart = CHARTS["western"] + for name in ["Am", "Dm", "Em"]: + assert name in chart, f"Missing chord: {name}" + + +def test_charts_has_seventh_chords(): + chart = CHARTS["western"] + for name in ["C7", "G7", "A7"]: + assert name in chart, f"Missing chord: {name}" + + +# ── System ─────────────────────────────────────────────────────────────────── + +def test_western_system_has_12_tones(): + system = pytheory.SYSTEMS["western"] + assert system.semitones == 12 + + +def test_western_system_tones(): + system = pytheory.SYSTEMS["western"] + tone_names = [t.name for t in system.tones] + assert "A" in tone_names + assert "C" in tone_names + assert "G" in tone_names + + +def test_western_system_scales(): + system = pytheory.SYSTEMS["western"] + scales = system.scales + assert "heptatonic" in scales + assert "major" in scales["heptatonic"] + assert "minor" in scales["heptatonic"] + + +def test_western_system_modes(): + system = pytheory.SYSTEMS["western"] + modes = system.modes + mode_names = [m["mode"] for m in modes] + assert "ionian" in mode_names + assert "dorian" in mode_names + + +# ── Scale intervals (music theory verification) ───────────────────────────── + +def test_major_scale_intervals(): + """Major scale should follow W-W-H-W-W-W-H pattern (2-2-1-2-2-2-1).""" + system = pytheory.SYSTEMS["western"] + major = system.scales["heptatonic"]["major"] + assert major["intervals"] == [2, 2, 1, 2, 2, 2, 1] + + +def test_minor_scale_intervals(): + """Natural minor should follow W-H-W-W-H-W-W pattern.""" + system = pytheory.SYSTEMS["western"] + minor = system.scales["heptatonic"]["minor"] + assert minor["intervals"] == [2, 1, 2, 2, 1, 2, 2] + + +def test_harmonic_minor_scale_intervals(): + """Harmonic minor should follow W-H-W-W-H-WH-H pattern.""" + system = pytheory.SYSTEMS["western"] + hminor = system.scales["heptatonic"]["harmonic minor"] + assert hminor["intervals"] == [2, 1, 2, 2, 1, 3, 1] + + +def test_dorian_mode_intervals(): + """Dorian: W-H-W-W-W-H-W (rotation of major by 1).""" + system = pytheory.SYSTEMS["western"] + dorian = system.scales["heptatonic"]["dorian"] + assert dorian["intervals"] == [2, 1, 2, 2, 2, 1, 2] + + +def test_lydian_mode_intervals(): + """Lydian: W-W-W-H-W-W-H (rotation of major by 3).""" + system = pytheory.SYSTEMS["western"] + lydian = system.scales["heptatonic"]["lydian"] + assert lydian["intervals"] == [2, 2, 2, 1, 2, 2, 1] + + +def test_mixolydian_mode_intervals(): + """Mixolydian: W-W-H-W-W-H-W (rotation of major by 4).""" + system = pytheory.SYSTEMS["western"] + mixolydian = system.scales["heptatonic"]["mixolydian"] + assert mixolydian["intervals"] == [2, 2, 1, 2, 2, 1, 2] + + +def test_all_mode_intervals_sum_to_12(): + """Every heptatonic mode's intervals should sum to 12 semitones.""" + system = pytheory.SYSTEMS["western"] + for name, scale in system.scales["heptatonic"].items(): + total = sum(scale["intervals"]) + assert total == 12, f"{name} intervals sum to {total}, not 12" + + +# ── Play module (non-audio tests) ─────────────────────────────────────────── + +def test_synth_enum(): + from pytheory.play import Synth, sine_wave, sawtooth_wave, triangle_wave + # Enum with function values: members are the functions themselves + assert Synth.SINE is sine_wave + assert Synth.SAW is sawtooth_wave + assert Synth.TRIANGLE is triangle_wave + # Should be directly callable + wave = Synth.SINE(440) + assert len(wave) > 0 + + +def test_sine_wave_length(): + from pytheory.play import sine_wave, SAMPLE_RATE + wave = sine_wave(440) + assert len(wave) == SAMPLE_RATE + + +def test_sine_wave_custom_samples(): + from pytheory.play import sine_wave + wave = sine_wave(440, n_samples=1000) + assert len(wave) == 1000