Skip to main content
Glama
TECH_SPEC.md39.8 kB
# Music21 Composer MCP - Technical Specification > **Version**: 0.1.0-draft > **Last Updated**: 2025-12-25 > **Status**: Ready for implementation ## Overview A composition-focused MCP server built on music21, designed for **generative** workflows rather than analysis. While the existing `music21-mcp-server` excels at analysis (key detection, harmony analysis, voice leading checks), this MCP focuses on **creating** music with AI assistance. ### Goals 1. Enable Claude to generate musical content with theoretical correctness 2. Provide constraint-based composition tools 3. Support iterative composition workflows (generate → refine → generate) 4. Export results in usable formats (MIDI, MusicXML) ### Non-Goals - Real-time audio synthesis (out of scope) - DAW integration (future consideration) - Full analysis suite (use existing MCP for that) - ML-based style inference (v1 uses explicit transformations only) --- ## Architecture ### Design Principles 1. **Stateless**: No server-side session storage. Claude holds intermediate results and passes them between calls. Simpler, more reliable, easier to test. 2. **Multi-interface**: MCP has ~40-50% reliability in production. HTTP fallback ensures tools remain accessible during development and when MCP fails. 3. **Fail gracefully**: Return best-effort results with warnings rather than hard failures when possible. ``` ┌─────────────────────────────────────────────────────────┐ │ Unified Launcher │ ├─────────────────────────────────────────────────────────┤ │ MCP Adapter │ HTTP Adapter │ CLI Adapter │ ├─────────────────────────────────────────────────────────┤ │ Core Composition Service │ │ (Protocol-independent music21 logic) │ ├─────────────────────────────────────────────────────────┤ │ music21 Library │ └─────────────────────────────────────────────────────────┘ ``` ### Performance Considerations **music21 Cold Start**: music21 loads corpus data on first import (~2-3 seconds). Mitigations: - Launcher pre-imports music21 on startup - HTTP/MCP servers keep process warm - Document expected cold start in README **Response Time SLAs**: | Operation | Input Size | Target | |-----------|-----------|--------| | `realize_chord` | Single chord | <100ms | | `generate_melody` | 8 measures | <500ms | | `generate_melody` | 32 measures | <2s | | `reharmonize` | 16 measures | <1s | | `add_voice` | 16 measures | <1s | | `export_midi` | Any | <200ms | ### Project Structure ``` music21-composer-mcp/ ├── src/ │ └── composer_mcp/ │ ├── __init__.py │ ├── core/ │ │ ├── __init__.py │ │ ├── service.py # Main composition service │ │ ├── melody.py # Melody generation logic │ │ ├── harmony.py # Reharmonization, chord voicing │ │ ├── counterpoint.py # Voice generation │ │ ├── transforms.py # Phrase transformation operations │ │ ├── validation.py # Input validation, constraint checking │ │ ├── scoring.py # Voice leading scoring algorithms │ │ └── models.py # Pydantic data models │ ├── adapters/ │ │ ├── __init__.py │ │ ├── mcp_adapter.py # FastMCP server │ │ ├── http_adapter.py # FastAPI REST API │ │ └── cli_adapter.py # Interactive CLI │ ├── errors.py # Error types and codes │ └── launcher.py # Unified entry point ├── tests/ │ ├── test_melody.py │ ├── test_harmony.py │ ├── test_counterpoint.py │ ├── test_validation.py │ └── test_integration.py ├── examples/ │ └── composition_workflows.py ├── pyproject.toml ├── README.md └── TECH_SPEC.md ``` --- ## Error Model All tools return responses conforming to this schema: ### Success Response ```json { "success": true, "data": { ... }, "warnings": [] } ``` ### Failure Response ```json { "success": false, "error": { "code": "INVALID_KEY", "message": "Unknown key signature: 'H major'. Valid examples: 'C major', 'D minor', 'F# dorian'", "field": "key", "suggestions": ["C major", "B major", "A major"] }, "partial_result": null } ``` ### Partial Success Response When generation partially succeeds (e.g., 6 of 8 requested measures generated before constraint violation): ```json { "success": true, "data": { ... }, "warnings": [ { "code": "CONSTRAINT_RELAXED", "message": "Could not maintain stepwise motion in measure 7; used P4 leap", "measure": 7 } ] } ``` ### Error Codes | Code | Description | Applicable Tools | |------|-------------|------------------| | `INVALID_KEY` | Unrecognized key signature | All | | `INVALID_NOTE` | Malformed note name (e.g., "X4") | All | | `INVALID_RANGE` | Low > high, or impossible range | generate_melody, add_voice | | `INVALID_INTERVAL` | Malformed interval (e.g., "X5") | generate_melody | | `INVALID_CHORD_SYMBOL` | Unparseable chord | realize_chord, reharmonize | | `INVALID_TIME_SIGNATURE` | Malformed time sig | generate_melody | | `PARSE_ERROR` | Could not parse input stream | continue_phrase, reharmonize, add_voice | | `UNSATISFIABLE_CONSTRAINTS` | Constraints cannot be met | generate_melody | | `GENERATION_FAILED` | Max attempts exceeded | All generative tools | | `EMPTY_INPUT` | Required input is empty | continue_phrase, reharmonize, add_voice | --- ## Input Format Detection For tools accepting musical input (`continue_phrase`, `reharmonize`, `add_voice`, `export_midi`): ### Detection Heuristics ```python def detect_format(input_string: str) -> str: stripped = input_string.strip() # MusicXML: starts with XML declaration or root element if stripped.startswith('<?xml') or stripped.startswith('<score'): return 'musicxml' # ABC: starts with field (X:, T:, M:, K:, etc.) if re.match(r'^[A-Z]:', stripped): return 'abc' # Note list: comma or space separated pitch names # e.g., "C4, D4, E4" or "C4 D4 E4" if re.match(r'^[A-Ga-g][#b]?\d', stripped): return 'notes' raise ParseError("Could not detect input format. Please specify format explicitly.") ``` ### Explicit Format Parameter All input-accepting tools have an optional `input_format` parameter: - `"musicxml"` — Full MusicXML document - `"abc"` — ABC notation - `"notes"` — Simplified format: `"C4:q, D4:q, E4:h"` (pitch:duration) When `input_format` is provided, auto-detection is skipped. ### Note List Format Specification ``` note := pitch duration? pitch := [A-G] accidental? octave accidental := '#' | 'b' | '##' | 'bb' octave := [0-9] duration := ':' ('w' | 'h' | 'q' | 'e' | 's' | 'd'*) # whole, half, quarter, eighth, sixteenth # 'd' suffix = dotted (can stack: 'qd' = dotted quarter) Examples: "C4:q, D4:q, E4:h" # quarter, quarter, half "C#5:e D5:e E5:q" # eighth, eighth, quarter (space-separated ok) "Bb3:qd A3:e G3:q" # dotted quarter, eighth, quarter "C4 D4 E4 G4" # no durations = all quarter notes (default) ``` --- ## Tool Specifications ### 1. `generate_melody` Generate a melodic line based on musical constraints. #### Parameters | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `key` | string | yes | — | Key signature (e.g., "C major", "D dorian") | | `length_measures` | int | yes | — | Number of measures (1-64) | | `time_signature` | string | no | "4/4" | Time signature | | `range_low` | string | no | "C4" | Lowest allowed note | | `range_high` | string | no | "C6" | Highest allowed note | | `contour` | string | no | null | "arch", "ascending", "descending", "wave", "static" | | `rhythmic_density` | string | no | "medium" | "sparse", "medium", "dense" | | `start_note` | string | no | null | Force starting pitch | | `end_note` | string | no | null | Force ending pitch | | `avoid_leaps_greater_than` | string | no | null | Max interval (e.g., "P5") | | `prefer_stepwise` | float | no | 0.7 | Probability of stepwise motion (0.0-1.0) | | `seed` | int | no | null | Random seed for reproducibility | | `max_attempts` | int | no | 100 | Max generation attempts before failure | #### Algorithm: Constrained Weighted Random Walk ```python def generate_melody(params: MelodyRequest) -> MelodyResponse: scale = music21.scale.ConcreteScale(params.key) scale_pitches = get_pitches_in_range(scale, params.range_low, params.range_high) if len(scale_pitches) < 3: raise UnsatisfiableConstraints("Range too narrow for scale") rng = random.Random(params.seed) rhythm_pattern = generate_rhythm(params.rhythmic_density, params.time_signature, params.length_measures, rng) for attempt in range(params.max_attempts): melody = [] current_pitch = select_start_pitch(params, scale_pitches, rng) for i, duration in enumerate(rhythm_pattern): melody.append((current_pitch, duration)) if i < len(rhythm_pattern) - 1: current_pitch = select_next_pitch( current=current_pitch, scale_pitches=scale_pitches, position_ratio=i / len(rhythm_pattern), # for contour contour=params.contour, prefer_stepwise=params.prefer_stepwise, max_leap=params.avoid_leaps_greater_than, rng=rng ) # Validate end note constraint if params.end_note and melody[-1][0] != params.end_note: # Try to approach end note in final measures melody = adjust_ending(melody, params.end_note, scale_pitches) if validate_melody(melody, params): return build_response(melody, params) # Max attempts exceeded - return best effort with warning return build_response(best_melody, params, warnings=[...]) ``` #### Contour Implementation | Contour | Behavior | |---------|----------| | `arch` | Ascend to ~60% point, descend to end | | `ascending` | Bias toward upward motion (+0.3 to up probability) | | `descending` | Bias toward downward motion (+0.3 to down probability) | | `wave` | Alternate direction every ~2 measures | | `static` | Strong bias toward repeated notes and small motion | | `null` | No contour bias, pure weighted random | #### Rhythmic Density | Density | Typical Note Values | Notes per Measure (4/4) | |---------|--------------------|-----------------------| | `sparse` | Half, dotted half, whole | 1-2 | | `medium` | Quarter, half, dotted quarter | 2-4 | | `dense` | Eighth, quarter, dotted eighth | 4-8 | #### Response ```json { "success": true, "data": { "melody": { "musicxml": "<xml>...</xml>", "notes": [ {"pitch": "C4", "duration": "quarter", "measure": 1, "beat": 1}, {"pitch": "D4", "duration": "quarter", "measure": 1, "beat": 2} ] }, "metadata": { "measures": 8, "note_count": 24, "actual_range": "C4-G5", "key": "C major", "seed_used": 12345 } }, "warnings": [] } ``` --- ### 2. `transform_phrase` > **Renamed from `continue_phrase`** — scoped down to explicit transformations rather than AI-style inference. Apply musical transformations to extend or develop a phrase. #### Parameters | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `input_stream` | string | yes | — | Musical input (MusicXML, ABC, or notes) | | `input_format` | string | no | auto | "musicxml", "abc", "notes" | | `transformation` | string | yes | — | Transformation type (see below) | | `repetitions` | int | no | 1 | How many times to apply transformation | | `interval` | string | no | "M2" | For sequence: transposition interval | | `direction` | string | no | "up" | "up" or "down" for sequence/inversion | | `append` | bool | no | true | Append to original or return only transformed | #### Supported Transformations | Transformation | Description | Parameters Used | |----------------|-------------|-----------------| | `repeat` | Exact repetition | `repetitions` | | `sequence` | Transpose and repeat | `repetitions`, `interval`, `direction` | | `inversion` | Flip intervals around axis | `direction` (axis = first note) | | `retrograde` | Reverse note order | — | | `retrograde_inversion` | Reverse + invert | `direction` | | `augmentation` | Double durations | — | | `diminution` | Halve durations | — | | `fragment_first` | Use first N notes | `repetitions` (as note count) | | `fragment_last` | Use last N notes | `repetitions` (as note count) | #### Algorithm ```python def transform_phrase(params: TransformRequest) -> TransformResponse: stream = parse_input(params.input_stream, params.input_format) match params.transformation: case "repeat": result = stream * params.repetitions case "sequence": interval = music21.interval.Interval(params.interval) if params.direction == "down": interval = interval.reverse() result = stream.copy() current = stream.copy() for _ in range(params.repetitions): current = current.transpose(interval) result.append(current) case "inversion": axis = stream.notes[0].pitch result = stream.invertDiatonic(axis) case "retrograde": result = stream.retrograde() # ... etc if params.append: final = stream.copy() final.append(result) else: final = result return build_response(final, original=stream) ``` #### Response ```json { "success": true, "data": { "original": { "musicxml": "...", "notes": [...] }, "transformed": { "musicxml": "...", "notes": [...] }, "combined": { "musicxml": "...", "notes": [...] }, "transformation_applied": "sequence", "parameters": { "interval": "M2", "direction": "up", "repetitions": 2 } }, "warnings": [] } ``` --- ### 3. `reharmonize` Generate alternative chord progressions for a given melody. #### Parameters | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `melody` | string | yes | — | Musical input | | `input_format` | string | no | auto | Input format | | `style` | string | yes | — | "classical", "jazz", "pop", "modal" | | `chord_rhythm` | string | no | "per_measure" | "per_measure", "per_beat", "per_half" | | `num_options` | int | no | 3 | Number of harmonization options to return | | `allow_extended` | bool | no | varies | Allow 7ths, 9ths, etc. (default: true for jazz) | | `bass_motion` | string | no | "any" | "stepwise", "fifths", "pedal", "any" | #### Style Rules **Classical:** ```python CLASSICAL_RULES = { "allowed_chords": ["I", "ii", "iii", "IV", "V", "vi", "viio"], "prefer_extensions": False, "common_progressions": [ ["I", "IV", "V", "I"], ["I", "vi", "IV", "V"], ["I", "ii", "V", "I"], ], "cadence_patterns": { "perfect": ["V", "I"], "plagal": ["IV", "I"], "half": ["*", "V"], "deceptive": ["V", "vi"], }, "avoid": ["parallel_fifths", "parallel_octaves"], } ``` **Jazz:** ```python JAZZ_RULES = { "allowed_chords": ["Imaj7", "ii7", "iii7", "IVmaj7", "V7", "vi7", "vii7b5"], "prefer_extensions": True, "substitutions": { "tritone": {"V7": "bII7"}, # G7 -> Db7 "relative": {"I": "vi", "IV": "ii"}, # Cmaj7 -> Am7 "diminished": {"V7": "viio7"}, }, "common_progressions": [ ["ii7", "V7", "Imaj7"], ["iii7", "vi7", "ii7", "V7"], ["Imaj7", "vi7", "ii7", "V7"], ], "allow_chromatic_approach": True, } ``` **Pop:** ```python POP_RULES = { "allowed_chords": ["I", "ii", "IV", "V", "vi"], "prefer_extensions": False, "common_progressions": [ ["I", "V", "vi", "IV"], # "4 chords" ["I", "IV", "vi", "V"], ["vi", "IV", "I", "V"], ], "prefer_root_position": True, } ``` **Modal:** ```python MODAL_RULES = { "chord_from_mode": True, # Build chords from modal scale "avoid_tritone": True, # Preserve modal character "prefer_quartal": True, # Quartal voicings "pedal_bass_common": True, } ``` #### Algorithm ```python def reharmonize(params: ReharmonizeRequest) -> ReharmonizeResponse: melody = parse_input(params.melody, params.input_format) key = melody.analyze('key') rules = get_style_rules(params.style) chord_points = get_chord_points(melody, params.chord_rhythm) options = [] for _ in range(params.num_options * 3): # Generate extra, keep best progression = [] for i, point in enumerate(chord_points): melody_notes = get_melody_notes_at(melody, point) candidates = get_chord_candidates( melody_notes=melody_notes, key=key, rules=rules, previous_chord=progression[-1] if progression else None, is_cadence=(i >= len(chord_points) - 2) ) chord = select_chord( candidates=candidates, bass_motion_pref=params.bass_motion, previous_chord=progression[-1] if progression else None, ) progression.append(chord) score = score_progression(progression, melody, rules) options.append((progression, score)) # Keep top N by score options.sort(key=lambda x: x[1], reverse=True) return build_response(options[:params.num_options], melody, key) ``` #### Response ```json { "success": true, "data": { "detected_key": "C major", "harmonizations": [ { "rank": 1, "chords": ["Cmaj7", "Am7", "Dm7", "G7"], "roman_numerals": ["Imaj7", "vi7", "ii7", "V7"], "musicxml": "...", "scores": { "voice_leading": 0.85, "chord_melody_fit": 0.92, "style_adherence": 0.88, "overall": 0.88 } } ] }, "warnings": [] } ``` --- ### 4. `add_voice` Generate a countermelody or additional voice part. #### Parameters | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `existing_voice` | string | yes | — | Musical input | | `input_format` | string | no | auto | Input format | | `new_voice_type` | string | yes | — | "soprano", "alto", "tenor", "bass" | | `relationship` | string | no | "contrary" | Motion type (see below) | | `species` | int | no | 0 | Counterpoint species 1-5, 0=free | | `range_low` | string | no | varies | Lowest note (defaults by voice type) | | `range_high` | string | no | varies | Highest note (defaults by voice type) | | `harmonic_context` | string | no | null | Chord symbols to follow | | `seed` | int | no | null | Random seed | | `max_attempts` | int | no | 50 | Max generation attempts | #### Voice Ranges (Defaults) | Voice | Low | High | |-------|-----|------| | soprano | C4 | A5 | | alto | F3 | D5 | | tenor | C3 | A4 | | bass | E2 | E4 | #### Relationship Types | Relationship | Behavior | |--------------|----------| | `contrary` | Move opposite direction when possible | | `oblique` | Hold notes while other voice moves | | `parallel_thirds` | Stay a 3rd below/above | | `parallel_sixths` | Stay a 6th below/above | | `free` | No motion constraints, only interval rules | #### Species Counterpoint Rules ```python SPECIES_RULES = { 1: { # Note against note "rhythm": "match", # Same rhythm as cantus "consonances": ["P1", "m3", "M3", "P5", "m6", "M6", "P8"], "start_end": ["P1", "P5", "P8"], "forbidden_parallels": ["P5", "P8"], }, 2: { # Two notes against one "rhythm": "half", # Two notes per cantus note "strong_beat": ["P1", "m3", "M3", "P5", "m6", "M6", "P8"], "weak_beat": ["P1", "m2", "M2", "m3", "M3", "P4", "P5", "m6", "M6", "m7", "M7", "P8"], "passing_tones": True, }, 3: { # Four notes against one "rhythm": "quarter", "first_beat": "consonant", "passing_tones": True, "neighbor_tones": True, }, 4: { # Suspensions "syncopation": True, "suspension_types": ["4-3", "7-6", "9-8"], "preparation": "consonant", "resolution": "stepwise_down", }, 5: { # Florid (free combination) "mix_species": True, "embellishments": True, }, } ``` #### Voice Leading Score Calculation ```python def calculate_voice_leading_score(voice1: Stream, voice2: Stream) -> VoiceLeadingAnalysis: """ Returns score 0.0-1.0 where 1.0 is perfect voice leading. Penalties: - Parallel perfect 5th: -0.15 each - Parallel perfect 8ve: -0.15 each - Direct/hidden 5th/8ve: -0.05 each - Voice crossing: -0.10 each - Spacing > octave (inner voices): -0.05 each - Leap > P5 without recovery: -0.03 each - Consecutive leaps same direction: -0.02 each Starting score: 1.0 """ score = 1.0 issues = [] intervals = get_vertical_intervals(voice1, voice2) motions = get_motion_types(voice1, voice2) for i in range(1, len(intervals)): prev_interval = intervals[i-1] curr_interval = intervals[i] motion = motions[i] # Parallel fifths/octaves if motion == "parallel": if curr_interval.simpleName in ["P5", "P8"] and prev_interval.simpleName == curr_interval.simpleName: score -= 0.15 issues.append({"type": "parallel_fifth" if "5" in curr_interval.simpleName else "parallel_octave", "location": i}) # Direct fifths/octaves if motion == "similar" and curr_interval.simpleName in ["P5", "P8"]: score -= 0.05 issues.append({"type": "direct_fifth_octave", "location": i}) # ... additional checks return VoiceLeadingAnalysis( score=max(0.0, score), parallel_fifths=[i for i in issues if i["type"] == "parallel_fifth"], parallel_octaves=[i for i in issues if i["type"] == "parallel_octave"], voice_crossings=[...], spacing_issues=[...], ) ``` #### Response ```json { "success": true, "data": { "new_voice": { "musicxml": "...", "notes": [...] }, "combined_score": { "musicxml": "...", "parts": ["soprano", "alto"] }, "voice_leading_analysis": { "score": 0.92, "parallel_fifths": [], "parallel_octaves": [], "voice_crossings": [], "direct_intervals": [{"location": 5, "interval": "P5"}], "spacing_issues": [] } }, "warnings": [] } ``` --- ### 5. `realize_chord` Generate specific voicings for chord symbols. #### Parameters | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `chord_symbol` | string | yes | — | Chord name (e.g., "Cmaj7", "Dm7b5") | | `voicing_style` | string | no | "close" | "close", "open", "drop2", "drop3", "quartal" | | `instrument` | string | no | "piano" | "piano", "guitar", "satb", "strings" | | `inversion` | int | no | 0 | 0=root, 1=first, 2=second, etc. | | `bass_note` | string | no | null | Slash chord bass | | `range_low` | string | no | varies | Lowest allowed note | | `range_high` | string | no | varies | Highest allowed note | | `previous_voicing` | list | no | null | Previous chord notes for voice leading | #### Voicing Algorithms **Close Position:** ```python def close_voicing(chord: Chord, inversion: int) -> list[Pitch]: """Notes stacked within an octave, minimal spacing.""" pitches = chord.pitches # Rotate for inversion pitches = pitches[inversion:] + pitches[:inversion] # Stack within octave from bass result = [pitches[0]] for p in pitches[1:]: while p.midi <= result[-1].midi: p = p.transpose(12) result.append(p) return result ``` **Drop 2:** ```python def drop2_voicing(chord: Chord) -> list[Pitch]: """Take close voicing, drop 2nd-from-top note an octave.""" close = close_voicing(chord, 0) if len(close) >= 4: close[-2] = close[-2].transpose(-12) return sorted(close, key=lambda p: p.midi) ``` **Drop 3:** ```python def drop3_voicing(chord: Chord) -> list[Pitch]: """Take close voicing, drop 3rd-from-top note an octave.""" close = close_voicing(chord, 0) if len(close) >= 4: close[-3] = close[-3].transpose(-12) return sorted(close, key=lambda p: p.midi) ``` **Quartal:** ```python def quartal_voicing(chord: Chord) -> list[Pitch]: """Stack in 4ths instead of 3rds.""" root = chord.root() return [root, root.transpose("P4"), root.transpose("m7"), root.transpose("m10")] ``` #### Instrument Constraints | Instrument | Max Notes | Range | Spacing Rules | |------------|-----------|-------|---------------| | piano | 10 | A0-C8 | None | | guitar | 6 | E2-E6 | Max stretch ~4 frets | | satb | 4 | E2-A5 | Voice-specific ranges | | strings | 4 | C2-E6 | Double stops considered | #### Response ```json { "success": true, "data": { "voicing": { "notes": ["E2", "B3", "D4", "G4", "C5"], "midi_pitches": [40, 59, 62, 67, 72], "musicxml": "..." }, "analysis": { "chord_quality": "major_seventh", "voicing_style": "drop2", "inversion": 1, "intervals_from_bass": ["P5", "m7", "m10", "P13"] }, "alternatives": [ {"notes": [...], "style": "close"}, {"notes": [...], "style": "drop3"} ] }, "warnings": [] } ``` --- ### 6. `export_midi` Export a musical stream to MIDI format. #### Parameters | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `stream` | string | yes | — | Musical input | | `input_format` | string | no | auto | Input format | | `tempo` | int | no | 120 | BPM | | `humanize` | bool | no | false | Add timing/velocity variation | | `humanize_amount` | float | no | 0.3 | Intensity 0.0-1.0 | | `velocity_curve` | string | no | "flat" | "flat", "dynamic", "crescendo", "diminuendo" | | `include_abc` | bool | no | false | Include ABC notation in response | #### Humanization Algorithm ```python def humanize(stream: Stream, amount: float, rng: Random) -> Stream: """ Add human-like imperfections. amount=0.3 (default): - Timing: ±15ms gaussian jitter on note starts - Velocity: ±8 variation (on 0-127 scale) - Duration: ±5% variation """ timing_jitter_ms = 50 * amount # max ±15ms at 0.3 velocity_jitter = int(25 * amount) # max ±8 at 0.3 duration_jitter = 0.15 * amount # max ±5% at 0.3 for note in stream.recurse().notes: note.offset += rng.gauss(0, timing_jitter_ms / 1000) note.volume.velocity += rng.randint(-velocity_jitter, velocity_jitter) note.volume.velocity = clamp(note.volume.velocity, 1, 127) note.duration.quarterLength *= (1 + rng.uniform(-duration_jitter, duration_jitter)) return stream ``` #### Response ```json { "success": true, "data": { "midi": { "base64": "TVRoZC...", "duration_seconds": 32.5, "track_count": 2, "tempo": 120 }, "metadata": { "measures": 8, "time_signature": "4/4", "key_signature": "C major", "note_count": 48 }, "abc": null }, "warnings": [] } ``` --- ## Data Models ```python from pydantic import BaseModel, Field, field_validator from typing import Optional, Literal from enum import Enum # === Enums === class ContourType(str, Enum): ARCH = "arch" ASCENDING = "ascending" DESCENDING = "descending" WAVE = "wave" STATIC = "static" class RhythmicDensity(str, Enum): SPARSE = "sparse" MEDIUM = "medium" DENSE = "dense" class HarmonizationStyle(str, Enum): CLASSICAL = "classical" JAZZ = "jazz" POP = "pop" MODAL = "modal" class VoicingStyle(str, Enum): CLOSE = "close" OPEN = "open" DROP2 = "drop2" DROP3 = "drop3" QUARTAL = "quartal" class TransformationType(str, Enum): REPEAT = "repeat" SEQUENCE = "sequence" INVERSION = "inversion" RETROGRADE = "retrograde" RETROGRADE_INVERSION = "retrograde_inversion" AUGMENTATION = "augmentation" DIMINUTION = "diminution" FRAGMENT_FIRST = "fragment_first" FRAGMENT_LAST = "fragment_last" class VoiceType(str, Enum): SOPRANO = "soprano" ALTO = "alto" TENOR = "tenor" BASS = "bass" class MotionRelationship(str, Enum): CONTRARY = "contrary" OBLIQUE = "oblique" PARALLEL_THIRDS = "parallel_thirds" PARALLEL_SIXTHS = "parallel_sixths" FREE = "free" class InputFormat(str, Enum): MUSICXML = "musicxml" ABC = "abc" NOTES = "notes" # === Validators === NOTE_PATTERN = r'^[A-Ga-g][#b]?[0-9]$' INTERVAL_PATTERN = r'^(P|M|m|A|d)[1-9][0-9]?$' KEY_PATTERN = r'^[A-Ga-g][#b]?\s+(major|minor|dorian|phrygian|lydian|mixolydian|aeolian|locrian)$' def validate_note(v: str) -> str: if not re.match(NOTE_PATTERN, v): raise ValueError(f"Invalid note: {v}. Expected format: C4, F#5, Bb3") return v def validate_key(v: str) -> str: if not re.match(KEY_PATTERN, v, re.IGNORECASE): raise ValueError(f"Invalid key: {v}. Expected format: 'C major', 'F# minor', 'D dorian'") return v # === Request Models === class MelodyRequest(BaseModel): key: str = Field(..., description="Key signature") length_measures: int = Field(..., ge=1, le=64) time_signature: str = Field(default="4/4") range_low: str = Field(default="C4") range_high: str = Field(default="C6") contour: Optional[ContourType] = None rhythmic_density: RhythmicDensity = RhythmicDensity.MEDIUM start_note: Optional[str] = None end_note: Optional[str] = None avoid_leaps_greater_than: Optional[str] = None prefer_stepwise: float = Field(default=0.7, ge=0.0, le=1.0) seed: Optional[int] = None max_attempts: int = Field(default=100, ge=1, le=1000) _validate_key = field_validator('key')(validate_key) _validate_range_low = field_validator('range_low')(validate_note) _validate_range_high = field_validator('range_high')(validate_note) _validate_start = field_validator('start_note')(lambda v: validate_note(v) if v else v) _validate_end = field_validator('end_note')(lambda v: validate_note(v) if v else v) class TransformRequest(BaseModel): input_stream: str = Field(..., min_length=1) input_format: Optional[InputFormat] = None transformation: TransformationType repetitions: int = Field(default=1, ge=1, le=16) interval: str = Field(default="M2") direction: Literal["up", "down"] = "up" append: bool = True class ReharmonizeRequest(BaseModel): melody: str = Field(..., min_length=1) input_format: Optional[InputFormat] = None style: HarmonizationStyle chord_rhythm: Literal["per_measure", "per_beat", "per_half"] = "per_measure" num_options: int = Field(default=3, ge=1, le=10) allow_extended: Optional[bool] = None # defaults based on style bass_motion: Literal["stepwise", "fifths", "pedal", "any"] = "any" class AddVoiceRequest(BaseModel): existing_voice: str = Field(..., min_length=1) input_format: Optional[InputFormat] = None new_voice_type: VoiceType relationship: MotionRelationship = MotionRelationship.CONTRARY species: int = Field(default=0, ge=0, le=5) range_low: Optional[str] = None range_high: Optional[str] = None harmonic_context: Optional[str] = None seed: Optional[int] = None max_attempts: int = Field(default=50, ge=1, le=500) class RealizeChordRequest(BaseModel): chord_symbol: str = Field(..., min_length=1) voicing_style: VoicingStyle = VoicingStyle.CLOSE instrument: Literal["piano", "guitar", "satb", "strings"] = "piano" inversion: int = Field(default=0, ge=0, le=6) bass_note: Optional[str] = None range_low: Optional[str] = None range_high: Optional[str] = None previous_voicing: Optional[list[str]] = None class ExportMidiRequest(BaseModel): stream: str = Field(..., min_length=1) input_format: Optional[InputFormat] = None tempo: int = Field(default=120, ge=20, le=300) humanize: bool = False humanize_amount: float = Field(default=0.3, ge=0.0, le=1.0) velocity_curve: Literal["flat", "dynamic", "crescendo", "diminuendo"] = "flat" include_abc: bool = False # === Response Models === class Warning(BaseModel): code: str message: str location: Optional[int] = None class ErrorDetail(BaseModel): code: str message: str field: Optional[str] = None suggestions: Optional[list[str]] = None class VoiceLeadingAnalysis(BaseModel): score: float = Field(ge=0.0, le=1.0) parallel_fifths: list[dict] parallel_octaves: list[dict] voice_crossings: list[dict] direct_intervals: list[dict] spacing_issues: list[dict] class BaseResponse(BaseModel): success: bool warnings: list[Warning] = [] error: Optional[ErrorDetail] = None ``` --- ## Dependencies ```toml [project] name = "music21-composer-mcp" version = "0.1.0" requires-python = ">=3.10" [project.dependencies] # Core music library music21 = ">=9.1.0" # Data validation pydantic = ">=2.0.0" # MCP protocol mcp = ">=1.0.0" fastmcp = ">=2.0.0" # HTTP adapter fastapi = ">=0.100.0" uvicorn = ">=0.23.0" [project.optional-dependencies] dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", "pytest-asyncio>=0.21.0", "black>=23.0.0", "ruff>=0.1.0", "mypy>=1.0.0", ] [project.scripts] composer-mcp = "composer_mcp.launcher:main" ``` **Note:** `midiutil` removed — music21's built-in MIDI export is sufficient. --- ## Implementation Phases ### Phase 1: Foundation + HTTP + Export (Week 1) - [x] Project scaffolding (pyproject.toml, structure) - [ ] Pydantic models for all tools - [ ] Error types and response builders - [ ] Input format detection - [ ] HTTP adapter with FastAPI (primary development interface) - [ ] `export_midi` tool (validates full pipeline) - [ ] Basic test harness **Exit Criteria:** Can call `export_midi` via HTTP, returns valid MIDI base64. ### Phase 2: Chord + Melody Generation - [ ] `realize_chord` (isolated, deterministic) - [ ] `generate_melody` with all constraints - [ ] Unit tests for: - All notes within range - All notes in key - Contour followed - Start/end notes respected **Exit Criteria:** Can generate 8-bar melody with specific constraints, all tests pass. ### Phase 3: Harmonization - [ ] Style rule definitions (classical, jazz, pop, modal) - [ ] `reharmonize` with chord candidate generation - [ ] Voice leading scoring - [ ] Integration tests **Exit Criteria:** Can harmonize melody in 4 styles, returns ranked options. ### Phase 4: Counterpoint - [ ] Voice range defaults and validation - [ ] Species counterpoint rule engine - [ ] `add_voice` with relationship types - [ ] Voice leading analysis in response **Exit Criteria:** Can add valid counterpoint to melody, no parallel 5ths/8ves. ### Phase 5: Transformations - [ ] `transform_phrase` with all 9 transformation types - [ ] Compound transformations (sequence + inversion) - [ ] Integration with other tools **Exit Criteria:** All transformations work, can chain with generate → transform → export. ### Phase 6: MCP + Polish - [ ] FastMCP adapter wrapping HTTP endpoints - [ ] CLI adapter for manual testing - [ ] End-to-end integration tests - [ ] Documentation and examples - [ ] Performance benchmarks vs SLAs **Exit Criteria:** Full workflow works via MCP in Claude Desktop. --- ## Test Acceptance Criteria Coverage percentage is secondary. Tests must verify: ### `generate_melody` - [ ] All pitches within `range_low` to `range_high` - [ ] All pitches belong to specified scale - [ ] Measure count matches `length_measures` - [ ] `start_note` constraint respected when provided - [ ] `end_note` constraint respected when provided - [ ] No intervals larger than `avoid_leaps_greater_than` - [ ] Same seed produces same output - [ ] Returns partial result with warnings when constraints relaxed ### `transform_phrase` - [ ] `sequence` transposes by correct interval - [ ] `inversion` flips intervals correctly - [ ] `retrograde` reverses note order exactly - [ ] `augmentation` doubles all durations - [ ] `append=false` returns only transformed segment ### `reharmonize` - [ ] All chord tones are consonant with melody on strong beats - [ ] Style rules are followed (no extensions in classical, etc.) - [ ] Returns `num_options` distinct harmonizations - [ ] Options are ranked by voice leading score ### `add_voice` - [ ] No parallel 5ths or 8ves (when `avoid_parallels=true`) - [ ] New voice stays within specified range - [ ] Species rules followed when `species > 0` - [ ] Voice leading score is calculated correctly ### `realize_chord` - [ ] Output notes match chord symbol pitches - [ ] Inversion puts correct note in bass - [ ] Voicing style algorithm applied correctly - [ ] Instrument constraints respected ### `export_midi` - [ ] Output is valid MIDI (parseable by music21) - [ ] Tempo matches requested BPM - [ ] Humanization adds measurable variation when enabled --- ## Versioning Strategy **Semantic Versioning:** `MAJOR.MINOR.PATCH` - **MAJOR**: Breaking API changes (parameter rename, response structure change) - **MINOR**: New features, new optional parameters - **PATCH**: Bug fixes, algorithm improvements **Deprecation Policy:** 1. Deprecated parameters/fields marked with `deprecated: true` in schema 2. Deprecated items work for 2 minor versions 3. Removal announced in CHANGELOG 1 minor version before **Version Header:** HTTP responses include `X-API-Version: 0.1.0` MCP responses include `api_version` in metadata --- ## Reusable from Existing MCP From `brightlikethelight/music21-mcp-server`: | Component | Reuse? | Notes | |-----------|--------|-------| | Multi-interface architecture | ✅ Yes | Proven pattern | | FastMCP adapter pattern | ✅ Yes | Reference setup | | HTTP/FastAPI adapter | ✅ Yes | Similar REST patterns | | pyproject.toml structure | ✅ Yes | Build config | | Score import/export logic | ✅ Yes | MusicXML/MIDI parsing | | Key analysis algorithms | ✅ Yes | For reharmonize | | Voice leading checks | ✅ Yes | Validation | | Harmonization (Bach/jazz) | ⚠️ Reference | Different API, use as starting point | | Counterpoint generation | ⚠️ Reference | Species rules useful | | Analysis tools | ❌ No | Out of scope |

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