diff --git a/src/components/AMLLWrapper/index.tsx b/src/components/AMLLWrapper/index.tsx index 8bb022e0..189ddc53 100644 --- a/src/components/AMLLWrapper/index.tsx +++ b/src/components/AMLLWrapper/index.tsx @@ -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, +) => { + 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); @@ -38,11 +79,15 @@ export const AMLLWrapper = memo(() => { const playerRef = useRef(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]); @@ -80,3 +125,4 @@ export const AMLLWrapper = memo(() => { }); export default AMLLWrapper; + diff --git a/src/components/Dialogs/index.tsx b/src/components/Dialogs/index.tsx index 40917f44..537292b5 100644 --- a/src/components/Dialogs/index.tsx +++ b/src/components/Dialogs/index.tsx @@ -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"; @@ -18,6 +19,7 @@ export const Dialogs = () => { + diff --git a/src/components/TopMenu/index.tsx b/src/components/TopMenu/index.tsx index f5db8227..8a1cd6d0 100644 --- a/src/components/TopMenu/index.tsx +++ b/src/components/TopMenu/index.tsx @@ -21,6 +21,7 @@ import { historyRestoreDialogAtom, latencyTestDialogAtom, metadataEditorDialogAtom, + vocalTagsEditorDialogAtom, settingsDialogAtom, submitToAMLLDBDialogAtom, timeShiftDialogAtom, @@ -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(); @@ -193,6 +195,10 @@ export const TopMenu: FC = () => { setMetadataEditorOpened(true); }, [setMetadataEditorOpened]); + const onOpenVocalTagsEditor = useCallback(() => { + setVocalTagsEditorOpened(true); + }, [setVocalTagsEditorOpened]); + const onOpenSettings = useCallback(() => { setSettingsDialogOpened(true); }, [setSettingsDialogOpened]); @@ -534,6 +540,11 @@ export const TopMenu: FC = () => { 编辑歌词元数据 + + + 编辑演唱者标签 + + 首选项 @@ -737,6 +748,9 @@ export const TopMenu: FC = () => { 编辑歌词元数据 + + 编辑演唱者标签 + 首选项 diff --git a/src/modules/lyric-editor/components/index.module.css b/src/modules/lyric-editor/components/index.module.css index 553d8da7..5412d7dd 100644 --- a/src/modules/lyric-editor/components/index.module.css +++ b/src/modules/lyric-editor/components/index.module.css @@ -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; diff --git a/src/modules/lyric-editor/components/lyric-line-view.tsx b/src/modules/lyric-editor/components/lyric-line-view.tsx index 6b7ad835..48eb6dcf 100644 --- a/src/modules/lyric-editor/components/lyric-line-view.tsx +++ b/src/modules/lyric-editor/components/lyric-line-view.tsx @@ -11,6 +11,7 @@ import { AddFilled, + People24Regular, TextAlignRightFilled, VideoBackgroundEffectFilled, } from "@fluentui/react-icons"; @@ -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); @@ -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(() => { @@ -630,15 +646,88 @@ export const LyricLineView: FC<{ type="translatedLyric" /> )} - {showRomanization && ( - - )} - - )} + {showRomanization && ( + + )} + {vocalTagIds.length > 0 && ( + + + {t("lyricLineView.vocalTagsLabel", "演唱者:")} + + + {(() => { + 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 ( + + ); + }), + , + ]; + })()} + + + )} + + )} {toolMode === ToolMode.Edit && ( diff --git a/src/modules/project/logic/ttml-parser.ts b/src/modules/project/logic/ttml-parser.ts index 1e3087ed..1e829e97 100644 --- a/src/modules/project/logic/ttml-parser.ts +++ b/src/modules/project/logic/ttml-parser.ts @@ -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"; @@ -86,6 +87,12 @@ export function parseLyric(ttmlText: string): TTMLLyric { }); const itunesLineRomanizations = new Map(); + 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(); const romanizationTextElements = ttmlDoc.querySelectorAll( @@ -216,6 +223,22 @@ export function parseLyric(ttmlText: string): TTMLLyric { } } + const vocalTagMap = new Map(); + 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", ); @@ -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"); @@ -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: [], @@ -277,6 +305,7 @@ export function parseLyric(ttmlText: string): TTMLLyric { startTime: parsedStartTime, endTime: parsedEndTime, ignoreSync: false, + vocal: parsedLineVocal, }; let haveBg = false; @@ -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 样式翻译时才使用内嵌翻译 @@ -414,7 +449,7 @@ 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); @@ -422,5 +457,6 @@ export function parseLyric(ttmlText: string): TTMLLyric { return { metadata, lyricLines: lyricLines, + vocalTags, }; } diff --git a/src/modules/project/logic/ttml-writer.ts b/src/modules/project/logic/ttml-writer.ts index 5fa79772..63a4b268 100644 --- a/src/modules/project/logic/ttml-writer.ts +++ b/src/modules/project/logic/ttml-writer.ts @@ -61,6 +61,15 @@ export default function exportTTMLText( return span; } + function normalizeVocalValue(vocal?: string | string[] | null): string { + if (!vocal) return ""; + const parts = Array.isArray(vocal) ? vocal : vocal.split(/[\s,]+/); + return parts + .map((v) => v.trim()) + .filter(Boolean) + .join(","); + } + const ttRoot = doc.createElement("tt"); ttRoot.setAttribute("xmlns", "http://www.w3.org/ns/ttml"); @@ -115,6 +124,20 @@ export default function exportTTMLText( metadataEl.appendChild(otherPersonAgent); } + const vocalTags = ttmlLyric.vocalTags?.filter( + (tag) => tag.key && tag.key.trim().length > 0, + ) ?? []; + if (vocalTags.length > 0) { + const vocalsEl = doc.createElement("amll:vocals"); + for (const tag of vocalTags) { + const vocalEl = doc.createElement("vocal"); + vocalEl.setAttribute("key", tag.key); + vocalEl.setAttribute("value", tag.value ?? ""); + vocalsEl.appendChild(vocalEl); + } + metadataEl.appendChild(vocalsEl); + } + // Extract songwriter metadata to emit in iTunes format (Spicylyrics compatibility) const songwriterMeta = ttmlLyric.metadata.find( (m) => m.key === "songwriter" && m.value.some((v) => v.trim().length > 0), @@ -184,6 +207,10 @@ export default function exportTTMLText( lineP.setAttribute("end", msToTimestamp(endTime)); lineP.setAttribute("ttm:agent", line.isDuet ? "v2" : "v1"); + const normalizedVocal = normalizeVocalValue(line.vocal); + if (normalizedVocal.length > 0) { + lineP.setAttribute("amll:vocal", normalizedVocal); + } const itunesKey = `L${++i}`; lineP.setAttribute("itunes:key", itunesKey); @@ -265,6 +292,11 @@ export default function exportTTMLText( bgLineSpan.setAttribute("end", msToTimestamp(word.endTime)); } + const normalizedBgVocal = normalizeVocalValue(bgLine.vocal); + if (normalizedBgVocal.length > 0) { + bgLineSpan.setAttribute("amll:vocal", normalizedBgVocal); + } + if (bgLine.translatedLyric) { const span = doc.createElement("span"); span.setAttribute("ttm:role", "x-translation"); diff --git a/src/modules/project/modals/VocalTagsEditor.module.css b/src/modules/project/modals/VocalTagsEditor.module.css new file mode 100644 index 00000000..4aa9a067 --- /dev/null +++ b/src/modules/project/modals/VocalTagsEditor.module.css @@ -0,0 +1,67 @@ +.dialogContent { + max-width: 760px; + max-height: 90vh; + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; +} + +.dialogHeader { + padding: 20px 20px 10px 20px; + flex-shrink: 0; +} + +.dialogBody { + flex: 1; + overflow-y: auto; + padding: 0 20px 20px 20px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.sectionHeader { + margin-bottom: 8px; +} + +.tagList { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tagControl { + min-width: 180px; +} + +.table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.table th, +.table td { + padding: 6px 4px; + vertical-align: top; +} + +.lineIndexColumn { + width: 60px; +} + +.lineText { + white-space: normal; +} + +.vocalButtonGroup { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding-left: 25px; +} + +.vocalButton { + min-width: 40px; +} diff --git a/src/modules/project/modals/VocalTagsEditor.tsx b/src/modules/project/modals/VocalTagsEditor.tsx new file mode 100644 index 00000000..af705b70 --- /dev/null +++ b/src/modules/project/modals/VocalTagsEditor.tsx @@ -0,0 +1,253 @@ +import { Add16Regular, Delete16Regular } from "@fluentui/react-icons"; +import { Button, Dialog, Flex, Text, TextField } from "@radix-ui/themes"; +import { useAtom } from "jotai"; +import { useImmerAtom } from "jotai-immer"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { vocalTagsEditorDialogAtom } from "$/states/dialogs.ts"; +import { lyricLinesAtom } from "$/states/main.ts"; +import type { LyricLine, TTMLLyric } from "$/types/ttml"; +import styles from "./VocalTagsEditor.module.css"; + +const getLineText = (line: LyricLine) => line.words.map((word) => word.word).join(""); +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 getNextVocalId = (ids: string[]) => { + let maxId = 0; + for (const id of ids) { + if (!/^[0-9]+$/.test(id)) continue; + const parsed = Number.parseInt(id, 10); + if (Number.isFinite(parsed)) maxId = Math.max(maxId, parsed); + } + const nextId = maxId > 0 ? maxId + 1 : ids.length + 1; + return `${nextId}`; +}; +const hasDuplicateTag = ( + tags: Array<{ value: string }>, + value: string, + excludeIndex?: number, +) => { + const normalized = value.trim(); + return tags.some((tag, index) => { + if (excludeIndex === index) return false; + return tag.value.trim() === normalized; + }); +}; +const reassignVocalIds = (draft: TTMLLyric) => { + if (!draft.vocalTags || draft.vocalTags.length === 0) { + draft.lyricLines.forEach((line) => { + line.vocal = []; + }); + return; + } + const idMap = new Map(); + draft.vocalTags.forEach((tag, index) => { + const nextId = `${index + 1}`; + idMap.set(tag.key, nextId); + tag.key = nextId; + }); + draft.lyricLines.forEach((line) => { + const ids = parseLineVocalIds(line.vocal); + const mapped = ids + .map((id) => idMap.get(id)) + .filter((value): value is string => !!value); + line.vocal = mapped; + }); +}; + +export const VocalTagsEditor = () => { + const [open, setOpen] = useAtom(vocalTagsEditorDialogAtom); + const [lyricLines, setLyricLines] = useImmerAtom(lyricLinesAtom); + const { t } = useTranslation(); + const [editingIndex, setEditingIndex] = useState(null); + const [editingValue, setEditingValue] = useState(""); + const [isAdding, setIsAdding] = useState(false); + const [newTagValue, setNewTagValue] = useState(""); + + const vocalTags = lyricLines.vocalTags ?? []; + const vocalTagIds = useMemo( + () => Array.from(new Set(vocalTags.map((tag) => tag.key).filter(Boolean))), + [vocalTags], + ); + const artistNames = useMemo(() => { + const artists = lyricLines.metadata.find((meta) => meta.key === "artists"); + if (!artists) return []; + return artists.value + .map((value) => value.trim()) + .filter((value) => value.length > 0); + }, [lyricLines.metadata]); + + return ( + + +
+ + {t("vocalTagsDialog.title", "演唱者标签")} + +
+
+
+
+ + + {t("vocalTagsDialog.mappingTitle", "演唱者标签")} + + + + + + + +
+ {vocalTags.length === 0 && ( + + {t("vocalTagsDialog.empty", "暂无演唱者标签。")} + + )} +
+ {vocalTags.map((tag, index) => + editingIndex === index ? ( + setEditingValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + const trimmed = editingValue.trim(); + setLyricLines((draft) => { + if (!draft.vocalTags) return; + if (!trimmed) { + draft.vocalTags.splice(index, 1); + reassignVocalIds(draft); + return; + } + if (hasDuplicateTag(draft.vocalTags, trimmed, index)) return; + const target = draft.vocalTags[index]; + if (target) target.value = trimmed; + }); + setEditingIndex(null); + }} + onBlur={() => setEditingIndex(null)} + /> + ) : ( + + ), + )} + {isAdding ? ( + setNewTagValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + const trimmed = newTagValue.trim(); + if (!trimmed) { + setIsAdding(false); + return; + } + setLyricLines((draft) => { + draft.vocalTags ??= []; + if (hasDuplicateTag(draft.vocalTags, trimmed)) return; + const nextId = getNextVocalId( + draft.vocalTags.map((tag) => tag.key), + ); + draft.vocalTags.push({ key: nextId, value: trimmed }); + }); + setIsAdding(false); + setNewTagValue(""); + }} + onBlur={() => setIsAdding(false)} + /> + ) : ( + + )} +
+
+
+
+
+ ); +}; + diff --git a/src/states/dialogs.ts b/src/states/dialogs.ts index 366cfd9a..fa432aeb 100644 --- a/src/states/dialogs.ts +++ b/src/states/dialogs.ts @@ -2,6 +2,7 @@ import { atom } from "jotai"; export const importFromTextDialogAtom = atom(false); export const metadataEditorDialogAtom = atom(false); +export const vocalTagsEditorDialogAtom = atom(false); export const settingsDialogAtom = atom(false); export const settingsTabAtom = atom("common"); export const latencyTestDialogAtom = atom(false); diff --git a/src/states/main.ts b/src/states/main.ts index 1702e437..bc968c62 100644 --- a/src/states/main.ts +++ b/src/states/main.ts @@ -40,6 +40,7 @@ export const autoDarkModeAtom = atom(true); export const lyricLinesAtom = atom({ lyricLines: [], metadata: [], + vocalTags: [], } as TTMLLyric); /** @@ -108,6 +109,7 @@ export const newLyricLinesAtom = atom( newState: TTMLLyric = { lyricLines: [], metadata: [], + vocalTags: [], }, ) => { set(lyricLinesAtom, newState); diff --git a/src/types/ttml.ts b/src/types/ttml.ts index 4739e76b..5bc8e3c5 100644 --- a/src/types/ttml.ts +++ b/src/types/ttml.ts @@ -21,9 +21,15 @@ export interface TTMLMetadata { error?: boolean; } +export interface TTMLVocalTag { + key: string; + value: string; +} + export interface TTMLLyric { metadata: TTMLMetadata[]; lyricLines: LyricLine[]; + vocalTags?: TTMLVocalTag[]; } export interface LyricWord extends AMLLLyricWord { @@ -55,6 +61,7 @@ export interface LyricLine extends AMLLLyricLine { // startTime: number; // endTime: number; ignoreSync: boolean; + vocal?: string | string[]; } export const newLyricLine = (): LyricLine => ({ @@ -67,4 +74,5 @@ export const newLyricLine = (): LyricLine => ({ startTime: 0, endTime: 0, ignoreSync: false, + vocal: [], });