Recorder Tab Converter (#1)

<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## What it does

Recorder Tab Converter turns a lead melody into soprano recorder fingering tabs. The instrument is a soprano in C at concert pitch. Baroque (English) fingering is the default. A German toggle changes only F, F♯, and B♭.

Timing stays on the page: time signature, bar lines, rests, and each note’s duration. Every note shows a fingering diagram (thumb and holes), the note name, and the duration. A compact text tab is the secondary view. The note list is editable, so a bad read can be corrected and the tabs update.

Two public-domain samples render immediately: the first verse of Twinkle, Twinkle, Little Star, and the Ode to Joy theme in C major.

## How to run

The usual way to run it is one Linux AppImage. From a checkout:

```bash
./scripts/build-appimage.sh
chmod +x dist/Recorder-Tab-Converter-0.1.0.AppImage
./dist/Recorder-Tab-Converter-0.1.0.AppImage
```

If FUSE is missing:

```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./dist/Recorder-Tab-Converter-0.1.0.AppImage
```

The build writes `dist/Recorder-Tab-Converter-0.1.0.AppImage`. On the machine that produced this branch, that file is `/workspace/dist/Recorder-Tab-Converter-0.1.0.AppImage` (467,138,256 bytes, about 446 MiB). The cap is 600MB. The binary is not committed.

That AppImage was started here. The window opened, the local page loaded, and the bundled reader reported oemer 0.1.8.

For browser development:

```bash
npm install
npm test
npm run dev
```

The dev server is http://127.0.0.1:3847. Photo reading uses `build/python` from the AppImage build via `OEMER_PYTHON`.

## Fingering source

Baroque fingerings are the first-choice rows on the [Dolmetsch Online chart for recorders in C](https://dolmetsch.com/textfingeringchart.htm) (Brian Blood). Low C is concert C5. The chart runs through D7 and includes forked F, half-holes, and the second-octave thumb pinch. Alternate fingerings on that page are omitted. Notes outside C5–D7 are flagged instead of guessed.

German mode changes three notes:

- F is Dolmetsch’s German fingering: thumb plus holes 1–4 (`0 123 4`).
- F♯ and B♭ follow the school German shapes in Zach VanderGraaff’s [Baroque vs German recorder](https://dynamicmusicroom.com/baroque-vs-german-recorder/) article.
- The octave above keeps those front holes and uses the Dolmetsch second-octave thumb. Every other note stays Baroque.

## Recognition limits

Reading is local oemer 0.1.8 on CPU ONNX Runtime. It is not the GPU package. It keeps 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. A failed read leaves the editor usable and does not invent a melody.

A real sheet-music photo was not transcribed while checking this build. Empty files, non-images, and a too-small PNG were rejected, and no notes were filled in.
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-6602f7d5-f1c1-53a9-8a48-a6b5bca61adc?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-6602f7d5-f1c1-53a9-8a48-a6b5bca61adc&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
This commit is contained in:
user-461470729 committed 2026-10-09 03:44:26 +00:00
commit f243ff78ef
55 files changed
+17536

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
/dist
*.AppImage
# local recognizer and tooling
.venv/
/agent-tools
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+64
View File
@@ -0,0 +1,64 @@
# Recorder Tab Converter
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♭.
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.
## Run the AppImage
```bash
chmod +x Recorder-Tab-Converter-0.1.0.AppImage
./Recorder-Tab-Converter-0.1.0.AppImage
```
If the file will not start because FUSE is missing:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./Recorder-Tab-Converter-0.1.0.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.
## Build the AppImage
From a checkout, one command:
```bash
./scripts/build-appimage.sh
```
That installs Node packages, downloads a relocatable Python plus oemer and its ONNX models, builds the app, and writes:
```text
dist/Recorder-Tab-Converter-0.1.0.AppImage
```
The build needs network access the first time. The AppImage is not committed; keep it under 600MB.
## Develop in a browser
```bash
npm install
npm test
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`.
## 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.
German mode changes three notes:
- **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.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+26
View File
@@ -0,0 +1,26 @@
appId: org.recorder.tabconverter
productName: Recorder Tab Converter
executableName: recorder-tab-converter
asar: true
# beforeBuild returns false so the project node_modules are not packed.
# The server uses .next/standalone/node_modules, copied below. electron-builder
# skips a root node_modules directory, so that folder is its own resource.
beforeBuild: electron/before-build.cjs
directories:
output: dist
files:
- electron/**/*
- package.json
extraResources:
- from: .next/standalone
to: standalone
- from: .next/standalone/node_modules
to: standalone/node_modules
- from: build/python
to: python
linux:
target:
- AppImage
category: AudioVideo
icon: electron/icon.png
artifactName: Recorder-Tab-Converter-${version}.${ext}
+9
View File
@@ -0,0 +1,9 @@
// The Next standalone server ships its own node_modules as an extra resource.
// Returning false tells electron-builder to leave the project node_modules out
// of the AppImage. Those packages are not what the window runs.
async function beforeBuild() {
return false;
}
module.exports = beforeBuild;
module.exports.beforeBuild = beforeBuild;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+126
View File
@@ -0,0 +1,126 @@
const { app, BrowserWindow, dialog } = require("electron");
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const http = require("node:http");
const net = require("node:net");
const path = require("node:path");
app.commandLine.appendSwitch("no-sandbox");
app.commandLine.appendSwitch("disable-dev-shm-usage");
let server;
function freePort() {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.listen(0, "127.0.0.1", () => {
const address = probe.address();
const port = address && typeof address === "object" ? address.port : 0;
probe.close(() => resolve(port));
});
probe.on("error", reject);
});
}
function waitFor(url, timeoutMs) {
const started = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
const request = http.get(url, (response) => {
response.resume();
if (response.statusCode && response.statusCode < 500) resolve();
else retry();
});
request.on("error", retry);
};
const retry = () => {
if (Date.now() - started > timeoutMs) reject(new Error(`Timed out waiting for ${url}`));
else setTimeout(tick, 250);
};
tick();
});
}
function stopServer() {
if (server && !server.killed) server.kill("SIGTERM");
}
async function open() {
const resources = process.resourcesPath;
const standalone = path.join(resources, "standalone");
const python = path.join(resources, "python", "bin", "python3");
const serverJs = path.join(standalone, "server.js");
if (!fs.existsSync(serverJs) || !fs.existsSync(python)) {
dialog.showErrorBox(
"Recorder Tab Converter",
"The bundled app or the local reader is missing from this AppImage.",
);
app.quit();
return;
}
const port = await freePort();
const logPath = path.join(app.getPath("temp"), "recorder-tab-converter.log");
const log = fs.createWriteStream(logPath, { flags: "a" });
server = spawn(process.execPath, [serverJs], {
cwd: standalone,
env: {
...process.env,
ELECTRON_RUN_AS_NODE: "1",
PORT: String(port),
HOSTNAME: "127.0.0.1",
OEMER_PYTHON: python,
NODE_ENV: "production",
},
stdio: ["ignore", "pipe", "pipe"],
});
server.stdout.pipe(log);
server.stderr.pipe(log);
server.on("exit", (code) => {
log.write(`\nserver exit ${code}\n`);
});
const url = `http://127.0.0.1:${port}/`;
try {
await waitFor(url, 45_000);
} catch (error) {
dialog.showErrorBox(
"Recorder Tab Converter",
`${error instanceof Error ? error.message : "The app server did not start."}\nLog: ${logPath}`,
);
stopServer();
app.quit();
return;
}
const window = new BrowserWindow({
width: 1280,
height: 880,
minWidth: 390,
minHeight: 640,
show: false,
title: "Recorder Tab Converter",
backgroundColor: "#f3ecdf",
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
// Renderer sandbox cannot create shared memory here, so the window
// fails with ERR_FAILED. The page is the local UI and has no Node access.
sandbox: false,
},
});
window.once("ready-to-show", () => window.show());
await window.loadURL(url);
}
app.whenReady().then(open).catch((error) => {
dialog.showErrorBox("Recorder Tab Converter", error instanceof Error ? error.message : String(error));
app.quit();
});
app.on("before-quit", stopServer);
app.on("window-all-closed", () => {
stopServer();
app.quit();
});
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"dist/**",
"electron/**",
".venv/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+22
View File
@@ -0,0 +1,22 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
outputFileTracingExcludes: {
"*": [".venv/**", "build/**"],
},
cacheComponents: true,
partialPrefetching: true,
// `npm run dev` binds 0.0.0.0. Opening 127.0.0.1 must still receive client bundles.
allowedDevOrigins: ["127.0.0.1"],
turbopack: {
rules: {
"*.css": {
loaders: ["@tailwindcss/turbopack"],
as: "*.css",
},
},
},
};
export default nextConfig;
+13662
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "recorder-tab-converter",
"version": "0.1.0",
"private": true,
"description": "Turn a sheet-music image into soprano recorder fingering tabs, with rhythm kept.",
"main": "electron/main.cjs",
"scripts": {
"dev": "OEMER_PYTHON=\"${OEMER_PYTHON:-$PWD/build/python/bin/python3}\" next dev -H 0.0.0.0 -p 3847",
"build": "next build",
"start": "OEMER_PYTHON=\"${OEMER_PYTHON:-$PWD/build/python/bin/python3}\" next start -H 0.0.0.0 -p 3847",
"lint": "eslint",
"test": "vitest run",
"appimage": "bash scripts/build-appimage.sh"
},
"dependencies": {
"@base-ui/react": "^1.8.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.4.0",
"fast-xml-parser": "^5.11.2",
"lucide-react": "^1.53.0",
"next": "16.4.0",
"next-themes": "^0.4.6",
"react": "19.3.0",
"react-dom": "19.3.0",
"shadcn": "^4.21.4",
"sonner": "^2.0.8",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/turbopack": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"electron": "^44.7.0",
"electron-builder": "^26.15.3",
"eslint": "^9",
"eslint-config-next": "16.4.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.11"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# One-command AppImage build. Needs network the first time (Python, models, Electron).
set -euo pipefail
cd "$(dirname "$0")/.."
ROOT="$(pwd)"
echo "== Install Node packages =="
npm install
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 =="
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"
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
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
"$PY" - <<'PY'
from PIL import Image, ImageDraw
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
echo "== Build the Next.js server =="
npm run build
rm -rf .next/standalone/public .next/standalone/.next/static .next/standalone/scripts
mkdir -p .next/standalone/.next
cp -a public .next/standalone/public
cp -a .next/static .next/standalone/.next/static
cp -a scripts .next/standalone/scripts
echo "== Package the AppImage =="
export CSC_IDENTITY_AUTO_DISCOVERY=false
npx electron-builder --linux AppImage
APPIMAGE="$(find dist -maxdepth 1 -name '*.AppImage' -printf '%T@ %p\n' | sort -nr | head -1 | cut -d' ' -f2-)"
chmod +x "$APPIMAGE"
SIZE="$(stat -c%s "$APPIMAGE")"
echo "Built $APPIMAGE ($SIZE bytes)"
LIMIT=$((600 * 1024 * 1024))
if (( SIZE > LIMIT )); then
echo "AppImage is over 600MB." >&2
exit 1
fi
+84
View File
@@ -0,0 +1,84 @@
#!/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())
+52
View File
@@ -0,0 +1,52 @@
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,
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-source);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-display);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(0.955 0.02 85);
--foreground: oklch(0.27 0.03 55);
--card: oklch(0.985 0.012 92);
--card-foreground: oklch(0.27 0.03 55);
--popover: oklch(0.985 0.012 92);
--popover-foreground: oklch(0.27 0.03 55);
--primary: oklch(0.42 0.07 48);
--primary-foreground: oklch(0.98 0.01 90);
--secondary: oklch(0.93 0.02 85);
--secondary-foreground: oklch(0.32 0.04 50);
--muted: oklch(0.94 0.015 85);
--muted-foreground: oklch(0.45 0.03 55);
--accent: oklch(0.9 0.035 150);
--accent-foreground: oklch(0.28 0.04 155);
--destructive: oklch(0.5 0.16 28);
--border: oklch(0.86 0.025 75);
--input: oklch(0.86 0.025 75);
--ring: oklch(0.55 0.07 48);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.8rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Fraunces, Source_Sans_3 } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const sans = Source_Sans_3({
subsets: ["latin"],
variable: "--font-source",
weight: ["400", "500", "600", "700"],
});
const display = Fraunces({
subsets: ["latin"],
variable: "--font-display",
weight: ["500", "600", "700"],
});
export const metadata: Metadata = {
title: "Recorder Tab Converter",
description:
"Turn a lead melody into soprano recorder fingering tabs, with rhythm, rests, and bar lines kept.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en" className={`${sans.variable} ${display.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col font-sans">
{children}
<Toaster />
</body>
</html>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { Converter } from "@/components/converter";
export default function Home() {
return (
<main>
<Converter />
</main>
);
}
+271
View File
@@ -0,0 +1,271 @@
"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>
);
}
+135
View File
@@ -0,0 +1,135 @@
import {
fingeringMarks,
fingeringProse,
type Fingering,
} from "@/lib/fingering";
type HoleState = Fingering["holes"][number];
function Hole({
cx,
cy,
r,
state,
}: {
cx: number;
cy: number;
r: number;
state: HoleState;
}) {
return (
<g>
<circle cx={cx} cy={cy} r={r} fill="#fbf6ec" />
{state === "closed" ? <circle cx={cx} cy={cy} r={r - 1.1} fill="#1c3830" /> : null}
{state === "half" ? (
<path
d={`M ${cx} ${cy - r + 1} A ${r - 1} ${r - 1} 0 0 0 ${cx} ${cy + r - 1} Z`}
fill="#1c3830"
/>
) : null}
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#5c3d28" strokeWidth="1.6" />
</g>
);
}
export function FingeringDiagram({
fingering,
pitch,
size = "lg",
}: {
fingering: Fingering | null;
pitch: string;
size?: "lg" | "sm";
}) {
const width = size === "lg" ? 112 : 88;
if (!fingering) {
return (
<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"
style={{ width, height: size === "lg" ? 220 : 172 }}
role="img"
aria-label={`${pitch} is outside the C5 to D7 chart. No fingering is shown.`}
>
Outside C5–D7. No fingering.
</div>
);
}
const [h1, h2, h3, h4, h5, h6a, h6b, h7a, h7b] = fingering.holes;
const label = `${pitch}. ${fingeringProse(fingering)}. ${fingeringMarks(fingering).join(", ")}`;
return (
<svg
viewBox="0 0 104 228"
width={width}
role="img"
aria-label={label}
className="h-auto shrink-0"
>
<rect x="34" y="8" width="46" height="208" rx="20" fill="#e4cfae" stroke="#6a4328" strokeWidth="2" />
<path d="M46 28c8-16 22-16 30 0" fill="none" stroke="#6a4328" strokeWidth="2" />
<Hole cx={18} cy={40} r={8} state={fingering.thumb} />
<text x={14} y={62} textAnchor="middle" fontSize="8" fill="#6a4328">
T
</text>
<Hole cx={57} cy={58} r={8} state={h1} />
<Hole cx={57} cy={80} r={8} state={h2} />
<Hole cx={57} cy={102} r={8} state={h3} />
<Hole cx={57} cy={132} r={8} state={h4} />
<Hole cx={57} cy={154} r={8} state={h5} />
<Hole cx={48} cy={178} r={6.5} state={h6a} />
<Hole cx={66} cy={178} r={6.5} state={h6b} />
<Hole cx={48} cy={198} r={6.5} state={h7a} />
<Hole cx={66} cy={198} r={6.5} state={h7b} />
<text x={84} y={61} fontSize="8" fill="#7a5a3c">1</text>
<text x={84} y={83} fontSize="8" fill="#7a5a3c">2</text>
<text x={84} y={105} fontSize="8" fill="#7a5a3c">3</text>
<text x={84} y={135} fontSize="8" fill="#7a5a3c">4</text>
<text x={84} y={157} fontSize="8" fill="#7a5a3c">5</text>
<text x={84} y={181} fontSize="8" fill="#7a5a3c">6</text>
<text x={84} y={201} fontSize="8" fill="#7a5a3c">7</text>
{fingering.bell ? (
<text x={57} y={222} textAnchor="middle" fontSize="8" fill="#6a4328">
bell
</text>
) : null}
</svg>
);
}
export function RestMark({ label }: { label: string }) {
const kind = label.includes("whole")
? "whole"
: label.includes("half")
? "half"
: label.includes("sixteenth")
? "sixteenth"
: label.includes("eighth")
? "eighth"
: "quarter";
return (
<svg viewBox="0 0 64 120" width={72} className="h-auto" role="img" aria-label={`${label} rest`}>
<line x1="8" y1="60" x2="56" y2="60" stroke="#c4b39a" strokeWidth="2" />
{kind === "whole" ? <rect x="20" y="36" width="24" height="14" rx="1" fill="#2a2118" /> : null}
{kind === "half" ? <rect x="20" y="60" width="24" height="14" rx="1" fill="#2a2118" /> : null}
{kind === "quarter" ? (
<path
d="M34 28c8 8 8 14 0 20s-8 12 2 20 10 10 4 18"
fill="none"
stroke="#2a2118"
strokeWidth="4"
strokeLinecap="round"
/>
) : 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>
);
}
+285
View File
@@ -0,0 +1,285 @@
"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>
);
}
+194
View File
@@ -0,0 +1,194 @@
"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>
);
}
+75
View File
@@ -0,0 +1,75 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+51
View File
@@ -0,0 +1,51 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+57
View File
@@ -0,0 +1,57 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+102
View File
@@ -0,0 +1,102 @@
import * as React from "react"
import { cn } from "cn"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+19
View File
@@ -0,0 +1,19 @@
"use client"
import * as React from "react"
import { cn } from "cn"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+54
View File
@@ -0,0 +1,54 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "cn"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
+200
View File
@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "cn"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+24
View File
@@ -0,0 +1,24 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "cn"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+81
View File
@@ -0,0 +1,81 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+89
View File
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
+44
View File
@@ -0,0 +1,44 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+55
View File
@@ -0,0 +1,55 @@
export type DurationPreset = {
quarters: number;
label: string;
};
export const DURATION_PRESETS: DurationPreset[] = [
{ quarters: 4, label: "whole" },
{ quarters: 3, label: "dotted half" },
{ quarters: 2, label: "half" },
{ quarters: 1.5, label: "dotted quarter" },
{ quarters: 1, label: "quarter" },
{ quarters: 0.75, label: "dotted eighth" },
{ quarters: 0.5, label: "eighth" },
{ quarters: 0.25, label: "sixteenth" },
];
const TYPE_NAMES: Record<string, string> = {
breve: "breve",
whole: "whole",
half: "half",
quarter: "quarter",
eighth: "eighth",
"16th": "sixteenth",
"32nd": "thirty-second",
"64th": "sixty-fourth",
};
export function quartersMatch(a: number, b: number): boolean {
return Math.abs(a - b) < 0.001;
}
export function durationLabel(
quarters: number,
type?: string | null,
dots = 0,
tuplet = false,
): string {
if (type && TYPE_NAMES[type]) {
let name = TYPE_NAMES[type];
if (tuplet) name = `triplet ${name}`;
if (dots >= 2) name = `double-dotted ${name}`;
else if (dots === 1) name = `dotted ${name}`;
return name;
}
const preset = DURATION_PRESETS.find((item) => quartersMatch(item.quarters, quarters));
if (preset) return preset.label;
const rounded = Math.round(quarters * 1000) / 1000;
return `${rounded} quarters`;
}
export function formatQuarterLengths(quarters: number): string {
const rounded = Math.round(quarters * 1000) / 1000;
const text = Number.isInteger(rounded) ? String(rounded) : String(rounded);
return `${text} quarter-length${rounded === 1 ? "" : "s"}`;
}
+195
View File
@@ -0,0 +1,195 @@
import { parsePitch } from "@/lib/pitch";
export type HoleState = "open" | "closed" | "half";
export type Fingering = {
thumb: HoleState;
/** Holes 1, 2, 3, 4, 5, 6a, 6b, 7a, 7b. */
holes: HoleState[];
bell: boolean;
};
export type FingeringSystem = "baroque" | "german";
export const RANGE_LABEL = "C5–D7";
export const LOW_MIDI = 72;
export const HIGH_MIDI = 98;
const HOLE_IDS = ["1", "2", "3", "4", "5", "6a", "6b", "7a", "7b"] as const;
type ThumbCode = "0" | "x" | "-";
type Row = [pitch: string, thumb: ThumbCode, holes: string, bell?: boolean];
/**
* First-choice Baroque (English) fingerings from the Dolmetsch Online chart
* for recorders in C (Brian Blood). Octave 1 is concert C5.
* A listed hole is closed. 6a without 6b, or 7a without 7b, is the half-hole.
* Thumb 0 is closed, x is pinched, - is open.
* Alternates on that chart (second B, all-closed D, and so on) are not encoded.
*/
const BAROQUE_ROWS: Row[] = [
["C5", "0", "123456a6b7a7b"],
["C#5", "0", "123456a6b7a"],
["D5", "0", "123456a6b"],
["Eb5", "0", "123456a"],
["E5", "0", "12345"],
["F5", "0", "12346a6b7a7b"],
["F#5", "0", "12356a6b"],
["G5", "0", "123"],
["Ab5", "0", "12456a"],
["A5", "0", "12"],
["Bb5", "0", "134"],
["B5", "0", "1"],
["C6", "0", "2"],
["C#6", "-", "12"],
["D6", "-", "2"],
["Eb6", "-", "23456a6b"],
["E6", "x", "12345"],
["F6", "x", "12346a6b"],
["F#6", "x", "1235"],
["G6", "x", "123"],
["Ab6", "x", "124"],
["A6", "x", "12"],
["Bb6", "x", "1256a6b"],
["B6", "x", "1245"],
["C7", "x", "145"],
["C#7", "x", "1346a6b", true],
["D7", "x", "1346a6b"],
];
/**
* German differs on F, F♯, and B♭.
* Low F is Dolmetsch’s German fingering: 0 123 4 (holes 5–7 open).
* F♯ is thumb, 1, 2, 3, 5, 6, and half-hole 7.
* B♭ is thumb, 1, 3, 4, 6.
* Those two accidentals are the school German pattern described by
* Zach VanderGraaff (Dynamic Music Room). The octave above keeps the same
* front holes and uses the Dolmetsch second-octave thumb.
*/
const GERMAN_ROWS: Row[] = [
["F5", "0", "1234"],
["F#5", "0", "12356a6b7a"],
["Bb5", "0", "1346a6b"],
["F6", "x", "1234"],
["F#6", "x", "12356a6b7a"],
["Bb6", "x", "1346a6b"],
];
export const CHART_NOTES = [
"C5",
"C#5",
"D5",
"Eb5",
"E5",
"F5",
"F#5",
"G5",
"Ab5",
"A5",
"Bb5",
"B5",
"C6",
"C#6",
"D6",
"Eb6",
"E6",
"F6",
"F#6",
"G6",
"Ab6",
"A6",
"Bb6",
"B6",
"C7",
"C#7",
"D7",
] as const;
function closedSet(spec: string): Set<string> {
const closed = new Set<string>();
let index = 0;
while (index < spec.length) {
const pair = spec.slice(index, index + 2);
if (pair === "6a" || pair === "6b" || pair === "7a" || pair === "7b") {
closed.add(pair);
index += 2;
} else {
closed.add(spec[index]);
index += 1;
}
}
return closed;
}
function make(thumb: ThumbCode, spec: string, bell = false): Fingering {
const closed = closedSet(spec);
return {
thumb: thumb === "0" ? "closed" : thumb === "x" ? "half" : "open",
holes: HOLE_IDS.map((id) => (closed.has(id) ? "closed" : "open")),
bell,
};
}
function table(rows: Row[]): Map<number, Fingering> {
const map = new Map<number, Fingering>();
for (const [pitch, thumb, holes, bell] of rows) {
const parsed = parsePitch(pitch);
if (!parsed) throw new Error(`Bad chart pitch ${pitch}`);
map.set(parsed.midi, make(thumb, holes, bell ?? false));
}
return map;
}
const BAROQUE = table(BAROQUE_ROWS);
const GERMAN = table(GERMAN_ROWS);
export function fingeringFor(pitch: string, system: FingeringSystem): Fingering | null {
const parsed = parsePitch(pitch);
if (!parsed || parsed.midi < LOW_MIDI || parsed.midi > HIGH_MIDI) return null;
if (system === "german") {
const german = GERMAN.get(parsed.midi);
if (german) return german;
}
return BAROQUE.get(parsed.midi) ?? null;
}
export function fingeringCode(fingering: Fingering): string {
const thumb = fingering.thumb === "closed" ? "0" : fingering.thumb === "half" ? "x" : "-";
const closed = HOLE_IDS.filter((_, index) => fingering.holes[index] === "closed");
const bell = fingering.bell ? " bell" : "";
return `${thumb} ${closed.join(" ")}${bell}`;
}
export function fingeringMarks(fingering: Fingering): string[] {
const marks: string[] = [];
if (fingering.thumb === "half") marks.push("pinch");
const [hole1, hole2, hole3, hole4, hole5, hole6a, hole6b, hole7a, hole7b] = fingering.holes;
if (hole6a !== hole6b) marks.push("half 6");
if (hole7a !== hole7b) marks.push("half 7");
const lowerRightClosed = hole6a === "closed" || hole6b === "closed" || hole7a === "closed" || hole7b === "closed";
if (hole4 === "closed" && hole5 === "open" && lowerRightClosed) marks.push("fork");
if (hole1 === "closed" && hole2 === "open" && hole3 === "closed") marks.push("fork");
if (fingering.bell) marks.push("cover bell");
return marks;
}
export function fingeringProse(fingering: Fingering): string {
const thumb =
fingering.thumb === "closed"
? "thumb closed"
: fingering.thumb === "half"
? "thumb pinched"
: "thumb open";
const names = ["1", "2", "3", "4", "5", "6a", "6b", "7a", "7b"];
const closed = names.filter((_, index) => fingering.holes[index] === "closed");
const holes = closed.length > 0 ? `holes ${closed.join(" ")} closed` : "no front holes closed";
const bell = fingering.bell ? ", cover the bell" : "";
return `${thumb}, ${holes}${bell}`;
}
export function germanDiffers(pitch: string): boolean {
const baroque = fingeringFor(pitch, "baroque");
const german = fingeringFor(pitch, "german");
if (!baroque || !german) return false;
return fingeringCode(baroque) !== fingeringCode(german);
}
+319
View File
@@ -0,0 +1,319 @@
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",
},
};
}
+40
View File
@@ -0,0 +1,40 @@
const STEPS: Record<string, number> = {
C: 0,
D: 2,
E: 4,
F: 5,
G: 7,
A: 9,
B: 11,
};
export type ParsedPitch = {
midi: number;
name: string;
};
/** Scientific pitch, concert pitch. C4 is MIDI 60, so soprano low C is C5. */
export function parsePitch(raw: string): ParsedPitch | null {
const match = /^([A-Ga-g])(##|bb|#|b|♯|♭)?(-?\d+)$/.exec(raw.trim());
if (!match) return null;
const step = match[1].toUpperCase();
const accidental = match[2] ?? "";
const alter =
accidental === "#" || accidental === "♯"
? 1
: accidental === "b" || accidental === "♭"
? -1
: accidental === "##"
? 2
: accidental === "bb"
? -2
: 0;
const octave = Number(match[3]);
if (!Number.isInteger(octave)) return null;
const pretty =
alter === 1 ? "#" : alter === -1 ? "b" : alter === 2 ? "##" : alter === -2 ? "bb" : "";
return {
midi: (octave + 1) * 12 + STEPS[step] + alter,
name: `${step}${pretty}${octave}`,
};
}
+201
View File
@@ -0,0 +1,201 @@
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 });
}
}
+92
View File
@@ -0,0 +1,92 @@
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;
+97
View File
@@ -0,0 +1,97 @@
import { formatQuarterLengths, quartersMatch } from "@/lib/duration";
export type TimeSignature = {
beats: number;
beatType: number;
};
export type ScoreEvent = {
id: string;
kind: "note" | "rest";
pitch: string | null;
quarters: number;
label: string;
tied: boolean;
};
export type Measure = {
id: string;
events: ScoreEvent[];
};
export type ScoreOrigin = "sample" | "upload" | "blank";
export type Score = {
title: string;
attribution: string;
timeSignature: TimeSignature;
measures: Measure[];
origin: ScoreOrigin;
};
export const TIME_SIGNATURES: TimeSignature[] = [
{ beats: 2, beatType: 4 },
{ beats: 3, beatType: 4 },
{ beats: 4, beatType: 4 },
{ beats: 6, beatType: 8 },
{ beats: 3, beatType: 8 },
{ beats: 2, beatType: 2 },
];
export function newId(prefix: string): string {
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
}
export function signatureKey(timeSignature: TimeSignature): string {
return `${timeSignature.beats}/${timeSignature.beatType}`;
}
export function parseSignature(key: string): TimeSignature {
const [beats, beatType] = key.split("/").map(Number);
return { beats, beatType };
}
export function expectedQuarters(timeSignature: TimeSignature): number {
return (timeSignature.beats * 4) / timeSignature.beatType;
}
export function barStatus(
measure: Measure,
timeSignature: TimeSignature,
): { ok: boolean; text: string } {
const sum = measure.events.reduce((total, event) => total + event.quarters, 0);
const expected = expectedQuarters(timeSignature);
const signature = signatureKey(timeSignature);
if (quartersMatch(sum, expected)) {
return { ok: true, text: `${signature} · ${formatQuarterLengths(sum)}` };
}
return {
ok: false,
text: `${formatQuarterLengths(sum)} in this bar; ${signature} holds ${formatQuarterLengths(expected)}.`,
};
}
export function startBeats(events: ScoreEvent[], timeSignature: TimeSignature): number[] {
const quartersPerBeat = 4 / timeSignature.beatType;
let cursor = 0;
return events.map((event) => {
const beat = cursor / quartersPerBeat + 1;
cursor += event.quarters;
return beat;
});
}
export function formatBeat(beat: number): string {
const rounded = Math.round(beat * 100) / 100;
return Number.isInteger(rounded) ? String(rounded) : String(rounded);
}
export function blankScore(): Score {
return {
title: "Untitled line",
attribution: "Hand-entered lead line. Add notes, then read the tabs.",
timeSignature: { beats: 4, beatType: 4 },
origin: "blank",
measures: [{ id: newId("m"), events: [] }],
};
}
+38
View File
@@ -0,0 +1,38 @@
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");
}
+1
View File
@@ -0,0 +1 @@
export { cn } from "cn"
+125
View File
@@ -0,0 +1,125 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { fingeringCode, fingeringFor, fingeringMarks, CHART_NOTES } from "@/lib/fingering";
import { parseMusicXml } from "@/lib/musicxml";
import { parsePitch } from "@/lib/pitch";
import { barStatus, expectedQuarters } from "@/lib/score";
import { odeToJoy, twinkle } from "@/lib/samples";
import { formatTextTab } from "@/lib/text-tab";
const BAROQUE: Record<string, string> = {
C5: "0 1 2 3 4 5 6a 6b 7a 7b",
"C#5": "0 1 2 3 4 5 6a 6b 7a",
D5: "0 1 2 3 4 5 6a 6b",
Eb5: "0 1 2 3 4 5 6a",
E5: "0 1 2 3 4 5",
F5: "0 1 2 3 4 6a 6b 7a 7b",
"F#5": "0 1 2 3 5 6a 6b",
G5: "0 1 2 3",
Ab5: "0 1 2 4 5 6a",
A5: "0 1 2",
Bb5: "0 1 3 4",
B5: "0 1",
C6: "0 2",
"C#6": "- 1 2",
D6: "- 2",
Eb6: "- 2 3 4 5 6a 6b",
E6: "x 1 2 3 4 5",
F6: "x 1 2 3 4 6a 6b",
"F#6": "x 1 2 3 5",
G6: "x 1 2 3",
Ab6: "x 1 2 4",
A6: "x 1 2",
Bb6: "x 1 2 5 6a 6b",
B6: "x 1 2 4 5",
C7: "x 1 4 5",
"C#7": "x 1 3 4 6a 6b bell",
D7: "x 1 3 4 6a 6b",
};
describe("soprano chart", () => {
it("covers every semitone from C5 through D7 and no others", () => {
const midis = CHART_NOTES.map((note) => parsePitch(note)?.midi);
expect(midis).toEqual(Array.from({ length: 27 }, (_, index) => 72 + index));
});
it("matches the Dolmetsch first-choice Baroque rows", () => {
for (const [note, code] of Object.entries(BAROQUE)) {
const fingering = fingeringFor(note, "baroque");
expect(fingering, note).not.toBeNull();
expect(fingeringCode(fingering!), note).toBe(code);
}
});
it("marks fork, half-holes, pinch, and bell without guessing outside the range", () => {
expect(fingeringMarks(fingeringFor("F5", "baroque")!)).toContain("fork");
expect(fingeringMarks(fingeringFor("Eb5", "baroque")!)).toContain("half 6");
expect(fingeringMarks(fingeringFor("C#5", "baroque")!)).toContain("half 7");
expect(fingeringMarks(fingeringFor("E6", "baroque")!)).toContain("pinch");
expect(fingeringMarks(fingeringFor("C#7", "baroque")!)).toContain("cover bell");
expect(fingeringFor("B4", "baroque")).toBeNull();
expect(fingeringFor("E7", "baroque")).toBeNull();
expect(fingeringCode(fingeringFor("Db5", "baroque")!)).toBe(BAROQUE["C#5"]);
});
it("applies only the cited German changes", () => {
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("Bb5", "german")!)).toBe("0 1 3 4 6a 6b");
expect(fingeringCode(fingeringFor("F6", "german")!)).toBe("x 1 2 3 4");
expect(fingeringCode(fingeringFor("G5", "german")!)).toBe(fingeringCode(fingeringFor("G5", "baroque")!));
expect(fingeringCode(fingeringFor("E6", "german")!)).toBe(fingeringCode(fingeringFor("E6", "baroque")!));
});
});
describe("sample melodies", () => {
it("keeps Twinkle in 4/4 with forked F and full bars", () => {
expect(twinkle.measures).toHaveLength(12);
expect(twinkle.measures[0].events.map((event) => event.pitch)).toEqual(["C5", "C5", "G5", "G5"]);
expect(twinkle.measures[2].events[0].pitch).toBe("F5");
for (const measure of twinkle.measures) {
expect(barStatus(measure, twinkle.timeSignature).ok).toBe(true);
}
expect(formatTextTab(twinkle, "baroque")).toContain("0 1 2 3 4 6a 6b 7a 7b");
});
it("keeps Ode to Joy dotted rhythm inside the bar", () => {
expect(expectedQuarters(odeToJoy.timeSignature)).toBe(4);
const bar = odeToJoy.measures[3];
expect(bar.events.map((event) => event.label)).toEqual(["dotted quarter", "eighth", "half"]);
expect(bar.events.reduce((sum, event) => sum + event.quarters, 0)).toBe(4);
for (const measure of odeToJoy.measures) {
expect(barStatus(measure, odeToJoy.timeSignature).ok).toBe(true);
}
});
});
describe("lead-line MusicXML", () => {
const xml = readFileSync(path.join(__dirname, "fixtures", "lead.musicxml"), "utf8");
const parsed = parseMusicXml(xml);
it("keeps timing, the top note, and the top staff", () => {
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
expect(parsed.score.title).toBe("Probe");
expect(parsed.score.timeSignature).toEqual({ beats: 4, beatType: 4 });
const [first, second, third, fourth] = parsed.score.measures;
expect(first.events.map((event) => [event.kind, event.pitch, event.label])).toEqual([
["note", "E5", "quarter"],
["rest", null, "quarter"],
["note", "G5", "dotted quarter"],
["note", "A4", "eighth"],
]);
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/);
});
});
+113
View File
@@ -0,0 +1,113 @@
<?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>
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+13
View File
@@ -0,0 +1,13 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
});