diff --git a/backend/app/api/routes_generate.py b/backend/app/api/routes_generate.py index 48823b7..d6bf9db 100644 --- a/backend/app/api/routes_generate.py +++ b/backend/app/api/routes_generate.py @@ -321,7 +321,8 @@ def event_stream(): response = GenerateResponse( generation_id=gen_id, style=req.style_id, files=files, summary=GenerateSummary(key=f"{req.key} {req.scale}", key_root=req.key, - scale=req.scale, bpm=bpm, bars=req.bars, + scale=req.scale, time_signature=getattr(req, "time_signature", "4/4"), + bpm=bpm, bars=req.bars, complexity=req.complexity, variation=req.variation, mode=req.mode, section_type=req.section_type), seed=best_seed, quality=quality, diff --git a/backend/app/api/routes_song.py b/backend/app/api/routes_song.py index 67d4b0f..502fb6e 100644 --- a/backend/app/api/routes_song.py +++ b/backend/app/api/routes_song.py @@ -32,6 +32,7 @@ from app.services.style_loader import load_style 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.meter import parse_meter from app.core.arrangement import ( _SONG_TEMPLATES, _song_tempo_map, ) @@ -90,6 +91,7 @@ def rebuild_song_progression(req: RebuildSongProgressionRequest): custom = meta.get("custom_template") song_req = BuildSongRequest( style_id=meta["style_id"], key=key, scale=scale, bpm=meta["bpm"], + time_signature=meta.get("time_signature", "4/4"), complexity=meta["complexity"], variation=meta["variation"], dynamics=meta.get("dynamics", 0.5), humanize=meta["humanize"], parts=meta["parts"], template=meta.get("template", "verse_chorus"), @@ -156,6 +158,7 @@ def _do_build_song(req: BuildSongRequest, user_progression: list[str] | None = N import json as _jmeta (output_dir / "song_meta.json").write_text(_jmeta.dumps({ "style_id": req.style_id, "key": req.key, "scale": req.scale, + "time_signature": getattr(req, "time_signature", "4/4"), "bpm": bpm, "complexity": req.complexity, "variation": req.variation, "dynamics": req.dynamics, "humanize": req.humanize, "parts": list(req.parts), "template": req.template, @@ -176,7 +179,8 @@ def _do_build_song(req: BuildSongRequest, user_progression: list[str] | None = N files = _write_song_output(song_events, output_dir, gen_id, bpm, style, programs, list(req.parts), total_bars, section_results, - key=req.key, scale=req.scale) + key=req.key, scale=req.scale, + meter=parse_meter(getattr(req, "time_signature", None))) import json as _jsong (output_dir / "song_structure.json").write_text(_jsong.dumps(section_results, indent=2)) @@ -240,6 +244,7 @@ def regenerate_song_part(req: RegenerateSongPartRequest): gen_parts = list(meta["parts"]) + ([req.part] if is_new_part else []) song_req = BuildSongRequest.model_construct( style_id=meta["style_id"], key=meta["key"], scale=meta["scale"], bpm=meta["bpm"], + time_signature=meta.get("time_signature", "4/4"), complexity=meta["complexity"], variation=meta["variation"], humanize=meta["humanize"], dynamics=meta.get("dynamics", 0.5), parts=gen_parts, template=meta["template"], use_priors=meta["use_priors"], @@ -287,7 +292,7 @@ def regenerate_song_part(req: RegenerateSongPartRequest): if req.part == "bass" and style.get("bass", {}).get("bass_style") == "808": part_pb = _generate_808_pitch_bends(evts, _PART_CHANNELS.get("bass", 1)) - tempo_map = _song_tempo_map(_sections, bpm, ending_bars=1) + tempo_map = _song_tempo_map(_sections, bpm, ending_bars=1, meter=parse_meter(meta.get("time_signature", "4/4"))) scaled = _drop_quiet(_scale_velocity(evts, req.part, _sid)) fname = f"{req.part}.mid" part_path = output_dir / fname @@ -300,8 +305,8 @@ def regenerate_song_part(req: RegenerateSongPartRequest): track_name=track_names.get(req.part)) # Rebuild song.mid from all stems on disk (new part + untouched others). - rebuild_combined_from_parts(output_dir, bpm, combined_name="song.mid", tempo_events=tempo_map, - markers=_section_markers(_sections, meta.get("key", "C")), + rebuild_combined_from_parts(output_dir, bpm, combined_name="song.mid", meter=parse_meter(meta.get("time_signature", "4/4")), tempo_events=tempo_map, + markers=_section_markers(_sections, meta.get("key", "C"), parse_meter(meta.get("time_signature", "4/4"))), key_signature=mido_key_signature(meta.get("key", "C"), meta.get("scale", "minor")), track_names=track_names) @@ -325,6 +330,7 @@ def _render_song_part_stem(output_dir, meta: dict, style: dict, part: str, salt: song_req = BuildSongRequest.model_construct( style_id=meta["style_id"], key=meta["key"], scale=meta["scale"], bpm=meta["bpm"], + time_signature=meta.get("time_signature", "4/4"), complexity=meta["complexity"], variation=meta["variation"], humanize=meta["humanize"], dynamics=meta.get("dynamics", 0.5), parts=meta["parts"], template=meta["template"], use_priors=meta["use_priors"], @@ -360,7 +366,7 @@ def _render_song_part_stem(output_dir, meta: dict, style: dict, part: str, salt: if part == "bass" and style.get("bass", {}).get("bass_style") == "808": part_pb = _generate_808_pitch_bends(evts, _PART_CHANNELS.get("bass", 1)) - tempo_map = _song_tempo_map(_sections, bpm, ending_bars=1) + tempo_map = _song_tempo_map(_sections, bpm, ending_bars=1, meter=parse_meter(meta.get("time_signature", "4/4"))) scaled = _drop_quiet(_scale_velocity(evts, part, _sid)) write_midi(scaled, output_dir / out_name, bpm=bpm, program=programs.get(part), cc_events=part_cc, pb_events=part_pb, tempo_events=tempo_map, @@ -448,9 +454,9 @@ def keep_song_part_candidate(req: KeepSongPartCandidateRequest): meta.get("template", "verse_chorus"), meta.get("key", "C"), custom=meta.get("custom_template")) rebuild_combined_from_parts( - output_dir, bpm, combined_name="song.mid", - tempo_events=_song_tempo_map(layout, bpm, ending_bars=1), - markers=_section_markers(layout, meta.get("key", "C")), + output_dir, bpm, combined_name="song.mid", meter=parse_meter(meta.get("time_signature", "4/4")), + tempo_events=_song_tempo_map(layout, bpm, ending_bars=1, meter=parse_meter(meta.get("time_signature", "4/4"))), + markers=_section_markers(layout, meta.get("key", "C"), parse_meter(meta.get("time_signature", "4/4"))), key_signature=mido_key_signature(meta.get("key", "C"), meta.get("scale", "minor")), track_names=track_names) return FileInfo(part=req.part, filename=f"{req.part}.mid", @@ -582,9 +588,9 @@ def set_part_gain(req: SetPartGainRequest): 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), - markers=_section_markers(layout, meta.get("key", "C")), + rebuild_combined_from_parts(output_dir, bpm, combined_name="song.mid", meter=parse_meter(meta.get("time_signature", "4/4")), + tempo_events=_song_tempo_map(layout, bpm, ending_bars=1, meter=parse_meter(meta.get("time_signature", "4/4"))), + markers=_section_markers(layout, meta.get("key", "C"), parse_meter(meta.get("time_signature", "4/4"))), key_signature=mido_key_signature(meta.get("key", "C"), meta.get("scale", "minor")), track_names=_track_names) return FileInfo(part=req.part, filename=f"{req.part}.mid", @@ -618,7 +624,7 @@ def edit_part(req: EditPartRequest): bpm = meta.get("bpm", 120) layout = _template_section_results(meta.get("template", "verse_chorus"), meta.get("key", "C"), custom=meta.get("custom_template")) - tempo_map = _song_tempo_map(layout, bpm, ending_bars=1) + tempo_map = _song_tempo_map(layout, bpm, ending_bars=1, meter=parse_meter(meta.get("time_signature", "4/4"))) total_bars = sum(s["bars"] for s in layout) channel = 9 if req.part == "drums" else _PART_CHANNELS.get(req.part, 0) @@ -645,8 +651,8 @@ def edit_part(req: EditPartRequest): cc_events=part_cc, tempo_events=tempo_map, track_name=track_names.get(req.part)) - rebuild_combined_from_parts(output_dir, bpm, combined_name="song.mid", tempo_events=tempo_map, - markers=_section_markers(layout, meta.get("key", "C")), + rebuild_combined_from_parts(output_dir, bpm, combined_name="song.mid", meter=parse_meter(meta.get("time_signature", "4/4")), tempo_events=tempo_map, + markers=_section_markers(layout, meta.get("key", "C"), parse_meter(meta.get("time_signature", "4/4"))), key_signature=mido_key_signature(meta.get("key", "C"), meta.get("scale", "minor")), track_names=track_names) return FileInfo(part=req.part, filename=f"{req.part}.mid", @@ -776,7 +782,7 @@ def undo_song_part(req: RegenerateSongPartRequest): bpm = meta.get("bpm", 120) _layout = _template_section_results(meta.get("template", "verse_chorus"), meta.get("key", "C"), custom=meta.get("custom_template")) - tempo_map = _song_tempo_map(_layout, bpm, ending_bars=1) + tempo_map = _song_tempo_map(_layout, bpm, ending_bars=1, meter=parse_meter(meta.get("time_signature", "4/4"))) shutil.copy(prev, output_dir / f"{req.part}.mid") prev.unlink() # one level of undo only @@ -786,8 +792,8 @@ def undo_song_part(req: RegenerateSongPartRequest): 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")), + rebuild_combined_from_parts(output_dir, bpm, combined_name="song.mid", meter=parse_meter(meta.get("time_signature", "4/4")), tempo_events=tempo_map, + markers=_section_markers(_layout, meta.get("key", "C"), parse_meter(meta.get("time_signature", "4/4"))), key_signature=mido_key_signature(meta.get("key", "C"), meta.get("scale", "minor")), track_names=_track_names) return FileInfo(part=req.part, filename=f"{req.part}.mid", @@ -837,6 +843,7 @@ def regenerate_song_section(req: RegenerateSongSectionRequest): song_req = BuildSongRequest.model_construct( style_id=meta["style_id"], key=meta["key"], scale=meta["scale"], bpm=meta["bpm"], + time_signature=meta.get("time_signature", "4/4"), complexity=meta["complexity"], variation=meta["variation"], humanize=meta["humanize"], dynamics=meta.get("dynamics", 0.5), parts=meta["parts"], template=meta["template"], use_priors=meta["use_priors"], @@ -862,7 +869,8 @@ def regenerate_song_section(req: RegenerateSongSectionRequest): files = _write_song_output(song_events, output_dir, req.generation_id, bpm, style, programs, list(meta["parts"]), total_bars, section_results, - key=meta.get("key", "C"), scale=meta.get("scale", "minor")) + key=meta.get("key", "C"), scale=meta.get("scale", "minor"), + meter=parse_meter(meta.get("time_signature", "4/4"))) # Part locking: any locked stem is restored to its pre-reroll state (the .prev # backup taken above) so it stays byte-identical — the section re-roll only @@ -875,9 +883,9 @@ def regenerate_song_section(req: RegenerateSongSectionRequest): if prev.exists(): shutil.copy(prev, output_dir / f"{part}.mid") rebuild_combined_from_parts( - output_dir, bpm, combined_name="song.mid", - tempo_events=_song_tempo_map(section_results, bpm, ending_bars=1), - markers=_section_markers(section_results, meta.get("key", "C")), + output_dir, bpm, combined_name="song.mid", meter=parse_meter(meta.get("time_signature", "4/4")), + tempo_events=_song_tempo_map(section_results, bpm, ending_bars=1, meter=parse_meter(meta.get("time_signature", "4/4"))), + markers=_section_markers(section_results, meta.get("key", "C"), parse_meter(meta.get("time_signature", "4/4"))), key_signature=mido_key_signature(meta.get("key", "C"), meta.get("scale", "minor")), track_names=track_names) # Don't ask the client to reload a locked stem that didn't change. diff --git a/backend/app/core/arrangement.py b/backend/app/core/arrangement.py index 73a678b..3d793c4 100644 --- a/backend/app/core/arrangement.py +++ b/backend/app/core/arrangement.py @@ -110,6 +110,7 @@ def _transpose_key(key: str, semitones: int) -> str: def _apply_section_ramp( all_events: dict[str, list[NoteEvent]], sections: list[dict], + meter: Meter = DEFAULT_METER, ) -> None: """Smooth dynamic steps where a section is louder than its predecessor. @@ -123,7 +124,7 @@ def _apply_section_ramp( if curr_dyn <= prev_dyn + 0.05: continue sec_start = float(sections[sec_i]["offset"]) - ramp_beats = min(8.0, sections[sec_i]["bars"] * 4 * 0.5) + ramp_beats = min(8.0, sections[sec_i]["bars"] * meter.bar_beats * 0.5) start_ratio = prev_dyn / curr_dyn # factor at the downbeat of the new section for part, evts in all_events.items(): if part == "drums": @@ -236,16 +237,18 @@ def _auto_arc_section_type(sections: list[dict], i: int) -> str | None: return "chorus" if sections[i].get("dynamic", 1.0) >= peak - 1e-9 else "verse" -def _section_end_bars(sections: list[dict], current_section_offset: int) -> list[int]: +def _section_end_bars(sections: list[dict], current_section_offset: int, + meter: Meter = DEFAULT_METER) -> list[int]: """Return bar indices (relative to current section) that are section boundaries. Used to tell the drum generator where to place builds/fills at section transitions. """ + bb = meter.bar_beats end_bars = [] for sec in sections: - sec_end_beat = sec["offset"] + sec["bars"] * 4 + sec_end_beat = sec["offset"] + sec["bars"] * bb # Convert to bar index within the current section - bar_idx = (sec_end_beat - current_section_offset) // 4 - 1 + bar_idx = (sec_end_beat - current_section_offset) // bb - 1 if 0 <= bar_idx: end_bars.append(int(bar_idx)) return end_bars @@ -345,7 +348,8 @@ def scaled_profile(section_type: str | None, dynamics: float = 0.5) -> dict: def apply_arrangement_dynamics(song_events: dict[str, list], section_results: list[dict], base_seed: int, - dynamics: float = 0.5) -> None: + dynamics: float = 0.5, + meter: Meter = DEFAULT_METER) -> None: """Classic arrangement dropouts, applied in place to the assembled song. Real records breathe: instruments drop out so others lead. Section @@ -377,6 +381,7 @@ def apply_arrangement_dynamics(song_events: dict[str, list], stripped. """ rng = random.Random(_part_seed(base_seed, 911, "dynamics")) + bb = meter.bar_beats # bar length in quarter-beats (4.0 for 4/4 → offsets unchanged) # Dynamics macro scales every device's odds around the historical values # (0.5 = exactly the old probabilities). Threshold-only scaling: the number # of rng draws never changes, so seed replay stays stable per version. @@ -402,7 +407,7 @@ def _strip(part: str, lo: float, hi: float, keep: set | None = None) -> None: chorus_occurrence = 0 for sec in section_results: stype = sec.get("section_type") - start_beat = sec.get("start_bar", 0) * 4.0 + start_beat = sec.get("start_bar", 0) * bb bars = sec.get("bars", 0) if stype == "chorus": @@ -440,7 +445,7 @@ def _strip(part: str, lo: float, hi: float, keep: set | None = None) -> None: _breakdown_roll = rng.random() if (chorus_occurrence == n_choruses and n_choruses > 1 and bars >= 8 and _breakdown_roll < _p(0.3)): - half = start_beat + (bars // 2) * 4.0 + half = start_beat + (bars // 2) * bb _strip("chords", start_beat, half) _strip("pads", start_beat, half) _strip("arpeggio", start_beat, half) @@ -448,12 +453,12 @@ def _strip(part: str, lo: float, hi: float, keep: set | None = None) -> None: _strip("drums", start_beat, half, keep=_KICK_HATS | {39}) # Arp growth: only when a LATER chorus exists to be the bigger one if chorus_occurrence == 1 and n_choruses > 1 and bars >= 4 and rng.random() < _p(0.5): - _strip("arpeggio", start_beat, start_beat + (bars // 2) * 4.0) + _strip("arpeggio", start_beat, start_beat + (bars // 2) * bb) elif stype == "bridge" and bars >= 4 and rng.random() < _p(0.75): - _strip("drums", start_beat, start_beat + (bars // 2) * 4.0) + _strip("drums", start_beat, start_beat + (bars // 2) * bb) if rng.random() < _p(0.4): - _strip("bass", start_beat, start_beat + (bars // 2) * 4.0) + _strip("bass", start_beat, start_beat + (bars // 2) * bb) elif stype == "verse": verse_occurrence += 1 @@ -468,26 +473,27 @@ def _strip(part: str, lo: float, hi: float, keep: set | None = None) -> None: for e in song_events.get("melody", [])) if not intro_had_melody: entry_bars = min(4, max(2, bars // 4)) - _strip("melody", start_beat, start_beat + entry_bars * 4.0) + _strip("melody", start_beat, start_beat + entry_bars * bb) elif verse_occurrence == 2: if rng.random() < _p(0.5): thin_bars = min(4, max(2, bars // 4)) - _strip("drums", start_beat, start_beat + thin_bars * 4.0, keep=_KICK_HATS) + _strip("drums", start_beat, start_beat + thin_bars * bb, keep=_KICK_HATS) if rng.random() < _p(0.5): - _strip("chords", start_beat, start_beat + thin_bars * 4.0) + _strip("chords", start_beat, start_beat + thin_bars * bb) # Arp holds back through verse 2's first half — mirrors the # chorus's arp growth, so a full-length arp doesn't flatten the # verse/chorus contrast (measured: verse-2 arp ran wall-to-wall # at chorus density, making the chorus entry read no bigger). if bars >= 4 and rng.random() < _p(0.5): - _strip("arpeggio", start_beat, start_beat + (bars // 2) * 4.0) + _strip("arpeggio", start_beat, start_beat + (bars // 2) * bb) def apply_melodic_pickups(song_events: dict[str, list], section_results: list[dict], base_seed: int, scale: str, - style: dict) -> None: + style: dict, + meter: Meter = DEFAULT_METER) -> None: """Melodic pickups (anacrusis) into sections, applied in place. The classic "and-4-and | ONE": a short stepwise run in the last beats of @@ -512,15 +518,16 @@ def apply_melodic_pickups(song_events: dict[str, list], profile = instrumentation_for(style).get("melody") _PICKUP_PROB = {"chorus": 0.55, "verse": 0.3, "bridge": 0.3} + bb = meter.bar_beats added: list = [] for sec in section_results: prob = _PICKUP_PROB.get(sec.get("section_type"), 0.0) - boundary = sec.get("start_bar", 0) * 4.0 + boundary = sec.get("start_bar", 0) * bb # RNG draws must not depend on event content — draw first, always. roll = rng.random() n_notes = rng.choice([2, 3]) from_below = rng.random() < 0.7 - if prob == 0.0 or boundary < 4.0 or roll >= prob: + if prob == 0.0 or boundary < bb or roll >= prob: continue # The target: the section's first melody note, on or just after its downbeat @@ -565,7 +572,8 @@ def apply_melodic_pickups(song_events: dict[str, list], def _song_tempo_map(section_results: list[dict], bpm: float, - ending_bars: int = 0) -> list[tuple[float, float]]: + ending_bars: int = 0, + meter: Meter = DEFAULT_METER) -> list[tuple[float, float]]: """Tempo map for a built song: subtle chorus push + final ritardando. Choruses run ~1.2% faster than the base tempo — enough to feel lifted @@ -574,10 +582,11 @@ def _song_tempo_map(section_results: list[dict], bpm: float, final chord lands with weight. Returns (beat, bpm) points for the MIDI tempo track; deterministic, so regeneration reproduces it exactly. """ + bb = meter.bar_beats points: list[tuple[float, float]] = [(0.0, float(bpm))] in_push = False for sec in section_results: - start_beat = float(sec["start_bar"] * 4) + start_beat = float(sec["start_bar"] * bb) stype = sec["section_type"] is_chorus = stype in ("chorus", "post_chorus") if stype == "pre_chorus" and not in_push: @@ -592,10 +601,10 @@ def _song_tempo_map(section_results: list[dict], bpm: float, in_push = False if ending_bars > 0 and section_results: last = section_results[-1] - end_start = float((last["start_bar"] + last["bars"]) * 4) + end_start = float((last["start_bar"] + last["bars"]) * bb) # last["bars"] already includes any ending bar appended by the caller; # the ritardando covers the final `ending_bars` bars. - rit_start = end_start - ending_bars * 4 + rit_start = end_start - ending_bars * bb for i, factor in enumerate((0.96, 0.90, 0.84, 0.76)): - points.append((rit_start + i * (ending_bars * 4) / 4.0, bpm * factor)) + points.append((rit_start + i * (ending_bars * bb) / 4.0, bpm * factor)) return points diff --git a/backend/app/generators/bass.py b/backend/app/generators/bass.py index 61fd1e3..234111a 100644 --- a/backend/app/generators/bass.py +++ b/backend/app/generators/bass.py @@ -131,65 +131,104 @@ def _roman_at(beat: float) -> str: vel = min(127, int((100 + kb) * phrase_dyn) + random.randint(-5, 5)) events.append(NoteEvent(pitch=root, start=_swing(_snap_to_kick(bar_start, kick_times)), duration=0.88, velocity=vel, channel=1)) - # Beat 2: 3rd or 5th - beat2 = bar_start + 1.0 - kb2 = 6 if _on_kick(beat2, kick_times) else 0 - if should_trigger(0.15) and bar % 2 == 1: - # Occasional octave displacement on bar 2 - p2 = max(28, min(52, root - 12 if root >= 40 else root + 12)) - elif pair_direction == 1: - # Ascending arc: start mid-low (3rd) so there's room to rise - p2 = third if should_trigger(0.62) else fifth - else: - # Descending arc: start mid-high (5th) so there's room to fall - p2 = fifth if should_trigger(0.62) else third - events.append(NoteEvent(pitch=p2, start=_swing(beat2), duration=0.88, - velocity=min(127, int((86 + kb2) * phrase_dyn) + random.randint(-8, 8)), channel=1)) - - # Beat 3: 5th or scalar passing note - beat3 = bar_start + 2.0 - kb3 = 6 if _on_kick(beat3, kick_times) else 0 - if mid_roman != roman: - # Harmony changes mid-bar (2 chords/bar): land the incoming chord's - # root on beat 3 rather than walking tones of the departed chord. - mid_pitches = roman_to_chord(mid_roman, key, scale, octave=2) - p3 = max(28, min(52, mid_pitches[0])) - elif should_trigger(0.35) and complexity > 0.4: - # Scalar passing note between beat-2 pitch and beat-4 approach - mid = (p2 + next_root) // 2 - # Snap to nearest scale or chromatic step - candidates = [mid, mid + 1, mid - 1] - p3 = max(28, min(52, min(candidates, key=lambda n: abs(n - mid) + (0 if n % 12 in scale_pcs else 1)))) - elif pair_direction == 1: - # Ascending arc: reach upward from p2 toward the 5th - p3 = max(p2, fifth) if should_trigger(0.55) else fifth - p3 = max(28, min(52, p3)) - else: - # Descending arc: come down from p2 toward the 3rd - p3 = min(p2, third) if should_trigger(0.55) else third - p3 = max(28, min(52, p3)) - events.append(NoteEvent(pitch=p3, start=_swing(beat3), duration=0.88, - velocity=min(127, int((84 + kb3) * phrase_dyn) + random.randint(-8, 8)), channel=1)) - - # Beat 4: chromatic or scale-step approach to next bar root - beat4 = bar_start + 3.0 - kb4 = 6 if _on_kick(beat4, kick_times) else 0 - # Alternate between below-approach (most common) and above-approach (encircle feel) - if should_trigger(0.35): - approach = max(28, min(52, next_root + 1)) # half-step from above + # The classic four-crotchet walk is 4/4-specific (beats 2/3/4 sit at + # fixed +1/+2/+3). In any other meter those offsets overflow a shorter + # bar or misplace the pulse, so the walk instead steps one note per felt + # pulse (meter.pulse_positions) — 3/4 → three notes, 6/8 → a dotted- + # quarter two-feel, 7/8 → the 2+2+3 pulse. 4/4 keeps the exact original + # path (and RNG sequence), so 4/4 output stays byte-identical. + if beats_per_bar == 4.0: + # Beat 2: 3rd or 5th + beat2 = bar_start + 1.0 + kb2 = 6 if _on_kick(beat2, kick_times) else 0 + if should_trigger(0.15) and bar % 2 == 1: + # Occasional octave displacement on bar 2 + p2 = max(28, min(52, root - 12 if root >= 40 else root + 12)) + elif pair_direction == 1: + # Ascending arc: start mid-low (3rd) so there's room to rise + p2 = third if should_trigger(0.62) else fifth + else: + # Descending arc: start mid-high (5th) so there's room to fall + p2 = fifth if should_trigger(0.62) else third + events.append(NoteEvent(pitch=p2, start=_swing(beat2), duration=0.88, + velocity=min(127, int((86 + kb2) * phrase_dyn) + random.randint(-8, 8)), channel=1)) + + # Beat 3: 5th or scalar passing note + beat3 = bar_start + 2.0 + kb3 = 6 if _on_kick(beat3, kick_times) else 0 + if mid_roman != roman: + # Harmony changes mid-bar (2 chords/bar): land the incoming chord's + # root on beat 3 rather than walking tones of the departed chord. + mid_pitches = roman_to_chord(mid_roman, key, scale, octave=2) + p3 = max(28, min(52, mid_pitches[0])) + elif should_trigger(0.35) and complexity > 0.4: + # Scalar passing note between beat-2 pitch and beat-4 approach + mid = (p2 + next_root) // 2 + # Snap to nearest scale or chromatic step + candidates = [mid, mid + 1, mid - 1] + p3 = max(28, min(52, min(candidates, key=lambda n: abs(n - mid) + (0 if n % 12 in scale_pcs else 1)))) + elif pair_direction == 1: + # Ascending arc: reach upward from p2 toward the 5th + p3 = max(p2, fifth) if should_trigger(0.55) else fifth + p3 = max(28, min(52, p3)) + else: + # Descending arc: come down from p2 toward the 3rd + p3 = min(p2, third) if should_trigger(0.55) else third + p3 = max(28, min(52, p3)) + events.append(NoteEvent(pitch=p3, start=_swing(beat3), duration=0.88, + velocity=min(127, int((84 + kb3) * phrase_dyn) + random.randint(-8, 8)), channel=1)) + + # Beat 4: chromatic or scale-step approach to next bar root + beat4 = bar_start + 3.0 + kb4 = 6 if _on_kick(beat4, kick_times) else 0 + # Alternate between below-approach (most common) and above-approach (encircle feel) + if should_trigger(0.35): + approach = max(28, min(52, next_root + 1)) # half-step from above + else: + approach = _approach(root, next_root, 1) # half-step from below + # Occasionally use a two-step approach for larger intervals + if abs(next_root - root) > 4 and complexity > 0.5 and should_trigger(0.45): + approach = _approach(root, next_root, 2) + # Sometimes diatonic (scale-step) instead of chromatic + nr2_down_ok = (next_root - 2) % 12 in scale_pcs + nr2_up_ok = (next_root + 2) % 12 in scale_pcs + if should_trigger(variation * 0.3) and (nr2_down_ok or nr2_up_ok): + approach = (next_root - 2) if nr2_down_ok else (next_root + 2) + approach = max(28, min(52, approach)) + events.append(NoteEvent(pitch=approach, start=_swing(beat4), duration=0.88, + velocity=min(127, int((88 + kb4) * phrase_dyn) + random.randint(-8, 8)), channel=1)) else: - approach = _approach(root, next_root, 1) # half-step from below - # Occasionally use a two-step approach for larger intervals - if abs(next_root - root) > 4 and complexity > 0.5 and should_trigger(0.45): - approach = _approach(root, next_root, 2) - # Sometimes diatonic (scale-step) instead of chromatic - nr2_down_ok = (next_root - 2) % 12 in scale_pcs - nr2_up_ok = (next_root + 2) % 12 in scale_pcs - if should_trigger(variation * 0.3) and (nr2_down_ok or nr2_up_ok): - approach = (next_root - 2) if nr2_down_ok else (next_root + 2) - approach = max(28, min(52, approach)) - events.append(NoteEvent(pitch=approach, start=_swing(beat4), duration=0.88, - velocity=min(127, int((88 + kb4) * phrase_dyn) + random.randint(-8, 8)), channel=1)) + # Generalized pulse walk for non-4/4 bars. The downbeat root is + # already placed; step through the remaining felt pulses, arcing + # between chord tones and leading into the next bar's root on the + # final pulse (same ascending/descending logic as the 4/4 walk). + pulses = meter.pulse_positions + for pi in range(1, len(pulses)): + pos = bar_start + pulses[pi] + nxt = bar_start + (pulses[pi + 1] if pi + 1 < len(pulses) else beats_per_bar) + dur = max(0.2, (nxt - pos) * 0.9) + kbp = 6 if _on_kick(pos, kick_times) else 0 + if pi == len(pulses) - 1: + # Final pulse: approach the next bar's root (chromatic below, + # sometimes above, occasionally a diatonic step). + if should_trigger(0.35): + pitch = max(28, min(52, next_root + 1)) + else: + pitch = _approach(root, next_root, 1) + if should_trigger(variation * 0.3) and (next_root - 2) % 12 in scale_pcs: + pitch = next_root - 2 + base = 88 + else: + # Inner pulse: a chord tone chosen by the phrase arc. + if pair_direction == 1: + pitch = third if should_trigger(0.6) else fifth + else: + pitch = fifth if should_trigger(0.6) else third + base = 86 + pitch = max(28, min(52, pitch)) + events.append(NoteEvent( + pitch=pitch, start=_swing(_snap_to_kick(pos, kick_times)), duration=dur, + velocity=min(127, int((base + kbp) * phrase_dyn) + random.randint(-8, 8)), channel=1)) bass_jitter = style_jitter(style) * 0.55 return [NoteEvent(e.pitch, max(0.0, e.start + timing_jitter(bass_jitter)), e.duration, e.velocity, e.channel) for e in events] @@ -311,7 +350,7 @@ def _root_at(beat: float) -> int: def _render_riff_bass( style: dict, key: str, scale: str, bars: int, progression: list, - section_type: str | None, + section_type: str | None, meter: Meter = DEFAULT_METER, ) -> List[NoteEvent]: """Render the song's riff (app.generators.riff) an octave below the guitar, as single notes — the bass locks to the guitar in unison. Accents get a @@ -331,11 +370,11 @@ def _swing(beat: float) -> float: # Pedal an octave below the guitar's low register so the two lock in unison. ch_low = style.get("chord_register", [40, 60])[0] riff = build_riff(style, key, scale, progression, bars, section_type, - pedal_low=max(28, ch_low - 12)) + pedal_low=max(28, ch_low - 12), meter=meter) events: List[NoteEvent] = [] for rn in riff: - bar_num = int(rn.start / 4.0) + bar_num = int(rn.start / meter.bar_beats) vel = int(vel_base * phrase_breath_factor(bar_num)) + (8 if rn.accent else 0) events.append(NoteEvent( pitch=min(127, max(0, rn.pitch)), @@ -378,7 +417,7 @@ def generate_bass( from app.generators.riff import riff_section_comp if riff_section_comp(style, section_type): return _fold_to_range( - _render_riff_bass(style, key, scale, bars, progression, section_type), + _render_riff_bass(style, key, scale, bars, progression, section_type, meter=meter), _bass_profile) if bass_cfg.get("bass_style") == "808": diff --git a/backend/app/generators/chords.py b/backend/app/generators/chords.py index f825489..f97f375 100644 --- a/backend/app/generators/chords.py +++ b/backend/app/generators/chords.py @@ -22,6 +22,7 @@ def _render_riff_guitar( style: dict, key: str, scale: str, bars: int, progression: list, section_type: str | None, ch_low: int, ch_high: int, velocity_base: int, vel_arc_start: float, swing_amount: float, strum_speed: float, + meter: Meter = DEFAULT_METER, ) -> List[NoteEvent]: """Render the song's riff (app.generators.riff) as a distorted guitar part: power-chord stabs (root+5th, +octave when it fits) on the figure's accents, @@ -37,10 +38,10 @@ def _swing(beat: float) -> float: tick = int(beat * ticks_per_beat) return _apply_swing(tick, swing_amount, ticks_per_beat) / ticks_per_beat - riff = build_riff(style, key, scale, progression, bars, section_type, pedal_low=ch_low) + riff = build_riff(style, key, scale, progression, bars, section_type, pedal_low=ch_low, meter=meter) events: List[NoteEvent] = [] for rn in riff: - bar_num = int(rn.start / 4.0) + bar_num = int(rn.start / meter.bar_beats) t = bar_num / max(1, bars - 1) base = int(velocity_base * (vel_arc_start + (1.0 - vel_arc_start) * t)) base = int(base * phrase_breath_factor(bar_num)) @@ -436,7 +437,7 @@ def _in_melody_rest(beat: float) -> bool: return _render_riff_guitar( style, key, scale, bars, prog_source or progression, section_type, ch_low, ch_high, style.get("velocity_base", 74), - style.get("vel_arc_start", 0.75), swing_amount, strum_speed) + style.get("vel_arc_start", 0.75), swing_amount, strum_speed, meter=meter) chord_rhythm = _COMP_RHYTHMS.get(comp_style) if comp_style else style.get("chord_rhythm") diff --git a/backend/app/generators/drums.py b/backend/app/generators/drums.py index 9083db1..ef8875e 100644 --- a/backend/app/generators/drums.py +++ b/backend/app/generators/drums.py @@ -187,6 +187,14 @@ def generate_drums( step = 0.25 # sixteenth note ticks_per_beat = 480 hat_base_vel = 74 # peak velocity for the 16-step accent curve + # End-of-bar fills/rolls/ghost gestures are written relative to a 4/4 bar + # (offsets in the 2.75–3.99 range). Shifting them by (bar_beats − 4) lands + # them at the ACTUAL bar end in any meter; for 4/4 the shift is 0, so those + # placements stay byte-identical. Full-bar figures instead clip to the bar. + fill_shift = beats_per_bar - 4.0 + # Backbeats are 1-indexed quarter-note beats ([2, 4]); drop any that fall + # outside a shorter bar (beat 4 overflows 3/4 and 6/8). Never leave it empty. + snare_beats = [b for b in snare_beats if (b - 1) < beats_per_bar] or [2] # Decide hat subdivision mode once per 4-bar phrase. phrase_hat_modes: dict[int, bool] = {} @@ -259,7 +267,7 @@ def generate_drums( if kick_pattern: kick_beats_bar1 = [ i * step for i, on in enumerate(kick_pattern) - if on and should_trigger(0.88 + variation * 0.10) + if on and i * step < beats_per_bar and should_trigger(0.88 + variation * 0.10) ] # Guarantee beat 1 if not any(b < step for b in kick_beats_bar1): @@ -279,6 +287,9 @@ def generate_drums( if bar_in_pair == 1 else list(kick_beats_bar1) ) + # The bar-2 anticipation kicks (3.75, 2.75, …) assume a 4/4 bar; drop any + # that overflow a shorter one. No RNG here, so 4/4 stays byte-identical. + kick_beats = [b for b in kick_beats if b < beats_per_bar] # Intro: strip the kick to its anchors — the groove arrives with the verse if is_intro: @@ -290,7 +301,7 @@ def generate_drums( # Choruses run it more often; never under a stripped or half-time feel. elif (double_kick_prob > 0 and not is_outro and should_trigger(min(0.9, double_kick_prob * (1.5 if is_chorus else 1.0) * dyn_f))): - kick_beats = [i * step for i in range(16)] + kick_beats = [i * step for i in range(meter.steps_per_bar)] for b in kick_beats: t = bar_start + b @@ -392,7 +403,7 @@ def generate_drums( use_triplet = phrase_hat_modes[bar // 4] if use_triplet: # 12 triplet 8th notes per bar (3 per beat × 4 beats) - hat_steps = [bar_start + i / 3.0 for i in range(12)] + hat_steps = [bar_start + i / 3.0 for i in range(int(round(beats_per_bar * 3)))] else: hat_steps = [bar_start + i * step for i in range(int(beats_per_bar / step))] # Half-time bridge: hats fall back to the 8th-note grid @@ -420,6 +431,8 @@ def generate_drums( for s, on in enumerate(_JAZZ_RIDE): if not on: continue + if s * step >= beats_per_bar: # 16-step pattern overflows a short bar + continue t = bar_start + s * step beat_frac = round(s * step % 1.0, 4) base_v = 66 if beat_frac < 0.01 else (54 if abs(beat_frac - 0.5) < 0.01 else 46) @@ -434,7 +447,7 @@ def generate_drums( channel=DRUM_CHANNEL, )) # Hi-hat "chick" on beats 2 and 4 - for chick_b in [1.0, 3.0]: + for chick_b in [b for b in [1.0, 3.0] if b < beats_per_bar]: t = bar_start + chick_b t_tick = int(t * ticks_per_beat) t_tick = apply_swing(t_tick, swing_amount, ticks_per_beat) @@ -543,7 +556,7 @@ def generate_drums( # EDM build: accelerating kick hits in last 2 beats if edm_build_active: - for edm_b in [2.0, 2.5, 3.0, 3.5]: + for edm_b in [b for b in [2.0, 2.5, 3.0, 3.5] if b < beats_per_bar]: if not any(abs(edm_b - k) < 0.05 for k in kick_beats): t = bar_start + edm_b t_tick = int(t * ticks_per_beat) @@ -631,7 +644,7 @@ def generate_drums( if (edm_drops and is_section_end and not is_last_bar and complexity > 0.2) or build_roll: # Snare roll: 8 32nd notes from 3.5 → 4.0, velocity builds 52 → 120 for r_i in range(8): - r_start = bar_start + 3.5 + r_i * 0.0625 + r_start = bar_start + fill_shift + 3.5 + r_i * 0.0625 r_vel = min(127, 52 + r_i * 9 + random.randint(-4, 4)) events.append(NoteEvent( pitch=DRUM_MAP["snare"], @@ -657,6 +670,9 @@ def generate_drums( key, vel = entry[1], entry[2] if key not in _FILL_LAYER_KEYS: continue + # Mined fills span a full 4/4 bar; drop steps past a short bar. + if entry[0] * step >= beats_per_bar: + continue fv = int(vel * _vel_scale * phrase_dyn) + random.randint(-5, 5) events.append(NoteEvent( pitch=DRUM_MAP[key], @@ -675,7 +691,7 @@ def generate_drums( _vel_scale = 0.7 + 0.3 * fill_intensity # Micro fill signal: one soft snare accent 3 16ths before bar end - micro_start = bar_start + 3.25 + micro_start = bar_start + fill_shift + 3.25 events.append(NoteEvent( pitch=DRUM_MAP["snare"], start=micro_start + _jitter("fill", h), @@ -718,7 +734,7 @@ def generate_drums( fill_vel = int(base_vel * _vel_scale * phrase_dyn) + random.randint(-6, 6) events.append(NoteEvent( pitch=DRUM_MAP[drum_key], - start=bar_start + b_offset + _jitter("fill", h), + start=bar_start + fill_shift + b_offset + _jitter("fill", h), duration=0.1, velocity=min(127, max(1, fill_vel)), channel=DRUM_CHANNEL, @@ -741,7 +757,7 @@ def generate_drums( for _mo, _mv in ((3.5, 44), (3.75, 60)): events.append(NoteEvent( pitch=DRUM_MAP["snare"], - start=bar_start + _mo + _jitter("ghost", h), + start=bar_start + fill_shift + _mo + _jitter("ghost", h), duration=0.05, velocity=max(1, min(127, int(_mv * phrase_dyn) + random.randint(-5, 5))), channel=DRUM_CHANNEL, @@ -750,7 +766,7 @@ def generate_drums( # Kick push: an extra kick on the "and of 4" nudging the turn. events.append(NoteEvent( pitch=DRUM_MAP["kick"], - start=bar_start + 3.5 + _jitter("kick", h), + start=bar_start + fill_shift + 3.5 + _jitter("kick", h), duration=0.1, velocity=max(1, min(127, int(82 * phrase_dyn) + random.randint(-6, 6))), channel=DRUM_CHANNEL, @@ -758,7 +774,7 @@ def generate_drums( # ── Percussion layers ───────────────────────────────────────────────── if "shaker" in perc_layers: - for s in [i * 0.5 for i in range(8)]: + for s in [i * 0.5 for i in range(int(round(beats_per_bar * 2)))]: if not should_trigger(0.80): continue t_tick = int((bar_start + s) * ticks_per_beat) @@ -774,7 +790,7 @@ def generate_drums( )) if "tambourine" in perc_layers: - for tamb_b in [1.0, 3.0]: + for tamb_b in [b for b in [1.0, 3.0] if b < beats_per_bar]: t_tick = int((bar_start + tamb_b) * ticks_per_beat) t_tick = apply_swing(t_tick, swing_amount, ticks_per_beat) tamb_vel = int((60 + random.randint(-10, 10)) * phrase_dyn) diff --git a/backend/app/generators/riff.py b/backend/app/generators/riff.py index a136f71..b9a8bc3 100644 --- a/backend/app/generators/riff.py +++ b/backend/app/generators/riff.py @@ -24,6 +24,7 @@ from dataclasses import dataclass from app.theory.chords import roman_to_chord +from app.core.meter import Meter, DEFAULT_METER _STEP = 0.25 # 16th note in beats _STEPS = 16 @@ -75,6 +76,7 @@ def build_riff( bars: int, section_type: str | None = None, pedal_low: int = 40, + meter: Meter = DEFAULT_METER, ) -> list[RiffNote]: """The section's riff as pedal-register RiffNotes (root octave ≈ `pedal_low`). @@ -100,7 +102,11 @@ def build_riff( next_root = _fit(roman_to_chord(next_roman, key, scale, octave=2)[0], pedal_low, pedal_high) for step in steps: - start = bar * 4.0 + step * _STEP + # Riff cells are a 4/4 16-step grid; in a shorter/odd bar drop the + # steps that overflow (4/4 keeps every step → byte-identical). + if step * _STEP >= meter.bar_beats: + continue + start = bar * meter.bar_beats + step * _STEP accent = step in accents dur = base_dur # Last 16th of the bar, heading into a new chord → chromatic approach. diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 322696f..87ea0f1 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -96,6 +96,7 @@ class GenerateSummary(BaseModel): key: str # formatted label e.g. "C minor" key_root: str # e.g. "C" scale: str # e.g. "minor" + time_signature: str = "4/4" bpm: int bars: int complexity: float diff --git a/backend/app/services/generation.py b/backend/app/services/generation.py index c25f7ab..9205421 100644 --- a/backend/app/services/generation.py +++ b/backend/app/services/generation.py @@ -521,7 +521,7 @@ def _pseed(sec_i: int, part: str) -> int: 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), + section_end_bars=_section_end_bars(sections, s_off, meter), is_loop=is_loop, section_type=s_sec_type, next_section_type=s_next_type, @@ -645,7 +645,7 @@ def _pseed(sec_i: int, part: str) -> int: # Smooth dynamic steps at section transitions (verse→chorus lift, etc.) if not is_loop and len(sections) > 1: - _apply_section_ramp(all_events, sections) + _apply_section_ramp(all_events, sections, meter=meter) if groove_push: for gp_part in ("melody", "chords", "arpeggio", "bass", "pads", "counter_melody"): diff --git a/backend/app/services/song_builder.py b/backend/app/services/song_builder.py index 84b7bfc..2addfab 100644 --- a/backend/app/services/song_builder.py +++ b/backend/app/services/song_builder.py @@ -26,6 +26,7 @@ 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.meter import Meter, DEFAULT_METER, parse_meter from app.core.arrangement import ( SECTION_PROFILES, _SONG_TEMPLATES, _part_seed, _transpose_key, _apply_section_ramp, _song_tempo_map, apply_arrangement_dynamics, @@ -122,6 +123,12 @@ def _generate_song_sections(req, style, bpm, base_seed, chorus_key_shift, else: template = [dict(sd) for sd in _SONG_TEMPLATES.get(req.template, _SONG_TEMPLATES["verse_chorus"])] + # The song's meter drives every bar→beat conversion below (section offsets, + # tease/outro windows, the ending bar) and is threaded into each section's + # generation request. 4/4 → bar_beats 4.0, so all arithmetic stays identical. + meter = parse_meter(getattr(req, "time_signature", None)) + bb = meter.bar_beats + # 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. @@ -278,6 +285,7 @@ def _style_for(style_id: str | None) -> tuple[dict, dict]: 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, + time_signature=str(meter), # every section inherits the song's meter ) # Choruses develop the verse's motif; other section types keep their own @@ -402,7 +410,7 @@ def _style_for(style_id: str | None) -> tuple[dict, dict]: _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 + _q = round((_o % bb) * 4) / 4 if _q not in rhythm_cell: rhythm_cell.append(_q) if len(rhythm_cell) >= 4: @@ -422,7 +430,7 @@ def _style_for(style_id: str | None) -> tuple[dict, dict]: "offset": beat_offset, "bars": sec_bars, "dynamic": SECTION_PROFILES.get(sec_type, {}).get("velocity_scale", 1.0), }) - beat_offset += sec_bars * 4 + beat_offset += sec_bars * bb total_bars += sec_bars # Smooth velocity jumps at section boundaries (verse→chorus lift, etc.). This @@ -430,7 +438,7 @@ def _style_for(style_id: str | None) -> tuple[dict, dict]: # 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) + _apply_section_ramp(song_events, ramp_sections, meter=meter) # ── Intro hook tease ───────────────────────────────────────────────────── # The intro previews the chorus melody: thinned to its structural notes, @@ -439,7 +447,7 @@ def _style_for(style_id: str | None) -> tuple[dict, dict]: 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 + limit = tease_intro["bars"] * bb 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 @@ -466,10 +474,10 @@ def _style_for(style_id: str | None) -> tuple[dict, dict]: # 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) + dynamics=req.dynamics, meter=meter) # 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) + apply_melodic_pickups(song_events, section_results, base_seed, req.scale, style, meter=meter) # ── Ending variety ──────────────────────────────────────────────────────── # Every song used to end with the identical ring-out formula. Three seeded @@ -483,8 +491,8 @@ def _style_for(style_id: str | None) -> tuple[dict, dict]: 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 + o_start = _outro_sec["start_bar"] * bb + o_beats = _outro_sec["bars"] * bb 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"] @@ -510,12 +518,12 @@ def _style_for(style_id: str | None) -> tuple[dict, dict]: # 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) + ending_start = float(total_bars * bb) 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 + ring = 0.35 if _ending_style == "cold" else bb 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 @@ -556,11 +564,12 @@ def _style_for(style_id: str | None) -> tuple[dict, dict]: 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]]: +def _section_markers(section_results: list[dict], home_key: str, + meter: Meter = DEFAULT_METER) -> 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), + (float(s["start_bar"] * meter.bar_beats), s["name"] if s.get("key", home_key) == home_key else f"{s['name']} ({s['key']})") for s in section_results ] @@ -569,7 +578,8 @@ def _section_markers(section_results: list[dict], home_key: str) -> list[tuple[f 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]: + scale: str = "minor", + meter: Meter = DEFAULT_METER) -> 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 @@ -602,7 +612,7 @@ def _write_song_output(song_events: dict, output_dir, gen_id: str, bpm: int, sty 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) + tempo_map = _song_tempo_map(section_results, bpm, ending_bars=1, meter=meter) _, track_names = part_midi_meta(style) files: list[FileInfo] = [] @@ -616,16 +626,16 @@ def _write_song_output(song_events: dict, output_dir, gen_id: str, bpm: int, sty 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)) + tempo_events=tempo_map, track_name=track_names.get(part), meter=meter) 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), + markers=_section_markers(section_results, key, meter), key_signature=mido_key_signature(key, scale), - track_names=track_names) + track_names=track_names, meter=meter) files.append(FileInfo(part="song", filename="song.mid", url=f"/exports/{gen_id}/song.mid")) return files diff --git a/docs/roadmap.md b/docs/roadmap.md index c5b4ad0..781daaa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -246,12 +246,26 @@ moves deferred (`_do_build_song`, the `toggle` shell). **Now starting Phase 4 — all 7 generators (which were already internally parameterized by a local `beats_per_bar`), `_plan_sections`, and the section loop. **4/4 stays byte-identical (135 backend tests green)** and non-4/4 runs end-to-end with correctly-sized bars + meta. - _Milestone 2 (remaining):_ generators still place some notes at fixed within-bar offsets that - overflow shorter bars (walking-bass beat 4, the drum 16-step grid) — needs per-generator - within-bar generalization; thread the meter through the song services (`song_builder`, - `mixdown`, `quality`) for full non-4/4 *songs*; idiomatic compound (6/8 triplet) / odd (7/8 - grouping) drum feel; a frontend selector; and 3/4/6/8/7/8 placement tests. Best done with - the app running so the *feel* gets ear-validated. → `backend/app/core/meter.py` + **Milestone 2 (mostly shipped 2026-07-29):** the meter is now threaded through the song + services — `song_builder` passes `time_signature` to every section request and scales all + bar→beat arithmetic (section offsets, tease/outro windows, the ending bar), and the + arrangement helpers (`_song_tempo_map`, `_apply_section_ramp`, `apply_arrangement_dynamics`, + `apply_melodic_pickups`, `_section_end_bars`, `_section_markers`) take a `meter` and place + ramps/dropouts/pickups/tempo-map/markers at meter-scaled positions. The regen/replay/undo + paths persist `time_signature` in `song_meta.json` and thread it back through. Generators no + longer overflow a short/odd bar: the **walking bass** steps one note per felt pulse + (`meter.pulse_positions`) off 4/4; the **drum grid** clips its 16-step mined patterns, + double-kick, jazz ride and bar-2 anticipation kicks to the bar and shifts end-of-bar + fills/rolls/ghosts to the real bar end (`fill_shift = bar_beats − 4`); the **riff** cells + space and clip to `bar_beats`. A **frontend time-signature selector** (curated simple / + compound / odd list) is on both the loop and song forms and round-trips through replay. + 81 placement tests cover 4/4·3/4·2/4·6/8·9/8·12/8·7/8·5/8 (onsets stay in-bar, walking-bass + note-count = pulse-count, song length + `time_signature` meta), and **4/4 stays + byte-identical** (216 backend tests green; explicit `Meter(4,4)` == default per generator). + _Remaining (best ear-validated with the app running):_ idiomatic compound (6/8 triplet) / + odd (7/8 2+2+3) drum *feel* — the current non-4/4 drums are correct and non-overflowing but + still swing/accent like a clipped 4/4 groove rather than in the meter's own grouping. → + `backend/app/core/meter.py` - [→] **Custom soundfont / SF2 upload** — _promoted to Phase 2 (Sound polish)._ - [ ] **Surface WAV/offline-audio export more prominently** — already implemented (OfflineAudioContext + render queue); just under-discovered. diff --git a/frontend/src/components/GenerateForm.vue b/frontend/src/components/GenerateForm.vue index 6630d6a..a57125a 100644 --- a/frontend/src/components/GenerateForm.vue +++ b/frontend/src/components/GenerateForm.vue @@ -73,6 +73,13 @@

{{ scaleMood }}

+
+ + +
+
@@ -304,10 +311,25 @@ const emit = defineEmits<{ const keys = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] const allParts = ['chords', 'bass', 'melody', 'drums', 'arpeggio', 'pads', 'counter_melody'] +// Curated meters. 4/4 is the default; the rest cover the common simple, compound, +// and odd feels the generators support (bad values fall back to 4/4 server-side). +const TIME_SIGNATURES = [ + { value: '4/4', label: '4/4 · common' }, + { value: '3/4', label: '3/4 · waltz' }, + { value: '2/4', label: '2/4 · march' }, + { value: '6/8', label: '6/8 · compound' }, + { value: '9/8', label: '9/8 · compound' }, + { value: '12/8', label: '12/8 · compound' }, + { value: '5/4', label: '5/4 · odd' }, + { value: '7/8', label: '7/8 · odd (2+2+3)' }, + { value: '5/8', label: '5/8 · odd (2+3)' }, +] + const form = reactive({ style_id: props.styles[0]?.id ?? 'dark_trap', key: 'C', scale: 'minor', + time_signature: '4/4', bpm: 140, bars: 8, complexity: 0.5, @@ -353,6 +375,7 @@ watch(() => props.replayData, (data) => { form.style_id = data.style form.key = data.summary.key_root form.scale = data.summary.scale + form.time_signature = data.summary.time_signature ?? '4/4' form.bpm = data.summary.bpm form.bars = data.summary.bars form.mode = data.summary.mode diff --git a/frontend/src/components/SongForm.vue b/frontend/src/components/SongForm.vue index 5dd471a..bc01835 100644 --- a/frontend/src/components/SongForm.vue +++ b/frontend/src/components/SongForm.vue @@ -104,6 +104,12 @@

{{ scaleMood }}

+
+ + +