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.