@hidechae/eztab-mcp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -4
- package/dist/server.js +458 -54
- 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-
|
|
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
|
-
(
|
|
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
|
|
|
@@ -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,10 @@ 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;
|
|
11
|
+
var MULTI_TRACK_FORMAT_REVISION = 2;
|
|
12
|
+
var MAX_TRACKS = 4;
|
|
13
|
+
var DEFAULT_MAIN_TRACK_NAME = "Guitar";
|
|
10
14
|
var DEFAULT_TEMPO = 120;
|
|
11
15
|
var DEFAULT_MIX = { melody: 1, backing: 0.4 };
|
|
12
16
|
function shiftSectionsForInsert(sections, index) {
|
|
@@ -126,9 +130,59 @@ function normalizeDocument(data) {
|
|
|
126
130
|
if (!data.mix || typeof data.mix.melody !== "number") {
|
|
127
131
|
data.mix = { ...DEFAULT_MIX };
|
|
128
132
|
}
|
|
133
|
+
normalizeTracks(data);
|
|
129
134
|
data.version = TAB_FILE_VERSION;
|
|
130
135
|
return data;
|
|
131
136
|
}
|
|
137
|
+
function normalizeTrackMeta(raw, fallbackName) {
|
|
138
|
+
const volume = Number(raw?.volume);
|
|
139
|
+
return {
|
|
140
|
+
id: typeof raw?.id === "string" && raw.id ? raw.id : newTabId(),
|
|
141
|
+
name: typeof raw?.name === "string" && raw.name.trim() ? raw.name : fallbackName,
|
|
142
|
+
volume: Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1,
|
|
143
|
+
muted: raw?.muted === true
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function defaultMainTrack() {
|
|
147
|
+
return { id: newTabId(), name: DEFAULT_MAIN_TRACK_NAME, volume: 1, muted: false };
|
|
148
|
+
}
|
|
149
|
+
function normalizeTracks(data) {
|
|
150
|
+
if (data.trackMismatch !== true) delete data.trackMismatch;
|
|
151
|
+
delete data.workspace;
|
|
152
|
+
const rawTracks = Array.isArray(data.tracks) ? data.tracks : [];
|
|
153
|
+
if (data.mainTrack != null || rawTracks.length > 0) {
|
|
154
|
+
data.mainTrack = normalizeTrackMeta(data.mainTrack, DEFAULT_MAIN_TRACK_NAME);
|
|
155
|
+
}
|
|
156
|
+
if (rawTracks.length === 0) {
|
|
157
|
+
delete data.tracks;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const measures = data.measures;
|
|
161
|
+
data.tracks = rawTracks.slice(0, MAX_TRACKS - 1).map((t, i) => {
|
|
162
|
+
const tuning = Array.isArray(t?.tuning) && t.tuning.length > 0 && t.tuning.every((n) => typeof n === "string") ? [...t.tuning] : [...PRESET_TUNINGS["Guitar (6)"]];
|
|
163
|
+
const rawMeasures = Array.isArray(t?.measures) ? t.measures : [];
|
|
164
|
+
if (rawMeasures.length !== measures.length) data.trackMismatch = true;
|
|
165
|
+
return {
|
|
166
|
+
...normalizeTrackMeta(t, `Track ${i + 2}`),
|
|
167
|
+
tuning,
|
|
168
|
+
measures: measures.map((m, mi) => fitTrackMeasure(rawMeasures[mi], m, tuning.length))
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function fitTrackMeasure(raw, main, stringCount) {
|
|
173
|
+
const rawSubs = Array.isArray(raw?.subdivisions) ? raw.subdivisions : [];
|
|
174
|
+
const subdivisions = Array.from({ length: main.beats }, (_, b) => {
|
|
175
|
+
const v = rawSubs[Math.min(b, rawSubs.length - 1)];
|
|
176
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= MAX_SUBDIVISION ? v : DEFAULT_SUBDIVISIONS;
|
|
177
|
+
});
|
|
178
|
+
const cols = subdivisions.reduce((a, b) => a + b, 0);
|
|
179
|
+
const rows = Array.isArray(raw?.cells) ? raw.cells : [];
|
|
180
|
+
const cells = Array.from(
|
|
181
|
+
{ length: stringCount },
|
|
182
|
+
(_, s) => Array.from({ length: cols }, (_2, c) => rows[s]?.[c] ?? null)
|
|
183
|
+
);
|
|
184
|
+
return { subdivisions, cells };
|
|
185
|
+
}
|
|
132
186
|
var MAX_SUBDIVISION = 12;
|
|
133
187
|
var PRESET_TUNING_GROUPS = [
|
|
134
188
|
{
|
|
@@ -248,6 +302,202 @@ function setBeatSubdivision(measure, beat, subdivision) {
|
|
|
248
302
|
return remapToSubdivisions(measure, subs);
|
|
249
303
|
}
|
|
250
304
|
|
|
305
|
+
// ../src/lib/tracks.ts
|
|
306
|
+
function newUid() {
|
|
307
|
+
return newTabId();
|
|
308
|
+
}
|
|
309
|
+
function storedMeasure(m) {
|
|
310
|
+
const { uid: _uid, ...rest } = m;
|
|
311
|
+
return rest;
|
|
312
|
+
}
|
|
313
|
+
function openWorkspace(doc) {
|
|
314
|
+
if (doc.workspace) return doc;
|
|
315
|
+
const measures = doc.measures.map((m) => ({ ...m, uid: m.uid ?? newUid() }));
|
|
316
|
+
const extras = doc.tracks ?? [];
|
|
317
|
+
const metas = [
|
|
318
|
+
doc.mainTrack ?? defaultMainTrack(),
|
|
319
|
+
...extras.map(({ tuning: _t, measures: _m, ...meta }) => meta)
|
|
320
|
+
];
|
|
321
|
+
const parked = [
|
|
322
|
+
null,
|
|
323
|
+
...extras.map(
|
|
324
|
+
(t) => ({
|
|
325
|
+
tuning: [...t.tuning],
|
|
326
|
+
measures: measures.map(
|
|
327
|
+
(main, i) => trackMeasureToMeasure(
|
|
328
|
+
main,
|
|
329
|
+
t.measures[i]?.subdivisions,
|
|
330
|
+
t.measures[i]?.cells,
|
|
331
|
+
t.tuning.length
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
})
|
|
335
|
+
)
|
|
336
|
+
];
|
|
337
|
+
const { mainTrack: _mt, tracks: _tr, ...rest } = doc;
|
|
338
|
+
return { ...rest, measures, workspace: { active: 0, metas, parked } };
|
|
339
|
+
}
|
|
340
|
+
function trackMeasureToMeasure(main, subdivisions, cells, stringCount) {
|
|
341
|
+
const base = createMeasure(
|
|
342
|
+
stringCount,
|
|
343
|
+
main.beats,
|
|
344
|
+
subdivisions ?? main.subdivisions,
|
|
345
|
+
main.beatUnit
|
|
346
|
+
);
|
|
347
|
+
return {
|
|
348
|
+
...base,
|
|
349
|
+
cells: base.cells.map((row, s) => row.map((_, c) => cells?.[s]?.[c] ?? null)),
|
|
350
|
+
uid: main.uid
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function trackBodies(doc) {
|
|
354
|
+
const ws = doc.workspace;
|
|
355
|
+
if (!ws) return [{ tuning: doc.tuning, measures: doc.measures }];
|
|
356
|
+
return ws.parked.map(
|
|
357
|
+
(b, i) => i === ws.active ? { tuning: doc.tuning, measures: doc.measures } : b
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
function trackMetas(doc) {
|
|
361
|
+
return doc.workspace?.metas ?? [doc.mainTrack ?? defaultMainTrack()];
|
|
362
|
+
}
|
|
363
|
+
function mainMeasures(doc) {
|
|
364
|
+
return trackBodies(doc)[0].measures;
|
|
365
|
+
}
|
|
366
|
+
function closeWorkspace(doc) {
|
|
367
|
+
const ws = doc.workspace;
|
|
368
|
+
if (!ws) return doc;
|
|
369
|
+
const bodies = trackBodies(doc);
|
|
370
|
+
const main = bodies[0];
|
|
371
|
+
const {
|
|
372
|
+
workspace: _ws,
|
|
373
|
+
trackMismatch: _tm,
|
|
374
|
+
mainTrack: _mt,
|
|
375
|
+
tracks: _tr,
|
|
376
|
+
formatRevision,
|
|
377
|
+
...rest
|
|
378
|
+
} = doc;
|
|
379
|
+
const out = {
|
|
380
|
+
...rest,
|
|
381
|
+
tuning: [...main.tuning],
|
|
382
|
+
measures: main.measures.map(storedMeasure)
|
|
383
|
+
};
|
|
384
|
+
const extras = bodies.slice(1).map(
|
|
385
|
+
(b, i) => ({
|
|
386
|
+
...ws.metas[i + 1],
|
|
387
|
+
tuning: [...b.tuning],
|
|
388
|
+
measures: b.measures.map((m) => ({ subdivisions: m.subdivisions, cells: m.cells }))
|
|
389
|
+
})
|
|
390
|
+
);
|
|
391
|
+
const mainMeta = ws.metas[0];
|
|
392
|
+
const mainIsDefault = mainMeta.name === DEFAULT_MAIN_TRACK_NAME && mainMeta.volume === 1 && !mainMeta.muted;
|
|
393
|
+
if (extras.length > 0 || !mainIsDefault) out.mainTrack = mainMeta;
|
|
394
|
+
if (extras.length > 0) {
|
|
395
|
+
out.tracks = extras;
|
|
396
|
+
out.formatRevision = Math.max(formatRevision ?? 1, MULTI_TRACK_FORMAT_REVISION);
|
|
397
|
+
} else if (formatRevision != null && formatRevision > MULTI_TRACK_FORMAT_REVISION) {
|
|
398
|
+
out.formatRevision = formatRevision;
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
function reconcileTracks(prev, next) {
|
|
403
|
+
let doc = next.workspace ? next : openWorkspace(next);
|
|
404
|
+
const ws = doc.workspace;
|
|
405
|
+
const measures = assignUids(prev, doc.measures);
|
|
406
|
+
if (measures !== doc.measures) doc = { ...doc, measures };
|
|
407
|
+
let changed = false;
|
|
408
|
+
const parked = ws.parked.map((body) => {
|
|
409
|
+
if (!body) return body;
|
|
410
|
+
const fitted = fitBody(body, measures);
|
|
411
|
+
if (fitted !== body) changed = true;
|
|
412
|
+
return fitted;
|
|
413
|
+
});
|
|
414
|
+
if (changed) doc = { ...doc, workspace: { ...ws, parked } };
|
|
415
|
+
return doc;
|
|
416
|
+
}
|
|
417
|
+
function assignUids(prev, measures) {
|
|
418
|
+
const seen = /* @__PURE__ */ new Set();
|
|
419
|
+
let out = null;
|
|
420
|
+
const present = new Set(measures.map((m) => m.uid).filter(Boolean));
|
|
421
|
+
measures.forEach((m, i) => {
|
|
422
|
+
let uid = m.uid;
|
|
423
|
+
if (uid && seen.has(uid)) uid = void 0;
|
|
424
|
+
if (!uid) {
|
|
425
|
+
const old = prev?.measures[i]?.uid;
|
|
426
|
+
uid = old && !present.has(old) && !seen.has(old) ? old : newUid();
|
|
427
|
+
out ??= [...measures];
|
|
428
|
+
out[i] = { ...m, uid };
|
|
429
|
+
}
|
|
430
|
+
seen.add(uid);
|
|
431
|
+
});
|
|
432
|
+
return out ?? measures;
|
|
433
|
+
}
|
|
434
|
+
function fitBody(body, measures) {
|
|
435
|
+
const same = body.measures.length === measures.length && body.measures.every(
|
|
436
|
+
(m, i) => m.uid === measures[i].uid && m.beats === measures[i].beats && m.beatUnit === measures[i].beatUnit
|
|
437
|
+
);
|
|
438
|
+
if (same) return body;
|
|
439
|
+
const byUid = new Map(body.measures.map((m) => [m.uid, m]));
|
|
440
|
+
const stringCount = body.tuning.length;
|
|
441
|
+
const out = [];
|
|
442
|
+
measures.forEach((m) => {
|
|
443
|
+
let pm = byUid.get(m.uid);
|
|
444
|
+
if (!pm) {
|
|
445
|
+
const before = out[out.length - 1];
|
|
446
|
+
const subs = before && before.beats === m.beats ? before.subdivisions : void 0;
|
|
447
|
+
pm = createMeasure(stringCount, m.beats, subs, m.beatUnit);
|
|
448
|
+
} else if (pm.beats !== m.beats || pm.beatUnit !== m.beatUnit) {
|
|
449
|
+
pm = setMeasureTimeSignature(pm, m.beats, m.beatUnit);
|
|
450
|
+
}
|
|
451
|
+
out.push({ ...pm, uid: m.uid });
|
|
452
|
+
});
|
|
453
|
+
return { ...body, measures: out };
|
|
454
|
+
}
|
|
455
|
+
function switchTrack(doc, index) {
|
|
456
|
+
const ws = doc.workspace;
|
|
457
|
+
if (!ws || index === ws.active || index < 0 || index >= ws.metas.length) return doc;
|
|
458
|
+
const parked = [...ws.parked];
|
|
459
|
+
parked[ws.active] = { tuning: doc.tuning, measures: doc.measures };
|
|
460
|
+
const body = parked[index];
|
|
461
|
+
parked[index] = null;
|
|
462
|
+
return {
|
|
463
|
+
...doc,
|
|
464
|
+
tuning: body.tuning,
|
|
465
|
+
measures: body.measures,
|
|
466
|
+
workspace: { ...ws, active: index, parked }
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function addTrack(doc, name, tuning) {
|
|
470
|
+
const ws = doc.workspace;
|
|
471
|
+
if (!ws || ws.metas.length >= MAX_TRACKS) return doc;
|
|
472
|
+
const main = mainMeasures(doc);
|
|
473
|
+
const body = {
|
|
474
|
+
tuning: [...tuning],
|
|
475
|
+
measures: main.map((m) => ({
|
|
476
|
+
...createMeasure(tuning.length, m.beats, m.subdivisions, m.beatUnit),
|
|
477
|
+
uid: m.uid
|
|
478
|
+
}))
|
|
479
|
+
};
|
|
480
|
+
const meta = { id: newTabId(), name, volume: 1, muted: false };
|
|
481
|
+
const added = {
|
|
482
|
+
...doc,
|
|
483
|
+
workspace: { ...ws, metas: [...ws.metas, meta], parked: [...ws.parked, body] }
|
|
484
|
+
};
|
|
485
|
+
return switchTrack(added, ws.metas.length);
|
|
486
|
+
}
|
|
487
|
+
function removeTrack(doc, index) {
|
|
488
|
+
const ws = doc.workspace;
|
|
489
|
+
if (!ws || index <= 0 || index >= ws.metas.length) return doc;
|
|
490
|
+
const shown = ws.active === index ? switchTrack(doc, 0) : doc;
|
|
491
|
+
const w = shown.workspace;
|
|
492
|
+
const metas = w.metas.filter((_, i) => i !== index);
|
|
493
|
+
const parked = w.parked.filter((_, i) => i !== index);
|
|
494
|
+
const active = w.active > index ? w.active - 1 : w.active;
|
|
495
|
+
return { ...shown, workspace: { active, metas, parked } };
|
|
496
|
+
}
|
|
497
|
+
function storedSize(doc) {
|
|
498
|
+
return new TextEncoder().encode(JSON.stringify(closeWorkspace(doc))).length;
|
|
499
|
+
}
|
|
500
|
+
|
|
251
501
|
// ../src/lib/config.ts
|
|
252
502
|
var CLOUD_BASE_URL = "https://eztab-sync-736328841982.asia-northeast1.run.app";
|
|
253
503
|
|
|
@@ -293,7 +543,12 @@ var cloud = {
|
|
|
293
543
|
async sendToInbox(payload) {
|
|
294
544
|
await req("/inbox", {
|
|
295
545
|
method: "POST",
|
|
296
|
-
headers: {
|
|
546
|
+
headers: {
|
|
547
|
+
"content-type": "application/json",
|
|
548
|
+
// The newest tab format this build edits safely; the server refuses
|
|
549
|
+
// newer tabs from older builds (docs/design/0001-multiple-tracks.md).
|
|
550
|
+
"x-eztab-format-revision": String(SUPPORTED_FORMAT_REVISION)
|
|
551
|
+
},
|
|
297
552
|
body: JSON.stringify(payload)
|
|
298
553
|
});
|
|
299
554
|
}
|
|
@@ -315,46 +570,79 @@ function cellText(cell) {
|
|
|
315
570
|
}
|
|
316
571
|
return String(cell).padStart(2, "-");
|
|
317
572
|
}
|
|
318
|
-
function render(doc, from = 0, to = doc.measures.length - 1) {
|
|
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);
|
|
319
579
|
const lines = [];
|
|
320
580
|
lines.push(`Title: ${doc.title}${doc.artist ? ` \u2014 ${doc.artist}` : ""} tempo=${doc.tempo}`);
|
|
321
|
-
|
|
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
|
+
}
|
|
322
591
|
const ann = [];
|
|
323
|
-
|
|
592
|
+
main.measures.forEach((_, m) => {
|
|
324
593
|
const k = doc.keys.find((x) => x.measure === m);
|
|
325
594
|
const s = doc.sections.find((x) => x.measure === m);
|
|
326
595
|
if (k) ann.push(`m${m}:key=${k.tonic} ${k.mode}`);
|
|
327
596
|
if (s && s.label) ann.push(`m${m}:section="${s.label}"`);
|
|
328
597
|
});
|
|
329
598
|
if (ann.length) lines.push(`Markers: ${ann.join(", ")}`);
|
|
330
|
-
|
|
599
|
+
main.measures.forEach((m, mi) => {
|
|
331
600
|
if (mi < from || mi > to) return;
|
|
332
601
|
const key = activeKeyAt(doc.keys, mi);
|
|
333
|
-
const sub = uniformSubdivision(m);
|
|
334
|
-
const subText = sub != null ? `${sub}/beat` : `[${m.subdivisions.join(",")}]/beat`;
|
|
335
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
|
+
}
|
|
336
611
|
lines.push(
|
|
337
|
-
`--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit},
|
|
612
|
+
`--- measure ${mi} (bar ${mi + 1}) (${m.beats}/${m.beatUnit}, key ${key.tonic} ${key.mode}) ---`
|
|
338
613
|
);
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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(" ")}`);
|
|
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
|
+
}
|
|
355
620
|
});
|
|
356
621
|
return lines.join("\n");
|
|
357
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
|
+
}
|
|
358
646
|
|
|
359
647
|
// notation.ts
|
|
360
648
|
var BEAT_UNITS2 = [2, 4, 8, 16];
|
|
@@ -449,6 +737,7 @@ var IMPORT_TAB_FROM_IMAGE = `Transcribe the TAB in the attached image(s) into an
|
|
|
449
737
|
- Identify which staff to take. A band score has several parts (Vocal, Guitar I/II, Keyboard, Bass, Drums). If a part was not named and more than one has TAB, list the parts you see and ask which one \u2014 do not guess.
|
|
450
738
|
- From the image work out: instrument and string count (6 lines = guitar, 4 = bass), tuning if printed (assume standard otherwise), time signature, key signature, tempo mark, and how many bars you will transcribe. Printed bar numbers (e.g. 35, 36 \u2026) are just labels \u2014 the document starts at measure 0.
|
|
451
739
|
- Start with new_tab (preset "Guitar (6)" / "Bass (4)" / \u2026, measures: 1, title if known). If the user is adding to a document already open in this session, append to it instead.
|
|
740
|
+
- To transcribe several parts into one tab (e.g. guitar + bass), write the first part on track 0, then add_track for each further part and pass its \`track\` index to write_measures. Measures and time signatures are shared by all tracks; chords (@Name) go on track 0 only.
|
|
452
741
|
- Set the key with set_key when there is a key signature (1 flat \u2192 F major, or D minor if the harmony says so), and the tempo with set_tempo when one is printed.
|
|
453
742
|
|
|
454
743
|
## 2. Write bar by bar with write_measures
|
|
@@ -480,9 +769,9 @@ var state = {
|
|
|
480
769
|
source: null
|
|
481
770
|
};
|
|
482
771
|
var server = new McpServer(
|
|
483
|
-
{ name: "eztab", version: "0.
|
|
772
|
+
{ name: "eztab", version: "0.3.0" },
|
|
484
773
|
{
|
|
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."
|
|
774
|
+
instructions: "All indices are 0-based: measure 0 is the first measure, string 0 is the top (highest) string, and column 0 is the first column. Notes and chords share the same column grid. Subdivisions are per beat and can differ within a measure (e.g. [2,2,3,2] mixes a triplet beat into straight eighths), so columns per measure = the sum of its per-beat subdivisions; get_tab's ruler shows the column indices. add_measures `at` is a 0-based insert position too. No need to ask the user about indexing. Exception: write_measures uses guitar string numbers (1 = top line), matching the labels get_tab prints. To write whole measures (e.g. transcribing a score or an image), prefer write_measures over set_cells \u2014 it takes one string per beat and does the column arithmetic for you. For transcribing from an image, follow the import_tab_from_image prompt. To deliver a result to the EZTab app, call send_to_eztab; to fix an existing tab, list_tabs \u2192 open_tab \u2192 edit \u2192 send_to_eztab. A tab can hold up to 4 tracks (parts, e.g. guitar + bass); track 0 is the main track. Tools that edit notes take an optional 0-based `track` (default 0). Measures and time signatures are shared by all tracks (adding/removing a measure affects every track); subdivisions are per track; chords belong to track 0 and use its columns."
|
|
486
775
|
}
|
|
487
776
|
);
|
|
488
777
|
function text(s) {
|
|
@@ -494,24 +783,44 @@ function requireDoc() {
|
|
|
494
783
|
}
|
|
495
784
|
return state.doc;
|
|
496
785
|
}
|
|
497
|
-
function
|
|
498
|
-
const
|
|
786
|
+
function onTrack(doc, track = 0) {
|
|
787
|
+
const n = trackMetas(doc).length;
|
|
788
|
+
if (!Number.isInteger(track) || track < 0 || track >= n) {
|
|
789
|
+
throw new Error(`track ${track} out of range (0..${n - 1}); get_tab lists the tracks`);
|
|
790
|
+
}
|
|
791
|
+
return switchTrack(doc, track);
|
|
792
|
+
}
|
|
793
|
+
function edit(track, mutate) {
|
|
794
|
+
const current = requireDoc();
|
|
795
|
+
if ((current.formatRevision ?? 1) > SUPPORTED_FORMAT_REVISION) {
|
|
796
|
+
throw new Error(
|
|
797
|
+
"This tab was saved by a newer EZTab and can only be read here. Update the EZTab MCP server (npx -y @hidechae/eztab-mcp@latest)."
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
const before = onTrack(current, track);
|
|
801
|
+
const doc = structuredClone(before);
|
|
499
802
|
mutate(doc);
|
|
803
|
+
state.doc = reconcileTracks(before, doc);
|
|
500
804
|
state.version += 1;
|
|
501
|
-
return
|
|
805
|
+
return state.doc;
|
|
502
806
|
}
|
|
503
|
-
function
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
807
|
+
function applyEdit(mutate, track) {
|
|
808
|
+
return text(render(edit(track, mutate)));
|
|
809
|
+
}
|
|
810
|
+
function applyEditRange(mutate, track) {
|
|
811
|
+
let range = [0, 0];
|
|
812
|
+
const doc = edit(track, (d) => {
|
|
813
|
+
range = mutate(d);
|
|
814
|
+
});
|
|
815
|
+
return text(render(doc, range[0], range[1], track));
|
|
509
816
|
}
|
|
510
817
|
function setDoc(doc) {
|
|
511
|
-
state.doc = doc;
|
|
818
|
+
state.doc = openWorkspace(doc);
|
|
512
819
|
state.version += 1;
|
|
513
|
-
return text(render(doc));
|
|
820
|
+
return text(render(state.doc));
|
|
514
821
|
}
|
|
822
|
+
var MISMATCH_NOTE = "Warning: this tab's extra tracks may be shifted against the main track \u2014 an older EZTab app added or removed measures without them. Check that the tracks line up (get_tab) and ask the user before sending it back.";
|
|
823
|
+
var trackParam = z.number().int().min(0).optional().describe("0-based track (part) index from get_tab; default 0, the main track.");
|
|
515
824
|
function assertMeasure(doc, m) {
|
|
516
825
|
if (!Number.isInteger(m) || m < 0 || m >= doc.measures.length) {
|
|
517
826
|
throw new Error(`measure ${m} out of range (0..${doc.measures.length - 1})`);
|
|
@@ -522,10 +831,14 @@ server.tool(
|
|
|
522
831
|
"Return a readable rendering of the document currently under edit in this session. `from`/`to` (0-based, inclusive) limit it to a range of measures. If nothing has been created yet, say so and offer new_tab.",
|
|
523
832
|
{
|
|
524
833
|
from: z.number().int().min(0).optional(),
|
|
525
|
-
to: z.number().int().min(0).optional()
|
|
834
|
+
to: z.number().int().min(0).optional(),
|
|
835
|
+
track: trackParam.describe("Show only this track (default: all tracks).")
|
|
526
836
|
},
|
|
527
837
|
async (a) => {
|
|
528
|
-
if (state.doc)
|
|
838
|
+
if (state.doc) {
|
|
839
|
+
if (a.track != null) onTrack(state.doc, a.track);
|
|
840
|
+
return text(render(state.doc, a.from, a.to, a.track));
|
|
841
|
+
}
|
|
529
842
|
return text("No document is open in this session. Call new_tab to start one.");
|
|
530
843
|
}
|
|
531
844
|
);
|
|
@@ -585,7 +898,17 @@ server.tool(
|
|
|
585
898
|
if (!meta) throw new Error(`No tab with id ${a.id}. Use list_tabs to find it.`);
|
|
586
899
|
const doc = normalize(await cloud.get(a.id));
|
|
587
900
|
state.source = { tabId: a.id, title: meta.title, baseUpdatedAt: meta.updatedAt };
|
|
588
|
-
|
|
901
|
+
const shown = setDoc(doc);
|
|
902
|
+
if (doc.trackMismatch) {
|
|
903
|
+
shown.content.unshift({ type: "text", text: MISMATCH_NOTE });
|
|
904
|
+
}
|
|
905
|
+
if ((doc.formatRevision ?? 1) > SUPPORTED_FORMAT_REVISION) {
|
|
906
|
+
shown.content.unshift({
|
|
907
|
+
type: "text",
|
|
908
|
+
text: "Note: this tab was saved by a newer EZTab; it can be read but not edited here. Update the EZTab MCP server to edit it."
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
return shown;
|
|
589
912
|
})
|
|
590
913
|
);
|
|
591
914
|
server.tool(
|
|
@@ -593,12 +916,30 @@ server.tool(
|
|
|
593
916
|
"Send the current document to the EZTab app's inbox. The user then chooses in the app what to do with it \u2014 insert its measures at the cursor, create a new tab, or (for a document opened with open_tab) replace that tab \u2014 and it stays unsaved until they press Save. Call this once the document has been checked with get_tab.",
|
|
594
917
|
{
|
|
595
918
|
label: z.string().optional().describe("Short description shown in the app, e.g. 'Guitar II, bars 1\u20138'."),
|
|
919
|
+
tracks_checked: z.boolean().optional().describe(
|
|
920
|
+
"Set to true after checking with the user that the extra tracks line up, when open_tab warned that they may be shifted."
|
|
921
|
+
),
|
|
596
922
|
as_new: z.boolean().optional().describe(
|
|
597
923
|
"For a document opened with open_tab: send it as new material instead of a revision of that tab."
|
|
598
924
|
)
|
|
599
925
|
},
|
|
600
926
|
async (a) => withCloud(async () => {
|
|
601
|
-
|
|
927
|
+
if (requireDoc().trackMismatch && !a.tracks_checked) {
|
|
928
|
+
throw new Error(
|
|
929
|
+
`${MISMATCH_NOTE} Not sent: confirm the alignment, then call send_to_eztab again with tracks_checked: true.`
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
const doc = closeWorkspace(requireDoc());
|
|
933
|
+
if ((doc.formatRevision ?? 1) > SUPPORTED_FORMAT_REVISION) {
|
|
934
|
+
throw new Error(
|
|
935
|
+
"This tab was saved by a newer EZTab; update the EZTab MCP server before sending it."
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
if (storedSize(doc) > 4.5 * 1024 * 1024) {
|
|
939
|
+
throw new Error(
|
|
940
|
+
"The tab is too large to send (over 4.5 MB). Remove measures or tracks, or send part of it as new material."
|
|
941
|
+
);
|
|
942
|
+
}
|
|
602
943
|
const target = state.source && !a.as_new ? state.source : null;
|
|
603
944
|
await cloud.sendToInbox({
|
|
604
945
|
doc,
|
|
@@ -640,7 +981,8 @@ server.tool(
|
|
|
640
981
|
tie: z.boolean().optional(),
|
|
641
982
|
pm: z.boolean().optional()
|
|
642
983
|
})
|
|
643
|
-
)
|
|
984
|
+
),
|
|
985
|
+
track: trackParam
|
|
644
986
|
},
|
|
645
987
|
async (a) => applyEdit((doc) => {
|
|
646
988
|
for (const e of a.edits) {
|
|
@@ -666,7 +1008,7 @@ server.tool(
|
|
|
666
1008
|
}
|
|
667
1009
|
m.cells[e.string][e.column] = value;
|
|
668
1010
|
}
|
|
669
|
-
})
|
|
1011
|
+
}, a.track)
|
|
670
1012
|
);
|
|
671
1013
|
server.tool(
|
|
672
1014
|
"write_measures",
|
|
@@ -678,7 +1020,10 @@ server.tool(
|
|
|
678
1020
|
time: z.string().optional(),
|
|
679
1021
|
beats: z.array(z.string()).min(1)
|
|
680
1022
|
})
|
|
681
|
-
).min(1)
|
|
1023
|
+
).min(1),
|
|
1024
|
+
track: trackParam.describe(
|
|
1025
|
+
"0-based track (part) to write; default 0. On a track other than 0, chord items (@Name) are rejected (chords belong to track 0) and `time` may not change an existing measure's time signature (it is shared by all tracks \u2014 change it on track 0). Appending adds the measure to every track."
|
|
1026
|
+
)
|
|
682
1027
|
},
|
|
683
1028
|
async (a) => applyEditRange((doc) => {
|
|
684
1029
|
let lo = Infinity;
|
|
@@ -698,17 +1043,30 @@ server.tool(
|
|
|
698
1043
|
fallback,
|
|
699
1044
|
`measure ${i}`
|
|
700
1045
|
);
|
|
701
|
-
|
|
1046
|
+
const existing = doc.measures[i];
|
|
1047
|
+
if (a.track) {
|
|
1048
|
+
if (built.chords?.some((c) => c)) {
|
|
1049
|
+
throw new Error(
|
|
1050
|
+
`measure ${i}: chord items (@Name) can only be written on track 0, which carries the chords`
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
if (existing && (existing.beats !== built.beats || existing.beatUnit !== built.beatUnit)) {
|
|
1054
|
+
throw new Error(
|
|
1055
|
+
`measure ${i}: the time signature is shared by all tracks; change it on track 0 (time "${existing.beats}/${existing.beatUnit}" here)`
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (!existing) doc.measures.push(built);
|
|
702
1060
|
else doc.measures[i] = built;
|
|
703
1061
|
lo = Math.min(lo, i);
|
|
704
1062
|
hi = Math.max(hi, i);
|
|
705
1063
|
}
|
|
706
1064
|
return [lo, hi];
|
|
707
|
-
})
|
|
1065
|
+
}, a.track)
|
|
708
1066
|
);
|
|
709
1067
|
server.tool(
|
|
710
1068
|
"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.',
|
|
1069
|
+
'Set chord-name onsets per column of track 0 (chords belong to the main track; the same time grid as its tab cells, so chords can sit on any subdivision). `measure` and `column` are 0-based; column = the sum of earlier beats\' subdivisions + slot-within-beat (see get_tab\'s ruler). A label of "rest" (or "N.C.") places a chord rest that stops the previous chord (rendered as N.C.). An empty label clears that column\'s chord.',
|
|
712
1070
|
{
|
|
713
1071
|
edits: z.array(
|
|
714
1072
|
z.object({
|
|
@@ -787,7 +1145,7 @@ server.tool(
|
|
|
787
1145
|
);
|
|
788
1146
|
server.tool(
|
|
789
1147
|
"set_time_signature",
|
|
790
|
-
"Set a measure's time signature, e.g. beats=6 beatUnit=8 for 6/8.
|
|
1148
|
+
"Set a measure's time signature, e.g. beats=6 beatUnit=8 for 6/8. Shared by all tracks: every track's measure is resized, preserving cells/chords by index.",
|
|
791
1149
|
{
|
|
792
1150
|
measure: z.number().int(),
|
|
793
1151
|
beats: z.number().int().min(1).max(32),
|
|
@@ -810,7 +1168,10 @@ server.tool(
|
|
|
810
1168
|
{
|
|
811
1169
|
measure: z.number().int(),
|
|
812
1170
|
subdivisions: z.number().int().min(1).max(12),
|
|
813
|
-
beat: z.number().int().min(0).optional()
|
|
1171
|
+
beat: z.number().int().min(0).optional(),
|
|
1172
|
+
track: trackParam.describe(
|
|
1173
|
+
"0-based track whose grid to change; default 0. Each track has its own subdivisions."
|
|
1174
|
+
)
|
|
814
1175
|
},
|
|
815
1176
|
async (a) => applyEdit((doc) => {
|
|
816
1177
|
assertMeasure(doc, a.measure);
|
|
@@ -825,11 +1186,11 @@ server.tool(
|
|
|
825
1186
|
} else {
|
|
826
1187
|
doc.measures[a.measure] = setMeasureSubdivisions(m, a.subdivisions);
|
|
827
1188
|
}
|
|
828
|
-
})
|
|
1189
|
+
}, a.track)
|
|
829
1190
|
);
|
|
830
1191
|
server.tool(
|
|
831
1192
|
"add_measures",
|
|
832
|
-
"Insert `count` empty measures. Without `at`, appends; otherwise inserts before index `at`.",
|
|
1193
|
+
"Insert `count` empty measures into every track. Without `at`, appends; otherwise inserts before index `at`.",
|
|
833
1194
|
{
|
|
834
1195
|
count: z.number().int().min(1).max(256).optional(),
|
|
835
1196
|
at: z.number().int().optional()
|
|
@@ -846,7 +1207,7 @@ server.tool(
|
|
|
846
1207
|
);
|
|
847
1208
|
server.tool(
|
|
848
1209
|
"remove_measure",
|
|
849
|
-
"Remove the measure at index `measure` (at least one is kept).",
|
|
1210
|
+
"Remove the measure at index `measure` from every track (at least one is kept).",
|
|
850
1211
|
{ measure: z.number().int() },
|
|
851
1212
|
async (a) => applyEdit((doc) => {
|
|
852
1213
|
assertMeasure(doc, a.measure);
|
|
@@ -859,11 +1220,54 @@ server.tool(
|
|
|
859
1220
|
server.tool(
|
|
860
1221
|
"set_tuning",
|
|
861
1222
|
"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) },
|
|
1223
|
+
{ tuning: z.array(z.string()).min(1), track: trackParam },
|
|
863
1224
|
async (a) => applyEdit((doc) => {
|
|
864
1225
|
doc.tuning = [...a.tuning];
|
|
865
1226
|
doc.measures = doc.measures.map((m) => resizeMeasureStrings(m, a.tuning.length));
|
|
866
|
-
})
|
|
1227
|
+
}, a.track)
|
|
1228
|
+
);
|
|
1229
|
+
var TRACK_PRESETS = [
|
|
1230
|
+
"Guitar (6)",
|
|
1231
|
+
"Guitar (7)",
|
|
1232
|
+
"Bass (4)",
|
|
1233
|
+
"Bass (5)",
|
|
1234
|
+
"Bass (6)",
|
|
1235
|
+
"Ukulele (4)"
|
|
1236
|
+
];
|
|
1237
|
+
server.tool(
|
|
1238
|
+
"add_track",
|
|
1239
|
+
"Add a track (part) to the tab, e.g. a bass line or a second guitar \u2014 up to 4 tracks including the main one. It starts empty, on track 0's measures and grid; write it with write_measures / set_cells using the new `track` index.",
|
|
1240
|
+
{
|
|
1241
|
+
name: z.string().min(1).describe("Shown in the app, e.g. 'Bass' or 'Guitar 2'."),
|
|
1242
|
+
preset: z.enum(TRACK_PRESETS).optional(),
|
|
1243
|
+
tuning: z.array(z.string()).min(1).optional().describe("Top (highest) to bottom; overrides preset.")
|
|
1244
|
+
},
|
|
1245
|
+
async (a) => {
|
|
1246
|
+
const before = trackMetas(requireDoc()).length;
|
|
1247
|
+
const tuning = a.tuning ?? PRESET_TUNINGS[a.preset ?? "Bass (4)"];
|
|
1248
|
+
const doc = edit(0, () => {
|
|
1249
|
+
});
|
|
1250
|
+
const next = addTrack(doc, a.name, tuning);
|
|
1251
|
+
if (trackMetas(next).length === before) {
|
|
1252
|
+
throw new Error("a tab can hold at most 4 tracks");
|
|
1253
|
+
}
|
|
1254
|
+
state.doc = next;
|
|
1255
|
+
return text(`Added track ${before} "${a.name}".
|
|
1256
|
+
|
|
1257
|
+
${render(next)}`);
|
|
1258
|
+
}
|
|
1259
|
+
);
|
|
1260
|
+
server.tool(
|
|
1261
|
+
"remove_track",
|
|
1262
|
+
"Remove a track (part) and its notes. Track 0 (the main track) cannot be removed.",
|
|
1263
|
+
{ track: z.number().int().min(1) },
|
|
1264
|
+
async (a) => {
|
|
1265
|
+
const doc = edit(0, () => {
|
|
1266
|
+
});
|
|
1267
|
+
onTrack(doc, a.track);
|
|
1268
|
+
state.doc = removeTrack(doc, a.track);
|
|
1269
|
+
return text(render(state.doc));
|
|
1270
|
+
}
|
|
867
1271
|
);
|
|
868
1272
|
server.prompt(
|
|
869
1273
|
"import_tab_from_image",
|