Skip to content
Draft
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
46 changes: 46 additions & 0 deletions src/components/AMLLWrapper/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,47 @@ import {
import { isDarkThemeAtom, lyricLinesAtom } from "$/states/main.ts";
import styles from "./index.module.css";

const parseLineVocalIds = (value?: string | string[]) => {
if (!value) return [];
const parts = Array.isArray(value) ? value : value.split(/[\s,]+/);
return parts.map((v) => v.trim()).filter(Boolean);
};

const mapVocalTagsForPreview = (
vocal: string | string[] | undefined,
vocalTagMap: Map<string, string>,
) => {
if (!vocal) return;
const fallbackParts = Array.isArray(vocal) ? vocal : [vocal];
const normalizedFallback = fallbackParts
.map((v) => v.trim())
.filter(Boolean);
if (vocalTagMap.size === 0) {
return normalizedFallback.length > 0 ? normalizedFallback : undefined;
}
const ids = parseLineVocalIds(vocal);
if (ids.length === 0) return;
let hasMatch = false;
const mapped = ids
.map((id) => {
const value = vocalTagMap.get(id);
if (value && value.trim().length > 0) {
hasMatch = true;
return value;
}
if (vocalTagMap.has(id)) {
hasMatch = true;
}
return id;
})
.map((v) => v.trim())
.filter(Boolean);
if (!hasMatch) {
return normalizedFallback.length > 0 ? normalizedFallback : undefined;
}
return mapped.length > 0 ? mapped : undefined;
};

export const AMLLWrapper = memo(() => {
const originalLyricLines = useAtomValue(lyricLinesAtom);
const currentTime = useAtomValue(currentTimeAtom);
Expand All @@ -38,11 +79,15 @@ export const AMLLWrapper = memo(() => {
const playerRef = useRef<LyricPlayerRef>(null);

const lyricLines = useMemo(() => {
const vocalTagMap = new Map(
(originalLyricLines.vocalTags ?? []).map((tag) => [tag.key, tag.value]),
);
return structuredClone(
originalLyricLines.lyricLines.map((line) => ({
...line,
translatedLyric: showTranslationLines ? line.translatedLyric : "",
romanLyric: showRomanLines ? line.romanLyric : "",
vocal: mapVocalTagsForPreview(line.vocal, vocalTagMap),
})),
);
}, [originalLyricLines, showTranslationLines, showRomanLines]);
Expand Down Expand Up @@ -80,3 +125,4 @@ export const AMLLWrapper = memo(() => {
});

export default AMLLWrapper;

2 changes: 2 additions & 0 deletions src/components/Dialogs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DistributeRomanizationDialog } from "$/modules/project/modals/Distribut
import { HistoryRestoreDialog } from "$/modules/project/modals/HistoryRestore.tsx";
import { ImportFromText } from "$/modules/project/modals/ImportFromText.tsx";
import { MetadataEditor } from "$/modules/project/modals/MetadataEditor.tsx";
import { VocalTagsEditor } from "$/modules/project/modals/VocalTagsEditor.tsx";
import { SubmitToAMLLDBDialog } from "$/modules/project/modals/SubmitToAmll.tsx";
import { AdvancedSegmentationDialog } from "$/modules/segmentation/components/AdvancedSegmentation.tsx";
import { SplitWordDialog } from "$/modules/segmentation/components/split-word.tsx";
Expand All @@ -18,6 +19,7 @@ export const Dialogs = () => {
<ImportFromText />
<ImportFromLRCLIB />
<MetadataEditor />
<VocalTagsEditor />
<SettingsDialog />
<SplitWordDialog />
<ReplaceWordDialog />
Expand Down
14 changes: 14 additions & 0 deletions src/components/TopMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
historyRestoreDialogAtom,
latencyTestDialogAtom,
metadataEditorDialogAtom,
vocalTagsEditorDialogAtom,
settingsDialogAtom,
submitToAMLLDBDialogAtom,
timeShiftDialogAtom,
Expand Down Expand Up @@ -80,6 +81,7 @@ export const TopMenu: FC = () => {
const newLyricLine = useSetAtom(newLyricLinesAtom);
const editLyricLines = useSetImmerAtom(lyricLinesAtom);
const setMetadataEditorOpened = useSetAtom(metadataEditorDialogAtom);
const setVocalTagsEditorOpened = useSetAtom(vocalTagsEditorDialogAtom);
const setSettingsDialogOpened = useSetAtom(settingsDialogAtom);
const undoLyricLines = useAtomValue(undoableLyricLinesAtom);
const store = useStore();
Expand Down Expand Up @@ -193,6 +195,10 @@ export const TopMenu: FC = () => {
setMetadataEditorOpened(true);
}, [setMetadataEditorOpened]);

const onOpenVocalTagsEditor = useCallback(() => {
setVocalTagsEditorOpened(true);
}, [setVocalTagsEditorOpened]);

const onOpenSettings = useCallback(() => {
setSettingsDialogOpened(true);
}, [setSettingsDialogOpened]);
Expand Down Expand Up @@ -534,6 +540,11 @@ export const TopMenu: FC = () => {
编辑歌词元数据
</Trans>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={onOpenVocalTagsEditor}>
<Trans i18nKey="topBar.menu.editVocalTags">
编辑演唱者标签
</Trans>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={onOpenSettings}>
<Trans i18nKey="settingsDialog.title">首选项</Trans>
Expand Down Expand Up @@ -737,6 +748,9 @@ export const TopMenu: FC = () => {
<DropdownMenu.Item onSelect={onOpenMetadataEditor}>
<Trans i18nKey="topBar.menu.editMetadata">编辑歌词元数据</Trans>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={onOpenVocalTagsEditor}>
<Trans i18nKey="topBar.menu.editVocalTags">编辑演唱者标签</Trans>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={onOpenSettings}>
<Trans i18nKey="settingsDialog.title">首选项</Trans>
Expand Down
12 changes: 12 additions & 0 deletions src/modules/lyric-editor/components/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,18 @@
}
}

.vocalTagsRow {
margin-top: var(--space-1);
}

.vocalTagsButtons {
gap: var(--space-1);
}

.vocalTagButton {
min-width: 32px;
}

.lyricWordsContainer {
min-width: 0;
flex-grow: 1;
Expand Down
107 changes: 98 additions & 9 deletions src/modules/lyric-editor/components/lyric-line-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import {
AddFilled,
People24Regular,
TextAlignRightFilled,
VideoBackgroundEffectFilled,
} from "@fluentui/react-icons";
Expand Down Expand Up @@ -65,6 +66,12 @@ import { RomanWordView } from "./roman-word-view.tsx";

const isDraggingAtom = atom(false);

const parseLineVocalIds = (value?: string | string[]) => {
if (!value) return [];
const parts = Array.isArray(value) ? value : value.split(/[\s,]+/);
return parts.map((v) => v.trim()).filter(Boolean);
};

// 定义一个派生 Atom,用于计算每一行的显示行号
const lineDisplayNumbersAtom = atom((get) => {
const { lyricLines } = get(lyricLinesAtom);
Expand Down Expand Up @@ -224,6 +231,15 @@ export const LyricLineView: FC<{
lineIndex: number;
}> = memo(({ lineAtom, lineIndex }) => {
const { t } = useTranslation();
const lyricState = useAtomValue(lyricLinesAtom);
const vocalTags = lyricState.vocalTags ?? [];
const vocalTagMap = useMemo(() => {
return new Map(vocalTags.map((tag) => [tag.key, tag.value]));
}, [vocalTags]);
const vocalTagIds = useMemo(
() => Array.from(new Set(vocalTags.map((tag) => tag.key).filter(Boolean))),
[vocalTags],
);
const line = useAtomValue(lineAtom);
const setSelectedLines = useSetImmerAtom(selectedLinesAtom);
const lineSelectedAtom = useMemo(() => {
Expand Down Expand Up @@ -630,15 +646,88 @@ export const LyricLineView: FC<{
type="translatedLyric"
/>
)}
{showRomanization && (
<SubLineEdit
lineAtom={lineAtom}
lineIndex={lineIndex}
type="romanLyric"
/>
)}
</>
)}
{showRomanization && (
<SubLineEdit
lineAtom={lineAtom}
lineIndex={lineIndex}
type="romanLyric"
/>
)}
{vocalTagIds.length > 0 && (
<Flex align="center" gap="2" className={styles.vocalTagsRow}>
<Text size="2">
{t("lyricLineView.vocalTagsLabel", "演唱者:")}
</Text>
<Flex gap="1" wrap="wrap" className={styles.vocalTagsButtons}>
{(() => {
const selectedIds = parseLineVocalIds(line.vocal);
const selectedSet = new Set(selectedIds);
const allSelected =
vocalTagIds.length > 0 &&
vocalTagIds.every((id) => selectedSet.has(id));
const orderedIds = [
...selectedIds.filter((id) => vocalTagIds.includes(id)),
...vocalTagIds.filter((id) => !selectedSet.has(id)),
];
return [
...orderedIds.map((id) => {
const isActive = selectedSet.has(id);
const tagName = vocalTagMap.get(id);
return (
<Button
key={`line-${lineIndex}-vocal-${id}`}
size="1"
variant={isActive ? "solid" : "soft"}
color={isActive ? "green" : "gray"}
className={styles.vocalTagButton}
title={tagName || undefined}
onClick={(evt) => {
evt.stopPropagation();
editLyricLines((state) => {
const targetLine = state.lyricLines[lineIndex];
const currentIds = parseLineVocalIds(
targetLine.vocal,
);
const existingIndex = currentIds.indexOf(id);
if (existingIndex > -1) {
currentIds.splice(existingIndex, 1);
} else {
currentIds.push(id);
}
targetLine.vocal = currentIds;
});
}}
>
{tagName || id}
</Button>
);
}),
<Button
key={`line-${lineIndex}-vocal-all`}
size="1"
variant={allSelected ? "solid" : "soft"}
color={allSelected ? "green" : "gray"}
className={styles.vocalTagButton}
onClick={(evt) => {
evt.stopPropagation();
editLyricLines((state) => {
const targetLine = state.lyricLines[lineIndex];
targetLine.vocal = allSelected ? [] : [...vocalTagIds];
});
}}
>
<Flex align="center" gap="1">
<People24Regular />
{t("lyricLineView.vocalTagsAll", "全体成员")}
</Flex>
</Button>,
];
})()}
</Flex>
</Flex>
)}
</>
)}
</div>
{toolMode === ToolMode.Edit && (
<Flex p="3">
Expand Down
40 changes: 38 additions & 2 deletions src/modules/project/logic/ttml-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
LyricWord,
TTMLLyric,
TTMLMetadata,
TTMLVocalTag,
} from "../../../types/ttml.ts";
import { log } from "../../../utils/logging.ts";
import { parseTimespan } from "../../../utils/timestamp.ts";
Expand Down Expand Up @@ -86,6 +87,12 @@ export function parseLyric(ttmlText: string): TTMLLyric {
});

const itunesLineRomanizations = new Map<string, LineMetadata>();
const parseVocalValue = (value: string | string[] | null | undefined) => {
if (!value) return [];
const parts = Array.isArray(value) ? value : value.split(/[\s,]+/);
return parts.map((v) => v.trim()).filter(Boolean);
};

const itunesWordRomanizations = new Map<string, WordRomanMetadata>();

const romanizationTextElements = ttmlDoc.querySelectorAll(
Expand Down Expand Up @@ -216,6 +223,22 @@ export function parseLyric(ttmlText: string): TTMLLyric {
}
}

const vocalTagMap = new Map<string, string>();
const vocalContainers = ttmlDoc.querySelectorAll(
"metadata > amll\\:vocals, metadata > vocals, amll\\:vocals, vocals",
);
for (const container of vocalContainers) {
for (const vocal of container.querySelectorAll("vocal")) {
const key = vocal.getAttribute("key");
if (!key) continue;
const value = vocal.getAttribute("value") ?? "";
vocalTagMap.set(key, value);
}
}
const vocalTags: TTMLVocalTag[] = Array.from(vocalTagMap.entries()).map(
([key, value]) => ({ key, value }),
);

const songwriterElements = ttmlDoc.querySelectorAll(
"iTunesMetadata > songwriters > songwriter",
);
Expand Down Expand Up @@ -252,6 +275,7 @@ export function parseLyric(ttmlText: string): TTMLLyric {
isBG = false,
isDuet = false,
parentItunesKey: string | null = null,
parentVocal: string | string[] | null = null,
) {
const startTimeAttr = lineEl.getAttribute("begin");
const endTimeAttr = lineEl.getAttribute("end");
Expand All @@ -264,6 +288,10 @@ export function parseLyric(ttmlText: string): TTMLLyric {
parsedEndTime = parseTimespan(endTimeAttr);
}

const lineVocalAttr =
lineEl.getAttribute("amll:vocal") ?? lineEl.getAttribute("vocal");
const lineVocal = lineVocalAttr ?? (isBG ? parentVocal : null);
const parsedLineVocal = parseVocalValue(lineVocal);
const line: LyricLine = {
id: uid(),
words: [],
Expand All @@ -277,6 +305,7 @@ export function parseLyric(ttmlText: string): TTMLLyric {
startTime: parsedStartTime,
endTime: parsedEndTime,
ignoreSync: false,
vocal: parsedLineVocal,
};
let haveBg = false;

Expand Down Expand Up @@ -326,7 +355,13 @@ export function parseLyric(ttmlText: string): TTMLLyric {

if (wordEl.nodeName === "span" && role) {
if (role === "x-bg") {
parseLineElement(wordEl, true, line.isDuet, itunesKey);
parseLineElement(
wordEl,
true,
line.isDuet,
itunesKey,
line.vocal?.length ? line.vocal : null,
);
haveBg = true;
} else if (role === "x-translation") {
// 没有 Apple Music 样式翻译时才使用内嵌翻译
Expand Down Expand Up @@ -414,13 +449,14 @@ export function parseLyric(ttmlText: string): TTMLLyric {
}

for (const lineEl of ttmlDoc.querySelectorAll("body p[begin][end]")) {
parseLineElement(lineEl, false, false, null);
parseLineElement(lineEl, false, false, null, null);
}

log("finished ttml load", lyricLines, metadata);

return {
metadata,
lyricLines: lyricLines,
vocalTags,
};
}
Loading