Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions backend/app/api/routes_generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
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, write_midi, write_combined_midi, rebuild_combined_from_parts, concatenate_midi_files, read_note_starts
from app.core.meter import parse_meter
from app.generators.chords import generate_chords, resolve_progression
from app.generators.bass import generate_bass
from app.generators.melody import generate_melody
Expand Down Expand Up @@ -73,6 +74,10 @@ def generate(req: GenerateRequest):
tritone_sub = style.get("tritone_substitution", False)
is_loop = (req.mode == "loop")
groove_push = style.get("groove_push", 0.0)
# The generators already place events in this meter (see services.generation);
# thread it to the writer too so the .mid's time_signature meta matches instead
# of always claiming 4/4. 4/4 → DEFAULT_METER, byte-identical.
meter = parse_meter(getattr(req, "time_signature", None))

style = _blend_styles(style, req.blend_style_id, req.blend_amount)

Expand Down Expand Up @@ -171,14 +176,14 @@ def _run_best_attempt():
out_path = output_dir / filename
write_midi(events, out_path, bpm=bpm, program=programs.get(part),
cc_events=cc_parts.get(part), pb_events=pb_parts.get(part),
track_name=track_names.get(part))
track_name=track_names.get(part), meter=meter)
files.append(FileInfo(part=part, filename=filename, url=f"/exports/{gen_id}/{filename}"))

if len(all_events) > 1:
combined_path = output_dir / "combined.mid"
clean_events = {p: _drop_quiet(_scale_velocity(e, p, _sid)) for p, e in all_events.items()}
write_combined_midi(clean_events, combined_path, bpm=bpm, programs=programs,
cc_parts=cc_parts, pb_parts=pb_parts, track_names=track_names)
cc_parts=cc_parts, pb_parts=pb_parts, track_names=track_names, meter=meter)
files.append(FileInfo(part="combined", filename="combined.mid", url=f"/exports/{gen_id}/combined.mid"))

# In arrangement mode, also write per-section MIDI files
Expand Down Expand Up @@ -253,6 +258,7 @@ def event_stream():
tritone_sub = style.get("tritone_substitution", False)
is_loop = (req.mode == "loop")
groove_push = style.get("groove_push", 0.0)
meter = parse_meter(getattr(req, "time_signature", None))
style = {**style, "_humanize_scale": req.humanize}
if req.custom_progression:
style = {**style, "progression_templates": [req.custom_progression]}
Expand Down Expand Up @@ -306,15 +312,15 @@ def event_stream():
program=programs.get(part),
cc_events=(best_cc or {}).get(part),
pb_events=(best_pb or {}).get(part),
track_name=track_names.get(part))
track_name=track_names.get(part), meter=meter)
files.append(FileInfo(part=part, filename=filename, url=f"/exports/{gen_id}/{filename}"))

if best_events and len(best_events) > 1:
combined_path = output_dir / "combined.mid"
clean = {p: _drop_quiet(_scale_velocity(e, p, _sid)) for p, e in best_events.items()}
write_combined_midi(clean, combined_path, bpm=bpm, programs=programs,
cc_parts=best_cc or {}, pb_parts=best_pb or {},
track_names=track_names)
track_names=track_names, meter=meter)
files.append(FileInfo(part="combined", filename="combined.mid",
url=f"/exports/{gen_id}/combined.mid"))

