vibeaudio 0.4.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 +140 -49
- package/package.json +7 -5
- package/scripts/postinstall.js +33 -0
- package/src/cli.js +286 -19
- package/src/hooks.js +217 -93
- package/src/interactive.js +56 -4
- package/src/mcp.js +9 -3
- package/src/player.js +102 -2
- package/src/synth/electronic.js +7 -2
- package/src/synth/generator.js +27 -0
- package/src/synth/synthwave.js +3 -2
package/src/hooks.js
CHANGED
|
@@ -20,10 +20,12 @@ const { AudioPlayer } = require("./player");
|
|
|
20
20
|
const STATE_DIR = path.join(os.homedir(), ".vibeaudio");
|
|
21
21
|
const PID_FILE = path.join(STATE_DIR, "daemon.pid");
|
|
22
22
|
const INTENSITY_FILE = path.join(STATE_DIR, "intensity");
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
const
|
|
23
|
+
// One file per agent session with a turn in flight. The music plays while any of
|
|
24
|
+
// them is working; each holds where its transcript began and, while paused for
|
|
25
|
+
// the user, what will resume it.
|
|
26
|
+
const SESSIONS_DIR = path.join(STATE_DIR, "sessions");
|
|
27
|
+
// Payloads that name no session share this one slot.
|
|
28
|
+
const ANON_SESSION = "anon";
|
|
27
29
|
const CLI_ENTRY = path.join(__dirname, "..", "bin", "vibeaudio.js");
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -343,29 +345,59 @@ function readPid() {
|
|
|
343
345
|
* ponytail: posix only. Windows has no cheap command-line lookup, so the pid
|
|
344
346
|
* is trusted there as before; revisit if hooks see real Windows use.
|
|
345
347
|
*/
|
|
346
|
-
function
|
|
347
|
-
if (process.platform === "win32") return
|
|
348
|
+
function daemonArgv(pid) {
|
|
349
|
+
if (process.platform === "win32") return null;
|
|
348
350
|
try {
|
|
349
|
-
|
|
351
|
+
return execFileSync("ps", ["-p", String(pid), "-o", "args="], {
|
|
350
352
|
encoding: "utf8",
|
|
351
353
|
stdio: ["ignore", "pipe", "ignore"]
|
|
352
354
|
});
|
|
353
|
-
return out.includes("vibeaudio") && out.includes("--daemon");
|
|
354
355
|
} catch (e) {
|
|
355
|
-
return
|
|
356
|
+
return null; // No such process, or ps unavailable.
|
|
356
357
|
}
|
|
357
358
|
}
|
|
358
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
|
+
|
|
359
391
|
/**
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
* uninstall) ends
|
|
363
|
-
* waiting for.
|
|
392
|
+
* Stops the player. `keepSessions` leaves the session files alone: the hooks
|
|
393
|
+
* use it because they decide per session what is still going on. Every other
|
|
394
|
+
* stop (--stop, --mute, uninstall) ends everything, or a later tool call would
|
|
395
|
+
* resume music nobody is waiting for.
|
|
364
396
|
*/
|
|
365
|
-
function stopDaemon({
|
|
397
|
+
function stopDaemon({ keepSessions = false } = {}) {
|
|
366
398
|
const pid = readPid();
|
|
367
399
|
fs.rmSync(PID_FILE, { force: true });
|
|
368
|
-
if (!
|
|
400
|
+
if (!keepSessions) fs.rmSync(SESSIONS_DIR, { recursive: true, force: true });
|
|
369
401
|
if (pid === null || !isOurDaemon(pid)) return false;
|
|
370
402
|
|
|
371
403
|
try {
|
|
@@ -376,11 +408,6 @@ function stopDaemon({ pause = false } = {}) {
|
|
|
376
408
|
}
|
|
377
409
|
}
|
|
378
410
|
|
|
379
|
-
function endTurnState() {
|
|
380
|
-
fs.rmSync(WAITING_FILE, { force: true });
|
|
381
|
-
fs.rmSync(TURN_FILE, { force: true });
|
|
382
|
-
}
|
|
383
|
-
|
|
384
411
|
function fileSize(file) {
|
|
385
412
|
try {
|
|
386
413
|
return fs.statSync(file).size;
|
|
@@ -400,14 +427,60 @@ function newTurn(raw) {
|
|
|
400
427
|
return { session: String(payload.session_id || ""), transcript, offset: transcript ? fileSize(transcript) : 0 };
|
|
401
428
|
}
|
|
402
429
|
|
|
403
|
-
|
|
430
|
+
// The id ends up in a file name.
|
|
431
|
+
function sessionId(id) {
|
|
432
|
+
return String(id || ANON_SESSION).replace(/[^\w.-]/g, "_").slice(0, 100);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function sessionFile(id) {
|
|
436
|
+
return path.join(SESSIONS_DIR, `${id}.json`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function readSession(id) {
|
|
404
440
|
try {
|
|
405
|
-
return JSON.parse(fs.readFileSync(
|
|
441
|
+
return JSON.parse(fs.readFileSync(sessionFile(id), "utf8"));
|
|
406
442
|
} catch (e) {
|
|
407
443
|
return null;
|
|
408
444
|
}
|
|
409
445
|
}
|
|
410
446
|
|
|
447
|
+
// Written beside the target and renamed into place: the daemon lists this
|
|
448
|
+
// directory every poll and must never read half a file.
|
|
449
|
+
function writeSession(id, data) {
|
|
450
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
451
|
+
const tmp = `${sessionFile(id)}.${process.pid}.tmp`;
|
|
452
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...data, ts: Date.now() }));
|
|
453
|
+
fs.renameSync(tmp, sessionFile(id));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Every session with a turn in flight. One that has not been touched for
|
|
458
|
+
* MAX_DAEMON_MS is a crashed agent - it never sent Stop - and is dropped here,
|
|
459
|
+
* the same ceiling the daemon itself stops at.
|
|
460
|
+
*/
|
|
461
|
+
function listSessions() {
|
|
462
|
+
let names;
|
|
463
|
+
try {
|
|
464
|
+
names = fs.readdirSync(SESSIONS_DIR);
|
|
465
|
+
} catch (e) {
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
const now = Date.now();
|
|
469
|
+
const sessions = [];
|
|
470
|
+
for (const name of names) {
|
|
471
|
+
if (!name.endsWith(".json")) continue;
|
|
472
|
+
const id = name.slice(0, -".json".length);
|
|
473
|
+
const data = readSession(id);
|
|
474
|
+
if (!data) continue;
|
|
475
|
+
if (now - (data.ts || 0) > MAX_DAEMON_MS) fs.rmSync(sessionFile(id), { force: true });
|
|
476
|
+
else sessions.push({ id, ...data });
|
|
477
|
+
}
|
|
478
|
+
return sessions;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Working, as opposed to paused for the user.
|
|
482
|
+
const working = (sessions) => sessions.filter((s) => s.waiting == null);
|
|
483
|
+
|
|
411
484
|
// Claude Code's entry for Esc / the stop button: a user message whose text is
|
|
412
485
|
// "[Request interrupted by user]" or "... for tool use]".
|
|
413
486
|
const INTERRUPT_MARK = "[Request interrupted by user";
|
|
@@ -466,29 +539,47 @@ function interruptWatcher(transcript, offset) {
|
|
|
466
539
|
const INTERRUPT_POLL_MS = 500;
|
|
467
540
|
|
|
468
541
|
/**
|
|
469
|
-
* Internal mode:
|
|
470
|
-
*
|
|
542
|
+
* Internal mode: plays while any session is working. The player's own loop
|
|
543
|
+
* timer keeps the event loop alive.
|
|
471
544
|
*/
|
|
472
545
|
function runDaemon(genre, volume, { reactive = false } = {}) {
|
|
473
|
-
const
|
|
474
|
-
|
|
546
|
+
const watchers = new Map(); // session id -> its transcript poll
|
|
547
|
+
|
|
548
|
+
// Drops sessions whose transcript shows an interrupt (Esc fires no hook, so
|
|
549
|
+
// this is the only way to learn of one) and says whether any is still working.
|
|
550
|
+
const sweep = () => {
|
|
551
|
+
const sessions = listSessions();
|
|
552
|
+
for (const id of watchers.keys()) if (!sessions.some((s) => s.id === id)) watchers.delete(id);
|
|
553
|
+
for (const s of sessions) {
|
|
554
|
+
if (!s.transcript) continue;
|
|
555
|
+
// Built from the offset taken at the prompt, so a daemon respawned mid-turn
|
|
556
|
+
// still sees an interrupt that came before it.
|
|
557
|
+
if (!watchers.has(s.id)) watchers.set(s.id, interruptWatcher(s.transcript, s.offset));
|
|
558
|
+
if (watchers.get(s.id)()) {
|
|
559
|
+
fs.rmSync(sessionFile(s.id), { force: true });
|
|
560
|
+
watchers.delete(s.id);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return working(listSessions()).length > 0;
|
|
564
|
+
};
|
|
475
565
|
|
|
476
566
|
// Silent, like Ctrl+C in the wrapper: an interrupt is never reported as done.
|
|
477
|
-
const
|
|
567
|
+
const endIdle = () => {
|
|
478
568
|
if (readPid() === process.pid) {
|
|
479
569
|
fs.rmSync(PID_FILE, { force: true });
|
|
480
|
-
endTurnState();
|
|
481
570
|
fs.rmSync(INTENSITY_FILE, { force: true });
|
|
482
571
|
}
|
|
483
572
|
process.exit(0);
|
|
484
573
|
};
|
|
485
|
-
// Already interrupted before this daemon started
|
|
486
|
-
// PostToolUse that an interrupted tool still fires.
|
|
487
|
-
if (interrupted && interrupted()) endInterrupted();
|
|
574
|
+
if (!sweep()) endIdle(); // Already interrupted before this daemon started.
|
|
488
575
|
|
|
489
576
|
const player = new AudioPlayer();
|
|
490
577
|
const started = player.start(genre, volume, {
|
|
491
|
-
intensity: reactive ? readIntensity : null
|
|
578
|
+
intensity: reactive ? readIntensity : null,
|
|
579
|
+
// The same piece with more of it, as time escalation already does: two
|
|
580
|
+
// sessions working is tier 2 at least, three or more is tier 3. Read at
|
|
581
|
+
// each loop boundary, so it lands cleanly and eases off as they finish.
|
|
582
|
+
minTier: () => Math.min(3, working(listSessions()).length)
|
|
492
583
|
});
|
|
493
584
|
if (!started) process.exit(0);
|
|
494
585
|
|
|
@@ -501,32 +592,46 @@ function runDaemon(genre, volume, { reactive = false } = {}) {
|
|
|
501
592
|
process.on("SIGINT", shutdown);
|
|
502
593
|
setTimeout(shutdown, MAX_DAEMON_MS);
|
|
503
594
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
}, INTERRUPT_POLL_MS);
|
|
510
|
-
}
|
|
595
|
+
setInterval(() => {
|
|
596
|
+
if (sweep()) return;
|
|
597
|
+
player.stop({ playChime: false });
|
|
598
|
+
endIdle();
|
|
599
|
+
}, INTERRUPT_POLL_MS);
|
|
511
600
|
}
|
|
512
601
|
|
|
513
|
-
function
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
// Before the spawn: the daemon reads it on startup.
|
|
518
|
-
if (turn) fs.writeFileSync(TURN_FILE, JSON.stringify(turn));
|
|
602
|
+
function daemonRunning() {
|
|
603
|
+
const pid = readPid();
|
|
604
|
+
return pid !== null && isOurDaemon(pid);
|
|
605
|
+
}
|
|
519
606
|
|
|
607
|
+
function spawnDaemon(genre, volume, reactive) {
|
|
520
608
|
const args = [CLI_ENTRY, "--daemon", "--genre", genre, "--volume", String(Math.round(volume * 100))];
|
|
521
609
|
if (reactive) args.push("--reactive");
|
|
522
610
|
|
|
523
611
|
const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
|
|
524
612
|
child.unref();
|
|
525
613
|
|
|
614
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
526
615
|
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
527
616
|
return child.pid;
|
|
528
617
|
}
|
|
529
618
|
|
|
619
|
+
/**
|
|
620
|
+
* A prompt. With no other session working this replaces the player, as it
|
|
621
|
+
* always did, so a genre change lands on the next prompt. With one working the
|
|
622
|
+
* music carries on: restarting it would cut every other session's stream.
|
|
623
|
+
*/
|
|
624
|
+
function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
|
|
625
|
+
const id = sessionId(turn && turn.session);
|
|
626
|
+
// Before the spawn: the daemon reads it on startup.
|
|
627
|
+
writeSession(id, { transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null });
|
|
628
|
+
if (working(listSessions()).some((s) => s.id !== id) && daemonRunning()) return readPid();
|
|
629
|
+
|
|
630
|
+
stopDaemon({ keepSessions: true });
|
|
631
|
+
fs.rmSync(INTENSITY_FILE, { force: true }); // Don't inherit the last prompt's activity
|
|
632
|
+
return spawnDaemon(genre, volume, reactive);
|
|
633
|
+
}
|
|
634
|
+
|
|
530
635
|
/**
|
|
531
636
|
* What the agent's stop event says about how the turn ended. Cursor reports
|
|
532
637
|
* it in `status` ("completed", "aborted" or "error"). Claude Code's Stop
|
|
@@ -574,27 +679,25 @@ function readPayload(done, timeoutMs = 500) {
|
|
|
574
679
|
setTimeout(finish, timeoutMs).unref();
|
|
575
680
|
}
|
|
576
681
|
|
|
577
|
-
function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false } = {}) {
|
|
578
|
-
|
|
579
|
-
// user - a denied tool that nothing
|
|
580
|
-
|
|
581
|
-
const
|
|
582
|
-
fs.rmSync(
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
//
|
|
682
|
+
function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, raw = "" } = {}) {
|
|
683
|
+
const id = sessionId(parsePayload(raw).session_id);
|
|
684
|
+
// A turn that ends while paused for the user - a denied tool that nothing
|
|
685
|
+
// resumed after - still finished, so its session counts either way.
|
|
686
|
+
const tracked = readSession(id) !== null;
|
|
687
|
+
fs.rmSync(sessionFile(id), { force: true });
|
|
688
|
+
|
|
689
|
+
// The music is every working session's, so it ends with the last of them.
|
|
690
|
+
// Everyone else's carries on, and this session still gets its own chime.
|
|
691
|
+
const others = working(listSessions()).length > 0;
|
|
692
|
+
const wasPlaying = others ? false : stopDaemon({ keepSessions: true });
|
|
693
|
+
if (!others) fs.rmSync(INTENSITY_FILE, { force: true });
|
|
694
|
+
if (!(tracked || wasPlaying) || noChime) return false;
|
|
695
|
+
|
|
696
|
+
// Chime plays in this short-lived hook process.
|
|
586
697
|
new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume });
|
|
587
698
|
return true;
|
|
588
699
|
}
|
|
589
700
|
|
|
590
|
-
function readWaiting() {
|
|
591
|
-
try {
|
|
592
|
-
return fs.readFileSync(WAITING_FILE, "utf8");
|
|
593
|
-
} catch (e) {
|
|
594
|
-
return null;
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
|
|
598
701
|
/**
|
|
599
702
|
* What a wait is waiting for, and what ends it: the tool's name for a
|
|
600
703
|
* permission dialog and its PostToolUse, the server's name for an MCP
|
|
@@ -618,11 +721,12 @@ const WAIT_NOTIFICATIONS = new Set([
|
|
|
618
721
|
* The agent is blocked on the user (a permission dialog, a question, a plan to
|
|
619
722
|
* approve, an MCP server asking for input). Music that keeps playing says
|
|
620
723
|
* "still working", which is the one thing that is not true, so it stops and
|
|
621
|
-
* the attention chime asks instead
|
|
724
|
+
* the attention chime asks instead - unless another session is still working,
|
|
725
|
+
* whose music is not this dialog's to end.
|
|
622
726
|
*
|
|
623
|
-
* Only
|
|
624
|
-
*
|
|
625
|
-
*
|
|
727
|
+
* Only for a turn in flight: after it has ended there is nobody to interrupt,
|
|
728
|
+
* and a second dialog while already waiting keeps the first wait rather than
|
|
729
|
+
* chiming twice.
|
|
626
730
|
*
|
|
627
731
|
* The chime is detached: the agent waits on this hook before showing the
|
|
628
732
|
* dialog, and a chime played to completion here held it back for its length.
|
|
@@ -632,8 +736,12 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
632
736
|
const type = payload.notification_type ?? payload.notificationType;
|
|
633
737
|
if (type !== undefined && !WAIT_NOTIFICATIONS.has(String(type))) return false;
|
|
634
738
|
|
|
635
|
-
|
|
636
|
-
|
|
739
|
+
const id = sessionId(payload.session_id);
|
|
740
|
+
const session = readSession(id);
|
|
741
|
+
if (!session || session.waiting != null) return false;
|
|
742
|
+
|
|
743
|
+
writeSession(id, { ...session, waiting: waitKey(raw) });
|
|
744
|
+
if (working(listSessions()).length === 0) stopDaemon({ keepSessions: true });
|
|
637
745
|
if (!noChime) new AudioPlayer().stop({ playChime: true, outcome: "attention", volume, chimeVolume, detach: true });
|
|
638
746
|
return true;
|
|
639
747
|
}
|
|
@@ -643,22 +751,24 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
643
751
|
* itself, so this is the first signal that work carries on after one - resume,
|
|
644
752
|
* if it is the one being waited for.
|
|
645
753
|
*
|
|
646
|
-
* The
|
|
647
|
-
*
|
|
648
|
-
*
|
|
754
|
+
* The session keeps its transcript offset from the prompt, so an approved tool
|
|
755
|
+
* interrupted with Esc, which still fires this PostToolUse, is seen by the
|
|
756
|
+
* daemon and must not bring the music back.
|
|
649
757
|
*
|
|
650
758
|
* ponytail: matching by name means two same-named tools in one parallel batch,
|
|
651
759
|
* one needing approval, can resume early - after the chime already did its
|
|
652
760
|
* job. Match on tool_input as well if that ever shows up in practice.
|
|
653
761
|
*/
|
|
654
762
|
function hookResume(raw, genre, volume, { reactive = false } = {}) {
|
|
655
|
-
const
|
|
656
|
-
|
|
763
|
+
const id = sessionId(parsePayload(raw).session_id);
|
|
764
|
+
const session = readSession(id);
|
|
765
|
+
if (!session || session.waiting == null) return false; // Not waiting - the common case, on every tool call.
|
|
657
766
|
// An empty key is a wait that named nothing (a Notification): the next tool
|
|
658
767
|
// to finish is the first sign of work carrying on.
|
|
659
|
-
if (
|
|
768
|
+
if (session.waiting !== "" && session.waiting !== waitKey(raw)) return false;
|
|
660
769
|
|
|
661
|
-
|
|
770
|
+
writeSession(id, { ...session, waiting: null });
|
|
771
|
+
if (!daemonRunning()) spawnDaemon(genre, volume, reactive);
|
|
662
772
|
return true;
|
|
663
773
|
}
|
|
664
774
|
|
|
@@ -666,18 +776,20 @@ function hookResume(raw, genre, volume, { reactive = false } = {}) {
|
|
|
666
776
|
* The agent's session is over (quit, /clear, logout). A turn cut off by it
|
|
667
777
|
* never reaches Stop. Silent: nothing finished.
|
|
668
778
|
*
|
|
669
|
-
* Only
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
779
|
+
* Only that session's own turn ends, so one terminal closing cannot silence
|
|
780
|
+
* another that is mid-turn. Fails closed on an unknown session: with none on
|
|
781
|
+
* record there is nothing of its to end, and the MAX_DAEMON_MS ceiling still
|
|
782
|
+
* applies.
|
|
673
783
|
*/
|
|
674
784
|
function hookEnd(raw) {
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
if (!owner || owner !== String(parsePayload(raw).session_id || "")) return false;
|
|
785
|
+
const id = sessionId(parsePayload(raw).session_id);
|
|
786
|
+
if (!readSession(id)) return false;
|
|
678
787
|
|
|
679
|
-
|
|
680
|
-
|
|
788
|
+
fs.rmSync(sessionFile(id), { force: true });
|
|
789
|
+
if (working(listSessions()).length === 0) {
|
|
790
|
+
stopDaemon({ keepSessions: true });
|
|
791
|
+
fs.rmSync(INTENSITY_FILE, { force: true });
|
|
792
|
+
}
|
|
681
793
|
return true;
|
|
682
794
|
}
|
|
683
795
|
|
|
@@ -699,13 +811,24 @@ function shellQuote(value) {
|
|
|
699
811
|
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
700
812
|
}
|
|
701
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
|
+
*/
|
|
702
828
|
function hookCommand(flag, genre, volume, reactive = false) {
|
|
703
829
|
const base = `${shellQuote(process.execPath)} ${shellQuote(CLI_ENTRY)} ${flag}`;
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const start = `${base} --genre ${genre} --volume ${Math.round(volume * 100)}`;
|
|
708
|
-
return reactive ? `${start} --reactive` : start;
|
|
830
|
+
if (!reactive) return base;
|
|
831
|
+
return flag === "--hook-start" || flag === "--hook-resume" ? `${base} --reactive` : base;
|
|
709
832
|
}
|
|
710
833
|
|
|
711
834
|
const VIBE_HOOK_FLAG = /--hook-(start|stop|tool|wait|resume|end)\b/;
|
|
@@ -891,10 +1014,10 @@ single short sentence. Do nothing else.
|
|
|
891
1014
|
- \`mute\` or \`mute <minutes>\`: \`${cli} --mute <minutes>\`
|
|
892
1015
|
- \`unmute\`: \`${cli} --unmute\`
|
|
893
1016
|
- \`stop\`: \`${cli} --stop\`
|
|
894
|
-
- \`genre <name
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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.
|
|
898
1021
|
Genres: lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, random.
|
|
899
1022
|
|
|
900
1023
|
For anything else, show the user the list above instead of running a command.
|
|
@@ -964,6 +1087,7 @@ module.exports = {
|
|
|
964
1087
|
readIntensity,
|
|
965
1088
|
stopDaemon,
|
|
966
1089
|
isOurDaemon,
|
|
1090
|
+
daemonPlaying,
|
|
967
1091
|
installHooks,
|
|
968
1092
|
ephemeralInstallReason,
|
|
969
1093
|
uninstallHooks,
|
package/src/interactive.js
CHANGED
|
@@ -52,7 +52,48 @@ const GENRES = [
|
|
|
52
52
|
{ name: "🎲 Shuffle / Random", desc: "Picks a surprise vibe each time", id: "random" }
|
|
53
53
|
];
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One loop of a genre, started from inside the menu and replaced on each
|
|
58
|
+
* press. Non-blocking: the menu stays interactive while it plays, so you can
|
|
59
|
+
* page through the list and hear each one.
|
|
60
|
+
*
|
|
61
|
+
* The process is module-level rather than per-menu because there is only ever
|
|
62
|
+
* one pair of speakers - a second press has to silence the first, or two
|
|
63
|
+
* genres play over each other.
|
|
64
|
+
*/
|
|
65
|
+
let previewChild = null;
|
|
66
|
+
|
|
67
|
+
function stopPreview() {
|
|
68
|
+
if (!previewChild) return;
|
|
69
|
+
try {
|
|
70
|
+
previewChild.kill();
|
|
71
|
+
} catch (e) {
|
|
72
|
+
// Already gone - the loop ended on its own.
|
|
73
|
+
}
|
|
74
|
+
previewChild = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function playPreview(genreId) {
|
|
78
|
+
const { spawn } = require("child_process");
|
|
79
|
+
const { detectPlayer, getAudioPath, resolveGenre, normalizeVolume, loadConfig, playbackDisabled } = require("./player");
|
|
80
|
+
|
|
81
|
+
stopPreview();
|
|
82
|
+
// A muted user pressing "p" is asking to hear something, the same as
|
|
83
|
+
// `--preview` is - but a mute set for a call should still hold. Silence is
|
|
84
|
+
// the safe read, and --status says why.
|
|
85
|
+
if (playbackDisabled()) return;
|
|
86
|
+
|
|
87
|
+
const backend = detectPlayer();
|
|
88
|
+
if (!backend) return;
|
|
89
|
+
|
|
90
|
+
const volume = normalizeVolume(loadConfig().volume, 0.4);
|
|
91
|
+
const file = getAudioPath(resolveGenre(genreId), 2);
|
|
92
|
+
previewChild = spawn(backend.cmd, backend.args(file, volume), { stdio: "ignore" });
|
|
93
|
+
previewChild.on("error", () => { previewChild = null; });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function selectMenu(title, items, renderItem, { onPreview = null } = {}) {
|
|
56
97
|
return new Promise((resolve) => {
|
|
57
98
|
let selected = 0;
|
|
58
99
|
const stdin = process.stdin;
|
|
@@ -92,6 +133,15 @@ function selectMenu(title, items, renderItem) {
|
|
|
92
133
|
process.exit(0);
|
|
93
134
|
}
|
|
94
135
|
|
|
136
|
+
// Auditioning is the whole reason the genre list is hard to choose from:
|
|
137
|
+
// nine names and a one-line description each, and no way to hear any of
|
|
138
|
+
// them without leaving the menu. A loop renders in ~150ms, so this is
|
|
139
|
+
// just a keypress.
|
|
140
|
+
if (onPreview && (key === "p" || key === "P")) {
|
|
141
|
+
onPreview(items[selected]);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
95
145
|
if (key === "\u001b[A") {
|
|
96
146
|
// Up arrow
|
|
97
147
|
selected = (selected - 1 + items.length) % items.length;
|
|
@@ -119,6 +169,7 @@ function selectMenu(title, items, renderItem) {
|
|
|
119
169
|
}
|
|
120
170
|
|
|
121
171
|
function cleanup() {
|
|
172
|
+
if (onPreview) stopPreview();
|
|
122
173
|
stdin.removeListener("data", onData);
|
|
123
174
|
stdin.setRawMode(false);
|
|
124
175
|
stdin.pause();
|
|
@@ -299,9 +350,10 @@ async function promptInteractive({ hooksInstalledFor = () => false } = {}) {
|
|
|
299
350
|
|
|
300
351
|
// 3. Select Music Genre
|
|
301
352
|
const selectedGenre = await selectMenu(
|
|
302
|
-
"Choose your sound vibe:",
|
|
353
|
+
"Choose your sound vibe: \x1b[0m\x1b[90m(press p to hear the highlighted one)\x1b[0m",
|
|
303
354
|
GENRES,
|
|
304
|
-
(item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m
|
|
355
|
+
(item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m`,
|
|
356
|
+
{ onPreview: (item) => playPreview(item.id) }
|
|
305
357
|
);
|
|
306
358
|
|
|
307
359
|
// 4. Select Volume Preset
|
|
@@ -335,4 +387,4 @@ async function promptInteractive({ hooksInstalledFor = () => false } = {}) {
|
|
|
335
387
|
};
|
|
336
388
|
}
|
|
337
389
|
|
|
338
|
-
module.exports = { promptInteractive, tokenizeCommand, isInstalled, hookTargetOf, AI_TOOLS };
|
|
390
|
+
module.exports = { promptInteractive, tokenizeCommand, isInstalled, hookTargetOf, playPreview, stopPreview, AI_TOOLS };
|
package/src/mcp.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const readline = require("readline");
|
|
9
|
-
const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, isKnownGenre } = require("./player");
|
|
9
|
+
const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, isKnownGenre, loadConfig } = require("./player");
|
|
10
10
|
const pkg = require("../package.json");
|
|
11
11
|
|
|
12
12
|
// A desktop client that crashes never sends vibe_stop, so playback needs its
|
|
@@ -132,7 +132,12 @@ function handleMessage(player, msg) {
|
|
|
132
132
|
const { name, arguments: args = {} } = params || {};
|
|
133
133
|
|
|
134
134
|
if (name === "vibe_play") {
|
|
135
|
-
|
|
135
|
+
// Same precedence as the CLI: what the model asked for, then the
|
|
136
|
+
// environment, then the user's saved default, then lofi. Reading the
|
|
137
|
+
// saved file here is what makes `vibe --genre jazz` mean jazz in a
|
|
138
|
+
// desktop client too, rather than only in the terminal.
|
|
139
|
+
const saved = loadConfig();
|
|
140
|
+
const requested = args.genre || process.env.VIBE_GENRE || saved.genre || "lofi";
|
|
136
141
|
// An unknown genre already fell back to lofi inside the generator, but
|
|
137
142
|
// player.genre kept the name nobody implements - so the model told the
|
|
138
143
|
// user it was playing something that does not exist.
|
|
@@ -140,7 +145,8 @@ function handleMessage(player, msg) {
|
|
|
140
145
|
// Both the tool argument and the env default go through the same parser
|
|
141
146
|
// as the CLI, so a mistyped VIBE_VOLUME falls back instead of reaching
|
|
142
147
|
// the player as NaN.
|
|
143
|
-
const volume = normalizeVolume(args.volume,
|
|
148
|
+
const volume = normalizeVolume(args.volume,
|
|
149
|
+
normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(saved.volume, 0.4)));
|
|
144
150
|
|
|
145
151
|
const started = player.start(genre, volume, { maxDurationMs: MAX_PLAYBACK_MS });
|
|
146
152
|
// start() returns false for three unrelated reasons, and the model
|