@hidechae/eztab-mcp 0.3.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 +1 -1
  2. package/dist/server.js +251 -122
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -92,7 +92,7 @@ Run standalone for testing: `task mcp` (or `npx tsx mcp/server.ts`).
92
92
  | `set_title` | Set the document title |
93
93
  | `set_artist` | Set the document artist |
94
94
  | `write_measures` | 小節単位の一括入力。1拍を1文字列で書き(`"6:5 6:~7"` など)、列番号への割り付けはサーバ側で行う。譜面・画像からの書き起こし向け |
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 |
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 |
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") |
package/dist/server.js CHANGED
@@ -7,8 +7,9 @@ import { z } from "zod";
7
7
 
8
8
  // ../src/types/tab.ts
9
9
  var TAB_FILE_VERSION = 1;
10
- var SUPPORTED_FORMAT_REVISION = 2;
10
+ var SUPPORTED_FORMAT_REVISION = 3;
11
11
  var MULTI_TRACK_FORMAT_REVISION = 2;
12
+ var HARMONICS_FORMAT_REVISION = 3;
12
13
  var MAX_TRACKS = 4;
13
14
  var DEFAULT_MAIN_TRACK_NAME = "Guitar";
14
15
  var DEFAULT_TEMPO = 120;
@@ -41,9 +42,92 @@ function shiftKeysForDelete(keys, index) {
41
42
  }
42
43
  return next;
43
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
+ }
44
110
  function cellPalmMute(c) {
45
111
  return typeof c === "object" && c !== null && !!c.pm;
46
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
+ }
47
131
  var CHORD_REST = "rest";
48
132
  var CHORD_REST_LABEL = "N.C.";
49
133
  function chordDisplay(label) {
@@ -115,7 +199,7 @@ function normalizeDocument(data) {
115
199
  });
116
200
  const cols = subdivisions.reduce((a, b) => a + b, 0);
117
201
  const cells = (m.cells ?? []).map(
118
- (row) => Array.from({ length: cols }, (_, c) => row?.[c] ?? null)
202
+ (row) => Array.from({ length: cols }, (_, c) => normalizeCell(row?.[c] ?? null))
119
203
  );
120
204
  const rawChords = Array.isArray(m.chords) ? m.chords : typeof m.chord === "string" && m.chord ? [m.chord] : [];
121
205
  const chords = normalizeChords(rawChords, subdivisions);
@@ -179,7 +263,7 @@ function fitTrackMeasure(raw, main, stringCount) {
179
263
  const rows = Array.isArray(raw?.cells) ? raw.cells : [];
180
264
  const cells = Array.from(
181
265
  { length: stringCount },
182
- (_, s) => Array.from({ length: cols }, (_2, c) => rows[s]?.[c] ?? null)
266
+ (_, s) => Array.from({ length: cols }, (_2, c) => normalizeCell(rows[s]?.[c] ?? null))
183
267
  );
184
268
  return { subdivisions, cells };
185
269
  }
@@ -307,7 +391,7 @@ function newUid() {
307
391
  return newTabId();
308
392
  }
309
393
  function storedMeasure(m) {
310
- const { uid: _uid, ...rest } = m;
394
+ const { uid: _uid, beatWidthOverride: _w, ...rest } = m;
311
395
  return rest;
312
396
  }
313
397
  function openWorkspace(doc) {
@@ -391,12 +475,12 @@ function closeWorkspace(doc) {
391
475
  const mainMeta = ws.metas[0];
392
476
  const mainIsDefault = mainMeta.name === DEFAULT_MAIN_TRACK_NAME && mainMeta.volume === 1 && !mainMeta.muted;
393
477
  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;
399
- }
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;
400
484
  return out;
401
485
  }
402
486
  function reconcileTracks(prev, next) {
@@ -554,96 +638,6 @@ var cloud = {
554
638
  }
555
639
  };
556
640
 
557
- // eztab.ts
558
- function normalize(data) {
559
- return normalizeDocument(data);
560
- }
561
- function cellText(cell) {
562
- if (cell === null) return "--";
563
- if (cell === "rest") return "-R";
564
- if (cell === "mute") return "-x";
565
- if (typeof cell === "object") {
566
- const bend = cell.bend ?? 0;
567
- if (cell.tie) return `~${cell.fret}`.padStart(2, "-").slice(-2);
568
- const mark = bend === 0.5 ? "b" : bend === 1.5 ? "^" : bend === 1 ? "B" : "";
569
- return `${cell.fret}${mark}`.padStart(2, "-").slice(-2);
570
- }
571
- return String(cell).padStart(2, "-");
572
- }
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);
579
- const lines = [];
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
- }
591
- const ann = [];
592
- main.measures.forEach((_, m) => {
593
- const k = doc.keys.find((x) => x.measure === m);
594
- const s = doc.sections.find((x) => x.measure === m);
595
- if (k) ann.push(`m${m}:key=${k.tonic} ${k.mode}`);
596
- if (s && s.label) ann.push(`m${m}:section="${s.label}"`);
597
- });
598
- if (ann.length) lines.push(`Markers: ${ann.join(", ")}`);
599
- main.measures.forEach((m, mi) => {
600
- if (mi < from || mi > to) return;
601
- const key = activeKeyAt(doc.keys, mi);
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
- }
611
- lines.push(
612
- `--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, key ${key.tonic} ${key.mode}) ---`
613
- );
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
- }
620
- });
621
- return lines.join("\n");
622
- }
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(" ")}`);
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(" ")}`);
645
- }
646
-
647
641
  // notation.ts
