From 2b3cee382c4c37dc35f6a2eec29e5aedefc71a61 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:50:15 -0300 Subject: [PATCH 01/18] feat: add music composer package --- music_composer/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 music_composer/__init__.py 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"] From dac264d15cc04213dc5dcdccd32419d8400809b6 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:50:24 -0300 Subject: [PATCH 02/18] feat: add music theory utilities --- music_composer/theory.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 music_composer/theory.py 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] From b8c86fb6bc0fa5c52703811d595844bd689dd7de Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:50:32 -0300 Subject: [PATCH 03/18] feat: add scale-aware melody composer --- music_composer/composer.py | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 music_composer/composer.py diff --git a/music_composer/composer.py b/music_composer/composer.py new file mode 100644 index 0000000..cf23dd5 --- /dev/null +++ b/music_composer/composer.py @@ -0,0 +1,58 @@ +"""Rule-based melody generation.""" + +from dataclasses import dataclass +import random + +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, +) -> list[MelodyEvent]: + """Generate a melody constrained to a musical scale and bar length.""" + 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) + + if rng.random() < randomness: + note = rng.choice(notes) + else: + note = min(notes, key=lambda candidate: abs(candidate - previous)) + index = notes.index(note) + step = rng.choice((-1, 0, 1)) + note = notes[max(0, min(len(notes) - 1, index + step))] + + velocity = rng.randint(72, 100) + events.append(MelodyEvent(note, cursor, duration, velocity)) + previous = note + cursor += duration + + return events From 643969e3be5683b100f795f2672edef02e82b647 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:50:38 -0300 Subject: [PATCH 04/18] feat: add MIDI export --- music_composer/midi.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 music_composer/midi.py diff --git a/music_composer/midi.py b/music_composer/midi.py new file mode 100644 index 0000000..b6f83c8 --- /dev/null +++ b/music_composer/midi.py @@ -0,0 +1,31 @@ +"""MIDI export helpers.""" + +from pathlib import Path +from midiutil import MIDIFile + +from .composer import MelodyEvent + + +def write_midi( + events: list[MelodyEvent], + filename: str | Path, + tempo: int = 120, + instrument: int = 0, +) -> Path: + """Write melody events to a standard MIDI file.""" + if not 20 <= tempo <= 300: + raise ValueError("tempo must be between 20 and 300 BPM") + if not 0 <= instrument <= 127: + raise ValueError("instrument must be a General MIDI program (0-127)") + + path = Path(filename) + midi = MIDIFile(1) + midi.addTempo(0, 0, tempo) + midi.addProgramChange(0, 0, 0, instrument) + + for event in events: + midi.addNote(0, 0, event.note, event.start, event.duration, event.velocity) + + with path.open("wb") as output: + midi.writeFile(output) + return path From 5e53c196f61365d47fc884e4c2c42af171367814 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:50:45 -0300 Subject: [PATCH 05/18] feat: add composer command line entry point --- main.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..e73eabe --- /dev/null +++ b/main.py @@ -0,0 +1,40 @@ +"""Command-line interface for Python Music Composer.""" + +import argparse + +from music_composer.composer import compose_melody +from music_composer.midi import write_midi + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate a scale-aware MIDI melody.") + parser.add_argument("--root", default="C", help="Root note, e.g. C, D, F#") + parser.add_argument("--scale", choices=("major", "minor"), default="major") + parser.add_argument("--octave", type=int, default=4) + parser.add_argument("--bars", type=int, default=4) + parser.add_argument("--beats", type=int, default=4, help="Beats per bar") + parser.add_argument("--tempo", type=int, default=120) + parser.add_argument("--instrument", type=int, default=0, help="General MIDI program 0-127") + parser.add_argument("--randomness", type=float, default=0.5) + parser.add_argument("--seed", type=int) + parser.add_argument("--output", default="composition.mid") + return parser + + +def main() -> None: + args = build_parser().parse_args() + melody = compose_melody( + root=args.root, + scale=args.scale, + octave=args.octave, + bars=args.bars, + beats_per_bar=args.beats, + randomness=args.randomness, + seed=args.seed, + ) + path = write_midi(melody, args.output, args.tempo, args.instrument) + print(f"Created {path} with {len(melody)} notes.") + + +if __name__ == "__main__": + main() From 9ac6141e354b50311a1ea0274ec542ebf89f8cff Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:50:49 -0300 Subject: [PATCH 06/18] build: add runtime and test dependencies --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 requirements.txt 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 From b47021dd490aee775eb2327f06fe4ed23553e771 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:50:53 -0300 Subject: [PATCH 07/18] chore: add Python gitignore --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .gitignore 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 From cbd8ba539faa4a568cdb68a0ffc7f0b6064cb960 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:51:00 -0300 Subject: [PATCH 08/18] test: add music theory tests --- tests/test_theory.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/test_theory.py 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") From b1e8692f0e79c1af07f691d0c0454bcf3d327652 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:51:06 -0300 Subject: [PATCH 09/18] test: add melody composer tests --- tests/test_composer.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/test_composer.py 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) From da3bf5beb71805c1b1c1738987bba6bbd66a34b1 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:52:51 -0300 Subject: [PATCH 10/18] feat: add Tkinter music composer interface --- music_composer/gui.py | 195 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 music_composer/gui.py diff --git a/music_composer/gui.py b/music_composer/gui.py new file mode 100644 index 0000000..0429da7 --- /dev/null +++ b/music_composer/gui.py @@ -0,0 +1,195 @@ +"""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 .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): + """Desktop UI backed by the scale-aware composition engine.""" + + def __init__(self, master: tk.Tk) -> None: + super().__init__(master, padding=18) + self.master = master + self.last_output: Path | None = None + self._configure_window() + self._build_variables() + self._build_layout() + + def _configure_window(self) -> None: + self.master.title("Python Music Composer") + self.master.minsize(620, 540) + self.master.columnconfigure(0, weight=1) + self.master.rowconfigure(0, weight=1) + self.grid(sticky="nsew") + self.columnconfigure(1, weight=1) + + 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.randomness = tk.IntVar(value=55) + 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: + title = ttk.Label(self, text="Python Music Composer", font=("TkDefaultFont", 18, "bold")) + title.grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 4)) + ttk.Label( + self, + text="Crie melodias MIDI coerentes com tonalidade, escala, andamento e instrumento.", + ).grid(row=1, column=0, columnspan=3, sticky="w", pady=(0, 18)) + + self._combo_row(2, "Tonalidade", self.root_note, ROOT_NOTES) + self._combo_row(3, "Escala", self.scale_name, tuple(SCALES)) + self._spin_row(4, "Oitava", self.octave, 1, 7) + self._spin_row(5, "Compassos", self.bars, 1, 64) + self._combo_row(6, "Compasso", self.time_signature, tuple(TIME_SIGNATURES)) + self._spin_row(7, "BPM", self.tempo, 40, 240) + self._combo_row(8, "Instrumento", self.instrument, tuple(INSTRUMENTS)) + + ttk.Label(self, text="Aleatoriedade").grid(row=9, column=0, sticky="w", pady=6) + randomness = ttk.Scale( + self, + from_=0, + to=100, + variable=self.randomness, + command=self._update_randomness_label, + ) + randomness.grid(row=9, column=1, sticky="ew", padx=(10, 8), pady=6) + self.randomness_label = ttk.Label(self, text="55%", width=5) + self.randomness_label.grid(row=9, column=2, sticky="w") + + ttk.Label(self, text="Seed (opcional)").grid(row=10, column=0, sticky="w", pady=6) + ttk.Entry(self, textvariable=self.seed).grid(row=10, column=1, columnspan=2, sticky="ew", padx=(10, 0), pady=6) + + ttk.Label(self, text="Arquivo MIDI").grid(row=11, column=0, sticky="w", pady=6) + ttk.Entry(self, textvariable=self.output).grid(row=11, column=1, sticky="ew", padx=(10, 8), pady=6) + ttk.Button(self, text="Escolher...", command=self.choose_output).grid(row=11, column=2, sticky="ew", pady=6) + + controls = ttk.Frame(self) + controls.grid(row=12, column=0, columnspan=3, sticky="ew", pady=(20, 10)) + controls.columnconfigure(0, weight=1) + controls.columnconfigure(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=13, column=0, columnspan=3, sticky="ew", pady=(4, 10)) + ttk.Label(self, textvariable=self.status).grid(row=14, column=0, columnspan=3, sticky="w") + + def _combo_row(self, row: int, label: str, variable: tk.Variable, values: tuple[str, ...]) -> None: + ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=6) + combo = ttk.Combobox(self, textvariable=variable, values=values, state="readonly") + combo.grid(row=row, column=1, columnspan=2, sticky="ew", padx=(10, 0), pady=6) + + def _spin_row(self, row: int, label: str, variable: tk.Variable, minimum: int, maximum: int) -> 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), pady=6 + ) + + def _update_randomness_label(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 os arquivos", "*.*")), + initialfile=Path(self.output.get()).name or "composition.mid", + ) + 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: + 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)) + + 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=TIME_SIGNATURES[self.time_signature.get()], + randomness=self.randomness.get() / 100.0, + seed=self._seed_value(), + ) + path = write_midi( + melody, + output, + tempo=self.tempo.get(), + instrument=INSTRUMENTS[self.instrument.get()], + ) + 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") + messagebox.showinfo("Composição concluída", f"Arquivo MIDI criado com sucesso:\n{self.last_output}") + + def open_last_output(self) -> None: + if not self.last_output or not self.last_output.exists(): + messagebox.showwarning("Arquivo indisponível", "Gere uma composição primeiro.") + 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() From 42e5dfc5de82969f092eb98b63db5e81055f5df2 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:52:56 -0300 Subject: [PATCH 11/18] feat: add desktop application entry point --- app.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 app.py 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() From 3518b47f92596fb7af265c68aa762067dc7ab4ba Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:53:04 -0300 Subject: [PATCH 12/18] feat: support time signatures in CLI --- main.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index e73eabe..e63ca90 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,8 @@ from music_composer.composer import compose_melody 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 scale-aware MIDI melody.") @@ -12,7 +14,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--scale", choices=("major", "minor"), default="major") parser.add_argument("--octave", type=int, default=4) parser.add_argument("--bars", type=int, default=4) - parser.add_argument("--beats", type=int, default=4, help="Beats per bar") + parser.add_argument("--time-signature", choices=tuple(TIME_SIGNATURES), default="4/4") parser.add_argument("--tempo", type=int, default=120) parser.add_argument("--instrument", type=int, default=0, help="General MIDI program 0-127") parser.add_argument("--randomness", type=float, default=0.5) @@ -28,7 +30,7 @@ def main() -> None: scale=args.scale, octave=args.octave, bars=args.bars, - beats_per_bar=args.beats, + beats_per_bar=TIME_SIGNATURES[args.time_signature], randomness=args.randomness, seed=args.seed, ) From 475d7e4099ee9c664a9f4b81f05a9d7248a18535 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:54:54 -0300 Subject: [PATCH 13/18] feat: add harmonic progressions and accompaniment --- music_composer/harmony.py | 80 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 music_composer/harmony.py 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 From 4c09db896971b2696d449445a094e4ffe090bcb6 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:55:03 -0300 Subject: [PATCH 14/18] feat: condition melody on chord progression --- music_composer/composer.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/music_composer/composer.py b/music_composer/composer.py index cf23dd5..7b3343a 100644 --- a/music_composer/composer.py +++ b/music_composer/composer.py @@ -1,8 +1,9 @@ -"""Rule-based melody generation.""" +"""Rule-based, harmony-aware melody generation.""" from dataclasses import dataclass import random +from .harmony import chord_for_bar from .theory import scale_midi_notes @@ -22,8 +23,9 @@ def compose_melody( 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 musical scale and bar length.""" + """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: @@ -41,14 +43,24 @@ def compose_melody( 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(notes) + note = rng.choice(candidates) else: - note = min(notes, key=lambda candidate: abs(candidate - previous)) - index = notes.index(note) + nearest = min(candidates, key=lambda candidate: abs(candidate - previous)) + index = candidates.index(nearest) step = rng.choice((-1, 0, 1)) - note = notes[max(0, min(len(notes) - 1, index + step))] + note = candidates[max(0, min(len(candidates) - 1, index + step))] velocity = rng.randint(72, 100) events.append(MelodyEvent(note, cursor, duration, velocity)) From 689f54011bfb9f1b231c0b4f81f959a2c207720d Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:55:12 -0300 Subject: [PATCH 15/18] feat: export melody chords bass and meter to MIDI --- music_composer/midi.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/music_composer/midi.py b/music_composer/midi.py index b6f83c8..71dfa5c 100644 --- a/music_composer/midi.py +++ b/music_composer/midi.py @@ -1,9 +1,10 @@ -"""MIDI export helpers.""" +"""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( @@ -11,21 +12,42 @@ def write_midi( 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 events to a standard MIDI file.""" + """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") - if not 0 <= instrument <= 127: - raise ValueError("instrument must be a General MIDI program (0-127)") + 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(1) + 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 From a6e8384d82c7b25a2f6c217700a76ac52f365083 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:55:35 -0300 Subject: [PATCH 16/18] feat: add harmony and bass controls to desktop app --- music_composer/gui.py | 148 +++++++++++++++--------------------------- 1 file changed, 54 insertions(+), 94 deletions(-) diff --git a/music_composer/gui.py b/music_composer/gui.py index 0429da7..e5961a7 100644 --- a/music_composer/gui.py +++ b/music_composer/gui.py @@ -10,45 +10,32 @@ 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, + "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): - """Desktop UI backed by the scale-aware composition engine.""" - def __init__(self, master: tk.Tk) -> None: super().__init__(master, padding=18) self.master = master self.last_output: Path | None = None - self._configure_window() - self._build_variables() - self._build_layout() - - def _configure_window(self) -> None: self.master.title("Python Music Composer") - self.master.minsize(620, 540) + 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") @@ -58,78 +45,58 @@ def _build_variables(self) -> None: self.time_signature = tk.StringVar(value="4/4") self.tempo = tk.IntVar(value=100) self.instrument = tk.StringVar(value="Piano") - self.randomness = tk.IntVar(value=55) + 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: - title = ttk.Label(self, text="Python Music Composer", font=("TkDefaultFont", 18, "bold")) - title.grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 4)) - ttk.Label( - self, - text="Crie melodias MIDI coerentes com tonalidade, escala, andamento e instrumento.", - ).grid(row=1, column=0, columnspan=3, sticky="w", pady=(0, 18)) - - self._combo_row(2, "Tonalidade", self.root_note, ROOT_NOTES) - self._combo_row(3, "Escala", self.scale_name, tuple(SCALES)) - self._spin_row(4, "Oitava", self.octave, 1, 7) - self._spin_row(5, "Compassos", self.bars, 1, 64) - self._combo_row(6, "Compasso", self.time_signature, tuple(TIME_SIGNATURES)) - self._spin_row(7, "BPM", self.tempo, 40, 240) - self._combo_row(8, "Instrumento", self.instrument, tuple(INSTRUMENTS)) - - ttk.Label(self, text="Aleatoriedade").grid(row=9, column=0, sticky="w", pady=6) - randomness = ttk.Scale( - self, - from_=0, - to=100, - variable=self.randomness, - command=self._update_randomness_label, - ) - randomness.grid(row=9, column=1, sticky="ew", padx=(10, 8), pady=6) - self.randomness_label = ttk.Label(self, text="55%", width=5) - self.randomness_label.grid(row=9, column=2, sticky="w") - - ttk.Label(self, text="Seed (opcional)").grid(row=10, column=0, sticky="w", pady=6) - ttk.Entry(self, textvariable=self.seed).grid(row=10, column=1, columnspan=2, sticky="ew", padx=(10, 0), pady=6) - - ttk.Label(self, text="Arquivo MIDI").grid(row=11, column=0, sticky="w", pady=6) - ttk.Entry(self, textvariable=self.output).grid(row=11, column=1, sticky="ew", padx=(10, 8), pady=6) - ttk.Button(self, text="Escolher...", command=self.choose_output).grid(row=11, column=2, sticky="ew", pady=6) + 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=12, column=0, columnspan=3, sticky="ew", pady=(20, 10)) - controls.columnconfigure(0, weight=1) - controls.columnconfigure(1, weight=1) + 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") - ttk.Separator(self).grid(row=13, column=0, columnspan=3, sticky="ew", pady=(4, 10)) - ttk.Label(self, textvariable=self.status).grid(row=14, column=0, columnspan=3, sticky="w") - - def _combo_row(self, row: int, label: str, variable: tk.Variable, values: tuple[str, ...]) -> None: + def _combo(self, row, label, variable, values) -> None: ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=6) - combo = ttk.Combobox(self, textvariable=variable, values=values, state="readonly") - combo.grid(row=row, column=1, columnspan=2, sticky="ew", padx=(10, 0), 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_row(self, row: int, label: str, variable: tk.Variable, minimum: int, maximum: int) -> None: + 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), 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_label(self, _value: str) -> None: + 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 os arquivos", "*.*")), - initialfile=Path(self.output.get()).name or "composition.mid", - ) + filename = filedialog.asksaveasfilename(title="Salvar composição MIDI", defaultextension=".mid", filetypes=(("MIDI", "*.mid"), ("Todos", "*.*"))) if filename: self.output.set(filename) @@ -144,39 +111,32 @@ def _seed_value(self) -> int | None: 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)) - - 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=TIME_SIGNATURES[self.time_signature.get()], - randomness=self.randomness.get() / 100.0, - seed=self._seed_value(), - ) - path = write_midi( - melody, - output, - tempo=self.tempo.get(), - instrument=INSTRUMENTS[self.instrument.get()], - ) + 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") - messagebox.showinfo("Composição concluída", f"Arquivo MIDI criado com sucesso:\n{self.last_output}") + 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(): - messagebox.showwarning("Arquivo indisponível", "Gere uma composição primeiro.") return try: if sys.platform.startswith("win"): From 7ba60e51b3a288fab859d578a8054cc8aa5a79c1 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:55:46 -0300 Subject: [PATCH 17/18] feat: expose harmony and bass in CLI --- main.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/main.py b/main.py index e63ca90..66e5284 100644 --- a/main.py +++ b/main.py @@ -3,21 +3,24 @@ 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 scale-aware MIDI melody.") - parser.add_argument("--root", default="C", help="Root note, e.g. C, D, F#") + 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=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=120) - parser.add_argument("--instrument", type=int, default=0, help="General MIDI program 0-127") - parser.add_argument("--randomness", type=float, default=0.5) + 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 @@ -25,17 +28,13 @@ def build_parser() -> argparse.ArgumentParser: def main() -> None: args = build_parser().parse_args() - melody = compose_melody( - root=args.root, - scale=args.scale, - octave=args.octave, - bars=args.bars, - beats_per_bar=TIME_SIGNATURES[args.time_signature], - randomness=args.randomness, - seed=args.seed, - ) - path = write_midi(melody, args.output, args.tempo, args.instrument) - print(f"Created {path} with {len(melody)} notes.") + 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__": From cee9f738836bd5c01657d473b87b1900ddae1534 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:55:52 -0300 Subject: [PATCH 18/18] test: add harmony and progression tests --- tests/test_harmony.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/test_harmony.py 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