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
25 changes: 14 additions & 11 deletions backend/app/core/arrangement.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
HTTP endpoints and per-part mixing concerns (see app/services/mixdown.py).
"""
import random
from app.core.meter import Meter, DEFAULT_METER

from app.services.midi_writer import NoteEvent

Expand Down Expand Up @@ -142,13 +143,15 @@ def _apply_section_ramp(


def _plan_sections(total_bars: int, complexity: float, requested_parts: list[str],
base_key: str = "C", key_shift: int = 0) -> list[dict]:
base_key: str = "C", key_shift: int = 0,
meter: Meter = DEFAULT_METER) -> list[dict]:
"""Return an arrangement arc as a list of section dicts.

Each dict has: bars, complexity, parts, offset (in beats).
Sections progress from sparse (foundation only) → full arrangement → sparse outro,
so the output feels like a song with an energy curve rather than a looping pattern.
"""
bb = meter.bar_beats # bar length in quarter-beats (4.0 for 4/4 → offsets unchanged)
full = list(requested_parts)
no_arp = [p for p in requested_parts if p != "arpeggio"]
sparse = [p for p in requested_parts if p in ("drums", "bass", "chords")]
Expand All @@ -166,7 +169,7 @@ def sec(b: int, c_mul: float, p: list, off: int, key: str = base_key, dyn: float
intro = max(1, total_bars // 4)
return [
sec(intro, 0.35, found, 0, dyn=0.72),
sec(total_bars - intro, 1.0, full, intro * 4, dyn=1.0),
sec(total_bars - intro, 1.0, full, intro * bb, dyn=1.0),
]

if total_bars <= 16:
Expand All @@ -176,9 +179,9 @@ def sec(b: int, c_mul: float, p: list, off: int, key: str = base_key, dyn: float
verse = mid // 2
chorus = mid - verse
secs, off = [], 0
secs.append(sec(intro, 0.3, found, off, dyn=0.70)); off += intro * 4
secs.append(sec(verse, 0.65, sparse, off, dyn=0.85)); off += verse * 4
secs.append(sec(chorus, 1.0, full, off, key=chorus_key, dyn=1.00)); off += chorus * 4
secs.append(sec(intro, 0.3, found, off, dyn=0.70)); off += intro * bb
secs.append(sec(verse, 0.65, sparse, off, dyn=0.85)); off += verse * bb
secs.append(sec(chorus, 1.0, full, off, key=chorus_key, dyn=1.00)); off += chorus * bb
secs.append(sec(outro, 0.35, found, off, dyn=0.72))
return secs

Expand All @@ -193,9 +196,9 @@ def sec(b: int, c_mul: float, p: list, off: int, key: str = base_key, dyn: float
chorus = 4
verse = mid - chorus
secs, off = [], 0
secs.append(sec(intro, 0.25, found, off, dyn=0.70)); off += intro * 4
secs.append(sec(verse, 0.6, no_arp, off, dyn=0.85)); off += verse * 4
secs.append(sec(chorus, 1.0, full, off, key=chorus_key, dyn=1.00)); off += chorus * 4
secs.append(sec(intro, 0.25, found, off, dyn=0.70)); off += intro * bb
secs.append(sec(verse, 0.6, no_arp, off, dyn=0.85)); off += verse * bb
secs.append(sec(chorus, 1.0, full, off, key=chorus_key, dyn=1.00)); off += chorus * bb
secs.append(sec(outro, 0.3, found, off, dyn=0.72))
return secs

Expand All @@ -209,9 +212,9 @@ def sec(b: int, c_mul: float, p: list, off: int, key: str = base_key, dyn: float
chorus = 8
verse = mid - chorus
secs, off = [], 0
secs.append(sec(intro, 0.25, found, off, dyn=0.70)); off += intro * 4
secs.append(sec(verse, 0.6, no_arp, off, dyn=0.85)); off += verse * 4
secs.append(sec(chorus, 1.0, full, off, key=chorus_key, dyn=1.00)); off += chorus * 4
secs.append(sec(intro, 0.25, found, off, dyn=0.70)); off += intro * bb
secs.append(sec(verse, 0.6, no_arp, off, dyn=0.85)); off += verse * bb
secs.append(sec(chorus, 1.0, full, off, key=chorus_key, dyn=1.00)); off += chorus * bb
secs.append(sec(outro, 0.3, found, off, dyn=0.72))
return secs

Expand Down
120 changes: 120 additions & 0 deletions backend/app/core/meter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# GenreGrid — a style-based MIDI generator.
# Copyright (C) 2026 Tw Dover
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# <https://www.gnu.org/licenses/> for details.
"""Time-signature model — everything the generators and services need to place
events in a bar of any meter.

