diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8de3eee90..3215b90a0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -845,6 +845,8 @@ "enableAutoTranslation": "Enable auto translation", "enableFurigana_description": "Display pronunciation guides (furigana) over Japanese kanji lyrics.", "enableFurigana": "Enable furigana generation", + "enableRomaji_description": "Display a romaji pronunciation line under Japanese lyrics.", + "enableRomaji": "Enable romaji generation", "equalizer_descriptionMpv": "Parametric equalizer via FFmpeg lavfi (MPV)", "equalizer_descriptionWebAudio": "Parametric equalizer via Web Audio API", "equalizer": "Equalizer", diff --git a/src/main/features/core/lyrics/furigana.ts b/src/main/features/core/lyrics/furigana.ts index 2596979a5..1621f6245 100644 --- a/src/main/features/core/lyrics/furigana.ts +++ b/src/main/features/core/lyrics/furigana.ts @@ -7,16 +7,19 @@ let kuroshiroInstance: any = null; let initPromise: null | Promise = null; const getKuroshiro = async () => { - if (kuroshiroInstance) return kuroshiroInstance; if (initPromise) { await initPromise; return kuroshiroInstance; } + if (kuroshiroInstance) return kuroshiroInstance; + const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro; kuroshiroInstance = new KuroshiroClass(); initPromise = kuroshiroInstance.init(new KuromojiAnalyzer()); await initPromise; + + initPromise = null; return kuroshiroInstance; }; @@ -35,3 +38,17 @@ export const convertFurigana = async (text: string): Promise => { return text; } }; + +export const convertRomaji = async (text: string): Promise => { + const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro; + + if (!KuroshiroClass.Util.hasKana(text)) return text; + + try { + const kuroshiro = await getKuroshiro(); + return await kuroshiro.convert(text, { mode: 'spaced', to: 'romaji' }); + } catch (e) { + console.error('Romaji conversion error: ', e); + return text; + } +}; diff --git a/src/main/features/core/lyrics/index.ts b/src/main/features/core/lyrics/index.ts index fb82352c1..2e0d7e2ba 100644 --- a/src/main/features/core/lyrics/index.ts +++ b/src/main/features/core/lyrics/index.ts @@ -1,7 +1,7 @@ import { ipcMain } from 'electron'; import { store } from '../settings'; -import { convertFurigana } from './furigana'; +import { convertFurigana, convertRomaji } from './furigana'; import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius'; import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib'; import { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease'; @@ -236,3 +236,7 @@ ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => { ipcMain.handle('lyric-convert-furigana', async (_event, text: string) => { return await convertFurigana(text); }); + +ipcMain.handle('lyric-convert-romaji', async (_event, text: string) => { + return await convertRomaji(text); +}); diff --git a/src/preload/lyrics.ts b/src/preload/lyrics.ts index 5748f6368..ad60f5ea3 100644 --- a/src/preload/lyrics.ts +++ b/src/preload/lyrics.ts @@ -30,8 +30,13 @@ const convertFurigana = (text: string): Promise => { return ipcRenderer.invoke('lyric-convert-furigana', text); }; +const convertRomaji = (text: string): Promise => { + return ipcRenderer.invoke('lyric-convert-romaji', text); +}; + export const lyrics = { convertFurigana, + convertRomaji, getRemoteLyricsByRemoteId, getRemoteLyricsBySong, searchRemoteLyrics, diff --git a/src/renderer/features/lyrics/components/lyrics-settings-form.tsx b/src/renderer/features/lyrics/components/lyrics-settings-form.tsx index 90b0b9932..35a1bcece 100644 --- a/src/renderer/features/lyrics/components/lyrics-settings-form.tsx +++ b/src/renderer/features/lyrics/components/lyrics-settings-form.tsx @@ -307,6 +307,20 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) => isHidden: !isElectron(), title: t('setting.enableFurigana'), }, + { + control: ( + updateLyricsSetting({ enableRomaji: e.currentTarget.checked })} + /> + ), + description: t('setting.enableRomaji', { + context: 'description', + }), + isHidden: !isElectron(), + title: t('setting.enableRomaji'), + }, { control: ( { + return useQuery({ + enabled: enabled && !!lyrics && !!lyricsApi, + queryFn: async () => { + if (!lyrics || !lyricsApi || !enabled) return lyrics; + + if (typeof lyrics === 'string') { + return await lyricsApi.convertRomaji(lyrics); + } else if (Array.isArray(lyrics)) { + const text = lyrics.map(([, line]) => line).join('\n'); + const converted = await lyricsApi.convertRomaji(text); + const convertedLines = converted.split('\n'); + return lyrics.map(([time], i) => [ + time, + convertedLines[i] ?? lyrics[i][1], + ]) as SynchronizedLyricsArray; + } + return lyrics; + }, + queryKey: ['romaji', lyrics], + staleTime: Infinity, + }); +}; diff --git a/src/renderer/features/lyrics/lyric-line.module.css b/src/renderer/features/lyrics/lyric-line.module.css index a06f3cc87..8d9d97a27 100644 --- a/src/renderer/features/lyrics/lyric-line.module.css +++ b/src/renderer/features/lyrics/lyric-line.module.css @@ -25,3 +25,8 @@ .lyric-line:global(.synchronized) { cursor: pointer; } + +.romaji-line { + font-size: 0.8em; + font-weight: 600; +} diff --git a/src/renderer/features/lyrics/lyric-line.tsx b/src/renderer/features/lyrics/lyric-line.tsx index dc0abdd3b..cc46396df 100644 --- a/src/renderer/features/lyrics/lyric-line.tsx +++ b/src/renderer/features/lyrics/lyric-line.tsx @@ -10,11 +10,21 @@ import { Stack } from '/@/shared/components/stack/stack'; interface LyricLineProps extends ComponentPropsWithoutRef<'div'> { alignment: 'center' | 'left' | 'right'; fontSize: number; + romajiText?: null | string; text: string; + translatedText?: null | string; } export const LyricLine = memo( - ({ alignment, className, fontSize, text, ...props }: LyricLineProps) => { + ({ + alignment, + className, + fontSize, + romajiText, + text, + translatedText, + ...props + }: LyricLineProps) => { const lines = useMemo(() => text.split('_BREAK_'), [text]); const style = useMemo( @@ -31,6 +41,15 @@ export const LyricLine = memo( {lines.map((line, index) => ( ))} + {romajiText && ( + + )} + {translatedText && ( + + )} ); diff --git a/src/renderer/features/lyrics/lyrics.tsx b/src/renderer/features/lyrics/lyrics.tsx index f8aa3ab4b..6e28a38a4 100644 --- a/src/renderer/features/lyrics/lyrics.tsx +++ b/src/renderer/features/lyrics/lyrics.tsx @@ -14,7 +14,10 @@ import { type LyricsQueryResult, } from '/@/renderer/features/lyrics/api/lyrics-api'; import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form'; -import { useFuriganaLyrics } from '/@/renderer/features/lyrics/hooks/use-furigana-lyrics'; +import { + useFuriganaLyrics, + useRomajiLyrics, +} from '/@/renderer/features/lyrics/hooks/use-furigana-lyrics'; import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions'; import { SynchronizedLyrics, @@ -51,6 +54,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' const { enableAutoTranslation, enableFurigana, + enableRomaji, preferLocalLyrics, translationApiKey, translationApiProvider, @@ -119,6 +123,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' }, [data, indexToUse, preferLocalLyrics]); const { data: furiganaConvertedLyrics } = useFuriganaLyrics(lyrics?.lyrics, !!enableFurigana); + const { data: romajiConvertedLyrics } = useRomajiLyrics(lyrics?.lyrics, !!enableRomaji); const displayLyrics = useMemo(() => { if (isLyricsDisabled || !lyrics) return null; @@ -344,12 +349,22 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' ) : ( diff --git a/src/renderer/features/lyrics/synchronized-lyrics.tsx b/src/renderer/features/lyrics/synchronized-lyrics.tsx index 9ca81c6e6..79ab034ee 100644 --- a/src/renderer/features/lyrics/synchronized-lyrics.tsx +++ b/src/renderer/features/lyrics/synchronized-lyrics.tsx @@ -23,6 +23,7 @@ const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null; export interface SynchronizedLyricsProps extends Omit { lyrics: SynchronizedLyricsArray; offsetMs?: number; + romajiLyrics?: null | SynchronizedLyricsArray; settingsKey?: string; style?: React.CSSProperties; translatedLyrics?: null | string; @@ -34,6 +35,7 @@ export const SynchronizedLyrics = ({ name, offsetMs, remote, + romajiLyrics, settingsKey = 'default', source, style, @@ -368,10 +370,9 @@ export const SynchronizedLyrics = ({ handleSeek(time / 1000); } }} - text={ - text + - (translatedLyrics ? `_BREAK_${translatedLyrics.split('\n')[idx]}` : '') - } + romajiText={romajiLyrics?.[idx]?.[1]} + text={text} + translatedText={translatedLyrics?.split('\n')[idx]} /> ))} diff --git a/src/renderer/features/lyrics/unsynchronized-lyrics.tsx b/src/renderer/features/lyrics/unsynchronized-lyrics.tsx index 036c0bf72..6ee2597c5 100644 --- a/src/renderer/features/lyrics/unsynchronized-lyrics.tsx +++ b/src/renderer/features/lyrics/unsynchronized-lyrics.tsx @@ -8,6 +8,7 @@ import { FullLyricsMetadata } from '/@/shared/types/domain-types'; export interface UnsynchronizedLyricsProps extends Omit { lyrics: string; + romajiLyrics?: null | string; settingsKey?: string; translatedLyrics?: null | string; } @@ -17,6 +18,7 @@ export const UnsynchronizedLyrics = ({ lyrics, name, remote, + romajiLyrics, settingsKey = 'default', source, translatedLyrics, @@ -42,6 +44,10 @@ export const UnsynchronizedLyrics = ({ return translatedLyrics ? translatedLyrics.split('\n') : []; }, [translatedLyrics]); + const romajiLines = useMemo(() => { + return romajiLyrics ? romajiLyrics.split('\n') : []; + }, [romajiLyrics]); + return (
{settings.showProvider && source && ( @@ -67,7 +73,9 @@ export const UnsynchronizedLyrics = ({ fontSize={settings.fontSizeUnsync} id={`lyric-${idx}`} key={idx} - text={text + (translatedLines[idx] ? `_BREAK_${translatedLines[idx]}` : '')} + romajiText={romajiLines[idx]} + text={text} + translatedText={translatedLines[idx]} /> ))}
diff --git a/src/renderer/features/settings/components/general/lyric-settings.tsx b/src/renderer/features/settings/components/general/lyric-settings.tsx index 02eb7240e..0068d1bce 100644 --- a/src/renderer/features/settings/components/general/lyric-settings.tsx +++ b/src/renderer/features/settings/components/general/lyric-settings.tsx @@ -107,6 +107,20 @@ export const LyricSettings = memo(() => { isHidden: !isElectron(), title: t('setting.enableFurigana'), }, + { + control: ( + updateSetting({ enableRomaji: e.currentTarget.checked })} + /> + ), + description: t('setting.enableRomaji', { + context: 'description', + }), + isHidden: !isElectron(), + title: t('setting.enableRomaji'), + }, { control: (