diff --git a/backend/app/services/songs.py b/backend/app/services/songs.py index 98570eb..05f5ef2 100644 --- a/backend/app/services/songs.py +++ b/backend/app/services/songs.py @@ -466,6 +466,9 @@ def _lyrics_prompt(team_name: str, context: str, league: str = "kbo") -> str: "- 경기장에서 수만 관중이 떼창하는 웅장한 스타디움 앤섬\n" "- 오늘 경기 맥락(어제/최근 결과 설욕·기세, 순위 싸움, 연전 차수, 선발·라인업)을 " "가사에 구체적으로 녹일 것 — 선수 실명 사용 가능\n" + "- 가사에 아라비아 숫자를 절대 쓰지 말 것 — 모두 한글 발음으로 표기 " + "(1승→일승, 1위→일위, 78승→칠십팔승, 2차전→이차전, 1번→일번, " + "7게임→일곱 게임, 15호→십오호, 5:3→오 대 삼)\n" "- 괄호로 관중 콜앤리스폰스 파트 표기, 섹션 태그에 연주 지시 포함\n" "- 분량은 1분 30초~2분 (Verse 2개 + Chorus 반복 + Bridge + Final Chorus)\n\n" f"[가사 형식 레퍼런스 — 구조와 톤만 참고, 내용은 오늘 경기에 맞게 새로 쓸 것]\n{_REFERENCE}\n\n" @@ -475,6 +478,62 @@ def _lyrics_prompt(team_name: str, context: str, league: str = "kbo") -> str: ) +# ── 가사 숫자 한글화 (Suno 가 아라비아 숫자를 잘못 읽는 문제 방지) ── +_SINO = "영일이삼사오육칠팔구" +_NATIVE = { + 1: "한", 2: "두", 3: "세", 4: "네", 5: "다섯", 6: "여섯", 7: "일곱", + 8: "여덟", 9: "아홉", 10: "열", 11: "열한", 12: "열두", 13: "열세", + 14: "열네", 15: "열다섯", 16: "열여섯", 17: "열일곱", 18: "열여덟", + 19: "열아홉", 20: "스무", +} + + +def _sino(n: int) -> str: + """한자어 수사 — 78 → 칠십팔 (0~9999).""" + if n == 0: + return "영" + parts = [] + for unit, name in ((1000, "천"), (100, "백"), (10, "십")): + d, n = divmod(n, unit) + if d: + parts.append(("" if d == 1 else _SINO[d]) + name) + if n: + parts.append(_SINO[n]) + return "".join(parts) + + +def _native(n: int) -> str: + """고유어 수사 — 7 → 일곱 (범위 밖은 한자어 폴백).""" + return _NATIVE.get(n) or _sino(n) + + +def hangulize_numbers(text: str) -> str: + """가사 속 아라비아 숫자를 전부 한글 발음으로 치환. + + 1승→일승, 78승→칠십팔승, 7게임→일곱 게임, 5:3→오 대 삼, 0.312→영점삼일이. + """ + # 소수(타율 등): 0.312 → 영점삼일이 (소수부는 자리별 낭독) + text = re.sub( + r"(\d)\.(\d+)", + lambda m: _sino(int(m.group(1))) + "점" + "".join(_SINO[int(c)] for c in m.group(2)), + text, + ) + # 스코어: 5:3 / 5대3 → 오 대 삼 + text = re.sub( + r"(\d+)\s*[:대]\s*(\d+)", + lambda m: f"{_sino(int(m.group(1)))} 대 {_sino(int(m.group(2)))}", + text, + ) + # 게임·경기 수는 고유어 수사 (7게임 → 일곱 게임) + text = re.sub( + r"(\d+)\s*(게임|경기)", + lambda m: f"{_native(int(m.group(1)))} {m.group(2)}", + text, + ) + # 남은 모든 숫자는 한자어 수사 (1위→일위, 2차전→이차전, 15호→십오호) + return re.sub(r"\d+", lambda m: _sino(int(m.group(0))), text) + + def _parse_json(text: str) -> dict: t = text.strip() t = re.sub(r"^```(?:json)?\s*|\s*```$", "", t) @@ -549,6 +608,8 @@ async def _start_one(db, m: Match, side: str, lineups: dict | None) -> bool: lyrics = str(piece.get("lyrics") or "").strip() if not lyrics: raise RuntimeError("LLM 가사 비어있음") + lyrics = hangulize_numbers(lyrics) # 프롬프트를 뚫고 남은 숫자 안전망 + title = hangulize_numbers(title) # 제목도 Suno 가 부르는 경우 대비 task_id = await suno.start_generation(title, style, lyrics) upgrade = bool(row and row.status == "complete")