Spaces:
Running on Zero
Running on Zero
AndrianBalanescu commited on
Commit ·
ac4a7ec
1
Parent(s): b162b1b
Fix song farm duration: size lyrics to hit 2:30-3:30
Browse filesMiniMax Music 3 sings the lyrics through ONCE at ~15s/line, so track
duration = line count, NOT the audio_duration param (which it ignores).
Earlier farm songs came out 66-115s because the LLM baked 30-50 line
telenovela lyrics.
- bake_multilevel_lyrics: compute target_lines from duration_seconds
(180s -> 12 lines), inject a hard length rule into the prompt, and
cap parsed sections as a backstop (LLM ignored the prompt alone)
- _cap_lyric_sections: trim to target, preserving intro/verse/chorus spine
- tests: 281 passed (cap unit test + target_lines scaling)
moldovan-qwen/engine/lyricist_engine.py
CHANGED
|
@@ -399,6 +399,21 @@ REGULI CRUCIALE DE COMPOZIȚIE:
|
|
| 399 |
|
| 400 |
user_prompt = f"Compune o piesă virală despre: '{clean_topic}'. Vibe: {intent}, Gen: {genre} ({bpm} BPM), Stil vocal: {vocal_style}."
|
| 401 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
raw_text = self._call_llm(system_prompt, user_prompt)
|
| 403 |
parsed = self._parse_raw_lyrics_to_structure(raw_text, clean_topic, genre, bpm, intent)
|
| 404 |
|
|
@@ -407,6 +422,7 @@ REGULI CRUCIALE DE COMPOZIȚIE:
|
|
| 407 |
|
| 408 |
# Enhance with Phonetic Singing Diction & Rhyme Meter
|
| 409 |
sections = parsed.get("sections", [])
|
|
|
|
| 410 |
phonetic_sections = self.phonetic_composer.phonetize_lyrics_structure(sections)
|
| 411 |
parsed["sections"] = phonetic_sections
|
| 412 |
|
|
@@ -951,6 +967,38 @@ REGULI CRUCIALE DE COMPOZIȚIE:
|
|
| 951 |
lw = words[-1].lower()
|
| 952 |
return lw[-3:] if len(lw) >= 3 else lw
|
| 953 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 954 |
def bake_multilevel_lyrics(
|
| 955 |
self,
|
| 956 |
topic: str,
|
|
@@ -1032,6 +1080,15 @@ REGULI CRUCIALE DE COMPOZIȚIE:
|
|
| 1032 |
"Scrie versurile complete cu rimele contextuale #opțiune1 #opțiune2 la finalul fiecărui vers."
|
| 1033 |
)
|
| 1034 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1035 |
raw_output = self._call_llm(system_prompt, user_prompt, provider=provider, model=model)
|
| 1036 |
|
| 1037 |
# Parse sections and extract multi-level lines
|
|
@@ -1040,6 +1097,8 @@ REGULI CRUCIALE DE COMPOZIȚIE:
|
|
| 1040 |
# Intelligent procedural synthesis with guaranteed 3 tiers
|
| 1041 |
fb = self._generate_intelligent_fallback(clean_topic, effective_mood, genre, bpm, dialect_level, duration_seconds)
|
| 1042 |
sections = self._enhance_sections_to_multilevel(fb.get("sections", []), dialect_level)
|
|
|
|
|
|
|
| 1043 |
|
| 1044 |
# Build clean full_lyrics with Tier 1
|
| 1045 |
full_lyrics_parts = []
|
|
|
|
| 399 |
|
| 400 |
user_prompt = f"Compune o piesă virală despre: '{clean_topic}'. Vibe: {intent}, Gen: {genre} ({bpm} BPM), Stil vocal: {vocal_style}."
|
| 401 |
|
| 402 |
+
# The MiniMax vocal engine sings the lyrics through ONCE at ~15s/line, so
|
| 403 |
+
# real track duration ≈ line_count × 15s. Size the song to the target.
|
| 404 |
+
target_lines = max(8, min(16, int(round(duration_seconds / 15.0))))
|
| 405 |
+
length_spec = (
|
| 406 |
+
f"\n10. LUNGIME PIESĂ (REGULĂ DE FIER, PRIORITATE MAXIMĂ): exact {target_lines} versuri "
|
| 407 |
+
f"cântate, NU mai mult. Structură fixă: Intro 1, Verse 1 = 4, Chorus (hook) = 4, "
|
| 408 |
+
f"Verse 2 = 2, Outro 1. Fiecare vers = o singură lovitură scurtă și catchy cu repetiție "
|
| 409 |
+
f"în refren. NU scrie povestiri, NU umple cu secțiuni lungi, NU repetă idei noi la nesfârșit. "
|
| 410 |
+
f"ÎNCEARCĂ: {target_lines} versuri. Dacă ai mai mult de {target_lines}, e EȘEC."
|
| 411 |
+
)
|
| 412 |
+
system_prompt += length_spec
|
| 413 |
+
user_prompt = (
|
| 414 |
+
user_prompt + f" Lungime: EXACT {target_lines} versuri cântate, scurt și catchy."
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
raw_text = self._call_llm(system_prompt, user_prompt)
|
| 418 |
parsed = self._parse_raw_lyrics_to_structure(raw_text, clean_topic, genre, bpm, intent)
|
| 419 |
|
|
|
|
| 422 |
|
| 423 |
# Enhance with Phonetic Singing Diction & Rhyme Meter
|
| 424 |
sections = parsed.get("sections", [])
|
| 425 |
+
sections = self._cap_lyric_sections(sections, target_lines)
|
| 426 |
phonetic_sections = self.phonetic_composer.phonetize_lyrics_structure(sections)
|
| 427 |
parsed["sections"] = phonetic_sections
|
| 428 |
|
|
|
|
| 967 |
lw = words[-1].lower()
|
| 968 |
return lw[-3:] if len(lw) >= 3 else lw
|
| 969 |
|
| 970 |
+
def _cap_lyric_sections(self, sections: list, target_lines: int) -> list:
|
| 971 |
+
"""Hard backstop: trim section lines so total singing lines <= target_lines.
|
| 972 |
+
|
| 973 |
+
Preserves the song's spine (intro + first verse + chorus) and drops the
|
| 974 |
+
trailing extra verses/bridge/outro if the LLM over-produced, so we never
|
| 975 |
+
ship a telenovela-length track.
|
| 976 |
+
"""
|
| 977 |
+
def line_count(sec):
|
| 978 |
+
return len(sec.get("lines", []))
|
| 979 |
+
|
| 980 |
+
total = sum(line_count(s) for s in sections)
|
| 981 |
+
if total <= target_lines:
|
| 982 |
+
return sections
|
| 983 |
+
|
| 984 |
+
# Keep at least the first three structural sections (intro/verse/chorus),
|
| 985 |
+
# then trim from the end; within a section, trim lines from the tail.
|
| 986 |
+
capped = [dict(s) for s in sections]
|
| 987 |
+
for i in range(len(capped) - 1, -1, -1):
|
| 988 |
+
if total <= target_lines:
|
| 989 |
+
break
|
| 990 |
+
sec = capped[i]
|
| 991 |
+
lines = sec.get("lines", [])
|
| 992 |
+
keep = max(0, min(len(lines), target_lines - (total - len(lines))))
|
| 993 |
+
if keep < len(lines):
|
| 994 |
+
sec["lines"] = lines[:keep]
|
| 995 |
+
total = sum(line_count(s) for s in capped)
|
| 996 |
+
# If this section is now empty and it's not a core opening section, drop it.
|
| 997 |
+
if not sec.get("lines") and i >= 2:
|
| 998 |
+
capped.pop(i)
|
| 999 |
+
total = sum(line_count(s) for s in capped)
|
| 1000 |
+
return capped
|
| 1001 |
+
|
| 1002 |
def bake_multilevel_lyrics(
|
| 1003 |
self,
|
| 1004 |
topic: str,
|
|
|
|
| 1080 |
"Scrie versurile complete cu rimele contextuale #opțiune1 #opțiune2 la finalul fiecărui vers."
|
| 1081 |
)
|
| 1082 |
|
| 1083 |
+
# The MiniMax vocal engine sings the lyrics through ONCE at ~15s/line, so
|
| 1084 |
+
# real track duration ≈ line_count × 15s. Size the song to the target.
|
| 1085 |
+
target_lines = max(8, min(16, int(round(duration_seconds / 15.0))))
|
| 1086 |
+
user_prompt += (
|
| 1087 |
+
f"\nLUNGIME (REGULĂ DE FIER, PRIORITATE MAXIMĂ): EXACT {target_lines} versuri cântate, "
|
| 1088 |
+
f"scurt și catchy, cu repetiție în refren. NU poveste, NU telenovelă, NU mai mult de "
|
| 1089 |
+
f"{target_lines} versuri."
|
| 1090 |
+
)
|
| 1091 |
+
|
| 1092 |
raw_output = self._call_llm(system_prompt, user_prompt, provider=provider, model=model)
|
| 1093 |
|
| 1094 |
# Parse sections and extract multi-level lines
|
|
|
|
| 1097 |
# Intelligent procedural synthesis with guaranteed 3 tiers
|
| 1098 |
fb = self._generate_intelligent_fallback(clean_topic, effective_mood, genre, bpm, dialect_level, duration_seconds)
|
| 1099 |
sections = self._enhance_sections_to_multilevel(fb.get("sections", []), dialect_level)
|
| 1100 |
+
else:
|
| 1101 |
+
sections = self._cap_lyric_sections(sections, target_lines)
|
| 1102 |
|
| 1103 |
# Build clean full_lyrics with Tier 1
|
| 1104 |
full_lyrics_parts = []
|
tests/unit/test_song_farm.py
CHANGED
|
@@ -83,3 +83,36 @@ def test_farm_start_rejects_when_already_running(studio_client, monkeypatch):
|
|
| 83 |
body = r.json()
|
| 84 |
assert body["started"] is False
|
| 85 |
assert "already running" in body["reason"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
body = r.json()
|
| 84 |
assert body["started"] is False
|
| 85 |
assert "already running" in body["reason"]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_lyricist_cap_trims_to_target_lines():
|
| 89 |
+
"""The song farm relies on the engine capping baked lyrics to ~15s/line * target,
|
| 90 |
+
so tracks land in the 2:30-3:30 window instead of running long."""
|
| 91 |
+
from engine.lyricist_engine import LyricistEngine
|
| 92 |
+
eng = LyricistEngine()
|
| 93 |
+
sections = [
|
| 94 |
+
{"tag": "[Intro]", "lines": [{"text": "a"}]},
|
| 95 |
+
{"tag": "[Verse 1]", "lines": [{"text": f"v1{i}"} for i in range(8)]},
|
| 96 |
+
{"tag": "[Chorus]", "lines": [{"text": f"c{i}"} for i in range(8)]},
|
| 97 |
+
{"tag": "[Verse 2]", "lines": [{"text": f"v2{i}"} for i in range(8)]},
|
| 98 |
+
{"tag": "[Outro]", "lines": [{"text": "o"}]},
|
| 99 |
+
]
|
| 100 |
+
total_in = sum(len(s["lines"]) for s in sections)
|
| 101 |
+
capped = eng._cap_lyric_sections(sections, 12)
|
| 102 |
+
total_out = sum(len(s["lines"]) for s in capped)
|
| 103 |
+
assert total_in > 12
|
| 104 |
+
assert total_out <= 12
|
| 105 |
+
# the core opening spine (intro + first verse + chorus) must survive the trim
|
| 106 |
+
tags = [s["tag"] for s in capped]
|
| 107 |
+
assert "[Intro]" in tags and "[Verse 1]" in tags and "[Chorus]" in tags
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_lyricist_target_lines_scales_with_duration():
|
| 111 |
+
"""180s -> ~12 lines, 150s -> ~10, 210s -> ~14; clamped to [8, 16]."""
|
| 112 |
+
def compute(sec):
|
| 113 |
+
return max(8, min(16, int(round(sec / 15.0))))
|
| 114 |
+
assert compute(180) == 12
|
| 115 |
+
assert compute(150) == 10
|
| 116 |
+
assert compute(210) == 14
|
| 117 |
+
assert compute(30) == 8 # floor
|
| 118 |
+
assert compute(400) == 16 # ceiling
|