bckgrnd-msc-mcp 0.0.0-stage → 0.2.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 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,110 @@
1
- # Temporary Holding Version
1
+ # bckgrnd-msc-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![skills.sh](https://skills.sh/b/chrislanejones/bckgrnd-msc-mcp)](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 version 0.2.0:
21
+
22
+ ```sh
23
+ claude mcp add bckgrnd-msc -- pnpm dlx bckgrnd-msc-mcp@0.2.0
24
+ pnpm dlx skills add chrislanejones/bckgrnd-msc-mcp --skill background-music
25
+ ```
26
+
27
+ Local processing keeps narration on your machine rather than uploading it to
28
+ our hosted MCP. You can also [run from a clone](docs/Getting-Started.md#from-a-clone).
29
+ The current skills installer requires Node 22.20+.
30
+
31
+ For an explicitly chosen hosted alternative (clips up to two minutes), read
32
+ [ownership, source and audio-processing details](docs/Vercel.md#ownership-and-audio-processing)
33
+ before uploading narration:
34
+
35
+ ```sh
36
+ claude mcp add --transport http bckgrnd-msc https://bckgrnd-msc-mcp.vercel.app/mcp
37
+ ```
38
+
39
+ Then ask for it in plain words: "put Rain under `~/notes/article.mp3`."
40
+
41
+ The server gives an agent the tools. The skill gives it the taste: which track
42
+ suits which content, where to set the level, and what to change when the listener
43
+ says "I can't hear the words." Other clients, settings and running from a clone →
44
+ **[Getting Started](docs/Getting-Started.md)**.
45
+
46
+ ## Documentation
47
+
48
+ - **[Vercel hosting](docs/Vercel.md)**: remote MCP endpoint, audio uploads and hosted clip limits.
49
+ - **[Getting Started](docs/Getting-Started.md)**: install it in Claude Code, Claude Desktop or any client, add the skill, the settings.
50
+ - **[Tools](docs/Tools.md)**: the local and hosted tools, every argument, what comes back, and what the errors mean.
51
+ - **[Levels](docs/Levels.md)**: how the music level is set by measured loudness, the ducking, and which numbers to pick for which content.
52
+ - **[Tracks](docs/Tracks.md)**: the eighteen tracks and eight stems, their moods and energy, and matching music to content.
53
+ - **[Architecture](docs/Architecture.md)**: one engine in two places, streaming to disk, and the ffmpeg mix step by step.
54
+ - **[File Map](docs/File-Map.md)**: where everything lives.
55
+ - **[Updating the Engine](docs/Updating-the-Engine.md)**: copying a new engine or new tracks in from bckgrnd-msc.
56
+ - **[Development](docs/Development.md)**: the gates, what the tests check, and releasing.
57
+ - **[Change Summary](docs/Change-summary.md)**: the full dated release history.
58
+ - **[The skill](skills/background-music/SKILL.md)**: what the agent reads.
59
+
60
+ Design decisions live in **[docs/adr/](docs/adr/INDEX.md)**. Each one says what was
61
+ decided, what was turned down, and what would make it the wrong call later.
62
+
63
+ | ADR | Decision |
64
+ |---|---|
65
+ | [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 |
66
+
67
+ ## Tools
68
+
69
+ | Tool | What it does |
70
+ |---|---|
71
+ | `bckgrnd_list_tracks` | The eighteen tracks, with bpm, mood, energy (1 to 5) and what each one suits |
72
+ | `bckgrnd_render_track` | One track to a 48 kHz stereo WAV of any length, up to an hour. Cut any stems, set the fades |
73
+ | `bckgrnd_preview_tracks` | Compare up to three tracks under the same short narration excerpt (local server) |
74
+ | `bckgrnd_mix_under_voice` | A voice file plus a track, or your own music file, to one MP3 or WAV |
75
+
76
+ Rendering runs about **20x faster** than real time. Seven minutes of music takes
77
+ about eighteen seconds.
78
+
79
+ ## Tech Stack
80
+
81
+ - **Rust → WebAssembly**: the bckgrnd-msc engine, built with `wasm-pack`, copied into `vendor/`
82
+ - **TypeScript** on **Node 20+**: the server
83
+ - **MCP TypeScript SDK**: the protocol, over stdio
84
+ - **Zod**: tool argument checking
85
+ - **ffmpeg**: loudness measuring (`ebur128`), ducking (`sidechaincompress`) and MP3 encoding
86
+ - **Vitest**: tests against the real server, real audio and real ffmpeg
87
+
88
+ ## Changelog
89
+
90
+ Latest release below. Full dated history → **[docs/Change-summary.md](docs/Change-summary.md)**.
91
+
92
+ ### v0.2.0: 10-21-2026 — package publication pending
93
+
94
+ Vercel HTTP hosting, atomic file writes, unique render filenames, cancellation
95
+ and progress, measured export loudness/true peak, narration previews, and CI
96
+ with isolated package-install checks. The hosted service has three tools; the
97
+ local server adds `bckgrnd_preview_tracks`.
98
+
99
+ ### v0.1.0: 10-21-2026
100
+
101
+ **First release. Music under a voice, with levels set by measured loudness.**
102
+
103
+ Three tools: list the tracks, render one to a WAV, and mix one under a narration.
104
+ The mix measures both files, sets the voice to -16 LUFS and the music 14 LU under
105
+ it, and dips the music while the voice talks. A quiet voice file and a loud one
106
+ come out at the same level. The `background-music` skill matches tracks to content.
107
+
108
+ ## License
109
+
110
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
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
+ async function main() {
8
+ await createServer().connect(new StdioServerTransport());
9
+ // stdout is the protocol channel; anything human-readable goes to stderr.
10
+ console.error(`bckgrnd-msc-mcp ${VERSION} on stdio`);
11
+ }
12
+ main().catch((error) => {
13
+ console.error(error);
14
+ process.exit(1);
15
+ });
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
+ }
@@ -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,135 @@
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 } from 'node:path';
19
+ import { atomicOutput } from './paths.js';
20
+ const run = promisify(execFile);
21
+ const ffmpeg = () => process.env.FFMPEG_PATH || 'ffmpeg';
22
+ const ffprobe = () => process.env.FFPROBE_PATH || 'ffprobe';
23
+ const INSTALL_HINT = 'Install it and try again: macOS "brew install ffmpeg", Debian/Ubuntu "sudo apt install ffmpeg", ' +
24
+ 'Windows "winget install Gyan.FFmpeg". Or point FFMPEG_PATH and FFPROBE_PATH at the binaries.';
25
+ /** ebur128 reports this for digital silence; anything at or below it has no level to match. */
26
+ export const SILENCE_LUFS = -70;
27
+ export class MissingFfmpegError extends Error {
28
+ }
29
+ async function tool(cmd, args, job = {}) {
30
+ try {
31
+ return await run(cmd, args, { maxBuffer: 4 * 1024 * 1024, timeout: 120_000, signal: job.signal, killSignal: 'SIGKILL' });
32
+ }
33
+ catch (error) {
34
+ if (error.code === 'ENOENT') {
35
+ throw new MissingFfmpegError(`"${cmd}" isn't installed or isn't on PATH. ${INSTALL_HINT}`, { cause: error });
36
+ }
37
+ const stderr = String(error.stderr ?? '').trim();
38
+ const tail = stderr.split('\n').slice(-4).join('\n');
39
+ throw new Error(`${cmd} failed: ${tail || error.message}`, { cause: error });
40
+ }
41
+ }
42
+ /** Length of any file ffmpeg can read, in seconds. */
43
+ export async function durationSeconds(path, job = {}) {
44
+ const { stdout } = await tool(ffprobe(), ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path], job);
45
+ const seconds = Number.parseFloat(stdout.trim());
46
+ if (!Number.isFinite(seconds) || seconds <= 0) {
47
+ throw new Error(`Couldn't read a duration from ${path}. Is it an audio file?`);
48
+ }
49
+ return seconds;
50
+ }
51
+ /** Integrated loudness of a file, in LUFS. */
52
+ export async function loudnessLufs(path, job = {}) {
53
+ const { stderr } = await tool(ffmpeg(), ['-hide_banner', '-nostats', '-nostdin', '-i', path, '-af', 'ebur128=framelog=quiet', '-f', 'null', '-'], job);
54
+ const lufs = parseIntegratedLoudness(stderr);
55
+ if (lufs === null)
56
+ throw new Error(`Couldn't measure the loudness of ${path}.`);
57
+ return lufs;
58
+ }
59
+ /** Pull "I: -19.6 LUFS" out of ebur128's summary. The last one is the whole file. */
60
+ export function parseIntegratedLoudness(log) {
61
+ const matches = [...log.matchAll(/^\s*I:\s+(-?\d+(?:\.\d+)?)\s+LUFS/gm)];
62
+ const last = matches.at(-1);
63
+ return last ? Number.parseFloat(last[1]) : null;
64
+ }
65
+ /**
66
+ * The two gains that put the voice at `targetLufs` and the music `musicDb` LU
67
+ * under it. Silent music is left alone: there's no level to match.
68
+ */
69
+ export function levelsFor(voiceLufs, musicLufs, targetLufs, musicDb) {
70
+ const round = (v) => Math.round(v * 10) / 10;
71
+ const voiceGainDb = voiceLufs > SILENCE_LUFS ? targetLufs - voiceLufs : 0;
72
+ const musicGainDb = musicLufs > SILENCE_LUFS ? targetLufs + musicDb - musicLufs : 0;
73
+ return { voiceGainDb: round(voiceGainDb), musicGainDb: round(musicGainDb) };
74
+ }
75
+ /** The `-filter_complex` graph. Exported so the tests can pin it. */
76
+ export function mixGraph(o) {
77
+ const leadMs = Math.round(o.leadInSeconds * 1000);
78
+ const fadeStart = Math.max(0, o.totalSeconds - o.tailSeconds).toFixed(3);
79
+ const fadeDur = Math.max(0.01, o.tailSeconds).toFixed(3);
80
+ const fmt = 'aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo';
81
+ const voice = `[0:a]${fmt},volume=${o.voiceGainDb}dB,adelay=delays=${leadMs}:all=1,apad[v]`;
82
+ const bed = `[1:a]${fmt},volume=${o.musicGainDb}dB,afade=t=out:st=${fadeStart}:d=${fadeDur}[m]`;
83
+ // The limiter only catches the odd peak the gain pushed over; levels come from the gains.
84
+ const out = `amix=inputs=2:duration=longest:normalize=0,alimiter=limit=0.95:level=0[out]`;
85
+ if (!o.duck)
86
+ return [voice, bed, `[v][m]${out}`].join(';');
87
+ return [
88
+ voice,
89
+ `[v]asplit=2[vkey][vout]`,
90
+ bed,
91
+ // With the voice at its target, this threshold sits under speech and over room tone.
92
+ `[m][vkey]sidechaincompress=threshold=0.02:ratio=6:attack=30:release=600[md]`,
93
+ `[vout][md]${out}`,
94
+ ].join(';');
95
+ }
96
+ export async function mixUnderVoice(o) {
97
+ if ([o.voicePath, o.musicPath].some((p) => resolve(p) === resolve(o.outputPath)))
98
+ throw new Error('Output must differ from input files.');
99
+ return atomicOutput(o.outputPath, o.overwrite, async (temporary) => {
100
+ await mixToFile({ ...o, outputPath: temporary });
101
+ await o.onProgress?.(90, 'Measuring finished audio');
102
+ return measureAudio(temporary, o);
103
+ }, o);
104
+ }
105
+ async function mixToFile(o) {
106
+ const codec = o.outputPath.toLowerCase().endsWith('.wav') ? ['-c:a', 'pcm_s16le'] : ['-c:a', 'libmp3lame', '-q:a', '2'];
107
+ await tool(ffmpeg(), [
108
+ '-hide_banner',
109
+ '-nostdin',
110
+ o.overwrite ? '-y' : '-n',
111
+ '-i', o.voicePath,
112
+ // Loop the music, so a bed shorter than the voice keeps going; -t cuts it.
113
+ '-stream_loop', '-1',
114
+ '-i', o.musicPath,
115
+ '-filter_complex', mixGraph(o),
116
+ '-map', '[out]',
117
+ '-t', o.totalSeconds.toFixed(3),
118
+ '-ar', '48000',
119
+ ...codec,
120
+ o.outputPath,
121
+ ], o);
122
+ }
123
+ /** Measure the actual encoded export, including ducking, fades and limiting. */
124
+ export async function measureAudio(path, job = {}) {
125
+ const { stderr } = await tool(ffmpeg(), ['-hide_banner', '-nostats', '-nostdin', '-i', path, '-af', 'ebur128=peak=true:framelog=quiet', '-f', 'null', '-'], job);
126
+ const loudness = parseIntegratedLoudness(stderr);
127
+ const peak = [...stderr.matchAll(/Peak:\s+(-?(?:\d+(?:\.\d+)?|inf))\s+dBFS/g)].at(-1);
128
+ if (loudness === null || !peak)
129
+ throw new Error('Could not measure the finished audio.');
130
+ const value = Number(peak[1]);
131
+ return { output_lufs: loudness, output_true_peak_dbfs: Number.isFinite(value) ? value : -120 };
132
+ }
133
+ export async function extractPreview(input, output, start, seconds, job = {}) {
134
+ 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);
135
+ }
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
+ }
@@ -0,0 +1,79 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { isAbsolute, join } from 'node:path';
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
6
+ import { z } from 'zod';
7
+ import { findTrack, STEMS, unknownTrackMessage } from './library.js';
8
+ import { defaultOutDir } from './paths.js';
9
+ import { durationSeconds, extractPreview } from './mix.js';
10
+ import { toolJob } from './jobs.js';
11
+ export function registerPreview(server) {
12
+ server.registerTool('bckgrnd_preview_tracks', {
13
+ title: 'Compare music under a narration excerpt',
14
+ description: 'Compare one to three tracks under the same 15–30 second narration excerpt before committing to a full mix. Returns separate MP3 paths and measured levels. Use start_seconds to select the excerpt.',
15
+ inputSchema: {
16
+ voice_path: z.string().min(1),
17
+ tracks: z.array(z.string().min(1)).min(1).max(3),
18
+ seconds: z.number().min(15).max(30).default(20),
19
+ start_seconds: z.number().min(0).default(0),
20
+ cut: z.array(z.enum(STEMS)).optional(),
21
+ music_db: z.number().min(-40).max(0).default(-14),
22
+ loudness_lufs: z.number().min(-30).max(-9).default(-16),
23
+ },
24
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
25
+ }, async (args, extra) => {
26
+ const job = toolJob(extra);
27
+ let scratch;
28
+ let outDir;
29
+ let complete = false;
30
+ let client;
31
+ let internal;
32
+ try {
33
+ if (!isAbsolute(args.voice_path))
34
+ throw new Error('voice_path must be absolute.');
35
+ for (const name of args.tracks)
36
+ if (!findTrack(name))
37
+ throw new Error(unknownTrackMessage(name));
38
+ const duration = await durationSeconds(args.voice_path, job);
39
+ if (args.start_seconds >= duration)
40
+ throw new Error('start_seconds must be before the end of the narration.');
41
+ scratch = mkdtempSync(join(tmpdir(), 'bckgrnd-preview-'));
42
+ outDir = mkdtempSync(join(defaultOutDir(), 'previews-'));
43
+ const excerpt = join(scratch, 'excerpt.wav');
44
+ await extractPreview(args.voice_path, excerpt, args.start_seconds, args.seconds, job);
45
+ const { createServer } = await import('./server.js');
46
+ internal = createServer();
47
+ client = new Client({ name: 'preview', version: '1' });
48
+ const [a, b] = InMemoryTransport.createLinkedPair();
49
+ await internal.connect(b);
50
+ await client.connect(a);
51
+ const previews = [];
52
+ for (const [i, track] of args.tracks.entries()) {
53
+ job.signal?.throwIfAborted();
54
+ const result = await client.callTool({ name: 'bckgrnd_mix_under_voice', arguments: {
55
+ voice_path: excerpt, track, cut: args.cut, music_db: args.music_db,
56
+ loudness_lufs: args.loudness_lufs, lead_in_seconds: 0, tail_seconds: 1,
57
+ output_path: join(outDir, `${i + 1}-${findTrack(track).id}.mp3`),
58
+ } }, undefined, { signal: job.signal, timeout: 180_000, onprogress: (p) => { void job.onProgress?.((i * 100 + p.progress) / args.tracks.length, `Preview ${i + 1}: ${p.message ?? track}`).catch(() => { }); } });
59
+ if (result.isError)
60
+ throw new Error(result.content[0]?.text ?? 'Preview mix failed.');
61
+ previews.push(result.structuredContent);
62
+ }
63
+ await job.onProgress?.(100, 'Previews complete');
64
+ complete = true;
65
+ return { content: [{ type: 'text', text: previews.map((p) => `${p.music}: ${p.path}`).join('\n') }], structuredContent: { previews, start_seconds: args.start_seconds } };
66
+ }
67
+ catch (cause) {
68
+ return { isError: true, content: [{ type: 'text', text: cause instanceof Error ? cause.message : String(cause) }] };
69
+ }
70
+ finally {
71
+ await client?.close();
72
+ await internal?.close();
73
+ if (scratch)
74
+ rmSync(scratch, { recursive: true, force: true });
75
+ if (!complete && outDir)
76
+ rmSync(outDir, { recursive: true, force: true });
77
+ }
78
+ });
79
+ }
package/dist/remote.js ADDED
@@ -0,0 +1,144 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
8
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { fileURLToPath } from 'node:url';
10
+ const ffmpegPath = fileURLToPath(new URL('../vendor/runtime/ffmpeg', import.meta.url));
11
+ const ffprobePath = fileURLToPath(new URL('../vendor/runtime/ffprobe', import.meta.url));
12
+ import { z } from 'zod';
13
+ import { durationSeconds, measureAudio } from './mix.js';
14
+ import { STEMS } from './library.js';
15
+ import { createServer, VERSION } from './server.js';
16
+ const run = promisify(execFile);
17
+ export const MAX_REMOTE_SECONDS = 120;
18
+ export const MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
19
+ const MAX_OUTPUT_BYTES = 2_000_000;
20
+ let running = 0;
21
+ function error(message) {
22
+ return { isError: true, content: [{ type: 'text', text: message }] };
23
+ }
24
+ /** Use the same local tools, but never expose their filesystem arguments remotely. */
25
+ async function localCall(name, args, signal) {
26
+ const server = createServer();
27
+ const client = new Client({ name: 'remote-adapter', version: VERSION });
28
+ const [a, b] = InMemoryTransport.createLinkedPair();
29
+ try {
30
+ await server.connect(b);
31
+ await client.connect(a);
32
+ return await client.callTool({ name, arguments: args }, undefined, { signal, timeout: 55_000 });
33
+ }
34
+ finally {
35
+ await client.close();
36
+ await server.close();
37
+ }
38
+ }
39
+ async function audioJob(work) {
40
+ if (running >= 2)
41
+ return error('This instance is busy. Retry shortly.');
42
+ running++;
43
+ let dir;
44
+ try {
45
+ if (!ffmpegPath)
46
+ throw new Error('Audio encoder unavailable on this platform.');
47
+ process.env.FFMPEG_PATH = ffmpegPath;
48
+ process.env.FFPROBE_PATH = ffprobePath;
49
+ dir = await mkdtemp(join(tmpdir(), 'bckgrnd-remote-'));
50
+ return await work(dir, AbortSignal.timeout(45_000));
51
+ }
52
+ catch (cause) {
53
+ // Do not return ffmpeg stderr or paths from user-supplied media to clients.
54
+ console.error('Remote audio job failed:', cause instanceof Error ? cause.name : 'unknown');
55
+ return error('Audio processing failed. Use a valid MP3, WAV, Ogg or FLAC within the upload and duration limits.');
56
+ }
57
+ finally {
58
+ if (dir)
59
+ await rm(dir, { recursive: true, force: true });
60
+ running--;
61
+ }
62
+ }
63
+ async function encodeResult(source, dir, metadata, signal) {
64
+ const output = join(dir, 'result.mp3');
65
+ await run(ffmpegPath, ['-v', 'error', '-nostdin', '-i', source, '-map', '0:a:0', '-c:a', 'libmp3lame', '-b:a', '96k', '-t', String(MAX_REMOTE_SECONDS), output], { timeout: 15_000, signal, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 });
66
+ const measured = await measureAudio(output, { signal });
67
+ const data = await readFile(output);
68
+ if (data.length > MAX_OUTPUT_BYTES)
69
+ throw new Error('Encoded output exceeds response limit.');
70
+ return {
71
+ content: [
72
+ { type: 'text', text: 'Generated MP3 attached as MCP audio. Save the audio content as an .mp3 file; no public download URL is stored.' },
73
+ { type: 'audio', mimeType: 'audio/mpeg', data: data.toString('base64') },
74
+ ],
75
+ structuredContent: { ...metadata, ...measured, mime_type: 'audio/mpeg', bytes: data.length },
76
+ };
77
+ }
78
+ const musicArgs = {
79
+ track: z.string().min(1).max(100).describe('Track id from bckgrnd_list_tracks'),
80
+ cut: z.array(z.enum(STEMS)).max(8).optional(),
81
+ };
82
+ export function createRemoteServer() {
83
+ const server = new McpServer({ name: 'bckgrnd-msc', version: VERSION }, {
84
+ instructions: 'Remote audio tools return embedded MP3 audio, not local paths or download URLs. Clips are limited to 120 seconds. For longer files or agents without audio upload/output support, use the local stdio server.',
85
+ });
86
+ server.registerTool('bckgrnd_list_tracks', {
87
+ description: 'List tracks, moods, energy, tempo and stems. Use this before selecting music.',
88
+ inputSchema: { section: z.enum(['EDM', 'Lofi', 'Breaks']).optional(), max_energy: z.number().int().min(1).max(5).optional() },
89
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
90
+ }, (args) => localCall('bckgrnd_list_tracks', args));
91
+ server.registerTool('bckgrnd_render_track', {
92
+ description: 'Render 1–120 seconds of synthesized background music. Returns embedded MP3 audio. No output path is needed. For longer renders use the local server.',
93
+ inputSchema: {
94
+ ...musicArgs,
95
+ seconds: z.number().min(1).max(MAX_REMOTE_SECONDS),
96
+ fade_in_seconds: z.number().min(0).max(30).default(1),
97
+ fade_out_seconds: z.number().min(0).max(60).default(4),
98
+ },
99
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
100
+ }, (args) => audioJob(async (dir, signal) => {
101
+ const output = join(dir, 'render.wav');
102
+ const result = await localCall('bckgrnd_render_track', { ...args, output_path: output }, signal);
103
+ if (result.isError)
104
+ return result;
105
+ const { path: _path, ...metadata } = result.structuredContent;
106
+ void _path;
107
+ return encodeResult(output, dir, metadata, signal);
108
+ }));
109
+ server.registerTool('bckgrnd_mix_under_voice', {
110
+ description: 'Mix a track beneath uploaded narration. Supply actual base64-encoded audio bytes (not a filename). Maximum upload 2 MiB; narration plus lead-in/tail must fit 120 seconds. Returns embedded MP3 audio. Use local stdio for longer files or custom music.',
111
+ inputSchema: {
112
+ ...musicArgs,
113
+ voice_base64: z.string().min(4).max(Math.ceil(MAX_UPLOAD_BYTES / 3) * 4).describe('Base64 file contents, without a data-URL prefix; never invent these bytes'),
114
+ voice_format: z.enum(['mp3', 'wav', 'ogg', 'flac']),
115
+ music_db: z.number().min(-40).max(0).default(-14),
116
+ loudness_lufs: z.number().min(-30).max(-9).default(-16),
117
+ duck: z.boolean().default(true),
118
+ lead_in_seconds: z.number().min(0).max(10).default(2),
119
+ tail_seconds: z.number().min(0).max(10).default(3),
120
+ },
121
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
122
+ }, (args) => audioJob(async (dir, signal) => {
123
+ const { voice_base64, voice_format, ...mixArgs } = args;
124
+ const bytes = Buffer.from(voice_base64, 'base64');
125
+ if (bytes.toString('base64') !== voice_base64 || bytes.length > MAX_UPLOAD_BYTES)
126
+ return error('Invalid base64 or upload exceeds 2 MiB.');
127
+ const input = join(dir, 'upload');
128
+ const voice = join(dir, 'voice.wav');
129
+ const output = join(dir, 'mixed.wav');
130
+ await writeFile(input, bytes);
131
+ // Force a simple audio demuxer: uploaded playlists cannot read server files or URLs.
132
+ await run(ffmpegPath, ['-v', 'error', '-nostdin', '-protocol_whitelist', 'file,pipe', '-f', voice_format, '-i', input, '-map', '0:a:0', '-t', '121', '-ar', '48000', '-ac', '2', '-c:a', 'pcm_s16le', voice], { timeout: 15_000, signal, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 });
133
+ const seconds = await durationSeconds(voice, { signal });
134
+ if (seconds + args.lead_in_seconds + args.tail_seconds > MAX_REMOTE_SECONDS)
135
+ return error('Narration plus lead-in and tail exceeds 120 seconds. Use a shorter clip or the local server.');
136
+ const result = await localCall('bckgrnd_mix_under_voice', { ...mixArgs, voice_path: voice, output_path: output }, signal);
137
+ if (result.isError)
138
+ return error('Could not mix this recording. Check that it contains audible speech and that the track exists.');
139
+ const { path: _path, ...metadata } = result.structuredContent;
140
+ void _path;
141
+ return encodeResult(output, dir, metadata, signal);
142
+ }));
143
+ return server;
144
+ }