vibeaudio 0.16.1 → 0.17.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 +7 -5
- package/package.json +1 -1
- package/src/hooks.js +11 -6
- package/src/mcp.js +115 -12
- package/src/player.js +8 -5
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<img src="docs/og.png?v=2" alt="VibeAudio — Focus music while your AI codes" width="720">
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
|
-
[](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml) [](https://www.npmjs.com/package/vibeaudio) [](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml) [](https://www.npmjs.com/package/vibeaudio) [](LICENSE) [](https://nodejs.org) [](https://npm-stat.com/charts.html?package=vibeaudio) [](https://m8ven.ai/mcp/kiril6/vibeaudio?s=readme)
|
|
8
8
|
|
|
9
9
|
> **Procedural focus music while your AI coding tools think.**
|
|
10
10
|
> Every project gets its own arrangement. Zero dependencies, zero audio files.
|
|
@@ -321,7 +321,7 @@ Every session of an agent is tracked separately, by the session id in its hook p
|
|
|
321
321
|
|
|
322
322
|
* **More sessions, more music.** Two sessions working at once plays at least tier 2 and three or more plays tier 3 — the same piece with more layers, arriving at the next loop boundary and easing off as sessions finish. It only ever raises the tier, so it works alongside time escalation and [reactive mode](#reactive-mode-opt-in).
|
|
323
323
|
* **Dialogs and Esc are per session.** A permission dialog in one terminal plays the "your turn" chime but only pauses the music once *every* session is waiting; Esc ends just that session's turn.
|
|
324
|
-
* **Crashed agents can't hold it hostage.** A session that
|
|
324
|
+
* **Crashed agents can't hold it hostage.** A session that goes 15 minutes without any hook (no tool call, no `Stop`) is taken for a crashed agent and dropped. A long turn that keeps working keeps its music.
|
|
325
325
|
|
|
326
326
|
Sessions with no id in their payload share a single slot, so they behave as one. VibeAudio keeps one stream: per-session genres or several streams mixed together aren't supported.
|
|
327
327
|
|
|
@@ -499,7 +499,7 @@ Changes land at the next loop boundary, so it shifts musically rather than cutti
|
|
|
499
499
|
* **Backs up first, once.** The first install copies your file alongside as `*.vibeaudio.bak`. Later installs leave it alone — re-copying would overwrite your real pre-VibeAudio config with a copy of VibeAudio's own last install.
|
|
500
500
|
* **Reinstalling updates, never duplicates.** Uninstalling sweeps every supported agent and removes only VibeAudio's own entries.
|
|
501
501
|
* **Refuses rather than clobbers.** Malformed JSON aborts the write; a temporary `npx` checkout is rejected outright, since the hook records an absolute path that npm's cache eviction would later delete.
|
|
502
|
-
* **Can't run away.** The background player
|
|
502
|
+
* **Can't run away.** The background player stops once its sessions have been silent for 15 minutes (and after 4 hours at most), so a missed stop event can't leave music looping.
|
|
503
503
|
|
|
504
504
|
</details>
|
|
505
505
|
|
|
@@ -630,9 +630,11 @@ VIBE_VOLUME = "25"
|
|
|
630
630
|
Restart the app afterwards — this one is read from the environment the server was launched with, so unlike the saved default it can't change under a running client.
|
|
631
631
|
|
|
632
632
|
#### Exposed MCP Tools:
|
|
633
|
-
* `vibe_play`: Start procedural focus music (`genre`: `lofi`, `synthwave`, `8bit`, `electronic`, `jazz`, `zen`, `piano`, `drone`, `rain`, `ocean`, `random`; `volume`: `5-100`).
|
|
633
|
+
* `vibe_play`: Start procedural focus music (`genre`: `lofi`, `synthwave`, `8bit`, `electronic`, `jazz`, `zen`, `piano`, `drone`, `rain`, `ocean`, `random`; `volume`: `5-100`; `project_dir`: the workspace's absolute path, optional).
|
|
634
634
|
* `vibe_stop`: Stop music and play the completion chime (`outcome`: `success` or `failure`).
|
|
635
|
-
* `vibe_status`: Return current playback state and active tier.
|
|
635
|
+
* `vibe_status`: Return current playback state and active tier, and which project the arrangement comes from.
|
|
636
|
+
|
|
637
|
+
**Which project it sounds like.** Hooks and the wrapper run inside your project, so each repo has its own arrangement. An MCP server is started once by the app, so it asks: a client that supports MCP *roots* (Claude Code does) reports its open folder, and any client's model can pass `project_dir`. That project's own settings apply too: a genre saved with `vibe --genre jazz --here` plays for that project over MCP as well. With neither, it uses the folder the app launched the server in, as before; `VIBE_SEED` still overrides all of it. `vibe_status` shows the `project` and its `projectSource` (`project_dir`, `roots`, `cwd` or `VIBE_SEED`).
|
|
636
638
|
|
|
637
639
|
Playback stops automatically if the desktop client disconnects, and caps out after 15 minutes — which also covers the likelier case of a model that started the music and never called `vibe_stop`.
|
|
638
640
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Procedural focus music while your AI coding tools (Claude Code, Codex, Cursor, Grok, Gemini, Copilot) think — a different arrangement per project.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vibeaudio": "bin/vibeaudio.js",
|
package/src/hooks.js
CHANGED
|
@@ -143,8 +143,13 @@ function hookTool() {
|
|
|
143
143
|
setTimeout(commit, 500).unref();
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
// A lost Stop hook must not leave music looping forever.
|
|
147
|
-
|
|
146
|
+
// A lost Stop hook must not leave music looping forever. This is how long a
|
|
147
|
+
// session may go without a hook before it is taken for a crashed agent; every
|
|
148
|
+
// tool call refreshes it, so a long turn that keeps working keeps its music.
|
|
149
|
+
// VIBE_MAX_DAEMON_MS is internal, for the suite.
|
|
150
|
+
const MAX_DAEMON_MS = Number(process.env.VIBE_MAX_DAEMON_MS) || 15 * 60 * 1000;
|
|
151
|
+
// The daemon's own ceiling, whatever the sessions say: a backstop, not the rule.
|
|
152
|
+
const DAEMON_CEILING_MS = Math.max(MAX_DAEMON_MS, 4 * 60 * 60 * 1000);
|
|
148
153
|
|
|
149
154
|
/**
|
|
150
155
|
* The agents VibeAudio can wire itself into, and the few things that differ
|
|
@@ -519,7 +524,7 @@ function readPid() {
|
|
|
519
524
|
* so an unverified kill would eventually SIGTERM an unrelated process.
|
|
520
525
|
*
|
|
521
526
|
* Failing closed is the safe direction here: a daemon we decline to kill stops
|
|
522
|
-
* itself
|
|
527
|
+
* itself once no session is left (MAX_DAEMON_MS of silence), while killing a stranger's process has no such
|
|
523
528
|
* ceiling.
|
|
524
529
|
*
|
|
525
530
|
* ponytail: posix only. Windows has no cheap command-line lookup, so the pid
|
|
@@ -694,8 +699,8 @@ function writeSession(id, data) {
|
|
|
694
699
|
|
|
695
700
|
/**
|
|
696
701
|
* Every session with a turn in flight. One that has not been touched for
|
|
697
|
-
* MAX_DAEMON_MS is a crashed agent - it never sent Stop - and is dropped here
|
|
698
|
-
* the
|
|
702
|
+
* MAX_DAEMON_MS is a crashed agent - it never sent Stop - and is dropped here;
|
|
703
|
+
* the daemon stops once none is left, with DAEMON_CEILING_MS as the backstop.
|
|
699
704
|
*/
|
|
700
705
|
function listSessions() {
|
|
701
706
|
let names;
|
|
@@ -947,7 +952,7 @@ function runDaemon(genre, volume, { reactive = false, volumeSource = null } = {}
|
|
|
947
952
|
|
|
948
953
|
process.on("SIGTERM", shutdown);
|
|
949
954
|
process.on("SIGINT", shutdown);
|
|
950
|
-
setTimeout(shutdown,
|
|
955
|
+
setTimeout(shutdown, DAEMON_CEILING_MS);
|
|
951
956
|
|
|
952
957
|
setInterval(() => {
|
|
953
958
|
// The pid file names the one daemon that owns the speakers. Two prompts
|
package/src/mcp.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const readline = require("readline");
|
|
9
|
-
const
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const { fileURLToPath } = require("url");
|
|
11
|
+
const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, musicEnabled, isKnownGenre, loadConfig, projectSettings } = require("./player");
|
|
10
12
|
const pkg = require("../package.json");
|
|
11
13
|
|
|
12
14
|
// A desktop client that crashes never sends vibe_stop, so playback needs its
|
|
@@ -26,6 +28,64 @@ const annotate = (title, { readOnly, idempotent }) => ({
|
|
|
26
28
|
|
|
27
29
|
const OUTCOMES = ["success", "failure"];
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Which project this conversation is about. Hooks and the wrapper know it (they
|
|
33
|
+
* run in it); an MCP server is launched once by the client, in a directory the
|
|
34
|
+
* client chose, so it has to be told. Two sources, in order:
|
|
35
|
+
* 1. `project_dir`, an argument the model passes when it has a workspace.
|
|
36
|
+
* 2. The client's roots (`roots/list`), the protocol's own answer.
|
|
37
|
+
* With neither, the server's cwd, which is what it always used.
|
|
38
|
+
* The value is only hashed and matched against keys in our own config file; it
|
|
39
|
+
* is never opened, so it is not a file-access question - but it comes from the
|
|
40
|
+
* client, so it must be a plain absolute path of sane length.
|
|
41
|
+
*/
|
|
42
|
+
const MAX_PROJECT_DIR = 1024;
|
|
43
|
+
function cleanProjectDir(value) {
|
|
44
|
+
if (typeof value !== "string") return null;
|
|
45
|
+
const v = value.trim();
|
|
46
|
+
if (!v || v.length > MAX_PROJECT_DIR || v.includes("\0") || !path.isAbsolute(v)) return null;
|
|
47
|
+
return path.resolve(v);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A root is a file: URI. Anything else (a remote workspace) is not a path here.
|
|
51
|
+
function rootDirs(roots) {
|
|
52
|
+
const dirs = [];
|
|
53
|
+
for (const root of Array.isArray(roots) ? roots : []) {
|
|
54
|
+
try {
|
|
55
|
+
const uri = root && typeof root.uri === "string" ? root.uri : "";
|
|
56
|
+
if (!uri.startsWith("file:")) continue;
|
|
57
|
+
const dir = cleanProjectDir(fileURLToPath(uri));
|
|
58
|
+
if (dir) dirs.push(dir);
|
|
59
|
+
} catch (e) { /* a malformed URI is skipped, not fatal */ }
|
|
60
|
+
}
|
|
61
|
+
return dirs;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** { dir, source }: what to seed from, and why - vibe_status reports the second. */
|
|
65
|
+
function resolveProject(args, session) {
|
|
66
|
+
const explicit = cleanProjectDir(args && args.project_dir);
|
|
67
|
+
let dir = null;
|
|
68
|
+
let source = "cwd";
|
|
69
|
+
if (explicit) { dir = explicit; source = "project_dir"; }
|
|
70
|
+
else if (session && session.roots.length) { dir = session.roots[0]; source = "roots"; }
|
|
71
|
+
// VIBE_SEED outranks every directory (projectSeed reads it); say so.
|
|
72
|
+
if (process.env.VIBE_SEED && !isNaN(parseInt(process.env.VIBE_SEED, 10))) source = "VIBE_SEED";
|
|
73
|
+
return { dir, source };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// What the client has told us about itself. `send` writes a request to it.
|
|
77
|
+
function newSession(send = null) {
|
|
78
|
+
return { supportsRoots: false, roots: [], rootsRequest: null, send, nextId: 1 };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Ask for the roots. Our own id space ("vibeaudio-roots-N") cannot collide with
|
|
82
|
+
// the client's, and a reply is matched by it.
|
|
83
|
+
function requestRoots(session) {
|
|
84
|
+
if (!session || !session.supportsRoots || !session.send) return;
|
|
85
|
+
session.rootsRequest = `vibeaudio-roots-${session.nextId++}`;
|
|
86
|
+
session.send({ jsonrpc: "2.0", id: session.rootsRequest, method: "roots/list" });
|
|
87
|
+
}
|
|
88
|
+
|
|
29
89
|
const TOOLS = [
|
|
30
90
|
{
|
|
31
91
|
name: "vibe_play",
|
|
@@ -49,6 +109,13 @@ const TOOLS = [
|
|
|
49
109
|
description: "Playback volume from 5 to 100 (default: 40)",
|
|
50
110
|
minimum: 5,
|
|
51
111
|
maximum: 100
|
|
112
|
+
},
|
|
113
|
+
project_dir: {
|
|
114
|
+
type: "string",
|
|
115
|
+
description:
|
|
116
|
+
"Absolute path of the active workspace, if there is one. Omit it when you " +
|
|
117
|
+
"have no workspace - do not guess a path. It makes this project sound like " +
|
|
118
|
+
"itself and applies the genre and volume the user saved for it."
|
|
52
119
|
}
|
|
53
120
|
}
|
|
54
121
|
}
|
|
@@ -90,10 +157,22 @@ const TOOLS = [
|
|
|
90
157
|
}
|
|
91
158
|
];
|
|
92
159
|
|
|
93
|
-
function handleMessage(player, msg) {
|
|
160
|
+
function handleMessage(player, msg, session = null) {
|
|
94
161
|
const { id, method, params } = msg;
|
|
95
162
|
|
|
163
|
+
// The client's answer to our roots/list: it has a result, not a method.
|
|
164
|
+
if (method === undefined && session && id !== undefined && id === session.rootsRequest) {
|
|
165
|
+
session.rootsRequest = null;
|
|
166
|
+
session.roots = rootDirs(msg.result && msg.result.roots);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
// Any other response is an answer to something we asked, not a request: it
|
|
170
|
+
// gets no reply (a "method not found" for it would be sent to the client).
|
|
171
|
+
if (method === undefined && (msg.result !== undefined || msg.error !== undefined)) return null;
|
|
172
|
+
|
|
96
173
|
if (method === "initialize") {
|
|
174
|
+
// roots is a client capability: only a client that declares it can be asked.
|
|
175
|
+
if (session) session.supportsRoots = Boolean(params && params.capabilities && params.capabilities.roots);
|
|
97
176
|
return {
|
|
98
177
|
jsonrpc: "2.0",
|
|
99
178
|
id,
|
|
@@ -117,13 +196,22 @@ function handleMessage(player, msg) {
|
|
|
117
196
|
"and you are ready to hand back a result.\n" +
|
|
118
197
|
"Do not use it for quick answers - starting and stopping music around a " +
|
|
119
198
|
"one-second reply is worse than silence. Leave the genre and volume alone " +
|
|
120
|
-
"unless the user asks; they are the user's preference, not yours
|
|
199
|
+
"unless the user asks; they are the user's preference, not yours.\n" +
|
|
200
|
+
"If you have a workspace, pass its absolute path as vibe_play's project_dir so " +
|
|
201
|
+
"this project sounds like itself; omit it when you have none."
|
|
121
202
|
}
|
|
122
203
|
};
|
|
123
204
|
}
|
|
124
205
|
|
|
125
206
|
if (method === "notifications/initialized") {
|
|
126
|
-
// Client acknowledgment - no response needed
|
|
207
|
+
// Client acknowledgment - no response needed, but this is the first moment
|
|
208
|
+
// a request of ours is allowed, so ask which project it has open.
|
|
209
|
+
requestRoots(session);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (method === "notifications/roots/list_changed") {
|
|
214
|
+
requestRoots(session);
|
|
127
215
|
return null;
|
|
128
216
|
}
|
|
129
217
|
|
|
@@ -153,10 +241,16 @@ function handleMessage(player, msg) {
|
|
|
153
241
|
// saved file here is what makes `vibe --genre jazz` mean jazz in a
|
|
154
242
|
// desktop client too, rather than only in the terminal.
|
|
155
243
|
const saved = loadConfig();
|
|
244
|
+
const project = resolveProject(args, session);
|
|
245
|
+
const dir = project.dir || process.cwd();
|
|
246
|
+
// What `vibe --here` saved for this project sits between the environment
|
|
247
|
+
// and the machine-wide default, as it does for the CLI and the hooks.
|
|
248
|
+
const here = projectSettings(saved, dir);
|
|
249
|
+
const setting = (key) => (here[key] !== undefined ? here[key] : saved[key]);
|
|
156
250
|
// The argument comes from the client: only a string counts. A number or
|
|
157
251
|
// object here used to reach `.toLowerCase()` and fail the whole call.
|
|
158
252
|
const asked = typeof args.genre === "string" ? args.genre.trim() : "";
|
|
159
|
-
const requested = String(asked || process.env.VIBE_GENRE ||
|
|
253
|
+
const requested = String(asked || process.env.VIBE_GENRE || setting("genre") || "lofi");
|
|
160
254
|
// An unknown genre already fell back to lofi inside the generator, but
|
|
161
255
|
// player.genre kept the name nobody implements - so the model told the
|
|
162
256
|
// user it was playing something that does not exist.
|
|
@@ -165,10 +259,10 @@ function handleMessage(player, msg) {
|
|
|
165
259
|
// as the CLI, so a mistyped VIBE_VOLUME falls back instead of reaching
|
|
166
260
|
// the player as NaN.
|
|
167
261
|
const volume = normalizeVolume(args.volume,
|
|
168
|
-
normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(
|
|
262
|
+
normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(setting("volume"), 0.4)));
|
|
169
263
|
|
|
170
264
|
// A deliberate setting, like a mute: report it as one, and start nothing.
|
|
171
|
-
if (!musicEnabled()) {
|
|
265
|
+
if (!musicEnabled(process.env, saved, dir)) {
|
|
172
266
|
return {
|
|
173
267
|
jsonrpc: "2.0",
|
|
174
268
|
id,
|
|
@@ -176,7 +270,7 @@ function handleMessage(player, msg) {
|
|
|
176
270
|
};
|
|
177
271
|
}
|
|
178
272
|
|
|
179
|
-
const started = player.start(genre, volume, { maxDurationMs: MAX_PLAYBACK_MS });
|
|
273
|
+
const started = player.start(genre, volume, { maxDurationMs: MAX_PLAYBACK_MS, project: project.dir });
|
|
180
274
|
// start() returns false for three unrelated reasons, and the model
|
|
181
275
|
// relays whatever we say here to the user. Reporting a deliberate mute
|
|
182
276
|
// as a missing audio player sends them debugging their sound stack.
|
|
@@ -232,6 +326,7 @@ function handleMessage(player, msg) {
|
|
|
232
326
|
}
|
|
233
327
|
|
|
234
328
|
if (name === "vibe_status") {
|
|
329
|
+
const statusProject = resolveProject({}, session);
|
|
235
330
|
return {
|
|
236
331
|
jsonrpc: "2.0",
|
|
237
332
|
id,
|
|
@@ -244,7 +339,13 @@ function handleMessage(player, msg) {
|
|
|
244
339
|
// Without this, "isPlaying: false" while muted reads as a bug
|
|
245
340
|
// worth investigating rather than a choice the user made.
|
|
246
341
|
muted: playbackDisabled(),
|
|
247
|
-
musicOff: !musicEnabled(),
|
|
342
|
+
musicOff: !musicEnabled(process.env, loadConfig(), statusProject.dir || process.cwd()),
|
|
343
|
+
// Which project the next vibe_play seeds from and how we learned
|
|
344
|
+
// it (project_dir | roots | cwd | VIBE_SEED), and, while music
|
|
345
|
+
// plays, the one it is playing for.
|
|
346
|
+
project: statusProject.dir || process.cwd(),
|
|
347
|
+
projectSource: statusProject.source,
|
|
348
|
+
playingProject: player.isPlaying ? (player.projectDir || process.cwd()) : null,
|
|
248
349
|
genre: player.genre,
|
|
249
350
|
currentTier: player.currentTier,
|
|
250
351
|
uptimeMs: player.isPlaying ? Date.now() - player.startTime : 0
|
|
@@ -281,6 +382,8 @@ function handleMessage(player, msg) {
|
|
|
281
382
|
|
|
282
383
|
function startMcpServer() {
|
|
283
384
|
const player = new AudioPlayer();
|
|
385
|
+
const send = (message) => process.stdout.write(JSON.stringify(message) + "\n");
|
|
386
|
+
const session = newSession(send);
|
|
284
387
|
|
|
285
388
|
// In stdio MCP mode, stderr is used for logging, stdout is strictly reserved for JSON-RPC
|
|
286
389
|
process.stderr.write(`[vibeaudio] MCP Server running on stdio (v${pkg.version})\n`);
|
|
@@ -306,7 +409,7 @@ function startMcpServer() {
|
|
|
306
409
|
|
|
307
410
|
let response;
|
|
308
411
|
try {
|
|
309
|
-
response = handleMessage(player, msg);
|
|
412
|
+
response = handleMessage(player, msg, session);
|
|
310
413
|
} catch (err) {
|
|
311
414
|
process.stderr.write(`[vibeaudio] Handler error: ${err.message}\n`);
|
|
312
415
|
// A request (one with an id) must always get a reply. Swallowing the
|
|
@@ -316,7 +419,7 @@ function startMcpServer() {
|
|
|
316
419
|
}
|
|
317
420
|
}
|
|
318
421
|
|
|
319
|
-
if (response)
|
|
422
|
+
if (response) send(response);
|
|
320
423
|
});
|
|
321
424
|
|
|
322
425
|
// Client disconnected - never leave audio looping behind.
|
|
@@ -336,4 +439,4 @@ function startMcpServer() {
|
|
|
336
439
|
});
|
|
337
440
|
}
|
|
338
441
|
|
|
339
|
-
module.exports = { startMcpServer, handleMessage, TOOLS };
|
|
442
|
+
module.exports = { startMcpServer, handleMessage, TOOLS, newSession, resolveProject, cleanProjectDir, rootDirs };
|
package/src/player.js
CHANGED
|
@@ -438,8 +438,8 @@ function playbackDisabled() {
|
|
|
438
438
|
* Env beats this directory's saved value beats the global one; a flag is
|
|
439
439
|
* resolved by parseArgs() and handed to whoever acts on it.
|
|
440
440
|
*/
|
|
441
|
-
function musicEnabled(env = process.env, config = loadConfig()) {
|
|
442
|
-
const raw = env.VIBE_MUSIC ?? projectSettings(config).music ?? config.music ?? "on";
|
|
441
|
+
function musicEnabled(env = process.env, config = loadConfig(), cwd = process.cwd()) {
|
|
442
|
+
const raw = env.VIBE_MUSIC ?? projectSettings(config, cwd).music ?? config.music ?? "on";
|
|
443
443
|
return !["off", "0", "false", "no"].includes(String(raw).trim().toLowerCase());
|
|
444
444
|
}
|
|
445
445
|
|
|
@@ -751,6 +751,7 @@ class AudioPlayer {
|
|
|
751
751
|
this.genre = "lofi";
|
|
752
752
|
this.volume = 0.42;
|
|
753
753
|
this.seed = projectSeed();
|
|
754
|
+
this.projectDir = null; // Set when a client names the project (MCP); null means the cwd.
|
|
754
755
|
this.intensity = null;
|
|
755
756
|
this.minTier = null;
|
|
756
757
|
this.tension = null;
|
|
@@ -764,12 +765,13 @@ class AudioPlayer {
|
|
|
764
765
|
* Returns true if playback actually started. Restarts when called with
|
|
765
766
|
* different settings while playing, so a genre switch is not silently dropped.
|
|
766
767
|
*/
|
|
767
|
-
start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null, tension = null } = {}) {
|
|
768
|
+
start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null, tension = null, project = null } = {}) {
|
|
768
769
|
const resolved = resolveGenre(genre);
|
|
769
770
|
const targetVolume = Math.max(0.05, Math.min(1.0, volume));
|
|
770
771
|
|
|
771
772
|
if (this.isPlaying) {
|
|
772
|
-
|
|
773
|
+
// A different project is a different arrangement, so it restarts too.
|
|
774
|
+
if (resolved === this.genre && targetVolume === this.volume && (project || null) === this.projectDir) return false;
|
|
773
775
|
this.stop({ playChime: false });
|
|
774
776
|
}
|
|
775
777
|
|
|
@@ -784,7 +786,8 @@ class AudioPlayer {
|
|
|
784
786
|
this.startTime = Date.now();
|
|
785
787
|
this.genre = resolved;
|
|
786
788
|
this.volume = targetVolume;
|
|
787
|
-
this.
|
|
789
|
+
this.projectDir = project || null;
|
|
790
|
+
this.seed = projectSeed(project || process.cwd());
|
|
788
791
|
this.bar = 0; // Every run opens on the project's own bar.
|
|
789
792
|
this.intensity = intensity;
|
|
790
793
|
this.minTier = minTier;
|