@hidechae/eztab-mcp 0.2.0 → 0.4.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 +24 -5
  2. package/dist/server.js +643 -110
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -47,11 +47,16 @@ npm versions are immutable, so publishing is deliberate (tag-driven):
47
47
 
48
48
  ```sh
49
49
  # bump mcp/package.json "version", commit, then:
50
- git tag mcp-v0.1.3 && git push --tags
50
+ git tag mcp-vX.Y.Z origin/main && git push origin mcp-vX.Y.Z
51
51
  ```
52
52
 
53
- The `Publish MCP to npm` GitHub Action builds and publishes on `mcp-v*` tags
54
- (needs the `NPM_TOKEN` repo secret).
53
+ The `Publish MCP to npm` GitHub Action builds and publishes on `mcp-v*` tags.
54
+ 認証は npm の Trusted Publishing(GitHub Actions の OIDC)で、トークンの Secret は不要。
55
+ npmjs.com のパッケージ設定で `hidechae/eztab` の `publish-mcp.yml` を Trusted Publisher に
56
+ 登録してある前提。タグ名と `mcp/package.json` の version が一致しないと公開しない。
57
+ 公開に失敗したタグをやり直すときは、失敗した run の re-run ではなく(古いワークフローのまま
58
+ 再実行される)、`Publish MCP to npm` を workflow_dispatch し、`tag` にそのタグを指定する
59
+ (`gh workflow run publish-mcp.yml -f tag=mcp-vX.Y.Z`)。指定したタグの中身が公開される。
55
60
 
56
61
  ## Setup (local dev)
57
62
 
@@ -87,7 +92,7 @@ Run standalone for testing: `task mcp` (or `npx tsx mcp/server.ts`).
87
92
  | `set_title` | Set the document title |
88
93
  | `set_artist` | Set the document artist |
89
94
  | `write_measures` | 小節単位の一括入力。1拍を1文字列で書き(`"6:5 6:~7"` など)、列番号への割り付けはサーバ側で行う。譜面・画像からの書き起こし向け |
90
- | `set_cells` | Set tab cells: a fret number, `rest`, `mute` (cutting "x"), or `clear` (empty = sustain). Optional `bend` (0.5/1/1.5) chokes a fret note; optional `tie` ties/slurs it to the previous note (same pitch = just sustain); optional `pm` palm-mutes it |
95
+ | `set_cells` | Set tab cells: a fret number, `rest`, `mute` (cutting "x"), or `clear` (empty = sustain). Optional `bend` (0.5/1/1.5) chokes a fret note; optional `tie` ties/slurs it to the previous note (same pitch = just sustain); optional `pm` palm-mutes it; optional `harm` (`natural` / `artificial` / `pinch` / `none`) + `harmAt` writes a harmonic |
91
96
  | `set_chords` | Set chord labels per column |
92
97
  | `set_key` / `remove_key` | Set/add or remove a key (key change) at a measure |
93
98
  | `set_section` | Set/remove a section marker (e.g. "Verse", "Chorus") |
@@ -95,7 +100,21 @@ Run standalone for testing: `task mcp` (or `npx tsx mcp/server.ts`).
95
100
  | `set_time_signature` | Set a measure's time signature |
96
101
  | `set_resolution` | Set a measure's subdivisions per beat (8th/16th/triplets/quintuplets) |
97
102
  | `add_measures` / `remove_measure` | Insert/remove measures (keys & sections re-anchored) |
98
- | `set_tuning` | Replace tuning / string count |
103
+ | `set_tuning` | Replace tuning / string count (`track` で対象パートを指定) |
104
+ | `add_track` / `remove_track` | パート(ベース・2本目のギターなど)の追加・削除。メインを含めて最大4、track 0(メイン)は削除不可 |
105
+
106
+ ### 複数パート(トラック)
107
+
108
+ 1つのタブにメインを含めて最大4パートを持てる(docs/design/0001-multiple-tracks.md)。
109
+
110
+ - `set_cells` / `write_measures` / `set_resolution` / `set_tuning` は任意の `track`(0 始まり、既定 0 = メイン)を取る
111
+ - 小節と拍子は全パート共通。`add_measures` / `remove_measure` / `set_time_signature` は全パートに作用し、
112
+ `write_measures` で末尾に書き足すと全パートに小節が増える。分割(subdivisions)はパートごと
113
+ - コードは track 0 のもの(`set_chords` は track 0 の列で指定)。`write_measures(track > 0)` ではコード指定と、
114
+ 既存小節の拍子を変える `time` を拒否する
115
+ - `get_tab` は全パートを小節ごとに並べて表示する(`track` で1パートに絞れる)
116
+ - `send_to_eztab` は `x-eztab-format-revision: 2` を付けて送る。0.2.0 以前の MCP は複数パートのタブを
117
+ 送り返せない(サーバが 409 で拒否する)。新しい形式のタブ(リビジョン 3 以降)は読めるが編集・送信できない
99
118
 
100
119
  These cover the editor's current model: variable string count, per-beat chords,
101
120
  keys with mid-piece changes, section markers, per-measure resolution, time
package/dist/server.js CHANGED
@@ -7,6 +7,11 @@ import { z } from "zod";
7
7
 
8
8
  // ../src/types/tab.ts
9
9
  var TAB_FILE_VERSION = 1;
10
+ var SUPPORTED_FORMAT_REVISION = 3;
11
+ var MULTI_TRACK_FORMAT_REVISION = 2;
12
+ var HARMONICS_FORMAT_REVISION = 3;
13
+ var MAX_TRACKS = 4;
14
+ var DEFAULT_MAIN_TRACK_NAME = "Guitar";
10
15
  var DEFAULT_TEMPO = 120;
11
16
  var DEFAULT_MIX = { melody: 1, backing: 0.4 };
12
17
  function shiftSectionsForInsert(sections, index) {
@@ -37,9 +42,92 @@ function shiftKeysForDelete(keys, index) {
37
42
  }
38
43
  return next;
39
44
  }
