Lyrics Translation and Romaji (Fulfill #732) [Translation Part] (#747)

This commit is contained in:
Xudong Zhou 2024-09-24 11:25:17 +08:00 committed by GitHub
parent e3946a9413
commit 31492fa9ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 243 additions and 46 deletions

13
package-lock.json generated
View file

@ -20286,10 +20286,11 @@
} }
}, },
"node_modules/serialize-javascript": { "node_modules/serialize-javascript": {
"version": "6.0.1", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true, "dev": true,
"license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"randombytes": "^2.1.0" "randombytes": "^2.1.0"
} }
@ -38353,9 +38354,9 @@
} }
}, },
"serialize-javascript": { "serialize-javascript": {
"version": "6.0.1", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true, "dev": true,
"requires": { "requires": {
"randombytes": "^2.1.0" "randombytes": "^2.1.0"

View file

@ -309,8 +309,8 @@
"@tanstack/react-query-persist-client": "^4.32.1", "@tanstack/react-query-persist-client": "^4.32.1",
"@ts-rest/core": "^3.23.0", "@ts-rest/core": "^3.23.0",
"@xhayper/discord-rpc": "^1.0.24", "@xhayper/discord-rpc": "^1.0.24",
"auto-text-size": "^0.2.3",
"audiomotion-analyzer": "^4.5.0", "audiomotion-analyzer": "^4.5.0",
"auto-text-size": "^0.2.3",
"axios": "^1.6.0", "axios": "^1.6.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",

View file

@ -2311,7 +2311,8 @@
"ws": { "ws": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==" "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"requires": {}
}, },
"xml2js": { "xml2js": {
"version": "0.4.23", "version": "0.4.23",

View file

@ -109,6 +109,7 @@
"trackNumber": "track", "trackNumber": "track",
"trackGain": "track gain", "trackGain": "track gain",
"trackPeak": "track peak", "trackPeak": "track peak",
"translation": "translation",
"unknown": "unknown", "unknown": "unknown",
"version": "version", "version": "version",
"year": "year", "year": "year",
@ -355,7 +356,8 @@
}, },
"lyrics": "lyrics", "lyrics": "lyrics",
"related": "related", "related": "related",
"upNext": "up next" "upNext": "up next",
"visualizer": "visualizer"
}, },
"genreList": { "genreList": {
"showAlbums": "show $t(entity.genre_one) $t(entity.album_other)", "showAlbums": "show $t(entity.genre_one) $t(entity.album_other)",
@ -653,6 +655,12 @@
"transcodeBitrate_description": "selects the bitrate to transcode. 0 means let the server pick", "transcodeBitrate_description": "selects the bitrate to transcode. 0 means let the server pick",
"transcodeFormat": "format to transcode", "transcodeFormat": "format to transcode",
"transcodeFormat_description": "selects the format to transcode. leave empty to let the server decide", "transcodeFormat_description": "selects the format to transcode. leave empty to let the server decide",
"translationApiProvider": "translation api provider",
"translationApiProvider_description": "api provider for translation",
"translationApiKey": "translation api key",
"translationApiKey_description": "api key for translation (Support global service endpoint only)",
"translationTargetLanguage": "translation target language",
"translationTargetLanguage_description": "target language for translation",
"trayEnabled": "show tray", "trayEnabled": "show tray",
"trayEnabled_description": "show/hide tray icon/menu. if disabled, also disables minimize/exit to tray", "trayEnabled_description": "show/hide tray icon/menu. if disabled, also disables minimize/exit to tray",
"useSystemTheme": "use system theme", "useSystemTheme": "use system theme",

View file

@ -19,6 +19,7 @@ interface LyricsActionsProps {
onRemoveLyric: () => void; onRemoveLyric: () => void;
onResetLyric: () => void; onResetLyric: () => void;
onSearchOverride: (params: LyricsOverride) => void; onSearchOverride: (params: LyricsOverride) => void;
onTranslateLyric: () => void;
setIndex: (idx: number) => void; setIndex: (idx: number) => void;
} }
@ -28,6 +29,7 @@ export const LyricsActions = ({
onRemoveLyric, onRemoveLyric,
onResetLyric, onResetLyric,
onSearchOverride, onSearchOverride,
onTranslateLyric,
setIndex, setIndex,
}: LyricsActionsProps) => { }: LyricsActionsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -120,7 +122,6 @@ export const LyricsActions = ({
{isDesktop && sources.length ? ( {isDesktop && sources.length ? (
<Button <Button
uppercase uppercase
color="red"
disabled={isActionsDisabled} disabled={isActionsDisabled}
variant="subtle" variant="subtle"
onClick={onRemoveLyric} onClick={onRemoveLyric}
@ -129,6 +130,19 @@ export const LyricsActions = ({
</Button> </Button>
) : null} ) : null}
</Box> </Box>
<Box style={{ position: 'absolute', right: 0, top: -50 }}>
{isDesktop && sources.length ? (
<Button
uppercase
disabled={isActionsDisabled}
variant="subtle"
onClick={onTranslateLyric}
>
{t('common.translation', { postProcess: 'sentenceCase' })}
</Button>
) : null}
</Box>
</Box> </Box>
); );
}; };

View file

@ -5,6 +5,7 @@ import { ErrorBoundary } from 'react-error-boundary';
import { RiInformationFill } from 'react-icons/ri'; import { RiInformationFill } from 'react-icons/ri';
import styled from 'styled-components'; import styled from 'styled-components';
import { useSongLyricsByRemoteId, useSongLyricsBySong } from './queries/lyric-query'; import { useSongLyricsByRemoteId, useSongLyricsBySong } from './queries/lyric-query';
import { translateLyrics } from './queries/lyric-translate';
import { SynchronizedLyrics, SynchronizedLyricsProps } from './synchronized-lyrics'; import { SynchronizedLyrics, SynchronizedLyricsProps } from './synchronized-lyrics';
import { Spinner, TextTitle } from '/@/renderer/components'; import { Spinner, TextTitle } from '/@/renderer/components';
import { ErrorFallback } from '/@/renderer/features/action-required'; import { ErrorFallback } from '/@/renderer/features/action-required';
@ -12,7 +13,7 @@ import {
UnsynchronizedLyrics, UnsynchronizedLyrics,
UnsynchronizedLyricsProps, UnsynchronizedLyricsProps,
} from '/@/renderer/features/lyrics/unsynchronized-lyrics'; } from '/@/renderer/features/lyrics/unsynchronized-lyrics';
import { useCurrentSong, usePlayerStore } from '/@/renderer/store'; import { useCurrentSong, usePlayerStore, useLyricsSettings } from '/@/renderer/store';
import { FullLyricsMetadata, LyricSource, LyricsOverride } from '/@/renderer/api/types'; import { FullLyricsMetadata, LyricSource, LyricsOverride } from '/@/renderer/api/types';
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions'; import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
@ -84,7 +85,10 @@ const ScrollContainer = styled(motion.div)`
export const Lyrics = () => { export const Lyrics = () => {
const currentSong = useCurrentSong(); const currentSong = useCurrentSong();
const lyricsSettings = useLyricsSettings();
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [translatedLyrics, setTranslatedLyrics] = useState<string | null>(null);
const [showTranslation, setShowTranslation] = useState(false);
const { data, isInitialLoading } = useSongLyricsBySong( const { data, isInitialLoading } = useSongLyricsBySong(
{ {
@ -96,6 +100,19 @@ export const Lyrics = () => {
const [override, setOverride] = useState<LyricsOverride | undefined>(undefined); const [override, setOverride] = useState<LyricsOverride | undefined>(undefined);
const [lyrics, synced] = useMemo(() => {
if (Array.isArray(data)) {
if (data.length > 0) {
const selectedLyric = data[Math.min(index, data.length)];
return [selectedLyric, selectedLyric.synced];
}
} else if (data?.lyrics) {
return [data, Array.isArray(data.lyrics)];
}
return [undefined, false];
}, [data, index]);
const handleOnSearchOverride = useCallback((params: LyricsOverride) => { const handleOnSearchOverride = useCallback((params: LyricsOverride) => {
setOverride(params); setOverride(params);
}, []); }, []);
@ -123,6 +140,27 @@ export const Lyrics = () => {
); );
}, [currentSong?.id, currentSong?.serverId]); }, [currentSong?.id, currentSong?.serverId]);
const handleOnTranslateLyric = useCallback(async () => {
if (translatedLyrics) {
setShowTranslation(!showTranslation);
return;
}
if (!lyrics) return;
const originalLyrics = Array.isArray(lyrics.lyrics)
? lyrics.lyrics.map(([, line]) => line).join('\n')
: lyrics.lyrics;
const { translationApiKey, translationApiProvider, translationTargetLanguage } =
lyricsSettings;
const TranslatedText: string | null = await translateLyrics(
originalLyrics,
translationApiKey,
translationApiProvider,
translationTargetLanguage,
);
setTranslatedLyrics(TranslatedText);
setShowTranslation(true);
}, [lyrics, lyricsSettings, translatedLyrics, showTranslation]);
const { isInitialLoading: isOverrideLoading } = useSongLyricsByRemoteId({ const { isInitialLoading: isOverrideLoading } = useSongLyricsByRemoteId({
options: { options: {
enabled: !!override, enabled: !!override,
@ -150,19 +188,6 @@ export const Lyrics = () => {
}; };
}, []); }, []);
const [lyrics, synced] = useMemo(() => {
if (Array.isArray(data)) {
if (data.length > 0) {
const selectedLyric = data[Math.min(index, data.length)];
return [selectedLyric, selectedLyric.synced];
}
} else if (data?.lyrics) {
return [data, Array.isArray(data.lyrics)];
}
return [undefined, false];
}, [data, index]);
const languages = useMemo(() => { const languages = useMemo(() => {
if (Array.isArray(data)) { if (Array.isArray(data)) {
return data.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() })); return data.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() }));
@ -203,10 +228,14 @@ export const Lyrics = () => {
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
> >
{synced ? ( {synced ? (
<SynchronizedLyrics {...(lyrics as SynchronizedLyricsProps)} /> <SynchronizedLyrics
{...(lyrics as SynchronizedLyricsProps)}
translatedLyrics={showTranslation ? translatedLyrics : null}
/>
) : ( ) : (
<UnsynchronizedLyrics <UnsynchronizedLyrics
{...(lyrics as UnsynchronizedLyricsProps)} {...(lyrics as UnsynchronizedLyricsProps)}
translatedLyrics={showTranslation ? translatedLyrics : null}
/> />
)} )}
</ScrollContainer> </ScrollContainer>
@ -221,6 +250,7 @@ export const Lyrics = () => {
onRemoveLyric={handleOnRemoveLyric} onRemoveLyric={handleOnRemoveLyric}
onResetLyric={handleOnResetLyric} onResetLyric={handleOnResetLyric}
onSearchOverride={handleOnSearchOverride} onSearchOverride={handleOnSearchOverride}
onTranslateLyric={handleOnTranslateLyric}
/> />
</ActionsContainer> </ActionsContainer>
</LyricsContainer> </LyricsContainer>

View file

@ -0,0 +1,50 @@
import axios from 'axios';
export const translateLyrics = async (
originalLyrics: string,
translationApiKey: string,
translationApiProvider: string | null,
translationTargetLanguage: string | null,
) => {
let TranslatedText = '';
if (translationApiProvider === 'Microsoft Azure') {
try {
const response = await axios({
data: [
{
Text: originalLyrics,
},
],
headers: {
'Content-Type': 'application/json',
'Ocp-Apim-Subscription-Key': translationApiKey,
},
method: 'post',
url: `https://api.cognitive.microsofttranslator.com/translate?api-version=3.0&to=${translationTargetLanguage as string}`,
});
TranslatedText = response.data[0].translations[0].text;
} catch (e) {
console.error('Microsoft Azure translate request got an error!', e);
return null;
}
} else if (translationApiProvider === 'Google Cloud') {
try {
const response = await axios({
data: {
format: 'text',
q: originalLyrics,
},
headers: {
'Content-Type': 'application/json',
},
method: 'post',
url: `https://translation.googleapis.com/language/translate/v2?target=${translationTargetLanguage as string}&key=${translationApiKey}`,
});
TranslatedText = response.data.data.translations[0].translatedText;
} catch (e) {
console.error('Google Cloud translate request got an error!', e);
return null;
}
}
return TranslatedText;
};

