Skip to content

Commit 70d1b23

Browse files
authored
Merge Music Composer v1
Merge the scale-aware desktop composer, harmonic progression engine, multi-track MIDI accompaniment, GUI/CLI controls, and automated tests into master.
2 parents d5e296c + cee9f73 commit 70d1b23

13 files changed

Lines changed: 521 additions & 0 deletions

File tree

‎.gitignore‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
__pycache__/
2+
*.py[cod]
3+
.pytest_cache/
4+
.venv/
5+
venv/
6+
.env
7+
*.mid
8+
.DS_Store

‎app.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Desktop entry point for Python Music Composer."""
2+
3+
from music_composer.gui import run_gui
4+
5+
6+
if __name__ == "__main__":
7+
run_gui()

‎main.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Command-line interface for Python Music Composer."""
2+
3+
import argparse
4+
5+
from music_composer.composer import compose_melody
6+
from music_composer.harmony import PROGRESSIONS, build_accompaniment
7+
from music_composer.midi import write_midi
8+
9+
TIME_SIGNATURES = {"4/4": 4, "3/4": 3, "2/4": 2}
10+
11+
12+
def build_parser() -> argparse.ArgumentParser:
13+
parser = argparse.ArgumentParser(description="Generate a harmony-aware MIDI composition.")
14+
parser.add_argument("--root", default="C")
15+
parser.add_argument("--scale", choices=("major", "minor"), default="major")
16+
parser.add_argument("--octave", type=int, default=4)
17+
parser.add_argument("--bars", type=int, default=8)
18+
parser.add_argument("--time-signature", choices=tuple(TIME_SIGNATURES), default="4/4")
19+
parser.add_argument("--tempo", type=int, default=100)
20+
parser.add_argument("--instrument", type=int, default=0)
21+
parser.add_argument("--progression", choices=tuple(PROGRESSIONS), default="I-V-vi-IV")
22+
parser.add_argument("--no-accompaniment", action="store_true")
23+
parser.add_argument("--randomness", type=float, default=0.45)
24+
parser.add_argument("--seed", type=int)
25+
parser.add_argument("--output", default="composition.mid")
26+
return parser
27+
28+
29+
def main() -> None:
30+
args = build_parser().parse_args()
31+
beats = TIME_SIGNATURES[args.time_signature]
32+
melody = compose_melody(args.root, args.scale, args.octave, args.bars, beats, args.randomness, args.seed, args.progression)
33+
chords, bass = ([], [])
34+
if not args.no_accompaniment:
35+
chords, bass = build_accompaniment(args.root, args.scale, args.bars, beats, args.progression)
36+
path = write_midi(melody, args.output, args.tempo, args.instrument, chords, bass, beats)
37+
print(f"Created {path}: {len(melody)} melody notes, {len(chords)} chords, {len(bass)} bass notes.")
38+
39+
40+
if __name__ == "__main__":
41+
main()