648
642
  var BEAT_UNITS2 = [2, 4, 8, 16];
649
643
  var BENDS = { b: 0.5, B: 1, "^": 1.5 };
@@ -659,18 +653,44 @@ function parseTime(text2) {
659
653
  function parseValue(raw, where) {
660
654
  if (raw === "x") return "mute";
661
655
  if (raw === "r") return "rest";
662
- const m = /^(~?)(\d+)([bB^p]*)$/.exec(raw);
656
+ const m = /^(~?)(?:<(\d+(?:\.\d+)?)>|(\d+)(?:<(\d+(?:\.\d+)?)>)?)([bB^pah]*)$/.exec(raw);
663
657
  if (!m) throw new Error(`${where}: bad value "${raw}"`);
664
- 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]);
665
661
  if (fret > MAX_FRET) throw new Error(`${where}: fret ${fret} is above ${MAX_FRET}`);
666
- const mods = m[3];
662
+ const mods = m[5];
667
663
  const bendMarks = [...mods].filter((c) => c in BENDS);
668
664
  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;
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" : ""}`;
674
694
  }
675
695
  function parseBeat(text2, stringCount, where) {
676
696
  const slots = text2.trim().split(/\s+/);
@@ -729,6 +749,105 @@ function buildMeasure(spec, stringCount, fallback, where = "measure") {
729
749
  }
730
750
  return measure;
731
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
+ }
732
851
 
733
852
  // prompts.ts
734
853
  var IMPORT_TAB_FROM_IMAGE = `Transcribe the TAB in the attached image(s) into an EZTab document with the eztab tools.
@@ -753,7 +872,12 @@ Read each bar's rhythm first (beams, flags, dots, tuplet brackets, rests), then
753
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.
754
873
  - Bridge mute (M, P.M. bracket in Japanese band scores = \u30DF\u30E5\u30FC\u30C8): add p to every note under the bracket.
755
874
  - 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.
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.
757
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.
758
882
  - Repeats, D.S., Coda, 1st/2nd endings: transcribe the bars as printed and tell the user; do not expand them unless asked.
759
883
 
@@ -769,7 +893,7 @@ var state = {
769
893
  source: null
770
894
  };
771
895
  var server = new McpServer(
772
- { name: "eztab", version: "0.3.0" },
896
+ { name: "eztab", version: "0.4.0" },
773
897
  {
774
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."
775
899
  }
@@ -969,7 +1093,7 @@ server.tool(
969
1093
  );
970
1094
  server.tool(
971
1095
  "set_cells",
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).",
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).",
973
1097
  {
974
1098
  edits: z.array(
975
1099
  z.object({
@@ -979,7 +1103,9 @@ server.tool(
979
1103
  value: z.union([z.number().int().min(0).max(24), z.enum(["rest", "mute", "clear"])]),
980
1104
  bend: z.union([z.literal(0.5), z.literal(1), z.literal(1.5)]).optional(),
981
1105
  tie: z.boolean().optional(),
982
- pm: z.boolean().optional()
1106
+ pm: z.boolean().optional(),
1107
+ harm: z.enum(["natural", "artificial", "pinch", "none"]).optional(),
1108
+ harmAt: z.number().optional()
983
1109
  })
984
1110
  ),
985
1111
  track: trackParam
@@ -997,22 +1123,25 @@ server.tool(
997
1123
  `column ${e.column} out of range for measure ${e.measure} (0..${cols - 1})`
998
1124
  );
999
1125
  }
1000
- let value = e.value === "clear" ? null : e.value;
1001
- if (typeof e.value === "number" && (e.bend != null || e.tie || e.pm)) {
1002
- value = {
1003
- fret: e.value,
1004
- ...e.bend != null ? { bend: e.bend } : {},
1005
- ...e.tie ? { tie: true } : {},
1006
- ...e.pm ? { pm: true } : {}
1007
- };
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;
1008
1133
  }
1009
- 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
+ );
1010
1139
  }
1011
1140
  }, a.track)
1012
1141
  );
1013
1142
  server.tool(
1014
1143
  "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"].',
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"].',
1016
1145
  {
1017
1146
  measures: z.array(
1018
1147
  z.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hidechae/eztab-mcp",
3
- "version": "0.3.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": {