View file

@ -55,6 +55,7 @@ const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> { export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: SynchronizedLyricsArray; lyrics: SynchronizedLyricsArray;
translatedLyrics?: string | null;
} }
export const SynchronizedLyrics = ({ export const SynchronizedLyrics = ({
@ -63,6 +64,7 @@ export const SynchronizedLyrics = ({
name, name,
remote, remote,
source, source,
translatedLyrics,
}: SynchronizedLyricsProps) => { }: SynchronizedLyricsProps) => {
const playersRef = PlayersRef; const playersRef = PlayersRef;
const status = useCurrentStatus(); const status = useCurrentStatus();
@ -364,15 +366,25 @@ export const SynchronizedLyrics = ({
/> />
)} )}
{lyrics.map(([time, text], idx) => ( {lyrics.map(([time, text], idx) => (
<LyricLine <div key={idx}>
key={idx} <LyricLine
alignment={settings.alignment} alignment={settings.alignment}
className="lyric-line synchronized" className="lyric-line synchronized"
fontSize={settings.fontSize} fontSize={settings.fontSize}
id={`lyric-${idx}`} id={`lyric-${idx}`}
text={text} text={text}
onClick={() => handleSeek(time / 1000)} onClick={() => handleSeek(time / 1000)}
/> />
{translatedLyrics && (
<LyricLine
alignment={settings.alignment}
className="lyric-line synchronized translation"
fontSize={settings.fontSize * 0.8}
text={translatedLyrics.split('\n')[idx]}
onClick={() => handleSeek(time / 1000)}
/>
)}
</div>
))} ))}
</SynchronizedLyricsContainer> </SynchronizedLyricsContainer>
); );

