From 0ed68e8ebb7e90107b271c69e09d2a6d4553ceea Mon Sep 17 00:00:00 2001 From: York Date: Mon, 22 Jun 2026 10:11:20 +0800 Subject: [PATCH] feat: add Japanese Furigana support to lyrics display (#2161) --- package.json | 2 + pnpm-lock.yaml | 47 +++++++++++++++++++ src/i18n/locales/en.json | 2 + src/main/features/core/lyrics/furigana.ts | 37 +++++++++++++++ src/main/features/core/lyrics/index.ts | 5 ++ src/preload/lyrics.ts | 5 ++ .../components/lyrics-settings-form.tsx | 16 +++++++ .../lyrics/hooks/use-furigana-lyrics.ts | 30 ++++++++++++ src/renderer/features/lyrics/lyric-line.tsx | 3 +- src/renderer/features/lyrics/lyrics.tsx | 12 ++++- .../components/general/lyric-settings.tsx | 14 ++++++ src/renderer/store/settings.store.ts | 2 + src/renderer/utils/sanitize.ts | 2 +- 13 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 src/main/features/core/lyrics/furigana.ts create mode 100644 src/renderer/features/lyrics/hooks/use-furigana-lyrics.ts diff --git a/package.json b/package.json index 0a0ba5bd7..43feebb63 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,8 @@ "idb-keyval": "^6.2.5", "immer": "^10.2.0", "is-electron": "^2.2.2", + "kuroshiro": "^1.2.0", + "kuroshiro-analyzer-kuromoji": "^1.1.0", "lodash": "^4.18.1", "md5": "^2.3.0", "motion": "^12.40.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a08be6e6f..6a9e5dc17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,12 @@ importers: is-electron: specifier: ^2.2.2 version: 2.2.2 + kuroshiro: + specifier: ^1.2.0 + version: 1.2.0 + kuroshiro-analyzer-kuromoji: + specifier: ^1.1.0 + version: 1.1.0 lodash: specifier: ^4.18.1 version: 4.18.1 @@ -2392,6 +2398,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async@2.6.4: + resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -2926,6 +2935,9 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + doublearray@0.0.2: + resolution: {integrity: sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3959,6 +3971,16 @@ packages: known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + kuromoji@0.1.2: + resolution: {integrity: sha512-V0dUf+C2LpcPEXhoHLMAop/bOht16Dyr+mDiIE39yX3vqau7p80De/koFqpiTcL1zzdZlc3xuHZ8u5gjYRfFaQ==} + + kuroshiro-analyzer-kuromoji@1.1.0: + resolution: {integrity: sha512-BSJFhpsQdPwfFLfjKxfLA9iL+/PC6LCR9vgwgb5Jc7jZwk9ilX8SAV6CwhAQZY611tiuhbB52ONYKDO8hgY1bA==} + + kuroshiro@1.2.0: + resolution: {integrity: sha512-yBGCK9oDOY3LGZ/KXaN9m7ADcAuSczOR2FoMRYwHLUlis3/o/uxdMVROAjENFO0NQJgALhIdWxI/vIBVrMCk9w==} + engines: {node: '>=6.5.0'} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -5756,6 +5778,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zlibjs@0.3.1: + resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==} + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -7948,6 +7973,10 @@ snapshots: async-function@1.0.0: {} + async@2.6.4: + dependencies: + lodash: 4.18.1 + async@3.2.6: {} asynckit@0.4.0: {} @@ -8544,6 +8573,8 @@ snapshots: dotenv@16.6.1: {} + doublearray@0.0.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -9829,6 +9860,20 @@ snapshots: known-css-properties@0.37.0: {} + kuromoji@0.1.2: + dependencies: + async: 2.6.4 + doublearray: 0.0.2 + zlibjs: 0.3.1 + + kuroshiro-analyzer-kuromoji@1.1.0: + dependencies: + kuromoji: 0.1.2 + + kuroshiro@1.2.0: + dependencies: + '@babel/runtime': 7.29.7 + lazy-val@1.0.5: {} lead@4.0.0: {} @@ -11818,6 +11863,8 @@ snapshots: yocto-queue@0.1.0: {} + zlibjs@0.3.1: {} + zod-validation-error@4.0.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a14a1780d..f23694ae6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -843,6 +843,8 @@ "discordUpdateInterval_description": "The time in seconds between each update (minimum 15 seconds)", "enableAutoTranslation_description": "Enable translation automatically when lyrics are loaded", "enableAutoTranslation": "Enable auto translation", + "enableFurigana_description": "Display pronunciation guides (furigana) over Japanese kanji lyrics.", + "enableFurigana": "Enable furigana generation", "enableRemote_description": "Enables the remote control server to allow other devices to control the application", "enableRemote": "Enable remote control server", "exitToTray_description": "Exit the application to the system tray", diff --git a/src/main/features/core/lyrics/furigana.ts b/src/main/features/core/lyrics/furigana.ts new file mode 100644 index 000000000..2596979a5 --- /dev/null +++ b/src/main/features/core/lyrics/furigana.ts @@ -0,0 +1,37 @@ +import Kuroshiro from 'kuroshiro'; +import KuromojiAnalyzer from 'kuroshiro-analyzer-kuromoji'; + +// doc: https://kuroshiro.org + +let kuroshiroInstance: any = null; +let initPromise: null | Promise = null; + +const getKuroshiro = async () => { + if (kuroshiroInstance) return kuroshiroInstance; + if (initPromise) { + await initPromise; + return kuroshiroInstance; + } + + const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro; + kuroshiroInstance = new KuroshiroClass(); + initPromise = kuroshiroInstance.init(new KuromojiAnalyzer()); + await initPromise; + return kuroshiroInstance; +}; + +export const convertFurigana = async (text: string): Promise => { + const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro; + + // check if the text contains any Japanese kana (to distinguish Japanese from Chinese text, which shares Kanji) + // If no Japanese kana is detected, skip processing + if (!KuroshiroClass.Util.hasKana(text)) return text; + + try { + const kuroshiro = await getKuroshiro(); + return await kuroshiro.convert(text, { mode: 'furigana', to: 'hiragana' }); + } catch (e) { + console.error('Furigana conversion error: ', e); + return text; + } +}; diff --git a/src/main/features/core/lyrics/index.ts b/src/main/features/core/lyrics/index.ts index 28dd11c17..fb82352c1 100644 --- a/src/main/features/core/lyrics/index.ts +++ b/src/main/features/core/lyrics/index.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron'; import { store } from '../settings'; +import { convertFurigana } 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'; @@ -231,3 +232,7 @@ ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => { const lyricResults = await getRemoteLyricsById(params); return lyricResults; }); + +ipcMain.handle('lyric-convert-furigana', async (_event, text: string) => { + return await convertFurigana(text); +}); diff --git a/src/preload/lyrics.ts b/src/preload/lyrics.ts index b69ab3842..5748f6368 100644 --- a/src/preload/lyrics.ts +++ b/src/preload/lyrics.ts @@ -26,7 +26,12 @@ const getRemoteLyricsByRemoteId = (id: LyricGetQuery) => { return result; }; +const convertFurigana = (text: string): Promise => { + return ipcRenderer.invoke('lyric-convert-furigana', text); +}; + export const lyrics = { + convertFurigana, 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 d9d8c7502..90b0b9932 100644 --- a/src/renderer/features/lyrics/components/lyrics-settings-form.tsx +++ b/src/renderer/features/lyrics/components/lyrics-settings-form.tsx @@ -291,6 +291,22 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) => isHidden: !isElectron(), title: t('setting.lyricFetchProvider'), }, + { + control: ( + + updateLyricsSetting({ enableFurigana: e.currentTarget.checked }) + } + /> + ), + description: t('setting.enableFurigana', { + context: 'description', + }), + isHidden: !isElectron(), + title: t('setting.enableFurigana'), + }, { control: ( { + return useQuery({ + enabled: enabled && !!lyrics && !!lyricsApi, + queryFn: async () => { + if (!lyrics || !lyricsApi || !enabled) return lyrics; + + if (typeof lyrics === 'string') { + return await lyricsApi.convertFurigana(lyrics); + } else if (Array.isArray(lyrics)) { + const text = lyrics.map(([, line]) => line).join('\n'); + const converted = await lyricsApi.convertFurigana(text); + const convertedLines = converted.split('\n'); + return lyrics.map(([time], i) => [ + time, + convertedLines[i] ?? lyrics[i][1], + ]) as SynchronizedLyricsArray; + } + return lyrics; + }, + queryKey: ['furigana', lyrics], + staleTime: Infinity, + }); +}; diff --git a/src/renderer/features/lyrics/lyric-line.tsx b/src/renderer/features/lyrics/lyric-line.tsx index 75ccd11d6..dc0abdd3b 100644 --- a/src/renderer/features/lyrics/lyric-line.tsx +++ b/src/renderer/features/lyrics/lyric-line.tsx @@ -3,6 +3,7 @@ import { ComponentPropsWithoutRef, memo, useMemo } from 'react'; import styles from './lyric-line.module.css'; +import { sanitize } from '/@/renderer/utils/sanitize'; import { Box } from '/@/shared/components/box/box'; import { Stack } from '/@/shared/components/stack/stack'; @@ -28,7 +29,7 @@ export const LyricLine = memo( {lines.map((line, index) => ( - {line} + ))} diff --git a/src/renderer/features/lyrics/lyrics.tsx b/src/renderer/features/lyrics/lyrics.tsx index f3887ccfa..9a3a7a474 100644 --- a/src/renderer/features/lyrics/lyrics.tsx +++ b/src/renderer/features/lyrics/lyrics.tsx @@ -14,6 +14,7 @@ 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 { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions'; import { SynchronizedLyrics, @@ -49,6 +50,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' const { enableAutoTranslation, + enableFurigana, preferLocalLyrics, translationApiKey, translationApiProvider, @@ -116,7 +118,15 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' return computeSelectedFromResult(data, preferLocalLyrics, indexToUse); }, [data, indexToUse, preferLocalLyrics]); - const displayLyrics = isLyricsDisabled ? null : lyrics; + const { data: furiganaConvertedLyrics } = useFuriganaLyrics(lyrics?.lyrics, !!enableFurigana); + + const displayLyrics = useMemo(() => { + if (isLyricsDisabled || !lyrics) return null; + if (enableFurigana && furiganaConvertedLyrics) { + return { ...lyrics, lyrics: furiganaConvertedLyrics }; + } + return lyrics; + }, [enableFurigana, isLyricsDisabled, lyrics, furiganaConvertedLyrics]); const currentOffsetMs = useMemo(() => { if (!data) return 0; diff --git a/src/renderer/features/settings/components/general/lyric-settings.tsx b/src/renderer/features/settings/components/general/lyric-settings.tsx index 5f13a3790..02eb7240e 100644 --- a/src/renderer/features/settings/components/general/lyric-settings.tsx +++ b/src/renderer/features/settings/components/general/lyric-settings.tsx @@ -93,6 +93,20 @@ export const LyricSettings = memo(() => { isHidden: !isElectron(), title: t('setting.lyricFetchProvider'), }, + { + control: ( + updateSetting({ enableFurigana: e.currentTarget.checked })} + /> + ), + description: t('setting.enableFurigana', { + context: 'description', + }), + isHidden: !isElectron(), + title: t('setting.enableFurigana'), + }, { control: (