45
+ var HARMONIC_KINDS = ["natural", "artificial", "pinch"];
46
+ var MAX_HARM_AT = 36;
47
+ function checkHarmonic(fret, harm, harmAt) {
48
+ if (harm == null) {
49
+ if (harmAt != null) {
50
+ return { ok: false, field: "harmAt", reason: "a harmonic position needs a harmonic kind" };
51
+ }
52
+ return { ok: true, value: { fret } };
53
+ }
54
+ if (!HARMONIC_KINDS.includes(harm)) {
55
+ return { ok: false, field: "harm", reason: `unknown harmonic kind "${String(harm)}"` };
56
+ }
57
+ const kind = harm;
58
+ if (harmAt == null) {
59
+ if (kind === "natural" && !(fret > 0)) {
60
+ return { ok: false, field: "harm", reason: "a natural harmonic needs a fret above 0" };
61
+ }
62
+ return { ok: true, value: { fret, harm: kind } };
63
+ }
64
+ if (typeof harmAt !== "number" || !Number.isFinite(harmAt) || harmAt <= 0 || harmAt > MAX_HARM_AT) {
65
+ return {
66
+ ok: false,
67
+ field: "harmAt",
68
+ reason: `harmonic position must be a number above 0 and up to ${MAX_HARM_AT}`
69
+ };
70
+ }
71
+ if (Math.abs(harmAt * 10 - Math.round(harmAt * 10)) > 1e-6) {
72
+ return { ok: false, field: "harmAt", reason: "harmonic position has at most one decimal" };
73
+ }
74
+ const at = Math.round(harmAt * 10) / 10;
75
+ if (kind === "natural") {
76
+ const touch = Math.round(at);
77
+ if (touch > MAX_FRET) {
78
+ return { ok: false, field: "harmAt", reason: `natural harmonic above fret ${MAX_FRET}` };
79
+ }
80
+ return {
81
+ ok: true,
82
+ value: Number.isInteger(at) ? { fret: touch, harm: kind } : { fret: touch, harm: kind, harmAt: at }
83
+ };
84
+ }
85
+ if (at <= fret) {
86
+ return {
87
+ ok: false,
88
+ field: "harmAt",
89
+ reason: `the harmonic position (${at}) must be above the fretted note (${fret})`
90
+ };
91
+ }
92
+ return { ok: true, value: { fret, harm: kind, harmAt: at } };
93
+ }
94
+ function normalizeCell(c) {
95
+ if (typeof c !== "object" || c === null || !("harm" in c || "harmAt" in c)) return c;
96
+ const { harm, harmAt, ...rest } = c;
97
+ let r = checkHarmonic(c.fret, harm, harmAt);
98
+ if (!r.ok && r.field === "harmAt" && harm != null) r = checkHarmonic(c.fret, harm, void 0);
99
+ const h = r.ok ? r.value : { fret: c.fret };
100
+ return { ...rest, ...h };
101
+ }
102
+ function measuresHaveHarmonics(measures) {
103
+ return measures.some((m) => m.cells.some((row) => row.some((c) => cellHarm(c) != null)));
104
+ }
105
+ function cellFret(c) {
106
+ if (typeof c === "number") return c;
107
+ if (typeof c === "object" && c !== null) return c.fret;
108
+ return null;
109
+ }
40
110
  function cellPalmMute(c) {
41
111
  return typeof c === "object" && c !== null && !!c.pm;
42
112
  }
113
+ function cellHarm(c) {
114
+ return typeof c === "object" && c !== null ? c.harm : void 0;
115
+ }
116
+ function cellHarmAt(c) {
117
+ return typeof c === "object" && c !== null && c.harm ? c.harmAt : void 0;
118
+ }
119
+ function makeNoteCell(fret, bend, tie, pm, harmonic) {
120
+ const harm = harmonic?.harm;
121
+ if (!(bend > 0 || tie || pm || harm)) return fret;
122
+ return {
123
+ fret,
124
+ ...bend > 0 ? { bend } : {},
125
+ ...tie ? { tie: true } : {},
126
+ ...pm ? { pm: true } : {},
127
+ ...harm ? { harm } : {},
128
+ ...harm && harmonic?.harmAt != null ? { harmAt: harmonic.harmAt } : {}
129
+ };
130
+ }
43
131
  var CHORD_REST = "rest";
44
132
  var CHORD_REST_LABEL = "N.C.";
45
133
  function chordDisplay(label) {
@@ -111,7 +199,7 @@ function normalizeDocument(data) {
111
199
  });
112
200
  const cols = subdivisions.reduce((a, b) => a + b, 0);
113
201
  const cells = (m.cells ?? []).map(
114
- (row) => Array.from({ length: cols }, (_, c) => row?.[c] ?? null)
202
+ (row) => Array.from({ length: cols }, (_, c) => normalizeCell(row?.[c] ?? null))
115
203
  );
116
204
  const rawChords = Array.isArray(m.chords) ? m.chords : typeof m.chord === "string" && m.chord ? [m.chord] : [];
117
205
  const chords = normalizeChords(rawChords, subdivisions);
@@ -126,9 +214,59 @@ function normalizeDocument(data) {
126
214
  if (!data.mix || typeof data.mix.melody !== "number") {
127
215
  data.mix = { ...DEFAULT_MIX };
128
216
  }
217
+ normalizeTracks(data);
129
218
  data.version = TAB_FILE_VERSION;
130
219
  return data;
131
220
  }
221
+ function normalizeTrackMeta(raw, fallbackName) {
222
+ const volume = Number(raw?.volume);
223
+ return {
224
+ id: typeof raw?.id === "string" && raw.id ? raw.id : newTabId(),
225
+ name: typeof raw?.name === "string" && raw.name.trim() ? raw.name : fallbackName,
226
+ volume: Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1,
227
+ muted: raw?.muted === true
228
+ };
229
+ }
230
+ function defaultMainTrack() {
231
+ return { id: newTabId(), name: DEFAULT_MAIN_TRACK_NAME, volume: 1, muted: false };
232
+ }
233
+ function normalizeTracks(data) {
234
+ if (data.trackMismatch !== true) delete data.trackMismatch;
235
+ delete data.workspace;
236
+ const rawTracks = Array.isArray(data.tracks) ? data.tracks : [];
237
+ if (data.mainTrack != null || rawTracks.length > 0) {
238
+ data.mainTrack = normalizeTrackMeta(data.mainTrack, DEFAULT_MAIN_TRACK_NAME);
239
+ }
240
+ if (rawTracks.length === 0) {
241
+ delete data.tracks;
242
+ return;
243
+ }
244
+ const measures = data.measures;
245
+ data.tracks = rawTracks.slice(0, MAX_TRACKS - 1).map((t, i) => {
246
+ const tuning = Array.isArray(t?.tuning) && t.tuning.length > 0 && t.tuning.every((n) => typeof n === "string") ? [...t.tuning] : [...PRESET_TUNINGS["Guitar (6)"]];
247
+ const rawMeasures = Array.isArray(t?.measures) ? t.measures : [];
248
+ if (rawMeasures.length !== measures.length) data.trackMismatch = true;
249
+ return {
250
+ ...normalizeTrackMeta(t, `Track ${i + 2}`),
251
+ tuning,
252
+ measures: measures.map((m, mi) => fitTrackMeasure(rawMeasures[mi], m, tuning.length))
253
+ };
254
+ });
255
+ }
256
+ function fitTrackMeasure(raw, main, stringCount) {
257
+ const rawSubs = Array.isArray(raw?.subdivisions) ? raw.subdivisions : [];
258
+ const subdivisions = Array.from({ length: main.beats }, (_, b) => {
259
+ const v = rawSubs[Math.min(b, rawSubs.length - 1)];
260
+ return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= MAX_SUBDIVISION ? v : DEFAULT_SUBDIVISIONS;
261
+ });
262
+ const cols = subdivisions.reduce((a, b) => a + b, 0);
263
+ const rows = Array.isArray(raw?.cells) ? raw.cells : [];
264
+ const cells = Array.from(
265
+ { length: stringCount },
266
+ (_, s) => Array.from({ length: cols }, (_2, c) => normalizeCell(rows[s]?.[c] ?? null))
267
+ );
268
+ return { subdivisions, cells };
269
+ }
132
270
  var MAX_SUBDIVISION = 12;
