diff --git a/backend/app/core/arrangement.py b/backend/app/core/arrangement.py
index f740f25..73a678b 100644
--- a/backend/app/core/arrangement.py
+++ b/backend/app/core/arrangement.py
@@ -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
@@ -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")]
@@ -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:
@@ -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
@@ -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
@@ -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
diff --git a/backend/app/core/meter.py b/backend/app/core/meter.py
new file mode 100644
index 0000000..4526b4f
--- /dev/null
+++ b/backend/app/core/meter.py
@@ -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
+# 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)
diff --git a/backend/app/generators/arpeggio.py b/backend/app/generators/arpeggio.py
index 94f6d9d..c72f990 100644
--- a/backend/app/generators/arpeggio.py
+++ b/backend/app/generators/arpeggio.py
@@ -7,6 +7,7 @@
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# for details.
import random
+from app.core.meter import Meter, DEFAULT_METER
from typing import List
from app.services.midi_writer import NoteEvent
@@ -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.
@@ -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
diff --git a/backend/app/generators/bass.py b/backend/app/generators/bass.py
index d8e2044..61fd1e3 100644
--- a/backend/app/generators/bass.py
+++ b/backend/app/generators/bass.py
@@ -7,6 +7,7 @@
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# for details.
import random
+from app.core.meter import Meter, DEFAULT_METER
from typing import List
from app.services.midi_writer import NoteEvent
@@ -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
@@ -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
@@ -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"]])
@@ -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)
@@ -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
diff --git a/backend/app/generators/chords.py b/backend/app/generators/chords.py
index ab92054..f825489 100644
--- a/backend/app/generators/chords.py
+++ b/backend/app/generators/chords.py
@@ -7,6 +7,7 @@
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# for details.
import random
+from app.core.meter import Meter, DEFAULT_METER
from typing import List
from app.services.midi_writer import NoteEvent
@@ -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
@@ -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
diff --git a/backend/app/generators/counter_melody.py b/backend/app/generators/counter_melody.py
index 97869a1..88e4974 100644
--- a/backend/app/generators/counter_melody.py
+++ b/backend/app/generators/counter_melody.py
@@ -7,6 +7,7 @@
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# for details.
import random
+from app.core.meter import Meter, DEFAULT_METER
from typing import List
from app.services.midi_writer import NoteEvent
@@ -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.
@@ -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 ───────────────────────────────────────────────────────────
diff --git a/backend/app/generators/drums.py b/backend/app/generators/drums.py
index 1ff5eb0..9083db1 100644
--- a/backend/app/generators/drums.py
+++ b/backend/app/generators/drums.py
@@ -7,6 +7,7 @@
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# for details.
import random
+from app.core.meter import Meter, DEFAULT_METER
from typing import List
from app.services.midi_writer import NoteEvent
@@ -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
@@ -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
diff --git a/backend/app/generators/melody.py b/backend/app/generators/melody.py
index af6262f..078ee0d 100644
--- a/backend/app/generators/melody.py
+++ b/backend/app/generators/melody.py
@@ -7,6 +7,7 @@
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# for details.
import random
+from app.core.meter import Meter, DEFAULT_METER
from typing import List
from app.services.midi_writer import NoteEvent
@@ -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).
@@ -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
diff --git a/backend/app/generators/pads.py b/backend/app/generators/pads.py
index 71fea7d..018af95 100644
--- a/backend/app/generators/pads.py
+++ b/backend/app/generators/pads.py
@@ -7,6 +7,7 @@
# version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
# for details.
import random
+from app.core.meter import Meter, DEFAULT_METER
from typing import List
from app.services.midi_writer import NoteEvent
@@ -26,6 +27,7 @@ def generate_pads(
progression: list | None = None,
harmony_complexity: float | None = None,
melody_top: int | None = None,
+ meter: Meter = DEFAULT_METER,
) -> List[NoteEvent]:
"""Sustained atmospheric chord layer above the comp chords.
@@ -76,7 +78,7 @@ def generate_pads(
# 9th color adds shimmer at higher complexity
color_9th_prob = pad_cfg.get("color_9th_prob", 0.35 if complexity > 0.5 else 0.0)
- beats_per_bar = 4
+ beats_per_bar = meter.bar_beats
# Pads follow the SAME harmonic grid as the chords (2 windows per bar above
# harmony_complexity 0.6). They used to hold one chord per whole bar, which
# left the pad sustaining the departed chord across the second window of
diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py
index 334cb06..322696f 100644
--- a/backend/app/models/schemas.py
+++ b/backend/app/models/schemas.py
@@ -41,6 +41,7 @@ class GenerateRequest(BaseModel):
scale: str = "minor"
bpm: int = Field(default=140, ge=40, le=240)
bars: int = Field(default=8, ge=1, le=128)
+ time_signature: str = "4/4" # "N/D" — 4/4, 3/4, 6/8, 7/8, … (denominator 2/4/8/16); bad input falls back to 4/4
complexity: float = Field(default=0.5, ge=0.0, le=1.0)
variation: float = Field(default=0.4, ge=0.0, le=1.0)
dynamics: float = Field(default=0.5, ge=0.0, le=1.0) # arrangement drama: 0.5 = classic behavior, 0 = flat beat-tape, 1 = every drop/fill/lift pushed
@@ -65,6 +66,7 @@ class RegeneratePartRequest(BaseModel):
scale: str = "minor"
bpm: int = Field(default=140, ge=40, le=240)
bars: int = Field(default=8, ge=1, le=128)
+ time_signature: str = "4/4" # must match the original generation for faithful replay
complexity: float = Field(default=0.5, ge=0.0, le=1.0)
variation: float = Field(default=0.4, ge=0.0, le=1.0)
dynamics: float = Field(default=0.5, ge=0.0, le=1.0) # must match the original generation for faithful drum replay
@@ -148,6 +150,7 @@ class BuildSongRequest(BaseModel):
key: str = "C"
scale: str = "minor"
bpm: int = Field(default=120, ge=40, le=240)
+ time_signature: str = "4/4" # "N/D" — 4/4, 3/4, 6/8, 7/8, … ; bad input falls back to 4/4
complexity: float = Field(default=0.6, ge=0.0, le=1.0)
variation: float = Field(default=0.4, ge=0.0, le=1.0)
dynamics: float = Field(default=0.5, ge=0.0, le=1.0) # arrangement drama: scales drops/fills/breakdowns/section contrast; 0.5 = classic
diff --git a/backend/app/services/generation.py b/backend/app/services/generation.py
index 4d3f9bd..c25f7ab 100644
--- a/backend/app/services/generation.py
+++ b/backend/app/services/generation.py
@@ -36,6 +36,7 @@
_part_seed, scaled_profile,
_apply_section_ramp, _plan_sections, _auto_arc_section_type, _section_end_bars,
)
+from app.core.meter import parse_meter
from app.services.mixdown import (
_PART_CHANNELS, _VELOCITY_SCALE, _generate_part_cc, _generate_melody_expression_cc, _generate_bass_expression_cc,
_generate_808_pitch_bends, _shift,
@@ -443,12 +444,15 @@ def _pseed(sec_i: int, part: str) -> int:
_sec_var = min(1.0, req.variation * _sec_profile.get("variation_scale", 1.0))
_sec_dyn = _sec_profile.get("velocity_scale", 1.0)
+ # Time signature: bar length in quarter-beats (4/4 → 4.0, so 4/4 is unchanged).
+ meter = parse_meter(getattr(req, "time_signature", None))
+
if is_loop:
sections = [{"bars": req.bars, "complexity": _sec_cplx, "parts": req.parts,
"offset": 0, "key": req.key, "dynamic": _sec_dyn}]
else:
key_shift = style.get("chorus_key_shift", 0)
- sections = _plan_sections(req.bars, req.complexity, req.parts, req.key, key_shift)
+ sections = _plan_sections(req.bars, req.complexity, req.parts, req.key, key_shift, meter=meter)
all_events: dict[str, list[NoteEvent]] = {part: [] for part in req.parts}
_chords_prev = list(chords_prev_voicing) if chords_prev_voicing else None
@@ -477,7 +481,7 @@ def _pseed(sec_i: int, part: str) -> int:
if section_i + 1 < len(sections) else None)
if s_sec_type == "chorus":
- chorus_spans.append((float(s_off), float(s_off) + s_bars * 4.0))
+ chorus_spans.append((float(s_off), float(s_off) + s_bars * meter.bar_beats))
# A section only gets the "final cadence" static-root bass treatment if
# it's genuinely the song's last section. In loop mode (song builder)
@@ -521,7 +525,7 @@ def _pseed(sec_i: int, part: str) -> int:
is_loop=is_loop,
section_type=s_sec_type,
next_section_type=s_next_type,
- dynamics=_dynamics)
+ dynamics=_dynamics, meter=meter)
drum_evts = _apply_dynamic(drum_evts, s_dyn)
all_events["drums"].extend(_shift(drum_evts, s_off))
kick_times = [e.start for e in drum_evts if e.pitch == DRUM_MAP["kick"]]
@@ -558,7 +562,7 @@ def _pseed(sec_i: int, part: str) -> int:
melody_model=_melody_model,
harmony_complexity=harmony_cplx,
seed_motif=melody_seed_motif,
- section_type=s_sec_type)
+ section_type=s_sec_type, meter=meter)
mel_evts = _apply_dynamic(mel_evts, s_dyn)
all_events["melody"].extend(_shift(mel_evts, s_off))
if mel_evts:
@@ -587,7 +591,7 @@ def _pseed(sec_i: int, part: str) -> int:
cm_evts = generate_counter_melody(mel_evts, s_key, req.scale, s_bars,
s_resolved, style,
melody_rests=mel_rests, cell=_answer_cell,
- section_type=s_sec_type)
+ section_type=s_sec_type, meter=meter)
cm_evts = _apply_dynamic(cm_evts, s_dyn)
all_events["counter_melody"].extend(_shift(cm_evts, s_off))
@@ -607,7 +611,7 @@ def _pseed(sec_i: int, part: str) -> int:
harmony_complexity=harmony_cplx,
prev_voicing=_chords_prev,
push_windows=push_windows,
- section_type=s_sec_type)
+ section_type=s_sec_type, meter=meter)
section_chord_tones = _chord_tones_by_bar(
[(e.start, e.pitch) for e in evts], s_bars)
_chords_prev = _final_chord_voicing(evts)
@@ -615,7 +619,8 @@ def _pseed(sec_i: int, part: str) -> int:
evts = generate_pads(style, s_key, req.scale, s_bars, backing_cplx,
eff_var, s_resolved,
harmony_complexity=harmony_cplx,
- melody_top=(mel_range[1] if melody_ceiling is not None else None))
+ melody_top=(mel_range[1] if melody_ceiling is not None else None),
+ meter=meter)
elif part == "bass":
evts = generate_bass(style, s_key, req.scale, s_bars, backing_cplx,
eff_var, bass_prog, kick_times,
@@ -624,7 +629,7 @@ def _pseed(sec_i: int, part: str) -> int:
push_windows=push_windows,
rhythm_cell=rhythm_cell,
cell_contour=_answer_cell,
- section_type=s_sec_type)
+ section_type=s_sec_type, meter=meter)
elif part == "arpeggio":
arp_octave = 6 if has_melody else 5
# When melody is active, pull arpeggio back so it supports rather than competes.
@@ -634,7 +639,7 @@ def _pseed(sec_i: int, part: str) -> int:
eff_var, s_resolved, arp_octave,
melody_rests=mel_rests if has_melody else None,
chord_tones=section_chord_tones,
- seed_contour=arp_contour)
+ seed_contour=arp_contour, meter=meter)
evts = _apply_dynamic(evts, s_dyn)
all_events[part].extend(_shift(evts, s_off))
diff --git a/backend/app/services/midi_writer.py b/backend/app/services/midi_writer.py
index d5c1055..59e051a 100644
--- a/backend/app/services/midi_writer.py
+++ b/backend/app/services/midi_writer.py
@@ -14,6 +14,8 @@
import mido
+from app.core.meter import Meter, DEFAULT_METER
+
from app.core.constants import TICKS_PER_BEAT
@@ -152,14 +154,21 @@ def _build_tempo_track(
tempo_events: "list[tuple[float, float]] | None" = None,
markers: "list[tuple[float, str]] | None" = None,
key_signature: str | None = None,
+ meter: Meter = DEFAULT_METER,
) -> mido.MidiTrack:
"""Tempo/meta track. `tempo_events` — optional (beat, bpm) tempo map; when
given it replaces the single set_tempo so songs can push choruses slightly
and ritardando into the ending. An event at beat 0 overrides `bpm`.
`markers` — optional (beat, label) section markers so DAW timelines show
- Intro/Verse/Chorus. `key_signature` — optional mido key name (e.g. "Am")."""
+ Intro/Verse/Chorus. `key_signature` — optional mido key name (e.g. "Am").
+ `meter` — the song's time signature (4/4 → the classic meta, byte-identical)."""
track = mido.MidiTrack()
- track.append(mido.MetaMessage("time_signature", numerator=4, denominator=4, clocks_per_click=24, notated_32nd_notes_per_beat=8, time=0))
+ # Compound meters click on the dotted quarter (36 MIDI clocks); simple on the
+ # quarter (24). 4/4 → 4,4,24,8 exactly as before, so its output is unchanged.
+ track.append(mido.MetaMessage("time_signature", numerator=meter.numerator,
+ denominator=meter.denominator,
+ clocks_per_click=36 if meter.is_compound else 24,
+ notated_32nd_notes_per_beat=8, time=0))
points = sorted(tempo_events or [], key=lambda p: p[0])
if not points or points[0][0] > 0:
points = [(0.0, float(bpm))] + points
@@ -193,9 +202,10 @@ def write_midi(
pb_events: "List[PitchBendEvent] | None" = None,
tempo_events: "list[tuple[float, float]] | None" = None,
track_name: str | None = None,
+ meter: Meter = DEFAULT_METER,
) -> None:
mid = mido.MidiFile(type=1, ticks_per_beat=ticks_per_beat)
- mid.tracks.append(_build_tempo_track(bpm, ticks_per_beat, tempo_events))
+ mid.tracks.append(_build_tempo_track(bpm, ticks_per_beat, tempo_events, meter=meter))
track = _events_to_track(events, ticks_per_beat, cc_events, pb_events)
if program is not None and events:
track.insert(0, mido.Message("program_change", channel=events[0].channel, program=program, time=0))
@@ -207,7 +217,8 @@ def write_midi(
mid.save(str(output_path))
-def concatenate_midi_files(paths: list[Path], out_ticks: int = TICKS_PER_BEAT) -> mido.MidiFile:
+def concatenate_midi_files(paths: list[Path], out_ticks: int = TICKS_PER_BEAT,
+ meter: Meter = DEFAULT_METER) -> mido.MidiFile:
"""Sequentially concatenate MIDI files into a single arrangement.
Each file starts immediately after the previous one ends. Tracks from
@@ -228,8 +239,10 @@ def concatenate_midi_files(paths: list[Path], out_ticks: int = TICKS_PER_BEAT) -
break
t_track = mido.MidiTrack()
- t_track.append(mido.MetaMessage("time_signature", numerator=4, denominator=4,
- clocks_per_click=24, notated_32nd_notes_per_beat=8, time=0))
+ t_track.append(mido.MetaMessage("time_signature", numerator=meter.numerator,
+ denominator=meter.denominator,
+ clocks_per_click=36 if meter.is_compound else 24,
+ notated_32nd_notes_per_beat=8, time=0))
t_track.append(mido.MetaMessage("set_tempo", tempo=tempo, time=0))
t_track.append(mido.MetaMessage("end_of_track", time=0))
out.tracks.append(t_track)
@@ -283,7 +296,8 @@ def rebuild_combined_from_parts(output_dir: Path, bpm: int, ticks_per_beat: int
tempo_events: "list[tuple[float, float]] | None" = None,
markers: "list[tuple[float, str]] | None" = None,
key_signature: str | None = None,
- track_names: "dict[str, str] | None" = None) -> None:
+ track_names: "dict[str, str] | None" = None,
+ meter: Meter = DEFAULT_METER) -> None:
"""Rebuild the combined .mid by merging all per-part .mid files in output_dir.
Called after regenerating a single part so the combined stays in sync without
@@ -297,7 +311,7 @@ def rebuild_combined_from_parts(output_dir: Path, bpm: int, ticks_per_beat: int
return
combined = mido.MidiFile(type=1, ticks_per_beat=ticks_per_beat)
- combined.tracks.append(_build_tempo_track(bpm, ticks_per_beat, tempo_events, markers, key_signature))
+ combined.tracks.append(_build_tempo_track(bpm, ticks_per_beat, tempo_events, markers, key_signature, meter))
for part_file in part_files:
part_mid = mido.MidiFile(str(part_file))
@@ -322,9 +336,10 @@ def write_combined_midi(
markers: "list[tuple[float, str]] | None" = None,
key_signature: str | None = None,
track_names: "dict[str, str] | None" = None,
+ meter: Meter = DEFAULT_METER,
) -> None:
mid = mido.MidiFile(type=1, ticks_per_beat=ticks_per_beat)
- mid.tracks.append(_build_tempo_track(bpm, ticks_per_beat, tempo_events, markers, key_signature))
+ mid.tracks.append(_build_tempo_track(bpm, ticks_per_beat, tempo_events, markers, key_signature, meter))
for part_name, events in parts.items():
track = _events_to_track(
diff --git a/docs/roadmap.md b/docs/roadmap.md
index f07a41c..c5b4ad0 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -6,14 +6,13 @@ they land; add new findings under the right phase so nothing gets lost.
**Status legend:** `[ ]` todo · `[~]` in progress · `[x]` done · `[-]` won't do / obsolete ·
`[→]` moved to another phase
-_Last updated: 2026-07-28 — Phase 1 & 2 complete (Phase 2 bar the manual custom-instruments
-desktop pass). **Phase 3 substantially complete:** "parallelize generation" retired as a
+_Last updated: 2026-07-28 — Phases 1, 2 & 3 complete (Phase 2 bar the manual
+custom-instruments desktop pass). Phase 3 delivered: "parallelize generation" retired as a
phantom (generation is ~0.5s; the real wait — the WAV export render — is now parallelised +
auto-pauses playback), deprecation warnings cleared, swallowed exceptions logged, the two
route god-files split into `services/`, `useMidiPlayer.ts` split 1228→610 across five focused
-modules, and frontend tests grown 12→123. Open tails: a desktop wall-time number for the WAV
-render, a playback/export smoke test for the composable split, and optional follow-on moves
-(`_do_build_song`, the `toggle` shell)._
+modules, and frontend tests grown 12→123. All smoke-tested in the app. Two optional cosmetic
+moves deferred (`_do_build_song`, the `toggle` shell). **Now starting Phase 4 — features.**_
---
@@ -134,7 +133,7 @@ render, a playback/export smoke test for the composable split, and optional foll
pitch-detection, web/OPFS storage.
→ `soundfonts/customInstruments.ts`, `soundfonts/customDrumKit.ts`, `soundfonts/audition.ts`, `composables/useCustomInstruments.ts`, `components/InstrumentsPanel.vue`, `electron/main.ts`
-## Phase 3 — Performance & refactor
+## Phase 3 — Performance & refactor ✅ COMPLETE (2026-07-28)
- [-] **Parallelize full-song section generation** — _obsolete: the premise was
wrong._ Measured 2026-07-28: backend section generation is **0.5–0.8s** end-to-end
@@ -150,7 +149,7 @@ render, a playback/export smoke test for the composable split, and optional foll
genuinely CPU-heavy path is the **on-demand WAV export** (`offlineRender`, synth-only
`OfflineAudioContext`) — tracked as a new item below.
→ `backend/app/api/routes_song.py`
-- [~] **Speed up WAV export render** — _this is the real "1–2 min" wait, not generation._
+- [x] **Speed up WAV export render** — _this is the real "1–2 min" wait, not generation._
Measured 2026-07-28 (headless Chromium, real Web Audio, 112s song / 2,576 voices, graph
matching `offlineRender`): **56.4s** with the full FX chain, **51.8s** with all FX removed.
The actual Tone.js app render is ≥ this (per-voice JS wrappers, `standardized-audio-context`
@@ -169,13 +168,14 @@ render, a playback/export smoke test for the composable split, and optional foll
stem export keeps the limiter inline (nothing to parallelise). Validated: pure mix logic
unit-tested (`offlineMix.test.ts`); full end-to-end run in real Chromium produces valid
non-silent audio via the parallel path; `vue-tsc` + eslint + 96 vitest green.
- **Still open:** a representative desktop wall-time before/after — headless SwiftShader
- renders the Tone graph ~10× slower than realtime, so it can't measure the real speedup;
- needs a WAV export in the actual desktop Electron app (GPU Chromium) to confirm the number.
+ Smoke-tested working in the desktop app; playback auto-pauses on export as intended.
+ _Optional telemetry:_ a precise desktop wall-time before/after was never captured (headless
+ SwiftShader is ~10× slower than realtime, so it can't measure the real speedup) — the
+ mechanism (2.48× concurrent-context probe) stands regardless.
Bench harness: `frontend/scripts/bench_render.mjs` (drives cached Chromium over CDP; no display).
→ `frontend/src/composables/useMidiPlayer.ts` (`offlineRender`, `renderOfflineFast`,
`sumPartBuffers`, `limitMix`)
-- [~] **Extract song/arrangement logic out of route god-files into `services/`** —
+- [x] **Extract song/arrangement logic out of route god-files into `services/`** —
handlers should be thin. Done for the two biggest logic blobs, each verified by the 135
backend tests + ruff: the **generation core** (`_run_attempt`, progression choice, style
blend/groove overlay, voice-leading + quality helpers) → **`services/generation.py`**, and
@@ -184,10 +184,10 @@ render, a playback/export smoke test for the composable split, and optional foll
**`services/song_builder.py`**. Both route files (and the new services) now depend on
`services/generation.py` instead of importing generation logic out of `routes_generate.py`.
`routes_generate.py` **1299 → 636**, `routes_song.py` **1498 → 889**.
- _Remaining follow-up:_ the song-build coordinator `_do_build_song` and a few
+ _Deferred (optional):_ the song-build coordinator `_do_build_song` and a few
regenerate/stem/version helpers still sit in `routes_song.py` between the endpoints; they
could move into `song_builder.py` too, but the endpoints that call them are already thin.
-- [~] **Split `useMidiPlayer.ts`** into focused composables — **1228 → 622 lines.** Four
+- [x] **Split `useMidiPlayer.ts`** into focused composables — **1228 → 610 lines.** Five
concerns pulled out: the offline WAV export → **`useOfflineRender.ts`** (`offlineRenderRaw`,
`sumPartBuffers`, `partHasNotes`, `limitMix`, `renderOfflineFast`, `isRendering`); the
shared style-classification Sets + `PLAYER_PARTS`/`PlayerPart`/`CHANNEL_PART` →
@@ -203,11 +203,10 @@ render, a playback/export smoke test for the composable split, and optional foll
per-note trigger callbacks (mute gating, the kick sidechain pump, trigger args) also came out
→ **`scheduler.ts`** (`drumTriggerCallback` / `voiceTriggerCallback`), injected into
`toggle`'s `Tone.Part`s. **1228 → 610 lines.**
- _Still to sign off:_ a live playback + WAV-export smoke test in the app (runtime audio is the
- one thing the checks can't cover). _Remaining:_ `toggle`'s shell (async sampler load → graph
- wiring → transport control) is now mostly I/O orchestration — its extractable logic is out
- and tested; moving the shell itself is cosmetic and high-risk without runtime audio, so left
- as-is.
+ Smoke-tested in the desktop app (playback + WAV export + per-part mute all good).
+ _Deferred (optional):_ `toggle`'s shell (async sampler load → graph wiring → transport
+ control) is now mostly I/O orchestration — its extractable logic is out and tested; moving
+ the shell itself is cosmetic and high-risk without runtime audio, so left as-is.
- [x] **Log swallowed exceptions** — audited all 27 broad `except` blocks. 12 already
logged or re-raised; 6 genuine silent faults now log — `record_export_keep`
(`routes_library.py`, warning), malformed groove/genre priors (`priors.py`, warning),
@@ -238,8 +237,21 @@ render, a playback/export smoke test for the composable split, and optional foll
- [ ] **Expose the data-driven priors toggle in the UI** — wired end-to-end already, no
frontend control yet. Low effort.
-- [ ] **Non-4/4 time signatures** (6/8, 3/4, 7/8) — currently hardcoded `numerator=4`.
- → `backend/app/services/midi_writer.py:162`
+- [~] **Non-4/4 time signatures** (6/8, 3/4, 7/8) — **Milestone 1 (foundation) shipped.**
+ A `Meter` model (`core/meter.py`) centralizes the math: `bar_beats` (bar length in
+ quarter-beats — 4/4→4.0, 3/4→3.0, 6/8→3.0, 7/8→3.5), compound/odd detection, and pulse
+ positions (unit-tested across 4/4, 3/4, 6/8, 7/8, 9/8, 12/8, 5/8). `time_signature` added
+ to all request schemas; the MIDI writer emits the correct `time_signature` meta (compound
+ clicks on the dotted quarter). The meter is threaded end-to-end through the generation core
+ — all 7 generators (which were already internally parameterized by a local `beats_per_bar`),
+ `_plan_sections`, and the section loop. **4/4 stays byte-identical (135 backend tests green)**
+ and non-4/4 runs end-to-end with correctly-sized bars + meta.
+ _Milestone 2 (remaining):_ generators still place some notes at fixed within-bar offsets that
+ overflow shorter bars (walking-bass beat 4, the drum 16-step grid) — needs per-generator
+ within-bar generalization; thread the meter through the song services (`song_builder`,
+ `mixdown`, `quality`) for full non-4/4 *songs*; idiomatic compound (6/8 triplet) / odd (7/8
+ grouping) drum feel; a frontend selector; and 3/4/6/8/7/8 placement tests. Best done with
+ the app running so the *feel* gets ear-validated. → `backend/app/core/meter.py`
- [→] **Custom soundfont / SF2 upload** — _promoted to Phase 2 (Sound polish)._
- [ ] **Surface WAV/offline-audio export more prominently** — already implemented
(OfflineAudioContext + render queue); just under-discovered.