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 (
)
diff --git a/preprocess/tools/midi_editor/src/i18n.ts b/preprocess/tools/midi_editor/src/i18n.ts
new file mode 100644
index 0000000..91be75b
--- /dev/null
+++ b/preprocess/tools/midi_editor/src/i18n.ts
@@ -0,0 +1,196 @@
+export type Lang = 'zh' | 'en'
+
+const zh = {
+ // Header
+ eyebrow: '歌声 MIDI 编辑器',
+ title: 'SoulX-Singer MIDI Editor',
+ subtitle: '导入、拖拽、实时修改歌词并导出标准 MIDI。',
+ switchToLight: '切换到亮色',
+ switchToDark: '切换到暗色',
+ importJson: '导入 JSON',
+ exportJson: '导出 JSON',
+ importMidi: '导入 MIDI',
+ exportMidi: '导出 MIDI',
+ transpose: '移调',
+ transposeTooltip: '整体升降调:所有音符的音高同步改变',
+ transposed: (n: number) => `已移调 ${n > 0 ? '+' : ''}${n} 半音`,
+ fixOverlaps: '消除重叠',
+ fixOverlapsTooltip: '自动消除重叠:将重叠音符的音尾提前到下一个音的音头',
+ jsonImported: (name: string) => `已从 JSON 载入 ${name}`,
+ jsonImportFailed: 'JSON 导入失败,请确认文件格式正确',
+ jsonExported: '已导出 META JSON 文件',
+
+ // Audio bar
+ importAudio: '对齐音频导入',
+ audioHint: '导入后显示音频波形并与 MIDI 同步走带',
+ midiLabel: 'MIDI',
+ audioLabel: '音频',
+
+ // Controls
+ horizontalZoom: '水平缩放',
+ verticalZoom: '垂直缩放',
+ goToStart: '回到开头',
+ back2s: '后退 2 秒',
+ pause: '暂停',
+ playSelection: '播放选区',
+ play: '播放',
+ forward2s: '前进 2 秒',
+ goToEnd: '回到结尾',
+ selectingRange: '选区中',
+ setRange: '设选区',
+ exitSelectMode: '退出选区模式(并清除选区)',
+ setRangeTooltip: '设置选区:在时间轴上拖拽选择播放范围',
+
+ // Status
+ ready: '准备就绪',
+ selectionPlayback: '选区回放中...',
+ playing: '正在回放...',
+ selectionDone: '选区播放完毕',
+ paused: '已暂停',
+ imported: (name: string) => `已载入 ${name}`,
+ importFailed: '导入失败,请确认文件合法',
+ audioImported: (name: string) => `已载入音频 ${name}`,
+ unsupportedFormat: (exts: string) => `不支持的文件格式,请选择音频文件(${exts})`,
+ fixedOverlaps: (count: number) => `已修复 ${count} 个重叠音符`,
+ noOverlaps: '没有检测到重叠音符',
+ exported: '已导出包含歌词的 MIDI 文件',
+
+ // Lyric table
+ fillPlaceholderSelected: '从选中音符开始按词/字填充',
+ fillPlaceholderDefault: '输入歌词,点击按词/字填充',
+ fillButton: '按词\n填充',
+ lyricPlaceholder: '输入歌词',
+ emptyHint: '导入或双击钢琴卷帘以添加音符',
+ confirmEdit: '确认修改 (Enter)',
+}
+
+const en: typeof zh = {
+ // Header
+ eyebrow: 'Vocal MIDI Editor',
+ title: 'SoulX-Singer MIDI Editor',
+ subtitle: 'Import, drag, edit lyrics in real-time, and export standard MIDI.',
+ switchToLight: 'Switch to light',
+ switchToDark: 'Switch to dark',
+ importJson: 'Import JSON',
+ exportJson: 'Export JSON',
+ importMidi: 'Import MIDI',
+ exportMidi: 'Export MIDI',
+ transpose: 'Transpose',
+ transposeTooltip: 'Transpose all notes up or down by semitones',
+ transposed: (n: number) => `Transposed ${n > 0 ? '+' : ''}${n} semitone(s)`,
+ fixOverlaps: 'Fix Overlaps',
+ fixOverlapsTooltip: 'Auto fix overlaps: trim note end to the start of the next note',
+ jsonImported: (name: string) => `Loaded from JSON ${name}`,
+ jsonImportFailed: 'JSON import failed, please check the file format',
+ jsonExported: 'Exported META JSON file',
+
+ // Audio bar
+ importAudio: 'Import Audio',
+ audioHint: 'Display audio waveform synced with MIDI transport',
+ midiLabel: 'MIDI',
+ audioLabel: 'Audio',
+
+ // Controls
+ horizontalZoom: 'H-Zoom',
+ verticalZoom: 'V-Zoom',
+ goToStart: 'Go to start',
+ back2s: 'Back 2s',
+ pause: 'Pause',
+ playSelection: 'Play selection',
+ play: 'Play',
+ forward2s: 'Forward 2s',
+ goToEnd: 'Go to end',
+ selectingRange: 'Selecting',
+ setRange: 'Select',
+ exitSelectMode: 'Exit selection mode (and clear selection)',
+ setRangeTooltip: 'Set selection: drag on the timeline to select playback range',
+
+ // Status
+ ready: 'Ready',
+ selectionPlayback: 'Playing selection...',
+ playing: 'Playing...',
+ selectionDone: 'Selection playback done',
+ paused: 'Paused',
+ imported: (name: string) => `Loaded ${name}`,
+ importFailed: 'Import failed, please check the file',
+ audioImported: (name: string) => `Loaded audio ${name}`,
+ unsupportedFormat: (exts: string) => `Unsupported format, please select an audio file (${exts})`,
+ fixedOverlaps: (count: number) => `Fixed ${count} overlapping note(s)`,
+ noOverlaps: 'No overlapping notes detected',
+ exported: 'Exported MIDI file with lyrics',
+
+ // Lyric table
+ fillPlaceholderSelected: 'Fill words from selected note',
+ fillPlaceholderDefault: 'Enter lyrics, click fill button',
+ fillButton: 'Fill\nWords',
+ lyricPlaceholder: 'Type lyric',
+ emptyHint: 'Import or double-click piano roll to add notes',
+ confirmEdit: 'Confirm (Enter)',
+}
+
+const translations: Record = { zh, en }
+
+export type Translations = typeof zh
+
+export function getTranslations(lang: Lang): Translations {
+ return translations[lang]
+}
+
+// Smart tokenizer for lyrics: CJK characters are individual tokens, Latin words are grouped
+function isCJK(char: string): boolean {
+ const code = char.codePointAt(0) || 0
+ return (
+ (code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified Ideographs
+ (code >= 0x3400 && code <= 0x4DBF) || // CJK Extension A
+ (code >= 0x20000 && code <= 0x2A6DF) || // CJK Extension B
+ (code >= 0x3040 && code <= 0x309F) || // Hiragana
+ (code >= 0x30A0 && code <= 0x30FF) || // Katakana
+ (code >= 0xAC00 && code <= 0xD7AF) // Hangul Syllables
+ )
+}
+
+/**
+ * Tokenize lyrics text for note filling.
+ * - CJK characters: each character becomes one token (one per note)
+ * - Latin/English words: each space-separated word becomes one token (one per note)
+ * - Mixed text is handled correctly
+ *
+ * Examples:
+ * "你好世界" -> ["你", "好", "世", "界"]
+ * "hello world" -> ["hello", "world"]
+ * "I love 你" -> ["I", "love", "你"]
+ * "something wrong" -> ["something", "wrong"]
+ */
+export function tokenizeLyrics(text: string): string[] {
+ const tokens: string[] = []
+ const cleaned = text.trim()
+ if (!cleaned) return tokens
+
+ let i = 0
+ while (i < cleaned.length) {
+ const char = cleaned[i]
+
+ // Skip whitespace
+ if (/\s/.test(char)) {
+ i++
+ continue
+ }
+
+ // CJK character - each is a separate token
+ if (isCJK(char)) {
+ tokens.push(char)
+ i++
+ continue
+ }
+
+ // Latin/number/other - collect until whitespace or CJK
+ let word = ''
+ while (i < cleaned.length && !/\s/.test(cleaned[i]) && !isCJK(cleaned[i])) {
+ word += cleaned[i]
+ i++
+ }
+ if (word) tokens.push(word)
+ }
+
+ return tokens
+}