Skip to main content
Glama
test_harmony.py9.81 kB
"""Tests for harmony/chord voicing.""" import pytest from composer_mcp.core.harmony import ( apply_range_constraints, close_voicing, drop2_voicing, drop3_voicing, get_intervals_from_bass, open_voicing, parse_chord_symbol, quartal_voicing, realize_chord, ) from composer_mcp.core.models import RealizeChordRequest, VoicingStyle from composer_mcp.errors import InvalidChordSymbolError class TestParseChordSymbol: """Tests for chord symbol parsing.""" def test_parse_major_triad(self): """Parse C major triad.""" cs = parse_chord_symbol("C") assert len(cs.pitches) == 3 def test_parse_major_seventh(self): """Parse Cmaj7.""" cs = parse_chord_symbol("Cmaj7") assert len(cs.pitches) == 4 def test_parse_minor_seventh(self): """Parse Dm7.""" cs = parse_chord_symbol("Dm7") assert len(cs.pitches) == 4 # Root should be D assert cs.root().name == "D" def test_parse_dominant_seventh(self): """Parse G7.""" cs = parse_chord_symbol("G7") assert len(cs.pitches) == 4 def test_parse_diminished(self): """Parse Bdim.""" cs = parse_chord_symbol("Bdim") assert cs.root().name == "B" def test_invalid_symbol_raises(self): """Invalid chord symbol raises error.""" with pytest.raises(InvalidChordSymbolError): parse_chord_symbol("XYZ123") class TestCloseVoicing: """Tests for close position voicing.""" def test_close_voicing_within_octave(self): """Close voicing spans roughly an octave.""" from music21 import pitch pitches = [pitch.Pitch("C4"), pitch.Pitch("E4"), pitch.Pitch("G4"), pitch.Pitch("B4")] voiced = close_voicing(pitches) span = voiced[-1].midi - voiced[0].midi # Close voicing should be within ~12 semitones (octave + a bit) assert span <= 14 def test_inversion_changes_bass(self): """First inversion has third in bass.""" from music21 import pitch pitches = [pitch.Pitch("C4"), pitch.Pitch("E4"), pitch.Pitch("G4")] root_pos = close_voicing(pitches, inversion=0) first_inv = close_voicing(pitches, inversion=1) # First inversion should have E as lowest assert first_inv[0].name == "E" def test_second_inversion(self): """Second inversion has fifth in bass.""" from music21 import pitch pitches = [pitch.Pitch("C4"), pitch.Pitch("E4"), pitch.Pitch("G4")] second_inv = close_voicing(pitches, inversion=2) assert second_inv[0].name == "G" class TestOpenVoicing: """Tests for open position voicing.""" def test_open_voicing_wider_than_close(self): """Open voicing has larger span than close.""" from music21 import pitch pitches = [pitch.Pitch("C4"), pitch.Pitch("E4"), pitch.Pitch("G4"), pitch.Pitch("B4")] close = close_voicing(pitches) opened = open_voicing(pitches) close_span = close[-1].midi - close[0].midi open_span = opened[-1].midi - opened[0].midi assert open_span > close_span class TestDrop2Voicing: """Tests for drop 2 voicing.""" def test_drop2_moves_second_from_top(self): """Drop 2 moves second-from-top note down an octave.""" from music21 import pitch pitches = [pitch.Pitch("C4"), pitch.Pitch("E4"), pitch.Pitch("G4"), pitch.Pitch("B4")] dropped = drop2_voicing(pitches) # Result should have wider spacing due to dropped note assert len(dropped) == 4 # G should be in a lower octave g_notes = [p for p in dropped if p.name == "G"] assert len(g_notes) == 1 class TestDrop3Voicing: """Tests for drop 3 voicing.""" def test_drop3_four_note_chord(self): """Drop 3 works on four-note chord.""" from music21 import pitch pitches = [pitch.Pitch("C4"), pitch.Pitch("E4"), pitch.Pitch("G4"), pitch.Pitch("B4")] dropped = drop3_voicing(pitches) assert len(dropped) == 4 class TestQuartalVoicing: """Tests for quartal (fourths-based) voicing.""" def test_quartal_stacks_fourths(self): """Quartal voicing stacks perfect fourths.""" from music21 import pitch root = pitch.Pitch("C4") voicing = quartal_voicing(root) assert len(voicing) == 4 # Each interval should be 5 semitones (P4) for i in range(len(voicing) - 1): interval = voicing[i + 1].midi - voicing[i].midi assert interval == 5 class TestApplyRangeConstraints: """Tests for range constraint application.""" def test_pitches_stay_in_range(self): """Pitches are adjusted to fit range.""" from music21 import pitch pitches = [pitch.Pitch("C2"), pitch.Pitch("G2"), pitch.Pitch("C3")] constrained = apply_range_constraints(pitches, "E2", "G4", "piano") for p in constrained: assert pitch.Pitch("E2").midi <= p.midi <= pitch.Pitch("G4").midi def test_guitar_max_six_notes(self): """Guitar voicing limited to 6 notes.""" from music21 import pitch pitches = [ pitch.Pitch("C3"), pitch.Pitch("E3"), pitch.Pitch("G3"), pitch.Pitch("B3"), pitch.Pitch("D4"), pitch.Pitch("F4"), pitch.Pitch("A4"), pitch.Pitch("C5"), ] constrained = apply_range_constraints(pitches, None, None, "guitar") assert len(constrained) <= 6 class TestGetIntervalsFromBass: """Tests for interval calculation.""" def test_major_triad_intervals(self): """Major triad has M3 and P5 from bass.""" from music21 import pitch pitches = [pitch.Pitch("C4"), pitch.Pitch("E4"), pitch.Pitch("G4")] intervals = get_intervals_from_bass(pitches) assert "M3" in intervals assert "P5" in intervals class TestRealizeChord: """Tests for full chord realization.""" def test_basic_realization(self): """Realize a basic chord.""" request = RealizeChordRequest(chord_symbol="Cmaj7") response = realize_chord(request) assert response.success is True assert "voicing" in response.data assert len(response.data["voicing"]["notes"]) >= 4 def test_voicing_style_applied(self): """Voicing style is reflected in analysis.""" request = RealizeChordRequest( chord_symbol="Dm7", voicing_style=VoicingStyle.DROP2, ) response = realize_chord(request) assert response.data["analysis"]["voicing_style"] == "drop2" def test_inversion_in_analysis(self): """Inversion is tracked in analysis.""" request = RealizeChordRequest( chord_symbol="C", inversion=1, ) response = realize_chord(request) assert response.data["analysis"]["inversion"] == 1 def test_slash_chord_bass(self): """Slash chord puts specified note in bass.""" request = RealizeChordRequest( chord_symbol="G", bass_note="D3", ) response = realize_chord(request) # First note should be D assert response.data["voicing"]["notes"][0].startswith("D") def test_instrument_range_respected(self): """SATB range is respected.""" from music21 import pitch request = RealizeChordRequest( chord_symbol="Cmaj7", instrument="satb", ) response = realize_chord(request) for note_str in response.data["voicing"]["notes"]: note_midi = pitch.Pitch(note_str).midi # SATB range is E2 to A5 assert pitch.Pitch("E2").midi <= note_midi <= pitch.Pitch("A5").midi def test_alternatives_provided(self): """Response includes alternative voicings.""" request = RealizeChordRequest(chord_symbol="Am7") response = realize_chord(request) assert "alternatives" in response.data assert len(response.data["alternatives"]) >= 1 def test_musicxml_included(self): """MusicXML is included in voicing.""" request = RealizeChordRequest(chord_symbol="F") response = realize_chord(request) assert response.data["voicing"]["musicxml"] is not None assert "<?xml" in response.data["voicing"]["musicxml"] def test_midi_pitches_included(self): """MIDI pitch numbers are included.""" request = RealizeChordRequest(chord_symbol="C") response = realize_chord(request) midi_pitches = response.data["voicing"]["midi_pitches"] assert len(midi_pitches) > 0 assert all(isinstance(p, int) for p in midi_pitches) def test_quartal_voicing(self): """Quartal voicing creates fourths-based structure.""" request = RealizeChordRequest( chord_symbol="C", voicing_style=VoicingStyle.QUARTAL, ) response = realize_chord(request) assert response.success is True midi_pitches = response.data["voicing"]["midi_pitches"] # Quartal voicing stacks P4s (5 semitones each) for i in range(len(midi_pitches) - 1): interval = midi_pitches[i + 1] - midi_pitches[i] assert interval == 5 def test_custom_range(self): """Custom range is respected.""" from music21 import pitch request = RealizeChordRequest( chord_symbol="Cmaj7", range_low="C3", range_high="C4", ) response = realize_chord(request) for note_str in response.data["voicing"]["notes"]: note_midi = pitch.Pitch(note_str).midi assert pitch.Pitch("C3").midi <= note_midi <= pitch.Pitch("C4").midi

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/viktorkelemen/music21-composer-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server