Files
pytheory/test_pytheory.py
T
kennethreitz 0b98f7bd77 Mellotron, hard sync, ring mod, wavefold, drift synths + analog presets — v0.40.9
Five new synth waveforms: tape-replay Mellotron (strings/flute/choir
tapes with wow, flutter, saturation, 8s fadeout), hard sync oscillator,
ring modulation, wavefolding, and analog drift VCO with pitch
instability. 14 new instrument presets for Score.part(). Synth kwargs
now pass through play()/save()/_render(). 808 bass envelope fixed
from pluck to piano.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:14:18 -04:00

7364 lines
229 KiB
Python

import pytest
import numpy
import pytheory
from pytheory import Tone, TonedScale, Fretboard, Chord, Key, Note
from pytheory.charts import CHARTS, NamedChord, charts_for_fretboard, QUALITIES
from pytheory.systems import System, SYSTEMS
try:
import sounddevice
HAS_PORTAUDIO = True
except OSError:
HAS_PORTAUDIO = False
needs_portaudio = pytest.mark.skipif(not HAS_PORTAUDIO, reason="PortAudio not available")
# ── 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_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) == "<Tone C4>"
def test_tone_system():
c4 = Tone(name="C", octave=4, system="western")
assert c4.system_name == "western"
assert c4.system == pytheory.SYSTEMS["western"]
def test_tone_exists():
c4 = Tone(name="C", octave=4, system="western")
assert c4.exists is True
def test_tone_invalid_raises():
"""Invalid tone names raise ValueError at construction time (fixes #39)."""
import pytest
with pytest.raises(ValueError, match="Unknown tone name"):
Tone(name="H", octave=4, system="western")
with pytest.raises(ValueError, match="Unknown tone name"):
Tone("X")
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_a0():
t = Tone.from_string("A0", system="western")
assert abs(t.pitch() - 27.5) < 0.01
def test_tone_octave_zero_full_name():
t = Tone.from_string("A0", system="western")
assert t.full_name == "A0"
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 flats for flat keys)
assert names == ["C", "D", "Eb", "F", "G", "Ab", "Bb", "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", "Eb", "F", "G", "Ab", "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", "Eb", "F", "G", "A", "Bb", "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", "Db", "Eb", "F", "G", "Ab", "Bb", "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", "Bb", "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", "Db", "Eb", "F", "Gb", "Ab", "Bb", "C"]
# ── Chords ───────────────────────────────────────────────────────────────────
def test_chord_creation():
c_major = Chord(
tones=[
Tone(name="C", octave=4),
Tone(name="E", octave=4),
Tone(name="G", octave=4),
]
)
assert len(c_major.tones) == 3
assert c_major.tones[0].full_name == "C4"
assert c_major.tones[1].full_name == "E4"
assert c_major.tones[2].full_name == "G4"
def test_chord_harmony():
c_major = Chord(
tones=[
Tone(name="C", octave=4),
Tone(name="E", octave=4),
Tone(name="G", octave=4),
]
)
assert c_major.harmony > 0
def test_chord_dissonance():
c_major = Chord(
tones=[
Tone(name="C", octave=4),
Tone(name="E", octave=4),
Tone(name="G", octave=4),
]
)
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 "Eb" in names # minor 3rd
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 "Bb" in names # minor 7th
def test_named_chord_diminished():
cdim = NamedChord(tone_name="C", quality="dim")
names = cdim.acceptable_tone_names
assert "C" in names
assert "Eb" in names # minor 3rd
assert "Gb" in names # diminished 5th
def test_named_chord_minor_7th():
cm7 = NamedChord(tone_name="C", quality="m7")
names = cm7.acceptable_tone_names
assert "C" in names
assert "Eb" in names # minor 3rd
assert "G" in names # perfect 5th
assert "Bb" 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),
Tone(name="B", octave=3),
Tone(name="G", octave=3),
Tone(name="D", octave=3),
Tone(name="A", octave=2),
Tone(name="E", octave=2),
]
fretboard = Fretboard(tones=standard_tuning)
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
@pytest.mark.slow
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) ───────────────────────────────────────────
@needs_portaudio
def test_synth_enum():
from pytheory.play import Synth
# Synth members are callable and produce audio
assert Synth.SINE.value == "sine"
assert Synth.SAW.value == "saw"
assert Synth.TRIANGLE.value == "triangle"
# Should be directly callable
wave = Synth.SINE(440)
assert len(wave) > 0
@needs_portaudio
def test_sine_wave_length():
from pytheory.play import sine_wave, SAMPLE_RATE
wave = sine_wave(440)
assert len(wave) == SAMPLE_RATE
@needs_portaudio
def test_sine_wave_custom_samples():
from pytheory.play import sine_wave
wave = sine_wave(440, n_samples=1000)
assert len(wave) == 1000
# ── Tone.from_tuple ─────────────────────────────────────────────────────────
def test_tone_from_tuple_single():
t = Tone.from_tuple(("A",))
assert t.name == "A"
assert t.alt_names == []
def test_tone_from_tuple_with_alt():
t = Tone.from_tuple(("C#", "Db"))
assert t.name == "C#"
assert t.alt_names == ("Db",)
def test_tone_from_tuple_with_octave():
t = Tone.from_tuple(("A4",))
assert t.name == "A"
assert t.octave == 4
# ── Tone.from_index ─────────────────────────────────────────────────────────
def test_tone_from_index():
system = SYSTEMS["western"]
t = Tone.from_index(3, octave=4, system=system) # C is index 3
assert t.name == "C"
assert t.octave == 4
def test_tone_from_index_zero():
system = SYSTEMS["western"]
t = Tone.from_index(0, octave=4, system=system) # A is index 0
assert t.name == "A"
assert t.octave == 4
# ── Tone._index ─────────────────────────────────────────────────────────────
def test_tone_index_in_system():
t = Tone.from_string("A4", system="western")
assert t._index == 0
def test_tone_index_c():
t = Tone.from_string("C4", system="western")
assert t._index == 3
def test_tone_index_without_system_raises():
t = Tone(name="C", octave=4)
t._system = None
t.system_name = None
with pytest.raises(ValueError, match="index"):
_ = t._index
# ── Tone._math errors ───────────────────────────────────────────────────────
def test_tone_math_without_system_raises():
t = Tone(name="C", octave=4)
t._system = None
t.system_name = None
with pytest.raises(ValueError):
t._math(1)
# ── Tone equality edge cases ────────────────────────────────────────────────
def test_tone_eq_non_tone_non_string():
t = Tone(name="C", octave=4)
assert not (t == 42)
assert not (t == None)
assert not (t == [])
def test_tone_eq_different_name():
a = Tone(name="C", octave=4)
b = Tone(name="D", octave=4)
assert not (a == b)
def test_tone_eq_no_octave():
a = Tone(name="C")
b = Tone(name="C")
assert a == b
# ── Tone arithmetic — multi-octave ──────────────────────────────────────────
def test_tone_add_two_octaves():
t = Tone.from_string("C4", system="western")
result = t.add(24)
assert result.name == "C"
assert result.octave == 6
def test_tone_subtract_two_octaves():
t = Tone.from_string("C6", system="western")
result = t.subtract(24)
assert result.name == "C"
assert result.octave == 4
def test_tone_add_tritone():
"""C + 6 semitones = F#."""
t = Tone.from_string("C4", system="western")
result = t.add(6)
assert result.name == "F#"
assert result.octave == 4
def test_tone_subtract_to_lower_octave():
"""E2 - 5 = B1."""
t = Tone.from_string("E2", system="western")
result = t.subtract(5)
assert result.name == "B"
assert result.octave == 1
def test_tone_chromatic_walk_descending():
"""Walk down from C5 to C4."""
t = Tone.from_string("C5", system="western")
expected = [
("C", 5), ("B", 4), ("A#", 4), ("A", 4),
("G#", 4), ("G", 4), ("F#", 4), ("F", 4),
("E", 4), ("D#", 4), ("D", 4), ("C#", 4), ("C", 4),
]
for i, (name, octave) in enumerate(expected):
result = t.subtract(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}"
def test_tone_add_zero():
t = Tone.from_string("C4", system="western")
result = t.add(0)
assert result.name == "C"
assert result.octave == 4
# ── Pitch — more frequencies ────────────────────────────────────────────────
def test_pitch_b4():
t = Tone.from_string("B4", system="western")
assert abs(t.pitch() - 493.88) < 0.01
def test_pitch_d4():
t = Tone.from_string("D4", system="western")
assert abs(t.pitch() - 293.66) < 0.01
def test_pitch_g3():
t = Tone.from_string("G3", system="western")
assert abs(t.pitch() - 196.00) < 0.01
def test_pitch_a2():
t = Tone.from_string("A2", system="western")
assert abs(t.pitch() - 110.0) < 0.01
def test_pitch_a5():
t = Tone.from_string("A5", system="western")
assert abs(t.pitch() - 880.0) < 0.01
def test_pitch_e2_low():
"""Low E on guitar."""
t = Tone.from_string("E2", system="western")
assert abs(t.pitch() - 82.41) < 0.01
def test_pitch_precision():
t = Tone.from_string("A4", system="western")
p = t.pitch(precision=10)
assert abs(p - 440.0) < 0.01
def test_pitch_without_system_raises():
t = Tone(name="C", octave=4)
t._system = None
t.system_name = None
with pytest.raises(ValueError, match="Pitches"):
t.pitch()
def test_pitch_pythagorean_temperament():
"""Pythagorean A4 should still be 440 (it's the reference)."""
t = Tone.from_string("A4", system="western")
assert abs(t.pitch(temperament="pythagorean") - 440.0) < 0.01
def test_pitch_all_chromatic_ascending():
"""Verify all 12 chromatic pitches from A4 are strictly ascending."""
pitches = []
for i in range(12):
t = Tone.from_string("A4", system="western").add(i)
pitches.append(t.pitch())
for i in range(1, len(pitches)):
assert pitches[i] > pitches[i - 1], f"Pitch at step {i} not ascending"
def test_pitch_consistency_across_octaves():
"""Every note an octave up should be exactly 2x the frequency."""
for note in ["C", "D", "E", "F", "G", "A", "B"]:
t3 = Tone.from_string(f"{note}3", system="western")
t4 = Tone.from_string(f"{note}4", system="western")
ratio = t4.pitch() / t3.pitch()
assert abs(ratio - 2.0) < 0.001, f"{note}4/{note}3 ratio = {ratio}"
# ── Scale — repr and degree access ──────────────────────────────────────────
def test_scale_repr():
c = TonedScale(tonic="C4")
major = c["major"]
r = repr(major)
assert "Scale" in r
assert "I=C4" in r
assert "V=G4" in r
def test_scale_degree_by_name_tonic():
c = TonedScale(tonic="C4")
major = c["major"]
tone = major.degree("tonic")
assert tone.name == "C"
def test_scale_degree_by_name_dominant():
c = TonedScale(tonic="C4")
major = c["major"]
tone = major.degree("dominant")
assert tone.name == "G"
def test_scale_degree_major_minor_both_raises():
c = TonedScale(tonic="C4")
major = c["major"]
with pytest.raises(ValueError):
major.degree("tonic", major=True, minor=True)
def test_scale_degree_all_roman_numerals():
c = TonedScale(tonic="C4")
major = c["major"]
expected = ["C", "D", "E", "F", "G", "A", "B"]
numerals = ["I", "II", "III", "IV", "V", "VI", "VII"]
for numeral, name in zip(numerals, expected):
assert major[numeral].name == name, f"Degree {numeral} expected {name}"
def test_scale_slice_access():
c = TonedScale(tonic="C4")
major = c["major"]
first_three = major[0:3]
assert len(first_three) == 3
assert first_three[0].name == "C"
assert first_three[2].name == "E"
def test_scale_degrees_length_mismatch_raises():
from pytheory.scales import Scale
with pytest.raises(ValueError, match="number of tones and degrees"):
Scale(tones=(Tone(name="C"), Tone(name="D")), degrees=("I",))
# ── TonedScale ──────────────────────────────────────────────────────────────
def test_toned_scale_repr():
c = TonedScale(tonic="C4")
r = repr(c)
assert "TonedScale" in r
assert "C4" in r
def test_toned_scale_get_none():
c = TonedScale(tonic="C4")
assert c.get("nonexistent") is None
def test_toned_scale_with_tone_object():
tone = Tone.from_string("D4", system="western")
d = TonedScale(tonic=tone)
major = d["major"]
assert major.tones[0].name == "D"
def test_d_major_scale():
d = TonedScale(tonic="D4")
major = d["major"]
names = [t.name for t in major.tones]
assert names == ["D", "E", "F#", "G", "A", "B", "C#", "D"]
def test_f_major_scale():
f = TonedScale(tonic="F4")
major = f["major"]
names = [t.name for t in major.tones]
assert names == ["F", "G", "A", "Bb", "C", "D", "E", "F"]
def test_a_minor_scale():
a = TonedScale(tonic="A4")
minor = a["minor"]
names = [t.name for t in minor.tones]
# A B C D E F G A
assert names == ["A", "B", "C", "D", "E", "F", "G", "A"]
def test_e_minor_scale():
e = TonedScale(tonic="E4")
minor = e["minor"]
names = [t.name for t in minor.tones]
# E F# G A B C D E
assert names == ["E", "F#", "G", "A", "B", "C", "D", "E"]
def test_b_major_scale():
b = TonedScale(tonic="B4")
major = b["major"]
names = [t.name for t in major.tones]
assert names == ["B", "C#", "D#", "E", "F#", "G#", "A#", "B"]
def test_d_dorian():
d = TonedScale(tonic="D4")
dorian = d["dorian"]
names = [t.name for t in dorian.tones]
# D Dorian: D E F G A B C D (same notes as C major)
assert names == ["D", "E", "F", "G", "A", "B", "C", "D"]
def test_g_mixolydian():
g = TonedScale(tonic="G4")
mixo = g["mixolydian"]
names = [t.name for t in mixo.tones]
# G Mixolydian: G A B C D E F G (same notes as C major)
assert names == ["G", "A", "B", "C", "D", "E", "F", "G"]
def test_scale_octaves_across_boundary():
"""A4 major scale should cross into octave 5 at the right point."""
a = TonedScale(tonic="A4")
major = a["major"]
full = [t.full_name for t in major.tones]
assert full == ["A4", "B4", "C#5", "D5", "E5", "F#5", "G#5", "A5"]
# ── Mode interval tests (remaining modes) ───────────────────────────────────
def test_phrygian_mode_intervals():
system = SYSTEMS["western"]
phrygian = system.scales["heptatonic"]["phrygian"]
assert phrygian["intervals"] == [1, 2, 2, 2, 1, 2, 2]
def test_aeolian_mode_intervals():
"""Aeolian should have same intervals as natural minor."""
system = SYSTEMS["western"]
aeolian = system.scales["heptatonic"]["aeolian"]
minor = system.scales["heptatonic"]["minor"]
assert aeolian["intervals"] == minor["intervals"]
def test_locrian_mode_intervals():
system = SYSTEMS["western"]
locrian = system.scales["heptatonic"]["locrian"]
assert locrian["intervals"] == [1, 2, 2, 1, 2, 2, 2]
def test_ionian_mode_intervals():
"""Ionian should have same intervals as major."""
system = SYSTEMS["western"]
ionian = system.scales["heptatonic"]["ionian"]
major = system.scales["heptatonic"]["major"]
assert ionian["intervals"] == major["intervals"]
# ── Chord intervals and properties ──────────────────────────────────────────
def test_chord_intervals_c_major():
"""C4-E4-G4 intervals should be 4 and 3 semitones (major 3rd + minor 3rd)."""
c_major = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
assert c_major.intervals == [4, 3]
def test_chord_intervals_octave():
c_oct = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("C5", system="western"),
])
assert c_oct.intervals == [12]
def test_chord_intervals_minor_triad():
a_minor = Chord(tones=[
Tone.from_string("A4", system="western"),
Tone.from_string("C5", system="western"),
Tone.from_string("E5", system="western"),
])
assert a_minor.intervals == [3, 4] # minor 3rd + major 3rd
def test_chord_beat_pulse_unison():
"""Two identical tones should have beat pulse of 0."""
chord = Chord(tones=[
Tone.from_string("A4", system="western"),
Tone.from_string("A4", system="western"),
])
assert chord.beat_pulse == 0
def test_chord_beat_pulse_octave():
"""Two tones an octave apart should have beat pulse = 440 Hz."""
chord = Chord(tones=[
Tone.from_string("A4", system="western"),
Tone.from_string("A5", system="western"),
])
assert abs(chord.beat_pulse - 440.0) < 0.01
def test_chord_beat_frequencies():
"""beat_frequencies returns sorted (tone, tone, hz) tuples."""
chord = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
beats = chord.beat_frequencies
assert len(beats) == 3 # 3 pairs from 3 tones
# Should be sorted ascending by Hz
assert beats[0][2] <= beats[1][2] <= beats[2][2]
def test_chord_empty():
chord = Chord(tones=[])
assert chord.harmony == 0
assert chord.dissonance == 0
assert chord.beat_pulse == 0
assert chord.intervals == []
assert chord.beat_frequencies == []
def test_chord_harmony_fifth_beats_tritone():
"""A perfect fifth (3:2) should score higher harmony than a tritone."""
fifth = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("G4", system="western"),
])
tritone = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("F#4", system="western"),
])
assert fifth.harmony > tritone.harmony
def test_chord_harmony_octave_highest():
"""An octave (2:1) should score highest harmony."""
octave = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("C5", system="western"),
])
fifth = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("G4", system="western"),
])
assert octave.harmony > fifth.harmony
def test_chord_dissonance_tritone_vs_fifth():
"""A tritone should produce more roughness than a perfect fifth."""
tritone = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("F#4", system="western"),
])
fifth = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("G4", system="western"),
])
assert tritone.dissonance > fifth.dissonance
def test_chord_dissonance_wide_interval_low():
"""Very wide intervals (octave+) should have low roughness."""
octave = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("C5", system="western"),
])
third = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
])
# Octave exceeds critical bandwidth → less roughness than a 3rd
assert octave.dissonance < third.dissonance
def test_chord_dissonance_positive():
"""Any two distinct tones should produce non-zero roughness."""
chord = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("G4", system="western"),
])
assert chord.dissonance > 0
def test_chord_fingering_wrong_positions_raises():
chord = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
])
with pytest.raises(ValueError, match="positions"):
chord.fingering(1, 2, 3) # 3 positions for 2 tones
# ── Fretboard fingering ─────────────────────────────────────────────────────
def test_fretboard_fingering():
tuning = [
Tone.from_string("E4", system="western"),
Tone.from_string("B3", system="western"),
]
fb = Fretboard(tones=tuning)
chord = fb.fingering(0, 0) # Open strings
assert chord.tones[0].name == "E"
assert chord.tones[1].name == "B"
def test_fretboard_fingering_fretted():
tuning = [
Tone.from_string("E4", system="western"),
]
fb = Fretboard(tones=tuning)
chord = fb.fingering(1) # 1st fret on E string = F
assert chord.tones[0].name == "F"
def test_fretboard_fingering_wrong_count_raises():
fb = Fretboard(tones=[Tone.from_string("E4", system="western")])
with pytest.raises(ValueError, match="positions"):
fb.fingering(1, 2) # 2 positions for 1 string
# ── Named chord — all qualities ─────────────────────────────────────────────
def test_named_chord_repr():
c = NamedChord(tone_name="C", quality="m7")
assert repr(c) == "<NamedChord name='Cm7'>"
def test_named_chord_name_property():
c = NamedChord(tone_name="A", quality="maj7")
assert c.name == "Amaj7"
def test_named_chord_flat_to_sharp_conversion():
"""Flat tone names should be converted to sharp equivalents internally."""
bb = NamedChord(tone_name="Bb", quality="")
assert bb.tone.name == "A#"
ab = NamedChord(tone_name="Ab", quality="")
assert ab.tone.name == "G#"
db = NamedChord(tone_name="Db", quality="")
assert db.tone.name == "C#"
eb = NamedChord(tone_name="Eb", quality="")
assert eb.tone.name == "D#"
gb = NamedChord(tone_name="Gb", quality="")
assert gb.tone.name == "F#"
def test_named_chord_m6_tones():
cm6 = NamedChord(tone_name="C", quality="m6")
names = cm6.acceptable_tone_names
assert "C" in names
assert "Eb" in names # minor 3rd
assert "G" in names # perfect 5th
assert "A" in names # major 6th
assert len(names) == 4
def test_named_chord_m9_tones():
cm9 = NamedChord(tone_name="C", quality="m9")
names = cm9.acceptable_tone_names
assert "C" in names
assert "Eb" in names # minor 3rd
assert "G" in names # perfect 5th
assert "Bb" in names # minor 7th
assert "D" in names # major 9th
assert len(names) == 5
def test_named_chord_maj9_tones():
cmaj9 = NamedChord(tone_name="C", quality="maj9")
names = cmaj9.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
assert "D" in names # major 9th
assert len(names) == 5
def test_named_chord_9_tones():
c9 = NamedChord(tone_name="C", quality="9")
names = c9.acceptable_tone_names
assert "C" in names
assert "E" in names # major 3rd
assert "G" in names # perfect 5th
assert "Bb" in names # minor 7th
assert "D" in names # major 9th
assert len(names) == 5
def test_named_chord_fix_fingering():
assert NamedChord.fix_fingering((0, 1, -1, 3)) == (0, 1, None, 3)
assert NamedChord.fix_fingering((0, 0, 0)) == (0, 0, 0)
assert NamedChord.fix_fingering((-1, -1)) == (None, None)
def test_named_chord_fingerings_returns_multiple(guitar_fretboard):
c = CHARTS["western"]["C"]
all_fingerings = c.fingerings(fretboard=guitar_fretboard)
assert len(all_fingerings) > 1
assert all(len(f) == 6 for f in all_fingerings)
def test_named_chord_possible_fingerings(guitar_fretboard):
c = CHARTS["western"]["C"]
possible = c._possible_fingerings(fretboard=guitar_fretboard)
assert len(possible) == 6 # One tuple per string
assert all(isinstance(p, tuple) for p in possible)
# ── Charts — comprehensive ──────────────────────────────────────────────────
def test_charts_total_chord_count():
"""Should have 12 tones * 12 qualities = 144 chords."""
assert len(CHARTS["western"]) == 12 * len(QUALITIES)
def test_charts_all_qualities_present():
chart = CHARTS["western"]
for quality in QUALITIES:
matching = [name for name in chart if name.endswith(quality) or (quality == "" and len(name) <= 2)]
assert len(matching) > 0, f"No chords with quality '{quality}'"
@pytest.mark.slow
def test_charts_for_fretboard(guitar_fretboard):
result = charts_for_fretboard(fretboard=guitar_fretboard)
assert len(result) == len(CHARTS["western"])
for name, fingering in result.items():
assert len(fingering) == 6, f"{name} has wrong fingering length"
@pytest.mark.slow
def test_charts_fingering_values_in_range(guitar_fretboard):
"""All fret values should be 0-6 or None (muted)."""
for name, chord in CHARTS["western"].items():
fingering = chord.fingering(fretboard=guitar_fretboard)
for i, f in enumerate(fingering):
assert f is None or (0 <= f < 7), \
f"{name} string {i}: fret {f} out of range"
# ── System ──────────────────────────────────────────────────────────────────
def test_system_repr():
system = SYSTEMS["western"]
assert repr(system) == "<System semitones=12>"
def test_system_semitones():
system = SYSTEMS["western"]
assert system.semitones == 12
def test_system_tones_are_tone_objects():
system = SYSTEMS["western"]
for tone in system.tones:
assert isinstance(tone, Tone)
def test_system_tones_have_alt_names():
"""Tones with enharmonic equivalents should have alt names."""
system = SYSTEMS["western"]
cs = [t for t in system.tones if t.name == "C#"]
assert len(cs) == 1
assert "Db" in cs[0].alt_names
def test_system_chromatic_scale():
system = SYSTEMS["western"]
chromatic = system.scales["chromatic"]["chromatic"]
assert chromatic["intervals"] == [1] * 12
def test_system_generate_scale_major_minor_raises():
with pytest.raises(ValueError, match="both major and minor"):
System.generate_scale(major=True, minor=True)
def test_system_modes_list():
system = SYSTEMS["western"]
modes = system.modes
assert len(modes) > 0
for mode in modes:
assert "degree" in mode
assert "mode" in mode
assert isinstance(mode["degree"], int)
# ── Wave generation ─────────────────────────────────────────────────────────
@needs_portaudio
def test_sawtooth_wave_length():
from pytheory.play import sawtooth_wave, SAMPLE_RATE
wave = sawtooth_wave(440)
assert len(wave) == SAMPLE_RATE
@needs_portaudio
def test_sawtooth_wave_custom_samples():
from pytheory.play import sawtooth_wave
wave = sawtooth_wave(440, n_samples=2000)
assert len(wave) == 2000
@needs_portaudio
def test_triangle_wave_length():
from pytheory.play import triangle_wave, SAMPLE_RATE
wave = triangle_wave(440)
assert len(wave) == SAMPLE_RATE
@needs_portaudio
def test_triangle_wave_custom_samples():
from pytheory.play import triangle_wave
wave = triangle_wave(440, n_samples=2000)
assert len(wave) == 2000
@needs_portaudio
def test_sine_wave_output_type():
from pytheory.play import sine_wave
wave = sine_wave(440)
assert wave.dtype == numpy.int16
@needs_portaudio
def test_sawtooth_wave_output_type():
from pytheory.play import sawtooth_wave
wave = sawtooth_wave(440)
assert wave.dtype == numpy.int16
@needs_portaudio
def test_triangle_wave_output_type():
from pytheory.play import triangle_wave
wave = triangle_wave(440)
assert wave.dtype == numpy.int16
@needs_portaudio
def test_sine_wave_different_frequencies():
from pytheory.play import sine_wave
wave_low = sine_wave(220)
wave_high = sine_wave(880)
# Both should be valid arrays of the same length
assert len(wave_low) == len(wave_high)
# But they should have different content
assert not numpy.array_equal(wave_low, wave_high)
@needs_portaudio
def test_synth_callable_with_pitch():
"""Synth enum members should work with actual pitch values from Tone."""
from pytheory.play import Synth
t = Tone.from_string("A4", system="western")
hz = t.pitch()
wave = Synth.SINE(hz)
assert len(wave) > 0
# ── Integration: scale → chord → fingering ──────────────────────────────────
def test_build_chord_from_scale(guitar_fretboard):
"""Build a I-IV-V progression from C major and verify fingerings."""
c = TonedScale(tonic="C4")
major = c["major"]
tonic = major["I"].name # C
subdominant = major["IV"].name # F
dominant = major["V"].name # G
chart = CHARTS["western"]
for name in [tonic, subdominant, dominant]:
fingering = chart[name].fingering(fretboard=guitar_fretboard)
assert len(fingering) == 6
def test_circle_of_fifths():
"""Walk the circle of fifths starting from C."""
t = Tone.from_string("C4", system="western")
expected = ["C", "G", "D", "A", "E", "B", "F#", "C#", "G#", "D#", "A#", "F"]
for i, name in enumerate(expected):
assert t.name == name, f"Step {i}: expected {name}, got {t.name}"
t = t.add(7) # perfect fifth = 7 semitones
def test_circle_of_fourths():
"""Walk the circle of fourths starting from C."""
t = Tone.from_string("C4", system="western")
expected = ["C", "F", "A#", "D#", "G#", "C#", "F#", "B", "E", "A", "D", "G"]
for i, name in enumerate(expected):
assert t.name == name, f"Step {i}: expected {name}, got {t.name}"
t = t.add(5) # perfect fourth = 5 semitones
def test_enharmonic_equivalence_in_scales():
"""D Dorian and C major should contain the same pitch classes."""
c_major = TonedScale(tonic="C4")["major"]
d_dorian = TonedScale(tonic="D4")["dorian"]
c_names = sorted(set(t.name for t in c_major.tones))
d_names = sorted(set(t.name for t in d_dorian.tones))
assert c_names == d_names
def test_relative_minor():
"""A minor (relative minor of C major) should share the same notes."""
c_major = TonedScale(tonic="C4")["major"]
a_minor = TonedScale(tonic="A4")["minor"]
c_names = sorted(set(t.name for t in c_major.tones))
a_names = sorted(set(t.name for t in a_minor.tones))
assert c_names == a_names
# ── Tone operators ──────────────────────────────────────────────────────────
def test_tone_add_operator():
t = Tone.from_string("C4", system="western")
result = t + 7
assert result.name == "G"
assert result.octave == 4
def test_tone_sub_int_operator():
t = Tone.from_string("G4", system="western")
result = t - 7
assert result.name == "C"
assert result.octave == 4
def test_tone_sub_tone_operator():
"""Subtracting two tones gives semitone distance."""
c4 = Tone.from_string("C4", system="western")
g4 = Tone.from_string("G4", system="western")
assert g4 - c4 == 7
def test_tone_sub_tone_negative():
c4 = Tone.from_string("C4", system="western")
g4 = Tone.from_string("G4", system="western")
assert c4 - g4 == -7
def test_tone_sub_tone_octave():
c4 = Tone.from_string("C4", system="western")
c5 = Tone.from_string("C5", system="western")
assert c5 - c4 == 12
def test_tone_lt():
c4 = Tone.from_string("C4", system="western")
g4 = Tone.from_string("G4", system="western")
assert c4 < g4
assert not g4 < c4
def test_tone_gt():
c4 = Tone.from_string("C4", system="western")
g4 = Tone.from_string("G4", system="western")
assert g4 > c4
assert not c4 > g4
def test_tone_le():
c4 = Tone.from_string("C4", system="western")
c4b = Tone.from_string("C4", system="western")
g4 = Tone.from_string("G4", system="western")
assert c4 <= g4
assert c4 <= c4b
def test_tone_ge():
c4 = Tone.from_string("C4", system="western")
c4b = Tone.from_string("C4", system="western")
g4 = Tone.from_string("G4", system="western")
assert g4 >= c4
assert c4 >= c4b
def test_tone_sorting():
"""Tones should be sortable by pitch."""
tones = [
Tone.from_string("G4", system="western"),
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("A3", system="western"),
]
sorted_tones = sorted(tones)
names = [t.name for t in sorted_tones]
assert names == ["A", "C", "E", "G"]
def test_tone_str():
c4 = Tone(name="C", octave=4)
assert str(c4) == "C4"
d = Tone(name="D")
assert str(d) == "D"
def test_tone_frequency_property():
t = Tone.from_string("A4", system="western")
assert abs(t.frequency - 440.0) < 0.01
def test_tone_frequency_c4():
t = Tone.from_string("C4", system="western")
assert abs(t.frequency - 261.63) < 0.01
def test_tone_chaining():
"""Operators should be chainable."""
t = Tone.from_string("C4", system="western")
result = t + 4 + 3 # C -> E -> G
assert result.name == "G"
assert result.octave == 4
# ── Scale iteration ─────────────────────────────────────────────────────────
def test_scale_iter():
c = TonedScale(tonic="C4")
major = c["major"]
names = [t.name for t in major]
assert names == ["C", "D", "E", "F", "G", "A", "B", "C"]
def test_scale_len():
c = TonedScale(tonic="C4")
major = c["major"]
assert len(major) == 8 # 7 notes + octave
def test_scale_contains_name():
c = TonedScale(tonic="C4")
major = c["major"]
assert "C" in major
assert "E" in major
assert "C#" not in major
def test_scale_contains_tone():
c = TonedScale(tonic="C4")
major = c["major"]
c4 = Tone(name="C", octave=4)
assert c4 in major
def test_scale_note_names():
c = TonedScale(tonic="C4")
major = c["major"]
assert major.note_names == ["C", "D", "E", "F", "G", "A", "B", "C"]
# ── Scale.chord() and Scale.triad() ─────────────────────────────────────────
def test_scale_chord():
c = TonedScale(tonic="C4")
major = c["major"]
chord = major.chord(0, 2, 4) # C E G
assert len(chord) == 3
assert chord.tones[0].name == "C"
assert chord.tones[1].name == "E"
assert chord.tones[2].name == "G"
def test_scale_triad_root():
c = TonedScale(tonic="C4")
major = c["major"]
triad = major.triad(0) # I chord = C E G
assert len(triad) == 3
names = [t.name for t in triad]
assert names == ["C", "E", "G"]
def test_scale_triad_iv():
c = TonedScale(tonic="C4")
major = c["major"]
triad = major.triad(3) # IV chord = F A C
names = [t.name for t in triad]
assert names == ["F", "A", "C"]
def test_scale_triad_v():
c = TonedScale(tonic="C4")
major = c["major"]
triad = major.triad(4) # V chord = G B D
names = [t.name for t in triad]
assert names == ["G", "B", "D"]
def test_scale_triad_ii_minor():
"""ii chord in C major should be D F A (minor triad)."""
c = TonedScale(tonic="C4")
major = c["major"]
triad = major.triad(1)
names = [t.name for t in triad]
assert names == ["D", "F", "A"]
def test_scale_chord_seventh():
"""Build a 7th chord from scale degrees."""
c = TonedScale(tonic="C4")
major = c["major"]
seventh = major.chord(0, 2, 4, 6) # C E G B = Cmaj7
assert len(seventh) == 4
names = [t.name for t in seventh]
assert names == ["C", "E", "G", "B"]
# ── Chord iteration ─────────────────────────────────────────────────────────
def test_chord_iter():
chord = Chord(tones=[
Tone(name="C", octave=4),
Tone(name="E", octave=4),
Tone(name="G", octave=4),
])
names = [t.name for t in chord]
assert names == ["C", "E", "G"]
def test_chord_len():
chord = Chord(tones=[
Tone(name="C", octave=4),
Tone(name="E", octave=4),
])
assert len(chord) == 2
def test_chord_contains_name():
chord = Chord(tones=[
Tone(name="C", octave=4),
Tone(name="E", octave=4),
Tone(name="G", octave=4),
])
assert "C" in chord
assert "E" in chord
assert "D" not in chord
def test_chord_contains_tone():
c4 = Tone(name="C", octave=4)
chord = Chord(tones=[c4, Tone(name="E", octave=4)])
assert c4 in chord
# ── Fretboard presets ───────────────────────────────────────────────────────
def test_fretboard_guitar():
fb = Fretboard.guitar()
assert len(fb) == 6
names = [t.name for t in fb]
assert names == ["E", "B", "G", "D", "A", "E"]
def test_fretboard_guitar_octaves():
fb = Fretboard.guitar()
octaves = [t.octave for t in fb]
assert octaves == [4, 3, 3, 3, 2, 2]
def test_fretboard_bass():
fb = Fretboard.bass()
assert len(fb) == 4
names = [t.name for t in fb]
assert names == ["G", "D", "A", "E"]
def test_fretboard_ukulele():
fb = Fretboard.ukulele()
assert len(fb) == 4
names = [t.name for t in fb]
assert names == ["A", "E", "C", "G"]
def test_fretboard_iter():
fb = Fretboard.guitar()
tones = list(fb)
assert len(tones) == 6
assert all(isinstance(t, Tone) for t in tones)
def test_fretboard_len():
fb = Fretboard.guitar()
assert len(fb) == 6
def test_fretboard_preset_fingerings():
"""Preset fretboards should work with chord charts."""
fb = Fretboard.guitar()
c = CHARTS["western"]["C"]
fingering = c.fingering(fretboard=fb)
assert len(fingering) == 6
def test_fretboard_ukulele_fingerings():
fb = Fretboard.ukulele()
c = CHARTS["western"]["C"]
fingering = c.fingering(fretboard=fb)
assert len(fingering) == 4
def test_fretboard_guitar_drop_d():
fb = Fretboard.guitar("drop d")
assert len(fb) == 6
assert fb.tones[-1].name == "D"
assert fb.tones[-1].octave == 2
def test_fretboard_guitar_open_g():
fb = Fretboard.guitar("open g")
assert len(fb) == 6
assert fb.tones[0].name == "D"
def test_fretboard_guitar_custom_tuple():
fb = Fretboard.guitar(("E4", "B3", "G3", "D3", "A2", "D2"))
assert len(fb) == 6
assert fb.tones[-1].name == "D"
def test_fretboard_bass_five_string():
fb = Fretboard.bass(five_string=True)
assert len(fb) == 5
assert fb.tones[-1].name == "B"
def test_fretboard_tunings_dict():
for name in Fretboard.TUNINGS:
fb = Fretboard.guitar(name)
assert len(fb) == 6, f"Tuning {name} should have 6 strings"
def test_fretboard_mandolin():
fb = Fretboard.mandolin()
assert len(fb) == 4
assert fb.tones[0].name == "E"
assert fb.tones[-1].name == "G"
def test_fretboard_violin():
fb = Fretboard.violin()
assert len(fb) == 4
names = [t.name for t in fb]
assert names == ["E", "A", "D", "G"]
def test_fretboard_viola():
fb = Fretboard.viola()
assert len(fb) == 4
names = [t.name for t in fb]
assert names == ["A", "D", "G", "C"]
def test_fretboard_cello():
fb = Fretboard.cello()
assert len(fb) == 4
names = [t.name for t in fb]
assert names == ["A", "D", "G", "C"]
assert fb.tones[0].octave == 3
def test_fretboard_banjo():
fb = Fretboard.banjo()
assert len(fb) == 5
assert fb.tones[-1].name == "G" # high drone string
def test_fretboard_banjo_open_d():
fb = Fretboard.banjo("open d")
assert len(fb) == 5
def test_fretboard_twelve_string():
fb = Fretboard.twelve_string()
assert len(fb) == 12
def test_fretboard_violin_tuned_in_fifths():
"""Violin strings should be a perfect 5th apart."""
fb = Fretboard.violin()
for i in range(len(fb.tones) - 1):
interval = fb.tones[i] - fb.tones[i + 1]
assert interval == 7, f"Strings {i} and {i+1} not a 5th apart"
def test_fretboard_octave_mandolin():
fb = Fretboard.octave_mandolin()
assert len(fb) == 4
assert fb.tones[0].name == "E"
assert fb.tones[0].octave == 4
def test_fretboard_mandocello():
fb = Fretboard.mandocello()
assert len(fb) == 4
names = [t.name for t in fb]
assert names == ["A", "D", "G", "C"]
assert fb.tones[0].octave == 3
def test_fretboard_double_bass():
fb = Fretboard.double_bass()
assert len(fb) == 4
names = [t.name for t in fb]
assert names == ["G", "D", "A", "E"]
def test_fretboard_double_bass_tuned_in_fourths():
fb = Fretboard.double_bass()
for i in range(len(fb.tones) - 1):
interval = fb.tones[i] - fb.tones[i + 1]
assert interval == 5, f"Strings {i} and {i+1} not a 4th apart"
def test_fretboard_harp():
fb = Fretboard.harp()
assert len(fb) == 47
assert fb.tones[0].name == "G"
assert fb.tones[0].octave == 7
assert fb.tones[-1].name == "C"
assert fb.tones[-1].octave == 1
def test_fretboard_pedal_steel():
fb = Fretboard.pedal_steel()
assert len(fb) == 10
def test_mandolin_family_fifths():
"""All mandolin family instruments should be tuned in 5ths."""
for name in ["mandolin", "mandola", "octave_mandolin", "mandocello"]:
fb = getattr(Fretboard, name)()
for i in range(len(fb.tones) - 1):
interval = fb.tones[i] - fb.tones[i + 1]
assert interval == 7, f"{name} strings {i},{i+1} not a 5th apart"
def test_all_instruments_create():
"""Every instrument preset should instantiate without error."""
instruments = [
"guitar", "twelve_string", "bass", "ukulele",
"mandolin", "mandola", "octave_mandolin", "mandocello",
"violin", "viola", "cello", "double_bass",
"banjo", "harp", "pedal_steel",
"bouzouki", "oud", "sitar", "shamisen", "erhu",
"charango", "pipa", "balalaika", "lute", "keyboard",
]
for name in instruments:
fb = getattr(Fretboard, name)()
assert len(fb) > 0, f"{name} has no strings"
def test_fretboard_oud():
fb = Fretboard.oud()
assert len(fb) == 6
def test_fretboard_shamisen():
fb = Fretboard.shamisen()
assert len(fb) == 3
def test_fretboard_erhu():
fb = Fretboard.erhu()
assert len(fb) == 2
assert fb.tones[0] - fb.tones[1] == 7 # tuned in 5ths
def test_fretboard_bouzouki_irish():
fb = Fretboard.bouzouki("irish")
assert len(fb) == 4
def test_fretboard_bouzouki_greek():
fb = Fretboard.bouzouki("greek")
assert len(fb) == 4
def test_fretboard_charango():
fb = Fretboard.charango()
assert len(fb) == 5
def test_fretboard_balalaika():
fb = Fretboard.balalaika()
assert len(fb) == 3
# Two unison strings
assert fb.tones[1].name == fb.tones[2].name
def test_fretboard_lute():
fb = Fretboard.lute()
assert len(fb) == 6
def test_fretboard_sitar():
fb = Fretboard.sitar()
assert len(fb) == 7
def test_fretboard_pipa():
fb = Fretboard.pipa()
assert len(fb) == 4
def test_keyboard_88():
kb = Fretboard.keyboard()
assert len(kb) == 88
def test_keyboard_25():
kb = Fretboard.keyboard(25, "C3")
assert len(kb) == 25
assert kb.tones[-1].name == "C"
assert kb.tones[-1].octave == 3
def test_keyboard_custom():
kb = Fretboard.keyboard(61, "C2")
assert len(kb) == 61
# ── Ergonomic integration tests ─────────────────────────────────────────────
def test_ergonomic_workflow():
"""Demonstrate the improved API in a realistic workflow."""
# Build a scale
c = TonedScale(tonic="C4")
major = c["major"]
# Iterate and check
assert "C" in major
assert len(major) == 8
# Build chords from the scale
I = major.triad(0) # C major
IV = major.triad(3) # F major
V = major.triad(4) # G major
assert "C" in I
assert "F" in IV
assert "G" in V
# Get fingerings
fb = Fretboard.guitar()
for name in ["C", "F", "G"]:
fingering = CHARTS["western"][name].fingering(fretboard=fb)
assert len(fingering) == len(fb)
def test_tone_arithmetic_workflow():
"""Demonstrate tone arithmetic with operators."""
c4 = Tone.from_string("C4", system="western")
# Build intervals
major_third = c4 + 4 # E4
perfect_fifth = c4 + 7 # G4
octave = c4 + 12 # C5
assert str(major_third) == "E4"
assert str(perfect_fifth) == "G4"
assert str(octave) == "C5"
# Measure intervals
assert perfect_fifth - c4 == 7
assert octave - c4 == 12
# Compare
assert c4 < major_third < perfect_fifth < octave
# ── Indian system ───────────────────────────────────────────────────────────
def test_indian_system_exists():
assert "indian" in SYSTEMS
assert SYSTEMS["indian"].semitones == 12
def test_indian_system_tones():
indian = SYSTEMS["indian"]
names = [t.name for t in indian.tones]
assert "Sa" in names
assert "Pa" in names
assert "Dha" in names
assert len(names) == 12
def test_indian_sa_pitch():
"""Sa4 should equal C4 = 261.63 Hz."""
sa = Tone.from_string("Sa4", system="indian")
assert abs(sa.frequency - 261.63) < 0.01
def test_indian_pa_pitch():
"""Pa4 should equal G4 = 392.00 Hz."""
sa = Tone.from_string("Sa4", system="indian")
pa = sa + 7
assert pa.name == "Pa"
assert abs(pa.frequency - 392.00) < 0.01
def test_indian_dha_pitch():
"""Dha4 should equal A4 = 440 Hz."""
dha = Tone.from_string("Dha4", system="indian")
assert abs(dha.frequency - 440.0) < 0.01
def test_indian_octave_sa():
"""Sa4 + 12 = Sa5."""
sa = Tone.from_string("Sa4", system="indian")
result = sa + 12
assert result.name == "Sa"
assert result.octave == 5
def test_indian_bilawal_thaat():
"""Bilawal = major scale: Sa Re Ga Ma Pa Dha Ni Sa."""
sa = TonedScale(tonic="Sa4", system=SYSTEMS["indian"])
bilawal = sa["bilawal"]
names = [t.name for t in bilawal]
assert names == ["Sa", "Re", "Ga", "Ma", "Pa", "Dha", "Ni", "Sa"]
def test_indian_bhairav_thaat():
"""Bhairav: Sa komal-Re Ga Ma Pa komal-Dha Ni Sa."""
sa = TonedScale(tonic="Sa4", system=SYSTEMS["indian"])
bhairav = sa["bhairav"]
names = [t.name for t in bhairav]
assert names == ["Sa", "komal Re", "Ga", "Ma", "Pa", "komal Dha", "Ni", "Sa"]
def test_indian_todi_thaat():
"""Todi: Sa komal-Re komal-Ga tivra-Ma Pa komal-Dha Ni Sa."""
sa = TonedScale(tonic="Sa4", system=SYSTEMS["indian"])
todi = sa["todi"]
names = [t.name for t in todi]
assert names == ["Sa", "komal Re", "komal Ga", "tivra Ma", "Pa", "komal Dha", "Ni", "Sa"]
def test_indian_kalyan_thaat():
"""Kalyan = Lydian: Sa Re Ga tivra-Ma Pa Dha Ni Sa."""
sa = TonedScale(tonic="Sa4", system=SYSTEMS["indian"])
kalyan = sa["kalyan"]
names = [t.name for t in kalyan]
assert names == ["Sa", "Re", "Ga", "tivra Ma", "Pa", "Dha", "Ni", "Sa"]
def test_indian_all_thaats_available():
sa = TonedScale(tonic="Sa4", system=SYSTEMS["indian"])
thaats = sa.scales
for thaat in ["bilawal", "bhairav", "todi", "kalyan", "kafi",
"asavari", "bhairavi", "khamaj", "poorvi", "marwa"]:
assert thaat in thaats, f"Missing thaat: {thaat}"
def test_indian_all_thaat_intervals_sum_to_12():
indian = SYSTEMS["indian"]
for name, scale in indian.scales["thaat"].items():
total = sum(scale["intervals"])
assert total == 12, f"{name} intervals sum to {total}, not 12"
def test_indian_bilawal_equals_western_major():
"""Bilawal intervals should match Western major."""
indian = SYSTEMS["indian"]
western = SYSTEMS["western"]
bilawal = indian.scales["thaat"]["bilawal"]["intervals"]
major = western.scales["heptatonic"]["major"]["intervals"]
assert bilawal == major
def test_indian_tone_arithmetic():
sa = Tone.from_string("Sa4", system="indian")
assert (sa + 2).name == "Re"
assert (sa + 4).name == "Ga"
assert (sa + 5).name == "Ma"
assert (sa + 7).name == "Pa"
assert (sa + 9).name == "Dha"
assert (sa + 11).name == "Ni"
def test_indian_chromatic_walk():
"""Walk all 12 swaras from Sa4."""
sa = Tone.from_string("Sa4", system="indian")
expected = ["Sa", "komal Re", "Re", "komal Ga", "Ga", "Ma",
"tivra Ma", "Pa", "komal Dha", "Dha", "komal Ni", "Ni", "Sa"]
for i, name in enumerate(expected):
result = sa + i
assert result.name == name, f"step {i}: expected {name}, got {result.name}"
def test_indian_scale_triad():
"""Build a triad from Bilawal (Sa Ga Pa)."""
sa = TonedScale(tonic="Sa4", system=SYSTEMS["indian"])
bilawal = sa["bilawal"]
triad = bilawal.triad(0)
names = [t.name for t in triad]
assert names == ["Sa", "Ga", "Pa"]
def test_indian_scale_degree_access():
sa = TonedScale(tonic="Sa4", system=SYSTEMS["indian"])
bilawal = sa["bilawal"]
assert bilawal[0].name == "Sa"
assert bilawal[4].name == "Pa"
assert bilawal["I"].name == "Sa"
assert bilawal["V"].name == "Pa"
# ── Arabic system ───────────────────────────────────────────────────────────
def test_arabic_system_exists():
assert "arabic" in SYSTEMS
assert SYSTEMS["arabic"].semitones == 12
def test_arabic_tones():
arabic = SYSTEMS["arabic"]
names = [t.name for t in arabic.tones]
assert "Do" in names
assert "Re" in names
assert "Sol" in names
def test_arabic_do_pitch():
"""Do4 should equal C4 = 261.63 Hz."""
do = Tone.from_string("Do4", system="arabic")
assert abs(do.frequency - 261.63) < 0.01
def test_arabic_ajam_maqam():
"""Ajam = major scale."""
do = TonedScale(tonic="Do4", system=SYSTEMS["arabic"])
ajam = do["ajam"]
names = [t.name for t in ajam]
assert names == ["Do", "Re", "Mi", "Fa", "Sol", "La", "Si", "Do"]
def test_arabic_hijaz_maqam():
"""Hijaz has augmented 2nd between 2nd and 3rd degrees."""
do = TonedScale(tonic="Do4", system=SYSTEMS["arabic"])
hijaz = do["hijaz"]
names = [t.name for t in hijaz]
assert names[0] == "Do"
assert names[1] == "Reb" # flat 2nd
assert names[2] == "Mi" # natural 3rd (augmented 2nd interval)
def test_arabic_all_maqamat_available():
do = TonedScale(tonic="Do4", system=SYSTEMS["arabic"])
for maqam in ["ajam", "nahawand", "kurd", "hijaz", "nikriz",
"bayati", "rast", "saba", "sikah", "jiharkah"]:
assert maqam in do.scales, f"Missing maqam: {maqam}"
def test_arabic_all_maqam_intervals_sum_to_12():
arabic = SYSTEMS["arabic"]
for name, scale in arabic.scales["maqam"].items():
total = sum(scale["intervals"])
assert total == 12, f"{name} intervals sum to {total}, not 12"
def test_arabic_ajam_equals_western_major():
arabic = SYSTEMS["arabic"]
western = SYSTEMS["western"]
ajam = arabic.scales["maqam"]["ajam"]["intervals"]
major = western.scales["heptatonic"]["major"]["intervals"]
assert ajam == major
def test_arabic_tone_arithmetic():
do = Tone.from_string("Do4", system="arabic")
assert (do + 2).name == "Re"
assert (do + 4).name == "Mi"
assert (do + 7).name == "Sol"
# ── Japanese system ─────────────────────────────────────────────────────────
def test_japanese_system_exists():
assert "japanese" in SYSTEMS
assert SYSTEMS["japanese"].semitones == 12
def test_japanese_hirajoshi():
"""Hirajoshi: C D Eb G Ab."""
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
h = c["hirajoshi"]
names = [t.name for t in h]
assert names == ["C", "D", "Eb", "G", "Ab", "C"]
def test_japanese_in_scale():
"""In (Miyako-bushi): C Db F G Ab."""
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
s = c["in"]
names = [t.name for t in s]
assert names == ["C", "Db", "F", "G", "Ab", "C"]
def test_japanese_yo_scale():
"""Yo: C D F G Bb."""
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
s = c["yo"]
names = [t.name for t in s]
assert names == ["C", "D", "F", "G", "A#", "C"]
def test_japanese_iwato():
"""Iwato: C Db F Gb Bb."""
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
s = c["iwato"]
names = [t.name for t in s]
assert names == ["C", "Db", "F", "Gb", "Bb", "C"]
def test_japanese_kumoi():
"""Kumoi: C D Eb G A."""
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
s = c["kumoi"]
names = [t.name for t in s]
assert names == ["C", "D", "Eb", "G", "A", "C"]
def test_japanese_ritsu():
"""Ritsu (gagaku): C D Eb F G A Bb = Dorian."""
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
s = c["ritsu"]
names = [t.name for t in s]
assert names == ["C", "D", "Eb", "F", "G", "A", "Bb", "C"]
def test_japanese_all_scales_available():
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
for scale in ["hirajoshi", "in", "yo", "iwato", "kumoi", "insen", "ritsu", "ryo"]:
assert scale in c.scales, f"Missing scale: {scale}"
def test_japanese_pentatonic_intervals_sum_to_12():
japanese = SYSTEMS["japanese"]
for name, scale in japanese.scales["pentatonic"].items():
total = sum(scale["intervals"])
assert total == 12, f"{name} intervals sum to {total}, not 12"
def test_japanese_heptatonic_intervals_sum_to_12():
japanese = SYSTEMS["japanese"]
for name, scale in japanese.scales["heptatonic"].items():
total = sum(scale["intervals"])
assert total == 12, f"{name} intervals sum to {total}, not 12"
# ── Blues system ────────────────────────────────────────────────────────────
def test_blues_system_exists():
assert "blues" in SYSTEMS
assert SYSTEMS["blues"].semitones == 12
def test_blues_major_pentatonic():
c = TonedScale(tonic="C4", system=SYSTEMS["blues"])
s = c["major pentatonic"]
assert s.note_names == ["C", "D", "E", "G", "A", "C"]
def test_blues_minor_pentatonic():
c = TonedScale(tonic="C4", system=SYSTEMS["blues"])
s = c["minor pentatonic"]
assert s.note_names == ["C", "D#", "F", "G", "A#", "C"]
def test_blues_scale():
c = TonedScale(tonic="C4", system=SYSTEMS["blues"])
s = c["blues"]
names = s.note_names
assert names == ["C", "Eb", "F", "Gb", "G", "Bb", "C"]
assert len(names) == 7 # 6 notes + octave
def test_blues_all_scales_available():
c = TonedScale(tonic="C4", system=SYSTEMS["blues"])
for scale in ["major pentatonic", "minor pentatonic", "blues",
"major blues", "dominant", "minor"]:
assert scale in c.scales, f"Missing scale: {scale}"
def test_blues_all_intervals_sum_to_12():
blues = SYSTEMS["blues"]
for scale_type in blues.scales:
for name, scale in blues.scales[scale_type].items():
total = sum(scale["intervals"])
assert total == 12, f"{name} intervals sum to {total}, not 12"
# ── Gamelan system ──────────────────────────────────────────────────────────
def test_gamelan_system_exists():
assert "gamelan" in SYSTEMS
assert SYSTEMS["gamelan"].semitones == 12
def test_gamelan_tones():
gamelan = SYSTEMS["gamelan"]
names = [t.name for t in gamelan.tones]
assert "ji" in names
assert "ro" in names
assert "mo" in names
def test_gamelan_slendro():
ji = TonedScale(tonic="ji4", system=SYSTEMS["gamelan"])
s = ji["slendro"]
assert s.note_names == ["ji", "ro", "pat", "mo", "pi", "ji"]
def test_gamelan_pelog():
ji = TonedScale(tonic="ji4", system=SYSTEMS["gamelan"])
s = ji["pelog"]
assert len(s) == 8 # 7 notes + octave
def test_gamelan_all_scales_available():
ji = TonedScale(tonic="ji4", system=SYSTEMS["gamelan"])
for scale in ["slendro", "pelog nem", "pelog barang", "pelog lima", "pelog"]:
assert scale in ji.scales, f"Missing scale: {scale}"
def test_gamelan_all_intervals_sum_to_12():
gamelan = SYSTEMS["gamelan"]
for scale_type in gamelan.scales:
for name, scale in gamelan.scales[scale_type].items():
total = sum(scale["intervals"])
assert total == 12, f"{name} intervals sum to {total}, not 12"
# ── Overtone series ─────────────────────────────────────────────────────────
def test_overtones_a4():
a4 = Tone.from_string("A4", system="western")
harmonics = a4.overtones(4)
assert len(harmonics) == 4
assert abs(harmonics[0] - 440.0) < 0.01
assert abs(harmonics[1] - 880.0) < 0.01
assert abs(harmonics[2] - 1320.0) < 0.01
assert abs(harmonics[3] - 1760.0) < 0.01
def test_overtones_default_count():
c4 = Tone.from_string("C4", system="western")
assert len(c4.overtones()) == 8
def test_overtones_ratios():
a4 = Tone.from_string("A4", system="western")
harmonics = a4.overtones(8)
f0 = harmonics[0]
for i, h in enumerate(harmonics):
assert abs(h / f0 - (i + 1)) < 0.001
# ── Chord identification ────────────────────────────────────────────────────
def test_identify_c_major():
chord = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
assert chord.identify() == "C major"
def test_identify_a_minor():
chord = Chord(tones=[
Tone.from_string("A4", system="western"),
Tone.from_string("C5", system="western"),
Tone.from_string("E5", system="western"),
])
assert chord.identify() == "A minor"
def test_identify_g_dominant_7th():
chord = Chord(tones=[
Tone.from_string("G4", system="western"),
Tone.from_string("B4", system="western"),
Tone.from_string("D5", system="western"),
Tone.from_string("F5", system="western"),
])
assert chord.identify() == "G dominant 7th"
def test_identify_power_chord():
chord = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("G4", system="western"),
])
assert chord.identify() == "C power"
def test_identify_diminished():
chord = Chord(tones=[
Tone.from_string("B4", system="western"),
Tone.from_string("D5", system="western"),
Tone.from_string("F5", system="western"),
])
assert chord.identify() == "B diminished"
def test_identify_single_tone():
chord = Chord(tones=[Tone.from_string("C4", system="western")])
assert chord.identify() is None
# ── Voice leading ───────────────────────────────────────────────────────────
def test_voice_leading_same_chord():
c_maj = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
vl = c_maj.voice_leading(c_maj)
total = sum(abs(v[2]) for v in vl)
assert total == 0
def test_voice_leading_returns_tuples():
c = Chord([Tone.from_string("C4", system="western")])
d = Chord([Tone.from_string("D4", system="western")])
vl = c.voice_leading(d)
assert len(vl) == 1
assert vl[0][2] == 2
# ── Harmonic analysis ───────────────────────────────────────────────────────
def test_analyze_I():
chord = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
assert chord.analyze("C") == "I"
def test_analyze_V():
chord = Chord(tones=[
Tone.from_string("G4", system="western"),
Tone.from_string("B4", system="western"),
Tone.from_string("D5", system="western"),
])
assert chord.analyze("C") == "V"
def test_analyze_ii():
chord = Chord(tones=[
Tone.from_string("D4", system="western"),
Tone.from_string("F4", system="western"),
Tone.from_string("A4", system="western"),
])
assert chord.analyze("C") == "ii"
def test_analyze_V7():
chord = Chord(tones=[
Tone.from_string("G4", system="western"),
Tone.from_string("B4", system="western"),
Tone.from_string("D5", system="western"),
Tone.from_string("F5", system="western"),
])
assert chord.analyze("C") == "V7"
def test_analyze_not_in_key():
"""F# major in C major is now recognized as a borrowed chord (bV)."""
chord = Chord(tones=[
Tone.from_string("F#4", system="western"),
Tone.from_string("A#4", system="western"),
Tone.from_string("C#5", system="western"),
])
result = chord.analyze("C")
assert result is not None
assert "b" in result # borrowed chord with flat prefix
# ── Tension ─────────────────────────────────────────────────────────────────
def test_tension_c_major_low():
chord = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
assert chord.tension["score"] == 0.0
assert chord.tension["tritones"] == 0
def test_tension_g7_high():
chord = Chord(tones=[
Tone.from_string("G4", system="western"),
Tone.from_string("B4", system="western"),
Tone.from_string("D5", system="western"),
Tone.from_string("F5", system="western"),
])
t = chord.tension
assert t["tritones"] == 1
assert t["has_dominant_function"] is True
assert t["score"] > 0.5
def test_tension_empty():
chord = Chord(tones=[])
assert chord.tension["score"] == 0.0
# ── Version ─────────────────────────────────────────────────────────────────
def test_version():
import pytheory
assert pytheory.__version__
def test_all_exports():
import pytheory
assert not hasattr(pytheory, "ceil")
assert not hasattr(pytheory, "floor")
assert "Tone" in pytheory.__all__
# ── Interval naming ─────────────────────────────────────────────────────────
def test_interval_to_perfect_fifth():
c4 = Tone.from_string("C4", system="western")
g4 = Tone.from_string("G4", system="western")
assert c4.interval_to(g4) == "perfect 5th"
def test_interval_to_major_third():
c4 = Tone.from_string("C4", system="western")
e4 = Tone.from_string("E4", system="western")
assert c4.interval_to(e4) == "major 3rd"
def test_interval_to_octave():
c4 = Tone.from_string("C4", system="western")
c5 = Tone.from_string("C5", system="western")
assert c4.interval_to(c5) == "octave"
def test_interval_to_unison():
c4 = Tone.from_string("C4", system="western")
assert c4.interval_to(c4) == "unison"
def test_interval_to_compound():
c4 = Tone.from_string("C4", system="western")
d5 = c4 + 14
assert c4.interval_to(d5) == "major 2nd + 1 octave"
def test_interval_to_two_octaves():
c4 = Tone.from_string("C4", system="western")
c6 = c4 + 24
assert c4.interval_to(c6) == "2 octaves"
# ── MIDI ────────────────────────────────────────────────────────────────────
def test_midi_c4():
c4 = Tone.from_string("C4", system="western")
assert c4.midi == 60
def test_midi_a4():
a4 = Tone.from_string("A4", system="western")
assert a4.midi == 69
def test_midi_c5():
c5 = Tone.from_string("C5", system="western")
assert c5.midi == 72
def test_midi_no_octave():
c = Tone(name="C")
assert c.midi is None
def test_midi_chromatic_sequence():
"""MIDI numbers should increment by 1 per semitone."""
c4 = Tone.from_string("C4", system="western")
for i in range(12):
assert (c4 + i).midi == 60 + i
# ── Transpose ──────────────────────────────────────────────────────────────
def test_tone_transpose():
c4 = Tone.from_string("C4", system="western")
assert c4.transpose(7).name == "G"
def test_chord_transpose():
c_maj = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
g_maj = c_maj.transpose(7)
assert g_maj.identify() == "G major"
def test_chord_transpose_preserves_quality():
am = Chord(tones=[
Tone.from_string("A4", system="western"),
Tone.from_string("C5", system="western"),
Tone.from_string("E5", system="western"),
])
dm = am.transpose(5)
assert dm.identify() == "D minor"
def test_chord_transpose_negative():
g_maj = Chord(tones=[
Tone.from_string("G4", system="western"),
Tone.from_string("B4", system="western"),
Tone.from_string("D5", system="western"),
])
c_maj = g_maj.transpose(-7)
assert c_maj.identify() == "C major"
def test_scale_transpose():
c_major = TonedScale(tonic="C4")["major"]
d_major = c_major.transpose(2)
assert d_major.note_names == ["D", "E", "F#", "G", "A", "B", "C#", "D"]
def test_scale_transpose_negative():
d_major = TonedScale(tonic="D4")["major"]
c_major = d_major.transpose(-2)
assert c_major.note_names == ["C", "D", "E", "F", "G", "A", "B", "C"]
# ── Chord root and quality ─────────────────────────────────────────────────
def test_chord_root():
c_maj = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
assert c_maj.root.name == "C"
def test_chord_quality():
c_maj = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
assert c_maj.quality == "major"
def test_chord_quality_minor():
am = Chord(tones=[
Tone.from_string("A4", system="western"),
Tone.from_string("C5", system="western"),
Tone.from_string("E5", system="western"),
])
assert am.quality == "minor"
def test_chord_root_unknown():
chord = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("D4", system="western"),
])
assert chord.root is None
assert chord.quality is None
# ── Tone.from_frequency ────────────────────────────────────────────────────
def test_from_frequency_a4():
t = Tone.from_frequency(440)
assert t.name == "A"
assert t.octave == 4
def test_from_frequency_c4():
t = Tone.from_frequency(261.63)
assert t.name == "C"
assert t.octave == 4
def test_from_frequency_a5():
t = Tone.from_frequency(880)
assert t.name == "A"
assert t.octave == 5
def test_from_frequency_a3():
t = Tone.from_frequency(220)
assert t.name == "A"
assert t.octave == 3
def test_from_frequency_roundtrip():
"""from_frequency(tone.frequency) should return the same note."""
for note in ["C4", "E4", "G4", "A4", "B3", "F#5"]:
t = Tone.from_string(note, system="western")
recovered = Tone.from_frequency(t.frequency)
assert recovered.name == t.name
assert recovered.octave == t.octave
# ── Tone.from_midi ─────────────────────────────────────────────────────────
def test_from_midi_c4():
assert Tone.from_midi(60).name == "C"
assert Tone.from_midi(60).octave == 4
def test_from_midi_a4():
assert Tone.from_midi(69).name == "A"
assert Tone.from_midi(69).octave == 4
def test_from_midi_roundtrip():
"""from_midi(tone.midi) should return the same note."""
for midi in [48, 60, 69, 72, 84]:
t = Tone.from_midi(midi)
assert t.midi == midi
# ── Chord.inversion ────────────────────────────────────────────────────────
def test_chord_inversion_0():
c = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
inv0 = c.inversion(0)
assert [t.full_name for t in inv0] == ["C4", "E4", "G4"]
def test_chord_inversion_1():
c = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
inv1 = c.inversion(1)
assert inv1.tones[0].name == "E"
assert inv1.tones[-1].name == "C"
assert inv1.tones[-1].octave == 5
def test_chord_inversion_2():
c = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
inv2 = c.inversion(2)
assert inv2.tones[0].name == "G"
assert inv2.tones[0].octave == 4
def test_chord_inversion_preserves_identity():
c = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
assert c.inversion(1).identify() == "C major"
assert c.inversion(2).identify() == "C major"
# ── Scale.seventh ──────────────────────────────────────────────────────────
def test_scale_seventh_I():
major = TonedScale(tonic="C4")["major"]
assert major.seventh(0).identify() == "C major 7th"
def test_scale_seventh_ii():
major = TonedScale(tonic="C4")["major"]
assert major.seventh(1).identify() == "D minor 7th"
def test_scale_seventh_V():
major = TonedScale(tonic="C4")["major"]
assert major.seventh(4).identify() == "G dominant 7th"
# ── Scale.harmonize ────────────────────────────────────────────────────────
def test_harmonize_c_major():
major = TonedScale(tonic="C4")["major"]
chords = major.harmonize()
assert len(chords) == 7
qualities = [c.identify() for c in chords]
assert qualities == [
"C major", "D minor", "E minor", "F major",
"G major", "A minor", "B diminished",
]
def test_harmonize_len():
minor = TonedScale(tonic="A4")["minor"]
assert len(minor.harmonize()) == 7
# ── Scale.progression ──────────────────────────────────────────────────────
def test_progression_I_IV_V():
major = TonedScale(tonic="C4")["major"]
prog = major.progression("I", "IV", "V")
assert len(prog) == 3
assert prog[0].identify() == "C major"
assert prog[1].identify() == "F major"
assert prog[2].identify() == "G major"
def test_progression_with_seventh():
major = TonedScale(tonic="C4")["major"]
prog = major.progression("I", "V7")
assert prog[0].identify() == "C major"
assert prog[1].identify() == "G dominant 7th"
def test_progression_pop():
major = TonedScale(tonic="G4")["major"]
prog = major.progression("I", "V", "vi", "IV")
assert prog[0].identify() == "G major"
assert prog[3].identify() == "C major"
# ── Key class ───────────────────────────────────────────────────────────────
def test_key_c_major():
k = Key("C", "major")
assert k.note_names == ["C", "D", "E", "F", "G", "A", "B", "C"]
def test_key_repr():
assert repr(Key("C", "major")) == "<Key C major>"
def test_key_chords():
k = Key("C", "major")
assert k.chords == [
"C major", "D minor", "E minor", "F major",
"G major", "A minor", "B diminished",
]
def test_key_seventh_chords():
k = Key("C", "major")
sevenths = k.seventh_chords
assert sevenths[0] == "C major 7th"
assert sevenths[4] == "G dominant 7th"
def test_key_triad():
k = Key("C", "major")
assert k.triad(0).identify() == "C major"
assert k.triad(4).identify() == "G major"
def test_key_seventh():
k = Key("C", "major")
assert k.seventh(4).identify() == "G dominant 7th"
def test_key_progression():
k = Key("G", "major")
prog = k.progression("I", "IV", "V")
assert prog[0].identify() == "G major"
def test_key_relative_major_to_minor():
k = Key("C", "major")
rel = k.relative
assert rel.tonic_name == "A"
assert rel.mode == "minor"
def test_key_relative_minor_to_major():
k = Key("A", "minor")
rel = k.relative
assert rel.tonic_name == "C"
assert rel.mode == "major"
def test_key_parallel():
k = Key("C", "major")
par = k.parallel
assert par.tonic_name == "C"
assert par.mode == "minor"
def test_key_relative_shares_notes():
c = Key("C", "major")
a = c.relative
c_notes = sorted(set(c.note_names))
a_notes = sorted(set(a.note_names))
assert c_notes == a_notes
# ── Note alias ──────────────────────────────────────────────────────────────
def test_note_is_tone():
assert Note is Tone
def test_note_from_string():
n = Note.from_string("C4", system="western")
assert n.name == "C"
assert n.frequency == Tone.from_string("C4", system="western").frequency
# ── Chord.from_name ────────────────────────────────────────────────────────
def test_chord_from_name_c():
c = Chord.from_name("C")
assert c.identify() == "C major"
def test_chord_from_name_am7():
am7 = Chord.from_name("Am7")
assert am7.identify() == "A minor 7th"
def test_chord_from_name_g7():
g7 = Chord.from_name("G7")
assert g7.identify() == "G dominant 7th"
def test_chord_from_name_unknown_raises():
with pytest.raises(ValueError):
Chord.from_name("Xmaj13")
def test_chord_str():
c = Chord.from_name("C")
assert str(c) == "C major"
# ── Interval constants ─────────────────────────────────────────────────────
def test_interval_constants():
from pytheory import Interval
assert Interval.PERFECT_FIFTH == 7
assert Interval.MAJOR_THIRD == 4
assert Interval.OCTAVE == 12
assert Interval.TRITONE == 6
def test_interval_with_tone():
from pytheory import Interval
c4 = Tone.from_string("C4", system="western")
assert (c4 + Interval.PERFECT_FIFTH).name == "G"
assert (c4 + Interval.MAJOR_THIRD).name == "E"
assert (c4 + Interval.MINOR_THIRD).name == "D#"
# ── Enharmonic ─────────────────────────────────────────────────────────────
def test_enharmonic_sharp():
cs = Tone.from_string("C#4", system="western")
assert cs.enharmonic == "Db"
def test_enharmonic_natural():
c = Tone.from_string("C4", system="western")
assert c.enharmonic is None
# ── PROGRESSIONS ───────────────────────────────────────────────────────────
def test_progressions_dict():
from pytheory import PROGRESSIONS
assert "I-V-vi-IV" in PROGRESSIONS
assert "12-bar blues" in PROGRESSIONS
assert len(PROGRESSIONS["12-bar blues"]) == 12
def test_progressions_with_key():
from pytheory import PROGRESSIONS
k = Key("C", "major")
pop = k.progression(*PROGRESSIONS["I-V-vi-IV"])
assert len(pop) == 4
assert pop[0].identify() == "C major"
# ── Key.__str__ ────────────────────────────────────────────────────────────
def test_key_str():
assert str(Key("C", "major")) == "C major"
assert str(Key("A", "minor")) == "A minor"
# ── Key.detect ─────────────────────────────────────────────────────────────
def test_key_detect_c_major():
k = Key.detect("C", "D", "E", "F", "G", "A", "B")
assert k.tonic_name == "C"
assert k.mode == "major"
def test_key_detect_a_major():
k = Key.detect("A", "B", "C#", "D", "E", "F#", "G#")
assert k.tonic_name == "A"
assert k.mode == "major"
def test_key_detect_prefers_major():
"""When major and minor match equally, prefer major."""
k = Key.detect("C", "E", "G")
assert k.mode == "major"
def test_key_detect_partial():
"""Should work with a subset of scale notes."""
k = Key.detect("C", "E", "G")
assert k.tonic_name == "C"
def test_key_detect_empty():
assert Key.detect() is None
# ── Tone properties ────────────────────────────────────────────────────────
def test_tone_is_natural():
assert Tone.from_string("C4").is_natural is True
assert Tone.from_string("B4").is_natural is True
def test_tone_is_sharp():
assert Tone.from_string("C#4").is_sharp is True
assert Tone.from_string("C4").is_sharp is False
def test_tone_is_flat():
t = Tone(name="Bb", octave=4)
assert t.is_flat is True
assert Tone.from_string("C4").is_flat is False
# B natural should NOT be detected as flat
assert Tone.from_string("B4").is_flat is False
# ── Fretboard.INSTRUMENTS ──────────────────────────────────────────────────
def test_instruments_list():
assert len(Fretboard.INSTRUMENTS) == 25
assert "guitar" in Fretboard.INSTRUMENTS
assert "sitar" in Fretboard.INSTRUMENTS
assert "keyboard" in Fretboard.INSTRUMENTS
# ── Chord.from_tones ──────────────────────────────────────────────────────
def test_chord_from_tones():
c = Chord.from_tones("C", "E", "G")
assert c.identify() == "C major"
def test_chord_from_tones_minor():
am = Chord.from_tones("A", "C", "E")
assert am.identify() == "A minor"
def test_chord_from_tones_octave():
c = Chord.from_tones("C", "E", "G", octave=3)
assert c.tones[0].octave == 3
# ── Tab output ────────────────────────────────────────────────────────────
def test_tab_output():
from pytheory.charts import CHARTS
fb = Fretboard.guitar()
tab = CHARTS["western"]["C"].tab(fretboard=fb)
assert "C" in tab
assert "|--" in tab
lines = tab.strip().split("\n")
assert len(lines) == 7 # chord name + 6 strings
def test_tab_muted_string():
from pytheory.charts import CHARTS
fb = Fretboard.guitar()
tab = CHARTS["western"]["F"].tab(fretboard=fb)
# F chord may have muted strings shown as 'x'
assert "|--" in tab
# ── Scale.detect ──────────────────────────────────────────────────────────
def test_scale_detect_c_major():
from pytheory.scales import Scale
result = Scale.detect("C", "D", "E", "F", "G", "A", "B")
assert result[0] == "C"
assert result[1] == "major"
assert result[2] == 7
def test_scale_detect_g_major():
from pytheory.scales import Scale
result = Scale.detect("C", "D", "E", "F#", "G", "A", "B")
assert result[0] == "G"
assert result[1] == "major"
def test_scale_detect_none():
from pytheory.scales import Scale
assert Scale.detect() is None
# ── Nashville numbers ────────────────────────────────────────────────────
def test_nashville_1_4_5():
k = Key("C", "major")
prog = k.nashville(1, 4, 5)
assert prog[0].identify() == "C major"
assert prog[1].identify() == "F major"
assert prog[2].identify() == "G major"
def test_nashville_with_seventh():
k = Key("G", "major")
prog = k.nashville(1, 4, "57")
assert prog[2].identify() == "D dominant 7th"
def test_nashville_on_scale():
scale = TonedScale(tonic="C4")["major"]
prog = scale.nashville(1, 5, 1)
assert prog[0].identify() == "C major"
assert prog[1].identify() == "G major"
# ── Capo ───────────────────────────────────────────────────────────────────
def test_guitar_capo():
fb = Fretboard.guitar(capo=2)
assert fb.tones[0].name == "F#"
assert len(fb) == 6
def test_capo_method():
fb = Fretboard.guitar()
fb3 = fb.capo(3)
assert fb3.tones[0].name == "G"
def test_capo_zero():
fb = Fretboard.guitar(capo=0)
assert fb.tones[0].name == "E"
# ── Chord.__add__ ─────────────────────────────────────────────────────────
def test_chord_add():
c = Chord.from_tones("C", "E", "G")
bass = Chord.from_tones("G", octave=2)
merged = c + bass
assert len(merged) == 4
def test_chord_add_preserves_tones():
a = Chord.from_tones("C", "E")
b = Chord.from_tones("G", "B")
merged = a + b
names = [t.name for t in merged]
assert "C" in names and "G" in names
# ── Tritone substitution ──────────────────────────────────────────────────
def test_tritone_sub():
g7 = Chord.from_name("G7")
sub = g7.tritone_sub()
assert sub.identify() == "C# dominant 7th"
def test_tritone_sub_is_6_semitones():
c = Chord.from_tones("C", "E", "G")
sub = c.tritone_sub()
assert sub.root.name == "F#"
# ── Secondary dominants ──────────────────────────────────────────────────
def test_secondary_dominant_V_of_V():
k = Key("C", "major")
vv = k.secondary_dominant(5)
assert vv.identify() == "D dominant 7th"
def test_secondary_dominant_V_of_ii():
k = Key("C", "major")
assert k.secondary_dominant(2).identify() == "A dominant 7th"
def test_secondary_dominant_V_of_vi():
k = Key("C", "major")
assert k.secondary_dominant(6).identify() == "E dominant 7th"
# ── Key.all_keys ─────────────────────────────────────────────────────────
def test_all_keys():
keys = Key.all_keys()
assert len(keys) == 24
majors = [k for k in keys if k.mode == "major"]
minors = [k for k in keys if k.mode == "minor"]
assert len(majors) == 12
assert len(minors) == 12
# ── More progressions ───────────────────────────────────────────────────
def test_progressions_count():
from pytheory.scales import PROGRESSIONS
assert len(PROGRESSIONS) >= 14
def test_pachelbel_progression():
from pytheory.scales import PROGRESSIONS
k = Key("C", "major")
prog = k.progression(*PROGRESSIONS["Pachelbel"])
assert len(prog) == 8
assert prog[0].identify() == "C major"
# ── Tone.letter ────────────────────────────────────────────────────────────
def test_tone_letter_natural():
assert Tone.from_string("C4").letter == "C"
def test_tone_letter_sharp():
assert Tone.from_string("C#4").letter == "C"
def test_tone_letter_flat():
assert Tone(name="Bb", octave=4).letter == "B"
# ── Key.signature ──────────────────────────────────────────────────────────
def test_key_signature_c_major():
sig = Key("C", "major").signature
assert sig["sharps"] == 0
assert sig["flats"] == 0
def test_key_signature_g_major():
sig = Key("G", "major").signature
assert sig["sharps"] == 1
assert sig["accidentals"] == ["F#"]
def test_key_signature_d_major():
sig = Key("D", "major").signature
assert sig["sharps"] == 2
# ── Chord.from_intervals ──────────────────────────────────────────────────
def test_chord_from_intervals_major():
assert Chord.from_intervals("C", 4, 7).identify() == "C major"
def test_chord_from_intervals_dom7():
assert Chord.from_intervals("G", 4, 7, 10).identify() == "G dominant 7th"
# ── Chord.from_midi_message ──────────────────────────────────────────────
def test_chord_from_midi_message():
c = Chord.from_midi_message(60, 64, 67)
assert c.identify() == "C major"
# ── Chord.add_tone / remove_tone ──────────────────────────────────────────
def test_chord_add_tone():
c = Chord.from_tones("C", "E", "G")
cmaj7 = c.add_tone(Tone("B", octave=4))
assert cmaj7.identify() == "C major 7th"
def test_chord_remove_tone():
cmaj7 = Chord.from_name("Cmaj7")
c = cmaj7.remove_tone("B")
assert c.identify() == "C major"
# ── analyze_progression ──────────────────────────────────────────────────
def test_analyze_progression():
from pytheory import analyze_progression
prog = [Chord.from_name("C"), Chord.from_name("Am"),
Chord.from_name("F"), Chord.from_name("G")]
assert analyze_progression(prog, key="C") == ["I", "vi", "IV", "V"]
# ── Key.borrowed_chords ─────────────────────────────────────────────────
def test_borrowed_chords():
borrowed = Key("C", "major").borrowed_chords
assert len(borrowed) > 0
# ── Key.random_progression ──────────────────────────────────────────────
def test_random_progression():
prog = Key("C", "major").random_progression(4)
assert len(prog) == 4
# ── Fretboard.scale_diagram ────────────────────────────────────────────
def test_scale_diagram():
fb = Fretboard.guitar()
scale = TonedScale(tonic="C4")["major"]
diagram = fb.scale_diagram(scale, frets=5)
assert "E|" in diagram
lines = diagram.strip().split("\n")
assert len(lines) == 7
# ── Coverage gap tests ─────────────────────────────────────────────────────
def test_tone_init_octave_parsed_from_name():
"""Tone('C4') should parse octave from name string."""
t = Tone("C4")
assert t.octave == 4
assert t.name == "C"
def test_tone_enharmonic_from_alt_names_direct():
t = Tone(name="C#", alt_names="Db", octave=4)
assert t.enharmonic == "Db"
def test_tone_sub_not_implemented():
t = Tone("C4")
result = t.__sub__(3.5)
assert result is NotImplemented
def test_tone_lt_not_implemented():
assert Tone("C4").__lt__("not a tone") is NotImplemented
def test_tone_le_not_implemented():
assert Tone("C4").__le__("not a tone") is NotImplemented
def test_tone_gt_not_implemented():
assert Tone("C4").__gt__("not a tone") is NotImplemented
def test_tone_ge_not_implemented():
assert Tone("C4").__ge__("not a tone") is NotImplemented
def test_tone_from_frequency_negative_raises():
with pytest.raises(ValueError, match="positive"):
Tone.from_frequency(-100)
def test_tone_interval_compound_2_octaves():
c4 = Tone.from_string("C4", system="western")
e6 = c4 + 28 # 2 octaves + major 3rd
assert "2 octaves" in c4.interval_to(e6)
def test_tone_circle_of_fifths_returns_12():
c = Tone.from_string("C4", system="western")
assert len(c.circle_of_fifths()) == 12
def test_tone_circle_of_fourths_returns_12():
c = Tone.from_string("C4", system="western")
assert len(c.circle_of_fourths()) == 12
def test_chord_repr_unidentified():
"""Chord with no known pattern should show raw tones in repr."""
c = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("D4", system="western"),
])
assert "tones=" in repr(c)
def test_chord_str_unidentified():
c = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("D4", system="western"),
])
assert "C4" in str(c)
def test_chord_add_not_implemented():
c = Chord.from_tones("C", "E", "G")
assert c.__add__("not a chord") is NotImplemented
def test_chord_identify_returns_none_for_unknown():
c = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("C#4", system="western"),
Tone.from_string("D4", system="western"),
])
assert c.identify() is None
def test_chord_voice_leading_different_sizes():
"""Voice leading should pad shorter chord."""
c3 = Chord.from_tones("C", "E", "G")
c4 = Chord.from_intervals("C", 4, 7, 10)
vl = c3.voice_leading(c4)
assert len(vl) == 4 # padded to match
def test_chord_analyze_with_tone_key():
"""analyze() should accept a Tone as key_tonic."""
c = Chord.from_tones("C", "E", "G")
key_tone = Tone.from_string("C4", system="western")
assert c.analyze(key_tone) == "I"
def test_chord_analyze_unknown_chord():
c = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("D4", system="western"),
])
assert c.analyze("C") is None
def test_chord_analyze_diminished():
b_dim = Chord.from_intervals("B", 3, 6)
result = b_dim.analyze("C")
assert "dim" in result
def test_chord_analyze_augmented():
c_aug = Chord.from_intervals("C", 4, 8)
result = c_aug.analyze("C")
assert "+" in result
def test_chord_analyze_9th():
c9 = Chord.from_intervals("C", 2, 4, 7, 10)
result = c9.analyze("C")
assert "9" in result
def test_scale_with_system_object():
"""Scale created with system object instead of string."""
from pytheory.scales import Scale
system = SYSTEMS["western"]
s = Scale(tones=(Tone("C", octave=4), Tone("D", octave=4)), system=system)
assert s.system == system
def test_scale_degree_by_mode_name():
major = TonedScale(tonic="C4")["major"]
# Access by mode name should work via degree lookup
tone = major.degree("ionian")
assert tone is not None
def test_scale_getitem_raises():
major = TonedScale(tonic="C4")["major"]
with pytest.raises(KeyError):
major["nonexistent_degree"]
def test_key_with_string_system():
k = Key("C", "major", system="western")
assert k.note_names[0] == "C"
def test_key_detect_returns_none_empty():
assert Key.detect() is None
def test_key_signature_flat_key():
"""F major has one flat (Bb)."""
# F major scale: F G A Bb C D E
# But our system uses sharps, so Bb = A#
sig = Key("F", "major").signature
# The scale uses A# which is sharp notation for Bb
assert sig["sharps"] + sig["flats"] >= 0 # at least runs
def test_key_borrowed_chords_minor():
"""Minor key should borrow from parallel major."""
borrowed = Key("A", "minor").borrowed_chords
assert len(borrowed) > 0
def test_key_parallel_returns_none_for_other_modes():
"""Parallel should return None for non-major/minor modes."""
k = Key("C", "major")
k.mode = "lydian" # force non-standard mode
assert k.parallel is None
def test_key_relative_returns_none_for_other_modes():
k = Key("C", "major")
k.mode = "lydian"
assert k.relative is None
def test_toned_scale_with_string_system():
ts = TonedScale(tonic="Do4", system="arabic")
assert "ajam" in ts.scales
def test_fretboard_fingering_method():
"""Fretboard.fingering should return a Chord."""
fb = Fretboard.guitar()
result = fb.fingering(0, 0, 0, 0, 0, 0)
assert len(result) == 6
def test_charts_muted_string():
"""A chord with no valid fret gets -1 → None."""
from pytheory.charts import NamedChord
nc = NamedChord(tone_name="C", quality="")
fixed = nc.fix_fingering((0, -1, 2))
assert fixed == (0, None, 2)
def test_fretboard_chord_method():
"""Fretboard.chord() looks up a chord by name."""
fb = Fretboard.guitar()
f = fb.chord("G")
assert f.identify() == "G major"
assert len(f) == 6
def test_fretboard_chord_system_kwarg():
"""Fretboard.chord() accepts a system keyword argument."""
fb = Fretboard.guitar()
f = fb.chord("Am", system="western")
assert f.identify() == "A minor"
def test_fretboard_tab_method():
"""Fretboard.tab() returns ASCII tablature."""
fb = Fretboard.guitar()
tab = fb.tab("C")
assert "C major" in tab
assert "e|" in tab
assert "E|" in tab
@pytest.mark.slow
def test_fretboard_chart_method():
"""Fretboard.chart() generates all fingerings."""
fb = Fretboard.guitar()
chart = fb.chart()
assert "C" in chart
assert "Am7" in chart
assert chart["C"].identify() == "C major"
def test_fingering_tab_method():
"""Fingering.tab() renders ASCII tablature."""
fb = Fretboard.guitar()
f = fb.chord("Em")
tab = f.tab()
assert "E minor" in tab
assert "e|" in tab
# ── Flat note support ─────────────────────────────────────────────────────────
def test_flat_tone_from_string():
db = Tone.from_string("Db4", system="western")
assert db.name == "Db"
assert db.octave == 4
def test_flat_tone_frequency_matches_sharp():
db = Tone.from_string("Db4", system="western")
cs = Tone.from_string("C#4", system="western")
assert db.frequency == cs.frequency
def test_flat_tone_frequency_all_enharmonics():
pairs = [("Bb3", "A#3"), ("Eb4", "D#4"), ("Gb4", "F#4"), ("Ab4", "G#4")]
for flat, sharp in pairs:
f = Tone.from_string(flat, system="western").frequency
s = Tone.from_string(sharp, system="western").frequency
assert f == s, f"{flat} != {sharp}"
def test_flat_tone_arithmetic():
db = Tone.from_string("Db4", system="western")
result = db + 2
assert result.name == "D#"
assert result.octave == 4
def test_flat_tone_interval():
c4 = Tone.from_string("C4", system="western")
db4 = Tone.from_string("Db4", system="western")
assert db4 - c4 == 1
def test_flat_tone_exists():
db = Tone.from_string("Db4", system="western")
assert db.exists is True
def test_flat_tone_index_resolves():
db = Tone.from_string("Db4", system="western")
cs = Tone.from_string("C#4", system="western")
assert db._index == cs._index
def test_flat_chord_from_tones():
chord = Chord.from_tones("Db", "F", "Ab")
assert chord.identify() == "Db major"
def test_flat_chord_from_tones_minor():
chord = Chord.from_tones("Bb", "Db", "F")
assert chord.identify() == "Bb minor"
def test_flat_chord_from_tones_seventh():
chord = Chord.from_tones("Eb", "G", "Bb", "Db")
assert chord.identify() == "Eb dominant 7th"
def test_system_resolve_name_sharp():
assert SYSTEMS["western"].resolve_name("C#") == "C#"
def test_system_resolve_name_flat():
assert SYSTEMS["western"].resolve_name("Db") == "C#"
def test_system_resolve_name_natural():
assert SYSTEMS["western"].resolve_name("C") == "C"
def test_system_resolve_name_unknown():
assert SYSTEMS["western"].resolve_name("X") is None
# ── CLI tests ─────────────────────────────────────────────────────────────────
def test_cli_tone(capsys):
from pytheory.cli import cmd_tone
import argparse
args = argparse.Namespace(note="A4", temperament="equal")
cmd_tone(args)
out = capsys.readouterr().out
assert "440.00" in out
assert "A4" in out
assert "MIDI" in out
def test_cli_tone_pythagorean(capsys):
from pytheory.cli import cmd_tone
import argparse
args = argparse.Namespace(note="C5", temperament="pythagorean")
cmd_tone(args)
out = capsys.readouterr().out
assert "Equal temp" in out
assert "cents" in out
def test_cli_scale(capsys):
from pytheory.cli import cmd_scale
import argparse
args = argparse.Namespace(tonic="C", mode="major", system="western")
cmd_scale(args)
out = capsys.readouterr().out
assert "C D E F G A B C" in out
def test_cli_chord(capsys):
from pytheory.cli import cmd_chord
import argparse
args = argparse.Namespace(notes=["C", "E", "G"])
cmd_chord(args)
out = capsys.readouterr().out
assert "C major" in out
assert "Harmony" in out
assert "Tension" in out
def test_cli_key(capsys):
from pytheory.cli import cmd_key
import argparse
args = argparse.Namespace(tonic="G", mode="major")
cmd_key(args)
out = capsys.readouterr().out
assert "G major" in out
assert "Signature" in out
assert "Relative" in out
def test_cli_fingering(capsys):
from pytheory.cli import cmd_fingering
import argparse
args = argparse.Namespace(chord="Am", capo=0)
cmd_fingering(args)
out = capsys.readouterr().out
assert "Am" in out
assert "|--" in out
def test_cli_progression(capsys):
from pytheory.cli import cmd_progression
import argparse
args = argparse.Namespace(tonic="C", mode="major", numerals=["I", "V", "vi", "IV"])
cmd_progression(args)
out = capsys.readouterr().out
assert "C major" in out
assert "I → V → vi → IV" in out
def test_cli_detect(capsys):
from pytheory.cli import cmd_detect
import argparse
args = argparse.Namespace(notes=["C", "E", "G", "A", "D"])
cmd_detect(args)
out = capsys.readouterr().out
assert "C major" in out
def test_cli_detect_no_match(capsys):
from pytheory.cli import cmd_detect
import argparse
args = argparse.Namespace(notes=[])
cmd_detect(args)
out = capsys.readouterr().out
assert "Could not detect" in out
def test_cli_main_no_args(capsys):
from pytheory.cli import main
import sys
old_argv = sys.argv
sys.argv = ["pytheory"]
try:
main()
except SystemExit:
pass
sys.argv = old_argv
# ── Play module tests ─────────────────────────────────────────────────────────
@needs_portaudio
def test_play_render():
"""_render produces a numpy array of the right length."""
from pytheory.play import _render, Synth, SAMPLE_RATE
tone = Tone.from_string("A4", system="western")
samples = _render(tone, synth=Synth.SINE, t=500)
expected = int(SAMPLE_RATE * 500 / 1000)
assert len(samples) == expected
@needs_portaudio
def test_play_render_chord():
from pytheory.play import _render, Synth
chord = Chord.from_tones("C", "E", "G")
samples = _render(chord, synth=Synth.SINE, t=200)
assert len(samples) > 0
@needs_portaudio
def test_play_render_all_synths():
from pytheory.play import _render, Synth
tone = Tone.from_string("C4", system="western")
for synth in Synth:
samples = _render(tone, synth=synth, t=100)
assert len(samples) > 0
@needs_portaudio
def test_play_save(tmp_path):
"""save() writes a valid WAV file."""
from pytheory.play import save, Synth
path = tmp_path / "test.wav"
tone = Tone.from_string("A4", system="western")
save(tone, str(path), synth=Synth.SINE, t=200)
assert path.exists()
assert path.stat().st_size > 44 # WAV header is 44 bytes
@needs_portaudio
def test_play_save_chord(tmp_path):
from pytheory.play import save
path = tmp_path / "chord.wav"
chord = Chord.from_tones("C", "E", "G")
save(chord, str(path), t=200)
assert path.exists()
# ── Chord.symbol ────────────────────────────────────────────────────────────
def test_chord_symbol_major():
c = Chord.from_tones("C", "E", "G")
assert c.symbol == "C"
def test_chord_symbol_minor():
c = Chord.from_tones("A", "C", "E")
assert c.symbol == "Am"
def test_chord_symbol_dominant_7th():
c = Chord.from_intervals("G", 4, 7, 10)
assert c.symbol == "G7"
def test_chord_symbol_major_7th():
c = Chord.from_intervals("C", 4, 7, 11)
assert c.symbol == "Cmaj7"
def test_chord_symbol_minor_7th():
c = Chord.from_intervals("D", 3, 7, 10)
assert c.symbol == "Dm7"
def test_chord_symbol_diminished():
c = Chord.from_intervals("B", 3, 6)
assert c.symbol == "Bdim"
def test_chord_symbol_augmented():
c = Chord.from_intervals("C", 4, 8)
assert c.symbol == "Caug"
def test_chord_symbol_sus2():
c = Chord.from_intervals("C", 2, 7)
assert c.symbol == "Csus2"
def test_chord_symbol_sus4():
c = Chord.from_intervals("C", 5, 7)
assert c.symbol == "Csus4"
def test_chord_symbol_power():
c = Chord.from_intervals("C", 7)
assert c.symbol == "C5"
def test_chord_symbol_half_diminished():
c = Chord.from_intervals("B", 3, 6, 10)
assert c.symbol == "Bm7b5"
def test_chord_symbol_dim7():
c = Chord.from_intervals("B", 3, 6, 9)
assert c.symbol == "Bdim7"
def test_chord_symbol_unidentifiable():
c = Chord.from_intervals("C", 1)
assert c.symbol is None
# ── Key.common_progressions ─────────────────────────────────────────────────
def test_common_progressions_returns_dict():
key = Key("C", "major")
progs = key.common_progressions()
assert isinstance(progs, dict)
assert len(progs) > 0
def test_common_progressions_contains_known():
key = Key("C", "major")
progs = key.common_progressions()
assert "I-V-vi-IV" in progs
assert "12-bar blues" in progs
assert "ii-V-I" in progs
def test_common_progressions_chords_are_correct():
key = Key("G", "major")
progs = key.common_progressions()
chords = progs["I-IV-V-I"]
symbols = [c.symbol for c in chords]
assert symbols == ["G", "C", "D", "G"]
def test_common_progressions_i_v_vi_iv():
key = Key("C", "major")
progs = key.common_progressions()
chords = progs["I-V-vi-IV"]
symbols = [c.symbol for c in chords]
assert symbols == ["C", "G", "Am", "F"]
# ── CLI: modes, circle, progressions ────────────────────────────────────────
def test_cli_modes(capsys):
from pytheory.cli import cmd_modes
import argparse
args = argparse.Namespace(tonic="C", system="western")
cmd_modes(args)
out = capsys.readouterr().out
assert "ionian" in out
assert "dorian" in out
assert "locrian" in out
def test_cli_circle(capsys):
from pytheory.cli import cmd_circle
import argparse
args = argparse.Namespace(tonic="C")
cmd_circle(args)
out = capsys.readouterr().out
assert "Circle of fifths" in out
assert "Circle of fourths" in out
assert "G" in out
assert "F" in out
def test_cli_progressions(capsys):
from pytheory.cli import cmd_progressions
import argparse
args = argparse.Namespace(tonic="C", mode="major")
cmd_progressions(args)
out = capsys.readouterr().out
assert "I-V-vi-IV" in out
assert "C" in out
# ── Chord.from_symbol ───────────────────────────────────────────────────────
def test_from_symbol_major():
c = Chord.from_symbol("C")
assert c.identify() == "C major"
def test_from_symbol_minor():
c = Chord.from_symbol("Am")
assert c.identify() == "A minor"
def test_from_symbol_dominant_7th():
c = Chord.from_symbol("G7")
assert c.identify() == "G dominant 7th"
def test_from_symbol_major_7th():
c = Chord.from_symbol("Cmaj7")
assert c.identify() == "C major 7th"
def test_from_symbol_minor_7th():
c = Chord.from_symbol("Dm7")
assert c.identify() == "D minor 7th"
def test_from_symbol_diminished():
c = Chord.from_symbol("Bdim")
assert c.identify() == "B diminished"
def test_from_symbol_augmented():
c = Chord.from_symbol("Caug")
assert c.identify() == "C augmented"
def test_from_symbol_sus4():
c = Chord.from_symbol("Csus4")
assert c.identify() == "C sus4"
def test_from_symbol_sus2():
c = Chord.from_symbol("Dsus2")
assert c.identify() == "D sus2"
def test_from_symbol_power():
c = Chord.from_symbol("C5")
assert c.identify() == "C power"
def test_from_symbol_half_diminished():
c = Chord.from_symbol("Bm7b5")
assert c.identify() == "B half-diminished 7th"
def test_from_symbol_flat_root():
c = Chord.from_symbol("Bbmaj7")
assert c.symbol == "Bbmaj7"
def test_from_symbol_sharp_root():
c = Chord.from_symbol("F#m")
assert c.identify() == "F# minor"
def test_from_symbol_dim7():
c = Chord.from_symbol("Cdim7")
assert c.identify() == "C diminished 7th"
def test_from_symbol_9th():
c = Chord.from_symbol("G9")
assert c.identify() == "G dominant 9th"
def test_from_symbol_roundtrip():
"""from_symbol → symbol should round-trip."""
for sym in ["C", "Am", "G7", "Dmaj7", "Em7", "Bdim", "Fsus4"]:
c = Chord.from_symbol(sym)
assert c.symbol == sym, f"Round-trip failed for {sym}: got {c.symbol}"
def test_from_symbol_invalid_raises():
with pytest.raises(ValueError):
Chord.from_symbol("Xmaj7")
def test_from_symbol_unknown_quality_raises():
with pytest.raises(ValueError):
Chord.from_symbol("Czzz")
# ── Tone.cents_difference ──────────────────────────────────────────────────
def test_cents_semitone():
a4 = Tone.from_string("A4", system="western")
bb4 = a4 + 1
cents = a4.cents_difference(bb4)
assert abs(cents - 100.0) < 0.01
def test_cents_octave():
a4 = Tone.from_string("A4", system="western")
a5 = Tone.from_string("A5", system="western")
cents = a4.cents_difference(a5)
assert abs(cents - 1200.0) < 0.01
def test_cents_unison():
a4 = Tone.from_string("A4", system="western")
assert abs(a4.cents_difference(a4)) < 0.01
def test_cents_negative():
a4 = Tone.from_string("A4", system="western")
g4 = a4 - 2
cents = a4.cents_difference(g4)
assert cents < 0
def test_cents_fifth():
c4 = Tone.from_string("C4", system="western")
g4 = c4 + 7
cents = c4.cents_difference(g4)
assert abs(cents - 700.0) < 0.01
# ── Key.pivot_chords ───────────────────────────────────────────────────────
def test_pivot_chords_closely_related():
c = Key("C", "major")
g = Key("G", "major")
pivots = c.pivot_chords(g)
assert len(pivots) > 0
assert "G major" in pivots
assert "E minor" in pivots
def test_pivot_chords_same_key():
c = Key("C", "major")
pivots = c.pivot_chords(c)
assert set(pivots) == set(c.chords)
def test_pivot_chords_distant_keys():
c = Key("C", "major")
fs = Key("F#", "major")
pivots = c.pivot_chords(fs)
assert len(pivots) < len(c.chords)
# ── Scale.parallel_modes ───────────────────────────────────────────────────
def test_parallel_modes_c_major():
c = TonedScale(tonic="C4")["major"]
modes = c.parallel_modes()
assert "C ionian" in modes
assert "D dorian" in modes
assert "E phrygian" in modes
assert "A aeolian" in modes
assert len(modes) == 7
def test_parallel_modes_share_notes():
c = TonedScale(tonic="C4")["major"]
modes = c.parallel_modes()
c_notes = set(modes["C ionian"][:-1])
for name, notes in modes.items():
assert set(notes[:-1]) == c_notes, f"{name} has different notes"
def test_parallel_modes_g_major():
g = TonedScale(tonic="G4")["major"]
modes = g.parallel_modes()
assert "G ionian" in modes
assert "A dorian" in modes
# ── ADSR envelope ──────────────────────────────────────────────────────────
@needs_portaudio
def test_envelope_enum_presets():
from pytheory.play import Envelope
assert len(Envelope) == 10
for e in Envelope:
a, d, s, r = e.value
assert a >= 0
assert d >= 0
assert 0 <= s <= 1.0
assert r >= 0
@needs_portaudio
def test_envelope_applied_to_render():
from pytheory.play import _render, Envelope
tone = Tone.from_string("A4", system="western")
raw = _render(tone, t=500, envelope=Envelope.NONE)
shaped = _render(tone, t=500, envelope=Envelope.PIANO)
# Shaped signal should start quieter (attack) and end quieter (release)
assert abs(float(shaped[0])) < abs(float(raw[0])) + 1
assert abs(float(shaped[-1])) < abs(float(raw[-1])) + 1
@needs_portaudio
def test_envelope_none_is_raw():
from pytheory.play import _render, Envelope
tone = Tone.from_string("A4", system="western")
raw = _render(tone, t=200, envelope=Envelope.NONE)
# With NONE envelope, first sample should be non-zero (no attack fade)
assert raw.dtype in (numpy.int16, numpy.float32)
@needs_portaudio
def test_all_envelopes_render():
from pytheory.play import _render, Envelope
tone = Tone.from_string("C4", system="western")
for e in Envelope:
samples = _render(tone, t=200, envelope=e)
assert len(samples) > 0
# ── C_INDEX constant ───────────────────────────────────────────────────────
def test_c_index_constant():
from pytheory._statics import C_INDEX
assert C_INDEX == 3
# ── Scale.fitness ──────────────────────────────────────────────────────────
def test_fitness_perfect():
c = TonedScale(tonic="C4")["major"]
assert c.fitness("C", "D", "E", "F", "G") == 1.0
def test_fitness_partial():
c = TonedScale(tonic="C4")["major"]
assert c.fitness("C", "D", "F#", "G") == 0.75
def test_fitness_none():
c = TonedScale(tonic="C4")["major"]
assert c.fitness("C#", "D#", "F#") == 0.0
def test_fitness_empty():
c = TonedScale(tonic="C4")["major"]
assert c.fitness() == 0.0
def test_fitness_single_match():
c = TonedScale(tonic="C4")["major"]
assert c.fitness("C") == 1.0
def test_fitness_single_miss():
c = TonedScale(tonic="C4")["major"]
assert c.fitness("C#") == 0.0
# ── Key.suggest_next ───────────────────────────────────────────────────────
def test_suggest_next_v_resolves_to_i():
key = Key("C", "major")
g_major = key.triad(4) # V
suggestions = key.suggest_next(g_major)
assert len(suggestions) > 0
assert suggestions[0].identify() == "C major" # V → I
def test_suggest_next_ii_goes_to_v():
key = Key("C", "major")
dm = key.triad(1) # ii
suggestions = key.suggest_next(dm)
assert suggestions[0].identify() == "G major" # ii → V
def test_suggest_next_iv():
key = Key("C", "major")
f_major = key.triad(3) # IV
suggestions = key.suggest_next(f_major)
assert suggestions[0].identify() == "G major" # IV → V
def test_suggest_next_returns_chords():
key = Key("G", "major")
for i in range(7):
chord = key.triad(i)
suggestions = key.suggest_next(chord)
assert len(suggestions) > 0
for s in suggestions:
assert s.identify() is not None
# ── Tone.helmholtz ─────────────────────────────────────────────────────────
def test_helmholtz_middle_c():
assert Tone.from_string("C4", system="western").helmholtz == "c"
def test_helmholtz_c3():
assert Tone.from_string("C3", system="western").helmholtz == "C"
def test_helmholtz_c5():
assert Tone.from_string("C5", system="western").helmholtz == "c'"
def test_helmholtz_c6():
assert Tone.from_string("C6", system="western").helmholtz == "c''"
def test_helmholtz_c2():
assert Tone.from_string("C2", system="western").helmholtz == "CC"
def test_helmholtz_a2():
assert Tone.from_string("A2", system="western").helmholtz == "AA"
def test_helmholtz_sharp():
assert Tone.from_string("C#4", system="western").helmholtz == "c#"
def test_helmholtz_sharp_high():
assert Tone.from_string("F#5", system="western").helmholtz == "f#'"
def test_scientific_is_full_name():
t = Tone.from_string("A4", system="western")
assert t.scientific == t.full_name
# ── Chord.slash ────────────────────────────────────────────────────────────
def test_slash_chord():
c = Chord.from_symbol("C")
c_over_g = c.slash("G")
assert len(c_over_g.tones) == 4
assert c_over_g.tones[0].name == "G"
def test_slash_name_different_bass():
c = Chord.from_symbol("C")
c_over_e = c.slash("E")
assert c_over_e.slash_name == "C/E"
def test_slash_name_root_bass():
c = Chord.from_symbol("C")
c_over_c = c.slash("C")
assert c_over_c.slash_name == "C"
def test_slash_chord_custom_octave():
c = Chord.from_symbol("C")
c_over_g2 = c.slash("G", octave=2)
assert c_over_g2.tones[0].octave == 2
# ── Borrowed chord analysis (bVI, bVII, etc.) ─────────────────────────────
def test_analyze_borrowed_bvi():
ab = Chord.from_symbol("Ab")
result = ab.analyze("C", "major")
assert result is not None
assert "b" in result.lower() or "VI" in result
def test_analyze_borrowed_bvii():
bb = Chord.from_symbol("Bb")
result = bb.analyze("C", "major")
assert result is not None
assert "b" in result.lower() or "VII" in result
def test_analyze_diatonic_still_works():
c = Chord.from_symbol("C")
assert c.analyze("C", "major") == "I"
g = Chord.from_symbol("G")
assert g.analyze("C", "major") == "V"
dm = Chord.from_symbol("Dm")
assert dm.analyze("C", "major") == "ii"
# ── Fretboard.scale_diagram with chord highlighting ───────────────────────
def test_scale_diagram_chord_highlight():
fb = Fretboard.guitar()
scale = TonedScale(tonic="A4")["minor"]
am = Chord.from_symbol("Am")
diagram = fb.scale_diagram(scale, frets=5, chord=am)
# Chord tones (A, C, E) should be uppercase
assert "A " in diagram or "A|" in diagram
assert "C " in diagram
assert "E " in diagram
# Non-chord scale tones should be lowercase
assert "d " in diagram or "d|" in diagram
def test_scale_diagram_no_chord_unchanged():
fb = Fretboard.guitar()
scale = TonedScale(tonic="C4")["major"]
diagram = fb.scale_diagram(scale, frets=3)
# Without chord arg, should have normal case
assert "C " in diagram
assert "E " in diagram
# ── MIDI export ────────────────────────────────────────────────────────────
def test_save_midi_tone(tmp_path):
from pytheory.play import save_midi
path = tmp_path / "tone.mid"
tone = Tone.from_string("C4", system="western")
save_midi(tone, str(path))
assert path.exists()
data = path.read_bytes()
assert data[:4] == b'MThd'
def test_save_midi_chord(tmp_path):
from pytheory.play import save_midi
path = tmp_path / "chord.mid"
chord = Chord.from_symbol("Am")
save_midi(chord, str(path))
assert path.exists()
data = path.read_bytes()
assert data[:4] == b'MThd'
def test_save_midi_progression(tmp_path):
from pytheory.play import save_midi
path = tmp_path / "prog.mid"
chords = Key("C", "major").progression("I", "V", "vi", "IV")
save_midi(chords, str(path), t=500, bpm=120)
assert path.exists()
assert path.stat().st_size > 14 # header is 14 bytes
def test_save_midi_with_gap(tmp_path):
from pytheory.play import save_midi
path = tmp_path / "gap.mid"
chords = Key("G", "major").progression("I", "IV", "V", "I")
save_midi(chords, str(path), gap=100)
assert path.exists()
# ══════════════════════════════════════════════════════════════════════════════
# Feature 1: Drop voicings on Chord class
# ══════════════════════════════════════════════════════════════════════════════
def test_close_voicing_c_major():
"""Close voicing packs tones within one octave."""
chord = Chord.from_symbol("C")
inv = chord.inversion(1) # E4, G4, C5
closed = inv.close_voicing()
# Should be identifiable as C major
assert closed.identify() == "C major"
# All tones within one octave of root
root = closed.tones[0]
for t in closed.tones[1:]:
diff = abs(t - root)
assert diff <= 12
def test_close_voicing_seventh():
chord = Chord.from_symbol("Cmaj7").inversion(2)
closed = chord.close_voicing()
assert closed.identify() == "C major 7th"
def test_open_voicing_c_major():
chord = Chord.from_symbol("Cmaj7")
opened = chord.open_voicing()
assert len(opened.tones) == 4
# The spread should be wider than close voicing for 4+ tones
closed = chord.close_voicing()
close_span = abs(closed.tones[-1] - closed.tones[0])
open_span = max(t.pitch() for t in opened.tones) - min(t.pitch() for t in opened.tones)
close_span_hz = max(t.pitch() for t in closed.tones) - min(t.pitch() for t in closed.tones)
assert open_span > close_span_hz
def test_open_voicing_seventh():
chord = Chord.from_symbol("Cmaj7")
opened = chord.open_voicing()
assert len(opened.tones) == 4
def test_drop2_voicing():
chord = Chord.from_symbol("Cmaj7")
d2 = chord.drop2()
assert len(d2.tones) == 4
# The lowest tone should be below the original root
assert d2.tones[0] < chord.tones[0]
def test_drop2_identifies_same():
chord = Chord.from_symbol("Cmaj7")
d2 = chord.drop2()
assert d2.identify() == "C major 7th"
def test_drop3_voicing():
chord = Chord.from_symbol("Cmaj7")
d3 = chord.drop3()
assert len(d3.tones) == 4
# The lowest tone should be below the original root
assert d3.tones[0] < chord.tones[0]
def test_drop3_identifies_same():
chord = Chord.from_symbol("Cmaj7")
d3 = chord.drop3()
assert d3.identify() == "C major 7th"
def test_drop2_small_chord():
"""Drop2 on a single-tone chord returns it unchanged."""
chord = Chord(tones=[Tone.from_string("C4", system="western")])
d2 = chord.drop2()
assert len(d2.tones) == 1
# ══════════════════════════════════════════════════════════════════════════════
# Feature 2: Key.modulation_path(target)
# ══════════════════════════════════════════════════════════════════════════════
def test_modulation_path_close_keys():
"""C major to G major — closely related, should find pivot chord."""
path = Key("C", "major").modulation_path(Key("G", "major"))
assert len(path) == 4 # I, pivot, V, I
assert path[0].identify() == "C major"
assert path[-1].identify() == "G major"
def test_modulation_path_returns_chords():
path = Key("C", "major").modulation_path(Key("F", "major"))
for chord in path:
assert isinstance(chord, Chord)
def test_modulation_path_distant_keys():
"""C major to F# major — distant, may use chromatic approach."""
path = Key("C", "major").modulation_path(Key("F#", "major"))
assert len(path) >= 3
assert path[0].identify() == "C major"
assert path[-1].identify() == "F# major"
def test_modulation_path_same_key():
"""Modulating to the same key should still return a path."""
path = Key("C", "major").modulation_path(Key("C", "major"))
assert len(path) >= 3
assert path[0].identify() == "C major"
assert path[-1].identify() == "C major"
# ══════════════════════════════════════════════════════════════════════════════
# Feature 3: Scale.degree_name(n)
# ══════════════════════════════════════════════════════════════════════════════
def test_degree_name_tonic():
scale = TonedScale(tonic="C4")["major"]
assert scale.degree_name(0) == "tonic"
def test_degree_name_dominant():
scale = TonedScale(tonic="C4")["major"]
assert scale.degree_name(4) == "dominant"
def test_degree_name_leading_tone():
scale = TonedScale(tonic="C4")["major"]
assert scale.degree_name(6) == "leading tone"
def test_degree_name_subtonic_minor():
scale = TonedScale(tonic="C4")["minor"]
assert scale.degree_name(6, minor=True) == "subtonic"
def test_degree_name_all_major():
scale = TonedScale(tonic="C4")["major"]
expected = ["tonic", "supertonic", "mediant", "subdominant",
"dominant", "submediant", "leading tone"]
for i, name in enumerate(expected):
assert scale.degree_name(i) == name
def test_degree_name_out_of_range():
scale = TonedScale(tonic="C4")["major"]
assert scale.degree_name(10) == "degree 10"
# ══════════════════════════════════════════════════════════════════════════════
# Feature 4: Chord.extensions()
# ══════════════════════════════════════════════════════════════════════════════
def test_extensions_c_major_triad():
chord = Chord.from_symbol("C")
exts = chord.extensions()
ext_names = [t.name for t in exts]
# 9th (D) and 13th (A) should be available; 11th (F) is avoid note on major
assert "D" in ext_names
assert "A" in ext_names
def test_extensions_returns_tones():
chord = Chord.from_symbol("C")
exts = chord.extensions()
for t in exts:
assert isinstance(t, Tone)
def test_extensions_with_scale():
scale = TonedScale(tonic="C4")["major"]
chord = Chord.from_symbol("C")
exts = chord.extensions(scale=scale)
ext_names = [t.name for t in exts]
# D, F, A are all in C major scale
assert "D" in ext_names
assert "A" in ext_names
def test_extensions_minor_chord():
chord = Chord.from_symbol("Am")
exts = chord.extensions()
# Should return some extensions
assert len(exts) >= 1
def test_extensions_empty_chord():
chord = Chord(tones=[])
exts = chord.extensions()
assert exts == []
# ══════════════════════════════════════════════════════════════════════════════
# Feature 5: CLI identify command
# ══════════════════════════════════════════════════════════════════════════════
def test_cli_identify_cmaj7(capsys):
from pytheory.cli import cmd_identify
import argparse
args = argparse.Namespace(symbol="Cmaj7")
cmd_identify(args)
out = capsys.readouterr().out
assert "C major 7th" in out
assert "Symbol" in out
assert "Tones" in out
assert "Harmony" in out
def test_cli_identify_am(capsys):
from pytheory.cli import cmd_identify
import argparse
args = argparse.Namespace(symbol="Am")
cmd_identify(args)
out = capsys.readouterr().out
assert "A minor" in out
def test_cli_identify_g7(capsys):
from pytheory.cli import cmd_identify
import argparse
args = argparse.Namespace(symbol="G7")
cmd_identify(args)
out = capsys.readouterr().out
assert "G dominant 7th" in out
assert "Tension" in out
assert "Dissonance" in out
def test_cli_identify_intervals(capsys):
from pytheory.cli import cmd_identify
import argparse
args = argparse.Namespace(symbol="C")
cmd_identify(args)
out = capsys.readouterr().out
assert "Intervals" in out
# ══════════════════════════════════════════════════════════════════════════════
# Feature 6: CLI midi command
# ══════════════════════════════════════════════════════════════════════════════
def test_cli_midi_basic(capsys, tmp_path):
from pytheory.cli import cmd_midi
import argparse
outfile = str(tmp_path / "test.mid")
args = argparse.Namespace(
tonic="C", mode="major", numerals=["I", "V", "vi", "IV"],
output=outfile, bpm=120, duration=500
)
cmd_midi(args)
out = capsys.readouterr().out
assert "C major" in out
assert "Output" in out
import os
assert os.path.exists(outfile)
def test_cli_midi_custom_bpm(capsys, tmp_path):
from pytheory.cli import cmd_midi
import argparse
outfile = str(tmp_path / "test_bpm.mid")
args = argparse.Namespace(
tonic="G", mode="major", numerals=["I", "IV", "V"],
output=outfile, bpm=90, duration=750
)
cmd_midi(args)
out = capsys.readouterr().out
assert "90" in out
def test_cli_midi_file_content(tmp_path):
from pytheory.cli import cmd_midi
import argparse
outfile = str(tmp_path / "content.mid")
args = argparse.Namespace(
tonic="C", mode="major", numerals=["I", "V"],
output=outfile, bpm=120, duration=500
)
cmd_midi(args)
data = (tmp_path / "content.mid").read_bytes()
assert data[:4] == b'MThd'
def test_cli_midi_minor(capsys, tmp_path):
from pytheory.cli import cmd_midi
import argparse
outfile = str(tmp_path / "minor.mid")
args = argparse.Namespace(
tonic="A", mode="minor", numerals=["i", "IV", "V"],
output=outfile, bpm=120, duration=500
)
cmd_midi(args)
out = capsys.readouterr().out
assert "A minor" in out
# ══════════════════════════════════════════════════════════════════════════════
# Feature 7: Tone.solfege property
# ══════════════════════════════════════════════════════════════════════════════
def test_solfege_natural_notes():
expected = {"C": "Do", "D": "Re", "E": "Mi", "F": "Fa",
"G": "Sol", "A": "La", "B": "Ti"}
for note, solf in expected.items():
t = Tone.from_string(f"{note}4", system="western")
assert t.solfege == solf, f"{note} should be {solf}, got {t.solfege}"
def test_solfege_sharps():
expected = {"C#": "Di", "D#": "Ri", "F#": "Fi", "G#": "Si", "A#": "Li"}
for note, solf in expected.items():
t = Tone.from_string(f"{note}4", system="western")
assert t.solfege == solf, f"{note} should be {solf}, got {t.solfege}"
def test_solfege_flats():
expected = {"Db": "Ra", "Eb": "Me", "Gb": "Se", "Ab": "Le", "Bb": "Te"}
for note, solf in expected.items():
t = Tone(name=note, octave=4, system="western")
assert t.solfege == solf, f"{note} should be {solf}, got {t.solfege}"
def test_solfege_no_octave():
t = Tone(name="C", system="western")
assert t.solfege == "Do"
def test_solfege_unknown_raises():
"""A non-standard name should raise ValueError at construction (fixes #39)."""
import pytest
with pytest.raises(ValueError, match="Unknown tone name"):
Tone(name="X", system="western")
# ── Rhythm / Duration system ────────────────────────────────────────────────
from pytheory.rhythm import Duration, TimeSignature, Note as RhythmNote, Rest, Score
def test_duration_values():
assert Duration.WHOLE.value == 4.0
assert Duration.HALF.value == 2.0
assert Duration.QUARTER.value == 1.0
assert Duration.EIGHTH.value == 0.5
assert Duration.SIXTEENTH.value == 0.25
assert Duration.DOTTED_HALF.value == 3.0
assert Duration.DOTTED_QUARTER.value == 1.5
assert abs(Duration.TRIPLET_QUARTER.value - 2 / 3) < 1e-9
def test_duration_arithmetic():
# Multiplication
assert Duration.WHOLE * 2 == 8.0
assert 2 * Duration.HALF == 4.0
assert Duration.QUARTER * 3 == 3.0
# Division
assert Duration.WHOLE / 2 == 2.0
# Addition
assert Duration.HALF + Duration.QUARTER == 3.0
assert Duration.HALF + 1.0 == 3.0
assert 1.0 + Duration.HALF == 3.0
def test_time_signature_from_string_4_4():
ts = TimeSignature.from_string("4/4")
assert ts.beats == 4
assert ts.unit == 4
assert ts.beats_per_measure == 4.0
def test_time_signature_from_string_3_4():
ts = TimeSignature.from_string("3/4")
assert ts.beats == 3
assert ts.unit == 4
assert ts.beats_per_measure == 3.0
def test_time_signature_from_string_6_8():
ts = TimeSignature.from_string("6/8")
assert ts.beats == 6
assert ts.unit == 8
assert ts.beats_per_measure == 3.0 # 6 * (4/8) = 3
def test_time_signature_repr():
assert repr(TimeSignature(3, 4)) == "3/4"
def test_rhythm_note_creation():
t = Tone.from_string("C4")
n = RhythmNote(tone=t, duration=Duration.QUARTER)
assert n.tone is t
assert n.duration is Duration.QUARTER
assert n.beats == 1.0
def test_rest_creation():
r = Rest(Duration.HALF)
assert r.tone is None
assert r.duration is Duration.HALF
assert r.beats == 2.0
def test_rest_default_duration():
r = Rest()
assert r.duration is Duration.QUARTER
def test_score_add_chaining():
t1 = Tone.from_string("C4")
t2 = Tone.from_string("E4")
score = Score("4/4", bpm=120)
result = score.add(t1, Duration.QUARTER).add(t2, Duration.QUARTER)
assert result is score
assert len(score) == 2
def test_score_total_beats():
score = Score("4/4", bpm=120)
score.add(Tone.from_string("C4"), Duration.QUARTER)
score.add(Tone.from_string("E4"), Duration.HALF)
score.rest(Duration.QUARTER)
assert score.total_beats == 4.0
def test_score_measures_complete():
score = Score("4/4", bpm=120)
for _ in range(4):
score.add(Tone.from_string("C4"), Duration.QUARTER)
assert score.measures == 1.0
def test_score_measures_fractional():
score = Score("4/4", bpm=120)
score.add(Tone.from_string("C4"), Duration.QUARTER)
score.add(Tone.from_string("E4"), Duration.QUARTER)
assert score.measures == 0.5
def test_score_measures_3_4():
score = Score("3/4", bpm=100)
for _ in range(3):
score.add(Tone.from_string("C4"), Duration.QUARTER)
assert score.measures == 1.0
def test_score_duration_ms():
score = Score("4/4", bpm=120)
# At 120 bpm, one beat = 500 ms
score.add(Tone.from_string("C4"), Duration.QUARTER) # 1 beat = 500 ms
score.add(Tone.from_string("E4"), Duration.HALF) # 2 beats = 1000 ms
assert score.duration_ms == 1500.0
def test_score_iteration():
score = Score("4/4", bpm=120)
t = Tone.from_string("C4")
score.add(t, Duration.QUARTER)
score.rest(Duration.QUARTER)
notes = list(score)
assert len(notes) == 2
assert notes[0].tone is t
assert notes[1].tone is None
def test_score_repr():
score = Score("4/4", bpm=120)
for _ in range(4):
score.add(Tone.from_string("C4"), Duration.QUARTER)
r = repr(score)
assert "4/4" in r
assert "120bpm" in r
assert "1.0 measures" in r
def test_score_with_rests():
score = Score("4/4", bpm=60)
score.add(Tone.from_string("C4"), Duration.QUARTER)
score.rest(Duration.QUARTER)
score.add(Tone.from_string("E4"), Duration.QUARTER)
score.rest(Duration.QUARTER)
assert score.total_beats == 4.0
assert score.measures == 1.0
# At 60 bpm, 1 beat = 1000 ms, 4 beats = 4000 ms
assert score.duration_ms == 4000.0
def test_score_save_midi(tmp_path):
"""save_midi writes a valid MIDI file header."""
score = Score("4/4", bpm=120)
score.add(Tone.from_string("C4"), Duration.QUARTER)
score.add(Tone.from_string("E4"), Duration.QUARTER)
score.rest(Duration.QUARTER)
score.add(Tone.from_string("G4"), Duration.QUARTER)
midi_path = tmp_path / "test.mid"
score.save_midi(str(midi_path))
data = midi_path.read_bytes()
# Valid MIDI starts with MThd
assert data[:4] == b"MThd"
# Contains a track chunk
assert b"MTrk" in data
# File is non-trivial
assert len(data) > 30
# ── DrumSound and Pattern ──────────────────────────────────────────────────
def test_drum_sound_values():
from pytheory.rhythm import DrumSound
assert DrumSound.KICK.value == 36
assert DrumSound.SNARE.value == 38
assert DrumSound.CLOSED_HAT.value == 42
assert DrumSound.RIDE.value == 51
def test_pattern_list_presets():
from pytheory import Pattern
presets = Pattern.list_presets()
assert len(presets) >= 40
assert "rock" in presets
assert "jazz" in presets
assert "salsa" in presets
assert "bossa nova" in presets
assert "bebop" in presets
assert "funk" in presets
def test_pattern_preset_rock():
from pytheory import Pattern
p = Pattern.preset("rock")
assert p.name == "rock"
assert p.beats == 4.0
assert len(p.hits) > 0
def test_pattern_preset_salsa():
from pytheory import Pattern
p = Pattern.preset("salsa")
assert p.beats == 8.0 # 2-bar clave cycle
assert len(p.hits) > 20
def test_pattern_preset_invalid():
from pytheory import Pattern
with pytest.raises(ValueError, match="Unknown preset"):
Pattern.preset("nonexistent")
def test_pattern_to_score():
from pytheory import Pattern
p = Pattern.preset("rock")
score = p.to_score(repeats=4, bpm=120)
assert score.total_beats == 16.0
assert score.measures == 4.0
def test_pattern_to_score_waltz():
from pytheory import Pattern
p = Pattern.preset("waltz")
score = p.to_score(repeats=4, bpm=180)
assert score.total_beats == 12.0
assert score.bpm == 180
def test_pattern_midi_export(tmp_path):
from pytheory import Pattern
p = Pattern.preset("bossa nova")
score = p.to_score(repeats=2, bpm=140)
path = tmp_path / "bossa.mid"
score.save_midi(str(path))
data = path.read_bytes()
assert data[:4] == b"MThd"
assert len(data) > 50
def test_pattern_all_presets_valid():
from pytheory import Pattern
for name in Pattern.list_presets():
p = Pattern.preset(name)
assert p.beats > 0
assert len(p.hits) > 0
score = p.to_score(repeats=1, bpm=120)
assert score.total_beats == p.beats
def test_pattern_repr():
from pytheory import Pattern
p = Pattern.preset("funk")
r = repr(p)
assert "funk" in r
assert "4/4" in r
# ── Drum synthesis ─────────────────────────────────────────────────────────
@needs_portaudio
def test_render_drum_hit_all_sounds():
from pytheory.play import _render_drum_hit
from pytheory.rhythm import DrumSound
for sound in DrumSound:
wave = _render_drum_hit(sound.value, 22050)
assert len(wave) == 22050
assert wave.dtype == numpy.float32
@needs_portaudio
def test_render_pattern_rock():
from pytheory.play import _render_pattern
from pytheory import Pattern
p = Pattern.preset("rock")
buf = _render_pattern(p, bpm=120)
assert len(buf) > 0
assert buf.dtype == numpy.float32
assert numpy.max(numpy.abs(buf)) <= 0.91 # normalized
@needs_portaudio
def test_render_pattern_all_presets():
from pytheory.play import _render_pattern
from pytheory import Pattern
for name in Pattern.list_presets():
p = Pattern.preset(name)
buf = _render_pattern(p, bpm=120)
assert len(buf) > 0, f"Empty buffer for {name}"
@needs_portaudio
def test_render_pattern_different_tempos():
from pytheory.play import _render_pattern
from pytheory import Pattern
p = Pattern.preset("jazz")
slow = _render_pattern(p, bpm=60)
fast = _render_pattern(p, bpm=240)
assert len(slow) > len(fast) # slower = more samples
# ── Part and multi-part Score ──────────────────────────────────────────────
def test_part_creation():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="saw", envelope="pluck")
assert lead.name == "lead"
assert lead.synth == "saw"
assert lead.envelope == "pluck"
assert "lead" in score.parts
def test_part_add_string():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.add("C5", Duration.QUARTER)
assert len(lead) == 1
assert lead.notes[0].tone.name == "C"
assert lead.notes[0].tone.octave == 5
def test_part_add_chaining():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
result = lead.add("C5", Duration.QUARTER).add("E5", Duration.QUARTER).rest(Duration.HALF)
assert result is lead
assert len(lead) == 3
assert lead.total_beats == 4.0
def test_part_total_beats_in_score():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.add("C5", Duration.WHOLE).add("E5", Duration.WHOLE)
assert score.total_beats == 8.0
def test_multiple_parts():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="saw")
bass = score.part("bass", synth="triangle")
lead.add("C5", Duration.WHOLE)
bass.add("C2", Duration.WHOLE).add("G2", Duration.WHOLE)
assert len(score.parts) == 2
assert score.total_beats == 8.0 # bass is longer
def test_score_add_pattern():
from pytheory import Score, Pattern
score = Score("4/4", bpm=120)
score.add_pattern(Pattern.preset("rock"), repeats=2)
assert score._drum_pattern_beats == 8.0
assert len(score._drum_hits) > 0
def test_score_add_pattern_chaining():
from pytheory import Score, Pattern
score = Score("4/4", bpm=120)
result = score.add_pattern(Pattern.preset("rock"), repeats=1)
assert result is score
def test_part_repr():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="saw")
lead.add("C5", Duration.QUARTER)
r = repr(lead)
assert "lead" in r
assert "saw" in r
def test_score_repr_with_parts():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
score.part("lead")
score.part("bass")
r = repr(score)
assert "2 parts" in r
@needs_portaudio
def test_render_score_with_parts():
from pytheory import Score, Duration, Pattern, Key
from pytheory.play import render_score
score = Score("4/4", bpm=120)
score.add_pattern(Pattern.preset("rock"), repeats=2)
chords = score.part("chords", synth="sine", envelope="pad")
lead = score.part("lead", synth="saw", envelope="pluck")
key = Key("C", "major")
for chord in key.progression("I", "V", "vi", "IV"):
chords.add(chord, Duration.HALF)
lead.add("E5", Duration.QUARTER).add("G5", Duration.QUARTER)
buf = render_score(score)
assert len(buf) > 0
assert buf.dtype == numpy.float32
def test_backwards_compat_add():
"""Score.add() still works without named parts."""
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
score.add(Chord.from_symbol("C"), Duration.WHOLE)
assert len(score.notes) == 1
assert score.total_beats == 4.0
# ── New synth waveforms ───────────────────────────────────────────────────
@needs_portaudio
def test_square_wave():
from pytheory.play import square_wave, SAMPLE_RATE
wave = square_wave(440)
assert len(wave) == SAMPLE_RATE
# Square wave should only have values at +peak and -peak
unique = set(numpy.unique(wave))
assert len(unique) <= 3 # +peak, -peak, possibly 0 at zero crossings
@needs_portaudio
def test_pulse_wave():
from pytheory.play import pulse_wave, SAMPLE_RATE
wave = pulse_wave(440, duty=0.25)
assert len(wave) == SAMPLE_RATE
@needs_portaudio
def test_pulse_wave_duty_affects_timbre():
from pytheory.play import pulse_wave
narrow = pulse_wave(440, duty=0.125, n_samples=1000)
wide = pulse_wave(440, duty=0.5, n_samples=1000)
# Different duty cycles produce different waveforms
assert not numpy.array_equal(narrow, wide)
@needs_portaudio
def test_fm_wave():
from pytheory.play import fm_wave, SAMPLE_RATE
wave = fm_wave(440)
assert len(wave) == SAMPLE_RATE
# FM should produce a more complex waveform than sine
assert len(numpy.unique(wave)) > 100
@needs_portaudio
def test_fm_wave_params():
from pytheory.play import fm_wave
bell = fm_wave(440, mod_ratio=3.5, mod_index=5, n_samples=1000)
piano = fm_wave(440, mod_ratio=1, mod_index=1.5, n_samples=1000)
assert not numpy.array_equal(bell, piano)
@needs_portaudio
def test_noise_wave():
from pytheory.play import noise_wave, SAMPLE_RATE
wave = noise_wave(n_samples=SAMPLE_RATE)
assert len(wave) == SAMPLE_RATE
# Noise should be random — two calls produce different results
wave2 = noise_wave(n_samples=SAMPLE_RATE)
assert not numpy.array_equal(wave, wave2)
@needs_portaudio
def test_supersaw_wave():
from pytheory.play import supersaw_wave, sawtooth_wave, SAMPLE_RATE
wave = supersaw_wave(440)
assert len(wave) == SAMPLE_RATE
@needs_portaudio
def test_all_synths_in_enum():
from pytheory.play import Synth
assert len(Synth) == 56
for s in Synth:
wave = s(440, n_samples=1000)
assert len(wave) == 1000
@needs_portaudio
def test_resolve_synth_new_names():
from pytheory.play import _resolve_synth, square_wave, fm_wave, supersaw_wave
assert _resolve_synth("square") is square_wave
assert _resolve_synth("fm") is fm_wave
assert _resolve_synth("supersaw") is supersaw_wave
@needs_portaudio
def test_part_with_new_synths():
from pytheory import Score, Duration
from pytheory.play import render_score
score = Score("4/4", bpm=120)
for synth_name in ["square", "pulse", "fm", "noise", "supersaw"]:
p = score.part(synth_name, synth=synth_name, envelope="pluck")
p.add("C4", Duration.QUARTER)
buf = render_score(score)
assert len(buf) > 0
# ── Drum fill tests ──────────────────────────────────────────────────────────
def test_fill_presets_exist():
from pytheory import Pattern
expected = [
"rock", "rock crash", "jazz", "jazz brush", "salsa", "samba",
"funk", "metal", "blast", "buildup", "breakdown",
"reggae", "afrobeat", "bossa nova", "house", "trap",
"hip hop", "disco", "cumbia", "highlife", "second line",
]
for name in expected:
p = Pattern.fill(name)
assert p is not None
def test_fill_is_pattern():
from pytheory import Pattern
p = Pattern.fill("rock")
assert isinstance(p, Pattern)
def test_fill_beats():
from pytheory import Pattern
for name in Pattern.list_fills():
p = Pattern.fill(name)
assert p.beats == 4.0, f"Fill {name!r} should be 4 beats, got {p.beats}"
def test_fill_invalid_raises():
from pytheory import Pattern
with pytest.raises(ValueError, match="Unknown fill"):
Pattern.fill("nonexistent_fill_xyz")
def test_score_fill():
from pytheory import Score
score = Score("4/4", bpm=120)
score.fill("rock")
assert len(score._drum_hits) > 0
def test_drums_with_fill():
from pytheory import Score
score = Score("4/4", bpm=120)
score.drums("rock", repeats=8, fill="rock", fill_every=4)
# 8 bars total, each 4 beats = 32 beats
assert score._drum_pattern_beats == 32.0
def test_drums_fill_last_bar_only():
from pytheory import Score, Pattern
score = Score("4/4", bpm=120)
score.drums("rock", repeats=4, fill="rock")
# 4 bars total, each 4 beats = 16 beats
assert score._drum_pattern_beats == 16.0
# The last bar should be a fill (different hit count than groove)
groove = Pattern.preset("rock")
fill_pat = Pattern.fill("rock")
# Build expected: 3 bars groove + 1 bar fill
expected_hits = 3 * len(groove.hits) + len(fill_pat.hits)
assert len(score._drum_hits) == expected_hits
def test_fill_all_presets_valid():
from pytheory import Pattern
for name in Pattern.list_fills():
p = Pattern.fill(name)
assert len(p.hits) > 0, f"Fill {name!r} has no hits"
def test_new_groove_presets():
from pytheory import Pattern
new_grooves = [
"country", "ska", "dub", "jungle", "techno",
"gospel", "swing", "bolero", "tango", "flamenco",
]
for name in new_grooves:
p = Pattern.preset(name)
assert len(p.hits) > 0, f"Groove {name!r} has no hits"
def test_new_fill_presets():
from pytheory import Pattern
new_fills = [
"reggae", "afrobeat", "bossa nova", "house", "trap",
"hip hop", "disco", "cumbia", "highlife", "second line",
]
for name in new_fills:
p = Pattern.fill(name)
assert len(p.hits) > 0, f"Fill {name!r} has no hits"
# ── Audio effects ──────────────────────────────────────────────────────────
@needs_portaudio
def test_reverb_effect():
from pytheory.play import _apply_reverb
dry = numpy.zeros(44100, dtype=numpy.float32)
dry[0] = 1.0 # impulse
wet = _apply_reverb(dry, mix=1.0, decay=0.5)
assert numpy.max(numpy.abs(wet[1000:])) > 0
@needs_portaudio
def test_reverb_zero_mix():
from pytheory.play import _apply_reverb
dry = numpy.random.uniform(-1, 1, 1000).astype(numpy.float32)
result = _apply_reverb(dry, mix=0.0)
assert numpy.allclose(result, dry)
@needs_portaudio
def test_delay_effect():
from pytheory.play import _apply_delay
dry = numpy.zeros(44100, dtype=numpy.float32)
dry[:100] = 1.0
wet = _apply_delay(dry, mix=0.5, time=0.1, feedback=0.3)
echo_start = int(0.1 * 44100)
assert numpy.max(numpy.abs(wet[echo_start:echo_start + 200])) > 0
@needs_portaudio
def test_delay_zero_mix():
from pytheory.play import _apply_delay
dry = numpy.random.uniform(-1, 1, 1000).astype(numpy.float32)
result = _apply_delay(dry, mix=0.0)
assert numpy.allclose(result, dry)
@needs_portaudio
def test_lowpass_filter():
from pytheory.play import _apply_lowpass, SAMPLE_RATE
t = numpy.arange(44100, dtype=numpy.float32) / SAMPLE_RATE
signal = numpy.sin(2 * numpy.pi * 100 * t) + numpy.sin(2 * numpy.pi * 5000 * t)
filtered = _apply_lowpass(signal.astype(numpy.float32), cutoff=500)
rms_orig = numpy.sqrt(numpy.mean(signal[22050:] ** 2))
rms_filt = numpy.sqrt(numpy.mean(filtered[22050:] ** 2))
assert rms_filt < rms_orig
@needs_portaudio
def test_lowpass_with_resonance():
from pytheory.play import _apply_lowpass
t = numpy.arange(44100, dtype=numpy.float32) / 44100
signal = numpy.sin(2 * numpy.pi * 1000 * t).astype(numpy.float32)
flat = _apply_lowpass(signal, cutoff=1000, q=0.707)
resonant = _apply_lowpass(signal, cutoff=1000, q=5.0)
assert numpy.max(numpy.abs(resonant)) > numpy.max(numpy.abs(flat))
@needs_portaudio
def test_part_effects_in_render():
from pytheory import Score, Duration
from pytheory.play import render_score
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="saw", envelope="pluck",
reverb=0.3, delay=0.2, lowpass=2000)
lead.add("C5", Duration.WHOLE)
buf = render_score(score)
assert len(buf) > 0
@needs_portaudio
def test_part_effects_change_output():
from pytheory import Score, Duration
from pytheory.play import render_score
s1 = Score("4/4", bpm=120)
s1.part("lead", synth="saw", envelope="pluck").add("C5", Duration.WHOLE)
dry = render_score(s1)
s2 = Score("4/4", bpm=120)
s2.part("lead", synth="saw", envelope="pluck",
reverb=0.5, delay=0.3).add("C5", Duration.WHOLE)
wet = render_score(s2)
assert not numpy.allclose(dry, wet, atol=0.01)
# ── Arpeggiator ───────────────────────────────────────────────────────────
def test_arpeggio_basic():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.arpeggio(Chord.from_symbol("C"), bars=1, pattern="up",
division=Duration.SIXTEENTH)
# 1 bar of 16ths = 16 notes
assert len(lead) == 16
def test_arpeggio_patterns():
from pytheory import Score, Duration
for pat in ["up", "down", "updown", "downup", "random"]:
score = Score("4/4", bpm=120)
lead = score.part(f"lead_{pat}")
lead.arpeggio(Chord.from_symbol("Am"), bars=1, pattern=pat)
assert len(lead) > 0
def test_arpeggio_octaves():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.arpeggio(Chord.from_symbol("C"), bars=1, octaves=2,
division=Duration.EIGHTH)
# C major = 3 tones, 2 octaves = 6 tones in the cycle
assert len(lead) == 8 # 1 bar of 8ths
def test_arpeggio_string_chord():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.arpeggio("Dm7", bars=1)
assert len(lead) > 0
def test_arpeggio_chaining():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
result = lead.arpeggio("C", bars=1).arpeggio("Am", bars=1)
assert result is lead
assert lead.total_beats == 8.0
def test_arpeggio_with_legato():
from pytheory import Score, Duration
from pytheory.play import render_score
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="saw", legato=True, glide=0.03)
lead.arpeggio("Cm", bars=2, pattern="updown", octaves=2)
buf = render_score(score)
assert len(buf) > 0
def test_arpeggio_updown_length():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.arpeggio(Chord.from_symbol("Am"), bars=2, pattern="updown",
division=Duration.EIGHTH)
# 2 bars of 8ths = 16 notes
assert len(lead) == 16
# ── Part.set() automation ─────────────────────────────────────────────────
def test_part_set_stores_automation():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.add("C5", Duration.WHOLE)
lead.set(lowpass=2000)
lead.add("E5", Duration.WHOLE)
assert len(lead._automation) == 1
assert lead._automation[0][0] == 4.0
def test_part_set_chaining():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
result = lead.set(lowpass=1000, reverb=0.3)
assert result is lead
def test_part_get_params_at():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", lowpass=500)
lead.add("C5", Duration.WHOLE)
lead.set(lowpass=2000, reverb=0.4)
lead.add("E5", Duration.WHOLE)
p0 = lead._get_params_at(0)
assert p0["lowpass"] == 500
assert p0["reverb_mix"] == 0
p4 = lead._get_params_at(4.0)
assert p4["lowpass"] == 2000
assert p4["reverb_mix"] == 0.4
@needs_portaudio
def test_automation_changes_output():
from pytheory import Score, Duration
from pytheory.play import render_score
s1 = Score("4/4", bpm=120)
s1.part("lead", synth="saw", lowpass=500).add("C5", Duration.WHOLE).add("C5", Duration.WHOLE)
buf1 = render_score(s1)
s2 = Score("4/4", bpm=120)
p2 = s2.part("lead", synth="saw", lowpass=500)
p2.add("C5", Duration.WHOLE)
p2.set(lowpass=5000)
p2.add("C5", Duration.WHOLE)
buf2 = render_score(s2)
assert not numpy.allclose(buf1, buf2, atol=0.01)
def test_part_set_multiple():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", lowpass=400)
lead.add("C5", Duration.WHOLE)
lead.set(lowpass=1000)
lead.add("C5", Duration.WHOLE)
lead.set(lowpass=3000, distortion=0.5)
lead.add("C5", Duration.WHOLE)
assert len(lead._automation) == 2
p8 = lead._get_params_at(8.0)
assert p8["lowpass"] == 3000
assert p8["distortion_mix"] == 0.5
# ── Chorus effect ──────────────────────────────────────────────────────────
@needs_portaudio
def test_chorus_effect():
from pytheory.play import _apply_chorus
t = numpy.arange(44100, dtype=numpy.float32) / 44100
signal = numpy.sin(2 * numpy.pi * 440 * t).astype(numpy.float32)
wet = _apply_chorus(signal, mix=0.5)
assert not numpy.allclose(signal, wet, atol=0.01)
@needs_portaudio
def test_chorus_zero_mix():
from pytheory.play import _apply_chorus
dry = numpy.random.uniform(-1, 1, 1000).astype(numpy.float32)
result = _apply_chorus(dry, mix=0.0)
assert numpy.allclose(result, dry)
@needs_portaudio
def test_part_with_chorus():
from pytheory import Score, Duration
from pytheory.play import render_score
score = Score("4/4", bpm=120)
score.part("lead", synth="saw", chorus=0.5).add("C5", Duration.WHOLE)
buf = render_score(score)
assert len(buf) > 0
# ── LFO automation ────────────────────────────────────────────────────────
def test_lfo_generates_automation():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.lfo("lowpass", rate=1.0, min=400, max=2000, bars=2)
# 2 bars * 4 beats / 0.25 resolution = 32 points
assert len(lead._automation) == 32
def test_lfo_sine_shape():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", lowpass=500)
lead.lfo("lowpass", rate=1.0, min=200, max=1000, bars=1, shape="sine")
# Check values stay in range
for beat, params in lead._automation:
assert 200 <= params["lowpass"] <= 1000
def test_lfo_all_shapes():
from pytheory import Score
for shape in ["sine", "triangle", "saw", "square"]:
score = Score("4/4", bpm=120)
lead = score.part(f"lead_{shape}")
lead.lfo("lowpass", rate=1.0, min=100, max=5000, bars=1, shape=shape)
assert len(lead._automation) > 0
def test_lfo_chaining():
from pytheory import Score
score = Score("4/4", bpm=120)
lead = score.part("lead")
result = lead.lfo("lowpass", rate=1.0, min=400, max=2000, bars=1)
assert result is lead
def test_lfo_multiple_params():
from pytheory import Score
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.lfo("lowpass", rate=1.0, min=400, max=2000, bars=2)
lead.lfo("distortion", rate=0.5, min=0.0, max=0.8, bars=2)
# Both sets of automation points should exist
lp_points = [p for _, p in lead._automation if "lowpass" in p]
dist_points = [p for _, p in lead._automation if "distortion_mix" in p]
assert len(lp_points) > 0
assert len(dist_points) > 0
@needs_portaudio
def test_lfo_renders_correctly():
from pytheory import Score, Duration
from pytheory.play import render_score
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="saw", lowpass=400, lowpass_q=3.0)
lead.lfo("lowpass", rate=1.0, min=300, max=3000, bars=2)
lead.add("C4", Duration.WHOLE).add("C4", Duration.WHOLE)
buf = render_score(score)
assert len(buf) > 0
# ── Per-note velocity tests ─────────────────────────────────────────────────
def test_note_velocity_default():
from pytheory.rhythm import Note, Duration
n = Note(tone=None, duration=Duration.QUARTER)
assert n.velocity == 100
def test_note_velocity_custom():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.add("C5", Duration.QUARTER, velocity=60)
assert lead.notes[0].velocity == 60
def test_arpeggio_velocity():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.arpeggio("Cm", bars=1, velocity=75)
for n in lead.notes:
assert n.velocity == 75
# ── Swing / groove tests ────────────────────────────────────────────────────
def test_score_swing_default():
from pytheory import Score
score = Score("4/4", bpm=120)
assert score.swing == 0.0
def test_score_swing_set():
from pytheory import Score
score = Score("4/4", bpm=120, swing=0.5)
assert score.swing == 0.5
# ── Tempo change tests ──────────────────────────────────────────────────────
def test_set_tempo():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.add("C4", Duration.WHOLE)
score.set_tempo(140)
assert len(score._tempo_changes) == 1
beat_pos, new_bpm = score._tempo_changes[0]
assert new_bpm == 140
assert beat_pos == 4.0 # after one WHOLE note
# ── Fade in/out tests ───────────────────────────────────────────────────────
def test_fade_in():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", volume=0.8)
lead.fade_in(bars=2)
# Should generate automation points with ascending volume
volumes = [p["volume"] for _, p in lead._automation]
assert len(volumes) > 0
assert volumes[0] == pytest.approx(0.0)
assert volumes[-1] == pytest.approx(0.8)
# Check ascending order
for i in range(1, len(volumes)):
assert volumes[i] >= volumes[i - 1]
def test_fade_out():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", volume=0.8)
lead.fade_out(bars=2)
# Should generate automation points with descending volume
volumes = [p["volume"] for _, p in lead._automation]
assert len(volumes) > 0
assert volumes[0] == pytest.approx(0.8)
assert volumes[-1] == pytest.approx(0.0)
# Check descending order
for i in range(1, len(volumes)):
assert volumes[i] <= volumes[i - 1]
@needs_portaudio
def test_velocity_affects_render():
from pytheory import Score, Duration
from pytheory.play import render_score
import numpy as np
# Loud note
score_loud = Score("4/4", bpm=120)
lead_loud = score_loud.part("lead", synth="sine", envelope="none")
lead_loud.add("A4", Duration.QUARTER, velocity=127)
buf_loud = render_score(score_loud)
# Quiet note
score_quiet = Score("4/4", bpm=120)
lead_quiet = score_quiet.part("lead", synth="sine", envelope="none")
lead_quiet.add("A4", Duration.QUARTER, velocity=30)
buf_quiet = render_score(score_quiet)
# Loud should have greater peak amplitude (both are normalized,
# but we compare RMS of the raw rendered parts before normalization)
# Actually, render_score normalizes. Let's just check they both render.
assert len(buf_loud) > 0
assert len(buf_quiet) > 0
# The loud note should have higher peak than the quiet note
# Since both scores have only one note, normalization makes peaks equal.
# Instead, render a score with BOTH loud and quiet notes and check
# the loud section is louder.
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="sine", envelope="none")
lead.add("A4", Duration.QUARTER, velocity=127)
lead.add("A4", Duration.QUARTER, velocity=30)
buf = render_score(score)
mid = len(buf) // 2
rms_first = np.sqrt(np.mean(buf[:mid] ** 2))
rms_second = np.sqrt(np.mean(buf[mid:] ** 2))
assert rms_first > rms_second
# ── Sidechain compression tests ─────────────────────────────────────────────
def test_sidechain_default():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
pad = score.part("pad", synth="sine", envelope="pad")
assert pad.sidechain == 0.0
assert pad.sidechain_release == 0.1
def test_sidechain_set():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
pad = score.part("pad", synth="sine", envelope="pad", sidechain=0.8,
sidechain_release=0.15)
assert pad.sidechain == 0.8
assert pad.sidechain_release == 0.15
@needs_portaudio
def test_sidechain_render():
from pytheory import Score, Duration, Pattern
from pytheory.play import render_score
import numpy as np
# Score without sidechain
score1 = Score("4/4", bpm=120)
score1.add_pattern(Pattern.preset("rock"), repeats=2)
pad1 = score1.part("pad", synth="sine", envelope="pad")
pad1.add("C4", Duration.WHOLE).add("C4", Duration.WHOLE)
buf1 = render_score(score1)
# Score with sidechain
score2 = Score("4/4", bpm=120)
score2.add_pattern(Pattern.preset("rock"), repeats=2)
pad2 = score2.part("pad", synth="sine", envelope="pad", sidechain=0.8)
pad2.add("C4", Duration.WHOLE).add("C4", Duration.WHOLE)
buf2 = render_score(score2)
# Both should render to non-empty buffers of the same length
assert len(buf1) > 0
assert len(buf1) == len(buf2)
# The buffers should differ (sidechain alters the mix)
assert not np.array_equal(buf1, buf2)
# ── Song structure / Section tests ───────────────────────────────────────────
def test_section_basic():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="sine")
score.section("verse")
lead.add("C5", Duration.WHOLE)
score.section("chorus")
lead.add("E5", Duration.WHOLE)
score.end_section()
assert "verse" in score._sections
assert "chorus" in score._sections
assert score._sections["verse"]._finalized
assert score._sections["chorus"]._finalized
def test_section_repeat():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="sine")
score.section("verse")
lead.add("C5", Duration.WHOLE) # 4 beats
score.end_section()
beats_before = score.total_beats
assert beats_before == 4.0
score.repeat("verse")
assert score.total_beats == 8.0
def test_section_repeat_parts():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="sine")
bass = score.part("bass", synth="triangle")
score.section("verse")
lead.add("C5", Duration.QUARTER)
lead.add("D5", Duration.QUARTER)
bass.add("C3", Duration.HALF)
score.end_section()
assert len(lead.notes) == 2
assert len(bass.notes) == 1
score.repeat("verse")
assert len(lead.notes) == 4
assert len(bass.notes) == 2
score.repeat("verse", times=2)
assert len(lead.notes) == 8
assert len(bass.notes) == 4
def test_section_unknown_raises():
from pytheory import Score
score = Score("4/4", bpm=120)
with pytest.raises(ValueError, match="Unknown section"):
score.repeat("nonexistent")
# ── REPL ──────────────────────────────────────────────────────────────────
def test_repl_session_defaults():
from pytheory.repl import Session
s = Session()
assert str(s.key) == "C major"
assert s.bpm == 120
assert s.current_part is None
assert s._drum_preset is None
def test_repl_cmd_key():
from pytheory.repl import Session, cmd_key
s = Session()
cmd_key(s, ["Am"])
assert s.key.tonic_name == "A"
assert s.key.mode == "minor"
def test_repl_cmd_key_major():
from pytheory.repl import Session, cmd_key
s = Session()
cmd_key(s, ["G", "major"])
assert s.key.tonic_name == "G"
assert s.key.mode == "major"
def test_repl_cmd_bpm():
from pytheory.repl import Session, cmd_bpm
s = Session()
cmd_bpm(s, ["140"])
assert s.bpm == 140
assert s.score.bpm == 140
def test_repl_cmd_swing():
from pytheory.repl import Session, cmd_swing
s = Session()
cmd_swing(s, ["0.5"])
assert s.swing == 0.5
def test_repl_cmd_drums():
from pytheory.repl import Session, cmd_drums
s = Session()
cmd_drums(s, ["rock"])
assert s._drum_preset == "rock"
assert len(s.score._drum_hits) > 0
def test_repl_cmd_part():
from pytheory.repl import Session, cmd_part
s = Session()
cmd_part(s, ["lead", "saw", "pluck"])
assert "lead" in s.parts
assert s.current_part is not None
assert s.current_part.synth == "saw"
assert s.current_part.envelope == "pluck"
def test_repl_cmd_add_note():
from pytheory.repl import Session, cmd_add
s = Session()
cmd_add(s, ["C5", "1"])
assert s.current_part is not None # auto-created
assert len(s.current_part.notes) == 1
def test_repl_cmd_add_chord():
from pytheory.repl import Session, cmd_add
s = Session()
cmd_add(s, ["Am", "4"])
assert len(s.current_part.notes) == 1
def test_repl_cmd_rest():
from pytheory.repl import Session, cmd_rest
s = Session()
s.ensure_part("lead")
s.current_part = s.parts["lead"]
cmd_rest(s, ["2"])
assert len(s.current_part.notes) == 1
assert s.current_part.notes[0].tone is None
def test_repl_cmd_arp():
from pytheory.repl import Session, cmd_part, cmd_arp
s = Session()
cmd_part(s, ["lead"])
cmd_arp(s, ["Am", "updown", "2", "2"])
assert len(s.current_part.notes) > 0
def test_repl_cmd_prog():
from pytheory.repl import Session, cmd_key, cmd_prog
s = Session()
cmd_key(s, ["Am"])
cmd_prog(s, ["i", "iv", "V", "i"])
assert len(s.current_part.notes) == 4
def test_repl_cmd_effects():
from pytheory.repl import Session, cmd_part, _set_effect
s = Session()
cmd_part(s, ["lead", "saw"])
_set_effect(s, "reverb", ["0.4"])
assert s.current_part.reverb_mix == 0.4
_set_effect(s, "delay", ["0.3", "0.375"])
assert s.current_part.delay_mix == 0.3
assert s.current_part.delay_time == 0.375
_set_effect(s, "lowpass", ["2000", "3"])
assert s.current_part.lowpass == 2000
assert s.current_part.lowpass_q == 3.0
_set_effect(s, "distortion", ["0.5"])
assert s.current_part.distortion_mix == 0.5
def test_repl_cmd_legato():
from pytheory.repl import Session, cmd_part, cmd_legato
s = Session()
cmd_part(s, ["lead"])
cmd_legato(s, [])
assert s.current_part.legato is True
cmd_legato(s, ["off"])
assert s.current_part.legato is False
def test_repl_cmd_set():
from pytheory.repl import Session, cmd_part, cmd_add, cmd_set
s = Session()
cmd_part(s, ["lead"])
cmd_add(s, ["C5", "4"])
cmd_set(s, ["lowpass", "3000"])
assert len(s.current_part._automation) == 1
def test_repl_cmd_lfo():
from pytheory.repl import Session, cmd_part, cmd_lfo
s = Session()
cmd_part(s, ["lead"])
cmd_lfo(s, ["lowpass", "0.5", "400", "3000", "4"])
assert len(s.current_part._automation) > 0
def test_repl_save_midi(tmp_path):
from pytheory.repl import Session, cmd_key, cmd_prog, cmd_save_midi
s = Session()
cmd_key(s, ["Am"])
cmd_prog(s, ["i", "iv", "V", "i"])
path = str(tmp_path / "test.mid")
cmd_save_midi(s, [path])
assert (tmp_path / "test.mid").exists()
def test_repl_prompt_compact():
from pytheory.repl import Session, _prompt
s = Session()
p = _prompt(s)
assert "key=C" in p
assert "bpm=120" in p
def test_repl_prompt_with_part():
from pytheory.repl import Session, cmd_part, _prompt
s = Session()
cmd_part(s, ["lead", "saw"])
p = _prompt(s)
assert "→lead(saw)" in p
def test_repl_prompt_multiline():
from pytheory.repl import Session, cmd_part, cmd_drums, _prompt, _set_effect
s = Session()
cmd_drums(s, ["bossa", "nova"])
cmd_part(s, ["lead", "saw"])
_set_effect(s, "reverb", ["0.4"])
_set_effect(s, "lowpass", ["2000"])
_set_effect(s, "distortion", ["0.5"])
p = _prompt(s)
assert "♫>" in p # should be multiline
def test_repl_clear():
from pytheory.repl import Session, cmd_part, cmd_drums, cmd_clear
s = Session()
cmd_drums(s, ["rock"])
cmd_part(s, ["lead"])
cmd_clear(s, [])
assert len(s.parts) == 0
assert s.current_part is None
def test_repl_chords(capsys):
from pytheory.repl import Session, cmd_key, cmd_chords
s = Session()
cmd_key(s, ["C"])
cmd_chords(s, [])
out = capsys.readouterr().out
assert "C major" in out
assert "D minor" in out
# ── Figured Bass ──────────────────────────────────────────────────────────────
def test_figured_bass_root_position():
chord = Chord.from_tones("C", "E", "G")
assert chord.figured_bass == ""
def test_figured_bass_first_inversion():
# E in bass: first inversion of C major
chord = Chord.from_symbol("C").inversion(1)
assert chord.figured_bass == "6"
def test_figured_bass_second_inversion():
# G in bass: second inversion of C major
chord = Chord.from_symbol("C").inversion(2)
assert chord.figured_bass == "6/4"
def test_figured_bass_seventh_root():
# G7 in root position: G is the lowest note
chord = Chord.from_symbol("G7", octave=4)
assert chord.figured_bass == "7"
def test_figured_bass_seventh_first_inv():
# First inversion of G7: B in bass
chord = Chord.from_symbol("G7").inversion(1)
assert chord.figured_bass == "6/5"
def test_figured_bass_seventh_second_inv():
chord = Chord.from_symbol("G7").inversion(2)
assert chord.figured_bass == "4/3"
def test_figured_bass_seventh_third_inv():
chord = Chord.from_symbol("G7").inversion(3)
assert chord.figured_bass == "2"
def test_analyze_figured():
# V7 in root position
chord = Chord.from_symbol("G7")
result = chord.analyze_figured("C")
assert result == "V7"
def test_analyze_figured_with_inversion():
# V in first inversion
chord = Chord.from_symbol("G").inversion(1)
result = chord.analyze_figured("C")
assert result == "V6"
# ── Pitch Class Set Theory ───────────────────────────────────────────────────
def test_pitch_classes():
chord = Chord.from_tones("C", "E", "G")
assert chord.pitch_classes == {0, 4, 7}
def test_pitch_classes_with_sharps():
chord = Chord.from_tones("C", "E", "G#")
assert chord.pitch_classes == {0, 4, 8}
def test_normal_form():
chord = Chord.from_tones("C", "E", "G")
assert chord.normal_form == (0, 4, 7)
def test_prime_form_major():
# Major and minor triads share the same prime form (0, 3, 7)
# because C major (0,4,7) inverts to (0,5,8) -> normal form (0,3,7)
chord = Chord.from_tones("C", "E", "G")
assert chord.prime_form == (0, 3, 7)
def test_prime_form_minor():
# Minor triad: A C E has intervals 0,3,7 which inverts to 0,5,9
# Normal form of inversion: best compact = (0,3,7) via inversion check
chord = Chord.from_tones("A", "C", "E")
assert chord.prime_form == (0, 3, 7)
def test_forte_number_triad():
chord = Chord.from_tones("C", "E", "G")
assert chord.forte_number == "3-11"
def test_forte_number_minor_triad():
chord = Chord.from_tones("A", "C", "E")
assert chord.forte_number == "3-11"
def test_forte_number_dom7():
chord = Chord.from_symbol("G7")
assert chord.forte_number == "4-27"
def test_forte_number_augmented():
chord = Chord.from_tones("C", "E", "G#")
assert chord.forte_number == "3-12"
def test_forte_number_diminished7():
chord = Chord.from_tones("B", "D", "F", "Ab")
assert chord.forte_number == "4-28"
# ── Scale.recommend ───────────────────────────────────────────────────────
def test_recommend_c_major_notes():
from pytheory.scales import Scale
results = Scale.recommend("C", "D", "E", "F", "G", "A", "B")
assert len(results) > 0
assert results[0][2] == 1.0 # perfect match
# Chromatic should NOT be the top result
assert "chromatic" not in results[0][1]
def test_recommend_pentatonic():
from pytheory.scales import Scale
results = Scale.recommend("C", "D", "E", "G", "A")
assert len(results) > 0
assert results[0][2] == 1.0
def test_recommend_returns_top():
from pytheory.scales import Scale
results = Scale.recommend("C", "E", "G", top=3)
assert len(results) <= 3
def test_recommend_empty():
from pytheory.scales import Scale
assert Scale.recommend() == []
def test_recommend_fitness_descending():
from pytheory.scales import Scale
results = Scale.recommend("C", "D", "E", "F#", "G")
for i in range(len(results) - 1):
assert results[i][2] >= results[i + 1][2]
# ── MIDI Import (Score.from_midi) ────────────────────────────────────────
def test_from_midi_basic(tmp_path):
"""Create a simple MIDI with save_midi, re-import with from_midi."""
from pytheory import Score, Duration, Tone
score = Score("4/4", bpm=120)
score.add(Tone.from_string("C4"), Duration.QUARTER)
score.add(Tone.from_string("E4"), Duration.QUARTER)
score.add(Tone.from_string("G4"), Duration.QUARTER)
midi_path = str(tmp_path / "basic.mid")
score.save_midi(midi_path)
imported = Score.from_midi(midi_path)
# Should have at least one part with notes
assert len(imported.parts) >= 1
total_notes = sum(
1 for p in imported.parts.values()
for n in p.notes if n.tone is not None
)
assert total_notes == 3
def test_from_midi_tempo(tmp_path):
"""Verify BPM is preserved through save/import."""
from pytheory import Score, Duration, Tone
score = Score("4/4", bpm=140)
score.add(Tone.from_string("A4"), Duration.QUARTER)
midi_path = str(tmp_path / "tempo.mid")
score.save_midi(midi_path)
imported = Score.from_midi(midi_path)
assert imported.bpm == 140
def test_from_midi_roundtrip(tmp_path):
"""Save a progression as MIDI, import it, check parts/notes."""
from pytheory import Score, Duration, Tone
score = Score("3/4", bpm=100)
score.add(Tone.from_string("C4"), Duration.QUARTER)
score.add(Tone.from_string("D4"), Duration.QUARTER)
score.add(Tone.from_string("E4"), Duration.QUARTER)
score.add(Tone.from_string("F4"), Duration.QUARTER)
midi_path = str(tmp_path / "roundtrip.mid")
score.save_midi(midi_path)
imported = Score.from_midi(midi_path)
assert imported.bpm == 100
assert imported.time_signature == TimeSignature(3, 4)
total_notes = sum(
1 for p in imported.parts.values()
for n in p.notes if n.tone is not None
)
assert total_notes == 4
def test_from_midi_velocity(tmp_path):
"""Verify velocity is preserved through save/import."""
from pytheory import Score, Duration, Tone
score = Score("4/4", bpm=120)
# save_midi uses a fixed velocity param, default 100
score.add(Tone.from_string("C4"), Duration.QUARTER)
score.add(Tone.from_string("E4"), Duration.HALF)
midi_path = str(tmp_path / "velocity.mid")
score.save_midi(midi_path, velocity=80)
imported = Score.from_midi(midi_path)
sounding = [
n for p in imported.parts.values()
for n in p.notes if n.tone is not None
]
assert len(sounding) == 2
for n in sounding:
assert n.velocity == 80
def test_from_midi_drums(tmp_path):
"""Verify drum hits survive a roundtrip."""
from pytheory import Score, Pattern
score = Score("4/4", bpm=120)
score.add_pattern(Pattern.preset("rock"), repeats=1)
midi_path = str(tmp_path / "drums.mid")
score.save_midi(midi_path)
imported = Score.from_midi(midi_path)
assert len(imported._drum_hits) > 0
def test_from_midi_time_signature(tmp_path):
"""Verify time signature is preserved."""
from pytheory import Score, Duration, Tone
score = Score("6/8", bpm=150)
score.add(Tone.from_string("C4"), Duration.QUARTER)
midi_path = str(tmp_path / "timesig.mid")
score.save_midi(midi_path)
imported = Score.from_midi(midi_path)
assert imported.time_signature == TimeSignature(6, 8)
assert imported.bpm == 150
def test_from_midi_note_durations(tmp_path):
"""Verify note durations are approximately preserved."""
from pytheory import Score, Duration, Tone
score = Score("4/4", bpm=120)
score.add(Tone.from_string("C4"), Duration.WHOLE) # 4 beats
score.add(Tone.from_string("E4"), Duration.HALF) # 2 beats
midi_path = str(tmp_path / "durations.mid")
score.save_midi(midi_path)
imported = Score.from_midi(midi_path)
sounding = [
n for p in imported.parts.values()
for n in p.notes if n.tone is not None
]
assert len(sounding) == 2
assert abs(sounding[0].beats - 4.0) < 0.01
assert abs(sounding[1].beats - 2.0) < 0.01
# ── Instrument presets ────────────────────────────────────────────────────────
def test_instrument_piano():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
p = score.part("p", instrument="piano")
assert p.synth == "piano_synth"
assert p.vel_to_filter == 3000
def test_instrument_violin():
from pytheory import Score
score = Score("4/4", bpm=120)
p = score.part("v", instrument="violin")
assert p.synth == "strings_synth"
assert p.envelope == "bowed"
assert p.humanize == 0.15
assert p.lowpass == 5000
assert p.detune == 2
def test_instrument_override():
from pytheory import Score
score = Score("4/4", bpm=120)
# Explicit synth overrides the preset
p = score.part("p", instrument="piano", synth="saw")
assert p.synth == "saw"
def test_instrument_unknown_raises():
from pytheory import Score
score = Score("4/4", bpm=120)
with pytest.raises(ValueError, match="Unknown instrument"):
score.part("x", instrument="kazoo")
def test_list_instruments():
from pytheory import Score, INSTRUMENTS
result = Score.list_instruments()
assert isinstance(result, list)
assert result == sorted(result)
assert "piano" in result
assert "violin" in result
assert "808_bass" in result
assert len(result) == len(INSTRUMENTS)
def test_instrument_effects():
from pytheory import Score
score = Score("4/4", bpm=120)
p = score.part("c", instrument="celesta")
assert p.reverb_mix == 0.3
assert p.reverb_type == "plate"
assert p.synth == "fm"
assert p.envelope == "mallet"
def test_instrument_808_bass():
from pytheory import Score
score = Score("4/4", bpm=120)
p = score.part("b", instrument="808_bass")
assert p.distortion_mix == 0.4
assert p.distortion_drive == 2.5
assert p.lowpass == 200
assert p.lowpass_q == 1.5
assert p.synth == "sine"
assert p.envelope == "piano"
# ── Non-12-TET / Microtonal systems ─────────────────────────────────────────
from pytheory import TET
def test_tet_factory_creates_system():
edo17 = TET(17)
assert len(edo17.tone_names) == 17
assert edo17.semitones == 17
def test_tet_factory_numbered_tones():
edo17 = TET(17)
t = Tone("0", octave=4, system=edo17)
assert t.frequency == pytest.approx(440.0, rel=1e-3)
# One octave up
t_up = t.add(17)
assert t_up.frequency == pytest.approx(880.0, rel=1e-3)
def test_tet_factory_custom_names():
names = ["A", "B", "C", "D", "E"]
edo5 = TET(5, names=names)
assert len(edo5.tone_names) == 5
t = Tone("A", octave=4, system=edo5)
assert t.frequency == pytest.approx(440.0, rel=1e-3)
def test_tet_factory_wrong_name_count():
with pytest.raises(ValueError):
TET(5, names=["A", "B", "C"])
def test_19tet_system():
sys19 = SYSTEMS["19-tet"]
assert sys19.semitones == 19
a = Tone("A", octave=4, system=sys19)
assert a.frequency == pytest.approx(440.0, rel=1e-3)
# Octave should double
a5 = a.add(19)
assert a5.frequency == pytest.approx(880.0, rel=1e-3)
def test_19tet_scale():
sys19 = SYSTEMS["19-tet"]
ts = TonedScale(system=sys19, tonic=Tone("C", octave=4, system=sys19))
major = ts["major"]
assert len(major.tones) == 8 # 7 + octave
def test_31tet_system():
sys31 = SYSTEMS["31-tet"]
assert sys31.semitones == 31
a = Tone("A", octave=4, system=sys31)
assert a.frequency == pytest.approx(440.0, rel=1e-3)
def test_shruti_system():
shruti = SYSTEMS["shruti"]
assert shruti.semitones == 22
sa = Tone("Sa", octave=4, system=shruti)
# Sa should be near C4 (261.63 Hz) — not exact due to 22-TET
assert 250 < sa.frequency < 270
def test_shruti_octave():
shruti = SYSTEMS["shruti"]
sa4 = Tone("Sa", octave=4, system=shruti)
sa5 = sa4.add(22)
assert sa5.frequency == pytest.approx(sa4.frequency * 2, rel=1e-3)
def test_shruti_bhairav_scale():
shruti = SYSTEMS["shruti"]
ts = TonedScale(system=shruti, tonic=Tone("Sa", octave=4, system=shruti))
bhairav = ts["bhairav"]
names = [t.name for t in bhairav.tones]
assert names[0] == "Sa"
assert "komal Re" in names # the microtonal komal Re
assert len(bhairav.tones) == 8
def test_maqam_system():
maqam = SYSTEMS["maqam"]
assert maqam.semitones == 24
do = Tone("Do", octave=4, system=maqam)
assert 250 < do.frequency < 270
def test_maqam_rast_has_quarter_tones():
maqam = SYSTEMS["maqam"]
ts = TonedScale(system=maqam, tonic=Tone("Do", octave=4, system=maqam))
rast = ts["rast"]
names = [t.name for t in rast.tones]
# Rast should contain quarter-tone positions
assert any("" in n or "" in n for n in names)
def test_slendro_system():
slendro = SYSTEMS["slendro"]
assert slendro.semitones == 5
ji = Tone("ji", octave=4, system=slendro)
# 5 steps = octave
ji_up = ji.add(5)
assert ji_up.frequency == pytest.approx(ji.frequency * 2, rel=1e-3)
def test_pelog_system():
pelog = SYSTEMS["pelog"]
assert pelog.semitones == 9
ts = TonedScale(system=pelog, tonic=Tone("ji", octave=4, system=pelog))
full_pelog = ts["pelog"]
assert len(full_pelog.tones) == 8
def test_thai_system():
thai = SYSTEMS["thai"]
assert thai.semitones == 7
do = Tone("do", octave=4, system=thai)
# 7 steps = octave
do_up = do.add(7)
assert do_up.frequency == pytest.approx(do.frequency * 2, rel=1e-3)
def test_turkish_makam_system():
makam = SYSTEMS["makam"]
assert makam.semitones == 53
ts = TonedScale(system=makam, tonic=Tone("Do", octave=4, system=makam))
rast = ts["rast"]
assert len(rast.tones) == 8
def test_carnatic_system():
carnatic = SYSTEMS["carnatic"]
assert carnatic.semitones == 72
ts = TonedScale(system=carnatic, tonic=Tone("Sa", octave=4, system=carnatic))
shankarabharanam = ts["shankarabharanam"]
assert len(shankarabharanam.tones) == 8
def test_circle_of_fifths_19tet():
sys19 = SYSTEMS["19-tet"]
c = Tone("C", octave=4, system=sys19)
cof = c.circle_of_fifths()
assert len(cof) == 19 # should cycle through all 19 tones
def test_circle_of_fifths_western_unchanged():
"""Existing 12-TET circle of fifths should not be affected."""
c = Tone("C", octave=4, system="western")
cof = c.circle_of_fifths()
assert len(cof) == 12
assert cof[0].name == "C"
assert cof[1].name == "G"
def test_from_frequency_non12():
sys19 = SYSTEMS["19-tet"]
t = Tone.from_frequency(440.0, system=sys19)
assert t.name == "A"
assert t.octave == 4
def test_score_system_param():
"""Score passes system to parts for string→Tone resolution."""
from pytheory import Score, Duration
shruti = SYSTEMS["shruti"]
score = Score("4/4", bpm=120, system=shruti)
p = score.part("test", synth="sine")
assert p._system is shruti
# String "Sa" should resolve via shruti system, not western
p.add(Tone("Sa", octave=4, system=shruti), Duration.QUARTER)
assert len(p.notes) == 1
def test_interval_to_non12():
sys19 = SYSTEMS["19-tet"]
a = Tone("A", octave=4, system=sys19)
a5 = a.add(19)
result = a.interval_to(a5)
assert "octave" in result
# ── Dedicated instrument synths ──────────────────────────────────────────────
def test_all_dedicated_synths_render():
"""Every dedicated synth waveform produces valid audio."""
from pytheory.play import (piano_wave, bass_guitar_wave, flute_wave,
trumpet_wave, clarinet_wave, oboe_wave,
marimba_wave, harpsichord_wave, cello_wave,
harp_wave, upright_bass_wave,
acoustic_guitar_wave, electric_guitar_wave,
sitar_wave, SAMPLE_RATE)
synths = [piano_wave, bass_guitar_wave, flute_wave, trumpet_wave,
clarinet_wave, oboe_wave, marimba_wave, harpsichord_wave,
cello_wave, harp_wave, upright_bass_wave,
acoustic_guitar_wave, electric_guitar_wave, sitar_wave]
for fn in synths:
wave = fn(440, n_samples=11025)
assert len(wave) == 11025
assert wave.dtype == numpy.int16
assert numpy.abs(wave).max() > 0
def test_piano_brightness_scales():
"""High-pitched piano should be brighter (more high harmonics)."""
from pytheory.play import piano_wave
low = piano_wave(130, n_samples=22050) # C3
high = piano_wave(1047, n_samples=22050) # C6
# Both should produce valid audio
assert numpy.abs(low).max() > 0
assert numpy.abs(high).max() > 0
def test_acoustic_guitar_body_resonance():
"""Acoustic guitar should produce richer spectrum than raw pluck."""
from pytheory.play import acoustic_guitar_wave, pluck_wave
ag = acoustic_guitar_wave(220, n_samples=22050)
pk = pluck_wave(220, n_samples=22050)
assert len(ag) == len(pk) == 22050
def test_cello_has_vibrato():
"""Cello synth should produce pitch variation (vibrato)."""
from pytheory.play import cello_wave
wave = cello_wave(220, n_samples=44100)
assert len(wave) == 44100
assert numpy.abs(wave).max() > 0
# ── Cabinet simulation ───────────────────────────────────────────────────────
def test_cabinet_reduces_highs():
"""Cabinet sim should reduce high-frequency content."""
from pytheory.play import _apply_cabinet
# White noise has flat spectrum
noise = numpy.random.uniform(-1, 1, 44100).astype(numpy.float32)
cabbed = _apply_cabinet(noise, brightness=0.5)
# RMS of cabbed should be lower (energy removed by filters)
assert numpy.sqrt(numpy.mean(cabbed ** 2)) < numpy.sqrt(numpy.mean(noise ** 2))
def test_cabinet_brightness_param():
"""Higher brightness = more high-frequency content passes through."""
from pytheory.play import _apply_cabinet
noise = numpy.random.uniform(-1, 1, 44100).astype(numpy.float32)
dark = _apply_cabinet(noise, brightness=0.0)
bright = _apply_cabinet(noise, brightness=1.0)
# Bright should have more energy than dark
assert numpy.sqrt(numpy.mean(bright ** 2)) > numpy.sqrt(numpy.mean(dark ** 2))
# ── Analog drift ─────────────────────────────────────────────────────────────
def test_analog_drift_varies_pitch():
"""Analog drift should make repeated renders slightly different."""
from pytheory import Score, Duration
score1 = Score("4/4", bpm=120)
p1 = score1.part("t", synth="saw", analog=0.5)
p1.add("C4", Duration.QUARTER)
p1.add("C4", Duration.QUARTER)
# With analog > 0, each C4 gets a random pitch offset
# This is hard to test deterministically, just verify it renders
from pytheory.play import render_score
buf = render_score(score1)
assert len(buf) > 0
# ── Guitar strumming ─────────────────────────────────────────────────────────
def test_strum_requires_fretboard():
"""Strumming without a fretboard should raise ValueError."""
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
p = score.part("g", synth="saw")
with pytest.raises(ValueError, match="fretboard"):
p.strum("Am", Duration.QUARTER)
def test_strum_adds_notes():
"""Strumming should add notes to the part."""
from pytheory import Score, Duration, Fretboard
score = Score("4/4", bpm=120)
fb = Fretboard.guitar()
p = score.part("g", instrument="acoustic_guitar", fretboard=fb)
p.strum("Am", Duration.HALF)
assert len(p.notes) > 0
def test_strum_direction():
"""Both down and up strums should work."""
from pytheory import Score, Duration, Fretboard
score = Score("4/4", bpm=120)
fb = Fretboard.guitar()
p = score.part("g", instrument="acoustic_guitar", fretboard=fb)
p.strum("G", Duration.QUARTER, direction="down")
p.strum("G", Duration.QUARTER, direction="up")
assert len(p.notes) >= 2 # grace notes + chord per strum
# ── World drums ──────────────────────────────────────────────────────────────
def test_tabla_sounds_render():
"""All tabla drum sounds should produce valid audio."""
from pytheory.play import _render_drum_hit
from pytheory.rhythm import DrumSound
for sound in [DrumSound.TABLA_NA, DrumSound.TABLA_TIN, DrumSound.TABLA_GE,
DrumSound.TABLA_DHA, DrumSound.TABLA_TIT, DrumSound.TABLA_KE]:
wave = _render_drum_hit(sound.value, 22050)
assert len(wave) == 22050
assert wave.dtype == numpy.float32
def test_dhol_sounds_render():
from pytheory.play import _render_drum_hit
from pytheory.rhythm import DrumSound
for sound in [DrumSound.DHOL_DAGGA, DrumSound.DHOL_TILLI, DrumSound.DHOL_BOTH]:
wave = _render_drum_hit(sound.value, 22050)
assert len(wave) == 22050
def test_mridangam_sounds_render():
from pytheory.play import _render_drum_hit
from pytheory.rhythm import DrumSound
for sound in [DrumSound.MRIDANGAM_THAM, DrumSound.MRIDANGAM_NAM,
DrumSound.MRIDANGAM_DIN, DrumSound.MRIDANGAM_THA]:
wave = _render_drum_hit(sound.value, 22050)
assert len(wave) == 22050
def test_djembe_sounds_render():
from pytheory.play import _render_drum_hit
from pytheory.rhythm import DrumSound
for sound in [DrumSound.DJEMBE_BASS, DrumSound.DJEMBE_TONE, DrumSound.DJEMBE_SLAP]:
wave = _render_drum_hit(sound.value, 22050)
assert len(wave) == 22050
def test_metal_kit_sounds_render():
from pytheory.play import _render_drum_hit
from pytheory.rhythm import DrumSound
for sound in [DrumSound.METAL_KICK, DrumSound.METAL_SNARE, DrumSound.METAL_HAT]:
wave = _render_drum_hit(sound.value, 22050)
assert len(wave) == 22050
def test_tabla_pattern_presets():
"""All tabla patterns should load without error."""
from pytheory.rhythm import Pattern
for name in ["teental", "jhaptaal", "rupak", "dadra",
"keherwa", "tabla solo", "tiri kita"]:
p = Pattern.preset(name)
assert p.beats > 0
def test_world_drum_pattern_presets():
"""All world drum patterns should load."""
from pytheory.rhythm import Pattern
for name in ["bhangra", "dhol chaal", "qawwali", "dholak folk",
"adi talam", "mridangam korvai", "djembe", "kuku", "soli",
"double kick", "metal blast", "metal groove", "metal gallop"]:
p = Pattern.preset(name)
assert p.beats > 0
# ── Guitar presets with cabinet sim ──────────────────────────────────────────
def test_guitar_presets_have_cabinet():
"""Distorted guitar presets should have cabinet simulation."""
from pytheory import Score
for preset in ["distorted_guitar", "orange_crunch", "metal_guitar"]:
score = Score("4/4", bpm=120)
p = score.part("g", instrument=preset)
assert p.cabinet > 0, f"{preset} should have cabinet sim"
def test_clean_guitar_preset():
from pytheory import Score
score = Score("4/4", bpm=120)
p = score.part("g", instrument="clean_guitar")
assert p.synth == "electric_guitar_synth"
assert p.cabinet > 0
# ── New instrument synths (v0.36+) ──────────────────────────────────────────
def test_new_synths_render():
"""All 7 new synths produce valid audio."""
from pytheory.play import (pedal_steel_wave, theremin_wave, kalimba_wave,
steel_drum_wave, accordion_wave,
didgeridoo_wave, bagpipe_wave,
banjo_wave, mandolin_wave, ukulele_wave,
vocal_wave, SAMPLE_RATE)
synths = [pedal_steel_wave, theremin_wave, kalimba_wave, steel_drum_wave,
accordion_wave, didgeridoo_wave, bagpipe_wave,
banjo_wave, mandolin_wave, ukulele_wave, vocal_wave]
for fn in synths:
wave = fn(440, n_samples=11025)
assert len(wave) == 11025
assert wave.dtype == numpy.int16
assert numpy.abs(wave).max() > 0
def test_vocal_synth_with_lyric():
"""Vocal synth accepts lyric parameter."""
from pytheory.play import vocal_wave
for lyric in ["ah", "ee", "oh", "oo", "hi", "la"]:
wave = vocal_wave(330, n_samples=11025, lyric=lyric)
assert len(wave) == 11025
assert numpy.abs(wave).max() > 0
def test_vocal_different_vowels_differ():
"""Different vowels should produce different waveforms."""
from pytheory.play import vocal_wave
ah = vocal_wave(330, n_samples=22050, lyric="ah")
ee = vocal_wave(330, n_samples=22050, lyric="ee")
# They should differ (different formant peaks)
assert not numpy.array_equal(ah, ee)
def test_all_instrument_presets_create():
"""Every instrument preset in INSTRUMENTS should create a valid Part."""
from pytheory import Score
from pytheory.rhythm import INSTRUMENTS
for name in INSTRUMENTS:
score = Score("4/4", bpm=120)
p = score.part("test", instrument=name)
assert p.synth is not None
def test_new_instrument_presets():
"""New instrument presets have correct synths."""
from pytheory import Score
presets = {
"pedal_steel": "pedal_steel_synth",
"theremin": "theremin_synth",
"kalimba": "kalimba_synth",
"steel_drum": "steel_drum_synth",
"accordion": "accordion_synth",
"didgeridoo": "didgeridoo_synth",
"bagpipe": "bagpipe_synth",
"banjo": "banjo_synth",
"mandolin": "mandolin_synth",
"ukulele": "ukulele_synth",
}
for name, expected_synth in presets.items():
score = Score("4/4", bpm=120)
p = score.part("t", instrument=name)
assert p.synth == expected_synth, f"{name} has {p.synth}, expected {expected_synth}"
# ── Cajón drums ─────────────────────────────────────────────────────────────
def test_cajon_sounds_render():
from pytheory.play import _render_drum_hit
from pytheory.rhythm import DrumSound
for sound in [DrumSound.CAJON_BASS, DrumSound.CAJON_SLAP, DrumSound.CAJON_TAP]:
wave = _render_drum_hit(sound.value, 22050)
assert len(wave) == 22050
assert wave.dtype == numpy.float32
def test_cajon_patterns():
from pytheory.rhythm import Pattern
for name in ["cajon", "cajon rumba", "cajon folk"]:
p = Pattern.preset(name)
assert p.beats > 0
# ── Pitch bends ─────────────────────────────────────────────────────────────
def test_pitch_bend_renders():
"""Pitch bend should produce valid audio without errors."""
from pytheory import Score, Duration
from pytheory.play import render_score
score = Score("4/4", bpm=120)
p = score.part("t", instrument="electric_guitar")
p.add("A4", Duration.HALF, bend=2, bend_type="smooth")
p.add("A4", Duration.HALF, bend=-1, bend_type="late")
p.add("A4", Duration.HALF, bend=3, bend_type="linear")
p.add("A4", Duration.HALF)
buf = render_score(score)
assert len(buf) > 0
def test_pitch_bend_types():
"""All three bend types should work."""
from pytheory.rhythm import Note, Duration
for bt in ["smooth", "linear", "late"]:
n = Note(tone=None, duration=Duration.QUARTER, bend=2, bend_type=bt)
assert n.bend_type == bt
# ── Roll method ─────────────────────────────────────────────────────────────
def test_roll_adds_notes():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
p = score.part("t", instrument="timpani")
p.roll("C3", Duration.WHOLE, velocity_start=30, velocity_end=100)
assert len(p.notes) > 4 # should be many 16th notes
def test_roll_velocity_ramp():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
p = score.part("t", instrument="timpani")
p.roll("C3", Duration.WHOLE, velocity_start=20, velocity_end=100)
velocities = [n.velocity for n in p.notes]
# First should be quieter than last
assert velocities[0] < velocities[-1]
def test_roll_custom_speed():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
p = score.part("t", synth="sine")
p.roll("A4", Duration.WHOLE, speed=0.125) # 32nd notes
# 4 beats / 0.125 = 32 notes
assert len(p.notes) == 32
# ── Int tone names ──────────────────────────────────────────────────────────
def test_int_tone_name():
from pytheory import Tone, TET
edo = TET(22)
t = Tone(0, octave=4, system=edo)
assert t.name == "0"
assert t.frequency == pytest.approx(440.0, rel=1e-3)
def test_int_tone_wrapping():
from pytheory import Tone, TET
edo = TET(22)
t = Tone(22, octave=4, system=edo)
assert t.name == "0"
assert t.octave == 5
assert t.frequency == pytest.approx(880.0, rel=1e-3)
def test_int_tone_negative():
from pytheory import Tone, TET
edo = TET(22)
t = Tone(-1, octave=4, system=edo)
assert t.name == "21"
assert t.octave == 3
def test_system_tone_method():
from pytheory import TET
edo = TET(19)
t = edo.tone(5, octave=4)
assert t.name == "5"
assert t.octave == 4
# ── B#/Cb octave boundary ──────────────────────────────────────────────────
def test_b_sharp_octave():
t = Tone("B#4")
assert t.octave == 5
assert t.frequency == pytest.approx(Tone("C5").frequency, rel=1e-3)
def test_c_flat_octave():
t = Tone("Cb4")
assert t.octave == 3
assert t.frequency == pytest.approx(Tone("B3").frequency, rel=1e-3)
# ── Note choking ────────────────────────────────────────────────────────────
def test_note_choking_renders():
"""Fast repeated notes should render without errors (choking active)."""
from pytheory import Score, Duration
from pytheory.play import render_score
score = Score("4/4", bpm=200)
p = score.part("t", instrument="piano")
for _ in range(32):
p.add("C4", Duration.SIXTEENTH)
buf = render_score(score)
assert len(buf) > 0
# ── Score system/temperament ───────────────────────────────────────────────
def test_score_temperament():
from pytheory import Score
score = Score("4/4", bpm=120, temperament="just")
assert score.temperament == "just"
def test_score_reference_pitch():
from pytheory import Score
score = Score("4/4", bpm=120, reference_pitch=415.0)
assert score.reference_pitch == 415.0
def test_score_system_propagates():
from pytheory import Score, SYSTEMS
shruti = SYSTEMS["shruti"]
score = Score("4/4", bpm=120, system=shruti)
p = score.part("t", synth="sine")
assert p._system is shruti
# ── Synth enum count ────────────────────────────────────────────────────────
def test_synth_enum_count():
from pytheory.play import Synth
assert len(Synth) == 56
def test_all_synths_render_and_enum_match():
"""Every Synth enum member should render valid audio."""
from pytheory.play import Synth
for s in Synth:
wave = s(440, n_samples=1000)
assert len(wave) == 1000
# ── Articulations ────────────────────────────────────────────────────────
def test_articulation_field_on_note():
from pytheory.rhythm import Note, Duration
n = Note(tone=None, duration=Duration.QUARTER, articulation="staccato")
assert n.articulation == "staccato"
def test_articulation_default_empty():
from pytheory.rhythm import Note, Duration
n = Note(tone=None, duration=Duration.QUARTER)
assert n.articulation == ""
def test_part_add_articulation():
score = pytheory.Score("4/4", bpm=120)
p = score.part("test", synth="sine")
p.add("C4", Duration.QUARTER, articulation="staccato")
p.add("D4", Duration.QUARTER, articulation="legato")
p.add("E4", Duration.QUARTER, articulation="marcato")
p.add("F4", Duration.QUARTER, articulation="tenuto")
p.add("G4", Duration.QUARTER, articulation="accent")
p.add("A4", Duration.QUARTER, articulation="fermata")
assert len(p.notes) == 6
assert p.notes[0].articulation == "staccato"
assert p.notes[5].articulation == "fermata"
@needs_portaudio
def test_articulations_render():
"""Articulations should produce audio without errors."""
from pytheory.play import render_score
score = pytheory.Score("4/4", bpm=120)
p = score.part("test", synth="sine", volume=0.3)
for art in ["", "staccato", "legato", "marcato", "tenuto", "accent", "fermata"]:
p.add("C4", Duration.QUARTER, articulation=art)
buf = render_score(score)
assert len(buf) > 0
# ── Dynamic curves ───────────────────────────────────────────────────────
def test_crescendo_adds_notes():
score = pytheory.Score("4/4", bpm=120)
p = score.part("test", synth="sine")
p.crescendo(["C4", "D4", "E4", "F4"], Duration.QUARTER,
start_vel=40, end_vel=100)
assert len(p.notes) == 4
assert p.notes[0].velocity == 40
assert p.notes[3].velocity == 100
def test_decrescendo_adds_notes():
score = pytheory.Score("4/4", bpm=120)
p = score.part("test", synth="sine")
p.decrescendo(["C4", "D4", "E4", "F4"], Duration.QUARTER,
start_vel=110, end_vel=40)
assert len(p.notes) == 4
assert p.notes[0].velocity == 110
assert p.notes[3].velocity == 40
def test_swell_velocity_shape():
score = pytheory.Score("4/4", bpm=120)
p = score.part("test", synth="sine")
p.swell(["C4", "D4", "E4", "F4", "G4"], Duration.QUARTER,
low_vel=30, peak_vel=110)
assert len(p.notes) == 5
# First and last should be near low_vel
assert p.notes[0].velocity == 30
assert p.notes[4].velocity == 30
# Middle should be at or near peak
assert p.notes[2].velocity == 110
def test_dynamics_custom_velocities():
score = pytheory.Score("4/4", bpm=120)
p = score.part("test", synth="sine")
p.dynamics(["C4", "D4", "E4"], Duration.QUARTER,
velocities=[50, 100, 75])
assert p.notes[0].velocity == 50
assert p.notes[1].velocity == 100
assert p.notes[2].velocity == 75
def test_dynamics_with_articulation():
score = pytheory.Score("4/4", bpm=120)
p = score.part("test", synth="sine")
p.crescendo(["C4", "D4"], Duration.QUARTER,
start_vel=40, end_vel=100, articulation="staccato")
assert p.notes[0].articulation == "staccato"
assert p.notes[1].articulation == "staccato"
# ── Part.hit() ───────────────────────────────────────────────────────────
def test_part_hit_adds_note():
from pytheory.rhythm import DrumSound, _DrumTone
score = pytheory.Score("4/4", bpm=120)
p = score.part("kit", synth="sine")
p.hit(DrumSound.KICK, Duration.QUARTER, velocity=100)
p.hit(DrumSound.SNARE, Duration.QUARTER, velocity=90, articulation="accent")
assert len(p.notes) == 2
assert isinstance(p.notes[0].tone, _DrumTone)
assert p.notes[0].tone.sound == DrumSound.KICK
assert p.notes[1].articulation == "accent"
@needs_portaudio
def test_part_hit_renders():
"""Part.hit() drum sounds should render through the note pipeline."""
from pytheory.rhythm import DrumSound
from pytheory.play import render_score
score = pytheory.Score("4/4", bpm=120)
p = score.part("kit", synth="sine", volume=0.5)
p.hit(DrumSound.KICK, Duration.QUARTER)
p.hit(DrumSound.SNARE, Duration.QUARTER)
p.hit(DrumSound.CLOSED_HAT, Duration.QUARTER)
p.hit(DrumSound.CRASH, Duration.QUARTER)
buf = render_score(score)
assert len(buf) > 0
# ── Part.ramp() ──────────────────────────────────────────────────────────
def test_ramp_generates_automation():
score = pytheory.Score("4/4", bpm=120)
p = score.part("test", synth="saw", lowpass=200)
p.ramp(over=4.0, lowpass=8000)
# Should have generated automation points
assert len(p._automation) > 0
# First point should be near 200, last near 8000
first_lp = p._automation[0][1].get("lowpass", 0)
last_lp = p._automation[-1][1].get("lowpass", 0)
assert first_lp < 1000 # near start
assert last_lp > 7000 # near target
def test_ramp_easing_curves():
score = pytheory.Score("4/4", bpm=120)
for curve in ["linear", "ease_in", "ease_out", "ease_in_out"]:
p = score.part(f"test_{curve}", synth="saw", lowpass=200)
p.ramp(over=4.0, curve=curve, lowpass=8000)
assert len(p._automation) > 0
def test_ramp_multiple_params():
score = pytheory.Score("4/4", bpm=120)
p = score.part("test", synth="saw", lowpass=200)
p.ramp(over=4.0, lowpass=8000, reverb=0.5)
# Should have both params in automation points
last_point = p._automation[-1][1]
assert "lowpass" in last_point
assert "reverb_mix" in last_point # mapped from "reverb"
# ── Cross-choke ──────────────────────────────────────────────────────────
def test_djembe_patterns_exist():
from pytheory.rhythm import Pattern
for name in ["djembe", "kuku", "soli", "dununba", "tiriba",
"yankadi", "djansa", "mendiani"]:
p = Pattern.preset(name)
assert p.beats > 0
assert len(p.hits) > 0
def test_djembe_fills_exist():
from pytheory.rhythm import Pattern
for name in ["djembe call", "djembe roll", "djembe break"]:
f = Pattern.fill(name)
assert f.beats == 4.0
assert len(f.hits) > 0
def test_cajon_fills_exist():
from pytheory.rhythm import Pattern
for name in ["cajon flam", "cajon rumble", "cajon breakdown"]:
f = Pattern.fill(name)
assert f.beats == 4.0
assert len(f.hits) > 0
def test_metal_fills_exist():
from pytheory.rhythm import Pattern
for name in ["metal triplet", "metal blast", "metal cascade"]:
f = Pattern.fill(name)
assert f.beats == 4.0
assert len(f.hits) > 0
# ── render_score in __all__ ──────────────────────────────────────────────
def test_render_score_exported():
assert "render_score" in pytheory.__all__