Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e51fdf8216 | ||
|
|
dcca7f5e83 | ||
|
|
b354ea9f88 | ||
|
|
229a8d4723 |
No files matched your search
@@ -1,23 +1,23 @@
|
|||||||
# Recorder Tab Converter
|
# Recorder fingering editor
|
||||||
|
|
||||||
A soprano recorder tool: a photo or scan of sheet music becomes a lead-line fingering tab, with note values, rests, and bar lines still visible. Baroque fingering is the default. A German toggle changes only F, F♯, and B♭.
|
A soprano recorder tool for arranging fingering charts. Drag a chart, a rest, or a space onto a line, reorder it, set how long it lasts, or take it off. Resize the line box, then save the finished row as a PNG. Baroque fingering is the chart. The foot, with the double holes, is at the top of each chart, and the mouthpiece is at the bottom.
|
||||||
|
|
||||||
The usual way to run it is one Linux AppImage. That file contains the window, the fingering charts, the sample tunes, and [oemer](https://github.com/BreezeWhite/oemer) for offline reading. Nothing is uploaded and no account is required.
|
The usual way to run it is one Linux AppImage. That file contains the window and the fingering charts. It does not read sheet music, and it does not include oemer, ONNX, or model files.
|
||||||
|
|
||||||
## Run the AppImage
|
## Run the AppImage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x Recorder-Tab-Converter-0.1.1.AppImage
|
chmod +x Recorder-Tab-Converter-0.2.2.AppImage
|
||||||
./Recorder-Tab-Converter-0.1.1.AppImage
|
./Recorder-Tab-Converter-0.2.2.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
If the file will not start because FUSE is missing:
|
If the file will not start because FUSE is missing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
APPIMAGE_EXTRACT_AND_RUN=1 ./Recorder-Tab-Converter-0.1.1.AppImage
|
APPIMAGE_EXTRACT_AND_RUN=1 ./Recorder-Tab-Converter-0.2.2.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
Open **Twinkle, Twinkle, Little Star** or **Ode to Joy** to see tabs immediately. Or drop a PNG, JPG, or WEBP of a page. The note list under the tab is editable. Fix a bad pitch or duration and the fingerings update. If a photo cannot be read, the app says so and does not invent notes.
|
Open **Empty line**, or start from **Twinkle, opening** or **Ode, held note**. Drag any chart from C5 to D7, or drag **Rest** or **Space**, onto the line. Drag a placed item to move it. Choose a duration under it. Remove takes it off. **Shorter**, **Taller**, **Narrower**, **Wider**, and the corner grip change the line box. **Save PNG** downloads the finished row.
|
||||||
|
|
||||||
## Build the AppImage
|
## Build the AppImage
|
||||||
|
|
||||||
@@ -27,15 +27,13 @@ From a checkout, one command:
|
|||||||
./scripts/build-appimage.sh
|
./scripts/build-appimage.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
That installs Node packages, downloads a relocatable Python plus oemer and its ONNX models, builds the app, and writes:
|
That installs Node packages, builds the app, and writes:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
dist/Recorder-Tab-Converter-0.1.1.AppImage
|
dist/Recorder-Tab-Converter-0.2.2.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
The U-Net checkpoint from oemer 0.1.8 has ConvTranspose pads `[0, 0, -1, -1]`. The build rewrites those nodes to pads `[0, 0, 0, 0]` and `output_padding` `[1, 1]` before packaging. That is the same output size, and it is what current ONNX Runtime will load. The rewrite does not change fingerings.
|
The AppImage is not committed. Keep it under 600MB.
|
||||||
|
|
||||||
The build needs network access the first time. The AppImage is not committed; keep it under 600MB.
|
|
||||||
|
|
||||||
## Develop in a browser
|
## Develop in a browser
|
||||||
|
|
||||||
@@ -45,22 +43,10 @@ npm test
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The dev server listens on [http://127.0.0.1:3847](http://127.0.0.1:3847). Photo reading uses the Python at `OEMER_PYTHON`, which `npm run dev` points at `build/python` from the AppImage build. That prefix needs CPU `onnxruntime`, not `onnxruntime-gpu`.
|
The dev server listens on [http://127.0.0.1:3847](http://127.0.0.1:3847).
|
||||||
|
|
||||||
## Fingerings
|
## Fingerings
|
||||||
|
|
||||||
Baroque (English) fingerings are the first choice on the [Dolmetsch Online chart for recorders in C](https://dolmetsch.com/textfingeringchart.htm) (Brian Blood). The soprano is treated in concert pitch: it sounds as written, low C is C5, and the chart stops at D7. Double holes 6 and 7, forked F, and the second-octave thumb pinch are included. Alternate fingerings on that chart are not shown. Notes outside C5–D7 are flagged instead of guessed.
|
Baroque (English) fingerings are the first choice on the [Dolmetsch Online chart for recorders in C](https://dolmetsch.com/textfingeringchart.htm) (Brian Blood). The soprano is labeled in concert pitch: low C is C5, and the chart stops at D7. Double holes 6 and 7, forked F, and the second-octave thumb pinch are included. Alternate fingerings on that chart are not shown.
|
||||||
|
|
||||||
German mode changes three notes:
|
Each chart is drawn foot-up: holes 7 and 6 at the top, then 5 through 1, then the thumb, with the mouthpiece at the bottom. A filled hole is closed. A treble staff under the holes is written one octave below the sounding pitch. Low C, labeled C5, sits on the ledger line below the staff. Sounding C6 is written C5, and sounding C7 is written C6. Length is a notehead, stem, flags, dots, and rests. Labels keep the sounding names.
|
||||||
|
|
||||||
- **F** — Dolmetsch’s German fingering, thumb plus holes 1–4 (`0 123 4`).
|
|
||||||
- **F♯** — thumb, 1, 2, 3, 5, 6, and half-hole 7.
|
|
||||||
- **B♭** — thumb, 1, 3, 4, and 6.
|
|
||||||
|
|
||||||
The F♯ and B♭ shapes follow the school German description in Zach VanderGraaff’s [Baroque vs German recorder](https://dynamicmusicroom.com/baroque-vs-german-recorder/) article. The octave above uses those same front holes with the Dolmetsch second-octave thumb. Every other note stays Baroque.
|
|
||||||
|
|
||||||
## Recognition limits
|
|
||||||
|
|
||||||
Reading is done locally by oemer 0.1.8 (ONNX Runtime on CPU). It looks for one lead line: the first part, the top staff, and the highest note of a chord. It misses notes, especially on phone photos, dense piano scores, and handwriting. The result is a draft in the note list. A failed read leaves that list usable and does not fill in a melody.
|
|
||||||
|
|
||||||
Lead-only means other parts, lower staves, and lower chord tones are dropped on purpose. Grace notes are omitted so the beat count stays put. Tied notes are shown separately so bar lines do not slide.
|
|
||||||
@@ -16,8 +16,6 @@ extraResources:
|
|||||||
to: standalone
|
to: standalone
|
||||||
- from: .next/standalone/node_modules
|
- from: .next/standalone/node_modules
|
||||||
to: standalone/node_modules
|
to: standalone/node_modules
|
||||||
- from: build/python
|
|
||||||
to: python
|
|
||||||
linux:
|
linux:
|
||||||
target:
|
target:
|
||||||
- AppImage
|
- AppImage
|
||||||
|
|||||||
+6
-8
@@ -48,12 +48,11 @@ function stopServer() {
|
|||||||
async function open() {
|
async function open() {
|
||||||
const resources = process.resourcesPath;
|
const resources = process.resourcesPath;
|
||||||
const standalone = path.join(resources, "standalone");
|
const standalone = path.join(resources, "standalone");
|
||||||
const python = path.join(resources, "python", "bin", "python3");
|
|
||||||
const serverJs = path.join(standalone, "server.js");
|
const serverJs = path.join(standalone, "server.js");
|
||||||
if (!fs.existsSync(serverJs) || !fs.existsSync(python)) {
|
if (!fs.existsSync(serverJs)) {
|
||||||
dialog.showErrorBox(
|
dialog.showErrorBox(
|
||||||
"Recorder Tab Converter",
|
"Recorder fingering editor",
|
||||||
"The bundled app or the local reader is missing from this AppImage.",
|
"The bundled app is missing from this AppImage.",
|
||||||
);
|
);
|
||||||
app.quit();
|
app.quit();
|
||||||
return;
|
return;
|
||||||
@@ -69,7 +68,6 @@ async function open() {
|
|||||||
ELECTRON_RUN_AS_NODE: "1",
|
ELECTRON_RUN_AS_NODE: "1",
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
HOSTNAME: "127.0.0.1",
|
HOSTNAME: "127.0.0.1",
|
||||||
OEMER_PYTHON: python,
|
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
@@ -85,7 +83,7 @@ async function open() {
|
|||||||
await waitFor(url, 45_000);
|
await waitFor(url, 45_000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dialog.showErrorBox(
|
dialog.showErrorBox(
|
||||||
"Recorder Tab Converter",
|
"Recorder fingering editor",
|
||||||
`${error instanceof Error ? error.message : "The app server did not start."}\nLog: ${logPath}`,
|
`${error instanceof Error ? error.message : "The app server did not start."}\nLog: ${logPath}`,
|
||||||
);
|
);
|
||||||
stopServer();
|
stopServer();
|
||||||
@@ -99,7 +97,7 @@ async function open() {
|
|||||||
minWidth: 390,
|
minWidth: 390,
|
||||||
minHeight: 640,
|
minHeight: 640,
|
||||||
show: false,
|
show: false,
|
||||||
title: "Recorder Tab Converter",
|
title: "Recorder fingering editor",
|
||||||
backgroundColor: "#f3ecdf",
|
backgroundColor: "#f3ecdf",
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
@@ -115,7 +113,7 @@ async function open() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(open).catch((error) => {
|
app.whenReady().then(open).catch((error) => {
|
||||||
dialog.showErrorBox("Recorder Tab Converter", error instanceof Error ? error.message : String(error));
|
dialog.showErrorBox("Recorder fingering editor", error instanceof Error ? error.message : String(error));
|
||||||
app.quit();
|
app.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-123
@@ -1,17 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "recorder-tab-converter",
|
"name": "recorder-tab-converter",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "recorder-tab-converter",
|
"name": "recorder-tab-converter",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.8.0",
|
"@base-ui/react": "^1.8.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cn": "^0.4.0",
|
"cn": "^0.4.0",
|
||||||
"fast-xml-parser": "^5.11.2",
|
|
||||||
"lucide-react": "^1.53.0",
|
"lucide-react": "^1.53.0",
|
||||||
"next": "16.4.0",
|
"next": "16.4.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -2200,18 +2199,6 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nodable/entities": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/nodable"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -4134,18 +4121,6 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/anynum": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/app-builder-lib": {
|
"node_modules/app-builder-lib": {
|
||||||
"version": "26.15.3",
|
"version": "26.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz",
|
||||||
@@ -7019,45 +6994,6 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-builder": {
|
|
||||||
"version": "1.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
|
|
||||||
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"path-expression-matcher": "^1.6.2",
|
|
||||||
"xml-naming": "^0.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-xml-parser": {
|
|
||||||
"version": "5.11.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz",
|
|
||||||
"integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@nodable/entities": "^3.0.1",
|
|
||||||
"fast-xml-builder": "^1.2.0",
|
|
||||||
"is-unsafe": "^2.0.0",
|
|
||||||
"path-expression-matcher": "^1.6.2",
|
|
||||||
"strnum": "^2.4.2",
|
|
||||||
"xml-naming": "^0.3.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"fxparser": "src/cli/cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
|
||||||
@@ -8481,18 +8417,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-unsafe": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/is-weakmap": {
|
"node_modules/is-weakmap": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||||
@@ -10152,21 +10076,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-expression-matcher": {
|
|
||||||
"version": "1.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
|
||||||
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/path-is-absolute": {
|
"node_modules/path-is-absolute": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
@@ -12028,21 +11937,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strnum": {
|
|
||||||
"version": "2.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
|
|
||||||
"integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"anynum": "^1.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/styled-jsx": {
|
"node_modules/styled-jsx": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||||
@@ -13495,21 +13389,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xml-naming": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xmlbuilder": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "15.1.1",
|
"version": "15.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
|
|||||||
+4
-5
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "recorder-tab-converter",
|
"name": "recorder-tab-converter",
|
||||||
"version": "0.1.1",
|
"version": "0.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Turn a sheet-music image into soprano recorder fingering tabs, with rhythm kept.",
|
"description": "Arrange soprano recorder fingering charts by dragging them onto a line.",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "OEMER_PYTHON=\"${OEMER_PYTHON:-$PWD/build/python/bin/python3}\" next dev -H 0.0.0.0 -p 3847",
|
"dev": "next dev -H 0.0.0.0 -p 3847",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "OEMER_PYTHON=\"${OEMER_PYTHON:-$PWD/build/python/bin/python3}\" next start -H 0.0.0.0 -p 3847",
|
"start": "next start -H 0.0.0.0 -p 3847",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"appimage": "bash scripts/build-appimage.sh"
|
"appimage": "bash scripts/build-appimage.sh"
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
"@base-ui/react": "^1.8.0",
|
"@base-ui/react": "^1.8.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cn": "^0.4.0",
|
"cn": "^0.4.0",
|
||||||
"fast-xml-parser": "^5.11.2",
|
|
||||||
"lucide-react": "^1.53.0",
|
"lucide-react": "^1.53.0",
|
||||||
"next": "16.4.0",
|
"next": "16.4.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
+4
-100
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# One-command AppImage build. Needs network the first time (Python, models, Electron).
|
# One-command AppImage of the fingering editor. No Python, oemer, or ONNX models.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
ROOT="$(pwd)"
|
ROOT="$(pwd)"
|
||||||
@@ -7,113 +7,17 @@ ROOT="$(pwd)"
|
|||||||
echo "== Install Node packages =="
|
echo "== Install Node packages =="
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
mkdir -p "$ROOT/build/cache"
|
|
||||||
TARBALL="$ROOT/build/cache/cpython.tar.gz"
|
|
||||||
URL="https://github.com/astral-sh/python-build-standalone/releases/download/20261003/cpython-3.12.15%2B20261003-x86_64-unknown-linux-gnu-install_only.tar.gz"
|
|
||||||
|
|
||||||
PY="$ROOT/build/python/bin/python3"
|
|
||||||
if ! "$PY" -c "import oemer.ete, onnxruntime" >/dev/null 2>&1; then
|
|
||||||
echo "== Download a relocatable Python and oemer =="
|
|
||||||
if [[ ! -f "$TARBALL" ]]; then
|
|
||||||
curl -fL --retry 3 -o "$TARBALL" "$URL"
|
|
||||||
fi
|
|
||||||
rm -rf "$ROOT/build/python"
|
|
||||||
tar -xzf "$TARBALL" -C "$ROOT/build"
|
|
||||||
"$PY" -m pip install --upgrade pip
|
|
||||||
"$PY" -m pip install \
|
|
||||||
"opencv-python-headless>=4.10,<5" \
|
|
||||||
"onnxruntime>=1.20" \
|
|
||||||
numpy scipy scikit-learn pillow matplotlib typing_extensions
|
|
||||||
"$PY" -m pip install --no-deps oemer
|
|
||||||
fi
|
|
||||||
|
|
||||||
SITE="$("$PY" -c "import oemer, pathlib; print(pathlib.Path(oemer.__file__).parent)")"
|
|
||||||
mkdir -p "$SITE/checkpoints/unet_big" "$SITE/checkpoints/seg_net"
|
|
||||||
if [[ ! -f "$SITE/checkpoints/unet_big/model.onnx" ]]; then
|
|
||||||
curl -fL --retry 3 -o "$SITE/checkpoints/unet_big/model.onnx" \
|
|
||||||
"https://github.com/BreezeWhite/oemer/releases/download/checkpoints/1st_model.onnx"
|
|
||||||
fi
|
|
||||||
if [[ ! -f "$SITE/checkpoints/seg_net/model.onnx" ]]; then
|
|
||||||
curl -fL --retry 3 -o "$SITE/checkpoints/seg_net/model.onnx" \
|
|
||||||
"https://github.com/BreezeWhite/oemer/releases/download/checkpoints/2nd_model.onnx"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== Rewrite U-Net ConvTranspose pads =="
|
|
||||||
# oemer 0.1.8's unet has ConvTranspose pads [0, 0, -1, -1]. ONNX Runtime
|
|
||||||
# 1.30+ rejects those. The rewrite keeps the same output size. onnx itself
|
|
||||||
# is not packaged.
|
|
||||||
if ! "$PY" -m pip --version >/dev/null 2>&1; then
|
|
||||||
if [[ ! -f "$TARBALL" ]]; then
|
|
||||||
echo "pip was removed and the Python tarball is missing, so the U-Net cannot be rewritten." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
PIP_RESTORE="$ROOT/build/cache/pip-restore"
|
|
||||||
rm -rf "$PIP_RESTORE"
|
|
||||||
mkdir -p "$PIP_RESTORE"
|
|
||||||
tar -xzf "$TARBALL" -C "$PIP_RESTORE" --wildcards \
|
|
||||||
'python/lib/python3.12/site-packages/pip' \
|
|
||||||
'python/lib/python3.12/site-packages/pip-*.dist-info'
|
|
||||||
rm -rf "$ROOT/build/python/lib/python3.12/site-packages/pip" \
|
|
||||||
"$ROOT/build/python/lib/python3.12/site-packages/pip-"*.dist-info
|
|
||||||
cp -a "$PIP_RESTORE/python/lib/python3.12/site-packages/pip" \
|
|
||||||
"$ROOT/build/python/lib/python3.12/site-packages/"
|
|
||||||
cp -a "$PIP_RESTORE"/python/lib/python3.12/site-packages/pip-*.dist-info \
|
|
||||||
"$ROOT/build/python/lib/python3.12/site-packages/"
|
|
||||||
rm -rf "$PIP_RESTORE"
|
|
||||||
fi
|
|
||||||
PATCH_PY="$ROOT/build/cache/onnx-patch"
|
|
||||||
rm -rf "$PATCH_PY"
|
|
||||||
"$PY" -m pip install --disable-pip-version-check --target "$PATCH_PY" 'onnx>=1.16,<2'
|
|
||||||
PYTHONPATH="$PATCH_PY${PYTHONPATH:+:$PYTHONPATH}" \
|
|
||||||
"$PY" "$ROOT/scripts/patch_oemer_unet.py" \
|
|
||||||
"$SITE/checkpoints/unet_big/model.onnx"
|
|
||||||
rm -rf "$PATCH_PY"
|
|
||||||
|
|
||||||
MPLBACKEND=Agg "$PY" -c "import oemer.ete, cv2; from cv2.typing import RotatedRect; print('oemer', cv2.__version__)"
|
|
||||||
|
|
||||||
echo "== Trim the Python prefix =="
|
|
||||||
rm -rf \
|
|
||||||
"$ROOT/build/python/lib/python3.12/test" \
|
|
||||||
"$ROOT/build/python/lib/python3.12/ensurepip" \
|
|
||||||
"$ROOT/build/python/lib/python3.12/idlelib" \
|
|
||||||
"$ROOT/build/python/lib/python3.12/turtledemo" \
|
|
||||||
"$ROOT/build/python/lib/python3.12/site-packages/pip" \
|
|
||||||
"$ROOT/build/python/lib/python3.12/site-packages/pip-"*.dist-info \
|
|
||||||
"$ROOT/build/python/lib/tcl9.0" \
|
|
||||||
"$ROOT/build/python/lib/tcl9" \
|
|
||||||
"$ROOT/build/python/lib/tk9.0" \
|
|
||||||
"$ROOT/build/python/lib/itcl4.3.8" \
|
|
||||||
"$ROOT/build/python/lib/libtcl9tk9.0.so" \
|
|
||||||
"$ROOT/build/python/lib/libtcl9.0.so" \
|
|
||||||
"$SITE/checkpoints/unet_big/weights.h5" \
|
|
||||||
"$SITE/checkpoints/seg_net/weights.h5" || true
|
|
||||||
find "$ROOT/build/python" -type d -name __pycache__ -prune -exec rm -rf {} + || true
|
|
||||||
find "$ROOT/build/python" -type d -name tests -path '*/site-packages/*' -prune -exec rm -rf {} + || true
|
|
||||||
find "$ROOT/build/python" -name '*.a' -delete || true
|
|
||||||
# Leave the CPython binary and libpython intact. Stripping those breaks the interpreter.
|
|
||||||
find "$ROOT/build/python/lib/python3.12/site-packages" -type f -name '*.so' \
|
|
||||||
-exec strip --strip-unneeded {} + || true
|
|
||||||
|
|
||||||
if [[ ! -f "$ROOT/electron/icon.png" ]]; then
|
if [[ ! -f "$ROOT/electron/icon.png" ]]; then
|
||||||
"$PY" - <<'PY'
|
echo "electron/icon.png is missing." >&2
|
||||||
from PIL import Image, ImageDraw
|
exit 1
|
||||||
image = Image.new("RGB", (512, 512), "#f3ecdf")
|
|
||||||
draw = ImageDraw.Draw(image)
|
|
||||||
draw.rounded_rectangle((206, 36, 306, 470), radius=48, fill="#e4cfae", outline="#6a4328", width=8)
|
|
||||||
draw.ellipse((156, 92, 214, 150), outline="#6a4328", width=6)
|
|
||||||
for y in (160, 210, 260, 330, 390, 450):
|
|
||||||
draw.ellipse((228, y, 284, y + 48), fill="#1c3830")
|
|
||||||
image.save("electron/icon.png")
|
|
||||||
PY
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "== Build the Next.js server =="
|
echo "== Build the Next.js server =="
|
||||||
npm run build
|
npm run build
|
||||||
rm -rf .next/standalone/public .next/standalone/.next/static .next/standalone/scripts
|
rm -rf .next/standalone/public .next/standalone/.next/static
|
||||||
mkdir -p .next/standalone/.next
|
mkdir -p .next/standalone/.next
|
||||||
cp -a public .next/standalone/public
|
cp -a public .next/standalone/public
|
||||||
cp -a .next/static .next/standalone/.next/static
|
cp -a .next/static .next/standalone/.next/static
|
||||||
cp -a scripts .next/standalone/scripts
|
|
||||||
|
|
||||||
echo "== Package the AppImage =="
|
echo "== Package the AppImage =="
|
||||||
export CSC_IDENTITY_AUTO_DISCOVERY=false
|
export CSC_IDENTITY_AUTO_DISCOVERY=false
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Rewrite oemer U-Net ConvTranspose pads so ONNX Runtime can load the model.
|
|
||||||
|
|
||||||
oemer 0.1.8's 1st_model.onnx ships ConvTranspose nodes with pads
|
|
||||||
[0, 0, -1, -1]. ONNX Runtime 1.30 and newer reject those during shape
|
|
||||||
inference. pads [0, 0, 0, 0] with output_padding [1, 1] is the same
|
|
||||||
output size for these stride-2, kernel-1 nodes. Weights are left as they are.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import onnx
|
|
||||||
from onnx import helper
|
|
||||||
|
|
||||||
BAD_PADS = [0, 0, -1, -1]
|
|
||||||
FIXED_PADS = [0, 0, 0, 0]
|
|
||||||
FIXED_OUTPUT_PADDING = [1, 1]
|
|
||||||
CRASH_NODE = "model/conv2d_transpose_1/conv2d_transpose"
|
|
||||||
|
|
||||||
|
|
||||||
def _attr(node: onnx.NodeProto, name: str) -> onnx.AttributeProto | None:
|
|
||||||
for attr in node.attribute:
|
|
||||||
if attr.name == name:
|
|
||||||
return attr
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _ints(node: onnx.NodeProto, name: str) -> list[int] | None:
|
|
||||||
attr = _attr(node, name)
|
|
||||||
if attr is None:
|
|
||||||
return None
|
|
||||||
return list(attr.ints)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_ints(attr: onnx.AttributeProto, values: list[int]) -> None:
|
|
||||||
del attr.ints[:]
|
|
||||||
attr.ints.extend(values)
|
|
||||||
|
|
||||||
|
|
||||||
def rewrite(model_path: Path) -> int:
|
|
||||||
model = onnx.load(model_path)
|
|
||||||
changed = 0
|
|
||||||
for node in model.graph.node:
|
|
||||||
if node.op_type != "ConvTranspose":
|
|
||||||
continue
|
|
||||||
pads = _ints(node, "pads")
|
|
||||||
if pads is None or pads == FIXED_PADS:
|
|
||||||
continue
|
|
||||||
if pads != BAD_PADS:
|
|
||||||
if any(value < 0 for value in pads):
|
|
||||||
raise SystemExit(
|
|
||||||
f"{model_path}: {node.name} has unexpected negative pads {pads}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
pads_attr = _attr(node, "pads")
|
|
||||||
if pads_attr is None:
|
|
||||||
raise SystemExit(f"{model_path}: {node.name} is missing pads")
|
|
||||||
_set_ints(pads_attr, FIXED_PADS)
|
|
||||||
output_padding = _attr(node, "output_padding")
|
|
||||||
if output_padding is None:
|
|
||||||
node.attribute.append(
|
|
||||||
helper.make_attribute("output_padding", FIXED_OUTPUT_PADDING)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_set_ints(output_padding, FIXED_OUTPUT_PADDING)
|
|
||||||
changed += 1
|
|
||||||
print(
|
|
||||||
f"rewrote {node.name}: pads {FIXED_PADS} output_padding {FIXED_OUTPUT_PADDING}"
|
|
||||||
)
|
|
||||||
if changed:
|
|
||||||
onnx.save(model, model_path)
|
|
||||||
verify(model_path)
|
|
||||||
print(f"rewrote {changed} ConvTranspose node(s) in {model_path}")
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
def verify(model_path: Path) -> None:
|
|
||||||
model = onnx.load(model_path)
|
|
||||||
found = False
|
|
||||||
for node in model.graph.node:
|
|
||||||
if node.op_type != "ConvTranspose":
|
|
||||||
continue
|
|
||||||
pads = _ints(node, "pads") or []
|
|
||||||
if any(value < 0 for value in pads):
|
|
||||||
raise SystemExit(f"{model_path}: {node.name} still has negative pads {pads}")
|
|
||||||
if node.name != CRASH_NODE:
|
|
||||||
continue
|
|
||||||
found = True
|
|
||||||
output_padding = _ints(node, "output_padding")
|
|
||||||
if pads != FIXED_PADS or output_padding != FIXED_OUTPUT_PADDING:
|
|
||||||
raise SystemExit(
|
|
||||||
f"{model_path}: {node.name} is {pads} / {output_padding}, "
|
|
||||||
f"expected {FIXED_PADS} / {FIXED_OUTPUT_PADDING}"
|
|
||||||
)
|
|
||||||
if not found:
|
|
||||||
raise SystemExit(f"{model_path}: missing {CRASH_NODE}")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
if len(sys.argv) != 2:
|
|
||||||
print("usage: patch_oemer_unet.py MODEL.onnx", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
path = Path(sys.argv[1])
|
|
||||||
if not path.is_file():
|
|
||||||
print(f"missing model: {path}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
rewrite(path)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Read one sheet-music image with oemer and print the MusicXML path.
|
|
||||||
|
|
||||||
oemer asks ONNX Runtime for CUDA first. This wrapper forces the CPU provider
|
|
||||||
so the AppImage does not need a GPU.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
|
|
||||||
def force_cpu() -> None:
|
|
||||||
import onnxruntime as rt
|
|
||||||
|
|
||||||
original = rt.InferenceSession
|
|
||||||
|
|
||||||
def cpu_session(model, providers=None, **kwargs): # type: ignore[no-untyped-def]
|
|
||||||
kwargs.pop("providers", None)
|
|
||||||
return original(model, providers=["CPUExecutionProvider"], **kwargs)
|
|
||||||
|
|
||||||
rt.InferenceSession = cpu_session # type: ignore[method-assign]
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_png(src: str, dest: str) -> None:
|
|
||||||
from PIL import Image, ImageOps
|
|
||||||
|
|
||||||
with Image.open(src) as image:
|
|
||||||
rgb = ImageOps.exif_transpose(image).convert("RGB")
|
|
||||||
if min(rgb.size) < 80:
|
|
||||||
raise ValueError("This image is too small to read as sheet music.")
|
|
||||||
rgb.thumbnail((2200, 2200))
|
|
||||||
rgb.save(dest, "PNG")
|
|
||||||
|
|
||||||
|
|
||||||
def recognize(png: str, out_dir: str) -> str:
|
|
||||||
from argparse import Namespace
|
|
||||||
|
|
||||||
from oemer.ete import clear_data, extract
|
|
||||||
|
|
||||||
def once(deskew: bool) -> str:
|
|
||||||
clear_data()
|
|
||||||
args = Namespace(
|
|
||||||
img_path=png,
|
|
||||||
output_path=out_dir,
|
|
||||||
use_tf=False,
|
|
||||||
save_cache=False,
|
|
||||||
without_deskew=not deskew,
|
|
||||||
)
|
|
||||||
return extract(args)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return once(True)
|
|
||||||
except (Exception, SystemExit):
|
|
||||||
traceback.print_exc()
|
|
||||||
return once(False)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
if len(sys.argv) != 3:
|
|
||||||
print("usage: run_oemer.py IMAGE OUT_DIR", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
src, out_dir = sys.argv[1], sys.argv[2]
|
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
|
||||||
png = os.path.join(out_dir, "page.png")
|
|
||||||
try:
|
|
||||||
prepare_png(src, png)
|
|
||||||
force_cpu()
|
|
||||||
musicxml = recognize(png, out_dir)
|
|
||||||
except Exception as exc: # noqa: BLE001 — surface a single readable failure
|
|
||||||
print(f"{exc.__class__.__name__}: {exc}", file=sys.stderr)
|
|
||||||
traceback.print_exc()
|
|
||||||
return 1
|
|
||||||
if not musicxml or not os.path.isfile(musicxml):
|
|
||||||
print("oemer did not write MusicXML", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
print(f"MUSICXML={musicxml}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { connection } from "next/server";
|
|
||||||
import { recognizeImage, recognizerInfo } from "@/lib/recognize";
|
|
||||||
|
|
||||||
const TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
await connection();
|
|
||||||
return Response.json(recognizerInfo());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
let form: FormData;
|
|
||||||
try {
|
|
||||||
form = await request.formData();
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: "The upload could not be read. No notes were invented." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = form.get("image");
|
|
||||||
if (!(image instanceof File)) {
|
|
||||||
return Response.json({ error: "Choose a PNG, JPG, or WEBP image first." }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (image.size === 0) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "That file is empty. No notes were invented." },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const named = /\.(png|jpe?g|webp)$/i.test(image.name);
|
|
||||||
if (image.type && !TYPES.has(image.type) && !named) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Use a PNG, JPG, or WEBP photo. That file is not one of those. No notes were invented." },
|
|
||||||
{ status: 415 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (image.size > 8 * 1024 * 1024) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "That image is over 8 MB. Crop the line or export a smaller JPEG." },
|
|
||||||
{ status: 413 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await recognizeImage(image);
|
|
||||||
if (!result.ok) {
|
|
||||||
return Response.json({ error: result.error, recognizer: result.recognizer }, { status: result.status });
|
|
||||||
}
|
|
||||||
return Response.json({
|
|
||||||
score: result.score,
|
|
||||||
warnings: result.warnings,
|
|
||||||
recognizer: result.recognizer,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -117,6 +117,12 @@
|
|||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.line-drag,
|
||||||
|
body.line-drag * {
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|||||||
+2
-2
@@ -16,9 +16,9 @@ const display = Fraunces({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Recorder Tab Converter",
|
title: "Recorder fingering editor",
|
||||||
description:
|
description:
|
||||||
"Turn a lead melody into soprano recorder fingering tabs, with rhythm, rests, and bar lines kept.",
|
"Drag soprano recorder fingering charts, rests, and spaces onto a line, then save the row as a PNG.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
import { Converter } from "@/components/converter";
|
import { Editor } from "@/components/editor";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<Converter />
|
<Editor />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { NoteEditor } from "@/components/note-editor";
|
|
||||||
import { ScoreViews } from "@/components/tab-score";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
||||||
import type { FingeringSystem } from "@/lib/fingering";
|
|
||||||
import { SAMPLES } from "@/lib/samples";
|
|
||||||
import { blankScore, type Score } from "@/lib/score";
|
|
||||||
import { Loader2, Music2, Upload } from "lucide-react";
|
|
||||||
import { useEffect, useId, useState } from "react";
|
|
||||||
|
|
||||||
type Status = "empty" | "loading" | "ready" | "error";
|
|
||||||
|
|
||||||
type RecognizerInfo = {
|
|
||||||
available: boolean;
|
|
||||||
name: string;
|
|
||||||
detail: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Converter() {
|
|
||||||
const inputId = useId();
|
|
||||||
const [score, setScore] = useState<Score | null>(null);
|
|
||||||
const [system, setSystem] = useState<FingeringSystem>("baroque");
|
|
||||||
const [status, setStatus] = useState<Status>("empty");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [warnings, setWarnings] = useState<string[]>([]);
|
|
||||||
const [recognizer, setRecognizer] = useState<RecognizerInfo | null>(null);
|
|
||||||
const [dragOver, setDragOver] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
fetch("/api/recognize")
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((info: RecognizerInfo) => {
|
|
||||||
if (!cancelled) setRecognizer(info);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setRecognizer({
|
|
||||||
available: false,
|
|
||||||
name: "oemer",
|
|
||||||
detail: "Could not check the local reader. Samples and the editor still work.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function loadScore(next: Score) {
|
|
||||||
setScore(structuredClone(next));
|
|
||||||
setWarnings([]);
|
|
||||||
setError(null);
|
|
||||||
setStatus("ready");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onFile(file: File) {
|
|
||||||
setError(null);
|
|
||||||
const allowed =
|
|
||||||
["image/png", "image/jpeg", "image/webp"].includes(file.type) ||
|
|
||||||
/\.(png|jpe?g|webp)$/i.test(file.name);
|
|
||||||
if (file.size === 0) {
|
|
||||||
setError("That file is empty. No notes were invented.");
|
|
||||||
setScore((current) => current ?? blankScore());
|
|
||||||
setStatus("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!allowed) {
|
|
||||||
setError("Use a PNG, JPG, or WEBP photo. That file is not one of those. No notes were invented.");
|
|
||||||
setScore((current) => current ?? blankScore());
|
|
||||||
setStatus("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.size > 8 * 1024 * 1024) {
|
|
||||||
setError("That image is over 8 MB. Crop the line or export a smaller JPEG.");
|
|
||||||
setScore((current) => current ?? blankScore());
|
|
||||||
setStatus("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("loading");
|
|
||||||
try {
|
|
||||||
const body = new FormData();
|
|
||||||
body.set("image", file);
|
|
||||||
const response = await fetch("/api/recognize", { method: "POST", body });
|
|
||||||
const data = (await response.json()) as {
|
|
||||||
error?: string;
|
|
||||||
score?: Score;
|
|
||||||
warnings?: string[];
|
|
||||||
};
|
|
||||||
if (!response.ok || data.error || !data.score) {
|
|
||||||
setError(data.error ?? "This image could not be read. No notes were invented.");
|
|
||||||
setWarnings([]);
|
|
||||||
setScore((current) => current ?? blankScore());
|
|
||||||
setStatus("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setScore(data.score);
|
|
||||||
setWarnings(data.warnings ?? []);
|
|
||||||
setStatus("ready");
|
|
||||||
} catch {
|
|
||||||
setError("The reader did not answer. No notes were invented. You can still enter the line by hand.");
|
|
||||||
setScore((current) => current ?? blankScore());
|
|
||||||
setStatus("error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 md:px-8 md:py-10">
|
|
||||||
<header className="space-y-3">
|
|
||||||
<p className="text-sm tracking-[0.16em] text-[#6a4328] uppercase">Soprano in C · concert pitch</p>
|
|
||||||
<h1 className="font-heading text-4xl leading-tight md:text-5xl">Recorder Tab Converter</h1>
|
|
||||||
<p className="max-w-2xl text-base leading-7 text-muted-foreground">
|
|
||||||
Turn a lead melody into fingering tabs a soprano player can read: thumb and holes, the note
|
|
||||||
name, and the rhythm, lined up in bars. Chords keep the top note. A second staff is ignored.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section
|
|
||||||
className={`rounded-3xl bg-card p-4 ring-1 ring-foreground/10 md:p-6 ${dragOver ? "ring-2 ring-primary" : ""}`}
|
|
||||||
onDragOver={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setDragOver(true);
|
|
||||||
}}
|
|
||||||
onDragLeave={() => setDragOver(false)}
|
|
||||||
onDrop={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setDragOver(false);
|
|
||||||
const file = event.dataTransfer.files[0];
|
|
||||||
if (file) void onFile(file);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h2 className="font-heading text-2xl">Photo or scan</h2>
|
|
||||||
<p className="max-w-xl text-sm leading-6 text-muted-foreground">
|
|
||||||
{recognizer?.detail ?? "Checking the local reader…"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={status === "loading"}
|
|
||||||
nativeButton={false}
|
|
||||||
render={<label htmlFor={inputId} />}
|
|
||||||
>
|
|
||||||
{status === "loading" ? <Loader2 className="animate-spin" /> : <Upload />}
|
|
||||||
{status === "loading" ? "Reading the page" : "Choose image"}
|
|
||||||
</Button>
|
|
||||||
<input
|
|
||||||
id={inputId}
|
|
||||||
className="sr-only"
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp"
|
|
||||||
onChange={(event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = "";
|
|
||||||
if (file) void onFile(file);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status === "loading" ? (
|
|
||||||
<p className="mt-4 text-sm text-muted-foreground" role="status">
|
|
||||||
oemer is reading the page on this computer. A dense photo can take a few minutes. Nothing
|
|
||||||
is uploaded.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="mt-4 text-sm text-muted-foreground">
|
|
||||||
Drop a PNG, JPG, or WEBP here, or open a sample below if you want to see the tabs before a
|
|
||||||
photo works.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>The page was not read</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{warnings.length > 0 ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>What the reader kept</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<ul className="list-disc pl-4">
|
|
||||||
{warnings.map((warning) => (
|
|
||||||
<li key={warning}>{warning}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{SAMPLES.map((sample) => (
|
|
||||||
<button
|
|
||||||
key={sample.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => loadScore(sample.score)}
|
|
||||||
className="rounded-2xl bg-card p-4 text-left ring-1 ring-foreground/10 transition hover:ring-primary"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2 font-heading text-xl">
|
|
||||||
<Music2 className="size-4 text-[#6a4328]" />
|
|
||||||
{sample.score.title}
|
|
||||||
</span>
|
|
||||||
<span className="mt-2 block text-sm leading-6 text-muted-foreground">{sample.blurb}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{score ? (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="font-heading text-3xl">{score.title}</h2>
|
|
||||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted-foreground">{score.attribution}</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs tracking-wide text-muted-foreground uppercase">Fingering</p>
|
|
||||||
<ToggleGroup
|
|
||||||
value={[system]}
|
|
||||||
spacing={0}
|
|
||||||
variant="outline"
|
|
||||||
onValueChange={(values) => {
|
|
||||||
const picked = values[values.length - 1];
|
|
||||||
if (picked === "baroque" || picked === "german") setSystem(picked);
|
|
||||||
}}
|
|
||||||
aria-label="Fingering system"
|
|
||||||
>
|
|
||||||
<ToggleGroupItem value="baroque">Baroque</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem value="german">German</ToggleGroupItem>
|
|
||||||
</ToggleGroup>
|
|
||||||
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
|
|
||||||
Baroque is the Dolmetsch chart and the default. German only changes F, F♯, and B♭.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(18rem,22rem)_minmax(0,1fr)]">
|
|
||||||
<div className="order-2 space-y-3 lg:order-1 lg:sticky lg:top-4 lg:max-h-[calc(100vh-2rem)] lg:overflow-y-auto lg:pr-1">
|
|
||||||
<h2 className="font-heading text-2xl">Note list</h2>
|
|
||||||
<p className="text-sm leading-6 text-muted-foreground">
|
|
||||||
Pitch, rest, and duration. Change anything the photo got wrong. The tabs follow this
|
|
||||||
list, including the bar lines.
|
|
||||||
</p>
|
|
||||||
<NoteEditor score={score} onChange={setScore} />
|
|
||||||
</div>
|
|
||||||
<div className="order-1 min-w-0 lg:order-2">
|
|
||||||
<ScoreViews score={score} system={system} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-3xl border border-dashed border-foreground/15 px-4 py-10 text-center">
|
|
||||||
<p className="font-heading text-2xl">No line loaded yet</p>
|
|
||||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-muted-foreground">
|
|
||||||
Open Twinkle or Ode to Joy to see fingerings and rhythm immediately, or start an empty
|
|
||||||
bar and type the notes yourself.
|
|
||||||
</p>
|
|
||||||
<Button type="button" className="mt-4" variant="secondary" onClick={() => loadScore(blankScore())}>
|
|
||||||
Start an empty line
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FingeringDiagram, RestMark } from "@/components/fingering-diagram";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DURATION_PRESETS } from "@/lib/duration";
|
||||||
|
import { downloadBlob, renderLinePng } from "@/lib/export-png";
|
||||||
|
import { CHART_NOTES, fingeringFor } from "@/lib/fingering";
|
||||||
|
import {
|
||||||
|
clampLineBox,
|
||||||
|
cloneLine,
|
||||||
|
createNote,
|
||||||
|
createRest,
|
||||||
|
createSpace,
|
||||||
|
dropIndexFromPoint,
|
||||||
|
insertNote,
|
||||||
|
LINE_EXAMPLES,
|
||||||
|
moveNote,
|
||||||
|
neighborDuration,
|
||||||
|
removeNote,
|
||||||
|
setDuration,
|
||||||
|
type LineKind,
|
||||||
|
type LineNote,
|
||||||
|
} from "@/lib/line";
|
||||||
|
import { chartPixelHeight } from "@/lib/notation";
|
||||||
|
import { pngFilename, PNG_CHART_WIDTH } from "@/lib/png-layout";
|
||||||
|
import { ChevronLeft, ChevronRight, Minus, Plus, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
|
||||||
|
|
||||||
|
const CHART_WIDTH = 44;
|
||||||
|
|
||||||
|
type Drag = {
|
||||||
|
source: "palette" | "line";
|
||||||
|
kind: LineKind;
|
||||||
|
pitch: string;
|
||||||
|
id: string;
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
armed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Editor() {
|
||||||
|
const [notes, setNotes] = useState<LineNote[]>([]);
|
||||||
|
const [title, setTitle] = useState("Empty line");
|
||||||
|
const [drag, setDrag] = useState<Drag | null>(null);
|
||||||
|
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||||
|
const [boxWidth, setBoxWidth] = useState<number | null>(null);
|
||||||
|
const [boxHeight, setBoxHeight] = useState(520);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const dragRef = useRef<Drag | null>(null);
|
||||||
|
const lineRef = useRef<HTMLDivElement>(null);
|
||||||
|
const frameRef = useRef<HTMLDivElement>(null);
|
||||||
|
const paletteRef = useRef<HTMLDivElement>(null);
|
||||||
|
const exportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
function updateDrag(next: Drag | null) {
|
||||||
|
dragRef.current = next;
|
||||||
|
setDrag(next);
|
||||||
|
if (!next) document.body.classList.remove("line-drag");
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function pointIndex(clientX: number, clientY: number) {
|
||||||
|
const line = lineRef.current;
|
||||||
|
if (!line) return -1;
|
||||||
|
const rect = line.getBoundingClientRect();
|
||||||
|
const centers = [...line.querySelectorAll<HTMLElement>("[data-note-id]")].map((element) => {
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
return box.left + box.width / 2;
|
||||||
|
});
|
||||||
|
return dropIndexFromPoint(clientX, clientY, rect, centers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(event: PointerEvent) {
|
||||||
|
const current = dragRef.current;
|
||||||
|
if (!current || event.pointerId !== current.pointerId) return;
|
||||||
|
const dx = event.clientX - current.startX;
|
||||||
|
const dy = event.clientY - current.startY;
|
||||||
|
if (!current.armed) {
|
||||||
|
const touchScroll =
|
||||||
|
event.pointerType === "touch" &&
|
||||||
|
current.source === "palette" &&
|
||||||
|
Math.abs(dx) > 12 &&
|
||||||
|
Math.abs(dx) > Math.abs(dy);
|
||||||
|
if (touchScroll) {
|
||||||
|
updateDrag(null);
|
||||||
|
setDropIndex(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Math.hypot(dx, dy) < 8) return;
|
||||||
|
}
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
if (lineRef.current) {
|
||||||
|
const rect = lineRef.current.getBoundingClientRect();
|
||||||
|
if (event.clientX > rect.right - 28) lineRef.current.scrollLeft += 14;
|
||||||
|
if (event.clientX < rect.left + 28) lineRef.current.scrollLeft -= 14;
|
||||||
|
}
|
||||||
|
const next = { ...current, x: event.clientX, y: event.clientY, armed: true };
|
||||||
|
updateDrag(next);
|
||||||
|
const index = pointIndex(event.clientX, event.clientY);
|
||||||
|
setDropIndex(index >= 0 ? index : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp(event: PointerEvent) {
|
||||||
|
const current = dragRef.current;
|
||||||
|
if (!current || event.pointerId !== current.pointerId) return;
|
||||||
|
const index = current.armed ? pointIndex(event.clientX, event.clientY) : -1;
|
||||||
|
if (current.armed && index >= 0) {
|
||||||
|
if (current.source === "palette") {
|
||||||
|
const note =
|
||||||
|
current.kind === "rest"
|
||||||
|
? createRest()
|
||||||
|
: current.kind === "space"
|
||||||
|
? createSpace()
|
||||||
|
: createNote(current.pitch);
|
||||||
|
setNotes((existing) => insertNote(existing, index, note));
|
||||||
|
setTitle("Your line");
|
||||||
|
} else {
|
||||||
|
setNotes((existing) => moveNote(existing, current.id, index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateDrag(null);
|
||||||
|
setDropIndex(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectStart(event: Event) {
|
||||||
|
if (dragRef.current) event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", onMove);
|
||||||
|
window.addEventListener("pointerup", onUp);
|
||||||
|
window.addEventListener("pointercancel", onUp);
|
||||||
|
document.addEventListener("selectstart", onSelectStart);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointermove", onMove);
|
||||||
|
window.removeEventListener("pointerup", onUp);
|
||||||
|
window.removeEventListener("pointercancel", onUp);
|
||||||
|
document.removeEventListener("selectstart", onSelectStart);
|
||||||
|
document.body.classList.remove("line-drag");
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function beginDrag(event: ReactPointerEvent, next: Drag) {
|
||||||
|
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||||
|
if (event.pointerType === "mouse") event.preventDefault();
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
document.body.classList.add("line-drag");
|
||||||
|
updateDrag(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageLimit() {
|
||||||
|
return frameRef.current?.parentElement?.clientWidth ?? 960;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudgeBox(dw: number, dh: number) {
|
||||||
|
const maxWidth = pageLimit();
|
||||||
|
const current = boxWidth ?? frameRef.current?.getBoundingClientRect().width ?? maxWidth;
|
||||||
|
const next = clampLineBox(current + dw, boxHeight + dh, maxWidth);
|
||||||
|
setBoxWidth(next.width);
|
||||||
|
setBoxHeight(next.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize(event: ReactPointerEvent) {
|
||||||
|
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
document.body.classList.add("line-drag");
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startY = event.clientY;
|
||||||
|
const startW = frameRef.current?.getBoundingClientRect().width ?? 640;
|
||||||
|
const startH = boxHeight;
|
||||||
|
const maxWidth = pageLimit();
|
||||||
|
function move(moveEvent: PointerEvent) {
|
||||||
|
const next = clampLineBox(
|
||||||
|
startW + (moveEvent.clientX - startX),
|
||||||
|
startH + (moveEvent.clientY - startY),
|
||||||
|
maxWidth,
|
||||||
|
);
|
||||||
|
setBoxWidth(next.width);
|
||||||
|
setBoxHeight(next.height);
|
||||||
|
}
|
||||||
|
function up() {
|
||||||
|
document.body.classList.remove("line-drag");
|
||||||
|
window.removeEventListener("pointermove", move);
|
||||||
|
window.removeEventListener("pointerup", up);
|
||||||
|
window.removeEventListener("pointercancel", up);
|
||||||
|
}
|
||||||
|
window.addEventListener("pointermove", move);
|
||||||
|
window.addEventListener("pointerup", up);
|
||||||
|
window.addEventListener("pointercancel", up);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePng() {
|
||||||
|
if (!exportRef.current || notes.length === 0) {
|
||||||
|
setSaveError("Add a note, rest, or space before saving a PNG.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
try {
|
||||||
|
const blob = await renderLinePng(exportRef.current, title);
|
||||||
|
downloadBlob(blob, pngFilename(title));
|
||||||
|
} catch (error) {
|
||||||
|
setSaveError(error instanceof Error ? error.message : "Could not save the PNG.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ghost = drag?.armed ? drag : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 md:px-8 md:py-10">
|
||||||
|
<header className="space-y-3">
|
||||||
|
<p className="text-sm tracking-[0.16em] text-[#6a4328] uppercase">Soprano in C · Baroque</p>
|
||||||
|
<h1 className="font-heading text-4xl leading-tight md:text-5xl">Recorder fingering editor</h1>
|
||||||
|
<p className="max-w-2xl text-base leading-7 text-muted-foreground">
|
||||||
|
Drag a chart, a rest, or a space onto the line. Drag a placed item sideways to reorder it.
|
||||||
|
The staff under the holes shows the pitch and the length. Resize the line box, then save the
|
||||||
|
finished row as a PNG. The foot, with the double holes, stays at the top.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={notes.length === 0 ? "default" : "outline"}
|
||||||
|
onClick={() => {
|
||||||
|
setNotes([]);
|
||||||
|
setTitle("Empty line");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Empty line
|
||||||
|
</Button>
|
||||||
|
{LINE_EXAMPLES.map((example) => (
|
||||||
|
<Button
|
||||||
|
key={example.id}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setNotes(cloneLine([...example.notes]));
|
||||||
|
setTitle(example.title);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{example.title}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-heading text-2xl">Palette</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
C5 through D7, plus a rest and a space. Drag downward onto the line. Swipe the row sideways
|
||||||
|
to see the rest of the charts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ScrollButtons target={paletteRef} label="palette" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={paletteRef}
|
||||||
|
className="flex gap-2 overflow-x-auto rounded-2xl bg-card p-3 ring-1 ring-foreground/10 select-none"
|
||||||
|
>
|
||||||
|
<PaletteToken
|
||||||
|
label="Rest"
|
||||||
|
onPointerDown={(event) =>
|
||||||
|
beginDrag(event, {
|
||||||
|
source: "palette",
|
||||||
|
kind: "rest",
|
||||||
|
pitch: "",
|
||||||
|
id: "",
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
armed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RestMark quarters={1} width={40} />
|
||||||
|
</PaletteToken>
|
||||||
|
<PaletteToken
|
||||||
|
label="Space"
|
||||||
|
onPointerDown={(event) =>
|
||||||
|
beginDrag(event, {
|
||||||
|
source: "palette",
|
||||||
|
kind: "space",
|
||||||
|
pitch: "",
|
||||||
|
id: "",
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
armed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SpaceGlyph width={40} height={chartPixelHeight(40)} />
|
||||||
|
</PaletteToken>
|
||||||
|
{CHART_NOTES.map((pitch) => (
|
||||||
|
<PaletteToken
|
||||||
|
key={pitch}
|
||||||
|
label={pitch}
|
||||||
|
pitch={pitch}
|
||||||
|
onPointerDown={(event) =>
|
||||||
|
beginDrag(event, {
|
||||||
|
source: "palette",
|
||||||
|
kind: "note",
|
||||||
|
pitch,
|
||||||
|
id: "",
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
armed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FingeringDiagram fingering={fingeringFor(pitch, "baroque")} pitch={pitch} size="sm" />
|
||||||
|
</PaletteToken>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-heading text-2xl">{title}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Treble staff written an octave below the sounding pitch, 4/4. Noteheads, stems, flags, and dots show the length. Plus and minus
|
||||||
|
change it. A space is empty room you can still drag. The box is {boxHeight}px tall
|
||||||
|
{boxWidth ? `, ${boxWidth}px wide` : ""}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ScrollButtons target={lineRef} label="line" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => nudgeBox(0, -48)}>
|
||||||
|
Shorter
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => nudgeBox(0, 48)}>
|
||||||
|
Taller
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => nudgeBox(-80, 0)}>
|
||||||
|
Narrower
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => nudgeBox(80, 0)}>
|
||||||
|
Wider
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setBoxWidth(null)}>
|
||||||
|
Fit width
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" onClick={savePng} disabled={notes.length === 0 || saving}>
|
||||||
|
{saving ? "Saving…" : "Save PNG"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{saveError ? <p className="text-sm text-destructive">{saveError}</p> : null}
|
||||||
|
<div ref={frameRef} className="relative max-w-full" style={{ width: boxWidth ?? "100%", height: boxHeight }}>
|
||||||
|
<div
|
||||||
|
ref={lineRef}
|
||||||
|
data-line
|
||||||
|
className={`h-full overflow-auto rounded-none border-2 bg-card px-3 py-4 select-none ${
|
||||||
|
dropIndex !== null ? "border-primary" : "border-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{notes.length === 0 ? (
|
||||||
|
<p className="px-2 py-16 text-center text-sm text-muted-foreground">
|
||||||
|
Drop a fingering, a rest, or a space here. The line is empty.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-max min-w-full items-end gap-2">
|
||||||
|
{notes.map((note, index) => (
|
||||||
|
<div key={note.id} className="flex items-end gap-2">
|
||||||
|
{dropIndex === index ? <DropCaret /> : null}
|
||||||
|
<PlacedItem
|
||||||
|
note={note}
|
||||||
|
dimmed={drag?.source === "line" && drag.armed && drag.id === note.id}
|
||||||
|
onPointerDown={(event) =>
|
||||||
|
beginDrag(event, {
|
||||||
|
source: "line",
|
||||||
|
kind: note.kind,
|
||||||
|
id: note.id,
|
||||||
|
pitch: note.pitch,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
armed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onDuration={(quarters) => {
|
||||||
|
const preset = DURATION_PRESETS.find((item) => item.quarters === quarters);
|
||||||
|
if (!preset) return;
|
||||||
|
setNotes((existing) => setDuration(existing, note.id, preset));
|
||||||
|
}}
|
||||||
|
onRemove={() => setNotes((existing) => removeNote(existing, note.id))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{dropIndex === notes.length ? <DropCaret /> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="slider"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Drag to resize the line box"
|
||||||
|
aria-valuemin={200}
|
||||||
|
aria-valuemax={880}
|
||||||
|
aria-valuenow={boxHeight}
|
||||||
|
aria-valuetext={`${boxWidth ? `${boxWidth} pixels wide` : "full width"}, ${boxHeight} pixels tall`}
|
||||||
|
className="absolute right-0 bottom-0 z-10 flex h-8 w-8 cursor-nwse-resize touch-none items-end justify-end p-1"
|
||||||
|
onPointerDown={startResize}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "ArrowUp") nudgeBox(0, -48);
|
||||||
|
if (event.key === "ArrowDown") nudgeBox(0, 48);
|
||||||
|
if (event.key === "ArrowLeft") nudgeBox(-80, 0);
|
||||||
|
if (event.key === "ArrowRight") nudgeBox(80, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="block h-3 w-3 border-r-2 border-b-2 border-foreground" aria-hidden />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<li>Foot at the top, mouthpiece at the bottom</li>
|
||||||
|
<li>Filled hole: closed</li>
|
||||||
|
<li>Empty hole: open</li>
|
||||||
|
<li>One of a pair filled: half-hole</li>
|
||||||
|
<li>Thumb half-filled: pinch</li>
|
||||||
|
<li>Staff under the chart: pitch and length</li>
|
||||||
|
<li>Rest: silence on the staff</li>
|
||||||
|
<li>Space: empty room</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{ghost ? (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed z-50 opacity-90 select-none"
|
||||||
|
style={{ left: ghost.x - 22, top: ghost.y - 80 }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{ghost.kind === "note" ? (
|
||||||
|
<FingeringDiagram fingering={fingeringFor(ghost.pitch, "baroque")} pitch={ghost.pitch} size="sm" />
|
||||||
|
) : null}
|
||||||
|
{ghost.kind === "rest" ? <RestMark quarters={1} width={40} /> : null}
|
||||||
|
{ghost.kind === "space" ? <SpaceGlyph width={40} height={chartPixelHeight(40)} /> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div ref={exportRef} className="pointer-events-none absolute h-px w-px overflow-hidden" aria-hidden>
|
||||||
|
{notes.map((note) => (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
data-export-slot
|
||||||
|
data-kind={note.kind}
|
||||||
|
data-quarters={note.quarters}
|
||||||
|
data-duration={note.label}
|
||||||
|
data-pitch={note.pitch}
|
||||||
|
>
|
||||||
|
{note.kind === "note" ? (
|
||||||
|
<FingeringDiagram
|
||||||
|
fingering={fingeringFor(note.pitch, "baroque")}
|
||||||
|
pitch={note.pitch}
|
||||||
|
quarters={note.quarters}
|
||||||
|
width={PNG_CHART_WIDTH}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{note.kind === "rest" ? <RestMark quarters={note.quarters} width={PNG_CHART_WIDTH} /> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaletteToken({
|
||||||
|
label,
|
||||||
|
pitch,
|
||||||
|
onPointerDown,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
pitch?: string;
|
||||||
|
onPointerDown: (event: ReactPointerEvent) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-palette-pitch={pitch}
|
||||||
|
className="flex w-14 shrink-0 cursor-grab touch-pan-x flex-col items-center gap-1 select-none active:cursor-grabbing"
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onDragStart={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<span className="text-[11px] leading-none font-medium">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlacedItem({
|
||||||
|
note,
|
||||||
|
dimmed,
|
||||||
|
onPointerDown,
|
||||||
|
onDuration,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
note: LineNote;
|
||||||
|
dimmed: boolean;
|
||||||
|
onPointerDown: (event: ReactPointerEvent) => void;
|
||||||
|
onDuration: (quarters: number) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const fingering = note.kind === "note" ? fingeringFor(note.pitch, "baroque") : null;
|
||||||
|
const caption = note.kind === "note" ? note.pitch : note.kind === "rest" ? "Rest" : "Space";
|
||||||
|
const chartHeight = chartPixelHeight(CHART_WIDTH);
|
||||||
|
const shorter = neighborDuration(note.quarters, -1);
|
||||||
|
const longer = neighborDuration(note.quarters, 1);
|
||||||
|
return (
|
||||||
|
<div data-note-id={note.id} className={`flex flex-col items-center gap-1 select-none ${dimmed ? "opacity-40" : ""}`}>
|
||||||
|
<div
|
||||||
|
className="flex cursor-grab touch-none items-center gap-1 active:cursor-grabbing"
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onDragStart={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
{note.kind === "note" ? (
|
||||||
|
<FingeringDiagram fingering={fingering} pitch={note.pitch} quarters={note.quarters} />
|
||||||
|
) : null}
|
||||||
|
{note.kind === "rest" ? <RestMark quarters={note.quarters} width={CHART_WIDTH} /> : null}
|
||||||
|
{note.kind === "space" ? (
|
||||||
|
<SpaceGlyph width={Math.max(36, Math.round(CHART_WIDTH * note.quarters))} height={chartHeight} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] leading-none font-medium">{caption}</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label={`Make ${caption} shorter`}
|
||||||
|
disabled={!shorter}
|
||||||
|
onClick={() => shorter && onDuration(shorter.quarters)}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Minus />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label={`Make ${caption} longer`}
|
||||||
|
disabled={!longer}
|
||||||
|
onClick={() => longer && onDuration(longer.quarters)}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
aria-label={`Remove ${caption}`}
|
||||||
|
onClick={onRemove}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpaceGlyph({ width, height }: { width: number; height: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-md border border-dashed border-foreground/35 bg-muted/50"
|
||||||
|
style={{ width, height }}
|
||||||
|
role="img"
|
||||||
|
aria-label="Space"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropCaret() {
|
||||||
|
return <div className="w-1 self-stretch rounded-full bg-primary" aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollButtons({
|
||||||
|
target,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
target: RefObject<HTMLDivElement | null>;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Scroll ${label} left`}
|
||||||
|
onClick={() => target.current?.scrollBy({ left: -180, behavior: "smooth" })}
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Scroll ${label} right`}
|
||||||
|
onClick={() => target.current?.scrollBy({ left: 180, behavior: "smooth" })}
|
||||||
|
>
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,32 @@
|
|||||||
|
import { durationLabel } from "@/lib/duration";
|
||||||
import {
|
import {
|
||||||
fingeringMarks,
|
fingeringMarks,
|
||||||
fingeringProse,
|
fingeringProse,
|
||||||
type Fingering,
|
type Fingering,
|
||||||
} from "@/lib/fingering";
|
} from "@/lib/fingering";
|
||||||
|
import {
|
||||||
|
CHART_VB_H,
|
||||||
|
CHART_VB_W,
|
||||||
|
chartPixelHeight,
|
||||||
|
dotIndex,
|
||||||
|
ledgerIndexes,
|
||||||
|
noteValue,
|
||||||
|
restValue,
|
||||||
|
staffPlacement,
|
||||||
|
staffY,
|
||||||
|
STAFF_SPACE,
|
||||||
|
stemDown,
|
||||||
|
} from "@/lib/notation";
|
||||||
|
|
||||||
type HoleState = Fingering["holes"][number];
|
type HoleState = Fingering["holes"][number];
|
||||||
|
|
||||||
|
const TUBE = "#9deaf3";
|
||||||
|
const INK = "#1a1a1a";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foot at the top, mouthpiece at the bottom.
|
||||||
|
* The drawing is the reverse of a held recorder. Hole states are not changed.
|
||||||
|
*/
|
||||||
function Hole({
|
function Hole({
|
||||||
cx,
|
cx,
|
||||||
cy,
|
cy,
|
||||||
@@ -19,15 +40,15 @@ function Hole({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
<circle cx={cx} cy={cy} r={r} fill="#fbf6ec" />
|
<circle cx={cx} cy={cy} r={r} fill={TUBE} />
|
||||||
{state === "closed" ? <circle cx={cx} cy={cy} r={r - 1.1} fill="#1c3830" /> : null}
|
{state === "closed" ? <circle cx={cx} cy={cy} r={r - 0.55} fill={INK} /> : null}
|
||||||
{state === "half" ? (
|
{state === "half" ? (
|
||||||
<path
|
<path
|
||||||
d={`M ${cx} ${cy - r + 1} A ${r - 1} ${r - 1} 0 0 0 ${cx} ${cy + r - 1} Z`}
|
d={`M ${cx} ${cy - r + 0.4} A ${r - 0.4} ${r - 0.4} 0 0 0 ${cx} ${cy + r - 0.4} Z`}
|
||||||
fill="#1c3830"
|
fill={INK}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#5c3d28" strokeWidth="1.6" />
|
<circle cx={cx} cy={cy} r={r} fill="none" stroke={INK} strokeWidth="1.25" />
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -35,101 +56,290 @@ function Hole({
|
|||||||
export function FingeringDiagram({
|
export function FingeringDiagram({
|
||||||
fingering,
|
fingering,
|
||||||
pitch,
|
pitch,
|
||||||
size = "lg",
|
size = "lead",
|
||||||
|
width: widthOverride,
|
||||||
|
quarters = 1,
|
||||||
}: {
|
}: {
|
||||||
fingering: Fingering | null;
|
fingering: Fingering | null;
|
||||||
pitch: string;
|
pitch: string;
|
||||||
size?: "lg" | "sm";
|
size?: "lead" | "sm";
|
||||||
|
width?: number;
|
||||||
|
quarters?: number;
|
||||||
}) {
|
}) {
|
||||||
const width = size === "lg" ? 112 : 88;
|
const width = widthOverride ?? (size === "lead" ? 44 : 40);
|
||||||
|
const height = chartPixelHeight(width);
|
||||||
if (!fingering) {
|
if (!fingering) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center rounded-2xl border border-dashed border-destructive/50 bg-destructive/5 px-2 text-center text-xs leading-snug text-destructive"
|
className="flex items-center justify-center rounded-md border border-dashed border-destructive/50 bg-destructive/5 px-1 text-center text-[10px] leading-tight text-destructive"
|
||||||
style={{ width, height: size === "lg" ? 220 : 172 }}
|
style={{ width, height }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`${pitch} is outside the C5 to D7 chart. No fingering is shown.`}
|
aria-label={`${pitch} is outside the C5 to D7 chart. No fingering is shown.`}
|
||||||
>
|
>
|
||||||
Outside C5–D7. No fingering.
|
No chart
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [h1, h2, h3, h4, h5, h6a, h6b, h7a, h7b] = fingering.holes;
|
const [h1, h2, h3, h4, h5, h6a, h6b, h7a, h7b] = fingering.holes;
|
||||||
const label = `${pitch}. ${fingeringProse(fingering)}. ${fingeringMarks(fingering).join(", ")}`;
|
const value = noteValue(quarters);
|
||||||
|
const label = `${pitch}, written one octave lower, ${durationLabel(quarters)} on the treble staff. Foot at the top, mouthpiece at the bottom. ${fingeringProse(fingering)}. ${fingeringMarks(fingering).join(", ")}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 104 228"
|
viewBox={`0 0 ${CHART_VB_W} ${CHART_VB_H}`}
|
||||||
width={width}
|
width={width}
|
||||||
|
height={height}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="h-auto shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<rect x="34" y="8" width="46" height="208" rx="20" fill="#e4cfae" stroke="#6a4328" strokeWidth="2" />
|
<path
|
||||||
<path d="M46 28c8-16 22-16 30 0" fill="none" stroke="#6a4328" strokeWidth="2" />
|
d="M9 3 H33 V148 H44 V184 H9 Z"
|
||||||
<Hole cx={18} cy={40} r={8} state={fingering.thumb} />
|
fill={TUBE}
|
||||||
<text x={14} y={62} textAnchor="middle" fontSize="8" fill="#6a4328">
|
stroke={INK}
|
||||||
T
|
strokeWidth="1.4"
|
||||||
</text>
|
strokeLinejoin="miter"
|
||||||
<Hole cx={57} cy={58} r={8} state={h1} />
|
/>
|
||||||
<Hole cx={57} cy={80} r={8} state={h2} />
|
{fingering.bell ? <rect x="12" y="4" width="18" height="5" rx="1.2" fill={INK} /> : null}
|
||||||
<Hole cx={57} cy={102} r={8} state={h3} />
|
<Hole cx={15} cy={16} r={3.5} state={h7a} />
|
||||||
<Hole cx={57} cy={132} r={8} state={h4} />
|
<Hole cx={27} cy={16} r={3.5} state={h7b} />
|
||||||
<Hole cx={57} cy={154} r={8} state={h5} />
|
<Hole cx={15} cy={30} r={3.5} state={h6a} />
|
||||||
<Hole cx={48} cy={178} r={6.5} state={h6a} />
|
<Hole cx={27} cy={30} r={3.5} state={h6b} />
|
||||||
<Hole cx={66} cy={178} r={6.5} state={h6b} />
|
<Hole cx={21} cy={46} r={4.7} state={h5} />
|
||||||
<Hole cx={48} cy={198} r={6.5} state={h7a} />
|
<Hole cx={21} cy={62} r={4.7} state={h4} />
|
||||||
<Hole cx={66} cy={198} r={6.5} state={h7b} />
|
<line x1="9" y1="74" x2="33" y2="74" stroke={INK} strokeWidth="1" />
|
||||||
<text x={84} y={61} fontSize="8" fill="#7a5a3c">1</text>
|
<Hole cx={21} cy={86} r={4.7} state={h3} />
|
||||||
<text x={84} y={83} fontSize="8" fill="#7a5a3c">2</text>
|
<Hole cx={21} cy={102} r={4.7} state={h2} />
|
||||||
<text x={84} y={105} fontSize="8" fill="#7a5a3c">3</text>
|
<Hole cx={21} cy={118} r={4.7} state={h1} />
|
||||||
<text x={84} y={135} fontSize="8" fill="#7a5a3c">4</text>
|
<line x1="9" y1="130" x2="33" y2="130" stroke={INK} strokeWidth="1" />
|
||||||
<text x={84} y={157} fontSize="8" fill="#7a5a3c">5</text>
|
<Hole cx={21} cy={140} r={4.5} state={fingering.thumb} />
|
||||||
<text x={84} y={181} fontSize="8" fill="#7a5a3c">6</text>
|
<line x1="9" y1="156" x2="44" y2="156" stroke={INK} strokeWidth="1" />
|
||||||
<text x={84} y={201} fontSize="8" fill="#7a5a3c">7</text>
|
<line x1="9" y1="166" x2="44" y2="166" stroke={INK} strokeWidth="1" />
|
||||||
{fingering.bell ? (
|
<line x1="9" y1="176" x2="44" y2="176" stroke={INK} strokeWidth="1" />
|
||||||
<text x={57} y={222} textAnchor="middle" fontSize="8" fill="#6a4328">
|
<ellipse cx="26" cy="166" rx="3.2" ry="4.4" fill={TUBE} stroke={INK} strokeWidth="1.2" transform="rotate(-24 26 166)" />
|
||||||
bell
|
<Staff pitch={pitch} />
|
||||||
</text>
|
<NoteOnStaff pitch={pitch} head={value.head} stem={value.stem} flags={value.flags} dots={value.dots} />
|
||||||
) : null}
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RestMark({ label }: { label: string }) {
|
export function RestMark({ quarters = 1, width = 44 }: { quarters?: number; width?: number }) {
|
||||||
const kind = label.includes("whole")
|
const height = chartPixelHeight(width);
|
||||||
? "whole"
|
const value = restValue(quarters);
|
||||||
: label.includes("half")
|
|
||||||
? "half"
|
|
||||||
: label.includes("sixteenth")
|
|
||||||
? "sixteenth"
|
|
||||||
: label.includes("eighth")
|
|
||||||
? "eighth"
|
|
||||||
: "quarter";
|
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 64 120" width={72} className="h-auto" role="img" aria-label={`${label} rest`}>
|
<svg
|
||||||
<line x1="8" y1="60" x2="56" y2="60" stroke="#c4b39a" strokeWidth="2" />
|
viewBox={`0 0 ${CHART_VB_W} ${CHART_VB_H}`}
|
||||||
{kind === "whole" ? <rect x="20" y="36" width="24" height="14" rx="1" fill="#2a2118" /> : null}
|
width={width}
|
||||||
{kind === "half" ? <rect x="20" y="60" width="24" height="14" rx="1" fill="#2a2118" /> : null}
|
height={height}
|
||||||
{kind === "quarter" ? (
|
className="shrink-0"
|
||||||
<path
|
role="img"
|
||||||
d="M34 28c8 8 8 14 0 20s-8 12 2 20 10 10 4 18"
|
aria-label={`${durationLabel(quarters)} rest on the treble staff`}
|
||||||
fill="none"
|
data-rest={value.rest}
|
||||||
stroke="#2a2118"
|
data-dots={value.dots}
|
||||||
strokeWidth="4"
|
>
|
||||||
strokeLinecap="round"
|
<Staff />
|
||||||
/>
|
<RestOnStaff rest={value.rest} dots={value.dots} />
|
||||||
) : null}
|
|
||||||
{kind === "eighth" || kind === "sixteenth" ? (
|
|
||||||
<g fill="#2a2118">
|
|
||||||
<circle cx="36" cy="78" r="6" />
|
|
||||||
<path d="M36 72c10-14 16-20 8-36" fill="none" stroke="#2a2118" strokeWidth="3" />
|
|
||||||
{kind === "sixteenth" ? (
|
|
||||||
<path d="M32 64c10-10 14-14 6-26" fill="none" stroke="#2a2118" strokeWidth="3" />
|
|
||||||
) : null}
|
|
||||||
</g>
|
|
||||||
) : null}
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NOTE_X = 31;
|
||||||
|
|
||||||
|
function Staff({ pitch }: { pitch?: string }) {
|
||||||
|
return (
|
||||||
|
<g data-staff={pitch ?? "rest"} aria-hidden>
|
||||||
|
{[0, 2, 4, 6, 8].map((index) => (
|
||||||
|
<line
|
||||||
|
key={index}
|
||||||
|
x1={1.5}
|
||||||
|
x2={46.5}
|
||||||
|
y1={staffY(index)}
|
||||||
|
y2={staffY(index)}
|
||||||
|
stroke={INK}
|
||||||
|
strokeWidth={0.7}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<TrebleClef />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrebleClef() {
|
||||||
|
const y = staffY(2);
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
d={`M 7.4 ${y - 10.5}
|
||||||
|
C 10.6 ${y - 12.2} 12.4 ${y - 8.2} 11.2 ${y - 5.2}
|
||||||
|
C 10 ${y - 2.4} 7.2 ${y - 1.6} 6.8 ${y + 1.2}
|
||||||
|
C 6.2 ${y + 4.6} 8.8 ${y + 6.2} 10.2 ${y + 3.6}
|
||||||
|
C 11.4 ${y + 1.2} 9.2 ${y - 0.6} 7.6 ${y + 0.8}
|
||||||
|
C 6.8 ${y + 1.6} 7 ${y + 4.2} 8.4 ${y + 7.4}
|
||||||
|
C 9.6 ${y + 11.2} 10.2 ${y + 15} 8.6 ${y + 17.6}
|
||||||
|
C 7 ${y + 20.2} 4.2 ${y + 18.6} 4.8 ${y + 15.4}
|
||||||
|
C 5.2 ${y + 13.2} 7.6 ${y + 13.6} 8 ${y + 15.8}`}
|
||||||
|
fill="none"
|
||||||
|
stroke={INK}
|
||||||
|
strokeWidth={1.45}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoteOnStaff({
|
||||||
|
pitch,
|
||||||
|
head,
|
||||||
|
stem,
|
||||||
|
flags,
|
||||||
|
dots,
|
||||||
|
}: {
|
||||||
|
pitch: string;
|
||||||
|
head: "open" | "filled";
|
||||||
|
stem: boolean;
|
||||||
|
flags: 0 | 1 | 2;
|
||||||
|
dots: 0 | 1;
|
||||||
|
}) {
|
||||||
|
const placed = staffPlacement(pitch);
|
||||||
|
if (!placed) return null;
|
||||||
|
const y = staffY(placed.index);
|
||||||
|
const down = stemDown(placed.index);
|
||||||
|
const stemX = NOTE_X + (down ? -1.55 : 1.55);
|
||||||
|
const stemEnd = y + (down ? 1 : -1) * STAFF_SPACE * 3.5;
|
||||||
|
return (
|
||||||
|
<g data-pitch={pitch} data-head={head} data-flags={flags} data-dots={dots} data-stem={stem ? (down ? "down" : "up") : "none"}>
|
||||||
|
{ledgerIndexes(placed.index).map((index) => (
|
||||||
|
<line
|
||||||
|
key={index}
|
||||||
|
x1={NOTE_X - 5.2}
|
||||||
|
x2={NOTE_X + 5.2}
|
||||||
|
y1={staffY(index)}
|
||||||
|
y2={staffY(index)}
|
||||||
|
stroke={INK}
|
||||||
|
strokeWidth={0.7}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{placed.accidental === "sharp" ? <Sharp x={NOTE_X - 7.2} y={y} /> : null}
|
||||||
|
{placed.accidental === "flat" ? <Flat x={NOTE_X - 7.4} y={y} /> : null}
|
||||||
|
{stem ? (
|
||||||
|
<line x1={stemX} y1={y} x2={stemX} y2={stemEnd} stroke={INK} strokeWidth={0.7} strokeLinecap="butt" />
|
||||||
|
) : null}
|
||||||
|
<ellipse
|
||||||
|
cx={NOTE_X}
|
||||||
|
cy={y}
|
||||||
|
rx={3.15}
|
||||||
|
ry={2.05}
|
||||||
|
transform={`rotate(-18 ${NOTE_X} ${y})`}
|
||||||
|
fill={head === "filled" ? INK : "var(--card)"}
|
||||||
|
stroke={INK}
|
||||||
|
strokeWidth={0.75}
|
||||||
|
/>
|
||||||
|
{stem && flags > 0 ? <Flag x={stemX} y={stemEnd} down={down} /> : null}
|
||||||
|
{stem && flags > 1 ? <Flag x={stemX} y={stemEnd + (down ? -STAFF_SPACE * 0.85 : STAFF_SPACE * 0.85)} down={down} /> : null}
|
||||||
|
{dots > 0 ? <Dot x={NOTE_X + 5.4} index={dotIndex(placed.index)} /> : null}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RestOnStaff({ rest, dots }: { rest: "whole" | "half" | "quarter" | "eighth" | "sixteenth"; dots: 0 | 1 }) {
|
||||||
|
const x = NOTE_X;
|
||||||
|
return (
|
||||||
|
<g data-rest={rest} data-dots={dots}>
|
||||||
|
{rest === "whole" ? (
|
||||||
|
<rect x={x - 2.4} y={staffY(6)} width={4.8} height={STAFF_SPACE * 0.48} fill={INK} />
|
||||||
|
) : null}
|
||||||
|
{rest === "half" ? (
|
||||||
|
<rect x={x - 2.4} y={staffY(4) - STAFF_SPACE * 0.48} width={4.8} height={STAFF_SPACE * 0.48} fill={INK} />
|
||||||
|
) : null}
|
||||||
|
{rest === "quarter" ? <QuarterRest x={x} /> : null}
|
||||||
|
{rest === "eighth" || rest === "sixteenth" ? <EighthRest x={x} flags={rest === "sixteenth" ? 2 : 1} /> : null}
|
||||||
|
{dots > 0 ? <Dot x={x + 6.2} index={5} /> : null}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dot({ x, index }: { x: number; index: number }) {
|
||||||
|
return <circle cx={x} cy={staffY(index)} r={0.72} fill={INK} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sharp({ x, y }: { x: number; y: number }) {
|
||||||
|
return (
|
||||||
|
<g stroke={INK} strokeLinecap="square">
|
||||||
|
<line x1={x - 1.15} y1={y - 3.3} x2={x - 1.15} y2={y + 3.1} strokeWidth={0.55} />
|
||||||
|
<line x1={x + 1.15} y1={y - 3.7} x2={x + 1.15} y2={y + 2.7} strokeWidth={0.55} />
|
||||||
|
<line x1={x - 2.5} y1={y - 0.55} x2={x + 2.5} y2={y - 1.45} strokeWidth={0.95} />
|
||||||
|
<line x1={x - 2.5} y1={y + 1.55} x2={x + 2.5} y2={y + 0.65} strokeWidth={0.95} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Flat({ x, y }: { x: number; y: number }) {
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
d={`M ${x} ${y - 4.8} V ${y + 1.7}
|
||||||
|
C ${x + 2.8} ${y + 1.5} ${x + 3.1} ${y - 1.5} ${x} ${y - 1.15} Z`}
|
||||||
|
fill={INK}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Flag({ x, y, down }: { x: number; y: number; down: boolean }) {
|
||||||
|
const dir = down ? -1 : 1;
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
d={`M ${x} ${y}
|
||||||
|
c ${3.2} ${dir * 0.2} ${5.4} ${dir * 2.4} ${4.2} ${dir * 5.2}
|
||||||
|
c ${-0.6} ${dir * 1.4} ${-2.2} ${dir * 1.8} ${-3.4} ${dir * 0.6}
|
||||||
|
c ${1.2} ${dir * -1.6} ${1.4} ${dir * -3.2} ${-0.8} ${dir * -3.6}
|
||||||
|
z`}
|
||||||
|
fill={INK}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuarterRest({ x }: { x: number }) {
|
||||||
|
const y = staffY(7);
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
d={`M ${x + 1.6} ${y}
|
||||||
|
c ${-2.4} ${1.6} ${-1.2} ${3.2} ${0.8} ${3.6}
|
||||||
|
c ${2.2} ${0.4} ${1.2} ${2.2} ${-0.8} ${3}
|
||||||
|
c ${-1.8} ${0.8} ${-0.4} ${2.2} ${1.2} ${2.6}
|
||||||
|
c ${1.4} ${0.4} ${1.2} ${1.6} ${-0.2} ${2.4}
|
||||||
|
c ${-1.6} ${0.8} ${-3.2} ${0.2} ${-2.2} ${-1.2}`}
|
||||||
|
fill="none"
|
||||||
|
stroke={INK}
|
||||||
|
strokeWidth={1.15}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EighthRest({ x, flags }: { x: number; flags: 1 | 2 }) {
|
||||||
|
const y = staffY(6);
|
||||||
|
return (
|
||||||
|
<g fill={INK}>
|
||||||
|
<path
|
||||||
|
d={`M ${x + 1.4} ${y}
|
||||||
|
c ${-0.4} ${2.2} ${0.2} ${3.4} ${1.2} ${4.2}
|
||||||
|
L ${x - 2.2} ${y + 9.2}
|
||||||
|
c ${0.8} ${0.2} ${1.6} ${-0.6} ${1} ${-1.4}
|
||||||
|
L ${x + 1.6} ${y + 4.4}
|
||||||
|
c ${-0.6} ${-0.8} ${-1} ${-1.8} ${-0.6} ${-3.2}
|
||||||
|
z`}
|
||||||
|
/>
|
||||||
|
<circle cx={x - 1.5} cy={y + 10.2} r={0.85} />
|
||||||
|
{flags > 1 ? (
|
||||||
|
<path
|
||||||
|
d={`M ${x + 0.4} ${y + 2.6}
|
||||||
|
c ${-0.2} ${1.6} ${0.4} ${2.4} ${1} ${3}
|
||||||
|
L ${x - 2.4} ${y + 11.4}
|
||||||
|
c ${0.7} ${0.2} ${1.4} ${-0.5} ${0.8} ${-1.2}
|
||||||
|
L ${x + 0.6} ${y + 5.6}
|
||||||
|
z`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { DURATION_PRESETS, quartersMatch } from "@/lib/duration";
|
|
||||||
import { CHART_NOTES } from "@/lib/fingering";
|
|
||||||
import {
|
|
||||||
barStatus,
|
|
||||||
newId,
|
|
||||||
parseSignature,
|
|
||||||
signatureKey,
|
|
||||||
TIME_SIGNATURES,
|
|
||||||
type Score,
|
|
||||||
type ScoreEvent,
|
|
||||||
} from "@/lib/score";
|
|
||||||
import { ChevronDown, ChevronUp, Plus, Trash2 } from "lucide-react";
|
|
||||||
|
|
||||||
const fieldClass =
|
|
||||||
"h-10 min-w-0 rounded-lg border border-input bg-card px-2 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50";
|
|
||||||
|
|
||||||
export function NoteEditor({
|
|
||||||
score,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
score: Score;
|
|
||||||
onChange: (score: Score) => void;
|
|
||||||
}) {
|
|
||||||
const signatures = TIME_SIGNATURES.some(
|
|
||||||
(item) =>
|
|
||||||
item.beats === score.timeSignature.beats && item.beatType === score.timeSignature.beatType,
|
|
||||||
)
|
|
||||||
? TIME_SIGNATURES
|
|
||||||
: [score.timeSignature, ...TIME_SIGNATURES];
|
|
||||||
|
|
||||||
function updateEvent(measureId: string, eventId: string, patch: Partial<ScoreEvent>) {
|
|
||||||
onChange({
|
|
||||||
...score,
|
|
||||||
measures: score.measures.map((measure) =>
|
|
||||||
measure.id !== measureId
|
|
||||||
? measure
|
|
||||||
: {
|
|
||||||
...measure,
|
|
||||||
events: measure.events.map((event) =>
|
|
||||||
event.id === eventId ? { ...event, ...patch } : event,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function move(measureIndex: number, eventIndex: number, direction: -1 | 1) {
|
|
||||||
const measures = score.measures.map((measure) => ({ ...measure, events: [...measure.events] }));
|
|
||||||
const measure = measures[measureIndex];
|
|
||||||
const target = eventIndex + direction;
|
|
||||||
if (target >= 0 && target < measure.events.length) {
|
|
||||||
const [item] = measure.events.splice(eventIndex, 1);
|
|
||||||
measure.events.splice(target, 0, item);
|
|
||||||
} else if (direction === -1 && measureIndex > 0) {
|
|
||||||
const [item] = measure.events.splice(eventIndex, 1);
|
|
||||||
measures[measureIndex - 1].events.push(item);
|
|
||||||
} else if (direction === 1 && measureIndex < measures.length - 1) {
|
|
||||||
const [item] = measure.events.splice(eventIndex, 1);
|
|
||||||
measures[measureIndex + 1].events.unshift(item);
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onChange({ ...score, measures });
|
|
||||||
}
|
|
||||||
|
|
||||||
function add(measureId: string, kind: "note" | "rest") {
|
|
||||||
const measure = score.measures.find((item) => item.id === measureId);
|
|
||||||
const previous = [...(measure?.events ?? [])].reverse().find((event) => event.kind === "note");
|
|
||||||
const event: ScoreEvent = {
|
|
||||||
id: newId("e"),
|
|
||||||
kind,
|
|
||||||
pitch: kind === "note" ? (previous?.pitch ?? "C5") : null,
|
|
||||||
quarters: 1,
|
|
||||||
label: "quarter",
|
|
||||||
tied: false,
|
|
||||||
};
|
|
||||||
onChange({
|
|
||||||
...score,
|
|
||||||
measures: score.measures.map((item) =>
|
|
||||||
item.id === measureId ? { ...item, events: [...item.events, event] } : item,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="score-title">Title</Label>
|
|
||||||
<Input
|
|
||||||
id="score-title"
|
|
||||||
value={score.title}
|
|
||||||
onChange={(event) => onChange({ ...score, title: event.target.value })}
|
|
||||||
className="h-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="time-signature">Time signature</Label>
|
|
||||||
<Select
|
|
||||||
value={signatureKey(score.timeSignature)}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (!value) return;
|
|
||||||
onChange({ ...score, timeSignature: parseSignature(value) });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="time-signature" className="w-full data-[size=default]:h-10">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{signatures.map((signature) => (
|
|
||||||
<SelectItem key={signatureKey(signature)} value={signatureKey(signature)}>
|
|
||||||
{signatureKey(signature)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{score.measures.map((measure, measureIndex) => {
|
|
||||||
const status = barStatus(measure, score.timeSignature);
|
|
||||||
return (
|
|
||||||
<fieldset key={measure.id} className="space-y-3 rounded-2xl bg-card p-3 ring-1 ring-foreground/10">
|
|
||||||
<legend className="px-1 font-medium">Bar {measureIndex + 1}</legend>
|
|
||||||
<p className={status.ok ? "text-xs text-muted-foreground" : "text-xs text-[#8a4b12]"}>
|
|
||||||
{status.text}
|
|
||||||
</p>
|
|
||||||
{measure.events.map((event, eventIndex) => (
|
|
||||||
<EventRow
|
|
||||||
key={event.id}
|
|
||||||
event={event}
|
|
||||||
onPitch={(value) => {
|
|
||||||
if (value === "rest") {
|
|
||||||
updateEvent(measure.id, event.id, { kind: "rest", pitch: null });
|
|
||||||
} else {
|
|
||||||
updateEvent(measure.id, event.id, { kind: "note", pitch: value });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDuration={(quarters) => {
|
|
||||||
const preset = DURATION_PRESETS.find((item) => quartersMatch(item.quarters, quarters));
|
|
||||||
updateEvent(measure.id, event.id, {
|
|
||||||
quarters,
|
|
||||||
label: preset?.label ?? event.label,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onDelete={() =>
|
|
||||||
onChange({
|
|
||||||
...score,
|
|
||||||
measures: score.measures.map((item) =>
|
|
||||||
item.id === measure.id
|
|
||||||
? { ...item, events: item.events.filter((row) => row.id !== event.id) }
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onUp={() => move(measureIndex, eventIndex, -1)}
|
|
||||||
onDown={() => move(measureIndex, eventIndex, 1)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => add(measure.id, "note")}>
|
|
||||||
<Plus />
|
|
||||||
Note
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => add(measure.id, "rest")}>
|
|
||||||
<Plus />
|
|
||||||
Rest
|
|
||||||
</Button>
|
|
||||||
{score.measures.length > 1 ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
onChange({
|
|
||||||
...score,
|
|
||||||
measures: score.measures.filter((item) => item.id !== measure.id),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Delete bar
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() =>
|
|
||||||
onChange({
|
|
||||||
...score,
|
|
||||||
measures: [...score.measures, { id: newId("m"), events: [] }],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
Add bar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventRow({
|
|
||||||
event,
|
|
||||||
onPitch,
|
|
||||||
onDuration,
|
|
||||||
onDelete,
|
|
||||||
onUp,
|
|
||||||
onDown,
|
|
||||||
}: {
|
|
||||||
event: ScoreEvent;
|
|
||||||
onPitch: (value: string) => void;
|
|
||||||
onDuration: (quarters: number) => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onUp: () => void;
|
|
||||||
onDown: () => void;
|
|
||||||
}) {
|
|
||||||
const pitches = [...CHART_NOTES] as string[];
|
|
||||||
if (event.pitch && !pitches.includes(event.pitch)) pitches.unshift(event.pitch);
|
|
||||||
const durations = [...DURATION_PRESETS];
|
|
||||||
if (!durations.some((item) => quartersMatch(item.quarters, event.quarters))) {
|
|
||||||
durations.push({ quarters: event.quarters, label: event.label });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<label className="sr-only" htmlFor={`${event.id}-pitch`}>
|
|
||||||
Pitch or rest
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id={`${event.id}-pitch`}
|
|
||||||
className={`${fieldClass} w-[7.5rem]`}
|
|
||||||
value={event.kind === "rest" ? "rest" : (event.pitch ?? "C5")}
|
|
||||||
onChange={(change) => onPitch(change.target.value)}
|
|
||||||
>
|
|
||||||
<option value="rest">rest</option>
|
|
||||||
{pitches.map((pitch) => (
|
|
||||||
<option key={pitch} value={pitch}>
|
|
||||||
{pitch}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<label className="sr-only" htmlFor={`${event.id}-duration`}>
|
|
||||||
Duration
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id={`${event.id}-duration`}
|
|
||||||
className={`${fieldClass} w-[9.5rem]`}
|
|
||||||
value={String(event.quarters)}
|
|
||||||
onChange={(change) => onDuration(Number(change.target.value))}
|
|
||||||
>
|
|
||||||
{durations.map((duration) => (
|
|
||||||
<option key={`${duration.label}-${duration.quarters}`} value={String(duration.quarters)}>
|
|
||||||
{duration.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div className="ml-auto flex gap-1">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={onUp} aria-label="Move earlier">
|
|
||||||
<ChevronUp />
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={onDown} aria-label="Move later">
|
|
||||||
<ChevronDown />
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={onDelete} aria-label="Delete note">
|
|
||||||
<Trash2 />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { FingeringDiagram, RestMark } from "@/components/fingering-diagram";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import {
|
|
||||||
CHART_NOTES,
|
|
||||||
fingeringCode,
|
|
||||||
fingeringFor,
|
|
||||||
fingeringMarks,
|
|
||||||
germanDiffers,
|
|
||||||
type FingeringSystem,
|
|
||||||
} from "@/lib/fingering";
|
|
||||||
import { formatBeat, signatureKey, startBeats, type Score } from "@/lib/score";
|
|
||||||
import { formatTextTab } from "@/lib/text-tab";
|
|
||||||
import { Copy } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export function ScoreViews({ score, system }: { score: Score; system: FingeringSystem }) {
|
|
||||||
const text = formatTextTab(score, system);
|
|
||||||
const outside = score.measures
|
|
||||||
.flatMap((measure) => measure.events)
|
|
||||||
.filter((event) => event.kind === "note" && event.pitch && !fingeringFor(event.pitch, system)).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs defaultValue="player" className="min-w-0 gap-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="player">Player tabs</TabsTrigger>
|
|
||||||
<TabsTrigger value="text">Text</TabsTrigger>
|
|
||||||
<TabsTrigger value="chart">Chart</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<p className="text-sm text-muted-foreground">{signatureKey(score.timeSignature)}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsContent value="player" className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Each cell is wide in proportion to its length, so a half note takes twice the room of a
|
|
||||||
quarter. Scroll a bar sideways if the fingerings need more room than the screen.
|
|
||||||
</p>
|
|
||||||
{outside > 0 ? (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{outside} {outside === 1 ? "note is" : "notes are"} outside C5–D7, so {outside === 1 ? "it has" : "they have"} no
|
|
||||||
fingering.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{score.measures.map((measure, index) => {
|
|
||||||
const beats = startBeats(measure.events, score.timeSignature);
|
|
||||||
return (
|
|
||||||
<section key={measure.id} className="rounded-2xl bg-card p-3 ring-1 ring-foreground/10">
|
|
||||||
<h3 className="mb-2 font-heading text-lg">Bar {index + 1}</h3>
|
|
||||||
{measure.events.length === 0 ? (
|
|
||||||
<p className="px-2 py-8 text-sm text-muted-foreground">Empty bar. Add a note or a rest in the editor.</p>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto pb-2">
|
|
||||||
<div className="flex w-max min-w-full border-x-[3px] border-foreground">
|
|
||||||
{measure.events.map((event, eventIndex) => {
|
|
||||||
const fingering =
|
|
||||||
event.kind === "note" && event.pitch
|
|
||||||
? fingeringFor(event.pitch, system)
|
|
||||||
: null;
|
|
||||||
const marks = fingering ? fingeringMarks(fingering) : [];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className="flex flex-col items-center gap-1 border-r border-foreground/15 px-2 py-2 last:border-r-0"
|
|
||||||
style={{ minWidth: Math.max(124, event.quarters * 124) }}
|
|
||||||
>
|
|
||||||
<div className="flex min-h-[230px] items-center">
|
|
||||||
{event.kind === "rest" ? (
|
|
||||||
<RestMark label={event.label} />
|
|
||||||
) : (
|
|
||||||
<FingeringDiagram
|
|
||||||
fingering={fingering}
|
|
||||||
pitch={event.pitch ?? "?"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="font-heading text-xl leading-none">
|
|
||||||
{event.kind === "rest" ? "rest" : event.pitch}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{event.label}</p>
|
|
||||||
{marks.length > 0 ? (
|
|
||||||
<p className="text-xs tracking-wide text-[#6a4328] uppercase">{marks.join(" · ")}</p>
|
|
||||||
) : null}
|
|
||||||
{event.tied ? <p className="text-xs text-muted-foreground">ties forward</p> : null}
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">beat {formatBeat(beats[eventIndex] ?? 1)}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<FingeringLegend />
|
|
||||||
{score.measures.some((measure) =>
|
|
||||||
measure.events.some((event) => event.pitch === "D7"),
|
|
||||||
) ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Dolmetsch’s primary top D leaves hole 7 open. The same page notes that covering 7a and 7b
|
|
||||||
is a common fix if that D is sharp.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="text" className="space-y-3">
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
void navigator.clipboard.writeText(text).then(
|
|
||||||
() => toast.success("Text tab copied"),
|
|
||||||
() => toast.error("Could not copy from this browser"),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy />
|
|
||||||
Copy text tab
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<pre className="overflow-x-auto rounded-2xl bg-[#2a2118] p-4 text-sm leading-6 text-[#f6efe2] whitespace-pre-wrap">
|
|
||||||
{text}
|
|
||||||
</pre>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="chart" className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Every note from C5 to D7. {system === "baroque" ? "Baroque is showing." : "German changes are badged."}
|
|
||||||
</p>
|
|
||||||
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
|
|
||||||
{CHART_NOTES.map((note) => {
|
|
||||||
const fingering = fingeringFor(note, system);
|
|
||||||
return (
|
|
||||||
<li key={note} className="flex flex-col items-center gap-1 rounded-2xl bg-card p-3 ring-1 ring-foreground/10">
|
|
||||||
<FingeringDiagram fingering={fingering} pitch={note} size="sm" />
|
|
||||||
<p className="font-heading text-lg">{note}</p>
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
|
||||||
{fingering ? fingeringCode(fingering) : "—"}
|
|
||||||
</p>
|
|
||||||
{system === "german" && germanDiffers(note) ? <Badge variant="secondary">German</Badge> : null}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Top D (D7) is the chart’s primary fingering, with hole 7 open. Dolmetsch notes that covering
|
|
||||||
7a and 7b is a common fix if the note is sharp on your instrument.
|
|
||||||
</p>
|
|
||||||
<FingeringSources />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FingeringLegend() {
|
|
||||||
return (
|
|
||||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<li>Filled hole: closed</li>
|
|
||||||
<li>Empty hole: open</li>
|
|
||||||
<li>One of a pair filled: half-hole</li>
|
|
||||||
<li>Thumb half-filled: pinch</li>
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FingeringSources() {
|
|
||||||
return (
|
|
||||||
<details className="rounded-2xl bg-card p-4 ring-1 ring-foreground/10">
|
|
||||||
<summary className="cursor-pointer font-medium">Where these fingerings come from</summary>
|
|
||||||
<div className="mt-3 space-y-3 text-sm leading-6 text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
Baroque (English) fingerings are the first row on the Dolmetsch Online chart for recorders
|
|
||||||
in C, prepared by Brian Blood. Holes are numbered 0 (thumb) through 7, and 6 and 7 are the
|
|
||||||
double holes on a modern soprano. This app uses concert pitch: the instrument sounds as
|
|
||||||
written, so low C is C5 and the encoded range stops at D7.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
German fingering changes three notes. Low F is Dolmetsch’s German fingering, thumb plus
|
|
||||||
holes 1–4, with 5, 6, and 7 open. F♯ is thumb plus 1, 2, 3, 5, 6, and half-hole 7. B♭ is
|
|
||||||
thumb plus 1, 3, 4, and 6. Those two accidentals follow the school German pattern described
|
|
||||||
by Zach VanderGraaff in Dynamic Music Room. The octave above uses the same front holes with
|
|
||||||
the Dolmetsch second-octave thumb. Every other note stays on the Baroque chart.
|
|
||||||
</p>
|
|
||||||
<p>Alternate fingerings from the Dolmetsch chart are not shown, and nothing outside C5–D7 is guessed.</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { pngItemFromNote, pngLayout, type PngItem } from "@/lib/png-layout";
|
||||||
|
|
||||||
|
const INK = "#1a1a1a";
|
||||||
|
const PAPER = "#fbf6ee";
|
||||||
|
const SPACE_STROKE = "#c4b8a8";
|
||||||
|
|
||||||
|
function loadSvg(svg: SVGElement): Promise<HTMLImageElement> {
|
||||||
|
const clone = svg.cloneNode(true) as SVGElement;
|
||||||
|
if (!clone.getAttribute("xmlns")) clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||||
|
const xml = new XMLSerializer().serializeToString(clone);
|
||||||
|
const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(image);
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error("Could not draw a chart."));
|
||||||
|
};
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderLinePng(exportRoot: HTMLElement, title: string): Promise<Blob> {
|
||||||
|
const slots = [...exportRoot.querySelectorAll<HTMLElement>("[data-export-slot]")];
|
||||||
|
const items: PngItem[] = [];
|
||||||
|
const images: Array<HTMLImageElement | null> = [];
|
||||||
|
for (const slot of slots) {
|
||||||
|
const kind = slot.dataset.kind;
|
||||||
|
if (kind !== "note" && kind !== "rest" && kind !== "space") continue;
|
||||||
|
items.push(
|
||||||
|
pngItemFromNote({
|
||||||
|
kind,
|
||||||
|
pitch: slot.dataset.pitch ?? "",
|
||||||
|
quarters: Number(slot.dataset.quarters),
|
||||||
|
label: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const svg = slot.querySelector("svg");
|
||||||
|
images.push(svg ? await loadSvg(svg) : null);
|
||||||
|
}
|
||||||
|
if (items.length === 0) throw new Error("Add a note, rest, or space before saving a PNG.");
|
||||||
|
|
||||||
|
const layout = pngLayout(items, title);
|
||||||
|
const scale = 2;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.ceil(layout.width * scale);
|
||||||
|
canvas.height = Math.ceil(layout.height * scale);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("Could not start a canvas.");
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
ctx.fillStyle = PAPER;
|
||||||
|
ctx.fillRect(0, 0, layout.width, layout.height);
|
||||||
|
ctx.fillStyle = "#6a4328";
|
||||||
|
ctx.font = "600 12px Georgia, serif";
|
||||||
|
ctx.fillText("Soprano in C · Baroque · 4/4", layout.pad, layout.pad + 14);
|
||||||
|
ctx.fillStyle = INK;
|
||||||
|
ctx.font = "600 22px Georgia, serif";
|
||||||
|
ctx.fillText(title, layout.pad, layout.pad + 40);
|
||||||
|
|
||||||
|
const glyphTop = layout.pad + 56;
|
||||||
|
let x = layout.pad;
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const slotWidth = layout.slots[index]?.width ?? item.glyphWidth;
|
||||||
|
if (item.kind === "space") {
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = SPACE_STROKE;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.strokeRect(x + 6, glyphTop + 8, Math.max(8, item.glyphWidth - 12), layout.glyphHeight - 16);
|
||||||
|
ctx.restore();
|
||||||
|
} else if (images[index]) {
|
||||||
|
ctx.drawImage(images[index], x, glyphTop, item.glyphWidth, item.glyphHeight);
|
||||||
|
}
|
||||||
|
const center = x + item.glyphWidth / 2;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillStyle = INK;
|
||||||
|
ctx.font = "600 13px sans-serif";
|
||||||
|
ctx.fillText(item.caption, center, glyphTop + layout.glyphHeight + 18);
|
||||||
|
ctx.textAlign = "start";
|
||||||
|
x += slotWidth + layout.gap;
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||||
|
if (!blob) throw new Error("Could not encode the PNG.");
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1500);
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
import { DURATION_PRESETS, type DurationPreset } from "@/lib/duration";
|
||||||
|
import { newId } from "@/lib/score";
|
||||||
|
|
||||||
|
export type LineKind = "note" | "rest" | "space";
|
||||||
|
|
||||||
|
export type LineNote = {
|
||||||
|
id: string;
|
||||||
|
kind: LineKind;
|
||||||
|
pitch: string;
|
||||||
|
quarters: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LINE_BOX = {
|
||||||
|
minWidth: 280,
|
||||||
|
minHeight: 200,
|
||||||
|
maxHeight: 880,
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUARTER = DURATION_PRESETS.find((preset) => preset.quarters === 1) ?? DURATION_PRESETS[3];
|
||||||
|
|
||||||
|
export function createNote(pitch: string, preset: DurationPreset = QUARTER, id = newId("n")): LineNote {
|
||||||
|
return { id, kind: "note", pitch, quarters: preset.quarters, label: preset.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRest(preset: DurationPreset = QUARTER, id = newId("r")): LineNote {
|
||||||
|
return { id, kind: "rest", pitch: "", quarters: preset.quarters, label: preset.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSpace(preset: DurationPreset = QUARTER, id = newId("s")): LineNote {
|
||||||
|
return { id, kind: "space", pitch: "", quarters: preset.quarters, label: preset.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep a resized line box on the page. `maxWidth` is the room the page actually has. */
|
||||||
|
export function clampLineBox(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
maxWidth: number,
|
||||||
|
): { width: number; height: number } {
|
||||||
|
const limit = Math.max(160, maxWidth);
|
||||||
|
const minWidth = Math.min(LINE_BOX.minWidth, limit);
|
||||||
|
return {
|
||||||
|
width: Math.round(Math.min(limit, Math.max(minWidth, width))),
|
||||||
|
height: Math.round(Math.min(LINE_BOX.maxHeight, Math.max(LINE_BOX.minHeight, height))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertNote(notes: LineNote[], index: number, note: LineNote): LineNote[] {
|
||||||
|
const next = notes.slice();
|
||||||
|
const at = Math.max(0, Math.min(index, next.length));
|
||||||
|
next.splice(at, 0, note);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `toIndex` is the slot in the current list, counting the note that is moving. */
|
||||||
|
export function moveNote(notes: LineNote[], id: string, toIndex: number): LineNote[] {
|
||||||
|
const from = notes.findIndex((note) => note.id === id);
|
||||||
|
if (from < 0) return notes;
|
||||||
|
const next = notes.filter((note) => note.id !== id);
|
||||||
|
const adjusted = toIndex > from ? toIndex - 1 : toIndex;
|
||||||
|
const at = Math.max(0, Math.min(adjusted, next.length));
|
||||||
|
next.splice(at, 0, notes[from]);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeNote(notes: LineNote[], id: string): LineNote[] {
|
||||||
|
return notes.filter((note) => note.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DURATION_ORDER = [...DURATION_PRESETS].sort((a, b) => a.quarters - b.quarters);
|
||||||
|
|
||||||
|
/** The next longer or shorter preset. Null at either end of the list. */
|
||||||
|
export function neighborDuration(quarters: number, direction: -1 | 1): DurationPreset | null {
|
||||||
|
const index = DURATION_ORDER.findIndex((preset) => Math.abs(preset.quarters - quarters) < 0.001);
|
||||||
|
if (index < 0) return null;
|
||||||
|
return DURATION_ORDER[index + direction] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDuration(notes: LineNote[], id: string, preset: DurationPreset): LineNote[] {
|
||||||
|
return notes.map((note) =>
|
||||||
|
note.id === id ? { ...note, quarters: preset.quarters, label: preset.label } : note,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneLine(notes: LineNote[]): LineNote[] {
|
||||||
|
return notes.map((note) => ({ ...note, id: newId("n") }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** -1 when the pointer is outside the line. Otherwise the insertion slot from left to right. */
|
||||||
|
export function dropIndexFromPoint(
|
||||||
|
pointerX: number,
|
||||||
|
pointerY: number,
|
||||||
|
line: { left: number; right: number; top: number; bottom: number },
|
||||||
|
noteCenters: number[],
|
||||||
|
slack = 36,
|
||||||
|
): number {
|
||||||
|
const inside =
|
||||||
|
pointerX >= line.left - slack &&
|
||||||
|
pointerX <= line.right + slack &&
|
||||||
|
pointerY >= line.top - slack &&
|
||||||
|
pointerY <= line.bottom + slack;
|
||||||
|
if (!inside) return -1;
|
||||||
|
for (let index = 0; index < noteCenters.length; index += 1) {
|
||||||
|
if (pointerX < noteCenters[index]) return index;
|
||||||
|
}
|
||||||
|
return noteCenters.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preset(quarters: number, label: string): DurationPreset {
|
||||||
|
return { quarters, label };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two short lines to edit. They are not a full tune. */
|
||||||
|
export const LINE_EXAMPLES = [
|
||||||
|
{
|
||||||
|
id: "twinkle",
|
||||||
|
title: "Twinkle, opening",
|
||||||
|
blurb: "Seven notes. The last one is held.",
|
||||||
|
notes: [
|
||||||
|
createNote("C5", preset(1, "quarter"), "twinkle-1"),
|
||||||
|
createNote("C5", preset(1, "quarter"), "twinkle-2"),
|
||||||
|
createNote("G5", preset(1, "quarter"), "twinkle-3"),
|
||||||
|
createNote("G5", preset(1, "quarter"), "twinkle-4"),
|
||||||
|
createNote("A5", preset(1, "quarter"), "twinkle-5"),
|
||||||
|
createNote("A5", preset(1, "quarter"), "twinkle-6"),
|
||||||
|
createNote("G5", preset(2, "half"), "twinkle-7"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ode",
|
||||||
|
title: "Ode, held note",
|
||||||
|
blurb: "A dotted quarter, an eighth, and a half note.",
|
||||||
|
notes: [
|
||||||
|
createNote("E5", preset(1.5, "dotted quarter"), "ode-1"),
|
||||||
|
createNote("D5", preset(0.5, "eighth"), "ode-2"),
|
||||||
|
createNote("D5", preset(2, "half"), "ode-3"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
import { XMLParser } from "fast-xml-parser";
|
|
||||||
import { durationLabel } from "@/lib/duration";
|
|
||||||
import { parsePitch } from "@/lib/pitch";
|
|
||||||
import { newId, type Measure, type Score, type ScoreEvent, type TimeSignature } from "@/lib/score";
|
|
||||||
|
|
||||||
type XNode = Record<string, unknown>;
|
|
||||||
|
|
||||||
export type ParseSuccess = {
|
|
||||||
ok: true;
|
|
||||||
score: Score;
|
|
||||||
warnings: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ParseFailure = {
|
|
||||||
ok: false;
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parser = new XMLParser({
|
|
||||||
preserveOrder: true,
|
|
||||||
ignoreAttributes: false,
|
|
||||||
attributeNamePrefix: "@_",
|
|
||||||
removeNSPrefix: true,
|
|
||||||
trimValues: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
function isNode(value: unknown): value is XNode {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tagOf(node: XNode): string | null {
|
|
||||||
return Object.keys(node).find((key) => key !== ":@" ) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function children(node: XNode): XNode[] {
|
|
||||||
const tag = tagOf(node);
|
|
||||||
if (!tag) return [];
|
|
||||||
const value = node[tag];
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
return value.filter(isNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
function textOf(node: XNode): string {
|
|
||||||
const tag = tagOf(node);
|
|
||||||
if (!tag) return "";
|
|
||||||
const value = node[tag];
|
|
||||||
if (typeof value === "string" || typeof value === "number") return String(value).trim();
|
|
||||||
if (!Array.isArray(value)) return "";
|
|
||||||
for (const item of value) {
|
|
||||||
if (isNode(item) && "#text" in item && item["#text"] != null) return String(item["#text"]).trim();
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function attrs(node: XNode): Record<string, string> {
|
|
||||||
const raw = node[":@"];
|
|
||||||
if (!isNode(raw)) return {};
|
|
||||||
const result: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(raw)) result[key.replace(/^@_/, "")] = String(value);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findChild(nodes: XNode[], name: string): XNode | undefined {
|
|
||||||
return nodes.find((node) => tagOf(node) === name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function childText(nodes: XNode[], name: string): string | null {
|
|
||||||
const child = findChild(nodes, name);
|
|
||||||
if (!child) return null;
|
|
||||||
const text = textOf(child);
|
|
||||||
return text === "" ? null : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTag(nodes: XNode[], name: string): boolean {
|
|
||||||
return nodes.some((node) => tagOf(node) === name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findDeep(nodes: XNode[], name: string): XNode | undefined {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (tagOf(node) === name) return node;
|
|
||||||
const nested = findDeep(children(node), name);
|
|
||||||
if (nested) return nested;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tieStarts(nodes: XNode[]): boolean {
|
|
||||||
for (const node of nodes) {
|
|
||||||
const tag = tagOf(node);
|
|
||||||
if (tag === "tie" && attrs(node).type === "start") return true;
|
|
||||||
if (tag === "notations") {
|
|
||||||
for (const inner of children(node)) {
|
|
||||||
if (tagOf(inner) === "tied" && attrs(inner).type === "start") return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pitchFrom(noteKids: XNode[]): { name: string; midi: number } | null {
|
|
||||||
const pitch = findChild(noteKids, "pitch");
|
|
||||||
if (!pitch) return null;
|
|
||||||
const kids = children(pitch);
|
|
||||||
const step = childText(kids, "step");
|
|
||||||
const octave = childText(kids, "octave");
|
|
||||||
if (!step || !octave) return null;
|
|
||||||
const alter = Number(childText(kids, "alter") ?? "0");
|
|
||||||
const accidental =
|
|
||||||
alter === 1 ? "#" : alter === -1 ? "b" : alter === 2 ? "##" : alter === -2 ? "bb" : "";
|
|
||||||
if (![0, 1, -1, 2, -2].includes(alter)) return null;
|
|
||||||
const parsed = parsePitch(`${step}${accidental}${octave}`);
|
|
||||||
return parsed ? { name: parsed.name, midi: parsed.midi } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Candidate = {
|
|
||||||
onset: number;
|
|
||||||
kind: "note" | "rest";
|
|
||||||
midi: number;
|
|
||||||
pitch: string | null;
|
|
||||||
quarters: number;
|
|
||||||
label: string;
|
|
||||||
tied: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function onsetKey(value: number): number {
|
|
||||||
return Math.round(value * 1000) / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMusicXml(xml: string): ParseSuccess | ParseFailure {
|
|
||||||
const cleaned = xml.replace(/<!DOCTYPE[\s\S]*?>/gi, "");
|
|
||||||
let root: unknown;
|
|
||||||
try {
|
|
||||||
root = parser.parse(cleaned);
|
|
||||||
} catch {
|
|
||||||
return { ok: false, error: "The recognizer returned text that is not MusicXML." };
|
|
||||||
}
|
|
||||||
if (!Array.isArray(root)) return { ok: false, error: "The recognizer returned text that is not MusicXML." };
|
|
||||||
|
|
||||||
const scoreNode = findDeep(root.filter(isNode), "score-partwise");
|
|
||||||
if (!scoreNode) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: "The recognizer did not return a partwise score. No notes were invented.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const top = children(scoreNode);
|
|
||||||
const work = findChild(top, "work");
|
|
||||||
const title =
|
|
||||||
(work && childText(children(work), "work-title")) ||
|
|
||||||
childText(top, "movement-title") ||
|
|
||||||
"Uploaded page";
|
|
||||||
|
|
||||||
const parts = top.filter((node) => tagOf(node) === "part");
|
|
||||||
if (parts.length === 0) {
|
|
||||||
return { ok: false, error: "The score has no part to read as a lead line." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const warnings: string[] = [];
|
|
||||||
if (parts.length > 1) warnings.push("Only the first part was read. Other parts were left out.");
|
|
||||||
|
|
||||||
let divisions = 1;
|
|
||||||
let timeSignature: TimeSignature = { beats: 4, beatType: 4 };
|
|
||||||
let sawTime = false;
|
|
||||||
let timeChanged = false;
|
|
||||||
let sawLowerStaff = false;
|
|
||||||
let reduced = false;
|
|
||||||
let sawGrace = false;
|
|
||||||
let sawTie = false;
|
|
||||||
|
|
||||||
const measures: Measure[] = [];
|
|
||||||
|
|
||||||
for (const [measureIndex, measureNode] of children(parts[0]).filter((node) => tagOf(node) === "measure").entries()) {
|
|
||||||
let cursor = 0;
|
|
||||||
let lastDuration = 0;
|
|
||||||
const grouped = new Map<number, Candidate[]>();
|
|
||||||
|
|
||||||
for (const element of children(measureNode)) {
|
|
||||||
const tag = tagOf(element);
|
|
||||||
if (tag === "attributes") {
|
|
||||||
const kids = children(element);
|
|
||||||
const nextDivisions = childText(kids, "divisions");
|
|
||||||
if (nextDivisions && Number(nextDivisions) > 0) divisions = Number(nextDivisions);
|
|
||||||
const time = findChild(kids, "time");
|
|
||||||
if (time) {
|
|
||||||
const timeKids = children(time);
|
|
||||||
const beats = Number(childText(timeKids, "beats"));
|
|
||||||
const beatType = Number(childText(timeKids, "beat-type"));
|
|
||||||
if (beats > 0 && beatType > 0) {
|
|
||||||
if (!sawTime) {
|
|
||||||
timeSignature = { beats, beatType };
|
|
||||||
sawTime = true;
|
|
||||||
} else if (timeSignature.beats !== beats || timeSignature.beatType !== beatType) {
|
|
||||||
timeChanged = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (tag === "backup" || tag === "forward") {
|
|
||||||
const delta = Number(childText(children(element), "duration") ?? "0");
|
|
||||||
cursor += tag === "backup" ? -delta : delta;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (tag !== "note") continue;
|
|
||||||
|
|
||||||
const kids = children(element);
|
|
||||||
if (hasTag(kids, "grace")) {
|
|
||||||
sawGrace = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const isChord = hasTag(kids, "chord");
|
|
||||||
const duration = Number(childText(kids, "duration") ?? "0");
|
|
||||||
const staff = childText(kids, "staff");
|
|
||||||
if (staff && staff !== "1") {
|
|
||||||
sawLowerStaff = true;
|
|
||||||
if (!isChord) {
|
|
||||||
cursor += duration;
|
|
||||||
lastDuration = duration;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onset = onsetKey(isChord ? cursor - lastDuration : cursor);
|
|
||||||
if (!isChord) {
|
|
||||||
cursor += duration;
|
|
||||||
lastDuration = duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = childText(kids, "type");
|
|
||||||
const dots = kids.filter((node) => tagOf(node) === "dot").length;
|
|
||||||
const modification = findChild(kids, "time-modification");
|
|
||||||
let tuplet = false;
|
|
||||||
if (modification) {
|
|
||||||
const actual = Number(childText(children(modification), "actual-notes"));
|
|
||||||
const normal = Number(childText(children(modification), "normal-notes"));
|
|
||||||
tuplet = actual === 3 && normal === 2;
|
|
||||||
}
|
|
||||||
const quarters = divisions > 0 ? duration / divisions : 0;
|
|
||||||
const tied = tieStarts(kids);
|
|
||||||
if (tied) sawTie = true;
|
|
||||||
|
|
||||||
if (hasTag(kids, "rest")) {
|
|
||||||
const list = grouped.get(onset) ?? [];
|
|
||||||
list.push({
|
|
||||||
onset,
|
|
||||||
kind: "rest",
|
|
||||||
midi: -1,
|
|
||||||
pitch: null,
|
|
||||||
quarters,
|
|
||||||
label: durationLabel(quarters, type, dots, tuplet),
|
|
||||||
tied: false,
|
|
||||||
});
|
|
||||||
grouped.set(onset, list);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pitch = pitchFrom(kids);
|
|
||||||
if (!pitch) continue;
|
|
||||||
const list = grouped.get(onset) ?? [];
|
|
||||||
list.push({
|
|
||||||
onset,
|
|
||||||
kind: "note",
|
|
||||||
midi: pitch.midi,
|
|
||||||
pitch: pitch.name,
|
|
||||||
quarters,
|
|
||||||
label: durationLabel(quarters, type, dots, tuplet),
|
|
||||||
tied,
|
|
||||||
});
|
|
||||||
grouped.set(onset, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
const events: ScoreEvent[] = [];
|
|
||||||
for (const onset of [...grouped.keys()].sort((a, b) => a - b)) {
|
|
||||||
const candidates = grouped.get(onset) ?? [];
|
|
||||||
const notes = candidates.filter((candidate) => candidate.kind === "note");
|
|
||||||
if (notes.length > 1) reduced = true;
|
|
||||||
const chosen = notes.length > 0 ? notes.reduce((best, note) => (note.midi > best.midi ? note : best)) : candidates[0];
|
|
||||||
if (!chosen || chosen.quarters <= 0) continue;
|
|
||||||
events.push({
|
|
||||||
id: newId("e"),
|
|
||||||
kind: chosen.kind,
|
|
||||||
pitch: chosen.pitch,
|
|
||||||
quarters: chosen.quarters,
|
|
||||||
label: chosen.label,
|
|
||||||
tied: chosen.tied,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
measures.push({ id: newId("m"), events });
|
|
||||||
void measureIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sawLowerStaff) warnings.push("Notes on lower staves were left out. The tab follows the top staff.");
|
|
||||||
if (reduced) warnings.push("Chords and overlapping voices were reduced to the highest note.");
|
|
||||||
if (sawGrace) warnings.push("Grace notes were left out so the beat stays aligned.");
|
|
||||||
if (timeChanged) warnings.push("A later time signature was ignored. The tab uses the first one.");
|
|
||||||
if (sawTie) warnings.push("Tied notes stay separate so the bar lines do not move.");
|
|
||||||
|
|
||||||
const eventCount = measures.reduce((total, measure) => total + measure.events.length, 0);
|
|
||||||
if (eventCount === 0) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: "The recognizer finished, but it did not find a lead note or a rest. No notes were invented.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
warnings,
|
|
||||||
score: {
|
|
||||||
title,
|
|
||||||
attribution: "Read from your image. Correct anything that looks wrong before you play.",
|
|
||||||
timeSignature,
|
|
||||||
measures: measures.length > 0 ? measures : [{ id: newId("m"), events: [] }],
|
|
||||||
origin: "upload",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { quartersMatch } from "@/lib/duration";
|
||||||
|
|
||||||
|
/** Recorder drawing stays in a 48×188 box. The staff is added underneath. */
|
||||||
|
export const CHART_VB_W = 48;
|
||||||
|
const RECORDER_VB_H = 188;
|
||||||
|
|
||||||
|
/** Distance between two staff lines. One diatonic step is half of this. */
|
||||||
|
export const STAFF_SPACE = 4.6;
|
||||||
|
const STEP = STAFF_SPACE / 2;
|
||||||
|
|
||||||
|
/** Highest written note is sounding D7, drawn as D6. */
|
||||||
|
const TOP_INDEX = 13;
|
||||||
|
/** Lowest written note is sounding C5, drawn as C4 on the ledger line below. */
|
||||||
|
const LOW_INDEX = -2;
|
||||||
|
const PAD_TOP = 6;
|
||||||
|
const PAD_BOTTOM = 14;
|
||||||
|
|
||||||
|
const ABOVE = TOP_INDEX * STEP;
|
||||||
|
const BELOW = -LOW_INDEX * STEP + PAD_BOTTOM;
|
||||||
|
|
||||||
|
export const BOTTOM_LINE_Y = RECORDER_VB_H + 3 + PAD_TOP + ABOVE;
|
||||||
|
export const CHART_VB_H = Math.ceil(BOTTOM_LINE_Y + BELOW);
|
||||||
|
|
||||||
|
export function chartPixelHeight(width: number): number {
|
||||||
|
return Math.round((width * CHART_VB_H) / CHART_VB_W);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function staffY(index: number): number {
|
||||||
|
return BOTTOM_LINE_Y - index * STEP;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LETTERS = ["C", "D", "E", "F", "G", "A", "B"] as const;
|
||||||
|
|
||||||
|
export type Accidental = "sharp" | "flat";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Treble staff, written one octave below sounding pitch.
|
||||||
|
* Index 0 is the bottom line, E4. Sounding C5 is written C4, index -2.
|
||||||
|
*/
|
||||||
|
export function staffPlacement(pitch: string): { index: number; accidental: Accidental | null } | null {
|
||||||
|
const match = /^([A-Ga-g])(#|b|♯|♭)?(-?\d+)$/.exec(pitch.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
const letter = match[1].toUpperCase();
|
||||||
|
const mark = match[2] ?? "";
|
||||||
|
const accidental: Accidental | null =
|
||||||
|
mark === "#" || mark === "♯" ? "sharp" : mark === "b" || mark === "♭" ? "flat" : null;
|
||||||
|
const octave = Number(match[3]);
|
||||||
|
if (!Number.isInteger(octave)) return null;
|
||||||
|
const diatonic = (octave - 1 - 4) * 7 + LETTERS.indexOf(letter as (typeof LETTERS)[number]);
|
||||||
|
return { index: diatonic - 2, accidental };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ledger lines a note needs outside the five-line staff. */
|
||||||
|
export function ledgerIndexes(index: number): number[] {
|
||||||
|
const lines: number[] = [];
|
||||||
|
if (index > 8) {
|
||||||
|
for (let line = 10; line <= index; line += 2) lines.push(line);
|
||||||
|
}
|
||||||
|
if (index < 0) {
|
||||||
|
const start = index % 2 === 0 ? index : index - 1;
|
||||||
|
for (let line = -2; line >= start; line -= 2) lines.push(line);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On the middle line or higher, the stem goes down. */
|
||||||
|
export function stemDown(index: number): boolean {
|
||||||
|
return index >= 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NoteValue = {
|
||||||
|
head: "open" | "filled";
|
||||||
|
stem: boolean;
|
||||||
|
flags: 0 | 1 | 2;
|
||||||
|
dots: 0 | 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RestValue = {
|
||||||
|
rest: "whole" | "half" | "quarter" | "eighth" | "sixteenth";
|
||||||
|
dots: 0 | 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One chart has its own staff, so a short note wears flags instead of a beam. */
|
||||||
|
export function noteValue(quarters: number): NoteValue {
|
||||||
|
if (quartersMatch(quarters, 4)) return { head: "open", stem: false, flags: 0, dots: 0 };
|
||||||
|
if (quartersMatch(quarters, 3)) return { head: "open", stem: true, flags: 0, dots: 1 };
|
||||||
|
if (quartersMatch(quarters, 2)) return { head: "open", stem: true, flags: 0, dots: 0 };
|
||||||
|
if (quartersMatch(quarters, 1.5)) return { head: "filled", stem: true, flags: 0, dots: 1 };
|
||||||
|
if (quartersMatch(quarters, 0.75)) return { head: "filled", stem: true, flags: 1, dots: 1 };
|
||||||
|
if (quartersMatch(quarters, 0.5)) return { head: "filled", stem: true, flags: 1, dots: 0 };
|
||||||
|
if (quartersMatch(quarters, 0.25)) return { head: "filled", stem: true, flags: 2, dots: 0 };
|
||||||
|
return { head: "filled", stem: true, flags: 0, dots: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restValue(quarters: number): RestValue {
|
||||||
|
if (quartersMatch(quarters, 4)) return { rest: "whole", dots: 0 };
|
||||||
|
if (quartersMatch(quarters, 3)) return { rest: "half", dots: 1 };
|
||||||
|
if (quartersMatch(quarters, 2)) return { rest: "half", dots: 0 };
|
||||||
|
if (quartersMatch(quarters, 1.5)) return { rest: "quarter", dots: 1 };
|
||||||
|
if (quartersMatch(quarters, 0.75)) return { rest: "eighth", dots: 1 };
|
||||||
|
if (quartersMatch(quarters, 0.5)) return { rest: "eighth", dots: 0 };
|
||||||
|
if (quartersMatch(quarters, 0.25)) return { rest: "sixteenth", dots: 0 };
|
||||||
|
return { rest: "quarter", dots: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A dot sits in a space. A note on a line takes the space above. */
|
||||||
|
export function dotIndex(index: number): number {
|
||||||
|
return index % 2 === 0 ? index + 1 : index;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { LineNote } from "@/lib/line";
|
||||||
|
import { chartPixelHeight } from "@/lib/notation";
|
||||||
|
|
||||||
|
export const PNG_CHART_WIDTH = 88;
|
||||||
|
const GAP = 18;
|
||||||
|
const PAD = 28;
|
||||||
|
|
||||||
|
export { chartPixelHeight };
|
||||||
|
|
||||||
|
/** A quarter space is about one chart wide. Longer spaces take more room. */
|
||||||
|
export function spacePixelWidth(quarters: number, chartWidth: number): number {
|
||||||
|
return Math.max(Math.round(chartWidth * 0.45), Math.round(chartWidth * Math.max(quarters, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PngItem = {
|
||||||
|
kind: LineNote["kind"];
|
||||||
|
caption: string;
|
||||||
|
glyphWidth: number;
|
||||||
|
glyphHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pngItemFromNote(note: Pick<LineNote, "kind" | "pitch" | "quarters" | "label">, chartWidth = PNG_CHART_WIDTH): PngItem {
|
||||||
|
const glyphHeight = chartPixelHeight(chartWidth);
|
||||||
|
const caption = note.kind === "note" ? note.pitch : note.kind === "rest" ? "Rest" : "Space";
|
||||||
|
return {
|
||||||
|
kind: note.kind,
|
||||||
|
caption,
|
||||||
|
glyphWidth: note.kind === "space" ? spacePixelWidth(note.quarters, chartWidth) : chartWidth,
|
||||||
|
glyphHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pngSlotWidth(item: PngItem): number {
|
||||||
|
const text = item.caption.length * 8;
|
||||||
|
return Math.max(item.glyphWidth, text, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pngLayout(items: PngItem[], title: string) {
|
||||||
|
const slots = items.map((item) => ({ item, width: pngSlotWidth(item) }));
|
||||||
|
const content =
|
||||||
|
slots.reduce((sum, slot) => sum + slot.width, 0) + GAP * Math.max(0, slots.length - 1);
|
||||||
|
const titleWidth = Math.max(220, title.length * 13 + 8);
|
||||||
|
const width = Math.max(titleWidth, content, 1) + PAD * 2;
|
||||||
|
const glyphHeight = chartPixelHeight(PNG_CHART_WIDTH);
|
||||||
|
const height = PAD + 44 + glyphHeight + 28 + PAD;
|
||||||
|
return { width, height, slots, glyphHeight, pad: PAD, gap: GAP };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pngFilename(title: string): string {
|
||||||
|
const slug = title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
return `${slug || "line"}.png`;
|
||||||
|
}
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
import { spawn } from "node:child_process";
|
|
||||||
import { existsSync, readdirSync } from "node:fs";
|
|
||||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { parseMusicXml } from "@/lib/musicxml";
|
|
||||||
import type { Score } from "@/lib/score";
|
|
||||||
|
|
||||||
export type RecognizerInfo = {
|
|
||||||
available: boolean;
|
|
||||||
name: string;
|
|
||||||
detail: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RecognizeSuccess = {
|
|
||||||
ok: true;
|
|
||||||
score: Score;
|
|
||||||
warnings: string[];
|
|
||||||
recognizer: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RecognizeFailure = {
|
|
||||||
ok: false;
|
|
||||||
error: string;
|
|
||||||
status: number;
|
|
||||||
recognizer: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resolvePython(): string | null {
|
|
||||||
const fromEnv = process.env.OEMER_PYTHON;
|
|
||||||
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelsReady(python: string): boolean {
|
|
||||||
const lib = path.resolve(path.dirname(python), "..", "lib");
|
|
||||||
if (!existsSync(lib)) return false;
|
|
||||||
for (const entry of readdirSync(lib)) {
|
|
||||||
const model = path.join(
|
|
||||||
lib,
|
|
||||||
entry,
|
|
||||||
"site-packages",
|
|
||||||
"oemer",
|
|
||||||
"checkpoints",
|
|
||||||
"unet_big",
|
|
||||||
"model.onnx",
|
|
||||||
);
|
|
||||||
if (existsSync(model)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function recognizerInfo(): RecognizerInfo {
|
|
||||||
const python = resolvePython();
|
|
||||||
if (!python) {
|
|
||||||
return {
|
|
||||||
available: false,
|
|
||||||
name: "oemer",
|
|
||||||
detail:
|
|
||||||
"The local reader (oemer) is not installed. Sample tunes and the note editor still work. No photo is turned into guessed notes.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!modelsReady(python)) {
|
|
||||||
return {
|
|
||||||
available: false,
|
|
||||||
name: "oemer 0.1.8",
|
|
||||||
detail:
|
|
||||||
"oemer is installed, but its model files are missing, so a photo cannot be read. Sample tunes and the editor still work.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
available: true,
|
|
||||||
name: "oemer 0.1.8",
|
|
||||||
detail:
|
|
||||||
"Photos are read on this computer by oemer. It misses notes. Correct the list; nothing is sent away, and a failed read does not invent notes.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scriptPath(): string {
|
|
||||||
const candidates = [
|
|
||||||
path.join(process.cwd(), "scripts", "run_oemer.py"),
|
|
||||||
path.join(process.cwd(), "..", "scripts", "run_oemer.py"),
|
|
||||||
];
|
|
||||||
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function runProcess(
|
|
||||||
command: string,
|
|
||||||
args: string[],
|
|
||||||
timeoutMs: number,
|
|
||||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn(command, args, {
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
MPLBACKEND: "Agg",
|
|
||||||
PYTHONUNBUFFERED: "1",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
child.kill("SIGKILL");
|
|
||||||
reject(new Error("timeout"));
|
|
||||||
}, timeoutMs);
|
|
||||||
child.stdout.setEncoding("utf8");
|
|
||||||
child.stderr.setEncoding("utf8");
|
|
||||||
child.stdout.on("data", (chunk) => {
|
|
||||||
stdout += chunk;
|
|
||||||
});
|
|
||||||
child.stderr.on("data", (chunk) => {
|
|
||||||
stderr += chunk;
|
|
||||||
});
|
|
||||||
child.on("error", (error) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
child.on("close", (code) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve({ code: code ?? 1, stdout, stderr });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortFailure(stderr: string, stdout: string): string {
|
|
||||||
const lines = `${stderr}\n${stdout}`
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.filter((line) => !line.startsWith("MUSICXML="));
|
|
||||||
const errorLine = [...lines].reverse().find((line) => /Error|Exception|assert|too small|could not/i.test(line));
|
|
||||||
const text = errorLine ?? lines.at(-1) ?? "The recognizer stopped without a result.";
|
|
||||||
return text.slice(0, 280);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recognizeImage(file: File): Promise<RecognizeSuccess | RecognizeFailure> {
|
|
||||||
const info = recognizerInfo();
|
|
||||||
if (!info.available) {
|
|
||||||
return { ok: false, error: info.detail, status: 503, recognizer: info.name };
|
|
||||||
}
|
|
||||||
const python = resolvePython();
|
|
||||||
if (!python) {
|
|
||||||
return { ok: false, error: info.detail, status: 503, recognizer: info.name };
|
|
||||||
}
|
|
||||||
|
|
||||||
const directory = await mkdtemp(path.join(tmpdir(), "recorder-tab-"));
|
|
||||||
try {
|
|
||||||
const inputPath = path.join(directory, "upload.bin");
|
|
||||||
await writeFile(inputPath, Buffer.from(await file.arrayBuffer()));
|
|
||||||
const script = scriptPath();
|
|
||||||
let result: { code: number; stdout: string; stderr: string };
|
|
||||||
try {
|
|
||||||
result = await runProcess(python, [script, inputPath, directory], 240_000);
|
|
||||||
} catch (error) {
|
|
||||||
const timedOut = error instanceof Error && error.message === "timeout";
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
status: 422,
|
|
||||||
recognizer: info.name,
|
|
||||||
error: timedOut
|
|
||||||
? "Reading this page took too long and was stopped. No notes were invented. Try a clearer crop of one line, or enter the notes by hand."
|
|
||||||
: "The local reader could not be started. No notes were invented.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const marker = result.stdout
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.find((line) => line.startsWith("MUSICXML="));
|
|
||||||
if (result.code !== 0 || !marker) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
status: 422,
|
|
||||||
recognizer: info.name,
|
|
||||||
error: `This image could not be read. ${shortFailure(result.stderr, result.stdout)} No notes were invented.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const xmlPath = marker.slice("MUSICXML=".length);
|
|
||||||
const xml = await readFile(xmlPath, "utf8");
|
|
||||||
const parsed = parseMusicXml(xml);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
return { ok: false, status: 422, recognizer: info.name, error: parsed.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = file.name.replace(/\.[^.]+$/, "").trim();
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
recognizer: info.name,
|
|
||||||
warnings: parsed.warnings,
|
|
||||||
score: {
|
|
||||||
...parsed.score,
|
|
||||||
title: title || parsed.score.title,
|
|
||||||
origin: "upload",
|
|
||||||
attribution: `Read locally by ${info.name}. Correct any wrong pitch or rhythm before you play.`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
await rm(directory, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import type { Score, ScoreEvent } from "@/lib/score";
|
|
||||||
|
|
||||||
const DURATION: Record<string, { quarters: number; label: string }> = {
|
|
||||||
w: { quarters: 4, label: "whole" },
|
|
||||||
dh: { quarters: 3, label: "dotted half" },
|
|
||||||
h: { quarters: 2, label: "half" },
|
|
||||||
dq: { quarters: 1.5, label: "dotted quarter" },
|
|
||||||
q: { quarters: 1, label: "quarter" },
|
|
||||||
e: { quarters: 0.5, label: "eighth" },
|
|
||||||
s: { quarters: 0.25, label: "sixteenth" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function events(barId: string, specs: string[]): ScoreEvent[] {
|
|
||||||
return specs.map((spec, index) => {
|
|
||||||
const [token, durationKey = "q"] = spec.split(":");
|
|
||||||
const duration = DURATION[durationKey] ?? DURATION.q;
|
|
||||||
const rest = token === "r";
|
|
||||||
return {
|
|
||||||
id: `${barId}-e${index + 1}`,
|
|
||||||
kind: rest ? "rest" : "note",
|
|
||||||
pitch: rest ? null : token,
|
|
||||||
quarters: duration.quarters,
|
|
||||||
label: duration.label,
|
|
||||||
tied: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function measures(prefix: string, bars: string[][]) {
|
|
||||||
return bars.map((bar, index) => {
|
|
||||||
const id = `${prefix}-m${index + 1}`;
|
|
||||||
return { id, events: events(id, bar) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Traditional melody at concert pitch, starting on the soprano’s low C. */
|
|
||||||
export const twinkle: Score = {
|
|
||||||
title: "Twinkle, Twinkle, Little Star",
|
|
||||||
attribution: "Traditional, public domain. First verse, concert pitch for soprano in C.",
|
|
||||||
timeSignature: { beats: 4, beatType: 4 },
|
|
||||||
origin: "sample",
|
|
||||||
measures: measures("twinkle", [
|
|
||||||
["C5", "C5", "G5", "G5"],
|
|
||||||
["A5", "A5", "G5:h"],
|
|
||||||
["F5", "F5", "E5", "E5"],
|
|
||||||
["D5", "D5", "C5:h"],
|
|
||||||
["G5", "G5", "F5", "F5"],
|
|
||||||
["E5", "E5", "D5:h"],
|
|
||||||
["G5", "G5", "F5", "F5"],
|
|
||||||
["E5", "E5", "D5:h"],
|
|
||||||
["C5", "C5", "G5", "G5"],
|
|
||||||
["A5", "A5", "G5:h"],
|
|
||||||
["F5", "F5", "E5", "E5"],
|
|
||||||
["D5", "D5", "C5:h"],
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ode to Joy theme in C major at concert pitch.
|
|
||||||
* The symphony states it in D; this is the familiar C-major teaching key,
|
|
||||||
* which sits on the soprano without leaving the first octave.
|
|
||||||
*/
|
|
||||||
export const odeToJoy: Score = {
|
|
||||||
title: "Ode to Joy",
|
|
||||||
attribution:
|
|
||||||
"Ludwig van Beethoven, public domain. Theme in C major, concert pitch for soprano in C.",
|
|
||||||
timeSignature: { beats: 4, beatType: 4 },
|
|
||||||
origin: "sample",
|
|
||||||
measures: measures("ode", [
|
|
||||||
["E5", "E5", "F5", "G5"],
|
|
||||||
["G5", "F5", "E5", "D5"],
|
|
||||||
["C5", "C5", "D5", "E5"],
|
|
||||||
["E5:dq", "D5:e", "D5:h"],
|
|
||||||
["E5", "E5", "F5", "G5"],
|
|
||||||
["G5", "F5", "E5", "D5"],
|
|
||||||
["C5", "C5", "D5", "E5"],
|
|
||||||
["D5:dq", "C5:e", "C5:h"],
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SAMPLES = [
|
|
||||||
{
|
|
||||||
id: "twinkle",
|
|
||||||
score: twinkle,
|
|
||||||
blurb: "First verse in C. Forked F, half notes, and four-square bars.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ode",
|
|
||||||
score: odeToJoy,
|
|
||||||
blurb: "The hymn theme in C, with dotted quarters and held notes.",
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
@@ -17,6 +17,10 @@ export type ScoreEvent = {
|
|||||||
export type Measure = {
|
export type Measure = {
|
||||||
id: string;
|
id: string;
|
||||||
events: ScoreEvent[];
|
events: ScoreEvent[];
|
||||||
|
/** A forward repeat barline stands at the start of this measure. */
|
||||||
|
repeatForward?: boolean;
|
||||||
|
/** A backward repeat ends on this measure. The number is how many times to play the passage. */
|
||||||
|
repeatTimes?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScoreOrigin = "sample" | "upload" | "blank";
|
export type ScoreOrigin = "sample" | "upload" | "blank";
|
||||||
@@ -86,6 +90,33 @@ export function formatBeat(beat: number): string {
|
|||||||
return Number.isInteger(rounded) ? String(rounded) : String(rounded);
|
return Number.isInteger(rounded) ? String(rounded) : String(rounded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extra full beats after the note itself. A half note in 4/4 returns 1. A dotted quarter returns 0. */
|
||||||
|
export function extraBeatArrows(quarters: number, timeSignature: TimeSignature): number {
|
||||||
|
const perBeat = 4 / timeSignature.beatType;
|
||||||
|
if (!(perBeat > 0) || !(quarters > 0)) return 0;
|
||||||
|
const wholeBeats = Math.floor(quarters / perBeat + 1e-6);
|
||||||
|
return Math.max(0, wholeBeats - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group a lead into horizontal rows. A row holds about `budget` notes, and never splits a bar. */
|
||||||
|
export function leadRows<T extends { events: { length: number } }>(measures: T[], budget = 14): T[][] {
|
||||||
|
const rows: T[][] = [];
|
||||||
|
let current: T[] = [];
|
||||||
|
let count = 0;
|
||||||
|
for (const measure of measures) {
|
||||||
|
const notes = Math.max(1, measure.events.length);
|
||||||
|
if (current.length > 0 && count + notes > budget) {
|
||||||
|
rows.push(current);
|
||||||
|
current = [];
|
||||||
|
count = 0;
|
||||||
|
}
|
||||||
|
current.push(measure);
|
||||||
|
count += notes;
|
||||||
|
}
|
||||||
|
if (current.length > 0) rows.push(current);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
export function blankScore(): Score {
|
export function blankScore(): Score {
|
||||||
return {
|
return {
|
||||||
title: "Untitled line",
|
title: "Untitled line",
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { fingeringCode, fingeringFor, type FingeringSystem } from "@/lib/fingering";
|
|
||||||
import { formatBeat, signatureKey, startBeats, type Score } from "@/lib/score";
|
|
||||||
|
|
||||||
export function formatTextTab(score: Score, system: FingeringSystem): string {
|
|
||||||
const lines: string[] = [
|
|
||||||
score.title,
|
|
||||||
"Soprano recorder in C · concert pitch · sounds as written",
|
|
||||||
system === "baroque"
|
|
||||||
? "Fingering: Baroque (English), Dolmetsch Online chart for recorders in C"
|
|
||||||
: "Fingering: German on F, F♯, and B♭; other notes stay on the Dolmetsch Baroque chart",
|
|
||||||
`Time: ${signatureKey(score.timeSignature)}`,
|
|
||||||
"",
|
|
||||||
];
|
|
||||||
|
|
||||||
score.measures.forEach((measure, measureIndex) => {
|
|
||||||
const beats = startBeats(measure.events, score.timeSignature);
|
|
||||||
lines.push(`Bar ${measureIndex + 1}`);
|
|
||||||
if (measure.events.length === 0) lines.push(" (empty)");
|
|
||||||
measure.events.forEach((event, eventIndex) => {
|
|
||||||
const beat = formatBeat(beats[eventIndex] ?? 1).padEnd(4, " ");
|
|
||||||
const name = (event.kind === "rest" ? "rest" : (event.pitch ?? "?")).padEnd(6, " ");
|
|
||||||
const duration = event.label.padEnd(16, " ");
|
|
||||||
let fingers = "—";
|
|
||||||
if (event.kind === "note" && event.pitch) {
|
|
||||||
const fingering = fingeringFor(event.pitch, system);
|
|
||||||
fingers = fingering ? fingeringCode(fingering) : "outside C5–D7 chart";
|
|
||||||
}
|
|
||||||
const tie = event.tied ? " tie" : "";
|
|
||||||
lines.push(` beat ${beat} ${name} ${duration} ${fingers}${tie}`);
|
|
||||||
});
|
|
||||||
lines.push("");
|
|
||||||
});
|
|
||||||
|
|
||||||
lines.push("Legend: 0 closed thumb, x pinched thumb, - open thumb.");
|
|
||||||
lines.push("Digits are closed holes. 6a without 6b, or 7a without 7b, is a half-hole.");
|
|
||||||
lines.push("bell means cover the foot. Notes outside C5–D7 have no fingering.");
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
+129
-48
@@ -1,12 +1,30 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { fingeringCode, fingeringFor, fingeringMarks, CHART_NOTES } from "@/lib/fingering";
|
import { fingeringCode, fingeringFor, fingeringMarks, CHART_NOTES } from "@/lib/fingering";
|
||||||
import { parseMusicXml } from "@/lib/musicxml";
|
import { DURATION_PRESETS } from "@/lib/duration";
|
||||||
|
import {
|
||||||
|
clampLineBox,
|
||||||
|
cloneLine,
|
||||||
|
createNote,
|
||||||
|
createRest,
|
||||||
|
createSpace,
|
||||||
|
dropIndexFromPoint,
|
||||||
|
insertNote,
|
||||||
|
LINE_EXAMPLES,
|
||||||
|
moveNote,
|
||||||
|
neighborDuration,
|
||||||
|
removeNote,
|
||||||
|
setDuration,
|
||||||
|
} from "@/lib/line";
|
||||||
|
import {
|
||||||
|
ledgerIndexes,
|
||||||
|
noteValue,
|
||||||
|
restValue,
|
||||||
|
staffPlacement,
|
||||||
|
stemDown,
|
||||||
|
} from "@/lib/notation";
|
||||||
import { parsePitch } from "@/lib/pitch";
|
import { parsePitch } from "@/lib/pitch";
|
||||||
import { barStatus, expectedQuarters } from "@/lib/score";
|
import { pngFilename, pngItemFromNote, pngLayout } from "@/lib/png-layout";
|
||||||
import { odeToJoy, twinkle } from "@/lib/samples";
|
import { extraBeatArrows } from "@/lib/score";
|
||||||
import { formatTextTab } from "@/lib/text-tab";
|
|
||||||
|
|
||||||
const BAROQUE: Record<string, string> = {
|
const BAROQUE: Record<string, string> = {
|
||||||
C5: "0 1 2 3 4 5 6a 6b 7a 7b",
|
C5: "0 1 2 3 4 5 6a 6b 7a 7b",
|
||||||
@@ -63,7 +81,7 @@ describe("soprano chart", () => {
|
|||||||
expect(fingeringCode(fingeringFor("Db5", "baroque")!)).toBe(BAROQUE["C#5"]);
|
expect(fingeringCode(fingeringFor("Db5", "baroque")!)).toBe(BAROQUE["C#5"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies only the cited German changes", () => {
|
it("keeps the cited German changes beside the Baroque chart", () => {
|
||||||
expect(fingeringCode(fingeringFor("F5", "german")!)).toBe("0 1 2 3 4");
|
expect(fingeringCode(fingeringFor("F5", "german")!)).toBe("0 1 2 3 4");
|
||||||
expect(fingeringCode(fingeringFor("F#5", "german")!)).toBe("0 1 2 3 5 6a 6b 7a");
|
expect(fingeringCode(fingeringFor("F#5", "german")!)).toBe("0 1 2 3 5 6a 6b 7a");
|
||||||
expect(fingeringCode(fingeringFor("Bb5", "german")!)).toBe("0 1 3 4 6a 6b");
|
expect(fingeringCode(fingeringFor("Bb5", "german")!)).toBe("0 1 3 4 6a 6b");
|
||||||
@@ -73,53 +91,116 @@ describe("soprano chart", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sample melodies", () => {
|
describe("fingering line", () => {
|
||||||
it("keeps Twinkle in 4/4 with forked F and full bars", () => {
|
const quarter = DURATION_PRESETS.find((preset) => preset.label === "quarter")!;
|
||||||
expect(twinkle.measures).toHaveLength(12);
|
const half = DURATION_PRESETS.find((preset) => preset.label === "half")!;
|
||||||
expect(twinkle.measures[0].events.map((event) => event.pitch)).toEqual(["C5", "C5", "G5", "G5"]);
|
|
||||||
expect(twinkle.measures[2].events[0].pitch).toBe("F5");
|
it("inserts, reorders, removes, and changes duration without touching other notes", () => {
|
||||||
for (const measure of twinkle.measures) {
|
const c = createNote("C5", quarter, "c");
|
||||||
expect(barStatus(measure, twinkle.timeSignature).ok).toBe(true);
|
const e = createNote("E5", quarter, "e");
|
||||||
}
|
const g = createNote("G5", quarter, "g");
|
||||||
expect(formatTextTab(twinkle, "baroque")).toContain("0 1 2 3 4 6a 6b 7a 7b");
|
const inserted = insertNote(insertNote([c], 1, g), 1, e);
|
||||||
|
expect(inserted.map((note) => note.pitch)).toEqual(["C5", "E5", "G5"]);
|
||||||
|
expect(moveNote(inserted, "c", 3).map((note) => note.pitch)).toEqual(["E5", "G5", "C5"]);
|
||||||
|
expect(moveNote(inserted, "g", 0).map((note) => note.pitch)).toEqual(["G5", "C5", "E5"]);
|
||||||
|
expect(moveNote(inserted, "e", 1).map((note) => note.id)).toEqual(["c", "e", "g"]);
|
||||||
|
const held = setDuration(inserted, "e", half);
|
||||||
|
expect(held.find((note) => note.id === "e")).toMatchObject({ quarters: 2, label: "half" });
|
||||||
|
expect(held.find((note) => note.id === "c")?.quarters).toBe(1);
|
||||||
|
expect(removeNote(held, "e").map((note) => note.pitch)).toEqual(["C5", "G5"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Ode to Joy dotted rhythm inside the bar", () => {
|
it("places a drop before the note whose center is to the right of the pointer", () => {
|
||||||
expect(expectedQuarters(odeToJoy.timeSignature)).toBe(4);
|
const line = { left: 0, right: 400, top: 0, bottom: 200 };
|
||||||
const bar = odeToJoy.measures[3];
|
expect(dropIndexFromPoint(20, 40, line, [50, 150, 250])).toBe(0);
|
||||||
expect(bar.events.map((event) => event.label)).toEqual(["dotted quarter", "eighth", "half"]);
|
expect(dropIndexFromPoint(80, 40, line, [50, 150, 250])).toBe(1);
|
||||||
expect(bar.events.reduce((sum, event) => sum + event.quarters, 0)).toBe(4);
|
expect(dropIndexFromPoint(300, 40, line, [50, 150, 250])).toBe(3);
|
||||||
for (const measure of odeToJoy.measures) {
|
expect(dropIndexFromPoint(20, 400, line, [50])).toBe(-1);
|
||||||
expect(barStatus(measure, odeToJoy.timeSignature).ok).toBe(true);
|
expect(dropIndexFromPoint(10, 10, line, [])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers two short editable examples and an empty start", () => {
|
||||||
|
expect(LINE_EXAMPLES).toHaveLength(2);
|
||||||
|
for (const example of LINE_EXAMPLES) {
|
||||||
|
expect(example.notes.length).toBeGreaterThan(0);
|
||||||
|
expect(example.notes.length).toBeLessThanOrEqual(8);
|
||||||
|
for (const note of example.notes) {
|
||||||
|
expect(fingeringFor(note.pitch, "baroque")).not.toBeNull();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const copy = cloneLine([...LINE_EXAMPLES[0].notes]);
|
||||||
|
expect(copy.map((note) => note.pitch)).toEqual(LINE_EXAMPLES[0].notes.map((note) => note.pitch));
|
||||||
|
expect(copy[0].id).not.toBe(LINE_EXAMPLES[0].notes[0].id);
|
||||||
|
expect(extraBeatArrows(LINE_EXAMPLES[0].notes[6].quarters, { beats: 4, beatType: 4 })).toBe(1);
|
||||||
|
expect(extraBeatArrows(LINE_EXAMPLES[1].notes[0].quarters, { beats: 4, beatType: 4 })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a rest and a space without changing the notes beside them", () => {
|
||||||
|
const note = createNote("C5", quarter, "c");
|
||||||
|
const rest = createRest(half, "r");
|
||||||
|
const space = createSpace(quarter, "s");
|
||||||
|
const line = insertNote(insertNote([note], 1, rest), 2, space);
|
||||||
|
expect(line.map((item) => item.kind)).toEqual(["note", "rest", "space"]);
|
||||||
|
expect(moveNote(line, "r", 0).map((item) => item.kind)).toEqual(["rest", "note", "space"]);
|
||||||
|
expect(setDuration(line, "s", half)[2]).toMatchObject({ kind: "space", quarters: 2, label: "half" });
|
||||||
|
expect(removeNote(line, "s").map((item) => item.kind)).toEqual(["note", "rest"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a resized line box inside the page", () => {
|
||||||
|
expect(clampLineBox(10, 10, 800)).toEqual({ width: 280, height: 200 });
|
||||||
|
expect(clampLineBox(900, 2000, 640)).toEqual({ width: 640, height: 880 });
|
||||||
|
expect(clampLineBox(400, 360, 200)).toEqual({ width: 200, height: 360 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lays out a PNG with a rest, a space, and a filename", () => {
|
||||||
|
const note = pngItemFromNote(createNote("G5", half, "g"));
|
||||||
|
const rest = pngItemFromNote(createRest(quarter, "r"));
|
||||||
|
const space = pngItemFromNote(createSpace(quarter, "s"));
|
||||||
|
expect(note.caption).toBe("G5");
|
||||||
|
expect(rest.kind).toBe("rest");
|
||||||
|
expect(space.glyphWidth).toBeGreaterThan(40);
|
||||||
|
expect(note.glyphHeight).toBeGreaterThan(note.glyphWidth);
|
||||||
|
const layout = pngLayout([note, rest, space], "Your line");
|
||||||
|
expect(layout.width).toBeGreaterThan(layout.slots[0].width + layout.slots[2].width);
|
||||||
|
expect(layout.height).toBeGreaterThan(layout.glyphHeight);
|
||||||
|
expect(pngFilename("Twinkle, opening")).toBe("twinkle-opening.png");
|
||||||
|
expect(pngFilename(" ")).toBe("line.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("steps duration without skipping a preset", () => {
|
||||||
|
expect(neighborDuration(1, 1)?.quarters).toBe(1.5);
|
||||||
|
expect(neighborDuration(1, -1)?.quarters).toBe(0.75);
|
||||||
|
expect(neighborDuration(0.25, -1)).toBeNull();
|
||||||
|
expect(neighborDuration(4, 1)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("lead-line MusicXML", () => {
|
describe("treble staff", () => {
|
||||||
const xml = readFileSync(path.join(__dirname, "fixtures", "lead.musicxml"), "utf8");
|
it("writes each sounding pitch one octave lower", () => {
|
||||||
const parsed = parseMusicXml(xml);
|
expect(staffPlacement("C5")).toEqual({ index: -2, accidental: null });
|
||||||
|
expect(staffPlacement("C#5")).toEqual({ index: -2, accidental: "sharp" });
|
||||||
|
expect(staffPlacement("Eb5")).toEqual({ index: 0, accidental: "flat" });
|
||||||
|
expect(staffPlacement("C6")).toEqual({ index: 5, accidental: null });
|
||||||
|
expect(staffPlacement("C7")).toEqual({ index: 12, accidental: null });
|
||||||
|
expect(staffPlacement("D7")).toEqual({ index: 13, accidental: null });
|
||||||
|
expect(ledgerIndexes(-2)).toEqual([-2]);
|
||||||
|
expect(ledgerIndexes(5)).toEqual([]);
|
||||||
|
expect(ledgerIndexes(12)).toEqual([10, 12]);
|
||||||
|
expect(ledgerIndexes(13)).toEqual([10, 12]);
|
||||||
|
expect(stemDown(-2)).toBe(false);
|
||||||
|
expect(stemDown(5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps timing, the top note, and the top staff", () => {
|
it("writes length as noteheads, stems, flags, and dots", () => {
|
||||||
expect(parsed.ok).toBe(true);
|
expect(noteValue(4)).toEqual({ head: "open", stem: false, flags: 0, dots: 0 });
|
||||||
if (!parsed.ok) return;
|
expect(noteValue(2)).toEqual({ head: "open", stem: true, flags: 0, dots: 0 });
|
||||||
expect(parsed.score.title).toBe("Probe");
|
expect(noteValue(1.5)).toEqual({ head: "filled", stem: true, flags: 0, dots: 1 });
|
||||||
expect(parsed.score.timeSignature).toEqual({ beats: 4, beatType: 4 });
|
expect(noteValue(1)).toEqual({ head: "filled", stem: true, flags: 0, dots: 0 });
|
||||||
const [first, second, third, fourth] = parsed.score.measures;
|
expect(noteValue(0.5)).toEqual({ head: "filled", stem: true, flags: 1, dots: 0 });
|
||||||
expect(first.events.map((event) => [event.kind, event.pitch, event.label])).toEqual([
|
expect(noteValue(0.25)).toEqual({ head: "filled", stem: true, flags: 2, dots: 0 });
|
||||||
["note", "E5", "quarter"],
|
expect(restValue(2)).toEqual({ rest: "half", dots: 0 });
|
||||||
["rest", null, "quarter"],
|
expect(restValue(3)).toEqual({ rest: "half", dots: 1 });
|
||||||
["note", "G5", "dotted quarter"],
|
expect(restValue(0.5)).toEqual({ rest: "eighth", dots: 0 });
|
||||||
["note", "A4", "eighth"],
|
expect(restValue(0.25)).toEqual({ rest: "sixteenth", dots: 0 });
|
||||||
]);
|
|
||||||
expect(second.events.map((event) => [event.pitch, event.label, event.tied])).toEqual([
|
|
||||||
["E5", "half", false],
|
|
||||||
[null, "half", false],
|
|
||||||
]);
|
|
||||||
expect(third.events.map((event) => event.pitch)).toEqual(["G5"]);
|
|
||||||
expect(fourth.events.map((event) => event.pitch)).toEqual(["C6", "D6"]);
|
|
||||||
expect(parsed.warnings.join(" ")).toMatch(/first part/);
|
|
||||||
expect(parsed.warnings.join(" ")).toMatch(/lower staves/);
|
|
||||||
expect(parsed.warnings.join(" ")).toMatch(/highest note/);
|
|
||||||
expect(parsed.warnings.join(" ")).toMatch(/Grace notes/);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Vendored
-113
@@ -1,113 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<score-partwise version="4.0">
|
|
||||||
<work><work-title>Probe</work-title></work>
|
|
||||||
<part-list>
|
|
||||||
<score-part id="P1"><part-name>Lead</part-name></score-part>
|
|
||||||
<score-part id="P2"><part-name>Other</part-name></score-part>
|
|
||||||
</part-list>
|
|
||||||
<part id="P1">
|
|
||||||
<measure number="1">
|
|
||||||
<attributes>
|
|
||||||
<divisions>2</divisions>
|
|
||||||
<time><beats>4</beats><beat-type>4</beat-type></time>
|
|
||||||
</attributes>
|
|
||||||
<note>
|
|
||||||
<pitch><step>E</step><octave>5</octave></pitch>
|
|
||||||
<duration>2</duration>
|
|
||||||
<type>quarter</type>
|
|
||||||
</note>
|
|
||||||
<note>
|
|
||||||
<rest/>
|
|
||||||
<duration>2</duration>
|
|
||||||
<type>quarter</type>
|
|
||||||
</note>
|
|
||||||
<note>
|
|
||||||
<pitch><step>G</step><octave>5</octave></pitch>
|
|
||||||
<duration>3</duration>
|
|
||||||
<type>quarter</type>
|
|
||||||
<dot/>
|
|
||||||
</note>
|
|
||||||
<note>
|
|
||||||
<pitch><step>A</step><octave>4</octave></pitch>
|
|
||||||
<duration>1</duration>
|
|
||||||
<type>eighth</type>
|
|
||||||
</note>
|
|
||||||
</measure>
|
|
||||||
<measure number="2">
|
|
||||||
<note>
|
|
||||||
<pitch><step>C</step><octave>5</octave></pitch>
|
|
||||||
<duration>4</duration>
|
|
||||||
<type>half</type>
|
|
||||||
<tie type="start"/>
|
|
||||||
</note>
|
|
||||||
<note>
|
|
||||||
<chord/>
|
|
||||||
<pitch><step>E</step><octave>5</octave></pitch>
|
|
||||||
<duration>4</duration>
|
|
||||||
<type>half</type>
|
|
||||||
</note>
|
|
||||||
<note>
|
|
||||||
<rest/>
|
|
||||||
<duration>4</duration>
|
|
||||||
<type>half</type>
|
|
||||||
</note>
|
|
||||||
</measure>
|
|
||||||
<measure number="3">
|
|
||||||
<note>
|
|
||||||
<grace/>
|
|
||||||
<pitch><step>F</step><octave>5</octave></pitch>
|
|
||||||
<type>eighth</type>
|
|
||||||
</note>
|
|
||||||
<note>
|
|
||||||
<pitch><step>G</step><octave>5</octave></pitch>
|
|
||||||
<duration>2</duration>
|
|
||||||
<voice>1</voice>
|
|
||||||
<staff>1</staff>
|
|
||||||
<type>quarter</type>
|
|
||||||
</note>
|
|
||||||
<note>
|
|
||||||
<pitch><step>C</step><octave>4</octave></pitch>
|
|
||||||
<duration>2</duration>
|
|
||||||
<voice>2</voice>
|
|
||||||
<staff>2</staff>
|
|
||||||
<type>quarter</type>
|
|
||||||
</note>
|
|
||||||
</measure>
|
|
||||||
<measure number="4">
|
|
||||||
<note>
|
|
||||||
<pitch><step>C</step><octave>6</octave></pitch>
|
|
||||||
<duration>2</duration>
|
|
||||||
<voice>1</voice>
|
|
||||||
<type>quarter</type>
|
|
||||||
</note>
|
|
||||||
<note>
|
|
||||||
<pitch><step>D</step><octave>6</octave></pitch>
|
|
||||||
<duration>2</duration>
|
|
||||||
<voice>1</voice>
|
|
||||||
<type>quarter</type>
|
|
||||||
</note>
|
|
||||||
<backup><duration>4</duration></backup>
|
|
||||||
<note>
|
|
||||||
<pitch><step>C</step><octave>5</octave></pitch>
|
|
||||||
<duration>2</duration>
|
|
||||||
<voice>2</voice>
|
|
||||||
<type>quarter</type>
|
|
||||||
</note>
|
|
||||||
<note>
|
|
||||||
<pitch><step>D</step><octave>5</octave></pitch>
|
|
||||||
<duration>2</duration>
|
|
||||||
<voice>2</voice>
|
|
||||||
<type>quarter</type>
|
|
||||||
</note>
|
|
||||||
</measure>
|
|
||||||
</part>
|
|
||||||
<part id="P2">
|
|
||||||
<measure number="1">
|
|
||||||
<note>
|
|
||||||
<pitch><step>C</step><octave>3</octave></pitch>
|
|
||||||
<duration>2</duration>
|
|
||||||
<type>quarter</type>
|
|
||||||
</note>
|
|
||||||
</measure>
|
|
||||||
</part>
|
|
||||||
</score-partwise>
|
|
||||||
Reference in new issue
Block a user