Skip to main content
Glama
test_melody.py8.73 kB
"""Tests for melody generation.""" import pytest from composer_mcp.core.melody import ( generate_melody, generate_rhythm_pattern, get_contour_bias, get_scale_pitches_in_range, parse_key_signature, select_next_pitch, ) from composer_mcp.core.models import ContourType, MelodyRequest, RhythmicDensity from composer_mcp.errors import InvalidKeyError, InvalidRangeError, UnsatisfiableConstraintsError class TestParseKeySignature: """Tests for key signature parsing.""" def test_parse_major_key(self): """Parse C major.""" k = parse_key_signature("C major") assert k.tonic.name == "C" assert k.mode == "major" def test_parse_minor_key(self): """Parse A minor.""" k = parse_key_signature("A minor") assert k.tonic.name == "A" assert k.mode == "minor" def test_parse_modal_key(self): """Parse D dorian.""" k = parse_key_signature("D dorian") assert k.tonic.name == "D" assert k.mode == "dorian" def test_parse_sharps(self): """Parse F# minor.""" k = parse_key_signature("F# minor") assert "F#" in k.tonic.name or k.tonic.name == "F#" def test_invalid_format_raises(self): """Invalid format raises InvalidKeyError.""" with pytest.raises(InvalidKeyError): parse_key_signature("invalid") class TestGetScalePitchesInRange: """Tests for scale pitch extraction.""" def test_c_major_one_octave(self): """C major from C4 to C5 gives 8 notes.""" from music21 import key k = key.Key("C", "major") pitches = get_scale_pitches_in_range(k, "C4", "C5") assert len(pitches) == 8 assert pitches[0].nameWithOctave == "C4" assert pitches[-1].nameWithOctave == "C5" def test_range_too_narrow(self): """Range where low >= high raises error.""" from music21 import key k = key.Key("C", "major") with pytest.raises(InvalidRangeError): get_scale_pitches_in_range(k, "C5", "C4") def test_pitches_are_sorted(self): """Returned pitches are in ascending order.""" from music21 import key k = key.Key("G", "major") pitches = get_scale_pitches_in_range(k, "G3", "G5") for i in range(len(pitches) - 1): assert pitches[i].midi < pitches[i + 1].midi class TestGenerateRhythmPattern: """Tests for rhythm pattern generation.""" def test_sparse_rhythm_length(self): """Sparse rhythm fills correct duration.""" import random rng = random.Random(42) rhythm = generate_rhythm_pattern(RhythmicDensity.SPARSE, "4/4", 2, rng) # 2 measures of 4/4 = 8 quarter notes assert abs(sum(rhythm) - 8.0) < 0.01 def test_dense_has_more_notes(self): """Dense rhythm has more notes than sparse.""" import random rng = random.Random(42) sparse = generate_rhythm_pattern(RhythmicDensity.SPARSE, "4/4", 4, rng) rng = random.Random(42) dense = generate_rhythm_pattern(RhythmicDensity.DENSE, "4/4", 4, rng) assert len(dense) > len(sparse) def test_different_time_signature(self): """3/4 time signature produces correct length.""" import random rng = random.Random(42) rhythm = generate_rhythm_pattern(RhythmicDensity.MEDIUM, "3/4", 4, rng) # 4 measures of 3/4 = 12 quarter notes assert abs(sum(rhythm) - 12.0) < 0.01 class TestGetContourBias: """Tests for contour bias calculation.""" def test_arch_early_is_up(self): """Arch contour early in phrase has upward bias.""" bias = get_contour_bias(0.2, ContourType.ARCH) assert bias > 0 def test_arch_late_is_down(self): """Arch contour late in phrase has downward bias.""" bias = get_contour_bias(0.8, ContourType.ARCH) assert bias < 0 def test_ascending_always_up(self): """Ascending contour always biases up.""" for pos in [0.0, 0.5, 1.0]: assert get_contour_bias(pos, ContourType.ASCENDING) > 0 def test_descending_always_down(self): """Descending contour always biases down.""" for pos in [0.0, 0.5, 1.0]: assert get_contour_bias(pos, ContourType.DESCENDING) < 0 def test_static_is_neutral(self): """Static contour has no bias.""" assert get_contour_bias(0.5, ContourType.STATIC) == 0 def test_none_is_neutral(self): """No contour has no bias.""" assert get_contour_bias(0.5, None) == 0 class TestGenerateMelody: """Tests for full melody generation.""" def test_basic_generation(self): """Generate a basic melody.""" request = MelodyRequest( key="C major", length_measures=2, time_signature="4/4", seed=42, ) response = generate_melody(request) assert response.success is True assert "melody" in response.data assert response.data["metadata"]["measures"] == 2 def test_seed_reproducibility(self): """Same seed produces same melody.""" request = MelodyRequest( key="C major", length_measures=4, seed=123, ) r1 = generate_melody(request) r2 = generate_melody(request) assert r1.data["melody"]["notes"] == r2.data["melody"]["notes"] def test_range_constraint_respected(self): """Generated notes stay within range.""" request = MelodyRequest( key="C major", length_measures=4, range_low="E4", range_high="G5", seed=42, ) response = generate_melody(request) from music21 import pitch low_midi = pitch.Pitch("E4").midi high_midi = pitch.Pitch("G5").midi for note in response.data["melody"]["notes"]: note_midi = pitch.Pitch(note["pitch"]).midi assert low_midi <= note_midi <= high_midi def test_start_note_constraint(self): """Start note is respected when in scale.""" request = MelodyRequest( key="C major", length_measures=2, start_note="E4", seed=42, ) response = generate_melody(request) first_note = response.data["melody"]["notes"][0]["pitch"] assert first_note == "E4" def test_end_note_constraint(self): """End note is respected when in scale.""" request = MelodyRequest( key="C major", length_measures=2, end_note="G4", seed=42, ) response = generate_melody(request) last_note = response.data["melody"]["notes"][-1]["pitch"] assert last_note == "G4" def test_narrow_range_fails(self): """Too narrow range raises error.""" request = MelodyRequest( key="C major", length_measures=2, range_low="C4", range_high="D4", # Only 2 scale tones ) with pytest.raises(UnsatisfiableConstraintsError): generate_melody(request) def test_contour_influences_shape(self): """Arch contour produces expected melodic shape.""" request = MelodyRequest( key="C major", length_measures=4, contour=ContourType.ARCH, range_low="C4", range_high="C6", seed=42, ) response = generate_melody(request) notes = response.data["melody"]["notes"] # Find highest note - should be in middle third from music21 import pitch midi_values = [pitch.Pitch(n["pitch"]).midi for n in notes] max_idx = midi_values.index(max(midi_values)) relative_pos = max_idx / len(notes) # Arch peak should be roughly in middle (0.3-0.7) assert 0.2 <= relative_pos <= 0.8 def test_warning_for_adjusted_start_note(self): """Non-scale start note produces warning.""" request = MelodyRequest( key="C major", length_measures=2, start_note="C#4", # Not in C major seed=42, ) response = generate_melody(request) assert response.success is True assert len(response.warnings) > 0 assert any(w.code == "START_NOTE_ADJUSTED" for w in response.warnings) def test_metadata_includes_seed(self): """Response includes seed used.""" request = MelodyRequest( key="G major", length_measures=2, ) response = generate_melody(request) assert response.data["metadata"]["seed_used"] is not None

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