‎music_composer/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Core package for Python Music Composer."""
2+
3+
from .composer import MelodyEvent, compose_melody
4+
from .theory import scale_pitch_classes
5+
6+
__all__ = ["MelodyEvent", "compose_melody", "scale_pitch_classes"]

‎music_composer/composer.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Rule-based, harmony-aware melody generation."""
2+
3+
from dataclasses import dataclass
4+
import random
5+
6+
from .harmony import chord_for_bar
7+
from .theory import scale_midi_notes
8+
9+
10+
@dataclass(frozen=True)
11+
class MelodyEvent:
12+
note: int
13+
start: float
14+
duration: float
15+
velocity: int
16+
17+
18+
def compose_melody(
19+
root: str = "C",
20+
scale: str = "major",
21+
octave: int = 4,
22+
bars: int = 4,
23+
beats_per_bar: int = 4,
24+
randomness: float = 0.5,
25+
seed: int | None = None,
26+
progression: str | None = None,
27+
) -> list[MelodyEvent]:
28+
"""Generate a melody constrained to a scale and optionally its harmony."""
29+
if bars < 1 or beats_per_bar < 1:
30+
raise ValueError("bars and beats_per_bar must be positive")
31+
if not 0 <= randomness <= 1:
32+
raise ValueError("randomness must be between 0 and 1")
33+
34+
rng = random.Random(seed)
35+
notes = scale_midi_notes(root, scale, octave)
36+
durations = (0.5, 1.0, 2.0)
37+
total_beats = float(bars * beats_per_bar)
38+
events: list[MelodyEvent] = []
39+
cursor = 0.0
40+
previous = notes[0]
41+
42+
while cursor < total_beats:
43+
remaining = total_beats - cursor
44+
allowed = [duration for duration in durations if duration <= remaining]
45+
duration = rng.choice(allowed)
46+
bar = min(int(cursor // beats_per_bar), bars - 1)
47+
beat_in_bar = cursor % beats_per_bar
48+
49+
candidates = notes
50+
if progression and beat_in_bar in (0.0, 2.0):
51+
chord = chord_for_bar(root, scale, octave, progression, bar)
52+
chord_pcs = {note % 12 for note in chord}
53+
harmonic = [note for note in notes if note % 12 in chord_pcs]
54+
if harmonic:
55+
candidates = harmonic
56+
57+
if rng.random() < randomness:
58+
note = rng.choice(candidates)
59+
else:
60+
nearest = min(candidates, key=lambda candidate: abs(candidate - previous))
61+
index = candidates.index(nearest)
62+
step = rng.choice((-1, 0, 1))
63+
note = candidates[max(0, min(len(candidates) - 1, index + step))]
64+
65+
velocity = rng.randint(72, 100)
66+
events.append(MelodyEvent(note, cursor, duration, velocity))
67+
previous = note
68+
cursor += duration
69+
70+
return events

‎music_composer/gui.py‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Tkinter desktop interface for Python Music Composer."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from pathlib import Path
7+
import subprocess
8+
import sys
9+
import tkinter as tk
10+
from tkinter import filedialog, messagebox, ttk
11+
12+
from .composer import compose_melody
13+
from .harmony import PROGRESSIONS, build_accompaniment
14+
from .midi import write_midi
15+
16+
ROOT_NOTES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
17+
SCALES = {"Maior": "major", "Menor natural": "minor"}
18+
TIME_SIGNATURES = {"4/4": 4, "3/4": 3, "2/4": 2}
19+
INSTRUMENTS = {
20+
"Piano": 0, "Piano elétrico": 4, "Violão": 24, "Guitarra limpa": 27,
21+
"Baixo acústico": 32, "Violino": 40, "Violoncelo": 42, "Cordas": 48,
22+
"Trompete": 56, "Sax alto": 65, "Flauta": 73, "Sintetizador": 80,
23+
}
24+
25+
26+
class MusicComposerApp(ttk.Frame):
27+
def __init__(self, master: tk.Tk) -> None:
28+
super().__init__(master, padding=18)
29+
self.master = master
30+
self.last_output: Path | None = None
31+
self.master.title("Python Music Composer")
32+
self.master.minsize(650, 650)
33+
self.master.columnconfigure(0, weight=1)
34+
self.master.rowconfigure(0, weight=1)
35+
self.grid(sticky="nsew")
36+
self.columnconfigure(1, weight=1)
37+
self._build_variables()
38+
self._build_layout()
39+
40+
def _build_variables(self) -> None:
41+
self.root_note = tk.StringVar(value="C")
42+
self.scale_name = tk.StringVar(value="Maior")
43+
self.octave = tk.IntVar(value=4)
44+
self.bars = tk.IntVar(value=8)
45+
self.time_signature = tk.StringVar(value="4/4")
46+
self.tempo = tk.IntVar(value=100)
47+
self.instrument = tk.StringVar(value="Piano")
48+
self.progression = tk.StringVar(value="I-V-vi-IV")
49+
self.harmony_enabled = tk.BooleanVar(value=True)
50+
self.randomness = tk.IntVar(value=45)
51+
self.seed = tk.StringVar(value="")
52+
self.output = tk.StringVar(value="composition.mid")
53+
self.status = tk.StringVar(value="Pronto para compor.")
54+
55+
def _build_layout(self) -> None:
56+
ttk.Label(self, text="Python Music Composer", font=("TkDefaultFont", 18, "bold")).grid(row=0, column=0, columnspan=3, sticky="w")
57+
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))
58+
self._combo(2, "Tonalidade", self.root_note, ROOT_NOTES)
59+
self._combo(3, "Escala", self.scale_name, tuple(SCALES))
60+
self._spin(4, "Oitava da melodia", self.octave, 1, 7)
61+
self._spin(5, "Compassos", self.bars, 1, 64)
62+
self._combo(6, "Compasso", self.time_signature, tuple(TIME_SIGNATURES))
63+
self._spin(7, "BPM", self.tempo, 40, 240)
64+
self._combo(8, "Instrumento melódico", self.instrument, tuple(INSTRUMENTS))
65+
self._combo(9, "Progressão harmônica", self.progression, tuple(PROGRESSIONS))
66+
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)
67+
68+
ttk.Label(self, text="Aleatoriedade").grid(row=11, column=0, sticky="w", pady=6)
69+
ttk.Scale(self, from_=0, to=100, variable=self.randomness, command=self._update_randomness).grid(row=11, column=1, sticky="ew", padx=(10, 8))
70+
self.randomness_label = ttk.Label(self, text="45%", width=5)
71+
self.randomness_label.grid(row=11, column=2, sticky="w")
72+
ttk.Label(self, text="Seed (opcional)").grid(row=12, column=0, sticky="w", pady=6)
73+
ttk.Entry(self, textvariable=self.seed).grid(row=12, column=1, columnspan=2, sticky="ew", padx=(10, 0))
74+
ttk.Label(self, text="Arquivo MIDI").grid(row=13, column=0, sticky="w", pady=6)
75+
ttk.Entry(self, textvariable=self.output).grid(row=13, column=1, sticky="ew", padx=(10, 8))
76+
ttk.Button(self, text="Escolher...", command=self.choose_output).grid(row=13, column=2, sticky="ew")
77+
78+
controls = ttk.Frame(self)
79+
controls.grid(row=14, column=0, columnspan=3, sticky="ew", pady=(20, 10))
80+
controls.columnconfigure((0, 1), weight=1)
81+
ttk.Button(controls, text="Gerar música", command=self.generate).grid(row=0, column=0, sticky="ew", padx=(0, 6))
82+
self.open_button = ttk.Button(controls, text="Abrir MIDI", command=self.open_last_output, state="disabled")
83+
self.open_button.grid(row=0, column=1, sticky="ew", padx=(6, 0))
84+
ttk.Separator(self).grid(row=15, column=0, columnspan=3, sticky="ew", pady=(4, 10))
85+
ttk.Label(self, textvariable=self.status).grid(row=16, column=0, columnspan=3, sticky="w")
86+
87+
def _combo(self, row, label, variable, values) -> None:
88+
ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=6)
89+
ttk.Combobox(self, textvariable=variable, values=values, state="readonly").grid(row=row, column=1, columnspan=2, sticky="ew", padx=(10, 0))
90+
91+
def _spin(self, row, label, variable, minimum, maximum) -> None:
92+
ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=6)
93+
ttk.Spinbox(self, textvariable=variable, from_=minimum, to=maximum).grid(row=row, column=1, columnspan=2, sticky="ew", padx=(10, 0))
94+
95+
def _update_randomness(self, _value: str) -> None:
96+
self.randomness_label.configure(text=f"{self.randomness.get()}%")
97+
98+
def choose_output(self) -> None:
99+
filename = filedialog.asksaveasfilename(title="Salvar composição MIDI", defaultextension=".mid", filetypes=(("MIDI", "*.mid"), ("Todos", "*.*")))
100+
if filename:
101+
self.output.set(filename)
102+
103+
def _seed_value(self) -> int | None:
104+
raw = self.seed.get().strip()
105+
if not raw:
106+
return None
107+
try:
108+
return int(raw)
109+
except ValueError as exc:
110+
raise ValueError("A seed deve ser um número inteiro.") from exc
111+
112+
def generate(self) -> None:
113+
try:
114+
beats = TIME_SIGNATURES[self.time_signature.get()]
115+
progression = self.progression.get()
116+
melody = compose_melody(
117+
root=self.root_note.get(), scale=SCALES[self.scale_name.get()], octave=self.octave.get(),
118+
bars=self.bars.get(), beats_per_bar=beats, randomness=self.randomness.get() / 100,
119+
seed=self._seed_value(), progression=progression,
120+
)
121+
chords, bass = ([], [])
122+
if self.harmony_enabled.get():
123+
chords, bass = build_accompaniment(self.root_note.get(), SCALES[self.scale_name.get()], self.bars.get(), beats, progression)
124+
output = Path(self.output.get().strip() or "composition.mid")
125+
if output.suffix.lower() not in {".mid", ".midi"}:
126+
output = output.with_suffix(".mid")
127+
self.output.set(str(output))
128+
path = write_midi(melody, output, self.tempo.get(), INSTRUMENTS[self.instrument.get()], chords, bass, beats)
129+
except (OSError, ValueError, KeyError, tk.TclError) as exc:
130+
messagebox.showerror("Não foi possível gerar a música", str(exc))
131+
self.status.set("Erro ao gerar composição.")
132+
return
133+
self.last_output = path.resolve()
134+
self.open_button.configure(state="normal")
135+
self.status.set(f"Criado: {self.last_output} — {len(melody)} notas, {len(chords)} acordes")
136+
messagebox.showinfo("Composição concluída", f"MIDI criado:\n{self.last_output}")
137+
138+
def open_last_output(self) -> None:
139+
if not self.last_output or not self.last_output.exists():
140+
return
141+
try:
142+
if sys.platform.startswith("win"):
143+
os.startfile(self.last_output) # type: ignore[attr-defined]
144+
elif sys.platform == "darwin":
145+
subprocess.run(["open", str(self.last_output)], check=True)
146+
else:
147+
subprocess.run(["xdg-open", str(self.last_output)], check=True)
148+
except (OSError, subprocess.CalledProcessError) as exc:
149+
messagebox.showerror("Não foi possível abrir o MIDI", str(exc))
150+
151+
152+
def run_gui() -> None:
153+
root = tk.Tk()
154+
MusicComposerApp(root)
155+
root.mainloop()

