From 8eb0f2c3f0adaa6e2ca87e5a08d3e1547a7df47e Mon Sep 17 00:00:00 2001 From: TwDover Date: Tue, 28 Jul 2026 22:45:20 -0400 Subject: [PATCH] feat/progression-picker-and-loop-polish --- backend/app/api/routes_generate.py | 14 +- backend/app/generators/drums.py | 46 ++++- backend/app/models/schemas.py | 1 + backend/app/services/style_loader.py | 22 +- backend/tests/test_meter_placement.py | 239 ++++++++++++++++++++++ docs/roadmap.md | 11 +- frontend/src/components/ExportPanel.vue | 1 + frontend/src/components/GenerateForm.vue | 209 ++++++++++++++++--- frontend/src/components/PartCard.vue | 3 +- frontend/src/components/SongForm.vue | 11 +- frontend/src/components/SongResult.vue | 3 +- frontend/src/composables/useMidiPlayer.ts | 7 +- frontend/src/types/midi.ts | 1 + 13 files changed, 521 insertions(+), 47 deletions(-) create mode 100644 backend/tests/test_meter_placement.py 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). +

+
+
+
+
-