All positions in GenreGrid are expressed in QUARTER-NOTE beats (a note's
``.start`` is in quarter-beats, and BPM is the quarter-note tempo). So the one
quantity that varies with meter is ``bar_beats`` — the bar length in
quarter-beats — and every ``bars * 4`` in the codebase becomes
``bars * meter.bar_beats``. For 4/4, ``bar_beats == 4.0``, so all arithmetic is
unchanged and output stays byte-identical (the project's gating invariant).

Meters split into:
• simple (x/4, x/2): one felt beat per denominator unit, 16th-note feel.
• compound (6/8, 9/8, 12/8): felt beats are dotted quarters (3 eighths each).
• odd (5/8, 7/8, …): eighths grouped irregularly (7/8 = 2+2+3).
"""
from __future__ import annotations

from dataclasses import dataclass

# How the N eighths of an x/8 bar split into felt beats. Compound meters are all
# 3s; odd meters mix. Anything not listed falls back to 3s with the remainder last.
_EIGHTH_GROUPINGS: dict[int, list[int]] = {
5: [2, 3], 6: [3, 3], 7: [2, 2, 3], 8: [3, 3, 2],
9: [3, 3, 3], 10: [3, 3, 2, 2], 11: [3, 3, 3, 2], 12: [3, 3, 3, 3],
}


def _eighth_grouping(num: int) -> list[int]:
if num in _EIGHTH_GROUPINGS:
return list(_EIGHTH_GROUPINGS[num])
groups, rem = [], num
while rem > 3:
groups.append(3)
rem -= 3
if rem:
groups.append(rem)
return groups or [num]


@dataclass(frozen=True)
class Meter:
"""A time signature. Defaults to 4/4 so `Meter()` is a no-op everywhere."""
numerator: int = 4
denominator: int = 4

@property
def bar_beats(self) -> float:
"""Bar length in quarter-note beats. 4/4→4.0, 3/4→3.0, 6/8→3.0, 7/8→3.5."""
return self.numerator * 4.0 / self.denominator

@property
def step(self) -> float:
"""Fine subdivision grid unit in quarter-beats (a 16th note)."""
return 0.25

@property
def steps_per_bar(self) -> int:
"""16th-note steps in a bar. 4/4→16, 3/4→12, 6/8→12, 7/8→14."""
return int(round(self.bar_beats / self.step))

@property
def is_compound(self) -> bool:
"""Compound meters (6/8, 9/8, 12/8) — dotted-quarter beats, triplet feel."""
return self.denominator == 8 and self.numerator in (6, 9, 12)

@property
def is_simple(self) -> bool:
return self.denominator in (2, 4) or (self.denominator == 8 and not self.is_compound)

@property
def pulse(self) -> float:
"""Nominal felt-beat spacing in quarter-beats (compound → dotted quarter,
1.5). Irregular meters vary — use `pulse_positions` for exact placement."""
return 1.5 if self.is_compound else 4.0 / self.denominator

@property
def pulse_positions(self) -> list[float]:
"""Felt-beat start positions within a bar, in quarter-beats.
4/4→[0,1,2,3], 3/4→[0,1,2], 6/8→[0,1.5], 7/8→[0,1.0,2.0] (2+2+3)."""
if self.denominator == 8:
out: list[float] = []
eighth = 0
for g in _eighth_grouping(self.numerator):
out.append(eighth * 0.5) # an eighth is 0.5 quarter-beats
eighth += g
return out
unit = 4.0 / self.denominator # quarter for x/4, half for x/2
return [i * unit for i in range(self.numerator)]

def __str__(self) -> str:
return f"{self.numerator}/{self.denominator}"


DEFAULT_METER = Meter(4, 4)

_ALLOWED_DENOMINATORS = (2, 4, 8, 16)


def parse_meter(s: str | Meter | None) -> Meter:
"""Parse "N/D" into a Meter, clamped to sane bounds. Bad input → 4/4."""
if isinstance(s, Meter):
return s
if not s:
return DEFAULT_METER
try:
num_s, den_s = str(s).split("/")
num, den = int(num_s), int(den_s)
except (ValueError, AttributeError):
return DEFAULT_METER
if den not in _ALLOWED_DENOMINATORS or not (1 <= num <= 32):
return DEFAULT_METER
return Meter(num, den)
4 changes: 3 additions & 1 deletion backend/app/generators/arpeggio.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# <https://www.gnu.org/licenses/> for details.
import random
from app.core.meter import Meter, DEFAULT_METER
from typing import List

from app.services.midi_writer import NoteEvent
Expand Down Expand Up @@ -43,6 +44,7 @@ def generate_arpeggio(
melody_rests: list | None = None,
chord_tones: list | None = None,
seed_contour: list[int] | None = None,
meter: Meter = DEFAULT_METER,
) -> List[NoteEvent]:
"""Generate an arpeggio part.

Expand All @@ -63,7 +65,7 @@ def generate_arpeggio(
allow_7th = arp_cfg.get("allow_7th", False)
swing_amount = style.get("drums", {}).get("swing", 0.0)

beats_per_bar = 4
beats_per_bar = meter.bar_beats
prog_len = len(progression)
ticks_per_beat = 480

Expand Down
14 changes: 9 additions & 5 deletions backend/app/generators/bass.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# <https://www.gnu.org/licenses/> for details.
import random
from app.core.meter import Meter, DEFAULT_METER
from typing import List

from app.services.midi_writer import NoteEvent
Expand Down Expand Up @@ -72,11 +73,12 @@ def _generate_walking_bass(
progression: list,
kick_times: list[float] | None = None,
harmony_complexity: float | None = None,
meter: Meter = DEFAULT_METER,
) -> List[NoteEvent]:
"""Jazz-style 4-to-the-floor walking bass with chord-tone navigation and chromatic approach."""
events: List[NoteEvent] = []
prog_len = len(progression)
beats_per_bar = 4
beats_per_bar = meter.bar_beats
swing_amount = style.get("drums", {}).get("swing", 0.0)
ticks_per_beat = 480

Expand Down Expand Up @@ -222,11 +224,12 @@ def _generate_808_bass(
harmony_complexity: float | None = None,
push_windows: set[int] | None = None,
rhythm_cell: list[float] | None = None,
meter: Meter = DEFAULT_METER,
) -> List[NoteEvent]:
"""Long-sustain 808-style bass with a randomly selected rhythmic pattern per generation."""
events: List[NoteEvent] = []
prog_len = len(progression)
beats_per_bar = 4
beats_per_bar = meter.bar_beats

# The chords/melody switch to 2 chords per bar above harmony_complexity 0.6
# (their shared grid). Each 808 hit takes the root of the chord actually
Expand Down Expand Up @@ -359,6 +362,7 @@ def generate_bass(
rhythm_cell: list[float] | None = None, # the song's rhythmic cell (theme onset offsets) — the bass quotes it
cell_contour: list[int] | None = None, # the song's melodic cell (scale-step deltas) — shapes call-response answers
section_type: str | None = None, # riff mode: the bass doubles the guitar riff in riff sections
meter: Meter = DEFAULT_METER,
) -> List[NoteEvent]:
if progression is None:
templates = style.get("progression_templates", [["i", "VI", "III", "VII"]])
Expand All @@ -381,12 +385,12 @@ def generate_bass(
return _fold_to_range(
_generate_808_bass(style, key, scale, bars, complexity, variation, progression,
kick_times, harmony_complexity=harmony_complexity,
push_windows=push_windows, rhythm_cell=rhythm_cell), _bass_profile)
push_windows=push_windows, rhythm_cell=rhythm_cell, meter=meter), _bass_profile)

if bass_cfg.get("bass_style") == "walking":
return _fold_to_range(
_generate_walking_bass(style, key, scale, bars, complexity, variation, progression,
kick_times, harmony_complexity=harmony_complexity), _bass_profile)
kick_times, harmony_complexity=harmony_complexity, meter=meter), _bass_profile)

