vibeaudio 0.4.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 +601 -0
- package/bin/vibeaudio.js +16 -0
- package/package.json +51 -0
- package/src/cli.js +993 -0
- package/src/hooks.js +980 -0
- package/src/hud.js +84 -0
- package/src/interactive.js +338 -0
- package/src/mcp.js +292 -0
- package/src/player.js +666 -0
- package/src/synth/chime.js +137 -0
- package/src/synth/chiptune.js +132 -0
- package/src/synth/drone.js +133 -0
- package/src/synth/electronic.js +133 -0
- package/src/synth/generator.js +179 -0
- package/src/synth/jazz.js +178 -0
- package/src/synth/lofi.js +148 -0
- package/src/synth/piano.js +158 -0
- package/src/synth/synthwave.js +170 -0
- package/src/synth/zen.js +148 -0
package/src/mcp.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Context Protocol (MCP) Stdio Server
|
|
3
|
+
* Connects VibeAudio to clients with no hook system (Claude Desktop, Antigravity,
|
|
4
|
+
* Gemini CLI). Claude Code, Codex and Cursor have hooks — use those instead.
|
|
5
|
+
* Zero dependencies - Pure Node.js JSON-RPC 2.0 over Stdio
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const readline = require("readline");
|
|
9
|
+
const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, isKnownGenre } = require("./player");
|
|
10
|
+
const pkg = require("../package.json");
|
|
11
|
+
|
|
12
|
+
// A desktop client that crashes never sends vibe_stop, so playback needs its
|
|
13
|
+
// own ceiling rather than looping forever.
|
|
14
|
+
const MAX_PLAYBACK_MS = 15 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
const TOOLS = [
|
|
17
|
+
{
|
|
18
|
+
name: "vibe_play",
|
|
19
|
+
description:
|
|
20
|
+
"Start background focus music for the user while you work. Call this at the " +
|
|
21
|
+
"START of a task you expect to take more than a few seconds - multi-step work, " +
|
|
22
|
+
"long file edits, repeated tool calls, anything the user will wait through. " +
|
|
23
|
+
"Always pair it with vibe_stop when the task resolves. Skip it for quick " +
|
|
24
|
+
"answers: music around a one-second reply is worse than silence.",
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
genre: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Music genre: lofi, synthwave, 8bit, electronic, jazz, zen, piano (sparse), drone (no melody), or random",
|
|
31
|
+
enum: [...AVAILABLE_GENRES, "random"]
|
|
32
|
+
},
|
|
33
|
+
volume: {
|
|
34
|
+
type: "number",
|
|
35
|
+
description: "Playback volume from 5 to 100 (default: 40)",
|
|
36
|
+
minimum: 5,
|
|
37
|
+
maximum: 100
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "vibe_stop",
|
|
44
|
+
description:
|
|
45
|
+
"Stop the focus music and play a completion chime. Call this as soon as the " +
|
|
46
|
+
"task resolves and you are ready to hand back a result, including when it " +
|
|
47
|
+
"failed - pass outcome 'failure' so the chime says so. Never leave music " +
|
|
48
|
+
"playing after a vibe_play task is done.",
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: "object",
|
|
51
|
+
properties: {
|
|
52
|
+
outcome: {
|
|
53
|
+
type: "string",
|
|
54
|
+
description: "Resolution outcome: 'success' (ascending chime) or 'failure' (soft minor tone)",
|
|
55
|
+
enum: ["success", "failure"]
|
|
56
|
+
},
|
|
57
|
+
playChime: {
|
|
58
|
+
type: "boolean",
|
|
59
|
+
description: "Whether to play the completion chime (default: true)"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "vibe_status",
|
|
66
|
+
description:
|
|
67
|
+
"Check whether focus music is currently playing, and in which genre and " +
|
|
68
|
+
"intensity tier. Use it to avoid starting a second track, or to confirm " +
|
|
69
|
+
"nothing was left running.",
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
function handleMessage(player, msg) {
|
|
78
|
+
const { id, method, params } = msg;
|
|
79
|
+
|
|
80
|
+
if (method === "initialize") {
|
|
81
|
+
return {
|
|
82
|
+
jsonrpc: "2.0",
|
|
83
|
+
id,
|
|
84
|
+
result: {
|
|
85
|
+
protocolVersion: "2024-11-05",
|
|
86
|
+
capabilities: {
|
|
87
|
+
tools: {}
|
|
88
|
+
},
|
|
89
|
+
serverInfo: {
|
|
90
|
+
name: "vibeaudio",
|
|
91
|
+
version: pkg.version
|
|
92
|
+
},
|
|
93
|
+
// Unlike the CLI's hooks, nothing fires these tools automatically - the
|
|
94
|
+
// model has to choose to. Without a nudge the server just sits idle, so
|
|
95
|
+
// state the intended usage pattern where the client will surface it.
|
|
96
|
+
instructions:
|
|
97
|
+
"VibeAudio plays background focus music while the user waits on long work.\n" +
|
|
98
|
+
"Call vibe_play at the start of a task you expect to take more than a few " +
|
|
99
|
+
"seconds (multi-step work, long file edits, repeated tool calls), then call " +
|
|
100
|
+
"vibe_stop with outcome 'success' or 'failure' as soon as the task resolves " +
|
|
101
|
+
"and you are ready to hand back a result.\n" +
|
|
102
|
+
"Do not use it for quick answers - starting and stopping music around a " +
|
|
103
|
+
"one-second reply is worse than silence. Leave the genre and volume alone " +
|
|
104
|
+
"unless the user asks; they are the user's preference, not yours."
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (method === "notifications/initialized") {
|
|
110
|
+
// Client acknowledgment - no response needed
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (method === "ping") {
|
|
115
|
+
return { jsonrpc: "2.0", id, result: {} };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (method === "tools/list") {
|
|
119
|
+
return {
|
|
120
|
+
jsonrpc: "2.0",
|
|
121
|
+
id,
|
|
122
|
+
result: {
|
|
123
|
+
tools: TOOLS
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (method === "tools/call") {
|
|
129
|
+
// A request with no params used to throw here, and the only thing that
|
|
130
|
+
// caught it wrote to stderr - leaving the client waiting on a response
|
|
131
|
+
// that never came. Every request must leave with an answer.
|
|
132
|
+
const { name, arguments: args = {} } = params || {};
|
|
133
|
+
|
|
134
|
+
if (name === "vibe_play") {
|
|
135
|
+
const requested = args.genre || process.env.VIBE_GENRE || "lofi";
|
|
136
|
+
// An unknown genre already fell back to lofi inside the generator, but
|
|
137
|
+
// player.genre kept the name nobody implements - so the model told the
|
|
138
|
+
// user it was playing something that does not exist.
|
|
139
|
+
const genre = isKnownGenre(requested) ? requested : "lofi";
|
|
140
|
+
// Both the tool argument and the env default go through the same parser
|
|
141
|
+
// as the CLI, so a mistyped VIBE_VOLUME falls back instead of reaching
|
|
142
|
+
// the player as NaN.
|
|
143
|
+
const volume = normalizeVolume(args.volume, normalizeVolume(process.env.VIBE_VOLUME, 0.4));
|
|
144
|
+
|
|
145
|
+
const started = player.start(genre, volume, { maxDurationMs: MAX_PLAYBACK_MS });
|
|
146
|
+
// start() returns false for three unrelated reasons, and the model
|
|
147
|
+
// relays whatever we say here to the user. Reporting a deliberate mute
|
|
148
|
+
// as a missing audio player sends them debugging their sound stack.
|
|
149
|
+
const fallbackNote = genre === requested
|
|
150
|
+
? ""
|
|
151
|
+
: ` (requested genre '${requested}' is not one of ${AVAILABLE_GENRES.join(", ")}, random)`;
|
|
152
|
+
const text = started
|
|
153
|
+
? `Started playing ${player.genre} procedural focus music at ${Math.round(volume * 100)}% volume.${fallbackNote}`
|
|
154
|
+
: player.isPlaying
|
|
155
|
+
? `Already playing ${player.genre} at ${Math.round(player.volume * 100)}% volume — nothing changed.`
|
|
156
|
+
: playbackDisabled()
|
|
157
|
+
? "The user has muted VibeAudio, so nothing will play. This is deliberate and not an error — do not try again or suggest fixes; they will unmute when they want music."
|
|
158
|
+
: "No supported audio player found on this system; playback is unavailable.";
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
jsonrpc: "2.0",
|
|
162
|
+
id,
|
|
163
|
+
result: {
|
|
164
|
+
content: [{ type: "text", text }]
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (name === "vibe_stop") {
|
|
170
|
+
const outcome = args.outcome || "success";
|
|
171
|
+
const playChime = args.playChime !== false;
|
|
172
|
+
|
|
173
|
+
const wasPlaying = player.stop({ playChime, outcome });
|
|
174
|
+
return {
|
|
175
|
+
jsonrpc: "2.0",
|
|
176
|
+
id,
|
|
177
|
+
result: {
|
|
178
|
+
content: [
|
|
179
|
+
{
|
|
180
|
+
type: "text",
|
|
181
|
+
text: `${wasPlaying ? "Stopped music." : "Nothing was playing."}${playChime ? ` Played ${outcome} resolution chime.` : ""}`
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (name === "vibe_status") {
|
|
189
|
+
return {
|
|
190
|
+
jsonrpc: "2.0",
|
|
191
|
+
id,
|
|
192
|
+
result: {
|
|
193
|
+
content: [
|
|
194
|
+
{
|
|
195
|
+
type: "text",
|
|
196
|
+
text: JSON.stringify({
|
|
197
|
+
isPlaying: player.isPlaying,
|
|
198
|
+
// Without this, "isPlaying: false" while muted reads as a bug
|
|
199
|
+
// worth investigating rather than a choice the user made.
|
|
200
|
+
muted: playbackDisabled(),
|
|
201
|
+
genre: player.genre,
|
|
202
|
+
currentTier: player.currentTier,
|
|
203
|
+
uptimeMs: player.isPlaying ? Date.now() - player.startTime : 0
|
|
204
|
+
}, null, 2)
|
|
205
|
+
}
|
|
206
|
+
]
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
jsonrpc: "2.0",
|
|
213
|
+
id,
|
|
214
|
+
error: {
|
|
215
|
+
code: -32601,
|
|
216
|
+
message: `Unknown tool: ${name}`
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (id !== undefined) {
|
|
222
|
+
return {
|
|
223
|
+
jsonrpc: "2.0",
|
|
224
|
+
id,
|
|
225
|
+
error: {
|
|
226
|
+
code: -32601,
|
|
227
|
+
message: `Method not found: ${method}`
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function startMcpServer() {
|
|
236
|
+
const player = new AudioPlayer();
|
|
237
|
+
|
|
238
|
+
// In stdio MCP mode, stderr is used for logging, stdout is strictly reserved for JSON-RPC
|
|
239
|
+
process.stderr.write(`[vibeaudio] MCP Server running on stdio (v${pkg.version})\n`);
|
|
240
|
+
|
|
241
|
+
const rl = readline.createInterface({
|
|
242
|
+
input: process.stdin,
|
|
243
|
+
output: process.stdout,
|
|
244
|
+
terminal: false
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
rl.on("line", (line) => {
|
|
248
|
+
const trimmed = line.trim();
|
|
249
|
+
if (!trimmed) return;
|
|
250
|
+
|
|
251
|
+
let msg;
|
|
252
|
+
try {
|
|
253
|
+
msg = JSON.parse(trimmed);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// Unparseable: there is no id to answer to, so stderr is all we have.
|
|
256
|
+
process.stderr.write(`[vibeaudio] Failed to parse JSON-RPC line: ${err.message}\n`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let response;
|
|
261
|
+
try {
|
|
262
|
+
response = handleMessage(player, msg);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
process.stderr.write(`[vibeaudio] Handler error: ${err.message}\n`);
|
|
265
|
+
// A request (one with an id) must always get a reply. Swallowing the
|
|
266
|
+
// throw left the client blocked on a response that never came.
|
|
267
|
+
if (msg && msg.id !== undefined && msg.id !== null) {
|
|
268
|
+
response = { jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: `Internal error: ${err.message}` } };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (response) process.stdout.write(JSON.stringify(response) + "\n");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Client disconnected - never leave audio looping behind.
|
|
276
|
+
rl.on("close", () => {
|
|
277
|
+
player.stop({ playChime: false });
|
|
278
|
+
process.exit(0);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
process.on("SIGINT", () => {
|
|
282
|
+
player.stop({ playChime: false });
|
|
283
|
+
process.exit(0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
process.on("SIGTERM", () => {
|
|
287
|
+
player.stop({ playChime: false });
|
|
288
|
+
process.exit(0);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
module.exports = { startMcpServer, handleMessage, TOOLS };
|