Initial commit

This commit is contained in:
王新升
2026-02-06 20:31:14 +08:00
parent a0b51be095
commit c589bcb837
145 changed files with 28773 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
# 🎹 MIDI Editor - Web-based Singing MIDI Editor
[English](README.md) | [简体中文](README_CN.md)
A full-featured web MIDI editor for singing voice production, similar to ACE-Studio and VOCALOID. It supports real-time drag editing of MIDI notes, lyric editing, audio waveform alignment, and importing/exporting MIDI files with lyrics.
![MIDI Editor](https://img.shields.io/badge/React-19.2-blue) ![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue) ![Vite](https://img.shields.io/badge/Vite-7.2-purple)
## ✨ Features
### 🎼 Piano Roll Editing
- **Visual note editing**: Full range from C1 to C8 with intuitive piano keys
- **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
- **Double-click to add**: Add new notes quickly in empty areas
- **Piano key preview**: Click a key to audition the pitch
### 🔍 Zoom & Navigation
- **Horizontal zoom**
- **Vertical zoom**
- **Dynamic snapping**: finer snap granularity at higher zoom (min 0.01s)
- **Auto scroll**: keep the playhead visible during playback
### 📝 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
- **Precise fields**: edit PITCH, START, and END directly
- **Confirm edits**: press Enter or click ✓ to confirm
### 🎵 Audio Alignment
- **Waveform display**: sync waveform with the MIDI timeline
- **Formats**: MP3, WAV, OGG, FLAC, M4A, AAC
- **Sync playback**: play audio and MIDI together with independent mute
- **Click to seek**: click waveform or timeline to seek
### ⚠️ 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
### 🎨 UI & UX
- **Theme toggle**: light and dark modes
- **Responsive layout**: adapts to window size
- **SVG grid**: cross-browser grid rendering
- **Status feedback**: real-time state and error tips
## 🚀 Quick Start
### Requirements
- Node.js 18+
- npm or yarn
### Install
```bash
# Clone
git clone <repository-url>
cd MIDI_Editor
# Install dependencies
npm install
# Start dev server
npm run dev
# Expose to LAN
npm run dev -- --host 0.0.0.0
```
### Build
```bash
# Build for production
npm run build
# Preview build
npm run preview
```
## 📖 Usage
### Basic Workflow
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
### Shortcuts
| Action | Description |
|------|------|
| Double-click piano roll | Add a new note |
| Double-click note | Edit lyric |
| Drag note | Move note and pitch |
| Drag note edges | Resize note |
| Backspace / Delete | Delete selected note |
| Enter | Confirm value edits |
| Escape | Cancel value edits |
| Ctrl(Command) + Wheel | Horizontal zoom |
| Ctrl(Command) + Shift(Option) + Wheel | Vertical zoom |
### Playback Controls
| Button | Description |
|------|------|
| ⏮ | Go to start |
| ⏪ 2s | Back 2 seconds |
| ▶ / ⏸ | Play / Pause |
| 2s ⏩ | Forward 2 seconds |
| ⏭ | Go to end |
## 🛠 Tech Stack
- **Frontend**: React 19 + TypeScript
- **Build**: Vite 7
- **State**: Zustand
- **Audio**: Tone.js
- **Waveform**: WaveSurfer.js
- **MIDI**: @tonejs/midi
- **Styles**: CSS with custom variables
## 📁 Project Structure
```
.
├── eslint.config.js
├── index.html
├── package.json
├── postcss.config.js
├── README.md
├── README_CN.md
├── tailwind.config.js
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
├── public/
└── src/
├── App.css
├── App.tsx
├── constants.ts
├── index.css
├── main.tsx
├── types.ts
├── assets/
├── components/
│ ├── AudioTrack.tsx
│ ├── LyricTable.tsx
│ └── PianoRoll.tsx
├── lib/
│ └── midi.ts
└── store/
└── useMidiStore.ts
```
+173
View File
@@ -0,0 +1,173 @@
# 🎹 MIDI Editor - 网页端歌声 MIDI 编辑器
[English](README.md) | [简体中文](README_CN.md)
一个功能完整的网页端歌声 MIDI 文件编辑器,类似 ACE-Studio 和 VOCALOID。支持实时拖拽调整 MIDI 音符、歌词编辑、音频波形对齐,以及导入导出含歌词的 MIDI 文件。
![MIDI Editor](https://img.shields.io/badge/React-19.2-blue) ![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue) ![Vite](https://img.shields.io/badge/Vite-7.2-purple)
## ✨ 功能特性
### 🎼 钢琴卷帘编辑
- **可视化音符编辑**:支持 C1-C8 全音域显示,直观的钢琴键布局
- **拖拽操作**:
- 移动音符:拖拽音符块调整位置和音高
- 调整音头:拖拽音符左边缘调整开始时间
- 调整音尾:拖拽音符右边缘调整结束时间
- **双击添加**:在钢琴卷帘空白处双击快速添加新音符
- **钢琴键试听**:点击左侧钢琴键可试听对应音高
### 🔍 缩放与导航
- **水平缩放**
- **垂直缩放**
- **动态精度**:缩放越大,音符调整的 snap 粒度越精细(最小 0.01 秒)
- **自动滚动**:播放时播放头自动保持可见
### 📝 歌词编辑
- **实时编辑**:右侧列表直接编辑每个音符的歌词
- **批量填充**:输入一段歌词,按字顺序自动填充到音符
- **从选中开始**:批量填充可从当前选中的音符开始
- **精确调整**:可直接编辑 PITCH(音高)、START(开始时间)、END(结束时间)
- **确认机制**:修改数值后按 Enter 或点击 ✓ 确认,避免误操作
### 🎵 音频对齐
- **波形显示**:导入音频后显示波形,与 MIDI 同步滚动
- **格式支持**:MP3、WAV、OGG、FLAC、M4A、AAC
- **同步播放**:音频与 MIDI 同步播放,可分别静音
- **点击定位**:点击波形或时间尺可快速定位播放位置
### ⚠️ 重叠检测
- **可视化标注**:时间重叠的音符显示为红色并闪烁
- **智能容差**:紧邻的音符(上一个结束 = 下一个开始)不视为重叠
- **一键修复**:点击消除重叠按钮自动修复所有重叠
- **导出提醒**:导出时如有重叠会弹出警告
### 📥 导入导出
- **MIDI 导入**:支持标准 MIDI 文件,自动解析歌词元数据
- **MIDI 导出**:导出包含歌词信息的 MIDI 文件
- **中文支持**:完整支持中文歌词的导入导出(UTF-8 编码)
### 🎨 界面特性
- **主题切换**:支持浅色/深色主题
- **响应式布局**:自适应窗口大小
- **SVG 网格**:跨浏览器兼容的网格渲染
- **状态提示**:实时显示操作状态和错误信息
## 🚀 快速开始
### 环境要求
- Node.js 18+
- npm 或 yarn
### 安装
```bash
# 克隆项目
git clone <repository-url>
cd MIDI_Editor
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 在局域网启动
npm run dev -- --host 0.0.0.0
```
### 构建
```bash
# 构建生产版本
npm run build
# 预览构建结果
npm run preview
```
## 📖 使用指南
### 基本工作流
1. **导入 MIDI**:点击导入 MIDI 按钮选择 .mid 文件
2. **编辑音符**:在钢琴卷帘中拖拽调整音符位置和时长
3. **添加歌词**:在右侧列表中输入每个音符的歌词
4. **对齐音频**(可选):导入参考音频进行对照编辑
5. **导出文件**:点击导出含歌词 MIDI 保存文件
### 快捷操作
| 操作 | 说明 |
|------|------|
| 双击钢琴卷帘 | 添加新音符 |
| 双击音符 | 修改歌词 |
| 拖拽音符 | 移动音符位置/音高 |
| 拖拽音符边缘 | 调整音符时长 |
| Backspace / Delete | 删除选中音符 |
| Enter | 确认数值修改 |
| Escape | 取消数值修改 |
| Ctrl(Command) + 滚轮 | 水平缩放 |
| Ctrl(Command) + Shift(Option) + 滚轮 | 垂直缩放 |
### 播放控制
| 按钮 | 功能 |
|------|------|
| ⏮ | 回到开头 |
| ⏪ 2s | 后退 2 秒 |
| ▶ / ⏸ | 播放 / 暂停 |
| 2s ⏩ | 前进 2 秒 |
| ⏭ | 跳到结尾 |
## 🛠 技术栈
- **前端框架**:React 19 + TypeScript
- **构建工具**:Vite 7
- **状态管理**:Zustand
- **音频引擎**:Tone.js
- **波形显示**:WaveSurfer.js
- **MIDI 解析**:@tonejs/midi
- **样式**:CSS(自定义变量主题)
## 📁 项目结构
```
.
├── eslint.config.js
├── index.html
├── package.json
├── postcss.config.js
├── README.md
├── README_CN.md
├── tailwind.config.js
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
├── public/
└── src/
├── App.css
├── App.tsx
├── constants.ts
├── index.css
├── main.tsx
├── types.ts
├── assets/
├── components/
│ ├── AudioTrack.tsx
│ ├── LyricTable.tsx
│ └── PianoRoll.tsx
├── lib/
│ └── midi.ts
└── store/
└── useMidiStore.ts
```
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>midi-editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "midi-editor",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tonejs/midi": "^2.0.28",
"class-variance-authority": "^0.7.1",
"nanoid": "^5.1.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tone": "^15.1.22",
"wavesurfer.js": "^7.12.1",
"zustand": "^5.0.10"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.15",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+785
View File
@@ -0,0 +1,785 @@
.app-shell {
padding: 24px;
color: var(--text-primary);
width: 100%;
max-width: 100%;
margin: 0;
height: 100vh;
max-height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
background: var(--panel-strong);
border: 1px solid var(--border-subtle);
border-radius: 16px;
padding: 20px 24px;
box-shadow: var(--shadow-panel);
}
.topbar h1 {
margin: 4px 0 0 0;
font-size: 26px;
letter-spacing: -0.5px;
}
.eyebrow {
margin: 0;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 2px;
color: var(--text-muted);
}
.muted {
margin: 6px 0 0 0;
color: var(--text-muted);
}
.actions {
display: flex;
gap: 10px;
}
.icon-toggle {
width: 40px;
height: 40px;
border-radius: 999px;
border: 1px solid var(--border-soft);
background: var(--button-ghost-bg);
color: var(--button-ghost-text);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
cursor: pointer;
}
.icon-toggle:hover {
transform: translateY(-1px);
}
.audio-bar {
margin-top: 14px;
padding: 12px 16px;
border-radius: 14px;
background: var(--panel-strong);
border: 1px solid var(--border-subtle);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.audio-left {
display: flex;
align-items: center;
gap: 12px;
}
.audio-hint {
color: var(--text-muted);
font-size: 12px;
}
.audio-right {
display: flex;
align-items: center;
gap: 20px;
}
.volume-control {
display: flex;
align-items: center;
gap: 8px;
}
.volume-label {
font-size: 12px;
color: var(--text-muted);
min-width: 32px;
}
.volume-slider {
width: 80px;
height: 4px;
cursor: pointer;
accent-color: var(--accent);
}
.volume-value {
font-size: 11px;
color: var(--text-muted);
min-width: 36px;
text-align: right;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
}
.panel {
margin-top: 18px;
background: var(--panel);
border: 1px solid var(--border-subtle);
border-radius: 16px;
padding: 18px;
box-shadow: var(--shadow-panel);
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.panel-split {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 16px;
align-items: stretch;
flex: 1;
min-height: 0;
max-height: 100%;
overflow: hidden;
}
.panel-main {
min-width: 0;
display: flex;
flex-direction: column;
min-height: 0;
max-height: 100%;
overflow: hidden;
}
.panel-side {
display: flex;
flex-direction: column;
gap: 16px;
width: 360px;
max-width: 360px;
/* Use absolute positioning to enforce height */
position: relative;
overflow: hidden;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
align-items: center;
background: var(--panel-soft);
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border-soft);
flex-shrink: 0;
}
.controls label {
display: block;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: 4px;
}
.controls input[type='number'] {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border-soft);
background: var(--input-bg);
color: var(--text-primary);
}
.timesig {
display: flex;
align-items: center;
gap: 6px;
}
.timesig span {
font-weight: 700;
color: var(--text-muted);
}
.transport {
display: flex;
gap: 8px;
align-items: center;
}
.selection-controls {
display: flex;
gap: 8px;
align-items: center;
padding: 6px 0;
}
.selection-btn {
font-size: 12px !important;
padding: 6px 10px !important;
}
.selection-btn.active {
background: var(--accent) !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,
.actions button,
.transport button,
.ghost,
.primary,
.soft {
cursor: pointer;
border-radius: 12px;
border: 1px solid transparent;
padding: 10px 14px;
font-weight: 600;
transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease, border 140ms ease;
color: #0f1528;
}
.ghost {
background: var(--button-ghost-bg);
color: var(--button-ghost-text);
border-color: var(--border-soft);
}
.primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: var(--button-primary-text);
box-shadow: 0 8px 26px rgba(72, 228, 194, 0.2);
}
.soft {
background: var(--button-soft-bg);
color: var(--button-soft-text);
border: 1px solid var(--border-soft);
}
.ghost:disabled,
.primary:disabled,
.soft:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ghost:hover,
.primary:hover,
.soft:hover {
transform: translateY(-1px);
}
.piano-shell {
border-radius: 12px;
background: var(--panel-strong);
border: 1px solid var(--border-subtle);
overflow: hidden;
flex: 1;
min-height: 0;
max-height: 100%;
display: flex;
flex-direction: column;
}
.ruler {
position: relative;
height: 32px;
background: var(--panel-soft);
border-bottom: 1px solid var(--border-soft);
min-width: 100%;
}
.ruler-shell {
display: flex;
}
.ruler-spacer {
background: var(--panel-soft);
border-bottom: 1px solid var(--border-soft);
height: 32px;
}
.ruler-scroll {
overflow: hidden;
flex: 1;
height: 32px;
cursor: pointer;
}
.measure-mark {
position: absolute;
top: 0;
height: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
font-size: 10px;
color: var(--text-muted);
padding-left: 4px;
border-left: 1px solid var(--border-soft);
}
.measure-mark span {
margin-top: 2px;
}
.ruler-playhead {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: #ff7043;
pointer-events: none;
z-index: 10;
}
.ruler-scroll.selecting {
cursor: crosshair;
}
.selection-range {
position: absolute;
top: 0;
height: 100%;
background: rgba(66, 165, 245, 0.35);
border-left: 2px solid #42a5f5;
border-right: 2px solid #42a5f5;
pointer-events: none;
z-index: 5;
}
.grid-selection-range {
position: absolute;
top: 0;
background: rgba(66, 165, 245, 0.15);
border-left: 2px dashed #42a5f5;
border-right: 2px dashed #42a5f5;
pointer-events: none;
z-index: 1;
}
.roll-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.pitch-rail {
background: var(--panel-strong);
border-right: 1px solid var(--border-subtle);
color: var(--text-primary);
font-size: 12px;
text-align: right;
overflow: hidden;
flex-shrink: 0;
height: 100%;
}
.pitch-cell {
border-bottom: 1px solid var(--border-soft);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 4px;
font-variant-numeric: tabular-nums;
box-sizing: border-box;
}
.pitch-white {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
.pitch-black {
background: rgba(0, 0, 0, 0.35);
color: rgba(233, 238, 247, 0.9);
}
.pitch-c {
background: rgba(100, 150, 255, 0.15);
font-weight: 600;
}
.pitch-label {
font-size: 10px;
}
.roll-grid {
position: relative;
overflow: auto;
flex: 1;
min-height: 0;
background-color: var(--grid-bg);
}
.grid-content {
background-color: var(--grid-bg);
}
.grid-svg {
shape-rendering: crispEdges;
}
.grid-overlay {
position: relative;
}
.note-chip {
position: absolute;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.16);
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.25);
display: flex;
align-items: center;
justify-content: center;
color: var(--note-text);
font-weight: 700;
user-select: none;
box-sizing: border-box;
}
.note-active {
outline: 2px solid #ff7043;
z-index: 2;
}
.note-overlap {
background: linear-gradient(135deg, #ef5350 0%, #ff7043 100%) !important;
animation: pulse-overlap 1s ease-in-out infinite;
}
/* Selected overlapping note - more visible outline */
.note-overlap.note-active {
outline: 3px solid #1e40af;
outline-offset: 1px;
box-shadow: 0 0 12px rgba(30, 64, 175, 0.8);
animation: none;
}
@keyframes pulse-overlap {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.playhead {
position: absolute;
top: 0;
width: 2px;
background: #ff7043;
box-shadow: 0 0 12px rgba(255, 112, 67, 0.6);
pointer-events: none;
z-index: 20;
}
.pitch-rail-inner {
will-change: transform;
}
.note-label {
width: 100%;
text-align: center;
font-size: 12px;
padding: 0 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-handle {
position: absolute;
top: 0;
width: 8px;
height: 100%;
background: rgba(255, 255, 255, 0.25);
cursor: ew-resize;
}
.note-handle.start {
left: 0;
border-radius: 6px 0 0 6px;
}
.note-handle.end {
right: 0;
border-radius: 0 6px 6px 0;
}
.lyric-container {
flex: 1;
min-height: 0;
position: relative;
}
.lyric-card {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
background: var(--panel-soft);
overflow: hidden;
display: flex;
flex-direction: column;
/* Force fixed height with absolute positioning */
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.lyric-bulk {
display: flex;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
align-items: center;
}
.lyric-bulk-input {
flex: 1;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--border-soft);
background: var(--input-bg);
color: var(--text-primary);
resize: vertical;
}
.lyric-header,
.lyric-row {
display: grid;
grid-template-columns: 1.4fr 0.5fr 0.5fr 0.5fr;
gap: 8px;
padding: 10px 12px;
align-items: center;
}
.lyric-header {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.lyric-list {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
min-height: 0;
}
.lyric-row {
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.lyric-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.lyric-row-active {
background: rgba(72, 228, 194, 0.08);
border-left: 3px solid #48e4c2;
}
.lyric-input {
width: 100%;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--border-soft);
background: var(--input-bg);
color: var(--text-primary);
}
.lyric-meta {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.editable-cell {
position: relative;
display: flex;
align-items: center;
gap: 2px;
}
.lyric-meta-input {
width: 100%;
padding: 2px 4px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
text-align: center;
outline: none;
transition: border-color 0.15s, background-color 0.15s;
}
.lyric-meta-input:hover {
background: var(--surface-elevated);
}
.lyric-meta-input:focus {
border-color: var(--accent);
background: var(--surface-elevated);
color: var(--text-primary);
}
.lyric-meta-dirty {
border-color: #f59e0b !important;
background: rgba(245, 158, 11, 0.1) !important;
}
.confirm-btn {
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 4px;
background: #22c55e;
color: white;
font-size: 12px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.confirm-btn:hover {
background: #16a34a;
}
/* Hide number input spinners */
.lyric-meta-input::-webkit-outer-spin-button,
.lyric-meta-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.lyric-meta-input[type=number] {
-moz-appearance: textfield;
}
.lyric-empty {
padding: 16px;
color: var(--text-muted);
text-align: center;
}
.audio-track {
display: grid;
grid-template-columns: 80px 1fr;
gap: 12px;
align-items: center;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border-soft);
background: var(--panel-soft);
margin-bottom: 12px;
flex-shrink: 0;
}
.audio-track-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
}
.audio-wave {
width: 100%;
height: 80px;
min-height: 80px;
}
:root {
--text-primary: #e9eef7;
--text-muted: rgba(233, 238, 247, 0.7);
--panel: rgba(13, 16, 28, 0.8);
--panel-strong: rgba(16, 21, 35, 0.95);
--panel-soft: rgba(255, 255, 255, 0.03);
--border-subtle: rgba(255, 255, 255, 0.08);
--border-soft: rgba(255, 255, 255, 0.12);
--input-bg: rgba(255, 255, 255, 0.06);
--grid-bg: rgba(14, 18, 30, 0.9);
--grid-line-minor: rgba(233, 238, 247, 0.08);
--grid-line-major: rgba(233, 238, 247, 0.16);
--accent: #48e4c2;
--accent-strong: #4b64bc;
--note-text: #0b1122;
--button-ghost-bg: rgba(233, 238, 247, 0.18);
--button-ghost-text: #ffffff;
--button-soft-bg: rgba(255, 255, 255, 0.14);
--button-soft-text: #ffffff;
--button-primary-text: #0b1122;
--shadow-panel: 0 18px 40px rgba(0, 0, 0, 0.32);
}
:root[data-theme='light'] {
--text-primary: #1b2238;
--text-muted: rgba(27, 34, 56, 0.7);
--panel: rgba(255, 255, 255, 0.9);
--panel-strong: rgba(250, 252, 255, 0.98);
--panel-soft: rgba(15, 23, 42, 0.04);
--border-subtle: rgba(15, 23, 42, 0.12);
--border-soft: rgba(15, 23, 42, 0.16);
--input-bg: rgba(15, 23, 42, 0.06);
--grid-bg: rgba(248, 250, 255, 0.95);
--grid-line-minor: rgba(15, 23, 42, 0.12);
--grid-line-major: rgba(15, 23, 42, 0.24);
--accent: #3f8cff;
--accent-strong: #4b64bc;
--note-text: #ffffff;
--button-ghost-bg: rgba(15, 23, 42, 0.06);
--button-ghost-text: #1b2238;
--button-soft-bg: rgba(15, 23, 42, 0.06);
--button-soft-text: #1b2238;
--button-primary-text: #0b1122;
--shadow-panel: 0 18px 40px rgba(15, 23, 42, 0.15);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
+654
View File
@@ -0,0 +1,654 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as Tone from 'tone'
import { PianoRoll } from './components/PianoRoll'
import { LyricTable } from './components/LyricTable'
import { AudioTrack } from './components/AudioTrack'
import { useMidiStore } from './store/useMidiStore'
import { exportMidi, importMidiFile } from './lib/midi'
import type { TimeSignature } from './types'
import { BASE_GRID_SECOND_WIDTH, BASE_ROW_HEIGHT, LOW_NOTE, HIGH_NOTE } from './constants'
import './App.css'
type PlayEvent = {
time: number
midi: number
duration: number
velocity: number
}
function App() {
const {
notes,
tempo,
timeSignature,
selectedId,
playhead,
ppq,
addNote,
updateNote,
removeNote,
setNotes,
setTempo,
setTimeSignature,
setPpq,
select,
setPlayhead,
} = useMidiStore()
const [status, setStatus] = useState('准备就绪')
const [isPlaying, setIsPlaying] = useState(false)
const [theme, setTheme] = useState<'dark' | 'light'>('light')
const [audioUrl, setAudioUrl] = useState<string | null>(null)
const [audioDuration, setAudioDuration] = useState(0)
const [midiVolume, setMidiVolume] = useState(80) // 0-100
const [audioVolume, setAudioVolume] = useState(80) // 0-100
const [horizontalZoom, setHorizontalZoom] = useState(1)
const [verticalZoom, setVerticalZoom] = useState(1)
const [focusLyricId, setFocusLyricId] = useState<string | null>(null)
// Selection range for loop playback (in seconds)
const [selectionStart, setSelectionStart] = useState<number | null>(null)
const [selectionEnd, setSelectionEnd] = useState<number | null>(null)
const [isSelectingRange, setIsSelectingRange] = useState(false)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const audioInputRef = useRef<HTMLInputElement | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const partRef = useRef<Tone.Part<PlayEvent> | null>(null)
const synthRef = useRef<Tone.PolySynth | null>(null)
const rafRef = useRef<number | null>(null)
const audioScrollRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
return () => {
stopPlayback()
synthRef.current?.dispose()
}
}, [])
useEffect(() => {
document.documentElement.dataset.theme = theme
}, [theme])
// Sync audio volume - also trigger when audioUrl changes (new audio loaded)
useEffect(() => {
if (audioRef.current) {
audioRef.current.volume = audioVolume / 100
}
}, [audioVolume, audioUrl])
// Sync MIDI synth volume
useEffect(() => {
if (synthRef.current) {
// Convert 0-100 to dB scale (-60 to 0)
const dbValue = midiVolume === 0 ? -Infinity : (midiVolume / 100) * 60 - 60
synthRef.current.volume.value = dbValue
}
}, [midiVolume])
useEffect(() => {
if (!audioUrl) return
return () => {
URL.revokeObjectURL(audioUrl)
}
}, [audioUrl])
const ensureSynth = async () => {
await Tone.start()
if (!synthRef.current) {
synthRef.current = new Tone.PolySynth(Tone.Synth).toDestination()
// Apply current volume
const dbValue = midiVolume === 0 ? -Infinity : (midiVolume / 100) * 60 - 60
synthRef.current.volume.value = dbValue
}
}
const playPreviewNote = useCallback(async (midi: number) => {
await ensureSynth()
const frequency = Tone.Frequency(midi, 'midi').toFrequency()
synthRef.current?.triggerAttackRelease(frequency, '8n', Tone.now(), 0.7)
}, [midiVolume])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!selectedId) return
const target = event.target as HTMLElement | null
if (target && ['INPUT', 'TEXTAREA'].includes(target.tagName)) return
// Delete note
if (event.key === 'Backspace' || event.key === 'Delete') {
event.preventDefault()
removeNote(selectedId)
select(null)
return
}
// Cmd/Ctrl + Up/Down to adjust pitch
const isCmdOrCtrl = event.metaKey || event.ctrlKey
if (isCmdOrCtrl && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
event.preventDefault()
const selectedNote = notes.find(n => n.id === selectedId)
if (!selectedNote) return
const delta = event.key === 'ArrowUp' ? 1 : -1
const newMidi = Math.max(LOW_NOTE, Math.min(HIGH_NOTE, selectedNote.midi + delta))
if (newMidi !== selectedNote.midi) {
updateNote(selectedId, { midi: newMidi })
playPreviewNote(newMidi)
}
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [selectedId, notes, removeNote, select, updateNote, playPreviewNote])
const noteEvents = useMemo<PlayEvent[]>(
() =>
notes.map((note) => ({
time: (60 / tempo) * note.start,
duration: (60 / tempo) * note.duration,
midi: note.midi,
velocity: note.velocity,
})),
[notes, tempo],
)
const beatToSeconds = (beat: number) => beat * (60 / tempo)
const secondsToBeat = (seconds: number) => seconds / (60 / tempo)
const seekBySeconds = (deltaSeconds: number) => {
const maxNoteEnd = notes.reduce((acc, n) => Math.max(acc, n.start + n.duration), 0)
const maxBeat = Math.max(secondsToBeat(audioDuration), maxNoteEnd)
const nextSeconds = Math.max(0, Math.min(beatToSeconds(maxBeat), beatToSeconds(playhead) + deltaSeconds))
seekToBeat(secondsToBeat(nextSeconds))
}
const gridSecondWidth = BASE_GRID_SECOND_WIDTH * horizontalZoom
const rowHeight = BASE_ROW_HEIGHT * verticalZoom
// Calculate MIDI content width to sync with audio track
const midiContentWidth = useMemo(() => {
const noteEndSeconds = notes.reduce((acc, n) => {
const endBeat = n.start + n.duration
return Math.max(acc, beatToSeconds(endBeat))
}, 8)
const maxSeconds = Math.max(noteEndSeconds + 10, audioDuration + 10, 30)
return maxSeconds * gridSecondWidth
}, [notes, audioDuration, gridSecondWidth, beatToSeconds])
const seekToBeat = (beat: number) => {
setPlayhead(beat)
Tone.Transport.seconds = beatToSeconds(beat)
if (audioRef.current) {
audioRef.current.currentTime = beatToSeconds(beat)
}
}
const schedulePlayback = async () => {
if (!notes.length && !audioUrl) return
await ensureSynth()
partRef.current?.dispose()
Tone.Transport.cancel()
Tone.Transport.stop()
Tone.Transport.bpm.value = tempo
// Determine playback range
const hasSelection = selectionStart !== null && selectionEnd !== null && selectionEnd > selectionStart
const startSeconds = hasSelection ? selectionStart : beatToSeconds(playhead)
const endSeconds = hasSelection ? selectionEnd : null
Tone.Transport.seconds = startSeconds
// Filter notes within selection range if applicable
const filteredEvents = hasSelection
? noteEvents.filter(e => e.time >= startSeconds && e.time < endSeconds!)
: noteEvents
if (filteredEvents.length) {
partRef.current = new Tone.Part((time, event) => {
if (midiVolume === 0) return
const frequency = Tone.Frequency(event.midi, 'midi').toFrequency()
synthRef.current?.triggerAttackRelease(frequency, event.duration, time, event.velocity)
}, filteredEvents)
partRef.current.start(0)
}
Tone.Transport.start()
if (audioRef.current && audioUrl) {
audioRef.current.currentTime = startSeconds
if (audioVolume > 0) {
audioRef.current.play().catch(() => null)
}
}
setIsPlaying(true)
setStatus(hasSelection ? '选区回放中...' : '正在回放...')
const tick = () => {
const seconds =
audioRef.current && audioUrl && !audioRef.current.paused
? audioRef.current.currentTime
: Tone.Transport.seconds
// Stop at selection end
if (endSeconds !== null && seconds >= endSeconds) {
pausePlayback()
seekToBeat(secondsToBeat(selectionStart!))
setStatus('选区播放完毕')
return
}
const beat = seconds / (60 / tempo)
setPlayhead(beat)
rafRef.current = requestAnimationFrame(tick)
}
rafRef.current = requestAnimationFrame(tick)
}
const stopPlayback = () => {
Tone.Transport.stop()
Tone.Transport.cancel()
partRef.current?.dispose()
partRef.current = null
setIsPlaying(false)
setPlayhead(0)
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.currentTime = 0
}
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
const pausePlayback = () => {
Tone.Transport.stop()
partRef.current?.dispose()
partRef.current = null
setIsPlaying(false)
if (audioRef.current) {
audioRef.current.pause()
}
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
const handlePlayToggle = async () => {
if (isPlaying) {
pausePlayback()
setStatus('已暂停')
} else {
await schedulePlayback()
}
}
const handleImportClick = () => fileInputRef.current?.click()
const handleAudioImportClick = () => audioInputRef.current?.click()
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
const snapshot = await importMidiFile(file)
setNotes(snapshot.notes)
setTempo(snapshot.tempo)
setTimeSignature(snapshot.timeSignature as TimeSignature)
setPpq(snapshot.ppq) // Preserve original ppq for accurate export
setStatus(`已载入 ${file.name}`)
} catch (error) {
console.error(error)
setStatus('导入失败,请确认文件合法')
} finally {
event.target.value = ''
}
}
const handleAudioChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
// Validate audio file type
const validAudioTypes = ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/flac', 'audio/mp4', 'audio/aac', 'audio/x-m4a']
const validExtensions = ['.mp3', '.wav', '.ogg', '.flac', '.m4a', '.aac']
const fileName = file.name.toLowerCase()
const isValidType = validAudioTypes.includes(file.type) || file.type.startsWith('audio/')
const isValidExtension = validExtensions.some(ext => fileName.endsWith(ext))
if (!isValidType && !isValidExtension) {
setStatus(`不支持的文件格式,请选择音频文件(${validExtensions.join(', ')})`)
event.target.value = ''
return
}
const url = URL.createObjectURL(file)
setAudioUrl(url)
setStatus(`已载入音频 ${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 = () => {
const sortedNotes = [...notes].sort((a, b) => a.start - b.start)
let fixCount = 0
for (let i = 0; i < sortedNotes.length - 1; i++) {
const noteA = sortedNotes[i]
const noteB = sortedNotes[i + 1]
const noteAEnd = noteA.start + noteA.duration
// If noteA overlaps with noteB
if (noteAEnd > noteB.start) {
// Trim noteA to end at noteB's start
const newDuration = Math.max(0.01, noteB.start - noteA.start)
updateNote(noteA.id, { duration: newDuration })
fixCount++
}
}
if (fixCount > 0) {
setStatus(`已修复 ${fixCount} 个重叠音符`)
} else {
setStatus('没有检测到重叠音符')
}
}
const handleExport = () => {
const overlapping = getOverlappingNotes()
if (overlapping.length > 0) {
const confirm = window.confirm(
`检测到 ${overlapping.length} 个音符存在时间重叠(标红色的音符),这可能导致播放异常。\n\n是否仍要导出?`
)
if (!confirm) return
}
const blob = exportMidi({ notes, 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 文件')
}
return (
<div className="app-shell">
<header className="topbar">
<div>
<p className="eyebrow">歌声 MIDI 编辑器</p>
<h1>Lyric-ready Piano Roll</h1>
<p className="muted">导入、拖拽、实时修改歌词并导出标准 MIDI。</p>
</div>
<div className="actions">
<button className="icon-toggle" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? (
<span className="icon" aria-label="切换到亮色">
☀️
</span>
) : (
<span className="icon" aria-label="切换到暗色">
🌙
</span>
)}
</button>
<button className="primary" onClick={handleImportClick}>
导入 MIDI
</button>
<button className="primary" onClick={handleExport}>
导出含歌词 MIDI
</button>
<button className="soft" onClick={handleFixOverlaps} title="自动消除重叠:将重叠音符的音尾提前到下一个音的音头">
消除重叠
</button>
<input ref={fileInputRef} type="file" accept=".mid,.midi" className="sr-only" onChange={handleFileChange} />
</div>
</header>
<section className="audio-bar">
<div className="audio-left">
<button className="ghost" onClick={handleAudioImportClick}>
对齐音频导入
</button>
<input
ref={audioInputRef}
type="file"
accept=".mp3,.wav,.ogg,.flac,.m4a,.aac"
className="sr-only"
onChange={handleAudioChange}
/>
<span className="audio-hint">导入后显示音频波形并与 MIDI 同步走带</span>
</div>
<div className="audio-right">
<div className="volume-control">
<span className="volume-label">MIDI</span>
<input
type="range"
min={0}
max={100}
value={midiVolume}
onChange={(e) => setMidiVolume(Number(e.target.value))}
className="volume-slider"
/>
<span className="volume-value">{midiVolume}%</span>
</div>
<div className="volume-control">
<span className="volume-label">音频</span>
<input
type="range"
min={0}
max={100}
value={audioVolume}
onChange={(e) => setAudioVolume(Number(e.target.value))}
className="volume-slider"
/>
<span className="volume-value">{audioVolume}%</span>
</div>
</div>
</section>
<section className="panel panel-split">
<div className="panel-main">
{audioUrl && (
<AudioTrack
key={audioUrl}
ref={audioScrollRef}
audioUrl={audioUrl}
muted={audioVolume === 0}
onSeek={(seconds) => seekToBeat(secondsToBeat(seconds))}
playheadSeconds={beatToSeconds(playhead)}
gridSecondWidth={gridSecondWidth}
minContentWidth={midiContentWidth}
/>
)}
<PianoRoll
notes={notes}
selectedId={selectedId}
timeSignature={timeSignature}
tempo={tempo}
playhead={playhead}
selectionStart={selectionStart}
selectionEnd={selectionEnd}
onAddNote={addNote}
onSelect={select}
onUpdateNote={updateNote}
onSeek={seekToBeat}
onScroll={(left) => {
if (audioScrollRef.current) {
audioScrollRef.current.scrollLeft = left
}
}}
onZoom={(deltaH, deltaV) => {
if (deltaH !== 0) {
setHorizontalZoom(prev => Math.max(0.5, prev + deltaH))
}
if (deltaV !== 0) {
setVerticalZoom(prev => Math.max(0.6, Math.min(2.5, prev + deltaV)))
}
}}
onPlayNote={playPreviewNote}
onFocusLyric={(noteId) => {
select(noteId)
setFocusLyricId(noteId)
}}
onSelectionChange={(start, end) => {
setSelectionStart(start)
setSelectionEnd(end)
}}
isSelectingRange={isSelectingRange}
audioDuration={audioDuration}
gridSecondWidth={gridSecondWidth}
rowHeight={rowHeight}
/>
</div>
<aside className="panel-side">
<div className="controls">
<div className="toggle" style={{ justifyContent: 'space-between' }}>
<span>水平缩放</span>
<input
type="range"
min={0.5}
max={10}
step={0.1}
value={Math.min(horizontalZoom, 10)}
onChange={(e) => setHorizontalZoom(Number(e.target.value))}
style={{ width: '140px' }}
/>
<span style={{ width: 42, textAlign: 'right' }}>{horizontalZoom.toFixed(1)}x</span>
</div>
<div className="toggle" style={{ justifyContent: 'space-between' }}>
<span>垂直缩放</span>
<input
type="range"
min={0.6}
max={2.5}
step={0.1}
value={verticalZoom}
onChange={(e) => setVerticalZoom(Number(e.target.value))}
style={{ width: '140px' }}
/>
<span style={{ width: 42, textAlign: 'right' }}>{verticalZoom.toFixed(1)}x</span>
</div>
<div className="transport">
<button
className="soft"
onClick={() => {
setPlayhead(0)
seekToBeat(0)
}}
title="回到开头"
>
⏮
</button>
<button
className="soft"
onClick={() => seekBySeconds(-2)}
title="后退 2 秒"
>
⏪ 2s
</button>
<button
className="primary"
onClick={handlePlayToggle}
disabled={!notes.length && !audioUrl}
title={isPlaying ? "暂停" : (selectionStart !== null && selectionEnd !== null ? "播放选区" : "播放")}
>
{isPlaying ? '⏸' : '▶'}
</button>
<button
className="soft"
onClick={() => seekBySeconds(2)}
title="前进 2 秒"
>
2s ⏩
</button>
<button
className="soft"
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)
seekToBeat(Math.max(secondsToBeat(audioDuration), maxNoteEnd))
}}
title="回到结尾"
>
⏭
</button>
</div>
<div className="selection-controls">
<button
className={`soft selection-btn ${isSelectingRange ? 'active' : ''}`}
onClick={() => setIsSelectingRange(!isSelectingRange)}
title={isSelectingRange ? "退出选区模式" : "设置选区:在时间轴上拖拽选择播放范围"}
>
{isSelectingRange ? '📍 选区中' : '📍 设选区'}
</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 className="status">{status}</div>
</div>
<div className="lyric-container">
<LyricTable
notes={notes}
selectedId={selectedId}
tempo={tempo}
focusLyricId={focusLyricId}
onSelect={select}
onUpdate={updateNote}
onFocusHandled={() => setFocusLyricId(null)}
/>
</div>
</aside>
</section>
<audio
ref={audioRef}
src={audioUrl ?? undefined}
preload="auto"
className="sr-only"
onLoadedMetadata={(e) => {
setAudioDuration(e.currentTarget.duration)
// Ensure volume is set when audio loads
e.currentTarget.volume = audioVolume / 100
}}
/>
</div>
)
}
export default App
@@ -0,0 +1,182 @@
import { useEffect, useRef, forwardRef, useState } from 'react'
import WaveSurfer from 'wavesurfer.js'
import { PITCH_WIDTH } from '../constants'
export type AudioTrackProps = {
audioUrl: string | null
muted: boolean
onSeek: (seconds: number) => void
mediaElement?: HTMLAudioElement | null
playheadSeconds: number
gridSecondWidth: number
minContentWidth?: number // Minimum width to match MIDI editor area
}
export const AudioTrack = forwardRef<HTMLDivElement, AudioTrackProps>(
({ audioUrl, muted, onSeek, playheadSeconds, gridSecondWidth, minContentWidth = 0 }, ref) => {
const containerRef = useRef<HTMLDivElement | null>(null)
const waveRef = useRef<WaveSurfer | null>(null)
const [waveWidth, setWaveWidth] = useState(0)
useEffect(() => {
if (!containerRef.current) return
if (!audioUrl) {
try {
waveRef.current?.destroy()
} catch {
// ignore teardown errors
}
waveRef.current = null
setWaveWidth(0)
return
}
let cancelled = false
// Clean up existing instance
if (waveRef.current) {
try {
waveRef.current.destroy()
} catch {
// ignore teardown errors
}
}
waveRef.current = WaveSurfer.create({
container: containerRef.current,
waveColor: '#4b64bc',
progressColor: '#4b64bc',
cursorColor: 'transparent',
barWidth: 2,
barGap: 2,
height: 60,
normalize: true,
minPxPerSec: gridSecondWidth,
interact: false,
hideScrollbar: true,
autoScroll: false,
})
waveRef.current.load(audioUrl).catch(() => null)
waveRef.current.on('error', () => null)
waveRef.current.on('ready', () => {
if (cancelled || !waveRef.current) return
const duration = waveRef.current.getDuration()
const requiredWidth = duration * gridSecondWidth
setWaveWidth(requiredWidth)
})
return () => {
cancelled = true
try {
waveRef.current?.destroy()
} catch {
// ignore teardown errors
}
waveRef.current = null
}
}, [audioUrl, gridSecondWidth])
useEffect(() => {
if (!waveRef.current) return
waveRef.current.setOptions({
waveColor: muted ? '#9aa6b2' : '#4b64bc',
progressColor: muted ? '#c0c9d4' : '#4b64bc',
})
}, [muted])
if (!audioUrl) return null
// Content width should be at least as wide as MIDI editor
const contentWidth = Math.max(waveWidth, minContentWidth)
return (
<div
className="audio-track-row"
style={{
display: 'flex',
borderBottom: '1px solid var(--border-soft)',
height: '70px',
flexShrink: 0
}}
>
<div
className="audio-gutter"
style={{
width: PITCH_WIDTH,
flexShrink: 0,
background: 'var(--panel-strong)',
borderRight: '1px solid var(--border-subtle)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
color: 'var(--text-muted)',
fontWeight: 600,
}}
>
AUDIO
</div>
{/* Scroll Mask - Controlled by parent via ref */}
<div
ref={ref}
className="audio-scroll-mask"
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
background: 'var(--panel-soft)',
}}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const scrollMask = e.currentTarget as HTMLDivElement
const x = e.clientX - rect.left + scrollMask.scrollLeft
const seconds = x / gridSecondWidth
onSeek(seconds)
}}
>
{/* Container that matches MIDI editor width */}
<div
className="audio-content"
style={{
width: contentWidth > 0 ? contentWidth : '100%',
height: '100%',
position: 'relative'
}}
>
{/* WaveSurfer container - only as wide as audio */}
<div
ref={containerRef}
className="wave-container"
style={{
width: waveWidth > 0 ? waveWidth : '100%',
height: '100%',
position: 'absolute',
left: 0,
top: 0
}}
/>
{/* Custom Playhead */}
<div
className="audio-playhead"
style={{
position: 'absolute',
top: 0,
bottom: 0,
width: '2px',
background: '#ff7043',
boxShadow: '0 0 12px rgba(255, 112, 67, 0.6)',
left: playheadSeconds * gridSecondWidth,
zIndex: 10,
pointerEvents: 'none',
}}
/>
</div>
</div>
</div>
)
}
)
@@ -0,0 +1,288 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { NoteEvent } from '../types'
export type LyricTableProps = {
notes: NoteEvent[]
selectedId: string | null
tempo: number
focusLyricId: string | null
onSelect: (id: string | null) => void
onUpdate: (id: string, patch: Partial<NoteEvent>) => void
onScrollToNote?: (noteId: string) => void
onFocusHandled?: () => void
}
const formatSeconds = (beats: number, tempo: number) => {
const seconds = beats * (60 / tempo)
return Number.parseFloat(seconds.toFixed(2))
}
const secondsToBeats = (seconds: number, tempo: number) => {
return seconds * (tempo / 60)
}
// Editable cell with confirmation
function EditableCell({
value,
noteId,
field,
tempo,
onConfirm,
type = 'number',
min,
step
}: {
value: number
noteId: string
field: 'midi' | 'start' | 'end'
tempo: number
onConfirm: (noteId: string, field: string, value: number) => void
type?: string
min?: number
step?: number
}) {
const displayValue = field === 'midi' ? value : formatSeconds(value, tempo)
const [localValue, setLocalValue] = useState(String(displayValue))
const [isDirty, setIsDirty] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// Sync with external value when it changes (and not dirty)
useEffect(() => {
if (!isDirty) {
setLocalValue(String(displayValue))
}
}, [displayValue, isDirty])
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(e.target.value)
setIsDirty(true)
}
const handleConfirm = () => {
const parsed = parseFloat(localValue)
if (!isNaN(parsed)) {
if (field === 'midi') {
if (parsed >= 0 && parsed <= 127) {
onConfirm(noteId, field, Math.round(parsed))
}
} else {
if (parsed >= 0) {
onConfirm(noteId, field, secondsToBeats(parsed, tempo))
}
}
}
setIsDirty(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleConfirm()
inputRef.current?.blur()
} else if (e.key === 'Escape') {
setLocalValue(String(displayValue))
setIsDirty(false)
inputRef.current?.blur()
}
}
const handleBlur = () => {
if (isDirty) {
// Reset to original on blur without confirm
setLocalValue(String(displayValue))
setIsDirty(false)
}
}
return (
<div className="editable-cell">
<input
ref={inputRef}
className={`lyric-meta-input ${isDirty ? 'lyric-meta-dirty' : ''}`}
type={type}
min={min}
step={step}
value={localValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onClick={(e) => e.stopPropagation()}
/>
{isDirty && (
<button
className="confirm-btn"
onMouseDown={(e) => {
e.preventDefault() // Prevent input blur
e.stopPropagation()
}}
onClick={(e) => {
e.stopPropagation()
handleConfirm()
}}
title="确认修改 (Enter)"
>
✓
</button>
)}
</div>
)
}
export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, onUpdate, onScrollToNote, onFocusHandled }: LyricTableProps) {
const listRef = useRef<HTMLDivElement | null>(null)
const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
const sorted = useMemo(() => [...notes].sort((a, b) => a.start - b.start), [notes])
// Scroll to selected note (no auto-focus on single click)
useEffect(() => {
if (!selectedId || !listRef.current) return
const target = listRef.current.querySelector<HTMLDivElement>(`[data-note-id="${selectedId}"]`)
if (target) {
target.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}, [selectedId])
// Focus lyric input when requested (double-click on note or click on list row)
useEffect(() => {
if (!focusLyricId) return
const input = inputRefs.current.get(focusLyricId)
if (input) {
setTimeout(() => {
input.focus()
input.select()
}, 50)
}
onFocusHandled?.()
}, [focusLyricId, onFocusHandled])
// Fill lyrics from selected note onwards
const handleBulkFill = (bulkText: string) => {
if (!sorted.length) return
const chars = Array.from(bulkText.replace(/\s+/g, ''))
let startIndex = 0
if (selectedId) {
const selectedIndex = sorted.findIndex(n => n.id === selectedId)
if (selectedIndex >= 0) {
startIndex = selectedIndex
}
}
let charIndex = 0
for (let i = startIndex; i < sorted.length && charIndex < chars.length; i++) {
onUpdate(sorted[i].id, { lyric: chars[charIndex] })
charIndex++
}
}
const handleRowClick = (noteId: string) => {
onSelect(noteId)
onScrollToNote?.(noteId)
}
const handleFieldConfirm = (noteId: string, field: string, value: number) => {
const note = notes.find(n => n.id === noteId)
if (!note) return
if (field === 'midi') {
onUpdate(noteId, { midi: value })
} else if (field === 'start') {
// Keep END the same, adjust duration accordingly
const currentEnd = note.start + note.duration
const newDuration = Math.max(0.01, currentEnd - value)
onUpdate(noteId, { start: value, duration: newDuration })
} else if (field === 'end') {
// End changed, update duration
const newDuration = Math.max(0.01, value - note.start)
onUpdate(noteId, { duration: newDuration })
}
}
return (
<div className="lyric-card">
<div className="lyric-bulk">
<textarea
className="lyric-bulk-input"
rows={2}
placeholder={selectedId ? "从选中音符开始按字填充" : "输入歌词,点击按字填充"}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleBulkFill(e.currentTarget.value)
}
}}
/>
<button
className="soft"
type="button"
onClick={(e) => {
const textarea = e.currentTarget.previousElementSibling as HTMLTextAreaElement
handleBulkFill(textarea.value)
}}
>
按字<br/>填充
</button>
</div>
<div className="lyric-header" style={{ flexShrink: 0 }}>
<div>LYRIC</div>
<div>PITCH</div>
<div>START</div>
<div>END</div>
</div>
<div className="lyric-list" ref={listRef}>
{sorted.map((note) => (
<div
key={note.id}
className={`lyric-row ${selectedId === note.id ? 'lyric-row-active' : ''}`}
data-note-id={note.id}
onClick={() => handleRowClick(note.id)}
>
<input
ref={(el) => {
if (el) {
inputRefs.current.set(note.id, el)
} else {
inputRefs.current.delete(note.id)
}
}}
className="lyric-input"
value={note.lyric}
placeholder="Type lyric"
onChange={(event) => onUpdate(note.id, { lyric: event.target.value })}
onClick={(e) => e.stopPropagation()}
/>
<EditableCell
value={note.midi}
noteId={note.id}
field="midi"
tempo={tempo}
onConfirm={handleFieldConfirm}
min={0}
/>
<EditableCell
value={note.start}
noteId={note.id}
field="start"
tempo={tempo}
onConfirm={handleFieldConfirm}
min={0}
step={0.01}
/>
<EditableCell
value={note.start + note.duration}
noteId={note.id}
field="end"
tempo={tempo}
onConfirm={handleFieldConfirm}
min={0}
step={0.01}
/>
</div>
))}
{sorted.length === 0 && <div className="lyric-empty">Import或双击钢琴卷帘以添加音符</div>}
</div>
</div>
)
}
@@ -0,0 +1,704 @@
import { useEffect, useMemo, useRef, useState, useCallback, memo } from 'react'
import type React from 'react'
import type { NoteEvent, TimeSignature } from '../types'
import { PITCH_WIDTH, LOW_NOTE, HIGH_NOTE } from '../constants'
const midiToName = (midi: number) => {
const names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
const octave = Math.floor(midi / 12) - 1
return `${names[midi % 12]}${octave}`
}
// Memoized note component to prevent unnecessary re-renders
const NoteChip = memo(function NoteChip({
note,
left,
top,
width,
height,
fontSize,
isSelected,
isOverlapping,
onPointerDown,
onDoubleClick,
}: {
note: NoteEvent
left: number
top: number
width: number
height: number
fontSize: number
isSelected: boolean
isOverlapping: boolean
onPointerDown: (event: React.PointerEvent<HTMLDivElement>, mode: 'move' | 'resize-start' | 'resize-end') => void
onDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => void
}) {
return (
<div
className={`note-chip ${isSelected ? 'note-active' : ''} ${isOverlapping ? 'note-overlap' : ''}`}
style={{
left,
top: top + 1,
width,
height,
willChange: 'transform', // GPU acceleration hint
}}
onPointerDown={(e) => onPointerDown(e, 'move')}
onDoubleClick={onDoubleClick}
>
<div className="note-label" style={{ fontSize }}>
<span>{note.lyric || '\u00a0'}</span>
</div>
<div className="note-handle start" onPointerDown={(e) => { e.stopPropagation(); onPointerDown(e, 'resize-start') }} />
<div className="note-handle end" onPointerDown={(e) => { e.stopPropagation(); onPointerDown(e, 'resize-end') }} />
</div>
)
})
// Dynamic snap based on zoom level - higher zoom = finer snap
const getSnapSeconds = (gridSecondWidth: number) => {
// At base width (80px/s), snap is 0.1s
// At 2x zoom (160px/s), snap is 0.05s
// At 4x zoom (320px/s), snap is 0.025s
// At 8x zoom (640px/s), snap is 0.01s
const baseSnap = 0.1
const zoomFactor = gridSecondWidth / 80
return Math.max(0.01, baseSnap / zoomFactor)
}
const snapSeconds = (value: number, gridSecondWidth: number) => {
const snap = getSnapSeconds(gridSecondWidth)
return Math.max(0, Math.round(value / snap) * snap)
}
export type PianoRollProps = {
notes: NoteEvent[]
selectedId: string | null
timeSignature: TimeSignature
tempo: number
playhead: number // in beats
selectionStart: number | null // in seconds
selectionEnd: number | null // in seconds
onAddNote: (note: Partial<NoteEvent>) => NoteEvent
onUpdateNote: (id: string, patch: Partial<NoteEvent>) => void
onSelect: (id: string | null) => void
onSeek: (beat: number) => void
onScroll?: (left: number) => void
onZoom?: (deltaH: number, deltaV: number) => void
onPlayNote?: (midi: number) => void
onFocusLyric?: (noteId: string) => void
onSelectionChange?: (start: number | null, end: number | null) => void
isSelectingRange?: boolean
audioDuration?: number
gridSecondWidth: number
rowHeight: number
}
export function PianoRoll({
notes,
selectedId,
timeSignature: _timeSignature,
tempo,
playhead,
selectionStart,
selectionEnd,
onAddNote,
onSelect,
onUpdateNote,
onSeek,
onScroll,
onZoom,
onPlayNote,
onFocusLyric,
onSelectionChange,
isSelectingRange = false,
audioDuration = 0,
gridSecondWidth,
rowHeight
}: PianoRollProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const rulerScrollRef = useRef<HTMLDivElement | null>(null)
const [scrollTop, setScrollTop] = useState(0)
const [scrollLeft, setScrollLeft] = useState(0)
const [viewportWidth, setViewportWidth] = useState(800)
const [viewportHeight, setViewportHeight] = useState(400)
const dragRef = useRef<{
id: string
mode: 'move' | 'resize-start' | 'resize-end'
originX: number
originY: number
startSeconds: number
durationSeconds: number
midi: number
lastMidi?: number // Track last midi for pitch change sound
} | null>(null)
// Selection drag state
const selectionDragRef = useRef<{
startX: number
startSeconds: number
} | null>(null)
// Store callbacks in refs to avoid stale closures in event handlers
const onPlayNoteRef = useRef(onPlayNote)
const onUpdateNoteRef = useRef(onUpdateNote)
useEffect(() => {
onPlayNoteRef.current = onPlayNote
onUpdateNoteRef.current = onUpdateNote
}, [onPlayNote, onUpdateNote])
// Conversion helpers
const beatToSeconds = useCallback((beat: number) => beat * (60 / tempo), [tempo])
const secondsToBeat = useCallback((seconds: number) => seconds / (60 / tempo), [tempo])
// Calculate dimensions
const totalRows = HIGH_NOTE - LOW_NOTE + 1
const contentHeight = totalRows * rowHeight
const [containerWidth, setContainerWidth] = useState(1200)
// Track container size
useEffect(() => {
const container = scrollContainerRef.current
if (!container) return
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width)
setViewportWidth(entry.contentRect.width)
setViewportHeight(entry.contentRect.height)
}
})
observer.observe(container)
return () => observer.disconnect()
}, [])
const maxSeconds = useMemo(() => {
const noteEndSeconds = notes.reduce((acc, n) => {
const endBeat = n.start + n.duration
return Math.max(acc, beatToSeconds(endBeat))
}, 8)
// Ensure grid extends at least 2x the visible area for smoother scrolling
const minSecondsForView = (containerWidth / gridSecondWidth) * 2
return Math.max(noteEndSeconds + 10, audioDuration + 10, minSecondsForView, 30)
}, [notes, audioDuration, beatToSeconds, containerWidth, gridSecondWidth])
const contentWidth = maxSeconds * gridSecondWidth
// Drag handlers - use refs to avoid stale closure issues
const handlePointerMove = useCallback((event: PointerEvent) => {
const drag = dragRef.current
if (!drag) return
const dxSeconds = (event.clientX - drag.originX) / gridSecondWidth
const dy = (event.clientY - drag.originY) / rowHeight
if (drag.mode === 'move') {
const nextSeconds = snapSeconds(drag.startSeconds + dxSeconds, gridSecondWidth)
const nextMidi = Math.min(HIGH_NOTE, Math.max(LOW_NOTE, Math.round(drag.midi - dy)))
// Play sound when pitch changes
if (nextMidi !== drag.lastMidi && onPlayNoteRef.current) {
onPlayNoteRef.current(nextMidi)
drag.lastMidi = nextMidi
}
onUpdateNoteRef.current(drag.id, {
start: secondsToBeat(nextSeconds),
midi: nextMidi
})
}
if (drag.mode === 'resize-start') {
const nextSeconds = snapSeconds(drag.startSeconds + dxSeconds, gridSecondWidth)
const delta = drag.startSeconds - nextSeconds
const nextDurationSeconds = Math.max(0.05, drag.durationSeconds + delta)
onUpdateNoteRef.current(drag.id, {
start: secondsToBeat(nextSeconds),
duration: secondsToBeat(nextDurationSeconds)
})
}
if (drag.mode === 'resize-end') {
const nextDurationSeconds = Math.max(0.05, snapSeconds(drag.durationSeconds + dxSeconds, gridSecondWidth))
onUpdateNoteRef.current(drag.id, { duration: secondsToBeat(nextDurationSeconds) })
}
}, [gridSecondWidth, rowHeight, secondsToBeat])
const handlePointerUp = useCallback(() => {
dragRef.current = null
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
}, [handlePointerMove])
useEffect(() => {
return () => {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
}
}, [handlePointerMove, handlePointerUp])
// Scroll sync
useEffect(() => {
const container = scrollContainerRef.current
const ruler = rulerScrollRef.current
if (!container || !ruler) return
const handleScroll = () => {
ruler.scrollLeft = container.scrollLeft
setScrollTop(container.scrollTop)
setScrollLeft(container.scrollLeft)
if (onScroll) onScroll(container.scrollLeft)
}
container.addEventListener('scroll', handleScroll)
return () => container.removeEventListener('scroll', handleScroll)
}, [onScroll])
// Zoom support via wheel/trackpad
// Mac: Cmd+滚轮 (水平缩放), Cmd+Shift+滚轮 (垂直缩放), 或双指捏合
// Windows/Linux: Ctrl+滚轮 (水平缩放), Ctrl+Shift+滚轮 (垂直缩放)
useEffect(() => {
const container = scrollContainerRef.current
if (!container || !onZoom) return
const handleWheel = (e: WheelEvent) => {
// Ctrl (Windows/Linux/捏合) or Cmd (Mac) triggers zoom
const isZoomTrigger = e.ctrlKey || e.metaKey
if (isZoomTrigger) {
e.preventDefault()
e.stopPropagation()
// Use deltaY for zoom amount, normalize for different input methods
// Pinch gestures typically have smaller delta values
let delta = -e.deltaY
if (Math.abs(delta) > 10) {
// Likely a mouse wheel, scale down
delta = delta * 0.01
} else {
// Likely a trackpad pinch, scale appropriately
delta = delta * 0.05
}
// Shift or Alt/Option for vertical zoom, otherwise horizontal
if (e.shiftKey || e.altKey) {
onZoom(0, delta)
} else {
onZoom(delta, 0)
}
}
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}, [onZoom])
// Playhead auto-scroll
useEffect(() => {
if (!scrollContainerRef.current) return
const container = scrollContainerRef.current
const playheadX = beatToSeconds(playhead) * gridSecondWidth
const viewStart = container.scrollLeft
const viewEnd = viewStart + container.clientWidth
if (playheadX > viewEnd) {
container.scrollLeft = playheadX
} else if (playheadX < viewStart) {
container.scrollLeft = playheadX
}
}, [playhead, gridSecondWidth, beatToSeconds])
// Selection auto-scroll
useEffect(() => {
if (!scrollContainerRef.current || !selectedId) return
const note = notes.find((n) => n.id === selectedId)
if (!note) return
const container = scrollContainerRef.current
const noteX = beatToSeconds(note.start) * gridSecondWidth
const noteY = (HIGH_NOTE - note.midi) * rowHeight
const viewStart = container.scrollLeft
const viewEnd = viewStart + container.clientWidth
if (noteX < viewStart + 50 || noteX > viewEnd - 50) {
container.scrollLeft = Math.max(0, noteX - container.clientWidth * 0.35)
}
const viewTop = container.scrollTop
const viewBottom = viewTop + container.clientHeight
if (noteY < viewTop || noteY > viewBottom - rowHeight) {
container.scrollTop = Math.max(0, noteY - container.clientHeight * 0.4)
}
}, [selectedId, notes, gridSecondWidth, rowHeight, beatToSeconds])
const handleGridDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
// Only add note if clicking on empty space (not on a note)
const target = event.target as HTMLElement
if (target.closest('.note-chip')) return
if (!scrollContainerRef.current) return
const container = scrollContainerRef.current
const rect = container.getBoundingClientRect()
const x = event.clientX - rect.left + container.scrollLeft
const y = event.clientY - rect.top + container.scrollTop
const seconds = snapSeconds(x / gridSecondWidth, gridSecondWidth)
const pitch = Math.min(HIGH_NOTE, Math.max(LOW_NOTE, HIGH_NOTE - Math.floor(y / rowHeight)))
const created = onAddNote({
start: secondsToBeat(seconds),
midi: pitch,
duration: secondsToBeat(0.5),
lyric: ''
})
onSelect(created.id)
}
const startDrag = (
event: React.PointerEvent<HTMLDivElement>,
note: NoteEvent,
mode: 'move' | 'resize-start' | 'resize-end',
) => {
event.preventDefault()
event.stopPropagation()
dragRef.current = {
id: note.id,
mode,
originX: event.clientX,
originY: event.clientY,
startSeconds: beatToSeconds(note.start),
durationSeconds: beatToSeconds(note.duration),
midi: note.midi,
lastMidi: note.midi, // Initialize last midi
}
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
onSelect(note.id)
// Play sound when clicking/selecting note
if (onPlayNote) {
onPlayNote(note.midi)
}
}
// Second-based ruler labels
const secondLabels = useMemo(() => {
const labels = [] as Array<{ left: number; label: string }>
const totalSeconds = Math.ceil(maxSeconds)
for (let s = 0; s <= totalSeconds; s += 1) {
labels.push({ left: s * gridSecondWidth, label: `${s}s` })
}
return labels
}, [maxSeconds, gridSecondWidth])
// Piano keys
const pitchRows = useMemo(() => {
const rows = [] as Array<{ midi: number; isBlack: boolean; label: string; isC: boolean }>
const black = new Set([1, 3, 6, 8, 10])
for (let p = HIGH_NOTE; p >= LOW_NOTE; p -= 1) {
const name = midiToName(p)
const isC = p % 12 === 0
rows.push({ midi: p, isBlack: black.has(p % 12), label: name, isC })
}
return rows
}, [])
// Detect overlapping notes using optimized sweep line algorithm
const overlappingNoteIds = useMemo(() => {
if (notes.length < 2) return new Set<string>()
const overlapping = new Set<string>()
const sortedNotes = [...notes].sort((a, b) => a.start - b.start)
const EPSILON = 0.05 // Tolerance for floating point comparison
// Use a sliding window approach - more efficient for typical music data
// Active notes: notes that haven't ended yet
const activeNotes: typeof sortedNotes = []
for (const note of sortedNotes) {
// Remove notes that have ended before current note starts
while (activeNotes.length > 0) {
const firstActive = activeNotes[0]
const firstActiveEnd = firstActive.start + firstActive.duration
if (firstActiveEnd <= note.start + EPSILON) {
activeNotes.shift()
} else {
break
}
}
// Check overlap with remaining active notes
for (const activeNote of activeNotes) {
const activeEnd = activeNote.start + activeNote.duration
if (note.start < activeEnd - EPSILON) {
overlapping.add(activeNote.id)
overlapping.add(note.id)
}
}
// Add current note to active set (maintain sorted order by end time)
const noteEnd = note.start + note.duration
let insertIndex = activeNotes.length
for (let i = 0; i < activeNotes.length; i++) {
const aEnd = activeNotes[i].start + activeNotes[i].duration
if (noteEnd < aEnd) {
insertIndex = i
break
}
}
activeNotes.splice(insertIndex, 0, note)
}
return overlapping
}, [notes])
// Calculate visible area with buffer for smooth scrolling
const BUFFER_PX = 200 // Render notes slightly outside viewport for smooth scrolling
const visibleArea = useMemo(() => {
return {
left: Math.max(0, scrollLeft - BUFFER_PX),
right: scrollLeft + viewportWidth + BUFFER_PX,
top: Math.max(0, scrollTop - BUFFER_PX),
bottom: scrollTop + viewportHeight + BUFFER_PX,
}
}, [scrollLeft, scrollTop, viewportWidth, viewportHeight])
// Filter notes to only render visible ones (virtualization)
const visibleNotes = useMemo(() => {
return notes.filter(note => {
const noteSeconds = beatToSeconds(note.start)
const noteDurationSeconds = beatToSeconds(note.duration)
const noteLeft = noteSeconds * gridSecondWidth
const noteRight = noteLeft + noteDurationSeconds * gridSecondWidth
const noteTop = (HIGH_NOTE - note.midi) * rowHeight
const noteBottom = noteTop + rowHeight
// Check if note intersects with visible area
const horizontallyVisible = noteRight >= visibleArea.left && noteLeft <= visibleArea.right
const verticallyVisible = noteBottom >= visibleArea.top && noteTop <= visibleArea.bottom
return horizontallyVisible && verticallyVisible
})
}, [notes, visibleArea, gridSecondWidth, rowHeight, beatToSeconds])
// Calculate visible grid lines (virtualization)
const visibleGridLines = useMemo(() => {
const startSecond = Math.max(0, Math.floor(visibleArea.left / gridSecondWidth) - 1)
const endSecond = Math.ceil(visibleArea.right / gridSecondWidth) + 1
const startRow = Math.max(0, Math.floor(visibleArea.top / rowHeight) - 1)
const endRow = Math.min(totalRows, Math.ceil(visibleArea.bottom / rowHeight) + 1)
return {
horizontalLines: Array.from({ length: endRow - startRow + 1 }, (_, i) => startRow + i),
verticalLines: Array.from({ length: endSecond - startSecond + 1 }, (_, i) => startSecond + i),
}
}, [visibleArea, gridSecondWidth, rowHeight, totalRows])
const playheadSeconds = beatToSeconds(playhead)
// Selection drag handlers
const handleRulerPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!isSelectingRange) {
// Normal click to seek
const rect = event.currentTarget.getBoundingClientRect()
const x = event.clientX - rect.left + (rulerScrollRef.current?.scrollLeft ?? 0)
const seconds = x / gridSecondWidth
onSeek(secondsToBeat(seconds))
return
}
// Start selection drag
const rect = event.currentTarget.getBoundingClientRect()
const x = event.clientX - rect.left + (rulerScrollRef.current?.scrollLeft ?? 0)
const seconds = Math.max(0, x / gridSecondWidth)
selectionDragRef.current = {
startX: event.clientX,
startSeconds: seconds,
}
onSelectionChange?.(seconds, seconds)
const handleSelectionMove = (e: PointerEvent) => {
if (!selectionDragRef.current) return
const currentX = e.clientX - rect.left + (rulerScrollRef.current?.scrollLeft ?? 0)
const currentSeconds = Math.max(0, currentX / gridSecondWidth)
const start = Math.min(selectionDragRef.current.startSeconds, currentSeconds)
const end = Math.max(selectionDragRef.current.startSeconds, currentSeconds)
onSelectionChange?.(start, end)
}
const handleSelectionUp = () => {
selectionDragRef.current = null
window.removeEventListener('pointermove', handleSelectionMove)
window.removeEventListener('pointerup', handleSelectionUp)
}
window.addEventListener('pointermove', handleSelectionMove)
window.addEventListener('pointerup', handleSelectionUp)
}
return (
<div className="piano-shell">
{/* Ruler */}
<div className="ruler-shell">
<div className="ruler-spacer" style={{ width: PITCH_WIDTH, flexShrink: 0 }} />
<div
ref={rulerScrollRef}
className={`ruler-scroll ${isSelectingRange ? 'selecting' : ''}`}
onPointerDown={handleRulerPointerDown}
>
<div className="ruler" style={{ width: contentWidth }}>
{secondLabels.map((mark) => (
<div key={mark.left} className="measure-mark" style={{ left: mark.left }}>
<span>{mark.label}</span>
</div>
))}
{/* Selection range indicator */}
{selectionStart !== null && selectionEnd !== null && selectionEnd > selectionStart && (
<div
className="selection-range"
style={{
left: selectionStart * gridSecondWidth,
width: (selectionEnd - selectionStart) * gridSecondWidth
}}
/>
)}
{/* Ruler playhead indicator */}
<div
className="ruler-playhead"
style={{ left: playheadSeconds * gridSecondWidth }}
/>
</div>
</div>
</div>
{/* Main content area */}
<div className="roll-body">
{/* Piano keys - synced with vertical scroll */}
<div className="pitch-rail" style={{ width: PITCH_WIDTH }}>
<div
className="pitch-rail-inner"
style={{
transform: `translateY(${-scrollTop}px)`,
height: contentHeight
}}
>
{pitchRows.map((pitch) => (
<div
key={pitch.midi}
className={`pitch-cell ${pitch.isBlack ? 'pitch-black' : 'pitch-white'} ${pitch.isC ? 'pitch-c' : ''}`}
style={{ height: rowHeight, cursor: 'pointer' }}
onClick={() => onPlayNote?.(pitch.midi)}
onMouseDown={(e) => e.preventDefault()}
>
<span className="pitch-label">{pitch.label}</span>
</div>
))}
</div>
</div>
{/* Scrollable grid area */}
<div
ref={scrollContainerRef}
className="roll-grid"
onDoubleClick={handleGridDoubleClick}
>
<div
className="grid-content"
style={{
width: contentWidth,
height: contentHeight,
position: 'relative'
}}
>
{/* SVG Grid - virtualized for performance */}
<svg
className="grid-svg"
width={contentWidth}
height={contentHeight}
style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}
>
{/* Horizontal lines (pitch rows) - only visible ones */}
{visibleGridLines.horizontalLines.map(i => (
<line
key={`h-${i}`}
x1={visibleArea.left}
y1={i * rowHeight}
x2={visibleArea.right}
y2={i * rowHeight}
stroke="var(--grid-line-minor)"
strokeWidth={1}
/>
))}
{/* Vertical lines (seconds) - only visible ones */}
{visibleGridLines.verticalLines.map(i => (
<line
key={`v-${i}`}
x1={i * gridSecondWidth}
y1={visibleArea.top}
x2={i * gridSecondWidth}
y2={visibleArea.bottom}
stroke="var(--grid-line-minor)"
strokeWidth={1}
/>
))}
</svg>
{/* Selection range in grid */}
{selectionStart !== null && selectionEnd !== null && selectionEnd > selectionStart && (
<div
className="grid-selection-range"
style={{
left: selectionStart * gridSecondWidth,
width: (selectionEnd - selectionStart) * gridSecondWidth,
height: contentHeight
}}
/>
)}
{/* Playhead */}
<div
className="playhead"
style={{
left: playheadSeconds * gridSecondWidth,
height: contentHeight
}}
/>
{/* Notes - virtualized: only render visible notes */}
{visibleNotes.map((note) => {
const noteSeconds = beatToSeconds(note.start)
const noteDurationSeconds = beatToSeconds(note.duration)
const left = noteSeconds * gridSecondWidth
const top = (HIGH_NOTE - note.midi) * rowHeight
const noteWidthPx = Math.max(noteDurationSeconds * gridSecondWidth, 4)
const noteHeight = rowHeight - 2
const isOverlapping = overlappingNoteIds.has(note.id)
// Dynamic font size based on row height (base: 12px at 20px row height)
const fontSize = Math.max(10, Math.min(24, rowHeight * 0.6))
return (
<NoteChip
key={note.id}
note={note}
left={left}
top={top}
width={noteWidthPx}
height={noteHeight}
fontSize={fontSize}
isSelected={selectedId === note.id}
isOverlapping={isOverlapping}
onPointerDown={(event, mode) => startDrag(event, note, mode)}
onDoubleClick={(event) => {
event.stopPropagation()
onFocusLyric?.(note.id)
}}
/>
)
})}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,8 @@
// Base values used for scaling; actual runtime values are derived in components
export const BASE_GRID_SECOND_WIDTH = 80
export const BASE_ROW_HEIGHT = 20
export const PITCH_WIDTH = 60
// C-1 to C8 range (MIDI note numbers)
// LOW_NOTE = 0 to support SP markers (pitch=0) in some MIDI files
export const LOW_NOTE = 0 // C-1 (also supports pitch=0 for SP markers)
export const HIGH_NOTE = 108 // C8
@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: 'Space Grotesk', 'IBM Plex Sans', system-ui, sans-serif;
color: var(--text-primary);
background: radial-gradient(circle at 20% 20%, rgba(72, 228, 194, 0.08), transparent 35%),
radial-gradient(circle at 80% 0%, rgba(75, 100, 188, 0.24), transparent 40%),
#0f1528;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
:root[data-theme='light'] {
background: radial-gradient(circle at 20% 20%, rgba(63, 140, 255, 0.08), transparent 35%),
radial-gradient(circle at 80% 0%, rgba(75, 100, 188, 0.14), transparent 40%),
#f5f7fb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: transparent;
}
#root {
min-height: 100vh;
}
a {
color: inherit;
}
@@ -0,0 +1,224 @@
import { Midi } from '@tonejs/midi'
import { writeMidi } from 'midi-file'
import type { MidiData, MidiEvent } from 'midi-file'
import type { NoteEvent, ProjectSnapshot, TimeSignature } from '../types'
const DEFAULT_SIGNATURE: TimeSignature = [4, 4]
// Decode UTF-8 byte string (latin1 encoded) to proper Unicode string
// This matches: text.encode("latin1").decode("utf-8") in Python
function decodeUtf8ByteString(byteString: string): string {
try {
const bytes = new Uint8Array(byteString.length)
for (let i = 0; i < byteString.length; i++) {
bytes[i] = byteString.charCodeAt(i)
}
return new TextDecoder('utf-8').decode(bytes)
} catch {
return byteString
}
}
// Encode Unicode string to UTF-8 byte string (latin1 encoding)
// This matches: text.encode("utf-8").decode("latin1") in Python
function encodeUtf8ByteString(text: string): string {
const bytes = new TextEncoder().encode(text)
let output = ''
bytes.forEach((b) => {
output += String.fromCharCode(b)
})
return output
}
export async function importMidiFile(file: File): Promise<ProjectSnapshot> {
const buffer = await file.arrayBuffer()
return parseMidiBuffer(buffer)
}
export async function parseMidiBuffer(buffer: ArrayBuffer): Promise<ProjectSnapshot> {
const midi = new Midi(buffer)
const tempo = midi.header.tempos[0]?.bpm ?? 120
const timeSignature = (midi.header.timeSignatures[0]?.timeSignature as TimeSignature | undefined) ?? DEFAULT_SIGNATURE
// Merge notes from all tracks and sort by ticks then by midi (for stable ordering)
const allNotes = midi.tracks
.flatMap(t => t.notes)
.sort((a, b) => a.ticks - b.ticks || a.midi - b.midi)
// Get lyrics from header.meta and sort by ticks
const lyricEvents = midi.header.meta
.filter((event) => event.type === 'lyrics')
.sort((a, b) => a.ticks - b.ticks)
// Match lyrics to notes by tick position
// Each lyric should be consumed by exactly one note at the same tick
const lyricsByTick = new Map<number, string[]>()
for (const event of lyricEvents) {
const existing = lyricsByTick.get(event.ticks) || []
existing.push(decodeUtf8ByteString(event.text))
lyricsByTick.set(event.ticks, existing)
}
// Track which lyrics have been used at each tick position
const usedLyricIndices = new Map<number, number>()
const notes: NoteEvent[] = allNotes.map((note, index) => {
const beat = note.ticks / midi.header.ppq
const durationBeats = note.durationTicks / midi.header.ppq
let lyric = ''
// First try exact tick match
const lyricsAtTick = lyricsByTick.get(note.ticks)
if (lyricsAtTick && lyricsAtTick.length > 0) {
const usedIndex = usedLyricIndices.get(note.ticks) || 0
if (usedIndex < lyricsAtTick.length) {
lyric = lyricsAtTick[usedIndex]
usedLyricIndices.set(note.ticks, usedIndex + 1)
}
}
// If no exact match, try nearby ticks (within small tolerance)
if (!lyric) {
const tolerance = midi.header.ppq / 100 // Very small tolerance
for (const [tick, lyrics] of lyricsByTick.entries()) {
if (Math.abs(tick - note.ticks) <= tolerance) {
const usedIndex = usedLyricIndices.get(tick) || 0
if (usedIndex < lyrics.length) {
lyric = lyrics[usedIndex]
usedLyricIndices.set(tick, usedIndex + 1)
break
}
}
}
}
return {
id: `${index}-${note.midi}-${Math.round(note.ticks)}`,
midi: note.midi,
start: beat,
duration: Math.max(durationBeats, 0.0625),
velocity: note.velocity,
lyric,
}
})
return { tempo, timeSignature, notes, ppq: midi.header.ppq }
}
// Used to add absoluteTime property for sorting
type WithAbsoluteTime<T> = T & { absoluteTime: number }
export function exportMidi(snapshot: ProjectSnapshot): Blob {
const ppq = snapshot.ppq ?? 480 // Use original ppq if available, otherwise default to 480
const microsecondsPerBeat = Math.round(60000000 / snapshot.tempo) // Convert BPM to microseconds per beat
// Sort notes by start time, then by midi for stable ordering
const sortedNotes = [...snapshot.notes].sort((a, b) => a.start - b.start || a.midi - b.midi)
// Build events for a single track containing both lyrics and notes
// Event order at same tick: note_off (0) < lyrics (1) < note_on (2)
// This matches meta.py's tg2midi implementation
const events: Array<WithAbsoluteTime<MidiEvent>> = []
// Add all note events and their corresponding lyrics
sortedNotes.forEach((note) => {
const startTicks = Math.round(note.start * ppq)
const endTicks = Math.round((note.start + note.duration) * ppq)
const velocity = Math.round(note.velocity * 127)
// Add lyric event at the same tick as note_on (but will be sorted before it)
const lyricText = note.lyric ?? ''
const encodedLyric = encodeUtf8ByteString(lyricText)
// Lyric event - sort key 1 (after note_off, before note_on)
events.push({
absoluteTime: startTicks,
deltaTime: 0,
meta: true,
type: 'lyrics',
text: encodedLyric,
_sortKey: 1,
} as WithAbsoluteTime<MidiEvent> & { _sortKey: number })
// Note on event - sort key 2 (after lyrics)
events.push({
absoluteTime: startTicks,
deltaTime: 0,
type: 'noteOn',
channel: 0,
noteNumber: note.midi,
velocity: velocity,
_sortKey: 2,
} as WithAbsoluteTime<MidiEvent> & { _sortKey: number })
// Note off event - sort key 0 (before everything at same tick)
events.push({
absoluteTime: endTicks,
deltaTime: 0,
type: 'noteOff',
channel: 0,
noteNumber: note.midi,
velocity: 0,
_sortKey: 0,
} as WithAbsoluteTime<MidiEvent> & { _sortKey: number })
})
// Sort events by absoluteTime, then by _sortKey
events.sort((a, b) => {
const aKey = (a as { _sortKey?: number })._sortKey ?? 1
const bKey = (b as { _sortKey?: number })._sortKey ?? 1
return a.absoluteTime - b.absoluteTime || aKey - bKey
})
// Convert absolute time to delta time
let lastTick = 0
events.forEach(event => {
event.deltaTime = event.absoluteTime - lastTick
lastTick = event.absoluteTime
delete (event as { absoluteTime?: number }).absoluteTime
delete (event as { _sortKey?: number })._sortKey
})
// Build the MIDI track with header events
const track: MidiEvent[] = [
// Set tempo
{
deltaTime: 0,
meta: true,
type: 'setTempo',
microsecondsPerBeat: microsecondsPerBeat,
},
// Time signature
{
deltaTime: 0,
meta: true,
type: 'timeSignature',
numerator: snapshot.timeSignature[0],
denominator: snapshot.timeSignature[1],
metronome: 24,
thirtyseconds: 8,
},
// All note and lyric events
...events,
// End of track
{
deltaTime: 0,
meta: true,
type: 'endOfTrack',
},
]
// Build MIDI data structure
const midiData: MidiData = {
header: {
format: 0, // Single track format (type 0)
numTracks: 1,
ticksPerBeat: ppq,
},
tracks: [track],
}
const bytes = writeMidi(midiData)
return new Blob([new Uint8Array(bytes)], { type: 'audio/midi' })
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,78 @@
import { nanoid } from 'nanoid'
import { create } from 'zustand'
import type { NoteEvent, TimeSignature } from '../types'
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max)
export type MidiStore = {
tempo: number
timeSignature: TimeSignature
notes: NoteEvent[]
selectedId: string | null
playhead: number
ppq: number | undefined // Ticks per quarter note (for preserving original MIDI timing)
addNote: (partial?: Partial<NoteEvent>) => NoteEvent
updateNote: (id: string, partial: Partial<NoteEvent>) => void
removeNote: (id: string) => void
setNotes: (notes: NoteEvent[]) => void
setTempo: (tempo: number) => void
setTimeSignature: (sig: TimeSignature) => void
setPpq: (ppq: number | undefined) => void
select: (id: string | null) => void
setLyric: (id: string, lyric: string) => void
setPlayhead: (beat: number) => void
clear: () => void
}
const defaultNotes: NoteEvent[] = [
{ id: nanoid(), midi: 64, start: 0, duration: 1.5, velocity: 0.9, lyric: 'la' },
{ id: nanoid(), midi: 67, start: 1.5, duration: 1.5, velocity: 0.85, lyric: 'na' },
{ id: nanoid(), midi: 69, start: 3, duration: 2, velocity: 0.8, lyric: 'ah' },
]
export const useMidiStore = create<MidiStore>((set) => ({
tempo: 110,
timeSignature: [4, 4],
notes: defaultNotes,
selectedId: null,
playhead: 0,
ppq: undefined,
addNote: (partial = {}) => {
const note: NoteEvent = {
id: nanoid(),
midi: partial.midi ?? 64,
start: partial.start ?? 0,
duration: partial.duration ?? 1,
velocity: clamp(partial.velocity ?? 0.85, 0, 1),
lyric: partial.lyric ?? '',
}
set((state) => ({ notes: [...state.notes, note] }))
return note
},
updateNote: (id, partial) => {
set((state) => ({
notes: state.notes.map((note) =>
note.id === id
? {
...note,
...partial,
duration: Math.max(partial.duration ?? note.duration, 0.0625),
}
: note,
),
}))
},
removeNote: (id) => set((state) => ({ notes: state.notes.filter((n) => n.id !== id) })),
setNotes: (notes) => set(() => ({ notes })),
setTempo: (tempo) => set(() => ({ tempo: clamp(tempo, 30, 240) })),
setTimeSignature: (sig) => set(() => ({ timeSignature: sig })),
setPpq: (ppq) => set(() => ({ ppq })),
select: (id) => set(() => ({ selectedId: id })),
setLyric: (id, lyric) =>
set((state) => ({
notes: state.notes.map((note) => (note.id === id ? { ...note, lyric } : note)),
})),
setPlayhead: (beat) => set(() => ({ playhead: Math.max(beat, 0) })),
clear: () => set(() => ({ notes: [], selectedId: null })),
}))
+17
View File
@@ -0,0 +1,17 @@
export type NoteEvent = {
id: string
midi: number
start: number // in beats
duration: number // in beats
velocity: number
lyric: string
}
export type TimeSignature = [number, number]
export type ProjectSnapshot = {
tempo: number
timeSignature: TimeSignature
notes: NoteEvent[]
ppq?: number // Ticks per quarter note (for preserving original MIDI timing)
}
@@ -0,0 +1,33 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}'],
theme: {
extend: {
fontFamily: {
display: ['"Space Grotesk"', '"IBM Plex Sans"', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
},
colors: {
ink: {
50: '#f4f7fb',
100: '#dfe7f5',
200: '#beceec',
300: '#95addf',
400: '#6a87ce',
500: '#4b64bc',
600: '#3b4ea7',
700: '#32418a',
800: '#2c376f',
900: '#262f5c',
},
ember: '#ff7043',
mint: '#48e4c2',
},
boxShadow: {
panel: '0 14px 35px rgba(0, 0, 0, 0.25)',
},
},
},
plugins: [],
}
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})