events: List[NoteEvent] = []
density = bass_cfg.get("pattern_density", 0.5)
Expand All @@ -402,7 +406,7 @@ def _swing(beat: float) -> float:
tick = int(beat * ticks_per_beat)
return apply_swing(tick, swing_amount, ticks_per_beat) / ticks_per_beat

beats_per_bar = 4
beats_per_bar = meter.bar_beats
# Match the chord generator's chord density: 2 chords/bar at high complexity
_harmony_cplx = complexity if harmony_complexity is None else harmony_complexity
chords_per_bar = 2 if _harmony_cplx > 0.6 else 1
Expand Down
4 changes: 3 additions & 1 deletion backend/app/generators/chords.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# <https://www.gnu.org/licenses/> for details.
import random
from app.core.meter import Meter, DEFAULT_METER
from typing import List

from app.services.midi_writer import NoteEvent
Expand Down Expand Up @@ -332,6 +333,7 @@ def generate_chords(
prev_voicing: list[int] | None = None,
push_windows: set[int] | None = None,
section_type: str | None = None,
meter: Meter = DEFAULT_METER,
) -> List[NoteEvent]:
"""`harmony_complexity` — the value that decides chords-per-bar, shared with
melody/bass so all three parts agree on the harmonic grid (falls back to
Expand Down Expand Up @@ -467,7 +469,7 @@ def _styled_arc(bar: int, total: int, base: int, start: float) -> int:
t = bar / max(1, total - 1)
return max(1, min(127, int(base * (start + (1.0 - start) * t))))

beats_per_bar = 4
beats_per_bar = meter.bar_beats
phrase_beats = beats_per_bar * 4 # 4-bar phrase = 16 beats
_harmony_cplx = complexity if harmony_complexity is None else harmony_complexity
chords_per_bar = 2 if _harmony_cplx > 0.6 else 1
Expand Down
4 changes: 3 additions & 1 deletion backend/app/generators/counter_melody.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# <https://www.gnu.org/licenses/> for details.
import random
from app.core.meter import Meter, DEFAULT_METER
from typing import List

from app.services.midi_writer import NoteEvent
Expand All @@ -26,6 +27,7 @@ def generate_counter_melody(
melody_rests: list | None = None,
cell: list[int] | None = None,
section_type: str | None = None,
meter: Meter = DEFAULT_METER,
) -> List[NoteEvent]:
"""The melody's second voice — dual mode depending on the section.

