Add one-line score visualizer with 3D orb and MP4 export
- Verovio single-system engraving, MuseScore audio/MIDI from the same source - React + Remotion player with live camera, colour and orb controls - Express pipeline, render queue and local H.264/AAC export - MIT license plus third-party notices for the reference skill
This commit is contained in:
commit
a237a7c604
38 files changed
+13958
No files matched your search
+23
@@ -0,0 +1,23 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Runtime data: uploaded scores, MuseScore exports, rendered MP4s, check frames
|
||||
data/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Local environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor / OS noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Music Visualizer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Music Visualizer
|
||||
|
||||
把一份 MusicXML / MXL / MIDI 谱面变成横向滚动、单行不换页的音乐视频:谱面始终只有一行,发光小球沿真实符头跳跃,支持实时调参并用 Remotion 在本地导出 MP4。
|
||||
|
||||
视觉与运动参数参考了 [Dancing Notes](https://github.com/SyanChen7/dancing-notes)(MIT),本项目重写了排版、音频、对齐、滚动和 Web 端流水线。
|
||||
|
||||
## 功能
|
||||
|
||||
- **单行谱面**:Verovio `breaks: none` 生成一条超宽 system,按播放进度横向滚动到结尾,不换行、不翻页。
|
||||
- **音画同源**:默认调用本机 MuseScore 4,从同一份 MusicXML 生成 WAV 和 MIDI;动画时间来自 MIDI,谱面符头坐标来自 Verovio SVG。
|
||||
- **发光小球**:每个声部一颗球,沿符头连续跳跃;包含起跳、抛物线、落点光环、拖尾和纸面光晕。
|
||||
- **反复段**:按演奏顺序回到前面的谱面位置,滚动也会短暂回退。
|
||||
- **实时预览**:生成一次后,画面高度、滚动提前量、3D 视角、颜色、光晕、小球大小、标题显示等参数会立即反映在 Remotion Player 中,不需要重新跑流水线。
|
||||
- **3D 倾斜视角**:可调俯仰、滚转、偏航、透视距离和缩放,内置平视 / 轻斜 / 电影 / 强透视预设。
|
||||
- **导出**:使用 `@remotion/renderer` 在本地渲染 1080p H.264 + AAC MP4。导出前会把当前实时参数写回任务,保证视频与预览一致。
|
||||
- **输入**:MusicXML、MXL(压缩包)、MIDI。MIDI 会先由 MuseScore 识别成 MusicXML。
|
||||
- **可选音频**:可以上传自己的 WAV / MP3 / M4A / OGG / FLAC 替代自动合成音频;动画仍使用 MIDI/谱面时间轴。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js 22+(推荐 24)
|
||||
- MuseScore 4(自动合成音频、MIDI 转谱需要)
|
||||
- Chrome 或 Edge(Remotion 渲染会优先使用本机浏览器,避免下载 Headless Shell)
|
||||
- Windows PowerShell 下开发即可;其他平台同样适用
|
||||
|
||||
MuseScore 默认从常见安装路径查找,也可以显式指定:
|
||||
|
||||
```powershell
|
||||
$env:MUSESCORE_PATH = "C:\Program Files\MuseScore 4\bin\MuseScore4.exe"
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- 前端:<http://127.0.0.1:5173>
|
||||
- API:<http://127.0.0.1:3001>
|
||||
- Vite 会把 `/api`、`/jobs`、`/demo` 代理到服务端。
|
||||
|
||||
## 生产运行
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
生产模式由 Express 同时提供 API 和 `dist/client` 静态文件,默认端口 `3001`。
|
||||
|
||||
可配置环境变量:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `PORT` | `3001` | 服务端口 |
|
||||
| `PUBLIC_BASE_URL` | `http://127.0.0.1:${PORT}` | Remotion 渲染时访问 SVG / 音频的公开地址 |
|
||||
| `DATA_DIR` | `data` | 任务与产物目录 |
|
||||
| `MUSESCORE_PATH` | 自动查找 | MuseScore 4 可执行文件 |
|
||||
| `REMOTION_BROWSER_EXECUTABLE` / `BROWSER_EXECUTABLE` | 自动查找 | 指定 Chrome / Edge 可执行文件 |
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 拖入 MusicXML / MXL / MIDI;或者点“加载示例曲”。
|
||||
2. 可选填写标题、副标题、作曲者;留空时读取谱面信息。
|
||||
3. 选择画幅、帧率、谱面高度、3D 视角和颜色。
|
||||
4. 点“生成预览”,等待解析、MuseScore 合成、Verovio 排版和 MIDI 对齐完成。
|
||||
5. 在右侧 Remotion Player 中播放、拖动时间轴、实时调整左侧画面参数。
|
||||
6. 点“导出 MP4”,服务端会先保存当前参数,再在本地渲染视频并给出下载链接。
|
||||
|
||||
> 首次生成需要先跑完一次流水线;之后调整视觉参数都是即时预览。修改谱面/音频/标题文字本身需要重新生成,标题、副标题、作曲者的非空修改也会在导出前写入。
|
||||
|
||||
### 开发时改渲染代码
|
||||
|
||||
服务端第一次导出时会把 Remotion 项目打包一次,并缓存在内存里。缓存带源码指纹:改动 `src/remotion/`、`src/shared/` 或 `public/` 后再次导出会自动重新打包(日志会打印 `[render] sources changed, rebuilding the Remotion bundle`),不需要重启服务,也不会出现“预览是新的、导出还是旧画面”。
|
||||
|
||||
## 3D 视角
|
||||
|
||||
“3D 视角”把纸面、谱面、纸面光和 Canvas 小球放进同一个 CSS 3D 变换平面,因此小球和符头会一起倾斜、透视,不会错位。
|
||||
|
||||
- **俯仰角**:数值越大,谱面上方越向后倒。
|
||||
- **滚转角**:负值让谱面右侧抬高,形成参考视频里的斜线。
|
||||
- **偏航角**:左右转向;过大时透视会明显压缩。
|
||||
- **透视距离**:越小透视越强,越大越接近正交投影。
|
||||
- **视角缩放**:倾斜后用于补足画面;谱面被裁切或留白时优先调它。
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/health` | MuseScore / 浏览器 / FFmpeg 状态 |
|
||||
| `GET` | `/api/jobs` | 最近任务 |
|
||||
| `POST` | `/api/jobs` | 创建任务,`multipart/form-data`:`score`、可选 `audio`、`settings`(JSON)、`title`、`subtitle`、`composer` |
|
||||
| `GET` | `/api/jobs/:id` | 任务状态与预览数据 |
|
||||
| `PATCH` | `/api/jobs/:id/settings` | 保存实时预览参数,并重新计算滚动关键帧 |
|
||||
| `POST` | `/api/jobs/:id/render` | 加入渲染队列 |
|
||||
| `GET` | `/api/jobs/:id/video` | 下载 MP4 |
|
||||
|
||||
## 验证脚本
|
||||
|
||||
```powershell
|
||||
npx tsx scripts/check-demo.ts
|
||||
```
|
||||
|
||||
会对内置示例跑解析、Verovio 单行排版、MuseScore 导出、MIDI 映射,并输出 `data/check/score.json`。也可以直接用 Remotion CLI 验证单帧:
|
||||
|
||||
```powershell
|
||||
# 用某个已完成任务的完整设置生成单帧 props(含按当前画幅重算的滚动关键帧)
|
||||
npx tsx scripts/props-from-job.ts <jobId> data/check/p-job.json
|
||||
|
||||
npx remotion still src/remotion/index.ts MusicScroll data/check/frame.png `
|
||||
--frame=300 --props=data/check/p-job.json `
|
||||
--browser-executable="C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||
```
|
||||
|
||||
`<jobId>` 是 `data/jobs/` 下的目录名,`props-from-job.ts` 会读取该任务的 `score.json` 与 `job.json`,因此导出的单帧和网页里看到的一致。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 反复、ending、D.C./D.S. 等跳转按谱面结构做近似展开;复杂跳转建议先在 MuseScore 中展开后导出 MusicXML。
|
||||
- 多声部/多谱表会渲染成同一条 system 上的多组五线谱,不是多行总谱。
|
||||
- 歌词、和弦符号、指法、tab 等会随 Verovio SVG 显示,但不参与小球坐标。
|
||||
- 上传音频时,动画时间仍来自 MIDI/谱面;录音速度与谱面差异过大时会渐进漂移,可用“音频偏移”做整体校准。
|
||||
- 视频最长 20 分钟,上传文件最大 30MB。
|
||||
- 渲染是 CPU 本地渲染,长曲会比较慢;可降低 fps 或谱面分辨率来加速。
|
||||
|
||||
## 许可与归属
|
||||
|
||||
本项目代码以 MIT 许可发布。
|
||||
|
||||
视觉与运动设计参考 **Dancing Notes**,Copyright (c) 2026 言说新事 (SyanChen7),MIT License。原版权声明与许可全文见 [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md)。
|
||||
|
||||
使用的第三方组件包括 Remotion、Verovio、MuseScore、React、Vite、zod 等,请分别遵守其许可证。Remotion 的商用许可要求请以官方说明为准。
|
||||
@@ -0,0 +1,38 @@
|
||||
# Third-party notices
|
||||
|
||||
## Dancing Notes
|
||||
|
||||
This project's visual language, ball motion and paper/light treatment are adapted from
|
||||
[Dancing Notes](https://github.com/SyanChen7/dancing-notes). The original project is
|
||||
licensed under the MIT License:
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 言说新事 (SyanChen7)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## Other components
|
||||
|
||||
- [Remotion](https://www.remotion.dev/) — video rendering. See its license and commercial terms.
|
||||
- [Verovio](https://www.verovio.org/) — MusicXML engraving.
|
||||
- [MuseScore](https://musescore.org/) — local audio/MIDI export.
|
||||
- [React](https://react.dev/), [Vite](https://vite.dev/), [zod](https://zod.dev/), [Express](https://expressjs.com/) and other npm dependencies — see their respective licenses.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Music Visualizer · 单行乐谱</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+6404
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "music-visualizer",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "One-line scrolling music score visualizer with ball animation and MP4 export.",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n server,client -c blue,magenta \"npm:dev:server\" \"npm:dev:client\"",
|
||||
"dev:server": "tsx watch src/server/index.ts",
|
||||
"dev:client": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"start": "cross-env NODE_ENV=production tsx src/server/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"remotion:studio": "remotion studio src/remotion/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/bundler": "4.0.538",
|
||||
"@remotion/cli": "4.0.538",
|
||||
"@remotion/player": "4.0.538",
|
||||
"@remotion/renderer": "4.0.538",
|
||||
"@tonejs/midi": "^2.0.28",
|
||||
"@xmldom/xmldom": "^0.8.11",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"fflate": "^0.8.2",
|
||||
"music-metadata": "^11.9.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"remotion": "4.0.538",
|
||||
"svg-path-bounds": "^1.0.2",
|
||||
"verovio": "6.3.0",
|
||||
"zod": "4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/web": "0.0.166",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
|
||||
<score-partwise version="4.0">
|
||||
<work><work-title>Morning Window</work-title></work>
|
||||
<identification>
|
||||
<creator type="composer">Demo · Original</creator>
|
||||
<encoding><software>music-visualizer demo generator</software></encoding>
|
||||
</identification>
|
||||
<part-list><score-part id="P1"><part-name>Piano</part-name></score-part></part-list>
|
||||
<part id="P1">
|
||||
<measure number="1">
|
||||
<attributes>
|
||||
<divisions>4</divisions>
|
||||
<key><fifths>0</fifths></key>
|
||||
<time><beats>4</beats><beat-type>4</beat-type></time>
|
||||
<staves>2</staves>
|
||||
<clef number="1"><sign>G</sign><line>2</line></clef>
|
||||
<clef number="2"><sign>F</sign><line>4</line></clef>
|
||||
</attributes>
|
||||
<direction placement="above">
|
||||
<direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>96</per-minute></metronome></direction-type>
|
||||
<sound tempo="96"/>
|
||||
</direction>
|
||||
<barline location="left"><repeat direction="forward"/></barline>
|
||||
<note><pitch><step>C</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>A</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>C</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="2">
|
||||
<note><pitch><step>F</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>D</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>C</step><octave>5</octave></pitch><duration>8</duration><voice>1</voice><type>half</type><staff>1</staff></note>
|
||||
<note><chord/><pitch><step>E</step><octave>5</octave></pitch><duration>8</duration><voice>1</voice><type>half</type><staff>1</staff></note>
|
||||
<note><chord/><pitch><step>G</step><octave>5</octave></pitch><duration>8</duration><voice>1</voice><type>half</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>F</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>F</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="3">
|
||||
<note><rest/><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>A</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>B</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>B</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="4">
|
||||
<note><pitch><step>D</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>C</step><octave>5</octave></pitch><duration>8</duration><voice>1</voice><type>half</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>C</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<barline location="right"><repeat direction="backward" times="2"/></barline>
|
||||
</measure>
|
||||
<measure number="5">
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>A</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>C</step><octave>6</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>B</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="6">
|
||||
<note><pitch><step>A</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>D</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>F</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>F</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="7">
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>D</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>C</step><octave>5</octave></pitch><duration>8</duration><voice>1</voice><type>half</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>B</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>B</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="8">
|
||||
<note><pitch><step>D</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>F</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>8</duration><tie type="start"/><voice>1</voice><type>half</type><notations><tied type="start"/></notations><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>C</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="9">
|
||||
<direction placement="above">
|
||||
<direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>104</per-minute></metronome></direction-type>
|
||||
<sound tempo="104"/>
|
||||
</direction>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>8</duration><tie type="stop"/><voice>1</voice><type>half</type><notations><tied type="stop"/></notations><staff>1</staff></note>
|
||||
<note><pitch><step>A</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="10">
|
||||
<note><grace/><pitch><step>A</step><octave>5</octave></pitch><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>D</step><octave>5</octave></pitch><duration>8</duration><voice>1</voice><type>half</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>F</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>F</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="11">
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>A</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>C</step><octave>6</octave></pitch><duration>8</duration><voice>1</voice><type>half</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>B</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>B</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="12">
|
||||
<note><pitch><step>B</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>A</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>8</duration><voice>1</voice><type>half</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>C</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="13">
|
||||
<note><pitch><step>C</step><octave>6</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>B</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>A</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>E</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="14">
|
||||
<note><pitch><step>F</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>D</step><octave>5</octave></pitch><duration>2</duration><voice>1</voice><type>eighth</type><staff>1</staff></note>
|
||||
<note><pitch><step>C</step><octave>5</octave></pitch><duration>8</duration><voice>1</voice><type>half</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>F</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>F</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>A</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="15">
|
||||
<note><pitch><step>D</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>E</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice><type>quarter</type><staff>1</staff></note>
|
||||
<note><pitch><step>G</step><octave>5</octave></pitch><duration>8</duration><voice>1</voice><type>half</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>B</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>G</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>B</step><octave>3</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration><voice>5</voice><type>eighth</type><staff>2</staff></note>
|
||||
</measure>
|
||||
<measure number="16">
|
||||
<note><pitch><step>C</step><octave>5</octave></pitch><duration>16</duration><voice>1</voice><type>whole</type><staff>1</staff></note>
|
||||
<note><chord/><pitch><step>E</step><octave>5</octave></pitch><duration>16</duration><voice>1</voice><type>whole</type><staff>1</staff></note>
|
||||
<note><chord/><pitch><step>G</step><octave>5</octave></pitch><duration>16</duration><voice>1</voice><type>whole</type><staff>1</staff></note>
|
||||
<backup><duration>16</duration></backup>
|
||||
<note><pitch><step>C</step><octave>3</octave></pitch><duration>16</duration><voice>5</voice><type>whole</type><staff>2</staff></note>
|
||||
<note><chord/><pitch><step>E</step><octave>3</octave></pitch><duration>16</duration><voice>5</voice><type>whole</type><staff>2</staff></note>
|
||||
<note><chord/><pitch><step>G</step><octave>3</octave></pitch><duration>16</duration><voice>5</voice><type>whole</type><staff>2</staff></note>
|
||||
</measure>
|
||||
</part>
|
||||
</score-partwise>
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {parseMusicXml, buildPerformanceOrder} from '../src/server/musicxml.js';
|
||||
import {engraveOneLine} from '../src/server/engrave.js';
|
||||
import {exportAudioAndMidi} from '../src/server/musescore.js';
|
||||
import {parseMidiFile} from '../src/server/midi.js';
|
||||
import {buildEvents} from '../src/server/events.js';
|
||||
import {readAudioDuration} from '../src/server/audio.js';
|
||||
import {DEFAULT_SETTINGS} from '../src/shared/types.js';
|
||||
|
||||
const demo = path.resolve('public/demo/demo.musicxml');
|
||||
const outDir = path.resolve('data/check');
|
||||
fs.mkdirSync(outDir, {recursive: true});
|
||||
|
||||
const parsed = parseMusicXml(fs.readFileSync(demo));
|
||||
console.log('title', parsed.title, '| composer', parsed.composer);
|
||||
console.log('parts', parsed.parts.length, 'measures', parsed.measures.length, 'notes', parsed.allNotes.length);
|
||||
console.log('unsupported', parsed.unsupported);
|
||||
console.log('warnings', parsed.warnings.slice(0, 5));
|
||||
|
||||
const performance = buildPerformanceOrder(parsed.measures, []);
|
||||
console.log('performance order length', performance.order.length);
|
||||
console.log('order', performance.order.map((i) => i + 1).join(', '));
|
||||
|
||||
const engraved = await engraveOneLine(parsed.annotatedXml);
|
||||
fs.writeFileSync(path.join(outDir, 'score.svg'), engraved.svg);
|
||||
console.log('svg', engraved.geometry.width, 'x', engraved.geometry.height, 'geometry notes', engraved.geometry.noteCount);
|
||||
console.log('measure times', engraved.measureStartMs.size, 'note times', engraved.noteTimes.size);
|
||||
|
||||
console.time('musescore');
|
||||
const {wav, midi} = await exportAudioAndMidi(demo, outDir);
|
||||
console.timeEnd('musescore');
|
||||
console.log('wav', fs.statSync(wav).size, 'midi', fs.statSync(midi).size);
|
||||
|
||||
const audioDuration = await readAudioDuration(wav);
|
||||
const parsedMidi = parseMidiFile(midi);
|
||||
console.log('audioDuration', audioDuration, 'midi notes', parsedMidi.notes.length, 'midi duration', parsedMidi.duration);
|
||||
|
||||
const built = buildEvents({
|
||||
parsed,
|
||||
engrave: engraved,
|
||||
midi: parsedMidi,
|
||||
performanceOrder: performance.order,
|
||||
audioDuration,
|
||||
settings: DEFAULT_SETTINGS,
|
||||
});
|
||||
console.log('events', built.events.length, 'scroll keys', built.scroll.length);
|
||||
console.log('meta', JSON.stringify({...built.meta, warnings: built.meta.warnings.slice(0, 8)}, null, 2));
|
||||
console.log('first events', built.events.slice(0, 8));
|
||||
console.log('last events', built.events.slice(-5));
|
||||
console.log('scroll head', built.scroll.slice(0, 6));
|
||||
console.log('scroll tail', built.scroll.slice(-4));
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, 'score.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
meta: built.meta,
|
||||
events: built.events,
|
||||
activations: built.activations,
|
||||
scroll: built.scroll,
|
||||
svgUrl: '/jobs/check/score.svg',
|
||||
audioUrl: '/jobs/check/audio.wav',
|
||||
settings: DEFAULT_SETTINGS,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log('wrote', path.join(outDir, 'score.json'));
|
||||
@@ -0,0 +1,292 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const divisions = 4;
|
||||
const typeFor = (d) =>
|
||||
({16: 'whole', 8: 'half', 4: 'quarter', 2: 'eighth', 1: '16th'})[d] ?? 'quarter';
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
|
||||
<score-partwise version="4.0">
|
||||
<work><work-title>Morning Window</work-title></work>
|
||||
<identification>
|
||||
<creator type="composer">Demo · Original</creator>
|
||||
<encoding><software>music-visualizer demo generator</software></encoding>
|
||||
</identification>
|
||||
<part-list><score-part id="P1"><part-name>Piano</part-name></score-part></part-list>
|
||||
<part id="P1">
|
||||
`;
|
||||
|
||||
function note(step, octave, duration, opts = {}) {
|
||||
const {chord = false, staff = 1, voice = '1', tie, grace = false} = opts;
|
||||
const parts = [];
|
||||
if (grace) parts.push('<grace/>');
|
||||
if (chord) parts.push('<chord/>');
|
||||
parts.push(`<pitch><step>${step}</step><octave>${octave}</octave></pitch>`);
|
||||
if (!grace) parts.push(`<duration>${duration}</duration>`);
|
||||
if (tie === 'start') parts.push('<tie type="start"/>');
|
||||
if (tie === 'stop') parts.push('<tie type="stop"/>');
|
||||
parts.push(`<voice>${voice}</voice>`);
|
||||
if (!grace) parts.push(`<type>${typeFor(duration)}</type>`);
|
||||
else parts.push('<type>eighth</type>');
|
||||
if (tie) {
|
||||
parts.push(`<notations><tied type="${tie}"/></notations>`);
|
||||
}
|
||||
parts.push(`<staff>${staff}</staff>`);
|
||||
return ` <note>${parts.join('')}</note>\n`;
|
||||
}
|
||||
|
||||
function rest(duration, opts = {}) {
|
||||
const {staff = 1, voice = '1'} = opts;
|
||||
return ` <note><rest/><duration>${duration}</duration><voice>${voice}</voice><type>${typeFor(duration)}</type><staff>${staff}</staff></note>\n`;
|
||||
}
|
||||
|
||||
function leftArpeggio(notes) {
|
||||
// 8 eighth notes = 16 divisions
|
||||
let out = '';
|
||||
for (const [step, octave] of notes) {
|
||||
out += note(step, octave, 2, {staff: 2, voice: '5'});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const measures = [
|
||||
// 1
|
||||
{
|
||||
right:
|
||||
note('C', 5, 4) +
|
||||
note('E', 5, 2) +
|
||||
note('G', 5, 2) +
|
||||
note('A', 5, 4) +
|
||||
note('G', 5, 4),
|
||||
left: leftArpeggio([
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
]),
|
||||
},
|
||||
// 2
|
||||
{
|
||||
right:
|
||||
note('F', 5, 4) +
|
||||
note('E', 5, 2) +
|
||||
note('D', 5, 2) +
|
||||
note('C', 5, 8) +
|
||||
note('E', 5, 8, {chord: true}) +
|
||||
note('G', 5, 8, {chord: true}),
|
||||
left: leftArpeggio([
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
]),
|
||||
},
|
||||
// 3
|
||||
{
|
||||
right:
|
||||
rest(4) +
|
||||
note('G', 5, 2) +
|
||||
note('A', 5, 2) +
|
||||
note('G', 5, 4) +
|
||||
note('E', 5, 4),
|
||||
left: leftArpeggio([
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
]),
|
||||
},
|
||||
// 4
|
||||
{
|
||||
right:
|
||||
note('D', 5, 4) +
|
||||
note('E', 5, 4) +
|
||||
note('C', 5, 8),
|
||||
left: leftArpeggio([
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
]),
|
||||
},
|
||||
// 5
|
||||
{
|
||||
right:
|
||||
note('E', 5, 4) +
|
||||
note('G', 5, 2) +
|
||||
note('A', 5, 2) +
|
||||
note('C', 6, 4) +
|
||||
note('B', 5, 4),
|
||||
left: leftArpeggio([
|
||||
['A', 3], ['E', 4], ['C', 4], ['E', 4],
|
||||
['A', 3], ['E', 4], ['C', 4], ['E', 4],
|
||||
]),
|
||||
},
|
||||
// 6
|
||||
{
|
||||
right:
|
||||
note('A', 5, 4) +
|
||||
note('G', 5, 2) +
|
||||
note('E', 5, 2) +
|
||||
note('D', 5, 4) +
|
||||
note('E', 5, 4),
|
||||
left: leftArpeggio([
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
]),
|
||||
},
|
||||
// 7
|
||||
{
|
||||
right:
|
||||
note('G', 5, 4) +
|
||||
note('E', 5, 2) +
|
||||
note('D', 5, 2) +
|
||||
note('C', 5, 8),
|
||||
left: leftArpeggio([
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
]),
|
||||
},
|
||||
// 8
|
||||
{
|
||||
right:
|
||||
note('D', 5, 4) +
|
||||
note('E', 5, 2) +
|
||||
note('F', 5, 2) +
|
||||
note('G', 5, 8, {tie: 'start'}),
|
||||
left: leftArpeggio([
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
]),
|
||||
},
|
||||
// 9
|
||||
{
|
||||
right:
|
||||
note('G', 5, 8, {tie: 'stop'}) +
|
||||
note('A', 5, 4) +
|
||||
note('G', 5, 4),
|
||||
left: leftArpeggio([
|
||||
['A', 3], ['E', 4], ['C', 4], ['E', 4],
|
||||
['A', 3], ['E', 4], ['C', 4], ['E', 4],
|
||||
]),
|
||||
},
|
||||
// 10
|
||||
{
|
||||
right:
|
||||
note('A', 5, 2, {grace: true}) +
|
||||
note('G', 5, 4) +
|
||||
note('E', 5, 4) +
|
||||
note('D', 5, 8),
|
||||
left: leftArpeggio([
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
]),
|
||||
},
|
||||
// 11
|
||||
{
|
||||
right:
|
||||
note('E', 5, 4) +
|
||||
note('G', 5, 2) +
|
||||
note('A', 5, 2) +
|
||||
note('C', 6, 8),
|
||||
left: leftArpeggio([
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
]),
|
||||
},
|
||||
// 12
|
||||
{
|
||||
right:
|
||||
note('B', 5, 4) +
|
||||
note('A', 5, 4) +
|
||||
note('G', 5, 8),
|
||||
left: leftArpeggio([
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
]),
|
||||
},
|
||||
// 13
|
||||
{
|
||||
right:
|
||||
note('C', 6, 4) +
|
||||
note('B', 5, 2) +
|
||||
note('A', 5, 2) +
|
||||
note('G', 5, 4) +
|
||||
note('E', 5, 4),
|
||||
left: leftArpeggio([
|
||||
['A', 3], ['E', 4], ['C', 4], ['E', 4],
|
||||
['A', 3], ['E', 4], ['C', 4], ['E', 4],
|
||||
]),
|
||||
},
|
||||
// 14
|
||||
{
|
||||
right:
|
||||
note('F', 5, 4) +
|
||||
note('E', 5, 2) +
|
||||
note('D', 5, 2) +
|
||||
note('C', 5, 8),
|
||||
left: leftArpeggio([
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
]),
|
||||
},
|
||||
// 15
|
||||
{
|
||||
right:
|
||||
note('D', 5, 4) +
|
||||
note('E', 5, 4) +
|
||||
note('G', 5, 8),
|
||||
left: leftArpeggio([
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
]),
|
||||
},
|
||||
// 16
|
||||
{
|
||||
right:
|
||||
note('C', 5, 16) +
|
||||
note('E', 5, 16, {chord: true}) +
|
||||
note('G', 5, 16, {chord: true}),
|
||||
left:
|
||||
note('C', 3, 16, {staff: 2, voice: '5'}) +
|
||||
note('E', 3, 16, {staff: 2, voice: '5', chord: true}) +
|
||||
note('G', 3, 16, {staff: 2, voice: '5', chord: true}),
|
||||
},
|
||||
];
|
||||
|
||||
measures.forEach((measure, index) => {
|
||||
const number = index + 1;
|
||||
xml += ` <measure number="${number}">\n`;
|
||||
if (number === 1) {
|
||||
xml += ` <attributes>
|
||||
<divisions>${divisions}</divisions>
|
||||
<key><fifths>0</fifths></key>
|
||||
<time><beats>4</beats><beat-type>4</beat-type></time>
|
||||
<staves>2</staves>
|
||||
<clef number="1"><sign>G</sign><line>2</line></clef>
|
||||
<clef number="2"><sign>F</sign><line>4</line></clef>
|
||||
</attributes>
|
||||
<direction placement="above">
|
||||
<direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>96</per-minute></metronome></direction-type>
|
||||
<sound tempo="96"/>
|
||||
</direction>
|
||||
<barline location="left"><repeat direction="forward"/></barline>
|
||||
`;
|
||||
}
|
||||
if (number === 9) {
|
||||
xml += ` <direction placement="above">
|
||||
<direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>104</per-minute></metronome></direction-type>
|
||||
<sound tempo="104"/>
|
||||
</direction>
|
||||
`;
|
||||
}
|
||||
xml += measure.right;
|
||||
xml += ` <backup><duration>16</duration></backup>\n`;
|
||||
xml += measure.left;
|
||||
if (number === 4) {
|
||||
xml += ` <barline location="right"><repeat direction="backward" times="2"/></barline>\n`;
|
||||
}
|
||||
xml += ` </measure>\n`;
|
||||
});
|
||||
|
||||
xml += ` </part>
|
||||
</score-partwise>
|
||||
`;
|
||||
|
||||
const out = path.resolve('public/demo/demo.musicxml');
|
||||
fs.mkdirSync(path.dirname(out), {recursive: true});
|
||||
fs.writeFileSync(out, xml, 'utf8');
|
||||
console.log(`Wrote ${out} (${xml.length} bytes, ${measures.length} measures)`);
|
||||
@@ -0,0 +1,44 @@
|
||||
import fs from 'node:fs';
|
||||
import {buildScrollKeys} from '../src/shared/scroll.js';
|
||||
import type {RenderData, VisualSettings} from '../src/shared/types.js';
|
||||
|
||||
/** Builds Remotion still props for a finished job, using exactly its saved settings. */
|
||||
const id = process.argv[2];
|
||||
if (!id) throw new Error('usage: tsx scripts/props-from-job.ts <jobId> [outFile]');
|
||||
const out = process.argv[3] ?? 'data/check/p-job.json';
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(`data/jobs/${id}/score.json`, 'utf8')) as RenderData;
|
||||
const job = JSON.parse(fs.readFileSync(`data/jobs/${id}/job.json`, 'utf8')) as {
|
||||
settings?: Partial<VisualSettings>;
|
||||
};
|
||||
const settings: VisualSettings = {...data.settings, ...(job.settings ?? {})};
|
||||
|
||||
const displayHeight = settings.height * settings.scoreHeightRatio;
|
||||
const displayScale = displayHeight / Math.max(1, data.meta.svgHeight);
|
||||
const viewWidthScore = settings.width / displayScale;
|
||||
const maxLeft = Math.max(0, data.meta.svgWidth - viewWidthScore);
|
||||
const scroll = buildScrollKeys(
|
||||
data.events,
|
||||
settings.scrollLead,
|
||||
viewWidthScore,
|
||||
maxLeft,
|
||||
data.meta.musicDuration,
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
out,
|
||||
JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
settings,
|
||||
scroll,
|
||||
svgUrl: `http://127.0.0.1:3001/jobs/${id}/score.svg`,
|
||||
audioUrl: `http://127.0.0.1:3001/jobs/${id}/audio.wav`,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(`${out}: ${settings.width}x${settings.height} tiltX=${settings.tiltX} tiltZ=${settings.tiltZ}`);
|
||||
+1116
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,174 @@
|
||||
import React, {useMemo, useRef, useState} from 'react';
|
||||
|
||||
type Camera = {
|
||||
tiltX: number;
|
||||
tiltY: number;
|
||||
tiltZ: number;
|
||||
};
|
||||
|
||||
type Props = Camera & {
|
||||
onChange: (patch: Partial<Camera>) => void;
|
||||
};
|
||||
|
||||
const AXES = [
|
||||
{key: 'x', label: 'X', color: '#ff7b8a', vector: [1, 0, 0] as const},
|
||||
{key: 'y', label: 'Y', color: '#8fe3c8', vector: [0, 1, 0] as const},
|
||||
{key: 'z', label: 'Z', color: '#7aa2ff', vector: [0, 0, 1] as const},
|
||||
];
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
function project(
|
||||
vector: readonly [number, number, number],
|
||||
camera: Camera,
|
||||
radius: number,
|
||||
): {x: number; y: number; z: number} {
|
||||
const rx = (camera.tiltX * Math.PI) / 180;
|
||||
const ry = (camera.tiltY * Math.PI) / 180;
|
||||
const rz = (camera.tiltZ * Math.PI) / 180;
|
||||
const [x, y, z] = vector;
|
||||
|
||||
// CSS applies rotateX * rotateY * rotateZ, so match that order.
|
||||
const x1 = x * Math.cos(rz) - y * Math.sin(rz);
|
||||
const y1 = x * Math.sin(rz) + y * Math.cos(rz);
|
||||
const z1 = z;
|
||||
|
||||
const x2 = x1 * Math.cos(ry) + z1 * Math.sin(ry);
|
||||
const z2 = -x1 * Math.sin(ry) + z1 * Math.cos(ry);
|
||||
const y2 = y1;
|
||||
|
||||
const y3 = y2 * Math.cos(rx) - z2 * Math.sin(rx);
|
||||
const z3 = y2 * Math.sin(rx) + z2 * Math.cos(rx);
|
||||
const x3 = x2;
|
||||
|
||||
const scale = 1 / Math.max(0.55, 1 - z3 * 0.28);
|
||||
return {x: x3 * radius * scale, y: y3 * radius * scale, z: z3};
|
||||
}
|
||||
|
||||
export const ViewGizmo: React.FC<Props> = ({tiltX, tiltY, tiltZ, onChange}) => {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const drag = useRef<{
|
||||
mode: 'orbit' | 'roll';
|
||||
startX: number;
|
||||
startY: number;
|
||||
baseX: number;
|
||||
baseY: number;
|
||||
baseZ: number;
|
||||
angle: number;
|
||||
} | null>(null);
|
||||
|
||||
const axes = useMemo(
|
||||
() =>
|
||||
AXES.map((axis) => ({
|
||||
...axis,
|
||||
...project(axis.vector, {tiltX, tiltY, tiltZ}, 38),
|
||||
})).sort((a, b) => a.z - b.z),
|
||||
[tiltX, tiltY, tiltZ],
|
||||
);
|
||||
|
||||
const pointerPosition = (event: React.PointerEvent<SVGSVGElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
return {
|
||||
x: ((event.clientX - rect.left) / rect.width) * 140 - 70,
|
||||
y: ((event.clientY - rect.top) / rect.height) * 140 - 70,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
const {x, y} = pointerPosition(event);
|
||||
const distance = Math.hypot(x, y);
|
||||
drag.current = {
|
||||
mode: distance > 46 ? 'roll' : 'orbit',
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
baseX: tiltX,
|
||||
baseY: tiltY,
|
||||
baseZ: tiltZ,
|
||||
angle: (Math.atan2(y, x) * 180) / Math.PI,
|
||||
};
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<SVGSVGElement>) => {
|
||||
const current = drag.current;
|
||||
if (!current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (current.mode === 'roll') {
|
||||
const {x, y} = pointerPosition(event);
|
||||
const angle = (Math.atan2(y, x) * 180) / Math.PI;
|
||||
let delta = angle - current.angle;
|
||||
if (delta > 180) delta -= 360;
|
||||
if (delta < -180) delta += 360;
|
||||
onChange({tiltZ: clamp(current.baseZ + delta, -60, 60)});
|
||||
return;
|
||||
}
|
||||
const dx = event.clientX - current.startX;
|
||||
const dy = event.clientY - current.startY;
|
||||
onChange({
|
||||
tiltY: clamp(current.baseY + dx * 0.36, -60, 60),
|
||||
tiltX: clamp(current.baseX - dy * 0.36, -85, 85),
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: React.PointerEvent<SVGSVGElement>) => {
|
||||
event.stopPropagation();
|
||||
drag.current = null;
|
||||
setDragging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`view-gizmo ${dragging ? 'dragging' : ''}`}
|
||||
viewBox="-70 -70 140 140"
|
||||
role="application"
|
||||
aria-label="视角控制:拖动内部旋转,拖动外环滚转"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<circle r="64" className="gizmo-glass" />
|
||||
<circle r="52" className="gizmo-ring" />
|
||||
<circle r="1.8" className="gizmo-center" />
|
||||
{axes.map((axis) => (
|
||||
<g key={axis.key} className="gizmo-axis">
|
||||
<line
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2={axis.x}
|
||||
y2={axis.y}
|
||||
stroke={axis.color}
|
||||
strokeWidth={axis.z > 0 ? 2.4 : 1.8}
|
||||
strokeOpacity={axis.z > 0 ? 0.95 : 0.55}
|
||||
/>
|
||||
<circle
|
||||
cx={axis.x}
|
||||
cy={axis.y}
|
||||
r="9.5"
|
||||
fill={axis.color}
|
||||
fillOpacity="0.16"
|
||||
stroke={axis.color}
|
||||
strokeWidth="1.3"
|
||||
strokeOpacity={axis.z > 0 ? 0.95 : 0.55}
|
||||
/>
|
||||
<text
|
||||
x={axis.x}
|
||||
y={axis.y + 3.4}
|
||||
textAnchor="middle"
|
||||
fill={axis.color}
|
||||
fillOpacity={axis.z > 0 ? 1 : 0.72}
|
||||
>
|
||||
{axis.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import type {HealthResponse, JobPublic, VisualSettings} from '../shared/types.js';
|
||||
|
||||
async function parseError(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await response.json()) as {error?: string};
|
||||
return body.error ?? `请求失败(HTTP ${response.status})`;
|
||||
} catch {
|
||||
return `请求失败(HTTP ${response.status})`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<HealthResponse> {
|
||||
const response = await fetch('/api/health');
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
return (await response.json()) as HealthResponse;
|
||||
}
|
||||
|
||||
export async function listJobs(): Promise<JobPublic[]> {
|
||||
const response = await fetch('/api/jobs');
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
const body = (await response.json()) as {jobs: JobPublic[]};
|
||||
return body.jobs ?? [];
|
||||
}
|
||||
|
||||
export async function getJob(id: string): Promise<JobPublic> {
|
||||
const response = await fetch(`/api/jobs/${encodeURIComponent(id)}`);
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
return (await response.json()) as JobPublic;
|
||||
}
|
||||
|
||||
export async function createJob(input: {
|
||||
score: File;
|
||||
audio?: File | null;
|
||||
settings: VisualSettings;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
composer?: string;
|
||||
}): Promise<{id: string}> {
|
||||
const form = new FormData();
|
||||
form.append('score', input.score, input.score.name);
|
||||
if (input.audio) form.append('audio', input.audio, input.audio.name);
|
||||
form.append('settings', JSON.stringify(input.settings));
|
||||
if (input.title !== undefined) form.append('title', input.title);
|
||||
if (input.subtitle !== undefined) form.append('subtitle', input.subtitle);
|
||||
if (input.composer !== undefined) form.append('composer', input.composer);
|
||||
const response = await fetch('/api/jobs', {method: 'POST', body: form});
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
return (await response.json()) as {id: string};
|
||||
}
|
||||
|
||||
export async function startRender(id: string): Promise<void> {
|
||||
const response = await fetch(`/api/jobs/${encodeURIComponent(id)}/render`, {method: 'POST'});
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
}
|
||||
|
||||
export async function updateJobSettings(
|
||||
id: string,
|
||||
settings: VisualSettings,
|
||||
metadata?: {title?: string; subtitle?: string; composer?: string},
|
||||
): Promise<JobPublic> {
|
||||
const response = await fetch(`/api/jobs/${encodeURIComponent(id)}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({settings, metadata}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
return (await response.json()) as JobPublic;
|
||||
}
|
||||
|
||||
export async function fetchDemoFile(): Promise<File> {
|
||||
const response = await fetch('/demo/demo.musicxml');
|
||||
if (!response.ok) throw new Error('示例曲目加载失败。');
|
||||
const text = await response.text();
|
||||
return new File([text], 'demo.musicxml', {type: 'application/vnd.recordare.musicxml+xml'});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {App} from './App';
|
||||
import './styles.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('Missing #root');
|
||||
createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,716 @@
|
||||
import React, {useLayoutEffect, useMemo, useRef} from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Audio,
|
||||
Img,
|
||||
Sequence,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import type {RenderData} from '../shared/types';
|
||||
import {scrollTrack} from '../shared/scroll';
|
||||
import {
|
||||
activeRingsAt,
|
||||
arcPoint,
|
||||
groupLandings,
|
||||
liftDepth,
|
||||
performanceAt,
|
||||
REST_DEPTH,
|
||||
type Motion,
|
||||
} from './animation';
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
/**
|
||||
* Accepts hex and rgb()/rgba() colours. Canvas silently keeps its previous
|
||||
* strokeStyle for invalid colours (default black), so this must cover whatever
|
||||
* `mix()` hands back.
|
||||
*/
|
||||
function parseColor(color: string): [number, number, number] {
|
||||
const value = color.trim();
|
||||
const rgb = value.match(/^rgba?\(([^)]+)\)$/i);
|
||||
if (rgb) {
|
||||
const parts = rgb[1]!.split(',').map((part) => Number.parseFloat(part.trim()));
|
||||
if (parts.length >= 3 && parts.slice(0, 3).every(Number.isFinite)) {
|
||||
return [parts[0]!, parts[1]!, parts[2]!];
|
||||
}
|
||||
}
|
||||
const hex = value.startsWith('#') ? value.slice(1) : value;
|
||||
if (/^[0-9a-f]{3}$/i.test(hex)) {
|
||||
return [
|
||||
Number.parseInt(hex[0]! + hex[0]!, 16),
|
||||
Number.parseInt(hex[1]! + hex[1]!, 16),
|
||||
Number.parseInt(hex[2]! + hex[2]!, 16),
|
||||
];
|
||||
}
|
||||
if (/^[0-9a-f]{6}$/i.test(hex)) {
|
||||
return [
|
||||
Number.parseInt(hex.slice(0, 2), 16),
|
||||
Number.parseInt(hex.slice(2, 4), 16),
|
||||
Number.parseInt(hex.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
return [255, 255, 255];
|
||||
}
|
||||
|
||||
function withAlpha(hex: string, alpha: number): string {
|
||||
const [r, g, b] = parseColor(hex);
|
||||
return `rgba(${r},${g},${b},${clamp(alpha, 0, 1)})`;
|
||||
}
|
||||
|
||||
function mix(hexA: string, hexB: string, amount: number): string {
|
||||
const a = parseColor(hexA);
|
||||
const b = parseColor(hexB);
|
||||
const t = clamp(amount, 0, 1);
|
||||
return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(
|
||||
a[1] + (b[1] - a[1]) * t,
|
||||
)},${Math.round(a[2] + (b[2] - a[2]) * t)})`;
|
||||
}
|
||||
|
||||
function makePaperTexture(): string | undefined {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
const size = 96;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return undefined;
|
||||
const image = ctx.createImageData(size, size);
|
||||
let seed = 0x1a2b3c4d;
|
||||
for (let i = 0; i < image.data.length; i += 4) {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
const grain = seed % 18;
|
||||
image.data[i] = 0;
|
||||
image.data[i + 1] = 0;
|
||||
image.data[i + 2] = 0;
|
||||
image.data[i + 3] = grain;
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
type BallState = {
|
||||
staff: number;
|
||||
color: string;
|
||||
motion: Motion;
|
||||
};
|
||||
|
||||
export const MusicScroll: React.FC<{data: RenderData}> = ({data}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const {width, height, fps} = useVideoConfig();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const paperTexture = useMemo(makePaperTexture, []);
|
||||
const settings = data.settings;
|
||||
const meta = data.meta;
|
||||
|
||||
const uniqueLanes = useMemo(
|
||||
() => [...new Set(data.activations.map((note) => note.staff))].sort((a, b) => a - b),
|
||||
[data.activations],
|
||||
);
|
||||
const landings = useMemo(
|
||||
() => new Map(uniqueLanes.map((staff) => [staff, groupLandings(data.activations, staff)])),
|
||||
[data.activations, uniqueLanes],
|
||||
);
|
||||
const colorForStaff = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
uniqueLanes.forEach((staff, index) => {
|
||||
map.set(staff, settings.staffColors[index % settings.staffColors.length] ?? '#bda0ff');
|
||||
});
|
||||
return map;
|
||||
}, [uniqueLanes, settings.staffColors]);
|
||||
|
||||
const introSeconds = settings.introSeconds;
|
||||
const t = frame / fps - introSeconds;
|
||||
const paperOpacity = clamp((t + introSeconds) / Math.max(0.2, Math.min(0.55, introSeconds)), 0, 1);
|
||||
const contentOpacity = clamp((t + 0.2) / 0.28, 0, 1);
|
||||
const titleOpacity = clamp((t + introSeconds) / Math.max(0.3, Math.min(0.7, introSeconds)), 0, 1);
|
||||
|
||||
const displayHeight = Math.max(120, height * settings.scoreHeightRatio);
|
||||
const displayScale = displayHeight / Math.max(1, meta.svgHeight);
|
||||
const totalWidth = meta.svgWidth * displayScale;
|
||||
const centerOffset = totalWidth < width ? (width - totalWidth) / 2 : 0;
|
||||
const scrollX = scrollTrack(data.scroll, t);
|
||||
const scoreLeft = centerOffset - scrollX * displayScale;
|
||||
const scoreTop = (height - displayHeight) / 2 + height * 0.018;
|
||||
const paperPad = Math.max(16, displayHeight * 0.07);
|
||||
/**
|
||||
* The sheet is much wider than the frame: once the camera tilts, a
|
||||
* viewport-wide band would show its left/right edges inside the picture.
|
||||
*/
|
||||
const paperOverhang = width * 2;
|
||||
|
||||
const balls: BallState[] = [];
|
||||
for (const staff of uniqueLanes) {
|
||||
const motion = performanceAt(landings.get(staff) ?? [], t);
|
||||
if (!motion) continue;
|
||||
if (motion.opacity <= 0.001) continue;
|
||||
balls.push({staff, color: colorForStaff.get(staff) ?? '#bda0ff', motion});
|
||||
}
|
||||
|
||||
const rings = activeRingsAt(data.activations, t);
|
||||
const noteAlpha = paperOpacity;
|
||||
const isPortrait = height > width;
|
||||
const headerTop = height * (isPortrait ? 0.05 : 0.055);
|
||||
const headerPadding = width * (isPortrait ? 0.065 : 0.055);
|
||||
const titleSize = Math.round(height * (isPortrait ? 0.026 : 0.04));
|
||||
const subtitleSize = Math.round(height * (isPortrait ? 0.014 : 0.019));
|
||||
const composerSize = Math.round(height * (isPortrait ? 0.013 : 0.017));
|
||||
const progressBottom = height * (isPortrait ? 0.045 : 0.042);
|
||||
const progress = clamp(t / Math.max(0.1, meta.musicDuration), 0, 1);
|
||||
const progressOpacity = titleOpacity * (settings.showProgress ? 0.6 : 0);
|
||||
|
||||
const tiltX = settings.tiltX ?? 0;
|
||||
const tiltY = settings.tiltY ?? 0;
|
||||
const tiltZ = settings.tiltZ ?? 0;
|
||||
const perspective = Math.max(500, settings.perspective ?? 1800);
|
||||
const tiltZoom = Math.max(0.5, settings.tiltZoom ?? 1);
|
||||
const paperAlpha = settings.paperAlpha ?? 1;
|
||||
const scoreColor = settings.scoreColor ?? '#14110d';
|
||||
const planeTransform = `rotateX(${tiltX}deg) rotateY(${tiltY}deg) rotateZ(${tiltZ}deg) scale(${tiltZoom})`;
|
||||
|
||||
const introFrames = Math.round(settings.introSeconds * fps);
|
||||
const offsetMs = settings.audioOffsetMs;
|
||||
const delayFrames = offsetMs > 0 ? Math.round((offsetMs / 1000) * fps) : 0;
|
||||
const trimFrames = offsetMs < 0 ? Math.round((-offsetMs / 1000) * fps) : 0;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
if (canvas.width !== width) canvas.width = width;
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const unit = displayScale;
|
||||
/** Score coordinates -> the plane's own pixel canvas (before the 3D transform). */
|
||||
const toPaper = (x: number, y: number): [number, number] => [
|
||||
(x - scrollX) * displayScale + centerOffset,
|
||||
scoreTop + y * displayScale,
|
||||
];
|
||||
|
||||
// Mirror of the CSS transform on the score plane, evaluated here so the orbs
|
||||
// can be drawn in screen space: they keep a round silhouette at any camera
|
||||
// angle instead of being squashed into ellipses by the paper's perspective.
|
||||
const rad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const cosX = Math.cos(rad(tiltX));
|
||||
const sinX = Math.sin(rad(tiltX));
|
||||
const cosY = Math.cos(rad(tiltY));
|
||||
const sinY = Math.sin(rad(tiltY));
|
||||
const cosZ = Math.cos(rad(tiltZ));
|
||||
const sinZ = Math.sin(rad(tiltZ));
|
||||
const midX = width / 2;
|
||||
const midY = height / 2;
|
||||
/** Score units above the paper -> plane-local pixels, before the 3D transform. */
|
||||
const lift = Math.max(0, settings.ballLift ?? 1);
|
||||
const depthToPixels = (depth: number) => liftDepth(depth, lift) * displayScale;
|
||||
const orbRadius = (depth: number) => 2.5 * unit * settings.ballScale * depth;
|
||||
|
||||
const project = (x: number, y: number, z = 0) => {
|
||||
const lx = (x - midX) * tiltZoom;
|
||||
const ly = (y - midY) * tiltZoom;
|
||||
// CSS applies rotateX * rotateY * rotateZ * scale to the local point.
|
||||
const rzX = lx * cosZ - ly * sinZ;
|
||||
const rzY = lx * sinZ + ly * cosZ;
|
||||
const ryX = rzX * cosY + z * sinY;
|
||||
const ryZ = -rzX * sinY + z * cosY;
|
||||
const rxY = rzY * cosX - ryZ * sinX;
|
||||
const rxZ = rzY * sinX + ryZ * cosX;
|
||||
const w = perspective / Math.max(1, perspective - rxZ);
|
||||
return {x: midX + ryX * w, y: midY + rxY * w, w, depth: rxZ};
|
||||
};
|
||||
|
||||
/** Draw in paper-plane units, so shadows and rings lie flat on the sheet. */
|
||||
const onPaper = (x: number, y: number, draw: () => void) => {
|
||||
const origin = project(x, y, 0);
|
||||
const alongX = project(x + 1, y, 0);
|
||||
const alongY = project(x, y + 1, 0);
|
||||
ctx.save();
|
||||
ctx.setTransform(
|
||||
alongX.x - origin.x,
|
||||
alongX.y - origin.y,
|
||||
alongY.x - origin.x,
|
||||
alongY.y - origin.y,
|
||||
origin.x,
|
||||
origin.y,
|
||||
);
|
||||
draw();
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// Contact shadows, flattened onto the sheet so each hop reads as height.
|
||||
for (const ball of balls) {
|
||||
const motion = ball.motion;
|
||||
const [px, py] = toPaper(motion.position[0], motion.position[1]);
|
||||
const r = 2.5 * unit * settings.ballScale;
|
||||
const height = Math.max(0, motion.depth - REST_DEPTH) * lift;
|
||||
const spread = r * (1.6 + Math.min(2.2, height * 0.08));
|
||||
const strength = 0.36 / (1 + height / 9);
|
||||
onPaper(px, py, () => {
|
||||
const shadow = ctx.createRadialGradient(0, 0, r * 0.1, 0, 0, spread);
|
||||
shadow.addColorStop(0, `rgba(0,0,0,${strength})`);
|
||||
shadow.addColorStop(0.5, `rgba(0,0,0,${strength * 0.4})`);
|
||||
shadow.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.globalAlpha = motion.opacity * 0.9 * contentOpacity;
|
||||
ctx.fillStyle = shadow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, spread, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
// Hit markers stay on the sheet and carry the orb's own lane colour, so a
|
||||
// played note reads as a trace of that ball instead of a dark ring.
|
||||
for (const note of rings) {
|
||||
const age = t - note.time;
|
||||
if (age < 0 || age > 3.6) continue;
|
||||
const sustain = note.kind === 'sustain';
|
||||
const flash = Math.exp(-age / 0.14) * (sustain ? 0.55 : 1);
|
||||
const fade = Math.max(0, 1 - age / 3.6) ** 1.15;
|
||||
const expansion = 1 + 0.23 * (1 - Math.exp(-age / 0.055));
|
||||
const color = colorForStaff.get(note.staff) ?? '#bda0ff';
|
||||
const [nx, ny] = toPaper(note.x, note.y);
|
||||
const ringR = 4.2 * unit * expansion;
|
||||
onPaper(nx, ny, () => {
|
||||
ctx.globalAlpha = noteAlpha;
|
||||
|
||||
const halo = ctx.createRadialGradient(0, 0, ringR * 0.45, 0, 0, ringR * 2.3);
|
||||
halo.addColorStop(0, withAlpha(color, (0.3 + flash * 0.25) * fade));
|
||||
halo.addColorStop(1, withAlpha(color, 0));
|
||||
ctx.fillStyle = halo;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, ringR * 2.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = withAlpha(color, (sustain ? 0.6 : 0.82) * fade);
|
||||
ctx.lineWidth = Math.max(1.2, 0.55 * unit);
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, ringR, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = withAlpha(mix(color, '#ffffff', 0.4 + flash * 0.4), 0.6 * fade);
|
||||
ctx.lineWidth = Math.max(0.9, 0.28 * unit);
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, ringR * 0.84, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// Trails: a 3D arc in the same space as the orb, drawn only along the part
|
||||
// of the hop already travelled so it reads as a tail behind the ball.
|
||||
for (const ball of balls) {
|
||||
const motion = ball.motion;
|
||||
if (motion.trailOpacity <= 0.001) continue;
|
||||
const steps = 30;
|
||||
const trailEnd = motion.departing ? Math.max(0.04, motion.u) : 1;
|
||||
const samples = Array.from({length: steps}, (_, i) => {
|
||||
const [x, y, z] = arcPoint(motion.from, motion.to, (i / (steps - 1)) * trailEnd);
|
||||
const [px, py] = toPaper(x, y);
|
||||
const point = project(px, py, depthToPixels(z));
|
||||
return {x: point.x, y: point.y, w: point.w};
|
||||
});
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
const stroke = (widthScale: number, alphaScale: number) => {
|
||||
for (let i = 1; i < samples.length; i += 1) {
|
||||
const a = samples[i - 1]!;
|
||||
const b = samples[i]!;
|
||||
const f = i / (samples.length - 1);
|
||||
const head = f * f;
|
||||
ctx.globalAlpha = noteAlpha * motion.trailOpacity * (0.04 + head * 0.72) * alphaScale;
|
||||
ctx.strokeStyle = withAlpha(ball.color, 0.85);
|
||||
ctx.lineWidth = Math.max(
|
||||
0.7,
|
||||
(0.3 + 0.62 * head) * unit * b.w * widthScale,
|
||||
);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
stroke(3.6, 0.16);
|
||||
stroke(1, 1);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Orbs: real spheres in screen space, so they never collapse into ellipses.
|
||||
for (const ball of balls) {
|
||||
const motion = ball.motion;
|
||||
const opacity = motion.opacity * contentOpacity;
|
||||
if (opacity <= 0.001) continue;
|
||||
const [px, py] = toPaper(motion.position[0], motion.position[1]);
|
||||
const head = project(px, py, depthToPixels(motion.depth));
|
||||
const r = orbRadius(head.w);
|
||||
const pulse = motion.pulse;
|
||||
const radius = r * (1 + pulse * 0.1);
|
||||
const sx = head.x;
|
||||
const sy = head.y;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.globalAlpha = opacity;
|
||||
const glowRadius = radius * 9.5;
|
||||
const glow = ctx.createRadialGradient(sx, sy, radius * 0.2, sx, sy, glowRadius);
|
||||
glow.addColorStop(0, withAlpha(ball.color, 0.5 * settings.glow));
|
||||
glow.addColorStop(0.22, withAlpha(ball.color, 0.16 * settings.glow));
|
||||
glow.addColorStop(0.6, withAlpha(ball.color, 0.04 * settings.glow));
|
||||
glow.addColorStop(1, withAlpha(ball.color, 0));
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, glowRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
const body = ctx.createRadialGradient(
|
||||
sx - radius * 0.34,
|
||||
sy - radius * 0.38,
|
||||
radius * 0.04,
|
||||
sx + radius * 0.1,
|
||||
sy + radius * 0.14,
|
||||
radius * 1.1,
|
||||
);
|
||||
body.addColorStop(0, 'rgba(255,255,255,1)');
|
||||
body.addColorStop(0.16, mix(ball.color, '#ffffff', 0.9));
|
||||
body.addColorStop(0.42, mix(ball.color, '#ffffff', 0.52));
|
||||
body.addColorStop(0.7, mix(ball.color, '#ffffff', 0.18));
|
||||
body.addColorStop(0.9, ball.color);
|
||||
body.addColorStop(1, mix(ball.color, '#000000', 0.12));
|
||||
ctx.fillStyle = body;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
const specular = ctx.createRadialGradient(
|
||||
sx - radius * 0.36,
|
||||
sy - radius * 0.4,
|
||||
0,
|
||||
sx - radius * 0.36,
|
||||
sy - radius * 0.4,
|
||||
radius * 0.46,
|
||||
);
|
||||
specular.addColorStop(0, 'rgba(255,255,255,0.92)');
|
||||
specular.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = specular;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.strokeStyle = withAlpha(mix(ball.color, '#ffffff', 0.85), 0.2 * opacity);
|
||||
ctx.lineWidth = Math.max(0.8, radius * 0.07);
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, radius * 0.94, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}, [
|
||||
width,
|
||||
height,
|
||||
t,
|
||||
scrollX,
|
||||
displayScale,
|
||||
displayHeight,
|
||||
centerOffset,
|
||||
scoreTop,
|
||||
balls,
|
||||
rings,
|
||||
noteAlpha,
|
||||
contentOpacity,
|
||||
settings.ballScale,
|
||||
settings.ballLift,
|
||||
settings.glow,
|
||||
colorForStaff,
|
||||
tiltX,
|
||||
tiltY,
|
||||
tiltZ,
|
||||
perspective,
|
||||
tiltZoom,
|
||||
]);
|
||||
|
||||
if (!data.events.length || !data.svgUrl) {
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: settings.background,
|
||||
color: '#e6ded2',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontSize: 38,
|
||||
fontFamily: settings.titleFont,
|
||||
}}
|
||||
>
|
||||
请先在网页中上传 MusicXML 并生成预览
|
||||
</AbsoluteFill>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{background: settings.background, overflow: 'hidden'}}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse at 50% 40%, rgba(255,255,255,0.045), transparent 58%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tilted score plane: paper, notation and its light spill share one 3D transform */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
perspective: `${perspective}px`,
|
||||
perspectiveOrigin: '50% 50%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
transformOrigin: '50% 50%',
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: planeTransform,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
{/* Paper band, wider than the frame so tilted edges stay off-screen */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -paperOverhang,
|
||||
width: width + paperOverhang * 2,
|
||||
top: scoreTop - paperPad,
|
||||
height: displayHeight + paperPad * 2,
|
||||
opacity: paperOpacity * paperAlpha,
|
||||
backgroundColor: settings.paperColor,
|
||||
backgroundImage: [
|
||||
paperTexture ? `url(${paperTexture})` : '',
|
||||
'linear-gradient(180deg, rgba(255,255,255,0.22), rgba(255,255,255,0.02) 16%, rgba(0,0,0,0.025) 78%, rgba(0,0,0,0.2))',
|
||||
'radial-gradient(ellipse at 50% 50%, rgba(255,255,255,0.12), transparent 72%)',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', '),
|
||||
backgroundSize: paperTexture
|
||||
? '96px 96px, 100% 100%, 100% 100%'
|
||||
: '100% 100%, 100% 100%',
|
||||
boxShadow:
|
||||
'0 32px 90px rgba(0,0,0,0.6), 0 2px 0 rgba(255,255,255,0.12) inset, 0 -2px 0 rgba(0,0,0,0.18) inset',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Score: the opaque SVG becomes a mask so its ink colour is live-editable */}
|
||||
<Img
|
||||
src={data.svgUrl}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: scoreLeft,
|
||||
top: scoreTop,
|
||||
width: totalWidth,
|
||||
height: displayHeight,
|
||||
opacity: paperOpacity,
|
||||
backgroundColor: scoreColor,
|
||||
WebkitMaskImage: `url("${data.svgUrl}")`,
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
WebkitMaskSize: '100% 100%',
|
||||
WebkitMaskPosition: '0 0',
|
||||
maskImage: `url("${data.svgUrl}")`,
|
||||
maskRepeat: 'no-repeat',
|
||||
maskSize: '100% 100%',
|
||||
maskPosition: '0 0',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Paper lights */}
|
||||
{balls.map((ball) => {
|
||||
const [x, y] = [
|
||||
(ball.motion.position[0] - scrollX) * displayScale + centerOffset,
|
||||
scoreTop + ball.motion.position[1] * displayScale,
|
||||
];
|
||||
const radius = Math.max(150, displayHeight * 0.48) * (0.85 + 0.2 * settings.glow);
|
||||
return (
|
||||
<div
|
||||
key={`light-${ball.staff}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
opacity: paperOpacity,
|
||||
mixBlendMode: 'screen',
|
||||
background: `radial-gradient(circle ${radius}px at ${x}px ${y}px, ${withAlpha(
|
||||
ball.color,
|
||||
0.34 * settings.glow * ball.motion.opacity,
|
||||
)} 0%, ${withAlpha(ball.color, 0.1 * settings.glow * ball.motion.opacity)} 34%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orbs live above the tilted plane and are drawn in screen space */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}
|
||||
/>
|
||||
|
||||
{/* Vignette keeps the eye on the score */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
background:
|
||||
'radial-gradient(ellipse at 50% 52%, transparent 34%, rgba(7,5,12,0.18) 76%, rgba(3,2,7,0.5) 112%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header scrim keeps the title readable over a tilted sheet */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: height * 0.24,
|
||||
pointerEvents: 'none',
|
||||
background: `linear-gradient(180deg, ${withAlpha(settings.background, 0.82)} 0%, ${withAlpha(
|
||||
settings.background,
|
||||
0.4,
|
||||
)} 55%, transparent 100%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{settings.showTitle && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: headerTop,
|
||||
left: headerPadding,
|
||||
right: headerPadding,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
gap: width * 0.04,
|
||||
opacity: titleOpacity,
|
||||
}}
|
||||
>
|
||||
<div style={{minWidth: 0, flex: 1}}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: settings.titleFont,
|
||||
fontSize: titleSize,
|
||||
lineHeight: 1.05,
|
||||
color: '#f4ede2',
|
||||
letterSpacing: '0.012em',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
textShadow: '0 2px 20px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
>
|
||||
{meta.title || 'Untitled'}
|
||||
</div>
|
||||
{meta.subtitle ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: height * 0.008,
|
||||
fontFamily: settings.titleFont,
|
||||
fontSize: subtitleSize,
|
||||
color: 'rgba(244,237,226,0.52)',
|
||||
letterSpacing: '0.05em',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{meta.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{meta.composer ? (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: settings.titleFont,
|
||||
fontSize: composerSize,
|
||||
color: 'rgba(244,237,226,0.55)',
|
||||
letterSpacing: '0.08em',
|
||||
whiteSpace: 'nowrap',
|
||||
paddingBottom: height * 0.006,
|
||||
}}
|
||||
>
|
||||
{meta.composer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thin rule under header */}
|
||||
{settings.showTitle && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: headerTop + titleSize * 1.35 + (meta.subtitle ? subtitleSize * 1.35 : 0),
|
||||
left: headerPadding,
|
||||
right: headerPadding,
|
||||
height: 1,
|
||||
opacity: titleOpacity * 0.5,
|
||||
background: `linear-gradient(90deg, ${withAlpha(
|
||||
settings.staffColors[0] ?? '#bda0ff',
|
||||
0.75,
|
||||
)}, rgba(255,255,255,0.08) 55%, transparent)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Progress */}
|
||||
{settings.showProgress && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: headerPadding,
|
||||
right: headerPadding,
|
||||
bottom: progressBottom,
|
||||
height: 2,
|
||||
opacity: progressOpacity,
|
||||
backgroundColor: 'rgba(255,255,255,0.12)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${progress * 100}%`,
|
||||
height: '100%',
|
||||
background: `linear-gradient(90deg, ${withAlpha(
|
||||
settings.staffColors[0] ?? '#bda0ff',
|
||||
0.9,
|
||||
)}, ${withAlpha(settings.staffColors[1] ?? '#ffbc6d', 0.9)})`,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.audioUrl ? (
|
||||
<Sequence from={introFrames + delayFrames}>
|
||||
<Audio src={data.audioUrl} startFrom={trimFrames} />
|
||||
</Sequence>
|
||||
) : null}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import {Composition} from 'remotion';
|
||||
import type {RenderData} from '../shared/types';
|
||||
import {DEFAULT_SETTINGS} from '../shared/types';
|
||||
import {MusicScroll} from './MusicScroll';
|
||||
|
||||
export const EMPTY_DATA: RenderData = {
|
||||
version: 1,
|
||||
meta: {
|
||||
title: 'Music Visualizer',
|
||||
subtitle: '',
|
||||
composer: '',
|
||||
svgWidth: 1120,
|
||||
svgHeight: 800,
|
||||
musicDuration: 3,
|
||||
audioDuration: 3,
|
||||
measureCount: 0,
|
||||
scoreNoteCount: 0,
|
||||
midiNoteCount: 0,
|
||||
mappedNoteCount: 0,
|
||||
warnings: [],
|
||||
unsupported: [],
|
||||
},
|
||||
events: [],
|
||||
activations: [],
|
||||
scroll: [
|
||||
{time: 0, x: 0},
|
||||
{time: 5, x: 0},
|
||||
],
|
||||
svgUrl: '',
|
||||
audioUrl: '',
|
||||
settings: DEFAULT_SETTINGS,
|
||||
};
|
||||
|
||||
export const Root: React.FC = () => (
|
||||
<Composition
|
||||
id="MusicScroll"
|
||||
component={MusicScroll}
|
||||
width={1920}
|
||||
height={1080}
|
||||
fps={30}
|
||||
durationInFrames={120}
|
||||
defaultProps={{data: EMPTY_DATA}}
|
||||
calculateMetadata={({props}) => {
|
||||
const data = (props as {data?: RenderData}).data ?? EMPTY_DATA;
|
||||
const settings = data.settings;
|
||||
const music = Math.max(0.5, data.meta.musicDuration || 0.5);
|
||||
const audio = Math.max(0, data.meta.audioDuration || 0);
|
||||
const total = settings.introSeconds + Math.max(music, audio) + settings.outroSeconds;
|
||||
return {
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
fps: settings.fps,
|
||||
durationInFrames: Math.max(1, Math.ceil(total * settings.fps)),
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,148 @@
|
||||
import type {NoteEvent} from '../shared/types';
|
||||
|
||||
export const BALL_ENTRY_SECONDS = 0.18;
|
||||
/** Resting height above the paper, in score units: just enough to clear the ink. */
|
||||
export const REST_DEPTH = 1.6;
|
||||
|
||||
/**
|
||||
* Amplifies only the part of the height that comes from a hop, so the ball
|
||||
* still rests just above the ink and 0 flattens every jump onto the sheet.
|
||||
*/
|
||||
export function liftDepth(depth: number, lift: number): number {
|
||||
return REST_DEPTH + (depth - REST_DEPTH) * Math.max(0, lift);
|
||||
}
|
||||
|
||||
export type Motion = {
|
||||
position: [number, number];
|
||||
/** Height above the paper along its normal, in score units. */
|
||||
depth: number;
|
||||
pulse: number;
|
||||
landed: boolean;
|
||||
opacity: number;
|
||||
from: NoteEvent;
|
||||
to: NoteEvent;
|
||||
u: number;
|
||||
trailOpacity: number;
|
||||
departing: boolean;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
export function groupLandings(notes: NoteEvent[], staff: number): NoteEvent[] {
|
||||
const groups: NoteEvent[][] = [];
|
||||
for (const note of notes
|
||||
.filter((n) => n.staff === staff)
|
||||
.sort((a, b) => a.time - b.time)) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && Math.abs(last[0]!.time - note.time) < 1e-6) last.push(note);
|
||||
else groups.push([note]);
|
||||
}
|
||||
const preferHigh = staff % 2 === 1;
|
||||
return groups.map(
|
||||
(group) =>
|
||||
[...group].sort(
|
||||
(a, b) =>
|
||||
(a.kind === 'attack' ? 0 : 1) - (b.kind === 'attack' ? 0 : 1) ||
|
||||
(preferHigh ? b.pitch - a.pitch : a.pitch - b.pitch),
|
||||
)[0]!,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hop from note `a` to note `b`. X/Y travel across the sheet in a straight line
|
||||
* while Z arcs above it, so the ball lands back on the paper at every note.
|
||||
*/
|
||||
export function arcPoint(
|
||||
a: NoteEvent,
|
||||
b: NoteEvent,
|
||||
u: number,
|
||||
): [number, number, number] {
|
||||
const phase = clamp(u, 0, 1);
|
||||
const distance = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const interval = Math.max(0, b.time - a.time);
|
||||
// Big enough that a hop still reads on a nearly face-on sheet, where a
|
||||
// movement along the paper normal mostly points at the camera.
|
||||
const hop =
|
||||
(22 + Math.min(22, distance * 0.15)) * Math.min(1, Math.max(0.25, interval / 0.24));
|
||||
return [
|
||||
a.x + (b.x - a.x) * phase,
|
||||
a.y + (b.y - a.y) * phase,
|
||||
REST_DEPTH + 4 * phase * (1 - phase) * hop,
|
||||
];
|
||||
}
|
||||
|
||||
export function performanceAt(targets: NoteEvent[], t: number): Motion | null {
|
||||
if (!targets.length) return null;
|
||||
let i = 0;
|
||||
while (i + 1 < targets.length && targets[i + 1]!.time <= t) i++;
|
||||
const current = targets[i]!;
|
||||
const next = targets[i + 1];
|
||||
const age = t - current.time;
|
||||
const landed = age >= 0;
|
||||
const resting = Boolean(next && next.time - current.time - current.duration > 0.65);
|
||||
const enteringNext = Boolean(resting && next && t >= next.time - BALL_ENTRY_SECONDS);
|
||||
const entryTarget = enteringNext ? next! : current;
|
||||
const entryPhase = clamp((t - entryTarget.time + BALL_ENTRY_SECONDS) / BALL_ENTRY_SECONDS, 0, 1);
|
||||
const departing = Boolean(next && landed && !resting);
|
||||
const u = departing ? clamp(age / Math.max(1e-6, next!.time - current.time), 0, 1) : 1;
|
||||
const [ballX, ballY, ballZ] = departing
|
||||
? arcPoint(current, next!, u)
|
||||
: arcPoint(entryTarget, entryTarget, 1);
|
||||
const position: [number, number] = [ballX, ballY];
|
||||
|
||||
let opacity = 1;
|
||||
if (!landed || enteringNext) {
|
||||
opacity = Math.min(1, entryPhase * 2.5);
|
||||
} else if (resting || !next) {
|
||||
opacity = clamp((current.time + current.duration + 0.18 - t) / 0.18, 0, 1);
|
||||
}
|
||||
|
||||
const pulse = landed && !enteringNext ? Math.exp(-Math.max(0, age) / 0.1) : 0;
|
||||
const from = departing ? current : targets[Math.max(0, i - 1)] ?? current;
|
||||
const to = departing ? next! : current;
|
||||
const trailOpacity = departing
|
||||
? 0.68
|
||||
: from.scoreId !== to.scoreId && i > 0
|
||||
? 0.48 * Math.max(0, 1 - age / 0.3)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
position,
|
||||
depth: ballZ,
|
||||
pulse,
|
||||
landed,
|
||||
opacity,
|
||||
from,
|
||||
to,
|
||||
u,
|
||||
trailOpacity,
|
||||
departing,
|
||||
};
|
||||
}
|
||||
|
||||
function lowerBound(notes: NoteEvent[], time: number): number {
|
||||
let low = 0;
|
||||
let high = notes.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (notes[mid]!.time < time) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
/** Latest ring per scoreId within the 3.6s window, matching the original behavior. */
|
||||
export function activeRingsAt(notes: NoteEvent[], t: number): NoteEvent[] {
|
||||
const start = lowerBound(notes, t - 3.6);
|
||||
const latest = new Map<string, NoteEvent>();
|
||||
for (let i = start; i < notes.length; i++) {
|
||||
const note = notes[i]!;
|
||||
if (note.time > t) break;
|
||||
latest.set(note.scoreId, note);
|
||||
}
|
||||
return [...latest.values()];
|
||||
}
|
||||
|
||||
export function staffColorIndex(staff: number): number {
|
||||
return Math.max(0, staff - 1);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import {registerRoot} from 'remotion';
|
||||
import {Root} from './Root';
|
||||
|
||||
registerRoot(Root);
|
||||
@@ -0,0 +1,37 @@
|
||||
import path from 'node:path';
|
||||
import {parseFile} from 'music-metadata';
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.aac', '.ogg', '.oga', '.flac', '.opus']);
|
||||
|
||||
export function isSupportedAudio(filename: string): boolean {
|
||||
return AUDIO_EXTENSIONS.has(path.extname(filename).toLowerCase());
|
||||
}
|
||||
|
||||
export async function readAudioDuration(filePath: string): Promise<number> {
|
||||
const metadata = await parseFile(filePath, {duration: true});
|
||||
const duration = metadata.format.duration;
|
||||
if (!duration || !Number.isFinite(duration) || duration <= 0) {
|
||||
throw new Error('无法读取音频时长。请换一个标准的 WAV/MP3/M4A 文件。');
|
||||
}
|
||||
return duration;
|
||||
}
|
||||
|
||||
export function audioMimeType(filename: string): string {
|
||||
switch (path.extname(filename).toLowerCase()) {
|
||||
case '.wav':
|
||||
return 'audio/wav';
|
||||
case '.mp3':
|
||||
return 'audio/mpeg';
|
||||
case '.m4a':
|
||||
case '.aac':
|
||||
return 'audio/mp4';
|
||||
case '.ogg':
|
||||
case '.oga':
|
||||
case '.opus':
|
||||
return 'audio/ogg';
|
||||
case '.flac':
|
||||
return 'audio/flac';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from 'node:fs';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
|
||||
export const PORT = Number(process.env.PORT ?? 3001);
|
||||
export const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL ?? `http://127.0.0.1:${PORT}`;
|
||||
export const IS_PRODUCTION = process.env.NODE_ENV === 'production';
|
||||
export const REMOTION_ENTRY = 'src/remotion/index.ts';
|
||||
export const REMOTION_COMPOSITION_ID = 'MusicScroll';
|
||||
|
||||
function exists(file: string | undefined): file is string {
|
||||
if (!file) return false;
|
||||
try {
|
||||
return fs.statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer an already installed Chrome/Edge so Remotion does not have to download
|
||||
* a 120 MB headless shell (which is slow or blocked on some networks).
|
||||
*/
|
||||
export function findBrowserExecutable(): string | undefined {
|
||||
const explicit = process.env.REMOTION_BROWSER_EXECUTABLE ?? process.env.BROWSER_EXECUTABLE;
|
||||
if (exists(explicit)) return explicit;
|
||||
const candidates: string[] = [];
|
||||
if (process.platform === 'win32') {
|
||||
candidates.push(
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
);
|
||||
} else if (process.platform === 'darwin') {
|
||||
candidates.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
);
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (exists(candidate)) return candidate;
|
||||
}
|
||||
for (const name of ['google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge', 'chrome']) {
|
||||
const result = spawnSync(process.platform === 'win32' ? 'where.exe' : 'which', [name], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const first = result.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
||||
if (exists(first)) return first;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const BROWSER_EXECUTABLE = findBrowserExecutable();
|
||||
@@ -0,0 +1,77 @@
|
||||
import createModule from 'verovio/wasm';
|
||||
import {VerovioToolkit} from 'verovio/esm';
|
||||
import {extractNoteGeometry, type SvgGeometry} from './geometry.js';
|
||||
|
||||
export type NoteTime = {
|
||||
timeMs: number;
|
||||
durationMs: number;
|
||||
pitch: number;
|
||||
};
|
||||
|
||||
export type EngraveResult = {
|
||||
svg: string;
|
||||
geometry: SvgGeometry;
|
||||
measureStartMs: Map<string, number>;
|
||||
noteTimes: Map<string, NoteTime>;
|
||||
pageCount: number;
|
||||
};
|
||||
|
||||
type VerovioExtra = {
|
||||
getTimeForElement(id: string): number;
|
||||
getMIDIValuesForElement(id: string): {duration: number; pitch: number; time: number} | null;
|
||||
getPageWithElement(id: string): number;
|
||||
};
|
||||
|
||||
export async function engraveOneLine(xml: string): Promise<EngraveResult> {
|
||||
const tk = new VerovioToolkit(await createModule());
|
||||
tk.resetXmlIdSeed(1);
|
||||
tk.setOptions({
|
||||
scale: 40,
|
||||
pageWidth: 50000,
|
||||
pageHeight: 3600,
|
||||
breaks: 'none',
|
||||
adjustPageHeight: true,
|
||||
header: 'none',
|
||||
footer: 'none',
|
||||
svgViewBox: true,
|
||||
spacingStaff: 12,
|
||||
spacingSystem: 12,
|
||||
});
|
||||
if (!tk.loadData(xml)) {
|
||||
throw new Error(`Verovio 无法读取 MusicXML:${tk.getLog()}`);
|
||||
}
|
||||
const pageCount = tk.getPageCount();
|
||||
if (pageCount > 1) {
|
||||
// breaks:none should always yield one page. If not, render all pages side by side later.
|
||||
throw new Error(`单行排版意外生成了 ${pageCount} 页,请报告这个谱面。`);
|
||||
}
|
||||
const svg = tk.renderToSVG(1);
|
||||
const geometry = extractNoteGeometry(svg);
|
||||
const extra = tk as unknown as VerovioExtra;
|
||||
|
||||
const measureStartMs = new Map<string, number>();
|
||||
const noteTimes = new Map<string, NoteTime>();
|
||||
const allElements = svg.match(/<g[^>]*\sid="([^"]+)"[^>]*>/g) ?? [];
|
||||
for (const tag of allElements) {
|
||||
const id = tag.match(/\sid="([^"]+)"/)?.[1];
|
||||
if (!id) continue;
|
||||
if (id.startsWith('meas-')) {
|
||||
const time = extra.getTimeForElement(id);
|
||||
if (Number.isFinite(time) && time >= 0) measureStartMs.set(id, time);
|
||||
} else if (id.startsWith('note-')) {
|
||||
const midi = extra.getMIDIValuesForElement(id);
|
||||
const time = extra.getTimeForElement(id);
|
||||
if (midi && Number.isFinite(midi.time) && midi.time >= 0) {
|
||||
noteTimes.set(id, {
|
||||
timeMs: midi.time,
|
||||
durationMs: Math.max(0, midi.duration ?? 0),
|
||||
pitch: midi.pitch ?? 0,
|
||||
});
|
||||
} else if (Number.isFinite(time) && time >= 0) {
|
||||
noteTimes.set(id, {timeMs: time, durationMs: 0, pitch: 0});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {svg, geometry, measureStartMs, noteTimes, pageCount};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type {NoteEvent, ScoreMeta, ScrollKey, VisualSettings} from '../shared/types.js';
|
||||
import {buildScrollKeys} from '../shared/scroll.js';
|
||||
import {measureDurationSeconds, type ParsedScore, type ParsedNote} from './musicxml.js';
|
||||
import type {EngraveResult} from './engrave.js';
|
||||
import type {ParsedMidi, MidiNote} from './midi.js';
|
||||
|
||||
export type BuildEventsInput = {
|
||||
parsed: ParsedScore;
|
||||
engrave: EngraveResult;
|
||||
midi: ParsedMidi | null;
|
||||
performanceOrder: number[];
|
||||
audioDuration: number;
|
||||
settings: VisualSettings;
|
||||
};
|
||||
|
||||
export type BuildEventsResult = {
|
||||
events: NoteEvent[];
|
||||
activations: NoteEvent[];
|
||||
scroll: ScrollKey[];
|
||||
meta: ScoreMeta;
|
||||
};
|
||||
|
||||
type PerformedNote = {
|
||||
eventId: string;
|
||||
scoreId: string;
|
||||
measureIndex: number;
|
||||
occurrence: number;
|
||||
partIndex: number;
|
||||
staff: number;
|
||||
pitch: number;
|
||||
nominalTimeMs: number;
|
||||
nominalDurationMs: number;
|
||||
x: number;
|
||||
y: number;
|
||||
isGrace: boolean;
|
||||
isTieContinuation: boolean;
|
||||
isCue: boolean;
|
||||
midiTime: number | null;
|
||||
midiDuration: number | null;
|
||||
midiVelocity: number | null;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
function buildMeasureTimings(parsed: ParsedScore, engrave: EngraveResult) {
|
||||
const timeline = parsed.measures;
|
||||
let accumulatedMs = 0;
|
||||
return timeline.map((measure, index) => {
|
||||
const durationQuarters = measure.durationQuarters > 0 ? measure.durationQuarters : 4;
|
||||
const durationMs = measureDurationSeconds(measure, parsed.tempoEvents) * 1000;
|
||||
const verovioStart = engrave.measureStartMs.get(measure.id);
|
||||
const timing = {
|
||||
index,
|
||||
id: measure.id,
|
||||
startMs: accumulatedMs,
|
||||
endMs: accumulatedMs + durationMs,
|
||||
verovioStartMs: Number.isFinite(verovioStart) ? (verovioStart as number) : accumulatedMs,
|
||||
durationQuarters,
|
||||
};
|
||||
accumulatedMs += durationMs;
|
||||
return timing;
|
||||
});
|
||||
}
|
||||
|
||||
function matchMidi(
|
||||
performed: PerformedNote[],
|
||||
midiNotes: MidiNote[],
|
||||
warnings: string[],
|
||||
): {matchedAttacks: number; unmatchedAttacks: number; unmatchedMidi: number} {
|
||||
const matched = new Array<boolean>(performed.length).fill(false);
|
||||
let minIndex = 0;
|
||||
let unmatchedMidi = 0;
|
||||
const window = 192;
|
||||
|
||||
for (const midi of midiNotes) {
|
||||
while (minIndex < performed.length && matched[minIndex]) minIndex++;
|
||||
if (minIndex >= performed.length) {
|
||||
unmatchedMidi++;
|
||||
continue;
|
||||
}
|
||||
const start = Math.max(0, minIndex - 32);
|
||||
const end = Math.min(performed.length, minIndex + window);
|
||||
let best = -1;
|
||||
let bestScore = Number.POSITIVE_INFINITY;
|
||||
for (let i = start; i < end; i++) {
|
||||
if (matched[i]) continue;
|
||||
const note = performed[i]!;
|
||||
if (note.pitch !== midi.midi) continue;
|
||||
const dt = Math.abs(note.nominalTimeMs - midi.time * 1000);
|
||||
const distancePenalty = i >= minIndex ? (i - minIndex) * 1.5 : (minIndex - i) * 4;
|
||||
const tiePenalty = note.isTieContinuation ? 30 : 0;
|
||||
const score = dt + distancePenalty + tiePenalty;
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
if (best < 0) {
|
||||
unmatchedMidi++;
|
||||
continue;
|
||||
}
|
||||
matched[best] = true;
|
||||
performed[best]!.midiTime = midi.time;
|
||||
performed[best]!.midiDuration = midi.duration;
|
||||
performed[best]!.midiVelocity = midi.velocity;
|
||||
if (bestScore > 8000) {
|
||||
warnings.push(
|
||||
`MIDI 与谱面在 ${midi.time.toFixed(2)}s 附近的匹配偏差较大(${(bestScore / 1000).toFixed(2)}s)。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let unmatchedAttacks = 0;
|
||||
let matchedAttacks = 0;
|
||||
for (let i = 0; i < performed.length; i++) {
|
||||
const note = performed[i]!;
|
||||
if (note.isTieContinuation) continue;
|
||||
if (matched[i]) matchedAttacks++;
|
||||
else unmatchedAttacks++;
|
||||
}
|
||||
return {matchedAttacks, unmatchedAttacks, unmatchedMidi};
|
||||
}
|
||||
|
||||
export function buildEvents(input: BuildEventsInput): BuildEventsResult {
|
||||
const {parsed, engrave, midi, performanceOrder, audioDuration, settings} = input;
|
||||
const warnings = [...parsed.warnings];
|
||||
const timings = buildMeasureTimings(parsed, engrave);
|
||||
const performed: PerformedNote[] = [];
|
||||
let accumulatedMs = 0;
|
||||
let missingGeometry = 0;
|
||||
let missingTime = 0;
|
||||
let cueCount = 0;
|
||||
|
||||
performanceOrder.forEach((measureIndex, occurrence) => {
|
||||
const timing = timings[measureIndex];
|
||||
if (!timing) return;
|
||||
const occurrenceStartMs = accumulatedMs;
|
||||
accumulatedMs += timing.endMs - timing.startMs;
|
||||
const notes = parsed.notesByMeasure[measureIndex] ?? [];
|
||||
for (const note of notes) {
|
||||
const scoreNote = note as ParsedNote;
|
||||
if (scoreNote.isRest) continue;
|
||||
const geometry = engrave.geometry.notes[scoreNote.id];
|
||||
if (!geometry) {
|
||||
missingGeometry++;
|
||||
continue;
|
||||
}
|
||||
if (scoreNote.cue) {
|
||||
cueCount++;
|
||||
continue;
|
||||
}
|
||||
const noteTiming = engrave.noteTimes.get(scoreNote.id);
|
||||
const quarters = Math.max(1e-6, timing.durationQuarters);
|
||||
const msPerQuarter = (timing.endMs - timing.startMs) / quarters;
|
||||
let offsetMs: number;
|
||||
let durationMs: number;
|
||||
if (noteTiming) {
|
||||
offsetMs = noteTiming.timeMs - timing.verovioStartMs;
|
||||
durationMs = noteTiming.durationMs;
|
||||
} else {
|
||||
missingTime++;
|
||||
offsetMs = scoreNote.onset * msPerQuarter;
|
||||
durationMs = scoreNote.duration * msPerQuarter;
|
||||
}
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) durationMs = 120;
|
||||
const pitch = scoreNote.pitch ?? noteTiming?.pitch ?? null;
|
||||
if (pitch === null) continue;
|
||||
performed.push({
|
||||
eventId: `evt-${occurrence}-${scoreNote.id}`,
|
||||
scoreId: scoreNote.id,
|
||||
measureIndex,
|
||||
occurrence,
|
||||
partIndex: scoreNote.partIndex,
|
||||
staff: scoreNote.lane,
|
||||
pitch,
|
||||
nominalTimeMs: occurrenceStartMs + offsetMs,
|
||||
nominalDurationMs: durationMs,
|
||||
x: geometry.x,
|
||||
y: geometry.y,
|
||||
isGrace: scoreNote.isGrace,
|
||||
isTieContinuation: scoreNote.tieStop,
|
||||
isCue: scoreNote.cue,
|
||||
midiTime: null,
|
||||
midiDuration: null,
|
||||
midiVelocity: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
performed.sort(
|
||||
(a, b) =>
|
||||
a.nominalTimeMs - b.nominalTimeMs ||
|
||||
a.partIndex - b.partIndex ||
|
||||
a.pitch - b.pitch ||
|
||||
a.eventId.localeCompare(b.eventId),
|
||||
);
|
||||
|
||||
let mapping: {matchedAttacks: number; unmatchedAttacks: number; unmatchedMidi: number};
|
||||
if (midi && midi.notes.length) {
|
||||
mapping = matchMidi(performed, midi.notes, warnings);
|
||||
} else {
|
||||
mapping = {matchedAttacks: 0, unmatchedAttacks: performed.filter((n) => !n.isTieContinuation).length, unmatchedMidi: 0};
|
||||
warnings.push('没有使用 MIDI 时间;动画使用谱面标称时间,上传音频时可能需要手动对齐。');
|
||||
}
|
||||
|
||||
const events: NoteEvent[] = [];
|
||||
for (const note of performed) {
|
||||
if (note.isTieContinuation || note.isCue) continue;
|
||||
const matched = note.midiTime !== null;
|
||||
let time = matched ? note.midiTime! : note.nominalTimeMs / 1000;
|
||||
if (!matched && note.isGrace) time = Math.max(0, time - 0.07);
|
||||
const duration = matched ? note.midiDuration ?? 0 : note.nominalDurationMs / 1000;
|
||||
const velocity = matched
|
||||
? clamp(Math.round((note.midiVelocity ?? 0.63) * 127), 1, 127)
|
||||
: 80;
|
||||
events.push({
|
||||
eventId: note.eventId,
|
||||
scoreId: note.scoreId,
|
||||
time: Math.max(0, time),
|
||||
duration: Math.max(0.02, duration),
|
||||
pitch: note.pitch,
|
||||
velocity,
|
||||
staff: note.staff,
|
||||
x: note.x,
|
||||
y: note.y,
|
||||
kind: 'attack',
|
||||
grace: note.isGrace || undefined,
|
||||
});
|
||||
}
|
||||
events.sort((a, b) => a.time - b.time || a.staff - b.staff || a.pitch - b.pitch);
|
||||
|
||||
if (!events.length) {
|
||||
throw new Error('没有生成任何可播放的音符事件。请检查谱面是否为有效音高。');
|
||||
}
|
||||
|
||||
const maxEventEnd = events.reduce((max, event) => Math.max(max, event.time + event.duration), 0);
|
||||
const musicDuration = Math.max(maxEventEnd, accumulatedMs / 1000, 0.5);
|
||||
const displayHeight = settings.height * settings.scoreHeightRatio;
|
||||
const displayScale = displayHeight / engrave.geometry.height;
|
||||
const viewWidthScore = settings.width / displayScale;
|
||||
const maxLeft = Math.max(0, engrave.geometry.width - viewWidthScore);
|
||||
const scroll = buildScrollKeys(events, settings.scrollLead, viewWidthScore, maxLeft, musicDuration);
|
||||
|
||||
if (mapping.unmatchedMidi > 0) {
|
||||
warnings.push(`${mapping.unmatchedMidi} 个 MIDI 起音没有对应谱面音符,已忽略(通常来自装饰音或额外声部)。`);
|
||||
}
|
||||
if (mapping.unmatchedAttacks > 0) {
|
||||
warnings.push(`${mapping.unmatchedAttacks} 个谱面音符没有匹配到 MIDI 起音,使用标称时间与默认力度。`);
|
||||
}
|
||||
if (missingGeometry > 0) {
|
||||
warnings.push(`${missingGeometry} 个音符没有取到符头坐标,已跳过。`);
|
||||
}
|
||||
if (missingTime > 0) {
|
||||
warnings.push(`${missingTime} 个音符没有 Verovio 时间,使用拍位估算。`);
|
||||
}
|
||||
if (cueCount > 0) {
|
||||
warnings.push(`${cueCount} 个提示音符(cue)已跳过动画。`);
|
||||
}
|
||||
if (mapping.matchedAttacks > 0 && mapping.matchedAttacks / Math.max(1, events.length) < 0.85) {
|
||||
warnings.push('MIDI 与谱面的匹配率低于 85%,建议检查反复/装饰音或改用同源 MIDI。');
|
||||
}
|
||||
|
||||
const meta: ScoreMeta = {
|
||||
title: parsed.title || 'Untitled',
|
||||
subtitle: parsed.subtitle,
|
||||
composer: parsed.composer,
|
||||
svgWidth: engrave.geometry.width,
|
||||
svgHeight: engrave.geometry.height,
|
||||
musicDuration,
|
||||
audioDuration,
|
||||
measureCount: parsed.measures.length,
|
||||
scoreNoteCount: performed.filter((n) => !n.isTieContinuation && !n.isCue).length,
|
||||
midiNoteCount: midi?.notes.length ?? 0,
|
||||
mappedNoteCount: mapping.matchedAttacks,
|
||||
warnings,
|
||||
unsupported: parsed.unsupported,
|
||||
};
|
||||
|
||||
return {
|
||||
events,
|
||||
activations: events,
|
||||
scroll,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import {DOMParser} from '@xmldom/xmldom';
|
||||
import getBounds from 'svg-path-bounds';
|
||||
|
||||
export type NoteGeometry = {x: number; y: number};
|
||||
export type SvgGeometry = {
|
||||
width: number;
|
||||
height: number;
|
||||
notes: Record<string, NoteGeometry>;
|
||||
noteCount: number;
|
||||
};
|
||||
|
||||
type Matrix = [number, number, number, number, number, number];
|
||||
|
||||
const IDENTITY: Matrix = [1, 0, 0, 1, 0, 0];
|
||||
|
||||
function multiply(a: Matrix, b: Matrix): Matrix {
|
||||
return [
|
||||
a[0] * b[0] + a[2] * b[1],
|
||||
a[1] * b[0] + a[3] * b[1],
|
||||
a[0] * b[2] + a[2] * b[3],
|
||||
a[1] * b[2] + a[3] * b[3],
|
||||
a[0] * b[4] + a[2] * b[5] + a[4],
|
||||
a[1] * b[4] + a[3] * b[5] + a[5],
|
||||
];
|
||||
}
|
||||
|
||||
function parseTransform(value: string | null): Matrix {
|
||||
if (!value) return IDENTITY;
|
||||
let matrix: Matrix = IDENTITY;
|
||||
const re = /(matrix|translate|scale|rotate)\s*\(([^)]*)\)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(value))) {
|
||||
const args = (match[2] ?? '')
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean)
|
||||
.map(Number);
|
||||
let next: Matrix = IDENTITY;
|
||||
switch (match[1]) {
|
||||
case 'matrix':
|
||||
if (args.length >= 6) {
|
||||
next = [args[0]!, args[1]!, args[2]!, args[3]!, args[4]!, args[5]!];
|
||||
}
|
||||
break;
|
||||
case 'translate':
|
||||
next = [1, 0, 0, 1, args[0] ?? 0, args[1] ?? 0];
|
||||
break;
|
||||
case 'scale': {
|
||||
const sx = args[0] ?? 1;
|
||||
const sy = args[1] ?? sx;
|
||||
next = [sx, 0, 0, sy, 0, 0];
|
||||
break;
|
||||
}
|
||||
case 'rotate': {
|
||||
const angle = ((args[0] ?? 0) * Math.PI) / 180;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const cx = args[1] ?? 0;
|
||||
const cy = args[2] ?? 0;
|
||||
// translate(cx,cy) rotate(a) translate(-cx,-cy)
|
||||
next = [
|
||||
cos,
|
||||
sin,
|
||||
-sin,
|
||||
cos,
|
||||
cx - cx * cos + cy * sin,
|
||||
cy - cx * sin - cy * cos,
|
||||
];
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
matrix = multiply(matrix, next);
|
||||
}
|
||||
return matrix;
|
||||
}
|
||||
|
||||
function applyMatrix(m: Matrix, x: number, y: number): [number, number] {
|
||||
return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
|
||||
}
|
||||
|
||||
function classList(el: Element): string[] {
|
||||
return (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function localName(el: Element): string {
|
||||
return (el.localName || el.nodeName || '').toLowerCase();
|
||||
}
|
||||
|
||||
function childElements(el: Element): Element[] {
|
||||
const out: Element[] = [];
|
||||
for (let node = el.firstChild; node; node = node.nextSibling) {
|
||||
if (node.nodeType === 1) out.push(node as Element);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findDescendantByClass(el: Element, className: string): Element | null {
|
||||
const stack: Element[] = [...childElements(el)];
|
||||
while (stack.length) {
|
||||
const current = stack.shift()!;
|
||||
if (classList(current).includes(className)) return current;
|
||||
stack.push(...childElements(current));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFirstUse(el: Element): Element | null {
|
||||
const stack: Element[] = [...childElements(el)];
|
||||
while (stack.length) {
|
||||
const current = stack.shift()!;
|
||||
if (localName(current) === 'use') return current;
|
||||
stack.push(...childElements(current));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function elementBox(el: Element): [number, number, number, number] | null {
|
||||
const name = localName(el);
|
||||
try {
|
||||
if (name === 'path') {
|
||||
const d = el.getAttribute('d');
|
||||
if (!d) return null;
|
||||
return getBounds(d) as [number, number, number, number];
|
||||
}
|
||||
if (name === 'rect') {
|
||||
const x = Number(el.getAttribute('x') ?? 0);
|
||||
const y = Number(el.getAttribute('y') ?? 0);
|
||||
const w = Number(el.getAttribute('width') ?? 0);
|
||||
const h = Number(el.getAttribute('height') ?? 0);
|
||||
return [x, y, x + w, y + h];
|
||||
}
|
||||
if (name === 'ellipse') {
|
||||
const cx = Number(el.getAttribute('cx') ?? 0);
|
||||
const cy = Number(el.getAttribute('cy') ?? 0);
|
||||
const rx = Number(el.getAttribute('rx') ?? 0);
|
||||
const ry = Number(el.getAttribute('ry') ?? 0);
|
||||
return [cx - rx, cy - ry, cx + rx, cy + ry];
|
||||
}
|
||||
if (name === 'circle') {
|
||||
const cx = Number(el.getAttribute('cx') ?? 0);
|
||||
const cy = Number(el.getAttribute('cy') ?? 0);
|
||||
const r = Number(el.getAttribute('r') ?? 0);
|
||||
return [cx - r, cy - r, cx + r, cy + r];
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function glyphBounds(glyph: Element): [number, number, number, number] | null {
|
||||
const stack: Element[] = [...childElements(glyph)];
|
||||
let box: [number, number, number, number] | null = null;
|
||||
while (stack.length) {
|
||||
const current = stack.shift()!;
|
||||
const name = localName(current);
|
||||
if (name === 'g' || name === 'symbol') {
|
||||
stack.push(...childElements(current));
|
||||
continue;
|
||||
}
|
||||
const raw = elementBox(current);
|
||||
if (!raw) continue;
|
||||
const matrix = parseTransform(current.getAttribute('transform'));
|
||||
const corners = [
|
||||
applyMatrix(matrix, raw[0], raw[1]),
|
||||
applyMatrix(matrix, raw[2], raw[1]),
|
||||
applyMatrix(matrix, raw[0], raw[3]),
|
||||
applyMatrix(matrix, raw[2], raw[3]),
|
||||
];
|
||||
for (const [x, y] of corners) {
|
||||
if (!box) box = [x, y, x, y];
|
||||
else {
|
||||
box = [Math.min(box[0], x), Math.min(box[1], y), Math.max(box[2], x), Math.max(box[3], y)];
|
||||
}
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function collectIds(root: Element): Map<string, Element> {
|
||||
const map = new Map<string, Element>();
|
||||
const stack: Element[] = [root];
|
||||
while (stack.length) {
|
||||
const current = stack.pop()!;
|
||||
const id = current.getAttribute('id');
|
||||
if (id) map.set(id, current);
|
||||
stack.push(...childElements(current));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function extractNoteGeometry(svg: string): SvgGeometry {
|
||||
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
|
||||
const root = doc.documentElement;
|
||||
const viewBoxRaw = root.getAttribute('viewBox') ?? '';
|
||||
const viewBox = viewBoxRaw.split(/[\s,]+/).filter(Boolean).map(Number);
|
||||
const width = Number(root.getAttribute('width')) || viewBox[2] || 0;
|
||||
const height = Number(root.getAttribute('height')) || viewBox[3] || 0;
|
||||
if (!width || !height) throw new Error('谱面 SVG 缺少 viewBox/宽高。');
|
||||
|
||||
const allSvgs = doc.getElementsByTagName('svg');
|
||||
let innerSvg: Element | null = null;
|
||||
for (let i = 0; i < allSvgs.length; i++) {
|
||||
const candidate = allSvgs.item(i) as Element | null;
|
||||
if (candidate && candidate !== root && classList(candidate).includes('definition-scale')) {
|
||||
innerSvg = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!innerSvg) {
|
||||
for (let i = 0; i < allSvgs.length; i++) {
|
||||
const candidate = allSvgs.item(i) as Element | null;
|
||||
if (candidate && candidate !== root) {
|
||||
innerSvg = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const innerBoxRaw = innerSvg?.getAttribute('viewBox') ?? viewBoxRaw;
|
||||
const innerBox = innerBoxRaw.split(/[\s,]+/).filter(Boolean).map(Number);
|
||||
const outerX = viewBox[0] ?? 0;
|
||||
const outerY = viewBox[1] ?? 0;
|
||||
const innerX = innerBox[0] ?? 0;
|
||||
const innerY = innerBox[1] ?? 0;
|
||||
const scaleX = (viewBox[2] ?? width) / (innerBox[2] || viewBox[2] || width);
|
||||
const scaleY = (viewBox[3] ?? height) / (innerBox[3] || viewBox[3] || height);
|
||||
|
||||
const byId = collectIds(root);
|
||||
const notes: Record<string, NoteGeometry> = {};
|
||||
const allGroups = root.getElementsByTagName('g');
|
||||
for (let i = 0; i < allGroups.length; i++) {
|
||||
const group = allGroups.item(i) as Element | null;
|
||||
if (!group || !classList(group).includes('note')) continue;
|
||||
const id = group.getAttribute('id');
|
||||
if (!id) continue;
|
||||
const head = findDescendantByClass(group, 'notehead');
|
||||
if (!head) continue;
|
||||
|
||||
let point: [number, number] | null = null;
|
||||
|
||||
const use = findFirstUse(head);
|
||||
if (use) {
|
||||
const href = use.getAttribute('xlink:href') || use.getAttribute('href') || '';
|
||||
const glyphId = href.startsWith('#') ? href.slice(1) : href;
|
||||
const glyph = byId.get(glyphId);
|
||||
const box = glyph ? glyphBounds(glyph) : null;
|
||||
if (box) {
|
||||
let p: [number, number] = [(box[0] + box[2]) / 2, (box[1] + box[3]) / 2];
|
||||
const useMatrix = multiply(
|
||||
parseTransform(use.getAttribute('transform')),
|
||||
parseTransform(
|
||||
`translate(${Number(use.getAttribute('x') ?? 0)},${Number(use.getAttribute('y') ?? 0)})`,
|
||||
),
|
||||
);
|
||||
p = applyMatrix(useMatrix, p[0], p[1]);
|
||||
point = p;
|
||||
}
|
||||
}
|
||||
|
||||
if (!point) {
|
||||
const box = elementBox(head);
|
||||
if (!box) continue;
|
||||
point = [(box[0] + box[2]) / 2, (box[1] + box[3]) / 2];
|
||||
}
|
||||
|
||||
// Apply ancestor transforms from the notehead up to (but not including) the inner SVG.
|
||||
let node: Element | null = head;
|
||||
while (node && node !== innerSvg && node.nodeType === 1) {
|
||||
const matrix = parseTransform(node.getAttribute('transform'));
|
||||
point = applyMatrix(matrix, point[0], point[1]);
|
||||
node = node.parentNode as Element | null;
|
||||
}
|
||||
|
||||
const x = outerX + (point[0] - innerX) * scaleX;
|
||||
const y = outerY + (point[1] - innerY) * scaleY;
|
||||
notes[id] = {x: Number(x.toFixed(4)), y: Number(y.toFixed(4))};
|
||||
}
|
||||
|
||||
return {width, height, notes, noteCount: Object.keys(notes).length};
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import multer from 'multer';
|
||||
import {z} from 'zod';
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
MAX_RENDER_SECONDS,
|
||||
MAX_UPLOAD_BYTES,
|
||||
type HealthResponse,
|
||||
type VisualSettings,
|
||||
} from '../shared/types.js';
|
||||
import {
|
||||
createJob,
|
||||
getJob,
|
||||
JOBS_DIR,
|
||||
jobDirectory,
|
||||
listJobs,
|
||||
loadJobsFromDisk,
|
||||
publicJob,
|
||||
updateJob,
|
||||
} from './jobs.js';
|
||||
import {buildScrollKeys} from '../shared/scroll.js';
|
||||
import {runPipeline} from './pipeline.js';
|
||||
import {enqueueRender} from './queue.js';
|
||||
import {findMuseScore, museScoreVersion} from './musescore.js';
|
||||
import {IS_PRODUCTION, PORT, PUBLIC_BASE_URL} from './config.js';
|
||||
|
||||
loadJobsFromDisk();
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({limit: '2mb'}));
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {fileSize: MAX_UPLOAD_BYTES, files: 2},
|
||||
});
|
||||
|
||||
const colorSchema = z.string().regex(/^#[0-9a-fA-F]{3,8}$/, '颜色必须是 #RRGGBB');
|
||||
const settingsSchema = z
|
||||
.object({
|
||||
width: z.number().int().min(320).max(3840),
|
||||
height: z.number().int().min(320).max(3840),
|
||||
fps: z.number().int().min(15).max(60),
|
||||
introSeconds: z.number().min(0).max(5),
|
||||
outroSeconds: z.number().min(0).max(15),
|
||||
scoreHeightRatio: z.number().min(0.18).max(0.82),
|
||||
scrollLead: z.number().min(0.1).max(0.7),
|
||||
paperColor: colorSchema,
|
||||
paperAlpha: z.number().min(0).max(1),
|
||||
scoreColor: colorSchema,
|
||||
background: colorSchema,
|
||||
staffColors: z.array(colorSchema).min(2).max(8),
|
||||
ballScale: z.number().min(0.4).max(3),
|
||||
ballLift: z.number().min(0).max(3),
|
||||
glow: z.number().min(0).max(3),
|
||||
showTitle: z.boolean(),
|
||||
showProgress: z.boolean(),
|
||||
titleFont: z.string().min(1).max(300),
|
||||
audioOffsetMs: z.number().min(-5000).max(5000),
|
||||
tiltX: z.number().min(-85).max(85),
|
||||
tiltY: z.number().min(-60).max(60),
|
||||
tiltZ: z.number().min(-60).max(60),
|
||||
perspective: z.number().min(400).max(6000),
|
||||
tiltZoom: z.number().min(0.5).max(3),
|
||||
})
|
||||
.partial();
|
||||
|
||||
function normalizeSettings(input: Partial<VisualSettings> | undefined): VisualSettings {
|
||||
return {...DEFAULT_SETTINGS, ...(input ?? {})};
|
||||
}
|
||||
|
||||
/** Scroll keys depend on the viewport and strip height, so recompute them when those change. */
|
||||
function scrollForSettings(
|
||||
data: {events: import('../shared/types.js').NoteEvent[]; meta: import('../shared/types.js').ScoreMeta},
|
||||
settings: VisualSettings,
|
||||
) {
|
||||
const displayHeight = settings.height * settings.scoreHeightRatio;
|
||||
const displayScale = displayHeight / Math.max(1, data.meta.svgHeight);
|
||||
const viewWidthScore = settings.width / displayScale;
|
||||
const maxLeft = Math.max(0, data.meta.svgWidth - viewWidthScore);
|
||||
return buildScrollKeys(
|
||||
data.events,
|
||||
settings.scrollLead,
|
||||
viewWidthScore,
|
||||
maxLeft,
|
||||
data.meta.musicDuration,
|
||||
);
|
||||
}
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
const musescorePath = findMuseScore();
|
||||
const body: HealthResponse = {
|
||||
ok: true,
|
||||
musescore: {
|
||||
available: Boolean(musescorePath),
|
||||
path: musescorePath ?? undefined,
|
||||
version: museScoreVersion(),
|
||||
},
|
||||
chrome: {
|
||||
available: true,
|
||||
note: 'Remotion 首次渲染会使用或下载 Chrome Headless Shell。',
|
||||
},
|
||||
ffmpeg: {
|
||||
available: true,
|
||||
note: 'Remotion 渲染使用内置 FFmpeg。',
|
||||
},
|
||||
version: process.env.npm_package_version ?? '0.1.0',
|
||||
};
|
||||
res.json(body);
|
||||
});
|
||||
|
||||
app.get('/api/jobs', (_req, res) => {
|
||||
res.json({jobs: listJobs().slice(0, 30).map((job) => publicJob(job))});
|
||||
});
|
||||
|
||||
app.post('/api/jobs', upload.fields([{name: 'score', maxCount: 1}, {name: 'audio', maxCount: 1}]), (req, res) => {
|
||||
const files = req.files as Record<string, Express.Multer.File[]> | undefined;
|
||||
const score = files?.score?.[0];
|
||||
if (!score) {
|
||||
res.status(400).json({error: '请选择 MusicXML / MXL / MIDI 谱面文件。'});
|
||||
return;
|
||||
}
|
||||
if (score.size > MAX_UPLOAD_BYTES) {
|
||||
res.status(413).json({error: '文件超过 30MB 限制。'});
|
||||
return;
|
||||
}
|
||||
let settings: VisualSettings = DEFAULT_SETTINGS;
|
||||
const rawSettings = typeof req.body?.settings === 'string' ? req.body.settings : null;
|
||||
if (rawSettings) {
|
||||
try {
|
||||
const parsed = settingsSchema.parse(JSON.parse(rawSettings));
|
||||
settings = normalizeSettings(parsed);
|
||||
} catch (error) {
|
||||
res.status(400).json({error: `设置无效:${error instanceof Error ? error.message : String(error)}`});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const metadata = {
|
||||
title: typeof req.body?.title === 'string' ? req.body.title : undefined,
|
||||
subtitle: typeof req.body?.subtitle === 'string' ? req.body.subtitle : undefined,
|
||||
composer: typeof req.body?.composer === 'string' ? req.body.composer : undefined,
|
||||
};
|
||||
const audio = files?.audio?.[0];
|
||||
const job = createJob({
|
||||
settings,
|
||||
inputName: score.originalname,
|
||||
inputFormat: path.extname(score.originalname).toLowerCase().replace('.', ''),
|
||||
});
|
||||
void runPipeline({
|
||||
jobId: job.id,
|
||||
file: {buffer: score.buffer, filename: score.originalname},
|
||||
audio: audio ? {buffer: audio.buffer, filename: audio.originalname} : undefined,
|
||||
settings,
|
||||
metadata,
|
||||
});
|
||||
res.status(202).json({id: job.id});
|
||||
});
|
||||
|
||||
app.get('/api/jobs/:id', (req, res) => {
|
||||
const job = getJob(req.params.id);
|
||||
if (!job) {
|
||||
res.status(404).json({error: '任务不存在。'});
|
||||
return;
|
||||
}
|
||||
res.json(publicJob(job));
|
||||
});
|
||||
|
||||
app.patch('/api/jobs/:id/settings', (req, res) => {
|
||||
const job = getJob(req.params.id);
|
||||
if (!job) {
|
||||
res.status(404).json({error: '任务不存在。'});
|
||||
return;
|
||||
}
|
||||
if (!job.data) {
|
||||
res.status(409).json({error: '任务还没有生成预览数据。'});
|
||||
return;
|
||||
}
|
||||
if (job.status === 'rendering') {
|
||||
res.status(409).json({error: '正在渲染,暂时不能修改参数。'});
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedSettings: Partial<VisualSettings>;
|
||||
try {
|
||||
parsedSettings = settingsSchema.parse(req.body?.settings ?? {});
|
||||
} catch (error) {
|
||||
res.status(400).json({error: `设置无效:${error instanceof Error ? error.message : String(error)}`});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = normalizeSettings({
|
||||
...job.data.settings,
|
||||
...job.settings,
|
||||
...parsedSettings,
|
||||
});
|
||||
|
||||
const meta = {...job.data.meta};
|
||||
const metadata = req.body?.metadata as
|
||||
| {title?: unknown; subtitle?: unknown; composer?: unknown}
|
||||
| undefined;
|
||||
if (typeof metadata?.title === 'string' && metadata.title.trim()) meta.title = metadata.title.trim();
|
||||
if (typeof metadata?.subtitle === 'string' && metadata.subtitle.trim()) {
|
||||
meta.subtitle = metadata.subtitle.trim();
|
||||
}
|
||||
if (typeof metadata?.composer === 'string' && metadata.composer.trim()) {
|
||||
meta.composer = metadata.composer.trim();
|
||||
}
|
||||
|
||||
const data = {
|
||||
...job.data,
|
||||
meta,
|
||||
settings,
|
||||
scroll: scrollForSettings(job.data, settings),
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(jobDirectory(job.id), 'score.json'), JSON.stringify(data, null, 2), 'utf8');
|
||||
const updated = updateJob(job.id, {
|
||||
settings,
|
||||
data,
|
||||
...(job.status === 'done'
|
||||
? {
|
||||
status: 'ready' as const,
|
||||
stage: 'done' as const,
|
||||
progress: 100,
|
||||
message: '参数已更新,可以重新导出。',
|
||||
videoFile: undefined,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
res.json(updated ? publicJob(updated) : {error: '任务不存在。'});
|
||||
});
|
||||
|
||||
app.post('/api/jobs/:id/render', (req, res) => {
|
||||
const job = getJob(req.params.id);
|
||||
if (!job) {
|
||||
res.status(404).json({error: '任务不存在。'});
|
||||
return;
|
||||
}
|
||||
if (!job.data) {
|
||||
res.status(409).json({error: '任务还没有生成预览数据。'});
|
||||
return;
|
||||
}
|
||||
if (job.data.meta.audioDuration > MAX_RENDER_SECONDS || job.data.meta.musicDuration > MAX_RENDER_SECONDS) {
|
||||
res.status(400).json({
|
||||
error: `这段音乐超过 ${Math.round(MAX_RENDER_SECONDS / 60)} 分钟,请先截取片段再渲染。`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (job.status === 'rendering') {
|
||||
res.json({ok: true, alreadyRunning: true});
|
||||
return;
|
||||
}
|
||||
updateJob(job.id, {message: '已加入渲染队列…'});
|
||||
enqueueRender(job.id);
|
||||
res.json({ok: true});
|
||||
});
|
||||
|
||||
app.get('/api/jobs/:id/video', (req, res) => {
|
||||
const job = getJob(req.params.id);
|
||||
if (!job || !job.videoFile) {
|
||||
res.status(404).json({error: '视频还没有生成。'});
|
||||
return;
|
||||
}
|
||||
const file = path.join(JOBS_DIR, job.id, job.videoFile);
|
||||
if (!fs.existsSync(file)) {
|
||||
res.status(404).json({error: '视频文件不存在。'});
|
||||
return;
|
||||
}
|
||||
const safeName = (job.data?.meta.title || 'music-visualizer').replace(/[\\/:*?"<>|]/g, '_').slice(0, 80);
|
||||
res.download(file, `${safeName}.mp4`);
|
||||
});
|
||||
|
||||
app.use('/jobs', express.static(JOBS_DIR, {maxAge: '1h'}));
|
||||
app.use('/demo', express.static(path.resolve('public/demo'), {maxAge: '1h'}));
|
||||
|
||||
if (IS_PRODUCTION) {
|
||||
const clientDir = path.resolve('dist/client');
|
||||
app.use(express.static(clientDir));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(clientDir, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
if (error instanceof multer.MulterError) {
|
||||
res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({error: error.message});
|
||||
return;
|
||||
}
|
||||
console.error(error);
|
||||
res.status(500).json({error: error instanceof Error ? error.message : '服务器错误。'});
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Music Visualizer API: http://127.0.0.1:${PORT}`);
|
||||
console.log(`Public base URL for render: ${PUBLIC_BASE_URL}`);
|
||||
console.log(`MuseScore: ${findMuseScore() ?? 'not found'}`);
|
||||
if (!IS_PRODUCTION) console.log('Client dev server: http://127.0.0.1:5173');
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {nanoid} from 'nanoid';
|
||||
import type {JobPublic, JobStage, JobStatus, RenderData, VisualSettings} from '../shared/types.js';
|
||||
|
||||
export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? 'data');
|
||||
export const JOBS_DIR = path.join(DATA_DIR, 'jobs');
|
||||
|
||||
export type JobRecord = {
|
||||
id: string;
|
||||
status: JobStatus;
|
||||
stage: JobStage;
|
||||
progress: number;
|
||||
message: string;
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
inputName?: string;
|
||||
inputFormat?: string;
|
||||
settings: VisualSettings;
|
||||
data?: RenderData;
|
||||
warnings: string[];
|
||||
unsupported: string[];
|
||||
videoFile?: string;
|
||||
audioFile?: string;
|
||||
audioMime?: string;
|
||||
};
|
||||
|
||||
const jobs = new Map<string, JobRecord>();
|
||||
|
||||
function jobFile(id: string): string {
|
||||
return path.join(JOBS_DIR, id, 'job.json');
|
||||
}
|
||||
|
||||
function ensureDirs(): void {
|
||||
fs.mkdirSync(JOBS_DIR, {recursive: true});
|
||||
}
|
||||
|
||||
function persist(job: JobRecord): void {
|
||||
ensureDirs();
|
||||
const dir = path.join(JOBS_DIR, job.id);
|
||||
fs.mkdirSync(dir, {recursive: true});
|
||||
fs.writeFileSync(jobFile(job.id), JSON.stringify(job, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export function loadJobsFromDisk(): void {
|
||||
ensureDirs();
|
||||
for (const entry of fs.readdirSync(JOBS_DIR, {withFileTypes: true})) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const file = jobFile(entry.name);
|
||||
if (!fs.existsSync(file)) continue;
|
||||
try {
|
||||
const job = JSON.parse(fs.readFileSync(file, 'utf8')) as JobRecord;
|
||||
if (job.status === 'running' || job.status === 'rendering' || job.status === 'queued') {
|
||||
job.status = 'error';
|
||||
job.stage = 'done';
|
||||
job.message = '服务重启,任务已中断。';
|
||||
job.error = '服务在任务完成前重启。请重新生成。';
|
||||
}
|
||||
jobs.set(job.id, job);
|
||||
} catch {
|
||||
// Ignore broken job metadata.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createJob(input: {
|
||||
settings: VisualSettings;
|
||||
inputName: string;
|
||||
inputFormat: string;
|
||||
}): JobRecord {
|
||||
const id = nanoid(10);
|
||||
const now = new Date().toISOString();
|
||||
const job: JobRecord = {
|
||||
id,
|
||||
status: 'queued',
|
||||
stage: 'queued',
|
||||
progress: 0,
|
||||
message: '等待处理…',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
inputName: input.inputName,
|
||||
inputFormat: input.inputFormat,
|
||||
settings: input.settings,
|
||||
warnings: [],
|
||||
unsupported: [],
|
||||
};
|
||||
jobs.set(id, job);
|
||||
fs.mkdirSync(path.join(JOBS_DIR, id), {recursive: true});
|
||||
persist(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
export function getJob(id: string): JobRecord | null {
|
||||
return jobs.get(id) ?? null;
|
||||
}
|
||||
|
||||
export function updateJob(id: string, patch: Partial<JobRecord>): JobRecord | null {
|
||||
const job = jobs.get(id);
|
||||
if (!job) return null;
|
||||
Object.assign(job, patch, {updatedAt: new Date().toISOString()});
|
||||
persist(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
export function listJobs(): JobRecord[] {
|
||||
return [...jobs.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function jobDirectory(id: string): string {
|
||||
return path.join(JOBS_DIR, id);
|
||||
}
|
||||
|
||||
export function publicJob(job: JobRecord): JobPublic {
|
||||
return {
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
stage: job.stage,
|
||||
progress: job.progress,
|
||||
message: job.message,
|
||||
error: job.error,
|
||||
warnings: job.warnings.length ? job.warnings : undefined,
|
||||
unsupported: job.unsupported.length ? job.unsupported : undefined,
|
||||
data: job.status === 'ready' || job.status === 'rendering' || job.status === 'done' ? job.data : undefined,
|
||||
videoUrl: job.videoFile ? `/jobs/${job.id}/${job.videoFile}` : undefined,
|
||||
createdAt: job.createdAt,
|
||||
inputName: job.inputName,
|
||||
inputFormat: job.inputFormat,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import fs from 'node:fs';
|
||||
import pkg from '@tonejs/midi';
|
||||
|
||||
const {Midi} = pkg;
|
||||
|
||||
export type MidiNote = {
|
||||
time: number;
|
||||
duration: number;
|
||||
midi: number;
|
||||
velocity: number;
|
||||
channel: number;
|
||||
track: number;
|
||||
};
|
||||
|
||||
export type ParsedMidi = {
|
||||
notes: MidiNote[];
|
||||
duration: number;
|
||||
trackCount: number;
|
||||
tempos: {time: number; bpm: number}[];
|
||||
};
|
||||
|
||||
export function parseMidiFile(filePath: string): ParsedMidi {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const midi = new Midi(buffer);
|
||||
const notes: MidiNote[] = [];
|
||||
midi.tracks.forEach((track, trackIndex) => {
|
||||
const channel = track.channel ?? 0;
|
||||
if (channel === 9) return; // percussion
|
||||
for (const note of track.notes) {
|
||||
notes.push({
|
||||
time: note.time,
|
||||
duration: note.duration,
|
||||
midi: note.midi,
|
||||
velocity: note.velocity,
|
||||
channel,
|
||||
track: trackIndex,
|
||||
});
|
||||
}
|
||||
});
|
||||
notes.sort((a, b) => a.time - b.time || a.midi - b.midi || a.duration - b.duration);
|
||||
return {
|
||||
notes,
|
||||
duration: midi.duration,
|
||||
trackCount: midi.tracks.length,
|
||||
tempos: midi.header.tempos.map((tempo) => ({time: tempo.time ?? 0, bpm: tempo.bpm})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import {spawn, spawnSync} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
let cachedPath: string | null | undefined;
|
||||
|
||||
function exists(file: string | undefined): file is string {
|
||||
if (!file) return false;
|
||||
try {
|
||||
return fs.statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fromPath(names: string[]): string | null {
|
||||
for (const name of names) {
|
||||
const command = process.platform === 'win32' ? 'where.exe' : 'which';
|
||||
const result = spawnSync(command, [name], {encoding: 'utf8', windowsHide: true});
|
||||
if (result.status === 0) {
|
||||
const first = result.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
||||
if (exists(first)) return first;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findMuseScore(): string | null {
|
||||
if (cachedPath !== undefined) return cachedPath;
|
||||
const candidates: string[] = [];
|
||||
if (process.env.MUSESCORE_PATH) candidates.push(process.env.MUSESCORE_PATH);
|
||||
if (process.platform === 'win32') {
|
||||
candidates.push(
|
||||
'C:\\Program Files\\MuseScore 4\\bin\\MuseScore4.exe',
|
||||
'C:\\Program Files\\MuseScore 4\\bin\\mscore.exe',
|
||||
'C:\\Program Files\\MuseScore 4\\bin\\musescore.exe',
|
||||
'C:\\Program Files (x86)\\MuseScore 4\\bin\\MuseScore4.exe',
|
||||
'C:\\Program Files\\MuseScore 3\\bin\\mscore.exe',
|
||||
);
|
||||
} else if (process.platform === 'darwin') {
|
||||
candidates.push(
|
||||
'/Applications/MuseScore 4.app/Contents/MacOS/mscore',
|
||||
'/Applications/MuseScore 4.app/Contents/MacOS/MuseScore 4',
|
||||
'/Applications/MuseScore 3.app/Contents/MacOS/mscore',
|
||||
);
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (exists(candidate)) {
|
||||
cachedPath = candidate;
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
cachedPath = fromPath(['MuseScore4', 'musescore4', 'mscore', 'musescore']);
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
export function hasMuseScore(): boolean {
|
||||
return Boolean(findMuseScore());
|
||||
}
|
||||
|
||||
export class MuseScoreError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'MuseScoreError';
|
||||
}
|
||||
}
|
||||
|
||||
function run(command: string, args: string[], timeoutMs: number): Promise<{stdout: string; stderr: string}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {windowsHide: true});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill();
|
||||
reject(new MuseScoreError(`MuseScore 处理超时(${Math.round(timeoutMs / 1000)} 秒)。`));
|
||||
}, timeoutMs);
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new MuseScoreError(`无法启动 MuseScore:${error.message}`));
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolve({stdout, stderr});
|
||||
else {
|
||||
const detail = (stderr || stdout).trim().slice(-1200);
|
||||
reject(new MuseScoreError(`MuseScore 退出码 ${code}${detail ? `:${detail}` : ''}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function exportOne(input: string, output: string, timeoutMs: number): Promise<void> {
|
||||
const executable = findMuseScore();
|
||||
if (!executable) throw new MuseScoreError('没有找到 MuseScore 4。请安装 MuseScore,或设置 MUSESCORE_PATH。');
|
||||
fs.mkdirSync(path.dirname(output), {recursive: true});
|
||||
if (fs.existsSync(output)) fs.rmSync(output, {force: true});
|
||||
await run(executable, ['-o', output, input], timeoutMs);
|
||||
if (!fs.existsSync(output) || fs.statSync(output).size === 0) {
|
||||
throw new MuseScoreError(`MuseScore 没有生成 ${path.basename(output)}。请检查输入谱面。`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportWav(inputMusicXml: string, outputDir: string): Promise<string> {
|
||||
fs.mkdirSync(outputDir, {recursive: true});
|
||||
const wav = path.join(outputDir, 'audio.wav');
|
||||
await exportOne(inputMusicXml, wav, 5 * 60 * 1000);
|
||||
return wav;
|
||||
}
|
||||
|
||||
export async function exportMidi(inputMusicXml: string, outputDir: string): Promise<string> {
|
||||
fs.mkdirSync(outputDir, {recursive: true});
|
||||
const midi = path.join(outputDir, 'performance.mid');
|
||||
await exportOne(inputMusicXml, midi, 5 * 60 * 1000);
|
||||
return midi;
|
||||
}
|
||||
|
||||
export async function exportAudioAndMidi(inputMusicXml: string, outputDir: string): Promise<{wav: string; midi: string}> {
|
||||
const wav = await exportWav(inputMusicXml, outputDir);
|
||||
const midi = await exportMidi(inputMusicXml, outputDir);
|
||||
return {wav, midi};
|
||||
}
|
||||
|
||||
export async function convertMidiToMusicXml(inputMidi: string, outputMusicXml: string): Promise<void> {
|
||||
await exportOne(inputMidi, outputMusicXml, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-trip a score through MuseScore so it regains the engraving information
|
||||
* (beams, tuplet grouping) that MIDI-derived MusicXML files often lack.
|
||||
*/
|
||||
export async function normalizeMusicXml(inputMusicXml: string, outputMusicXml: string): Promise<void> {
|
||||
await exportOne(inputMusicXml, outputMusicXml, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
export function museScoreVersion(): string | undefined {
|
||||
const executable = findMuseScore();
|
||||
if (!executable) return undefined;
|
||||
try {
|
||||
const result = spawnSync(executable, ['--version'], {encoding: 'utf8', windowsHide: true, timeout: 10000});
|
||||
const text = `${result.stdout ?? ''} ${result.stderr ?? ''}`.trim();
|
||||
const match = text.match(/(?:MuseScore|mscore)\s*([0-9][^\s]*)/i);
|
||||
return match?.[1] ?? text.split(/\r?\n/)[0]?.slice(0, 80);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import {DOMParser, XMLSerializer} from '@xmldom/xmldom';
|
||||
import {unzipSync} from 'fflate';
|
||||
|
||||
export type ParsedNote = {
|
||||
id: string;
|
||||
partIndex: number;
|
||||
measureIndex: number;
|
||||
noteIndex: number;
|
||||
staff: number;
|
||||
/** Stable visual lane: partIndex * 10 + staff. */
|
||||
lane: number;
|
||||
voice: string;
|
||||
pitch: number | null;
|
||||
isRest: boolean;
|
||||
isGrace: boolean;
|
||||
isChord: boolean;
|
||||
/** Quarter-note units within the measure. */
|
||||
onset: number;
|
||||
duration: number;
|
||||
tieStart: boolean;
|
||||
tieStop: boolean;
|
||||
type: string;
|
||||
dots: number;
|
||||
cue: boolean;
|
||||
};
|
||||
|
||||
export type EndingGroup = {
|
||||
start: number;
|
||||
end: number;
|
||||
numbers: number[];
|
||||
};
|
||||
|
||||
export type TempoEvent = {
|
||||
measureIndex: number;
|
||||
/** Quarter-note offset inside the measure. */
|
||||
beat: number;
|
||||
bpm: number;
|
||||
};
|
||||
|
||||
export type ParsedMeasure = {
|
||||
index: number;
|
||||
id: string;
|
||||
number: string;
|
||||
partIndex: number;
|
||||
repeatForward: boolean;
|
||||
repeatBackward: boolean;
|
||||
repeatTimes: number | null;
|
||||
forwardTimes: number | null;
|
||||
endingStart: boolean;
|
||||
endingStop: boolean;
|
||||
endingNumbers: number[] | null;
|
||||
endingGroup: EndingGroup | null;
|
||||
dacapo: boolean;
|
||||
dalsegno: boolean;
|
||||
tocoda: boolean;
|
||||
fine: boolean;
|
||||
segno: boolean;
|
||||
coda: boolean;
|
||||
tempoMarks: number;
|
||||
fermataCount: number;
|
||||
cueCount: number;
|
||||
/** Notated length in quarter notes, including trailing rests. */
|
||||
durationQuarters: number;
|
||||
};
|
||||
|
||||
export type ParsedPart = {
|
||||
index: number;
|
||||
id: string;
|
||||
name: string;
|
||||
staves: number;
|
||||
measures: ParsedMeasure[];
|
||||
};
|
||||
|
||||
export type ParsedScore = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
composer: string;
|
||||
parts: ParsedPart[];
|
||||
/** First part's measure timeline. */
|
||||
measures: ParsedMeasure[];
|
||||
/** All notes grouped by measure index across parts. */
|
||||
notesByMeasure: ParsedNote[][];
|
||||
allNotes: ParsedNote[];
|
||||
/** Tempo marks from the first part, sorted by measure and beat. */
|
||||
tempoEvents: TempoEvent[];
|
||||
annotatedXml: string;
|
||||
originalXml: string;
|
||||
warnings: string[];
|
||||
unsupported: string[];
|
||||
};
|
||||
|
||||
const STEP_SEMITONES: Record<string, number> = {
|
||||
C: 0,
|
||||
D: 2,
|
||||
E: 4,
|
||||
F: 5,
|
||||
G: 7,
|
||||
A: 9,
|
||||
B: 11,
|
||||
};
|
||||
|
||||
function childElements(el: Element, localName?: string): Element[] {
|
||||
const out: Element[] = [];
|
||||
for (let node = el.firstChild; node; node = node.nextSibling) {
|
||||
if (node.nodeType !== 1) continue;
|
||||
const element = node as Element;
|
||||
const name = element.localName || element.nodeName;
|
||||
if (!localName || name === localName) out.push(element);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function firstChild(el: Element, localName: string): Element | null {
|
||||
for (let node = el.firstChild; node; node = node.nextSibling) {
|
||||
if (node.nodeType !== 1) continue;
|
||||
const element = node as Element;
|
||||
if ((element.localName || element.nodeName) === localName) return element;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function childText(el: Element, localName: string): string | null {
|
||||
return firstChild(el, localName)?.textContent?.trim() ?? null;
|
||||
}
|
||||
|
||||
function descendants(el: Element, localName: string): Element[] {
|
||||
const nodes = el.getElementsByTagName(localName);
|
||||
return Array.from({length: nodes.length}, (_, i) => nodes.item(i) as Element | null).filter(
|
||||
(node): node is Element => Boolean(node),
|
||||
);
|
||||
}
|
||||
|
||||
function textOfDescendant(el: Element, localName: string): string | null {
|
||||
return descendants(el, localName)[0]?.textContent?.trim() ?? null;
|
||||
}
|
||||
|
||||
function parsePitch(note: Element): number | null {
|
||||
const pitch = firstChild(note, 'pitch');
|
||||
if (!pitch) return null;
|
||||
const step = childText(pitch, 'step');
|
||||
const octaveRaw = childText(pitch, 'octave');
|
||||
if (!step || octaveRaw === null) return null;
|
||||
const octave = Number(octaveRaw);
|
||||
const alter = Number(childText(pitch, 'alter') ?? '0');
|
||||
const semitone = STEP_SEMITONES[step.toUpperCase()];
|
||||
if (!Number.isFinite(octave) || semitone === undefined || !Number.isFinite(alter)) return null;
|
||||
return 12 * (octave + 1) + semitone + alter;
|
||||
}
|
||||
|
||||
function hasTie(note: Element, type: 'start' | 'stop'): boolean {
|
||||
const direct = descendants(note, 'tie').some((tie) => tie.getAttribute('type') === type);
|
||||
if (direct) return true;
|
||||
const tied = descendants(note, 'tied').some((tie) => tie.getAttribute('type') === type);
|
||||
return tied;
|
||||
}
|
||||
|
||||
function readSoundFlags(el: Element | null): {
|
||||
dacapo: boolean;
|
||||
dalsegno: boolean;
|
||||
tocoda: boolean;
|
||||
fine: boolean;
|
||||
} {
|
||||
const result = {dacapo: false, dalsegno: false, tocoda: false, fine: false};
|
||||
if (!el) return result;
|
||||
for (const sound of descendants(el, 'sound')) {
|
||||
if (sound.getAttribute('dacapo') === 'yes') result.dacapo = true;
|
||||
if (sound.getAttribute('dalsegno')) result.dalsegno = true;
|
||||
if (sound.getAttribute('tocoda')) result.tocoda = true;
|
||||
if (sound.getAttribute('fine') === 'yes') result.fine = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseMeasure(
|
||||
measure: Element,
|
||||
partIndex: number,
|
||||
measureIndex: number,
|
||||
measureId: string,
|
||||
noteIndexStart: number,
|
||||
divisionsRef: {value: number},
|
||||
stavesRef: {value: number},
|
||||
notes: ParsedNote[],
|
||||
warnings: string[],
|
||||
tempoEvents: TempoEvent[],
|
||||
): {measure: ParsedMeasure; noteIndexEnd: number} {
|
||||
let cursor = 0;
|
||||
let maxCursor = 0;
|
||||
let lastNonChordOnset = 0;
|
||||
let noteIndex = noteIndexStart;
|
||||
let repeatForward = false;
|
||||
let repeatBackward = false;
|
||||
let repeatTimes: number | null = null;
|
||||
let forwardTimes: number | null = null;
|
||||
let endingNumbers: number[] | null = null;
|
||||
let endingStart = false;
|
||||
let endingStop = false;
|
||||
let soundFlags = {dacapo: false, dalsegno: false, tocoda: false, fine: false};
|
||||
let segno = false;
|
||||
let coda = false;
|
||||
let tempoMarks = 0;
|
||||
let fermataCount = 0;
|
||||
let cueCount = 0;
|
||||
|
||||
for (const child of childElements(measure)) {
|
||||
const name = child.localName || child.nodeName;
|
||||
if (name === 'attributes') {
|
||||
const div = Number(childText(child, 'divisions') ?? '');
|
||||
if (Number.isFinite(div) && div > 0) divisionsRef.value = div;
|
||||
const staves = Number(childText(child, 'staves') ?? '');
|
||||
if (Number.isFinite(staves) && staves > 0) stavesRef.value = Math.max(stavesRef.value, staves);
|
||||
continue;
|
||||
}
|
||||
if (name === 'backup' || name === 'forward') {
|
||||
const raw = Number(childText(child, 'duration') ?? '0');
|
||||
if (Number.isFinite(raw)) {
|
||||
const quarter = raw / divisionsRef.value;
|
||||
cursor += name === 'backup' ? -quarter : quarter;
|
||||
maxCursor = Math.max(maxCursor, cursor);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (name === 'barline') {
|
||||
for (const repeat of descendants(child, 'repeat')) {
|
||||
const direction = repeat.getAttribute('direction');
|
||||
const times = repeat.getAttribute('times');
|
||||
if (direction === 'forward') {
|
||||
repeatForward = true;
|
||||
if (times) forwardTimes = Math.max(2, Number(times) || 2);
|
||||
} else if (direction === 'backward') {
|
||||
repeatBackward = true;
|
||||
if (times) repeatTimes = Math.max(2, Number(times) || 2);
|
||||
}
|
||||
}
|
||||
for (const ending of descendants(child, 'ending')) {
|
||||
const type = ending.getAttribute('type');
|
||||
if (type === 'start') {
|
||||
endingStart = true;
|
||||
const raw = ending.getAttribute('number') ?? '';
|
||||
endingNumbers = raw
|
||||
.split(/[,\s]+/)
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
} else if (type === 'stop') {
|
||||
endingStop = true;
|
||||
} else if (type === 'discontinue') {
|
||||
endingStop = true;
|
||||
}
|
||||
}
|
||||
soundFlags = {...soundFlags, ...readSoundFlags(child)};
|
||||
continue;
|
||||
}
|
||||
if (name === 'direction') {
|
||||
const soundTempo = descendants(child, 'sound').find((sound) => sound.getAttribute('tempo'));
|
||||
const metronome = descendants(child, 'metronome')[0];
|
||||
if (soundTempo || metronome) {
|
||||
tempoMarks++;
|
||||
let bpm: number | null = null;
|
||||
if (soundTempo) {
|
||||
const value = Number(soundTempo.getAttribute('tempo'));
|
||||
if (Number.isFinite(value) && value > 0) bpm = value;
|
||||
}
|
||||
if (bpm === null && metronome) bpm = metronomeToQpm(metronome);
|
||||
if (bpm !== null && partIndex === 0) {
|
||||
tempoEvents.push({measureIndex, beat: Math.max(0, cursor), bpm});
|
||||
}
|
||||
}
|
||||
if (descendants(child, 'segno').length) segno = true;
|
||||
if (descendants(child, 'coda').length) coda = true;
|
||||
const words = descendants(child, 'words')
|
||||
.map((w) => w.textContent?.trim() ?? '')
|
||||
.join(' ');
|
||||
if (/\bD\.?\s*C\.?\b/i.test(words) || /da\s*capo/i.test(words)) soundFlags.dacapo = true;
|
||||
if (/\bD\.?\s*S\.?\b/i.test(words) || /dal\s*segno/i.test(words)) soundFlags.dalsegno = true;
|
||||
soundFlags = {...soundFlags, ...readSoundFlags(child)};
|
||||
continue;
|
||||
}
|
||||
if (name !== 'note') continue;
|
||||
|
||||
const noteId = `note-${partIndex}-${measureIndex}-${noteIndex}`;
|
||||
child.setAttribute('id', noteId);
|
||||
noteIndex++;
|
||||
|
||||
const isRest = Boolean(firstChild(child, 'rest'));
|
||||
const isGrace = Boolean(firstChild(child, 'grace'));
|
||||
const isChord = Boolean(firstChild(child, 'chord'));
|
||||
const isCue = Boolean(firstChild(child, 'cue'));
|
||||
const durationRaw = Number(childText(child, 'duration') ?? '0');
|
||||
const duration = Number.isFinite(durationRaw) && durationRaw > 0 ? durationRaw / divisionsRef.value : 0;
|
||||
const staff = Number(childText(child, 'staff') ?? '1') || 1;
|
||||
const voice = childText(child, 'voice') ?? '1';
|
||||
const pitch = parsePitch(child);
|
||||
const onset = isChord ? lastNonChordOnset : cursor;
|
||||
|
||||
if (isChord && lastNonChordOnset === 0 && cursor === 0) {
|
||||
warnings.push(`第 ${measureIndex + 1} 小节出现无前导音的 chord,按当前拍位处理。`);
|
||||
}
|
||||
if (!isChord && !isGrace) {
|
||||
lastNonChordOnset = cursor;
|
||||
cursor += duration;
|
||||
maxCursor = Math.max(maxCursor, cursor);
|
||||
}
|
||||
if (isCue) cueCount++;
|
||||
if (descendants(child, 'fermata').length) fermataCount++;
|
||||
|
||||
notes.push({
|
||||
id: noteId,
|
||||
partIndex,
|
||||
measureIndex,
|
||||
noteIndex: noteIndex - 1,
|
||||
staff,
|
||||
lane: partIndex * 10 + staff,
|
||||
voice,
|
||||
pitch,
|
||||
isRest,
|
||||
isGrace,
|
||||
isChord,
|
||||
onset,
|
||||
duration,
|
||||
tieStart: hasTie(child, 'start'),
|
||||
tieStop: hasTie(child, 'stop'),
|
||||
type: childText(child, 'type') ?? '',
|
||||
dots: descendants(child, 'dot').length,
|
||||
cue: isCue,
|
||||
});
|
||||
}
|
||||
|
||||
const parsedMeasure: ParsedMeasure = {
|
||||
index: measureIndex,
|
||||
id: measureId,
|
||||
number: measure.getAttribute('number') ?? String(measureIndex + 1),
|
||||
partIndex,
|
||||
repeatForward,
|
||||
repeatBackward,
|
||||
repeatTimes,
|
||||
forwardTimes,
|
||||
endingStart,
|
||||
endingStop,
|
||||
endingNumbers,
|
||||
endingGroup: null,
|
||||
...soundFlags,
|
||||
segno,
|
||||
coda,
|
||||
tempoMarks,
|
||||
fermataCount,
|
||||
cueCount,
|
||||
durationQuarters: Math.max(maxCursor, 0),
|
||||
};
|
||||
return {measure: parsedMeasure, noteIndexEnd: noteIndex};
|
||||
}
|
||||
|
||||
const NOTE_VALUE: Record<string, number> = {
|
||||
whole: 1,
|
||||
half: 2,
|
||||
quarter: 4,
|
||||
eighth: 8,
|
||||
'16th': 16,
|
||||
'32nd': 32,
|
||||
'64th': 64,
|
||||
};
|
||||
|
||||
function metronomeToQpm(metronome: Element): number | null {
|
||||
const unit = childText(metronome, 'beat-unit');
|
||||
const perMinute = Number(childText(metronome, 'per-minute') ?? '');
|
||||
if (!unit || !Number.isFinite(perMinute) || perMinute <= 0) return null;
|
||||
const value = NOTE_VALUE[unit];
|
||||
if (!value) return null;
|
||||
const dots = descendants(metronome, 'beat-unit-dot').length;
|
||||
const quarterLength = (4 / value) * (2 - 1 / Math.pow(2, dots));
|
||||
return perMinute * quarterLength;
|
||||
}
|
||||
|
||||
function tempoAt(measureIndex: number, beat: number, tempoEvents: TempoEvent[]): number {
|
||||
let bpm = 120;
|
||||
for (const event of tempoEvents) {
|
||||
if (event.measureIndex < measureIndex || (event.measureIndex === measureIndex && event.beat <= beat + 1e-9)) {
|
||||
bpm = event.bpm;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return bpm > 0 ? bpm : 120;
|
||||
}
|
||||
|
||||
/** Notated duration in seconds from the first part's tempo map. */
|
||||
export function measureDurationSeconds(measure: ParsedMeasure, tempoEvents: TempoEvent[]): number {
|
||||
const quarters = measure.durationQuarters > 0 ? measure.durationQuarters : 4;
|
||||
const changes = tempoEvents
|
||||
.filter((event) => event.measureIndex === measure.index && event.beat > 0 && event.beat < quarters)
|
||||
.sort((a, b) => a.beat - b.beat);
|
||||
let bpm = tempoAt(measure.index, 0, tempoEvents);
|
||||
let cursor = 0;
|
||||
let seconds = 0;
|
||||
for (const change of changes) {
|
||||
const beat = Math.max(cursor, Math.min(quarters, change.beat));
|
||||
if (beat > cursor) {
|
||||
seconds += ((beat - cursor) * 60) / bpm;
|
||||
cursor = beat;
|
||||
}
|
||||
bpm = change.bpm;
|
||||
}
|
||||
seconds += ((quarters - cursor) * 60) / bpm;
|
||||
return Math.max(0.01, seconds);
|
||||
}
|
||||
|
||||
function attachEndingGroups(measures: ParsedMeasure[]): void {
|
||||
let current: EndingGroup | null = null;
|
||||
for (const measure of measures) {
|
||||
if (measure.endingStart) {
|
||||
if (current) current.end = measure.index - 1;
|
||||
current = {
|
||||
start: measure.index,
|
||||
end: measure.index,
|
||||
numbers: measure.endingNumbers ?? [],
|
||||
};
|
||||
}
|
||||
if (current) {
|
||||
measure.endingGroup = current;
|
||||
current.end = Math.max(current.end, measure.index);
|
||||
}
|
||||
if (measure.endingStop) {
|
||||
if (current) {
|
||||
current.end = measure.index;
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the performed measure order from repeat and ending marks.
|
||||
* Supports sequential and nested repeats, and the common 1st/2nd ending form.
|
||||
*/
|
||||
export function buildPerformanceOrder(
|
||||
measures: ParsedMeasure[],
|
||||
warnings: string[],
|
||||
): {order: number[]; passes: number[]} {
|
||||
const order: number[] = [];
|
||||
const passes: number[] = [];
|
||||
type Frame = {start: number; times: number; plays: number; pass: number};
|
||||
const stack: Frame[] = [];
|
||||
let i = 0;
|
||||
let steps = 0;
|
||||
|
||||
while (i < measures.length && steps < 20000) {
|
||||
steps++;
|
||||
const m = measures[i];
|
||||
if (!m) break;
|
||||
|
||||
// If we're inside an ending group that doesn't apply to the current repeat pass,
|
||||
// skip the whole group. This is what makes 1st/2nd endings work.
|
||||
if (m.endingGroup && stack.length) {
|
||||
const frame = stack[stack.length - 1]!;
|
||||
if (!m.endingGroup.numbers.includes(frame.pass)) {
|
||||
i = m.endingGroup.end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (m.repeatForward) {
|
||||
const top = stack[stack.length - 1];
|
||||
if (!top || top.start !== i) {
|
||||
const times = Math.max(2, m.forwardTimes ?? m.repeatTimes ?? 2);
|
||||
stack.push({start: i, times, plays: 1, pass: 1});
|
||||
}
|
||||
}
|
||||
|
||||
order.push(i);
|
||||
passes.push(stack.length ? stack[stack.length - 1]!.pass : 1);
|
||||
|
||||
if (m.repeatBackward) {
|
||||
const frame = stack[stack.length - 1];
|
||||
if (frame && frame.start <= i && frame.times === 2 && m.repeatTimes && m.repeatTimes > 2) {
|
||||
frame.times = m.repeatTimes;
|
||||
}
|
||||
if (!frame || frame.start > i) {
|
||||
warnings.push(`第 ${i + 1} 小节有反复结束标记但找不到对应开始,已忽略。`);
|
||||
} else if (frame.plays < frame.times) {
|
||||
frame.plays++;
|
||||
frame.pass = frame.plays;
|
||||
i = frame.start;
|
||||
continue;
|
||||
} else {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (stack.length) {
|
||||
warnings.push('存在未闭合的反复开始标记;已按顺序播放到结尾。');
|
||||
}
|
||||
if (steps >= 20000) {
|
||||
warnings.push('反复展开超过安全上限,可能包含异常嵌套;已截断。');
|
||||
}
|
||||
return {order, passes};
|
||||
}
|
||||
|
||||
export function needsRepeatUnroll(measures: ParsedMeasure[]): boolean {
|
||||
return measures.some((m) => m.repeatForward || m.repeatBackward);
|
||||
}
|
||||
|
||||
export function detectUnsupported(parts: ParsedPart[], measures: ParsedMeasure[]): string[] {
|
||||
const unsupported = new Set<string>();
|
||||
for (const part of parts) {
|
||||
for (const measure of part.measures) {
|
||||
if (measure.dacapo) unsupported.add('D.C.(da capo)跳转');
|
||||
if (measure.dalsegno) unsupported.add('D.S.(dal segno)跳转');
|
||||
if (measure.tocoda) unsupported.add('To Coda 跳转');
|
||||
if (measure.fine) unsupported.add('Fine 终止标记');
|
||||
}
|
||||
}
|
||||
if (measures.some((m) => m.fermataCount > 0)) {
|
||||
unsupported.add('延长记号(fermata)的额外时值');
|
||||
}
|
||||
return [...unsupported];
|
||||
}
|
||||
|
||||
export function parseMusicXml(input: Buffer | string): ParsedScore {
|
||||
const originalXml = typeof input === 'string' ? input : input.toString('utf8');
|
||||
const xml = originalXml.replace(/^\uFEFF/, '');
|
||||
const doc = new DOMParser().parseFromString(xml, 'text/xml');
|
||||
|
||||
const root = doc.documentElement;
|
||||
if (!root) throw new Error('MusicXML 为空或无法解析。');
|
||||
if (root.localName === 'parsererror' || root.nodeName === 'parsererror') {
|
||||
throw new Error('MusicXML 格式不正确(parsererror)。');
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const title =
|
||||
textOfDescendant(root, 'work-title') ??
|
||||
textOfDescendant(root, 'movement-title') ??
|
||||
'';
|
||||
const subtitle = textOfDescendant(root, 'movement-title') ?? '';
|
||||
const composer =
|
||||
descendants(root, 'creator')
|
||||
.find((creator) => creator.getAttribute('type') === 'composer')
|
||||
?.textContent?.trim() ?? '';
|
||||
|
||||
const partList = descendants(root, 'score-part');
|
||||
const partNames = new Map<string, string>();
|
||||
for (const part of partList) {
|
||||
const id = part.getAttribute('id');
|
||||
if (id) partNames.set(id, childText(part, 'part-name') ?? id);
|
||||
}
|
||||
|
||||
const partElements = descendants(root, 'part');
|
||||
if (!partElements.length) throw new Error('MusicXML 中没有找到 <part>。');
|
||||
if (partElements.length > 4) {
|
||||
warnings.push(`检测到 ${partElements.length} 个 part,单行谱面会很高;建议只保留需要的声部。`);
|
||||
}
|
||||
|
||||
const parts: ParsedPart[] = [];
|
||||
const notesByMeasure: ParsedNote[][] = [];
|
||||
const allNotes: ParsedNote[] = [];
|
||||
const tempoEvents: TempoEvent[] = [];
|
||||
let maxMeasureCount = 0;
|
||||
let timeline: ParsedMeasure[] = [];
|
||||
|
||||
partElements.forEach((partElement, partIndex) => {
|
||||
const partId = partElement.getAttribute('id') ?? `P${partIndex + 1}`;
|
||||
const measures: ParsedMeasure[] = [];
|
||||
const divisionsRef = {value: 1};
|
||||
const stavesRef = {value: 1};
|
||||
let noteIndex = 0;
|
||||
const measureElements = childElements(partElement, 'measure');
|
||||
measureElements.forEach((measureElement, measureIndex) => {
|
||||
const measureId = `meas-${partIndex}-${measureIndex}`;
|
||||
measureElement.setAttribute('id', measureId);
|
||||
const result = parseMeasure(
|
||||
measureElement,
|
||||
partIndex,
|
||||
measureIndex,
|
||||
measureId,
|
||||
noteIndex,
|
||||
divisionsRef,
|
||||
stavesRef,
|
||||
allNotes,
|
||||
warnings,
|
||||
tempoEvents,
|
||||
);
|
||||
noteIndex = result.noteIndexEnd;
|
||||
measures.push(result.measure);
|
||||
});
|
||||
attachEndingGroups(measures);
|
||||
parts.push({
|
||||
index: partIndex,
|
||||
id: partId,
|
||||
name: partNames.get(partId) ?? partId,
|
||||
staves: stavesRef.value,
|
||||
measures,
|
||||
});
|
||||
if (measures.length > maxMeasureCount) {
|
||||
maxMeasureCount = measures.length;
|
||||
timeline = measures;
|
||||
}
|
||||
});
|
||||
|
||||
for (const note of allNotes) {
|
||||
const bucket = (notesByMeasure[note.measureIndex] ??= []);
|
||||
bucket.push(note);
|
||||
}
|
||||
|
||||
// Parts may disagree on measure count. Pad the timeline so note measure indices remain valid.
|
||||
if (parts.some((p) => p.measures.length !== timeline.length)) {
|
||||
warnings.push('各 part 的小节数不一致;以最长 part 的时间线为准。');
|
||||
}
|
||||
|
||||
const annotatedXml = new XMLSerializer().serializeToString(doc);
|
||||
const unsupported = detectUnsupported(parts, timeline);
|
||||
tempoEvents.sort((a, b) => a.measureIndex - b.measureIndex || a.beat - b.beat);
|
||||
const dedupedTempo: TempoEvent[] = [];
|
||||
for (const event of tempoEvents) {
|
||||
const prev = dedupedTempo[dedupedTempo.length - 1];
|
||||
if (prev && prev.measureIndex === event.measureIndex && Math.abs(prev.beat - event.beat) < 1e-6) {
|
||||
prev.bpm = event.bpm;
|
||||
} else {
|
||||
dedupedTempo.push({...event});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
subtitle,
|
||||
composer,
|
||||
parts,
|
||||
measures: timeline,
|
||||
notesByMeasure,
|
||||
allNotes,
|
||||
tempoEvents: dedupedTempo,
|
||||
annotatedXml,
|
||||
originalXml: xml,
|
||||
warnings,
|
||||
unsupported,
|
||||
};
|
||||
}
|
||||
|
||||
export function readMusicXmlFile(filename: string, buffer: Buffer): {xml: string; format: string} {
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith('.mxl')) {
|
||||
const files = unzipSync(new Uint8Array(buffer));
|
||||
const containerBytes = files['META-INF/container.xml'];
|
||||
let rootPath: string | null = null;
|
||||
if (containerBytes) {
|
||||
const container = new DOMParser().parseFromString(
|
||||
new TextDecoder('utf-8').decode(containerBytes),
|
||||
'text/xml',
|
||||
);
|
||||
const rootfile = descendants(container.documentElement, 'rootfile')[0];
|
||||
rootPath = rootfile?.getAttribute('full-path') ?? null;
|
||||
}
|
||||
if (!rootPath) {
|
||||
rootPath = Object.keys(files).find((name) => /\.(musicxml|xml)$/i.test(name)) ?? null;
|
||||
}
|
||||
if (!rootPath || !files[rootPath]) {
|
||||
throw new Error('MXL 中没有找到 MusicXML 主文件。');
|
||||
}
|
||||
const xml = new TextDecoder('utf-8').decode(files[rootPath]).replace(/^\uFEFF/, '');
|
||||
return {xml, format: 'mxl'};
|
||||
}
|
||||
if (lower.endsWith('.mid') || lower.endsWith('.midi')) {
|
||||
throw new Error('MIDI 需要先通过 MuseScore 转换为 MusicXML。');
|
||||
}
|
||||
return {xml: buffer.toString('utf8').replace(/^\uFEFF/, ''), format: lower.endsWith('.musicxml') ? 'musicxml' : 'xml'};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type {JobStage, RenderData, VisualSettings} from '../shared/types.js';
|
||||
import {readMusicXmlFile, parseMusicXml, buildPerformanceOrder} from './musicxml.js';
|
||||
import {engraveOneLine} from './engrave.js';
|
||||
import {parseMidiFile} from './midi.js';
|
||||
import {buildEvents} from './events.js';
|
||||
import {
|
||||
exportAudioAndMidi,
|
||||
exportMidi,
|
||||
convertMidiToMusicXml,
|
||||
normalizeMusicXml,
|
||||
findMuseScore,
|
||||
MuseScoreError,
|
||||
} from './musescore.js';
|
||||
import {isSupportedAudio, readAudioDuration, audioMimeType} from './audio.js';
|
||||
import {jobDirectory, updateJob} from './jobs.js';
|
||||
|
||||
export type PipelineInput = {
|
||||
jobId: string;
|
||||
file: {buffer: Buffer; filename: string};
|
||||
audio?: {buffer: Buffer; filename: string};
|
||||
settings: VisualSettings;
|
||||
metadata?: {title?: string; subtitle?: string; composer?: string};
|
||||
};
|
||||
|
||||
function setProgress(jobId: string, stage: JobStage, progress: number, message: string) {
|
||||
updateJob(jobId, {status: 'running', stage, progress, message});
|
||||
}
|
||||
|
||||
/** MusicXML written by MIDI converters carries no beaming, so every eighth note gets its own flag. */
|
||||
function needsBeamFix(xml: string): boolean {
|
||||
if (/<beam\b/.test(xml)) return false;
|
||||
return /<type>(eighth|16th|32nd|64th)<\/type>/.test(xml);
|
||||
}
|
||||
|
||||
export async function runPipeline(input: PipelineInput): Promise<void> {
|
||||
const {jobId, file, audio, settings, metadata} = input;
|
||||
const dir = jobDirectory(jobId);
|
||||
fs.mkdirSync(dir, {recursive: true});
|
||||
|
||||
try {
|
||||
setProgress(jobId, 'reading', 3, '读取上传文件…');
|
||||
const ext = path.extname(file.filename).toLowerCase();
|
||||
const isMidiInput = ext === '.mid' || ext === '.midi';
|
||||
if (!['.musicxml', '.xml', '.mxl', '.mid', '.midi'].includes(ext)) {
|
||||
throw new Error('谱面输入只支持 .musicxml / .xml / .mxl / .mid / .midi;音频请放在单独的音频输入框。');
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(dir, `original${ext}`), file.buffer);
|
||||
const inputMusicXml = path.join(dir, 'input.musicxml');
|
||||
|
||||
if (isMidiInput) {
|
||||
if (!findMuseScore()) {
|
||||
throw new Error(
|
||||
'收到 MIDI 文件,但没有找到 MuseScore。MIDI 需要先转换为 MusicXML。请安装 MuseScore 4,或直接上传 MusicXML/MXL。',
|
||||
);
|
||||
}
|
||||
setProgress(jobId, 'reading', 8, '用 MuseScore 将 MIDI 识别为谱面…');
|
||||
await convertMidiToMusicXml(path.join(dir, `original${ext}`), inputMusicXml);
|
||||
} else {
|
||||
const {xml} = readMusicXmlFile(file.filename, file.buffer);
|
||||
fs.writeFileSync(inputMusicXml, xml, 'utf8');
|
||||
}
|
||||
|
||||
setProgress(jobId, 'reading', 12, '解析 MusicXML…');
|
||||
const warnings: string[] = [];
|
||||
const musescore = findMuseScore();
|
||||
|
||||
// Re-engrave angle: files without <beam> make Verovio flag every eighth note.
|
||||
let scorePath = inputMusicXml;
|
||||
if (musescore && needsBeamFix(fs.readFileSync(inputMusicXml, 'utf8'))) {
|
||||
setProgress(jobId, 'reading', 10, '用 MuseScore 规整谱面(符杠/连音)…');
|
||||
const normalized = path.join(dir, 'score.musicxml');
|
||||
try {
|
||||
await normalizeMusicXml(inputMusicXml, normalized);
|
||||
scorePath = normalized;
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`MuseScore 规整谱面失败,按原始文件排版,符杠可能缺失:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseMusicXml(fs.readFileSync(scorePath));
|
||||
if (!parsed.measures.length) throw new Error('谱面里没有小节。');
|
||||
warnings.push(...parsed.warnings);
|
||||
|
||||
if (parsed.unsupported.length) {
|
||||
warnings.push(
|
||||
`谱面包含当前版本不会展开的结构:${parsed.unsupported.join('、')}。自动合成音频会按 MuseScore 的播放跳转,但动画时间轴按谱面顺序;如出现不同步,请先在 MuseScore 中展开反复/跳转后导出 MusicXML。`,
|
||||
);
|
||||
}
|
||||
if (parsed.parts.length > 1) {
|
||||
warnings.push(`检测到 ${parsed.parts.length} 个 part,单行谱面会包含多组五线谱。`);
|
||||
}
|
||||
|
||||
const hasRepeats = parsed.measures.some((m) => m.repeatForward || m.repeatBackward);
|
||||
if (hasRepeats) {
|
||||
warnings.push('谱面包含反复段:动画会按演奏顺序回到前面的符头,谱面滚动也会短暂回退。');
|
||||
}
|
||||
|
||||
// Audio + MIDI. MuseScore is the source of truth for both when it is available.
|
||||
let audioPath: string | null = null;
|
||||
let audioFile: string | null = null;
|
||||
let audioMime: string | undefined;
|
||||
let midiPath: string | null = null;
|
||||
|
||||
if (audio) {
|
||||
if (!isSupportedAudio(audio.filename)) throw new Error('音频格式不支持。请使用 WAV / MP3 / M4A / OGG / FLAC。');
|
||||
setProgress(jobId, 'synthesizing', 20, '保存上传音频…');
|
||||
const audioExt = path.extname(audio.filename).toLowerCase() || '.wav';
|
||||
audioFile = `audio${audioExt}`;
|
||||
audioPath = path.join(dir, audioFile);
|
||||
fs.writeFileSync(audioPath, audio.buffer);
|
||||
audioMime = audioMimeType(audio.filename);
|
||||
if (musescore) {
|
||||
setProgress(jobId, 'synthesizing', 24, '用 MuseScore 生成 MIDI 时间轴…');
|
||||
try {
|
||||
midiPath = await exportMidi(scorePath, dir);
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`MuseScore 生成 MIDI 失败,将使用谱面标称时间:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warnings.push('没有找到 MuseScore,使用谱面标称时间;上传音频可能需要手动对齐。');
|
||||
}
|
||||
warnings.push('使用上传音频:动画时间来自谱面/MIDI,若录音速度与谱面不同,可能出现渐进漂移。');
|
||||
} else {
|
||||
if (!musescore) {
|
||||
throw new Error(
|
||||
'没有找到 MuseScore 4。自动合成音频需要 MuseScore;也可以上传自己的音频文件。安装后可设置 MUSESCORE_PATH 指向可执行文件。',
|
||||
);
|
||||
}
|
||||
setProgress(jobId, 'synthesizing', 20, 'MuseScore 正在合成音频(长曲可能需要几十秒)…');
|
||||
const exported = await exportAudioAndMidi(scorePath, dir);
|
||||
audioPath = exported.wav;
|
||||
midiPath = exported.midi;
|
||||
audioFile = path.basename(exported.wav);
|
||||
audioMime = 'audio/wav';
|
||||
}
|
||||
|
||||
setProgress(jobId, 'engraving', 55, 'Verovio 正在排版单行谱面…');
|
||||
const engraved = await engraveOneLine(parsed.annotatedXml);
|
||||
fs.writeFileSync(path.join(dir, 'score.svg'), engraved.svg, 'utf8');
|
||||
|
||||
setProgress(jobId, 'mapping', 70, '展开演奏顺序并对齐音符…');
|
||||
const performance = buildPerformanceOrder(parsed.measures, warnings);
|
||||
const midi = midiPath ? parseMidiFile(midiPath) : null;
|
||||
const audioDuration = audioPath ? await readAudioDuration(audioPath) : 0;
|
||||
|
||||
const built = buildEvents({
|
||||
parsed,
|
||||
engrave: engraved,
|
||||
midi,
|
||||
performanceOrder: performance.order,
|
||||
audioDuration,
|
||||
settings,
|
||||
});
|
||||
|
||||
if (audioDuration > 0 && built.meta.musicDuration > audioDuration + 0.5) {
|
||||
warnings.push(
|
||||
`音频时长(${audioDuration.toFixed(1)}s)短于谱面时长(${built.meta.musicDuration.toFixed(1)}s),结尾可能没有声音。`,
|
||||
);
|
||||
}
|
||||
if (metadata?.title) built.meta.title = metadata.title.trim();
|
||||
if (metadata?.subtitle !== undefined) built.meta.subtitle = metadata.subtitle.trim();
|
||||
if (metadata?.composer !== undefined) built.meta.composer = metadata.composer.trim();
|
||||
|
||||
built.meta.warnings = [...new Set([...warnings, ...built.meta.warnings])];
|
||||
built.meta.unsupported = parsed.unsupported;
|
||||
|
||||
if (!audioFile) throw new Error('没有生成音频文件。');
|
||||
|
||||
const renderData: RenderData = {
|
||||
version: 1,
|
||||
meta: built.meta,
|
||||
events: built.events,
|
||||
activations: built.activations,
|
||||
scroll: built.scroll,
|
||||
svgUrl: `/jobs/${jobId}/score.svg`,
|
||||
audioUrl: `/jobs/${jobId}/${audioFile}`,
|
||||
settings,
|
||||
};
|
||||
fs.writeFileSync(path.join(dir, 'score.json'), JSON.stringify(renderData, null, 2), 'utf8');
|
||||
|
||||
setProgress(jobId, 'building', 92, '生成预览数据…');
|
||||
updateJob(jobId, {
|
||||
status: 'ready',
|
||||
stage: 'done',
|
||||
progress: 100,
|
||||
message: '预览已生成,可以播放或导出视频。',
|
||||
data: renderData,
|
||||
warnings: built.meta.warnings,
|
||||
unsupported: built.meta.unsupported,
|
||||
audioFile,
|
||||
audioMime,
|
||||
error: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
updateJob(jobId, {
|
||||
status: 'error',
|
||||
stage: 'done',
|
||||
progress: 100,
|
||||
message: '生成失败。',
|
||||
error: message,
|
||||
});
|
||||
if (!(error instanceof MuseScoreError)) {
|
||||
console.error(`[job ${jobId}]`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {getJob} from './jobs.js';
|
||||
import {renderJob} from './render.js';
|
||||
|
||||
const pending: string[] = [];
|
||||
let running = false;
|
||||
|
||||
export function enqueueRender(jobId: string): void {
|
||||
const job = getJob(jobId);
|
||||
if (!job || !job.data) return;
|
||||
if (job.status === 'rendering') return;
|
||||
if (!pending.includes(jobId)) pending.push(jobId);
|
||||
void drain();
|
||||
}
|
||||
|
||||
async function drain(): Promise<void> {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try {
|
||||
while (pending.length) {
|
||||
const id = pending.shift();
|
||||
if (!id) continue;
|
||||
await renderJob(id);
|
||||
}
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import crypto from 'node:crypto';
|
||||
import {bundle} from '@remotion/bundler';
|
||||
import {renderMedia, selectComposition} from '@remotion/renderer';
|
||||
import type {RenderData} from '../shared/types.js';
|
||||
import {jobDirectory, updateJob, getJob} from './jobs.js';
|
||||
import {BROWSER_EXECUTABLE, PUBLIC_BASE_URL, REMOTION_COMPOSITION_ID, REMOTION_ENTRY} from './config.js';
|
||||
|
||||
const PROJECT_ROOT = path.resolve('.');
|
||||
const BUNDLE_IGNORED = new Set(['node_modules', '.git', 'data', 'dist', 'out', '.vite']);
|
||||
|
||||
/**
|
||||
* Every file the bundler can inline, fingerprinted by size and mtime. The bundle
|
||||
* is reused only while this stays the same, so an edit never leaks an old
|
||||
* composition into a render while the Vite preview already shows the new one.
|
||||
*/
|
||||
export function bundleFingerprint(): string {
|
||||
const hash = crypto.createHash('sha1');
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of fs.readdirSync(dir, {withFileTypes: true}).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (BUNDLE_IGNORED.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
const stat = fs.statSync(full);
|
||||
hash.update(`${path.relative(PROJECT_ROOT, full)}:${stat.size}:${stat.mtimeMs}\n`);
|
||||
}
|
||||
};
|
||||
walk(PROJECT_ROOT);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
let bundleCache: {fingerprint: string; serveUrl: Promise<string>} | null = null;
|
||||
let servePortPromise: Promise<number | null> | null = null;
|
||||
|
||||
function getServeUrl(): Promise<string> {
|
||||
const fingerprint = bundleFingerprint();
|
||||
if (bundleCache?.fingerprint === fingerprint) return bundleCache.serveUrl;
|
||||
if (bundleCache) console.log('[render] sources changed, rebuilding the Remotion bundle');
|
||||
const serveUrl = bundle({
|
||||
entryPoint: path.resolve(REMOTION_ENTRY),
|
||||
publicDir: path.resolve('public'),
|
||||
onProgress: () => {},
|
||||
});
|
||||
bundleCache = {fingerprint, serveUrl};
|
||||
serveUrl.catch(() => {
|
||||
if (bundleCache?.serveUrl === serveUrl) bundleCache = null;
|
||||
});
|
||||
return serveUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remotion serves the bundle from its own HTTP server. Left to itself it walks
|
||||
* ports 3000-3100 and can collide with this API (or a lingering dev server),
|
||||
* so hand it a port that is free right now.
|
||||
*/
|
||||
function getServePort(): Promise<number | null> {
|
||||
if (!servePortPromise) {
|
||||
servePortPromise = new Promise((resolve) => {
|
||||
const probe = net.createServer();
|
||||
probe.once('error', () => resolve(null));
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const address = probe.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
probe.close(() => resolve(port > 0 ? port : null));
|
||||
});
|
||||
});
|
||||
}
|
||||
return servePortPromise;
|
||||
}
|
||||
|
||||
function absoluteUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url, PUBLIC_BASE_URL).toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderJob(jobId: string): Promise<void> {
|
||||
const job = getJob(jobId);
|
||||
if (!job) return;
|
||||
if (!job.data) {
|
||||
updateJob(jobId, {status: 'error', stage: 'done', progress: 100, error: '没有可渲染的数据。'});
|
||||
return;
|
||||
}
|
||||
const output = path.join(jobDirectory(jobId), 'video.mp4');
|
||||
if (fs.existsSync(output)) fs.rmSync(output, {force: true});
|
||||
updateJob(jobId, {
|
||||
status: 'rendering',
|
||||
stage: 'rendering',
|
||||
progress: 1,
|
||||
message: '打包 Remotion 项目…',
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
const data: RenderData = {
|
||||
...job.data,
|
||||
svgUrl: absoluteUrl(job.data.svgUrl),
|
||||
audioUrl: absoluteUrl(job.data.audioUrl),
|
||||
};
|
||||
const serveUrl = await getServeUrl();
|
||||
const port = await getServePort();
|
||||
updateJob(jobId, {progress: 4, message: '准备 Composition…'});
|
||||
const composition = await selectComposition({
|
||||
serveUrl,
|
||||
id: REMOTION_COMPOSITION_ID,
|
||||
inputProps: {data},
|
||||
browserExecutable: BROWSER_EXECUTABLE,
|
||||
timeoutInMilliseconds: 120000,
|
||||
port,
|
||||
});
|
||||
updateJob(jobId, {progress: 6, message: '渲染视频帧…'});
|
||||
await renderMedia({
|
||||
composition,
|
||||
serveUrl,
|
||||
inputProps: {data},
|
||||
port,
|
||||
codec: 'h264',
|
||||
audioCodec: 'aac',
|
||||
outputLocation: output,
|
||||
overwrite: true,
|
||||
imageFormat: 'jpeg',
|
||||
jpegQuality: 92,
|
||||
crf: 18,
|
||||
concurrency: 2,
|
||||
browserExecutable: BROWSER_EXECUTABLE,
|
||||
timeoutInMilliseconds: 120000,
|
||||
onProgress: ({progress}) => {
|
||||
const value = Math.min(99, Math.round(6 + progress * 93));
|
||||
updateJob(jobId, {
|
||||
progress: value,
|
||||
message: `渲染视频帧… ${Math.round(progress * 100)}%`,
|
||||
});
|
||||
},
|
||||
});
|
||||
updateJob(jobId, {
|
||||
status: 'done',
|
||||
stage: 'done',
|
||||
progress: 100,
|
||||
message: '视频已生成,可以下载。',
|
||||
videoFile: 'video.mp4',
|
||||
error: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[render ${jobId}]`, error);
|
||||
updateJob(jobId, {
|
||||
status: 'error',
|
||||
stage: 'done',
|
||||
progress: 100,
|
||||
message: '视频渲染失败。',
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {NoteEvent, ScrollKey} from './types';
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
/**
|
||||
* Build the scrolling keyframes from note geometry.
|
||||
* Shared by the server pipeline and the client's live preview so both agree.
|
||||
*/
|
||||
export function buildScrollKeys(
|
||||
events: NoteEvent[],
|
||||
lead: number,
|
||||
viewWidthScore: number,
|
||||
maxLeft: number,
|
||||
musicDuration: number,
|
||||
): ScrollKey[] {
|
||||
const keys: ScrollKey[] = [{time: 0, x: 0}];
|
||||
if (maxLeft <= 0.001) {
|
||||
return [
|
||||
{time: 0, x: 0},
|
||||
{time: Math.max(0.001, musicDuration + 2), x: 0},
|
||||
];
|
||||
}
|
||||
|
||||
const groups: {time: number; x: number; count: number}[] = [];
|
||||
for (const event of events) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && Math.abs(last.time - event.time) <= 0.003) {
|
||||
last.x += event.x;
|
||||
last.count++;
|
||||
} else {
|
||||
groups.push({time: event.time, x: event.x, count: 1});
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
const avgX = group.x / group.count;
|
||||
const target = clamp(avgX - lead * viewWidthScore, 0, maxLeft);
|
||||
if (group.time <= 0.05) {
|
||||
keys[0]!.x = target;
|
||||
continue;
|
||||
}
|
||||
const last = keys[keys.length - 1]!;
|
||||
if (group.time <= last.time + 0.001) {
|
||||
last.x = target;
|
||||
} else {
|
||||
keys.push({time: group.time, x: target});
|
||||
}
|
||||
}
|
||||
|
||||
const lastNoteTime = groups[groups.length - 1]?.time ?? 0;
|
||||
const endTime = Math.max(musicDuration, lastNoteTime + 0.4);
|
||||
const lastKey = keys[keys.length - 1]!;
|
||||
if (lastKey.x < maxLeft - 0.001) {
|
||||
keys.push({time: endTime, x: maxLeft});
|
||||
}
|
||||
keys.push({time: Math.max(endTime + 1, musicDuration + 2), x: maxLeft});
|
||||
// Strictly increasing times are required by the interpolator.
|
||||
const sorted = keys
|
||||
.filter((key) => Number.isFinite(key.time) && Number.isFinite(key.x))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
const deduped: ScrollKey[] = [];
|
||||
for (const key of sorted) {
|
||||
const prev = deduped[deduped.length - 1];
|
||||
if (prev && Math.abs(prev.time - key.time) < 0.0005) prev.x = key.x;
|
||||
else deduped.push({...key});
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cubic Hermite interpolation through explicit scroll keys.
|
||||
* Slopes are harmonic means, zeroed at direction changes, so repeats and
|
||||
* rests ease instead of overshooting. All values are SVG user units.
|
||||
*/
|
||||
export function scrollTrack(keys: ScrollKey[], t: number): number {
|
||||
if (!keys.length) return 0;
|
||||
const first = keys[0]!;
|
||||
const last = keys[keys.length - 1]!;
|
||||
if (keys.length === 1) return first.x;
|
||||
if (t <= first.time) return first.x;
|
||||
if (t >= last.time) return last.x;
|
||||
|
||||
let i = 0;
|
||||
while (i + 1 < keys.length && keys[i + 1]!.time <= t) i++;
|
||||
i = Math.min(i, keys.length - 2);
|
||||
const a = keys[i]!;
|
||||
const b = keys[i + 1]!;
|
||||
const h = b.time - a.time;
|
||||
if (h <= 0) return a.x;
|
||||
const p = clamp((t - a.time) / h, 0, 1);
|
||||
|
||||
const slope = (j: number) => {
|
||||
const from = keys[j]!;
|
||||
const to = keys[j + 1]!;
|
||||
const dt = to.time - from.time;
|
||||
return dt > 0 ? (to.x - from.x) / dt : 0;
|
||||
};
|
||||
const tangent = (j: number) => {
|
||||
if (j === 0) return slope(0);
|
||||
if (j === keys.length - 1) return slope(j - 1);
|
||||
const left = slope(j - 1);
|
||||
const right = slope(j);
|
||||
if (left * right <= 0) return 0;
|
||||
return (2 * left * right) / (left + right);
|
||||
};
|
||||
|
||||
const p2 = p * p;
|
||||
const p3 = p2 * p;
|
||||
return (
|
||||
(2 * p3 - 3 * p2 + 1) * a.x +
|
||||
(p3 - 2 * p2 + p) * h * tangent(i) +
|
||||
(-2 * p3 + 3 * p2) * b.x +
|
||||
(p3 - p2) * h * tangent(i + 1)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
export const APP_NAME = 'Music Visualizer';
|
||||
|
||||
export type NoteKind = 'attack' | 'sustain';
|
||||
|
||||
export type NoteEvent = {
|
||||
/** Unique per performance occurrence. */
|
||||
eventId: string;
|
||||
/** Verovio SVG g.note id, shared by repeated performances of the same notehead. */
|
||||
scoreId: string;
|
||||
time: number;
|
||||
duration: number;
|
||||
pitch: number;
|
||||
velocity: number;
|
||||
staff: number;
|
||||
/** SVG user coordinates (outer viewBox). */
|
||||
x: number;
|
||||
y: number;
|
||||
kind: NoteKind;
|
||||
grace?: boolean;
|
||||
};
|
||||
|
||||
export type ScrollKey = {
|
||||
/** Music seconds. */
|
||||
time: number;
|
||||
/** Left edge of the visible score in SVG user units. */
|
||||
x: number;
|
||||
};
|
||||
|
||||
export type ScoreMeta = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
composer: string;
|
||||
svgWidth: number;
|
||||
svgHeight: number;
|
||||
/** Last sounding note end, in music seconds. */
|
||||
musicDuration: number;
|
||||
/** Audio duration, in seconds. */
|
||||
audioDuration: number;
|
||||
measureCount: number;
|
||||
scoreNoteCount: number;
|
||||
midiNoteCount: number;
|
||||
mappedNoteCount: number;
|
||||
warnings: string[];
|
||||
unsupported: string[];
|
||||
};
|
||||
|
||||
export type VisualSettings = {
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
introSeconds: number;
|
||||
outroSeconds: number;
|
||||
/** Score strip height as a fraction of the video height. 0.3 - 0.75. */
|
||||
scoreHeightRatio: number;
|
||||
/** Active note target position as a fraction of viewport width. 0.25 - 0.6. */
|
||||
scrollLead: number;
|
||||
paperColor: string;
|
||||
/** Paper opacity; 0 makes the score float on the background with no paper at all. */
|
||||
paperAlpha: number;
|
||||
/** Ink colour for the engraved notation. */
|
||||
scoreColor: string;
|
||||
background: string;
|
||||
staffColors: string[];
|
||||
/** Ball radius multiplier around the default. */
|
||||
ballScale: number;
|
||||
/**
|
||||
* Amplifies the orb's jump along the sheet normal. Resting height above the
|
||||
* ink stays put; 0 slides every hop flat onto the paper.
|
||||
*/
|
||||
ballLift: number;
|
||||
/** Glow intensity multiplier. */
|
||||
glow: number;
|
||||
showTitle: boolean;
|
||||
showProgress: boolean;
|
||||
titleFont: string;
|
||||
audioOffsetMs: number;
|
||||
/** Camera pitch in degrees. Positive values tip the top of the score away. */
|
||||
tiltX: number;
|
||||
/** Camera yaw in degrees. Positive values turn the right side away. */
|
||||
tiltY: number;
|
||||
/** Camera roll in degrees. Negative values lift the right side of the staff. */
|
||||
tiltZ: number;
|
||||
/** CSS perspective distance in pixels. Smaller values give stronger convergence. */
|
||||
perspective: number;
|
||||
/** Extra zoom applied to the tilted score plane so it still fills the frame. */
|
||||
tiltZoom: number;
|
||||
};
|
||||
|
||||
export type RenderData = {
|
||||
version: 1;
|
||||
meta: ScoreMeta;
|
||||
events: NoteEvent[];
|
||||
activations: NoteEvent[];
|
||||
scroll: ScrollKey[];
|
||||
svgUrl: string;
|
||||
audioUrl: string;
|
||||
settings: VisualSettings;
|
||||
};
|
||||
|
||||
export type JobStatus = 'queued' | 'running' | 'ready' | 'rendering' | 'done' | 'error';
|
||||
|
||||
export type JobStage =
|
||||
| 'queued'
|
||||
| 'reading'
|
||||
| 'synthesizing'
|
||||
| 'engraving'
|
||||
| 'mapping'
|
||||
| 'building'
|
||||
| 'rendering'
|
||||
| 'done';
|
||||
|
||||
export type JobPublic = {
|
||||
id: string;
|
||||
status: JobStatus;
|
||||
stage: JobStage;
|
||||
progress: number;
|
||||
message: string;
|
||||
error?: string;
|
||||
warnings?: string[];
|
||||
unsupported?: string[];
|
||||
data?: RenderData;
|
||||
videoUrl?: string;
|
||||
createdAt: string;
|
||||
inputName?: string;
|
||||
inputFormat?: string;
|
||||
};
|
||||
|
||||
export type CreateJobResponse = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type HealthResponse = {
|
||||
ok: boolean;
|
||||
musescore: { available: boolean; path?: string; version?: string };
|
||||
chrome: { available: boolean; note: string };
|
||||
ffmpeg: { available: boolean; note: string };
|
||||
version: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: VisualSettings = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
introSeconds: 0.65,
|
||||
outroSeconds: 1.8,
|
||||
scoreHeightRatio: 0.5,
|
||||
scrollLead: 0.38,
|
||||
paperColor: '#c9c2b6',
|
||||
paperAlpha: 1,
|
||||
scoreColor: '#14110d',
|
||||
background: '#111015',
|
||||
staffColors: ['#bda0ff', '#ffbc6d', '#8fe3c8', '#ff8fa3'],
|
||||
ballScale: 1,
|
||||
ballLift: 1,
|
||||
glow: 1,
|
||||
showTitle: true,
|
||||
showProgress: true,
|
||||
titleFont: "Georgia, 'Times New Roman', 'Noto Serif SC', 'Songti SC', serif",
|
||||
audioOffsetMs: 0,
|
||||
tiltX: 12,
|
||||
tiltY: 0,
|
||||
tiltZ: -5,
|
||||
perspective: 1800,
|
||||
tiltZoom: 1.12,
|
||||
};
|
||||
|
||||
export const RESOLUTION_PRESETS = [
|
||||
{id: 'landscape', label: '横屏 · 16:9', width: 1920, height: 1080},
|
||||
{id: 'vertical', label: '竖屏 · 9:16', width: 1080, height: 1920},
|
||||
{id: 'square', label: '方形 · 1:1', width: 1080, height: 1080},
|
||||
] as const;
|
||||
|
||||
export const MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
|
||||
export const MAX_RENDER_SECONDS = 20 * 60;
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
declare module 'verovio/wasm' {
|
||||
const createModule: () => Promise<unknown>;
|
||||
export default createModule;
|
||||
}
|
||||
|
||||
declare module 'verovio/esm' {
|
||||
export class VerovioToolkit {
|
||||
constructor(module: unknown);
|
||||
resetXmlIdSeed(seed: number): void;
|
||||
setOptions(options: Record<string, unknown>): void;
|
||||
loadData(data: string): boolean;
|
||||
getLog(): string;
|
||||
getPageCount(): number;
|
||||
renderToSVG(page: number): string;
|
||||
getTimeForElement(id: string): number;
|
||||
getMIDIValuesForElement(id: string): {duration: number; pitch: number; time: number} | null;
|
||||
getPageWithElement(id: string): number;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'svg-path-bounds' {
|
||||
const getBounds: (path: string) => [number, number, number, number];
|
||||
export default getBounds;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node", "vite/client"]
|
||||
},
|
||||
"include": ["src", "scripts", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "data"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {defineConfig} from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
publicDir: 'public',
|
||||
build: {
|
||||
outDir: 'dist/client',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:3001',
|
||||
'/jobs': 'http://127.0.0.1:3001',
|
||||
'/demo': 'http://127.0.0.1:3001',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user