diff --git a/backend/app/api/routes_generate.py b/backend/app/api/routes_generate.py
index d6bf9db..7adbd53 100644
--- a/backend/app/api/routes_generate.py
+++ b/backend/app/api/routes_generate.py
@@ -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
@@ -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)
@@ -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
@@ -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]}
@@ -306,7 +312,7 @@ 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:
@@ -314,7 +320,7 @@ def event_stream():
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"))
diff --git a/backend/app/generators/drums.py b/backend/app/generators/drums.py
index ef8875e..38ea348 100644
--- a/backend/app/generators/drums.py
+++ b/backend/app/generators/drums.py
@@ -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)
@@ -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] = {}
@@ -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:
@@ -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)
@@ -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:
diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py
index 87ea0f1..0b542ba 100644
--- a/backend/app/models/schemas.py
+++ b/backend/app/models/schemas.py
@@ -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):
diff --git a/backend/app/services/style_loader.py b/backend/app/services/style_loader.py
index ca4d2a3..4a730e1 100644
--- a/backend/app/services/style_loader.py
+++ b/backend/app/services/style_loader.py
@@ -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
@@ -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")):
@@ -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"])
diff --git a/backend/tests/test_meter_placement.py b/backend/tests/test_meter_placement.py
new file mode 100644
index 0000000..2ddbc60
--- /dev/null
+++ b/backend/tests/test_meter_placement.py
@@ -0,0 +1,239 @@
+# 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.
+"""Non-4/4 placement tests (roadmap Phase 4, Milestone 2).
+
+Two guarantees:
+ 1. In any meter, no generated onset falls outside the bar it belongs to and
+ nothing spills past the end of the loop.
+ 2. 4/4 stays byte-identical — passing an explicit Meter(4, 4) produces the
+ exact same events as the default, so the meter plumbing is a no-op there.
+"""
+import random
+
+import mido
+import pytest
+
+from app.core.meter import Meter, parse_meter, DEFAULT_METER
+from app.generators.bass import generate_bass
+from app.generators.drums import generate_drums
+from app.generators.chords import generate_chords
+from app.services.style_loader import load_style
+
+_METERS = ["4/4", "3/4", "2/4", "6/8", "9/8", "12/8", "7/8", "5/8"]
+_BARS = 8
+
+
+# ── Meter model math ─────────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("sig,bar_beats,steps,pulses", [
+ ("4/4", 4.0, 16, [0.0, 1.0, 2.0, 3.0]),
+ ("3/4", 3.0, 12, [0.0, 1.0, 2.0]),
+ ("6/8", 3.0, 12, [0.0, 1.5]),
+ ("7/8", 3.5, 14, [0.0, 1.0, 2.0]), # 2+2+3 grouping
+ ("9/8", 4.5, 18, [0.0, 1.5, 3.0]),
+ ("5/8", 2.5, 10, [0.0, 1.0]), # 2+3 grouping
+])
+def test_meter_math(sig, bar_beats, steps, pulses):
+ m = parse_meter(sig)
+ assert m.bar_beats == bar_beats
+ assert m.steps_per_bar == steps
+ assert m.pulse_positions == pulses
+
+
+def test_compound_meters_flagged():
+ for sig in ("6/8", "9/8", "12/8"):
+ assert parse_meter(sig).is_compound
+ for sig in ("4/4", "3/4", "7/8", "5/8"):
+ assert not parse_meter(sig).is_compound
+
+
+# ── No onset overflows its bar, in any meter ─────────────────────────────────
+
+def _assert_within_bars(events, meter, bars, label):
+ bb = meter.bar_beats
+ span = bars * bb
+ for e in events:
+ rel = e.start % bb
+ assert -1e-6 <= rel < bb, f"{label}: onset {e.start} sits outside its {meter} bar"
+ assert e.start < span + 1e-6, f"{label}: onset {e.start} runs past the {span}-beat loop"
+
+
+@pytest.mark.parametrize("sig", _METERS)
+@pytest.mark.parametrize("style_id", ["rock", "house", "metal", "jazz", "funk"])
+def test_drum_onsets_stay_in_bar(sig, style_id):
+ style = load_style(style_id)
+ meter = parse_meter(sig)
+ random.seed(11)
+ events = generate_drums(style, _BARS, 0.7, 0.5, meter=meter, is_loop=True)
+ assert events, f"{style_id} {sig}: no drum events"
+ _assert_within_bars(events, meter, _BARS, f"drums/{style_id}")
+
+
+@pytest.mark.parametrize("sig", _METERS)
+def test_walking_bass_onsets_stay_in_bar(sig):
+ style = load_style("jazz") # jazz uses walking bass
+ meter = parse_meter(sig)
+ random.seed(7)
+ events = generate_bass(style, "C", "major", _BARS, 0.6, 0.5,
+ progression=["ii", "V", "I", "vi"], meter=meter)
+ assert events
+ _assert_within_bars(events, meter, _BARS, f"bass/{sig}")
+
+
+@pytest.mark.parametrize("sig", _METERS)
+def test_walking_bass_note_count_matches_pulses(sig):
+ """The pulse walk places one note per felt pulse per bar (4/4 keeps its
+ classic four-crotchet walk)."""
+ style = load_style("jazz")
+ meter = parse_meter(sig)
+ random.seed(7)
+ events = generate_bass(style, "C", "major", _BARS, 0.6, 0.5,
+ progression=["ii", "V", "I", "vi"], meter=meter)
+ expected = len(meter.pulse_positions) * _BARS
+ assert len(events) == expected, f"{sig}: {len(events)} notes, expected {expected}"
+
+
+@pytest.mark.parametrize("sig", _METERS)
+def test_riff_chords_stay_in_bar(sig):
+ style = load_style("metal") # metal comps with a riff (16-step 4/4 cells)
+ meter = parse_meter(sig)
+ random.seed(3)
+ events = generate_chords(style, "E", "minor", _BARS, 0.7, 0.5,
+ progression=["i", "VI", "III", "VII"],
+ meter=meter, section_type="verse")
+ assert events
+ _assert_within_bars(events, meter, _BARS, f"riff-chords/{sig}")
+
+
+# ── Idiomatic compound / odd feel ────────────────────────────────────────────
+
+def _snare_onsets(events):
+ from app.core.constants import DRUM_MAP
+ return sorted({round(e.start, 3) for e in events if e.pitch == DRUM_MAP["snare"]})
+
+
+def _hat_onsets(events):
+ from app.core.constants import DRUM_MAP
+ hats = {DRUM_MAP["closed_hat"], DRUM_MAP["open_hat"]}
+ return sorted({round(e.start, 3) for e in events if e.pitch in hats})
+
+
+@pytest.mark.parametrize("sig,pulse", [
+ ("6/8", 1.5), # compound duple → snare on the 2nd dotted-quarter pulse
+ ("3/4", 1.0), # waltz → backbeat on beat 2
+ ("7/8", 1.0), # 2+2+3 → snare starts the second group
+])
+def test_snare_lands_on_felt_pulse(sig, pulse):
+ """Non-4/4 snare backbone sits on a felt pulse, not the clipped 4/4 grid.
+ Ghost snares are quiet (<50) and filtered out — we check backbeat hits."""
+ style = load_style("rock")
+ meter = parse_meter(sig)
+ random.seed(4)
+ events = generate_drums(style, _BARS, 0.6, 0.4, meter=meter, is_loop=True)
+ # Humanize jitter offsets onsets by up to ~0.02 beats — match within tolerance.
+ backbeats = sorted({round(e.start % meter.bar_beats, 3)
+ for e in _backbeat_snares(events)})
+ assert any(abs(b - pulse) < 0.05 for b in backbeats), \
+ f"{sig}: snare backbeats {backbeats}, expected one near {pulse}"
+
+
+def _backbeat_snares(events):
+ from app.core.constants import DRUM_MAP
+ return [e for e in events if e.pitch == DRUM_MAP["snare"] and e.velocity >= 60]
+
+
+@pytest.mark.parametrize("sig", ["6/8", "9/8", "12/8"])
+def test_compound_hats_ride_eighth_grid(sig):
+ """Compound hats fall on the eighth-note grid (0.5-beat spacing), not a
+ 16th grid — every onset is a whole multiple of 0.5 within the bar."""
+ style = load_style("rock")
+ meter = parse_meter(sig)
+ random.seed(8)
+ events = generate_drums(style, _BARS, 0.6, 0.3, meter=meter, is_loop=True)
+ for h in _hat_onsets(events):
+ rel = h % meter.bar_beats
+ # Nearest eighth-grid slot, allowing for humanize jitter (~0.02 beats).
+ nearest = round(rel / 0.5) * 0.5
+ assert abs(rel - nearest) < 0.05, f"{sig}: hat at {round(rel,3)} off the eighth grid"
+
+
+# ── 4/4 stays byte-identical ─────────────────────────────────────────────────
+
+@pytest.mark.parametrize("style_id", ["rock", "house", "metal", "jazz"])
+def test_drums_44_byte_identical(style_id):
+ style = load_style(style_id)
+ random.seed(99)
+ default = generate_drums(style, _BARS, 0.7, 0.5, is_loop=True)
+ random.seed(99)
+ explicit = generate_drums(style, _BARS, 0.7, 0.5, meter=Meter(4, 4), is_loop=True)
+ assert default == explicit
+
+
+def test_walking_bass_44_byte_identical():
+ style = load_style("jazz")
+ random.seed(42)
+ default = generate_bass(style, "C", "major", _BARS, 0.6, 0.5,
+ progression=["ii", "V", "I", "vi"])
+ random.seed(42)
+ explicit = generate_bass(style, "C", "major", _BARS, 0.6, 0.5,
+ progression=["ii", "V", "I", "vi"], meter=Meter(4, 4))
+ assert default == explicit
+
+
+def test_riff_chords_44_byte_identical():
+ style = load_style("metal")
+ random.seed(5)
+ default = generate_chords(style, "E", "minor", _BARS, 0.7, 0.5,
+ progression=["i", "VI", "III", "VII"], section_type="verse")
+ random.seed(5)
+ explicit = generate_chords(style, "E", "minor", _BARS, 0.7, 0.5,
+ progression=["i", "VI", "III", "VII"],
+ meter=Meter(4, 4), section_type="verse")
+ assert default == explicit
+
+
+# ── End-to-end song length + MIDI meta ───────────────────────────────────────
+
+@pytest.mark.parametrize("sig", ["4/4", "3/4", "6/8", "7/8"])
+def test_song_length_and_meta(tmp_path, sig):
+ from app.models.schemas import BuildSongRequest
+ from app.api.routes_song import _do_build_song
+ from app.core.config import EXPORTS_DIR
+
+ req = BuildSongRequest(
+ style_id="rock", key="C", scale="major", bpm=120,
+ complexity=0.6, variation=0.5, parts=["drums", "bass", "chords", "melody"],
+ template="verse_chorus", seed=42, time_signature=sig,
+ )
+ resp = _do_build_song(req)
+ meter = parse_meter(sig)
+
+ song = mido.MidiFile(EXPORTS_DIR / resp.generation_id / "song.mid")
+ ts = next((m for tr in song.tracks for m in tr if m.type == "time_signature"), None)
+ assert ts is not None
+ assert (ts.numerator, ts.denominator) == (meter.numerator, meter.denominator)
+ # Compound meters click on the dotted quarter (36 clocks); everything else on 24.
+ assert ts.clocks_per_click == (36 if meter.is_compound else 24)
+
+ # Bars are meter-scaled: a 3/4 song is shorter in beats than the same 4/4 one.
+ tpb = song.ticks_per_beat
+ last_beat = 0.0
+ for tr in song.tracks:
+ t = 0
+ for msg in tr:
+ t += msg.time
+ if msg.type in ("note_on", "note_off"):
+ last_beat = max(last_beat, t / tpb)
+ # The last note lands inside the final bar; total span is a whole number of
+ # meter-scaled bars, so last_beat / bar_beats is a plausible bar count.
+ approx_bars = last_beat / meter.bar_beats
+ assert 4 <= approx_bars, f"{sig}: song only {approx_bars:.1f} bars long"
+ # And a shorter meter really is shorter in absolute beats than 4/4 would be.
+ if meter.bar_beats < 4.0:
+ assert last_beat < approx_bars * 4.0
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 781daaa..5c75be2 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -235,8 +235,15 @@ moves deferred (`_do_build_song`, the `toggle` shell). **Now starting Phase 4
## Phase 4 — Features
-- [ ] **Expose the data-driven priors toggle in the UI** — wired end-to-end already, no
- frontend control yet. Low effort.
+- [x] **Expose the data-driven priors toggle in the UI** — a "Use my local MIDI corpus"
+ checkbox is on both the loop (`GenerateForm`) and song (`SongForm`) forms, bound to
+ `form.use_priors` and threaded through `handleGenerate` → `generate()` → the request body
+ → the backend's `use_priors`. It was already wired; the earlier "no control yet" note was
+ stale (priors are gitignored, so on a fresh clone no style reports `has_prior` and the
+ toggle self-hid). Now the toggle **always renders**: enabled for styles that have a mined
+ prior, and shown **disabled with a "mine a MIDI corpus for this style to enable" hint**
+ otherwise, so the feature is discoverable before you've mined anything.
+ → `frontend/src/components/GenerateForm.vue`, `SongForm.vue`
- [~] **Non-4/4 time signatures** (6/8, 3/4, 7/8) — **Milestone 1 (foundation) shipped.**
A `Meter` model (`core/meter.py`) centralizes the math: `bar_beats` (bar length in
quarter-beats — 4/4→4.0, 3/4→3.0, 6/8→3.0, 7/8→3.5), compound/odd detection, and pulse
diff --git a/frontend/src/components/ExportPanel.vue b/frontend/src/components/ExportPanel.vue
index caea379..8885c8b 100644
--- a/frontend/src/components/ExportPanel.vue
+++ b/frontend/src/components/ExportPanel.vue
@@ -162,6 +162,7 @@
:hasUndo="!!undoFiles[`${response.generation_id}:${file.part}`]"
:keyRoot="response.summary.key_root"
:scale="response.summary.scale"
+ :loop="response.summary.mode === 'loop'"
@regen="handleRegen(response, file.part)"
@toggle-lock="toggleLock(response.generation_id, file.part)"
@undo="handleUndo(response, file.part)"
diff --git a/frontend/src/components/GenerateForm.vue b/frontend/src/components/GenerateForm.vue
index a57125a..541fd89 100644
--- a/frontend/src/components/GenerateForm.vue
+++ b/frontend/src/components/GenerateForm.vue
@@ -73,6 +73,60 @@
{{ scaleMood }}
+
+
+
+
+
+
{{ chordLabel(currentTokens) }}
+
+ {{ chordLabel(currentTokens) }}
+
+
+
+
+
+
+
{{ progressionError }}
+
{{ chordLabel(form.custom_progression) }}
+
+
+
+
+
+ What's this notation?
+
+ Roman numerals name chords by their position in the key, so the
+ progression follows whatever Key you pick.
+ Uppercase = major (V), lowercase = minor (ii);
+ add quality like maj7, m7, 7,
+ dim, sus4. Leading b flattens
+ the root (bVII).
+