@hidechae/eztab-mcp 0.4.1 → 0.5.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 +1 -1
  2. package/dist/server.js +117 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -96,7 +96,7 @@ Run standalone for testing: `task mcp` (or `npx tsx mcp/server.ts`).
96
96
  | `set_chords` | Set chord labels per column |
97
97
  | `set_key` / `remove_key` | Set/add or remove a key (key change) at a measure |
98
98
  | `set_section` | Set/remove a section marker (e.g. "Verse", "Chorus") |
99
- | `set_tempo` | Set playback BPM |
99
+ | `set_tempo` / `remove_tempo` | Set the start BPM, or set/remove a tempo change at a measure |
100
100
  | `set_time_signature` | Set a measure's time signature |
101
101
  | `set_resolution` | Set a measure's subdivisions per beat (8th/16th/triplets/quintuplets) |
102
102
  | `add_measures` / `remove_measure` | Insert/remove measures (keys & sections re-anchored) |
package/dist/server.js CHANGED
@@ -7,12 +7,15 @@ 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;
10
+ var SUPPORTED_FORMAT_REVISION = 4;
11
11
  var MULTI_TRACK_FORMAT_REVISION = 2;
12
12
  var HARMONICS_FORMAT_REVISION = 3;
13
+ var TEMPO_CHANGES_FORMAT_REVISION = 4;
13
14
  var MAX_TRACKS = 4;
14
15
  var DEFAULT_MAIN_TRACK_NAME = "Guitar";
15
16
  var DEFAULT_TEMPO = 120;
17
+ var MIN_TEMPO = 20;
18
+ var MAX_TEMPO = 400;
16
19
  var DEFAULT_MIX = { melody: 1, backing: 0.4 };
17
20
  function shiftSectionsForInsert(sections, index) {
18
21
  return sections.map((s) => s.measure >= index ? { ...s, measure: s.measure + 1 } : s);
@@ -28,6 +31,68 @@ function activeKeyAt(keys, measure) {
28
31
  }
29
32
  return result;
30
33
  }
