diff --git a/.gitignore b/.gitignore index 5f37ac3..32a18f4 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,8 @@ dev/ data/ outputs/ deploy/ -.gradio/ \ No newline at end of file +.gradio/ + +# midi editor +node_modules/ +dist/ \ No newline at end of file diff --git a/preprocess/tools/midi_editor/README.md b/preprocess/tools/midi_editor/README.md index 97e3bac..1b26170 100644 --- a/preprocess/tools/midi_editor/README.md +++ b/preprocess/tools/midi_editor/README.md @@ -10,14 +10,16 @@ A full-featured web MIDI editor for singing voice preprocess. It supports real-t ### 🎼 Piano Roll Editing -- **Visual note editing**: Full range from C1 to C8 with intuitive piano keys +- **Visual note editing**: Full range from C1 to C8 with intuitive piano key layout - **Drag operations**: - Move notes: drag note blocks to adjust position and pitch - Resize start: drag the left edge to adjust start time - Resize end: drag the right edge to adjust end time -- **Quick pitch adjust**: Command/Ctrl + Up/Down to nudge selected notes +- **Quick pitch adjust**: + - Command/Ctrl + Up/Down to nudge selected note pitch + - Use the Transpose control in the toolbar to shift all notes at once - **Double-click to add**: Add new notes quickly in empty areas -- **Piano key preview**: Click a key to audition the pitch +- **Piano key preview**: Click a key on the left to audition the pitch ### 🔍 Zoom & Navigation @@ -29,14 +31,14 @@ A full-featured web MIDI editor for singing voice preprocess. It supports real-t ### 📝 Lyric Editing - **Inline editing**: edit lyrics for each note in the side list -- **Batch fill**: enter a string and auto-fill notes in order -- **Fill from selection**: start batch fill from the selected note +- **Batch fill**: enter lyrics and auto-fill notes in order +- **Fill from selection**: start batch fill from the currently selected note - **Precise fields**: edit PITCH, START, and END directly -- **Confirm edits**: press Enter or click ✓ to confirm +- **Confirm edits**: press Enter or click ✓ to confirm, avoiding accidental changes ### 🎵 Audio Alignment -- **Waveform display**: sync waveform with the MIDI timeline +- **Waveform display**: import audio to display waveform, synced with the MIDI timeline - **Formats**: MP3, WAV, OGG, FLAC, M4A, AAC - **Sync playback**: play audio and MIDI together with independent volume control - **Click to seek**: click waveform or timeline to seek @@ -44,15 +46,12 @@ A full-featured web MIDI editor for singing voice preprocess. It supports real-t ### ⚠️ Overlap Detection - **Visual highlight**: overlapping notes blink in red -- **Smart tolerance**: adjacent notes (end equals next start) are not overlaps - **One-click fix**: remove all overlaps automatically -- **Export warning**: warn if overlaps exist during export ### 📥 Import & Export -- **MIDI import**: parse standard MIDI and lyric metadata -- **MIDI export**: export MIDI with lyrics -- **Chinese support**: full UTF-8 lyrics support +- **MIDI import**: parse standard MIDI files with automatic lyric metadata extraction +- **MIDI export**: export MIDI files with lyric information ### 🎨 UI & UX @@ -97,9 +96,9 @@ npm run preview 1. **Import MIDI**: click Import MIDI and select a .mid file 2. **Edit notes**: drag notes in the piano roll to adjust time and pitch -3. **Add lyrics**: edit lyrics in the right-side list -4. **Align audio** (optional): import reference audio -5. **Export**: click Export MIDI with lyrics +3. **Add lyrics**: edit lyrics in the right-side list, or use batch fill +4. **Align audio** (optional): import reference audio for side-by-side editing +5. **Export**: click Export MIDI to save ### Shortcuts @@ -120,9 +119,9 @@ npm run preview | Button | Description | |------|------| | ⏮ | Go to start | -| ⏪ 2s | Back 2 seconds | +| ⏪ | Back 2 seconds | | ▶ / ⏸ | Play / Pause | -| 2s ⏩ | Forward 2 seconds | +| ⏩ | Forward 2 seconds | | ⏭ | Go to end | | Selection | Play selected region | @@ -153,19 +152,19 @@ npm run preview ├── vite.config.ts ├── public/ └── src/ - ├── App.css - ├── App.tsx - ├── constants.ts - ├── index.css - ├── main.tsx - ├── types.ts - ├── assets/ + ├── App.css # Main styles (theme variables, layout, components) + ├── App.tsx # Main app component (transport, import/export, transpose) + ├── constants.ts # Constants (grid width, row height, pitch range) + ├── i18n.ts # Internationalization (zh/en translations, smart lyric tokenizer) + ├── index.css # Global styles (Tailwind, root font, theme gradients) + ├── main.tsx # React entry point + ├── types.ts # Type definitions (NoteEvent, TimeSignature, etc.) ├── components/ - │ ├── AudioTrack.tsx - │ ├── LyricTable.tsx - │ └── PianoRoll.tsx + │ ├── AudioTrack.tsx # Audio waveform display component + │ ├── LyricTable.tsx # Lyric editing table component + │ └── PianoRoll.tsx # Piano roll editor component ├── lib/ - │ └── midi.ts + │ └── midi.ts # MIDI import/export utilities (UTF-8 lyric encoding) └── store/ - └── useMidiStore.ts + └── useMidiStore.ts # Zustand state management ``` diff --git a/preprocess/tools/midi_editor/README_CN.md b/preprocess/tools/midi_editor/README_CN.md index 2cedf51..16e8d3d 100644 --- a/preprocess/tools/midi_editor/README_CN.md +++ b/preprocess/tools/midi_editor/README_CN.md @@ -15,7 +15,9 @@ - 移动音符:拖拽音符块调整位置和音高 - 调整音头:拖拽音符左边缘调整开始时间 - 调整音尾:拖拽音符右边缘调整结束时间 -- **快捷音高调整**:Command/Ctrl + 上/下键微调选中音符的音高 +- **快捷音高调整**: + - Command/Ctrl + 上/下键调整选中音符的音高 + - 通过功能区的移调功能来整体移动音高 - **双击添加**:在钢琴卷帘空白处双击快速添加新音符 - **钢琴键试听**:点击左侧钢琴键可试听对应音高 @@ -44,15 +46,12 @@ ### ⚠️ 重叠检测 - **可视化标注**:时间重叠的音符显示为红色并闪烁 -- **智能容差**:紧邻的音符(上一个结束 = 下一个开始)不视为重叠 - **一键修复**:点击消除重叠按钮自动修复所有重叠 -- **导出提醒**:导出时如有重叠会弹出警告 ### 📥 导入导出 - **MIDI 导入**:支持标准 MIDI 文件,自动解析歌词元数据 - **MIDI 导出**:导出包含歌词信息的 MIDI 文件 -- **中文支持**:完整支持中文歌词的导入导出(UTF-8 编码) ### 🎨 界面特性 @@ -97,7 +96,7 @@ npm run preview 1. **导入 MIDI**:点击导入 MIDI 按钮选择 .mid 文件 2. **编辑音符**:在钢琴卷帘中拖拽调整音符位置和时长 -3. **添加歌词**:在右侧列表中输入每个音符的歌词 +3. **添加歌词**:在右侧列表中输入句级别的歌词或单字编辑 4. **对齐音频**(可选):导入参考音频进行对照编辑 5. **导出文件**:点击导出含歌词 MIDI 保存文件 @@ -120,9 +119,9 @@ npm run preview | 按钮 | 功能 | |------|------| | ⏮ | 回到开头 | -| ⏪ 2s | 后退 2 秒 | +| ⏪ | 后退 2 秒 | | ▶ / ⏸ | 播放 / 暂停 | -| 2s ⏩ | 前进 2 秒 | +| ⏩ | 前进 2 秒 | | ⏭ | 跳到结尾 | | 选定区域 | 播放选定区域 | @@ -153,19 +152,19 @@ npm run preview ├── vite.config.ts ├── public/ └── src/ - ├── App.css - ├── App.tsx - ├── constants.ts - ├── index.css - ├── main.tsx - ├── types.ts - ├── assets/ + ├── App.css # 主样式(含主题变量、布局、组件样式) + ├── App.tsx # 主应用组件(走带、导入导出、移调等) + ├── constants.ts # 常量定义(网格宽度、行高、音域范围) + ├── i18n.ts # 国际化(中英文翻译、歌词智能分词器) + ├── index.css # 全局样式(Tailwind、根字体、主题渐变) + ├── main.tsx # React 入口 + ├── types.ts # 类型定义(NoteEvent、TimeSignature 等) ├── components/ - │ ├── AudioTrack.tsx - │ ├── LyricTable.tsx - │ └── PianoRoll.tsx + │ ├── AudioTrack.tsx # 音频波形显示组件 + │ ├── LyricTable.tsx # 歌词编辑表格组件 + │ └── PianoRoll.tsx # 钢琴卷帘编辑器组件 ├── lib/ - │ └── midi.ts + │ └── midi.ts # MIDI 导入导出工具(含 UTF-8 歌词编解码) └── store/ - └── useMidiStore.ts + └── useMidiStore.ts # Zustand 状态管理 ``` diff --git a/preprocess/tools/midi_editor/index.html b/preprocess/tools/midi_editor/index.html index a4c2df7..cd2904a 100644 --- a/preprocess/tools/midi_editor/index.html +++ b/preprocess/tools/midi_editor/index.html @@ -4,7 +4,7 @@ - midi-editor + SoulX-Singer MIDI Editor
diff --git a/preprocess/tools/midi_editor/package-lock.json b/preprocess/tools/midi_editor/package-lock.json index 9a25298..2547a00 100644 --- a/preprocess/tools/midi_editor/package-lock.json +++ b/preprocess/tools/midi_editor/package-lock.json @@ -79,6 +79,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1510,6 +1511,7 @@ "integrity": "sha512-r0bBaXu5Swb05doFYO2kTWHMovJnNVbCsII0fhesM8bNRlLhXIuckley4a2DaD+vOdmm5G+zGkQZAPZsF80+YQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1520,6 +1522,7 @@ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1579,6 +1582,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -1830,6 +1834,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2059,6 +2064,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2386,6 +2392,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3329,6 +3336,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3376,6 +3384,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.0", @@ -3590,6 +3599,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4031,6 +4041,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4124,6 +4135,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4315,6 +4327,7 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/preprocess/tools/midi_editor/src/App.css b/preprocess/tools/midi_editor/src/App.css index 7f5bdfa..6aeaab7 100644 --- a/preprocess/tools/midi_editor/src/App.css +++ b/preprocess/tools/midi_editor/src/App.css @@ -46,6 +46,39 @@ .actions { display: flex; gap: 10px; + align-items: center; +} + +.transpose-group { + display: flex; + align-items: center; +} + +.transpose-select { + padding: 10px 10px; + border-radius: 12px; + border: 1px solid var(--border-soft); + background: var(--button-soft-bg); + color: var(--button-soft-text); + font-weight: 600; + font-size: 14px; + cursor: pointer; + outline: none; + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 26px; + transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease; +} + +.transpose-select:hover { + transform: translateY(-1px); +} + +.transpose-select:focus { + border-color: var(--accent); } .icon-toggle { @@ -66,6 +99,12 @@ transform: translateY(-1px); } +.lang-label { + font-size: 14px; + font-weight: 700; + line-height: 1; +} + .audio-bar { margin-top: 14px; padding: 12px 16px; @@ -217,20 +256,35 @@ .transport { display: flex; - gap: 8px; + gap: 6px; align-items: center; + grid-column: 1 / -1; } -.selection-controls { - display: flex; - gap: 8px; - align-items: center; - padding: 6px 0; +.transport button { + padding: 6px 10px !important; + font-size: 13px !important; + min-width: 0; +} + +.status { + grid-column: 1 / -1; + color: var(--text-muted); + font-size: 13px; +} + +.transport-divider { + width: 1px; + height: 20px; + background: var(--border-soft); + margin: 0 2px; + flex-shrink: 0; } .selection-btn { font-size: 12px !important; padding: 6px 10px !important; + white-space: nowrap; } .selection-btn.active { @@ -238,25 +292,12 @@ color: white !important; } -.selection-info { - font-size: 12px; - color: var(--accent); - font-weight: 500; - padding: 4px 8px; - background: rgba(var(--accent-rgb), 0.1); - border-radius: 6px; -} - -.status { - color: var(--text-muted); - font-size: 13px; -} - .button, .actions button, .transport button, .ghost, .primary, +.json-btn, .soft { cursor: pointer; border-radius: 12px; @@ -279,6 +320,12 @@ box-shadow: 0 8px 26px rgba(72, 228, 194, 0.2); } +.json-btn { + background: linear-gradient(135deg, #f59e0b, #d97706); + color: #fff; + box-shadow: 0 8px 26px rgba(245, 158, 11, 0.2); +} + .soft { background: var(--button-soft-bg); color: var(--button-soft-text); @@ -287,6 +334,7 @@ .ghost:disabled, .primary:disabled, +.json-btn:disabled, .soft:disabled { opacity: 0.6; cursor: not-allowed; @@ -294,6 +342,7 @@ .ghost:hover, .primary:hover, +.json-btn:hover, .soft:hover { transform: translateY(-1px); } diff --git a/preprocess/tools/midi_editor/src/App.tsx b/preprocess/tools/midi_editor/src/App.tsx index 3fb68b0..7292172 100644 --- a/preprocess/tools/midi_editor/src/App.tsx +++ b/preprocess/tools/midi_editor/src/App.tsx @@ -6,6 +6,8 @@ import { AudioTrack } from './components/AudioTrack' import { useMidiStore } from './store/useMidiStore' import { exportMidi, importMidiFile } from './lib/midi' import type { TimeSignature } from './types' +import type { Lang } from './i18n' +import { getTranslations } from './i18n' import { BASE_GRID_SECOND_WIDTH, BASE_ROW_HEIGHT, LOW_NOTE, HIGH_NOTE } from './constants' import './App.css' @@ -35,7 +37,10 @@ function App() { setPlayhead, } = useMidiStore() - const [status, setStatus] = useState('准备就绪') + const [lang, setLang] = useState('zh') + const t = getTranslations(lang) + + const [status, setStatus] = useState(t.ready) const [isPlaying, setIsPlaying] = useState(false) const [theme, setTheme] = useState<'dark' | 'light'>('light') const [audioUrl, setAudioUrl] = useState(null) @@ -68,6 +73,11 @@ function App() { document.documentElement.dataset.theme = theme }, [theme]) + // Update status text when language changes + useEffect(() => { + setStatus(t.ready) + }, [lang]) + // Sync audio volume - also trigger when audioUrl changes (new audio loaded) useEffect(() => { if (audioRef.current) { @@ -218,7 +228,7 @@ function App() { } } setIsPlaying(true) - setStatus(hasSelection ? '选区回放中...' : '正在回放...') + setStatus(hasSelection ? t.selectionPlayback : t.playing) const tick = () => { const seconds = @@ -230,7 +240,7 @@ function App() { if (endSeconds !== null && seconds >= endSeconds) { pausePlayback() seekToBeat(secondsToBeat(selectionStart!)) - setStatus('选区播放完毕') + setStatus(t.selectionDone) return } @@ -275,7 +285,7 @@ function App() { const handlePlayToggle = async () => { if (isPlaying) { pausePlayback() - setStatus('已暂停') + setStatus(t.paused) } else { await schedulePlayback() } @@ -294,10 +304,10 @@ function App() { setTempo(snapshot.tempo) setTimeSignature(snapshot.timeSignature as TimeSignature) setPpq(snapshot.ppq) // Preserve original ppq for accurate export - setStatus(`已载入 ${file.name}`) + setStatus(t.imported(file.name)) } catch (error) { console.error(error) - setStatus('导入失败,请确认文件合法') + setStatus(t.importFailed) } finally { event.target.value = '' } @@ -315,40 +325,20 @@ function App() { const isValidExtension = validExtensions.some(ext => fileName.endsWith(ext)) if (!isValidType && !isValidExtension) { - setStatus(`不支持的文件格式,请选择音频文件(${validExtensions.join(', ')})`) + setStatus(t.unsupportedFormat(validExtensions.join(', '))) event.target.value = '' return } const url = URL.createObjectURL(file) setAudioUrl(url) - setStatus(`已载入音频 ${file.name}`) + setStatus(t.audioImported(file.name)) event.target.value = '' } - // Check for overlapping notes (any pitch) - const getOverlappingNotes = () => { - const overlapping: string[] = [] - const sortedNotes = [...notes].sort((a, b) => a.start - b.start) - const EPSILON = 0.05 // Tolerance for floating point comparison - - for (let i = 0; i < sortedNotes.length; i++) { - for (let j = i + 1; j < sortedNotes.length; j++) { - const noteA = sortedNotes[i] - const noteB = sortedNotes[j] - const noteAEnd = noteA.start + noteA.duration - // If noteB starts at or after noteA ends (with tolerance), no overlap - if (noteB.start >= noteAEnd - EPSILON) break - // True overlap: noteB starts before noteA ends - if (!overlapping.includes(noteA.id)) overlapping.push(noteA.id) - if (!overlapping.includes(noteB.id)) overlapping.push(noteB.id) - } - } - return overlapping - } - - // Auto-fix overlapping notes by trimming the first note to end where the second begins - const handleFixOverlaps = () => { + // Fix overlapping notes by trimming the first note to end where the second begins + // Returns the number of fixed overlaps + const fixOverlaps = (): number => { const sortedNotes = [...notes].sort((a, b) => a.start - b.start) let fixCount = 0 @@ -366,61 +356,101 @@ function App() { } } + return fixCount + } + + // UI handler for fix overlaps button + const handleFixOverlaps = () => { + const fixCount = fixOverlaps() if (fixCount > 0) { - setStatus(`已修复 ${fixCount} 个重叠音符`) + setStatus(t.fixedOverlaps(fixCount)) } else { - setStatus('没有检测到重叠音符') + setStatus(t.noOverlaps) } } const handleExport = () => { - const overlapping = getOverlappingNotes() - if (overlapping.length > 0) { - const confirm = window.confirm( - `检测到 ${overlapping.length} 个音符存在时间重叠(标红色的音符),这可能导致播放异常。\n\n是否仍要导出?` - ) - if (!confirm) return - } + // Auto-fix overlaps before export + fixOverlaps() - const blob = exportMidi({ notes, tempo, timeSignature, ppq }) + // Get the latest notes from store (after fix, zustand set is synchronous) + const latestNotes = useMidiStore.getState().notes + + const blob = exportMidi({ notes: latestNotes, tempo, timeSignature, ppq }) const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = 'vocal-midi.mid' anchor.click() URL.revokeObjectURL(url) - setStatus('已导出包含歌词的 MIDI 文件') + setStatus(t.exported) } + const handleTranspose = (semitones: number) => { + if (semitones === 0 || !notes.length) return + for (const note of notes) { + const newMidi = Math.max(0, Math.min(127, note.midi + semitones)) + updateNote(note.id, { midi: newMidi }) + } + setStatus(t.transposed(semitones)) + } return (
-

歌声 MIDI 编辑器

-

Lyric-ready Piano Roll

-

导入、拖拽、实时修改歌词并导出标准 MIDI。

+

{t.eyebrow}

+

{t.title}

+

{t.subtitle}

+ + +
+ +
+ - - -
@@ -429,7 +459,7 @@ function App() {
- 导入后显示音频波形并与 MIDI 同步走带 + {t.audioHint}
- MIDI + {t.midiLabel} {midiVolume}%
- 音频 + {t.audioLabel}
- 水平缩放 + {t.horizontalZoom} {horizontalZoom.toFixed(1)}x
- 垂直缩放 + {t.verticalZoom} ⏮ -
-
+ - {selectionStart !== null && selectionEnd !== null && ( - <> - - {selectionStart.toFixed(1)}s - {selectionEnd.toFixed(1)}s - - - - )}
{status}
@@ -629,6 +649,7 @@ function App() { selectedId={selectedId} tempo={tempo} focusLyricId={focusLyricId} + lang={lang} onSelect={select} onUpdate={updateNote} onFocusHandled={() => setFocusLyricId(null)} diff --git a/preprocess/tools/midi_editor/src/components/LyricTable.tsx b/preprocess/tools/midi_editor/src/components/LyricTable.tsx index 0ef4600..5baf0c8 100644 --- a/preprocess/tools/midi_editor/src/components/LyricTable.tsx +++ b/preprocess/tools/midi_editor/src/components/LyricTable.tsx @@ -1,11 +1,14 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { NoteEvent } from '../types' +import type { Lang } from '../i18n' +import { getTranslations, tokenizeLyrics } from '../i18n' export type LyricTableProps = { notes: NoteEvent[] selectedId: string | null tempo: number focusLyricId: string | null + lang: Lang onSelect: (id: string | null) => void onUpdate: (id: string, patch: Partial) => void onScrollToNote?: (noteId: string) => void @@ -28,6 +31,7 @@ function EditableCell({ field, tempo, onConfirm, + confirmTitle, type = 'number', min, step @@ -37,6 +41,7 @@ function EditableCell({ field: 'midi' | 'start' | 'end' tempo: number onConfirm: (noteId: string, field: string, value: number) => void + confirmTitle?: string type?: string min?: number step?: number @@ -119,7 +124,7 @@ function EditableCell({ e.stopPropagation() handleConfirm() }} - title="确认修改 (Enter)" + title={confirmTitle} > ✓ @@ -128,7 +133,8 @@ function EditableCell({ ) } -export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, onUpdate, onScrollToNote, onFocusHandled }: LyricTableProps) { +export function LyricTable({ notes, selectedId, tempo, focusLyricId, lang, onSelect, onUpdate, onScrollToNote, onFocusHandled }: LyricTableProps) { + const t = getTranslations(lang) const listRef = useRef(null) const inputRefs = useRef>(new Map()) const sorted = useMemo(() => [...notes].sort((a, b) => a.start - b.start), [notes]) @@ -158,9 +164,11 @@ export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, o }, [focusLyricId, onFocusHandled]) // Fill lyrics from selected note onwards + // Uses smart tokenizer: CJK chars -> one per note, English words -> one per note const handleBulkFill = (bulkText: string) => { if (!sorted.length) return - const chars = Array.from(bulkText.replace(/\s+/g, '')) + const tokens = tokenizeLyrics(bulkText) + if (!tokens.length) return let startIndex = 0 if (selectedId) { @@ -170,10 +178,10 @@ export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, o } } - let charIndex = 0 - for (let i = startIndex; i < sorted.length && charIndex < chars.length; i++) { - onUpdate(sorted[i].id, { lyric: chars[charIndex] }) - charIndex++ + let tokenIndex = 0 + for (let i = startIndex; i < sorted.length && tokenIndex < tokens.length; i++) { + onUpdate(sorted[i].id, { lyric: tokens[tokenIndex] }) + tokenIndex++ } } @@ -206,7 +214,7 @@ export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, o