AndrianBalanescu commited on
Commit
ac4a7ec
·
1 Parent(s): b162b1b

Fix song farm duration: size lyrics to hit 2:30-3:30

Browse files

MiniMax 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