Update midi editor
This commit is contained in:
+5
-1
@@ -35,4 +35,8 @@ dev/
|
|||||||
data/
|
data/
|
||||||
outputs/
|
outputs/
|
||||||
deploy/
|
deploy/
|
||||||
.gradio/
|
.gradio/
|
||||||
|
|
||||||
|
# midi editor
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -10,14 +10,16 @@ A full-featured web MIDI editor for singing voice preprocess. It supports real-t
|
|||||||
|
|
||||||
### 🎼 Piano Roll Editing
|
### 🎼 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**:
|
- **Drag operations**:
|
||||||
- Move notes: drag note blocks to adjust position and pitch
|
- Move notes: drag note blocks to adjust position and pitch
|
||||||
- Resize start: drag the left edge to adjust start time
|
- Resize start: drag the left edge to adjust start time
|
||||||
- Resize end: drag the right edge to adjust end 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
|
- **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
|
### 🔍 Zoom & Navigation
|
||||||
|
|
||||||
@@ -29,14 +31,14 @@ A full-featured web MIDI editor for singing voice preprocess. It supports real-t
|
|||||||
### 📝 Lyric Editing
|
### 📝 Lyric Editing
|
||||||
|
|
||||||
- **Inline editing**: edit lyrics for each note in the side list
|
- **Inline editing**: edit lyrics for each note in the side list
|
||||||
- **Batch fill**: enter a string and auto-fill notes in order
|
- **Batch fill**: enter lyrics and auto-fill notes in order
|
||||||
- **Fill from selection**: start batch fill from the selected note
|
- **Fill from selection**: start batch fill from the currently selected note
|
||||||
- **Precise fields**: edit PITCH, START, and END directly
|
- **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
|
### 🎵 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
|
- **Formats**: MP3, WAV, OGG, FLAC, M4A, AAC
|
||||||
- **Sync playback**: play audio and MIDI together with independent volume control
|
- **Sync playback**: play audio and MIDI together with independent volume control
|
||||||
- **Click to seek**: click waveform or timeline to seek
|
- **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
|
### ⚠️ Overlap Detection
|
||||||
|
|
||||||
- **Visual highlight**: overlapping notes blink in red
|
- **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
|
- **One-click fix**: remove all overlaps automatically
|
||||||
- **Export warning**: warn if overlaps exist during export
|
|
||||||
|
|
||||||
### 📥 Import & Export
|
### 📥 Import & Export
|
||||||
|
|
||||||
- **MIDI import**: parse standard MIDI and lyric metadata
|
- **MIDI import**: parse standard MIDI files with automatic lyric metadata extraction
|
||||||
- **MIDI export**: export MIDI with lyrics
|
- **MIDI export**: export MIDI files with lyric information
|
||||||
- **Chinese support**: full UTF-8 lyrics support
|
|
||||||
|
|
||||||
### 🎨 UI & UX
|
### 🎨 UI & UX
|
||||||
|
|
||||||
@@ -97,9 +96,9 @@ npm run preview
|
|||||||
|
|
||||||
1. **Import MIDI**: click Import MIDI and select a .mid file
|
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
|
2. **Edit notes**: drag notes in the piano roll to adjust time and pitch
|
||||||
3. **Add lyrics**: edit lyrics in the right-side list
|
3. **Add lyrics**: edit lyrics in the right-side list, or use batch fill
|
||||||
4. **Align audio** (optional): import reference audio
|
4. **Align audio** (optional): import reference audio for side-by-side editing
|
||||||
5. **Export**: click Export MIDI with lyrics
|
5. **Export**: click Export MIDI to save
|
||||||
|
|
||||||
### Shortcuts
|
### Shortcuts
|
||||||
|
|
||||||
@@ -120,9 +119,9 @@ npm run preview
|
|||||||
| Button | Description |
|
| Button | Description |
|
||||||
|------|------|
|
|------|------|
|
||||||
| ⏮ | Go to start |
|
| ⏮ | Go to start |
|
||||||
| ⏪ 2s | Back 2 seconds |
|
| ⏪ | Back 2 seconds |
|
||||||
| ▶ / ⏸ | Play / Pause |
|
| ▶ / ⏸ | Play / Pause |
|
||||||
| 2s ⏩ | Forward 2 seconds |
|
| ⏩ | Forward 2 seconds |
|
||||||
| ⏭ | Go to end |
|
| ⏭ | Go to end |
|
||||||
| Selection | Play selected region |
|
| Selection | Play selected region |
|
||||||
|
|
||||||
@@ -153,19 +152,19 @@ npm run preview
|
|||||||
├── vite.config.ts
|
├── vite.config.ts
|
||||||
├── public/
|
├── public/
|
||||||
└── src/
|
└── src/
|
||||||
├── App.css
|
├── App.css # Main styles (theme variables, layout, components)
|
||||||
├── App.tsx
|
├── App.tsx # Main app component (transport, import/export, transpose)
|
||||||
├── constants.ts
|
├── constants.ts # Constants (grid width, row height, pitch range)
|
||||||
├── index.css
|
├── i18n.ts # Internationalization (zh/en translations, smart lyric tokenizer)
|
||||||
├── main.tsx
|
├── index.css # Global styles (Tailwind, root font, theme gradients)
|
||||||
├── types.ts
|
├── main.tsx # React entry point
|
||||||
├── assets/
|
├── types.ts # Type definitions (NoteEvent, TimeSignature, etc.)
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── AudioTrack.tsx
|
│ ├── AudioTrack.tsx # Audio waveform display component
|
||||||
│ ├── LyricTable.tsx
|
│ ├── LyricTable.tsx # Lyric editing table component
|
||||||
│ └── PianoRoll.tsx
|
│ └── PianoRoll.tsx # Piano roll editor component
|
||||||
├── lib/
|
├── lib/
|
||||||
│ └── midi.ts
|
│ └── midi.ts # MIDI import/export utilities (UTF-8 lyric encoding)
|
||||||
└── store/
|
└── store/
|
||||||
└── useMidiStore.ts
|
└── useMidiStore.ts # Zustand state management
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
- 移动音符:拖拽音符块调整位置和音高
|
- 移动音符:拖拽音符块调整位置和音高
|
||||||
- 调整音头:拖拽音符左边缘调整开始时间
|
- 调整音头:拖拽音符左边缘调整开始时间
|
||||||
- 调整音尾:拖拽音符右边缘调整结束时间
|
- 调整音尾:拖拽音符右边缘调整结束时间
|
||||||
- **快捷音高调整**:Command/Ctrl + 上/下键微调选中音符的音高
|
- **快捷音高调整**:
|
||||||
|
- Command/Ctrl + 上/下键调整选中音符的音高
|
||||||
|
- 通过功能区的移调功能来整体移动音高
|
||||||
- **双击添加**:在钢琴卷帘空白处双击快速添加新音符
|
- **双击添加**:在钢琴卷帘空白处双击快速添加新音符
|
||||||
- **钢琴键试听**:点击左侧钢琴键可试听对应音高
|
- **钢琴键试听**:点击左侧钢琴键可试听对应音高
|
||||||
|
|
||||||
@@ -44,15 +46,12 @@
|
|||||||
### ⚠️ 重叠检测
|
### ⚠️ 重叠检测
|
||||||
|
|
||||||
- **可视化标注**:时间重叠的音符显示为红色并闪烁
|
- **可视化标注**:时间重叠的音符显示为红色并闪烁
|
||||||
- **智能容差**:紧邻的音符(上一个结束 = 下一个开始)不视为重叠
|
|
||||||
- **一键修复**:点击消除重叠按钮自动修复所有重叠
|
- **一键修复**:点击消除重叠按钮自动修复所有重叠
|
||||||
- **导出提醒**:导出时如有重叠会弹出警告
|
|
||||||
|
|
||||||
### 📥 导入导出
|
### 📥 导入导出
|
||||||
|
|
||||||
- **MIDI 导入**:支持标准 MIDI 文件,自动解析歌词元数据
|
- **MIDI 导入**:支持标准 MIDI 文件,自动解析歌词元数据
|
||||||
- **MIDI 导出**:导出包含歌词信息的 MIDI 文件
|
- **MIDI 导出**:导出包含歌词信息的 MIDI 文件
|
||||||
- **中文支持**:完整支持中文歌词的导入导出(UTF-8 编码)
|
|
||||||
|
|
||||||
### 🎨 界面特性
|
### 🎨 界面特性
|
||||||
|
|
||||||
@@ -97,7 +96,7 @@ npm run preview
|
|||||||
|
|
||||||
1. **导入 MIDI**:点击导入 MIDI 按钮选择 .mid 文件
|
1. **导入 MIDI**:点击导入 MIDI 按钮选择 .mid 文件
|
||||||
2. **编辑音符**:在钢琴卷帘中拖拽调整音符位置和时长
|
2. **编辑音符**:在钢琴卷帘中拖拽调整音符位置和时长
|
||||||
3. **添加歌词**:在右侧列表中输入每个音符的歌词
|
3. **添加歌词**:在右侧列表中输入句级别的歌词或单字编辑
|
||||||
4. **对齐音频**(可选):导入参考音频进行对照编辑
|
4. **对齐音频**(可选):导入参考音频进行对照编辑
|
||||||
5. **导出文件**:点击导出含歌词 MIDI 保存文件
|
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
|
├── vite.config.ts
|
||||||
├── public/
|
├── public/
|
||||||
└── src/
|
└── src/
|
||||||
├── App.css
|
├── App.css # 主样式(含主题变量、布局、组件样式)
|
||||||
├── App.tsx
|
├── App.tsx # 主应用组件(走带、导入导出、移调等)
|
||||||
├── constants.ts
|
├── constants.ts # 常量定义(网格宽度、行高、音域范围)
|
||||||
├── index.css
|
├── i18n.ts # 国际化(中英文翻译、歌词智能分词器)
|
||||||
├── main.tsx
|
├── index.css # 全局样式(Tailwind、根字体、主题渐变)
|
||||||
├── types.ts
|
├── main.tsx # React 入口
|
||||||
├── assets/
|
├── types.ts # 类型定义(NoteEvent、TimeSignature 等)
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── AudioTrack.tsx
|
│ ├── AudioTrack.tsx # 音频波形显示组件
|
||||||
│ ├── LyricTable.tsx
|
│ ├── LyricTable.tsx # 歌词编辑表格组件
|
||||||
│ └── PianoRoll.tsx
|
│ └── PianoRoll.tsx # 钢琴卷帘编辑器组件
|
||||||
├── lib/
|
├── lib/
|
||||||
│ └── midi.ts
|
│ └── midi.ts # MIDI 导入导出工具(含 UTF-8 歌词编解码)
|
||||||
└── store/
|
└── store/
|
||||||
└── useMidiStore.ts
|
└── useMidiStore.ts # Zustand 状态管理
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>midi-editor</title>
|
<title>SoulX-Singer MIDI Editor</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+13
@@ -79,6 +79,7 @@
|
|||||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.28.6",
|
||||||
"@babel/generator": "^7.28.6",
|
"@babel/generator": "^7.28.6",
|
||||||
@@ -1510,6 +1511,7 @@
|
|||||||
"integrity": "sha512-r0bBaXu5Swb05doFYO2kTWHMovJnNVbCsII0fhesM8bNRlLhXIuckley4a2DaD+vOdmm5G+zGkQZAPZsF80+YQ==",
|
"integrity": "sha512-r0bBaXu5Swb05doFYO2kTWHMovJnNVbCsII0fhesM8bNRlLhXIuckley4a2DaD+vOdmm5G+zGkQZAPZsF80+YQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -1520,6 +1522,7 @@
|
|||||||
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1579,6 +1582,7 @@
|
|||||||
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.53.0",
|
"@typescript-eslint/scope-manager": "8.53.0",
|
||||||
"@typescript-eslint/types": "8.53.0",
|
"@typescript-eslint/types": "8.53.0",
|
||||||
@@ -1830,6 +1834,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2059,6 +2064,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -2386,6 +2392,7 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3329,6 +3336,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3376,6 +3384,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
"picocolors": "^1.1.0",
|
"picocolors": "^1.1.0",
|
||||||
@@ -3590,6 +3599,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -4031,6 +4041,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -4124,6 +4135,7 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -4315,6 +4327,7 @@
|
|||||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,39 @@
|
|||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
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 {
|
.icon-toggle {
|
||||||
@@ -66,6 +99,12 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lang-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.audio-bar {
|
.audio-bar {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
@@ -217,20 +256,35 @@
|
|||||||
|
|
||||||
.transport {
|
.transport {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-controls {
|
.transport button {
|
||||||
display: flex;
|
padding: 6px 10px !important;
|
||||||
gap: 8px;
|
font-size: 13px !important;
|
||||||
align-items: center;
|
min-width: 0;
|
||||||
padding: 6px 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 {
|
.selection-btn {
|
||||||
font-size: 12px !important;
|
font-size: 12px !important;
|
||||||
padding: 6px 10px !important;
|
padding: 6px 10px !important;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-btn.active {
|
.selection-btn.active {
|
||||||
@@ -238,25 +292,12 @@
|
|||||||
color: white !important;
|
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,
|
.button,
|
||||||
.actions button,
|
.actions button,
|
||||||
.transport button,
|
.transport button,
|
||||||
.ghost,
|
.ghost,
|
||||||
.primary,
|
.primary,
|
||||||
|
.json-btn,
|
||||||
.soft {
|
.soft {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -279,6 +320,12 @@
|
|||||||
box-shadow: 0 8px 26px rgba(72, 228, 194, 0.2);
|
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 {
|
.soft {
|
||||||
background: var(--button-soft-bg);
|
background: var(--button-soft-bg);
|
||||||
color: var(--button-soft-text);
|
color: var(--button-soft-text);
|
||||||
@@ -287,6 +334,7 @@
|
|||||||
|
|
||||||
.ghost:disabled,
|
.ghost:disabled,
|
||||||
.primary:disabled,
|
.primary:disabled,
|
||||||
|
.json-btn:disabled,
|
||||||
.soft:disabled {
|
.soft:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -294,6 +342,7 @@
|
|||||||
|
|
||||||
.ghost:hover,
|
.ghost:hover,
|
||||||
.primary:hover,
|
.primary:hover,
|
||||||
|
.json-btn:hover,
|
||||||
.soft:hover {
|
.soft:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { AudioTrack } from './components/AudioTrack'
|
|||||||
import { useMidiStore } from './store/useMidiStore'
|
import { useMidiStore } from './store/useMidiStore'
|
||||||
import { exportMidi, importMidiFile } from './lib/midi'
|
import { exportMidi, importMidiFile } from './lib/midi'
|
||||||
import type { TimeSignature } from './types'
|
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 { BASE_GRID_SECOND_WIDTH, BASE_ROW_HEIGHT, LOW_NOTE, HIGH_NOTE } from './constants'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
@@ -35,7 +37,10 @@ function App() {
|
|||||||
setPlayhead,
|
setPlayhead,
|
||||||
} = useMidiStore()
|
} = useMidiStore()
|
||||||
|
|
||||||
const [status, setStatus] = useState('准备就绪')
|
const [lang, setLang] = useState<Lang>('zh')
|
||||||
|
const t = getTranslations(lang)
|
||||||
|
|
||||||
|
const [status, setStatus] = useState(t.ready)
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const [theme, setTheme] = useState<'dark' | 'light'>('light')
|
const [theme, setTheme] = useState<'dark' | 'light'>('light')
|
||||||
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
||||||
@@ -68,6 +73,11 @@ function App() {
|
|||||||
document.documentElement.dataset.theme = theme
|
document.documentElement.dataset.theme = 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)
|
// Sync audio volume - also trigger when audioUrl changes (new audio loaded)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
@@ -218,7 +228,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsPlaying(true)
|
setIsPlaying(true)
|
||||||
setStatus(hasSelection ? '选区回放中...' : '正在回放...')
|
setStatus(hasSelection ? t.selectionPlayback : t.playing)
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const seconds =
|
const seconds =
|
||||||
@@ -230,7 +240,7 @@ function App() {
|
|||||||
if (endSeconds !== null && seconds >= endSeconds) {
|
if (endSeconds !== null && seconds >= endSeconds) {
|
||||||
pausePlayback()
|
pausePlayback()
|
||||||
seekToBeat(secondsToBeat(selectionStart!))
|
seekToBeat(secondsToBeat(selectionStart!))
|
||||||
setStatus('选区播放完毕')
|
setStatus(t.selectionDone)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +285,7 @@ function App() {
|
|||||||
const handlePlayToggle = async () => {
|
const handlePlayToggle = async () => {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
pausePlayback()
|
pausePlayback()
|
||||||
setStatus('已暂停')
|
setStatus(t.paused)
|
||||||
} else {
|
} else {
|
||||||
await schedulePlayback()
|
await schedulePlayback()
|
||||||
}
|
}
|
||||||
@@ -294,10 +304,10 @@ function App() {
|
|||||||
setTempo(snapshot.tempo)
|
setTempo(snapshot.tempo)
|
||||||
setTimeSignature(snapshot.timeSignature as TimeSignature)
|
setTimeSignature(snapshot.timeSignature as TimeSignature)
|
||||||
setPpq(snapshot.ppq) // Preserve original ppq for accurate export
|
setPpq(snapshot.ppq) // Preserve original ppq for accurate export
|
||||||
setStatus(`已载入 ${file.name}`)
|
setStatus(t.imported(file.name))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
setStatus('导入失败,请确认文件合法')
|
setStatus(t.importFailed)
|
||||||
} finally {
|
} finally {
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
@@ -315,40 +325,20 @@ function App() {
|
|||||||
const isValidExtension = validExtensions.some(ext => fileName.endsWith(ext))
|
const isValidExtension = validExtensions.some(ext => fileName.endsWith(ext))
|
||||||
|
|
||||||
if (!isValidType && !isValidExtension) {
|
if (!isValidType && !isValidExtension) {
|
||||||
setStatus(`不支持的文件格式,请选择音频文件(${validExtensions.join(', ')})`)
|
setStatus(t.unsupportedFormat(validExtensions.join(', ')))
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = URL.createObjectURL(file)
|
const url = URL.createObjectURL(file)
|
||||||
setAudioUrl(url)
|
setAudioUrl(url)
|
||||||
setStatus(`已载入音频 ${file.name}`)
|
setStatus(t.audioImported(file.name))
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for overlapping notes (any pitch)
|
// Fix overlapping notes by trimming the first note to end where the second begins
|
||||||
const getOverlappingNotes = () => {
|
// Returns the number of fixed overlaps
|
||||||
const overlapping: string[] = []
|
const fixOverlaps = (): number => {
|
||||||
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 = () => {
|
|
||||||
const sortedNotes = [...notes].sort((a, b) => a.start - b.start)
|
const sortedNotes = [...notes].sort((a, b) => a.start - b.start)
|
||||||
let fixCount = 0
|
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) {
|
if (fixCount > 0) {
|
||||||
setStatus(`已修复 ${fixCount} 个重叠音符`)
|
setStatus(t.fixedOverlaps(fixCount))
|
||||||
} else {
|
} else {
|
||||||
setStatus('没有检测到重叠音符')
|
setStatus(t.noOverlaps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
const overlapping = getOverlappingNotes()
|
// Auto-fix overlaps before export
|
||||||
if (overlapping.length > 0) {
|
fixOverlaps()
|
||||||
const confirm = window.confirm(
|
|
||||||
`检测到 ${overlapping.length} 个音符存在时间重叠(标红色的音符),这可能导致播放异常。\n\n是否仍要导出?`
|
|
||||||
)
|
|
||||||
if (!confirm) return
|
|
||||||
}
|
|
||||||
|
|
||||||
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 url = URL.createObjectURL(blob)
|
||||||
const anchor = document.createElement('a')
|
const anchor = document.createElement('a')
|
||||||
anchor.href = url
|
anchor.href = url
|
||||||
anchor.download = 'vocal-midi.mid'
|
anchor.download = 'vocal-midi.mid'
|
||||||
anchor.click()
|
anchor.click()
|
||||||
URL.revokeObjectURL(url)
|
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 (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">歌声 MIDI 编辑器</p>
|
<p className="eyebrow">{t.eyebrow}</p>
|
||||||
<h1>Lyric-ready Piano Roll</h1>
|
<h1>{t.title}</h1>
|
||||||
<p className="muted">导入、拖拽、实时修改歌词并导出标准 MIDI。</p>
|
<p className="muted">{t.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
|
<button className="primary" onClick={handleImportClick}>
|
||||||
|
{t.importMidi}
|
||||||
|
</button>
|
||||||
|
<button className="primary" onClick={handleExport}>
|
||||||
|
{t.exportMidi}
|
||||||
|
</button>
|
||||||
|
<div className="transpose-group" title={t.transposeTooltip}>
|
||||||
|
<select
|
||||||
|
className="transpose-select"
|
||||||
|
value={0}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = Number(e.target.value)
|
||||||
|
if (val !== 0) handleTranspose(val)
|
||||||
|
e.target.value = '0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={0}>{t.transpose}</option>
|
||||||
|
{Array.from({ length: 24 }, (_, i) => i - 12)
|
||||||
|
.filter(v => v !== 0)
|
||||||
|
.reverse()
|
||||||
|
.map(v => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{v > 0 ? `+${v}` : v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button className="soft" onClick={handleFixOverlaps} title={t.fixOverlapsTooltip}>
|
||||||
|
{t.fixOverlaps}
|
||||||
|
</button>
|
||||||
<button className="icon-toggle" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
<button className="icon-toggle" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
||||||
{theme === 'dark' ? (
|
{theme === 'dark' ? (
|
||||||
<span className="icon" aria-label="切换到亮色">
|
<span className="icon" aria-label={t.switchToLight}>
|
||||||
☀️
|
☀️
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="icon" aria-label="切换到暗色">
|
<span className="icon" aria-label={t.switchToDark}>
|
||||||
🌙
|
🌙
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button className="primary" onClick={handleImportClick}>
|
<button
|
||||||
导入 MIDI
|
className="icon-toggle"
|
||||||
</button>
|
onClick={() => setLang(lang === 'zh' ? 'en' : 'zh')}
|
||||||
<button className="primary" onClick={handleExport}>
|
title={lang === 'zh' ? 'Switch to English' : '切换到中文'}
|
||||||
导出含歌词 MIDI
|
>
|
||||||
</button>
|
<span className="lang-label">{lang === 'zh' ? 'EN' : '中'}</span>
|
||||||
<button className="soft" onClick={handleFixOverlaps} title="自动消除重叠:将重叠音符的音尾提前到下一个音的音头">
|
|
||||||
消除重叠
|
|
||||||
</button>
|
</button>
|
||||||
<input ref={fileInputRef} type="file" accept=".mid,.midi" className="sr-only" onChange={handleFileChange} />
|
<input ref={fileInputRef} type="file" accept=".mid,.midi" className="sr-only" onChange={handleFileChange} />
|
||||||
</div>
|
</div>
|
||||||
@@ -429,7 +459,7 @@ function App() {
|
|||||||
<section className="audio-bar">
|
<section className="audio-bar">
|
||||||
<div className="audio-left">
|
<div className="audio-left">
|
||||||
<button className="ghost" onClick={handleAudioImportClick}>
|
<button className="ghost" onClick={handleAudioImportClick}>
|
||||||
对齐音频导入
|
{t.importAudio}
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={audioInputRef}
|
ref={audioInputRef}
|
||||||
@@ -438,11 +468,11 @@ function App() {
|
|||||||
className="sr-only"
|
className="sr-only"
|
||||||
onChange={handleAudioChange}
|
onChange={handleAudioChange}
|
||||||
/>
|
/>
|
||||||
<span className="audio-hint">导入后显示音频波形并与 MIDI 同步走带</span>
|
<span className="audio-hint">{t.audioHint}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="audio-right">
|
<div className="audio-right">
|
||||||
<div className="volume-control">
|
<div className="volume-control">
|
||||||
<span className="volume-label">MIDI</span>
|
<span className="volume-label">{t.midiLabel}</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -454,7 +484,7 @@ function App() {
|
|||||||
<span className="volume-value">{midiVolume}%</span>
|
<span className="volume-value">{midiVolume}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="volume-control">
|
<div className="volume-control">
|
||||||
<span className="volume-label">音频</span>
|
<span className="volume-label">{t.audioLabel}</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -525,7 +555,7 @@ function App() {
|
|||||||
<aside className="panel-side">
|
<aside className="panel-side">
|
||||||
<div className="controls">
|
<div className="controls">
|
||||||
<div className="toggle" style={{ justifyContent: 'space-between' }}>
|
<div className="toggle" style={{ justifyContent: 'space-between' }}>
|
||||||
<span>水平缩放</span>
|
<span>{t.horizontalZoom}</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0.5}
|
min={0.5}
|
||||||
@@ -538,7 +568,7 @@ function App() {
|
|||||||
<span style={{ width: 42, textAlign: 'right' }}>{horizontalZoom.toFixed(1)}x</span>
|
<span style={{ width: 42, textAlign: 'right' }}>{horizontalZoom.toFixed(1)}x</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="toggle" style={{ justifyContent: 'space-between' }}>
|
<div className="toggle" style={{ justifyContent: 'space-between' }}>
|
||||||
<span>垂直缩放</span>
|
<span>{t.verticalZoom}</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0.6}
|
min={0.6}
|
||||||
@@ -557,69 +587,59 @@ function App() {
|
|||||||
setPlayhead(0)
|
setPlayhead(0)
|
||||||
seekToBeat(0)
|
seekToBeat(0)
|
||||||
}}
|
}}
|
||||||
title="回到开头"
|
title={t.goToStart}
|
||||||
>
|
>
|
||||||
⏮
|
⏮
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="soft"
|
className="soft"
|
||||||
onClick={() => seekBySeconds(-2)}
|
onClick={() => seekBySeconds(-2)}
|
||||||
title="后退 2 秒"
|
title={t.back2s}
|
||||||
>
|
>
|
||||||
⏪ 2s
|
⏪
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="primary"
|
className="primary"
|
||||||
onClick={handlePlayToggle}
|
onClick={handlePlayToggle}
|
||||||
disabled={!notes.length && !audioUrl}
|
disabled={!notes.length && !audioUrl}
|
||||||
title={isPlaying ? "暂停" : (selectionStart !== null && selectionEnd !== null ? "播放选区" : "播放")}
|
title={isPlaying ? t.pause : (selectionStart !== null && selectionEnd !== null ? t.playSelection : t.play)}
|
||||||
>
|
>
|
||||||
{isPlaying ? '⏸' : '▶'}
|
{isPlaying ? '⏸' : '▶'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="soft"
|
className="soft"
|
||||||
onClick={() => seekBySeconds(2)}
|
onClick={() => seekBySeconds(2)}
|
||||||
title="前进 2 秒"
|
title={t.forward2s}
|
||||||
>
|
>
|
||||||
2s ⏩
|
⏩
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="soft"
|
className="soft"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Logic to find end of song? Max note end or audio duration
|
|
||||||
const maxNoteEnd = notes.reduce((acc, n) => Math.max(acc, n.start + n.duration), 0)
|
const maxNoteEnd = notes.reduce((acc, n) => Math.max(acc, n.start + n.duration), 0)
|
||||||
seekToBeat(Math.max(secondsToBeat(audioDuration), maxNoteEnd))
|
seekToBeat(Math.max(secondsToBeat(audioDuration), maxNoteEnd))
|
||||||
}}
|
}}
|
||||||
title="回到结尾"
|
title={t.goToEnd}
|
||||||
>
|
>
|
||||||
⏭
|
⏭
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<span className="transport-divider" />
|
||||||
<div className="selection-controls">
|
|
||||||
<button
|
<button
|
||||||
className={`soft selection-btn ${isSelectingRange ? 'active' : ''}`}
|
className={`soft selection-btn ${isSelectingRange ? 'active' : ''}`}
|
||||||
onClick={() => setIsSelectingRange(!isSelectingRange)}
|
onClick={() => {
|
||||||
title={isSelectingRange ? "退出选区模式" : "设置选区:在时间轴上拖拽选择播放范围"}
|
if (isSelectingRange) {
|
||||||
|
// Exiting selection mode - auto clear selection
|
||||||
|
setIsSelectingRange(false)
|
||||||
|
setSelectionStart(null)
|
||||||
|
setSelectionEnd(null)
|
||||||
|
} else {
|
||||||
|
setIsSelectingRange(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={isSelectingRange ? t.exitSelectMode : t.setRangeTooltip}
|
||||||
>
|
>
|
||||||
{isSelectingRange ? '📍 选区中' : '📍 设选区'}
|
{isSelectingRange ? `📍 ${t.selectingRange}` : `📍 ${t.setRange}`}
|
||||||
</button>
|
</button>
|
||||||
{selectionStart !== null && selectionEnd !== null && (
|
|
||||||
<>
|
|
||||||
<span className="selection-info">
|
|
||||||
{selectionStart.toFixed(1)}s - {selectionEnd.toFixed(1)}s
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="soft"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectionStart(null)
|
|
||||||
setSelectionEnd(null)
|
|
||||||
}}
|
|
||||||
title="清除选区"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="status">{status}</div>
|
<div className="status">{status}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -629,6 +649,7 @@ function App() {
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
tempo={tempo}
|
tempo={tempo}
|
||||||
focusLyricId={focusLyricId}
|
focusLyricId={focusLyricId}
|
||||||
|
lang={lang}
|
||||||
onSelect={select}
|
onSelect={select}
|
||||||
onUpdate={updateNote}
|
onUpdate={updateNote}
|
||||||
onFocusHandled={() => setFocusLyricId(null)}
|
onFocusHandled={() => setFocusLyricId(null)}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { NoteEvent } from '../types'
|
import type { NoteEvent } from '../types'
|
||||||
|
import type { Lang } from '../i18n'
|
||||||
|
import { getTranslations, tokenizeLyrics } from '../i18n'
|
||||||
|
|
||||||
export type LyricTableProps = {
|
export type LyricTableProps = {
|
||||||
notes: NoteEvent[]
|
notes: NoteEvent[]
|
||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
tempo: number
|
tempo: number
|
||||||
focusLyricId: string | null
|
focusLyricId: string | null
|
||||||
|
lang: Lang
|
||||||
onSelect: (id: string | null) => void
|
onSelect: (id: string | null) => void
|
||||||
onUpdate: (id: string, patch: Partial<NoteEvent>) => void
|
onUpdate: (id: string, patch: Partial<NoteEvent>) => void
|
||||||
onScrollToNote?: (noteId: string) => void
|
onScrollToNote?: (noteId: string) => void
|
||||||
@@ -28,6 +31,7 @@ function EditableCell({
|
|||||||
field,
|
field,
|
||||||
tempo,
|
tempo,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
|
confirmTitle,
|
||||||
type = 'number',
|
type = 'number',
|
||||||
min,
|
min,
|
||||||
step
|
step
|
||||||
@@ -37,6 +41,7 @@ function EditableCell({
|
|||||||
field: 'midi' | 'start' | 'end'
|
field: 'midi' | 'start' | 'end'
|
||||||
tempo: number
|
tempo: number
|
||||||
onConfirm: (noteId: string, field: string, value: number) => void
|
onConfirm: (noteId: string, field: string, value: number) => void
|
||||||
|
confirmTitle?: string
|
||||||
type?: string
|
type?: string
|
||||||
min?: number
|
min?: number
|
||||||
step?: number
|
step?: number
|
||||||
@@ -119,7 +124,7 @@ function EditableCell({
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleConfirm()
|
handleConfirm()
|
||||||
}}
|
}}
|
||||||
title="确认修改 (Enter)"
|
title={confirmTitle}
|
||||||
>
|
>
|
||||||
✓
|
✓
|
||||||
</button>
|
</button>
|
||||||
@@ -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<HTMLDivElement | null>(null)
|
const listRef = useRef<HTMLDivElement | null>(null)
|
||||||
const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
|
const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
|
||||||
const sorted = useMemo(() => [...notes].sort((a, b) => a.start - b.start), [notes])
|
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])
|
}, [focusLyricId, onFocusHandled])
|
||||||
|
|
||||||
// Fill lyrics from selected note onwards
|
// Fill lyrics from selected note onwards
|
||||||
|
// Uses smart tokenizer: CJK chars -> one per note, English words -> one per note
|
||||||
const handleBulkFill = (bulkText: string) => {
|
const handleBulkFill = (bulkText: string) => {
|
||||||
if (!sorted.length) return
|
if (!sorted.length) return
|
||||||
const chars = Array.from(bulkText.replace(/\s+/g, ''))
|
const tokens = tokenizeLyrics(bulkText)
|
||||||
|
if (!tokens.length) return
|
||||||
|
|
||||||
let startIndex = 0
|
let startIndex = 0
|
||||||
if (selectedId) {
|
if (selectedId) {
|
||||||
@@ -170,10 +178,10 @@ export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, o
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let charIndex = 0
|
let tokenIndex = 0
|
||||||
for (let i = startIndex; i < sorted.length && charIndex < chars.length; i++) {
|
for (let i = startIndex; i < sorted.length && tokenIndex < tokens.length; i++) {
|
||||||
onUpdate(sorted[i].id, { lyric: chars[charIndex] })
|
onUpdate(sorted[i].id, { lyric: tokens[tokenIndex] })
|
||||||
charIndex++
|
tokenIndex++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +214,7 @@ export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, o
|
|||||||
<textarea
|
<textarea
|
||||||
className="lyric-bulk-input"
|
className="lyric-bulk-input"
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder={selectedId ? "从选中音符开始按字填充" : "输入歌词,点击按字填充"}
|
placeholder={selectedId ? t.fillPlaceholderSelected : t.fillPlaceholderDefault}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -222,7 +230,9 @@ export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, o
|
|||||||
handleBulkFill(textarea.value)
|
handleBulkFill(textarea.value)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
按字<br/>填充
|
{t.fillButton.split('\n').map((line, i) => (
|
||||||
|
<span key={i}>{line}{i === 0 && <br/>}</span>
|
||||||
|
))}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="lyric-header" style={{ flexShrink: 0 }}>
|
<div className="lyric-header" style={{ flexShrink: 0 }}>
|
||||||
@@ -249,7 +259,7 @@ export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, o
|
|||||||
}}
|
}}
|
||||||
className="lyric-input"
|
className="lyric-input"
|
||||||
value={note.lyric}
|
value={note.lyric}
|
||||||
placeholder="Type lyric"
|
placeholder={t.lyricPlaceholder}
|
||||||
onChange={(event) => onUpdate(note.id, { lyric: event.target.value })}
|
onChange={(event) => onUpdate(note.id, { lyric: event.target.value })}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
@@ -259,6 +269,7 @@ export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, o
|
|||||||
field="midi"
|
field="midi"
|
||||||
tempo={tempo}
|
tempo={tempo}
|
||||||
onConfirm={handleFieldConfirm}
|
onConfirm={handleFieldConfirm}
|
||||||
|
confirmTitle={t.confirmEdit}
|
||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
<EditableCell
|
<EditableCell
|
||||||
@@ -267,6 +278,7 @@ export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, o
|
|||||||
field="start"
|
field="start"
|
||||||
tempo={tempo}
|
tempo={tempo}
|
||||||
onConfirm={handleFieldConfirm}
|
onConfirm={handleFieldConfirm}
|
||||||
|
confirmTitle={t.confirmEdit}
|
||||||
min={0}
|
min={0}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
/>
|
/>
|
||||||
@@ -276,12 +288,13 @@ export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, o
|
|||||||
field="end"
|
field="end"
|
||||||
tempo={tempo}
|
tempo={tempo}
|
||||||
onConfirm={handleFieldConfirm}
|
onConfirm={handleFieldConfirm}
|
||||||
|
confirmTitle={t.confirmEdit}
|
||||||
min={0}
|
min={0}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{sorted.length === 0 && <div className="lyric-empty">Import或双击钢琴卷帘以添加音符</div>}
|
{sorted.length === 0 && <div className="lyric-empty">{t.emptyHint}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<Lang, typeof zh> = { 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user