View file

@ -6,6 +6,7 @@ import { useLyricsSettings } from '/@/renderer/store';
export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> { export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: string; lyrics: string;
translatedLyrics?: string | null;
} }
const UnsynchronizedLyricsContainer = styled.div<{ $gap: number }>` const UnsynchronizedLyricsContainer = styled.div<{ $gap: number }>`
@ -45,12 +46,17 @@ export const UnsynchronizedLyrics = ({
name, name,
remote, remote,
source, source,
translatedLyrics,
}: UnsynchronizedLyricsProps) => { }: UnsynchronizedLyricsProps) => {
const settings = useLyricsSettings(); const settings = useLyricsSettings();
const lines = useMemo(() => { const lines = useMemo(() => {
return lyrics.split('\n'); return lyrics.split('\n');
}, [lyrics]); }, [lyrics]);
const translatedLines = useMemo(() => {
return translatedLyrics ? translatedLyrics.split('\n') : [];
}, [translatedLyrics]);
return ( return (
<UnsynchronizedLyricsContainer <UnsynchronizedLyricsContainer
$gap={settings.gapUnsync} $gap={settings.gapUnsync}
@ -73,14 +79,23 @@ export const UnsynchronizedLyrics = ({
/> />
)} )}
{lines.map((text, idx) => ( {lines.map((text, idx) => (
<LyricLine <div key={idx}>
key={idx} <LyricLine
alignment={settings.alignment} alignment={settings.alignment}
className="lyric-line unsynchronized" className="lyric-line unsynchronized"
fontSize={settings.fontSizeUnsync} fontSize={settings.fontSizeUnsync}
id={`lyric-${idx}`} id={`lyric-${idx}`}
text={text} text={text}
/> />
{translatedLines[idx] && (
<LyricLine
alignment={settings.alignment}
className="lyric-line unsynchronized translation"
fontSize={settings.fontSizeUnsync * 0.8}
text={translatedLines[idx]}
/>
)}
</div>
))} ))}
</UnsynchronizedLyricsContainer> </UnsynchronizedLyricsContainer>
); );