‎music_composer/harmony.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Harmony helpers: diatonic triads, progressions, chords and bass."""
2+
3+
from dataclasses import dataclass
4+
5+
from .theory import scale_midi_notes
6+
7+
PROGRESSIONS = {
8+
"I-IV-V-I": (1, 4, 5, 1),
9+
"I-V-vi-IV": (1, 5, 6, 4),
10+
"ii-V-I-I": (2, 5, 1, 1),
11+
"I-vi-IV-V": (1, 6, 4, 5),
12+
}
13+
14+
15+
@dataclass(frozen=True)
16+
class ChordEvent:
17+
notes: tuple[int, int, int]
18+
start: float
19+
duration: float
20+
velocity: int = 58
21+
22+
23+
@dataclass(frozen=True)
24+
class BassEvent:
25+
note: int
26+
start: float
27+
duration: float
28+
velocity: int = 72
29+
30+
31+
def diatonic_triad(root: str, scale: str, octave: int, degree: int) -> tuple[int, int, int]:
32+
"""Return a scale-built triad for degree 1..7."""
33+
if not 1 <= degree <= 7:
34+
raise ValueError("degree must be between 1 and 7")
35+
base = scale_midi_notes(root, scale, octave)
36+
extended = base + [note + 12 for note in base]
37+
index = degree - 1
38+
return (extended[index], extended[index + 2], extended[index + 4])
39+
40+
41+
def progression_degrees(name: str) -> tuple[int, ...]:
42+
if name not in PROGRESSIONS:
43+
raise ValueError(f"Unknown progression: {name}")
44+
return PROGRESSIONS[name]
45+
46+
47+
def chord_for_bar(root: str, scale: str, octave: int, progression: str, bar: int) -> tuple[int, int, int]:
48+
degrees = progression_degrees(progression)
49+
degree = degrees[bar % len(degrees)]
50+
return diatonic_triad(root, scale, octave, degree)
51+
52+
53+
def build_accompaniment(
54+
root: str,
55+
scale: str,
56+
bars: int,
57+
beats_per_bar: int,
58+
progression: str,
59+
chord_octave: int = 3,
60+
bass_octave: int = 2,
61+
) -> tuple[list[ChordEvent], list[BassEvent]]:
62+
"""Create one sustained triad and a root/fifth bass pattern per bar."""
63+
chords: list[ChordEvent] = []
64+
bass: list[BassEvent] = []
65+
66+
for bar in range(bars):
67+
start = float(bar * beats_per_bar)
68+
triad = chord_for_bar(root, scale, chord_octave, progression, bar)
69+
chords.append(ChordEvent(triad, start, float(beats_per_bar)))
70+
71+
bass_triad = chord_for_bar(root, scale, bass_octave, progression, bar)
72+
root_note, _, fifth = bass_triad
73+
if beats_per_bar >= 4:
74+
bass.append(BassEvent(root_note, start, 2.0))
75+
bass.append(BassEvent(fifth, start + 2.0, float(beats_per_bar - 2)))
76+
else:
77+
bass.append(BassEvent(root_note, start, 1.0))
78+
bass.append(BassEvent(fifth, start + 1.0, float(beats_per_bar - 1)))
79+
80+
return chords, bass

0 commit comments

Comments
 (0)