bckgrnd-msc-mcp 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +140 -2
- package/dist/doctor.js +52 -0
- package/dist/index.js +30 -0
- package/dist/jobs.js +11 -0
- package/dist/library.js +47 -0
- package/dist/mix.js +183 -0
- package/dist/moods.js +22 -0
- package/dist/paths.js +48 -0
- package/dist/presets.js +68 -0
- package/dist/preview.js +103 -0
- package/dist/remote.js +201 -0
- package/dist/render.js +122 -0
- package/dist/server.js +17 -0
- package/dist/settings.js +34 -0
- package/dist/tools.js +186 -0
- package/dist/video.js +91 -0
- package/dist/wav.js +59 -0
- package/dist/workflow.js +73 -0
- package/package.json +59 -3
- package/skills/background-music/SKILL.md +164 -0
- package/skills/background-music/references/hosted.md +42 -0
- package/vendor/SOURCE.json +5 -0
- package/vendor/engine/bckgrnd_msc_engine.d.ts +198 -0
- package/vendor/engine/bckgrnd_msc_engine.js +553 -0
- package/vendor/engine/bckgrnd_msc_engine_bg.wasm +0 -0
- package/vendor/library/acid.json +1 -0
- package/vendor/library/basement.json +1 -0
- package/vendor/library/bricks.json +1 -0
- package/vendor/library/drive.json +1 -0
- package/vendor/library/glass.json +1 -0
- package/vendor/library/index.json +1 -0
- package/vendor/library/kettle.json +1 -0
- package/vendor/library/lowtide.json +1 -0
- package/vendor/library/nightbus.json +1 -0
- package/vendor/library/pirate.json +1 -0
- package/vendor/library/porch.json +1 -0
- package/vendor/library/rain.json +1 -0
- package/vendor/library/ravetape.json +1 -0
- package/vendor/library/slipstream.json +1 -0
- package/vendor/library/southside.json +1 -0
- package/vendor/library/study.json +1 -0
- package/vendor/library/tape.json +1 -0
- package/vendor/library/tunnel.json +1 -0
- package/vendor/library/warehouse.json +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chris Lane Jones
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,141 @@
|
|
|
1
|
-
#
|
|
1
|
+
# bckgrnd-msc-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://skills.sh/chrislanejones/bckgrnd-msc-mcp)
|
|
4
|
+
|
|
5
|
+
An MCP server that puts music under a voice. Hand it a narration MP3 and a track
|
|
6
|
+
name, and it renders the music to the right length, sets it under the voice by
|
|
7
|
+
**measured loudness**, dips it while the voice talks, and hands back one file.
|
|
8
|
+
|
|
9
|
+
The music comes from [bckgrnd-msc](https://github.com/chrislanejones/bckgrnd-msc), a
|
|
10
|
+
stem machine with eighteen tracks and eight stems each, synthesized by a **Rust**
|
|
11
|
+
engine. This server runs that same engine, compiled to **WebAssembly**, in Node. No
|
|
12
|
+
samples, no streaming service, no licensing questions. The music is made on your
|
|
13
|
+
machine when you ask for it.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
Needs Node 20 or newer. Mixing under a voice also needs ffmpeg
|
|
18
|
+
(`brew install ffmpeg`, `sudo apt install ffmpeg` or `winget install Gyan.FFmpeg`).
|
|
19
|
+
|
|
20
|
+
Use the local server by default, pinned to the v0.3.0 release archive.
|
|
21
|
+
The GitHub package is ready to install; publication to the npm registry is
|
|
22
|
+
pending restoration of the maintainer's registry login.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
claude mcp add bckgrnd-msc -- pnpm dlx https://github.com/chrislanejones/bckgrnd-msc-mcp/releases/download/v0.3.0/bckgrnd-msc-mcp-0.3.0.tgz
|
|
26
|
+
pnpm dlx skills add chrislanejones/bckgrnd-msc-mcp --skill background-music
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Local processing keeps narration on your machine rather than uploading it to
|
|
30
|
+
our hosted MCP. You can also [run from a clone](docs/Getting-Started.md#from-a-clone).
|
|
31
|
+
The current skills installer requires Node 22.20+.
|
|
32
|
+
|
|
33
|
+
For an explicitly chosen hosted alternative (clips up to two minutes), read
|
|
34
|
+
[ownership, source and audio-processing details](docs/Vercel.md#ownership-and-audio-processing)
|
|
35
|
+
before uploading narration:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
claude mcp add --transport http bckgrnd-msc https://bckgrnd-msc-mcp.vercel.app/mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then ask for it in plain words: "put Rain under `~/notes/article.mp3`."
|
|
42
|
+
|
|
43
|
+
The server gives an agent the tools. The skill gives it the taste: which track
|
|
44
|
+
suits which content, where to set the level, and what to change when the listener
|
|
45
|
+
says "I can't hear the words." Other clients, settings and running from a clone →
|
|
46
|
+
**[Getting Started](docs/Getting-Started.md)**.
|
|
47
|
+
|
|
48
|
+
## Preview, remember and export
|
|
49
|
+
|
|
50
|
+
Ask your agent:
|
|
51
|
+
|
|
52
|
+
- "Compare two gentle tracks under this tutorial before mixing the whole file."
|
|
53
|
+
- "Save these as my podcast settings; use subtle ducking and an evolving arrangement."
|
|
54
|
+
- "Add music to this screen recording and keep the video synchronized."
|
|
55
|
+
- "Master the finished export to -18 LUFS with a -2 dBTP ceiling."
|
|
56
|
+
|
|
57
|
+
Local setup check: `pnpm dlx https://github.com/chrislanejones/bckgrnd-msc-mcp/releases/download/v0.3.0/bckgrnd-msc-mcp-0.3.0.tgz --check`.
|
|
58
|
+
Codex setup: `codex mcp add bckgrnd-msc -- pnpm dlx https://github.com/chrislanejones/bckgrnd-msc-mcp/releases/download/v0.3.0/bckgrnd-msc-mcp-0.3.0.tgz`.
|
|
59
|
+
|
|
60
|
+
[Workflow examples](docs/Workflows.md) cover previews, presets, video exports
|
|
61
|
+
and mastering. All seven tools run locally. Hosted processing adds previews,
|
|
62
|
+
ducking controls, arrangements and mastering for short uploaded audio; saved
|
|
63
|
+
presets, video and setup diagnostics stay local.
|
|
64
|
+
|
|
65
|
+
## Documentation
|
|
66
|
+
|
|
67
|
+
- **[Vercel hosting](docs/Vercel.md)**: remote MCP endpoint, audio uploads and hosted clip limits.
|
|
68
|
+
- **[Getting Started](docs/Getting-Started.md)**: install it in Claude Code, Claude Desktop or any client, add the skill, the settings.
|
|
69
|
+
- **[Tools](docs/Tools.md)**: the local and hosted tools, every argument, what comes back, and what the errors mean.
|
|
70
|
+
- **[Workflows](docs/Workflows.md)**: compare music, save favorites, export video and master final loudness.
|
|
71
|
+
- **[Levels](docs/Levels.md)**: how the music level is set by measured loudness, the ducking, and which numbers to pick for which content.
|
|
72
|
+
- **[Tracks](docs/Tracks.md)**: the eighteen tracks and eight stems, their moods and energy, and matching music to content.
|
|
73
|
+
- **[Architecture](docs/Architecture.md)**: one engine in two places, streaming to disk, and the ffmpeg mix step by step.
|
|
74
|
+
- **[File Map](docs/File-Map.md)**: where everything lives.
|
|
75
|
+
- **[Updating the Engine](docs/Updating-the-Engine.md)**: copying a new engine or new tracks in from bckgrnd-msc.
|
|
76
|
+
- **[Development](docs/Development.md)**: the gates, what the tests check, and releasing.
|
|
77
|
+
- **[Change Summary](docs/Change-summary.md)**: the full dated release history.
|
|
78
|
+
- **[The skill](skills/background-music/SKILL.md)**: what the agent reads.
|
|
79
|
+
|
|
80
|
+
Design decisions live in **[docs/adr/](docs/adr/INDEX.md)**. Each one says what was
|
|
81
|
+
decided, what was turned down, and what would make it the wrong call later.
|
|
82
|
+
|
|
83
|
+
| ADR | Decision |
|
|
84
|
+
|---|---|
|
|
85
|
+
| [001](docs/adr/001-typescript-server-over-vendored-wasm.md) | A TypeScript server runs bckgrnd-msc's own `.wasm` build, and ffmpeg does the mix |
|
|
86
|
+
|
|
87
|
+
## Tools
|
|
88
|
+
|
|
89
|
+
| Tool | What it does |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `bckgrnd_list_tracks` | The eighteen tracks, with bpm, mood, energy (1 to 5) and what each one suits |
|
|
92
|
+
| `bckgrnd_render_track` | One track to a 48 kHz stereo WAV of any length, up to an hour. Cut any stems, set the fades |
|
|
93
|
+
| `bckgrnd_preview_tracks` | Automatically select an audible excerpt and compare up to three tracks locally or hosted |
|
|
94
|
+
| `bckgrnd_mix_under_voice` | Narration plus music to MP3/WAV, with optional presets, ducking strength, evolving arrangements and final mastering |
|
|
95
|
+
| `bckgrnd_mix_video` | Synchronized MP4 export, copying the original picture (local) |
|
|
96
|
+
| `bckgrnd_presets` | Save, list, retrieve and delete favorite mix settings (local) |
|
|
97
|
+
| `bckgrnd_check_setup` | Check dependencies, output access and the actual music engine (local) |
|
|
98
|
+
|
|
99
|
+
Rendering runs about **20x faster** than real time. Seven minutes of music takes
|
|
100
|
+
about eighteen seconds.
|
|
101
|
+
|
|
102
|
+
## Tech Stack
|
|
103
|
+
|
|
104
|
+
- **Rust → WebAssembly**: the bckgrnd-msc engine, built with `wasm-pack`, copied into `vendor/`
|
|
105
|
+
- **TypeScript** on **Node 20+**: the server
|
|
106
|
+
- **MCP TypeScript SDK**: the protocol, over stdio
|
|
107
|
+
- **Zod**: tool argument checking
|
|
108
|
+
- **ffmpeg**: loudness measuring (`ebur128`), ducking (`sidechaincompress`) and MP3 encoding
|
|
109
|
+
- **Vitest**: tests against the real server, real audio and real ffmpeg
|
|
110
|
+
|
|
111
|
+
## Changelog
|
|
112
|
+
|
|
113
|
+
Latest release below. Full dated history → **[docs/Change-summary.md](docs/Change-summary.md)**.
|
|
114
|
+
|
|
115
|
+
### v0.3.0: 10-31-2026
|
|
116
|
+
|
|
117
|
+
Automatic preview selection and recommendations, persistent favorite presets,
|
|
118
|
+
three ducking strengths, synchronized video export, evolving stem arrangements,
|
|
119
|
+
finished-export mastering and a setup health check. Seven local tools and four
|
|
120
|
+
hosted tools. The companion skill covers the complete workflow for Claude and
|
|
121
|
+
Codex. Existing calls retain their defaults; limiter delay is now compensated.
|
|
122
|
+
|
|
123
|
+
### v0.2.0: 10-21-2026
|
|
124
|
+
|
|
125
|
+
Vercel HTTP hosting, atomic file writes, unique render filenames, cancellation
|
|
126
|
+
and progress, measured export loudness/true peak, narration previews, and CI
|
|
127
|
+
with isolated package-install checks. The hosted service has three tools; the
|
|
128
|
+
local server adds `bckgrnd_preview_tracks`.
|
|
129
|
+
|
|
130
|
+
### v0.1.0: 10-21-2026
|
|
131
|
+
|
|
132
|
+
**First release. Music under a voice, with levels set by measured loudness.**
|
|
133
|
+
|
|
134
|
+
Three tools: list the tracks, render one to a WAV, and mix one under a narration.
|
|
135
|
+
The mix measures both files, sets the voice to -16 LUFS and the music 14 LU under
|
|
136
|
+
it, and dips the music while the voice talks. A quiet voice file and a loud one
|
|
137
|
+
come out at the same level. The `background-music` skill matches tracks to content.
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tool } from './mix.js';
|
|
4
|
+
import { defaultOutDir } from './paths.js';
|
|
5
|
+
import { findTrack, listTracks } from './library.js';
|
|
6
|
+
import { renderTrack } from './render.js';
|
|
7
|
+
import { toolJob } from './jobs.js';
|
|
8
|
+
export async function checkSetup(job = {}) {
|
|
9
|
+
const checks = [];
|
|
10
|
+
checks.push({ name: 'Node.js', ok: Number(process.versions.node.split('.')[0]) >= 20, detail: process.version });
|
|
11
|
+
for (const name of ['ffmpeg', 'ffprobe']) {
|
|
12
|
+
try {
|
|
13
|
+
const { stdout } = await tool(process.env[name === 'ffmpeg' ? 'FFMPEG_PATH' : 'FFPROBE_PATH'] || name, ['-version'], job);
|
|
14
|
+
checks.push({ name, ok: true, detail: stdout.split('\n')[0] ?? name });
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
checks.push({ name, ok: false, detail: error.message });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
let scratch;
|
|
21
|
+
try {
|
|
22
|
+
scratch = mkdtempSync(join(defaultOutDir(), '.health-'));
|
|
23
|
+
writeFileSync(join(scratch, 'write-check'), 'ok');
|
|
24
|
+
checks.push({ name: 'output folder', ok: true, detail: defaultOutDir() });
|
|
25
|
+
const tracks = listTracks();
|
|
26
|
+
const rendered = await renderTrack({ ...job, track: findTrack('rain'), seconds: 0.5, outputPath: join(scratch, 'engine.wav') });
|
|
27
|
+
checks.push({ name: 'music engine', ok: tracks.length === 18 && rendered.peak_dbfs > -70, detail: `${tracks.length} tracks; WASM rendered a short audio check.` });
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
checks.push({ name: scratch ? 'music engine' : 'output folder', ok: false, detail: error.message });
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
if (scratch)
|
|
34
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
return { ok: checks.every((c) => c.ok), checks,
|
|
37
|
+
connections: {
|
|
38
|
+
claude: 'claude mcp add bckgrnd-msc -- pnpm dlx https://github.com/chrislanejones/bckgrnd-msc-mcp/releases/download/v0.3.0/bckgrnd-msc-mcp-0.3.0.tgz',
|
|
39
|
+
codex: 'codex mcp add bckgrnd-msc -- pnpm dlx https://github.com/chrislanejones/bckgrnd-msc-mcp/releases/download/v0.3.0/bckgrnd-msc-mcp-0.3.0.tgz',
|
|
40
|
+
skill: 'pnpm dlx skills add chrislanejones/bckgrnd-msc-mcp --skill background-music',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function registerDoctor(server) {
|
|
45
|
+
server.registerTool('bckgrnd_check_setup', {
|
|
46
|
+
title: 'Check local audio setup', description: 'Check Node, ffmpeg/ffprobe, output-folder access and the actual WASM engine. Writes and removes a tiny temporary render. Returns fixes and Claude/Codex connection commands; it does not change client configuration.',
|
|
47
|
+
inputSchema: {}, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
48
|
+
}, async (_args, extra) => {
|
|
49
|
+
const result = await checkSetup(toolJob(extra));
|
|
50
|
+
return { content: [{ type: 'text', text: result.checks.map((c) => `${c.ok ? 'OK' : 'FAIL'} ${c.name}: ${c.detail}`).join('\n') }], structuredContent: result };
|
|
51
|
+
});
|
|
52
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bckgrnd-msc-mcp: the bckgrnd-msc stem machine as an MCP server, over stdio.
|
|
4
|
+
*/
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { createServer, VERSION } from './server.js';
|
|
7
|
+
import { checkSetup } from './doctor.js';
|
|
8
|
+
async function main() {
|
|
9
|
+
if (process.argv.includes('--check') || process.argv.includes('doctor')) {
|
|
10
|
+
const result = await checkSetup();
|
|
11
|
+
console.log(JSON.stringify(result, null, 2));
|
|
12
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (process.argv.includes('--version')) {
|
|
16
|
+
console.log(VERSION);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (process.argv.includes('--help')) {
|
|
20
|
+
console.log('bckgrnd-msc-mcp: stdio MCP server. Use --check (or doctor) to check setup, --version to show version.');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
await createServer().connect(new StdioServerTransport());
|
|
24
|
+
// stdout is the protocol channel; anything human-readable goes to stderr.
|
|
25
|
+
console.error(`bckgrnd-msc-mcp ${VERSION} on stdio`);
|
|
26
|
+
}
|
|
27
|
+
main().catch((error) => {
|
|
28
|
+
console.error(error);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
});
|
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function toolJob(extra) {
|
|
2
|
+
const token = extra._meta?.progressToken;
|
|
3
|
+
return {
|
|
4
|
+
signal: extra.signal,
|
|
5
|
+
onProgress: async (progress, message) => {
|
|
6
|
+
extra.signal.throwIfAborted();
|
|
7
|
+
if (token !== undefined)
|
|
8
|
+
await extra.sendNotification({ method: 'notifications/progress', params: { progressToken: token, progress, total: 100, message } });
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
package/dist/library.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The track library, read from the JSON bckgrnd-msc exports.
|
|
3
|
+
*
|
|
4
|
+
* `vendor/library/index.json` lists the tracks; each `<id>.json` holds the full
|
|
5
|
+
* arrangement under `track`, which is the exact object the engine's
|
|
6
|
+
* `load_track_wasm` parses.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { moodFor } from './moods.js';
|
|
11
|
+
const libraryDir = fileURLToPath(new URL('../vendor/library/', import.meta.url));
|
|
12
|
+
/** The eight stems, in the engine's mixer order (`Stem::ALL`). */
|
|
13
|
+
export const STEMS = ['kick', 'clap', 'hats', 'bass', 'stab', 'lead', 'pad', 'arp'];
|
|
14
|
+
let cached = null;
|
|
15
|
+
/** Every track, in library order. */
|
|
16
|
+
export function listTracks() {
|
|
17
|
+
if (!cached) {
|
|
18
|
+
const index = JSON.parse(readFileSync(`${libraryDir}index.json`, 'utf8'));
|
|
19
|
+
cached = index.tracks.map(({ id, name, section, style, bpm, kind }) => ({
|
|
20
|
+
id,
|
|
21
|
+
name,
|
|
22
|
+
section,
|
|
23
|
+
style,
|
|
24
|
+
bpm,
|
|
25
|
+
kind,
|
|
26
|
+
...moodFor(style),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
return cached;
|
|
30
|
+
}
|
|
31
|
+
/** Find a track by id or display name, ignoring case. */
|
|
32
|
+
export function findTrack(idOrName) {
|
|
33
|
+
const key = idOrName.trim().toLowerCase();
|
|
34
|
+
return listTracks().find((t) => t.id === key || t.name.toLowerCase() === key);
|
|
35
|
+
}
|
|
36
|
+
/** The arrangement JSON for one track, ready for `Engine.load_track_wasm`. */
|
|
37
|
+
export function arrangementJson(id) {
|
|
38
|
+
const file = JSON.parse(readFileSync(`${libraryDir}${id}.json`, 'utf8'));
|
|
39
|
+
return JSON.stringify(file.track);
|
|
40
|
+
}
|
|
41
|
+
/** An error message that names the tracks that do exist. */
|
|
42
|
+
export function unknownTrackMessage(asked) {
|
|
43
|
+
const ids = listTracks()
|
|
44
|
+
.map((t) => t.id)
|
|
45
|
+
.join(', ');
|
|
46
|
+
return `No track called "${asked}". Use one of: ${ids}. Call bckgrnd_list_tracks for styles and tempos.`;
|
|
47
|
+
}
|
package/dist/mix.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lay a music bed under a narration with ffmpeg.
|
|
3
|
+
*
|
|
4
|
+
* Levels are set by measurement, not by guesswork. Both files are measured in
|
|
5
|
+
* LUFS (EBU R128 integrated loudness, the scale podcasts and streaming services
|
|
6
|
+
* normalize to), the voice is brought to a target loudness, and the music is set
|
|
7
|
+
* a fixed number of LU below the voice. A quiet TTS file and a hot podcast take
|
|
8
|
+
* end up with the same balance.
|
|
9
|
+
*
|
|
10
|
+
* The voice is then the key input to a sidechain compressor on the music, so the
|
|
11
|
+
* bed dips while someone is talking and comes back up in the pauses between
|
|
12
|
+
* paragraphs. Because the voice has already been brought to its target, the
|
|
13
|
+
* compressor's threshold means the same thing for every input. The music starts
|
|
14
|
+
* a moment before the voice and rings out after it, then fades.
|
|
15
|
+
*/
|
|
16
|
+
import { execFile } from 'node:child_process';
|
|
17
|
+
import { promisify } from 'node:util';
|
|
18
|
+
import { resolve, join } from 'node:path';
|
|
19
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { atomicOutput } from './paths.js';
|
|
22
|
+
const run = promisify(execFile);
|
|
23
|
+
const ffmpeg = () => process.env.FFMPEG_PATH || 'ffmpeg';
|
|
24
|
+
const ffprobe = () => process.env.FFPROBE_PATH || 'ffprobe';
|
|
25
|
+
const INSTALL_HINT = 'Install it and try again: macOS "brew install ffmpeg", Debian/Ubuntu "sudo apt install ffmpeg", ' +
|
|
26
|
+
'Windows "winget install Gyan.FFmpeg". Or point FFMPEG_PATH and FFPROBE_PATH at the binaries.';
|
|
27
|
+
/** ebur128 reports this for digital silence; anything at or below it has no level to match. */
|
|
28
|
+
export const SILENCE_LUFS = -70;
|
|
29
|
+
export class MissingFfmpegError extends Error {
|
|
30
|
+
}
|
|
31
|
+
export async function tool(cmd, args, job = {}) {
|
|
32
|
+
try {
|
|
33
|
+
return await run(cmd, args, { maxBuffer: 4 * 1024 * 1024, timeout: 120_000, signal: job.signal, killSignal: 'SIGKILL' });
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code === 'ENOENT') {
|
|
37
|
+
throw new MissingFfmpegError(`"${cmd}" isn't installed or isn't on PATH. ${INSTALL_HINT}`, { cause: error });
|
|
38
|
+
}
|
|
39
|
+
const stderr = String(error.stderr ?? '').trim();
|
|
40
|
+
const tail = stderr.split('\n').slice(-4).join('\n');
|
|
41
|
+
throw new Error(`${cmd} failed: ${tail || error.message}`, { cause: error });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Length of any file ffmpeg can read, in seconds. */
|
|
45
|
+
export async function durationSeconds(path, job = {}) {
|
|
46
|
+
const { stdout } = await tool(ffprobe(), ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path], job);
|
|
47
|
+
const seconds = Number.parseFloat(stdout.trim());
|
|
48
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
49
|
+
throw new Error(`Couldn't read a duration from ${path}. Is it an audio file?`);
|
|
50
|
+
}
|
|
51
|
+
return seconds;
|
|
52
|
+
}
|
|
53
|
+
/** Integrated loudness of a file, in LUFS. */
|
|
54
|
+
export async function loudnessLufs(path, job = {}) {
|
|
55
|
+
const { stderr } = await tool(ffmpeg(), ['-hide_banner', '-nostats', '-nostdin', '-i', path, '-map', '0:a:0', '-af', 'aformat=channel_layouts=stereo,ebur128=framelog=quiet', '-f', 'null', '-'], job);
|
|
56
|
+
const lufs = parseIntegratedLoudness(stderr);
|
|
57
|
+
if (lufs === null)
|
|
58
|
+
throw new Error(`Couldn't measure the loudness of ${path}.`);
|
|
59
|
+
return lufs;
|
|
60
|
+
}
|
|
61
|
+
/** Pull "I: -19.6 LUFS" out of ebur128's summary. The last one is the whole file. */
|
|
62
|
+
export function parseIntegratedLoudness(log) {
|
|
63
|
+
const matches = [...log.matchAll(/^\s*I:\s+(-?\d+(?:\.\d+)?)\s+LUFS/gm)];
|
|
64
|
+
const last = matches.at(-1);
|
|
65
|
+
return last ? Number.parseFloat(last[1]) : null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The two gains that put the voice at `targetLufs` and the music `musicDb` LU
|
|
69
|
+
* under it. Silent music is left alone: there's no level to match.
|
|
70
|
+
*/
|
|
71
|
+
export function levelsFor(voiceLufs, musicLufs, targetLufs, musicDb) {
|
|
72
|
+
const round = (v) => Math.round(v * 10) / 10;
|
|
73
|
+
const voiceGainDb = voiceLufs > SILENCE_LUFS ? targetLufs - voiceLufs : 0;
|
|
74
|
+
const musicGainDb = musicLufs > SILENCE_LUFS ? targetLufs + musicDb - musicLufs : 0;
|
|
75
|
+
return { voiceGainDb: round(voiceGainDb), musicGainDb: round(musicGainDb) };
|
|
76
|
+
}
|
|
77
|
+
/** The `-filter_complex` graph. Exported so the tests can pin it. */
|
|
78
|
+
export function mixGraph(o) {
|
|
79
|
+
const leadMs = Math.round(o.leadInSeconds * 1000);
|
|
80
|
+
const fadeStart = Math.max(0, o.totalSeconds - o.tailSeconds).toFixed(3);
|
|
81
|
+
const fadeDur = Math.max(0.01, o.tailSeconds).toFixed(3);
|
|
82
|
+
const fmt = 'aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo';
|
|
83
|
+
const voice = `[0:a:0]${fmt},volume=${o.voiceGainDb}dB,adelay=delays=${leadMs}:all=1,apad[v]`;
|
|
84
|
+
const bed = `[1:a:0]${fmt},volume=${o.musicGainDb}dB,afade=t=out:st=${fadeStart}:d=${fadeDur}[m]`;
|
|
85
|
+
// The limiter only catches the odd peak the gain pushed over; levels come from the gains.
|
|
86
|
+
const out = `amix=inputs=2:duration=longest:normalize=0,alimiter=limit=0.95:level=0:latency=1[out]`;
|
|
87
|
+
if (!o.duck)
|
|
88
|
+
return [voice, bed, `[v][m]${out}`].join(';');
|
|
89
|
+
const compressor = {
|
|
90
|
+
subtle: 'threshold=0.035:ratio=2:attack=60:release=1200',
|
|
91
|
+
normal: 'threshold=0.02:ratio=6:attack=30:release=600',
|
|
92
|
+
strong: 'threshold=0.012:ratio=10:attack=15:release=900',
|
|
93
|
+
}[o.ducking ?? 'normal'];
|
|
94
|
+
return [
|
|
95
|
+
voice,
|
|
96
|
+
`[v]asplit=2[vkey][vout]`,
|
|
97
|
+
bed,
|
|
98
|
+
// With the voice at its target, this threshold sits under speech and over room tone.
|
|
99
|
+
`[m][vkey]sidechaincompress=${compressor}[md]`,
|
|
100
|
+
`[vout][md]${out}`,
|
|
101
|
+
].join(';');
|
|
102
|
+
}
|
|
103
|
+
export async function mixUnderVoice(o) {
|
|
104
|
+
if ([o.voicePath, o.musicPath].some((p) => resolve(p) === resolve(o.outputPath)))
|
|
105
|
+
throw new Error('Output must differ from input files.');
|
|
106
|
+
return atomicOutput(o.outputPath, o.overwrite, async (temporary) => {
|
|
107
|
+
if (o.finalLufs === undefined)
|
|
108
|
+
await mixToFile({ ...o, outputPath: temporary });
|
|
109
|
+
else {
|
|
110
|
+
const scratch = mkdtempSync(join(tmpdir(), 'bckgrnd-master-'));
|
|
111
|
+
try {
|
|
112
|
+
const raw = join(scratch, 'mix.wav');
|
|
113
|
+
await mixToFile({ ...o, outputPath: raw });
|
|
114
|
+
await o.onProgress?.(85, 'Mastering finished mix');
|
|
115
|
+
await masterAudio(raw, temporary, o.finalLufs, o.truePeakDbfs ?? -1.5, o);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
await o.onProgress?.(90, 'Measuring finished audio');
|
|
122
|
+
return measureAudio(temporary, o);
|
|
123
|
+
}, o);
|
|
124
|
+
}
|
|
125
|
+
/** Two-pass normalization of the complete mix, with headroom for lossy encoding. */
|
|
126
|
+
export async function masterAudio(input, output, target, peak, job = {}, codec) {
|
|
127
|
+
const encoding = codec ?? (output.toLowerCase().endsWith('.wav') ? ['-c:a', 'pcm_s16le'] : ['-c:a', 'libmp3lame', '-q:a', '2']);
|
|
128
|
+
const { stderr } = await tool(ffmpeg(), ['-hide_banner', '-nostats', '-nostdin', '-i', input, '-map', '0:a:0', '-af', `loudnorm=I=${target}:TP=${peak}:LRA=50:print_format=json`, '-f', 'null', '-'], job);
|
|
129
|
+
const json = stderr.match(/\{\s*"input_i"[\s\S]*?\}/)?.[0];
|
|
130
|
+
if (!json)
|
|
131
|
+
throw new Error('Could not measure the mix for mastering.');
|
|
132
|
+
const stats = JSON.parse(json);
|
|
133
|
+
for (const key of ['input_i', 'input_tp', 'input_lra', 'input_thresh', 'target_offset']) {
|
|
134
|
+
if (!Number.isFinite(Number(stats[key])))
|
|
135
|
+
throw new Error('Cannot master silent or invalid audio.');
|
|
136
|
+
}
|
|
137
|
+
let ceiling = Math.max(-9, peak - 0.3);
|
|
138
|
+
let normalizationTarget = target;
|
|
139
|
+
let measured;
|
|
140
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
141
|
+
const filter = `loudnorm=I=${normalizationTarget}:TP=${ceiling}:LRA=50:measured_I=${stats.input_i}:measured_TP=${stats.input_tp}:measured_LRA=${stats.input_lra}:measured_thresh=${stats.input_thresh}:offset=${stats.target_offset}:linear=true`;
|
|
142
|
+
await tool(ffmpeg(), ['-v', 'error', '-nostdin', '-y', '-i', input, '-map', '0:a:0', '-af', filter, '-ar', '48000', ...encoding, output], job);
|
|
143
|
+
measured = await measureAudio(output, job);
|
|
144
|
+
if (measured.output_true_peak_dbfs <= peak + 0.05 && Math.abs(measured.output_lufs - target) <= 0.3)
|
|
145
|
+
return;
|
|
146
|
+
normalizationTarget = Math.max(-70, Math.min(-5, normalizationTarget + target - measured.output_lufs));
|
|
147
|
+
if (measured.output_true_peak_dbfs > peak + 0.05)
|
|
148
|
+
ceiling = Math.max(-9, ceiling - (measured.output_true_peak_dbfs - peak) - 0.3);
|
|
149
|
+
}
|
|
150
|
+
if (measured.output_true_peak_dbfs > peak + 0.05)
|
|
151
|
+
throw new Error('Could not meet the requested encoded true-peak ceiling. Try a lower final_lufs target.');
|
|
152
|
+
}
|
|
153
|
+
async function mixToFile(o) {
|
|
154
|
+
const codec = o.outputPath.toLowerCase().endsWith('.wav') ? ['-c:a', 'pcm_s16le'] : ['-c:a', 'libmp3lame', '-q:a', '2'];
|
|
155
|
+
await tool(ffmpeg(), [
|
|
156
|
+
'-hide_banner',
|
|
157
|
+
'-nostdin',
|
|
158
|
+
o.overwrite ? '-y' : '-n',
|
|
159
|
+
'-i', o.voicePath,
|
|
160
|
+
// Loop the music, so a bed shorter than the voice keeps going; -t cuts it.
|
|
161
|
+
'-stream_loop', '-1',
|
|
162
|
+
'-i', o.musicPath,
|
|
163
|
+
'-filter_complex', mixGraph(o),
|
|
164
|
+
'-map', '[out]',
|
|
165
|
+
'-t', o.totalSeconds.toFixed(3),
|
|
166
|
+
'-ar', '48000',
|
|
167
|
+
...codec,
|
|
168
|
+
o.outputPath,
|
|
169
|
+
], o);
|
|
170
|
+
}
|
|
171
|
+
/** Measure the actual encoded export, including ducking, fades and limiting. */
|
|
172
|
+
export async function measureAudio(path, job = {}) {
|
|
173
|
+
const { stderr } = await tool(ffmpeg(), ['-hide_banner', '-nostats', '-nostdin', '-i', path, '-af', 'ebur128=peak=true:framelog=quiet', '-f', 'null', '-'], job);
|
|
174
|
+
const loudness = parseIntegratedLoudness(stderr);
|
|
175
|
+
const peak = [...stderr.matchAll(/Peak:\s+(-?(?:\d+(?:\.\d+)?|inf))\s+dBFS/g)].at(-1);
|
|
176
|
+
if (loudness === null || !peak)
|
|
177
|
+
throw new Error('Could not measure the finished audio.');
|
|
178
|
+
const value = Number(peak[1]);
|
|
179
|
+
return { output_lufs: loudness, output_true_peak_dbfs: Number.isFinite(value) ? value : -120 };
|
|
180
|
+
}
|
|
181
|
+
export async function extractPreview(input, output, start, seconds, job = {}) {
|
|
182
|
+
await tool(ffmpeg(), ['-v', 'error', '-nostdin', '-ss', String(start), '-i', input, '-t', String(seconds), '-map', '0:a:0', '-c:a', 'pcm_s16le', '-ar', '48000', output], job);
|
|
183
|
+
}
|
package/dist/moods.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each style is for, so an agent can match music to what's being read.
|
|
3
|
+
*
|
|
4
|
+
* These are set per style, not per track, and they're editorial: a first pass
|
|
5
|
+
* from the style and tempo, to be tuned by ear. Energy runs 1 (barely there) to
|
|
6
|
+
* 5 (wants your attention). Anything at 3 or above competes with dense speech.
|
|
7
|
+
*/
|
|
8
|
+
export const MOODS = {
|
|
9
|
+
Chillhop: { mood: 'relaxed, unhurried', energy: 1, good_for: ['long articles', 'explainers', 'study notes', 'how-tos'] },
|
|
10
|
+
Dusty: { mood: 'warm, nostalgic', energy: 1, good_for: ['stories', 'history', 'personal essays', 'memoir'] },
|
|
11
|
+
Jazz: { mood: 'cozy, jazzy', energy: 1, good_for: ['food and travel', 'culture', 'interviews', 'reviews'] },
|
|
12
|
+
Deep: { mood: 'moody, late-night', energy: 2, good_for: ['tech and science', 'analysis', 'night reading'] },
|
|
13
|
+
House: { mood: 'upbeat, steady four-on-the-floor', energy: 3, good_for: ['news roundups', 'product updates', 'changelogs', 'demos'] },
|
|
14
|
+
Acid: { mood: 'intense, squelchy', energy: 4, good_for: ['hype reels', 'launch trailers'] },
|
|
15
|
+
Garage: { mood: 'bouncy, swung', energy: 3, good_for: ['pop culture', 'sports', 'light commentary'] },
|
|
16
|
+
Breakbeat: { mood: 'energetic, rave', energy: 4, good_for: ['gaming', 'trailers', 'montages'] },
|
|
17
|
+
'Liquid DnB': { mood: 'fast but smooth', energy: 3, good_for: ['recaps', 'fast-paced explainers', 'workouts'] },
|
|
18
|
+
};
|
|
19
|
+
const UNKNOWN = { mood: 'unrated', energy: 3, good_for: [] };
|
|
20
|
+
export function moodFor(style) {
|
|
21
|
+
return MOODS[style] ?? UNKNOWN;
|
|
22
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where output goes, and the checks that keep a tool call from writing somewhere
|
|
3
|
+
* it shouldn't.
|
|
4
|
+
*
|
|
5
|
+
* An MCP server is started by the client from a working directory nobody chose,
|
|
6
|
+
* so relative paths are refused rather than guessed at.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, renameSync, linkSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { basename, dirname, extname, isAbsolute, join } from 'node:path';
|
|
11
|
+
/** Build beside the destination, then publish atomically without a check/write race. */
|
|
12
|
+
export async function atomicOutput(path, overwrite, work, job = {}) {
|
|
13
|
+
job.signal?.throwIfAborted();
|
|
14
|
+
const dir = mkdtempSync(join(dirname(path), '.bckgrnd-'));
|
|
15
|
+
try {
|
|
16
|
+
const temporary = join(dir, basename(path));
|
|
17
|
+
const result = await work(temporary);
|
|
18
|
+
job.signal?.throwIfAborted();
|
|
19
|
+
if (overwrite)
|
|
20
|
+
renameSync(temporary, path);
|
|
21
|
+
else
|
|
22
|
+
linkSync(temporary, path);
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** `BCKGRND_MSC_OUT_DIR`, or a folder in the system temp directory. */
|
|
30
|
+
export function defaultOutDir() {
|
|
31
|
+
const dir = process.env.BCKGRND_MSC_OUT_DIR || join(tmpdir(), 'bckgrnd-msc');
|
|
32
|
+
mkdirSync(dir, { recursive: true });
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Validate an output path and return a message if it can't be used.
|
|
37
|
+
* Returns null when the path is fine.
|
|
38
|
+
*/
|
|
39
|
+
export function outputProblem(path, allowed, overwrite) {
|
|
40
|
+
if (!isAbsolute(path))
|
|
41
|
+
return `output_path must be absolute, got "${path}".`;
|
|
42
|
+
const ext = extname(path).toLowerCase();
|
|
43
|
+
if (!allowed.includes(ext))
|
|
44
|
+
return `output_path must end in ${allowed.join(' or ')}, got "${ext || 'no extension'}".`;
|
|
45
|
+
if (!overwrite && existsSync(path))
|
|
46
|
+
return `${path} already exists. Pass overwrite: true to replace it, or pick another output_path.`;
|
|
47
|
+
return null;
|
|
48
|
+
}
|
package/dist/presets.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, isAbsolute } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { atomicOutput } from './paths.js';
|
|
6
|
+
import { findTrack, unknownTrackMessage } from './library.js';
|
|
7
|
+
import { mixSettingsSchema, presetNameSchema } from './settings.js';
|
|
8
|
+
export function presetDir() {
|
|
9
|
+
const root = process.env.BCKGRND_MSC_CONFIG_DIR ?? join(homedir(), '.config', 'bckgrnd-msc');
|
|
10
|
+
if (!isAbsolute(root))
|
|
11
|
+
throw new Error('BCKGRND_MSC_CONFIG_DIR must be absolute.');
|
|
12
|
+
return join(root, 'presets');
|
|
13
|
+
}
|
|
14
|
+
function presetPath(name) { return join(presetDir(), `${presetNameSchema.parse(name)}.json`); }
|
|
15
|
+
export function readPreset(name) {
|
|
16
|
+
const path = presetPath(name);
|
|
17
|
+
if (!existsSync(path))
|
|
18
|
+
throw new Error(`No preset called "${name}". List saved settings with bckgrnd_presets.`);
|
|
19
|
+
return mixSettingsSchema.parse(JSON.parse(readFileSync(path, 'utf8')));
|
|
20
|
+
}
|
|
21
|
+
export function resolveSettings(input) {
|
|
22
|
+
const explicit = Object.fromEntries(Object.entries(input).filter(([key, value]) => key in mixSettingsSchema.shape && value !== undefined));
|
|
23
|
+
const settings = { ...(input.preset ? readPreset(input.preset) : {}), ...explicit };
|
|
24
|
+
if (input.music_path && input.track === undefined)
|
|
25
|
+
delete settings.track;
|
|
26
|
+
return mixSettingsSchema.parse(settings);
|
|
27
|
+
}
|
|
28
|
+
export function registerPresets(server) {
|
|
29
|
+
server.registerTool('bckgrnd_presets', {
|
|
30
|
+
title: 'Save and reuse favorite mix settings',
|
|
31
|
+
description: 'List, get, save or delete local named presets. Saves track, levels, ducking, stem cuts, arrangement and intro/outro/mastering settings, never media paths. save refuses an existing name unless overwrite:true. Explicit mix arguments override a preset.',
|
|
32
|
+
inputSchema: {
|
|
33
|
+
action: z.enum(['list', 'get', 'save', 'delete']), name: presetNameSchema.optional(),
|
|
34
|
+
settings: mixSettingsSchema.optional(), overwrite: z.boolean().default(false),
|
|
35
|
+
},
|
|
36
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
37
|
+
}, async ({ action, name, settings, overwrite }) => {
|
|
38
|
+
try {
|
|
39
|
+
if (action === 'list') {
|
|
40
|
+
const names = existsSync(presetDir()) ? readdirSync(presetDir()).filter((f) => /^[a-z0-9][a-z0-9-]{0,63}\.json$/.test(f)).map((f) => f.slice(0, -5)).sort() : [];
|
|
41
|
+
return { content: [{ type: 'text', text: names.join('\n') || 'No saved presets yet.' }], structuredContent: { presets: names } };
|
|
42
|
+
}
|
|
43
|
+
if (!name)
|
|
44
|
+
throw new Error(`name is required for ${action}.`);
|
|
45
|
+
if (action === 'save') {
|
|
46
|
+
if (!settings || Object.keys(settings).length === 0)
|
|
47
|
+
throw new Error('Provide at least one setting to save.');
|
|
48
|
+
if (settings.track && !findTrack(settings.track))
|
|
49
|
+
throw new Error(unknownTrackMessage(settings.track));
|
|
50
|
+
if (settings.true_peak_dbfs !== undefined && settings.final_lufs === undefined)
|
|
51
|
+
throw new Error('Set final_lufs when specifying a true-peak ceiling.');
|
|
52
|
+
mkdirSync(presetDir(), { recursive: true });
|
|
53
|
+
if (!overwrite && existsSync(presetPath(name)))
|
|
54
|
+
throw new Error(`Preset "${name}" already exists. Use overwrite:true to replace it.`);
|
|
55
|
+
await atomicOutput(presetPath(name), overwrite, async (temporary) => { writeFileSync(temporary, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 }); });
|
|
56
|
+
}
|
|
57
|
+
if (action === 'delete') {
|
|
58
|
+
readPreset(name);
|
|
59
|
+
unlinkSync(presetPath(name));
|
|
60
|
+
}
|
|
61
|
+
const result = { name, ...(action === 'delete' ? { deleted: true } : { settings: readPreset(name) }) };
|
|
62
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result };
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return { isError: true, content: [{ type: 'text', text: error.message }] };
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|