Skip to content
Open
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
3 changes: 2 additions & 1 deletion LDDC/common/data/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def __init__(self) -> None:
"desktop_lyrics_rect": (), # 默认为空自动移动到屏幕中央
"desktop_lyrics_font_size": 30.0,
"desktop_lyrics_show_furigana": True,
"kana": False,

"language": "auto",
"color_scheme": "auto",
Expand Down Expand Up @@ -120,7 +121,7 @@ def __setitem__(self, key: Any, value: Any) -> None:
super().__setitem__(key, value)
self.write_config()

if key in ("langs_order", "lrc_ms_digit_count", "add_end_timestamp_line", "last_ref_line_time_sty", "lrc_tag_info_src"):
if key in ("langs_order", "lrc_ms_digit_count", "add_end_timestamp_line", "last_ref_line_time_sty", "lrc_tag_info_src", "kana"):
self.lyrics_changed.emit((key, value))
elif key in (
"desktop_lyrics_font_family",
Expand Down
12 changes: 11 additions & 1 deletion LDDC/core/converter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

from LDDC.common.data.config import cfg
from LDDC.common.logger import logger
from LDDC.common.models import Language, LyricsBase, LyricsFormat, LyricsType, Source
from LDDC.common.models import FSLyricsData, FSLyricsLine, FSLyricsWord, Language, LyricsBase, LyricsFormat, LyricsType, Source
from LDDC.core.algorithm import find_closest_match
from LDDC.core.romaji import romaji_to_hiragana

from .ass import ass_converter
from .lrc import lrc_converter
Expand Down Expand Up @@ -58,6 +59,15 @@ def handle_info(info: dict) -> dict:
if "LDDC_ts" in lyrics_dict: # 使用LDDC的翻译覆盖原本的翻译
lyrics_dict["ts"] = lyrics_dict.pop("LDDC_ts")

if cfg["kana"] and "roma" in lyrics_dict:
lyrics_dict["roma"] = FSLyricsData([
FSLyricsLine(line.start, line.end, [
FSLyricsWord(word.start, word.end, romaji_to_hiragana(word.text))
for word in line.words
])
for line in lyrics_dict["roma"]
])


langs_order: list[str] = [lang for lang in cfg["langs_order"] if lang in langs and lang in lyrics_dict]
langs_order += [lang for lang in langs if lang not in langs_order and lang in lyrics_dict]
Expand Down
264 changes: 264 additions & 0 deletions LDDC/core/romaji.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
# SPDX-FileCopyrightText: Copyright (C) 2024-2025 沉默の金 <cmzj@cmzj.org>
# SPDX-License-Identifier: GPL-3.0-only
"""Romaji to hiragana conversion module.

Port of the Rust romaji-to-hiragana mapping from UtaBuild.

Splits input by whitespace, maps each word to hiragana using the
longest-match-first strategy, and joins all results without spaces.
Unmappable tokens (non-Japanese text, numbers, punctuation) pass through
unchanged.
"""
def _build_romaji_map() -> dict[str, str]:
pairs: list[tuple[str, str]] = [
("a", "あ"),
("i", "い"),
("u", "う"),
("e", "え"),
("o", "お"),
("ka", "か"),
("ki", "き"),
("ku", "く"),
("ke", "け"),
("ko", "こ"),
("sa", "さ"),
("shi", "し"),
("su", "す"),
("se", "せ"),
("so", "そ"),
("ta", "た"),
("chi", "ち"),
("tsu", "つ"),
("te", "て"),
("to", "と"),
("na", "な"),
("ni", "に"),
("nu", "ぬ"),
("ne", "ね"),
("no", "の"),
("ha", "は"),
("hi", "ひ"),
("fu", "ふ"),
("fa", "ふぁ"),
("fi", "ふぃ"),
("fe", "ふぇ"),
("fo", "ふぉ"),
("fya", "ふゃ"),
("fyu", "ふゅ"),
("fyo", "ふょ"),
("he", "へ"),
("ho", "ほ"),
("ma", "ま"),
("mi", "み"),
("mu", "む"),
("me", "め"),
("mo", "も"),
("ya", "や"),
("yu", "ゆ"),
("yo", "よ"),
("ra", "ら"),
("ri", "り"),
("ru", "る"),
("re", "れ"),
("ro", "ろ"),
("wa", "わ"),
("wo", "を"),
("n", "ん"),
("ga", "が"),
("gi", "ぎ"),
("gu", "ぐ"),
("ge", "げ"),
("go", "ご"),
("za", "ざ"),
("ji", "じ"),
("zu", "ず"),
("ze", "ぜ"),
("zo", "ぞ"),
("da", "だ"),
("di", "ぢ"),
("du", "づ"),
("de", "で"),
("do", "ど"),
("ba", "ば"),
("bi", "び"),
("bu", "ぶ"),
("be", "べ"),
("bo", "ぼ"),
("pa", "ぱ"),
("pi", "ぴ"),
("pu", "ぷ"),
("pe", "ぺ"),
("po", "ぽ"),
("kya", "きゃ"),
("kyu", "きゅ"),
("kyo", "きょ"),
("sha", "しゃ"),
("shu", "しゅ"),
("sho", "しょ"),
("cha", "ちゃ"),
("chu", "ちゅ"),
("cho", "ちょ"),
("nya", "にゃ"),
("nyu", "にゅ"),
("nyo", "にょ"),
("hya", "ひゃ"),
("hyu", "ひゅ"),
("hyo", "ひょ"),
("mya", "みゃ"),
("myu", "みゅ"),
("myo", "みょ"),
("rya", "りゃ"),
("ryu", "りゅ"),
("ryo", "りょ"),
("gya", "ぎゃ"),
("gyu", "ぎゅ"),
("gyo", "ぎょ"),
("ja", "じゃ"),
("ju", "じゅ"),
("jo", "じょ"),
("bya", "びゃ"),
("byu", "びゅ"),
("byo", "びょ"),
("pya", "ぴゃ"),
("pyu", "ぴゅ"),
("pyo", "ぴょ"),
("kka", "っか"),
("kki", "っき"),
("kku", "っく"),
("kke", "っけ"),
("kko", "っこ"),
("ssa", "っさ"),
("sshi", "っし"),
("ssu", "っす"),
("sse", "っせ"),
("sso", "っそ"),
("tta", "った"),
("tchi", "っち"),
("ttsu", "っつ"),
("tte", "って"),
("tto", "っと"),
("ppa", "っぱ"),
("ppi", "っぴ"),
("ppu", "っぷ"),
("ppe", "っぺ"),
("ppo", "っぽ"),
("dda", "っだ"),
("ddi", "っぢ"),
("ddu", "っづ"),
("dde", "っで"),
("ddo", "っど"),
("gga", "っが"),
("ggi", "っぎ"),
("ggu", "っぐ"),
("gge", "っげ"),
("ggo", "っご"),
("bba", "っば"),
("bbi", "っび"),
("bbu", "っぶ"),
("bbe", "っべ"),
("bbo", "っぼ"),
("ffa", "っふぁ"),
("ffi", "っふぃ"),
("ffe", "っふぇ"),
("ffo", "っふぉ"),
("ffya", "っふゃ"),
("ffyu", "っふゅ"),
("ffyo", "っふょ"),
("aa", "あー"),
("ii", "いー"),
("uu", "うー"),
("ee", "えー"),
("oo", "おー"),
("kkya", "っきゃ"),
("kkyu", "っきゅ"),
("kkyo", "っきょ"),
("ssha", "っしゃ"),
("sshu", "っしゅ"),
("ssho", "っしょ"),
("tcha", "っちゃ"),
("tchu", "っちゅ"),
("tcho", "っちょ"),
("ppya", "っぴゃ"),
("ppyu", "っぴゅ"),
("ppyo", "っぴょ"),
("bbya", "っびゃ"),
("bbyu", "っびゅ"),
("bbyo", "っびょ"),
("ggya", "っぎゃ"),
("ggyu", "っぎゅ"),
("ggyo", "っぎょ"),
("jja", "っじゃ"),
("jju", "っじゅ"),
("jjo", "っじょ"),
# Apostrophe-sokuon: 't, 's, 'k, etc. mark gemination in some romaji notations
("'t", "っ"),
("'s", "っ"),
("'k", "っ"),
("'p", "っ"),
("'b", "っ"),
("'d", "っ"),
("'g", "っ"),
("'j", "っ"),
("'c", "っ"),
]
return dict(pairs)


_ROMANJI_MAP: dict[str, str] = _build_romaji_map()

# Precompute length-based lookup keys for longest-match-first strategy
_MAX_KEY_LEN: int = max(len(k) for k in _ROMANJI_MAP)


def romaji_to_hiragana(text: str) -> str:
"""Convert space-separated romaji text to hiragana (no spaces).

Each whitespace-delimited token is matched against the romaji mapping
table. Unmappable tokens pass through unchanged. Results are joined
without any separator.

Args:
text: Space-separated romaji string.

Returns:
Hiragana string without spaces.

"""
if not text or not text.strip():
return ""

result_parts: list[str] = []

for word in text.split():
if not word:
continue
result_parts.append(_convert_word(word))

return "".join(result_parts)


def _convert_word(word: str) -> str:
"""Convert a single romaji word token to hiragana.

Returns the hiragana conversion if the entire word can be fully
consumed by the romaji mapping. If any character cannot be mapped,
the original word is returned unchanged (e.g. "Hello" → "Hello").
"""
i = 0
result: list[str] = []
while i < len(word):
matched = False
# Try longest match first (from max key length down to 1)
for length in range(min(_MAX_KEY_LEN, len(word) - i), 0, -1):
chunk = word[i:i + length]
if hiragana := _ROMANJI_MAP.get(chunk):
result.append(hiragana)
i += length
matched = True
break
if not matched:
# This word contains an unmappable character — pass through
# the whole token unchanged.
return word

return "".join(result)
3 changes: 3 additions & 0 deletions LDDC/gui/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
from LDDC.common.task_manager import TaskManager
from LDDC.common.thread import cross_thread_func, in_main_thread, in_other_thread
from LDDC.common.utils import has_content
from LDDC.core.romaji import romaji_to_hiragana
from LDDC.core.algorithm import assign_lyrics_positions, find_closest_match
from LDDC.core.api.lyrics import get_lyrics
from LDDC.core.auto_fetch import auto_fetch
Expand Down Expand Up @@ -847,6 +848,8 @@ def update_lyrics(self) -> None:
continue

text = "".join(word.text for word in display_line.words) # 获取当前行歌词文本
if lang == "roma" and self.config.get("kana"):
text = romaji_to_hiragana(text)
if lang == "orig" and self.rubys_mapping:
rubys = self.rubys_mapping.get(orig_index, [(0, 0, "")])
if not rubys:
Expand Down
8 changes: 8 additions & 0 deletions LDDC/gui/ui/local_match_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ def setupUi(self, local_match):

self.gridLayout.addWidget(self.romanized_checkBox, 1, 11, 1, 1)

self.kana_checkBox = QCheckBox(self.control_bar)
self.kana_checkBox.setObjectName("kana_checkBox")
sizePolicy2.setHeightForWidth(self.kana_checkBox.sizePolicy().hasHeightForWidth())
self.kana_checkBox.setSizePolicy(sizePolicy2)

self.gridLayout.addWidget(self.kana_checkBox, 1, 12, 1, 1)

self.label_8 = QLabel(self.control_bar)
self.label_8.setObjectName("label_8")
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
Expand Down Expand Up @@ -285,6 +292,7 @@ def retranslateUi(self, local_match):
self.select_dirs_button.setText(QCoreApplication.translate("local_match", "\u9009\u62e9\u6587\u4ef6\u5939", None))
self.save_path_button.setText(QCoreApplication.translate("local_match", "\u9009\u62e9\u4fdd\u5b58\u8def\u5f84", None))
self.romanized_checkBox.setText(QCoreApplication.translate("local_match", "\u7f57\u9a6c\u97f3", None))
self.kana_checkBox.setText(QCoreApplication.translate("local_match", "\u5e73\u5047\u540d", None))
self.label_8.setText(QCoreApplication.translate("local_match", "\u6b4c\u8bcd\u6587\u4ef6\u540d:", None))
self.lyricsformat_comboBox.setItemText(0, QCoreApplication.translate("local_match", "LRC(\u9010\u5b57)", None))
self.lyricsformat_comboBox.setItemText(1, QCoreApplication.translate("local_match", "LRC(\u9010\u884c)", None))
Expand Down
8 changes: 8 additions & 0 deletions LDDC/gui/ui/local_song_lyrics_db_manager/export_lyrics_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ def setupUi(self, export_lyrics):

self.gridLayout.addWidget(self.romanized_checkBox, 2, 3, 1, 2)

self.kana_checkBox = QCheckBox(export_lyrics)
self.kana_checkBox.setObjectName("kana_checkBox")
sizePolicy1.setHeightForWidth(self.kana_checkBox.sizePolicy().hasHeightForWidth())
self.kana_checkBox.setSizePolicy(sizePolicy1)

self.gridLayout.addWidget(self.kana_checkBox, 3, 3, 1, 2)


self.retranslateUi(export_lyrics)
self.buttonBox.accepted.connect(export_lyrics.accept)
Expand Down Expand Up @@ -146,5 +153,6 @@ def retranslateUi(self, export_lyrics):
self.label_9.setText(QCoreApplication.translate("export_lyrics", "\u6b4c\u8bcd\u7c7b\u578b:", None))
self.original_checkBox.setText(QCoreApplication.translate("export_lyrics", "\u539f\u6587", None))
self.romanized_checkBox.setText(QCoreApplication.translate("export_lyrics", "\u7f57\u9a6c\u97f3", None))
self.kana_checkBox.setText(QCoreApplication.translate("export_lyrics", "\u5e73\u5047\u540d", None))
# retranslateUi

8 changes: 8 additions & 0 deletions LDDC/gui/ui/open_lyrics_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ def setupUi(self, open_lyrics):

self.horizontalLayout_2.addWidget(self.romanized_checkBox, 0, Qt.AlignmentFlag.AlignLeft)

self.kana_checkBox = QCheckBox(open_lyrics)
self.kana_checkBox.setObjectName("kana_checkBox")
sizePolicy.setHeightForWidth(self.kana_checkBox.sizePolicy().hasHeightForWidth())
self.kana_checkBox.setSizePolicy(sizePolicy)

self.horizontalLayout_2.addWidget(self.kana_checkBox, 0, Qt.AlignmentFlag.AlignLeft)

self.label_5 = QLabel(open_lyrics)
self.label_5.setObjectName("label_5")
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
Expand Down Expand Up @@ -156,6 +163,7 @@ def retranslateUi(self, open_lyrics):
self.original_checkBox.setText(QCoreApplication.translate("open_lyrics", "\u539f\u6587", None))
self.translate_checkBox.setText(QCoreApplication.translate("open_lyrics", "\u8bd1\u6587", None))
self.romanized_checkBox.setText(QCoreApplication.translate("open_lyrics", "\u7f57\u9a6c\u97f3", None))
self.kana_checkBox.setText(QCoreApplication.translate("open_lyrics", "\u5e73\u5047\u540d", None))
self.label_5.setText(QCoreApplication.translate("open_lyrics", "\u504f\u79fb\u91cf:", None))
self.label_2.setText(QCoreApplication.translate("open_lyrics", "\u8f6c\u6362\u7684\u683c\u5f0f\uff1a", None))
self.lyricsformat_comboBox.setItemText(0, QCoreApplication.translate("open_lyrics", "LRC(\u9010\u5b57)", None))
Expand Down
Loading