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
24 changes: 14 additions & 10 deletions backend/app/api/routes_song.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
31 changes: 19 additions & 12 deletions backend/app/core/arrangement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)))
Expand All @@ -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
1 change: 1 addition & 0 deletions backend/app/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions backend/app/services/song_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] = []
Expand Down
30 changes: 29 additions & 1 deletion backend/tests/test_song_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
19 changes: 16 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

---

Expand Down
Loading
Loading