Expand Down
46 changes: 42 additions & 4 deletions backend/app/generators/drums.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ def generate_drums(
snare_beats = drum_cfg.get("snare_standard_beats", [2, 4])
if half_time:
snare_beats = [3]
meter_is_44 = meter == Meter(4, 4)
swing_amount = drum_cfg.get("swing", 0.0)
use_ride = drum_cfg.get("use_ride", False)
use_clap = drum_cfg.get("use_clap", False)
Expand Down Expand Up @@ -195,6 +196,22 @@ def generate_drums(
# 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]
# Idiomatic backbeat for non-4/4: land the snare on FELT pulses, not the
# clipped 4/4 [2,4] grid. Compound meters (6/8, 9/8, 12/8) put it on the
# "off" dotted-quarter pulses (6/8 → pulse 2 only; 12/8 → pulses 2 & 4);
# simple/odd meters land it on the central pulse (3/4 → beat 2, 7/8 → the
# start of the second group). Values are 1-indexed quarter positions so the
# snare loop's `b_f = beat − 1` still applies. 4/4 and half-time skip this
# (byte-identical). Ear-tuned defaults — the roadmap flags feel for review.
if not meter_is_44 and not half_time:
pulses = meter.pulse_positions
if meter.is_compound:
backbeat = pulses[1::2] or [pulses[-1]]
elif len(pulses) > 1:
backbeat = [pulses[len(pulses) // 2]]
else:
backbeat = pulses
snare_beats = [p + 1.0 for p in backbeat]

# Decide hat subdivision mode once per 4-bar phrase.
phrase_hat_modes: dict[int, bool] = {}
Expand Down Expand Up @@ -401,7 +418,16 @@ def generate_drums(

# ── Hi-hats / ride ────────────────────────────────────────────────────
use_triplet = phrase_hat_modes[bar // 4]
if use_triplet:
# Compound meters (6/8, 9/8, 12/8) FEEL in eighths grouped by three: the
# eighth-note IS the natural subdivision, so the hat rides the eighth
# grid (0.5-beat spacing) with the dotted-quarter pulse heads accented —
# not a 16th grid clipped to the bar (which reads as a busy, un-grouped
# 4/4). This overrides the probabilistic triplet mode for compound bars.
compound_feel = meter.is_compound and not use_ride
if compound_feel:
use_triplet = False
hat_steps = [bar_start + i * 0.5 for i in range(int(round(beats_per_bar / 0.5)))]
elif use_triplet:
# 12 triplet 8th notes per bar (3 per beat × 4 beats)
hat_steps = [bar_start + i / 3.0 for i in range(int(round(beats_per_bar * 3)))]
else:
Expand Down Expand Up @@ -467,8 +493,10 @@ def generate_drums(
step_idx = round(pos_key / step)

# Placement: a real drummer's per-step hat probability (mined) on the
# straight grid, else the procedural density.
if mined_hat_pattern is not None and not use_triplet:
# straight grid, else the procedural density. The mined curve is a
# 16-step 4/4 pattern, so it's meaningless on a compound eighth grid
# — compound bars fall through to the procedural density instead.
if mined_hat_pattern is not None and not use_triplet and not compound_feel:
place_p = mined_hat_pattern[step_idx % 16] * hat_breath
else:
place_p = hat_density * hat_breath * (0.70 + complexity * 0.30)
Expand All @@ -484,7 +512,17 @@ def generate_drums(
is_eighth = beat_frac < 0.01 or abs(beat_frac - 0.5) < 0.01

# 16-step accent weight — mined velocity curve when available.
if not use_triplet:
if compound_feel:
# Group of three eighths: strong pulse head, weak middle, a
# slight lift on the third that leans into the next pulse.
within = int(round(pos_key / 0.5)) % 3
if within == 0:
accent = 1.0 if pos_key < 0.01 else 0.86
elif within == 1:
accent = 0.50
else:
accent = 0.62
elif not use_triplet:
accent = (mined_hat_vel[step_idx % 16] if mined_hat_vel is not None
else _HAT_VEL_WEIGHTS[step_idx % 16])
else:
Expand Down
1 change: 1 addition & 0 deletions backend/app/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ class StyleInfo(BaseModel):
has_prior: bool = False # a mined corpus prior exists for this style
instruments: dict[str, str] = {} # part role → instrument display name ("melody": "Alto Sax")
voices: dict[str, str] = {} # part role → playback voice id ("melody": "melody_lead") — drives in-app audio
progression_templates: List[List[str]] = [] # the style's roman-numeral progressions, for the picker


class FileInfo(BaseModel):
Expand Down
22 changes: 20 additions & 2 deletions backend/app/services/style_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ def _voices(data: dict) -> dict[str, str]:
from app.core.instruments import instrumentation_for
return {part: inst["playback_voice"] for part, inst in instrumentation_for(data).items()}

def _progressions(data: dict) -> list[list[str]]:
"""The style's shipped progression templates, de-duplicated and capped,
so the frontend can offer them as one-click picks in the progression
control. Order-preserving dedupe keeps the style's own priority."""
seen_p: set[tuple[str, ...]] = set()
out: list[list[str]] = []
for tmpl in data.get("progression_templates", []):
if not isinstance(tmpl, list):
continue
key = tuple(tmpl)
if key in seen_p:
continue
seen_p.add(key)
out.append(list(tmpl))
if len(out) >= 24:
break
return out

styles = []
seen: set[str] = set()
# Custom styles override built-ins with the same id
Expand All @@ -58,7 +76,7 @@ def _voices(data: dict) -> dict[str, str]:
with open(path) as f:
data = json.load(f)
seen.add(data["id"])
styles.append({"id": data["id"], "name": data["name"], "bpm_range": data.get("bpm_range", [40, 240]), "default_scale": data.get("default_scale", "minor"), "custom": True, "has_prior": _has_prior(data), "instruments": _instruments(data), "voices": _voices(data)})
styles.append({"id": data["id"], "name": data["name"], "bpm_range": data.get("bpm_range", [40, 240]), "default_scale": data.get("default_scale", "minor"), "custom": True, "has_prior": _has_prior(data), "instruments": _instruments(data), "voices": _voices(data), "progression_templates": _progressions(data)})
except Exception as exc:
_logger.warning("Skipping malformed custom style %s: %s", path, exc)
for path in sorted(STYLES_DIR.glob("*.json")):
Expand All @@ -67,7 +85,7 @@ def _voices(data: dict) -> dict[str, str]:
data = json.load(f)
if data["id"] in seen:
continue
styles.append({"id": data["id"], "name": data["name"], "bpm_range": data.get("bpm_range", [40, 240]), "default_scale": data.get("default_scale", "minor"), "has_prior": _has_prior(data), "instruments": _instruments(data), "voices": _voices(data)})
styles.append({"id": data["id"], "name": data["name"], "bpm_range": data.get("bpm_range", [40, 240]), "default_scale": data.get("default_scale", "minor"), "has_prior": _has_prior(data), "instruments": _instruments(data), "voices": _voices(data), "progression_templates": _progressions(data)})
except Exception as exc:
_logger.warning("Skipping malformed style %s: %s", path, exc)
return sorted(styles, key=lambda s: s["name"])
Expand Down
Loading
Loading