vibeaudio 0.5.0 → 0.6.1
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 +124 -44
- package/package.json +5 -3
- package/scripts/postinstall.js +33 -0
- package/src/cli.js +284 -18
- package/src/hooks.js +56 -14
- package/src/interactive.js +56 -4
- package/src/mcp.js +9 -3
- package/src/player.js +97 -0
- package/src/synth/electronic.js +7 -2
- package/src/synth/generator.js +27 -0
- package/src/synth/synthwave.js +3 -2
package/src/cli.js
CHANGED
|
@@ -14,6 +14,10 @@ const {
|
|
|
14
14
|
resolveGenre,
|
|
15
15
|
isKnownGenre,
|
|
16
16
|
normalizeVolume,
|
|
17
|
+
loadConfig,
|
|
18
|
+
saveConfig,
|
|
19
|
+
projectSettings,
|
|
20
|
+
saveProjectConfig,
|
|
17
21
|
wavDurationMs,
|
|
18
22
|
AVAILABLE_GENRES
|
|
19
23
|
} = require("./player");
|
|
@@ -44,6 +48,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
44
48
|
|
|
45
49
|
\x1b[1mEXAMPLES:\x1b[0m
|
|
46
50
|
vibe --install-hooks \x1b[90m# best for interactive agents — music follows thinking\x1b[0m
|
|
51
|
+
vibe --genre jazz --volume 25 \x1b[90m# save your default — reaches hooks, wrapper and MCP\x1b[0m
|
|
47
52
|
vibe --install-hooks --tools codex \x1b[90m# wire up one agent instead of every one found\x1b[0m
|
|
48
53
|
vibe npm test \x1b[90m# wrap any command that exits when it's done\x1b[0m
|
|
49
54
|
vibe claude -p "explain this"
|
|
@@ -51,12 +56,14 @@ Procedural focus music while your AI coding tools think.
|
|
|
51
56
|
vibe --genre 8bit sleep 5
|
|
52
57
|
vibe --volume 30 npm test
|
|
53
58
|
vibe --preview jazz
|
|
59
|
+
vibe \x1b[90m# menu: pick a tool and a sound (p auditions a genre)\x1b[0m
|
|
54
60
|
|
|
55
61
|
\x1b[1mOPTIONS:\x1b[0m
|
|
56
62
|
-g, --genre <name> Select genre: lofi (default), synthwave, 8bit, electronic, jazz, zen, piano, drone, random
|
|
57
63
|
-v, --volume <5-100> Set playback volume (default: 40)
|
|
58
64
|
-cv, --chime-volume <5-100> Set independent completion chime volume
|
|
59
65
|
--grace <ms> Silence window before music starts, in ms (default: ${DEFAULT_GRACE_PERIOD_MS})
|
|
66
|
+
--here With a saved setting: this directory only, not everywhere
|
|
60
67
|
--seed <n> Force a specific arrangement (default: derived from the project directory)
|
|
61
68
|
--whisper Preset: 15% volume (late night / headphones)
|
|
62
69
|
--quiet Preset: 25% volume (focus / open office)
|
|
@@ -78,11 +85,22 @@ Procedural focus music while your AI coding tools think.
|
|
|
78
85
|
-h, --help Show this help message
|
|
79
86
|
--version Show version
|
|
80
87
|
|
|
88
|
+
\x1b[1mSETTINGS:\x1b[0m
|
|
89
|
+
A genre or volume with no command after it is saved as your default:
|
|
90
|
+
|
|
91
|
+
vibe --genre zen \x1b[90m# applies on the next prompt, nothing to reinstall\x1b[0m
|
|
92
|
+
vibe --genre zen --here \x1b[90m# this project only — the default stays as it was\x1b[0m
|
|
93
|
+
|
|
94
|
+
Saved to ~/.vibeaudio/config.json and read by the wrapper, your agent hooks
|
|
95
|
+
and the MCP server alike. A flag beats an environment variable beats a
|
|
96
|
+
\x1b[1m--here\x1b[0m setting beats the global one, so a one-off
|
|
97
|
+
\x1b[1mvibe --genre 8bit npm test\x1b[0m stays a one-off.
|
|
98
|
+
|
|
81
99
|
\x1b[1mENVIRONMENT:\x1b[0m
|
|
82
|
-
VIBE_GENRE=<name>
|
|
83
|
-
VIBE_VOLUME=<5-100>
|
|
84
|
-
VIBE_CHIME_VOLUME=<5-100>
|
|
85
|
-
VIBE_GRACE_MS=<ms>
|
|
100
|
+
VIBE_GENRE=<name> Override the saved genre for this shell
|
|
101
|
+
VIBE_VOLUME=<5-100> Override the saved volume for this shell
|
|
102
|
+
VIBE_CHIME_VOLUME=<5-100> Override the saved chime volume for this shell
|
|
103
|
+
VIBE_GRACE_MS=<ms> Override the saved grace window, in ms
|
|
86
104
|
VIBE_SEED=<n> Pin the arrangement instead of deriving it from the directory
|
|
87
105
|
VIBE_DISABLE=1 Mute automatic playback without uninstalling anything
|
|
88
106
|
`);
|
|
@@ -105,13 +123,33 @@ const VALUE_FLAGS = new Set([
|
|
|
105
123
|
|
|
106
124
|
function parseArgs(argv) {
|
|
107
125
|
const args = argv.slice(2);
|
|
108
|
-
let genre = (process.env.VIBE_GENRE || "lofi").toLowerCase();
|
|
109
126
|
|
|
110
|
-
|
|
111
|
-
|
|
127
|
+
// Flag beats env var beats saved config beats the built-in default. The
|
|
128
|
+
// saved config is what makes "set my genre" have one answer instead of three
|
|
129
|
+
// - see loadConfig() in player.js - and it sits below the env var so a shell
|
|
130
|
+
// that exports one keeps winning, as it always did.
|
|
131
|
+
const saved = loadConfig();
|
|
132
|
+
// A setting saved for this directory sits between the environment and the
|
|
133
|
+
// global default: `--here` is a deliberate choice about one project, so it
|
|
134
|
+
// outranks the machine-wide one, and an export still outranks both.
|
|
135
|
+
const here = projectSettings(saved);
|
|
136
|
+
const setting = (key) => (here[key] !== undefined ? here[key] : saved[key]);
|
|
137
|
+
|
|
138
|
+
let genre = (process.env.VIBE_GENRE || setting("genre") || "lofi").toLowerCase();
|
|
139
|
+
|
|
140
|
+
let volume = normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(setting("volume"), 0.40));
|
|
141
|
+
let chimeVolume = normalizeVolume(process.env.VIBE_CHIME_VOLUME, normalizeVolume(setting("chimeVolume"), null));
|
|
112
142
|
|
|
113
143
|
const envGrace = process.env.VIBE_GRACE_MS ? parseInt(process.env.VIBE_GRACE_MS, 10) : NaN;
|
|
114
|
-
let grace = !isNaN(envGrace) ? Math.max(0, envGrace)
|
|
144
|
+
let grace = !isNaN(envGrace) ? Math.max(0, envGrace)
|
|
145
|
+
: Number.isFinite(setting("grace")) ? Math.max(0, setting("grace"))
|
|
146
|
+
: DEFAULT_GRACE_PERIOD_MS;
|
|
147
|
+
|
|
148
|
+
// What the user typed on THIS command line, as opposed to what was inherited
|
|
149
|
+
// from the environment or the saved file. `vibe --genre jazz` with nothing
|
|
150
|
+
// after it is a request to change the default, and this is how run() tells
|
|
151
|
+
// that apart from a genre that merely happens to be in effect.
|
|
152
|
+
const typed = {};
|
|
115
153
|
|
|
116
154
|
let noChime = false;
|
|
117
155
|
let noHud = false;
|
|
@@ -124,6 +162,7 @@ function parseArgs(argv) {
|
|
|
124
162
|
let hookAction = null;
|
|
125
163
|
let mcp = false;
|
|
126
164
|
let reactive = false;
|
|
165
|
+
let here_flag = false;
|
|
127
166
|
let dryRun = false;
|
|
128
167
|
let tools = null;
|
|
129
168
|
let cmdArgs = [];
|
|
@@ -150,6 +189,7 @@ function parseArgs(argv) {
|
|
|
150
189
|
if (arg === "-g" || arg === "--genre") {
|
|
151
190
|
if (i + 1 < args.length) {
|
|
152
191
|
genre = args[i + 1].toLowerCase();
|
|
192
|
+
typed.genre = genre;
|
|
153
193
|
i += 2;
|
|
154
194
|
continue;
|
|
155
195
|
}
|
|
@@ -158,6 +198,7 @@ function parseArgs(argv) {
|
|
|
158
198
|
if (arg === "-v" || arg === "--volume") {
|
|
159
199
|
if (i + 1 < args.length) {
|
|
160
200
|
volume = normalizeVolume(args[i + 1], volume);
|
|
201
|
+
typed.volume = volume;
|
|
161
202
|
i += 2;
|
|
162
203
|
continue;
|
|
163
204
|
}
|
|
@@ -166,6 +207,7 @@ function parseArgs(argv) {
|
|
|
166
207
|
if (arg === "-cv" || arg === "--chime-volume") {
|
|
167
208
|
if (i + 1 < args.length) {
|
|
168
209
|
chimeVolume = normalizeVolume(args[i + 1], chimeVolume);
|
|
210
|
+
typed.chimeVolume = chimeVolume;
|
|
169
211
|
i += 2;
|
|
170
212
|
continue;
|
|
171
213
|
}
|
|
@@ -176,6 +218,7 @@ function parseArgs(argv) {
|
|
|
176
218
|
const parsed = parseInt(args[i + 1], 10);
|
|
177
219
|
if (!isNaN(parsed)) {
|
|
178
220
|
grace = Math.max(0, parsed);
|
|
221
|
+
typed.grace = grace;
|
|
179
222
|
}
|
|
180
223
|
i += 2;
|
|
181
224
|
continue;
|
|
@@ -239,6 +282,12 @@ function parseArgs(argv) {
|
|
|
239
282
|
continue;
|
|
240
283
|
}
|
|
241
284
|
|
|
285
|
+
if (arg === "--here") {
|
|
286
|
+
here_flag = true;
|
|
287
|
+
i += 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
242
291
|
if (arg === "--reactive") {
|
|
243
292
|
reactive = true;
|
|
244
293
|
i += 1;
|
|
@@ -259,18 +308,21 @@ function parseArgs(argv) {
|
|
|
259
308
|
|
|
260
309
|
if (arg === "--whisper") {
|
|
261
310
|
volume = 0.15;
|
|
311
|
+
typed.volume = volume;
|
|
262
312
|
i += 1;
|
|
263
313
|
continue;
|
|
264
314
|
}
|
|
265
315
|
|
|
266
316
|
if (arg === "--quiet") {
|
|
267
317
|
volume = 0.25;
|
|
318
|
+
typed.volume = volume;
|
|
268
319
|
i += 1;
|
|
269
320
|
continue;
|
|
270
321
|
}
|
|
271
322
|
|
|
272
323
|
if (arg === "--loud") {
|
|
273
324
|
volume = 0.75;
|
|
325
|
+
typed.volume = volume;
|
|
274
326
|
i += 1;
|
|
275
327
|
continue;
|
|
276
328
|
}
|
|
@@ -313,11 +365,22 @@ function parseArgs(argv) {
|
|
|
313
365
|
}
|
|
314
366
|
|
|
315
367
|
if (!isKnownGenre(genre)) {
|
|
368
|
+
// A typo that would be SAVED is a different thing from a typo on a run:
|
|
369
|
+
// falling back to lofi here would quietly overwrite the default the user
|
|
370
|
+
// already had, on the strength of a misspelling. Refuse and change nothing.
|
|
371
|
+
if (typed.genre !== undefined && cmdArgs.length === 0) {
|
|
372
|
+
console.error(
|
|
373
|
+
`\x1b[31m[vibeaudio] Unknown genre '${genre}' — nothing saved.\x1b[0m\n` +
|
|
374
|
+
` Available: ${AVAILABLE_GENRES.join(", ")}, random`
|
|
375
|
+
);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
316
378
|
console.error(
|
|
317
379
|
`\x1b[33m[vibeaudio] Unknown genre '${genre}' — falling back to lofi.\x1b[0m\n` +
|
|
318
380
|
` Available: ${AVAILABLE_GENRES.join(", ")}, random`
|
|
319
381
|
);
|
|
320
382
|
genre = "lofi";
|
|
383
|
+
if (typed.genre) typed.genre = "lofi";
|
|
321
384
|
}
|
|
322
385
|
|
|
323
386
|
return {
|
|
@@ -338,6 +401,8 @@ function parseArgs(argv) {
|
|
|
338
401
|
reactive,
|
|
339
402
|
dryRun,
|
|
340
403
|
tools,
|
|
404
|
+
typed,
|
|
405
|
+
hereOnly: here_flag,
|
|
341
406
|
cmdArgs
|
|
342
407
|
};
|
|
343
408
|
}
|
|
@@ -382,9 +447,44 @@ function resolveTargets(explicit) {
|
|
|
382
447
|
* The one install path. Both --install-hooks and the menu's hooks branch call
|
|
383
448
|
* this, so the two entry points can't drift in what they write or report.
|
|
384
449
|
*/
|
|
385
|
-
function installHookTargets(ids, genre, volume, reactive, dryRun = false) {
|
|
450
|
+
function installHookTargets(ids, genre, volume, reactive, dryRun = false, typed = {}) {
|
|
386
451
|
const hooks = require("./hooks");
|
|
452
|
+
|
|
453
|
+
// Migrating off a pinned install must not change what is playing: those
|
|
454
|
+
// entries carry the settings the user has actually been hearing, while the
|
|
455
|
+
// config file may not exist yet - so an untyped reinstall inherits from the
|
|
456
|
+
// entries it is about to replace.
|
|
457
|
+
//
|
|
458
|
+
// A pin fills a gap and never overrides. Once a setting is saved, somebody
|
|
459
|
+
// chose it, and a legacy pin left in some other agent's config is the older
|
|
460
|
+
// fact of the two - without this, installing for every detected agent lets
|
|
461
|
+
// four untouched installs outvote the genre the user picked a minute ago.
|
|
462
|
+
const saved = loadConfig();
|
|
463
|
+
const inherited = [];
|
|
464
|
+
const gap = (key) => typed[key] === undefined && saved[key] === undefined;
|
|
465
|
+
|
|
466
|
+
if (gap("genre") || gap("volume")) {
|
|
467
|
+
const pinned = pinnedSettingsFor(ids);
|
|
468
|
+
if (gap("genre") && pinned.genre && pinned.genre !== genre) {
|
|
469
|
+
genre = pinned.genre;
|
|
470
|
+
inherited.push(`genre ${genre}`);
|
|
471
|
+
}
|
|
472
|
+
if (gap("volume") && pinned.volume !== null && pinned.volume !== volume) {
|
|
473
|
+
volume = pinned.volume;
|
|
474
|
+
inherited.push(`volume ${Math.round(volume * 100)}%`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
387
478
|
if (dryRun) console.log(`\x1b[1mDry run\x1b[0m — nothing below is written.\n`);
|
|
479
|
+
if (inherited.length) {
|
|
480
|
+
console.log(`\x1b[90mKeeping your current ${inherited.join(" and ")} — pass --genre/--volume to change it.\x1b[0m`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// The hook entries no longer carry the genre and volume, so this is where
|
|
484
|
+
// they live. Saved here rather than inside installHooks(), which stays a
|
|
485
|
+
// pure config edit that tests can point at a throwaway file - the same
|
|
486
|
+
// reason the slash command and stopDaemon() are driven from this layer.
|
|
487
|
+
if (!dryRun) saveConfig({ genre, volume: Math.round(volume * 100) });
|
|
388
488
|
|
|
389
489
|
for (const id of ids) {
|
|
390
490
|
const result = hooks.installHooks(genre, volume, null, { reactive, id, dryRun });
|
|
@@ -424,6 +524,7 @@ function installHookTargets(ids, genre, volume, reactive, dryRun = false) {
|
|
|
424
524
|
// verified for each, so an open session picks this up on its next prompt.
|
|
425
525
|
const live = ids.filter((id) => hooks.TARGETS[id].liveReload !== false).map((id) => hooks.TARGETS[id].name);
|
|
426
526
|
if (live.length) console.log(` ${live.join(", ")}: takes effect on your next prompt - no restart needed.`);
|
|
527
|
+
console.log(` \x1b[90mChange the sound any time with: vibe --genre <name> --volume <n> — no reinstall.\x1b[0m`);
|
|
427
528
|
if (ids.length > 1) {
|
|
428
529
|
console.log(` \x1b[90mOne player is shared: whichever agent you prompt last owns the music.\x1b[0m`);
|
|
429
530
|
}
|
|
@@ -515,9 +616,24 @@ function printStatus() {
|
|
|
515
616
|
console.log(`\x1b[33m⏸ Muted by VIBE_DISABLE=${process.env.VIBE_DISABLE}\x1b[0m — automatic playback is off (--preview still works).\n`);
|
|
516
617
|
}
|
|
517
618
|
|
|
619
|
+
// What will actually play, and which of the three possible sources decided
|
|
620
|
+
// it - the usual confusion is a saved default quietly beaten by an export.
|
|
621
|
+
const saved = loadConfig();
|
|
622
|
+
const here = projectSettings(saved);
|
|
623
|
+
const source = (env, key) =>
|
|
624
|
+
process.env[env] ? off(` from ${env}`)
|
|
625
|
+
: here[key] !== undefined ? off(" saved for this project")
|
|
626
|
+
: saved[key] !== undefined ? off(" saved")
|
|
627
|
+
: off(" default");
|
|
628
|
+
|
|
629
|
+
console.log(`\x1b[1mSound\x1b[0m`);
|
|
630
|
+
console.log(` genre ${on(genreNow())}${source("VIBE_GENRE", "genre")}`);
|
|
631
|
+
console.log(` volume ${on(`${Math.round(volumeNow() * 100)}%`)}${source("VIBE_VOLUME", "volume")}`);
|
|
632
|
+
console.log(` ${off("change either with: vibe --genre <name> --volume <n>")}`);
|
|
633
|
+
|
|
518
634
|
// Audio backend
|
|
519
635
|
const backend = detect();
|
|
520
|
-
console.log(`\x1b[1mAudio\x1b[0m`);
|
|
636
|
+
console.log(`\n\x1b[1mAudio\x1b[0m`);
|
|
521
637
|
console.log(backend
|
|
522
638
|
? ` player ${on(backend.cmd)}${backend.volume ? "" : off(" (no volume support — gain is baked into the file)")}`
|
|
523
639
|
: ` player \x1b[31mnone found — VibeAudio runs silently\x1b[0m`);
|
|
@@ -539,28 +655,47 @@ function printStatus() {
|
|
|
539
655
|
const why = detected.includes(id)
|
|
540
656
|
? `${off("not installed")} — run: vibe --install-hooks`
|
|
541
657
|
: `${off("not installed")} ${off("(not found on this machine)")}`;
|
|
542
|
-
console.log(` ${t.name.padEnd(
|
|
658
|
+
console.log(` ${t.name.padEnd(20)}${why}`);
|
|
543
659
|
continue;
|
|
544
660
|
}
|
|
545
661
|
|
|
546
662
|
console.log(` ${t.name}`);
|
|
547
663
|
for (const { event, command } of installed) {
|
|
664
|
+
// A baked genre means an install from before settings moved into the
|
|
665
|
+
// config file: worth showing, because it still overrides that file.
|
|
548
666
|
const g = /--genre (\S+)/.exec(command);
|
|
549
667
|
const vol = /--volume (\d+)/.exec(command);
|
|
550
|
-
const
|
|
668
|
+
const parts = [];
|
|
669
|
+
if (g) parts.push(`pinned ${g[1]} @ ${vol ? vol[1] : "?"}%`);
|
|
670
|
+
if (/--reactive/.test(command)) parts.push("reactive");
|
|
671
|
+
const extra = parts.length ? ` ${parts.join(", ")}` : "";
|
|
551
672
|
console.log(` ${on("✔")} ${extra ? event.padEnd(19) : event}${extra ? off(extra) : ""}`);
|
|
552
673
|
}
|
|
674
|
+
if (readVibeHooks(t.file(), t).some(({ command }) => /--genre /.test(command))) {
|
|
675
|
+
console.log(` \x1b[33m↑ pinned by an older install — vibe --install-hooks makes it follow Sound above\x1b[0m`);
|
|
676
|
+
}
|
|
553
677
|
}
|
|
554
678
|
|
|
555
679
|
// Background player
|
|
556
680
|
console.log(`\n\x1b[1mBackground player\x1b[0m`);
|
|
557
681
|
const pid = readDaemonPid(hooks.PID_FILE);
|
|
682
|
+
const playing = hooks.daemonPlaying();
|
|
558
683
|
if (pid === null) {
|
|
559
684
|
console.log(` ${off("not running")}`);
|
|
560
|
-
} else if (!
|
|
685
|
+
} else if (!playing) {
|
|
561
686
|
console.log(` ${off(`stale pid file (${pid} is not ours) — cleared on the next prompt`)}`);
|
|
562
687
|
} else {
|
|
563
|
-
|
|
688
|
+
// What it was started with, which is not necessarily what is saved: a
|
|
689
|
+
// daemon keeps its settings until the next prompt replaces it, and
|
|
690
|
+
// "I changed the genre and nothing happened" is that gap.
|
|
691
|
+
const now = playing.genre
|
|
692
|
+
? ` ${on(playing.genre)}${playing.volume === null ? "" : on(` @ ${playing.volume}%`)}${playing.reactive ? off(", reactive") : ""}`
|
|
693
|
+
: "";
|
|
694
|
+
console.log(` ${on("playing")}${now}${off(` pid ${pid}`)}`);
|
|
695
|
+
if (playing.genre && playing.genre !== genreNow()) {
|
|
696
|
+
console.log(` ${off(`your saved default is ${genreNow()} — this one keeps playing until the next prompt`)}`);
|
|
697
|
+
}
|
|
698
|
+
console.log(` ${off("stop it with: vibe --stop")}`);
|
|
564
699
|
}
|
|
565
700
|
|
|
566
701
|
// Which tools are actually here, and what each one can use
|
|
@@ -576,6 +711,24 @@ function printStatus() {
|
|
|
576
711
|
console.log();
|
|
577
712
|
}
|
|
578
713
|
|
|
714
|
+
/**
|
|
715
|
+
* The effective genre and volume, resolved the same way parseArgs does it, so
|
|
716
|
+
* --status reports what would actually play rather than a second opinion.
|
|
717
|
+
*/
|
|
718
|
+
function settingNow(key) {
|
|
719
|
+
const config = loadConfig();
|
|
720
|
+
const here = projectSettings(config);
|
|
721
|
+
return here[key] !== undefined ? here[key] : config[key];
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function genreNow() {
|
|
725
|
+
return (process.env.VIBE_GENRE || settingNow("genre") || "lofi").toLowerCase();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function volumeNow() {
|
|
729
|
+
return normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(settingNow("volume"), 0.40));
|
|
730
|
+
}
|
|
731
|
+
|
|
579
732
|
function dirSize(dir) {
|
|
580
733
|
try {
|
|
581
734
|
let total = 0;
|
|
@@ -646,6 +799,107 @@ function detectAiTools(hooked = new Set()) {
|
|
|
646
799
|
return CANDIDATES.filter((c) => isInstalled(c.cmd));
|
|
647
800
|
}
|
|
648
801
|
|
|
802
|
+
/**
|
|
803
|
+
* `vibe --genre jazz` with no command after it. That used to open the launcher
|
|
804
|
+
* menu, which then asked for a genre and used its own answer - the most
|
|
805
|
+
* natural way to phrase the request did something else entirely, and the
|
|
806
|
+
* README had to warn about it. It saves the default instead.
|
|
807
|
+
*/
|
|
808
|
+
function saveDefaults(typed, hereOnly = false) {
|
|
809
|
+
const patch = {};
|
|
810
|
+
if (typed.genre !== undefined) patch.genre = typed.genre;
|
|
811
|
+
if (typed.volume !== undefined) patch.volume = Math.round(typed.volume * 100);
|
|
812
|
+
if (typed.chimeVolume !== undefined) patch.chimeVolume = Math.round(typed.chimeVolume * 100);
|
|
813
|
+
if (typed.grace !== undefined) patch.grace = typed.grace;
|
|
814
|
+
|
|
815
|
+
const { CONFIG_FILE } = require("./player");
|
|
816
|
+
if (hereOnly) saveProjectConfig(patch);
|
|
817
|
+
else saveConfig(patch);
|
|
818
|
+
|
|
819
|
+
const label = {
|
|
820
|
+
genre: (v) => `genre ${v}`,
|
|
821
|
+
volume: (v) => `volume ${v}%`,
|
|
822
|
+
chimeVolume: (v) => `chime volume ${v}%`,
|
|
823
|
+
grace: (v) => `grace ${v}ms`
|
|
824
|
+
};
|
|
825
|
+
const changed = Object.keys(patch).map((k) => label[k](patch[k])).join(", ");
|
|
826
|
+
const where = hereOnly ? ` for ${process.cwd()}` : "";
|
|
827
|
+
console.log(`\x1b[32m✔ Saved: ${changed}${where}\x1b[0m \x1b[90m(${CONFIG_FILE})\x1b[0m`);
|
|
828
|
+
console.log(` Applies to the wrapper, your agent hooks and MCP — on the next prompt.`);
|
|
829
|
+
if (hereOnly) console.log(` \x1b[90mThis directory and everything under it. Drop --here to change the default everywhere.\x1b[0m`);
|
|
830
|
+
|
|
831
|
+
// Music already in the air is the one thing this does not reach, and
|
|
832
|
+
// silence about it reads as the setting having done nothing at all.
|
|
833
|
+
const playing = require("./hooks").daemonPlaying();
|
|
834
|
+
if (playing && patch.genre && playing.genre && playing.genre !== patch.genre) {
|
|
835
|
+
console.log(` \x1b[90m${playing.genre} is still playing — vibe --stop cuts it short.\x1b[0m`);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// An install from before the settings moved out of the hook command line
|
|
839
|
+
// still carries its own genre, and a flag beats this file. Saying nothing
|
|
840
|
+
// would leave the user with a saved default that audibly does nothing.
|
|
841
|
+
const pinned = pinnedHookSettings();
|
|
842
|
+
if (pinned.length) {
|
|
843
|
+
console.log(
|
|
844
|
+
`\n\x1b[33m ${pinned.join(", ")}: hooks installed by an older version pin their own settings.\x1b[0m\n` +
|
|
845
|
+
` Run \x1b[1mvibe --install-hooks\x1b[0m once to have them follow this file instead.`
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
if (typed.genre !== undefined) console.log(`\n Audition it: vibe --preview ${typed.genre}`);
|
|
849
|
+
console.log();
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Hook targets whose installed entries still carry a baked --genre/--volume.
|
|
854
|
+
*/
|
|
855
|
+
/**
|
|
856
|
+
* What an install from before the config file pinned, for the first of `ids`
|
|
857
|
+
* that pinned anything.
|
|
858
|
+
*
|
|
859
|
+
* A reinstall is how a user moves off that older scheme, and it must not be
|
|
860
|
+
* the moment their music changes: the settings frozen into those entries are
|
|
861
|
+
* the ones they have actually been listening to, and the config file may hold
|
|
862
|
+
* nothing but an untouched default. Adopted only for what the user did not
|
|
863
|
+
* type on this command line - naming a genre still means that genre.
|
|
864
|
+
*/
|
|
865
|
+
function pinnedSettingsFor(ids) {
|
|
866
|
+
const hooks = require("./hooks");
|
|
867
|
+
const genres = new Set();
|
|
868
|
+
const volumes = new Set();
|
|
869
|
+
|
|
870
|
+
for (const id of ids) {
|
|
871
|
+
const t = hooks.TARGETS[id];
|
|
872
|
+
if (!t) continue;
|
|
873
|
+
for (const { command } of readVibeHooks(t.file(), t)) {
|
|
874
|
+
const genre = /--genre (\S+)/.exec(command);
|
|
875
|
+
const volume = /--volume (\d+)/.exec(command);
|
|
876
|
+
if (genre && isKnownGenre(genre[1])) genres.add(genre[1]);
|
|
877
|
+
if (volume) volumes.add(normalizeVolume(volume[1], null));
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Only when they agree. The saved config is one value for the machine, so
|
|
882
|
+
// two agents pinned to different genres have no single answer to inherit -
|
|
883
|
+
// and taking whichever was found first would let an untouched install for
|
|
884
|
+
// some other tool silently overwrite the genre the user actually chose.
|
|
885
|
+
return {
|
|
886
|
+
genre: genres.size === 1 ? [...genres][0] : null,
|
|
887
|
+
volume: volumes.size === 1 ? [...volumes][0] : null
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function pinnedHookSettings() {
|
|
892
|
+
const hooks = require("./hooks");
|
|
893
|
+
const names = [];
|
|
894
|
+
for (const id of Object.keys(hooks.TARGETS)) {
|
|
895
|
+
const t = hooks.TARGETS[id];
|
|
896
|
+
if (readVibeHooks(t.file(), t).some(({ command }) => /--genre |--volume /.test(command))) {
|
|
897
|
+
names.push(t.name);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return names;
|
|
901
|
+
}
|
|
902
|
+
|
|
649
903
|
function signalExitCode(signal) {
|
|
650
904
|
return 128 + (os.constants.signals[signal] || 0);
|
|
651
905
|
}
|
|
@@ -670,7 +924,7 @@ function previewGenre(genre, volume) {
|
|
|
670
924
|
spawnSync(backend.cmd, backend.args(audioFile, volume), { stdio: "ignore" });
|
|
671
925
|
}
|
|
672
926
|
|
|
673
|
-
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun }) {
|
|
927
|
+
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun, typed = {} }) {
|
|
674
928
|
const hooks = require("./hooks");
|
|
675
929
|
|
|
676
930
|
switch (action) {
|
|
@@ -715,7 +969,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
|
|
|
715
969
|
return;
|
|
716
970
|
|
|
717
971
|
case "install-hooks":
|
|
718
|
-
return installHookTargets(resolveTargets(tools), genre, volume, reactive, dryRun);
|
|
972
|
+
return installHookTargets(resolveTargets(tools), genre, volume, reactive, dryRun, typed);
|
|
719
973
|
|
|
720
974
|
case "uninstall-hooks":
|
|
721
975
|
return uninstallHookTargets();
|
|
@@ -877,6 +1131,8 @@ async function run() {
|
|
|
877
1131
|
reactive,
|
|
878
1132
|
dryRun,
|
|
879
1133
|
tools,
|
|
1134
|
+
typed,
|
|
1135
|
+
hereOnly,
|
|
880
1136
|
cmdArgs
|
|
881
1137
|
} = parseArgs(process.argv);
|
|
882
1138
|
|
|
@@ -890,7 +1146,7 @@ async function run() {
|
|
|
890
1146
|
|
|
891
1147
|
if (hookAction) {
|
|
892
1148
|
try {
|
|
893
|
-
return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun });
|
|
1149
|
+
return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun, typed });
|
|
894
1150
|
} catch (e) {
|
|
895
1151
|
// Settings problems are the user's to fix — report them, don't stack-trace.
|
|
896
1152
|
console.error(`\x1b[31m[vibeaudio] ${e.message}\x1b[0m`);
|
|
@@ -951,6 +1207,15 @@ async function run() {
|
|
|
951
1207
|
return previewGenre(preview, volume);
|
|
952
1208
|
}
|
|
953
1209
|
|
|
1210
|
+
// Settings typed with no command to run: save them, don't open the launcher.
|
|
1211
|
+
if (cmdArgs.length === 0 && Object.keys(typed).length > 0) {
|
|
1212
|
+
return saveDefaults(typed, hereOnly);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (hereOnly) {
|
|
1216
|
+
console.error("\x1b[33m[vibeaudio] --here only applies when saving a setting; ignoring it.\x1b[0m");
|
|
1217
|
+
}
|
|
1218
|
+
|
|
954
1219
|
if (cmdArgs.length === 0) {
|
|
955
1220
|
if (!process.stdin.isTTY) {
|
|
956
1221
|
printHelp();
|
|
@@ -979,7 +1244,8 @@ async function run() {
|
|
|
979
1244
|
// Either way, say so and still launch the tool the user asked for -
|
|
980
1245
|
// they came here to start an agent, not to configure one.
|
|
981
1246
|
try {
|
|
982
|
-
installHookTargets([selection.hookTarget], selection.genre, chosenVol, selection.reactive
|
|
1247
|
+
installHookTargets([selection.hookTarget], selection.genre, chosenVol, selection.reactive, false,
|
|
1248
|
+
{ genre: selection.genre, volume: chosenVol });
|
|
983
1249
|
} catch (err) {
|
|
984
1250
|
console.error(`\x1b[31m[vibeaudio] ${err.message}\x1b[0m`);
|
|
985
1251
|
}
|
package/src/hooks.js
CHANGED
|
@@ -345,19 +345,49 @@ function readPid() {
|
|
|
345
345
|
* ponytail: posix only. Windows has no cheap command-line lookup, so the pid
|
|
346
346
|
* is trusted there as before; revisit if hooks see real Windows use.
|
|
347
347
|
*/
|
|
348
|
-
function
|
|
349
|
-
if (process.platform === "win32") return
|
|
348
|
+
function daemonArgv(pid) {
|
|
349
|
+
if (process.platform === "win32") return null;
|
|
350
350
|
try {
|
|
351
|
-
|
|
351
|
+
return execFileSync("ps", ["-p", String(pid), "-o", "args="], {
|
|
352
352
|
encoding: "utf8",
|
|
353
353
|
stdio: ["ignore", "pipe", "ignore"]
|
|
354
354
|
});
|
|
355
|
-
return out.includes("vibeaudio") && out.includes("--daemon");
|
|
356
355
|
} catch (e) {
|
|
357
|
-
return
|
|
356
|
+
return null; // No such process, or ps unavailable.
|
|
358
357
|
}
|
|
359
358
|
}
|
|
360
359
|
|
|
360
|
+
function isOurDaemon(pid) {
|
|
361
|
+
if (process.platform === "win32") return true;
|
|
362
|
+
const out = daemonArgv(pid);
|
|
363
|
+
// Failing closed: no argv means no kill.
|
|
364
|
+
return out !== null && out.includes("vibeaudio") && out.includes("--daemon");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* What the background player is playing right now, or null if nothing is.
|
|
369
|
+
*
|
|
370
|
+
* Read from the daemon's own command line rather than a file it would have to
|
|
371
|
+
* keep in step - spawnDaemon() puts the settings there already, and a process
|
|
372
|
+
* that dies without cleanup cannot leave a stale answer behind. It is the
|
|
373
|
+
* settings *in flight*, which is the question a running daemon raises: a
|
|
374
|
+
* genre saved a moment ago does not reach it until the next prompt.
|
|
375
|
+
*/
|
|
376
|
+
function daemonPlaying() {
|
|
377
|
+
const pid = readPid();
|
|
378
|
+
if (pid === null || !isOurDaemon(pid)) return null;
|
|
379
|
+
|
|
380
|
+
const argv = daemonArgv(pid) || "";
|
|
381
|
+
const genre = /--genre (\S+)/.exec(argv);
|
|
382
|
+
const volume = /--volume (\d+)/.exec(argv);
|
|
383
|
+
return {
|
|
384
|
+
pid,
|
|
385
|
+
genre: genre ? genre[1] : null,
|
|
386
|
+
volume: volume ? Number(volume[1]) : null,
|
|
387
|
+
reactive: /--reactive/.test(argv)
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
361
391
|
/**
|
|
362
392
|
* Stops the player. `keepSessions` leaves the session files alone: the hooks
|
|
363
393
|
* use it because they decide per session what is still going on. Every other
|
|
@@ -781,13 +811,24 @@ function shellQuote(value) {
|
|
|
781
811
|
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
782
812
|
}
|
|
783
813
|
|
|
814
|
+
/**
|
|
815
|
+
* Genre and volume are deliberately NOT written into the command line.
|
|
816
|
+
*
|
|
817
|
+
* They used to be, and that is what made "change the genre" mean "re-run the
|
|
818
|
+
* installer": a value frozen into seven hook entries at install time, which no
|
|
819
|
+
* later `vibe --genre jazz` and no exported variable could reach. Each hook
|
|
820
|
+
* fires as a fresh process and parses its own arguments, so leaving them out
|
|
821
|
+
* means it reads the saved config (see loadConfig() in player.js) on every
|
|
822
|
+
* prompt - and a genre change lands on the next one, with nothing reinstalled.
|
|
823
|
+
*
|
|
824
|
+
* `--reactive` stays, because it is not a setting the hook reads: it decides
|
|
825
|
+
* which hooks exist at all, and the PreToolUse entry is written or removed to
|
|
826
|
+
* match. A daemon respawned by a resume has to know it too.
|
|
827
|
+
*/
|
|
784
828
|
function hookCommand(flag, genre, volume, reactive = false) {
|
|
785
829
|
const base = `${shellQuote(process.execPath)} ${shellQuote(CLI_ENTRY)} ${flag}`;
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
const start = `${base} --genre ${genre} --volume ${Math.round(volume * 100)}`;
|
|
790
|
-
return reactive ? `${start} --reactive` : start;
|
|
830
|
+
if (!reactive) return base;
|
|
831
|
+
return flag === "--hook-start" || flag === "--hook-resume" ? `${base} --reactive` : base;
|
|
791
832
|
}
|
|
792
833
|
|
|
793
834
|
const VIBE_HOOK_FLAG = /--hook-(start|stop|tool|wait|resume|end)\b/;
|
|
@@ -973,10 +1014,10 @@ single short sentence. Do nothing else.
|
|
|
973
1014
|
- \`mute\` or \`mute <minutes>\`: \`${cli} --mute <minutes>\`
|
|
974
1015
|
- \`unmute\`: \`${cli} --unmute\`
|
|
975
1016
|
- \`stop\`: \`${cli} --stop\`
|
|
976
|
-
- \`genre <name
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1017
|
+
- \`genre <name>\`: \`${cli} --genre <name>\`
|
|
1018
|
+
- \`volume <5-100>\`: \`${cli} --volume <n>\`
|
|
1019
|
+
Both save the user's default and reach the hooks on the next prompt - there
|
|
1020
|
+
is nothing to reinstall, so do not run --install-hooks for these.
|
|
980
1021
|
Genres: lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, random.
|
|
981
1022
|
|
|
982
1023
|
For anything else, show the user the list above instead of running a command.
|
|
@@ -1046,6 +1087,7 @@ module.exports = {
|
|
|
1046
1087
|
readIntensity,
|
|
1047
1088
|
stopDaemon,
|
|
1048
1089
|
isOurDaemon,
|
|
1090
|
+
daemonPlaying,
|
|
1049
1091
|
installHooks,
|
|
1050
1092
|
ephemeralInstallReason,
|
|
1051
1093
|
uninstallHooks,
|