Expand All @@ -50,7 +52,7 @@ def generate_counter_melody(
vel_scale = cm_cfg.get("velocity_scale", 0.72)
floor = cm_cfg.get("floor", 55) # don't harmonize below this — muddiness guard

beats_per_bar = 4
beats_per_bar = meter.bar_beats
prog_len = len(progression) if progression else 0

# ── Answer mode ───────────────────────────────────────────────────────────
Expand Down
4 changes: 3 additions & 1 deletion backend/app/generators/drums.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# <https://www.gnu.org/licenses/> for details.
import random
from app.core.meter import Meter, DEFAULT_METER
from typing import List

from app.services.midi_writer import NoteEvent
Expand Down Expand Up @@ -106,6 +107,7 @@ def generate_drums(
section_type: str | None = None,
next_section_type: str | None = None,
dynamics: float = 0.5,
meter: Meter = DEFAULT_METER,
) -> List[NoteEvent]:
"""`section_type` / `next_section_type` — song-section context (loop-mode song
building). The groove *arranges* per section instead of playing the same beat
Expand Down Expand Up @@ -181,7 +183,7 @@ def generate_drums(
section_ends = set(section_end_bars) if section_end_bars else set()
hat_note = DRUM_MAP["ride"] if use_ride else DRUM_MAP["closed_hat"]

beats_per_bar = 4
beats_per_bar = meter.bar_beats
step = 0.25 # sixteenth note
ticks_per_beat = 480
hat_base_vel = 74 # peak velocity for the 16-step accent curve
Expand Down
4 changes: 3 additions & 1 deletion backend/app/generators/melody.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# <https://www.gnu.org/licenses/> for details.
import random
from app.core.meter import Meter, DEFAULT_METER
from typing import List

from app.services.midi_writer import NoteEvent
Expand Down Expand Up @@ -193,6 +194,7 @@ def generate_melody(
harmony_complexity: float | None = None,
seed_motif: list[int] | None = None,
section_type: str | None = None,
meter: Meter = DEFAULT_METER,
) -> List[NoteEvent]:
"""`melody_model` — optional mined melody prior (interval/phrase-bigram stats).

Expand Down Expand Up @@ -289,7 +291,7 @@ def _styled_arc(bar: int, total: int, base: int, start: float) -> int:
use_hi_register = False
current_phrase_idx = -1

beats_per_bar = 4
beats_per_bar = meter.bar_beats
step = 0.25 # 16th notes
_harmony_cplx = complexity if harmony_complexity is None else harmony_complexity
chords_per_bar = 2 if _harmony_cplx > 0.6 else 1
Expand Down
Loading