@hidechae/eztab-mcp 0.5.2 → 0.6.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.
- package/README.md +15 -1
- package/dist/server.js +216 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -102,6 +102,7 @@ Run standalone for testing: `task mcp` (or `npx tsx mcp/server.ts`).
|
|
|
102
102
|
| `add_measures` / `remove_measure` | Insert/remove measures (keys & sections re-anchored) |
|
|
103
103
|
| `set_tuning` | Replace tuning / string count (`track` で対象パートを指定) |
|
|
104
104
|
| `add_track` / `remove_track` | パート(ベース・2本目のギターなど)の追加・削除。メインを含めて最大4、track 0(メイン)は削除不可 |
|
|
105
|
+
| `set_tone` | パートの再生音色を設定する(`track` で対象パートを指定)。`add_track` でも `tone` を指定できる |
|
|
105
106
|
|
|
106
107
|
### 複数パート(トラック)
|
|
107
108
|
|
|
@@ -113,10 +114,23 @@ Run standalone for testing: `task mcp` (or `npx tsx mcp/server.ts`).
|
|
|
113
114
|
- コードは track 0 のもの(`set_chords` は track 0 の列で指定)。`write_measures(track > 0)` ではコード指定と、
|
|
114
115
|
既存小節の拍子を変える `time` を拒否する
|
|
115
116
|
- `get_tab` は全パートを小節ごとに並べて表示する(`track` で1パートに絞れる)
|
|
116
|
-
- `send_to_eztab` は対応する最新の形式リビジョンをヘッダーに付けて送る(0.
|
|
117
|
+
- `send_to_eztab` は対応する最新の形式リビジョンをヘッダーに付けて送る(0.6.0では `7`)。
|
|
117
118
|
対応上限を超える形式のタブは閲覧のみで、編集・送信できない。
|
|
118
119
|
0.2.0以前のMCPは複数パートのタブを送り返せない(サーバが409で拒否する)。
|
|
119
120
|
|
|
121
|
+
### パートの音色
|
|
122
|
+
|
|
123
|
+
0.6.0以降はパートごとの再生音色(形式リビジョン7、docs/design/0010-track-tones.md)を扱えます。
|
|
124
|
+
|
|
125
|
+
- 値は `clean`(既定)/ `crunch` / `distortion` / `acoustic` / `piano` / `synth` / `strings`
|
|
126
|
+
- 値の意味はパートの楽器で変わる。名前かチューニングでベースと判定されるパートでは、
|
|
127
|
+
`crunch` はオーバードライブ、`acoustic` はウッドベース、`synth` はシンセベースとして鳴る
|
|
128
|
+
- ベースと判定されるパートに設定できるのは `clean` / `crunch` / `acoustic` / `synth` だけ(0.6.1以降。
|
|
129
|
+
それ以外は `set_tone` / `add_track` がエラーを返す)
|
|
130
|
+
- `get_tab` はクリーン以外の音色をパート一覧に `[distortion]` のように表示する
|
|
131
|
+
(1パートのタブでは `tone=distortion`)
|
|
132
|
+
- 0.5.2以前のMCPは、クリーン以外の音色を持つタブを編集・送信できない
|
|
133
|
+
|
|
120
134
|
### 小節メモの保持
|
|
121
135
|
|
|
122
136
|
0.5.1以降はアプリで入力した小節共通メモ(形式リビジョン5)を保持して編集・送信できます。
|
package/dist/server.js
CHANGED
|
@@ -7,13 +7,26 @@ import { z } from "zod";
|
|
|
7
7
|
|
|
8
8
|
// ../src/types/tab.ts
|
|
9
9
|
var TAB_FILE_VERSION = 1;
|
|
10
|
-
var SUPPORTED_FORMAT_REVISION =
|
|
10
|
+
var SUPPORTED_FORMAT_REVISION = 7;
|
|
11
11
|
var SONG_MEMO_FORMAT_REVISION = 6;
|
|
12
12
|
var MULTI_TRACK_FORMAT_REVISION = 2;
|
|
13
13
|
var HARMONICS_FORMAT_REVISION = 3;
|
|
14
14
|
var TEMPO_CHANGES_FORMAT_REVISION = 4;
|
|
15
15
|
var MEASURE_MEMOS_FORMAT_REVISION = 5;
|
|
16
|
+
var TONE_FORMAT_REVISION = 7;
|
|
16
17
|
var MAX_TRACKS = 4;
|
|
18
|
+
var TONES = [
|
|
19
|
+
"clean",
|
|
20
|
+
"crunch",
|
|
21
|
+
"distortion",
|
|
22
|
+
"acoustic",
|
|
23
|
+
"piano",
|
|
24
|
+
"synth",
|
|
25
|
+
"strings"
|
|
26
|
+
];
|
|
27
|
+
function normalizeTone(raw) {
|
|
28
|
+
return raw !== "clean" && TONES.includes(raw) ? raw : void 0;
|
|
29
|
+
}
|
|
17
30
|
var DEFAULT_MAIN_TRACK_NAME = "Guitar";
|
|
18
31
|
var DEFAULT_TEMPO = 120;
|
|
19
32
|
var MIN_TEMPO = 20;
|
|
@@ -321,12 +334,15 @@ function normalizeDocument(data) {
|
|
|
321
334
|
}
|
|
322
335
|
function normalizeTrackMeta(raw, fallbackName) {
|
|
323
336
|
const volume = Number(raw?.volume);
|
|
324
|
-
|
|
337
|
+
const meta = {
|
|
325
338
|
id: typeof raw?.id === "string" && raw.id ? raw.id : newTabId(),
|
|
326
339
|
name: typeof raw?.name === "string" && raw.name.trim() ? raw.name : fallbackName,
|
|
327
340
|
volume: Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1,
|
|
328
341
|
muted: raw?.muted === true
|
|
329
342
|
};
|
|
343
|
+
const tone = normalizeTone(raw?.tone);
|
|
344
|
+
if (tone) meta.tone = tone;
|
|
345
|
+
return meta;
|
|
330
346
|
}
|
|
331
347
|
function defaultMainTrack() {
|
|
332
348
|
return { id: newTabId(), name: DEFAULT_MAIN_TRACK_NAME, volume: 1, muted: false };
|
|
@@ -487,6 +503,31 @@ function setBeatSubdivision(measure, beat, subdivision) {
|
|
|
487
503
|
return remapToSubdivisions(measure, subs);
|
|
488
504
|
}
|
|
489
505
|
|
|
506
|
+
// ../src/lib/music.ts
|
|
507
|
+
var LETTER_PC = {
|
|
508
|
+
C: 0,
|
|
509
|
+
D: 2,
|
|
510
|
+
E: 4,
|
|
511
|
+
F: 5,
|
|
512
|
+
G: 7,
|
|
513
|
+
A: 9,
|
|
514
|
+
B: 11
|
|
515
|
+
};
|
|
516
|
+
function noteToMidi(note) {
|
|
517
|
+
const m = /^([A-Ga-g])([#♯b♭]*)(-?\d+)?$/.exec(note.trim());
|
|
518
|
+
if (!m) return null;
|
|
519
|
+
let pc = LETTER_PC[m[1].toUpperCase()];
|
|
520
|
+
for (const ch of m[2]) {
|
|
521
|
+
if (ch === "#" || ch === "\u266F") pc += 1;
|
|
522
|
+
if (ch === "b" || ch === "\u266D") pc -= 1;
|
|
523
|
+
}
|
|
524
|
+
const octave = m[3] != null ? parseInt(m[3], 10) : 4;
|
|
525
|
+
return (octave + 1) * 12 + pc;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ../src/lib/playbackRate.ts
|
|
529
|
+
var PLAYBACK_RATES = Array.from({ length: 36 }, (_, i) => (5 + i) / 20);
|
|
530
|
+
|
|
490
531
|
// ../src/lib/tracks.ts
|
|
491
532
|
function newUid() {
|
|
492
533
|
return newTabId();
|
|
@@ -580,7 +621,7 @@ function closeWorkspace(doc) {
|
|
|
580
621
|
})
|
|
581
622
|
);
|
|
582
623
|
const mainMeta = ws.metas[0];
|
|
583
|
-
const mainIsDefault = mainMeta.name === DEFAULT_MAIN_TRACK_NAME && mainMeta.volume === 1 && !mainMeta.muted;
|
|
624
|
+
const mainIsDefault = mainMeta.name === DEFAULT_MAIN_TRACK_NAME && mainMeta.volume === 1 && !mainMeta.muted && !mainMeta.tone;
|
|
584
625
|
if (extras.length > 0 || !mainIsDefault) out.mainTrack = mainMeta;
|
|
585
626
|
if (extras.length > 0) out.tracks = extras;
|
|
586
627
|
if (tempoChanges) out.tempoChanges = tempoChanges;
|
|
@@ -591,6 +632,7 @@ function closeWorkspace(doc) {
|
|
|
591
632
|
required = Math.max(required, HARMONICS_FORMAT_REVISION);
|
|
592
633
|
}
|
|
593
634
|
if (tempoChanges) required = Math.max(required, TEMPO_CHANGES_FORMAT_REVISION);
|
|
635
|
+
if (ws.metas.some((m) => m.tone)) required = Math.max(required, TONE_FORMAT_REVISION);
|
|
594
636
|
if (out.measures.some((m) => m.memo !== void 0)) {
|
|
595
637
|
required = Math.max(required, MEASURE_MEMOS_FORMAT_REVISION);
|
|
596
638
|
}
|
|
@@ -691,6 +733,10 @@ function addTrack(doc, name, tuning) {
|
|
|
691
733
|
}))
|
|
692
734
|
};
|
|
693
735
|
const meta = { id: newTabId(), name, volume: 1, muted: false };
|
|
736
|
+
return appendTrack(doc, meta, body);
|
|
737
|
+
}
|
|
738
|
+
function appendTrack(doc, meta, body) {
|
|
739
|
+
const ws = doc.workspace;
|
|
694
740
|
const added = {
|
|
695
741
|
...doc,
|
|
696
742
|
workspace: { ...ws, metas: [...ws.metas, meta], parked: [...ws.parked, body] }
|
|
@@ -707,10 +753,142 @@ function removeTrack(doc, index) {
|
|
|
707
753
|
const active = w.active > index ? w.active - 1 : w.active;
|
|
708
754
|
return { ...shown, workspace: { active, metas, parked } };
|
|
709
755
|
}
|
|
756
|
+
function setTrackTone(doc, index, tone) {
|
|
757
|
+
const ws = doc.workspace;
|
|
758
|
+
if (!ws || !ws.metas[index]) return doc;
|
|
759
|
+
const metas = ws.metas.map((m, i) => {
|
|
760
|
+
if (i !== index) return m;
|
|
761
|
+
const { tone: _tone, ...rest } = m;
|
|
762
|
+
return tone === "clean" ? rest : { ...rest, tone };
|
|
763
|
+
});
|
|
764
|
+
return { ...doc, workspace: { ...ws, metas } };
|
|
765
|
+
}
|
|
710
766
|
function storedSize(doc) {
|
|
711
767
|
return new TextEncoder().encode(JSON.stringify(closeWorkspace(doc))).length;
|
|
712
768
|
}
|
|
713
769
|
|
|
770
|
+
// ../src/lib/player.ts
|
|
771
|
+
function instrumentOf(tuning, name = "") {
|
|
772
|
+
if (/bass|ベース/i.test(name)) return "bass";
|
|
773
|
+
const open = tuning.map(noteToMidi).filter((m) => m != null);
|
|
774
|
+
return open.length > 0 && Math.max(...open) < 50 ? "bass" : "guitar";
|
|
775
|
+
}
|
|
776
|
+
var BUFFER_BUDGET = 12e6;
|
|
777
|
+
var PREPARE_CAPACITY = BUFFER_BUDGET / 2;
|
|
778
|
+
var OPEN = 2e4;
|
|
779
|
+
var GUITAR_CLEAN = {
|
|
780
|
+
pickupHz: 4500,
|
|
781
|
+
pickupQ: 4,
|
|
782
|
+
tightHz: 20,
|
|
783
|
+
pushDb: 0,
|
|
784
|
+
drive: 1,
|
|
785
|
+
stage1: null,
|
|
786
|
+
drive2: 1,
|
|
787
|
+
stage2: null,
|
|
788
|
+
interHpHz: 20,
|
|
789
|
+
interLpHz: OPEN,
|
|
790
|
+
bodyHz: 110,
|
|
791
|
+
bodyDb: 0,
|
|
792
|
+
cabHz: OPEN,
|
|
793
|
+
cabQ: 0,
|
|
794
|
+
lowCutHz: 70,
|
|
795
|
+
level: 1
|
|
796
|
+
};
|
|
797
|
+
var BASS_CLEAN = { ...GUITAR_CLEAN, pickupHz: 2600, pickupQ: 1, lowCutHz: 30 };
|
|
798
|
+
var DIRECT = { ...GUITAR_CLEAN, pickupHz: 16e3, pickupQ: 0, lowCutHz: 30 };
|
|
799
|
+
var PIANO = { ...DIRECT, pickupHz: 7e3, bodyHz: 220, bodyDb: 3, level: 1.3 };
|
|
800
|
+
var CRUNCH = { gain: 16, bias: 0.3 };
|
|
801
|
+
var CHAINS = {
|
|
802
|
+
guitar: {
|
|
803
|
+
clean: GUITAR_CLEAN,
|
|
804
|
+
crunch: {
|
|
805
|
+
...GUITAR_CLEAN,
|
|
806
|
+
tightHz: 120,
|
|
807
|
+
pushDb: 2,
|
|
808
|
+
drive: 0.5,
|
|
809
|
+
stage1: CRUNCH,
|
|
810
|
+
bodyHz: 2800,
|
|
811
|
+
bodyDb: 2,
|
|
812
|
+
cabHz: 5200,
|
|
813
|
+
cabQ: 1,
|
|
814
|
+
lowCutHz: 80,
|
|
815
|
+
level: 0.26
|
|
816
|
+
},
|
|
817
|
+
distortion: {
|
|
818
|
+
...GUITAR_CLEAN,
|
|
819
|
+
pickupQ: 2,
|
|
820
|
+
tightHz: 250,
|
|
821
|
+
pushDb: 6,
|
|
822
|
+
drive: 0.5,
|
|
823
|
+
stage1: { gain: 24, bias: 0.25 },
|
|
824
|
+
// The next tube stage is inverted, so it clips the other half harder.
|
|
825
|
+
drive2: 1,
|
|
826
|
+
stage2: { gain: 5, bias: -0.35 },
|
|
827
|
+
interHpHz: 160,
|
|
828
|
+
interLpHz: 6500,
|
|
829
|
+
bodyHz: 2800,
|
|
830
|
+
bodyDb: 4,
|
|
831
|
+
cabHz: 4800,
|
|
832
|
+
cabQ: 3,
|
|
833
|
+
lowCutHz: 90,
|
|
834
|
+
level: 0.17
|
|
835
|
+
},
|
|
836
|
+
acoustic: { ...GUITAR_CLEAN, pickupHz: 9e3, pickupQ: 0, bodyDb: 5, level: 1.2 },
|
|
837
|
+
piano: PIANO,
|
|
838
|
+
synth: { ...DIRECT, level: 0.8 },
|
|
839
|
+
strings: { ...DIRECT, level: 0.8 }
|
|
840
|
+
},
|
|
841
|
+
bass: {
|
|
842
|
+
clean: BASS_CLEAN,
|
|
843
|
+
// Overdrive.
|
|
844
|
+
crunch: {
|
|
845
|
+
...BASS_CLEAN,
|
|
846
|
+
tightHz: 40,
|
|
847
|
+
pushDb: 3,
|
|
848
|
+
drive: 0.5,
|
|
849
|
+
stage1: { gain: 22, bias: 0.3 },
|
|
850
|
+
cabHz: 3500,
|
|
851
|
+
cabQ: 1,
|
|
852
|
+
level: 0.24
|
|
853
|
+
},
|
|
854
|
+
// The mixer offers a bass only the tones in tonesFor(); these three still
|
|
855
|
+
// play if a tab stores them (a guitar track retuned to a bass, say).
|
|
856
|
+
distortion: {
|
|
857
|
+
...BASS_CLEAN,
|
|
858
|
+
tightHz: 60,
|
|
859
|
+
pushDb: 4,
|
|
860
|
+
drive: 0.5,
|
|
861
|
+
stage1: { gain: 24, bias: 0.25 },
|
|
862
|
+
stage2: { gain: 5, bias: -0.35 },
|
|
863
|
+
interHpHz: 50,
|
|
864
|
+
interLpHz: 5e3,
|
|
865
|
+
cabHz: 3e3,
|
|
866
|
+
cabQ: 2,
|
|
867
|
+
lowCutHz: 35,
|
|
868
|
+
level: 0.15
|
|
869
|
+
},
|
|
870
|
+
// Upright bass.
|
|
871
|
+
acoustic: {
|
|
872
|
+
...BASS_CLEAN,
|
|
873
|
+
pickupHz: 3e3,
|
|
874
|
+
pickupQ: 0,
|
|
875
|
+
bodyHz: 100,
|
|
876
|
+
bodyDb: 6,
|
|
877
|
+
level: 1.4
|
|
878
|
+
},
|
|
879
|
+
piano: PIANO,
|
|
880
|
+
// Synth bass.
|
|
881
|
+
synth: { ...DIRECT, level: 0.8 },
|
|
882
|
+
strings: { ...DIRECT, level: 0.8 }
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
// ../src/lib/tones.ts
|
|
887
|
+
var BASS_TONES = ["clean", "crunch", "acoustic", "synth"];
|
|
888
|
+
function tonesFor(instrument) {
|
|
889
|
+
return instrument === "bass" ? BASS_TONES : TONES;
|
|
890
|
+
}
|
|
891
|
+
|
|
714
892
|
// ../src/lib/config.ts
|
|
715
893
|
var CLOUD_BASE_URL = "https://eztab-sync-736328841982.asia-northeast1.run.app";
|
|
716
894
|
|
|
@@ -927,11 +1105,13 @@ function render(doc, from = 0, to = doc.measures.length - 1, track) {
|
|
|
927
1105
|
lines.push(`Tracks (track index: name, tuning top\u2192bottom) measures=${main.measures.length}`);
|
|
928
1106
|
metas.forEach((t, i) => {
|
|
929
1107
|
lines.push(
|
|
930
|
-
` ${i}: ${t.name} ${bodies[i].tuning.join(" ")}${i === 0 ? " (main: carries the chords)" : ""}`
|
|
1108
|
+
` ${i}: ${t.name}${t.tone ? ` [${t.tone}]` : ""} ${bodies[i].tuning.join(" ")}${i === 0 ? " (main: carries the chords)" : ""}`
|
|
931
1109
|
);
|
|
932
1110
|
});
|
|
933
1111
|
} else {
|
|
934
|
-
lines.push(
|
|
1112
|
+
lines.push(
|
|
1113
|
+
`Tuning (top\u2192bottom): ${main.tuning.join(" ")} measures=${main.measures.length}${metas[0].tone ? ` tone=${metas[0].tone}` : ""}`
|
|
1114
|
+
);
|
|
935
1115
|
}
|
|
936
1116
|
const ann = [];
|
|
937
1117
|
main.measures.forEach((_, m) => {
|
|
@@ -1040,7 +1220,7 @@ var state = {
|
|
|
1040
1220
|
source: null
|
|
1041
1221
|
};
|
|
1042
1222
|
var server = new McpServer(
|
|
1043
|
-
{ name: "eztab", version: "0.
|
|
1223
|
+
{ name: "eztab", version: "0.6.1" },
|
|
1044
1224
|
{
|
|
1045
1225
|
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."
|
|
1046
1226
|
}
|
|
@@ -1529,6 +1709,31 @@ server.tool(
|
|
|
1529
1709
|
doc.measures = doc.measures.map((m) => resizeMeasureStrings(m, a.tuning.length));
|
|
1530
1710
|
}, a.track)
|
|
1531
1711
|
);
|
|
1712
|
+
var toneParam = z.enum(TONES).describe(
|
|
1713
|
+
"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."
|
|
1714
|
+
);
|
|
1715
|
+
function withTone(doc, track, tone) {
|
|
1716
|
+
const instrument = instrumentOf(trackBodies(doc)[track].tuning, trackMetas(doc)[track].name);
|
|
1717
|
+
const offered = tonesFor(instrument);
|
|
1718
|
+
if (!offered.includes(tone)) {
|
|
1719
|
+
throw new Error(
|
|
1720
|
+
`a ${instrument} track can't use the "${tone}" tone; choose one of: ${offered.join(", ")}`
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
return setTrackTone(doc, track, tone);
|
|
1724
|
+
}
|
|
1725
|
+
server.tool(
|
|
1726
|
+
"set_tone",
|
|
1727
|
+
"Set a track's playback tone. Only the sound changes; the notes stay as written.",
|
|
1728
|
+
{ tone: toneParam, track: trackParam },
|
|
1729
|
+
async (a) => {
|
|
1730
|
+
const doc = edit(0, () => {
|
|
1731
|
+
});
|
|
1732
|
+
onTrack(doc, a.track);
|
|
1733
|
+
state.doc = withTone(doc, a.track ?? 0, a.tone);
|
|
1734
|
+
return text(render(state.doc));
|
|
1735
|
+
}
|
|
1736
|
+
);
|
|
1532
1737
|
var TRACK_PRESETS = [
|
|
1533
1738
|
"Guitar (6)",
|
|
1534
1739
|
"Guitar (7)",
|
|
@@ -1543,17 +1748,19 @@ server.tool(
|
|
|
1543
1748
|
{
|
|
1544
1749
|
name: z.string().min(1).describe("Shown in the app, e.g. 'Bass' or 'Guitar 2'."),
|
|
1545
1750
|
preset: z.enum(TRACK_PRESETS).optional(),
|
|
1546
|
-
tuning: z.array(z.string()).min(1).optional().describe("Top (highest) to bottom; overrides preset.")
|
|
1751
|
+
tuning: z.array(z.string()).min(1).optional().describe("Top (highest) to bottom; overrides preset."),
|
|
1752
|
+
tone: toneParam.optional().describe("Playback tone (see set_tone); default clean.")
|
|
1547
1753
|
},
|
|
1548
1754
|
async (a) => {
|
|
1549
1755
|
const before = trackMetas(requireDoc()).length;
|
|
1550
1756
|
const tuning = a.tuning ?? PRESET_TUNINGS[a.preset ?? "Bass (4)"];
|
|
1551
1757
|
const doc = edit(0, () => {
|
|
1552
1758
|
});
|
|
1553
|
-
const
|
|
1554
|
-
if (trackMetas(
|
|
1759
|
+
const added = addTrack(doc, a.name, tuning);
|
|
1760
|
+
if (trackMetas(added).length === before) {
|
|
1555
1761
|
throw new Error("a tab can hold at most 4 tracks");
|
|
1556
1762
|
}
|
|
1763
|
+
const next = withTone(added, before, a.tone ?? "clean");
|
|
1557
1764
|
state.doc = next;
|
|
1558
1765
|
return text(`Added track ${before} "${a.name}".
|
|
1559
1766
|
|