@hidechae/eztab-mcp 0.6.1 → 0.7.1

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 +17 -0
  2. package/dist/server.js +93 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -103,6 +103,7 @@ Run standalone for testing: `task mcp` (or `npx tsx mcp/server.ts`).
103
103
  | `set_tuning` | Replace tuning / string count (`track` で対象パートを指定) |
104
104
  | `add_track` / `remove_track` | パート(ベース・2本目のギターなど)の追加・削除。メインを含めて最大4、track 0(メイン)は削除不可 |
105
105
  | `set_tone` | パートの再生音色を設定する(`track` で対象パートを指定)。`add_track` でも `tone` を指定できる |
106
+ | `set_backing_style` | 伴奏(コード)の音を設定する: `pad`(既定)/ `guitar` / `piano` / `bass`(ルートのみ) |
106
107
 
107
108
  ### 複数パート(トラック)
108
109
 
@@ -118,6 +119,14 @@ Run standalone for testing: `task mcp` (or `npx tsx mcp/server.ts`).
118
119
  対応上限を超える形式のタブは閲覧のみで、編集・送信できない。
119
120
  0.2.0以前のMCPは複数パートのタブを送り返せない(サーバが409で拒否する)。
120
121
 
122
+ ### チューニングの音名
123
+
124
+ チューニングの開放弦は、オクターブの数字を付けた音名で指定します(標準のギターは `E4 B3 G3 D3 A2 E2`、
125
+ 4弦ベースは `G2 D2 A1 E1`)。数字の無い音名はすべて同じオクターブとして鳴ってしまうため、
126
+ 0.7.1以降は `new_tab` / `set_tuning` / `add_track` が、弦が上から下へ低くなるようにオクターブを補います
127
+ (音名がプリセットと一致すればそのオクターブ)。音名として読めない値はエラーになります。
128
+ アプリも、数字の無いチューニングを持つ既存のタブを開くときに同じ補正をします。
129
+
121
130
  ### パートの音色
122
131
 
123
132
  0.6.0以降はパートごとの再生音色(形式リビジョン7、docs/design/0010-track-tones.md)を扱えます。
@@ -131,6 +140,14 @@ Run standalone for testing: `task mcp` (or `npx tsx mcp/server.ts`).
131
140
  (1パートのタブでは `tone=distortion`)
132
141
  - 0.5.2以前のMCPは、クリーン以外の音色を持つタブを編集・送信できない
133
142
 
143
+ ### 伴奏の音
144
+
145
+ 0.7.0以降は伴奏(コード)の音(docs/design/0011-backing-styles.md)を `set_backing_style` で設定できます。
146
+
147
+ - `pad`(既定。従来の音)/ `guitar` / `piano` / `bass`(コードの構成音を鳴らさず、ルートだけをベースで弾く。分数コードは分母の音)
148
+ - `get_tab` はパッド以外のとき、先頭行に `backing=guitar` のように表示する
149
+ - 形式リビジョンは変わらない。0.6.1以前のMCPでも、設定されたタブを編集・送信でき、値は保持される
150
+
134
151
  ### 小節メモの保持
135
152
 
136
153
  0.5.1以降はアプリで入力した小節共通メモ(形式リビジョン5)を保持して編集・送信できます。
package/dist/server.js CHANGED
@@ -28,6 +28,15 @@ function normalizeTone(raw) {
28
28
  return raw !== "clean" && TONES.includes(raw) ? raw : void 0;
29
29
  }
30
30
  var DEFAULT_MAIN_TRACK_NAME = "Guitar";
31
+ var BACKING_STYLES = ["pad", "guitar", "piano", "bass"];
32
+ function normalizeBackingStyle(raw) {
33
+ return raw !== "pad" && BACKING_STYLES.includes(raw) ? raw : void 0;
34
+ }
35
+ function withBackingStyle(doc, style) {
36
+ const { backingStyle: _old, ...rest } = doc;
37
+ const stored = normalizeBackingStyle(style);
38
+ return stored ? { ...rest, backingStyle: stored } : rest;
39
+ }
31
40
  var DEFAULT_TEMPO = 120;
32
41
  var MIN_TEMPO = 20;
33
42
  var MAX_TEMPO = 400;