133
271
  var PRESET_TUNING_GROUPS = [
134
272
  {
@@ -248,6 +386,202 @@ function setBeatSubdivision(measure, beat, subdivision) {
248
386
  return remapToSubdivisions(measure, subs);
249
387
  }
250
388
 
389
+ // ../src/lib/tracks.ts
390
+ function newUid() {
391
+ return newTabId();
392
+ }
393
+ function storedMeasure(m) {
394
+ const { uid: _uid, beatWidthOverride: _w, ...rest } = m;
395
+ return rest;
396
+ }
397
+ function openWorkspace(doc) {
398
+ if (doc.workspace) return doc;
399
+ const measures = doc.measures.map((m) => ({ ...m, uid: m.uid ?? newUid() }));
400
+ const extras = doc.tracks ?? [];
401
+ const metas = [
402
+ doc.mainTrack ?? defaultMainTrack(),
403
+ ...extras.map(({ tuning: _t, measures: _m, ...meta }) => meta)
404
+ ];
405
+ const parked = [
406
+ null,
407
+ ...extras.map(
408
+ (t) => ({
409
+ tuning: [...t.tuning],
410
+ measures: measures.map(
411
+ (main, i) => trackMeasureToMeasure(
412
+ main,
413
+ t.measures[i]?.subdivisions,
414
+ t.measures[i]?.cells,
415
+ t.tuning.length
416
+ )
417
+ )
418
+ })
419
+ )
420
+ ];
421
+ const { mainTrack: _mt, tracks: _tr, ...rest } = doc;
422
+ return { ...rest, measures, workspace: { active: 0, metas, parked } };
423
+ }
424
+ function trackMeasureToMeasure(main, subdivisions, cells, stringCount) {
425
+ const base = createMeasure(
426
+ stringCount,
427
+ main.beats,
428
+ subdivisions ?? main.subdivisions,
429
+ main.beatUnit
430
+ );
431
+ return {
432
+ ...base,
433
+ cells: base.cells.map((row, s) => row.map((_, c) => cells?.[s]?.[c] ?? null)),
434
+ uid: main.uid
435
+ };
436
+ }
437
+ function trackBodies(doc) {
438
+ const ws = doc.workspace;
439
+ if (!ws) return [{ tuning: doc.tuning, measures: doc.measures }];
440
+ return ws.parked.map(
441
+ (b, i) => i === ws.active ? { tuning: doc.tuning, measures: doc.measures } : b
442
+ );
443
+ }
444
+ function trackMetas(doc) {
445
+ return doc.workspace?.metas ?? [doc.mainTrack ?? defaultMainTrack()];
446
+ }
447
+ function mainMeasures(doc) {
448
+ return trackBodies(doc)[0].measures;
449
+ }
450
+ function closeWorkspace(doc) {
451
+ const ws = doc.workspace;
452
+ if (!ws) return doc;
453
+ const bodies = trackBodies(doc);
454
+ const main = bodies[0];
455
+ const {
456
+ workspace: _ws,
457
+ trackMismatch: _tm,
458
+ mainTrack: _mt,
459
+ tracks: _tr,
460
+ formatRevision,
461
+ ...rest
462
+ } = doc;
463
+ const out = {
464
+ ...rest,
465
+ tuning: [...main.tuning],
466
+ measures: main.measures.map(storedMeasure)
467
+ };
468
+ const extras = bodies.slice(1).map(
469
+ (b, i) => ({
470
+ ...ws.metas[i + 1],
471
+ tuning: [...b.tuning],
472
+ measures: b.measures.map((m) => ({ subdivisions: m.subdivisions, cells: m.cells }))
473
+ })
474
+ );
475
+ const mainMeta = ws.metas[0];
476
+ const mainIsDefault = mainMeta.name === DEFAULT_MAIN_TRACK_NAME && mainMeta.volume === 1 && !mainMeta.muted;
477
+ if (extras.length > 0 || !mainIsDefault) out.mainTrack = mainMeta;
478
+ if (extras.length > 0) out.tracks = extras;
479
+ let required = 1;
480
+ if (extras.length > 0) required = MULTI_TRACK_FORMAT_REVISION;
481
+ if (bodies.some((b) => measuresHaveHarmonics(b.measures))) required = HARMONICS_FORMAT_REVISION;
482
+ const revision = formatRevision != null && formatRevision > HARMONICS_FORMAT_REVISION ? formatRevision : required;
483
+ if (revision > 1) out.formatRevision = revision;
484
+ return out;
485
+ }
486
+ function reconcileTracks(prev, next) {
487
+ let doc = next.workspace ? next : openWorkspace(next);
488
+ const ws = doc.workspace;
489
+ const measures = assignUids(prev, doc.measures);
490
+ if (measures !== doc.measures) doc = { ...doc, measures };
491
+ let changed = false;
492
+ const parked = ws.parked.map((body) => {
493
+ if (!body) return body;
494
+ const fitted = fitBody(body, measures);
495
+ if (fitted !== body) changed = true;
496
+ return fitted;
497
+ });
498
+ if (changed) doc = { ...doc, workspace: { ...ws, parked } };
499
+ return doc;
500
+ }
501
+ function assignUids(prev, measures) {
502
+ const seen = /* @__PURE__ */ new Set();
503
+ let out = null;
504
+ const present = new Set(measures.map((m) => m.uid).filter(Boolean));
505
+ measures.forEach((m, i) => {
506
+ let uid = m.uid;
507
+ if (uid && seen.has(uid)) uid = void 0;
508
+ if (!uid) {
509
+ const old = prev?.measures[i]?.uid;
510
+ uid = old && !present.has(old) && !seen.has(old) ? old : newUid();
511
+ out ??= [...measures];
512
+ out[i] = { ...m, uid };
513
+ }
514
+ seen.add(uid);
515
+ });
516
+ return out ?? measures;
517
+ }
518
+ function fitBody(body, measures) {
519
+ const same = body.measures.length === measures.length && body.measures.every(
520
+ (m, i) => m.uid === measures[i].uid && m.beats === measures[i].beats && m.beatUnit === measures[i].beatUnit
521
+ );
522
+ if (same) return body;
523
+ const byUid = new Map(body.measures.map((m) => [m.uid, m]));
524
+ const stringCount = body.tuning.length;
525
+ const out = [];
526
+ measures.forEach((m) => {
527
+ let pm = byUid.get(m.uid);
528
+ if (!pm) {
529
+ const before = out[out.length - 1];
530
+ const subs = before && before.beats === m.beats ? before.subdivisions : void 0;
531
+ pm = createMeasure(stringCount, m.beats, subs, m.beatUnit);
532
+ } else if (pm.beats !== m.beats || pm.beatUnit !== m.beatUnit) {
533
+ pm = setMeasureTimeSignature(pm, m.beats, m.beatUnit);
534
+ }
535
+ out.push({ ...pm, uid: m.uid });
536
+ });
537
+ return { ...body, measures: out };
538
+ }
539
+ function switchTrack(doc, index) {
540
+ const ws = doc.workspace;
541
+ if (!ws || index === ws.active || index < 0 || index >= ws.metas.length) return doc;
542
+ const parked = [...ws.parked];
543
+ parked[ws.active] = { tuning: doc.tuning, measures: doc.measures };
544
+ const body = parked[index];
545
+ parked[index] = null;
546
+ return {
547
+ ...doc,
548
+ tuning: body.tuning,
549
+ measures: body.measures,
550
+ workspace: { ...ws, active: index, parked }
551
+ };
552
+ }
553
+ function addTrack(doc, name, tuning) {
554
+ const ws = doc.workspace;
555
+ if (!ws || ws.metas.length >= MAX_TRACKS) return doc;
556
+ const main = mainMeasures(doc);
557
+ const body = {
558
+ tuning: [...tuning],
559
+ measures: main.map((m) => ({
560
+ ...createMeasure(tuning.length, m.beats, m.subdivisions, m.beatUnit),
561
+ uid: m.uid
562
+ }))
563
+ };
564
+ const meta = { id: newTabId(), name, volume: 1, muted: false };
565
+ const added = {
566
+ ...doc,
567
+ workspace: { ...ws, metas: [...ws.metas, meta], parked: [...ws.parked, body] }
568
+ };
569
+ return switchTrack(added, ws.metas.length);
570
+ }
571
+ function removeTrack(doc, index) {
572
+ const ws = doc.workspace;
573
+ if (!ws || index <= 0 || index >= ws.metas.length) return doc;
574
+ const shown = ws.active === index ? switchTrack(doc, 0) : doc;
575
+ const w = shown.workspace;
576
+ const metas = w.metas.filter((_, i) => i !== index);
577
+ const parked = w.parked.filter((_, i) => i !== index);
578
+ const active = w.active > index ? w.active - 1 : w.active;
579
+ return { ...shown, workspace: { active, metas, parked } };
580
+ }
581
+ function storedSize(doc) {
582
+ return new TextEncoder().encode(JSON.stringify(closeWorkspace(doc))).length;
583
+ }
584
+
251
585
  // ../src/lib/config.ts
252
586
  var CLOUD_BASE_URL = "https://eztab-sync-736328841982.asia-northeast1.run.app";
253
587
 
@@ -293,69 +627,17 @@ var cloud = {
293
627
  async sendToInbox(payload) {
294
628
  await req("/inbox", {
295
629
  method: "POST",
296
- headers: { "content-type": "application/json" },
630
+ headers: {
631
+ "content-type": "application/json",
632
+ // The newest tab format this build edits safely; the server refuses
633
+ // newer tabs from older builds (docs/design/0001-multiple-tracks.md).
634
+ "x-eztab-format-revision": String(SUPPORTED_FORMAT_REVISION)
635
+ },
297
636
  body: JSON.stringify(payload)
298
637
  });
299
638
  }
300
639
  };
301
640
 
302
- // eztab.ts
303
- function normalize(data) {
304
- return normalizeDocument(data);
305
- }
306
- function cellText(cell) {
307
- if (cell === null) return "--";
308
- if (cell === "rest") return "-R";
309
- if (cell === "mute") return "-x";
310
- if (typeof cell === "object") {
311
- const bend = cell.bend ?? 0;
312
- if (cell.tie) return `~${cell.fret}`.padStart(2, "-").slice(-2);
313
- const mark = bend === 0.5 ? "b" : bend === 1.5 ? "^" : bend === 1 ? "B" : "";
314
- return `${cell.fret}${mark}`.padStart(2, "-").slice(-2);
315
- }
316
- return String(cell).padStart(2, "-");
317
- }
318
- function render(doc, from = 0, to = doc.measures.length - 1) {
319
- const lines = [];
320
- lines.push(`Title: ${doc.title}${doc.artist ? ` \u2014 ${doc.artist}` : ""} tempo=${doc.tempo}`);
321
- lines.push(`Tuning (top\u2192bottom): ${doc.tuning.join(" ")} measures=${doc.measures.length}`);
322
- const ann = [];
323
- doc.measures.forEach((_, m) => {
324
- const k = doc.keys.find((x) => x.measure === m);
325
- const s = doc.sections.find((x) => x.measure === m);
326
- if (k) ann.push(`m${m}:key=${k.tonic} ${k.mode}`);
327
- if (s && s.label) ann.push(`m${m}:section="${s.label}"`);
328
- });
329
- if (ann.length) lines.push(`Markers: ${ann.join(", ")}`);
330
- doc.measures.forEach((m, mi) => {
331
- if (mi < from || mi > to) return;
332
- const key = activeKeyAt(doc.keys, mi);
333
- const sub = uniformSubdivision(m);
334
- const subText = sub != null ? `${sub}/beat` : `[${m.subdivisions.join(",")}]/beat`;
335
- lines.push("");
336
- lines.push(
337
- `--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, ${subText}, ${columnCount(m)} cols, key ${key.tonic} ${key.mode}) ---`
338
- );
339
- const chordRow = (m.chords ?? []).map((label, c) => label ? `c${c}:${chordDisplay(label)}` : null).filter(Boolean).join(" ");
340
- lines.push(`chords: ${chordRow || "(none)"}`);
341
- const ruler = Array.from({ length: columnCount(m) }, (_, c) => String(c).padStart(2, " ")).join(
342
- " "
343
- );
344
- lines.push(`col: ${ruler}`);
345
- m.cells.forEach((row, s) => {
346
- const label = `${s + 1} ${doc.tuning[s] ?? "?"}`.padEnd(6, " ");
347
- const cells = row.map(cellText).join(" ");
348
- lines.push(`${label}| ${cells}`);
349
- });
350
- const pm = Array.from(
351
- { length: columnCount(m) },
352
- (_, c) => m.cells.some((row) => cellPalmMute(row[c] ?? null)) ? "PM" : " "
353
- );
354
- if (pm.some((x) => x !== " ")) lines.push(`pm: ${pm.join(" ")}`);
355
- });
356
- return lines.join("\n");
357
- }
358
-
359
641
  // notation.ts
360
642
  var BEAT_UNITS2 = [2, 4, 8, 16];
361
643
  var BENDS = { b: 0.5, B: 1, "^": 1.5 };
@@ -371,18 +653,44 @@ function parseTime(text2) {
371
653
  function parseValue(raw, where) {
372
654
  if (raw === "x") return "mute";
373
655
  if (raw === "r") return "rest";
374
- const m = /^(~?)(\d+)([bB^p]*)$/.exec(raw);
656
+ const m = /^(~?)(?:<(\d+(?:\.\d+)?)>|(\d+)(?:<(\d+(?:\.\d+)?)>)?)([bB^pah]*)$/.exec(raw);
375
657
  if (!m) throw new Error(`${where}: bad value "${raw}"`);
376
- const fret = Number(m[2]);
658
+ const natural = m[2] != null;
659
+ const at = natural ? Number(m[2]) : m[4] != null ? Number(m[4]) : void 0;
660
+ const fret = natural ? Math.round(Number(m[2])) : Number(m[3]);
377
661
  if (fret > MAX_FRET) throw new Error(`${where}: fret ${fret} is above ${MAX_FRET}`);
378
- const mods = m[3];
662
+ const mods = m[5];
379
663
  const bendMarks = [...mods].filter((c) => c in BENDS);
380
664
  if (bendMarks.length > 1) throw new Error(`${where}: more than one choke mark in "${raw}"`);
381
- const note = { fret };
382
- if (bendMarks.length) note.bend = BENDS[bendMarks[0]];
383
- if (m[1]) note.tie = true;
384
- if (mods.includes("p")) note.pm = true;
385
- return Object.keys(note).length === 1 ? fret : note;
665
+ const a = mods.includes("a");
666
+ const h = mods.includes("h");
667
+ if (a && h) throw new Error(`${where}: "a" and "h" cannot be combined in "${raw}"`);
668
+ if (natural && (a || h)) {
669
+ throw new Error(`${where}: a natural harmonic <pos> takes no "a"/"h" in "${raw}"`);
670
+ }
671
+ if (a && at != null) {
672
+ throw new Error(`${where}: "${raw}" \u2014 a position <pos> already means artificial; drop "a"`);
673
+ }
674
+ const harm = natural ? "natural" : h ? "pinch" : a || at != null ? "artificial" : void 0;
675
+ const checked = checkHarmonic(fret, harm, at);
676
+ if (!checked.ok) throw new Error(`${where}: ${checked.reason} in "${raw}"`);
677
+ const bend = bendMarks.length ? BENDS[bendMarks[0]] : 0;
678
+ return makeNoteCell(checked.value.fret, bend, !!m[1], mods.includes("p"), checked.value);
679
+ }
680
+ function noteText(cell) {
681
+ if (typeof cell === "number") return String(cell);
682
+ const bend = cell.bend ?? 0;
683
+ const bendMark = bend === 0.5 ? "b" : bend === 1.5 ? "^" : bend === 1 ? "B" : "";
684
+ let body;
685
+ let harmMark = "";
686
+ if (cell.harm === "natural") {
687
+ body = `<${cell.harmAt ?? cell.fret}>`;
688
+ } else {
689
+ body = cell.harm && cell.harmAt != null ? `${cell.fret}<${cell.harmAt}>` : String(cell.fret);
690
+ if (cell.harm === "pinch") harmMark = "h";
691
+ else if (cell.harm === "artificial" && cell.harmAt == null) harmMark = "a";
692
+ }
693
+ return `${cell.tie ? "~" : ""}${body}${bendMark}${harmMark}${cell.pm ? "p" : ""}`;
386
694
  }
387
695
  function parseBeat(text2, stringCount, where) {
388
696
  const slots = text2.trim().split(/\s+/);
@@ -441,6 +749,105 @@ function buildMeasure(spec, stringCount, fallback, where = "measure") {
441
749
  }
442
750
  return measure;
443
751
  }
752
+ function setCellNote(prev, e, where) {
753
+ if (e.harm === "none" && e.harmAt != null) {
754
+ throw new Error(`${where}: harmAt cannot be set with harm "none"`);
755
+ }
756
+ const sameNote = cellFret(prev) === e.fret;
757
+ const harm = e.harm === "none" ? void 0 : e.harm ?? (sameNote ? cellHarm(prev) : void 0);
758
+ const keptAt = e.harmAt == null && harm != null && sameNote ? cellHarmAt(prev) : void 0;
759
+ let checked = checkHarmonic(e.fret, harm, e.harmAt ?? keptAt);
760
+ if (!checked.ok && keptAt != null) checked = checkHarmonic(e.fret, harm, void 0);
761
+ if (!checked.ok) throw new Error(`${where}: ${checked.reason}`);
762
+ return makeNoteCell(checked.value.fret, e.bend ?? 0, !!e.tie, !!e.pm, checked.value);
763
+ }
764
+
765
+ // eztab.ts
766
+ function normalize(data) {
767
+ return normalizeDocument(data);
768
+ }
769
+ function cellText(cell) {
770
+ if (cell === null) return "";
771
+ if (cell === "rest") return "R";
772
+ if (cell === "mute") return "x";
773
+ return noteText(cell);
774
+ }
775
+ function render(doc, from = 0, to = doc.measures.length - 1, track) {
776
+ const bodies = trackBodies(doc);
777
+ const metas = trackMetas(doc);
778
+ const multi = bodies.length > 1;
779
+ const main = bodies[0];
780
+ const shown = bodies.map((_, i) => i).filter((i) => track == null || i === track);
781
+ const lines = [];
782
+ lines.push(`Title: ${doc.title}${doc.artist ? ` \u2014 ${doc.artist}` : ""} tempo=${doc.tempo}`);
783
+ if (multi) {
784
+ lines.push(`Tracks (track index: name, tuning top\u2192bottom) measures=${main.measures.length}`);
785
+ metas.forEach((t, i) => {
786
+ lines.push(
787
+ ` ${i}: ${t.name} ${bodies[i].tuning.join(" ")}${i === 0 ? " (main: carries the chords)" : ""}`
788
+ );
789
+ });
790
+ } else {
791
+ lines.push(`Tuning (top\u2192bottom): ${main.tuning.join(" ")} measures=${main.measures.length}`);
792
+ }
793
+ const ann = [];
794
+ main.measures.forEach((_, m) => {
795
+ const k = doc.keys.find((x) => x.measure === m);
796
+ const s = doc.sections.find((x) => x.measure === m);
797
+ if (k) ann.push(`m${m}:key=${k.tonic} ${k.mode}`);
798
+ if (s && s.label) ann.push(`m${m}:section="${s.label}"`);
799
+ });
800
+ if (ann.length) lines.push(`Markers: ${ann.join(", ")}`);
801
+ main.measures.forEach((m, mi) => {
802
+ if (mi < from || mi > to) return;
803
+ const key = activeKeyAt(doc.keys, mi);
804
+ lines.push("");
805
+ if (!multi) {
806
+ lines.push(
807
+ `--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, ${subText(m)}, ${columnCount(m)} cols, key ${key.tonic} ${key.mode}) ---`
808
+ );
809
+ lines.push(chordLine(m));
810
+ measureRows(lines, m, main.tuning);
811
+ return;
812
+ }
813
+ lines.push(
814
+ `--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, key ${key.tonic} ${key.mode}) ---`
815
+ );
816
+ lines.push(`${chordLine(m)} (columns of track 0)`);
817
+ for (const t of shown) {
818
+ const tm = bodies[t].measures[mi];
819
+ lines.push(`[track ${t}: ${metas[t].name}] ${subText(tm)}, ${columnCount(tm)} cols`);
820
+ measureRows(lines, tm, bodies[t].tuning);
821
+ }
822
+ });
823
+ return lines.join("\n");
824
+ }
825
+ function subText(m) {
826
+ const sub = uniformSubdivision(m);
827
+ return sub != null ? `${sub}/beat` : `[${m.subdivisions.join(",")}]/beat`;
828
+ }
829
+ function chordLine(m) {
830
+ const row = (m.chords ?? []).map((label, c) => label ? `c${c}:${chordDisplay(label)}` : null).filter(Boolean).join(" ");
831
+ return `chords: ${row || "(none)"}`;
832
+ }
833
+ function measureRows(lines, m, tuning) {
834
+ const texts = m.cells.map((row) => row.map(cellText));
835
+ const width = Math.max(2, ...texts.flat().map((t) => t.length));
836
+ const ruler = Array.from(
837
+ { length: columnCount(m) },
838
+ (_, c) => String(c).padStart(width, " ")
839
+ ).join(" ");
840
+ lines.push(`col: ${ruler}`);
841
+ texts.forEach((row, s) => {
842
+ const label = `${s + 1} ${tuning[s] ?? "?"}`.padEnd(6, " ");
843
+ lines.push(`${label}| ${row.map((t) => t.padStart(width, "-")).join(" ")}`);
844
+ });
845
+ const pm = Array.from(
846
+ { length: columnCount(m) },
847
+ (_, c) => (m.cells.some((row) => cellPalmMute(row[c] ?? null)) ? "PM" : "").padStart(width, " ")
848
+ );
849
+ if (pm.some((x) => x.trim() !== "")) lines.push(`pm: ${pm.join(" ")}`);
850
+ }
444
851
 
445
852
  // prompts.ts
446
853
  var IMPORT_TAB_FROM_IMAGE = `Transcribe the TAB in the attached image(s) into an EZTab document with the eztab tools.
@@ -449,6 +856,7 @@ var IMPORT_TAB_FROM_IMAGE = `Transcribe the TAB in the attached image(s) into an
449
856
  - Identify which staff to take. A band score has several parts (Vocal, Guitar I/II, Keyboard, Bass, Drums). If a part was not named and more than one has TAB, list the parts you see and ask which one \u2014 do not guess.
450
857
  - From the image work out: instrument and string count (6 lines = guitar, 4 = bass), tuning if printed (assume standard otherwise), time signature, key signature, tempo mark, and how many bars you will transcribe. Printed bar numbers (e.g. 35, 36 \u2026) are just labels \u2014 the document starts at measure 0.
451
858
  - 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.
859
+ - 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.
452
860
  - Set the key with set_key when there is a key signature (1 flat \u2192 F major, or D minor if the harmony says so), and the tempo with set_tempo when one is printed.
453
861
 
454
862
  ## 2. Write bar by bar with write_measures
@@ -464,7 +872,12 @@ Read each bar's rhythm first (beams, flags, dots, tuplet brackets, rests), then
464
872
  - Choke / bend (C, Cho., "full", "1/2", up arrow): S:FB for full, S:Fb for \xBD, S:F^ for 1\xBD. A release / down choke: write the returning note tied.
465
873
  - Bridge mute (M, P.M. bracket in Japanese band scores = \u30DF\u30E5\u30FC\u30C8): add p to every note under the bracket.
466
874
  - Dead notes / brushing (x): S:x.
467
- - Natural/artificial harmonics (o, Harm., <7>): plain fret. Vibrato, slide-in from nowhere (/6), slide-out, arpeggio wavy line, grace notes: write the main note only.
875
+ - Harmonics:
876
+ - Natural harmonic (fret in angle brackets <12>, <7>, "Harm.", "N.H.", a diamond notehead): S:<12>. Between frets (<2.4>, <3.2>, <5.8>): S:<2.4> as printed.
877
+ - Artificial harmonic (7<19>, "A.H.", "T.H." with the fretted note and the touch point): S:7<19>. Only the fretted note printed with "A.H." / "T.H.": S:7a.
878
+ - Pinch harmonic ("P.H." bracket, like P.M.): add h to every note under it, S:7h; with a printed position, S:7<19>h.
879
+ - "AH" / "A.H." is used for both artificial and pinch harmonics. Decide from context: a picked note under a P.H.-like bracket or marked over a distorted riff is pinch (h); a tapped / touched octave (typically +12) on a clean part is artificial. If unsure, choose one and list those bars in the report.
880
+ - Vibrato, slide-in from nowhere (/6), slide-out, arpeggio wavy line, grace notes: write the main note only.
468
881
  - Chord diagrams / chord-form boxes drawn inside the TAB staff (no fret numbers): write the chord name on the chord row. Write the voicing as notes only if the user asks or the diagram is unambiguous.
469
882
  - Repeats, D.S., Coda, 1st/2nd endings: transcribe the bars as printed and tell the user; do not expand them unless asked.
470
883
 
@@ -480,9 +893,9 @@ var state = {
480
893
  source: null
481
894
  };
482
895
  var server = new McpServer(
483
- { name: "eztab", version: "0.2.0" },
896
+ { name: "eztab", version: "0.4.0" },
484
897
  {
485
- instructions: "All indices are 0-based: measure 0 is the first measure, string 0 is the top (highest) string, and column 0 is the first column. Notes and chords share the same column grid. Subdivisions are per beat and can differ within a measure (e.g. [2,2,3,2] mixes a triplet beat into straight eighths), so columns per measure = the sum of its per-beat subdivisions; get_tab's ruler shows the column indices. add_measures `at` is a 0-based insert position too. No need to ask the user about indexing. Exception: write_measures uses guitar string numbers (1 = top line), matching the labels get_tab prints. To write whole measures (e.g. transcribing a score or an image), prefer write_measures over set_cells \u2014 it takes one string per beat and does the column arithmetic for you. For transcribing from an image, follow the import_tab_from_image prompt. To deliver a result to the EZTab app, call send_to_eztab; to fix an existing tab, list_tabs \u2192 open_tab \u2192 edit \u2192 send_to_eztab."
898
+ 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."
486
899
  }
487
900
  );
488
901
  function text(s) {
@@ -494,24 +907,44 @@ function requireDoc() {
494
907
  }
495
908
  return state.doc;
496
909
  }
497
- function applyEdit(mutate) {
498
- const doc = requireDoc();
910
+ function onTrack(doc, track = 0) {
911
+ const n = trackMetas(doc).length;
912
+ if (!Number.isInteger(track) || track < 0 || track >= n) {
913
+ throw new Error(`track ${track} out of range (0..${n - 1}); get_tab lists the tracks`);
914
+ }
915
+ return switchTrack(doc, track);
916
+ }
917
+ function edit(track, mutate) {
918
+ const current = requireDoc();
919
+ if ((current.formatRevision ?? 1) > SUPPORTED_FORMAT_REVISION) {
920
+ throw new Error(
921
+ "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)."
922
+ );
923
+ }
924
+ const before = onTrack(current, track);
925
+ const doc = structuredClone(before);
499
926
  mutate(doc);
927
+ state.doc = reconcileTracks(before, doc);
500
928
  state.version += 1;
501
- return text(render(doc));
929
+ return state.doc;
502
930
  }
503
- function applyEditRange(mutate) {
504
- const doc = structuredClone(requireDoc());
505
- const [from, to] = mutate(doc);
506
- state.doc = doc;
507
- state.version += 1;
508
- return text(render(doc, from, to));
931
+ function applyEdit(mutate, track) {
932
+ return text(render(edit(track, mutate)));
933
+ }
934
+ function applyEditRange(mutate, track) {
935
+ let range = [0, 0];
936
+ const doc = edit(track, (d) => {
937
+ range = mutate(d);
938
+ });
939
+ return text(render(doc, range[0], range[1], track));
509
940
  }
510
941
  function setDoc(doc) {
511
- state.doc = doc;
942
+ state.doc = openWorkspace(doc);
512
943
  state.version += 1;
513
- return text(render(doc));
944
+ return text(render(state.doc));
514
945
  }
946
+ 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.";
947
+ var trackParam = z.number().int().min(0).optional().describe("0-based track (part) index from get_tab; default 0, the main track.");
515
948
  function assertMeasure(doc, m) {
516
949
  if (!Number.isInteger(m) || m < 0 || m >= doc.measures.length) {
517
950
  throw new Error(`measure ${m} out of range (0..${doc.measures.length - 1})`);
@@ -522,10 +955,14 @@ server.tool(
522
955
  "Return a readable rendering of the document currently under edit in this session. `from`/`to` (0-based, inclusive) limit it to a range of measures. If nothing has been created yet, say so and offer new_tab.",
523
956
  {
524
957
  from: z.number().int().min(0).optional(),
525
- to: z.number().int().min(0).optional()
958
+ to: z.number().int().min(0).optional(),
959
+ track: trackParam.describe("Show only this track (default: all tracks).")
526
960
  },
527
961
  async (a) => {
528
- if (state.doc) return text(render(state.doc, a.from, a.to));
962
+ if (state.doc) {
963
+ if (a.track != null) onTrack(state.doc, a.track);
964
+ return text(render(state.doc, a.from, a.to, a.track));
965
+ }
529
966
  return text("No document is open in this session. Call new_tab to start one.");
530
967
  }
531
968
  );
@@ -585,7 +1022,17 @@ server.tool(
585
1022
  if (!meta) throw new Error(`No tab with id ${a.id}. Use list_tabs to find it.`);
586
1023
  const doc = normalize(await cloud.get(a.id));
587
1024
  state.source = { tabId: a.id, title: meta.title, baseUpdatedAt: meta.updatedAt };
588
- return setDoc(doc);
1025
+ const shown = setDoc(doc);
1026
+ if (doc.trackMismatch) {
1027
+ shown.content.unshift({ type: "text", text: MISMATCH_NOTE });
1028
+ }
1029
+ if ((doc.formatRevision ?? 1) > SUPPORTED_FORMAT_REVISION) {
1030
+ shown.content.unshift({
1031
+ type: "text",
1032
+ 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."
1033
+ });
1034
+ }
1035
+ return shown;
589
1036
  })
590
1037
  );
591
1038
  server.tool(
@@ -593,12 +1040,30 @@ server.tool(
593
1040
  "Send the current document to the EZTab app's inbox. The user then chooses in the app what to do with it \u2014 insert its measures at the cursor, create a new tab, or (for a document opened with open_tab) replace that tab \u2014 and it stays unsaved until they press Save. Call this once the document has been checked with get_tab.",
594
1041
  {
595
1042
  label: z.string().optional().describe("Short description shown in the app, e.g. 'Guitar II, bars 1\u20138'."),
1043
+ tracks_checked: z.boolean().optional().describe(
1044
+ "Set to true after checking with the user that the extra tracks line up, when open_tab warned that they may be shifted."
1045
+ ),
596
1046
  as_new: z.boolean().optional().describe(
597
1047
  "For a document opened with open_tab: send it as new material instead of a revision of that tab."
598
1048
  )
599
1049
  },
600
1050
  async (a) => withCloud(async () => {
601
- const doc = requireDoc();
1051
+ if (requireDoc().trackMismatch && !a.tracks_checked) {
1052
+ throw new Error(
1053
+ `${MISMATCH_NOTE} Not sent: confirm the alignment, then call send_to_eztab again with tracks_checked: true.`
1054
+ );
1055
+ }
1056
+ const doc = closeWorkspace(requireDoc());
1057
+ if ((doc.formatRevision ?? 1) > SUPPORTED_FORMAT_REVISION) {
1058
+ throw new Error(
1059
+ "This tab was saved by a newer EZTab; update the EZTab MCP server before sending it."
1060
+ );
1061
+ }
1062
+ if (storedSize(doc) > 4.5 * 1024 * 1024) {
1063
+ throw new Error(
1064
+ "The tab is too large to send (over 4.5 MB). Remove measures or tracks, or send part of it as new material."
1065
+ );
1066
+ }
602
1067
  const target = state.source && !a.as_new ? state.source : null;
603
1068
  await cloud.sendToInbox({
604
1069
  doc,
@@ -628,7 +1093,7 @@ server.tool(
628
1093
  );
629
1094
  server.tool(
630
1095
  "set_cells",
631
- "Set tab cells. value: a fret number (0-24), 'rest' (stops a sustaining note), 'mute' (a muted/dead 'x' stroke for cutting), or 'clear' (empty = previous note sustains through). Optional `bend` (0.5, 1, or 1.5 semitones) chokes a fret-number note. Optional `tie` (boolean) ties/slurs a fret-number note to the previous note on the same string \u2014 a matching pitch just sustains (no re-pick), a different pitch is a slur. Optional `pm` (boolean) palm-mutes (bridge-mutes) a fret-number note. measure/string/column are all 0-based (measure 0 = the first measure, string 0 = the top line).",
1096
+ "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. Optional `harm` makes it a harmonic: 'natural' (touch the open string at `harmAt`, or at the fret when omitted \u2014 `harmAt` may be between frets, e.g. 2.4, and then the fret is its rounded value), 'artificial' / 'pinch' (fret the note, harmonic at `harmAt` above it; pinch without `harmAt` = wherever it is picked), or 'none' to remove it. When `harm` is omitted and the cell already holds a harmonic at the same fret, the harmonic is kept. measure/string/column are all 0-based (measure 0 = the first measure, string 0 = the top line).",
632
1097
  {
633
1098
  edits: z.array(
634
1099
  z.object({
@@ -638,9 +1103,12 @@ server.tool(
638
1103
  value: z.union([z.number().int().min(0).max(24), z.enum(["rest", "mute", "clear"])]),
639
1104
  bend: z.union([z.literal(0.5), z.literal(1), z.literal(1.5)]).optional(),
640
1105
  tie: z.boolean().optional(),
641
- pm: z.boolean().optional()
1106
+ pm: z.boolean().optional(),
1107
+ harm: z.enum(["natural", "artificial", "pinch", "none"]).optional(),
1108
+ harmAt: z.number().optional()
642
1109
  })
643
- )
1110
+ ),
1111
+ track: trackParam
644
1112
  },
645
1113
  async (a) => applyEdit((doc) => {
646
1114
  for (const e of a.edits) {
@@ -655,22 +1123,25 @@ server.tool(
655
1123
  `column ${e.column} out of range for measure ${e.measure} (0..${cols - 1})`
656
1124
  );
657
1125
  }
658
- let value = e.value === "clear" ? null : e.value;
659
- if (typeof e.value === "number" && (e.bend != null || e.tie || e.pm)) {
660
- value = {
661
- fret: e.value,
662
- ...e.bend != null ? { bend: e.bend } : {},
663
- ...e.tie ? { tie: true } : {},
664
- ...e.pm ? { pm: true } : {}
665
- };
1126
+ const where = `measure ${e.measure} string ${e.string} column ${e.column}`;
1127
+ if (typeof e.value !== "number") {
1128
+ if (e.harm != null || e.harmAt != null) {
1129
+ throw new Error(`${where}: harm / harmAt need a fret-number value`);
1130
+ }
1131
+ m.cells[e.string][e.column] = e.value === "clear" ? null : e.value;
1132
+ continue;
666
1133
  }
667
- m.cells[e.string][e.column] = value;
1134
+ m.cells[e.string][e.column] = setCellNote(
1135
+ m.cells[e.string][e.column],
1136
+ { fret: e.value, bend: e.bend, tie: e.tie, pm: e.pm, harm: e.harm, harmAt: e.harmAt },
1137
+ where
1138
+ );
668
1139
  }
669
- })
1140
+ }, a.track)
670
1141
  );
671
1142
  server.tool(
672
1143
  "write_measures",
673
- 'Write whole measures, one text string per beat \u2014 the easiest way to transcribe a score. Each listed measure is replaced entirely (notes, chords, per-beat subdivisions). `measure` is the 0-based index; using the current measure count appends, so a transcription can be written in order without add_measures. `time` (e.g. "4/4", "6/8") is optional: it defaults to the measure being replaced, or the previous measure when appending. `beats` has exactly one string per beat of the time signature.\n\nA beat string is space-separated slots; the slot count is that beat\'s subdivision (1 = quarter, 2 = eighths, 3 = eighth triplet, 4 = sixteenths, 6 = sixteenth triplet \u2026). Pick the smallest count that puts every onset of the beat on a slot (a dotted eighth + sixteenth = 4 slots: note - - note). A slot is "-" (nothing starts there; the previous note rings on) or items joined by "+":\n S:F string S (1 = top line of the TAB, as printed) at fret F\n S:~F tied / slurred from the previous note on that string (hammer-on, pull-off, slide into F, or a tie when F is the same fret)\n S:Fb / S:FB / S:F^ choke (bend) \xBD / 1 / 1\xBD steps\n S:Fp palm (bridge) mute; combines, e.g. S:~5p, S:7Bp\n S:x muted dead-note stroke (brushing)\n S:r rest: stops the note ringing on that string\n @Name chord name onset for the chord row (e.g. @Am7, @N.C.)\nExamples: ["6:5 6:7", "5:5 5:7", "4:5 - 4:7", "@F+4:3+3:2+2:1+1:1"].',
1144
+ '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:<P> natural harmonic touched at fret P (S:<12>, S:<7>; between frets S:<2.4>)\n S:F<P> artificial harmonic: fret F, harmonic at P (S:7<19>); S:Fa = artificial, position not written\n S:Fh pinch harmonic (P.H.); S:F<P>h = pinch harmonic at P (S:7<19>h)\n S:x muted dead-note stroke (brushing)\n S:r rest: stops the note ringing on that string\n @Name chord name onset for the chord row (e.g. @Am7, @N.C.)\nExamples: ["6:5 6:7", "5:5 5:7", "4:5 - 4:7", "@F+4:3+3:2+2:1+1:1"].',
674
1145
  {
675
1146
  measures: z.array(
676
1147
  z.object({
@@ -678,7 +1149,10 @@ server.tool(
678
1149
  time: z.string().optional(),
679
1150
  beats: z.array(z.string()).min(1)
680
1151
  })
681
- ).min(1)
1152
+ ).min(1),
1153
+ track: trackParam.describe(
1154
+ "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."
1155
+ )
682
1156
  },
683
1157
  async (a) => applyEditRange((doc) => {
684
1158
  let lo = Infinity;
@@ -698,17 +1172,30 @@ server.tool(
698
1172
  fallback,
699
1173
  `measure ${i}`
700
1174
  );
701
- if (i === doc.measures.length) doc.measures.push(built);
1175
+ const existing = doc.measures[i];
1176
+ if (a.track) {
1177
+ if (built.chords?.some((c) => c)) {
1178
+ throw new Error(
1179
+ `measure ${i}: chord items (@Name) can only be written on track 0, which carries the chords`
1180
+ );
1181
+ }
1182
+ if (existing && (existing.beats !== built.beats || existing.beatUnit !== built.beatUnit)) {
1183
+ throw new Error(
1184
+ `measure ${i}: the time signature is shared by all tracks; change it on track 0 (time "${existing.beats}/${existing.beatUnit}" here)`
1185
+ );
1186
+ }
1187
+ }
1188
+ if (!existing) doc.measures.push(built);
702
1189
  else doc.measures[i] = built;
703
1190
  lo = Math.min(lo, i);
704
1191
  hi = Math.max(hi, i);
705
1192
  }
706
1193
  return [lo, hi];
707
- })
1194
+ }, a.track)
708
1195
  );
709
1196
  server.tool(
710
1197
  "set_chords",
711
- 'Set chord-name onsets per column (the same time grid as tab cells, so chords can sit on any subdivision). `measure` and `column` are 0-based; column = the sum of earlier beats\' subdivisions + slot-within-beat (see get_tab\'s ruler). A label of "rest" (or "N.C.") places a chord rest that stops the previous chord (rendered as N.C.). An empty label clears that column\'s chord.',
1198
+ '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.',
712
1199
  {
713
1200
  edits: z.array(
714
1201
  z.object({
@@ -787,7 +1274,7 @@ server.tool(
787
1274
  );
788
1275
  server.tool(
789
1276
  "set_time_signature",
790
- "Set a measure's time signature, e.g. beats=6 beatUnit=8 for 6/8. Resizes the measure, preserving cells/chords by index.",
1277
+ "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.",
791
1278
  {
792
1279
  measure: z.number().int(),
793
1280
  beats: z.number().int().min(1).max(32),
@@ -810,7 +1297,10 @@ server.tool(
810
1297
  {
811
1298
  measure: z.number().int(),
812
1299
  subdivisions: z.number().int().min(1).max(12),
813
- beat: z.number().int().min(0).optional()
1300
+ beat: z.number().int().min(0).optional(),
1301
+ track: trackParam.describe(
1302
+ "0-based track whose grid to change; default 0. Each track has its own subdivisions."
1303
+ )
814
1304
  },
815
1305
  async (a) => applyEdit((doc) => {
816
1306
  assertMeasure(doc, a.measure);
@@ -825,11 +1315,11 @@ server.tool(
825
1315
  } else {
826
1316
  doc.measures[a.measure] = setMeasureSubdivisions(m, a.subdivisions);
827
1317
  }
828
- })
1318
+ }, a.track)
829
1319
  );
830
1320
  server.tool(
831
1321
  "add_measures",
832
- "Insert `count` empty measures. Without `at`, appends; otherwise inserts before index `at`.",
1322
+ "Insert `count` empty measures into every track. Without `at`, appends; otherwise inserts before index `at`.",
833
1323
  {
834
1324
  count: z.number().int().min(1).max(256).optional(),
835
1325
  at: z.number().int().optional()
@@ -846,7 +1336,7 @@ server.tool(
846
1336
  );
847
1337
  server.tool(
848
1338
  "remove_measure",
849
- "Remove the measure at index `measure` (at least one is kept).",
1339
+ "Remove the measure at index `measure` from every track (at least one is kept).",
850
1340
  { measure: z.number().int() },
851
1341
  async (a) => applyEdit((doc) => {
852
1342
  assertMeasure(doc, a.measure);
@@ -859,11 +1349,54 @@ server.tool(
859
1349
  server.tool(
860
1350
  "set_tuning",
861
1351
  "Replace the tuning / string count. Existing frets are preserved where strings overlap. Order is top (highest) to bottom (lowest).",
862
- { tuning: z.array(z.string()).min(1) },
1352
+ { tuning: z.array(z.string()).min(1), track: trackParam },
863
1353
  async (a) => applyEdit((doc) => {
864
1354
  doc.tuning = [...a.tuning];
865
1355
  doc.measures = doc.measures.map((m) => resizeMeasureStrings(m, a.tuning.length));
866
- })
1356
+ }, a.track)
1357
+ );
1358
+ var TRACK_PRESETS = [
1359
+ "Guitar (6)",
1360
+ "Guitar (7)",
1361
+ "Bass (4)",
1362
+ "Bass (5)",
1363
+ "Bass (6)",
1364
+ "Ukulele (4)"
1365
+ ];
1366
+ server.tool(
1367
+ "add_track",
1368
+ "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.",
1369
+ {
1370
+ name: z.string().min(1).describe("Shown in the app, e.g. 'Bass' or 'Guitar 2'."),
1371
+ preset: z.enum(TRACK_PRESETS).optional(),
1372
+ tuning: z.array(z.string()).min(1).optional().describe("Top (highest) to bottom; overrides preset.")
1373
+ },
1374
+ async (a) => {
1375
+ const before = trackMetas(requireDoc()).length;
1376
+ const tuning = a.tuning ?? PRESET_TUNINGS[a.preset ?? "Bass (4)"];
1377
+ const doc = edit(0, () => {
1378
+ });
1379
+ const next = addTrack(doc, a.name, tuning);
1380
+ if (trackMetas(next).length === before) {
1381
+ throw new Error("a tab can hold at most 4 tracks");
1382
+ }
1383
+ state.doc = next;
1384
+ return text(`Added track ${before} "${a.name}".
1385
+
1386
+ ${render(next)}`);
1387
+ }
1388
+ );
1389
+ server.tool(
1390
+ "remove_track",
1391
+ "Remove a track (part) and its notes. Track 0 (the main track) cannot be removed.",
1392
+ { track: z.number().int().min(1) },
1393
+ async (a) => {
1394
+ const doc = edit(0, () => {
1395
+ });
1396
+ onTrack(doc, a.track);
1397
+ state.doc = removeTrack(doc, a.track);
1398
+ return text(render(state.doc));
1399
+ }
867
1400
  );
868
1401
  server.prompt(
869
1402
  "import_tab_from_image",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hidechae/eztab-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for EZTab \u2014 lets an AI agent build and edit guitar/bass tablature.",
5
5
  "type": "module",
6
6
  "bin": {