diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbf9684 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +venv/ +.env +*.mid +.DS_Store diff --git a/app.py b/app.py new file mode 100644 index 0000000..b55231f --- /dev/null +++ b/app.py @@ -0,0 +1,7 @@ +"""Desktop entry point for Python Music Composer.""" + +from music_composer.gui import run_gui + + +if __name__ == "__main__": + run_gui() diff --git a/main.py b/main.py new file mode 100644 index 0000000..66e5284 --- /dev/null +++ b/main.py @@ -0,0 +1,41 @@ +"""Command-line interface for Python Music Composer.""" + +import argparse + +from music_composer.composer import compose_melody +from music_composer.harmony import PROGRESSIONS, build_accompaniment +from music_composer.midi import write_midi + +TIME_SIGNATURES = {"4/4": 4, "3/4": 3, "2/4": 2} + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate a harmony-aware MIDI composition.") + parser.add_argument("--root", default="C") + parser.add_argument("--scale", choices=("major", "minor"), default="major") + parser.add_argument("--octave", type=int, default=4) + parser.add_argument("--bars", type=int, default=8) + parser.add_argument("--time-signature", choices=tuple(TIME_SIGNATURES), default="4/4") + parser.add_argument("--tempo", type=int, default=100) + parser.add_argument("--instrument", type=int, default=0) + parser.add_argument("--progression", choices=tuple(PROGRESSIONS), default="I-V-vi-IV") + parser.add_argument("--no-accompaniment", action="store_true") + parser.add_argument("--randomness", type=float, default=0.45) + parser.add_argument("--seed", type=int) + parser.add_argument("--output", default="composition.mid") + return parser + + +def main() -> None: + args = build_parser().parse_args() + beats = TIME_SIGNATURES[args.time_signature] + melody = compose_melody(args.root, args.scale, args.octave, args.bars, beats, args.randomness, args.seed, args.progression) + chords, bass = ([], []) + if not args.no_accompaniment: + chords, bass = build_accompaniment(args.root, args.scale, args.bars, beats, args.progression) + path = write_midi(melody, args.output, args.tempo, args.instrument, chords, bass, beats) + print(f"Created {path}: {len(melody)} melody notes, {len(chords)} chords, {len(bass)} bass notes.") + + +if __name__ == "__main__": + main() diff --git a/music_composer/__init__.py b/music_composer/__init__.py new file mode 100644 index 0000000..099fdeb --- /dev/null +++ b/music_composer/__init__.py @@ -0,0 +1,6 @@ +"""Core package for Python Music Composer.""" + +from .composer import MelodyEvent, compose_melody +from .theory import scale_pitch_classes + +__all__ = ["MelodyEvent", "compose_melody", "scale_pitch_classes"] diff --git a/music_composer/composer.py b/music_composer/composer.py new file mode 100644 index 0000000..7b3343a --- /dev/null +++ b/music_composer/composer.py @@ -0,0 +1,70 @@ +"""Rule-based, harmony-aware melody generation.""" + +from dataclasses import dataclass +import random + +from .harmony import chord_for_bar +from .theory import scale_midi_notes + + +@dataclass(frozen=True) +class MelodyEvent: + note: int + start: float + duration: float + velocity: int + + +def compose_melody( + root: str = "C", + scale: str = "major", + octave: int = 4, + bars: int = 4, + beats_per_bar: int = 4, + randomness: float = 0.5, + seed: int | None = None, + progression: str | None = None, +) -> list[MelodyEvent]: + """Generate a melody constrained to a scale and optionally its harmony.""" + if bars < 1 or beats_per_bar < 1: + raise ValueError("bars and beats_per_bar must be positive") + if not 0 <= randomness <= 1: + raise ValueError("randomness must be between 0 and 1") + + rng = random.Random(seed) + notes = scale_midi_notes(root, scale, octave) + durations = (0.5, 1.0, 2.0) + total_beats = float(bars * beats_per_bar) + events: list[MelodyEvent] = [] + cursor = 0.0 + previous = notes[0] + + while cursor < total_beats: + remaining = total_beats - cursor + allowed = [duration for duration in durations if duration <= remaining] + duration = rng.choice(allowed) + bar = min(int(cursor // beats_per_bar), bars - 1) + beat_in_bar = cursor % beats_per_bar + + candidates = notes + if progression and beat_in_bar in (0.0, 2.0): + chord = chord_for_bar(root, scale, octave, progression, bar) + chord_pcs = {note % 12 for note in chord} + harmonic = [note for note in notes if note % 12 in chord_pcs] + if harmonic: + candidates = harmonic + + if rng.random() < randomness: + note = rng.choice(candidates) + else: + nearest = min(candidates, key=lambda candidate: abs(candidate - previous)) + index = candidates.index(nearest) + step = rng.choice((-1, 0, 1)) + note = candidates[max(0, min(len(candidates) - 1, index + step))] + + velocity = rng.randint(72, 100) + events.append(MelodyEvent(note, cursor, duration, velocity)) + previous = note + cursor += duration + + return events diff --git a/music_composer/gui.py b/music_composer/gui.py new file mode 100644 index 0000000..e5961a7 --- /dev/null +++ b/music_composer/gui.py @@ -0,0 +1,155 @@ +"""Tkinter desktop interface for Python Music Composer.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +import tkinter as tk +from tkinter import filedialog, messagebox, ttk + +from .composer import compose_melody +from .harmony import PROGRESSIONS, build_accompaniment +from .midi import write_midi + +ROOT_NOTES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") +SCALES = {"Maior": "major", "Menor natural": "minor"} +TIME_SIGNATURES = {"4/4": 4, "3/4": 3, "2/4": 2} +INSTRUMENTS = { + "Piano": 0, "Piano elétrico": 4, "Violão": 24, "Guitarra limpa": 27, + "Baixo acústico": 32, "Violino": 40, "Violoncelo": 42, "Cordas": 48, + "Trompete": 56, "Sax alto": 65, "Flauta": 73, "Sintetizador": 80, +} + + +class MusicComposerApp(ttk.Frame): + def __init__(self, master: tk.Tk) -> None: + super().__init__(master, padding=18) + self.master = master + self.last_output: Path | None = None + self.master.title("Python Music Composer") + self.master.minsize(650, 650) + self.master.columnconfigure(0, weight=1) + self.master.rowconfigure(0, weight=1) + self.grid(sticky="nsew") + self.columnconfigure(1, weight=1) + self._build_variables() + self._build_layout() + + def _build_variables(self) -> None: + self.root_note = tk.StringVar(value="C") + self.scale_name = tk.StringVar(value="Maior") + self.octave = tk.IntVar(value=4) + self.bars = tk.IntVar(value=8) + self.time_signature = tk.StringVar(value="4/4") + self.tempo = tk.IntVar(value=100) + self.instrument = tk.StringVar(value="Piano") + self.progression = tk.StringVar(value="I-V-vi-IV") + self.harmony_enabled = tk.BooleanVar(value=True) + self.randomness = tk.IntVar(value=45) + self.seed = tk.StringVar(value="") + self.output = tk.StringVar(value="composition.mid") + self.status = tk.StringVar(value="Pronto para compor.") + + def _build_layout(self) -> None: + ttk.Label(self, text="Python Music Composer", font=("TkDefaultFont", 18, "bold")).grid(row=0, column=0, columnspan=3, sticky="w") + ttk.Label(self, text="Melodia, harmonia e baixo construídos a partir da teoria musical.").grid(row=1, column=0, columnspan=3, sticky="w", pady=(4, 16)) + self._combo(2, "Tonalidade", self.root_note, ROOT_NOTES) + self._combo(3, "Escala", self.scale_name, tuple(SCALES)) + self._spin(4, "Oitava da melodia", self.octave, 1, 7) + self._spin(5, "Compassos", self.bars, 1, 64) + self._combo(6, "Compasso", self.time_signature, tuple(TIME_SIGNATURES)) + self._spin(7, "BPM", self.tempo, 40, 240) + self._combo(8, "Instrumento melódico", self.instrument, tuple(INSTRUMENTS)) + self._combo(9, "Progressão harmônica", self.progression, tuple(PROGRESSIONS)) + ttk.Checkbutton(self, text="Adicionar acordes e linha de baixo", variable=self.harmony_enabled).grid(row=10, column=0, columnspan=3, sticky="w", pady=6) + + ttk.Label(self, text="Aleatoriedade").grid(row=11, column=0, sticky="w", pady=6) + ttk.Scale(self, from_=0, to=100, variable=self.randomness, command=self._update_randomness).grid(row=11, column=1, sticky="ew", padx=(10, 8)) + self.randomness_label = ttk.Label(self, text="45%", width=5) + self.randomness_label.grid(row=11, column=2, sticky="w") + ttk.Label(self, text="Seed (opcional)").grid(row=12, column=0, sticky="w", pady=6) + ttk.Entry(self, textvariable=self.seed).grid(row=12, column=1, columnspan=2, sticky="ew", padx=(10, 0)) + ttk.Label(self, text="Arquivo MIDI").grid(row=13, column=0, sticky="w", pady=6) + ttk.Entry(self, textvariable=self.output).grid(row=13, column=1, sticky="ew", padx=(10, 8)) + ttk.Button(self, text="Escolher...", command=self.choose_output).grid(row=13, column=2, sticky="ew") + + controls = ttk.Frame(self) + controls.grid(row=14, column=0, columnspan=3, sticky="ew", pady=(20, 10)) + controls.columnconfigure((0, 1), weight=1) + ttk.Button(controls, text="Gerar música", command=self.generate).grid(row=0, column=0, sticky="ew", padx=(0, 6)) + self.open_button = ttk.Button(controls, text="Abrir MIDI", command=self.open_last_output, state="disabled") + self.open_button.grid(row=0, column=1, sticky="ew", padx=(6, 0)) + ttk.Separator(self).grid(row=15, column=0, columnspan=3, sticky="ew", pady=(4, 10)) + ttk.Label(self, textvariable=self.status).grid(row=16, column=0, columnspan=3, sticky="w") + + def _combo(self, row, label, variable, values) -> None: + ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=6) + ttk.Combobox(self, textvariable=variable, values=values, state="readonly").grid(row=row, column=1, columnspan=2, sticky="ew", padx=(10, 0)) + + def _spin(self, row, label, variable, minimum, maximum) -> None: + ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=6) + ttk.Spinbox(self, textvariable=variable, from_=minimum, to=maximum).grid(row=row, column=1, columnspan=2, sticky="ew", padx=(10, 0)) + + def _update_randomness(self, _value: str) -> None: + self.randomness_label.configure(text=f"{self.randomness.get()}%") + + def choose_output(self) -> None: + filename = filedialog.asksaveasfilename(title="Salvar composição MIDI", defaultextension=".mid", filetypes=(("MIDI", "*.mid"), ("Todos", "*.*"))) + if filename: + self.output.set(filename) + + def _seed_value(self) -> int | None: + raw = self.seed.get().strip() + if not raw: + return None + try: + return int(raw) + except ValueError as exc: + raise ValueError("A seed deve ser um número inteiro.") from exc + + def generate(self) -> None: + try: + beats = TIME_SIGNATURES[self.time_signature.get()] + progression = self.progression.get() + melody = compose_melody( + root=self.root_note.get(), scale=SCALES[self.scale_name.get()], octave=self.octave.get(), + bars=self.bars.get(), beats_per_bar=beats, randomness=self.randomness.get() / 100, + seed=self._seed_value(), progression=progression, + ) + chords, bass = ([], []) + if self.harmony_enabled.get(): + chords, bass = build_accompaniment(self.root_note.get(), SCALES[self.scale_name.get()], self.bars.get(), beats, progression) + output = Path(self.output.get().strip() or "composition.mid") + if output.suffix.lower() not in {".mid", ".midi"}: + output = output.with_suffix(".mid") + self.output.set(str(output)) + path = write_midi(melody, output, self.tempo.get(), INSTRUMENTS[self.instrument.get()], chords, bass, beats) + except (OSError, ValueError, KeyError, tk.TclError) as exc: + messagebox.showerror("Não foi possível gerar a música", str(exc)) + self.status.set("Erro ao gerar composição.") + return + self.last_output = path.resolve() + self.open_button.configure(state="normal") + self.status.set(f"Criado: {self.last_output} — {len(melody)} notas, {len(chords)} acordes") + messagebox.showinfo("Composição concluída", f"MIDI criado:\n{self.last_output}") + + def open_last_output(self) -> None: + if not self.last_output or not self.last_output.exists(): + return + try: + if sys.platform.startswith("win"): + os.startfile(self.last_output) # type: ignore[attr-defined] + elif sys.platform == "darwin": + subprocess.run(["open", str(self.last_output)], check=True) + else: + subprocess.run(["xdg-open", str(self.last_output)], check=True) + except (OSError, subprocess.CalledProcessError) as exc: + messagebox.showerror("Não foi possível abrir o MIDI", str(exc)) + + +def run_gui() -> None: + root = tk.Tk() + MusicComposerApp(root) + root.mainloop() diff --git a/music_composer/harmony.py b/music_composer/harmony.py new file mode 100644 index 0000000..41d5cd3 --- /dev/null +++ b/music_composer/harmony.py @@ -0,0 +1,80 @@ +"""Harmony helpers: diatonic triads, progressions, chords and bass.""" + +from dataclasses import dataclass + +from .theory import scale_midi_notes + +PROGRESSIONS = { + "I-IV-V-I": (1, 4, 5, 1), + "I-V-vi-IV": (1, 5, 6, 4), + "ii-V-I-I": (2, 5, 1, 1), + "I-vi-IV-V": (1, 6, 4, 5), +} + + +@dataclass(frozen=True) +class ChordEvent: + notes: tuple[int, int, int] + start: float + duration: float + velocity: int = 58 + + +@dataclass(frozen=True) +class BassEvent: + note: int + start: float + duration: float + velocity: int = 72 + + +def diatonic_triad(root: str, scale: str, octave: int, degree: int) -> tuple[int, int, int]: + """Return a scale-built triad for degree 1..7.""" + if not 1 <= degree <= 7: + raise ValueError("degree must be between 1 and 7") + base = scale_midi_notes(root, scale, octave) + extended = base + [note + 12 for note in base] + index = degree - 1 + return (extended[index], extended[index + 2], extended[index + 4]) + + +def progression_degrees(name: str) -> tuple[int, ...]: + if name not in PROGRESSIONS: + raise ValueError(f"Unknown progression: {name}") + return PROGRESSIONS[name] + + +def chord_for_bar(root: str, scale: str, octave: int, progression: str, bar: int) -> tuple[int, int, int]: + degrees = progression_degrees(progression) + degree = degrees[bar % len(degrees)] + return diatonic_triad(root, scale, octave, degree) + + +def build_accompaniment( + root: str, + scale: str, + bars: int, + beats_per_bar: int, + progression: str, + chord_octave: int = 3, + bass_octave: int = 2, +) -> tuple[list[ChordEvent], list[BassEvent]]: + """Create one sustained triad and a root/fifth bass pattern per bar.""" + chords: list[ChordEvent] = [] + bass: list[BassEvent] = [] + + for bar in range(bars): + start = float(bar * beats_per_bar) + triad = chord_for_bar(root, scale, chord_octave, progression, bar) + chords.append(ChordEvent(triad, start, float(beats_per_bar))) + + bass_triad = chord_for_bar(root, scale, bass_octave, progression, bar) + root_note, _, fifth = bass_triad + if beats_per_bar >= 4: + bass.append(BassEvent(root_note, start, 2.0)) + bass.append(BassEvent(fifth, start + 2.0, float(beats_per_bar - 2))) + else: + bass.append(BassEvent(root_note, start, 1.0)) + bass.append(BassEvent(fifth, start + 1.0, float(beats_per_bar - 1))) + + return chords, bass diff --git a/music_composer/midi.py b/music_composer/midi.py new file mode 100644 index 0000000..71dfa5c --- /dev/null +++ b/music_composer/midi.py @@ -0,0 +1,53 @@ +"""Multi-track MIDI export helpers.""" + +from pathlib import Path +from midiutil import MIDIFile + +from .composer import MelodyEvent +from .harmony import BassEvent, ChordEvent + + +def write_midi( + events: list[MelodyEvent], + filename: str | Path, + tempo: int = 120, + instrument: int = 0, + chords: list[ChordEvent] | None = None, + bass: list[BassEvent] | None = None, + beats_per_bar: int = 4, + chord_instrument: int = 48, + bass_instrument: int = 32, +) -> Path: + """Write melody plus optional harmony and bass to a multi-track MIDI file.""" + if not 20 <= tempo <= 300: + raise ValueError("tempo must be between 20 and 300 BPM") + for program in (instrument, chord_instrument, bass_instrument): + if not 0 <= program <= 127: + raise ValueError("instruments must be General MIDI programs (0-127)") + if beats_per_bar not in (2, 3, 4): + raise ValueError("beats_per_bar must be 2, 3 or 4") + + path = Path(filename) + midi = MIDIFile(3) + midi.addTempo(0, 0, tempo) + midi.addTimeSignature(0, 0, beats_per_bar, 2, 24) + midi.addTrackName(0, 0, "Melody") + midi.addTrackName(1, 0, "Harmony") + midi.addTrackName(2, 0, "Bass") + midi.addProgramChange(0, 0, 0, instrument) + midi.addProgramChange(1, 1, 0, chord_instrument) + midi.addProgramChange(2, 2, 0, bass_instrument) + + for event in events: + midi.addNote(0, 0, event.note, event.start, event.duration, event.velocity) + + for event in chords or []: + for note in event.notes: + midi.addNote(1, 1, note, event.start, event.duration, event.velocity) + + for event in bass or []: + midi.addNote(2, 2, event.note, event.start, event.duration, event.velocity) + + with path.open("wb") as output: + midi.writeFile(output) + return path diff --git a/music_composer/theory.py b/music_composer/theory.py new file mode 100644 index 0000000..32d30d9 --- /dev/null +++ b/music_composer/theory.py @@ -0,0 +1,34 @@ +"""Small music-theory helpers used by the composer.""" + +NOTE_TO_PC = { + "C": 0, "C#": 1, "D": 2, "D#": 3, "E": 4, "F": 5, + "F#": 6, "G": 7, "G#": 8, "A": 9, "A#": 10, "B": 11, +} + +SCALES = { + "major": (0, 2, 4, 5, 7, 9, 11), + "minor": (0, 2, 3, 5, 7, 8, 10), +} + + +def scale_pitch_classes(root: str, scale: str = "major") -> tuple[int, ...]: + """Return pitch classes (0-11) for a major or natural-minor scale.""" + root = root.upper() + if root not in NOTE_TO_PC: + raise ValueError(f"Unknown root note: {root}") + if scale not in SCALES: + raise ValueError(f"Unknown scale: {scale}") + base = NOTE_TO_PC[root] + return tuple((base + interval) % 12 for interval in SCALES[scale]) + + +def scale_midi_notes(root: str, scale: str, octave: int) -> list[int]: + """Build one octave of MIDI notes for the selected scale.""" + if not 0 <= octave <= 8: + raise ValueError("Octave must be between 0 and 8") + root_pc = NOTE_TO_PC.get(root.upper()) + if root_pc is None: + raise ValueError(f"Unknown root note: {root}") + root_midi = 12 * (octave + 1) + root_pc + notes = [root_midi + interval for interval in SCALES[scale]] + return [note for note in notes if 0 <= note <= 127] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3e0f82c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +MIDIUtil>=1.2.1,<2.0 +pytest>=8.0,<9.0 diff --git a/tests/test_composer.py b/tests/test_composer.py new file mode 100644 index 0000000..853d9d5 --- /dev/null +++ b/tests/test_composer.py @@ -0,0 +1,25 @@ +import pytest + +from music_composer.composer import compose_melody +from music_composer.theory import scale_midi_notes + + +def test_melody_fills_requested_number_of_beats(): + melody = compose_melody(bars=4, beats_per_bar=4, seed=42) + assert sum(event.duration for event in melody) == pytest.approx(16.0) + + +def test_generated_notes_stay_in_selected_scale(): + allowed = set(scale_midi_notes("D", "minor", 4)) + melody = compose_melody(root="D", scale="minor", octave=4, seed=7) + assert melody + assert all(event.note in allowed for event in melody) + + +def test_seed_makes_generation_reproducible(): + assert compose_melody(seed=123) == compose_melody(seed=123) + + +def test_invalid_randomness_is_rejected(): + with pytest.raises(ValueError): + compose_melody(randomness=1.1) diff --git a/tests/test_harmony.py b/tests/test_harmony.py new file mode 100644 index 0000000..9d9368c --- /dev/null +++ b/tests/test_harmony.py @@ -0,0 +1,20 @@ +from music_composer.harmony import build_accompaniment, diatonic_triad, progression_degrees + + +def test_c_major_tonic_triad(): + assert diatonic_triad("C", "major", 4, 1) == (60, 64, 67) + + +def test_pop_progression_degrees(): + assert progression_degrees("I-V-vi-IV") == (1, 5, 6, 4) + + +def test_accompaniment_creates_one_chord_per_bar(): + chords, bass = build_accompaniment("C", "major", 8, 4, "I-V-vi-IV") + assert len(chords) == 8 + assert len(bass) == 16 + + +def test_progression_repeats_across_bars(): + chords, _ = build_accompaniment("C", "major", 5, 4, "I-IV-V-I") + assert chords[0].notes == chords[3].notes == chords[4].notes diff --git a/tests/test_theory.py b/tests/test_theory.py new file mode 100644 index 0000000..95feec3 --- /dev/null +++ b/tests/test_theory.py @@ -0,0 +1,20 @@ +import pytest + +from music_composer.theory import scale_midi_notes, scale_pitch_classes + + +def test_c_major_pitch_classes(): + assert scale_pitch_classes("C", "major") == (0, 2, 4, 5, 7, 9, 11) + + +def test_a_minor_pitch_classes(): + assert scale_pitch_classes("A", "minor") == (9, 11, 0, 2, 4, 5, 7) + + +def test_c_major_octave_four_starts_at_middle_c(): + assert scale_midi_notes("C", "major", 4)[0] == 60 + + +def test_invalid_root_is_rejected(): + with pytest.raises(ValueError): + scale_pitch_classes("H", "major")