@@ -281,6 +290,7 @@ function normalizeDocument(data) {
281
290
  if (!data || !Array.isArray(data.tuning) || !Array.isArray(data.measures)) {
282
291
  throw new Error("Not a valid EZTab document");
283
292
  }
293
+ data.tuning = completeTuning(data.tuning, namedBass(data.mainTrack?.name));
284
294
  const memo = normalizeSongMemo(data.memo);
285
295
  if (memo === void 0) delete data.memo;
286
296
  else data.memo = memo;
@@ -328,6 +338,9 @@ function normalizeDocument(data) {
328
338
  if (!data.mix || typeof data.mix.melody !== "number") {
329
339
  data.mix = { ...DEFAULT_MIX };
330
340
  }
341
+ const backingStyle = normalizeBackingStyle(data.backingStyle);
342
+ if (backingStyle) data.backingStyle = backingStyle;
343
+ else delete data.backingStyle;
331
344
  normalizeTracks(data);
332
345
  data.version = TAB_FILE_VERSION;
333
346
  return data;
@@ -360,7 +373,7 @@ function normalizeTracks(data) {
360
373
  }
361
374
  const measures = data.measures;
362
375
  data.tracks = rawTracks.slice(0, MAX_TRACKS - 1).map((t, i) => {
363
- const tuning = Array.isArray(t?.tuning) && t.tuning.length > 0 && t.tuning.every((n) => typeof n === "string") ? [...t.tuning] : [...PRESET_TUNINGS["Guitar (6)"]];
376
+ const tuning = Array.isArray(t?.tuning) && t.tuning.length > 0 && t.tuning.every((n) => typeof n === "string") ? completeTuning([...t.tuning], namedBass(t?.name)) : [...PRESET_TUNINGS["Guitar (6)"]];
364
377
  const rawMeasures = Array.isArray(t?.measures) ? t.measures : [];
365
378
  if (rawMeasures.length !== measures.length) data.trackMismatch = true;
366
379
  return {
@@ -414,6 +427,51 @@ var PRESET_TUNING_GROUPS = [
414
427
  var PRESET_TUNINGS = Object.fromEntries(
415
428
  PRESET_TUNING_GROUPS.flatMap((g) => g.items.map((i) => [i.name, i.tuning]))
416
429
  );
430
+ var OPEN_NOTE = /^([A-Ga-g])([#♯b♭]*)(-?\d+)?$/;
431
+ var LETTER_SEMITONES = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
432
+ function parseOpenNote(raw) {
433
+ const m = OPEN_NOTE.exec(typeof raw === "string" ? raw.trim() : "");
434
+ if (!m) return null;
435
+ let semis = LETTER_SEMITONES[m[1].toUpperCase()];
436
+ for (const ch of m[2]) semis += ch === "#" || ch === "\u266F" ? 1 : -1;
437
+ return {
438
+ name: m[1].toUpperCase() + m[2],
439
+ semis,
440
+ pc: (semis % 12 + 12) % 12,
441
+ midi: m[3] != null ? (parseInt(m[3], 10) + 1) * 12 + semis : null
442
+ };
443
+ }
444
+ function completeTuning(tuning, bass = false) {
445
+ const parsed = tuning.map(parseOpenNote);
446
+ if (parsed.some((n) => n === null) || parsed.every((n) => n?.midi != null)) return tuning;
447
+ const notes = parsed;
448
+ const spell = (i, midi) => notes[i].midi !== null ? tuning[i] : notes[i].name + ((midi - notes[i].semis) / 12 - 1);
449
+ const above = (pc, floor) => floor + ((pc - floor) % 12 + 12) % 12;
450
+ const below = (pc, ceil) => ceil - ((ceil - pc) % 12 + 12) % 12;
451
+ const midis = notes.map((n) => n.midi);
452
+ if (notes.every((n) => n.midi === null)) {
453
+ for (const preset of PRESET_TUNING_GROUPS.flatMap((g) => g.items)) {
454
+ const open = preset.tuning.map(parseOpenNote);
455
+ if (open.length === notes.length && open.every((o, i) => o?.pc === notes[i].pc)) {
456
+ return notes.map((_, i) => spell(i, open[i]?.midi));
457
+ }
458
+ }
459
+ const reference = bass || notes.length <= 5 ? 43 : 64;
460
+ const up = above(notes[0].pc, reference);
461
+ midis[0] = up - reference <= 6 ? up : up - 12;
462
+ }
463
+ const anchor = midis.findIndex((m) => m !== null);
464
+ for (let i = anchor - 1; i >= 0; i--) {
465
+ midis[i] = above(notes[i].pc, midis[i + 1] + 1);
466
+ }
467
+ for (let i = anchor + 1; i < midis.length; i++) {
468
+ if (midis[i] === null) midis[i] = below(notes[i].pc, midis[i - 1] - 1);
469
+ }
470
+ return notes.map((_, i) => spell(i, midis[i]));
471
+ }
472
+ function namedBass(name) {
473
+ return typeof name === "string" && /bass|ベース/i.test(name);
474
+ }
417
475
  var DEFAULT_BEATS = 4;
418
476
  var DEFAULT_BEAT_UNIT = 4;
419
477
  var DEFAULT_SUBDIVISIONS = 2;
@@ -769,7 +827,7 @@ function storedSize(doc) {
769
827
 
770
828
  // ../src/lib/player.ts
771
829
  function instrumentOf(tuning, name = "") {
772
- if (/bass|ベース/i.test(name)) return "bass";
830
+ if (namedBass(name)) return "bass";
773
831
  const open = tuning.map(noteToMidi).filter((m) => m != null);
774
832
  return open.length > 0 && Math.max(...open) < 50 ? "bass" : "guitar";
775
833
  }
@@ -1100,7 +1158,9 @@ function render(doc, from = 0, to = doc.measures.length - 1, track) {
1100
1158
  const main = bodies[0];
1101
1159
  const shown = bodies.map((_, i) => i).filter((i) => track == null || i === track);
1102
1160
  const lines = [];
1103
- lines.push(`Title: ${doc.title}${doc.artist ? ` \u2014 ${doc.artist}` : ""} tempo=${doc.tempo}`);
1161
+ lines.push(
1162
+ `Title: ${doc.title}${doc.artist ? ` \u2014 ${doc.artist}` : ""} tempo=${doc.tempo}${doc.backingStyle ? ` backing=${doc.backingStyle}` : ""}`
1163
+ );
1104
1164
  if (multi) {
1105
1165
  lines.push(`Tracks (track index: name, tuning top\u2192bottom) measures=${main.measures.length}`);
1106
1166
  metas.forEach((t, i) => {
@@ -1220,7 +1280,7 @@ var state = {
1220
1280
  source: null
1221
1281
  };
1222
1282
  var server = new McpServer(
1223
- { name: "eztab", version: "0.6.1" },
1283
+ { name: "eztab", version: "0.7.1" },
1224
1284
  {
1225
1285
  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."
1226
1286
  }
@@ -1271,6 +1331,9 @@ function setDoc(doc) {
1271
1331
  return text(render(state.doc));
1272
1332
  }
1273
1333
  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.";
1334
+ var tuningParam = z.array(z.string().regex(/^[A-Ga-g][#♯b♭]*(-?\d+)?$/, "a note name such as E2, F#3 or Bb1")).min(1).describe(
1335
+ 'Open notes from the top (highest) string to the bottom (lowest), each WITH its octave, e.g. standard guitar ["E4","B3","G3","D3","A2","E2"], bass ["G2","D2","A1","E1"]. A missing octave is filled in so the strings run from high to low.'
1336
+ );
1274
1337
  var trackParam = z.number().int().min(0).optional().describe("0-based track (part) index from get_tab; default 0, the main track.");
1275
1338
  function assertMeasure(doc, m) {
1276
1339
  if (!Number.isInteger(m) || m < 0 || m >= doc.measures.length) {
@@ -1300,12 +1363,12 @@ server.tool(
1300
1363
  title: z.string().optional(),
1301
1364
  artist: z.string().optional(),
1302
1365
  preset: z.enum(["Guitar (6)", "Bass (4)", "Bass (5)", "Ukulele (4)"]).optional(),
1303
- tuning: z.array(z.string()).optional(),
1366
+ tuning: tuningParam.optional(),
1304
1367
  measures: z.number().int().min(1).max(512).optional(),
1305
1368
  tempo: z.number().int().min(20).max(400).optional()
1306
1369
  },
1307
1370
  async (a) => {
1308
- const tuning = a.tuning ?? (a.preset ? PRESET_TUNINGS[a.preset] : void 0);
1371
+ const tuning = a.tuning ? completeTuning(a.tuning) : a.preset ? PRESET_TUNINGS[a.preset] : void 0;
1309
1372
  const doc = createDocument(tuning, a.measures ?? 4);
1310
1373
  if (a.title) doc.title = a.title;
1311
1374
  if (a.artist) doc.artist = a.artist;
@@ -1703,11 +1766,15 @@ server.tool(
1703
1766
  server.tool(
1704
1767
  "set_tuning",
1705
1768
  "Replace the tuning / string count. Existing frets are preserved where strings overlap. Order is top (highest) to bottom (lowest).",
1706
- { tuning: z.array(z.string()).min(1), track: trackParam },
1707
- async (a) => applyEdit((doc) => {
1708
- doc.tuning = [...a.tuning];
1709
- doc.measures = doc.measures.map((m) => resizeMeasureStrings(m, a.tuning.length));
1710
- }, a.track)
1769
+ { tuning: tuningParam, track: trackParam },
1770
+ async (a) => {
1771
+ const name = trackMetas(requireDoc())[a.track ?? 0]?.name;
1772
+ const tuning = completeTuning(a.tuning, namedBass(name));
1773
+ return applyEdit((doc) => {
1774
+ doc.tuning = tuning;
1775
+ doc.measures = doc.measures.map((m) => resizeMeasureStrings(m, tuning.length));
1776
+ }, a.track);
1777
+ }
1711
1778
  );
1712
1779
  var toneParam = z.enum(TONES).describe(
1713
1780
  "How the track sounds in playback. Guitar tracks: clean, crunch (light overdrive), distortion, acoustic, piano, synth, strings. Bass tracks (named or tuned like a bass): clean, crunch (overdrive), acoustic (upright bass), synth (synth bass) only."
@@ -1734,6 +1801,17 @@ server.tool(
1734
1801
  return text(render(state.doc));
1735
1802
  }
1736
1803
  );
1804
+ server.tool(
1805
+ "set_backing_style",
1806
+ "Set how the chord backing (the chords written with set_chords / @Name) sounds in playback: pad (the default soft pad), guitar, piano, or bass (only the root \u2014 or a slash chord's bass note \u2014 on a bass).",
1807
+ { style: z.enum(BACKING_STYLES) },
1808
+ async (a) => {
1809
+ const doc = edit(0, () => {
1810
+ });
1811
+ state.doc = withBackingStyle(doc, a.style);
1812
+ return text(render(state.doc));
1813
+ }
1814
+ );
1737
1815
  var TRACK_PRESETS = [
1738
1816
  "Guitar (6)",
1739
1817
  "Guitar (7)",
@@ -1748,12 +1826,14 @@ server.tool(
1748
1826
  {
1749
1827
  name: z.string().min(1).describe("Shown in the app, e.g. 'Bass' or 'Guitar 2'."),
1750
1828
  preset: z.enum(TRACK_PRESETS).optional(),
1751
- tuning: z.array(z.string()).min(1).optional().describe("Top (highest) to bottom; overrides preset."),
1829
+ tuning: tuningParam.optional().describe(
1830
+ "Overrides preset. Open notes top (highest) to bottom, each WITH its octave (e.g. G2 D2 A1 E1)."
1831
+ ),
1752
1832
  tone: toneParam.optional().describe("Playback tone (see set_tone); default clean.")
1753
1833
  },
1754
1834
  async (a) => {
1755
1835
  const before = trackMetas(requireDoc()).length;
1756
- const tuning = a.tuning ?? PRESET_TUNINGS[a.preset ?? "Bass (4)"];
1836
+ const tuning = a.tuning ? completeTuning(a.tuning, namedBass(a.name)) : PRESET_TUNINGS[a.preset ?? "Bass (4)"];
1757
1837
  const doc = edit(0, () => {
1758
1838
  });
1759
1839
  const added = addTrack(doc, a.name, tuning);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hidechae/eztab-mcp",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "MCP server for EZTab — lets an AI agent build and edit guitar/bass tablature.",
5
5
  "type": "module",
6
6
  "bin": {