diff --git a/backend/app/api/routes_song.py b/backend/app/api/routes_song.py index 502fb6e..4f2095b 100644 --- a/backend/app/api/routes_song.py +++ b/backend/app/api/routes_song.py @@ -160,7 +160,7 @@ def _do_build_song(req: BuildSongRequest, user_progression: list[str] | None = N "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, + "dynamics": req.dynamics, "tempo_automation": req.tempo_automation, "humanize": req.humanize, "parts": list(req.parts), "template": req.template, "use_priors": req.use_priors, "chorus_key_shift": chorus_key_shift, "bridge_key_shift": bridge_key_shift, "base_seed": base_seed, @@ -180,7 +180,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, - meter=parse_meter(getattr(req, "time_signature", None))) + meter=parse_meter(getattr(req, "time_signature", None)), + tempo_automation=req.tempo_automation) import json as _jsong (output_dir / "song_structure.json").write_text(_jsong.dumps(section_results, indent=2)) @@ -292,7 +293,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, meter=parse_meter(meta.get("time_signature", "4/4"))) + tempo_map = _song_tempo_map(_sections, bpm, ending_bars=1, meter=parse_meter(meta.get("time_signature", "4/4")), intensity=meta.get("tempo_automation", 0.5)) scaled = _drop_quiet(_scale_velocity(evts, req.part, _sid)) fname = f"{req.part}.mid" part_path = output_dir / fname @@ -366,7 +367,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, meter=parse_meter(meta.get("time_signature", "4/4"))) + tempo_map = _song_tempo_map(_sections, bpm, ending_bars=1, meter=parse_meter(meta.get("time_signature", "4/4")), intensity=meta.get("tempo_automation", 0.5)) 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, @@ -455,7 +456,7 @@ def keep_song_part_candidate(req: KeepSongPartCandidateRequest): custom=meta.get("custom_template")) 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"))), + tempo_events=_song_tempo_map(layout, bpm, ending_bars=1, meter=parse_meter(meta.get("time_signature", "4/4")), intensity=meta.get("tempo_automation", 0.5)), 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) @@ -498,6 +499,7 @@ async def build_song_from_melody( use_priors: bool = Form(False), chorus_key_shift: int = Form(0), final_chorus_lift: int = Form(1), + tempo_automation: float = Form(0.5), seed: int | None = Form(None), ): """Build a full song around an uploaded melody. @@ -535,6 +537,7 @@ async def build_song_from_melody( parts=[p.strip() for p in parts.split(",") if p.strip()], template=template, seed=seed, use_priors=use_priors, chorus_key_shift=chorus_key_shift, final_chorus_lift=final_chorus_lift, + tempo_automation=tempo_automation, ) return _do_build_song(req, user_progression=progression, hook_melody=melody) @@ -589,7 +592,7 @@ def set_part_gain(req: SetPartGainRequest): meta.get("style_id", ""), exc_info=True) _track_names = {} 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"))), + tempo_events=_song_tempo_map(layout, bpm, ending_bars=1, meter=parse_meter(meta.get("time_signature", "4/4")), intensity=meta.get("tempo_automation", 0.5)), 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) @@ -624,7 +627,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, meter=parse_meter(meta.get("time_signature", "4/4"))) + tempo_map = _song_tempo_map(layout, bpm, ending_bars=1, meter=parse_meter(meta.get("time_signature", "4/4")), intensity=meta.get("tempo_automation", 0.5)) total_bars = sum(s["bars"] for s in layout) channel = 9 if req.part == "drums" else _PART_CHANNELS.get(req.part, 0) @@ -782,7 +785,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, meter=parse_meter(meta.get("time_signature", "4/4"))) + tempo_map = _song_tempo_map(_layout, bpm, ending_bars=1, meter=parse_meter(meta.get("time_signature", "4/4")), intensity=meta.get("tempo_automation", 0.5)) shutil.copy(prev, output_dir / f"{req.part}.mid") prev.unlink() # one level of undo only @@ -870,7 +873,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"), - meter=parse_meter(meta.get("time_signature", "4/4"))) + meter=parse_meter(meta.get("time_signature", "4/4")), + tempo_automation=meta.get("tempo_automation", 0.5)) # 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 @@ -884,7 +888,7 @@ def regenerate_song_section(req: RegenerateSongSectionRequest): shutil.copy(prev, output_dir / f"{part}.mid") 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(section_results, bpm, ending_bars=1, 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")), intensity=meta.get("tempo_automation", 0.5)), 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) diff --git a/backend/app/core/arrangement.py b/backend/app/core/arrangement.py index 3d793c4..356d202 100644 --- a/backend/app/core/arrangement.py +++ b/backend/app/core/arrangement.py @@ -573,15 +573,22 @@ def apply_melodic_pickups(song_events: dict[str, list], def _song_tempo_map(section_results: list[dict], bpm: 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 - without reading as a tempo change — and drop back at the next section. - When an ending bar is appended, the last bar slows in four steps so the - final chord lands with weight. Returns (beat, bpm) points for the MIDI - tempo track; deterministic, so regeneration reproduces it exactly. + meter: Meter = DEFAULT_METER, + intensity: float = 0.5) -> list[tuple[float, float]]: + """Tempo map for a built song: chorus push + pre-chorus lean + final ritardando. + + `intensity` scales how much the tempo moves, via ``m = intensity / 0.5``: + • 0.0 → flat — a single tempo, no movement at all; + • 0.5 → the classic subtle default (m = 1): choruses ~1.2% faster than + base, pre-choruses lean +0.6%, and a four-step ending ritardando — + byte-for-byte the historical behavior; + • 1.0 → expressive (m = 2): the same gestures at double the depth. + Choruses drop back to base at the next section. Returns (beat, bpm) points + for the MIDI tempo track; deterministic, so regeneration reproduces it. """ + if intensity <= 0.0: + return [(0.0, float(bpm))] # flat: automation off + m = intensity / 0.5 bb = meter.bar_beats points: list[tuple[float, float]] = [(0.0, float(bpm))] in_push = False @@ -592,9 +599,9 @@ def _song_tempo_map(section_results: list[dict], bpm: float, if stype == "pre_chorus" and not in_push: # Micro-accel through the build: half the chorus push, so the # pre-chorus leans forward and the chorus completes the lift. - points.append((start_beat, bpm * 1.006)) + points.append((start_beat, bpm * (1.0 + 0.006 * m))) if is_chorus and not in_push: - points.append((start_beat, bpm * 1.012)) + points.append((start_beat, bpm * (1.0 + 0.012 * m))) in_push = True elif not is_chorus and stype != "pre_chorus" and in_push: points.append((start_beat, float(bpm))) @@ -605,6 +612,6 @@ def _song_tempo_map(section_results: list[dict], bpm: float, # 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 * bb - for i, factor in enumerate((0.96, 0.90, 0.84, 0.76)): - points.append((rit_start + i * (ending_bars * bb) / 4.0, bpm * factor)) + for i, depth in enumerate((0.04, 0.10, 0.16, 0.24)): + points.append((rit_start + i * (ending_bars * bb) / 4.0, bpm * (1.0 - depth * m))) return points diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 0b542ba..dfe6a5d 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -156,6 +156,7 @@ class BuildSongRequest(BaseModel): complexity: float = Field(default=0.6, ge=0.0, le=1.0) variation: float = Field(default=0.4, ge=0.0, le=1.0) dynamics: float = Field(default=0.5, ge=0.0, le=1.0) # arrangement drama: scales drops/fills/breakdowns/section contrast; 0.5 = classic + tempo_automation: float = Field(default=0.5, ge=0.0, le=1.0) # tempo movement: chorus push + pre-chorus lean + ending ritardando. 0 = flat/off, 0.5 = subtle (classic), 1 = expressive humanize: float = Field(default=0.5, ge=0.0, le=1.0) parts: List[str] = ["chords", "bass", "melody", "drums"] template: str = "verse_chorus" diff --git a/backend/app/services/song_builder.py b/backend/app/services/song_builder.py index 2addfab..6571c91 100644 --- a/backend/app/services/song_builder.py +++ b/backend/app/services/song_builder.py @@ -579,7 +579,8 @@ def _write_song_output(song_events: dict, output_dir, gen_id: str, bpm: int, sty programs: dict, parts: list[str], total_bars: int, section_results: list[dict], key: str = "C", scale: str = "minor", - meter: Meter = DEFAULT_METER) -> list[FileInfo]: + meter: Meter = DEFAULT_METER, + tempo_automation: float = 0.5) -> 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 @@ -612,7 +613,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, meter=meter) + tempo_map = _song_tempo_map(section_results, bpm, ending_bars=1, meter=meter, intensity=tempo_automation) _, track_names = part_midi_meta(style) files: list[FileInfo] = [] diff --git a/backend/tests/test_song_features.py b/backend/tests/test_song_features.py index 9cff935..639144c 100644 --- a/backend/tests/test_song_features.py +++ b/backend/tests/test_song_features.py @@ -209,7 +209,7 @@ def test_build_song_from_melody_end_to_end(): file=upload, style_id="lofi", template="compact", parts="chords,bass,melody,drums", complexity=0.6, variation=0.4, humanize=0.5, use_priors=False, chorus_key_shift=0, final_chorus_lift=0, - seed=64)) + tempo_automation=0.5, seed=64)) assert r.key.startswith("C major") d = EXPORTS_DIR / r.generation_id @@ -317,3 +317,31 @@ def test_edit_part_404_on_missing_song_or_stem(): with pytest.raises(HTTPException) as exc: edit_part(EditPartRequest(generation_id=r.generation_id, part="melody", notes=note)) assert exc.value.status_code == 404 + + +def test_song_tempo_map_intensity(): + """The tempo-automation knob scales the chorus push, pre-chorus lean and + ending ritardando: 0 = flat, 0.5 = the classic subtle default, 1 = double.""" + from app.core.arrangement import _song_tempo_map + bpm = 120.0 + sections = [ + {"start_bar": 0, "bars": 4, "section_type": "verse"}, + {"start_bar": 4, "bars": 2, "section_type": "pre_chorus"}, + {"start_bar": 6, "bars": 4, "section_type": "chorus"}, + {"start_bar": 10, "bars": 4, "section_type": "verse"}, # drops back to base + ] + + # Off → a single flat tempo point, no movement anywhere. + assert _song_tempo_map(sections, bpm, ending_bars=1, intensity=0.0) == [(0.0, 120.0)] + + # Subtle (default 0.5) → the historical values, byte-for-byte. + subtle = {round(b, 4) for _, b in _song_tempo_map(sections, bpm, ending_bars=1, intensity=0.5)} + assert round(120 * 1.006, 4) in subtle # pre-chorus lean + assert round(120 * 1.012, 4) in subtle # chorus push + assert round(120 * 0.76, 4) in subtle # deepest ritardando step + assert 120.0 in subtle # base + drop-back to base after chorus + + # Expressive (1.0) → the same gestures at double the deviation. + expr = {round(b, 4) for _, b in _song_tempo_map(sections, bpm, ending_bars=1, intensity=1.0)} + assert round(120 * 1.024, 4) in expr # chorus push doubled + assert round(120 * 0.52, 4) in expr # deepest ritardando doubled (1 - 0.48) diff --git a/docs/roadmap.md b/docs/roadmap.md index 5c75be2..42c2edf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -274,9 +274,22 @@ moves deferred (`_do_build_song`, the `toggle` shell). **Now starting Phase 4 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. -- [ ] **Tempo automation & mid-song modulation** — partial today (`chorus_key_shift`). +- [x] **Surface WAV/offline-audio export more prominently** — the OfflineAudioContext + render + queue existed but the WAV/Stems buttons were lost in a single long action row + (Copy·Replay·Save·Share·Download·ZIP·WAV·Stems) with a cryptic ⚡ and no signal audio + export existed. Split out a labelled **Export** row that separates **Audio** (accent-styled + `⏬ WAV` / `⏬ Stems (WAV)`) from **MIDI** (`All parts (ZIP)` / `Sections (ZIP)`), so audio + export reads as a first-class action. → `frontend/src/components/ExportPanel.vue` +- [x] **Tempo automation & mid-song modulation** — _mid-song modulation was already + fully shipped_ (`chorus_key_shift` on every chorus, `bridge_key_shift` to the subdominant, + `final_chorus_lift` gear-change on the last chorus — all with SongForm controls). The gap + was **tempo automation**: the song tempo map (chorus push + pre-chorus lean + ending + ritardando) existed but was hardcoded and invisible. Now a **`tempo_automation` knob** + (0–1) scales all three gestures via `m = intensity/0.5`: **0 = flat/steady**, **0.5 = + the classic subtle default** (byte-identical to before), **1 = expressive** (double depth). + Exposed as a **Tempo motion** control (Off / Subtle / Expressive) on the song form, + threaded through build + melody-upload + every regen/replay path (persisted in + `song_meta.json` so re-rolls stay sample-locked). → `core/arrangement.py` (`_song_tempo_map`) --- diff --git a/frontend/src/components/ExportPanel.vue b/frontend/src/components/ExportPanel.vue index 8885c8b..ee44f85 100644 --- a/frontend/src/components/ExportPanel.vue +++ b/frontend/src/components/ExportPanel.vue @@ -86,44 +86,52 @@ - - + + + +
+ Export + Audio + MIDI + +
Progression @@ -727,6 +735,38 @@ async function handleZipDownload(response: GenerateResponse, kind: 'bundle' | 's font-family: var(--f-mono); } +/* Export row — a labelled home for the downloads, so WAV/audio export isn't + lost among the seed/save/share actions. Mirrors .seed-row's chrome. */ +.export-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.4rem; + padding: 0.5rem 0.75rem; + background: var(--panel-deep); + border-radius: 6px; + border: 1px solid var(--surface); +} +.export-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); +} +.export-kind { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--accent); + font-weight: 600; +} +.export-kind-midi { color: var(--text-faint); margin-left: 0.35rem; } + +/* Audio-export buttons carry the accent so they read as the primary export. */ +.btn-audio { color: var(--accent); border-color: var(--accent-edge); } +.btn-audio:hover:not(:disabled) { background: var(--accent-wash); color: var(--accent); border-color: var(--accent); } + .regen-error { font-size: 0.75rem; color: var(--error); diff --git a/frontend/src/components/SongForm.vue b/frontend/src/components/SongForm.vue index a2fc5e8..890d82b 100644 --- a/frontend/src/components/SongForm.vue +++ b/frontend/src/components/SongForm.vue @@ -176,6 +176,14 @@
+
+ + +
@@ -331,6 +339,7 @@ const form = ref({ use_priors: false, chorus_key_shift: 0, final_chorus_lift: 1, + tempo_automation: 0.5, blend_style_id: '' as string, blend_amount: 0.5, }) @@ -403,6 +412,7 @@ async function generate() { use_priors: form.value.use_priors, chorus_key_shift: form.value.chorus_key_shift, final_chorus_lift: form.value.final_chorus_lift, + tempo_automation: form.value.tempo_automation, }) emit('built', result, `${templateLabel.value} (your melody)`) return diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 77de8fa..5ca6e74 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -147,7 +147,8 @@ export async function buildSongFromMelody( params: { style_id: string; template: string; parts: string[] complexity: number; variation: number; humanize: number - use_priors?: boolean; chorus_key_shift?: number; final_chorus_lift?: number; seed?: number + use_priors?: boolean; chorus_key_shift?: number; final_chorus_lift?: number + tempo_automation?: number; seed?: number }, ): Promise { const fd = new FormData() @@ -161,6 +162,7 @@ export async function buildSongFromMelody( fd.append('use_priors', String(params.use_priors ?? false)) fd.append('chorus_key_shift', String(params.chorus_key_shift ?? 0)) fd.append('final_chorus_lift', String(params.final_chorus_lift ?? 1)) + fd.append('tempo_automation', String(params.tempo_automation ?? 0.5)) if (params.seed != null) fd.append('seed', String(params.seed)) const res = await fetch(`${BASE_URL}/build-song-from-melody`, { method: 'POST', body: fd }) if (!res.ok) { diff --git a/frontend/src/types/midi.ts b/frontend/src/types/midi.ts index 189cfb7..4505343 100644 --- a/frontend/src/types/midi.ts +++ b/frontend/src/types/midi.ts @@ -143,6 +143,7 @@ export interface BuildSongRequest { complexity: number variation: number dynamics?: number + tempo_automation?: number // tempo movement: 0 = flat, 0.5 = subtle, 1 = expressive humanize: number parts: string[] template: string