claude-music 1.0.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 +83 -0
- package/daemon/CMakeLists.txt +26 -0
- package/daemon/include/audio_output.h +36 -0
- package/daemon/include/command.h +24 -0
- package/daemon/include/socket_server.h +30 -0
- package/daemon/src/audio_output.cpp +68 -0
- package/daemon/src/command.cpp +35 -0
- package/daemon/src/main.cpp +126 -0
- package/daemon/src/socket_server.cpp +78 -0
- package/daemon/tests/test_command.cpp +74 -0
- package/daemon/tests/test_socket.cpp +89 -0
- package/dist/__tests__/download.test.js +25 -0
- package/dist/__tests__/vibe.test.js +15 -0
- package/dist/cli.js +66 -0
- package/dist/download.js +154 -0
- package/dist/install.js +232 -0
- package/dist/paths.js +26 -0
- package/dist/server.js +128 -0
- package/dist/vibe.js +3 -0
- package/package.json +56 -0
- package/templates/music.md +28 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { MODEL_FILES } from "../download.js";
|
|
3
|
+
import { MODEL_FILE, RESOURCES_DIR, MAGENTA_HOME } from "../paths.js";
|
|
4
|
+
describe("MODEL_FILES", () => {
|
|
5
|
+
it("includes the compiled small model and its state", () => {
|
|
6
|
+
expect(MODEL_FILES).toContain("models/mrt2_small/mrt2_small.mlxfn");
|
|
7
|
+
expect(MODEL_FILES).toContain("models/mrt2_small/mrt2_small_state.safetensors");
|
|
8
|
+
});
|
|
9
|
+
it("includes all musiccoca and spectrostream resources", () => {
|
|
10
|
+
const musiccoca = MODEL_FILES.filter((f) => f.startsWith("resources/musiccoca/"));
|
|
11
|
+
const spectrostream = MODEL_FILES.filter((f) => f.startsWith("resources/spectrostream/"));
|
|
12
|
+
expect(musiccoca).toHaveLength(6);
|
|
13
|
+
expect(spectrostream).toHaveLength(4);
|
|
14
|
+
});
|
|
15
|
+
it("has no duplicate entries", () => {
|
|
16
|
+
expect(new Set(MODEL_FILES).size).toBe(MODEL_FILES.length);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
describe("paths", () => {
|
|
20
|
+
it("derives the model + resources paths from the magenta home", () => {
|
|
21
|
+
expect(MODEL_FILE.startsWith(MAGENTA_HOME)).toBe(true);
|
|
22
|
+
expect(RESOURCES_DIR.startsWith(MAGENTA_HOME)).toBe(true);
|
|
23
|
+
expect(MODEL_FILE.endsWith("models/mrt2_small/mrt2_small.mlxfn")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildVibeMessage } from "../vibe.js";
|
|
3
|
+
describe("buildVibeMessage", () => {
|
|
4
|
+
it("serializes the prompt", () => {
|
|
5
|
+
const parsed = JSON.parse(buildVibeMessage("jazz piano trio"));
|
|
6
|
+
expect(parsed.prompt).toBe("jazz piano trio");
|
|
7
|
+
});
|
|
8
|
+
it("produces valid JSON", () => {
|
|
9
|
+
expect(() => JSON.parse(buildVibeMessage("test"))).not.toThrow();
|
|
10
|
+
});
|
|
11
|
+
it("does not include an intensity field", () => {
|
|
12
|
+
const parsed = JSON.parse(buildVibeMessage("x"));
|
|
13
|
+
expect("intensity" in parsed).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
});
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { install, uninstall, doctor } from "./install.js";
|
|
3
|
+
import { ensureDaemon, runServer, sendLine, socketAlive, stopDaemon, } from "./server.js";
|
|
4
|
+
const USAGE = `claude-music — live AI background music for Claude Code
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
claude-music install Download the daemon + model, register the MCP
|
|
8
|
+
server, and install the /music command.
|
|
9
|
+
claude-music uninstall Remove the MCP registration and /music command.
|
|
10
|
+
(add --purge to also delete the cached binary + model)
|
|
11
|
+
claude-music doctor Check that everything is installed correctly.
|
|
12
|
+
claude-music start Start the music daemon now.
|
|
13
|
+
claude-music stop Stop the music daemon.
|
|
14
|
+
claude-music status Report what's currently playing.
|
|
15
|
+
claude-music mcp Run the MCP server (used internally by Claude Code).
|
|
16
|
+
|
|
17
|
+
After 'install', restart Claude Code and type /music.`;
|
|
18
|
+
async function main() {
|
|
19
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
20
|
+
switch (cmd) {
|
|
21
|
+
case "mcp":
|
|
22
|
+
await runServer();
|
|
23
|
+
return; // server keeps the process alive on the stdio transport
|
|
24
|
+
case "install":
|
|
25
|
+
await install();
|
|
26
|
+
break;
|
|
27
|
+
case "uninstall":
|
|
28
|
+
await uninstall(rest.includes("--purge"));
|
|
29
|
+
break;
|
|
30
|
+
case "doctor":
|
|
31
|
+
await doctor();
|
|
32
|
+
break;
|
|
33
|
+
case "start": {
|
|
34
|
+
await ensureDaemon();
|
|
35
|
+
console.log("Music daemon started.");
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
case "stop":
|
|
39
|
+
stopDaemon();
|
|
40
|
+
console.log("Music stopped.");
|
|
41
|
+
break;
|
|
42
|
+
case "status": {
|
|
43
|
+
if (!(await socketAlive())) {
|
|
44
|
+
console.log("Music is not running.");
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
const reply = await sendLine(JSON.stringify({ status: true }));
|
|
48
|
+
console.log(reply);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case undefined:
|
|
52
|
+
case "-h":
|
|
53
|
+
case "--help":
|
|
54
|
+
case "help":
|
|
55
|
+
console.log(USAGE);
|
|
56
|
+
break;
|
|
57
|
+
default:
|
|
58
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
59
|
+
console.error(USAGE);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
main().catch((err) => {
|
|
64
|
+
console.error(`Error: ${err.message}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
package/dist/download.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { Readable } from "node:stream";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { MAGENTA_HOME } from "./paths.js";
|
|
9
|
+
const HF_REPO = "google/magenta-realtime-2";
|
|
10
|
+
const HF_BASE = `https://huggingface.co/${HF_REPO}/resolve/main`;
|
|
11
|
+
// The complete set of files the mrt2d daemon needs at runtime: the compiled
|
|
12
|
+
// small model + its state, and the shared musiccoca / spectrostream resources.
|
|
13
|
+
// All are public on HuggingFace and download without auth.
|
|
14
|
+
export const MODEL_FILES = [
|
|
15
|
+
"models/mrt2_small/mrt2_small.mlxfn",
|
|
16
|
+
"models/mrt2_small/mrt2_small_state.safetensors",
|
|
17
|
+
"resources/musiccoca/audio_preprocessor.tflite",
|
|
18
|
+
"resources/musiccoca/mapper.tflite",
|
|
19
|
+
"resources/musiccoca/music_encoder.tflite",
|
|
20
|
+
"resources/musiccoca/pretrained_vector_quantizer.tflite",
|
|
21
|
+
"resources/musiccoca/spm.model",
|
|
22
|
+
"resources/musiccoca/text_encoder.tflite",
|
|
23
|
+
"resources/spectrostream/decoder.safetensors",
|
|
24
|
+
"resources/spectrostream/encoder.safetensors",
|
|
25
|
+
"resources/spectrostream/quantizer.safetensors",
|
|
26
|
+
"resources/spectrostream/spectrostream_encoder.mlxfn",
|
|
27
|
+
];
|
|
28
|
+
function humanBytes(n) {
|
|
29
|
+
if (n >= 1 << 30)
|
|
30
|
+
return `${(n / (1 << 30)).toFixed(2)} GiB`;
|
|
31
|
+
if (n >= 1 << 20)
|
|
32
|
+
return `${(n / (1 << 20)).toFixed(1)} MiB`;
|
|
33
|
+
if (n >= 1 << 10)
|
|
34
|
+
return `${(n / (1 << 10)).toFixed(1)} KiB`;
|
|
35
|
+
return `${n} B`;
|
|
36
|
+
}
|
|
37
|
+
/** Remote Content-Length via a HEAD request (follows redirects). 0 if unknown. */
|
|
38
|
+
async function remoteSize(url) {
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(url, { method: "HEAD", redirect: "follow" });
|
|
41
|
+
const len = res.headers.get("content-length");
|
|
42
|
+
return len ? parseInt(len, 10) : 0;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Stream a URL to a destination file, printing line-buffered progress (survives
|
|
50
|
+
* pipes/tee — see the project's progress-visibility convention). Downloads to a
|
|
51
|
+
* .part file and renames on success so an interrupted download never looks done.
|
|
52
|
+
*/
|
|
53
|
+
async function downloadFile(url, dest, label) {
|
|
54
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
55
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
56
|
+
if (!res.ok || !res.body) {
|
|
57
|
+
throw new Error(`download failed (${res.status}) for ${url}`);
|
|
58
|
+
}
|
|
59
|
+
const total = parseInt(res.headers.get("content-length") || "0", 10);
|
|
60
|
+
const tmp = dest + ".part";
|
|
61
|
+
let received = 0;
|
|
62
|
+
let lastTick = 0;
|
|
63
|
+
const out = fs.createWriteStream(tmp);
|
|
64
|
+
const src = Readable.fromWeb(res.body);
|
|
65
|
+
src.on("data", (chunk) => {
|
|
66
|
+
received += chunk.length;
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
// Throttle progress lines to ~1/sec so logs stay readable.
|
|
69
|
+
if (now - lastTick >= 1000) {
|
|
70
|
+
lastTick = now;
|
|
71
|
+
const pct = total ? ` (${((received / total) * 100).toFixed(0)}%)` : "";
|
|
72
|
+
console.log(` ${label}: ${humanBytes(received)}${total ? ` / ${humanBytes(total)}` : ""}${pct}`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
await pipeline(src, out);
|
|
76
|
+
fs.renameSync(tmp, dest);
|
|
77
|
+
console.log(` ${label}: done (${humanBytes(received)})`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Download all model + resource files into MAGENTA_HOME, skipping any file that
|
|
81
|
+
* already exists locally with the same size as the remote. Returns how many
|
|
82
|
+
* files were actually fetched.
|
|
83
|
+
*/
|
|
84
|
+
export async function downloadModel() {
|
|
85
|
+
let fetched = 0;
|
|
86
|
+
console.log(`==> Downloading model + resources to ${MAGENTA_HOME}`);
|
|
87
|
+
for (let i = 0; i < MODEL_FILES.length; i++) {
|
|
88
|
+
const rel = MODEL_FILES[i];
|
|
89
|
+
const dest = path.join(MAGENTA_HOME, rel);
|
|
90
|
+
const url = `${HF_BASE}/${rel}`;
|
|
91
|
+
const tag = `[${i + 1}/${MODEL_FILES.length}] ${rel}`;
|
|
92
|
+
if (fs.existsSync(dest)) {
|
|
93
|
+
const local = fs.statSync(dest).size;
|
|
94
|
+
const remote = await remoteSize(url);
|
|
95
|
+
if (remote > 0 && local === remote) {
|
|
96
|
+
console.log(` skip ${tag} (already present, ${humanBytes(local)})`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
console.log(` get ${tag}`);
|
|
101
|
+
await downloadFile(url, dest, rel.split("/").pop() || rel);
|
|
102
|
+
fetched++;
|
|
103
|
+
}
|
|
104
|
+
console.log(`==> Model ready (${fetched} file(s) downloaded).`);
|
|
105
|
+
return fetched;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Download the prebuilt daemon tarball and extract it into binDir. The tarball
|
|
109
|
+
* contains the mrt2d binary plus any bundled dylibs. Returns the path to the
|
|
110
|
+
* extracted binary.
|
|
111
|
+
*/
|
|
112
|
+
export async function downloadBinary(url, binDir) {
|
|
113
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
114
|
+
let tmpTar;
|
|
115
|
+
let fromLocal = false;
|
|
116
|
+
if (url.startsWith("file://") || url.startsWith("/")) {
|
|
117
|
+
// Local tarball (used for testing a freshly built artifact). Node's fetch
|
|
118
|
+
// doesn't handle file:// URLs, so resolve to a path and use it directly.
|
|
119
|
+
tmpTar = url.startsWith("file://") ? fileURLToPath(url) : url;
|
|
120
|
+
fromLocal = true;
|
|
121
|
+
if (!fs.existsSync(tmpTar)) {
|
|
122
|
+
throw new Error(`local tarball not found: ${tmpTar}`);
|
|
123
|
+
}
|
|
124
|
+
console.log(`==> Using local daemon tarball ${tmpTar}`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
tmpTar = path.join(os.tmpdir(), `mrt2d-${process.pid}-${Date.now()}.tar.gz`);
|
|
128
|
+
console.log(`==> Downloading prebuilt daemon from ${url}`);
|
|
129
|
+
await downloadFile(url, tmpTar, "mrt2d.tar.gz");
|
|
130
|
+
}
|
|
131
|
+
console.log(`==> Extracting to ${binDir}`);
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
const tar = spawn("tar", ["-xzf", tmpTar, "-C", binDir], {
|
|
134
|
+
stdio: "inherit",
|
|
135
|
+
});
|
|
136
|
+
tar.on("error", reject);
|
|
137
|
+
tar.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`tar exited ${code}`)));
|
|
138
|
+
});
|
|
139
|
+
if (!fromLocal)
|
|
140
|
+
fs.rmSync(tmpTar, { force: true });
|
|
141
|
+
const bin = path.join(binDir, "mrt2d");
|
|
142
|
+
if (!fs.existsSync(bin)) {
|
|
143
|
+
throw new Error(`tarball did not contain an mrt2d binary in ${binDir}`);
|
|
144
|
+
}
|
|
145
|
+
fs.chmodSync(bin, 0o755);
|
|
146
|
+
// Clear the quarantine attribute so Gatekeeper doesn't block a downloaded
|
|
147
|
+
// binary. Best-effort: harmless if the attribute isn't set.
|
|
148
|
+
await new Promise((resolve) => {
|
|
149
|
+
const xattr = spawn("xattr", ["-dr", "com.apple.quarantine", binDir]);
|
|
150
|
+
xattr.on("error", () => resolve());
|
|
151
|
+
xattr.on("exit", () => resolve());
|
|
152
|
+
});
|
|
153
|
+
return bin;
|
|
154
|
+
}
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { downloadBinary, downloadModel } from "./download.js";
|
|
7
|
+
import { BIN_DIR, CLAUDE_MUSIC_HOME, COMMANDS_DIR, DAEMON_BIN, MAGENTA_HOME, MODEL_FILE, MUSIC_COMMAND, RESOURCES_DIR, } from "./paths.js";
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const PKG_ROOT = path.resolve(__dirname, "..");
|
|
10
|
+
const CLI_JS = path.join(__dirname, "cli.js");
|
|
11
|
+
const TEMPLATE_MUSIC = path.join(PKG_ROOT, "templates", "music.md");
|
|
12
|
+
const DAEMON_SRC = path.join(PKG_ROOT, "daemon");
|
|
13
|
+
const DEFAULT_BINARY_URL = "https://github.com/noo-bass/claude-music/releases/latest/download/mrt2d-darwin-arm64.tar.gz";
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// small helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
/** Run a command, inheriting stdio. Resolves with the exit code. */
|
|
18
|
+
function run(cmd, args) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const c = spawn(cmd, args, { stdio: "inherit" });
|
|
21
|
+
c.on("error", () => resolve(127));
|
|
22
|
+
c.on("exit", (code) => resolve(code ?? 1));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function have(cmd) {
|
|
26
|
+
// `command -v` is a shell builtin; run it as a single shell string (no args
|
|
27
|
+
// array) so we don't trip Node's DEP0190 shell-args warning. cmd is always a
|
|
28
|
+
// fixed literal ("claude" / "git" / "cmake"), so interpolation is safe.
|
|
29
|
+
const r = spawnSync(`command -v ${cmd}`, { shell: true, stdio: "ignore" });
|
|
30
|
+
return r.status === 0;
|
|
31
|
+
}
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// install steps
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
function preflight() {
|
|
36
|
+
console.log("==> Checking prerequisites...");
|
|
37
|
+
if (process.platform !== "darwin") {
|
|
38
|
+
throw new Error("macOS required (Magenta RealTime uses MLX/Metal).");
|
|
39
|
+
}
|
|
40
|
+
if (process.arch !== "arm64") {
|
|
41
|
+
throw new Error("Apple Silicon (arm64) required.");
|
|
42
|
+
}
|
|
43
|
+
const major = parseInt(process.versions.node.split(".")[0], 10);
|
|
44
|
+
if (major < 18) {
|
|
45
|
+
throw new Error(`Node >= 18 required (have ${process.versions.node}).`);
|
|
46
|
+
}
|
|
47
|
+
if (!have("claude")) {
|
|
48
|
+
throw new Error("The `claude` CLI is not on your PATH — install Claude Code first.");
|
|
49
|
+
}
|
|
50
|
+
console.log(" macOS arm64, Node " + process.versions.node + ", claude CLI ✓");
|
|
51
|
+
}
|
|
52
|
+
/** True if the binary exists and runs (exec bit + loads without crashing). */
|
|
53
|
+
function binaryUsable() {
|
|
54
|
+
if (!fs.existsSync(DAEMON_BIN))
|
|
55
|
+
return false;
|
|
56
|
+
// We can't fully launch it without the model, but a missing-dylib failure
|
|
57
|
+
// surfaces immediately as a non-spawnable file. A bare exec with no model
|
|
58
|
+
// exits non-zero quickly; we only treat ENOENT/dyld failures as "unusable".
|
|
59
|
+
const r = spawnSync(DAEMON_BIN, ["--model", "/nonexistent"], {
|
|
60
|
+
timeout: 5000,
|
|
61
|
+
});
|
|
62
|
+
if (r.error && r.error.code === "ENOENT") {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
// A dyld load failure prints to stderr and yields a signal/137-ish status.
|
|
66
|
+
const stderr = (r.stderr?.toString() || "") + (r.stdout?.toString() || "");
|
|
67
|
+
if (/dyld|Library not loaded|image not found/i.test(stderr))
|
|
68
|
+
return false;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
async function ensureBinary() {
|
|
72
|
+
if (binaryUsable()) {
|
|
73
|
+
console.log(`==> Daemon already installed: ${DAEMON_BIN}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const url = process.env.CLAUDE_MUSIC_BINARY_URL || DEFAULT_BINARY_URL;
|
|
77
|
+
try {
|
|
78
|
+
await downloadBinary(url, BIN_DIR);
|
|
79
|
+
if (!binaryUsable()) {
|
|
80
|
+
throw new Error("downloaded binary is not runnable on this machine");
|
|
81
|
+
}
|
|
82
|
+
console.log(`==> Daemon installed: ${DAEMON_BIN}`);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
console.log(`==> Prebuilt download failed (${err.message}).`);
|
|
86
|
+
console.log("==> Falling back to build-from-source...");
|
|
87
|
+
await buildFromSource();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Fallback path: clone magenta-realtime, wire in the bundled daemon source, and
|
|
92
|
+
* build mrt2d with CMake. Used only when no prebuilt binary can be fetched/run.
|
|
93
|
+
* Requires git + cmake + Xcode command-line tools.
|
|
94
|
+
*/
|
|
95
|
+
async function buildFromSource() {
|
|
96
|
+
for (const tool of ["git", "cmake"]) {
|
|
97
|
+
if (!have(tool)) {
|
|
98
|
+
throw new Error(`build-from-source needs '${tool}', which is not installed. ` +
|
|
99
|
+
"Install Xcode CLT + CMake, or set CLAUDE_MUSIC_BINARY_URL to a working prebuilt.");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const srcRoot = path.join(CLAUDE_MUSIC_HOME, "src");
|
|
103
|
+
const mrt = path.join(srcRoot, "magenta-realtime");
|
|
104
|
+
fs.mkdirSync(srcRoot, { recursive: true });
|
|
105
|
+
if (!fs.existsSync(path.join(mrt, "CMakeLists.txt"))) {
|
|
106
|
+
console.log("==> Cloning magenta-realtime (shallow)...");
|
|
107
|
+
const code = await run("git", [
|
|
108
|
+
"clone",
|
|
109
|
+
"--depth",
|
|
110
|
+
"1",
|
|
111
|
+
"https://github.com/magenta/magenta-realtime.git",
|
|
112
|
+
mrt,
|
|
113
|
+
]);
|
|
114
|
+
if (code !== 0)
|
|
115
|
+
throw new Error("git clone failed");
|
|
116
|
+
}
|
|
117
|
+
// Wire the bundled daemon source into the magenta-realtime build (idempotent).
|
|
118
|
+
const cmakeLists = path.join(mrt, "CMakeLists.txt");
|
|
119
|
+
const marker = "claude-music mrt2d daemon (added by claude-music install)";
|
|
120
|
+
let contents = fs.readFileSync(cmakeLists, "utf8");
|
|
121
|
+
if (!contents.includes(marker)) {
|
|
122
|
+
contents +=
|
|
123
|
+
`\n# ${marker}\n` +
|
|
124
|
+
`add_subdirectory(${JSON.stringify(DAEMON_SRC)} ` +
|
|
125
|
+
`${JSON.stringify(path.join(mrt, "build", "mrt2d"))})\n`;
|
|
126
|
+
fs.writeFileSync(cmakeLists, contents);
|
|
127
|
+
}
|
|
128
|
+
console.log("==> Building mrt2d (first build pulls C++ deps — several minutes)...");
|
|
129
|
+
const cpus = String(os.cpus().length);
|
|
130
|
+
let code = await run("cmake", [
|
|
131
|
+
"-S",
|
|
132
|
+
mrt,
|
|
133
|
+
"-B",
|
|
134
|
+
path.join(mrt, "build"),
|
|
135
|
+
"-DCMAKE_BUILD_TYPE=Release",
|
|
136
|
+
"-DCMAKE_POLICY_VERSION_MINIMUM=3.5",
|
|
137
|
+
"-DCMAKE_POLICY_DEFAULT_CMP0169=OLD",
|
|
138
|
+
"-Wno-dev",
|
|
139
|
+
]);
|
|
140
|
+
if (code !== 0)
|
|
141
|
+
throw new Error("cmake configure failed");
|
|
142
|
+
code = await run("cmake", [
|
|
143
|
+
"--build",
|
|
144
|
+
path.join(mrt, "build"),
|
|
145
|
+
"--target",
|
|
146
|
+
"mrt2d",
|
|
147
|
+
"-j",
|
|
148
|
+
cpus,
|
|
149
|
+
]);
|
|
150
|
+
if (code !== 0)
|
|
151
|
+
throw new Error("cmake build failed");
|
|
152
|
+
const built = path.join(mrt, "build", "mrt2d", "mrt2d");
|
|
153
|
+
if (!fs.existsSync(built))
|
|
154
|
+
throw new Error("build produced no mrt2d binary");
|
|
155
|
+
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
156
|
+
fs.copyFileSync(built, DAEMON_BIN);
|
|
157
|
+
fs.chmodSync(DAEMON_BIN, 0o755);
|
|
158
|
+
console.log(`==> Daemon built: ${DAEMON_BIN}`);
|
|
159
|
+
}
|
|
160
|
+
async function registerMcp() {
|
|
161
|
+
console.log("==> Registering mrt2-mcp with Claude Code...");
|
|
162
|
+
// Idempotent: drop any prior registration, then add. Use node + absolute CLI
|
|
163
|
+
// path so it works regardless of whether the global bin is on PATH.
|
|
164
|
+
await run("claude", ["mcp", "remove", "mrt2-mcp", "-s", "user"]);
|
|
165
|
+
const code = await run("claude", [
|
|
166
|
+
"mcp",
|
|
167
|
+
"add",
|
|
168
|
+
"mrt2-mcp",
|
|
169
|
+
"-s",
|
|
170
|
+
"user",
|
|
171
|
+
"--",
|
|
172
|
+
process.execPath,
|
|
173
|
+
CLI_JS,
|
|
174
|
+
"mcp",
|
|
175
|
+
]);
|
|
176
|
+
if (code !== 0)
|
|
177
|
+
throw new Error("`claude mcp add` failed");
|
|
178
|
+
}
|
|
179
|
+
function installCommand() {
|
|
180
|
+
console.log("==> Installing /music command...");
|
|
181
|
+
fs.mkdirSync(COMMANDS_DIR, { recursive: true });
|
|
182
|
+
fs.copyFileSync(TEMPLATE_MUSIC, MUSIC_COMMAND);
|
|
183
|
+
}
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// entry points
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
export async function install() {
|
|
188
|
+
preflight();
|
|
189
|
+
await ensureBinary();
|
|
190
|
+
await downloadModel();
|
|
191
|
+
await registerMcp();
|
|
192
|
+
installCommand();
|
|
193
|
+
console.log("");
|
|
194
|
+
console.log("claude-music installed.");
|
|
195
|
+
console.log(" Restart Claude Code, then type /music to start. It steers itself as you");
|
|
196
|
+
console.log(" work and stops when you end the session.");
|
|
197
|
+
}
|
|
198
|
+
export async function uninstall(purge) {
|
|
199
|
+
console.log("==> Removing mrt2-mcp registration...");
|
|
200
|
+
await run("claude", ["mcp", "remove", "mrt2-mcp", "-s", "user"]);
|
|
201
|
+
console.log("==> Removing /music command...");
|
|
202
|
+
fs.rmSync(MUSIC_COMMAND, { force: true });
|
|
203
|
+
if (purge) {
|
|
204
|
+
console.log(`==> Purging ${CLAUDE_MUSIC_HOME} and ${MAGENTA_HOME}...`);
|
|
205
|
+
fs.rmSync(CLAUDE_MUSIC_HOME, { recursive: true, force: true });
|
|
206
|
+
fs.rmSync(MAGENTA_HOME, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
console.log("claude-music uninstalled.");
|
|
209
|
+
}
|
|
210
|
+
export async function doctor() {
|
|
211
|
+
const ok = (b) => (b ? "OK " : "MISS");
|
|
212
|
+
const platformOk = process.platform === "darwin" && process.arch === "arm64";
|
|
213
|
+
const claudeOk = have("claude");
|
|
214
|
+
const binOk = fs.existsSync(DAEMON_BIN);
|
|
215
|
+
const modelOk = fs.existsSync(MODEL_FILE);
|
|
216
|
+
const resourcesOk = fs.existsSync(RESOURCES_DIR);
|
|
217
|
+
let mcpOk = false;
|
|
218
|
+
const r = spawnSync("claude", ["mcp", "list"], { encoding: "utf8" });
|
|
219
|
+
if (r.status === 0)
|
|
220
|
+
mcpOk = /mrt2-mcp/.test(r.stdout || "");
|
|
221
|
+
const cmdOk = fs.existsSync(MUSIC_COMMAND);
|
|
222
|
+
console.log("claude-music doctor");
|
|
223
|
+
console.log(` [${ok(platformOk)}] platform: ${process.platform}/${process.arch}`);
|
|
224
|
+
console.log(` [${ok(claudeOk)}] claude CLI on PATH`);
|
|
225
|
+
console.log(` [${ok(binOk)}] daemon binary: ${DAEMON_BIN}`);
|
|
226
|
+
console.log(` [${ok(modelOk)}] model: ${MODEL_FILE}`);
|
|
227
|
+
console.log(` [${ok(resourcesOk)}] resources: ${RESOURCES_DIR}`);
|
|
228
|
+
console.log(` [${ok(mcpOk)}] mrt2-mcp registered`);
|
|
229
|
+
console.log(` [${ok(cmdOk)}] /music command: ${MUSIC_COMMAND}`);
|
|
230
|
+
const allOk = platformOk && claudeOk && binOk && modelOk && resourcesOk && mcpOk && cmdOk;
|
|
231
|
+
console.log(allOk ? "All good — restart Claude Code and /music." : "Run: claude-music install");
|
|
232
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
// Central path constants for claude-music. Everything the installer writes and
|
|
4
|
+
// everything the MCP server reads is resolved here so the layout is defined in
|
|
5
|
+
// exactly one place.
|
|
6
|
+
const HOME = os.homedir();
|
|
7
|
+
/** Per-user claude-music state (downloaded daemon binary lives here). */
|
|
8
|
+
export const CLAUDE_MUSIC_HOME = path.join(HOME, ".claude-music");
|
|
9
|
+
/** Directory holding the prebuilt mrt2d binary (+ any bundled dylibs). */
|
|
10
|
+
export const BIN_DIR = path.join(CLAUDE_MUSIC_HOME, "bin");
|
|
11
|
+
/**
|
|
12
|
+
* The mrt2d daemon binary. Override with MRT2D_BIN (used by the build-from-source
|
|
13
|
+
* fallback, which leaves the binary inside the magenta-realtime build tree).
|
|
14
|
+
*/
|
|
15
|
+
export const DAEMON_BIN = process.env.MRT2D_BIN || path.join(BIN_DIR, "mrt2d");
|
|
16
|
+
/** Where the model + shared resources are downloaded. The daemon hardcodes this. */
|
|
17
|
+
export const MAGENTA_HOME = path.join(HOME, "Documents", "Magenta", "magenta-rt-v2");
|
|
18
|
+
export const MODEL_FILE = path.join(MAGENTA_HOME, "models", "mrt2_small", "mrt2_small.mlxfn");
|
|
19
|
+
export const RESOURCES_DIR = path.join(MAGENTA_HOME, "resources");
|
|
20
|
+
/** Runtime artefacts created by the daemon. */
|
|
21
|
+
export const SOCKET_PATH = path.join(HOME, ".mrt2d.sock");
|
|
22
|
+
export const PID_PATH = path.join(HOME, ".mrt2d.pid");
|
|
23
|
+
export const LOG_PATH = path.join(HOME, ".mrt2d.log");
|
|
24
|
+
/** Claude Code user-scope slash commands directory. */
|
|
25
|
+
export const COMMANDS_DIR = path.join(HOME, ".claude", "commands");
|
|
26
|
+
export const MUSIC_COMMAND = path.join(COMMANDS_DIR, "music.md");
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as net from "node:net";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { buildVibeMessage } from "./vibe.js";
|
|
8
|
+
import { DAEMON_BIN, LOG_PATH, PID_PATH, SOCKET_PATH } from "./paths.js";
|
|
9
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
10
|
+
/** Can we actually connect to the daemon's socket right now? */
|
|
11
|
+
export function socketAlive() {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const c = net.createConnection(SOCKET_PATH);
|
|
14
|
+
c.on("connect", () => {
|
|
15
|
+
c.destroy();
|
|
16
|
+
resolve(true);
|
|
17
|
+
});
|
|
18
|
+
c.on("error", () => resolve(false));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Ensure the daemon is running. If not, spawn it detached and wait for its
|
|
23
|
+
* socket. This is what makes `/music` a single silent action — the caller never
|
|
24
|
+
* has to launch the daemon separately.
|
|
25
|
+
*/
|
|
26
|
+
export async function ensureDaemon() {
|
|
27
|
+
if (await socketAlive())
|
|
28
|
+
return;
|
|
29
|
+
if (!fs.existsSync(DAEMON_BIN)) {
|
|
30
|
+
throw new Error(`mrt2d not installed at ${DAEMON_BIN} — run: claude-music install`);
|
|
31
|
+
}
|
|
32
|
+
const out = fs.openSync(LOG_PATH, "a");
|
|
33
|
+
const child = spawn(DAEMON_BIN, [], {
|
|
34
|
+
detached: true,
|
|
35
|
+
stdio: ["ignore", out, out],
|
|
36
|
+
});
|
|
37
|
+
child.unref();
|
|
38
|
+
// Model load is fast for mrt2_small (~2s) but allow generous headroom.
|
|
39
|
+
for (let i = 0; i < 60; i++) {
|
|
40
|
+
await sleep(1000);
|
|
41
|
+
if (await socketAlive())
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`daemon did not come up within 60s — check ${LOG_PATH}`);
|
|
45
|
+
}
|
|
46
|
+
/** Send one newline-terminated JSON command to the daemon and await its reply. */
|
|
47
|
+
export function sendLine(message) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const client = net.createConnection(SOCKET_PATH);
|
|
50
|
+
let buf = "";
|
|
51
|
+
client.on("connect", () => client.write(message + "\n"));
|
|
52
|
+
client.on("data", (data) => {
|
|
53
|
+
buf += data.toString();
|
|
54
|
+
if (buf.includes("\n")) {
|
|
55
|
+
client.destroy();
|
|
56
|
+
resolve(buf.trim());
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
client.on("error", (err) => reject(new Error(`socket error: ${err.message}`)));
|
|
60
|
+
client.setTimeout(3000, () => {
|
|
61
|
+
client.destroy();
|
|
62
|
+
reject(new Error("timeout — daemon did not respond within 3s"));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** Stop the daemon (SIGTERM the recorded pid) and clean up its socket. */
|
|
67
|
+
export function stopDaemon() {
|
|
68
|
+
if (fs.existsSync(PID_PATH)) {
|
|
69
|
+
const pid = parseInt(fs.readFileSync(PID_PATH, "utf8").trim(), 10);
|
|
70
|
+
if (pid) {
|
|
71
|
+
try {
|
|
72
|
+
process.kill(pid, "SIGTERM");
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* already gone */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
fs.rmSync(PID_PATH, { force: true });
|
|
79
|
+
}
|
|
80
|
+
fs.rmSync(SOCKET_PATH, { force: true });
|
|
81
|
+
}
|
|
82
|
+
/** Build and connect the MCP stdio server. Invoked by `claude-music mcp`. */
|
|
83
|
+
export async function runServer() {
|
|
84
|
+
const server = new McpServer({ name: "mrt2-mcp", version: "1.0.0" });
|
|
85
|
+
server.tool("set_music_vibe", "You are the DJ scoring this coding session in real time. If music isn't " +
|
|
86
|
+
"playing, this starts it. There is NO fixed playlist — pick whatever genre, " +
|
|
87
|
+
"mood, tempo, and instrumentation genuinely fit THIS project and THIS moment, " +
|
|
88
|
+
"with taste and opinion. React to the real work: the project's character " +
|
|
89
|
+
"(a playful game vs gnarly infra vs a slick frontend all sound different), " +
|
|
90
|
+
"what you're doing (careful exploration, a flow-state grind, hunting a nasty " +
|
|
91
|
+
"bug, mechanical cleanup, a victory lap after green tests), and how it feels " +
|
|
92
|
+
"(stuck and tense, cruising, triumphant). Let the soundtrack carry that arc " +
|
|
93
|
+
"and evolve as the work shifts — vary it, don't loop one vibe. Call this " +
|
|
94
|
+
"silently at natural transitions; never announce it.", {
|
|
95
|
+
prompt: z.string().describe("Free-text music style — be specific and inventive: genre, instruments, " +
|
|
96
|
+
"era, tempo, mood, AND energy (put the intensity right in the words). " +
|
|
97
|
+
"Anything MusicCoCa can interpret. e.g. " +
|
|
98
|
+
'"1980s synthwave, neon and propulsive", "sparse, slow modular ambient for ' +
|
|
99
|
+
'deep reading", "frantic high-BPM drum-and-bass while the bug fights back", ' +
|
|
100
|
+
'"warm gospel organ, triumphant, the tests are finally green".'),
|
|
101
|
+
}, async ({ prompt }) => {
|
|
102
|
+
try {
|
|
103
|
+
await ensureDaemon();
|
|
104
|
+
await sendLine(buildVibeMessage(prompt));
|
|
105
|
+
return { content: [{ type: "text", text: "ok" }] };
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: "text", text: err.message }],
|
|
110
|
+
isError: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
server.tool("stop_music", "Stop the background music daemon. Use when the user asks to stop the music.", {}, async () => {
|
|
115
|
+
try {
|
|
116
|
+
stopDaemon();
|
|
117
|
+
return { content: [{ type: "text", text: "stopped" }] };
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text", text: err.message }],
|
|
122
|
+
isError: true,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
const transport = new StdioServerTransport();
|
|
127
|
+
await server.connect(transport);
|
|
128
|
+
}
|
package/dist/vibe.js
ADDED