@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.
Files changed (3) hide show
  1. package/README.md +75 -41
  2. package/dist/server.js +938 -382
  3. 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 cols = beats * measure.subdivisions;
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.chords?.[b] ?? null
62
+ (_, b) => measure.subdivisions[Math.min(b, measure.subdivisions.length - 1)] ?? DEFAULT_SUBDIVISIONS
53
63
  );
54
- return { ...measure, beats, beatUnit, cells, chords };
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
- return measure.beats * measure.subdivisions;
70
+ let sum = 0;
71
+ for (const s of measure.subdivisions) sum += s;
72
+ return sum;
58
73
  }
59
- var PRESET_TUNINGS = {
60
- "Guitar (6)": ["E4", "B3", "G3", "D3", "A2", "E2"],
61
- "Bass (4)": ["G2", "D2", "A1", "E1"],
62
- "Bass (5)": ["G2", "D2", "A1", "E1", "B0"],
63
- "Ukulele (4)": ["A4", "E4", "C4", "G4"]
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 columns = beats * subdivisions;
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
- { length: stringCount },
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 setMeasureSubdivisions(measure, subdivisions) {
113
- const oldCols = columnCount(measure);
114
- const newCols = measure.beats * subdivisions;
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
- for (let c = 0; c < oldCols; c++) {
118
- if (row[c] == null) continue;
119
- const ni = Math.round(c * subdivisions / measure.subdivisions);
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
- return { ...measure, subdivisions, cells };
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
- // eztab.ts
128
- function normalize(data) {
129
- if (!data || !Array.isArray(data.tuning) || !Array.isArray(data.measures)) {
130
- throw new Error("Not a valid EZTab document");
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
- data.measures = data.measures.map((m) => {
133
- let beats = m.beats;
134
- let subdivisions = m.subdivisions;
135
- if (typeof beats !== "number" || typeof subdivisions !== "number") {
136
- const cols2 = m.cells?.[0]?.length ?? m.columns ?? 8;
137
- subdivisions = cols2 % 4 === 0 ? cols2 / 4 : 2;
138
- beats = cols2 % 4 === 0 ? 4 : Math.max(1, Math.round(cols2 / subdivisions));
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
- 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)
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
- let chords = Array.isArray(m.chords) ? m.chords.slice(0, beats) : [];
146
- while (chords.length < beats) chords.push(null);
147
- return { beats, beatUnit, subdivisions, cells, chords };
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 (!Array.isArray(data.keys) || data.keys.length === 0) {
150
- data.keys = [{ measure: 0, tonic: "C", mode: "major" }];
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 (typeof data.artist !== "string") data.artist = "";
153
- if (!Array.isArray(data.sections)) data.sections = [];
154
- if (typeof data.tempo !== "number") data.tempo = 120;
155
- if (!data.mix || typeof data.mix.melody !== "number") {
156
- data.mix = { melody: 1, backing: 0.4 };
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
- data.version = 1;
159
- return data;
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
- `Title: ${doc.title}${doc.artist ? ` \u2014 ${doc.artist}` : ""} tempo=${doc.tempo}`
177
- );
178
- lines.push(
179
- `Tuning (top\u2192bottom): ${doc.tuning.join(" ")} measures=${doc.measures.length}`
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
- doc.measures.forEach((_, m) => {
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 + 1}:key=${k.tonic} ${k.mode}`);
186
- if (s && s.label) ann.push(`m${m + 1}:section="${s.label}"`);
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
- doc.measures.forEach((m, mi) => {
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
- `--- Measure ${mi + 1} (${m.beats}/${m.beatUnit}, ${m.subdivisions}/beat, ${columnCount(m)} cols, key ${key.tonic} ${key.mode}) ---`
612
+ `--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, key ${key.tonic} ${key.mode}) ---`
194
613
  );
195
- const chordRow = (m.chords ?? []).map((c, b) => `b${b + 1}:${c ?? "-"}`).join(" ");
196
- lines.push(`chords: ${chordRow}`);
197
- const ruler = Array.from(
198
- { length: columnCount(m) },
199
- (_, c) => String(c).padStart(2, " ")
200
- ).join(" ");
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
- // server.ts
212
- var PORT = Number(process.env.EZTAB_LIVE_PORT ?? 47823);
213
- var state = {
214
- doc: null,
215
- version: 0
216
- };
217
- var mode = "host";
218
- var clients = /* @__PURE__ */ new Set();
219
- var upstream = null;
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;
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
- function settleResponse(reqId, data) {
264
- const p = pending.get(reqId);
265
- if (p) {
266
- clearTimeout(p.timer);
267
- pending.delete(reqId);
268
- p.resolve(data);
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 pushState() {
272
- if (mode === "host") {
273
- const msg = JSON.stringify({
274
- type: "state",
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 }));
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 broadcastExcept(except) {
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);
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
- var wss = new WebSocketServer({ port: PORT });
296
- wss.on("connection", (ws) => {
297
- clients.add(ws);
298
- ws.send(
299
- JSON.stringify({ type: "state", doc: state.doc, version: state.version })
300
- );
301
- ws.on("message", (data) => {
302
- let msg;
303
- try {
304
- msg = JSON.parse(data.toString());
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);
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
- } else if (msg.type === "request" && msg.reqId) {
335
- const app = findAppClient();
336
- if (app) app.send(JSON.stringify(msg));
337
- } else if (msg.type === "response" && msg.reqId) {
338
- settleResponse(msg.reqId, msg.data);
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
- const onGone = () => {
346
- clients.delete(ws);
347
- if (ws._isApp) {
348
- appView = null;
349
- appEditable = null;
350
- const out = JSON.stringify({ type: "appstate", view: null, editable: null });
351
- for (const c of clients) {
352
- if (c.readyState === WebSocket.OPEN) c.send(out);
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
- 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
- }
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.2.0" },
772
+ { name: "eztab", version: "0.3.0" },
398
773
  {
399
- instructions: "All indices are 0-based: measure 0 is the first measure, beat 0 is the first beat of a measure, string 0 is the top (highest) string, and column 0 is the first column. add_measures `at` is a 0-based insert position too. No need to ask the user about indexing."
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 assertEditable() {
414
- if (appView === "library") {
415
- throw new Error(
416
- "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."
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
- if (appEditable === false) {
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
- "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.)"
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
- function applyEdit(mutate) {
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
- pushState();
431
- const connected = mode === "host" ? clients.size > 0 : upstream != null;
432
- return text(
433
- `${connected ? "Pushed to app. " : "No app connected (will sync when it opens). "}
434
-
435
- ${render(doc)}`
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
- assertEditable();
440
- state.doc = doc;
818
+ state.doc = openWorkspace(doc);
441
819
  state.version += 1;
442
- pushState();
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 tab the EZTab app currently has open. If the app isn't connected, or is on the library screen (no tab open), this says so instead \u2014 relay that to the user (ask them to open the app / open a tab) rather than guessing.",
453
- {},
454
- async () => {
455
- if (appView === "library") {
456
- return text(
457
- "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."
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
- try {
503
- const pl = await requestFromApp("get_playlist", { id: a.id, name: a.name });
504
- return text(JSON.stringify(pl, null, 2));
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
- "Replace the live document with a fresh one and push it to the app. Tuning is top (highest) to bottom (lowest).",
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
- "Set chord labels per beat. `measure` and `beat` are 0-based (measure 0 = the first measure, beat 0 = the first beat). An empty label clears that beat's chord.",
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
- beat: z.number().int(),
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
- if (e.beat < 0 || e.beat >= m.beats) {
1083
+ const cols = columnCount(m);
1084
+ if (e.column < 0 || e.column >= cols) {
608
1085
  throw new Error(
609
- `beat ${e.beat} out of range for measure ${e.measure} (0..${m.beats - 1})`
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: m.beats }, () => null);
613
- m.chords[e.beat] = e.label.trim() || null;
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. Resizes the measure, preserving cells/chords by index.",
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 a measure's subdivisions per beat (1=quarter, 2=eighth, 4=sixteenth, 3=eighth triplet, 6=sixteenth triplet). Existing notes are remapped.",
690
- { measure: z.number().int(), subdivisions: z.number().int().min(1).max(12) },
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] = setMeasureSubdivisions(
694
- doc.measures[a.measure],
695
- a.subdivisions
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
- (m) => resizeMeasureStrings(m, a.tuning.length)
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(`eztab MCP server running (stdio) + live hub on ws://127.0.0.1:${PORT}`);
1297
+ console.error("eztab MCP server running (stdio)");