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 {