@hidechae/eztab-mcp 0.1.2 → 0.3.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 +75 -41
- package/dist/server.js +938 -382
- package/package.json +3 -4
package/dist/server.js
CHANGED
|
@@ -4,16 +4,17 @@
|
|
|
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;
|
|
10
|
+
var SUPPORTED_FORMAT_REVISION = 2;
|
|
11
|
+
var MULTI_TRACK_FORMAT_REVISION = 2;
|
|
12
|
+
var MAX_TRACKS = 4;
|
|
13
|
+
var DEFAULT_MAIN_TRACK_NAME = "Guitar";
|
|
11
14
|
var DEFAULT_TEMPO = 120;
|
|
12
15
|
var DEFAULT_MIX = { melody: 1, backing: 0.4 };
|
|
13
16
|
function shiftSectionsForInsert(sections, index) {
|
|
14
|
-
return sections.map(
|
|
15
|
-
(s) => s.measure >= index ? { ...s, measure: s.measure + 1 } : s
|
|
16
|
-
);
|
|
17
|
+
return sections.map((s) => s.measure >= index ? { ...s, measure: s.measure + 1 } : s);
|
|
17
18
|
}
|
|
18
19
|
function shiftSectionsForDelete(sections, index) {
|
|
19
20
|
return sections.filter((s) => s.measure !== index).map((s) => s.measure > index ? { ...s, measure: s.measure - 1 } : s);
|
|
@@ -27,9 +28,7 @@ function activeKeyAt(keys, measure) {
|
|
|
27
28
|
return result;
|
|
28
29
|
}
|
|
29
30
|
function shiftKeysForInsert(keys, index) {
|
|
30
|
-
const shifted = keys.map(
|
|
31
|
-
(k) => k.measure >= index ? { ...k, measure: k.measure + 1 } : k
|
|
32
|
-
);
|
|
31
|
+
const shifted = keys.map((k) => k.measure >= index ? { ...k, measure: k.measure + 1 } : k);
|
|
33
32
|
if (!shifted.some((k) => k.measure === 0)) {
|
|
34
33
|
shifted.unshift({ measure: 0, tonic: keys[0].tonic, mode: keys[0].mode });
|
|
35
34
|
}
|
|
@@ -42,40 +41,196 @@ function shiftKeysForDelete(keys, index) {
|
|
|
42
41
|
}
|
|
43
42
|
return next;
|
|
44
43
|
}
|
|
44
|
+
function cellPalmMute(c) {
|
|
45
|
+
return typeof c === "object" && c !== null && !!c.pm;
|
|
46
|
+
}
|
|
47
|
+
var CHORD_REST = "rest";
|
|
48
|
+
var CHORD_REST_LABEL = "N.C.";
|
|
49
|
+
function chordDisplay(label) {
|
|
50
|
+
if (!label) return "";
|
|
51
|
+
return label === CHORD_REST ? CHORD_REST_LABEL : label;
|
|
52
|
+
}
|
|
53
|
+
function parseChordInput(raw) {
|
|
54
|
+
if (raw === "") return "";
|
|
55
|
+
if (/^(r|n\.?c?\.?)$/i.test(raw)) return CHORD_REST;
|
|
56
|
+
const v = raw.charAt(0).toUpperCase() + raw.slice(1);
|
|
57
|
+
return /^[A-G]/.test(v) ? v : null;
|
|
58
|
+
}
|
|
45
59
|
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(
|
|
60
|
+
const subdivisions = Array.from(
|
|
51
61
|
{ length: beats },
|
|
52
|
-
(_, b) => measure.
|
|
62
|
+
(_, b) => measure.subdivisions[Math.min(b, measure.subdivisions.length - 1)] ?? DEFAULT_SUBDIVISIONS
|
|
53
63
|
);
|
|
54
|
-
|
|
64
|
+
const cols = subdivisions.reduce((a, b) => a + b, 0);
|
|
65
|
+
const cells = measure.cells.map((row) => Array.from({ length: cols }, (_, c) => row[c] ?? null));
|
|
66
|
+
const chords = Array.from({ length: cols }, (_, c) => measure.chords?.[c] ?? null);
|
|
67
|
+
return { ...measure, beats, beatUnit, subdivisions, cells, chords };
|
|
55
68
|
}
|
|
56
69
|
function columnCount(measure) {
|
|
57
|
-
|
|
70
|
+
let sum = 0;
|
|
71
|
+
for (const s of measure.subdivisions) sum += s;
|
|
72
|
+
return sum;
|
|
58
73
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
function uniformSubdivision(measure) {
|
|
75
|
+
const first = measure.subdivisions[0];
|
|
76
|
+
return measure.subdivisions.every((s) => s === first) ? first : null;
|
|
77
|
+
}
|
|
78
|
+
function normalizeChords(raw, subdivisions) {
|
|
79
|
+
const beats = subdivisions.length;
|
|
80
|
+
const cols = subdivisions.reduce((a, b) => a + b, 0);
|
|
81
|
+
const src = Array.isArray(raw) ? raw : [];
|
|
82
|
+
const out = Array.from({ length: cols }, () => null);
|
|
83
|
+
if (src.length > beats) {
|
|
84
|
+
for (let c = 0; c < Math.min(cols, src.length); c++) {
|
|
85
|
+
out[c] = src[c] || null;
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
let col = 0;
|
|
89
|
+
for (let b = 0; b < beats; b++) {
|
|
90
|
+
if (b < src.length) out[col] = src[b] || null;
|
|
91
|
+
col += subdivisions[b];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
function normalizeDocument(data) {
|
|
97
|
+
if (!data || !Array.isArray(data.tuning) || !Array.isArray(data.measures)) {
|
|
98
|
+
throw new Error("Not a valid EZTab document");
|
|
99
|
+
}
|
|
100
|
+
data.measures = data.measures.map((m) => {
|
|
101
|
+
let beats = m.beats;
|
|
102
|
+
let rawSubs = m.subdivisions;
|
|
103
|
+
const subsValid = typeof rawSubs === "number" || Array.isArray(rawSubs) && rawSubs.length > 0;
|
|
104
|
+
if (!Number.isInteger(beats) || beats < 1 || beats > MAX_BEATS || !subsValid) {
|
|
105
|
+
const cols2 = m.cells?.[0]?.length ?? m.columns ?? 8;
|
|
106
|
+
const sub = cols2 % 4 === 0 ? cols2 / 4 : 2;
|
|
107
|
+
beats = cols2 % 4 === 0 ? 4 : Math.max(1, Math.round(cols2 / sub));
|
|
108
|
+
rawSubs = sub;
|
|
109
|
+
}
|
|
110
|
+
const rawUnit = m.beatUnit;
|
|
111
|
+
const beatUnit = BEAT_UNITS.includes(rawUnit) ? rawUnit : DEFAULT_BEAT_UNIT;
|
|
112
|
+
const subdivisions = Array.from({ length: beats }, (_, b) => {
|
|
113
|
+
const v = Array.isArray(rawSubs) ? rawSubs[Math.min(b, rawSubs.length - 1)] : rawSubs;
|
|
114
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= MAX_SUBDIVISION ? v : DEFAULT_SUBDIVISIONS;
|
|
115
|
+
});
|
|
116
|
+
const cols = subdivisions.reduce((a, b) => a + b, 0);
|
|
117
|
+
const cells = (m.cells ?? []).map(
|
|
118
|
+
(row) => Array.from({ length: cols }, (_, c) => row?.[c] ?? null)
|
|
119
|
+
);
|
|
120
|
+
const rawChords = Array.isArray(m.chords) ? m.chords : typeof m.chord === "string" && m.chord ? [m.chord] : [];
|
|
121
|
+
const chords = normalizeChords(rawChords, subdivisions);
|
|
122
|
+
return { beats, beatUnit, subdivisions, cells, chords };
|
|
123
|
+
});
|
|
124
|
+
if (!Array.isArray(data.keys) || data.keys.length === 0) {
|
|
125
|
+
data.keys = [{ measure: 0, tonic: "C", mode: "major" }];
|
|
126
|
+
}
|
|
127
|
+
if (typeof data.artist !== "string") data.artist = "";
|
|
128
|
+
if (!Array.isArray(data.sections)) data.sections = [];
|
|
129
|
+
if (typeof data.tempo !== "number") data.tempo = DEFAULT_TEMPO;
|
|
130
|
+
if (!data.mix || typeof data.mix.melody !== "number") {
|
|
131
|
+
data.mix = { ...DEFAULT_MIX };
|
|
132
|
+
}
|
|
133
|
+
normalizeTracks(data);
|
|
134
|
+
data.version = TAB_FILE_VERSION;
|
|
135
|
+
return data;
|
|
136
|
+
}
|
|
137
|
+
function normalizeTrackMeta(raw, fallbackName) {
|
|
138
|
+
const volume = Number(raw?.volume);
|
|
139
|
+
return {
|
|
140
|
+
id: typeof raw?.id === "string" && raw.id ? raw.id : newTabId(),
|
|
141
|
+
name: typeof raw?.name === "string" && raw.name.trim() ? raw.name : fallbackName,
|
|
142
|
+
volume: Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1,
|
|
143
|
+
muted: raw?.muted === true
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function defaultMainTrack() {
|
|
147
|
+
return { id: newTabId(), name: DEFAULT_MAIN_TRACK_NAME, volume: 1, muted: false };
|
|
148
|
+
}
|
|
149
|
+
function normalizeTracks(data) {
|
|
150
|
+
if (data.trackMismatch !== true) delete data.trackMismatch;
|
|
151
|
+
delete data.workspace;
|
|
152
|
+
const rawTracks = Array.isArray(data.tracks) ? data.tracks : [];
|
|
153
|
+
if (data.mainTrack != null || rawTracks.length > 0) {
|
|
154
|
+
data.mainTrack = normalizeTrackMeta(data.mainTrack, DEFAULT_MAIN_TRACK_NAME);
|
|
155
|
+
}
|
|
156
|
+
if (rawTracks.length === 0) {
|
|
157
|
+
delete data.tracks;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const measures = data.measures;
|
|
161
|
+
data.tracks = rawTracks.slice(0, MAX_TRACKS - 1).map((t, i) => {
|
|
162
|
+
const tuning = Array.isArray(t?.tuning) && t.tuning.length > 0 && t.tuning.every((n) => typeof n === "string") ? [...t.tuning] : [...PRESET_TUNINGS["Guitar (6)"]];
|
|
163
|
+
const rawMeasures = Array.isArray(t?.measures) ? t.measures : [];
|
|
164
|
+
if (rawMeasures.length !== measures.length) data.trackMismatch = true;
|
|
165
|
+
return {
|
|
166
|
+
...normalizeTrackMeta(t, `Track ${i + 2}`),
|
|
167
|
+
tuning,
|
|
168
|
+
measures: measures.map((m, mi) => fitTrackMeasure(rawMeasures[mi], m, tuning.length))
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function fitTrackMeasure(raw, main, stringCount) {
|
|
173
|
+
const rawSubs = Array.isArray(raw?.subdivisions) ? raw.subdivisions : [];
|
|
174
|
+
const subdivisions = Array.from({ length: main.beats }, (_, b) => {
|
|
175
|
+
const v = rawSubs[Math.min(b, rawSubs.length - 1)];
|
|
176
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= MAX_SUBDIVISION ? v : DEFAULT_SUBDIVISIONS;
|
|
177
|
+
});
|
|
178
|
+
const cols = subdivisions.reduce((a, b) => a + b, 0);
|
|
179
|
+
const rows = Array.isArray(raw?.cells) ? raw.cells : [];
|
|
180
|
+
const cells = Array.from(
|
|
181
|
+
{ length: stringCount },
|
|
182
|
+
(_, s) => Array.from({ length: cols }, (_2, c) => rows[s]?.[c] ?? null)
|
|
183
|
+
);
|
|
184
|
+
return { subdivisions, cells };
|
|
185
|
+
}
|
|
186
|
+
var MAX_SUBDIVISION = 12;
|
|
187
|
+
var PRESET_TUNING_GROUPS = [
|
|
188
|
+
{
|
|
189
|
+
group: "Standard",
|
|
190
|
+
items: [
|
|
191
|
+
{ name: "Guitar (6)", tuning: ["E4", "B3", "G3", "D3", "A2", "E2"] },
|
|
192
|
+
{ name: "Guitar (7)", tuning: ["E4", "B3", "G3", "D3", "A2", "E2", "B1"] },
|
|
193
|
+
{ name: "Bass (4)", tuning: ["G2", "D2", "A1", "E1"] },
|
|
194
|
+
{ name: "Bass (5)", tuning: ["G2", "D2", "A1", "E1", "B0"] },
|
|
195
|
+
{ name: "Bass (6)", tuning: ["C3", "G2", "D2", "A1", "E1", "B0"] },
|
|
196
|
+
{ name: "Ukulele (4)", tuning: ["A4", "E4", "C4", "G4"] }
|
|
197
|
+
]
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
group: "Drop",
|
|
201
|
+
items: [{ name: "Drop D", tuning: ["E4", "B3", "G3", "D3", "A2", "D2"] }]
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
group: "Open / Modal",
|
|
205
|
+
items: [
|
|
206
|
+
{ name: "DADGAD", tuning: ["D4", "A3", "G3", "D3", "A2", "D2"] },
|
|
207
|
+
{ name: "Open G", tuning: ["D4", "B3", "G3", "D3", "G2", "D2"] },
|
|
208
|
+
{ name: "Open D", tuning: ["D4", "A3", "F#3", "D3", "A2", "D2"] },
|
|
209
|
+
{ name: "Open E", tuning: ["E4", "B3", "G#3", "E3", "B2", "E2"] }
|
|
210
|
+
]
|
|
211
|
+
}
|
|
212
|
+
];
|
|
213
|
+
var PRESET_TUNINGS = Object.fromEntries(
|
|
214
|
+
PRESET_TUNING_GROUPS.flatMap((g) => g.items.map((i) => [i.name, i.tuning]))
|
|
215
|
+
);
|
|
65
216
|
var DEFAULT_BEATS = 4;
|
|
66
217
|
var DEFAULT_BEAT_UNIT = 4;
|
|
67
218
|
var DEFAULT_SUBDIVISIONS = 2;
|
|
219
|
+
var MAX_FRET = 24;
|
|
220
|
+
var MAX_BEATS = 32;
|
|
221
|
+
var BEAT_UNITS = [1, 2, 4, 8, 16, 32];
|
|
68
222
|
function createMeasure(stringCount, beats = DEFAULT_BEATS, subdivisions = DEFAULT_SUBDIVISIONS, beatUnit = DEFAULT_BEAT_UNIT) {
|
|
69
|
-
const
|
|
223
|
+
const subs = Array.from(
|
|
224
|
+
{ length: beats },
|
|
225
|
+
(_, b) => typeof subdivisions === "number" ? subdivisions : subdivisions[Math.min(b, subdivisions.length - 1)] ?? DEFAULT_SUBDIVISIONS
|
|
226
|
+
);
|
|
227
|
+
const columns = subs.reduce((a, b) => a + b, 0);
|
|
70
228
|
return {
|
|
71
229
|
beats,
|
|
72
230
|
beatUnit,
|
|
73
|
-
subdivisions,
|
|
74
|
-
cells: Array.from(
|
|
75
|
-
|
|
76
|
-
() => Array.from({ length: columns }, () => null)
|
|
77
|
-
),
|
|
78
|
-
chords: Array.from({ length: beats }, () => null)
|
|
231
|
+
subdivisions: subs,
|
|
232
|
+
cells: Array.from({ length: stringCount }, () => Array.from({ length: columns }, () => null)),
|
|
233
|
+
chords: Array.from({ length: columns }, () => null)
|
|
79
234
|
};
|
|
80
235
|
}
|
|
81
236
|
function newTabId() {
|
|
@@ -91,10 +246,7 @@ function createDocument(tuning = PRESET_TUNINGS["Guitar (6)"], measureCount = 4)
|
|
|
91
246
|
title: "Untitled",
|
|
92
247
|
artist: "",
|
|
93
248
|
tuning: [...tuning],
|
|
94
|
-
measures: Array.from(
|
|
95
|
-
{ length: measureCount },
|
|
96
|
-
() => createMeasure(tuning.length)
|
|
97
|
-
),
|
|
249
|
+
measures: Array.from({ length: measureCount }, () => createMeasure(tuning.length)),
|
|
98
250
|
keys: [{ measure: 0, tonic: "C", mode: "major" }],
|
|
99
251
|
sections: [],
|
|
100
252
|
tempo: DEFAULT_TEMPO,
|
|
@@ -109,54 +261,302 @@ function resizeMeasureStrings(measure, stringCount) {
|
|
|
109
261
|
);
|
|
110
262
|
return { ...measure, cells };
|
|
111
263
|
}
|
|
112
|
-
function
|
|
113
|
-
const
|
|
114
|
-
const
|
|
264
|
+
function remapToSubdivisions(measure, subs) {
|
|
265
|
+
const newCols = subs.reduce((a, b) => a + b, 0);
|
|
266
|
+
const map = [];
|
|
267
|
+
let newStart = 0;
|
|
268
|
+
for (let b = 0; b < measure.subdivisions.length; b++) {
|
|
269
|
+
const os = measure.subdivisions[b];
|
|
270
|
+
const ns = subs[b];
|
|
271
|
+
for (let s = 0; s < os; s++) {
|
|
272
|
+
map.push(newStart + Math.min(ns - 1, Math.round(s * ns / os)));
|
|
273
|
+
}
|
|
274
|
+
newStart += ns;
|
|
275
|
+
}
|
|
115
276
|
const cells = measure.cells.map((row) => {
|
|
116
277
|
const next = Array.from({ length: newCols }, () => null);
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
if (ni >= 0 && ni < newCols) next[ni] = row[c];
|
|
121
|
-
}
|
|
278
|
+
row.forEach((cell, c) => {
|
|
279
|
+
if (cell != null && map[c] != null) next[map[c]] = cell;
|
|
280
|
+
});
|
|
122
281
|
return next;
|
|
123
282
|
});
|
|
124
|
-
|
|
283
|
+
let chords = measure.chords;
|
|
284
|
+
if (chords) {
|
|
285
|
+
const next = Array.from({ length: newCols }, () => null);
|
|
286
|
+
chords.forEach((label, c) => {
|
|
287
|
+
if (label && map[c] != null) next[map[c]] = label;
|
|
288
|
+
});
|
|
289
|
+
chords = next;
|
|
290
|
+
}
|
|
291
|
+
return { ...measure, subdivisions: subs, cells, chords };
|
|
292
|
+
}
|
|
293
|
+
function setMeasureSubdivisions(measure, subdivisions) {
|
|
294
|
+
return remapToSubdivisions(
|
|
295
|
+
measure,
|
|
296
|
+
Array.from({ length: measure.beats }, () => subdivisions)
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
function setBeatSubdivision(measure, beat, subdivision) {
|
|
300
|
+
const subs = [...measure.subdivisions];
|
|
301
|
+
subs[beat] = subdivision;
|
|
302
|
+
return remapToSubdivisions(measure, subs);
|
|
125
303
|
}
|
|
126
304
|
|
|
127
|
-
//
|
|
128
|
-
function
|
|
129
|
-
|
|
130
|
-
|
|
305
|
+
// ../src/lib/tracks.ts
|
|
306
|
+
function newUid() {
|
|
307
|
+
return newTabId();
|
|
308
|
+
}
|
|
309
|
+
function storedMeasure(m) {
|
|
310
|
+
const { uid: _uid, ...rest } = m;
|
|
311
|
+
return rest;
|
|
312
|
+
}
|
|
313
|
+
function openWorkspace(doc) {
|
|
314
|
+
if (doc.workspace) return doc;
|
|
315
|
+
const measures = doc.measures.map((m) => ({ ...m, uid: m.uid ?? newUid() }));
|
|
316
|
+
const extras = doc.tracks ?? [];
|
|
317
|
+
const metas = [
|
|
318
|
+
doc.mainTrack ?? defaultMainTrack(),
|
|
319
|
+
...extras.map(({ tuning: _t, measures: _m, ...meta }) => meta)
|
|
320
|
+
];
|
|
321
|
+
const parked = [
|
|
322
|
+
null,
|
|
323
|
+
...extras.map(
|
|
324
|
+
(t) => ({
|
|
325
|
+
tuning: [...t.tuning],
|
|
326
|
+
measures: measures.map(
|
|
327
|
+
(main, i) => trackMeasureToMeasure(
|
|
328
|
+
main,
|
|
329
|
+
t.measures[i]?.subdivisions,
|
|
330
|
+
t.measures[i]?.cells,
|
|
331
|
+
t.tuning.length
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
})
|
|
335
|
+
)
|
|
336
|
+
];
|
|
337
|
+
const { mainTrack: _mt, tracks: _tr, ...rest } = doc;
|
|
338
|
+
return { ...rest, measures, workspace: { active: 0, metas, parked } };
|
|
339
|
+
}
|
|
340
|
+
function trackMeasureToMeasure(main, subdivisions, cells, stringCount) {
|
|
341
|
+
const base = createMeasure(
|
|
342
|
+
stringCount,
|
|
343
|
+
main.beats,
|
|
344
|
+
subdivisions ?? main.subdivisions,
|
|
345
|
+
main.beatUnit
|
|
346
|
+
);
|
|
347
|
+
return {
|
|
348
|
+
...base,
|
|
349
|
+
cells: base.cells.map((row, s) => row.map((_, c) => cells?.[s]?.[c] ?? null)),
|
|
350
|
+
uid: main.uid
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function trackBodies(doc) {
|
|
354
|
+
const ws = doc.workspace;
|
|
355
|
+
if (!ws) return [{ tuning: doc.tuning, measures: doc.measures }];
|
|
356
|
+
return ws.parked.map(
|
|
357
|
+
(b, i) => i === ws.active ? { tuning: doc.tuning, measures: doc.measures } : b
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
function trackMetas(doc) {
|
|
361
|
+
return doc.workspace?.metas ?? [doc.mainTrack ?? defaultMainTrack()];
|
|
362
|
+
}
|
|
363
|
+
function mainMeasures(doc) {
|
|
364
|
+
return trackBodies(doc)[0].measures;
|
|
365
|
+
}
|
|
366
|
+
function closeWorkspace(doc) {
|
|
367
|
+
const ws = doc.workspace;
|
|
368
|
+
if (!ws) return doc;
|
|
369
|
+
const bodies = trackBodies(doc);
|
|
370
|
+
const main = bodies[0];
|
|
371
|
+
const {
|
|
372
|
+
workspace: _ws,
|
|
373
|
+
trackMismatch: _tm,
|
|
374
|
+
mainTrack: _mt,
|
|
375
|
+
tracks: _tr,
|
|
376
|
+
formatRevision,
|
|
377
|
+
...rest
|
|
378
|
+
} = doc;
|
|
379
|
+
const out = {
|
|
380
|
+
...rest,
|
|
381
|
+
tuning: [...main.tuning],
|
|
382
|
+
measures: main.measures.map(storedMeasure)
|
|
383
|
+
};
|
|
384
|
+
const extras = bodies.slice(1).map(
|
|
385
|
+
(b, i) => ({
|
|
386
|
+
...ws.metas[i + 1],
|
|
387
|
+
tuning: [...b.tuning],
|
|
388
|
+
measures: b.measures.map((m) => ({ subdivisions: m.subdivisions, cells: m.cells }))
|
|
389
|
+
})
|
|
390
|
+
);
|
|
391
|
+
const mainMeta = ws.metas[0];
|
|
392
|
+
const mainIsDefault = mainMeta.name === DEFAULT_MAIN_TRACK_NAME && mainMeta.volume === 1 && !mainMeta.muted;
|
|
393
|
+
if (extras.length > 0 || !mainIsDefault) out.mainTrack = mainMeta;
|
|
394
|
+
if (extras.length > 0) {
|
|
395
|
+
out.tracks = extras;
|
|
396
|
+
out.formatRevision = Math.max(formatRevision ?? 1, MULTI_TRACK_FORMAT_REVISION);
|
|
397
|
+
} else if (formatRevision != null && formatRevision > MULTI_TRACK_FORMAT_REVISION) {
|
|
398
|
+
out.formatRevision = formatRevision;
|
|
131
399
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
function reconcileTracks(prev, next) {
|
|
403
|
+
let doc = next.workspace ? next : openWorkspace(next);
|
|
404
|
+
const ws = doc.workspace;
|
|
405
|
+
const measures = assignUids(prev, doc.measures);
|
|
406
|
+
if (measures !== doc.measures) doc = { ...doc, measures };
|
|
407
|
+
let changed = false;
|
|
408
|
+
const parked = ws.parked.map((body) => {
|
|
409
|
+
if (!body) return body;
|
|
410
|
+
const fitted = fitBody(body, measures);
|
|
411
|
+
if (fitted !== body) changed = true;
|
|
412
|
+
return fitted;
|
|
413
|
+
});
|
|
414
|
+
if (changed) doc = { ...doc, workspace: { ...ws, parked } };
|
|
415
|
+
return doc;
|
|
416
|
+
}
|
|
417
|
+
function assignUids(prev, measures) {
|
|
418
|
+
const seen = /* @__PURE__ */ new Set();
|
|
419
|
+
let out = null;
|
|
420
|
+
const present = new Set(measures.map((m) => m.uid).filter(Boolean));
|
|
421
|
+
measures.forEach((m, i) => {
|
|
422
|
+
let uid = m.uid;
|
|
423
|
+
if (uid && seen.has(uid)) uid = void 0;
|
|
424
|
+
if (!uid) {
|
|
425
|
+
const old = prev?.measures[i]?.uid;
|
|
426
|
+
uid = old && !present.has(old) && !seen.has(old) ? old : newUid();
|
|
427
|
+
out ??= [...measures];
|
|
428
|
+
out[i] = { ...m, uid };
|
|
139
429
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
430
|
+
seen.add(uid);
|
|
431
|
+
});
|
|
432
|
+
return out ?? measures;
|
|
433
|
+
}
|
|
434
|
+
function fitBody(body, measures) {
|
|
435
|
+
const same = body.measures.length === measures.length && body.measures.every(
|
|
436
|
+
(m, i) => m.uid === measures[i].uid && m.beats === measures[i].beats && m.beatUnit === measures[i].beatUnit
|
|
437
|
+
);
|
|
438
|
+
if (same) return body;
|
|
439
|
+
const byUid = new Map(body.measures.map((m) => [m.uid, m]));
|
|
440
|
+
const stringCount = body.tuning.length;
|
|
441
|
+
const out = [];
|
|
442
|
+
measures.forEach((m) => {
|
|
443
|
+
let pm = byUid.get(m.uid);
|
|
444
|
+
if (!pm) {
|
|
445
|
+
const before = out[out.length - 1];
|
|
446
|
+
const subs = before && before.beats === m.beats ? before.subdivisions : void 0;
|
|
447
|
+
pm = createMeasure(stringCount, m.beats, subs, m.beatUnit);
|
|
448
|
+
} else if (pm.beats !== m.beats || pm.beatUnit !== m.beatUnit) {
|
|
449
|
+
pm = setMeasureTimeSignature(pm, m.beats, m.beatUnit);
|
|
450
|
+
}
|
|
451
|
+
out.push({ ...pm, uid: m.uid });
|
|
452
|
+
});
|
|
453
|
+
return { ...body, measures: out };
|
|
454
|
+
}
|
|
455
|
+
function switchTrack(doc, index) {
|
|
456
|
+
const ws = doc.workspace;
|
|
457
|
+
if (!ws || index === ws.active || index < 0 || index >= ws.metas.length) return doc;
|
|
458
|
+
const parked = [...ws.parked];
|
|
459
|
+
parked[ws.active] = { tuning: doc.tuning, measures: doc.measures };
|
|
460
|
+
const body = parked[index];
|
|
461
|
+
parked[index] = null;
|
|
462
|
+
return {
|
|
463
|
+
...doc,
|
|
464
|
+
tuning: body.tuning,
|
|
465
|
+
measures: body.measures,
|
|
466
|
+
workspace: { ...ws, active: index, parked }
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function addTrack(doc, name, tuning) {
|
|
470
|
+
const ws = doc.workspace;
|
|
471
|
+
if (!ws || ws.metas.length >= MAX_TRACKS) return doc;
|
|
472
|
+
const main = mainMeasures(doc);
|
|
473
|
+
const body = {
|
|
474
|
+
tuning: [...tuning],
|
|
475
|
+
measures: main.map((m) => ({
|
|
476
|
+
...createMeasure(tuning.length, m.beats, m.subdivisions, m.beatUnit),
|
|
477
|
+
uid: m.uid
|
|
478
|
+
}))
|
|
479
|
+
};
|
|
480
|
+
const meta = { id: newTabId(), name, volume: 1, muted: false };
|
|
481
|
+
const added = {
|
|
482
|
+
...doc,
|
|
483
|
+
workspace: { ...ws, metas: [...ws.metas, meta], parked: [...ws.parked, body] }
|
|
484
|
+
};
|
|
485
|
+
return switchTrack(added, ws.metas.length);
|
|
486
|
+
}
|
|
487
|
+
function removeTrack(doc, index) {
|
|
488
|
+
const ws = doc.workspace;
|
|
489
|
+
if (!ws || index <= 0 || index >= ws.metas.length) return doc;
|
|
490
|
+
const shown = ws.active === index ? switchTrack(doc, 0) : doc;
|
|
491
|
+
const w = shown.workspace;
|
|
492
|
+
const metas = w.metas.filter((_, i) => i !== index);
|
|
493
|
+
const parked = w.parked.filter((_, i) => i !== index);
|
|
494
|
+
const active = w.active > index ? w.active - 1 : w.active;
|
|
495
|
+
return { ...shown, workspace: { active, metas, parked } };
|
|
496
|
+
}
|
|
497
|
+
function storedSize(doc) {
|
|
498
|
+
return new TextEncoder().encode(JSON.stringify(closeWorkspace(doc))).length;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ../src/lib/config.ts
|
|
502
|
+
var CLOUD_BASE_URL = "https://eztab-sync-736328841982.asia-northeast1.run.app";
|
|
503
|
+
|
|
504
|
+
// cloud.ts
|
|
505
|
+
var CloudError = class extends Error {
|
|
506
|
+
};
|
|
507
|
+
var BASE_URL = process.env.EZTAB_API_URL ?? CLOUD_BASE_URL;
|
|
508
|
+
function token() {
|
|
509
|
+
const t = process.env.EZTAB_TOKEN?.trim();
|
|
510
|
+
if (!t) {
|
|
511
|
+
throw new CloudError(
|
|
512
|
+
`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
513
|
);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
514
|
+
}
|
|
515
|
+
return t;
|
|
516
|
+
}
|
|
517
|
+
async function req(path, init = {}) {
|
|
518
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
519
|
+
...init,
|
|
520
|
+
headers: { ...init.headers ?? {}, authorization: `Bearer ${token()}` }
|
|
148
521
|
});
|
|
149
|
-
if (
|
|
150
|
-
|
|
522
|
+
if (res.status === 401) {
|
|
523
|
+
throw new CloudError(
|
|
524
|
+
"EZTab rejected the MCP link token (revoked or mistyped). Issue a new one in the app's settings."
|
|
525
|
+
);
|
|
151
526
|
}
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
527
|
+
if (!res.ok) {
|
|
528
|
+
const body = await res.json().catch(() => null);
|
|
529
|
+
throw new CloudError(
|
|
530
|
+
`EZTab request ${path} failed (${res.status})${body?.error ? `: ${body.error}` : ""}`
|
|
531
|
+
);
|
|
157
532
|
}
|
|
158
|
-
|
|
159
|
-
|
|
533
|
+
return res;
|
|
534
|
+
}
|
|
535
|
+
var cloud = {
|
|
536
|
+
async list() {
|
|
537
|
+
const { tabs } = await (await req("/tabs")).json();
|
|
538
|
+
return tabs;
|
|
539
|
+
},
|
|
540
|
+
async get(id) {
|
|
541
|
+
return (await req(`/tabs/${encodeURIComponent(id)}`)).json();
|
|
542
|
+
},
|
|
543
|
+
async sendToInbox(payload) {
|
|
544
|
+
await req("/inbox", {
|
|
545
|
+
method: "POST",
|
|
546
|
+
headers: {
|
|
547
|
+
"content-type": "application/json",
|
|
548
|
+
// The newest tab format this build edits safely; the server refuses
|
|
549
|
+
// newer tabs from older builds (docs/design/0001-multiple-tracks.md).
|
|
550
|
+
"x-eztab-format-revision": String(SUPPORTED_FORMAT_REVISION)
|
|
551
|
+
},
|
|
552
|
+
body: JSON.stringify(payload)
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
// eztab.ts
|
|
558
|
+
function normalize(data) {
|
|
559
|
+
return normalizeDocument(data);
|
|
160
560
|
}
|
|
161
561
|
function cellText(cell) {
|
|
162
562
|
if (cell === null) return "--";
|
|
@@ -170,233 +570,208 @@ function cellText(cell) {
|
|
|
170
570
|
}
|
|
171
571
|
return String(cell).padStart(2, "-");
|
|
172
572
|
}
|
|
173
|
-
function render(doc) {
|
|
573
|
+
function render(doc, from = 0, to = doc.measures.length - 1, track) {
|
|
574
|
+
const bodies = trackBodies(doc);
|
|
575
|
+
const metas = trackMetas(doc);
|
|
576
|
+
const multi = bodies.length > 1;
|
|
577
|
+
const main = bodies[0];
|
|
578
|
+
const shown = bodies.map((_, i) => i).filter((i) => track == null || i === track);
|
|
174
579
|
const lines = [];
|
|
175
|
-
lines.push(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
)
|
|
580
|
+
lines.push(`Title: ${doc.title}${doc.artist ? ` \u2014 ${doc.artist}` : ""} tempo=${doc.tempo}`);
|
|
581
|
+
if (multi) {
|
|
582
|
+
lines.push(`Tracks (track index: name, tuning top\u2192bottom) measures=${main.measures.length}`);
|
|
583
|
+
metas.forEach((t, i) => {
|
|
584
|
+
lines.push(
|
|
585
|
+
` ${i}: ${t.name} ${bodies[i].tuning.join(" ")}${i === 0 ? " (main: carries the chords)" : ""}`
|
|
586
|
+
);
|
|
587
|
+
});
|
|
588
|
+
} else {
|
|
589
|
+
lines.push(`Tuning (top\u2192bottom): ${main.tuning.join(" ")} measures=${main.measures.length}`);
|
|
590
|
+
}
|
|
181
591
|
const ann = [];
|
|
182
|
-
|
|
592
|
+
main.measures.forEach((_, m) => {
|
|
183
593
|
const k = doc.keys.find((x) => x.measure === m);
|
|
184
594
|
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
|
|
595
|
+
if (k) ann.push(`m${m}:key=${k.tonic} ${k.mode}`);
|
|
596
|
+
if (s && s.label) ann.push(`m${m}:section="${s.label}"`);
|
|
187
597
|
});
|
|
188
598
|
if (ann.length) lines.push(`Markers: ${ann.join(", ")}`);
|
|
189
|
-
|
|
599
|
+
main.measures.forEach((m, mi) => {
|
|
600
|
+
if (mi < from || mi > to) return;
|
|
190
601
|
const key = activeKeyAt(doc.keys, mi);
|
|
191
602
|
lines.push("");
|
|
603
|
+
if (!multi) {
|
|
604
|
+
lines.push(
|
|
605
|
+
`--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, ${subText(m)}, ${columnCount(m)} cols, key ${key.tonic} ${key.mode}) ---`
|
|
606
|
+
);
|
|
607
|
+
lines.push(chordLine(m));
|
|
608
|
+
measureRows(lines, m, main.tuning);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
192
611
|
lines.push(
|
|
193
|
-
`---
|
|
612
|
+
`--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, key ${key.tonic} ${key.mode}) ---`
|
|
194
613
|
);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
{
|
|
199
|
-
(
|
|
200
|
-
|
|
201
|
-
lines.push(`col: ${ruler}`);
|
|
202
|
-
m.cells.forEach((row, s) => {
|
|
203
|
-
const label = (doc.tuning[s] ?? "?").padEnd(3, " ");
|
|
204
|
-
const cells = row.map(cellText).join(" ");
|
|
205
|
-
lines.push(`${label}| ${cells}`);
|
|
206
|
-
});
|
|
614
|
+
lines.push(`${chordLine(m)} (columns of track 0)`);
|
|
615
|
+
for (const t of shown) {
|
|
616
|
+
const tm = bodies[t].measures[mi];
|
|
617
|
+
lines.push(`[track ${t}: ${metas[t].name}] ${subText(tm)}, ${columnCount(tm)} cols`);
|
|
618
|
+
measureRows(lines, tm, bodies[t].tuning);
|
|
619
|
+
}
|
|
207
620
|
});
|
|
208
621
|
return lines.join("\n");
|
|
209
622
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
for (const c of clients) {
|
|
227
|
-
if (c._isApp && c.readyState === WebSocket.OPEN) return c;
|
|
228
|
-
}
|
|
229
|
-
return void 0;
|
|
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
|
-
}
|
|
623
|
+
function subText(m) {
|
|
624
|
+
const sub = uniformSubdivision(m);
|
|
625
|
+
return sub != null ? `${sub}/beat` : `[${m.subdivisions.join(",")}]/beat`;
|
|
626
|
+
}
|
|
627
|
+
function chordLine(m) {
|
|
628
|
+
const row = (m.chords ?? []).map((label, c) => label ? `c${c}:${chordDisplay(label)}` : null).filter(Boolean).join(" ");
|
|
629
|
+
return `chords: ${row || "(none)"}`;
|
|
630
|
+
}
|
|
631
|
+
function measureRows(lines, m, tuning) {
|
|
632
|
+
const ruler = Array.from({ length: columnCount(m) }, (_, c) => String(c).padStart(2, " ")).join(
|
|
633
|
+
" "
|
|
634
|
+
);
|
|
635
|
+
lines.push(`col: ${ruler}`);
|
|
636
|
+
m.cells.forEach((row, s) => {
|
|
637
|
+
const label = `${s + 1} ${tuning[s] ?? "?"}`.padEnd(6, " ");
|
|
638
|
+
lines.push(`${label}| ${row.map(cellText).join(" ")}`);
|
|
261
639
|
});
|
|
640
|
+
const pm = Array.from(
|
|
641
|
+
{ length: columnCount(m) },
|
|
642
|
+
(_, c) => m.cells.some((row) => cellPalmMute(row[c] ?? null)) ? "PM" : " "
|
|
643
|
+
);
|
|
644
|
+
if (pm.some((x) => x !== " ")) lines.push(`pm: ${pm.join(" ")}`);
|
|
262
645
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
646
|
+
|
|
647
|
+
// notation.ts
|
|
648
|
+
var BEAT_UNITS2 = [2, 4, 8, 16];
|
|
649
|
+
var BENDS = { b: 0.5, B: 1, "^": 1.5 };
|
|
650
|
+
function parseTime(text2) {
|
|
651
|
+
const m = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(text2);
|
|
652
|
+
const beats = m ? Number(m[1]) : NaN;
|
|
653
|
+
const beatUnit = m ? Number(m[2]) : NaN;
|
|
654
|
+
if (!(beats >= 1 && beats <= 32) || !BEAT_UNITS2.includes(beatUnit)) {
|
|
655
|
+
throw new Error(`bad time signature "${text2}" (e.g. "4/4", "6/8")`);
|
|
269
656
|
}
|
|
657
|
+
return { beats, beatUnit };
|
|
270
658
|
}
|
|
271
|
-
function
|
|
272
|
-
if (
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
659
|
+
function parseValue(raw, where) {
|
|
660
|
+
if (raw === "x") return "mute";
|
|
661
|
+
if (raw === "r") return "rest";
|
|
662
|
+
const m = /^(~?)(\d+)([bB^p]*)$/.exec(raw);
|
|
663
|
+
if (!m) throw new Error(`${where}: bad value "${raw}"`);
|
|
664
|
+
const fret = Number(m[2]);
|
|
665
|
+
if (fret > MAX_FRET) throw new Error(`${where}: fret ${fret} is above ${MAX_FRET}`);
|
|
666
|
+
const mods = m[3];
|
|
667
|
+
const bendMarks = [...mods].filter((c) => c in BENDS);
|
|
668
|
+
if (bendMarks.length > 1) throw new Error(`${where}: more than one choke mark in "${raw}"`);
|
|
669
|
+
const note = { fret };
|
|
670
|
+
if (bendMarks.length) note.bend = BENDS[bendMarks[0]];
|
|
671
|
+
if (m[1]) note.tie = true;
|
|
672
|
+
if (mods.includes("p")) note.pm = true;
|
|
673
|
+
return Object.keys(note).length === 1 ? fret : note;
|
|
284
674
|
}
|
|
285
|
-
function
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
version: state.version
|
|
290
|
-
});
|
|
291
|
-
for (const c of clients) {
|
|
292
|
-
if (c !== except && c.readyState === WebSocket.OPEN) c.send(msg);
|
|
675
|
+
function parseBeat(text2, stringCount, where) {
|
|
676
|
+
const slots = text2.trim().split(/\s+/);
|
|
677
|
+
if (slots.length === 1 && slots[0] === "") {
|
|
678
|
+
throw new Error(`${where}: empty beat (write "-" for a beat with nothing on it)`);
|
|
293
679
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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);
|
|
680
|
+
if (slots.length > MAX_SUBDIVISION) {
|
|
681
|
+
throw new Error(`${where}: ${slots.length} slots exceeds the max of ${MAX_SUBDIVISION}`);
|
|
682
|
+
}
|
|
683
|
+
return slots.map((slot, i) => {
|
|
684
|
+
const at = `${where} slot ${i + 1}`;
|
|
685
|
+
const cells = /* @__PURE__ */ new Map();
|
|
686
|
+
let chord = null;
|
|
687
|
+
if (slot === "-") return { cells, chord };
|
|
688
|
+
for (const item of slot.split("+")) {
|
|
689
|
+
if (item.startsWith("@")) {
|
|
690
|
+
const parsed = parseChordInput(item.slice(1));
|
|
691
|
+
if (!parsed) throw new Error(`${at}: bad chord "${item}"`);
|
|
692
|
+
chord = parsed;
|
|
693
|
+
continue;
|
|
333
694
|
}
|
|
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);
|
|
695
|
+
const m = /^(\d+):(.+)$/.exec(item);
|
|
696
|
+
if (!m) throw new Error(`${at}: bad item "${item}" (expected string:value or @Chord)`);
|
|
697
|
+
const string = Number(m[1]);
|
|
698
|
+
if (string < 1 || string > stringCount) {
|
|
699
|
+
throw new Error(`${at}: string ${string} out of range (1..${stringCount})`);
|
|
342
700
|
}
|
|
701
|
+
if (cells.has(string - 1)) throw new Error(`${at}: string ${string} written twice`);
|
|
702
|
+
cells.set(string - 1, parseValue(m[2], at));
|
|
343
703
|
}
|
|
704
|
+
return { cells, chord };
|
|
344
705
|
});
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
706
|
+
}
|
|
707
|
+
function buildMeasure(spec, stringCount, fallback, where = "measure") {
|
|
708
|
+
const time = spec.time ? parseTime(spec.time) : fallback;
|
|
709
|
+
if (spec.beats.length !== time.beats) {
|
|
710
|
+
throw new Error(
|
|
711
|
+
`${where}: ${spec.beats.length} beats given but the time signature ${time.beats}/${time.beatUnit} has ${time.beats}`
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
const beats = spec.beats.map((b, i) => parseBeat(b, stringCount, `${where} beat ${i + 1}`));
|
|
715
|
+
const measure = createMeasure(
|
|
716
|
+
stringCount,
|
|
717
|
+
time.beats,
|
|
718
|
+
beats.map((slots) => slots.length),
|
|
719
|
+
time.beatUnit
|
|
720
|
+
);
|
|
721
|
+
const chords = measure.chords;
|
|
722
|
+
let col = 0;
|
|
723
|
+
for (const slots of beats) {
|
|
724
|
+
for (const slot of slots) {
|
|
725
|
+
for (const [s, cell] of slot.cells) measure.cells[s][col] = cell;
|
|
726
|
+
if (slot.chord) chords[col] = slot.chord;
|
|
727
|
+
col++;
|
|
354
728
|
}
|
|
355
|
-
}
|
|
356
|
-
|
|
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
|
-
}
|
|
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();
|
|
729
|
+
}
|
|
730
|
+
return measure;
|
|
395
731
|
}
|
|
732
|
+
|
|
733
|
+
// prompts.ts
|
|
734
|
+
var IMPORT_TAB_FROM_IMAGE = `Transcribe the TAB in the attached image(s) into an EZTab document with the eztab tools.
|
|
735
|
+
|
|
736
|
+
## 1. Survey before writing
|
|
737
|
+
- 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.
|
|
738
|
+
- 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.
|
|
739
|
+
- 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.
|
|
740
|
+
- To transcribe several parts into one tab (e.g. guitar + bass), write the first part on track 0, then add_track for each further part and pass its \`track\` index to write_measures. Measures and time signatures are shared by all tracks; chords (@Name) go on track 0 only.
|
|
741
|
+
- 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.
|
|
742
|
+
|
|
743
|
+
## 2. Write bar by bar with write_measures
|
|
744
|
+
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.
|
|
745
|
+
- 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.
|
|
746
|
+
- 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.
|
|
747
|
+
- Chord names printed above the staff go on the chord row with @Name at the slot where the chord changes (usually beat 1).
|
|
748
|
+
- Rests: write S:r on the string that was ringing only when the line clearly stops (a rest symbol); otherwise leave "-".
|
|
749
|
+
|
|
750
|
+
## 3. Map notation EZTab cannot store exactly
|
|
751
|
+
- Hammer-on (H), pull-off (P), slide into a note (S, gliss line from a fretted note): mark the destination note tied, S:~F.
|
|
752
|
+
- A tie arc to the same fret, or a parenthesised fret "(6)" that continues a note: S:~F with the same fret.
|
|
753
|
+
- 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.
|
|
754
|
+
- Bridge mute (M, P.M. bracket in Japanese band scores = \u30DF\u30E5\u30FC\u30C8): add p to every note under the bracket.
|
|
755
|
+
- Dead notes / brushing (x): S:x.
|
|
756
|
+
- 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.
|
|
757
|
+
- 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.
|
|
758
|
+
- Repeats, D.S., Coda, 1st/2nd endings: transcribe the bars as printed and tell the user; do not expand them unless asked.
|
|
759
|
+
|
|
760
|
+
## 4. Check, then report
|
|
761
|
+
- 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).
|
|
762
|
+
- 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.
|
|
763
|
+
- 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.`;
|
|
764
|
+
|
|
765
|
+
// server.ts
|
|
766
|
+
var state = {
|
|
767
|
+
doc: null,
|
|
768
|
+
version: 0,
|
|
769
|
+
source: null
|
|
770
|
+
};
|
|
396
771
|
var server = new McpServer(
|
|
397
|
-
{ name: "eztab", version: "0.
|
|
772
|
+
{ name: "eztab", version: "0.3.0" },
|
|
398
773
|
{
|
|
399
|
-
instructions: "All indices are 0-based: measure 0 is the first measure,
|
|
774
|
+
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. A tab can hold up to 4 tracks (parts, e.g. guitar + bass); track 0 is the main track. Tools that edit notes take an optional 0-based `track` (default 0). Measures and time signatures are shared by all tracks (adding/removing a measure affects every track); subdivisions are per track; chords belong to track 0 and use its columns."
|
|
400
775
|
}
|
|
401
776
|
);
|
|
402
777
|
function text(s) {
|
|
@@ -404,44 +779,48 @@ function text(s) {
|
|
|
404
779
|
}
|
|
405
780
|
function requireDoc() {
|
|
406
781
|
if (!state.doc) {
|
|
407
|
-
throw new Error(
|
|
408
|
-
"No document is open. Open the EZTab app (it will connect and share its tab), or call new_tab first."
|
|
409
|
-
);
|
|
782
|
+
throw new Error("No document is open. Call new_tab first.");
|
|
410
783
|
}
|
|
411
784
|
return state.doc;
|
|
412
785
|
}
|
|
413
|
-
function
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
);
|
|
786
|
+
function onTrack(doc, track = 0) {
|
|
787
|
+
const n = trackMetas(doc).length;
|
|
788
|
+
if (!Number.isInteger(track) || track < 0 || track >= n) {
|
|
789
|
+
throw new Error(`track ${track} out of range (0..${n - 1}); get_tab lists the tracks`);
|
|
418
790
|
}
|
|
419
|
-
|
|
791
|
+
return switchTrack(doc, track);
|
|
792
|
+
}
|
|
793
|
+
function edit(track, mutate) {
|
|
794
|
+
const current = requireDoc();
|
|
795
|
+
if ((current.formatRevision ?? 1) > SUPPORTED_FORMAT_REVISION) {
|
|
420
796
|
throw new Error(
|
|
421
|
-
"
|
|
797
|
+
"This tab was saved by a newer EZTab and can only be read here. Update the EZTab MCP server (npx -y @hidechae/eztab-mcp@latest)."
|
|
422
798
|
);
|
|
423
799
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
assertEditable();
|
|
427
|
-
const doc = requireDoc();
|
|
800
|
+
const before = onTrack(current, track);
|
|
801
|
+
const doc = structuredClone(before);
|
|
428
802
|
mutate(doc);
|
|
803
|
+
state.doc = reconcileTracks(before, doc);
|
|
429
804
|
state.version += 1;
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
805
|
+
return state.doc;
|
|
806
|
+
}
|
|
807
|
+
function applyEdit(mutate, track) {
|
|
808
|
+
return text(render(edit(track, mutate)));
|
|
809
|
+
}
|
|
810
|
+
function applyEditRange(mutate, track) {
|
|
811
|
+
let range = [0, 0];
|
|
812
|
+
const doc = edit(track, (d) => {
|
|
813
|
+
range = mutate(d);
|
|
814
|
+
});
|
|
815
|
+
return text(render(doc, range[0], range[1], track));
|
|
437
816
|
}
|
|
438
817
|
function setDoc(doc) {
|
|
439
|
-
|
|
440
|
-
state.doc = doc;
|
|
818
|
+
state.doc = openWorkspace(doc);
|
|
441
819
|
state.version += 1;
|
|
442
|
-
|
|
443
|
-
return text(render(doc));
|
|
820
|
+
return text(render(state.doc));
|
|
444
821
|
}
|
|
822
|
+
var MISMATCH_NOTE = "Warning: this tab's extra tracks may be shifted against the main track \u2014 an older EZTab app added or removed measures without them. Check that the tracks line up (get_tab) and ask the user before sending it back.";
|
|
823
|
+
var trackParam = z.number().int().min(0).optional().describe("0-based track (part) index from get_tab; default 0, the main track.");
|
|
445
824
|
function assertMeasure(doc, m) {
|
|
446
825
|
if (!Number.isInteger(m) || m < 0 || m >= doc.measures.length) {
|
|
447
826
|
throw new Error(`measure ${m} out of range (0..${doc.measures.length - 1})`);
|
|
@@ -449,67 +828,23 @@ function assertMeasure(doc, m) {
|
|
|
449
828
|
}
|
|
450
829
|
server.tool(
|
|
451
830
|
"get_tab",
|
|
452
|
-
"Return a readable rendering of the
|
|
453
|
-
{
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
if (appView === "editor" && state.doc) {
|
|
461
|
-
return text(render(state.doc));
|
|
462
|
-
}
|
|
463
|
-
if (state.doc) {
|
|
464
|
-
return text(render(state.doc));
|
|
465
|
-
}
|
|
466
|
-
return text(
|
|
467
|
-
"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."
|
|
468
|
-
);
|
|
469
|
-
}
|
|
470
|
-
);
|
|
471
|
-
server.tool(
|
|
472
|
-
"list_tabs",
|
|
473
|
-
"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.",
|
|
474
|
-
{},
|
|
475
|
-
async () => {
|
|
476
|
-
try {
|
|
477
|
-
const tabs = await requestFromApp("list_tabs");
|
|
478
|
-
return text(JSON.stringify(tabs, null, 2));
|
|
479
|
-
} catch (e) {
|
|
480
|
-
return text(String(e?.message ?? e));
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
);
|
|
484
|
-
server.tool(
|
|
485
|
-
"list_playlists",
|
|
486
|
-
"List the user's playlists (id, name, song count). Read-only. Requires the app to be open with Live enabled.",
|
|
487
|
-
{},
|
|
488
|
-
async () => {
|
|
489
|
-
try {
|
|
490
|
-
const pls = await requestFromApp("list_playlists");
|
|
491
|
-
return text(JSON.stringify(pls, null, 2));
|
|
492
|
-
} catch (e) {
|
|
493
|
-
return text(String(e?.message ?? e));
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
);
|
|
497
|
-
server.tool(
|
|
498
|
-
"get_playlist",
|
|
499
|
-
"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.",
|
|
500
|
-
{ id: z.string().optional(), name: z.string().optional() },
|
|
831
|
+
"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.",
|
|
832
|
+
{
|
|
833
|
+
from: z.number().int().min(0).optional(),
|
|
834
|
+
to: z.number().int().min(0).optional(),
|
|
835
|
+
track: trackParam.describe("Show only this track (default: all tracks).")
|
|
836
|
+
},
|
|
501
837
|
async (a) => {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
return text(
|
|
505
|
-
} catch (e) {
|
|
506
|
-
return text(String(e?.message ?? e));
|
|
838
|
+
if (state.doc) {
|
|
839
|
+
if (a.track != null) onTrack(state.doc, a.track);
|
|
840
|
+
return text(render(state.doc, a.from, a.to, a.track));
|
|
507
841
|
}
|
|
842
|
+
return text("No document is open in this session. Call new_tab to start one.");
|
|
508
843
|
}
|
|
509
844
|
);
|
|
510
845
|
server.tool(
|
|
511
846
|
"new_tab",
|
|
512
|
-
"
|
|
847
|
+
"Start a fresh document, replacing any current one. Tuning is top (highest) to bottom (lowest).",
|
|
513
848
|
{
|
|
514
849
|
title: z.string().optional(),
|
|
515
850
|
artist: z.string().optional(),
|
|
@@ -524,9 +859,98 @@ server.tool(
|
|
|
524
859
|
if (a.title) doc.title = a.title;
|
|
525
860
|
if (a.artist) doc.artist = a.artist;
|
|
526
861
|
if (a.tempo) doc.tempo = a.tempo;
|
|
862
|
+
state.source = null;
|
|
527
863
|
return setDoc(doc);
|
|
528
864
|
}
|
|
529
865
|
);
|
|
866
|
+
function formatTime(ms) {
|
|
867
|
+
return ms ? new Date(ms).toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }) : "?";
|
|
868
|
+
}
|
|
869
|
+
async function withCloud(fn) {
|
|
870
|
+
try {
|
|
871
|
+
return await fn();
|
|
872
|
+
} catch (e) {
|
|
873
|
+
if (e instanceof CloudError) throw new Error(e.message);
|
|
874
|
+
throw e;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
server.tool(
|
|
878
|
+
"list_tabs",
|
|
879
|
+
"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.",
|
|
880
|
+
{ query: z.string().optional().describe("Filter by title/artist (case-insensitive).") },
|
|
881
|
+
async (a) => withCloud(async () => {
|
|
882
|
+
const q = a.query?.toLowerCase() ?? "";
|
|
883
|
+
const tabs = (await cloud.list()).filter((t) => !q || `${t.title} ${t.artist}`.toLowerCase().includes(q)).sort((x, y) => y.updatedAt - x.updatedAt);
|
|
884
|
+
if (!tabs.length) return text(q ? `No tabs match "${a.query}".` : "The library is empty.");
|
|
885
|
+
return text(
|
|
886
|
+
tabs.map(
|
|
887
|
+
(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"}` : "")
|
|
888
|
+
).join("\n")
|
|
889
|
+
);
|
|
890
|
+
})
|
|
891
|
+
);
|
|
892
|
+
server.tool(
|
|
893
|
+
"open_tab",
|
|
894
|
+
"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.",
|
|
895
|
+
{ id: z.string().describe("Tab id from list_tabs.") },
|
|
896
|
+
async (a) => withCloud(async () => {
|
|
897
|
+
const meta = (await cloud.list()).find((t) => t.id === a.id);
|
|
898
|
+
if (!meta) throw new Error(`No tab with id ${a.id}. Use list_tabs to find it.`);
|
|
899
|
+
const doc = normalize(await cloud.get(a.id));
|
|
900
|
+
state.source = { tabId: a.id, title: meta.title, baseUpdatedAt: meta.updatedAt };
|
|
901
|
+
const shown = setDoc(doc);
|
|
902
|
+
if (doc.trackMismatch) {
|
|
903
|
+
shown.content.unshift({ type: "text", text: MISMATCH_NOTE });
|
|
904
|
+
}
|
|
905
|
+
if ((doc.formatRevision ?? 1) > SUPPORTED_FORMAT_REVISION) {
|
|
906
|
+
shown.content.unshift({
|
|
907
|
+
type: "text",
|
|
908
|
+
text: "Note: this tab was saved by a newer EZTab; it can be read but not edited here. Update the EZTab MCP server to edit it."
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
return shown;
|
|
912
|
+
})
|
|
913
|
+
);
|
|
914
|
+
server.tool(
|
|
915
|
+
"send_to_eztab",
|
|
916
|
+
"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.",
|
|
917
|
+
{
|
|
918
|
+
label: z.string().optional().describe("Short description shown in the app, e.g. 'Guitar II, bars 1\u20138'."),
|
|
919
|
+
tracks_checked: z.boolean().optional().describe(
|
|
920
|
+
"Set to true after checking with the user that the extra tracks line up, when open_tab warned that they may be shifted."
|
|
921
|
+
),
|
|
922
|
+
as_new: z.boolean().optional().describe(
|
|
923
|
+
"For a document opened with open_tab: send it as new material instead of a revision of that tab."
|
|
924
|
+
)
|
|
925
|
+
},
|
|
926
|
+
async (a) => withCloud(async () => {
|
|
927
|
+
if (requireDoc().trackMismatch && !a.tracks_checked) {
|
|
928
|
+
throw new Error(
|
|
929
|
+
`${MISMATCH_NOTE} Not sent: confirm the alignment, then call send_to_eztab again with tracks_checked: true.`
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
const doc = closeWorkspace(requireDoc());
|
|
933
|
+
if ((doc.formatRevision ?? 1) > SUPPORTED_FORMAT_REVISION) {
|
|
934
|
+
throw new Error(
|
|
935
|
+
"This tab was saved by a newer EZTab; update the EZTab MCP server before sending it."
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
if (storedSize(doc) > 4.5 * 1024 * 1024) {
|
|
939
|
+
throw new Error(
|
|
940
|
+
"The tab is too large to send (over 4.5 MB). Remove measures or tracks, or send part of it as new material."
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
const target = state.source && !a.as_new ? state.source : null;
|
|
944
|
+
await cloud.sendToInbox({
|
|
945
|
+
doc,
|
|
946
|
+
label: a.label ?? "",
|
|
947
|
+
target: target ? { tabId: target.tabId, baseUpdatedAt: target.baseUpdatedAt } : null
|
|
948
|
+
});
|
|
949
|
+
return text(
|
|
950
|
+
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.`
|
|
951
|
+
);
|
|
952
|
+
})
|
|
953
|
+
);
|
|
530
954
|
server.tool(
|
|
531
955
|
"set_title",
|
|
532
956
|
"Set the document title.",
|
|
@@ -545,30 +969,27 @@ server.tool(
|
|
|
545
969
|
);
|
|
546
970
|
server.tool(
|
|
547
971
|
"set_cells",
|
|
548
|
-
"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. measure/string/column are all 0-based (measure 0 = the first measure, string 0 = the top line).",
|
|
972
|
+
"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).",
|
|
549
973
|
{
|
|
550
974
|
edits: z.array(
|
|
551
975
|
z.object({
|
|
552
976
|
measure: z.number().int(),
|
|
553
977
|
string: z.number().int(),
|
|
554
978
|
column: z.number().int(),
|
|
555
|
-
value: z.union([
|
|
556
|
-
z.number().int().min(0).max(24),
|
|
557
|
-
z.enum(["rest", "mute", "clear"])
|
|
558
|
-
]),
|
|
979
|
+
value: z.union([z.number().int().min(0).max(24), z.enum(["rest", "mute", "clear"])]),
|
|
559
980
|
bend: z.union([z.literal(0.5), z.literal(1), z.literal(1.5)]).optional(),
|
|
560
|
-
tie: z.boolean().optional()
|
|
981
|
+
tie: z.boolean().optional(),
|
|
982
|
+
pm: z.boolean().optional()
|
|
561
983
|
})
|
|
562
|
-
)
|
|
984
|
+
),
|
|
985
|
+
track: trackParam
|
|
563
986
|
},
|
|
564
987
|
async (a) => applyEdit((doc) => {
|
|
565
988
|
for (const e of a.edits) {
|
|
566
989
|
assertMeasure(doc, e.measure);
|
|
567
990
|
const m = doc.measures[e.measure];
|
|
568
991
|
if (e.string < 0 || e.string >= doc.tuning.length) {
|
|
569
|
-
throw new Error(
|
|
570
|
-
`string ${e.string} out of range (0..${doc.tuning.length - 1})`
|
|
571
|
-
);
|
|
992
|
+
throw new Error(`string ${e.string} out of range (0..${doc.tuning.length - 1})`);
|
|
572
993
|
}
|
|
573
994
|
const cols = columnCount(m);
|
|
574
995
|
if (e.column < 0 || e.column >= cols) {
|
|
@@ -577,25 +998,80 @@ server.tool(
|
|
|
577
998
|
);
|
|
578
999
|
}
|
|
579
1000
|
let value = e.value === "clear" ? null : e.value;
|
|
580
|
-
if (typeof e.value === "number" && (e.bend != null || e.tie)) {
|
|
1001
|
+
if (typeof e.value === "number" && (e.bend != null || e.tie || e.pm)) {
|
|
581
1002
|
value = {
|
|
582
1003
|
fret: e.value,
|
|
583
1004
|
...e.bend != null ? { bend: e.bend } : {},
|
|
584
|
-
...e.tie ? { tie: true } : {}
|
|
1005
|
+
...e.tie ? { tie: true } : {},
|
|
1006
|
+
...e.pm ? { pm: true } : {}
|
|
585
1007
|
};
|
|
586
1008
|
}
|
|
587
1009
|
m.cells[e.string][e.column] = value;
|
|
588
1010
|
}
|
|
589
|
-
})
|
|
1011
|
+
}, a.track)
|
|
1012
|
+
);
|
|
1013
|
+
server.tool(
|
|
1014
|
+
"write_measures",
|
|
1015
|
+
'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"].',
|
|
1016
|
+
{
|
|
1017
|
+
measures: z.array(
|
|
1018
|
+
z.object({
|
|
1019
|
+
measure: z.number().int().min(0),
|
|
1020
|
+
time: z.string().optional(),
|
|
1021
|
+
beats: z.array(z.string()).min(1)
|
|
1022
|
+
})
|
|
1023
|
+
).min(1),
|
|
1024
|
+
track: trackParam.describe(
|
|
1025
|
+
"0-based track (part) to write; default 0. On a track other than 0, chord items (@Name) are rejected (chords belong to track 0) and `time` may not change an existing measure's time signature (it is shared by all tracks \u2014 change it on track 0). Appending adds the measure to every track."
|
|
1026
|
+
)
|
|
1027
|
+
},
|
|
1028
|
+
async (a) => applyEditRange((doc) => {
|
|
1029
|
+
let lo = Infinity;
|
|
1030
|
+
let hi = -1;
|
|
1031
|
+
for (const spec of a.measures) {
|
|
1032
|
+
const i = spec.measure;
|
|
1033
|
+
if (i > doc.measures.length) {
|
|
1034
|
+
throw new Error(
|
|
1035
|
+
`measure ${i} would leave a gap: there are ${doc.measures.length} measures, so append at ${doc.measures.length}`
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
const ref = doc.measures[i] ?? doc.measures[i - 1];
|
|
1039
|
+
const fallback = { beats: ref?.beats ?? 4, beatUnit: ref?.beatUnit ?? 4 };
|
|
1040
|
+
const built = buildMeasure(
|
|
1041
|
+
spec,
|
|
1042
|
+
doc.tuning.length,
|
|
1043
|
+
fallback,
|
|
1044
|
+
`measure ${i}`
|
|
1045
|
+
);
|
|
1046
|
+
const existing = doc.measures[i];
|
|
1047
|
+
if (a.track) {
|
|
1048
|
+
if (built.chords?.some((c) => c)) {
|
|
1049
|
+
throw new Error(
|
|
1050
|
+
`measure ${i}: chord items (@Name) can only be written on track 0, which carries the chords`
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
if (existing && (existing.beats !== built.beats || existing.beatUnit !== built.beatUnit)) {
|
|
1054
|
+
throw new Error(
|
|
1055
|
+
`measure ${i}: the time signature is shared by all tracks; change it on track 0 (time "${existing.beats}/${existing.beatUnit}" here)`
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (!existing) doc.measures.push(built);
|
|
1060
|
+
else doc.measures[i] = built;
|
|
1061
|
+
lo = Math.min(lo, i);
|
|
1062
|
+
hi = Math.max(hi, i);
|
|
1063
|
+
}
|
|
1064
|
+
return [lo, hi];
|
|
1065
|
+
}, a.track)
|
|
590
1066
|
);
|
|
591
1067
|
server.tool(
|
|
592
1068
|
"set_chords",
|
|
593
|
-
|
|
1069
|
+
'Set chord-name onsets per column of track 0 (chords belong to the main track; the same time grid as its 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.',
|
|
594
1070
|
{
|
|
595
1071
|
edits: z.array(
|
|
596
1072
|
z.object({
|
|
597
1073
|
measure: z.number().int(),
|
|
598
|
-
|
|
1074
|
+
column: z.number().int(),
|
|
599
1075
|
label: z.string()
|
|
600
1076
|
})
|
|
601
1077
|
)
|
|
@@ -604,13 +1080,15 @@ server.tool(
|
|
|
604
1080
|
for (const e of a.edits) {
|
|
605
1081
|
assertMeasure(doc, e.measure);
|
|
606
1082
|
const m = doc.measures[e.measure];
|
|
607
|
-
|
|
1083
|
+
const cols = columnCount(m);
|
|
1084
|
+
if (e.column < 0 || e.column >= cols) {
|
|
608
1085
|
throw new Error(
|
|
609
|
-
`
|
|
1086
|
+
`column ${e.column} out of range for measure ${e.measure} (0..${cols - 1})`
|
|
610
1087
|
);
|
|
611
1088
|
}
|
|
612
|
-
if (!m.chords) m.chords = Array.from({ length:
|
|
613
|
-
|
|
1089
|
+
if (!m.chords) m.chords = Array.from({ length: cols }, () => null);
|
|
1090
|
+
const label = e.label.trim();
|
|
1091
|
+
m.chords[e.column] = /^(rest|n\.?c\.?)$/i.test(label) ? CHORD_REST : label || null;
|
|
614
1092
|
}
|
|
615
1093
|
})
|
|
616
1094
|
);
|
|
@@ -667,7 +1145,7 @@ server.tool(
|
|
|
667
1145
|
);
|
|
668
1146
|
server.tool(
|
|
669
1147
|
"set_time_signature",
|
|
670
|
-
"Set a measure's time signature, e.g. beats=6 beatUnit=8 for 6/8.
|
|
1148
|
+
"Set a measure's time signature, e.g. beats=6 beatUnit=8 for 6/8. Shared by all tracks: every track's measure is resized, preserving cells/chords by index.",
|
|
671
1149
|
{
|
|
672
1150
|
measure: z.number().int(),
|
|
673
1151
|
beats: z.number().int().min(1).max(32),
|
|
@@ -686,19 +1164,33 @@ server.tool(
|
|
|
686
1164
|
);
|
|
687
1165
|
server.tool(
|
|
688
1166
|
"set_resolution",
|
|
689
|
-
"Set
|
|
690
|
-
{
|
|
1167
|
+
"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.",
|
|
1168
|
+
{
|
|
1169
|
+
measure: z.number().int(),
|
|
1170
|
+
subdivisions: z.number().int().min(1).max(12),
|
|
1171
|
+
beat: z.number().int().min(0).optional(),
|
|
1172
|
+
track: trackParam.describe(
|
|
1173
|
+
"0-based track whose grid to change; default 0. Each track has its own subdivisions."
|
|
1174
|
+
)
|
|
1175
|
+
},
|
|
691
1176
|
async (a) => applyEdit((doc) => {
|
|
692
1177
|
assertMeasure(doc, a.measure);
|
|
693
|
-
doc.measures[a.measure]
|
|
694
|
-
|
|
695
|
-
a.
|
|
696
|
-
|
|
697
|
-
|
|
1178
|
+
const m = doc.measures[a.measure];
|
|
1179
|
+
if (a.beat != null) {
|
|
1180
|
+
if (a.beat >= m.beats) {
|
|
1181
|
+
throw new Error(
|
|
1182
|
+
`beat ${a.beat} out of range for measure ${a.measure} (0..${m.beats - 1})`
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
doc.measures[a.measure] = setBeatSubdivision(m, a.beat, a.subdivisions);
|
|
1186
|
+
} else {
|
|
1187
|
+
doc.measures[a.measure] = setMeasureSubdivisions(m, a.subdivisions);
|
|
1188
|
+
}
|
|
1189
|
+
}, a.track)
|
|
698
1190
|
);
|
|
699
1191
|
server.tool(
|
|
700
1192
|
"add_measures",
|
|
701
|
-
"Insert `count` empty measures. Without `at`, appends; otherwise inserts before index `at`.",
|
|
1193
|
+
"Insert `count` empty measures into every track. Without `at`, appends; otherwise inserts before index `at`.",
|
|
702
1194
|
{
|
|
703
1195
|
count: z.number().int().min(1).max(256).optional(),
|
|
704
1196
|
at: z.number().int().optional()
|
|
@@ -715,7 +1207,7 @@ server.tool(
|
|
|
715
1207
|
);
|
|
716
1208
|
server.tool(
|
|
717
1209
|
"remove_measure",
|
|
718
|
-
"Remove the measure at index `measure` (at least one is kept).",
|
|
1210
|
+
"Remove the measure at index `measure` from every track (at least one is kept).",
|
|
719
1211
|
{ measure: z.number().int() },
|
|
720
1212
|
async (a) => applyEdit((doc) => {
|
|
721
1213
|
assertMeasure(doc, a.measure);
|
|
@@ -728,14 +1220,78 @@ server.tool(
|
|
|
728
1220
|
server.tool(
|
|
729
1221
|
"set_tuning",
|
|
730
1222
|
"Replace the tuning / string count. Existing frets are preserved where strings overlap. Order is top (highest) to bottom (lowest).",
|
|
731
|
-
{ tuning: z.array(z.string()).min(1) },
|
|
1223
|
+
{ tuning: z.array(z.string()).min(1), track: trackParam },
|
|
732
1224
|
async (a) => applyEdit((doc) => {
|
|
733
1225
|
doc.tuning = [...a.tuning];
|
|
734
|
-
doc.measures = doc.measures.map(
|
|
735
|
-
|
|
736
|
-
|
|
1226
|
+
doc.measures = doc.measures.map((m) => resizeMeasureStrings(m, a.tuning.length));
|
|
1227
|
+
}, a.track)
|
|
1228
|
+
);
|
|
1229
|
+
var TRACK_PRESETS = [
|
|
1230
|
+
"Guitar (6)",
|
|
1231
|
+
"Guitar (7)",
|
|
1232
|
+
"Bass (4)",
|
|
1233
|
+
"Bass (5)",
|
|
1234
|
+
"Bass (6)",
|
|
1235
|
+
"Ukulele (4)"
|
|
1236
|
+
];
|
|
1237
|
+
server.tool(
|
|
1238
|
+
"add_track",
|
|
1239
|
+
"Add a track (part) to the tab, e.g. a bass line or a second guitar \u2014 up to 4 tracks including the main one. It starts empty, on track 0's measures and grid; write it with write_measures / set_cells using the new `track` index.",
|
|
1240
|
+
{
|
|
1241
|
+
name: z.string().min(1).describe("Shown in the app, e.g. 'Bass' or 'Guitar 2'."),
|
|
1242
|
+
preset: z.enum(TRACK_PRESETS).optional(),
|
|
1243
|
+
tuning: z.array(z.string()).min(1).optional().describe("Top (highest) to bottom; overrides preset.")
|
|
1244
|
+
},
|
|
1245
|
+
async (a) => {
|
|
1246
|
+
const before = trackMetas(requireDoc()).length;
|
|
1247
|
+
const tuning = a.tuning ?? PRESET_TUNINGS[a.preset ?? "Bass (4)"];
|
|
1248
|
+
const doc = edit(0, () => {
|
|
1249
|
+
});
|
|
1250
|
+
const next = addTrack(doc, a.name, tuning);
|
|
1251
|
+
if (trackMetas(next).length === before) {
|
|
1252
|
+
throw new Error("a tab can hold at most 4 tracks");
|
|
1253
|
+
}
|
|
1254
|
+
state.doc = next;
|
|
1255
|
+
return text(`Added track ${before} "${a.name}".
|
|
1256
|
+
|
|
1257
|
+
${render(next)}`);
|
|
1258
|
+
}
|
|
1259
|
+
);
|
|
1260
|
+
server.tool(
|
|
1261
|
+
"remove_track",
|
|
1262
|
+
"Remove a track (part) and its notes. Track 0 (the main track) cannot be removed.",
|
|
1263
|
+
{ track: z.number().int().min(1) },
|
|
1264
|
+
async (a) => {
|
|
1265
|
+
const doc = edit(0, () => {
|
|
1266
|
+
});
|
|
1267
|
+
onTrack(doc, a.track);
|
|
1268
|
+
state.doc = removeTrack(doc, a.track);
|
|
1269
|
+
return text(render(state.doc));
|
|
1270
|
+
}
|
|
1271
|
+
);
|
|
1272
|
+
server.prompt(
|
|
1273
|
+
"import_tab_from_image",
|
|
1274
|
+
"Transcribe a TAB score from attached image(s) into an EZTab document.",
|
|
1275
|
+
{
|
|
1276
|
+
part: z.string().optional().describe("Which part to take from a band score, e.g. 'Guitar II' or 'Bass'."),
|
|
1277
|
+
notes: z.string().optional().describe("Anything else to know (tuning, which bars, \u2026).")
|
|
1278
|
+
},
|
|
1279
|
+
(a) => ({
|
|
1280
|
+
messages: [
|
|
1281
|
+
{
|
|
1282
|
+
role: "user",
|
|
1283
|
+
content: {
|
|
1284
|
+
type: "text",
|
|
1285
|
+
text: IMPORT_TAB_FROM_IMAGE + (a.part ? `
|
|
1286
|
+
|
|
1287
|
+
Part to transcribe: ${a.part}` : "") + (a.notes ? `
|
|
1288
|
+
|
|
1289
|
+
Notes from the user: ${a.notes}` : "")
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
]
|
|
737
1293
|
})
|
|
738
1294
|
);
|
|
739
1295
|
var transport = new StdioServerTransport();
|
|
740
1296
|
await server.connect(transport);
|
|
741
|
-
console.error(
|
|
1297
|
+
console.error("eztab MCP server running (stdio)");
|