34
+ function tempoSourceAt(doc, measure) {
35
+ let result = 0;
36
+ for (const t of doc.tempoChanges ?? []) {
37
+ if (t.measure <= measure) result = t.measure;
38
+ else break;
39
+ }
40
+ return result;
41
+ }
42
+ function validTempo(raw) {
43
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return null;
44
+ const bpm = Math.round(raw);
45
+ return bpm >= MIN_TEMPO && bpm <= MAX_TEMPO ? bpm : null;
46
+ }
47
+ function normalizeTempoChanges(tempo, raw, measureCount) {
48
+ if (!Array.isArray(raw)) return void 0;
49
+ const byMeasure = /* @__PURE__ */ new Map();
50
+ for (const t of raw) {
51
+ const measure = t?.measure;
52
+ const bpm = validTempo(t?.bpm);
53
+ if (!Number.isInteger(measure) || measure < 1 || measure >= measureCount || bpm === null) {
54
+ continue;
55
+ }
56
+ byMeasure.set(measure, bpm);
57
+ }
58
+ const out = [];
59
+ let prev = tempo;
60
+ for (const measure of [...byMeasure.keys()].sort((a, b) => a - b)) {
61
+ const bpm = byMeasure.get(measure);
62
+ if (bpm === prev) continue;
63
+ out.push({ measure, bpm });
64
+ prev = bpm;
65
+ }
66
+ return out.length > 0 ? out : void 0;
67
+ }
68
+ function withNormalizedTempoChanges(doc) {
69
+ const changes = normalizeTempoChanges(doc.tempo, doc.tempoChanges, doc.measures.length);
70
+ const { tempoChanges: _t, ...rest } = doc;
71
+ return changes ? { ...rest, tempoChanges: changes } : rest;
72
+ }
73
+ function shiftTempoChangesForInsert(doc, index) {
74
+ if (!doc.tempoChanges) return doc;
75
+ return {
76
+ ...doc,
77
+ tempoChanges: doc.tempoChanges.map(
78
+ (t) => t.measure >= index ? { ...t, measure: t.measure + 1 } : t
79
+ )
80
+ };
81
+ }
82
+ function shiftTempoChangesForDelete(doc, index) {
83
+ if (!doc.tempoChanges) return doc;
84
+ let tempo = doc.tempo;
85
+ const changes = [];
86
+ for (const t of doc.tempoChanges) {
87
+ if (t.measure === index) continue;
88
+ const measure = t.measure > index ? t.measure - 1 : t.measure;
89
+ if (measure === 0) tempo = t.bpm;
90
+ else changes.push({ ...t, measure });
91
+ }
92
+ const canonical = normalizeTempoChanges(tempo, changes, Number.POSITIVE_INFINITY);
93
+ const { tempoChanges: _t, ...rest } = doc;
94
+ return canonical ? { ...rest, tempo, tempoChanges: canonical } : { ...rest, tempo };
95
+ }
31
96
  function shiftKeysForInsert(keys, index) {
32
97
  const shifted = keys.map((k) => k.measure >= index ? { ...k, measure: k.measure + 1 } : k);
33
98
  if (!shifted.some((k) => k.measure === 0)) {
@@ -211,6 +276,9 @@ function normalizeDocument(data) {
211
276
  if (typeof data.artist !== "string") data.artist = "";
212
277
  if (!Array.isArray(data.sections)) data.sections = [];
213
278
  if (typeof data.tempo !== "number") data.tempo = DEFAULT_TEMPO;
279
+ const tempoChanges = normalizeTempoChanges(data.tempo, data.tempoChanges, data.measures.length);
280
+ if (tempoChanges) data.tempoChanges = tempoChanges;
281
+ else delete data.tempoChanges;
214
282
  if (!data.mix || typeof data.mix.melody !== "number") {
215
283
  data.mix = { ...DEFAULT_MIX };
216
284
  }
@@ -458,8 +526,10 @@ function closeWorkspace(doc) {
458
526
  mainTrack: _mt,
459
527
  tracks: _tr,
460
528
  formatRevision,
529
+ tempoChanges: rawTempoChanges,
461
530
  ...rest
462
531
  } = doc;
532
+ const tempoChanges = normalizeTempoChanges(doc.tempo, rawTempoChanges, main.measures.length);
463
533
  const out = {
464
534
  ...rest,
465
535
  tuning: [...main.tuning],
@@ -476,10 +546,14 @@ function closeWorkspace(doc) {
476
546
  const mainIsDefault = mainMeta.name === DEFAULT_MAIN_TRACK_NAME && mainMeta.volume === 1 && !mainMeta.muted;
477
547
  if (extras.length > 0 || !mainIsDefault) out.mainTrack = mainMeta;
478
548
  if (extras.length > 0) out.tracks = extras;
549
+ if (tempoChanges) out.tempoChanges = tempoChanges;
479
550
  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;
551
+ if (extras.length > 0) required = Math.max(required, MULTI_TRACK_FORMAT_REVISION);
552
+ if (bodies.some((b) => measuresHaveHarmonics(b.measures))) {
553
+ required = Math.max(required, HARMONICS_FORMAT_REVISION);
554
+ }
555
+ if (tempoChanges) required = Math.max(required, TEMPO_CHANGES_FORMAT_REVISION);
556
+ const revision = formatRevision != null && formatRevision > SUPPORTED_FORMAT_REVISION ? formatRevision : required;
483
557
  if (revision > 1) out.formatRevision = revision;
484
558
  return out;
485
559
  }
@@ -808,24 +882,28 @@ function render(doc, from = 0, to = doc.measures.length - 1, track) {
808
882
  main.measures.forEach((_, m) => {
809
883
  const k = doc.keys.find((x) => x.measure === m);
810
884
  const s = doc.sections.find((x) => x.measure === m);
885
+ const t = doc.tempoChanges?.find((x) => x.measure === m);
811
886
  if (k) ann.push(`m${m}:key=${k.tonic} ${k.mode}`);
887
+ if (t) ann.push(`m${m}:tempo=${t.bpm}`);
812
888
  if (s && s.label) ann.push(`m${m}:section="${s.label}"`);
813
889
  });
814
890
  if (ann.length) lines.push(`Markers: ${ann.join(", ")}`);
815
891
  main.measures.forEach((m, mi) => {
816
892
  if (mi < from || mi > to) return;
817
893
  const key = activeKeyAt(doc.keys, mi);
894
+ const change = doc.tempoChanges?.find((x) => x.measure === mi);
895
+ const tempoNote = change ? `, tempo ${change.bpm}` : "";
818
896
  lines.push("");
819
897
  if (!multi) {
820
898
  lines.push(
821
- `--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, ${subText(m)}, ${columnCount(m)} cols, key ${key.tonic} ${key.mode}) ---`
899
+ `--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, ${subText(m)}, ${columnCount(m)} cols, key ${key.tonic} ${key.mode}${tempoNote}) ---`
822
900
  );
823
901
  lines.push(chordLine(m));
824
902
  measureRows(lines, m, main.tuning);
825
903
  return;
826
904
  }
827
905
  lines.push(
828
- `--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, key ${key.tonic} ${key.mode}) ---`
906
+ `--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, key ${key.tonic} ${key.mode}${tempoNote}) ---`
829
907
  );
830
908
  lines.push(`${chordLine(m)} (columns of track 0)`);
831
909
  for (const t of shown) {
@@ -871,7 +949,7 @@ var IMPORT_TAB_FROM_IMAGE = `Transcribe the TAB in the attached image(s) into an
871
949
  - 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.
872
950
  - 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.
873
951
  - 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.
874
- - 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.
952
+ - 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. A tempo mark printed later in the piece (e.g. \u2669=96 over a bar) is a tempo change: set_tempo with that bar's 0-based \`measure\`.
875
953
 
876
954
  ## 2. Write bar by bar with write_measures
877
955
  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.
@@ -907,7 +985,7 @@ var state = {
907
985
  source: null
908
986
  };
909
987
  var server = new McpServer(
910
- { name: "eztab", version: "0.4.1" },
988
+ { name: "eztab", version: "0.5.0" },
911
989
  {
912
990
  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."
913
991
  }
@@ -938,7 +1016,7 @@ function edit(track, mutate) {
938
1016
  const before = onTrack(current, track);
939
1017
  const doc = structuredClone(before);
940
1018
  mutate(doc);
941
- state.doc = reconcileTracks(before, doc);
1019
+ state.doc = reconcileTracks(before, withNormalizedTempoChanges(doc));
942
1020
  state.version += 1;
943
1021
  return state.doc;
944
1022
  }
@@ -1280,10 +1358,33 @@ server.tool(
1280
1358
  );
1281
1359
  server.tool(
1282
1360
  "set_tempo",
1283
- "Set the playback tempo (BPM).",
1284
- { bpm: z.number().int().min(20).max(400) },
1361
+ "Set the playback tempo (quarter-note BPM). Without `measure` (or 0) this is the tempo at the start of the song; with `measure` >= 1 it sets a tempo change starting at that measure (an existing change there is updated). A tempo lasts until the next change. A change equal to the tempo before it is dropped.",
1362
+ {
1363
+ bpm: z.number().int().min(20).max(400),
1364
+ measure: z.number().int().min(0).optional()
1365
+ },
1366
+ async (a) => applyEdit((doc) => {
1367
+ const measure = a.measure ?? 0;
1368
+ assertMeasure(doc, measure);
1369
+ if (measure === 0) {
1370
+ doc.tempo = a.bpm;
1371
+ return;
1372
+ }
1373
+ const changes = (doc.tempoChanges ?? []).filter((t) => t.measure !== measure);
1374
+ changes.push({ measure, bpm: a.bpm });
1375
+ doc.tempoChanges = changes.sort((x, y) => x.measure - y.measure);
1376
+ })
1377
+ );
1378
+ server.tool(
1379
+ "remove_tempo",
1380
+ "Remove the tempo change at `measure` (>= 1); the tempo before it then carries on. The start tempo (measure 0) cannot be removed.",
1381
+ { measure: z.number().int() },
1285
1382
  async (a) => applyEdit((doc) => {
1286
- doc.tempo = a.bpm;
1383
+ if (a.measure === 0) throw new Error("cannot remove the start tempo; use set_tempo");
1384
+ if (tempoSourceAt(doc, a.measure) !== a.measure) {
1385
+ throw new Error(`no tempo change at measure ${a.measure}`);
1386
+ }
1387
+ doc.tempoChanges = doc.tempoChanges?.filter((t) => t.measure !== a.measure);
1287
1388
  })
1288
1389
  );
1289
1390
  server.tool(
@@ -1345,6 +1446,7 @@ server.tool(
1345
1446
  doc.measures.splice(at, 0, createMeasure(doc.tuning.length));
1346
1447
  doc.keys = shiftKeysForInsert(doc.keys, at);
1347
1448
  doc.sections = shiftSectionsForInsert(doc.sections, at);
1449
+ doc.tempoChanges = shiftTempoChangesForInsert(doc, at).tempoChanges;
1348
1450
  }
1349
1451
  })
1350
1452
  );
@@ -1358,6 +1460,9 @@ server.tool(
1358
1460
  doc.measures.splice(a.measure, 1);
1359
1461
  doc.keys = shiftKeysForDelete(doc.keys, a.measure);
1360
1462
  doc.sections = shiftSectionsForDelete(doc.sections, a.measure);
1463
+ const shifted = shiftTempoChangesForDelete(doc, a.measure);
1464
+ doc.tempo = shifted.tempo;
1465
+ doc.tempoChanges = shifted.tempoChanges;
1361
1466
  })
1362
1467
  );
1363
1468
  server.tool(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hidechae/eztab-mcp",
3
- "version": "0.4.1",
3
+ "version": "0.5.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": {