@hidechae/eztab-mcp 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -38
- package/dist/server.js +527 -370
- package/package.json +3 -4
package/README.md
CHANGED
|
@@ -1,28 +1,29 @@
|
|
|
1
|
-
# EZTab MCP server
|
|
1
|
+
# EZTab MCP server
|
|
2
2
|
|
|
3
3
|
An [MCP](https://modelcontextprotocol.io) server that lets an AI agent (e.g.
|
|
4
|
-
Claude)
|
|
5
|
-
|
|
6
|
-
The server holds the authoritative document and runs a localhost WebSocket
|
|
7
|
-
**hub**. The desktop app connects to it (toggle **Live** in the toolbar) and
|
|
8
|
-
syncs both ways: the agent's tool calls appear in the app instantly, and your
|
|
9
|
-
edits in the app are visible to the agent.
|
|
4
|
+
Claude) build and edit guitar/bass tablature using EZTab's data model.
|
|
10
5
|
|
|
11
6
|
## How it works
|
|
12
7
|
|
|
13
8
|
```
|
|
14
|
-
Claude ──stdio──> MCP server
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
Claude ──stdio──> MCP server ──> in-memory document
|
|
10
|
+
│ EZTAB_TOKEN (MCP 連携トークン)
|
|
11
|
+
▼
|
|
12
|
+
eztab-sync ── GET /tabs, GET /tabs/:id (list_tabs / open_tab)
|
|
13
|
+
└─ POST /inbox (send_to_eztab)
|
|
14
|
+
▲ ポーリング
|
|
15
|
+
EZTab アプリ: 画面上部のバナーで挿入 / 新規タブ / 修正版の反映
|
|
17
16
|
```
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
エージェントのツール呼び出しがメモリ上のドキュメントを書き換え、`get_tab` が
|
|
19
|
+
ASCII レンダリングを返します。`send_to_eztab` で結果をアプリの inbox に送ると、
|
|
20
|
+
アプリ側で「カーソルの後に挿入」「新規タブとして作成」「(open_tab で開いたタブの)
|
|
21
|
+
修正版を反映」を選べます。挿入・反映は未保存の編集として入り、Save するまで確定
|
|
22
|
+
しません。MCP からライブラリを直接書き換える経路はありません。
|
|
24
23
|
|
|
25
|
-
|
|
24
|
+
連携トークンはアプリの Settings → MCP 連携 で発行します(1ユーザー1本、再発行で
|
|
25
|
+
旧トークンは失効)。トークンで使えるのはタブ一覧・取得と inbox への投函だけです。
|
|
26
|
+
トークン無しでも、ライブラリ連携以外のツールは今まで通り使えます。
|
|
26
27
|
|
|
27
28
|
## Install (published package)
|
|
28
29
|
|
|
@@ -31,21 +32,22 @@ Published to npm as **`@hidechae/eztab-mcp`**. Add it to any MCP client:
|
|
|
31
32
|
```json
|
|
32
33
|
{
|
|
33
34
|
"mcpServers": {
|
|
34
|
-
"eztab": {
|
|
35
|
+
"eztab": {
|
|
36
|
+
"command": "npx",
|
|
37
|
+
"args": ["-y", "@hidechae/eztab-mcp"],
|
|
38
|
+
"env": { "EZTAB_TOKEN": "eztab_…" }
|
|
39
|
+
}
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
```
|
|
38
43
|
|
|
39
|
-
Requires the EZTab desktop app (toggle **Live** in its toolbar) on the same
|
|
40
|
-
machine — the server runs a localhost WebSocket hub the app connects to.
|
|
41
|
-
|
|
42
44
|
### Releasing a new version
|
|
43
45
|
|
|
44
46
|
npm versions are immutable, so publishing is deliberate (tag-driven):
|
|
45
47
|
|
|
46
48
|
```sh
|
|
47
49
|
# bump mcp/package.json "version", commit, then:
|
|
48
|
-
git tag mcp-v0.1.
|
|
50
|
+
git tag mcp-v0.1.3 && git push --tags
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
The `Publish MCP to npm` GitHub Action builds and publishes on `mcp-v*` tags
|
|
@@ -55,7 +57,7 @@ The `Publish MCP to npm` GitHub Action builds and publishes on `mcp-v*` tags
|
|
|
55
57
|
|
|
56
58
|
Dependencies are in the repo's `package.json` (`npm install`). This repo ships a
|
|
57
59
|
project-scoped `.mcp.json`, so Claude Code running here offers the `eztab`
|
|
58
|
-
server automatically (approve it when asked).
|
|
60
|
+
server automatically (approve it when asked).
|
|
59
61
|
|
|
60
62
|
For Claude Desktop or elsewhere:
|
|
61
63
|
|
|
@@ -75,32 +77,45 @@ Run standalone for testing: `task mcp` (or `npx tsx mcp/server.ts`).
|
|
|
75
77
|
|
|
76
78
|
## Tools
|
|
77
79
|
|
|
78
|
-
These act on the live shared document (no file path needed):
|
|
79
|
-
|
|
80
80
|
| Tool | What it does |
|
|
81
81
|
|------|--------------|
|
|
82
|
-
| `
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
86
|
-
| `new_tab` |
|
|
87
|
-
| `open_in_app` | Ask the connected app to open a tab by path (the app reads/saves it — the server never touches the filesystem) |
|
|
82
|
+
| `list_tabs` | ライブラリの一覧(アプリで編集中のタブは端末名つき)。要 `EZTAB_TOKEN` |
|
|
83
|
+
| `open_tab` | ライブラリのタブをこのセッションに読み込む(修正用)。要 `EZTAB_TOKEN` |
|
|
84
|
+
| `send_to_eztab` | 今のドキュメントをアプリの inbox に送る。`open_tab` で開いたものは修正版として届く(`as_new` で新規扱い)。要 `EZTAB_TOKEN` |
|
|
85
|
+
| `get_tab` | Readable ASCII rendering of the document under edit (`from`/`to` で小節範囲を絞れる) |
|
|
86
|
+
| `new_tab` | Start a fresh document (title, artist, tuning/preset, measures, tempo) |
|
|
88
87
|
| `set_title` | Set the document title |
|
|
89
88
|
| `set_artist` | Set the document artist |
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
89
|
+
| `write_measures` | 小節単位の一括入力。1拍を1文字列で書き(`"6:5 6:~7"` など)、列番号への割り付けはサーバ側で行う。譜面・画像からの書き起こし向け |
|
|
90
|
+
| `set_cells` | Set tab cells: a fret number, `rest`, `mute` (cutting "x"), or `clear` (empty = sustain). Optional `bend` (0.5/1/1.5) chokes a fret note; optional `tie` ties/slurs it to the previous note (same pitch = just sustain); optional `pm` palm-mutes it |
|
|
91
|
+
| `set_chords` | Set chord labels per column |
|
|
92
92
|
| `set_key` / `remove_key` | Set/add or remove a key (key change) at a measure |
|
|
93
93
|
| `set_section` | Set/remove a section marker (e.g. "Verse", "Chorus") |
|
|
94
94
|
| `set_tempo` | Set playback BPM |
|
|
95
|
-
| `
|
|
95
|
+
| `set_time_signature` | Set a measure's time signature |
|
|
96
|
+
| `set_resolution` | Set a measure's subdivisions per beat (8th/16th/triplets/quintuplets) |
|
|
96
97
|
| `add_measures` / `remove_measure` | Insert/remove measures (keys & sections re-anchored) |
|
|
97
98
|
| `set_tuning` | Replace tuning / string count |
|
|
98
99
|
|
|
99
100
|
These cover the editor's current model: variable string count, per-beat chords,
|
|
100
|
-
keys with mid-piece changes, section markers, per-measure resolution,
|
|
101
|
-
title/artist metadata. (Playback, the on-screen keypad,
|
|
102
|
-
library/archive are app-only UI concerns and intentionally
|
|
101
|
+
keys with mid-piece changes, section markers, per-measure resolution, time
|
|
102
|
+
signature, tempo, and title/artist metadata. (Playback, the on-screen keypad,
|
|
103
|
+
undo/redo and the library/archive are app-only UI concerns and intentionally
|
|
104
|
+
have no MCP tools.)
|
|
103
105
|
|
|
104
106
|
Indices are 0-based. `string` 0 is the top line; tuning is ordered high→low.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
例外として `write_measures` の弦番号はギターの呼び方どおり 1 始まり(1 = TAB の一番上の線)
|
|
108
|
+
で、`get_tab` の行ラベルと一致する。
|
|
109
|
+
|
|
110
|
+
## Prompts
|
|
111
|
+
|
|
112
|
+
| Prompt | What it does |
|
|
113
|
+
|--------|--------------|
|
|
114
|
+
| `import_tab_from_image` | 添付した TAB 譜画像(通常 TAB・コードフォーム図・YouTube の TAB・バンドスコア1ページ)を書き起こす手順。引数 `part`(例: `Guitar II`)でバンドスコアのパートを指定、`notes` で補足 |
|
|
115
|
+
|
|
116
|
+
Claude Desktop では入力欄の「+」→ eztab → `import_tab_from_image`、Claude Code
|
|
117
|
+
では `/mcp__eztab__import_tab_from_image` で呼び出し、画像を添付して送る。
|
|
118
|
+
|
|
119
|
+
EZTab のモデルに無い記譜(スライド・ハンマリング・プリング → タイ/スラー、
|
|
120
|
+
ハーモニクス・ビブラート・装飾音 → 主音のみ等)の寄せ方は手順書
|
|
121
|
+
([prompts.ts](prompts.ts))に書いてあり、書き起こし後に簡略化した箇所を報告させる。
|
package/dist/server.js
CHANGED
|
@@ -4,16 +4,13 @@
|
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import { WebSocket, WebSocketServer } from "ws";
|
|
8
7
|
|
|
9
8
|
// ../src/types/tab.ts
|
|
10
9
|
var TAB_FILE_VERSION = 1;
|
|
11
10
|
var DEFAULT_TEMPO = 120;
|
|
12
11
|
var DEFAULT_MIX = { melody: 1, backing: 0.4 };
|
|
13
12
|
function shiftSectionsForInsert(sections, index) {
|
|
14
|
-
return sections.map(
|
|
15
|
-
(s) => s.measure >= index ? { ...s, measure: s.measure + 1 } : s
|
|
16
|
-
);
|
|
13
|
+
return sections.map((s) => s.measure >= index ? { ...s, measure: s.measure + 1 } : s);
|
|
17
14
|
}
|
|
18
15
|
function shiftSectionsForDelete(sections, index) {
|
|
19
16
|
return sections.filter((s) => s.measure !== index).map((s) => s.measure > index ? { ...s, measure: s.measure - 1 } : s);
|
|
@@ -27,9 +24,7 @@ function activeKeyAt(keys, measure) {
|
|
|
27
24
|
return result;
|
|
28
25
|
}
|
|
29
26
|
function shiftKeysForInsert(keys, index) {
|
|
30
|
-
const shifted = keys.map(
|
|
31
|
-
(k) => k.measure >= index ? { ...k, measure: k.measure + 1 } : k
|
|
32
|
-
);
|
|
27
|
+
const shifted = keys.map((k) => k.measure >= index ? { ...k, measure: k.measure + 1 } : k);
|
|
33
28
|
if (!shifted.some((k) => k.measure === 0)) {
|
|
34
29
|
shifted.unshift({ measure: 0, tonic: keys[0].tonic, mode: keys[0].mode });
|
|
35
30
|
}
|
|
@@ -42,40 +37,146 @@ function shiftKeysForDelete(keys, index) {
|
|
|
42
37
|
}
|
|
43
38
|
return next;
|
|
44
39
|
}
|
|
40
|
+
function cellPalmMute(c) {
|
|
41
|
+
return typeof c === "object" && c !== null && !!c.pm;
|
|
42
|
+
}
|
|
43
|
+
var CHORD_REST = "rest";
|
|
44
|
+
var CHORD_REST_LABEL = "N.C.";
|
|
45
|
+
function chordDisplay(label) {
|
|
46
|
+
if (!label) return "";
|
|
47
|
+
return label === CHORD_REST ? CHORD_REST_LABEL : label;
|
|
48
|
+
}
|
|
49
|
+
function parseChordInput(raw) {
|
|
50
|
+
if (raw === "") return "";
|
|
51
|
+
if (/^(r|n\.?c?\.?)$/i.test(raw)) return CHORD_REST;
|
|
52
|
+
const v = raw.charAt(0).toUpperCase() + raw.slice(1);
|
|
53
|
+
return /^[A-G]/.test(v) ? v : null;
|
|
54
|
+
}
|
|
45
55
|
function setMeasureTimeSignature(measure, beats, beatUnit) {
|
|
46
|
-
const
|
|
47
|
-
const cells = measure.cells.map(
|
|
48
|
-
(row) => Array.from({ length: cols }, (_, c) => row[c] ?? null)
|
|
49
|
-
);
|
|
50
|
-
const chords = Array.from(
|
|
56
|
+
const subdivisions = Array.from(
|
|
51
57
|
{ length: beats },
|
|
52
|
-
(_, b) => measure.
|
|
58
|
+
(_, b) => measure.subdivisions[Math.min(b, measure.subdivisions.length - 1)] ?? DEFAULT_SUBDIVISIONS
|
|
53
59
|
);
|
|
54
|
-
|
|
60
|
+
const cols = subdivisions.reduce((a, b) => a + b, 0);
|
|
61
|
+
const cells = measure.cells.map((row) => Array.from({ length: cols }, (_, c) => row[c] ?? null));
|
|
62
|
+
const chords = Array.from({ length: cols }, (_, c) => measure.chords?.[c] ?? null);
|
|
63
|
+
return { ...measure, beats, beatUnit, subdivisions, cells, chords };
|
|
55
64
|
}
|
|
56
65
|
function columnCount(measure) {
|
|
57
|
-
|
|
66
|
+
let sum = 0;
|
|
67
|
+
for (const s of measure.subdivisions) sum += s;
|
|
68
|
+
return sum;
|
|
58
69
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
function uniformSubdivision(measure) {
|
|
71
|
+
const first = measure.subdivisions[0];
|
|
72
|
+
return measure.subdivisions.every((s) => s === first) ? first : null;
|
|
73
|
+
}
|
|
74
|
+
function normalizeChords(raw, subdivisions) {
|
|
75
|
+
const beats = subdivisions.length;
|
|
76
|
+
const cols = subdivisions.reduce((a, b) => a + b, 0);
|
|
77
|
+
const src = Array.isArray(raw) ? raw : [];
|
|
78
|
+
const out = Array.from({ length: cols }, () => null);
|
|
79
|
+
if (src.length > beats) {
|
|
80
|
+
for (let c = 0; c < Math.min(cols, src.length); c++) {
|
|
81
|
+
out[c] = src[c] || null;
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
let col = 0;
|
|
85
|
+
for (let b = 0; b < beats; b++) {
|
|
86
|
+
if (b < src.length) out[col] = src[b] || null;
|
|
87
|
+
col += subdivisions[b];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
function normalizeDocument(data) {
|
|
93
|
+
if (!data || !Array.isArray(data.tuning) || !Array.isArray(data.measures)) {
|
|
94
|
+
throw new Error("Not a valid EZTab document");
|
|
95
|
+
}
|
|
96
|
+
data.measures = data.measures.map((m) => {
|
|
97
|
+
let beats = m.beats;
|
|
98
|
+
let rawSubs = m.subdivisions;
|
|
99
|
+
const subsValid = typeof rawSubs === "number" || Array.isArray(rawSubs) && rawSubs.length > 0;
|
|
100
|
+
if (!Number.isInteger(beats) || beats < 1 || beats > MAX_BEATS || !subsValid) {
|
|
101
|
+
const cols2 = m.cells?.[0]?.length ?? m.columns ?? 8;
|
|
102
|
+
const sub = cols2 % 4 === 0 ? cols2 / 4 : 2;
|
|
103
|
+
beats = cols2 % 4 === 0 ? 4 : Math.max(1, Math.round(cols2 / sub));
|
|
104
|
+
rawSubs = sub;
|
|
105
|
+
}
|
|
106
|
+
const rawUnit = m.beatUnit;
|
|
107
|
+
const beatUnit = BEAT_UNITS.includes(rawUnit) ? rawUnit : DEFAULT_BEAT_UNIT;
|
|
108
|
+
const subdivisions = Array.from({ length: beats }, (_, b) => {
|
|
109
|
+
const v = Array.isArray(rawSubs) ? rawSubs[Math.min(b, rawSubs.length - 1)] : rawSubs;
|
|
110
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= MAX_SUBDIVISION ? v : DEFAULT_SUBDIVISIONS;
|
|
111
|
+
});
|
|
112
|
+
const cols = subdivisions.reduce((a, b) => a + b, 0);
|
|
113
|
+
const cells = (m.cells ?? []).map(
|
|
114
|
+
(row) => Array.from({ length: cols }, (_, c) => row?.[c] ?? null)
|
|
115
|
+
);
|
|
116
|
+
const rawChords = Array.isArray(m.chords) ? m.chords : typeof m.chord === "string" && m.chord ? [m.chord] : [];
|
|
117
|
+
const chords = normalizeChords(rawChords, subdivisions);
|
|
118
|
+
return { beats, beatUnit, subdivisions, cells, chords };
|
|
119
|
+
});
|
|
120
|
+
if (!Array.isArray(data.keys) || data.keys.length === 0) {
|
|
121
|
+
data.keys = [{ measure: 0, tonic: "C", mode: "major" }];
|
|
122
|
+
}
|
|
123
|
+
if (typeof data.artist !== "string") data.artist = "";
|
|
124
|
+
if (!Array.isArray(data.sections)) data.sections = [];
|
|
125
|
+
if (typeof data.tempo !== "number") data.tempo = DEFAULT_TEMPO;
|
|
126
|
+
if (!data.mix || typeof data.mix.melody !== "number") {
|
|
127
|
+
data.mix = { ...DEFAULT_MIX };
|
|
128
|
+
}
|
|
129
|
+
data.version = TAB_FILE_VERSION;
|
|
130
|
+
return data;
|
|
131
|
+
}
|
|
132
|
+
var MAX_SUBDIVISION = 12;
|
|
133
|
+
var PRESET_TUNING_GROUPS = [
|
|
134
|
+
{
|
|
135
|
+
group: "Standard",
|
|
136
|
+
items: [
|
|
137
|
+
{ name: "Guitar (6)", tuning: ["E4", "B3", "G3", "D3", "A2", "E2"] },
|
|
138
|
+
{ name: "Guitar (7)", tuning: ["E4", "B3", "G3", "D3", "A2", "E2", "B1"] },
|
|
139
|
+
{ name: "Bass (4)", tuning: ["G2", "D2", "A1", "E1"] },
|
|
140
|
+
{ name: "Bass (5)", tuning: ["G2", "D2", "A1", "E1", "B0"] },
|
|
141
|
+
{ name: "Bass (6)", tuning: ["C3", "G2", "D2", "A1", "E1", "B0"] },
|
|
142
|
+
{ name: "Ukulele (4)", tuning: ["A4", "E4", "C4", "G4"] }
|
|
143
|
+
]
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
group: "Drop",
|
|
147
|
+
items: [{ name: "Drop D", tuning: ["E4", "B3", "G3", "D3", "A2", "D2"] }]
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
group: "Open / Modal",
|
|
151
|
+
items: [
|
|
152
|
+
{ name: "DADGAD", tuning: ["D4", "A3", "G3", "D3", "A2", "D2"] },
|
|
153
|
+
{ name: "Open G", tuning: ["D4", "B3", "G3", "D3", "G2", "D2"] },
|
|
154
|
+
{ name: "Open D", tuning: ["D4", "A3", "F#3", "D3", "A2", "D2"] },
|
|
155
|
+
{ name: "Open E", tuning: ["E4", "B3", "G#3", "E3", "B2", "E2"] }
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
];
|
|
159
|
+
var PRESET_TUNINGS = Object.fromEntries(
|
|
160
|
+
PRESET_TUNING_GROUPS.flatMap((g) => g.items.map((i) => [i.name, i.tuning]))
|
|
161
|
+
);
|
|
65
162
|
var DEFAULT_BEATS = 4;
|
|
66
163
|
var DEFAULT_BEAT_UNIT = 4;
|
|
67
164
|
var DEFAULT_SUBDIVISIONS = 2;
|
|
165
|
+
var MAX_FRET = 24;
|
|
166
|
+
var MAX_BEATS = 32;
|
|
167
|
+
var BEAT_UNITS = [1, 2, 4, 8, 16, 32];
|
|
68
168
|
function createMeasure(stringCount, beats = DEFAULT_BEATS, subdivisions = DEFAULT_SUBDIVISIONS, beatUnit = DEFAULT_BEAT_UNIT) {
|
|
69
|
-
const
|
|
169
|
+
const subs = Array.from(
|
|
170
|
+
{ length: beats },
|
|
171
|
+
(_, b) => typeof subdivisions === "number" ? subdivisions : subdivisions[Math.min(b, subdivisions.length - 1)] ?? DEFAULT_SUBDIVISIONS
|
|
172
|
+
);
|
|
173
|
+
const columns = subs.reduce((a, b) => a + b, 0);
|
|
70
174
|
return {
|
|
71
175
|
beats,
|
|
72
176
|
beatUnit,
|
|
73
|
-
subdivisions,
|
|
74
|
-
cells: Array.from(
|
|
75
|
-
|
|
76
|
-
() => Array.from({ length: columns }, () => null)
|
|
77
|
-
),
|
|
78
|
-
chords: Array.from({ length: beats }, () => null)
|
|
177
|
+
subdivisions: subs,
|
|
178
|
+
cells: Array.from({ length: stringCount }, () => Array.from({ length: columns }, () => null)),
|
|
179
|
+
chords: Array.from({ length: columns }, () => null)
|
|
79
180
|
};
|
|
80
181
|
}
|
|
81
182
|
function newTabId() {
|
|
@@ -91,10 +192,7 @@ function createDocument(tuning = PRESET_TUNINGS["Guitar (6)"], measureCount = 4)
|
|
|
91
192
|
title: "Untitled",
|
|
92
193
|
artist: "",
|
|
93
194
|
tuning: [...tuning],
|
|
94
|
-
measures: Array.from(
|
|
95
|
-
{ length: measureCount },
|
|
96
|
-
() => createMeasure(tuning.length)
|
|
97
|
-
),
|
|
195
|
+
measures: Array.from({ length: measureCount }, () => createMeasure(tuning.length)),
|
|
98
196
|
keys: [{ measure: 0, tonic: "C", mode: "major" }],
|
|
99
197
|
sections: [],
|
|
100
198
|
tempo: DEFAULT_TEMPO,
|
|
@@ -109,54 +207,101 @@ function resizeMeasureStrings(measure, stringCount) {
|
|
|
109
207
|
);
|
|
110
208
|
return { ...measure, cells };
|
|
111
209
|
}
|
|
112
|
-
function
|
|
113
|
-
const
|
|
114
|
-
const
|
|
210
|
+
function remapToSubdivisions(measure, subs) {
|
|
211
|
+
const newCols = subs.reduce((a, b) => a + b, 0);
|
|
212
|
+
const map = [];
|
|
213
|
+
let newStart = 0;
|
|
214
|
+
for (let b = 0; b < measure.subdivisions.length; b++) {
|
|
215
|
+
const os = measure.subdivisions[b];
|
|
216
|
+
const ns = subs[b];
|
|
217
|
+
for (let s = 0; s < os; s++) {
|
|
218
|
+
map.push(newStart + Math.min(ns - 1, Math.round(s * ns / os)));
|
|
219
|
+
}
|
|
220
|
+
newStart += ns;
|
|
221
|
+
}
|
|
115
222
|
const cells = measure.cells.map((row) => {
|
|
116
223
|
const next = Array.from({ length: newCols }, () => null);
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
if (ni >= 0 && ni < newCols) next[ni] = row[c];
|
|
121
|
-
}
|
|
224
|
+
row.forEach((cell, c) => {
|
|
225
|
+
if (cell != null && map[c] != null) next[map[c]] = cell;
|
|
226
|
+
});
|
|
122
227
|
return next;
|
|
123
228
|
});
|
|
124
|
-
|
|
229
|
+
let chords = measure.chords;
|
|
230
|
+
if (chords) {
|
|
231
|
+
const next = Array.from({ length: newCols }, () => null);
|
|
232
|
+
chords.forEach((label, c) => {
|
|
233
|
+
if (label && map[c] != null) next[map[c]] = label;
|
|
234
|
+
});
|
|
235
|
+
chords = next;
|
|
236
|
+
}
|
|
237
|
+
return { ...measure, subdivisions: subs, cells, chords };
|
|
238
|
+
}
|
|
239
|
+
function setMeasureSubdivisions(measure, subdivisions) {
|
|
240
|
+
return remapToSubdivisions(
|
|
241
|
+
measure,
|
|
242
|
+
Array.from({ length: measure.beats }, () => subdivisions)
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
function setBeatSubdivision(measure, beat, subdivision) {
|
|
246
|
+
const subs = [...measure.subdivisions];
|
|
247
|
+
subs[beat] = subdivision;
|
|
248
|
+
return remapToSubdivisions(measure, subs);
|
|
125
249
|
}
|
|
126
250
|
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
const beatUnit = typeof m.beatUnit === "number" ? m.beatUnit : 4;
|
|
141
|
-
const cols = beats * subdivisions;
|
|
142
|
-
const cells = (m.cells ?? []).map(
|
|
143
|
-
(row) => Array.from({ length: cols }, (_, c) => row?.[c] ?? null)
|
|
251
|
+
// ../src/lib/config.ts
|
|
252
|
+
var CLOUD_BASE_URL = "https://eztab-sync-736328841982.asia-northeast1.run.app";
|
|
253
|
+
|
|
254
|
+
// cloud.ts
|
|
255
|
+
var CloudError = class extends Error {
|
|
256
|
+
};
|
|
257
|
+
var BASE_URL = process.env.EZTAB_API_URL ?? CLOUD_BASE_URL;
|
|
258
|
+
function token() {
|
|
259
|
+
const t = process.env.EZTAB_TOKEN?.trim();
|
|
260
|
+
if (!t) {
|
|
261
|
+
throw new CloudError(
|
|
262
|
+
`EZTAB_TOKEN is not set. Issue an MCP link token in the EZTab app (Settings \u2192 MCP \u9023\u643A), then add it to this MCP server's config: "env": { "EZTAB_TOKEN": "eztab_\u2026" }.`
|
|
144
263
|
);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
264
|
+
}
|
|
265
|
+
return t;
|
|
266
|
+
}
|
|
267
|
+
async function req(path, init = {}) {
|
|
268
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
269
|
+
...init,
|
|
270
|
+
headers: { ...init.headers ?? {}, authorization: `Bearer ${token()}` }
|
|
148
271
|
});
|
|
149
|
-
if (
|
|
150
|
-
|
|
272
|
+
if (res.status === 401) {
|
|
273
|
+
throw new CloudError(
|
|
274
|
+
"EZTab rejected the MCP link token (revoked or mistyped). Issue a new one in the app's settings."
|
|
275
|
+
);
|
|
151
276
|
}
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
277
|
+
if (!res.ok) {
|
|
278
|
+
const body = await res.json().catch(() => null);
|
|
279
|
+
throw new CloudError(
|
|
280
|
+
`EZTab request ${path} failed (${res.status})${body?.error ? `: ${body.error}` : ""}`
|
|
281
|
+
);
|
|
157
282
|
}
|
|
158
|
-
|
|
159
|
-
|
|
283
|
+
return res;
|
|
284
|
+
}
|
|
285
|
+
var cloud = {
|
|
286
|
+
async list() {
|
|
287
|
+
const { tabs } = await (await req("/tabs")).json();
|
|
288
|
+
return tabs;
|
|
289
|
+
},
|
|
290
|
+
async get(id) {
|
|
291
|
+
return (await req(`/tabs/${encodeURIComponent(id)}`)).json();
|
|
292
|
+
},
|
|
293
|
+
async sendToInbox(payload) {
|
|
294
|
+
await req("/inbox", {
|
|
295
|
+
method: "POST",
|
|
296
|
+
headers: { "content-type": "application/json" },
|
|
297
|
+
body: JSON.stringify(payload)
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// eztab.ts
|
|
303
|
+
function normalize(data) {
|
|
304
|
+
return normalizeDocument(data);
|
|
160
305
|
}
|
|
161
306
|
function cellText(cell) {
|
|
162
307
|
if (cell === null) return "--";
|
|
@@ -170,271 +315,201 @@ function cellText(cell) {
|
|
|
170
315
|
}
|
|
171
316
|
return String(cell).padStart(2, "-");
|
|
172
317
|
}
|
|
173
|
-
function render(doc) {
|
|
318
|
+
function render(doc, from = 0, to = doc.measures.length - 1) {
|
|
174
319
|
const lines = [];
|
|
175
|
-
lines.push(
|
|
176
|
-
|
|
177
|
-
);
|
|
178
|
-
lines.push(
|
|
179
|
-
`Tuning (top\u2192bottom): ${doc.tuning.join(" ")} measures=${doc.measures.length}`
|
|
180
|
-
);
|
|
320
|
+
lines.push(`Title: ${doc.title}${doc.artist ? ` \u2014 ${doc.artist}` : ""} tempo=${doc.tempo}`);
|
|
321
|
+
lines.push(`Tuning (top\u2192bottom): ${doc.tuning.join(" ")} measures=${doc.measures.length}`);
|
|
181
322
|
const ann = [];
|
|
182
323
|
doc.measures.forEach((_, m) => {
|
|
183
324
|
const k = doc.keys.find((x) => x.measure === m);
|
|
184
325
|
const s = doc.sections.find((x) => x.measure === m);
|
|
185
|
-
if (k) ann.push(`m${m
|
|
186
|
-
if (s && s.label) ann.push(`m${m
|
|
326
|
+
if (k) ann.push(`m${m}:key=${k.tonic} ${k.mode}`);
|
|
327
|
+
if (s && s.label) ann.push(`m${m}:section="${s.label}"`);
|
|
187
328
|
});
|
|
188
329
|
if (ann.length) lines.push(`Markers: ${ann.join(", ")}`);
|
|
189
330
|
doc.measures.forEach((m, mi) => {
|
|
331
|
+
if (mi < from || mi > to) return;
|
|
190
332
|
const key = activeKeyAt(doc.keys, mi);
|
|
333
|
+
const sub = uniformSubdivision(m);
|
|
334
|
+
const subText = sub != null ? `${sub}/beat` : `[${m.subdivisions.join(",")}]/beat`;
|
|
191
335
|
lines.push("");
|
|
192
336
|
lines.push(
|
|
193
|
-
`---
|
|
337
|
+
`--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, ${subText}, ${columnCount(m)} cols, key ${key.tonic} ${key.mode}) ---`
|
|
194
338
|
);
|
|
195
|
-
const chordRow = (m.chords ?? []).map((
|
|
196
|
-
lines.push(`chords: ${chordRow}`);
|
|
197
|
-
const ruler = Array.from(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
lines.push(`col: ${ruler}`);
|
|
339
|
+
const chordRow = (m.chords ?? []).map((label, c) => label ? `c${c}:${chordDisplay(label)}` : null).filter(Boolean).join(" ");
|
|
340
|
+
lines.push(`chords: ${chordRow || "(none)"}`);
|
|
341
|
+
const ruler = Array.from({ length: columnCount(m) }, (_, c) => String(c).padStart(2, " ")).join(
|
|
342
|
+
" "
|
|
343
|
+
);
|
|
344
|
+
lines.push(`col: ${ruler}`);
|
|
202
345
|
m.cells.forEach((row, s) => {
|
|
203
|
-
const label =
|
|
346
|
+
const label = `${s + 1} ${doc.tuning[s] ?? "?"}`.padEnd(6, " ");
|
|
204
347
|
const cells = row.map(cellText).join(" ");
|
|
205
348
|
lines.push(`${label}| ${cells}`);
|
|
206
349
|
});
|
|
350
|
+
const pm = Array.from(
|
|
351
|
+
{ length: columnCount(m) },
|
|
352
|
+
(_, c) => m.cells.some((row) => cellPalmMute(row[c] ?? null)) ? "PM" : " "
|
|
353
|
+
);
|
|
354
|
+
if (pm.some((x) => x !== " ")) lines.push(`pm: ${pm.join(" ")}`);
|
|
207
355
|
});
|
|
208
356
|
return lines.join("\n");
|
|
209
357
|
}
|
|
210
358
|
|
|
211
|
-
//
|
|
212
|
-
var
|
|
213
|
-
var
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
var appView = null;
|
|
221
|
-
var appEditable = null;
|
|
222
|
-
var pending = /* @__PURE__ */ new Map();
|
|
223
|
-
var reqSeq = 0;
|
|
224
|
-
var reqTag = Math.random().toString(36).slice(2, 8);
|
|
225
|
-
function findAppClient() {
|
|
226
|
-
for (const c of clients) {
|
|
227
|
-
if (c._isApp && c.readyState === WebSocket.OPEN) return c;
|
|
359
|
+
// notation.ts
|
|
360
|
+
var BEAT_UNITS2 = [2, 4, 8, 16];
|
|
361
|
+
var BENDS = { b: 0.5, B: 1, "^": 1.5 };
|
|
362
|
+
function parseTime(text2) {
|
|
363
|
+
const m = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(text2);
|
|
364
|
+
const beats = m ? Number(m[1]) : NaN;
|
|
365
|
+
const beatUnit = m ? Number(m[2]) : NaN;
|
|
366
|
+
if (!(beats >= 1 && beats <= 32) || !BEAT_UNITS2.includes(beatUnit)) {
|
|
367
|
+
throw new Error(`bad time signature "${text2}" (e.g. "4/4", "6/8")`);
|
|
228
368
|
}
|
|
229
|
-
return
|
|
230
|
-
}
|
|
231
|
-
function requestFromApp(action, params = {}, timeoutMs = 6e3) {
|
|
232
|
-
const reqId = `${reqTag}-${++reqSeq}`;
|
|
233
|
-
return new Promise((resolve, reject) => {
|
|
234
|
-
const out = JSON.stringify({ type: "request", reqId, action, params });
|
|
235
|
-
if (mode === "host") {
|
|
236
|
-
const app = findAppClient();
|
|
237
|
-
if (!app) {
|
|
238
|
-
reject(
|
|
239
|
-
new Error(
|
|
240
|
-
"No EZTab app is connected. Ask the user to open the app and enable Live (toolbar)."
|
|
241
|
-
)
|
|
242
|
-
);
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
const timer = setTimeout(() => {
|
|
246
|
-
pending.delete(reqId);
|
|
247
|
-
reject(new Error("The EZTab app didn't respond in time."));
|
|
248
|
-
}, timeoutMs);
|
|
249
|
-
pending.set(reqId, { resolve, reject, timer });
|
|
250
|
-
app.send(out);
|
|
251
|
-
} else if (upstream && upstream.readyState === WebSocket.OPEN) {
|
|
252
|
-
const timer = setTimeout(() => {
|
|
253
|
-
pending.delete(reqId);
|
|
254
|
-
reject(new Error("The EZTab app didn't respond in time."));
|
|
255
|
-
}, timeoutMs);
|
|
256
|
-
pending.set(reqId, { resolve, reject, timer });
|
|
257
|
-
upstream.send(out);
|
|
258
|
-
} else {
|
|
259
|
-
reject(new Error("Not connected to the hub."));
|
|
260
|
-
}
|
|
261
|
-
});
|
|
369
|
+
return { beats, beatUnit };
|
|
262
370
|
}
|
|
263
|
-
function
|
|
264
|
-
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
371
|
+
function parseValue(raw, where) {
|
|
372
|
+
if (raw === "x") return "mute";
|
|
373
|
+
if (raw === "r") return "rest";
|
|
374
|
+
const m = /^(~?)(\d+)([bB^p]*)$/.exec(raw);
|
|
375
|
+
if (!m) throw new Error(`${where}: bad value "${raw}"`);
|
|
376
|
+
const fret = Number(m[2]);
|
|
377
|
+
if (fret > MAX_FRET) throw new Error(`${where}: fret ${fret} is above ${MAX_FRET}`);
|
|
378
|
+
const mods = m[3];
|
|
379
|
+
const bendMarks = [...mods].filter((c) => c in BENDS);
|
|
380
|
+
if (bendMarks.length > 1) throw new Error(`${where}: more than one choke mark in "${raw}"`);
|
|
381
|
+
const note = { fret };
|
|
382
|
+
if (bendMarks.length) note.bend = BENDS[bendMarks[0]];
|
|
383
|
+
if (m[1]) note.tie = true;
|
|
384
|
+
if (mods.includes("p")) note.pm = true;
|
|
385
|
+
return Object.keys(note).length === 1 ? fret : note;
|
|
270
386
|
}
|
|
271
|
-
function
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
doc: state.doc,
|
|
276
|
-
version: state.version
|
|
277
|
-
});
|
|
278
|
-
for (const c of clients) {
|
|
279
|
-
if (c.readyState === WebSocket.OPEN) c.send(msg);
|
|
280
|
-
}
|
|
281
|
-
} else if (upstream && upstream.readyState === WebSocket.OPEN) {
|
|
282
|
-
upstream.send(JSON.stringify({ type: "set", doc: state.doc }));
|
|
387
|
+
function parseBeat(text2, stringCount, where) {
|
|
388
|
+
const slots = text2.trim().split(/\s+/);
|
|
389
|
+
if (slots.length === 1 && slots[0] === "") {
|
|
390
|
+
throw new Error(`${where}: empty beat (write "-" for a beat with nothing on it)`);
|
|
283
391
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const msg = JSON.stringify({
|
|
287
|
-
type: "state",
|
|
288
|
-
doc: state.doc,
|
|
289
|
-
version: state.version
|
|
290
|
-
});
|
|
291
|
-
for (const c of clients) {
|
|
292
|
-
if (c !== except && c.readyState === WebSocket.OPEN) c.send(msg);
|
|
392
|
+
if (slots.length > MAX_SUBDIVISION) {
|
|
393
|
+
throw new Error(`${where}: ${slots.length} slots exceeds the max of ${MAX_SUBDIVISION}`);
|
|
293
394
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
} catch {
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
if (msg.type === "set" && msg.doc) {
|
|
309
|
-
state.doc = normalize(msg.doc);
|
|
310
|
-
state.version += 1;
|
|
311
|
-
broadcastExcept(ws);
|
|
312
|
-
} else if (msg.type === "seed" && msg.doc) {
|
|
313
|
-
if (!state.doc) {
|
|
314
|
-
state.doc = normalize(msg.doc);
|
|
315
|
-
state.version += 1;
|
|
316
|
-
broadcastExcept(ws);
|
|
317
|
-
}
|
|
318
|
-
} else if (msg.type === "get") {
|
|
319
|
-
ws.send(
|
|
320
|
-
JSON.stringify({
|
|
321
|
-
type: "state",
|
|
322
|
-
doc: state.doc,
|
|
323
|
-
version: state.version
|
|
324
|
-
})
|
|
325
|
-
);
|
|
326
|
-
} else if (msg.type === "appstate") {
|
|
327
|
-
appView = msg.view === "editor" || msg.view === "library" ? msg.view : null;
|
|
328
|
-
appEditable = typeof msg.editable === "boolean" ? msg.editable : null;
|
|
329
|
-
ws._isApp = true;
|
|
330
|
-
const out = JSON.stringify({ type: "appstate", view: appView, editable: appEditable });
|
|
331
|
-
for (const c of clients) {
|
|
332
|
-
if (c !== ws && c.readyState === WebSocket.OPEN) c.send(out);
|
|
395
|
+
return slots.map((slot, i) => {
|
|
396
|
+
const at = `${where} slot ${i + 1}`;
|
|
397
|
+
const cells = /* @__PURE__ */ new Map();
|
|
398
|
+
let chord = null;
|
|
399
|
+
if (slot === "-") return { cells, chord };
|
|
400
|
+
for (const item of slot.split("+")) {
|
|
401
|
+
if (item.startsWith("@")) {
|
|
402
|
+
const parsed = parseChordInput(item.slice(1));
|
|
403
|
+
if (!parsed) throw new Error(`${at}: bad chord "${item}"`);
|
|
404
|
+
chord = parsed;
|
|
405
|
+
continue;
|
|
333
406
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const out = JSON.stringify(msg);
|
|
340
|
-
for (const c of clients) {
|
|
341
|
-
if (c !== ws && c.readyState === WebSocket.OPEN) c.send(out);
|
|
407
|
+
const m = /^(\d+):(.+)$/.exec(item);
|
|
408
|
+
if (!m) throw new Error(`${at}: bad item "${item}" (expected string:value or @Chord)`);
|
|
409
|
+
const string = Number(m[1]);
|
|
410
|
+
if (string < 1 || string > stringCount) {
|
|
411
|
+
throw new Error(`${at}: string ${string} out of range (1..${stringCount})`);
|
|
342
412
|
}
|
|
413
|
+
if (cells.has(string - 1)) throw new Error(`${at}: string ${string} written twice`);
|
|
414
|
+
cells.set(string - 1, parseValue(m[2], at));
|
|
343
415
|
}
|
|
416
|
+
return { cells, chord };
|
|
344
417
|
});
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
418
|
+
}
|
|
419
|
+
function buildMeasure(spec, stringCount, fallback, where = "measure") {
|
|
420
|
+
const time = spec.time ? parseTime(spec.time) : fallback;
|
|
421
|
+
if (spec.beats.length !== time.beats) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
`${where}: ${spec.beats.length} beats given but the time signature ${time.beats}/${time.beatUnit} has ${time.beats}`
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
const beats = spec.beats.map((b, i) => parseBeat(b, stringCount, `${where} beat ${i + 1}`));
|
|
427
|
+
const measure = createMeasure(
|
|
428
|
+
stringCount,
|
|
429
|
+
time.beats,
|
|
430
|
+
beats.map((slots) => slots.length),
|
|
431
|
+
time.beatUnit
|
|
432
|
+
);
|
|
433
|
+
const chords = measure.chords;
|
|
434
|
+
let col = 0;
|
|
435
|
+
for (const slots of beats) {
|
|
436
|
+
for (const slot of slots) {
|
|
437
|
+
for (const [s, cell] of slot.cells) measure.cells[s][col] = cell;
|
|
438
|
+
if (slot.chord) chords[col] = slot.chord;
|
|
439
|
+
col++;
|
|
354
440
|
}
|
|
355
|
-
};
|
|
356
|
-
ws.on("close", onGone);
|
|
357
|
-
ws.on("error", onGone);
|
|
358
|
-
});
|
|
359
|
-
wss.on("error", (e) => {
|
|
360
|
-
if (e.code === "EADDRINUSE") {
|
|
361
|
-
enterClientMode();
|
|
362
|
-
} else {
|
|
363
|
-
console.error("hub error:", e);
|
|
364
441
|
}
|
|
365
|
-
|
|
366
|
-
function enterClientMode() {
|
|
367
|
-
mode = "client";
|
|
368
|
-
console.error(`port ${PORT} in use; proxying to existing hub as a client`);
|
|
369
|
-
const connect = () => {
|
|
370
|
-
const ws = new WebSocket(`ws://127.0.0.1:${PORT}`);
|
|
371
|
-
upstream = ws;
|
|
372
|
-
ws.on("open", () => ws.send(JSON.stringify({ type: "get" })));
|
|
373
|
-
ws.on("message", (data) => {
|
|
374
|
-
try {
|
|
375
|
-
const msg = JSON.parse(data.toString());
|
|
376
|
-
if (msg.type === "state" && msg.doc) {
|
|
377
|
-
state.doc = normalize(msg.doc);
|
|
378
|
-
state.version = msg.version;
|
|
379
|
-
} else if (msg.type === "appstate") {
|
|
380
|
-
appView = msg.view === "editor" || msg.view === "library" ? msg.view : null;
|
|
381
|
-
appEditable = typeof msg.editable === "boolean" ? msg.editable : null;
|
|
382
|
-
} else if (msg.type === "response" && msg.reqId) {
|
|
383
|
-
settleResponse(msg.reqId, msg.data);
|
|
384
|
-
}
|
|
385
|
-
} catch {
|
|
386
|
-
}
|
|
387
|
-
});
|
|
388
|
-
ws.on("close", () => {
|
|
389
|
-
upstream = null;
|
|
390
|
-
setTimeout(connect, 2e3);
|
|
391
|
-
});
|
|
392
|
-
ws.on("error", () => ws.close());
|
|
393
|
-
};
|
|
394
|
-
connect();
|
|
442
|
+
return measure;
|
|
395
443
|
}
|
|
396
|
-
|
|
444
|
+
|
|
445
|
+
// prompts.ts
|
|
446
|
+
var IMPORT_TAB_FROM_IMAGE = `Transcribe the TAB in the attached image(s) into an EZTab document with the eztab tools.
|
|
447
|
+
|
|
448
|
+
## 1. Survey before writing
|
|
449
|
+
- Identify which staff to take. A band score has several parts (Vocal, Guitar I/II, Keyboard, Bass, Drums). If a part was not named and more than one has TAB, list the parts you see and ask which one \u2014 do not guess.
|
|
450
|
+
- From the image work out: instrument and string count (6 lines = guitar, 4 = bass), tuning if printed (assume standard otherwise), time signature, key signature, tempo mark, and how many bars you will transcribe. Printed bar numbers (e.g. 35, 36 \u2026) are just labels \u2014 the document starts at measure 0.
|
|
451
|
+
- Start with new_tab (preset "Guitar (6)" / "Bass (4)" / \u2026, measures: 1, title if known). If the user is adding to a document already open in this session, append to it instead.
|
|
452
|
+
- Set the key with set_key when there is a key signature (1 flat \u2192 F major, or D minor if the harmony says so), and the tempo with set_tempo when one is printed.
|
|
453
|
+
|
|
454
|
+
## 2. Write bar by bar with write_measures
|
|
455
|
+
Read each bar's rhythm first (beams, flags, dots, tuplet brackets, rests), then the fret numbers, then write it. Send a few bars per call, in order. In a fresh document the first bar goes to measure 0 (replacing the empty measure new_tab made); after that append at the current measure count.
|
|
456
|
+
- Subdivision per beat = the fewest slots that put every onset of the beat on a slot: quarter \u2192 1, eighths \u2192 2, 16ths or dotted-8th + 16th \u2192 4, triplet \u2192 3. Only onsets are stored; a held note is just empty slots ("-") after it.
|
|
457
|
+
- Use the TAB numbers for frets, and the staff notation (when present) to settle rhythm and ambiguous digits. String 1 is the TOP line of the TAB.
|
|
458
|
+
- Chord names printed above the staff go on the chord row with @Name at the slot where the chord changes (usually beat 1).
|
|
459
|
+
- Rests: write S:r on the string that was ringing only when the line clearly stops (a rest symbol); otherwise leave "-".
|
|
460
|
+
|
|
461
|
+
## 3. Map notation EZTab cannot store exactly
|
|
462
|
+
- Hammer-on (H), pull-off (P), slide into a note (S, gliss line from a fretted note): mark the destination note tied, S:~F.
|
|
463
|
+
- A tie arc to the same fret, or a parenthesised fret "(6)" that continues a note: S:~F with the same fret.
|
|
464
|
+
- Choke / bend (C, Cho., "full", "1/2", up arrow): S:FB for full, S:Fb for \xBD, S:F^ for 1\xBD. A release / down choke: write the returning note tied.
|
|
465
|
+
- Bridge mute (M, P.M. bracket in Japanese band scores = \u30DF\u30E5\u30FC\u30C8): add p to every note under the bracket.
|
|
466
|
+
- Dead notes / brushing (x): S:x.
|
|
467
|
+
- Natural/artificial harmonics (o, Harm., <7>): plain fret. Vibrato, slide-in from nowhere (/6), slide-out, arpeggio wavy line, grace notes: write the main note only.
|
|
468
|
+
- Chord diagrams / chord-form boxes drawn inside the TAB staff (no fret numbers): write the chord name on the chord row. Write the voicing as notes only if the user asks or the diagram is unambiguous.
|
|
469
|
+
- Repeats, D.S., Coda, 1st/2nd endings: transcribe the bars as printed and tell the user; do not expand them unless asked.
|
|
470
|
+
|
|
471
|
+
## 4. Check, then report
|
|
472
|
+
- After writing, call get_tab with from/to for the bars you wrote and compare them bar by bar against the image: beat count, slot positions, string lines and fret numbers. Fix anything off with write_measures (rewrite the bar).
|
|
473
|
+
- Then call send_to_eztab with a label naming the part and bars (e.g. "Guitar II, bars 35\u201343"); the user applies it in the app. If it fails because no token is configured, tell the user how to set it up (the error says how) \u2014 the document stays in this session.
|
|
474
|
+
- Finish with a short report: what was transcribed (part, bars), the notation you had to simplify (from section 3), and any digits or rhythms you were unsure of, by measure index.`;
|
|
475
|
+
|
|
476
|
+
// server.ts
|
|
477
|
+
var state = {
|
|
478
|
+
doc: null,
|
|
479
|
+
version: 0,
|
|
480
|
+
source: null
|
|
481
|
+
};
|
|
482
|
+
var server = new McpServer(
|
|
483
|
+
{ name: "eztab", version: "0.2.0" },
|
|
484
|
+
{
|
|
485
|
+
instructions: "All indices are 0-based: measure 0 is the first measure, string 0 is the top (highest) string, and column 0 is the first column. Notes and chords share the same column grid. Subdivisions are per beat and can differ within a measure (e.g. [2,2,3,2] mixes a triplet beat into straight eighths), so columns per measure = the sum of its per-beat subdivisions; get_tab's ruler shows the column indices. add_measures `at` is a 0-based insert position too. No need to ask the user about indexing. Exception: write_measures uses guitar string numbers (1 = top line), matching the labels get_tab prints. To write whole measures (e.g. transcribing a score or an image), prefer write_measures over set_cells \u2014 it takes one string per beat and does the column arithmetic for you. For transcribing from an image, follow the import_tab_from_image prompt. To deliver a result to the EZTab app, call send_to_eztab; to fix an existing tab, list_tabs \u2192 open_tab \u2192 edit \u2192 send_to_eztab."
|
|
486
|
+
}
|
|
487
|
+
);
|
|
397
488
|
function text(s) {
|
|
398
489
|
return { content: [{ type: "text", text: s }] };
|
|
399
490
|
}
|
|
400
491
|
function requireDoc() {
|
|
401
492
|
if (!state.doc) {
|
|
402
|
-
throw new Error(
|
|
403
|
-
"No document is open. Open the EZTab app (it will connect and share its tab), or call new_tab first."
|
|
404
|
-
);
|
|
493
|
+
throw new Error("No document is open. Call new_tab first.");
|
|
405
494
|
}
|
|
406
495
|
return state.doc;
|
|
407
496
|
}
|
|
408
|
-
function assertEditable() {
|
|
409
|
-
if (appView === "library") {
|
|
410
|
-
throw new Error(
|
|
411
|
-
"The EZTab app is on the library screen \u2014 no tab is open to edit. Ask the user to open a tab in the app and switch it to Edit mode, then retry."
|
|
412
|
-
);
|
|
413
|
-
}
|
|
414
|
-
if (appEditable === false) {
|
|
415
|
-
throw new Error(
|
|
416
|
-
"The open tab is in View mode, so it can't be changed. Ask the user to switch the tab to Edit mode in the app, then retry. (Reading is always allowed.)"
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
497
|
function applyEdit(mutate) {
|
|
421
|
-
assertEditable();
|
|
422
498
|
const doc = requireDoc();
|
|
423
499
|
mutate(doc);
|
|
424
500
|
state.version += 1;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
501
|
+
return text(render(doc));
|
|
502
|
+
}
|
|
503
|
+
function applyEditRange(mutate) {
|
|
504
|
+
const doc = structuredClone(requireDoc());
|
|
505
|
+
const [from, to] = mutate(doc);
|
|
506
|
+
state.doc = doc;
|
|
507
|
+
state.version += 1;
|
|
508
|
+
return text(render(doc, from, to));
|
|
432
509
|
}
|
|
433
510
|
function setDoc(doc) {
|
|
434
|
-
assertEditable();
|
|
435
511
|
state.doc = doc;
|
|
436
512
|
state.version += 1;
|
|
437
|
-
pushState();
|
|
438
513
|
return text(render(doc));
|
|
439
514
|
}
|
|
440
515
|
function assertMeasure(doc, m) {
|
|
@@ -444,67 +519,19 @@ function assertMeasure(doc, m) {
|
|
|
444
519
|
}
|
|
445
520
|
server.tool(
|
|
446
521
|
"get_tab",
|
|
447
|
-
"Return a readable rendering of the
|
|
448
|
-
{
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
"The EZTab app is open on the library screen \u2014 no tab is selected, so there's nothing to show. Ask the user to open a tab in the app."
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
if (appView === "editor" && state.doc) {
|
|
456
|
-
return text(render(state.doc));
|
|
457
|
-
}
|
|
458
|
-
if (state.doc) {
|
|
459
|
-
return text(render(state.doc));
|
|
460
|
-
}
|
|
461
|
-
return text(
|
|
462
|
-
"No tab is open. Ask the user to open the EZTab app and open a tab (it will be shared here automatically), or call new_tab to start a fresh one."
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
);
|
|
466
|
-
server.tool(
|
|
467
|
-
"list_tabs",
|
|
468
|
-
"List the tabs in the user's EZTab library (id, title, artist, measures, tempo). Read-only. Requires the app to be open with Live enabled.",
|
|
469
|
-
{},
|
|
470
|
-
async () => {
|
|
471
|
-
try {
|
|
472
|
-
const tabs = await requestFromApp("list_tabs");
|
|
473
|
-
return text(JSON.stringify(tabs, null, 2));
|
|
474
|
-
} catch (e) {
|
|
475
|
-
return text(String(e?.message ?? e));
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
);
|
|
479
|
-
server.tool(
|
|
480
|
-
"list_playlists",
|
|
481
|
-
"List the user's playlists (id, name, song count). Read-only. Requires the app to be open with Live enabled.",
|
|
482
|
-
{},
|
|
483
|
-
async () => {
|
|
484
|
-
try {
|
|
485
|
-
const pls = await requestFromApp("list_playlists");
|
|
486
|
-
return text(JSON.stringify(pls, null, 2));
|
|
487
|
-
} catch (e) {
|
|
488
|
-
return text(String(e?.message ?? e));
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
);
|
|
492
|
-
server.tool(
|
|
493
|
-
"get_playlist",
|
|
494
|
-
"Show a playlist's songs in order (resolved to titles). Provide its `id` or `name` (use list_playlists first). Read-only. Requires the app open with Live enabled.",
|
|
495
|
-
{ id: z.string().optional(), name: z.string().optional() },
|
|
522
|
+
"Return a readable rendering of the document currently under edit in this session. `from`/`to` (0-based, inclusive) limit it to a range of measures. If nothing has been created yet, say so and offer new_tab.",
|
|
523
|
+
{
|
|
524
|
+
from: z.number().int().min(0).optional(),
|
|
525
|
+
to: z.number().int().min(0).optional()
|
|
526
|
+
},
|
|
496
527
|
async (a) => {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
return text(JSON.stringify(pl, null, 2));
|
|
500
|
-
} catch (e) {
|
|
501
|
-
return text(String(e?.message ?? e));
|
|
502
|
-
}
|
|
528
|
+
if (state.doc) return text(render(state.doc, a.from, a.to));
|
|
529
|
+
return text("No document is open in this session. Call new_tab to start one.");
|
|
503
530
|
}
|
|
504
531
|
);
|
|
505
532
|
server.tool(
|
|
506
533
|
"new_tab",
|
|
507
|
-
"
|
|
534
|
+
"Start a fresh document, replacing any current one. Tuning is top (highest) to bottom (lowest).",
|
|
508
535
|
{
|
|
509
536
|
title: z.string().optional(),
|
|
510
537
|
artist: z.string().optional(),
|
|
@@ -519,9 +546,70 @@ server.tool(
|
|
|
519
546
|
if (a.title) doc.title = a.title;
|
|
520
547
|
if (a.artist) doc.artist = a.artist;
|
|
521
548
|
if (a.tempo) doc.tempo = a.tempo;
|
|
549
|
+
state.source = null;
|
|
522
550
|
return setDoc(doc);
|
|
523
551
|
}
|
|
524
552
|
);
|
|
553
|
+
function formatTime(ms) {
|
|
554
|
+
return ms ? new Date(ms).toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }) : "?";
|
|
555
|
+
}
|
|
556
|
+
async function withCloud(fn) {
|
|
557
|
+
try {
|
|
558
|
+
return await fn();
|
|
559
|
+
} catch (e) {
|
|
560
|
+
if (e instanceof CloudError) throw new Error(e.message);
|
|
561
|
+
throw e;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
server.tool(
|
|
565
|
+
"list_tabs",
|
|
566
|
+
"List the tabs in the user's EZTab library (id, title, artist, measures, last update). Tabs open for editing in the app are marked with the device. Use the id with open_tab.",
|
|
567
|
+
{ query: z.string().optional().describe("Filter by title/artist (case-insensitive).") },
|
|
568
|
+
async (a) => withCloud(async () => {
|
|
569
|
+
const q = a.query?.toLowerCase() ?? "";
|
|
570
|
+
const tabs = (await cloud.list()).filter((t) => !q || `${t.title} ${t.artist}`.toLowerCase().includes(q)).sort((x, y) => y.updatedAt - x.updatedAt);
|
|
571
|
+
if (!tabs.length) return text(q ? `No tabs match "${a.query}".` : "The library is empty.");
|
|
572
|
+
return text(
|
|
573
|
+
tabs.map(
|
|
574
|
+
(t) => `${t.id} ${t.title || "(untitled)"}${t.artist ? ` \u2014 ${t.artist}` : ""} [${t.measures} measures, updated ${formatTime(t.updatedAt)}]` + (t.lock ? ` \u2190 editing on ${t.lock.deviceName || "another device"}` : "")
|
|
575
|
+
).join("\n")
|
|
576
|
+
);
|
|
577
|
+
})
|
|
578
|
+
);
|
|
579
|
+
server.tool(
|
|
580
|
+
"open_tab",
|
|
581
|
+
"Load a tab from the user's EZTab library into this session (replacing the current document) so it can be read and edited with the other tools. send_to_eztab then delivers it back to the app as a revision of that tab.",
|
|
582
|
+
{ id: z.string().describe("Tab id from list_tabs.") },
|
|
583
|
+
async (a) => withCloud(async () => {
|
|
584
|
+
const meta = (await cloud.list()).find((t) => t.id === a.id);
|
|
585
|
+
if (!meta) throw new Error(`No tab with id ${a.id}. Use list_tabs to find it.`);
|
|
586
|
+
const doc = normalize(await cloud.get(a.id));
|
|
587
|
+
state.source = { tabId: a.id, title: meta.title, baseUpdatedAt: meta.updatedAt };
|
|
588
|
+
return setDoc(doc);
|
|
589
|
+
})
|
|
590
|
+
);
|
|
591
|
+
server.tool(
|
|
592
|
+
"send_to_eztab",
|
|
593
|
+
"Send the current document to the EZTab app's inbox. The user then chooses in the app what to do with it \u2014 insert its measures at the cursor, create a new tab, or (for a document opened with open_tab) replace that tab \u2014 and it stays unsaved until they press Save. Call this once the document has been checked with get_tab.",
|
|
594
|
+
{
|
|
595
|
+
label: z.string().optional().describe("Short description shown in the app, e.g. 'Guitar II, bars 1\u20138'."),
|
|
596
|
+
as_new: z.boolean().optional().describe(
|
|
597
|
+
"For a document opened with open_tab: send it as new material instead of a revision of that tab."
|
|
598
|
+
)
|
|
599
|
+
},
|
|
600
|
+
async (a) => withCloud(async () => {
|
|
601
|
+
const doc = requireDoc();
|
|
602
|
+
const target = state.source && !a.as_new ? state.source : null;
|
|
603
|
+
await cloud.sendToInbox({
|
|
604
|
+
doc,
|
|
605
|
+
label: a.label ?? "",
|
|
606
|
+
target: target ? { tabId: target.tabId, baseUpdatedAt: target.baseUpdatedAt } : null
|
|
607
|
+
});
|
|
608
|
+
return text(
|
|
609
|
+
target ? `Sent to the EZTab inbox as a revision of "${target.title}". The user applies it in the app and saves.` : `Sent ${doc.measures.length} measure(s) to the EZTab inbox. The user can insert them at the cursor or open them as a new tab.`
|
|
610
|
+
);
|
|
611
|
+
})
|
|
612
|
+
);
|
|
525
613
|
server.tool(
|
|
526
614
|
"set_title",
|
|
527
615
|
"Set the document title.",
|
|
@@ -540,19 +628,17 @@ server.tool(
|
|
|
540
628
|
);
|
|
541
629
|
server.tool(
|
|
542
630
|
"set_cells",
|
|
543
|
-
"Set tab cells. value: a fret number (0-24), 'rest' (stops a sustaining note), 'mute' (a muted/dead 'x' stroke for cutting), or 'clear' (empty = previous note sustains through). Optional `bend` (0.5, 1, or 1.5 semitones) chokes a fret-number note. Optional `tie` (boolean) ties/slurs a fret-number note to the previous note on the same string \u2014 a matching pitch just sustains (no re-pick), a different pitch is a slur. string/column are 0-based
|
|
631
|
+
"Set tab cells. value: a fret number (0-24), 'rest' (stops a sustaining note), 'mute' (a muted/dead 'x' stroke for cutting), or 'clear' (empty = previous note sustains through). Optional `bend` (0.5, 1, or 1.5 semitones) chokes a fret-number note. Optional `tie` (boolean) ties/slurs a fret-number note to the previous note on the same string \u2014 a matching pitch just sustains (no re-pick), a different pitch is a slur. Optional `pm` (boolean) palm-mutes (bridge-mutes) a fret-number note. measure/string/column are all 0-based (measure 0 = the first measure, string 0 = the top line).",
|
|
544
632
|
{
|
|
545
633
|
edits: z.array(
|
|
546
634
|
z.object({
|
|
547
635
|
measure: z.number().int(),
|
|
548
636
|
string: z.number().int(),
|
|
549
637
|
column: z.number().int(),
|
|
550
|
-
value: z.union([
|
|
551
|
-
z.number().int().min(0).max(24),
|
|
552
|
-
z.enum(["rest", "mute", "clear"])
|
|
553
|
-
]),
|
|
638
|
+
value: z.union([z.number().int().min(0).max(24), z.enum(["rest", "mute", "clear"])]),
|
|
554
639
|
bend: z.union([z.literal(0.5), z.literal(1), z.literal(1.5)]).optional(),
|
|
555
|
-
tie: z.boolean().optional()
|
|
640
|
+
tie: z.boolean().optional(),
|
|
641
|
+
pm: z.boolean().optional()
|
|
556
642
|
})
|
|
557
643
|
)
|
|
558
644
|
},
|
|
@@ -561,9 +647,7 @@ server.tool(
|
|
|
561
647
|
assertMeasure(doc, e.measure);
|
|
562
648
|
const m = doc.measures[e.measure];
|
|
563
649
|
if (e.string < 0 || e.string >= doc.tuning.length) {
|
|
564
|
-
throw new Error(
|
|
565
|
-
`string ${e.string} out of range (0..${doc.tuning.length - 1})`
|
|
566
|
-
);
|
|
650
|
+
throw new Error(`string ${e.string} out of range (0..${doc.tuning.length - 1})`);
|
|
567
651
|
}
|
|
568
652
|
const cols = columnCount(m);
|
|
569
653
|
if (e.column < 0 || e.column >= cols) {
|
|
@@ -572,25 +656,64 @@ server.tool(
|
|
|
572
656
|
);
|
|
573
657
|
}
|
|
574
658
|
let value = e.value === "clear" ? null : e.value;
|
|
575
|
-
if (typeof e.value === "number" && (e.bend != null || e.tie)) {
|
|
659
|
+
if (typeof e.value === "number" && (e.bend != null || e.tie || e.pm)) {
|
|
576
660
|
value = {
|
|
577
661
|
fret: e.value,
|
|
578
662
|
...e.bend != null ? { bend: e.bend } : {},
|
|
579
|
-
...e.tie ? { tie: true } : {}
|
|
663
|
+
...e.tie ? { tie: true } : {},
|
|
664
|
+
...e.pm ? { pm: true } : {}
|
|
580
665
|
};
|
|
581
666
|
}
|
|
582
667
|
m.cells[e.string][e.column] = value;
|
|
583
668
|
}
|
|
584
669
|
})
|
|
585
670
|
);
|
|
671
|
+
server.tool(
|
|
672
|
+
"write_measures",
|
|
673
|
+
'Write whole measures, one text string per beat \u2014 the easiest way to transcribe a score. Each listed measure is replaced entirely (notes, chords, per-beat subdivisions). `measure` is the 0-based index; using the current measure count appends, so a transcription can be written in order without add_measures. `time` (e.g. "4/4", "6/8") is optional: it defaults to the measure being replaced, or the previous measure when appending. `beats` has exactly one string per beat of the time signature.\n\nA beat string is space-separated slots; the slot count is that beat\'s subdivision (1 = quarter, 2 = eighths, 3 = eighth triplet, 4 = sixteenths, 6 = sixteenth triplet \u2026). Pick the smallest count that puts every onset of the beat on a slot (a dotted eighth + sixteenth = 4 slots: note - - note). A slot is "-" (nothing starts there; the previous note rings on) or items joined by "+":\n S:F string S (1 = top line of the TAB, as printed) at fret F\n S:~F tied / slurred from the previous note on that string (hammer-on, pull-off, slide into F, or a tie when F is the same fret)\n S:Fb / S:FB / S:F^ choke (bend) \xBD / 1 / 1\xBD steps\n S:Fp palm (bridge) mute; combines, e.g. S:~5p, S:7Bp\n S:x muted dead-note stroke (brushing)\n S:r rest: stops the note ringing on that string\n @Name chord name onset for the chord row (e.g. @Am7, @N.C.)\nExamples: ["6:5 6:7", "5:5 5:7", "4:5 - 4:7", "@F+4:3+3:2+2:1+1:1"].',
|
|
674
|
+
{
|
|
675
|
+
measures: z.array(
|
|
676
|
+
z.object({
|
|
677
|
+
measure: z.number().int().min(0),
|
|
678
|
+
time: z.string().optional(),
|
|
679
|
+
beats: z.array(z.string()).min(1)
|
|
680
|
+
})
|
|
681
|
+
).min(1)
|
|
682
|
+
},
|
|
683
|
+
async (a) => applyEditRange((doc) => {
|
|
684
|
+
let lo = Infinity;
|
|
685
|
+
let hi = -1;
|
|
686
|
+
for (const spec of a.measures) {
|
|
687
|
+
const i = spec.measure;
|
|
688
|
+
if (i > doc.measures.length) {
|
|
689
|
+
throw new Error(
|
|
690
|
+
`measure ${i} would leave a gap: there are ${doc.measures.length} measures, so append at ${doc.measures.length}`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
const ref = doc.measures[i] ?? doc.measures[i - 1];
|
|
694
|
+
const fallback = { beats: ref?.beats ?? 4, beatUnit: ref?.beatUnit ?? 4 };
|
|
695
|
+
const built = buildMeasure(
|
|
696
|
+
spec,
|
|
697
|
+
doc.tuning.length,
|
|
698
|
+
fallback,
|
|
699
|
+
`measure ${i}`
|
|
700
|
+
);
|
|
701
|
+
if (i === doc.measures.length) doc.measures.push(built);
|
|
702
|
+
else doc.measures[i] = built;
|
|
703
|
+
lo = Math.min(lo, i);
|
|
704
|
+
hi = Math.max(hi, i);
|
|
705
|
+
}
|
|
706
|
+
return [lo, hi];
|
|
707
|
+
})
|
|
708
|
+
);
|
|
586
709
|
server.tool(
|
|
587
710
|
"set_chords",
|
|
588
|
-
|
|
711
|
+
'Set chord-name onsets per column (the same time grid as tab cells, so chords can sit on any subdivision). `measure` and `column` are 0-based; column = the sum of earlier beats\' subdivisions + slot-within-beat (see get_tab\'s ruler). A label of "rest" (or "N.C.") places a chord rest that stops the previous chord (rendered as N.C.). An empty label clears that column\'s chord.',
|
|
589
712
|
{
|
|
590
713
|
edits: z.array(
|
|
591
714
|
z.object({
|
|
592
715
|
measure: z.number().int(),
|
|
593
|
-
|
|
716
|
+
column: z.number().int(),
|
|
594
717
|
label: z.string()
|
|
595
718
|
})
|
|
596
719
|
)
|
|
@@ -599,13 +722,15 @@ server.tool(
|
|
|
599
722
|
for (const e of a.edits) {
|
|
600
723
|
assertMeasure(doc, e.measure);
|
|
601
724
|
const m = doc.measures[e.measure];
|
|
602
|
-
|
|
725
|
+
const cols = columnCount(m);
|
|
726
|
+
if (e.column < 0 || e.column >= cols) {
|
|
603
727
|
throw new Error(
|
|
604
|
-
`
|
|
728
|
+
`column ${e.column} out of range for measure ${e.measure} (0..${cols - 1})`
|
|
605
729
|
);
|
|
606
730
|
}
|
|
607
|
-
if (!m.chords) m.chords = Array.from({ length:
|
|
608
|
-
|
|
731
|
+
if (!m.chords) m.chords = Array.from({ length: cols }, () => null);
|
|
732
|
+
const label = e.label.trim();
|
|
733
|
+
m.chords[e.column] = /^(rest|n\.?c\.?)$/i.test(label) ? CHORD_REST : label || null;
|
|
609
734
|
}
|
|
610
735
|
})
|
|
611
736
|
);
|
|
@@ -681,14 +806,25 @@ server.tool(
|
|
|
681
806
|
);
|
|
682
807
|
server.tool(
|
|
683
808
|
"set_resolution",
|
|
684
|
-
"Set
|
|
685
|
-
{
|
|
809
|
+
"Set subdivisions per beat (1=quarter, 2=eighth, 4=sixteenth, 3=eighth triplet, 6=sixteenth triplet, 5=sixteenth quintuplet, 10=32nd quintuplet). Without `beat`, applies to every beat of the measure; with `beat` (0-based) only that beat changes, so straight and tuplet beats can mix in one measure. Existing notes are remapped.",
|
|
810
|
+
{
|
|
811
|
+
measure: z.number().int(),
|
|
812
|
+
subdivisions: z.number().int().min(1).max(12),
|
|
813
|
+
beat: z.number().int().min(0).optional()
|
|
814
|
+
},
|
|
686
815
|
async (a) => applyEdit((doc) => {
|
|
687
816
|
assertMeasure(doc, a.measure);
|
|
688
|
-
doc.measures[a.measure]
|
|
689
|
-
|
|
690
|
-
a.
|
|
691
|
-
|
|
817
|
+
const m = doc.measures[a.measure];
|
|
818
|
+
if (a.beat != null) {
|
|
819
|
+
if (a.beat >= m.beats) {
|
|
820
|
+
throw new Error(
|
|
821
|
+
`beat ${a.beat} out of range for measure ${a.measure} (0..${m.beats - 1})`
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
doc.measures[a.measure] = setBeatSubdivision(m, a.beat, a.subdivisions);
|
|
825
|
+
} else {
|
|
826
|
+
doc.measures[a.measure] = setMeasureSubdivisions(m, a.subdivisions);
|
|
827
|
+
}
|
|
692
828
|
})
|
|
693
829
|
);
|
|
694
830
|
server.tool(
|
|
@@ -726,11 +862,32 @@ server.tool(
|
|
|
726
862
|
{ tuning: z.array(z.string()).min(1) },
|
|
727
863
|
async (a) => applyEdit((doc) => {
|
|
728
864
|
doc.tuning = [...a.tuning];
|
|
729
|
-
doc.measures = doc.measures.map(
|
|
730
|
-
|
|
731
|
-
|
|
865
|
+
doc.measures = doc.measures.map((m) => resizeMeasureStrings(m, a.tuning.length));
|
|
866
|
+
})
|
|
867
|
+
);
|
|
868
|
+
server.prompt(
|
|
869
|
+
"import_tab_from_image",
|
|
870
|
+
"Transcribe a TAB score from attached image(s) into an EZTab document.",
|
|
871
|
+
{
|
|
872
|
+
part: z.string().optional().describe("Which part to take from a band score, e.g. 'Guitar II' or 'Bass'."),
|
|
873
|
+
notes: z.string().optional().describe("Anything else to know (tuning, which bars, \u2026).")
|
|
874
|
+
},
|
|
875
|
+
(a) => ({
|
|
876
|
+
messages: [
|
|
877
|
+
{
|
|
878
|
+
role: "user",
|
|
879
|
+
content: {
|
|
880
|
+
type: "text",
|
|
881
|
+
text: IMPORT_TAB_FROM_IMAGE + (a.part ? `
|
|
882
|
+
|
|
883
|
+
Part to transcribe: ${a.part}` : "") + (a.notes ? `
|
|
884
|
+
|
|
885
|
+
Notes from the user: ${a.notes}` : "")
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
]
|
|
732
889
|
})
|
|
733
890
|
);
|
|
734
891
|
var transport = new StdioServerTransport();
|
|
735
892
|
await server.connect(transport);
|
|
736
|
-
console.error(
|
|
893
|
+
console.error("eztab MCP server running (stdio)");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hidechae/eztab-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for EZTab
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server for EZTab \u2014 lets an AI agent build and edit guitar/bass tablature.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"eztab-mcp": "dist/server.js"
|
|
@@ -31,11 +31,10 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
34
|
-
"ws": "^8.21.0",
|
|
35
34
|
"zod": "^4.4.3"
|
|
36
35
|
},
|
|
37
36
|
"devDependencies": {
|
|
38
37
|
"tsup": "^8.3.0",
|
|
39
|
-
"typescript": "^
|
|
38
|
+
"typescript": "^7.0.0"
|
|
40
39
|
}
|
|
41
40
|
}
|