mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 14:50:18 +00:00
7d56ed7a2c
Tests for articulations, dynamic curves, Part.hit(), Part.ramp(), djembe/cajón/metal patterns and fills. 882 tests total. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7364 lines
229 KiB
Python
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) == 42
|
|
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 == "pluck"
|
|
|
|
|
|
# ── 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) == 42
|
|
|
|
|
|
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__
|