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:
blumia committed 2026-11-05 11:38:25 +08:00
commit a237a7c604
38 files changed
+13958

No files matched your search

+23
View File
@@ -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/
+21
View File
@@ -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.
+133
View File
@@ -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 的商用许可要求请以官方说明为准。
+38
View File
@@ -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
View File
@@ -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>
+6404
View File
File diff suppressed because it is too large. Load diff
+52
View File
@@ -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"
}
}
+267
View File
@@ -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>
+71
View File
@@ -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'));
+292
View File
@@ -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)`);
+44
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+174
View File
@@ -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>
);
};
+75
View File
@@ -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'});
}
+12
View File
@@ -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
+716
View File
@@ -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>
);
};
+58
View File
@@ -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)),
};
}}
/>
);
+148
View File
@@ -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);
}
+4
View File
@@ -0,0 +1,4 @@
import {registerRoot} from 'remotion';
import {Root} from './Root';
registerRoot(Root);
+37
View File
@@ -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';
}
}
+57
View File
@@ -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();
+77
View File
@@ -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};
}
+285
View File
@@ -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,
};
}
+281
View File
@@ -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};
}
+301
View File
@@ -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');
});
+130
View File
@@ -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,
};
}
+47
View File
@@ -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})),
};
}
+159
View File
@@ -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;
}
}
+665
View File
@@ -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'};
}
+216
View File
@@ -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);
}
}
}
+27
View File
@@ -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;
}
}
+161
View File
@@ -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,
});
}
}
+115
View File
@@ -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)
);
}
+174
View File
@@ -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;
+24
View File
@@ -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;
}
+21
View File
@@ -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"]
}
+19
View File
@@ -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',
},
},
});