Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
venv/
.env
*.mid
.DS_Store
7 changes: 7 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Desktop entry point for Python Music Composer."""

from music_composer.gui import run_gui


if __name__ == "__main__":
run_gui()
41 changes: 41 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 6 additions & 0 deletions music_composer/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
70 changes: 70 additions & 0 deletions music_composer/composer.py
Original file line number Diff line number Diff line change
@@ -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
155 changes: 155 additions & 0 deletions music_composer/gui.py
Original file line number Diff line number Diff line change
@@ -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()
80 changes: 80 additions & 0 deletions music_composer/harmony.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading