diff --git a/backend/app/api/routes_generate.py b/backend/app/api/routes_generate.py
index bfb0af5..48823b7 100644
--- a/backend/app/api/routes_generate.py
+++ b/backend/app/api/routes_generate.py
@@ -21,727 +21,38 @@
from app.models.schemas import GenerateRequest, RegeneratePartRequest, GenerateResponse, FileInfo, GenerateSummary, QualityScore, BatchGenerateRequest
from app.services.style_loader import load_style
-from app.services.midi_writer import NoteEvent, ControlEvent, PitchBendEvent, write_midi, write_combined_midi, rebuild_combined_from_parts, concatenate_midi_files, read_note_starts
+from app.services.midi_writer import NoteEvent, write_midi, write_combined_midi, rebuild_combined_from_parts, concatenate_midi_files, read_note_starts
from app.generators.chords import generate_chords, resolve_progression
-from app.theory.scales import build_scale, scale_mode as _scale_mode
from app.generators.bass import generate_bass
from app.generators.melody import generate_melody
from app.generators.drums import generate_drums
from app.generators.arpeggio import generate_arpeggio
from app.generators.pads import generate_pads
from app.generators.counter_melody import generate_counter_melody
-from app.generators.answer import melody_cell
from app.core.config import EXPORTS_DIR
from app.core.constants import DRUM_MAP
-from app.services.humanize import apply_groove_pocket, apply_feel
-from app.services.quality import score_generation, extract_rhythm_patterns
-from app.services.priors import load_prior, sample_progression, melody_prior_for, groove_fields_for
+from app.services.priors import melody_prior_for
from app.services.library import save_generation as lib_save, build_scoring_style
from app.core.arrangement import (
- _part_seed, scaled_profile,
- _apply_section_ramp, _plan_sections, _auto_arc_section_type, _section_end_bars,
+ _part_seed, _plan_sections, _section_end_bars,
)
from app.services.mixdown import (
- _PART_CHANNELS, _VELOCITY_SCALE, part_midi_meta,
+ _PART_CHANNELS, part_midi_meta,
_generate_part_cc, _generate_melody_expression_cc, _generate_bass_expression_cc,
_generate_808_pitch_bends, _drop_quiet, _scale_velocity, _shift,
_apply_groove_push, _apply_dynamic,
)
+from app.services.generation import (
+ _chord_tones_by_bar, _MAX_QUALITY_ATTEMPTS, _GENERATION_TIMEOUT_S, _all_green,
+ _blend_styles, _prior_name, _overlay_groove,
+ _choose_progression, _run_attempt,
+)
+
router = APIRouter()
logger = logging.getLogger(__name__)
-def _final_chord_voicing(chord_events: list[NoteEvent]) -> list[int] | None:
- """Extract the last sounded chord voicing from a chord part's events.
-
- Used to thread voice leading across song sections: the closing voicing of
- one section seeds `prev_voicing` for the next, so the comp doesn't jump
- back to root position at every section seam. Strums offset note starts by
- a few ms, so everything within half a beat of the final onset counts as
- one voicing.
- """
- if not chord_events:
- return None
- last_start = max(e.start for e in chord_events)
- voicing = sorted({e.pitch for e in chord_events if e.start >= last_start - 0.5})
- return voicing or None
-
-
-def _chord_tones_by_bar(chord_notes, bars: int) -> list | None:
- """Per-bar sorted pitch-class lists from chord notes.
-
- ``chord_notes`` is an iterable of (start_beat, pitch) pairs (from generated
- events or a read-back .mid). Lets the arpeggio arpeggiate the chords' *actual*
- voiced harmony (including the 7ths/9ths and borrowed color the chord generator
- chose) instead of a re-derived plain triad. Empty bars (chord rests) inherit
- the nearest neighbouring bar's harmony so the arp never lands on a single stray
- note. Returns None when no chord notes exist (caller falls back to roman voicing).
- """
- tones: list[set[int]] = [set() for _ in range(bars)]
- for start, pitch in chord_notes:
- b = int(start // 4)
- if 0 <= b < bars:
- tones[b].add(pitch % 12)
- if not any(tones):
- return None
- last: set[int] | None = None
- for i in range(bars):
- if tones[i]:
- last = tones[i]
- elif last is not None:
- tones[i] = set(last)
- nxt: set[int] | None = None
- for i in range(bars - 1, -1, -1):
- if tones[i]:
- nxt = tones[i]
- elif nxt is not None:
- tones[i] = set(nxt)
- return [sorted(t) for t in tones]
-
-
-def _prevent_parallel_motion(
- melody_events: list[NoteEvent],
- bass_events: list[NoteEvent],
- section_scales: list[tuple[float, float, set[int]]] | None = None,
-) -> list[NoteEvent]:
- """Nudge melody notes that form parallel octaves or fifths with the bass.
-
- Only fixes the worst offender (direct parallel motion into an octave or fifth).
- Preserves all timing and velocity. Parallel motion into a unison (octave) is
- most objectionable; fifths are secondary.
-
- ``section_scales`` — (start_beat, end_beat, scale_pcs) per section, so the
- replacement pitch is the nearest non-parallel SCALE tone for the key that
- section actually sounds in. Without it the nudge is a raw +1/+2 semitones,
- which planted flat-out out-of-key notes (D+2 in C minor = E natural) in the
- melody — trading a subtle voice-leading blemish for an audible wrong note.
- """
- if not bass_events or not melody_events:
- return melody_events
-
- # Build a quick lookup: for each beat position, what bass pitch is sounding?
- bass_sorted = sorted(bass_events, key=lambda e: e.start)
-
- def _bass_pitch_at(beat: float) -> int | None:
- # Find the last bass note that started at or before `beat`
- result = None
- for e in bass_sorted:
- if e.start <= beat + 0.05:
- result = e.pitch
- else:
- break
- return result
-
- def _scale_pcs_at(beat: float) -> set[int] | None:
- if not section_scales:
- return None
- for s_start, s_end, pcs in section_scales:
- if s_start <= beat < s_end:
- return pcs
- return section_scales[-1][2]
-
- _PARALLEL_INTERVALS = {0, 7} # unison/octave (mod 12) and fifth
-
- def _nudged(pitch: int, bass_pitch: int, beat: float) -> int:
- pcs = _scale_pcs_at(beat)
- # Climb to the nearest higher pitch that breaks the parallel interval
- # AND stays in the section's scale (when known).
- for cand in range(pitch + 1, pitch + 13):
- if (cand - bass_pitch) % 12 in _PARALLEL_INTERVALS:
- continue
- if pcs is None or cand % 12 in pcs:
- return max(48, min(96, cand))
- return pitch
-
- fixed = []
- for i, mel in enumerate(melody_events):
- b_pitch = _bass_pitch_at(mel.start)
- if b_pitch is None:
- fixed.append(mel)
- continue
-
- # Check current interval
- interval = (mel.pitch - b_pitch) % 12
- if interval not in _PARALLEL_INTERVALS:
- fixed.append(mel)
- continue
-
- # Check if the previous mel note also made the same interval with prev bass
- # (that's what makes it "parallel" — motion into the same interval type)
- if i > 0:
- prev_mel = melody_events[i - 1]
- prev_bass = _bass_pitch_at(prev_mel.start)
- if prev_bass is not None:
- prev_interval = (prev_mel.pitch - prev_bass) % 12
- if prev_interval in _PARALLEL_INTERVALS:
- # Parallel motion confirmed — nudge to a scale-safe pitch
- fixed.append(NoteEvent(
- pitch=_nudged(mel.pitch, b_pitch, mel.start), start=mel.start,
- duration=mel.duration, velocity=mel.velocity, channel=mel.channel,
- ))
- continue
-
- fixed.append(mel)
-
- return fixed
-
-
-def _resolve_avoid_notes(
- melody_events: list[NoteEvent],
- harmony_events: list[NoteEvent],
- section_scales: list[tuple[float, float, set[int]]] | None = None,
- min_duration: float = 0.45,
-) -> list[NoteEvent]:
- """Nudge SUSTAINED melody notes off harsh clashes with the sounding harmony.
-
- An "avoid note" here is a melody note that (a) is not doubling any pitch
- class the harmony is sounding, and (b) sits at an ACTUAL distance of a
- minor 2nd (1 semitone) or minor 9th (13) from a simultaneously sounding
- chord/pad tone. Held for half a beat or more, that reads as a wrong note —
- the melody generator can't see the voicings the chords/pads actually chose
- (registers, extensions, strums), so this runs where the real events exist.
- Short notes are exempt: passing dissonance on 16ths is normal melodic
- motion. Wider mod-12 relatives (maj7, compound maj7) are consonant color
- and never touched.
-
- The replacement is the nearest in-scale pitch (per section, via
- ``section_scales``) that clears every overlapping harmony note; if none
- exists within a major 3rd, the note is left alone rather than mangled.
- """
- if not melody_events or not harmony_events:
- return melody_events
-
- harmony_sorted = sorted(harmony_events, key=lambda e: e.start)
- _HARSH = (1, 13)
-
- def _scale_pcs_at(beat: float) -> set[int] | None:
- if not section_scales:
- return None
- for s_start, s_end, pcs in section_scales:
- if s_start <= beat < s_end:
- return pcs
- return section_scales[-1][2]
-
- def _sounding(mel: NoteEvent) -> list[int]:
- out = []
- m_end = mel.start + mel.duration
- for h in harmony_sorted:
- if h.start >= m_end:
- break
- overlap = min(m_end, h.start + h.duration) - max(mel.start, h.start)
- if overlap > 0.1:
- out.append(h.pitch)
- return out
-
- fixed: list[NoteEvent] = []
- for mel in melody_events:
- if mel.duration < min_duration:
- fixed.append(mel)
- continue
- sounding = _sounding(mel)
- if not sounding or mel.pitch % 12 in {h % 12 for h in sounding}:
- fixed.append(mel)
- continue
- if not any(abs(mel.pitch - h) in _HARSH for h in sounding):
- fixed.append(mel)
- continue
- pcs = _scale_pcs_at(mel.start)
- new_pitch = mel.pitch
- for delta in (-1, 1, -2, 2, -3, 3, -4, 4):
- cand = mel.pitch + delta
- if not (0 <= cand <= 127):
- continue
- if pcs is not None and cand % 12 not in pcs:
- continue
- if any(abs(cand - h) in _HARSH for h in sounding):
- continue
- new_pitch = cand
- break
- fixed.append(NoteEvent(pitch=new_pitch, start=mel.start, duration=mel.duration,
- velocity=mel.velocity, channel=mel.channel)
- if new_pitch != mel.pitch else mel)
- return fixed
-
-
-_MAX_QUALITY_ATTEMPTS = 5
-_GREEN_THRESHOLD = 0.82
-_GENERATION_TIMEOUT_S = 30
-_QUALITY_DIMS = ("harmonic", "separation", "rhythm", "density", "mix")
-
-
-def _all_green(quality_raw: dict) -> bool:
- return all(quality_raw.get(d, 0.0) >= _GREEN_THRESHOLD for d in _QUALITY_DIMS)
-
-
-def _blend_styles(style: dict, blend_style_id: str | None, blend_amount: float) -> dict:
- """Numerically blend a second style into `style` (shared by /generate and
- the Song Builder). Groove/density/swing fields interpolate; progression
- template pools merge. Returns `style` unchanged when no blend applies."""
- if not blend_style_id or blend_style_id == style.get("id"):
- return style
- try:
- b_style = load_style(blend_style_id)
- except ValueError:
- logger.warning("Blend style %r not found — ignoring blend", blend_style_id)
- return style
- w = blend_amount
- _NUMERIC_BLEND = ("hat_density", "kick_density", "snare_density",
- "swing", "syncopation_prob", "groove_push")
- blended = {**style}
- for key in _NUMERIC_BLEND:
- if key in style and key in b_style:
- blended[key] = (1 - w) * style[key] + w * b_style[key]
- a_progs = style.get("progression_templates", [])
- b_progs = b_style.get("progression_templates", [])
- if a_progs and b_progs:
- blended["progression_templates"] = a_progs + b_progs
- if "drums" in style and "drums" in b_style:
- d_a, d_b = style["drums"], b_style["drums"]
- drum_blend = {**d_a}
- for k in ("hat_density", "kick_density", "snare_density", "swing",
- "triplet_probability", "ghost_probability"):
- if k in d_a and k in d_b:
- drum_blend[k] = (1 - w) * d_a[k] + w * d_b[k]
- blended["drums"] = drum_blend
- return blended
-
-
-def _prior_name(style: dict) -> str:
- """Which mined prior a style draws from: explicit `prior` field, else its id."""
- return style.get("prior") or style.get("id", "")
-
-
-def _overlay_groove(style: dict, use_priors: bool) -> dict:
- """Overlay a style with drum fields learned from a groove corpus, if one exists.
-
- The learned kick_pattern / snare backbeat / hat density / swing replace the
- style's hand-authored values so drums play a real, mined groove. Returns the
- style unchanged when no groove prior applies.
- """
- fields = groove_fields_for(style, use_priors)
- if not fields:
- return style
- return {**style, "drums": {**style.get("drums", {}), **fields}}
-
-
-# Chord-type suffixes a template token may carry (mirrors roman_to_chord's list;
-# longest first so e.g. 'ivsus2' strips to 'iv', not 'ivsus').
-_ROMAN_SUFFIXES = ("m7b5", "mM7", "dim7", "maj7", "9sus4", "7sus4", "sus2", "sus4",
- "add11", "add9", "aug", "dim", "m6", "m9", "6", "9")
-
-
-def _template_tonic_mode(template: list[str]) -> str | None:
- """'minor' if the template's tonic chord is bare i, 'major' if bare I,
- 'mixed' if it somehow contains both (a data bug), None if it never states
- an explicit tonic (e.g. a ii-V vamp) and so fits either mode."""
- has_min = has_maj = False
- for token in template:
- s = token.lstrip("b#")
- for suffix in _ROMAN_SUFFIXES:
- if s.lower().endswith(suffix):
- s = s[: -len(suffix)]
- break
- if s == "i":
- has_min = True
- elif s == "I":
- has_maj = True
- if has_min and has_maj:
- return "mixed"
- if has_min:
- return "minor"
- if has_maj:
- return "major"
- return None
-
-
-def _choose_progression(style: dict, use_priors: bool, seed: int, scale: str = "minor") -> list[str]:
- """Pick a progression: a mined corpus prior when available+enabled, else a template.
-
- The template RNG draw happens regardless so seeds stay stable with the legacy
- path; the prior only replaces the resulting progression when one exists. The
- prior is queried by mode so a major-key request gets a major progression.
-
- Templates are filtered to those whose tonic quality matches the requested
- scale's mode: a bare-'i' (minor tonic) template under a major-scale melody
- puts the whole harmony in the parallel minor while the melody stays major —
- every E-natural grinds against the chords' Eb (and vice versa for 'I'
- templates under a minor scale). Style JSONs may legitimately carry both
- major and minor templates; the request's scale decides which set applies.
- """
- templates = style.get("progression_templates", [["i", "VI", "III", "VII"]])
- mode = _scale_mode(scale)
- compatible = [t for t in templates if _template_tonic_mode(t) in (mode, None)]
- random.seed(seed)
- progression = random.choice(compatible or templates)
- if use_priors:
- prior = load_prior(_prior_name(style))
- if prior:
- sampled = sample_progression(prior, length=len(progression), seed=seed, mode=scale)
- if sampled:
- progression = sampled
- hrb = style.get("harmonic_rhythm_bars", 1)
- if hrb > 1:
- progression = [chord for chord in progression for _ in range(hrb)]
- # Chromatic color: season the diatonic progression with borrowed chords and
- # secondary dominants (roadmap-2 item 4). Gated by the style's chromatic_color
- # (default 0 = byte-identical). Applied once here so every section and the
- # scorer share the identical colored progression.
- _color = style.get("chromatic_color", 0.0)
- if _color:
- from app.generators.chords import apply_chromatic_color
- progression = apply_chromatic_color(progression, scale, _color,
- random.Random(seed ^ 0x00C010A))
- return progression
-
-
-def _run_attempt(
- req,
- style: dict,
- seed: int,
- is_loop: bool,
- groove_push: float,
- secondary_dominants: bool,
- tritone_sub: bool,
- scoring_style: dict | None = None,
- regen_part: str | None = None,
- regen_salt: int = 0,
- fixed_progression: list[str] | None = None,
- chords_prev_voicing: list[int] | None = None,
- melody_seed_motif: list[int] | None = None,
- rhythm_cell: list[float] | None = None,
- arp_contour: list[int] | None = None,
-) -> tuple[dict, dict, dict, list, dict | None, dict, list]:
- """Run one generation attempt for a given seed.
-
- Returns (all_events, cc_parts, pb_parts, progression, quality_raw, patterns).
- quality_raw is None if scoring raised an exception.
- scoring_style overrides the style dict used for quality scoring only,
- so learned patterns can improve scorer accuracy without touching generation.
-
- regen_part/regen_salt re-roll a single part in place: only that part's seed is
- salted, so harmony and every other part come out byte-identical — used to
- regenerate one stem of a song without disturbing the rest.
-
- fixed_progression — when given, skips the per-call progression draw and uses this
- progression instead. Used by the song builder so every section of a song shares
- one harmonic identity instead of each section type independently rolling its own
- progression; per-section chord substitutions (resolve_progression, seeded per
- section) still vary, giving related-but-not-identical harmony across sections.
-
- chords_prev_voicing — the previous song section's final chord voicing, threaded
- into generate_chords so voice leading continues across section seams.
- melody_seed_motif — scale-step motif intervals from an earlier section (the
- verse theme), passed to generate_melody so choruses develop the verse's idea.
- """
- def _pseed(sec_i: int, part: str) -> int:
- s = (seed + regen_salt) if (regen_part and part == regen_part) else seed
- return _part_seed(s, sec_i, part)
-
- _use_priors = getattr(req, "use_priors", True) and not getattr(req, "custom_progression", None)
- style = _overlay_groove(style, getattr(req, "use_priors", True))
- progression = fixed_progression if fixed_progression is not None else _choose_progression(style, _use_priors, seed, req.scale)
- _melody_model = melody_prior_for(_prior_name(style), getattr(req, "use_priors", True))
-
- # Resolve section profile for loop-mode shaping (contrast spread scaled by
- # the dynamics macro — 0.5 reproduces SECTION_PROFILES exactly)
- _dynamics = getattr(req, "dynamics", 0.5)
- _sec_profile = scaled_profile(req.section_type, _dynamics) if is_loop else {}
- _sec_cplx = min(1.0, req.complexity * _sec_profile.get("complexity_scale", 1.0))
- _sec_var = min(1.0, req.variation * _sec_profile.get("variation_scale", 1.0))
- _sec_dyn = _sec_profile.get("velocity_scale", 1.0)
-
- 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)
-
- all_events: dict[str, list[NoteEvent]] = {part: [] for part in req.parts}
- _chords_prev = list(chords_prev_voicing) if chords_prev_voicing else None
- chorus_spans: list[tuple[float, float]] = [] # (start_beat, end_beat) per chorus, for the hook scorer
-
- for section_i, section in enumerate(sections):
- s_bars = section["bars"]
- s_cplx = section["complexity"]
- s_parts = set(section["parts"])
- s_off = section["offset"]
- s_key = section.get("key", req.key)
-
- random.seed(_part_seed(seed, section_i, "harmony"))
- s_resolved = resolve_progression(progression, req.scale, s_cplx, secondary_dominants, tritone_sub)
-
- s_dyn = section.get("dynamic", 1.0)
-
- # Section context for the drums: explicit in song-builder loop mode,
- # derived from the auto-arc's shape otherwise.
- if is_loop:
- s_sec_type = req.section_type
- s_next_type = getattr(req, "next_section_type", None)
- else:
- s_sec_type = _auto_arc_section_type(sections, section_i)
- s_next_type = (_auto_arc_section_type(sections, section_i + 1)
- 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))
-
- # 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)
- # every template section — verse, chorus, etc. — runs through its own
- # single-section call here, so `sections` always has exactly one entry
- # and section_i is always 0; checking "is this the last entry in
- # `sections`" degenerates to "yes, always", mislabelling every
- # low-complexity section (typically verses) as an outro and freezing
- # their bass on the tonic for the whole section regardless of the
- # chord progression moving underneath it. Loop mode instead checks the
- # section's actual declared type.
- is_last_section = (s_sec_type in ("outro", "ending") if is_loop
- else section_i == len(sections) - 1)
- is_outro = (is_last_section and s_cplx < 0.5 and s_bars >= 2)
- bass_prog = (["I"] * len(progression)) if is_outro else progression
-
- # Per-part complexity / variation overrides from section profile (loop mode only)
- eff_var = _sec_var if is_loop else req.variation
- mel_cplx = min(1.0, s_cplx * _sec_profile.get("melody_complexity_scale", 1.0))
- backing_cplx = min(1.0, s_cplx * _sec_profile.get("backing_complexity_scale", 1.0))
-
- # Harmonic rhythm: choruses/pre-choruses change chords faster. The boost
- # feeds one shared value into chords, bass, and melody so their grids agree.
- _harm_boost = scaled_profile(s_sec_type, _dynamics).get("harmonic_boost", 0.0)
- harmony_cplx = min(1.0, backing_cplx + _harm_boost)
-
- # Ensemble pushes: which chord changes anticipate ("and of 4" early) is
- # decided ONCE here, seeded, and observed by BOTH the comp and the
- # bass — a lone early comp against an on-grid bass reads as a mistake;
- # the band pushing together reads as an arrangement.
- random.seed(_pseed(section_i, "pushes"))
- _push_prob = style.get("chord_anticipation_prob", 0.15)
- _cpb = 2 if harmony_cplx > 0.6 else 1
- push_windows = {w for w in range(1, s_bars * _cpb) if random.random() < _push_prob}
-
- kick_times: list[float] = []
- if "drums" in req.parts and "drums" in s_parts:
- random.seed(_pseed(section_i, "drums"))
- drum_evts = generate_drums(style, s_bars, s_cplx, eff_var,
- section_end_bars=_section_end_bars(sections, s_off),
- is_loop=is_loop,
- section_type=s_sec_type,
- next_section_type=s_next_type,
- dynamics=_dynamics)
- 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"]]
-
- has_melody = "melody" in s_parts
- mel_range = style.get("melody", {}).get("range", [60, 79])
- # Always cap chord voicings below the melody's lowest note when melody is active.
- # Without this, default chord_register [48,72] lets chord tops reach into the
- # melody range [60,79], causing the two parts to fight for the same register.
- #
- # The cap is decided by whether the SONG carries a melody, not this section:
- # song-builder intros/outros/endings often drop the melody (hook tease,
- # sparse part modes), and deciding per-section made their chords float a
- # full octave above every melody-bearing section — a jarring register jump
- # into the first verse and again at the ending.
- _song_parts = getattr(req, "song_parts", None) or req.parts
- melody_ceiling = mel_range[0] if (has_melody or "melody" in _song_parts) else None
-
- # Riff mode: the sung melody stands down in riff verses (the riff IS the
- # part) and returns for choruses, bridges, and lead/solo sections.
- from app.generators.riff import riff_section_comp
- _melody_tacet = (
- riff_section_comp(style, s_sec_type)
- and s_sec_type not in ("chorus", "post_chorus", "pre_chorus",
- "bridge", "instrumental_solo")
- )
-
- mel_rests: list = []
- mel_evts: list = []
- if has_melody and "melody" in req.parts and not _melody_tacet:
- random.seed(_pseed(section_i, "melody"))
- mel_evts = generate_melody(style, s_key, req.scale, s_bars, mel_cplx,
- eff_var, s_resolved, is_loop=is_loop,
- melody_model=_melody_model,
- harmony_complexity=harmony_cplx,
- seed_motif=melody_seed_motif,
- section_type=s_sec_type)
- mel_evts = _apply_dynamic(mel_evts, s_dyn)
- all_events["melody"].extend(_shift(mel_evts, s_off))
- if mel_evts:
- sorted_mel = sorted(mel_evts, key=lambda e: e.start)
- for _i in range(1, len(sorted_mel)):
- gap_s = sorted_mel[_i - 1].start + sorted_mel[_i - 1].duration
- gap_e = sorted_mel[_i].start
- if gap_e - gap_s >= 1.5:
- mel_rests.append((round(gap_s, 3), round(gap_e, 3)))
-
- # Call-response answers trace the song's melodic cell. Before that cell
- # exists (the first verse — its theme is captured only after its backing
- # is built) fall back to THIS section's own opening contour, so verse 1
- # (where the space is greatest) can still answer thematically.
- _answer_cell = arp_contour or melody_cell(mel_evts, s_key, req.scale)
-
- # Counter-melody: harmony under the hook (choruses) or a thematic ANSWER
- # in the melody's holes (verse/intro/outro — see generate_counter_melody).
- # When it's answering, the bass floor-fill stands down for this section
- # (below) so the two don't both crowd into the same gap.
- _cm_answering = ("counter_melody" in req.parts and "counter_melody" in s_parts
- and bool(mel_evts)
- and s_sec_type not in (None, "chorus", "post_chorus"))
- if mel_evts and "counter_melody" in req.parts and "counter_melody" in s_parts:
- random.seed(_pseed(section_i, "counter_melody"))
- 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)
- cm_evts = _apply_dynamic(cm_evts, s_dyn)
- all_events["counter_melody"].extend(_shift(cm_evts, s_off))
-
- # Fixed order so chords are generated before the arpeggio, letting the arp
- # arpeggiate the chords' real voiced harmony (chord_tones below).
- section_chord_tones: list | None = None
- for part in ("chords", "pads", "bass", "arpeggio"):
- if part not in req.parts or part not in s_parts:
- continue
- random.seed(_pseed(section_i, part))
- if part == "chords":
- evts = generate_chords(style, s_key, req.scale, s_bars, backing_cplx,
- eff_var, progression, s_resolved,
- melody_ceiling=melody_ceiling,
- kick_times=kick_times,
- melody_rests=mel_rests if has_melody else None,
- harmony_complexity=harmony_cplx,
- prev_voicing=_chords_prev,
- push_windows=push_windows,
- section_type=s_sec_type)
- section_chord_tones = _chord_tones_by_bar(
- [(e.start, e.pitch) for e in evts], s_bars)
- _chords_prev = _final_chord_voicing(evts)
- elif part == "pads":
- 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))
- elif part == "bass":
- evts = generate_bass(style, s_key, req.scale, s_bars, backing_cplx,
- eff_var, bass_prog, kick_times,
- melody_rests=(None if _cm_answering else mel_rests),
- harmony_complexity=harmony_cplx,
- push_windows=push_windows,
- rhythm_cell=rhythm_cell,
- cell_contour=_answer_cell,
- section_type=s_sec_type)
- elif part == "arpeggio":
- arp_octave = 6 if has_melody else 5
- # When melody is active, pull arpeggio back so it supports rather than competes.
- # mel_rests lets arpeggio fill the space when melody is silent (call-and-response).
- arp_cplx = backing_cplx * (0.68 if has_melody and "melody" in s_parts else 1.0)
- evts = generate_arpeggio(style, s_key, req.scale, s_bars, arp_cplx,
- eff_var, s_resolved, arp_octave,
- melody_rests=mel_rests if has_melody else None,
- chord_tones=section_chord_tones,
- seed_contour=arp_contour)
- evts = _apply_dynamic(evts, s_dyn)
- all_events[part].extend(_shift(evts, s_off))
-
- # Smooth dynamic steps at section transitions (verse→chorus lift, etc.)
- if not is_loop and len(sections) > 1:
- _apply_section_ramp(all_events, sections)
-
- if groove_push:
- for gp_part in ("melody", "chords", "arpeggio", "bass", "pads", "counter_melody"):
- if gp_part in all_events and all_events[gp_part]:
- all_events[gp_part] = _apply_groove_push(all_events[gp_part], groove_push)
-
- # Systematic feel first (styles with a feel profile): drums get per-class
- # microtiming + velocity contour, bass sits behind the kick. Those parts are
- # then excluded from the generic pocket so the two don't stack. Styles with
- # no feel profile skip this entirely and fall through unchanged.
- _feel_parts = apply_feel(all_events, style)
-
- # Shared groove pocket: every remaining part shifts onto the same per-16th-slot
- # micro-offsets (style-seeded, deterministic) so the band plays TOGETHER —
- # independent per-note jitter alone made the composite subtly smear.
- apply_groove_pocket(all_events, style, skip=_feel_parts)
-
- if all_events.get("melody"):
- # Per-section scale pcs (sections can sit in shifted keys — chorus lift)
- # so both melody clean-up passes nudge to in-key pitches, never chromatic ones.
- _mel_scale_name = style.get("melody_scale", req.scale)
- _section_scales = [
- (float(s["offset"]), float(s["offset"] + s["bars"] * 4),
- {p % 12 for p in build_scale(s.get("key", req.key), _mel_scale_name,
- octave_start=4, num_octaves=1)})
- for s in sections
- ]
- if all_events.get("bass"):
- all_events["melody"] = _prevent_parallel_motion(
- all_events["melody"], all_events["bass"], _section_scales
- )
- # Avoid-note pass: sustained melody notes a real m2/m9 from the
- # chords'/pads' ACTUAL voicing move to a safe scale tone (the melody
- # generator can't see voicings; this runs on the finished events).
- _harmony_evts = (all_events.get("chords") or []) + (all_events.get("pads") or [])
- if _harmony_evts:
- all_events["melody"] = _resolve_avoid_notes(
- all_events["melody"], _harmony_evts, _section_scales
- )
-
- cc_parts: dict[str, list[ControlEvent]] = {}
- for part in req.parts:
- if part == "drums" or part not in all_events or not all_events[part]:
- continue
- channel = _PART_CHANNELS.get(part, 0)
- cc_parts[part] = _generate_part_cc(part, req.bars, channel, style=style)
-
- if "melody" in all_events and all_events["melody"]:
- ch = _PART_CHANNELS.get("melody", 2)
- melody_cc11 = _generate_melody_expression_cc(all_events["melody"], ch)
- cc_parts.setdefault("melody", []).extend(melody_cc11)
-
- bass_style_type = style.get("bass", {}).get("bass_style", "standard")
- if bass_style_type != "808" and "bass" in all_events and all_events["bass"]:
- ch = _PART_CHANNELS.get("bass", 1)
- cc_parts.setdefault("bass", []).extend(
- _generate_bass_expression_cc(all_events["bass"], ch)
- )
-
- pb_parts: dict[str, list[PitchBendEvent]] = {}
- if "bass" in all_events and all_events["bass"]:
- bass_cfg = style.get("bass", {})
- if bass_cfg.get("bass_style") == "808":
- ch = _PART_CHANNELS.get("bass", 1)
- pb_parts["bass"] = _generate_808_pitch_bends(all_events["bass"], ch)
-
- patterns = extract_rhythm_patterns(all_events, req.bars)
-
- # Pre-apply the same velocity scaling used for MIDI output so the mix scorer
- # evaluates what the listener will actually hear, not the raw generator values.
- _scored_events = {
- part: [
- NoteEvent(e.pitch, e.start, e.duration,
- max(1, min(127, int(e.velocity * _VELOCITY_SCALE.get(part, 1.0)))),
- e.channel)
- for e in evts
- ]
- for part, evts in all_events.items()
- }
-
- try:
- quality_raw = score_generation(
- _scored_events, scoring_style or style,
- req.key, req.scale, req.bars, progression, req.complexity,
- chorus_spans=chorus_spans,
- )
- except Exception as exc:
- logger.error("Quality scoring failed (seed=%s): %s", seed, exc, exc_info=True)
- quality_raw = None
-
- return all_events, cc_parts, pb_parts, progression, quality_raw, patterns, sections
-
-
@router.post("/generate", response_model=GenerateResponse)
def generate(req: GenerateRequest):
try:
@@ -1083,6 +394,7 @@ def regenerate_part(req: RegeneratePartRequest):
try:
global_chord_tones = _chord_tones_by_bar(read_note_starts(chords_path), req.bars)
except Exception:
+ logger.debug("Could not derive chord tones for arpeggio from %s", chords_path, exc_info=True)
global_chord_tones = None
for section_i, section in enumerate(sections):
diff --git a/backend/app/api/routes_library.py b/backend/app/api/routes_library.py
index c01f744..cd3432c 100644
--- a/backend/app/api/routes_library.py
+++ b/backend/app/api/routes_library.py
@@ -6,12 +6,16 @@
# 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.
+import logging
+
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.services.library import save_generation, list_library, exclude_generation
from app.core.config import EXPORTS_DIR
+logger = logging.getLogger(__name__)
+
router = APIRouter(prefix="/library", tags=["library"])
# How much more a user-kept generation counts than a merely high-scoring one.
@@ -38,6 +42,7 @@ def record_export_keep(gen_id: str) -> bool:
)
return True
except Exception:
+ logger.warning("Failed to record export keep for gen_id=%s", gen_id, exc_info=True)
return False
diff --git a/backend/app/api/routes_song.py b/backend/app/api/routes_song.py
index a4ded4a..67d4b0f 100644
--- a/backend/app/api/routes_song.py
+++ b/backend/app/api/routes_song.py
@@ -23,648 +23,35 @@
from fastapi import APIRouter, HTTPException, UploadFile, Form
from fastapi import File as FastAPIFile
-from app.models.schemas import (GenerateRequest, FileInfo, BuildSongRequest, BuildSongResponse,
+from app.models.schemas import (FileInfo, BuildSongRequest, BuildSongResponse,
SongSectionResult, RegenerateSongPartRequest,
RegenerateSongSectionRequest, RestoreSongVersionRequest,
SetPartGainRequest, EditPartRequest, SongSectionDef,
RollSongPartRequest, SongPartCandidate, KeepSongPartCandidateRequest,
RebuildSongProgressionRequest)
from app.services.style_loader import load_style
-from app.services.midi_writer import (NoteEvent, write_midi, write_combined_midi,
- rebuild_combined_from_parts, mido_key_signature)
-from app.services.library import build_scoring_style
-from app.generators.counter_melody import generate_counter_melody
-from app.theory.chords import roman_to_chord
+from app.services.midi_writer import (NoteEvent, write_midi, rebuild_combined_from_parts, mido_key_signature)
from app.core.config import EXPORTS_DIR
-from app.core.constants import DRUM_MAP
from app.core.arrangement import (
- SECTION_PROFILES, _SONG_TEMPLATES, _part_seed, _transpose_key,
- _apply_section_ramp, _song_tempo_map, apply_arrangement_dynamics,
- apply_melodic_pickups,
+ _SONG_TEMPLATES, _song_tempo_map,
)
from app.services.mixdown import (
_PART_CHANNELS, part_midi_meta,
_generate_part_cc, _generate_melody_expression_cc,
- _generate_808_pitch_bends, _drop_quiet, _scale_velocity, _shift,
- generate_build_sweeps, generate_section_crescendo,
+ _generate_808_pitch_bends, _drop_quiet, _scale_velocity, generate_build_sweeps, generate_section_crescendo,
)
-from app.api.routes_generate import (
- _run_attempt, _choose_progression, _blend_styles, _all_green,
- _MAX_QUALITY_ATTEMPTS, _SAFE_PATH, _final_chord_voicing,
+from app.api.routes_generate import _SAFE_PATH
+from app.services.generation import (
+ _blend_styles,
+)
+
+from app.services.song_builder import (
+ _generate_song_sections,
+ _section_markers, _write_song_output,
)
router = APIRouter()
logger = logging.getLogger(__name__)
-
-
-def _vary_repeat(events: list[NoteEvent], part: str) -> list[NoteEvent]:
- """Light variation applied when a cached section theme repeats.
-
- Keeps the theme recognizable (same pitches, same rhythm skeleton) while
- removing the photocopy feel: velocities re-humanize, and ~18% of the
- melody's long notes gain an upper-neighbor turn ornament. Caller seeds the
- RNG so repeats are deterministic per section occurrence.
- """
- out: list[NoteEvent] = []
- for e in events:
- vel = max(1, min(127, e.velocity + random.randint(-6, 6)))
- if part == "melody" and e.duration >= 1.0 and random.random() < 0.18:
- d1, d2 = e.duration * 0.5, e.duration * 0.22
- d3 = e.duration - d1 - d2
- out.append(NoteEvent(e.pitch, e.start, d1 * 0.95, vel, e.channel))
- out.append(NoteEvent(min(127, e.pitch + 2), e.start + d1, d2 * 0.9,
- max(1, vel - 10), e.channel))
- out.append(NoteEvent(e.pitch, e.start + d1 + d2, d3 * 0.95,
- max(1, vel - 4), e.channel))
- else:
- out.append(NoteEvent(e.pitch, e.start, e.duration, vel, e.channel))
- return out
-
-
-def _melody_motif_intervals(mel_events: list[NoteEvent], key: str, scale: str) -> list[int] | None:
- """Scale-step intervals of a melody's opening motif (up to 4 intervals).
-
- Extracted from the first verse of a built song and handed to chorus
- generation so the chorus melody develops the verse's theme instead of
- inventing an unrelated one. Pitches are snapped to the scale lattice and
- expressed as index deltas, so the motif transposes cleanly to any register.
- """
- if not mel_events:
- return None
- from app.theory.scales import build_scale
- lattice = build_scale(key, scale, octave_start=2, num_octaves=6)
- pitches = [e.pitch for e in sorted(mel_events, key=lambda e: e.start)[:5]]
- if len(pitches) < 2:
- return None
- idxs = [min(range(len(lattice)), key=lambda i: abs(lattice[i] - p)) for p in pitches]
- intervals = [idxs[k + 1] - idxs[k] for k in range(len(idxs) - 1)]
- # An all-zero motif (repeated note) carries no shape worth reusing
- return intervals if any(intervals) else None
-
-
-def _generate_song_sections(req, style, bpm, base_seed, chorus_key_shift,
- secondary_dominants, tritone_sub, groove_push,
- regen_part=None, regen_salt=0, bridge_key_shift=0,
- fixed_section_seeds=None, final_chorus_lift=0,
- custom_template=None, user_progression=None,
- hook_melody=None):
- """Run a song template's section loop → (song_events, section_results, total_bars, section_seeds).
-
- Shared by build_song and regenerate_song_part. regen_part/regen_salt re-roll one
- part in place while harmony and every other part stay identical.
-
- Each section runs the same quality-scored, multi-attempt search plain /generate
- uses (`_MAX_QUALITY_ATTEMPTS`, best-of scoring) instead of a single unscreened
- attempt — sections used to ship whatever the first random roll produced.
-
- `fixed_section_seeds` — when given (regenerate_song_part), replays the exact
- winning attempt seed chosen for each section by the original build_song call
- instead of re-deriving and re-searching, so non-regenerated parts come out
- byte-identical to what's already on disk.
-
- `final_chorus_lift` — extra semitones added to the LAST chorus's key (the
- classic gear-change); the cached chorus theme is transposed to match.
- `custom_template` — list of section dicts overriding the named template.
- `user_progression` / `hook_melody` — melody-import mode: the derived
- progression replaces the style draw, and the user's melody becomes the
- chorus hook (cached as the chorus theme, so repeats/tease/counter-melody
- all build on it).
- """
- if custom_template:
- template = [dict(sd) for sd in custom_template]
- else:
- template = [dict(sd) for sd in _SONG_TEMPLATES.get(req.template, _SONG_TEMPLATES["verse_chorus"])]
-
- # DJ edit: bookend the arrangement with an 8-bar beat-only (drums+bass)
- # section for mixing — steady, no fills, no melodic content, outside the arc.
- # Only meaningful when the song actually has a rhythm section.
- if getattr(req, "dj_edit", False) and {"drums", "bass"} & set(req.parts):
- template = (
- [{"name": "DJ Intro", "section_type": "dj_intro", "bars": 8, "parts_mode": "foundation"}]
- + template
- + [{"name": "DJ Outro", "section_type": "dj_outro", "bars": 8, "parts_mode": "foundation"}]
- )
-
- full_parts = list(req.parts)
- no_arp = [p for p in req.parts if p != "arpeggio"]
- foundation = [p for p in req.parts if p in ("drums", "bass")]
- sparse_parts = [p for p in req.parts if p in ("drums", "bass", "chords")]
- melodic = [p for p in req.parts if p in ("chords", "melody")]
- no_drums = [p for p in req.parts if p != "drums"]
- chords_only = [p for p in req.parts if p == "chords"]
- parts_modes = {
- "full": full_parts, "no_arp": no_arp, "foundation": foundation,
- "sparse": sparse_parts, "melodic": melodic or chords_only,
- "no_drums": no_drums, "chords_only": chords_only or foundation,
- }
-
- scoring_style = build_scoring_style(style, req.style_id)
-
- # Per-section style overrides (custom templates): a section can generate in a
- # different style while the whole song keeps one progression/key, so a lofi
- # verse can drop into a house chorus without losing harmonic identity.
- _sec_style_cache: dict[str, tuple[dict, dict]] = {}
-
- def _style_for(style_id: str | None) -> tuple[dict, dict]:
- if not style_id or style_id == style.get("id"):
- return style, scoring_style
- if style_id not in _sec_style_cache:
- try:
- s = {**load_style(style_id), "_humanize_scale": style.get("_humanize_scale", 0.5)}
- _sec_style_cache[style_id] = (s, build_scoring_style(s, style_id))
- except ValueError:
- logger.warning("Section style %r not found — using the song style", style_id)
- _sec_style_cache[style_id] = (style, scoring_style)
- return _sec_style_cache[style_id]
-
- # One progression for the whole song so every section shares a harmonic identity.
- # Per-section chord substitutions (inside _run_attempt, seeded per section) still
- # vary each section's exact chords, so chorus/bridge relate to the verse instead
- # of each section type independently rolling an unrelated progression.
- song_progression = (list(user_progression) if user_progression
- else _choose_progression(style, req.use_priors, base_seed, req.scale))
-
- # Pre-choruses get a rising harmonic ramp instead of the song loop — the
- # classic build (predominant -> dominant) that makes the chorus drop land.
- _prechorus_prog = (["ii", "IV", "V", "V"] if req.scale == "major"
- else ["iv", "v", "VI", "VII"])
-
- song_events: dict[str, list] = {p: [] for p in req.parts}
- section_results: list[dict] = []
- section_seeds: list[int] = []
- ramp_sections: list[dict] = []
- beat_offset = 0.0
- total_bars = 0
- type_seed: dict[str, int] = {}
- type_occurrence: dict[str, int] = {}
- # First occurrence of each section type caches its melodic/harmonic parts so
- # later sections of the same type reuse the theme (the verse tune returns).
- type_theme: dict[str, dict] = {}
-
- # The counter-melody is reserved for the climactic last chorus so the final
- # chorus sounds bigger than the ones before it.
- last_chorus_i = max((i for i, s in enumerate(template)
- if s["section_type"] == "chorus"), default=-1)
-
- # Threaded across sections: the closing chord voicing (voice-leading
- # continuity at seams) and the verse's opening motif (chorus develops it).
- prev_voicing: list[int] | None = None
- verse_motif: list[int] | None = None
- rhythm_cell: list[float] | None = None # the song's rhythmic cell (onset offsets, from the first theme)
- _hook_motif = (_melody_motif_intervals(hook_melody, req.key, req.scale)
- if hook_melody else None)
- # Intro hook tease: when the intro would carry a melody, hold it back and
- # overlay a thinned copy of the chorus melody after the loop instead.
- tease_intro: dict | None = None
- chorus_theme_shift = 0 # key shift the cached chorus theme was generated in
-
- for sec_i, sec_def in enumerate(template):
- sec_type = sec_def["section_type"]
- sec_bars = sec_def.get("bars", 8)
- sec_name = sec_def.get("name") or sec_type
- parts_mode = sec_def.get("parts_mode", "full")
- sec_parts = list(parts_modes.get(parts_mode, full_parts) or full_parts)
-
- # Arrangement colors: pads fill out only the big sections. The
- # counter-melody harmonizes the hook on the final chorus AND answers the
- # lead's holes in the sections with space (verse/intro/outro); it's kept
- # out of the dense middle sections so it never turns into a constant
- # second lead. See generate_counter_melody for the mode split.
- if "pads" in sec_parts and sec_type not in ("chorus", "bridge"):
- sec_parts = [p for p in sec_parts if p != "pads"]
- if "counter_melody" in sec_parts and not (
- sec_i == last_chorus_i or sec_type in ("verse", "intro", "outro")):
- sec_parts = [p for p in sec_parts if p != "counter_melody"]
-
- # Layer accumulation: every RETURN of a section type carries something
- # its first pass didn't, so repeats escalate instead of photocopying.
- # Verse 2+ gains the arpeggio its template mode withheld; repeated
- # choruses get busier hats (below, via the style overlay).
- _prior_occ = type_occurrence.get(sec_type, 0)
- if (sec_type == "verse" and _prior_occ >= 1
- and "arpeggio" in req.parts and "arpeggio" not in sec_parts):
- sec_parts = sec_parts + ["arpeggio"]
-
- # Intro tease: strip the intro's own melody — the chorus hook (thinned)
- # takes its place once the chorus theme exists.
- if sec_i == 0 and sec_type == "intro" and "melody" in sec_parts and last_chorus_i > 0:
- sec_parts = [p for p in sec_parts if p != "melody"]
- tease_intro = {"bars": sec_bars}
-
- key_shift = (
- chorus_key_shift if sec_def.get("chorus_key")
- else bridge_key_shift if sec_def.get("bridge_key")
- else 0
- )
- # Gear change: the last chorus lifts above the earlier ones.
- if sec_i == last_chorus_i:
- key_shift += final_chorus_lift
- sec_key = _transpose_key(req.key, key_shift) if key_shift else req.key
-
- occ = type_occurrence.get(sec_type, 0)
- if sec_type not in type_seed:
- type_seed[sec_type] = _part_seed(base_seed, sec_i, "type")
- type_occurrence[sec_type] = occ + 1
- sec_seed = (type_seed[sec_type] + occ * 73_856) % (2 ** 31)
-
- next_sec_type = template[sec_i + 1]["section_type"] if sec_i + 1 < len(template) else None
- sec_style, sec_scoring = _style_for(sec_def.get("style_id"))
- if sec_type == "chorus" and _prior_occ >= 1:
- _drums_cfg = sec_style.get("drums", {})
- sec_style = {**sec_style, "drums": {
- **_drums_cfg, "hat_density": min(1.0, _drums_cfg.get("hat_density", 0.5) * 1.18)}}
- sec_progression = _prechorus_prog if sec_type == "pre_chorus" else song_progression
- # Bridge escape: a fresh progression that opens off the song's beaten path
- # and walks home on a dominant pedal. Seeded so ~half of songs keep today's
- # bridge; opt-in per style via bridge_escape_prob (default 0 = unchanged).
- if sec_type == "bridge":
- _besc = style.get("bridge_escape_prob", 0.0)
- if _besc and random.Random(base_seed ^ 0x8B12D6).random() < _besc:
- sec_progression, _ = _bridge_escape_progression(song_progression, req.scale)
-
- sec_req = GenerateRequest.model_construct(
- style_id=req.style_id, key=sec_key, scale=req.scale, bpm=bpm,
- bars=sec_bars, complexity=req.complexity, variation=req.variation,
- dynamics=req.dynamics,
- parts=sec_parts, mode="loop", seed=sec_seed, section_type=sec_type,
- next_section_type=next_sec_type,
- song_parts=list(req.parts), # full song part list — keeps register decisions consistent in sections that drop parts
- humanize=req.humanize, custom_progression=None, blend_style_id=None,
- blend_amount=0.5, use_priors=req.use_priors,
- )
-
- # Choruses develop the verse's motif; other section types keep their own
- # ideas. In melody-import mode every melodic section develops the HOOK's
- # motif instead, so the whole song grows out of the user's idea.
- if hook_melody:
- sec_motif = _hook_motif if sec_type in ("verse", "chorus", "bridge") else None
- else:
- sec_motif = verse_motif if sec_type == "chorus" else None
-
- if fixed_section_seeds is not None:
- # Replay: reuse the exact seed the original build_song call landed on
- # for this section — no re-search, so untouched parts stay identical.
- winning_seed = fixed_section_seeds[sec_i] if sec_i < len(fixed_section_seeds) else sec_seed
- _qraw = None
- try:
- evts, _cc, _pb, _prog, _qraw, _patterns, _secs = _run_attempt(
- sec_req, sec_style, winning_seed, True, groove_push, secondary_dominants, tritone_sub,
- scoring_style=sec_scoring, regen_part=regen_part, regen_salt=regen_salt,
- fixed_progression=sec_progression,
- chords_prev_voicing=prev_voicing, melody_seed_motif=sec_motif,
- rhythm_cell=rhythm_cell, arp_contour=verse_motif,
- )
- except Exception as exc:
- logger.error("build_song section %r failed: %s", sec_name, exc, exc_info=True)
- evts = {}
- else:
- # Quality-gated multi-attempt search, mirroring plain /generate's
- # _run_best_attempt — song sections previously ran once with no
- # quality check at all.
- best_evts, best_total, winning_seed = None, -1.0, sec_seed
- for attempt in range(_MAX_QUALITY_ATTEMPTS):
- attempt_seed = sec_seed if attempt == 0 else _part_seed(sec_seed, attempt, "retry")
- try:
- evts, _cc, _pb, _prog, qraw, _patterns, _secs = _run_attempt(
- sec_req, sec_style, attempt_seed, True, groove_push, secondary_dominants, tritone_sub,
- scoring_style=sec_scoring, regen_part=regen_part, regen_salt=regen_salt,
- fixed_progression=sec_progression,
- chords_prev_voicing=prev_voicing, melody_seed_motif=sec_motif,
- rhythm_cell=rhythm_cell, arp_contour=verse_motif,
- )
- except Exception as exc:
- logger.error("build_song section %r attempt %d failed: %s", sec_name, attempt, exc, exc_info=True)
- continue
- total = qraw.get("total", 0.0) if qraw is not None else 0.0
- if best_evts is None or total > best_total:
- best_evts, best_total, winning_seed = evts, total, attempt_seed
- if qraw is not None and _all_green(qraw):
- break
- evts = best_evts or {}
-
- section_seeds.append(winning_seed)
-
- # Melody-import: the user's melody IS the chorus. Swap it in before the
- # theme cache locks, so every chorus repeat, the intro tease, and the
- # counter-melody derive from the real hook. Transposed to the chorus key.
- if hook_melody and sec_type == "chorus" and "chorus" not in type_theme and evts.get("melody") is not None:
- from app.services.melody_import import fit_melody_to_bars
- fitted = fit_melody_to_bars(hook_melody, sec_bars)
- if key_shift:
- fitted = [NoteEvent(min(127, max(0, e.pitch + key_shift)), e.start,
- e.duration, e.velocity, e.channel) for e in fitted]
- evts["melody"] = fitted
-
- sec_quality = best_total if (fixed_section_seeds is None and best_total >= 0) else (
- _qraw.get("total") if fixed_section_seeds is not None and _qraw else None)
-
- # Cross-section motif reuse: the first section of each type sets the theme
- # (melody + harmony); later sections of that type reuse it, keeping fresh
- # drums so the groove still evolves. Same-type sections share a key, so the
- # reused parts need no transposition.
- if sec_type not in type_theme:
- type_theme[sec_type] = {p: list(e) for p, e in evts.items() if p != "drums"}
- if sec_type == "chorus":
- chorus_theme_shift = key_shift # key the cached chorus theme sounds in
- else:
- for p, cached in type_theme[sec_type].items():
- if p in evts:
- evts[p] = list(cached)
- # Gear change: the cached chorus theme sounds in the earlier chorus
- # key — transpose it up to this section's lifted key.
- if sec_i == last_chorus_i and final_chorus_lift:
- for p in type_theme[sec_type]:
- if evts.get(p):
- evts[p] = [NoteEvent(min(127, max(0, e.pitch + final_chorus_lift)),
- e.start, e.duration, e.velocity, e.channel)
- for e in evts[p]]
- # Light variation so the repeat isn't a photocopy of the first pass.
- random.seed(_part_seed(winning_seed, 0, "repeat_var"))
- for p in type_theme[sec_type]:
- if evts.get(p):
- evts[p] = _vary_repeat(evts[p], p)
- # The theme swap replaced this section's melody with the cached one, so
- # any counter-melody derived from the discarded fresh melody would be
- # answering/harmonizing a line that no longer sounds — re-derive it
- # from the melody that will actually play (in the same mode the
- # section uses: harmony on the chorus, answer in verse/intro/outro).
- if "counter_melody" in evts and evts.get("melody"):
- random.seed(_part_seed(winning_seed, 0, "counter_melody"))
- _cm_mel = sorted(evts["melody"], key=lambda e: e.start)
- _cm_rests = [(round(_cm_mel[i - 1].start + _cm_mel[i - 1].duration, 3),
- round(_cm_mel[i].start, 3))
- for i in range(1, len(_cm_mel))
- if _cm_mel[i].start - (_cm_mel[i - 1].start + _cm_mel[i - 1].duration) >= 1.5]
- _cm_cell = verse_motif or _melody_motif_intervals(evts["melody"], sec_key, req.scale)
- evts["counter_melody"] = generate_counter_melody(
- evts["melody"], sec_key, req.scale, sec_bars,
- song_progression, sec_style,
- melody_rests=_cm_rests, cell=_cm_cell, section_type=sec_type)
-
- # Thread voice-leading and the verse theme into the next section: the
- # post-theme-swap events are what actually sound, so extract from those.
- if evts.get("chords"):
- prev_voicing = _final_chord_voicing(evts["chords"])
- if verse_motif is None and sec_type == "verse" and evts.get("melody"):
- verse_motif = _melody_motif_intervals(evts["melody"], req.key, req.scale)
- if rhythm_cell is None and evts.get("melody"):
- # The song's rhythmic cell: the first theme's opening onset
- # pattern (16th-quantized, within-bar offsets). Later sections'
- # bass echoes it and the arpeggio takes its contour — the "one
- # composer" glue between parts.
- _onsets = sorted(e.start for e in evts["melody"] if e.start < 8.0)
- rhythm_cell = []
- for _o in _onsets:
- _q = round((_o % 4) * 4) / 4
- if _q not in rhythm_cell:
- rhythm_cell.append(_q)
- if len(rhythm_cell) >= 4:
- break
- rhythm_cell = rhythm_cell or None
-
- for part, part_evts in evts.items():
- if part in song_events and part_evts:
- song_events[part].extend(_shift(part_evts, beat_offset))
-
- section_results.append({
- "name": sec_name, "section_type": sec_type,
- "bars": sec_bars, "start_bar": total_bars, "key": sec_key,
- "quality": round(sec_quality, 3) if sec_quality is not None else None,
- })
- ramp_sections.append({
- "offset": beat_offset, "bars": sec_bars,
- "dynamic": SECTION_PROFILES.get(sec_type, {}).get("velocity_scale", 1.0),
- })
- beat_offset += sec_bars * 4
- total_bars += sec_bars
-
- # Smooth velocity jumps at section boundaries (verse→chorus lift, etc.). This
- # previously only ran inside a single _run_attempt's own internal auto-arc
- # sections and never across the song builder's independently-generated
- # sections, so every Verse→Chorus/Chorus→Bridge transition was a hard jump.
- if len(ramp_sections) > 1:
- _apply_section_ramp(song_events, ramp_sections)
-
- # ── Intro hook tease ─────────────────────────────────────────────────────
- # The intro previews the chorus melody: thinned to its structural notes,
- # softer, and transposed back to the home key if the chorus modulates.
- # Deterministic — derived entirely from the cached chorus theme.
- if tease_intro and "melody" in song_events:
- chorus_mel = type_theme.get("chorus", {}).get("melody") or []
- if chorus_mel:
- limit = tease_intro["bars"] * 4
- in_window = [e for e in chorus_mel if e.start < limit - 0.05]
- # Prefer the hook's structural notes for a clean preview.
- thin = [e for e in in_window
- if (e.start % 1.0) < 0.13 or e.duration >= 0.75]
- # Commit to a real phrase or stay silent: a lone note (or two) reads
- # as an accidental keypress, not a hook preview. Use the thinned hook
- # only if it's a phrase; else tease the fuller line; if even that is
- # just a note or two, leave the intro to the groove.
- _PHRASE = 3
- tease = (thin if len(thin) >= _PHRASE
- else in_window if len(in_window) >= _PHRASE else [])
- song_events["melody"].extend(
- NoteEvent(min(127, max(0, e.pitch - chorus_theme_shift)), e.start,
- min(e.duration, limit - e.start),
- # Floor above the _drop_quiet threshold (20): a soft-
- # style hook (lofi) scaled by 0.72 dips below it, and
- # the mixdown would then cull the quiet notes back down
- # to the lone stray this whole branch exists to avoid.
- max(34, int(e.velocity * 0.72)), e.channel)
- for e in tease
- )
-
- # ── Arrangement dynamics ──────────────────────────────────────────────────
- # Dropouts and breakdowns (pre-chorus drop, bridge breakdown, thinned
- # verse 2) — applied before the ending bar so the final cadence survives.
- apply_arrangement_dynamics(song_events, section_results, base_seed,
- dynamics=req.dynamics)
- # Pickups run AFTER dynamics so they lead into melody that survived the
- # dropouts (and can sing across a full-band stop).
- apply_melodic_pickups(song_events, section_results, base_seed, req.scale, style)
-
- # ── Ending variety ────────────────────────────────────────────────────────
- # Every song used to end with the identical ring-out formula. Three seeded
- # endings now: ring-out (the classic), cold stop (staccato final hit), and
- # the hook-echo outro — the outro's own melody is replaced by thinned
- # fragments of the chorus hook, fading, so the song looks BACK at its own
- # idea on the way out instead of introducing fresh material.
- _end_rng = random.Random(_part_seed(base_seed, 917, "ending"))
- _ending_style = _end_rng.choices(["ring", "cold", "hook_echo"], weights=[0.45, 0.2, 0.35])[0]
- _outro_sec = next((s for s in section_results if s.get("section_type") == "outro"), None)
- if _ending_style == "hook_echo":
- _hook = type_theme.get("chorus", {}).get("melody") or []
- if _outro_sec and _hook and "melody" in song_events:
- o_start = _outro_sec["start_bar"] * 4.0
- o_beats = _outro_sec["bars"] * 4.0
- frag = [e for e in sorted(_hook, key=lambda e: e.start) if e.start < 8.0]
- thin = [e for e in frag if (e.start % 1.0) < 0.13 or e.duration >= 0.75] or frag
- song_events["melody"] = [e for e in song_events["melody"]
- if not (o_start - 0.1 <= e.start < o_start + o_beats)]
- placements = [(o_start, 0.72)]
- if o_beats >= 12:
- placements.append((o_start + o_beats / 2, 0.5))
- for _base, _velf in placements:
- for e in thin:
- t = _base + e.start
- if t < o_start + o_beats - 0.25:
- song_events["melody"].append(NoteEvent(
- min(127, max(0, e.pitch - chorus_theme_shift)), t,
- min(e.duration, o_start + o_beats - t),
- max(1, int(e.velocity * _velf)), e.channel))
- song_events["melody"].sort(key=lambda e: e.start)
- else:
- _ending_style = "ring" # nothing to echo — fall back gracefully
-
- # ── Ending bar ────────────────────────────────────────────────────────────
- # A real cadence instead of just stopping: the tonic chord, bass root, and a
- # kick+crash land on one extra bar and ring out (the tempo map's ritardando
- # covers this bar). Deterministic from base_seed so every regeneration flow
- # reproduces identical ending events for untouched parts.
- random.seed(_part_seed(base_seed, len(template), "ending"))
- ending_start = float(total_bars * 4)
- tonic_roman = "i" if req.scale in ("minor", "dorian", "phrygian", "harmonic_minor",
- "pentatonic_minor", "blues", "locrian") else "I"
- tonic = roman_to_chord(tonic_roman, req.key, req.scale, octave=4)
- # Cold stop: the band hits the final chord staccato and it's over — no ring.
- ring = 0.35 if _ending_style == "cold" else 4.0
- if "chords" in song_events:
- # Voice the final chord in the register the song's comp actually ended
- # in (prev_voicing = the outro's closing voicing). The hardcoded
- # octave-4 tonic sat a full octave above the melody-capped comp, so the
- # very last bar leapt upward out of the song's register.
- chord_tonic = sorted(tonic)
- if prev_voicing:
- _target = sum(prev_voicing) / len(prev_voicing)
- _mean = sum(chord_tonic) / len(chord_tonic)
- _oct_shift = min((-24, -12, 0, 12, 24), key=lambda o: abs(_mean + o - _target))
- chord_tonic = [max(0, min(127, p + _oct_shift)) for p in chord_tonic]
- for ni, p in enumerate(chord_tonic):
- song_events["chords"].append(NoteEvent(
- pitch=p, start=ending_start + ni * 0.012, duration=ring,
- velocity=max(1, 84 - ni * 4), channel=0))
- if "pads" in song_events and song_events.get("pads"):
- for ni, p in enumerate(sorted(tonic)):
- song_events["pads"].append(NoteEvent(
- pitch=min(127, p + 12), start=ending_start, duration=ring,
- velocity=56 - ni * 2, channel=4))
- if "bass" in song_events:
- song_events["bass"].append(NoteEvent(
- pitch=max(0, tonic[0] - 24), start=ending_start, duration=ring,
- velocity=92, channel=1))
- # The melody deliberately does NOT restate a note on the ending bar: its
- # line already resolved in the outro, and a lone root popping up over the
- # final chord read as an accidental keypress. The cadence is the band's —
- # chord + bass + kick/crash ringing out.
- if "drums" in song_events:
- song_events["drums"].append(NoteEvent(DRUM_MAP["kick"], ending_start, 0.1, 116, 9))
- song_events["drums"].append(NoteEvent(DRUM_MAP["crash"], ending_start, ring, 104, 9))
- section_results.append({
- "name": "End", "section_type": "ending",
- "bars": 1, "start_bar": total_bars, "key": req.key,
- })
- total_bars += 1
-
- return song_events, section_results, total_bars, section_seeds, song_progression
-
-
-def _section_markers(section_results: list[dict], home_key: str) -> list[tuple[float, str]]:
- """MIDI section markers for the DAW timeline; sections that modulate away
- from the home key carry the key in the label (e.g. "Final Chorus (B)")."""
- return [
- (float(s["start_bar"] * 4),
- s["name"] if s.get("key", home_key) == home_key else f"{s['name']} ({s['key']})")
- for s in section_results
- ]
-
-
-def _write_song_output(song_events: dict, output_dir, gen_id: str, bpm: int, style: dict,
- programs: dict, parts: list[str], total_bars: int,
- section_results: list[dict], key: str = "C",
- scale: str = "minor") -> list[FileInfo]:
- """Write every stem + song.mid for a built song (CC, pitch bends, tempo map).
-
- Shared by build_song and regenerate_song_section so both produce identical
- file layouts. The tempo map (chorus push + ending ritardando) is written
- into every stem so they stay sample-locked in any DAW; section markers and
- the key signature go into song.mid so DAW timelines mirror the app's.
- """
- song_cc: dict[str, list] = {}
- for part in parts:
- if part == "drums" or not song_events.get(part):
- continue
- channel = _PART_CHANNELS.get(part, 0)
- song_cc[part] = _generate_part_cc(part, total_bars, channel, style=style)
-
- if song_events.get("melody"):
- ch = _PART_CHANNELS.get("melody", 2)
- song_cc.setdefault("melody", []).extend(
- _generate_melody_expression_cc(song_events["melody"], ch)
- )
-
- # Section-level automation: pre-chorus filter sweeps + crescendo into the chorus.
- for automation in (generate_build_sweeps(section_results, parts),
- generate_section_crescendo(section_results, parts)):
- for part, evs in automation.items():
- if song_events.get(part):
- song_cc.setdefault(part, []).extend(evs)
-
- song_pb: dict[str, list] = {}
- if song_events.get("bass") and style.get("bass", {}).get("bass_style") == "808":
- ch = _PART_CHANNELS.get("bass", 1)
- song_pb["bass"] = _generate_808_pitch_bends(song_events["bass"], ch)
-
- tempo_map = _song_tempo_map(section_results, bpm, ending_bars=1)
- _, track_names = part_midi_meta(style)
-
- files: list[FileInfo] = []
- _sid = style.get("id", "")
- for part, evts in song_events.items():
- if not evts:
- continue
- clean = _drop_quiet(_scale_velocity(evts, part, _sid))
- if not clean:
- continue
- fname = f"{part}.mid"
- write_midi(clean, output_dir / fname, bpm=bpm, program=programs.get(part),
- cc_events=song_cc.get(part), pb_events=song_pb.get(part),
- tempo_events=tempo_map, track_name=track_names.get(part))
- files.append(FileInfo(part=part, filename=fname, url=f"/exports/{gen_id}/{fname}"))
-
- if len([p for p, e in song_events.items() if e]) > 1:
- clean_all = {p: _drop_quiet(_scale_velocity(e, p, _sid)) for p, e in song_events.items() if e}
- write_combined_midi(clean_all, output_dir / "song.mid", bpm=bpm, programs=programs,
- cc_parts=song_cc, pb_parts=song_pb, tempo_events=tempo_map,
- markers=_section_markers(section_results, key),
- key_signature=mido_key_signature(key, scale),
- track_names=track_names)
- files.append(FileInfo(part="song", filename="song.mid", url=f"/exports/{gen_id}/song.mid"))
- return files
-
-
-_MAJOR_FAMILY_SCALES = ("major", "mixolydian", "lydian", "pentatonic_major")
-
-
-def _bridge_escape_progression(song_progression: list, scale: str) -> tuple[list, str]:
- """A bridge grammar that starts somewhere the song hasn't been and walks home
- (roadmap-2 item 5). Opens on a diatonic chord absent from the verse/chorus
- loop (vi if the song is I-heavy, ♭VI as the deceptive option in minor), takes
- a bar of departure, then a dominant-pedal bar that pulls back into the return.
-
- Returns (progression, opening_chord). The caller decides (seeded) whether to
- use it, so half of songs keep today's bridge sound."""
- used = set(song_progression)
- if scale in _MAJOR_FAMILY_SCALES:
- openers, mid, dom = ["vi", "IV", "ii", "iii"], "ii", "V"
- else:
- openers, mid, dom = ["bVI", "iv", "bII", "bVII"], "iv", "V"
- opener = next((c for c in openers if c not in used), openers[0])
- if mid == opener:
- mid = dom
- return [opener, mid, dom, dom], opener
-
-
@router.post("/build-song", response_model=BuildSongResponse)
def build_song(req: BuildSongRequest):
"""Generate a full song by stitching independently-generated sections."""
@@ -1192,6 +579,8 @@ def set_part_gain(req: SetPartGainRequest):
try:
_, _track_names = part_midi_meta(load_style(meta.get("style_id", "")))
except Exception:
+ logger.warning("Could not load style %r for track names — using defaults",
+ meta.get("style_id", ""), exc_info=True)
_track_names = {}
rebuild_combined_from_parts(output_dir, bpm, combined_name="song.mid",
tempo_events=_song_tempo_map(layout, bpm, ending_bars=1),
@@ -1394,6 +783,8 @@ def undo_song_part(req: RegenerateSongPartRequest):
try:
_, _track_names = part_midi_meta(load_style(meta.get("style_id", "")))
except Exception:
+ logger.warning("Could not load style %r for track names — using defaults",
+ meta.get("style_id", ""), exc_info=True)
_track_names = {}
rebuild_combined_from_parts(output_dir, bpm, combined_name="song.mid", tempo_events=tempo_map,
markers=_section_markers(_layout, meta.get("key", "C")),
diff --git a/backend/app/services/generation.py b/backend/app/services/generation.py
new file mode 100644
index 0000000..4d3f9bd
--- /dev/null
+++ b/backend/app/services/generation.py
@@ -0,0 +1,734 @@
+# 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.
+
+"""Generation core — one seeded, quality-scored attempt and its helpers.
+
+Extracted from routes_generate.py so the /generate endpoints AND the Song
+Builder both depend on a service module instead of importing generation logic
+out of a route file. Holds `_run_attempt` (build every part for one seed),
+progression choice, style blending / groove overlay, and the voice-leading and
+quality helpers. Imports only services / generators / core — never routes."""
+import logging
+import random
+
+from app.services.style_loader import load_style
+from app.services.midi_writer import NoteEvent, ControlEvent, PitchBendEvent
+from app.generators.chords import generate_chords, resolve_progression
+from app.theory.scales import build_scale, scale_mode as _scale_mode
+from app.generators.bass import generate_bass
+from app.generators.melody import generate_melody
+from app.generators.drums import generate_drums
+from app.generators.arpeggio import generate_arpeggio
+from app.generators.pads import generate_pads
+from app.generators.counter_melody import generate_counter_melody
+from app.generators.answer import melody_cell
+from app.core.constants import DRUM_MAP
+from app.services.humanize import apply_groove_pocket, apply_feel
+from app.services.quality import score_generation, extract_rhythm_patterns
+from app.services.priors import load_prior, sample_progression, melody_prior_for, groove_fields_for
+from app.core.arrangement import (
+ _part_seed, scaled_profile,
+ _apply_section_ramp, _plan_sections, _auto_arc_section_type, _section_end_bars,
+)
+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,
+ _apply_groove_push, _apply_dynamic,
+)
+
+logger = logging.getLogger(__name__)
+
+def _final_chord_voicing(chord_events: list[NoteEvent]) -> list[int] | None:
+ """Extract the last sounded chord voicing from a chord part's events.
+
+ Used to thread voice leading across song sections: the closing voicing of
+ one section seeds `prev_voicing` for the next, so the comp doesn't jump
+ back to root position at every section seam. Strums offset note starts by
+ a few ms, so everything within half a beat of the final onset counts as
+ one voicing.
+ """
+ if not chord_events:
+ return None
+ last_start = max(e.start for e in chord_events)
+ voicing = sorted({e.pitch for e in chord_events if e.start >= last_start - 0.5})
+ return voicing or None
+
+
+def _chord_tones_by_bar(chord_notes, bars: int) -> list | None:
+ """Per-bar sorted pitch-class lists from chord notes.
+
+ ``chord_notes`` is an iterable of (start_beat, pitch) pairs (from generated
+ events or a read-back .mid). Lets the arpeggio arpeggiate the chords' *actual*
+ voiced harmony (including the 7ths/9ths and borrowed color the chord generator
+ chose) instead of a re-derived plain triad. Empty bars (chord rests) inherit
+ the nearest neighbouring bar's harmony so the arp never lands on a single stray
+ note. Returns None when no chord notes exist (caller falls back to roman voicing).
+ """
+ tones: list[set[int]] = [set() for _ in range(bars)]
+ for start, pitch in chord_notes:
+ b = int(start // 4)
+ if 0 <= b < bars:
+ tones[b].add(pitch % 12)
+ if not any(tones):
+ return None
+ last: set[int] | None = None
+ for i in range(bars):
+ if tones[i]:
+ last = tones[i]
+ elif last is not None:
+ tones[i] = set(last)
+ nxt: set[int] | None = None
+ for i in range(bars - 1, -1, -1):
+ if tones[i]:
+ nxt = tones[i]
+ elif nxt is not None:
+ tones[i] = set(nxt)
+ return [sorted(t) for t in tones]
+
+
+def _prevent_parallel_motion(
+ melody_events: list[NoteEvent],
+ bass_events: list[NoteEvent],
+ section_scales: list[tuple[float, float, set[int]]] | None = None,
+) -> list[NoteEvent]:
+ """Nudge melody notes that form parallel octaves or fifths with the bass.
+
+ Only fixes the worst offender (direct parallel motion into an octave or fifth).
+ Preserves all timing and velocity. Parallel motion into a unison (octave) is
+ most objectionable; fifths are secondary.
+
+ ``section_scales`` — (start_beat, end_beat, scale_pcs) per section, so the
+ replacement pitch is the nearest non-parallel SCALE tone for the key that
+ section actually sounds in. Without it the nudge is a raw +1/+2 semitones,
+ which planted flat-out out-of-key notes (D+2 in C minor = E natural) in the
+ melody — trading a subtle voice-leading blemish for an audible wrong note.
+ """
+ if not bass_events or not melody_events:
+ return melody_events
+
+ # Build a quick lookup: for each beat position, what bass pitch is sounding?
+ bass_sorted = sorted(bass_events, key=lambda e: e.start)
+
+ def _bass_pitch_at(beat: float) -> int | None:
+ # Find the last bass note that started at or before `beat`
+ result = None
+ for e in bass_sorted:
+ if e.start <= beat + 0.05:
+ result = e.pitch
+ else:
+ break
+ return result
+
+ def _scale_pcs_at(beat: float) -> set[int] | None:
+ if not section_scales:
+ return None
+ for s_start, s_end, pcs in section_scales:
+ if s_start <= beat < s_end:
+ return pcs
+ return section_scales[-1][2]
+
+ _PARALLEL_INTERVALS = {0, 7} # unison/octave (mod 12) and fifth
+
+ def _nudged(pitch: int, bass_pitch: int, beat: float) -> int:
+ pcs = _scale_pcs_at(beat)
+ # Climb to the nearest higher pitch that breaks the parallel interval
+ # AND stays in the section's scale (when known).
+ for cand in range(pitch + 1, pitch + 13):
+ if (cand - bass_pitch) % 12 in _PARALLEL_INTERVALS:
+ continue
+ if pcs is None or cand % 12 in pcs:
+ return max(48, min(96, cand))
+ return pitch
+
+ fixed = []
+ for i, mel in enumerate(melody_events):
+ b_pitch = _bass_pitch_at(mel.start)
+ if b_pitch is None:
+ fixed.append(mel)
+ continue
+
+ # Check current interval
+ interval = (mel.pitch - b_pitch) % 12
+ if interval not in _PARALLEL_INTERVALS:
+ fixed.append(mel)
+ continue
+
+ # Check if the previous mel note also made the same interval with prev bass
+ # (that's what makes it "parallel" — motion into the same interval type)
+ if i > 0:
+ prev_mel = melody_events[i - 1]
+ prev_bass = _bass_pitch_at(prev_mel.start)
+ if prev_bass is not None:
+ prev_interval = (prev_mel.pitch - prev_bass) % 12
+ if prev_interval in _PARALLEL_INTERVALS:
+ # Parallel motion confirmed — nudge to a scale-safe pitch
+ fixed.append(NoteEvent(
+ pitch=_nudged(mel.pitch, b_pitch, mel.start), start=mel.start,
+ duration=mel.duration, velocity=mel.velocity, channel=mel.channel,
+ ))
+ continue
+
+ fixed.append(mel)
+
+ return fixed
+
+
+def _resolve_avoid_notes(
+ melody_events: list[NoteEvent],
+ harmony_events: list[NoteEvent],
+ section_scales: list[tuple[float, float, set[int]]] | None = None,
+ min_duration: float = 0.45,
+) -> list[NoteEvent]:
+ """Nudge SUSTAINED melody notes off harsh clashes with the sounding harmony.
+
+ An "avoid note" here is a melody note that (a) is not doubling any pitch
+ class the harmony is sounding, and (b) sits at an ACTUAL distance of a
+ minor 2nd (1 semitone) or minor 9th (13) from a simultaneously sounding
+ chord/pad tone. Held for half a beat or more, that reads as a wrong note —
+ the melody generator can't see the voicings the chords/pads actually chose
+ (registers, extensions, strums), so this runs where the real events exist.
+ Short notes are exempt: passing dissonance on 16ths is normal melodic
+ motion. Wider mod-12 relatives (maj7, compound maj7) are consonant color
+ and never touched.
+
+ The replacement is the nearest in-scale pitch (per section, via
+ ``section_scales``) that clears every overlapping harmony note; if none
+ exists within a major 3rd, the note is left alone rather than mangled.
+ """
+ if not melody_events or not harmony_events:
+ return melody_events
+
+ harmony_sorted = sorted(harmony_events, key=lambda e: e.start)
+ _HARSH = (1, 13)
+
+ def _scale_pcs_at(beat: float) -> set[int] | None:
+ if not section_scales:
+ return None
+ for s_start, s_end, pcs in section_scales:
+ if s_start <= beat < s_end:
+ return pcs
+ return section_scales[-1][2]
+
+ def _sounding(mel: NoteEvent) -> list[int]:
+ out = []
+ m_end = mel.start + mel.duration
+ for h in harmony_sorted:
+ if h.start >= m_end:
+ break
+ overlap = min(m_end, h.start + h.duration) - max(mel.start, h.start)
+ if overlap > 0.1:
+ out.append(h.pitch)
+ return out
+
+ fixed: list[NoteEvent] = []
+ for mel in melody_events:
+ if mel.duration < min_duration:
+ fixed.append(mel)
+ continue
+ sounding = _sounding(mel)
+ if not sounding or mel.pitch % 12 in {h % 12 for h in sounding}:
+ fixed.append(mel)
+ continue
+ if not any(abs(mel.pitch - h) in _HARSH for h in sounding):
+ fixed.append(mel)
+ continue
+ pcs = _scale_pcs_at(mel.start)
+ new_pitch = mel.pitch
+ for delta in (-1, 1, -2, 2, -3, 3, -4, 4):
+ cand = mel.pitch + delta
+ if not (0 <= cand <= 127):
+ continue
+ if pcs is not None and cand % 12 not in pcs:
+ continue
+ if any(abs(cand - h) in _HARSH for h in sounding):
+ continue
+ new_pitch = cand
+ break
+ fixed.append(NoteEvent(pitch=new_pitch, start=mel.start, duration=mel.duration,
+ velocity=mel.velocity, channel=mel.channel)
+ if new_pitch != mel.pitch else mel)
+ return fixed
+
+
+_MAX_QUALITY_ATTEMPTS = 5
+_GREEN_THRESHOLD = 0.82
+_GENERATION_TIMEOUT_S = 30
+_QUALITY_DIMS = ("harmonic", "separation", "rhythm", "density", "mix")
+
+
+def _all_green(quality_raw: dict) -> bool:
+ return all(quality_raw.get(d, 0.0) >= _GREEN_THRESHOLD for d in _QUALITY_DIMS)
+
+
+def _blend_styles(style: dict, blend_style_id: str | None, blend_amount: float) -> dict:
+ """Numerically blend a second style into `style` (shared by /generate and
+ the Song Builder). Groove/density/swing fields interpolate; progression
+ template pools merge. Returns `style` unchanged when no blend applies."""
+ if not blend_style_id or blend_style_id == style.get("id"):
+ return style
+ try:
+ b_style = load_style(blend_style_id)
+ except ValueError:
+ logger.warning("Blend style %r not found — ignoring blend", blend_style_id)
+ return style
+ w = blend_amount
+ _NUMERIC_BLEND = ("hat_density", "kick_density", "snare_density",
+ "swing", "syncopation_prob", "groove_push")
+ blended = {**style}
+ for key in _NUMERIC_BLEND:
+ if key in style and key in b_style:
+ blended[key] = (1 - w) * style[key] + w * b_style[key]
+ a_progs = style.get("progression_templates", [])
+ b_progs = b_style.get("progression_templates", [])
+ if a_progs and b_progs:
+ blended["progression_templates"] = a_progs + b_progs
+ if "drums" in style and "drums" in b_style:
+ d_a, d_b = style["drums"], b_style["drums"]
+ drum_blend = {**d_a}
+ for k in ("hat_density", "kick_density", "snare_density", "swing",
+ "triplet_probability", "ghost_probability"):
+ if k in d_a and k in d_b:
+ drum_blend[k] = (1 - w) * d_a[k] + w * d_b[k]
+ blended["drums"] = drum_blend
+ return blended
+
+
+def _prior_name(style: dict) -> str:
+ """Which mined prior a style draws from: explicit `prior` field, else its id."""
+ return style.get("prior") or style.get("id", "")
+
+
+def _overlay_groove(style: dict, use_priors: bool) -> dict:
+ """Overlay a style with drum fields learned from a groove corpus, if one exists.
+
+ The learned kick_pattern / snare backbeat / hat density / swing replace the
+ style's hand-authored values so drums play a real, mined groove. Returns the
+ style unchanged when no groove prior applies.
+ """
+ fields = groove_fields_for(style, use_priors)
+ if not fields:
+ return style
+ return {**style, "drums": {**style.get("drums", {}), **fields}}
+
+
+# Chord-type suffixes a template token may carry (mirrors roman_to_chord's list;
+# longest first so e.g. 'ivsus2' strips to 'iv', not 'ivsus').
+_ROMAN_SUFFIXES = ("m7b5", "mM7", "dim7", "maj7", "9sus4", "7sus4", "sus2", "sus4",
+ "add11", "add9", "aug", "dim", "m6", "m9", "6", "9")
+
+
+def _template_tonic_mode(template: list[str]) -> str | None:
+ """'minor' if the template's tonic chord is bare i, 'major' if bare I,
+ 'mixed' if it somehow contains both (a data bug), None if it never states
+ an explicit tonic (e.g. a ii-V vamp) and so fits either mode."""
+ has_min = has_maj = False
+ for token in template:
+ s = token.lstrip("b#")
+ for suffix in _ROMAN_SUFFIXES:
+ if s.lower().endswith(suffix):
+ s = s[: -len(suffix)]
+ break
+ if s == "i":
+ has_min = True
+ elif s == "I":
+ has_maj = True
+ if has_min and has_maj:
+ return "mixed"
+ if has_min:
+ return "minor"
+ if has_maj:
+ return "major"
+ return None
+
+
+def _choose_progression(style: dict, use_priors: bool, seed: int, scale: str = "minor") -> list[str]:
+ """Pick a progression: a mined corpus prior when available+enabled, else a template.
+
+ The template RNG draw happens regardless so seeds stay stable with the legacy
+ path; the prior only replaces the resulting progression when one exists. The
+ prior is queried by mode so a major-key request gets a major progression.
+
+ Templates are filtered to those whose tonic quality matches the requested
+ scale's mode: a bare-'i' (minor tonic) template under a major-scale melody
+ puts the whole harmony in the parallel minor while the melody stays major —
+ every E-natural grinds against the chords' Eb (and vice versa for 'I'
+ templates under a minor scale). Style JSONs may legitimately carry both
+ major and minor templates; the request's scale decides which set applies.
+ """
+ templates = style.get("progression_templates", [["i", "VI", "III", "VII"]])
+ mode = _scale_mode(scale)
+ compatible = [t for t in templates if _template_tonic_mode(t) in (mode, None)]
+ random.seed(seed)
+ progression = random.choice(compatible or templates)
+ if use_priors:
+ prior = load_prior(_prior_name(style))
+ if prior:
+ sampled = sample_progression(prior, length=len(progression), seed=seed, mode=scale)
+ if sampled:
+ progression = sampled
+ hrb = style.get("harmonic_rhythm_bars", 1)
+ if hrb > 1:
+ progression = [chord for chord in progression for _ in range(hrb)]
+ # Chromatic color: season the diatonic progression with borrowed chords and
+ # secondary dominants (roadmap-2 item 4). Gated by the style's chromatic_color
+ # (default 0 = byte-identical). Applied once here so every section and the
+ # scorer share the identical colored progression.
+ _color = style.get("chromatic_color", 0.0)
+ if _color:
+ from app.generators.chords import apply_chromatic_color
+ progression = apply_chromatic_color(progression, scale, _color,
+ random.Random(seed ^ 0x00C010A))
+ return progression
+
+
+def _run_attempt(
+ req,
+ style: dict,
+ seed: int,
+ is_loop: bool,
+ groove_push: float,
+ secondary_dominants: bool,
+ tritone_sub: bool,
+ scoring_style: dict | None = None,
+ regen_part: str | None = None,
+ regen_salt: int = 0,
+ fixed_progression: list[str] | None = None,
+ chords_prev_voicing: list[int] | None = None,
+ melody_seed_motif: list[int] | None = None,
+ rhythm_cell: list[float] | None = None,
+ arp_contour: list[int] | None = None,
+) -> tuple[dict, dict, dict, list, dict | None, dict, list]:
+ """Run one generation attempt for a given seed.
+
+ Returns (all_events, cc_parts, pb_parts, progression, quality_raw, patterns).
+ quality_raw is None if scoring raised an exception.
+ scoring_style overrides the style dict used for quality scoring only,
+ so learned patterns can improve scorer accuracy without touching generation.
+
+ regen_part/regen_salt re-roll a single part in place: only that part's seed is
+ salted, so harmony and every other part come out byte-identical — used to
+ regenerate one stem of a song without disturbing the rest.
+
+ fixed_progression — when given, skips the per-call progression draw and uses this
+ progression instead. Used by the song builder so every section of a song shares
+ one harmonic identity instead of each section type independently rolling its own
+ progression; per-section chord substitutions (resolve_progression, seeded per
+ section) still vary, giving related-but-not-identical harmony across sections.
+
+ chords_prev_voicing — the previous song section's final chord voicing, threaded
+ into generate_chords so voice leading continues across section seams.
+ melody_seed_motif — scale-step motif intervals from an earlier section (the
+ verse theme), passed to generate_melody so choruses develop the verse's idea.
+ """
+ def _pseed(sec_i: int, part: str) -> int:
+ s = (seed + regen_salt) if (regen_part and part == regen_part) else seed
+ return _part_seed(s, sec_i, part)
+
+ _use_priors = getattr(req, "use_priors", True) and not getattr(req, "custom_progression", None)
+ style = _overlay_groove(style, getattr(req, "use_priors", True))
+ progression = fixed_progression if fixed_progression is not None else _choose_progression(style, _use_priors, seed, req.scale)
+ _melody_model = melody_prior_for(_prior_name(style), getattr(req, "use_priors", True))
+
+ # Resolve section profile for loop-mode shaping (contrast spread scaled by
+ # the dynamics macro — 0.5 reproduces SECTION_PROFILES exactly)
+ _dynamics = getattr(req, "dynamics", 0.5)
+ _sec_profile = scaled_profile(req.section_type, _dynamics) if is_loop else {}
+ _sec_cplx = min(1.0, req.complexity * _sec_profile.get("complexity_scale", 1.0))
+ _sec_var = min(1.0, req.variation * _sec_profile.get("variation_scale", 1.0))
+ _sec_dyn = _sec_profile.get("velocity_scale", 1.0)
+
+ 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)
+
+ all_events: dict[str, list[NoteEvent]] = {part: [] for part in req.parts}
+ _chords_prev = list(chords_prev_voicing) if chords_prev_voicing else None
+ chorus_spans: list[tuple[float, float]] = [] # (start_beat, end_beat) per chorus, for the hook scorer
+
+ for section_i, section in enumerate(sections):
+ s_bars = section["bars"]
+ s_cplx = section["complexity"]
+ s_parts = set(section["parts"])
+ s_off = section["offset"]
+ s_key = section.get("key", req.key)
+
+ random.seed(_part_seed(seed, section_i, "harmony"))
+ s_resolved = resolve_progression(progression, req.scale, s_cplx, secondary_dominants, tritone_sub)
+
+ s_dyn = section.get("dynamic", 1.0)
+
+ # Section context for the drums: explicit in song-builder loop mode,
+ # derived from the auto-arc's shape otherwise.
+ if is_loop:
+ s_sec_type = req.section_type
+ s_next_type = getattr(req, "next_section_type", None)
+ else:
+ s_sec_type = _auto_arc_section_type(sections, section_i)
+ s_next_type = (_auto_arc_section_type(sections, section_i + 1)
+ 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))
+
+ # 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)
+ # every template section — verse, chorus, etc. — runs through its own
+ # single-section call here, so `sections` always has exactly one entry
+ # and section_i is always 0; checking "is this the last entry in
+ # `sections`" degenerates to "yes, always", mislabelling every
+ # low-complexity section (typically verses) as an outro and freezing
+ # their bass on the tonic for the whole section regardless of the
+ # chord progression moving underneath it. Loop mode instead checks the
+ # section's actual declared type.
+ is_last_section = (s_sec_type in ("outro", "ending") if is_loop
+ else section_i == len(sections) - 1)
+ is_outro = (is_last_section and s_cplx < 0.5 and s_bars >= 2)
+ bass_prog = (["I"] * len(progression)) if is_outro else progression
+
+ # Per-part complexity / variation overrides from section profile (loop mode only)
+ eff_var = _sec_var if is_loop else req.variation
+ mel_cplx = min(1.0, s_cplx * _sec_profile.get("melody_complexity_scale", 1.0))
+ backing_cplx = min(1.0, s_cplx * _sec_profile.get("backing_complexity_scale", 1.0))
+
+ # Harmonic rhythm: choruses/pre-choruses change chords faster. The boost
+ # feeds one shared value into chords, bass, and melody so their grids agree.
+ _harm_boost = scaled_profile(s_sec_type, _dynamics).get("harmonic_boost", 0.0)
+ harmony_cplx = min(1.0, backing_cplx + _harm_boost)
+
+ # Ensemble pushes: which chord changes anticipate ("and of 4" early) is
+ # decided ONCE here, seeded, and observed by BOTH the comp and the
+ # bass — a lone early comp against an on-grid bass reads as a mistake;
+ # the band pushing together reads as an arrangement.
+ random.seed(_pseed(section_i, "pushes"))
+ _push_prob = style.get("chord_anticipation_prob", 0.15)
+ _cpb = 2 if harmony_cplx > 0.6 else 1
+ push_windows = {w for w in range(1, s_bars * _cpb) if random.random() < _push_prob}
+
+ kick_times: list[float] = []
+ if "drums" in req.parts and "drums" in s_parts:
+ random.seed(_pseed(section_i, "drums"))
+ drum_evts = generate_drums(style, s_bars, s_cplx, eff_var,
+ section_end_bars=_section_end_bars(sections, s_off),
+ is_loop=is_loop,
+ section_type=s_sec_type,
+ next_section_type=s_next_type,
+ dynamics=_dynamics)
+ 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"]]
+
+ has_melody = "melody" in s_parts
+ mel_range = style.get("melody", {}).get("range", [60, 79])
+ # Always cap chord voicings below the melody's lowest note when melody is active.
+ # Without this, default chord_register [48,72] lets chord tops reach into the
+ # melody range [60,79], causing the two parts to fight for the same register.
+ #
+ # The cap is decided by whether the SONG carries a melody, not this section:
+ # song-builder intros/outros/endings often drop the melody (hook tease,
+ # sparse part modes), and deciding per-section made their chords float a
+ # full octave above every melody-bearing section — a jarring register jump
+ # into the first verse and again at the ending.
+ _song_parts = getattr(req, "song_parts", None) or req.parts
+ melody_ceiling = mel_range[0] if (has_melody or "melody" in _song_parts) else None
+
+ # Riff mode: the sung melody stands down in riff verses (the riff IS the
+ # part) and returns for choruses, bridges, and lead/solo sections.
+ from app.generators.riff import riff_section_comp
+ _melody_tacet = (
+ riff_section_comp(style, s_sec_type)
+ and s_sec_type not in ("chorus", "post_chorus", "pre_chorus",
+ "bridge", "instrumental_solo")
+ )
+
+ mel_rests: list = []
+ mel_evts: list = []
+ if has_melody and "melody" in req.parts and not _melody_tacet:
+ random.seed(_pseed(section_i, "melody"))
+ mel_evts = generate_melody(style, s_key, req.scale, s_bars, mel_cplx,
+ eff_var, s_resolved, is_loop=is_loop,
+ melody_model=_melody_model,
+ harmony_complexity=harmony_cplx,
+ seed_motif=melody_seed_motif,
+ section_type=s_sec_type)
+ mel_evts = _apply_dynamic(mel_evts, s_dyn)
+ all_events["melody"].extend(_shift(mel_evts, s_off))
+ if mel_evts:
+ sorted_mel = sorted(mel_evts, key=lambda e: e.start)
+ for _i in range(1, len(sorted_mel)):
+ gap_s = sorted_mel[_i - 1].start + sorted_mel[_i - 1].duration
+ gap_e = sorted_mel[_i].start
+ if gap_e - gap_s >= 1.5:
+ mel_rests.append((round(gap_s, 3), round(gap_e, 3)))
+
+ # Call-response answers trace the song's melodic cell. Before that cell
+ # exists (the first verse — its theme is captured only after its backing
+ # is built) fall back to THIS section's own opening contour, so verse 1
+ # (where the space is greatest) can still answer thematically.
+ _answer_cell = arp_contour or melody_cell(mel_evts, s_key, req.scale)
+
+ # Counter-melody: harmony under the hook (choruses) or a thematic ANSWER
+ # in the melody's holes (verse/intro/outro — see generate_counter_melody).
+ # When it's answering, the bass floor-fill stands down for this section
+ # (below) so the two don't both crowd into the same gap.
+ _cm_answering = ("counter_melody" in req.parts and "counter_melody" in s_parts
+ and bool(mel_evts)
+ and s_sec_type not in (None, "chorus", "post_chorus"))
+ if mel_evts and "counter_melody" in req.parts and "counter_melody" in s_parts:
+ random.seed(_pseed(section_i, "counter_melody"))
+ 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)
+ cm_evts = _apply_dynamic(cm_evts, s_dyn)
+ all_events["counter_melody"].extend(_shift(cm_evts, s_off))
+
+ # Fixed order so chords are generated before the arpeggio, letting the arp
+ # arpeggiate the chords' real voiced harmony (chord_tones below).
+ section_chord_tones: list | None = None
+ for part in ("chords", "pads", "bass", "arpeggio"):
+ if part not in req.parts or part not in s_parts:
+ continue
+ random.seed(_pseed(section_i, part))
+ if part == "chords":
+ evts = generate_chords(style, s_key, req.scale, s_bars, backing_cplx,
+ eff_var, progression, s_resolved,
+ melody_ceiling=melody_ceiling,
+ kick_times=kick_times,
+ melody_rests=mel_rests if has_melody else None,
+ harmony_complexity=harmony_cplx,
+ prev_voicing=_chords_prev,
+ push_windows=push_windows,
+ section_type=s_sec_type)
+ section_chord_tones = _chord_tones_by_bar(
+ [(e.start, e.pitch) for e in evts], s_bars)
+ _chords_prev = _final_chord_voicing(evts)
+ elif part == "pads":
+ 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))
+ elif part == "bass":
+ evts = generate_bass(style, s_key, req.scale, s_bars, backing_cplx,
+ eff_var, bass_prog, kick_times,
+ melody_rests=(None if _cm_answering else mel_rests),
+ harmony_complexity=harmony_cplx,
+ push_windows=push_windows,
+ rhythm_cell=rhythm_cell,
+ cell_contour=_answer_cell,
+ section_type=s_sec_type)
+ elif part == "arpeggio":
+ arp_octave = 6 if has_melody else 5
+ # When melody is active, pull arpeggio back so it supports rather than competes.
+ # mel_rests lets arpeggio fill the space when melody is silent (call-and-response).
+ arp_cplx = backing_cplx * (0.68 if has_melody and "melody" in s_parts else 1.0)
+ evts = generate_arpeggio(style, s_key, req.scale, s_bars, arp_cplx,
+ eff_var, s_resolved, arp_octave,
+ melody_rests=mel_rests if has_melody else None,
+ chord_tones=section_chord_tones,
+ seed_contour=arp_contour)
+ evts = _apply_dynamic(evts, s_dyn)
+ all_events[part].extend(_shift(evts, s_off))
+
+ # Smooth dynamic steps at section transitions (verse→chorus lift, etc.)
+ if not is_loop and len(sections) > 1:
+ _apply_section_ramp(all_events, sections)
+
+ if groove_push:
+ for gp_part in ("melody", "chords", "arpeggio", "bass", "pads", "counter_melody"):
+ if gp_part in all_events and all_events[gp_part]:
+ all_events[gp_part] = _apply_groove_push(all_events[gp_part], groove_push)
+
+ # Systematic feel first (styles with a feel profile): drums get per-class
+ # microtiming + velocity contour, bass sits behind the kick. Those parts are
+ # then excluded from the generic pocket so the two don't stack. Styles with
+ # no feel profile skip this entirely and fall through unchanged.
+ _feel_parts = apply_feel(all_events, style)
+
+ # Shared groove pocket: every remaining part shifts onto the same per-16th-slot
+ # micro-offsets (style-seeded, deterministic) so the band plays TOGETHER —
+ # independent per-note jitter alone made the composite subtly smear.
+ apply_groove_pocket(all_events, style, skip=_feel_parts)
+
+ if all_events.get("melody"):
+ # Per-section scale pcs (sections can sit in shifted keys — chorus lift)
+ # so both melody clean-up passes nudge to in-key pitches, never chromatic ones.
+ _mel_scale_name = style.get("melody_scale", req.scale)
+ _section_scales = [
+ (float(s["offset"]), float(s["offset"] + s["bars"] * 4),
+ {p % 12 for p in build_scale(s.get("key", req.key), _mel_scale_name,
+ octave_start=4, num_octaves=1)})
+ for s in sections
+ ]
+ if all_events.get("bass"):
+ all_events["melody"] = _prevent_parallel_motion(
+ all_events["melody"], all_events["bass"], _section_scales
+ )
+ # Avoid-note pass: sustained melody notes a real m2/m9 from the
+ # chords'/pads' ACTUAL voicing move to a safe scale tone (the melody
+ # generator can't see voicings; this runs on the finished events).
+ _harmony_evts = (all_events.get("chords") or []) + (all_events.get("pads") or [])
+ if _harmony_evts:
+ all_events["melody"] = _resolve_avoid_notes(
+ all_events["melody"], _harmony_evts, _section_scales
+ )
+
+ cc_parts: dict[str, list[ControlEvent]] = {}
+ for part in req.parts:
+ if part == "drums" or part not in all_events or not all_events[part]:
+ continue
+ channel = _PART_CHANNELS.get(part, 0)
+ cc_parts[part] = _generate_part_cc(part, req.bars, channel, style=style)
+
+ if "melody" in all_events and all_events["melody"]:
+ ch = _PART_CHANNELS.get("melody", 2)
+ melody_cc11 = _generate_melody_expression_cc(all_events["melody"], ch)
+ cc_parts.setdefault("melody", []).extend(melody_cc11)
+
+ bass_style_type = style.get("bass", {}).get("bass_style", "standard")
+ if bass_style_type != "808" and "bass" in all_events and all_events["bass"]:
+ ch = _PART_CHANNELS.get("bass", 1)
+ cc_parts.setdefault("bass", []).extend(
+ _generate_bass_expression_cc(all_events["bass"], ch)
+ )
+
+ pb_parts: dict[str, list[PitchBendEvent]] = {}
+ if "bass" in all_events and all_events["bass"]:
+ bass_cfg = style.get("bass", {})
+ if bass_cfg.get("bass_style") == "808":
+ ch = _PART_CHANNELS.get("bass", 1)
+ pb_parts["bass"] = _generate_808_pitch_bends(all_events["bass"], ch)
+
+ patterns = extract_rhythm_patterns(all_events, req.bars)
+
+ # Pre-apply the same velocity scaling used for MIDI output so the mix scorer
+ # evaluates what the listener will actually hear, not the raw generator values.
+ _scored_events = {
+ part: [
+ NoteEvent(e.pitch, e.start, e.duration,
+ max(1, min(127, int(e.velocity * _VELOCITY_SCALE.get(part, 1.0)))),
+ e.channel)
+ for e in evts
+ ]
+ for part, evts in all_events.items()
+ }
+
+ try:
+ quality_raw = score_generation(
+ _scored_events, scoring_style or style,
+ req.key, req.scale, req.bars, progression, req.complexity,
+ chorus_spans=chorus_spans,
+ )
+ except Exception as exc:
+ logger.error("Quality scoring failed (seed=%s): %s", seed, exc, exc_info=True)
+ quality_raw = None
+
+ return all_events, cc_parts, pb_parts, progression, quality_raw, patterns, sections
diff --git a/backend/app/services/library.py b/backend/app/services/library.py
index 2b60e98..00f8a28 100644
--- a/backend/app/services/library.py
+++ b/backend/app/services/library.py
@@ -74,7 +74,7 @@ def save_generation(
if prev.get("source") in ("thumbs_up", "export") and source == "score":
source = prev["source"] # don't demote an explicit keep
except Exception:
- pass
+ _logger.debug("Ignoring unreadable prior library entry %s", path, exc_info=True)
entry = {
"gen_id": gen_id,
"style_id": style_id,
diff --git a/backend/app/services/priors.py b/backend/app/services/priors.py
index 1d7d18c..ba5bfa5 100644
--- a/backend/app/services/priors.py
+++ b/backend/app/services/priors.py
@@ -14,9 +14,12 @@
templates. Priors live in ``backend/app/priors/.json``.
"""
import json
+import logging
import random
from pathlib import Path
+logger = logging.getLogger(__name__)
+
_PRIORS_DIR = Path(__file__).resolve().parent.parent / "priors"
_GROOVES_DIR = _PRIORS_DIR / "grooves"
@@ -47,6 +50,7 @@ def load_groove(name: str) -> dict | None:
try:
g = json.loads(path.read_text())
except Exception:
+ logger.warning("Malformed groove prior %s — ignoring", path, exc_info=True)
g = None
_groove_cache[name] = g
return g
@@ -78,6 +82,7 @@ def load_prior(genre: str) -> dict | None:
try:
prior = json.loads(path.read_text())
except Exception:
+ logger.warning("Malformed genre prior %s — ignoring", path, exc_info=True)
prior = None
_cache[genre] = prior
return prior
diff --git a/backend/app/services/song_builder.py b/backend/app/services/song_builder.py
new file mode 100644
index 0000000..84b7bfc
--- /dev/null
+++ b/backend/app/services/song_builder.py
@@ -0,0 +1,652 @@
+# 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.
+
+"""Song-builder core — arrangement engine extracted from routes_song.py.
+
+The template->sections loop (`_generate_song_sections`), its motif/voice-leading
+helpers, the section-marker + combined-MIDI writer, and the bridge-escape
+progression. Keeps the Song Builder endpoints thin: they parse the request and
+call in here. Depends on the generation service + generators/arrangement — never
+on a route module."""
+import logging
+import random
+
+
+from app.models.schemas import (GenerateRequest, FileInfo)
+from app.services.style_loader import load_style
+from app.services.midi_writer import (NoteEvent, write_midi, write_combined_midi,
+ mido_key_signature)
+from app.services.library import build_scoring_style
+from app.generators.counter_melody import generate_counter_melody
+from app.theory.chords import roman_to_chord
+from app.core.constants import DRUM_MAP
+from app.core.arrangement import (
+ SECTION_PROFILES, _SONG_TEMPLATES, _part_seed, _transpose_key,
+ _apply_section_ramp, _song_tempo_map, apply_arrangement_dynamics,
+ apply_melodic_pickups,
+)
+from app.services.mixdown import (
+ _PART_CHANNELS, part_midi_meta,
+ _generate_part_cc, _generate_melody_expression_cc,
+ _generate_808_pitch_bends, _drop_quiet, _scale_velocity, _shift,
+ generate_build_sweeps, generate_section_crescendo,
+)
+from app.services.generation import (
+ _run_attempt, _choose_progression, _all_green,
+ _MAX_QUALITY_ATTEMPTS, _final_chord_voicing,
+)
+
+logger = logging.getLogger(__name__)
+
+def _vary_repeat(events: list[NoteEvent], part: str) -> list[NoteEvent]:
+ """Light variation applied when a cached section theme repeats.
+
+ Keeps the theme recognizable (same pitches, same rhythm skeleton) while
+ removing the photocopy feel: velocities re-humanize, and ~18% of the
+ melody's long notes gain an upper-neighbor turn ornament. Caller seeds the
+ RNG so repeats are deterministic per section occurrence.
+ """
+ out: list[NoteEvent] = []
+ for e in events:
+ vel = max(1, min(127, e.velocity + random.randint(-6, 6)))
+ if part == "melody" and e.duration >= 1.0 and random.random() < 0.18:
+ d1, d2 = e.duration * 0.5, e.duration * 0.22
+ d3 = e.duration - d1 - d2
+ out.append(NoteEvent(e.pitch, e.start, d1 * 0.95, vel, e.channel))
+ out.append(NoteEvent(min(127, e.pitch + 2), e.start + d1, d2 * 0.9,
+ max(1, vel - 10), e.channel))
+ out.append(NoteEvent(e.pitch, e.start + d1 + d2, d3 * 0.95,
+ max(1, vel - 4), e.channel))
+ else:
+ out.append(NoteEvent(e.pitch, e.start, e.duration, vel, e.channel))
+ return out
+
+
+def _melody_motif_intervals(mel_events: list[NoteEvent], key: str, scale: str) -> list[int] | None:
+ """Scale-step intervals of a melody's opening motif (up to 4 intervals).
+
+ Extracted from the first verse of a built song and handed to chorus
+ generation so the chorus melody develops the verse's theme instead of
+ inventing an unrelated one. Pitches are snapped to the scale lattice and
+ expressed as index deltas, so the motif transposes cleanly to any register.
+ """
+ if not mel_events:
+ return None
+ from app.theory.scales import build_scale
+ lattice = build_scale(key, scale, octave_start=2, num_octaves=6)
+ pitches = [e.pitch for e in sorted(mel_events, key=lambda e: e.start)[:5]]
+ if len(pitches) < 2:
+ return None
+ idxs = [min(range(len(lattice)), key=lambda i: abs(lattice[i] - p)) for p in pitches]
+ intervals = [idxs[k + 1] - idxs[k] for k in range(len(idxs) - 1)]
+ # An all-zero motif (repeated note) carries no shape worth reusing
+ return intervals if any(intervals) else None
+
+
+def _generate_song_sections(req, style, bpm, base_seed, chorus_key_shift,
+ secondary_dominants, tritone_sub, groove_push,
+ regen_part=None, regen_salt=0, bridge_key_shift=0,
+ fixed_section_seeds=None, final_chorus_lift=0,
+ custom_template=None, user_progression=None,
+ hook_melody=None):
+ """Run a song template's section loop → (song_events, section_results, total_bars, section_seeds).
+
+ Shared by build_song and regenerate_song_part. regen_part/regen_salt re-roll one
+ part in place while harmony and every other part stay identical.
+
+ Each section runs the same quality-scored, multi-attempt search plain /generate
+ uses (`_MAX_QUALITY_ATTEMPTS`, best-of scoring) instead of a single unscreened
+ attempt — sections used to ship whatever the first random roll produced.
+
+ `fixed_section_seeds` — when given (regenerate_song_part), replays the exact
+ winning attempt seed chosen for each section by the original build_song call
+ instead of re-deriving and re-searching, so non-regenerated parts come out
+ byte-identical to what's already on disk.
+
+ `final_chorus_lift` — extra semitones added to the LAST chorus's key (the
+ classic gear-change); the cached chorus theme is transposed to match.
+ `custom_template` — list of section dicts overriding the named template.
+ `user_progression` / `hook_melody` — melody-import mode: the derived
+ progression replaces the style draw, and the user's melody becomes the
+ chorus hook (cached as the chorus theme, so repeats/tease/counter-melody
+ all build on it).
+ """
+ if custom_template:
+ template = [dict(sd) for sd in custom_template]
+ else:
+ template = [dict(sd) for sd in _SONG_TEMPLATES.get(req.template, _SONG_TEMPLATES["verse_chorus"])]
+
+ # DJ edit: bookend the arrangement with an 8-bar beat-only (drums+bass)
+ # section for mixing — steady, no fills, no melodic content, outside the arc.
+ # Only meaningful when the song actually has a rhythm section.
+ if getattr(req, "dj_edit", False) and {"drums", "bass"} & set(req.parts):
+ template = (
+ [{"name": "DJ Intro", "section_type": "dj_intro", "bars": 8, "parts_mode": "foundation"}]
+ + template
+ + [{"name": "DJ Outro", "section_type": "dj_outro", "bars": 8, "parts_mode": "foundation"}]
+ )
+
+ full_parts = list(req.parts)
+ no_arp = [p for p in req.parts if p != "arpeggio"]
+ foundation = [p for p in req.parts if p in ("drums", "bass")]
+ sparse_parts = [p for p in req.parts if p in ("drums", "bass", "chords")]
+ melodic = [p for p in req.parts if p in ("chords", "melody")]
+ no_drums = [p for p in req.parts if p != "drums"]
+ chords_only = [p for p in req.parts if p == "chords"]
+ parts_modes = {
+ "full": full_parts, "no_arp": no_arp, "foundation": foundation,
+ "sparse": sparse_parts, "melodic": melodic or chords_only,
+ "no_drums": no_drums, "chords_only": chords_only or foundation,
+ }
+
+ scoring_style = build_scoring_style(style, req.style_id)
+
+ # Per-section style overrides (custom templates): a section can generate in a
+ # different style while the whole song keeps one progression/key, so a lofi
+ # verse can drop into a house chorus without losing harmonic identity.
+ _sec_style_cache: dict[str, tuple[dict, dict]] = {}
+
+ def _style_for(style_id: str | None) -> tuple[dict, dict]:
+ if not style_id or style_id == style.get("id"):
+ return style, scoring_style
+ if style_id not in _sec_style_cache:
+ try:
+ s = {**load_style(style_id), "_humanize_scale": style.get("_humanize_scale", 0.5)}
+ _sec_style_cache[style_id] = (s, build_scoring_style(s, style_id))
+ except ValueError:
+ logger.warning("Section style %r not found — using the song style", style_id)
+ _sec_style_cache[style_id] = (style, scoring_style)
+ return _sec_style_cache[style_id]
+
+ # One progression for the whole song so every section shares a harmonic identity.
+ # Per-section chord substitutions (inside _run_attempt, seeded per section) still
+ # vary each section's exact chords, so chorus/bridge relate to the verse instead
+ # of each section type independently rolling an unrelated progression.
+ song_progression = (list(user_progression) if user_progression
+ else _choose_progression(style, req.use_priors, base_seed, req.scale))
+
+ # Pre-choruses get a rising harmonic ramp instead of the song loop — the
+ # classic build (predominant -> dominant) that makes the chorus drop land.
+ _prechorus_prog = (["ii", "IV", "V", "V"] if req.scale == "major"
+ else ["iv", "v", "VI", "VII"])
+
+ song_events: dict[str, list] = {p: [] for p in req.parts}
+ section_results: list[dict] = []
+ section_seeds: list[int] = []
+ ramp_sections: list[dict] = []
+ beat_offset = 0.0
+ total_bars = 0
+ type_seed: dict[str, int] = {}
+ type_occurrence: dict[str, int] = {}
+ # First occurrence of each section type caches its melodic/harmonic parts so
+ # later sections of the same type reuse the theme (the verse tune returns).
+ type_theme: dict[str, dict] = {}
+
+ # The counter-melody is reserved for the climactic last chorus so the final
+ # chorus sounds bigger than the ones before it.
+ last_chorus_i = max((i for i, s in enumerate(template)
+ if s["section_type"] == "chorus"), default=-1)
+
+ # Threaded across sections: the closing chord voicing (voice-leading
+ # continuity at seams) and the verse's opening motif (chorus develops it).
+ prev_voicing: list[int] | None = None
+ verse_motif: list[int] | None = None
+ rhythm_cell: list[float] | None = None # the song's rhythmic cell (onset offsets, from the first theme)
+ _hook_motif = (_melody_motif_intervals(hook_melody, req.key, req.scale)
+ if hook_melody else None)
+ # Intro hook tease: when the intro would carry a melody, hold it back and
+ # overlay a thinned copy of the chorus melody after the loop instead.
+ tease_intro: dict | None = None
+ chorus_theme_shift = 0 # key shift the cached chorus theme was generated in
+
+ for sec_i, sec_def in enumerate(template):
+ sec_type = sec_def["section_type"]
+ sec_bars = sec_def.get("bars", 8)
+ sec_name = sec_def.get("name") or sec_type
+ parts_mode = sec_def.get("parts_mode", "full")
+ sec_parts = list(parts_modes.get(parts_mode, full_parts) or full_parts)
+
+ # Arrangement colors: pads fill out only the big sections. The
+ # counter-melody harmonizes the hook on the final chorus AND answers the
+ # lead's holes in the sections with space (verse/intro/outro); it's kept
+ # out of the dense middle sections so it never turns into a constant
+ # second lead. See generate_counter_melody for the mode split.
+ if "pads" in sec_parts and sec_type not in ("chorus", "bridge"):
+ sec_parts = [p for p in sec_parts if p != "pads"]
+ if "counter_melody" in sec_parts and not (
+ sec_i == last_chorus_i or sec_type in ("verse", "intro", "outro")):
+ sec_parts = [p for p in sec_parts if p != "counter_melody"]
+
+ # Layer accumulation: every RETURN of a section type carries something
+ # its first pass didn't, so repeats escalate instead of photocopying.
+ # Verse 2+ gains the arpeggio its template mode withheld; repeated
+ # choruses get busier hats (below, via the style overlay).
+ _prior_occ = type_occurrence.get(sec_type, 0)
+ if (sec_type == "verse" and _prior_occ >= 1
+ and "arpeggio" in req.parts and "arpeggio" not in sec_parts):
+ sec_parts = sec_parts + ["arpeggio"]
+
+ # Intro tease: strip the intro's own melody — the chorus hook (thinned)
+ # takes its place once the chorus theme exists.
+ if sec_i == 0 and sec_type == "intro" and "melody" in sec_parts and last_chorus_i > 0:
+ sec_parts = [p for p in sec_parts if p != "melody"]
+ tease_intro = {"bars": sec_bars}
+
+ key_shift = (
+ chorus_key_shift if sec_def.get("chorus_key")
+ else bridge_key_shift if sec_def.get("bridge_key")
+ else 0
+ )
+ # Gear change: the last chorus lifts above the earlier ones.
+ if sec_i == last_chorus_i:
+ key_shift += final_chorus_lift
+ sec_key = _transpose_key(req.key, key_shift) if key_shift else req.key
+
+ occ = type_occurrence.get(sec_type, 0)
+ if sec_type not in type_seed:
+ type_seed[sec_type] = _part_seed(base_seed, sec_i, "type")
+ type_occurrence[sec_type] = occ + 1
+ sec_seed = (type_seed[sec_type] + occ * 73_856) % (2 ** 31)
+
+ next_sec_type = template[sec_i + 1]["section_type"] if sec_i + 1 < len(template) else None
+ sec_style, sec_scoring = _style_for(sec_def.get("style_id"))
+ if sec_type == "chorus" and _prior_occ >= 1:
+ _drums_cfg = sec_style.get("drums", {})
+ sec_style = {**sec_style, "drums": {
+ **_drums_cfg, "hat_density": min(1.0, _drums_cfg.get("hat_density", 0.5) * 1.18)}}
+ sec_progression = _prechorus_prog if sec_type == "pre_chorus" else song_progression
+ # Bridge escape: a fresh progression that opens off the song's beaten path
+ # and walks home on a dominant pedal. Seeded so ~half of songs keep today's
+ # bridge; opt-in per style via bridge_escape_prob (default 0 = unchanged).
+ if sec_type == "bridge":
+ _besc = style.get("bridge_escape_prob", 0.0)
+ if _besc and random.Random(base_seed ^ 0x8B12D6).random() < _besc:
+ sec_progression, _ = _bridge_escape_progression(song_progression, req.scale)
+
+ sec_req = GenerateRequest.model_construct(
+ style_id=req.style_id, key=sec_key, scale=req.scale, bpm=bpm,
+ bars=sec_bars, complexity=req.complexity, variation=req.variation,
+ dynamics=req.dynamics,
+ parts=sec_parts, mode="loop", seed=sec_seed, section_type=sec_type,
+ next_section_type=next_sec_type,
+ song_parts=list(req.parts), # full song part list — keeps register decisions consistent in sections that drop parts
+ humanize=req.humanize, custom_progression=None, blend_style_id=None,
+ blend_amount=0.5, use_priors=req.use_priors,
+ )
+
+ # Choruses develop the verse's motif; other section types keep their own
+ # ideas. In melody-import mode every melodic section develops the HOOK's
+ # motif instead, so the whole song grows out of the user's idea.
+ if hook_melody:
+ sec_motif = _hook_motif if sec_type in ("verse", "chorus", "bridge") else None
+ else:
+ sec_motif = verse_motif if sec_type == "chorus" else None
+
+ if fixed_section_seeds is not None:
+ # Replay: reuse the exact seed the original build_song call landed on
+ # for this section — no re-search, so untouched parts stay identical.
+ winning_seed = fixed_section_seeds[sec_i] if sec_i < len(fixed_section_seeds) else sec_seed
+ _qraw = None
+ try:
+ evts, _cc, _pb, _prog, _qraw, _patterns, _secs = _run_attempt(
+ sec_req, sec_style, winning_seed, True, groove_push, secondary_dominants, tritone_sub,
+ scoring_style=sec_scoring, regen_part=regen_part, regen_salt=regen_salt,
+ fixed_progression=sec_progression,
+ chords_prev_voicing=prev_voicing, melody_seed_motif=sec_motif,
+ rhythm_cell=rhythm_cell, arp_contour=verse_motif,
+ )
+ except Exception as exc:
+ logger.error("build_song section %r failed: %s", sec_name, exc, exc_info=True)
+ evts = {}
+ else:
+ # Quality-gated multi-attempt search, mirroring plain /generate's
+ # _run_best_attempt — song sections previously ran once with no
+ # quality check at all.
+ best_evts, best_total, winning_seed = None, -1.0, sec_seed
+ for attempt in range(_MAX_QUALITY_ATTEMPTS):
+ attempt_seed = sec_seed if attempt == 0 else _part_seed(sec_seed, attempt, "retry")
+ try:
+ evts, _cc, _pb, _prog, qraw, _patterns, _secs = _run_attempt(
+ sec_req, sec_style, attempt_seed, True, groove_push, secondary_dominants, tritone_sub,
+ scoring_style=sec_scoring, regen_part=regen_part, regen_salt=regen_salt,
+ fixed_progression=sec_progression,
+ chords_prev_voicing=prev_voicing, melody_seed_motif=sec_motif,
+ rhythm_cell=rhythm_cell, arp_contour=verse_motif,
+ )
+ except Exception as exc:
+ logger.error("build_song section %r attempt %d failed: %s", sec_name, attempt, exc, exc_info=True)
+ continue
+ total = qraw.get("total", 0.0) if qraw is not None else 0.0
+ if best_evts is None or total > best_total:
+ best_evts, best_total, winning_seed = evts, total, attempt_seed
+ if qraw is not None and _all_green(qraw):
+ break
+ evts = best_evts or {}
+
+ section_seeds.append(winning_seed)
+
+ # Melody-import: the user's melody IS the chorus. Swap it in before the
+ # theme cache locks, so every chorus repeat, the intro tease, and the
+ # counter-melody derive from the real hook. Transposed to the chorus key.
+ if hook_melody and sec_type == "chorus" and "chorus" not in type_theme and evts.get("melody") is not None:
+ from app.services.melody_import import fit_melody_to_bars
+ fitted = fit_melody_to_bars(hook_melody, sec_bars)
+ if key_shift:
+ fitted = [NoteEvent(min(127, max(0, e.pitch + key_shift)), e.start,
+ e.duration, e.velocity, e.channel) for e in fitted]
+ evts["melody"] = fitted
+
+ sec_quality = best_total if (fixed_section_seeds is None and best_total >= 0) else (
+ _qraw.get("total") if fixed_section_seeds is not None and _qraw else None)
+
+ # Cross-section motif reuse: the first section of each type sets the theme
+ # (melody + harmony); later sections of that type reuse it, keeping fresh
+ # drums so the groove still evolves. Same-type sections share a key, so the
+ # reused parts need no transposition.
+ if sec_type not in type_theme:
+ type_theme[sec_type] = {p: list(e) for p, e in evts.items() if p != "drums"}
+ if sec_type == "chorus":
+ chorus_theme_shift = key_shift # key the cached chorus theme sounds in
+ else:
+ for p, cached in type_theme[sec_type].items():
+ if p in evts:
+ evts[p] = list(cached)
+ # Gear change: the cached chorus theme sounds in the earlier chorus
+ # key — transpose it up to this section's lifted key.
+ if sec_i == last_chorus_i and final_chorus_lift:
+ for p in type_theme[sec_type]:
+ if evts.get(p):
+ evts[p] = [NoteEvent(min(127, max(0, e.pitch + final_chorus_lift)),
+ e.start, e.duration, e.velocity, e.channel)
+ for e in evts[p]]
+ # Light variation so the repeat isn't a photocopy of the first pass.
+ random.seed(_part_seed(winning_seed, 0, "repeat_var"))
+ for p in type_theme[sec_type]:
+ if evts.get(p):
+ evts[p] = _vary_repeat(evts[p], p)
+ # The theme swap replaced this section's melody with the cached one, so
+ # any counter-melody derived from the discarded fresh melody would be
+ # answering/harmonizing a line that no longer sounds — re-derive it
+ # from the melody that will actually play (in the same mode the
+ # section uses: harmony on the chorus, answer in verse/intro/outro).
+ if "counter_melody" in evts and evts.get("melody"):
+ random.seed(_part_seed(winning_seed, 0, "counter_melody"))
+ _cm_mel = sorted(evts["melody"], key=lambda e: e.start)
+ _cm_rests = [(round(_cm_mel[i - 1].start + _cm_mel[i - 1].duration, 3),
+ round(_cm_mel[i].start, 3))
+ for i in range(1, len(_cm_mel))
+ if _cm_mel[i].start - (_cm_mel[i - 1].start + _cm_mel[i - 1].duration) >= 1.5]
+ _cm_cell = verse_motif or _melody_motif_intervals(evts["melody"], sec_key, req.scale)
+ evts["counter_melody"] = generate_counter_melody(
+ evts["melody"], sec_key, req.scale, sec_bars,
+ song_progression, sec_style,
+ melody_rests=_cm_rests, cell=_cm_cell, section_type=sec_type)
+
+ # Thread voice-leading and the verse theme into the next section: the
+ # post-theme-swap events are what actually sound, so extract from those.
+ if evts.get("chords"):
+ prev_voicing = _final_chord_voicing(evts["chords"])
+ if verse_motif is None and sec_type == "verse" and evts.get("melody"):
+ verse_motif = _melody_motif_intervals(evts["melody"], req.key, req.scale)
+ if rhythm_cell is None and evts.get("melody"):
+ # The song's rhythmic cell: the first theme's opening onset
+ # pattern (16th-quantized, within-bar offsets). Later sections'
+ # bass echoes it and the arpeggio takes its contour — the "one
+ # composer" glue between parts.
+ _onsets = sorted(e.start for e in evts["melody"] if e.start < 8.0)
+ rhythm_cell = []
+ for _o in _onsets:
+ _q = round((_o % 4) * 4) / 4
+ if _q not in rhythm_cell:
+ rhythm_cell.append(_q)
+ if len(rhythm_cell) >= 4:
+ break
+ rhythm_cell = rhythm_cell or None
+
+ for part, part_evts in evts.items():
+ if part in song_events and part_evts:
+ song_events[part].extend(_shift(part_evts, beat_offset))
+
+ section_results.append({
+ "name": sec_name, "section_type": sec_type,
+ "bars": sec_bars, "start_bar": total_bars, "key": sec_key,
+ "quality": round(sec_quality, 3) if sec_quality is not None else None,
+ })
+ ramp_sections.append({
+ "offset": beat_offset, "bars": sec_bars,
+ "dynamic": SECTION_PROFILES.get(sec_type, {}).get("velocity_scale", 1.0),
+ })
+ beat_offset += sec_bars * 4
+ total_bars += sec_bars
+
+ # Smooth velocity jumps at section boundaries (verse→chorus lift, etc.). This
+ # previously only ran inside a single _run_attempt's own internal auto-arc
+ # sections and never across the song builder's independently-generated
+ # sections, so every Verse→Chorus/Chorus→Bridge transition was a hard jump.
+ if len(ramp_sections) > 1:
+ _apply_section_ramp(song_events, ramp_sections)
+
+ # ── Intro hook tease ─────────────────────────────────────────────────────
+ # The intro previews the chorus melody: thinned to its structural notes,
+ # softer, and transposed back to the home key if the chorus modulates.
+ # Deterministic — derived entirely from the cached chorus theme.
+ if tease_intro and "melody" in song_events:
+ chorus_mel = type_theme.get("chorus", {}).get("melody") or []
+ if chorus_mel:
+ limit = tease_intro["bars"] * 4
+ in_window = [e for e in chorus_mel if e.start < limit - 0.05]
+ # Prefer the hook's structural notes for a clean preview.
+ thin = [e for e in in_window
+ if (e.start % 1.0) < 0.13 or e.duration >= 0.75]
+ # Commit to a real phrase or stay silent: a lone note (or two) reads
+ # as an accidental keypress, not a hook preview. Use the thinned hook
+ # only if it's a phrase; else tease the fuller line; if even that is
+ # just a note or two, leave the intro to the groove.
+ _PHRASE = 3
+ tease = (thin if len(thin) >= _PHRASE
+ else in_window if len(in_window) >= _PHRASE else [])
+ song_events["melody"].extend(
+ NoteEvent(min(127, max(0, e.pitch - chorus_theme_shift)), e.start,
+ min(e.duration, limit - e.start),
+ # Floor above the _drop_quiet threshold (20): a soft-
+ # style hook (lofi) scaled by 0.72 dips below it, and
+ # the mixdown would then cull the quiet notes back down
+ # to the lone stray this whole branch exists to avoid.
+ max(34, int(e.velocity * 0.72)), e.channel)
+ for e in tease
+ )
+
+ # ── Arrangement dynamics ──────────────────────────────────────────────────
+ # Dropouts and breakdowns (pre-chorus drop, bridge breakdown, thinned
+ # verse 2) — applied before the ending bar so the final cadence survives.
+ apply_arrangement_dynamics(song_events, section_results, base_seed,
+ dynamics=req.dynamics)
+ # Pickups run AFTER dynamics so they lead into melody that survived the
+ # dropouts (and can sing across a full-band stop).
+ apply_melodic_pickups(song_events, section_results, base_seed, req.scale, style)
+
+ # ── Ending variety ────────────────────────────────────────────────────────
+ # Every song used to end with the identical ring-out formula. Three seeded
+ # endings now: ring-out (the classic), cold stop (staccato final hit), and
+ # the hook-echo outro — the outro's own melody is replaced by thinned
+ # fragments of the chorus hook, fading, so the song looks BACK at its own
+ # idea on the way out instead of introducing fresh material.
+ _end_rng = random.Random(_part_seed(base_seed, 917, "ending"))
+ _ending_style = _end_rng.choices(["ring", "cold", "hook_echo"], weights=[0.45, 0.2, 0.35])[0]
+ _outro_sec = next((s for s in section_results if s.get("section_type") == "outro"), None)
+ if _ending_style == "hook_echo":
+ _hook = type_theme.get("chorus", {}).get("melody") or []
+ if _outro_sec and _hook and "melody" in song_events:
+ o_start = _outro_sec["start_bar"] * 4.0
+ o_beats = _outro_sec["bars"] * 4.0
+ frag = [e for e in sorted(_hook, key=lambda e: e.start) if e.start < 8.0]
+ thin = [e for e in frag if (e.start % 1.0) < 0.13 or e.duration >= 0.75] or frag
+ song_events["melody"] = [e for e in song_events["melody"]
+ if not (o_start - 0.1 <= e.start < o_start + o_beats)]
+ placements = [(o_start, 0.72)]
+ if o_beats >= 12:
+ placements.append((o_start + o_beats / 2, 0.5))
+ for _base, _velf in placements:
+ for e in thin:
+ t = _base + e.start
+ if t < o_start + o_beats - 0.25:
+ song_events["melody"].append(NoteEvent(
+ min(127, max(0, e.pitch - chorus_theme_shift)), t,
+ min(e.duration, o_start + o_beats - t),
+ max(1, int(e.velocity * _velf)), e.channel))
+ song_events["melody"].sort(key=lambda e: e.start)
+ else:
+ _ending_style = "ring" # nothing to echo — fall back gracefully
+
+ # ── Ending bar ────────────────────────────────────────────────────────────
+ # A real cadence instead of just stopping: the tonic chord, bass root, and a
+ # kick+crash land on one extra bar and ring out (the tempo map's ritardando
+ # covers this bar). Deterministic from base_seed so every regeneration flow
+ # reproduces identical ending events for untouched parts.
+ random.seed(_part_seed(base_seed, len(template), "ending"))
+ ending_start = float(total_bars * 4)
+ tonic_roman = "i" if req.scale in ("minor", "dorian", "phrygian", "harmonic_minor",
+ "pentatonic_minor", "blues", "locrian") else "I"
+ tonic = roman_to_chord(tonic_roman, req.key, req.scale, octave=4)
+ # Cold stop: the band hits the final chord staccato and it's over — no ring.
+ ring = 0.35 if _ending_style == "cold" else 4.0
+ if "chords" in song_events:
+ # Voice the final chord in the register the song's comp actually ended
+ # in (prev_voicing = the outro's closing voicing). The hardcoded
+ # octave-4 tonic sat a full octave above the melody-capped comp, so the
+ # very last bar leapt upward out of the song's register.
+ chord_tonic = sorted(tonic)
+ if prev_voicing:
+ _target = sum(prev_voicing) / len(prev_voicing)
+ _mean = sum(chord_tonic) / len(chord_tonic)
+ _oct_shift = min((-24, -12, 0, 12, 24), key=lambda o: abs(_mean + o - _target))
+ chord_tonic = [max(0, min(127, p + _oct_shift)) for p in chord_tonic]
+ for ni, p in enumerate(chord_tonic):
+ song_events["chords"].append(NoteEvent(
+ pitch=p, start=ending_start + ni * 0.012, duration=ring,
+ velocity=max(1, 84 - ni * 4), channel=0))
+ if "pads" in song_events and song_events.get("pads"):
+ for ni, p in enumerate(sorted(tonic)):
+ song_events["pads"].append(NoteEvent(
+ pitch=min(127, p + 12), start=ending_start, duration=ring,
+ velocity=56 - ni * 2, channel=4))
+ if "bass" in song_events:
+ song_events["bass"].append(NoteEvent(
+ pitch=max(0, tonic[0] - 24), start=ending_start, duration=ring,
+ velocity=92, channel=1))
+ # The melody deliberately does NOT restate a note on the ending bar: its
+ # line already resolved in the outro, and a lone root popping up over the
+ # final chord read as an accidental keypress. The cadence is the band's —
+ # chord + bass + kick/crash ringing out.
+ if "drums" in song_events:
+ song_events["drums"].append(NoteEvent(DRUM_MAP["kick"], ending_start, 0.1, 116, 9))
+ song_events["drums"].append(NoteEvent(DRUM_MAP["crash"], ending_start, ring, 104, 9))
+ section_results.append({
+ "name": "End", "section_type": "ending",
+ "bars": 1, "start_bar": total_bars, "key": req.key,
+ })
+ total_bars += 1
+
+ return song_events, section_results, total_bars, section_seeds, song_progression
+
+
+def _section_markers(section_results: list[dict], home_key: str) -> list[tuple[float, str]]:
+ """MIDI section markers for the DAW timeline; sections that modulate away
+ from the home key carry the key in the label (e.g. "Final Chorus (B)")."""
+ return [
+ (float(s["start_bar"] * 4),
+ s["name"] if s.get("key", home_key) == home_key else f"{s['name']} ({s['key']})")
+ for s in section_results
+ ]
+
+
+def _write_song_output(song_events: dict, output_dir, gen_id: str, bpm: int, style: dict,
+ programs: dict, parts: list[str], total_bars: int,
+ section_results: list[dict], key: str = "C",
+ scale: str = "minor") -> list[FileInfo]:
+ """Write every stem + song.mid for a built song (CC, pitch bends, tempo map).
+
+ Shared by build_song and regenerate_song_section so both produce identical
+ file layouts. The tempo map (chorus push + ending ritardando) is written
+ into every stem so they stay sample-locked in any DAW; section markers and
+ the key signature go into song.mid so DAW timelines mirror the app's.
+ """
+ song_cc: dict[str, list] = {}
+ for part in parts:
+ if part == "drums" or not song_events.get(part):
+ continue
+ channel = _PART_CHANNELS.get(part, 0)
+ song_cc[part] = _generate_part_cc(part, total_bars, channel, style=style)
+
+ if song_events.get("melody"):
+ ch = _PART_CHANNELS.get("melody", 2)
+ song_cc.setdefault("melody", []).extend(
+ _generate_melody_expression_cc(song_events["melody"], ch)
+ )
+
+ # Section-level automation: pre-chorus filter sweeps + crescendo into the chorus.
+ for automation in (generate_build_sweeps(section_results, parts),
+ generate_section_crescendo(section_results, parts)):
+ for part, evs in automation.items():
+ if song_events.get(part):
+ song_cc.setdefault(part, []).extend(evs)
+
+ song_pb: dict[str, list] = {}
+ if song_events.get("bass") and style.get("bass", {}).get("bass_style") == "808":
+ ch = _PART_CHANNELS.get("bass", 1)
+ song_pb["bass"] = _generate_808_pitch_bends(song_events["bass"], ch)
+
+ tempo_map = _song_tempo_map(section_results, bpm, ending_bars=1)
+ _, track_names = part_midi_meta(style)
+
+ files: list[FileInfo] = []
+ _sid = style.get("id", "")
+ for part, evts in song_events.items():
+ if not evts:
+ continue
+ clean = _drop_quiet(_scale_velocity(evts, part, _sid))
+ if not clean:
+ continue
+ fname = f"{part}.mid"
+ write_midi(clean, output_dir / fname, bpm=bpm, program=programs.get(part),
+ cc_events=song_cc.get(part), pb_events=song_pb.get(part),
+ tempo_events=tempo_map, track_name=track_names.get(part))
+ files.append(FileInfo(part=part, filename=fname, url=f"/exports/{gen_id}/{fname}"))
+
+ if len([p for p, e in song_events.items() if e]) > 1:
+ clean_all = {p: _drop_quiet(_scale_velocity(e, p, _sid)) for p, e in song_events.items() if e}
+ write_combined_midi(clean_all, output_dir / "song.mid", bpm=bpm, programs=programs,
+ cc_parts=song_cc, pb_parts=song_pb, tempo_events=tempo_map,
+ markers=_section_markers(section_results, key),
+ key_signature=mido_key_signature(key, scale),
+ track_names=track_names)
+ files.append(FileInfo(part="song", filename="song.mid", url=f"/exports/{gen_id}/song.mid"))
+ return files
+
+
+_MAJOR_FAMILY_SCALES = ("major", "mixolydian", "lydian", "pentatonic_major")
+
+
+def _bridge_escape_progression(song_progression: list, scale: str) -> tuple[list, str]:
+ """A bridge grammar that starts somewhere the song hasn't been and walks home
+ (roadmap-2 item 5). Opens on a diatonic chord absent from the verse/chorus
+ loop (vi if the song is I-heavy, ♭VI as the deceptive option in minor), takes
+ a bar of departure, then a dominant-pedal bar that pulls back into the return.
+
+ Returns (progression, opening_chord). The caller decides (seeded) whether to
+ use it, so half of songs keep today's bridge sound."""
+ used = set(song_progression)
+ if scale in _MAJOR_FAMILY_SCALES:
+ openers, mid, dom = ["vi", "IV", "ii", "iii"], "ii", "V"
+ else:
+ openers, mid, dom = ["bVI", "iv", "bII", "bVII"], "iv", "V"
+ opener = next((c for c in openers if c not in used), openers[0])
+ if mid == opener:
+ mid = dom
+ return [opener, mid, dom, dom], opener
diff --git a/backend/requirements-lock.txt b/backend/requirements-lock.txt
index 65dd3fd..12cfea3 100644
--- a/backend/requirements-lock.txt
+++ b/backend/requirements-lock.txt
@@ -7,8 +7,10 @@ click==8.3.2
fastapi==0.139.0
h11==0.16.0
httpcore==1.0.9
+httpcore2==2.9.1
httptools==0.8.0
httpx==0.28.1
+httpx2==2.9.1
idna==3.18
iniconfig==2.3.0
mido==1.3.3
@@ -27,6 +29,7 @@ PyYAML==6.0.3
ruff==0.15.22
setuptools==83.0.0
starlette==1.3.1
+truststore==0.10.4
typing-inspection==0.4.2
typing_extensions==4.15.0
uvicorn==0.50.0
diff --git a/backend/requirements.txt b/backend/requirements.txt
index f38fb88..2f3810c 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -5,7 +5,9 @@ mido>=1.3.2
python-multipart>=0.0.32
pytest>=8.2.0
pytest-asyncio>=0.23.7
-httpx>=0.27.0
+# httpx2 (not httpx): Starlette 1.3+ TestClient prefers httpx2 and warns on plain
+# httpx as deprecated. Test-only dependency (nothing imports httpx at runtime).
+httpx2>=2.0.0
# Security floors for transitive deps (patched versions — see Dependabot alerts)
starlette>=1.3.1
diff --git a/backend/tests/test_grooves.py b/backend/tests/test_grooves.py
index 66926e2..d90c85c 100644
--- a/backend/tests/test_grooves.py
+++ b/backend/tests/test_grooves.py
@@ -56,7 +56,7 @@ def test_groove_overlay_and_runtime(tmp_path, monkeypatch):
"""A groove prior should overlay the style's drums and drive live generation."""
import app.services.priors as priors
from app.services.priors import groove_fields_for
- from app.api.routes_generate import _overlay_groove
+ from app.services.generation import _overlay_groove
from app.models.schemas import GenerateRequest
from app.api.routes_generate import generate
diff --git a/backend/tests/test_musicality.py b/backend/tests/test_musicality.py
index f71919e..a5145cb 100644
--- a/backend/tests/test_musicality.py
+++ b/backend/tests/test_musicality.py
@@ -304,7 +304,7 @@ def test_dj_edit_adds_beat_only_bookends():
def test_bridge_escape_opens_off_path_and_walks_home():
- from app.api.routes_song import _bridge_escape_progression
+ from app.services.song_builder import _bridge_escape_progression
# I-heavy major song → bridge opens on vi (unused), ends on a dominant pedal.
prog, opener = _bridge_escape_progression(["I", "IV", "V", "I"], "major")
diff --git a/backend/tests/test_progression_mode.py b/backend/tests/test_progression_mode.py
index c245431..8d18b4f 100644
--- a/backend/tests/test_progression_mode.py
+++ b/backend/tests/test_progression_mode.py
@@ -20,7 +20,8 @@
import json
from pathlib import Path
-from app.api.routes_generate import _choose_progression, _scale_mode, _template_tonic_mode
+from app.services.generation import _choose_progression, _template_tonic_mode
+from app.theory.scales import scale_mode as _scale_mode
STYLES_DIR = Path(__file__).parent.parent / "app" / "styles"
@@ -79,7 +80,7 @@ def test_no_shipped_style_has_mixed_tonic_templates():
def test_resolve_avoid_notes():
- from app.api.routes_generate import _resolve_avoid_notes
+ from app.services.generation import _resolve_avoid_notes
from app.services.midi_writer import NoteEvent
c_minor_pcs = {0, 2, 3, 5, 7, 8, 10}
diff --git a/backend/tests/test_song.py b/backend/tests/test_song.py
index e8b6805..9f3bf31 100644
--- a/backend/tests/test_song.py
+++ b/backend/tests/test_song.py
@@ -42,7 +42,7 @@ def test_regenerate_song_part_isolates_the_target():
def test_recurring_sections_reuse_the_theme():
"""Verse 2 reuses Verse's theme (with light variation); drums stay fresh."""
from app.services.style_loader import load_style
- from app.api.routes_song import _generate_song_sections
+ from app.services.song_builder import _generate_song_sections
req = BuildSongRequest(style_id="lofi", key="C", scale="major", bpm=90,
template="verse_chorus", parts=["chords", "bass", "melody", "drums"],
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 630972b..c81b569 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -6,9 +6,11 @@ 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-23 — Phase 1 complete. Phase 2 complete except for desktop runtime
-testing of the custom-instruments MVP: limiter, per-style FX, loudness norm, velocity-layer
-engine, license compliance, and the sample re-sourcing pass have all landed._
+_Last updated: 2026-07-28 — Phase 1 & 2 complete (Phase 2 bar the manual custom-instruments
+desktop pass). Phase 3 well underway: the "parallelize generation" item was retired as a
+phantom (generation is ~0.5s; the real wait is the WAV export render, now parallelised),
+deprecation warnings cleared, swallowed exceptions logged, and the two route god-files
+split into `services/`. Remaining Phase 3: split `useMidiPlayer.ts`, grow frontend tests._
---
@@ -131,19 +133,76 @@ engine, license compliance, and the sample re-sourcing pass have all landed._
## Phase 3 — Performance & refactor
-- [ ] **Parallelize full-song section generation** — song builds take ~1–2 min and
- sections generate serially though they're embarrassingly parallel. SSE progress
- plumbing (`generate-stream`) already exists.
+- [-] **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
+ (`_do_build_song`, every style, up to max complexity / 12-section templates), not the
+ claimed "~1–2 min". Building a song never renders audio — it calls the backend (~0.5s)
+ then plays back **live**, so the "1–2 min" was the song's own playback length (a 56-bar
+ song at 120 BPM *is* ~112s of audio), not generation time. Parallelizing a sub-second
+ step would *regress* latency (process-pool spawn + pickling > the serial work) and, per
+ the dependency audit, isn't even embarrassingly parallel: the section loop threads real
+ cross-section state (`prev_voicing` seam voice-leading, `verse_motif`/`rhythm_cell`
+ motif glue, `type_theme` repeat reuse) and generation uses the process-global `random`
+ module (17 `random.seed()` calls) so threads can't run it deterministically. The only
+ 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`
-- [ ] **Extract song/arrangement logic out of route god-files into `services/`** —
- handlers should be thin. → `routes_song.py` (1498), `routes_generate.py` (1299)
+- [~] **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`
+ shims, `render(true)` yields). **The bottleneck is per-note voice synthesis (~92%), NOT the
+ reverb (~8%)** — so caching the convolution IR is nearly worthless (an earlier guess, now
+ disproven by measurement).
+ **Shipped — parallel per-part render.** Empirically, concurrent `OfflineAudioContext`
+ renders run on separate Chromium threads (probe: 4 contexts, 9.2s serial → 3.7s parallel,
+ **2.48× on 8 cores**), so `offlineRender` now renders each part on its own context via
+ `Promise.all` instead of one big context. The offline graph has no shared reverb — every
+ voice runs its own delay/chorus/filter into a plain gain — so the per-part outputs SUM to
+ exactly the old pre-limiter mix; the master soft-clip limiter is then applied once to the
+ sum in a raw-WaveShaper pass that reproduces `makeMasterLimiter` bit-for-bit (curve =
+ `softClipCurve` sampled Tone's way, `oversample='4x'`). Result is mathematically identical
+ to the old single-pass render (bar float summation order), just parallelised. A single-part
+ 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.
+ 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/`** —
+ 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
+ the **song arrangement engine** (`_generate_song_sections` — the 465-line section loop —
+ plus its motif/voice-leading helpers, the combined-MIDI writer, and bridge-escape) →
+ **`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
+ 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`** (1018 lines) into focused composables.
-- [ ] **Log swallowed exceptions** — 27 broad/bare `except`; several return silently
- (e.g. `record_export_keep`). Add logging.
+- [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),
+ library keep-merge on a corrupt prior entry (`library.py`, debug), style-load for track
+ names (`routes_song.py` ×2, warning), and the arpeggio chord-tone derivation
+ (`routes_generate.py`, debug). The rest were left deliberately: `mido_key_signature`'s
+ `except` is by-design control flow (exotic modes have no MIDI key sig, documented), and
+ the generator inner-loop `except`s (bass/melody/quality/answer/corpus) are intentional
+ musical graceful-degradation, not swallowed faults — logging each would be pure noise.
- [ ] **Grow frontend test coverage** — 12 tests vs 132 backend; the audio scheduler and
- WAV-render path (most fragile code) are untested.
-- [ ] **Clear deprecation warnings** — Starlette `TestClient` httpx warning; Electron
- numeric-level `console-message` signature. → `frontend/electron/main.ts:206`
+ WAV-render path (most fragile code) are untested. _(Up to 96 frontend tests now — the
+ WAV-render mix path gained `offlineMix.test.ts` — but the scheduler is still untested.)_
+- [x] **Clear deprecation warnings** — **Starlette `TestClient`**: moved the test dep from
+ `httpx` to `httpx2` (`requirements.txt`); TestClient prefers httpx2 and the
+ `StarletteDeprecationWarning` is gone, 135 backend tests still green. **Electron
+ `console-message`**: rewrote the handler for the Electron 36+ single-`details` signature
+ (string `level` instead of the deprecated positional numeric level — which the old code
+ also mapped wrong). → `frontend/electron/main.ts`
## Phase 4 — Features
@@ -160,6 +219,28 @@ engine, license compliance, and the sample re-sourcing pass have all landed._
## Done log
+- **2026-07-28 — Phase 3 performance & refactor (first pass):**
+ - **Retired "parallelize section generation"** — measured generation at **0.5–0.8s**
+ (not the claimed 1–2 min), so parallelising it would only add process overhead. The real
+ "1–2 min" is the on-demand **WAV export render** (~56s, headless-Chromium bench).
+ - **Parallel per-part WAV export** — `offlineRender` now renders each part on its own
+ `OfflineAudioContext` concurrently (Chromium runs them on separate threads — measured
+ 2.48× on 8 cores), sums them, and applies the master limiter once in a raw-WaveShaper
+ pass that reproduces `makeMasterLimiter` bit-for-bit. Mathematically identical to the old
+ single-pass mix. Pure mix logic unit-tested (`offlineMix.test.ts`); real-browser e2e
+ produced valid audio. Desktop wall-time number still to confirm.
+ - **Auto-pause playback on WAV export** — the render walks the timeline on the main thread
+ and starved the live Transport scheduler (playback froze mid-export); `offlineRender` now
+ pauses playback for the duration (MIDI export was never affected).
+ - **Route god-files split into `services/`** — generation core → `services/generation.py`,
+ song arrangement engine → `services/song_builder.py`. `routes_generate.py` 1299→636,
+ `routes_song.py` 1498→889; killed the route→route generation import.
+ - **Deprecation warnings cleared** — test dep `httpx`→`httpx2` (Starlette TestClient);
+ Electron `console-message` rewritten for the 36+ single-`details` signature.
+ - **Swallowed exceptions logged** — 6 genuine silent faults now log (`record_export_keep`,
+ malformed priors, style-load for track names, …); by-design fallbacks left alone.
+ - Tests: backend 135 green + ruff clean; frontend 92→96 green + `vue-tsc`/eslint clean.
+
- **2026-07-23 — Phase 1 security hardening** (uncommitted working changes):
- Export downloads hardened against path traversal (`_safe_export_dir` containment
check on `/exports/{gen_id}/bundle.zip`, `/sections.zip`, and `/{filename}`).
diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts
index 12e0807..039c317 100644
--- a/frontend/electron/main.ts
+++ b/frontend/electron/main.ts
@@ -230,9 +230,13 @@ async function createWindow(): Promise {
// ── DEBUG: mirror the whole renderer console into the main-process stdout, so the full
// renderer log (audio setup, errors, everything) shows in the terminal without DevTools.
- win.webContents.on('console-message', (_e, level, message) => {
- const tag = ['LOG', 'WARN', 'ERR', 'INFO'][level] ?? 'LOG'
- console.log(`[renderer:${tag}] ${message}`)
+ // Electron 36+ replaced the positional (event, level:number, message, …) signature with a
+ // single details object whose `level` is a string — the old form is deprecated. Map the
+ // string severity to a short tag (the old numeric mapping was also wrong: 0-3 is
+ // verbose/info/warning/error, not the log/warn/err/info it assumed).
+ win.webContents.on('console-message', (details) => {
+ const tag = ({ info: 'INFO', warning: 'WARN', error: 'ERR', debug: 'LOG' } as const)[details.level] ?? 'LOG'
+ console.log(`[renderer:${tag}] ${details.message}`)
})
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
console.error(`[main] renderer FAILED to load: code=${code} "${desc}" url=${url}`)
diff --git a/frontend/scripts/bench_render.mjs b/frontend/scripts/bench_render.mjs
new file mode 100644
index 0000000..86780db
--- /dev/null
+++ b/frontend/scripts/bench_render.mjs
@@ -0,0 +1,154 @@
+/*
+ * GenreGrid — WAV-export render benchmark.
+ *
+ * Measures how long an OfflineAudioContext takes to render a full song's worth
+ * of synth-only audio — the real cost behind the "1-2 min" WAV export wait (song
+ * *generation* is ~0.5s; see docs/roadmap.md). Runs in headless Chromium driven
+ * over CDP, so it needs no X display and no Playwright JS package — only the
+ * cached Chromium binary (Playwright's, or set CHROME_BIN) and Node >= 20.
+ *
+ * Usage: node scripts/bench_render.mjs
+ * Output: RESULT {"withVerbMs":..,"noVerbMs":..,"notes":..,"songSeconds":..}
+ *
+ * The graph mirrors offlineRender()'s: per-note oscillator+gain voices through a
+ * convolution reverb + chorus + feedback delay + waveshaper limiter. Rendering
+ * with FX vs. without isolates the reverb's share (measured ~8%): the dominant
+ * cost is the per-note voice synthesis, so the optimization lever is timeline
+ * parallelism (chunked Web Worker renders), not caching the reverb IR.
+ *
+ * Copyright (C) 2026 Tw Dover. GPL-3.0-or-later; NO WARRANTY.
+ * See .
+ */
+import { spawn } from 'node:child_process';
+import { globSync } from 'node:fs';
+import os from 'node:os';
+
+function findChrome() {
+ if (process.env.CHROME_BIN) return process.env.CHROME_BIN;
+ const patterns = [
+ `${os.homedir()}/.cache/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-linux64/chrome-headless-shell`,
+ `${os.homedir()}/.cache/ms-playwright/chromium-*/chrome-linux64/chrome`,
+ ];
+ for (const p of patterns) {
+ const hits = globSync(p).sort();
+ if (hits.length) return hits[hits.length - 1];
+ }
+ throw new Error('No Chromium found. Set CHROME_BIN or install Playwright browsers.');
+}
+
+const PORT = 9333;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+const chrome = spawn(findChrome(), [
+ '--headless', '--disable-gpu', '--no-sandbox', '--mute-audio',
+ `--remote-debugging-port=${PORT}`, 'about:blank',
+], { stdio: ['ignore', 'ignore', 'ignore'] });
+
+async function getWsUrl() {
+ for (let i = 0; i < 100; i++) {
+ try {
+ const targets = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
+ const page = targets.find(t => t.type === 'page' && t.webSocketDebuggerUrl);
+ if (page) return page.webSocketDebuggerUrl;
+ } catch { /* not up yet */ }
+ await sleep(100);
+ }
+ throw new Error('CDP endpoint never came up');
+}
+
+const RENDER_EXPR = `(async () => {
+ const BPM = 120, BARS = 56, SR = 44100;
+ const seconds = BARS * 4 * 60 / BPM; // 56 bars @120 = 112s of audio
+ const total = seconds + 2.5; // pad tail
+
+ function buildNotes() {
+ const notes = [];
+ const push = (t, dur, freq, vel) => notes.push({ t, dur, freq, vel });
+ for (let b = 0; b < BARS * 4; b++) {
+ const bt = b * 60 / BPM;
+ push(bt, 0.45, 55 * Math.pow(2, (b % 5) / 12), 0.9); // bass
+ if (b % 2 === 0) for (const iv of [0, 4, 7]) push(bt, 0.9, 220 * Math.pow(2, iv / 12), 0.5); // chords
+ for (let s = 0; s < 2; s++) push(bt + s * 0.5, 0.4, 440 * Math.pow(2, ((b + s) % 8) / 12), 0.7); // melody
+ for (let s = 0; s < 4; s++) push(bt + s * 0.25, 0.2, 660 * Math.pow(2, (s % 4) / 12), 0.5); // arp
+ if (b % 4 === 0) for (const iv of [0, 7]) push(bt, 3.5, 330 * Math.pow(2, iv / 12), 0.35); // pads
+ if (b % 2 === 1) push(bt, 0.5, 550 * Math.pow(2, (b % 6) / 12), 0.5); // counter-melody
+ push(bt, 0.12, 60, 1.0); push(bt + 0.5, 0.05, 8000, 0.4); // drums
+ }
+ return notes;
+ }
+ function makeIR(ctx, decay) {
+ const len = Math.floor(SR * decay), ir = ctx.createBuffer(2, len, SR);
+ for (let ch = 0; ch < 2; ch++) {
+ const d = ir.getChannelData(ch);
+ for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.5);
+ }
+ return ir;
+ }
+ async function render(withVerb) {
+ const ctx = new OfflineAudioContext(2, Math.ceil(total * SR), SR);
+ const master = ctx.createGain(); master.gain.value = 0.8;
+ const ws = ctx.createWaveShaper(), curve = new Float32Array(1024);
+ for (let i = 0; i < 1024; i++) { const x = (i / 512) - 1; curve[i] = Math.tanh(x * 1.2); }
+ ws.curve = curve; master.connect(ws).connect(ctx.destination);
+ const dry = ctx.createGain(); dry.connect(master);
+ let busIn = dry;
+ if (withVerb) {
+ const pre = ctx.createGain(); pre.connect(dry);
+ const chorusDelay = ctx.createDelay(); chorusDelay.delayTime.value = 0.025;
+ const lfo = ctx.createOscillator(); lfo.frequency.value = 1.5;
+ const lfoGain = ctx.createGain(); lfoGain.gain.value = 0.008;
+ lfo.connect(lfoGain).connect(chorusDelay.delayTime); lfo.start(0);
+ pre.connect(chorusDelay); chorusDelay.connect(master);
+ const fbDelay = ctx.createDelay(); fbDelay.delayTime.value = 0.375;
+ const fb = ctx.createGain(); fb.gain.value = 0.3;
+ fbDelay.connect(fb).connect(fbDelay); pre.connect(fbDelay); fbDelay.connect(master);
+ const conv = ctx.createConvolver(); conv.buffer = makeIR(ctx, 2.5);
+ const wet = ctx.createGain(); wet.gain.value = 0.3;
+ pre.connect(conv).connect(wet).connect(master);
+ busIn = pre;
+ }
+ const notes = buildNotes();
+ for (const n of notes) {
+ const o = ctx.createOscillator();
+ o.type = n.freq > 3000 ? 'square' : 'sawtooth'; o.frequency.value = n.freq;
+ const g = ctx.createGain();
+ g.gain.setValueAtTime(0, n.t);
+ g.gain.linearRampToValueAtTime(n.vel * 0.25, n.t + 0.01);
+ g.gain.exponentialRampToValueAtTime(0.0001, n.t + n.dur);
+ o.connect(g).connect(busIn); o.start(n.t); o.stop(n.t + n.dur + 0.02);
+ }
+ const t0 = performance.now();
+ await ctx.startRendering();
+ return { ms: Math.round(performance.now() - t0), notes: notes.length };
+ }
+ const a = await render(true), b = await render(false);
+ return { withVerbMs: a.ms, noVerbMs: b.ms, notes: a.notes, songSeconds: Math.round(seconds) };
+})()`;
+
+let msgId = 0;
+function send(ws, method, params) {
+ return new Promise((resolve, reject) => {
+ const id = ++msgId;
+ const onMsg = ev => {
+ const m = JSON.parse(ev.data);
+ if (m.id === id) { ws.removeEventListener('message', onMsg); m.error ? reject(new Error(m.error.message)) : resolve(m.result); }
+ };
+ ws.addEventListener('message', onMsg);
+ ws.send(JSON.stringify({ id, method, params }));
+ });
+}
+
+try {
+ const ws = new WebSocket(await getWsUrl());
+ await new Promise((res, rej) => { ws.addEventListener('open', res); ws.addEventListener('error', rej); });
+ await send(ws, 'Runtime.enable', {});
+ const r = await send(ws, 'Runtime.evaluate', { expression: RENDER_EXPR, awaitPromise: true, returnByValue: true });
+ if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails));
+ console.log('RESULT', JSON.stringify(r.result.value));
+ ws.close();
+} catch (e) {
+ console.log('ERROR', e.message);
+ process.exitCode = 1;
+} finally {
+ chrome.kill('SIGKILL');
+}
diff --git a/frontend/src/composables/offlineMix.test.ts b/frontend/src/composables/offlineMix.test.ts
new file mode 100644
index 0000000..6ae8a7d
--- /dev/null
+++ b/frontend/src/composables/offlineMix.test.ts
@@ -0,0 +1,67 @@
+/*
+ * 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.
+ */
+import { describe, it, expect } from 'vitest'
+import { sumPartBuffers } from './useMidiPlayer'
+
+// Minimal stand-in for the parts of AudioBuffer sumPartBuffers reads. (jsdom has no
+// Web Audio, and the summing logic is pure, so a fake is enough to test the maths.)
+function fakeBuffer(channels: number[][], sampleRate = 44100) {
+ return {
+ length: channels[0].length,
+ sampleRate,
+ numberOfChannels: channels.length,
+ getChannelData: (c: number) => Float32Array.from(channels[c]),
+ }
+}
+
+describe('sumPartBuffers', () => {
+ it('sums stereo parts sample-by-sample per channel', () => {
+ const a = fakeBuffer([[0.1, 0.2, 0.3], [-0.1, -0.2, -0.3]])
+ const b = fakeBuffer([[0.4, 0.4, 0.4], [0.05, 0.05, 0.05]])
+ const { length, sampleRate, channels } = sumPartBuffers([a, b])
+ expect(length).toBe(3)
+ expect(sampleRate).toBe(44100)
+ expect(Array.from(channels[0])).toEqual([
+ expect.closeTo(0.5), expect.closeTo(0.6), expect.closeTo(0.7),
+ ])
+ expect(Array.from(channels[1])).toEqual([
+ expect.closeTo(-0.05), expect.closeTo(-0.15), expect.closeTo(-0.25),
+ ])
+ })
+
+ it('is the identity for a single part (parallel path === single-pass pre-limiter mix)', () => {
+ const only = fakeBuffer([[0.2, -0.4, 0.6], [0.1, -0.2, 0.3]])
+ const { channels } = sumPartBuffers([only])
+ expect(Array.from(channels[0])).toEqual([
+ expect.closeTo(0.2), expect.closeTo(-0.4), expect.closeTo(0.6),
+ ])
+ expect(Array.from(channels[1])).toEqual([
+ expect.closeTo(0.1), expect.closeTo(-0.2), expect.closeTo(0.3),
+ ])
+ })
+
+ it('upmixes a mono part to both output channels', () => {
+ const mono = fakeBuffer([[1, 1, 1]]) // one channel only
+ const { channels } = sumPartBuffers([mono])
+ expect(Array.from(channels[0])).toEqual([1, 1, 1])
+ expect(Array.from(channels[1])).toEqual([1, 1, 1]) // mono fed to R too
+ })
+
+ it('handles parts of differing lengths (mix spans the longest)', () => {
+ const long = fakeBuffer([[0.1, 0.1, 0.1, 0.1], [0, 0, 0, 0]])
+ const short = fakeBuffer([[0.5, 0.5], [0.5, 0.5]])
+ const { length, channels } = sumPartBuffers([long, short])
+ expect(length).toBe(4)
+ expect(Array.from(channels[0])).toEqual([
+ expect.closeTo(0.6), expect.closeTo(0.6), expect.closeTo(0.1), expect.closeTo(0.1),
+ ])
+ })
+})
diff --git a/frontend/src/composables/useMidiPlayer.ts b/frontend/src/composables/useMidiPlayer.ts
index cc04ae3..46d67ec 100644
--- a/frontend/src/composables/useMidiPlayer.ts
+++ b/frontend/src/composables/useMidiPlayer.ts
@@ -12,7 +12,7 @@ import { ref } from 'vue'
import * as Tone from 'tone'
import { Midi } from '@tonejs/midi'
import { downloadUrl } from '../services/api'
-import { getPianoSampler, getMasterLimiterNode, getBassBus, getMelodicBus, getDrumBus, duckOnKick, resetBusLevels, makeMasterLimiter, applyMelodicFxPreset, setMasterTrimDb } from '../soundfonts/loader'
+import { getPianoSampler, getMasterLimiterNode, getBassBus, getMelodicBus, getDrumBus, duckOnKick, resetBusLevels, makeMasterLimiter, softClipCurve, applyMelodicFxPreset, setMasterTrimDb } from '../soundfonts/loader'
import { MELODIC_FX_PRESETS, MASTER_TRIM_DB, fxFamilyFor } from '../soundfonts/fxPresets'
import { drumCharacterForStyle } from '../soundfonts/drums'
import { makeSynthKit } from '../soundfonts/synthDrums'
@@ -291,6 +291,64 @@ async function renderOfflineFast(
return await context.render(true)
}
+// Does the parsed MIDI carry any notes for a given part? Mirrors the render's own
+// channel→voice routing (percussion / ch9 → drums; everything else via CHANNEL_PART,
+// with unknown non-perc channels falling through to chords like the scheduler does).
+// Used to decide which parts to spin up as parallel per-part renders.
+function partHasNotes(midi: Midi, part: PlayerPart): boolean {
+ return midi.tracks.some(t => {
+ if (t.notes.length === 0) return false
+ const perc = t.instrument.percussion || (t.channel ?? 0) === 9
+ const p = perc ? 'drums' : CHANNEL_PART[t.channel ?? 0] ?? 'chords'
+ return p === part
+ })
+}
+
+// Sum several rendered part buffers into one stereo pair. Pure (no Web Audio), so it's
+// unit-testable. The per-part offline graphs never share nodes, so summing their outputs
+// reproduces exactly the signal the single-pass graph summed at its master bus — the
+// pre-limiter mix. Exported for the mix-identity test.
+export function sumPartBuffers(
+ buffers: Array>,
+): { length: number; sampleRate: number; channels: [Float32Array, Float32Array] } {
+ const length = buffers.reduce((m, b) => Math.max(m, b.length), 0)
+ const sampleRate = buffers[0]?.sampleRate ?? 44100
+ const L = new Float32Array(length)
+ const R = new Float32Array(length)
+ for (const b of buffers) {
+ const bl = b.getChannelData(0)
+ const br = b.numberOfChannels > 1 ? b.getChannelData(1) : bl
+ for (let i = 0; i < b.length; i++) { L[i] += bl[i]; R[i] += br[i] }
+ }
+ return { length, sampleRate, channels: [L, R] }
+}
+
+// Sum the parallel per-part renders and apply the master soft-clip limiter ONCE, in a
+// raw Web-Audio pass. The limiter is non-linear, so it must run on the summed mix, not
+// per part. A raw WaveShaperNode reproduces makeMasterLimiter exactly: Tone samples the
+// `mapping` over [-1, 1] into `length` points (curve[i] = softClipCurve(i/(len-1)*2-1))
+// and sets oversample '4x' — so the parallel path's output is bit-for-bit the single-
+// pass path's, minus float summation order (inaudible).
+async function limitMix(
+ buffers: Array>,
+): Promise {
+ const { length, sampleRate, channels } = sumPartBuffers(buffers)
+ const ctx = new OfflineAudioContext(2, length, sampleRate)
+ const mix = ctx.createBuffer(2, length, sampleRate)
+ mix.copyToChannel(channels[0], 0)
+ mix.copyToChannel(channels[1], 1)
+ const src = ctx.createBufferSource()
+ src.buffer = mix
+ const ws = ctx.createWaveShaper()
+ const curve = new Float32Array(4096)
+ for (let i = 0; i < 4096; i++) curve[i] = softClipCurve((i / 4095) * 2 - 1)
+ ws.curve = curve
+ ws.oversample = '4x'
+ src.connect(ws).connect(ctx.destination)
+ src.start(0)
+ return await ctx.startRendering()
+}
+
export function useMidiPlayer() {
async function toggle(url: string, styleId?: string, label?: string) {
if (currentlyPlaying.value === url) {
@@ -678,6 +736,16 @@ export function useMidiPlayer() {
): Promise {
isRendering.value = true
onProgress?.(0.02)
+ // A WAV render walks the song timeline on the MAIN thread (Tone's render clock, plus
+ // the per-part sum + limiter passes), which starves the live Transport's main-thread
+ // lookahead scheduler and makes playback stutter/freeze mid-export. Pause live playback
+ // for the duration: the render is offline and position-independent, so pausing doesn't
+ // change the exported audio, and playback holds its spot to resume from. (MIDI export
+ // does no audio work, so it never had this problem.)
+ if (currentlyPlaying.value !== null && !isPaused.value) {
+ Tone.getTransport().pause()
+ isPaused.value = true
+ }
try {
const fetchUrl = url.startsWith('blob:') || url.startsWith('data:') ? url : downloadUrl(url)
const buf = await fetch(fetchUrl).then(r => r.arrayBuffer())
@@ -723,15 +791,26 @@ export function useMidiPlayer() {
)
})
- const renderPromise = renderOfflineFast(async (context) => {
+ // Build the synth graph for a subset of parts. `wants(p)` picks which parts this
+ // pass renders; `withLimiter` puts the master soft-clip limiter inline. The
+ // parallel path renders each part on its OWN OfflineAudioContext WITHOUT the
+ // limiter (Chromium bounces them on separate threads) and limits the summed mix
+ // once afterwards; a single-part stem render keeps the limiter inline because its
+ // output already IS the final mix. See the orchestration below.
+ const buildGraph = async (context: Tone.OfflineContext, wants: (p: PlayerPart) => boolean, withLimiter: boolean) => {
const dest = context.destination
dest.volume.value = 0
// Plain gain, not a Tone.Compressor — a DynamicsCompressorNode renders silence on
// Linux packaged Electron (see getMasterCompressor in loader.ts), which would make
// WAV exports silent there too. A WaveShaper soft-clip limiter after it catches
// peaks so the exported WAV can't clip, matching live playback's master chain.
- const limiter = makeMasterLimiter(context).connect(dest)
- const comp = new Tone.Gain({ context, gain: 1 }).connect(limiter)
+ let busOut: Tone.ToneAudioNode = dest
+ if (withLimiter) {
+ const limiter = makeMasterLimiter(context)
+ limiter.connect(dest)
+ busOut = limiter
+ }
+ const comp = new Tone.Gain({ context, gain: 1 }).connect(busOut)
// Only needed so Transport-relative delay-time notation ('8n', '4n', …)
// in the effects below resolves against the song's real tempo — no note
@@ -744,13 +823,13 @@ export function useMidiPlayer() {
// ── Drum kit ─────────────────────────────────────────────────────
// Same synthesized engine as live playback, routed to the offline
// compressor so the export matches what the user auditions.
- const drumKit = wantsPart('drums')
+ const drumKit = wants('drums')
? makeSynthKit(drumCharacterForStyle(styleId), comp, context)
: null
// ── Bass synth ───────────────────────────────────────────────────
let bassSynth: Tone.MonoSynth | null = null
- if (wantsPart('bass')) {
+ if (wants('bass')) {
bassSynth = new Tone.MonoSynth({
context,
oscillator: { type: 'sawtooth' },
@@ -909,11 +988,11 @@ export function useMidiPlayer() {
midi.tracks.some(t => (t.channel ?? 0) === ch && t.notes.length > 0)
// Only built when wanted AND the file actually carries the part — keeps
// the offline graph light for single-stem renders.
- if (wantsPart('chords')) chordsSynth = mkVoice('chords', panForChannel(0))
- if (wantsPart('melody')) leadSynth = mkVoice('lead', panForChannel(2))
- if (wantsPart('arpeggio')) arpSynth = mkVoice('arp', panForChannel(3))
- if (wantsPart('pads') && hasTrackOn(4)) padsSynth = mkVoice('pads', panForChannel(4))
- if (wantsPart('counter_melody') && hasTrackOn(5)) stringsSynth = mkVoice('strings', panForChannel(5))
+ if (wants('chords')) chordsSynth = mkVoice('chords', panForChannel(0))
+ if (wants('melody')) leadSynth = mkVoice('lead', panForChannel(2))
+ if (wants('arpeggio')) arpSynth = mkVoice('arp', panForChannel(3))
+ if (wants('pads') && hasTrackOn(4)) padsSynth = mkVoice('pads', panForChannel(4))
+ if (wants('counter_melody') && hasTrackOn(5)) stringsSynth = mkVoice('strings', panForChannel(5))
// ── Schedule MIDI events ─────────────────────────────────────────
// Triggered directly at each note's absolute time (seconds from render
@@ -980,25 +1059,54 @@ export function useMidiPlayer() {
lastTime = time
t.fn(time)
}
- }, totalDuration, 2)
-
- // If the timeout wins the race, the render keeps running in the background
- // (an OfflineAudioContext can't be cancelled) — swallow its eventual
- // settlement so a late rejection doesn't surface as an unhandled promise.
- renderPromise.catch(() => {})
+ }
- let toneBuffer: Awaited
+ // The individual parts this render covers: the wanted universe intersected with
+ // the parts the file actually carries. Each renders on its own OfflineAudioContext
+ // so Chromium can bounce them on separate threads (measured ~2.5× faster on 8
+ // cores); the per-part graphs never interact, so the summed mix is the same signal
+ // the single-pass graph fed its limiter. A lone part (stem export) has nothing to
+ // parallelise, so it renders inline with the limiter — its output IS the mix.
+ const present = PLAYER_PARTS.filter(p => wantsPart(p) && partHasNotes(midi, p))
+
+ // If the timeout wins a race, the render keeps going in the background (an
+ // OfflineAudioContext can't be cancelled) — the `.catch(() => {})` on each promise
+ // swallows its eventual settlement so a late rejection isn't an unhandled promise.
+ let finalBuffer: AudioBuffer
try {
- toneBuffer = await Promise.race([renderPromise, timeout])
+ if (present.length <= 1) {
+ const single = renderOfflineFast(c => buildGraph(c, wantsPart, true), totalDuration, 2)
+ single.catch(() => {})
+ const toneBuffer = await Promise.race([single, timeout])
+ const ab = toneBuffer.get()
+ if (!ab) throw new Error('Offline render returned empty buffer')
+ finalBuffer = ab
+ } else {
+ // Render every part in parallel (no limiter); progress advances as each lands.
+ let done = 0
+ const partRenders = present.map(p =>
+ renderOfflineFast(c => buildGraph(c, q => q === p, false), totalDuration, 2)
+ .then(buf => {
+ done += 1
+ onProgress?.(0.08 + 0.8 * (done / present.length))
+ return buf
+ }),
+ )
+ partRenders.forEach(pr => pr.catch(() => {}))
+ const dry = await Promise.race([Promise.all(partRenders), timeout])
+ const buffers = dry.map(b => b.get()).filter((b): b is AudioBuffer => !!b)
+ if (buffers.length === 0) throw new Error('Offline render returned empty buffer')
+ onProgress?.(0.9)
+ // Sum the parts and apply the master limiter once, on the full mix.
+ finalBuffer = await limitMix(buffers)
+ }
} finally {
clearInterval(progressTimer)
if (timeoutHandle !== null) clearTimeout(timeoutHandle)
}
onProgress?.(0.92)
- const ab = toneBuffer.get()
- if (!ab) throw new Error('Offline render returned empty buffer')
- const blob = encodeWav(ab)
+ const blob = encodeWav(finalBuffer)
onProgress?.(1)
return blob
} finally {