View file

@ -98,7 +98,7 @@ export const FullScreenPlayerQueue = () => {
items.push({ items.push({
active: activeTab === 'visualizer', active: activeTab === 'visualizer',
icon: <RiFileTextLine size="1.5rem" />, icon: <RiFileTextLine size="1.5rem" />,
label: 'Visualizer', label: t('page.fullscreenPlayer.visualizer'),
onClick: () => setStore({ activeTab: 'visualizer' }), onClick: () => setStore({ activeTab: 'visualizer' }),
}); });
} }

View file

@ -5,12 +5,12 @@ import styled from 'styled-components';
import { useSettingsStore } from '/@/renderer/store'; import { useSettingsStore } from '/@/renderer/store';
const StyledContainer = styled.div` const StyledContainer = styled.div`
margin: auto;
max-width: 100%; max-width: 100%;
margin: auto;
canvas { canvas {
margin: auto;
width: 100%; width: 100%;
margin: auto;
} }
`; `;

View file

@ -3,11 +3,19 @@ import {
SettingsSection, SettingsSection,
} from '/@/renderer/features/settings/components/settings-section'; } from '/@/renderer/features/settings/components/settings-section';
import { useLyricsSettings, useSettingsStoreActions } from '/@/renderer/store'; import { useLyricsSettings, useSettingsStoreActions } from '/@/renderer/store';
import { MultiSelect, MultiSelectProps, NumberInput, Switch } from '/@/renderer/components'; import {
Select,
MultiSelect,
MultiSelectProps,
TextInput,
NumberInput,
Switch,
} from '/@/renderer/components';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import styled from 'styled-components'; import styled from 'styled-components';
import { LyricSource } from '/@/renderer/api/types'; import { LyricSource } from '/@/renderer/api/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { languages } from '/@/i18n/i18n';
const localSettings = isElectron() ? window.electron.localSettings : null; const localSettings = isElectron() ? window.electron.localSettings : null;
@ -116,6 +124,58 @@ export const LyricSettings = () => {
isHidden: !isElectron(), isHidden: !isElectron(),
title: t('setting.lyricOffset', { postProcess: 'sentenceCase' }), title: t('setting.lyricOffset', { postProcess: 'sentenceCase' }),
}, },
{
control: (
<Select
data={languages}
value={settings.translationTargetLanguage}
onChange={(value) => {
setSettings({ lyrics: { ...settings, translationTargetLanguage: value } });
}}
/>
),
description: t('setting.translationTargetLanguage', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.translationTargetLanguage', { postProcess: 'sentenceCase' }),
},
{
control: (
<Select
data={['Microsoft Azure', 'Google Cloud']}
value={settings.translationApiProvider}
onChange={(value) => {
setSettings({ lyrics: { ...settings, translationApiProvider: value } });
}}
/>
),
description: t('setting.translationApiProvider', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.translationApiProvider', { postProcess: 'sentenceCase' }),
},
{
control: (
<TextInput
value={settings.translationApiKey}
onChange={(e) => {
setSettings({
lyrics: { ...settings, translationApiKey: e.currentTarget.value },
});
}}
/>
),
description: t('setting.translationApiKey', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.translationApiKey', { postProcess: 'sentenceCase' }),
},
]; ];
return ( return (

View file

@ -275,6 +275,9 @@ export interface SettingsState {
showMatch: boolean; showMatch: boolean;
showProvider: boolean; showProvider: boolean;
sources: LyricSource[]; sources: LyricSource[];
translationApiKey: string;
translationApiProvider: string | null;
translationTargetLanguage: string | null;
}; };
playback: { playback: {
audioDeviceId?: string | null; audioDeviceId?: string | null;
@ -449,6 +452,9 @@ const initialState: SettingsState = {
showMatch: true, showMatch: true,
showProvider: true, showProvider: true,
sources: [], sources: [],
translationApiKey: '',
translationApiProvider: '',
translationTargetLanguage: 'en',
}, },
playback: { playback: {
audioDeviceId: undefined, audioDeviceId: undefined,