vibeaudio 0.9.0 → 0.10.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 +22 -0
- package/package.json +1 -1
- package/src/cli.js +125 -0
- package/src/history.js +57 -0
- package/src/hooks.js +15 -6
- package/src/output.js +57 -0
- package/src/player.js +40 -6
- package/src/synth/chime.js +21 -5
package/README.md
CHANGED
|
@@ -357,6 +357,23 @@ Removing them is one command:
|
|
|
357
357
|
vibe --uninstall-hooks
|
|
358
358
|
```
|
|
359
359
|
|
|
360
|
+
### Know where your time goes: `vibe --report`
|
|
361
|
+
|
|
362
|
+
Hooks log each finished turn — which project, how long, how it ended, and how much of it the agent spent blocked on a dialog of yours — to `~/.vibeaudio/history.jsonl`. `vibe --report` (or `--report 30`) totals it:
|
|
363
|
+
|
|
364
|
+
```
|
|
365
|
+
Turns 30 3 failed
|
|
366
|
+
You waited 3h 55m on agents, 7m 51s per turn
|
|
367
|
+
Agents waited 5m 35s on you — permission dialogs and questions
|
|
368
|
+
Typical turn 2m 57s median
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
plus where the time went by project and, for windows up to two weeks, by day. "You waited" is the agent's own working time; the time it spent blocked on you is shown separately, because they are different problems. The log stays on your machine, never leaves it, is capped at about 1 MB, and `VIBE_NO_HISTORY=1` turns it off. Only hook-driven turns are logged — a command wrapped as `vibe <command>` is not, since its lifetime isn't the same thing as an agent's working time.
|
|
372
|
+
|
|
373
|
+
### The chime is in the music's key
|
|
374
|
+
|
|
375
|
+
The success chime is the tonic chord of whatever key your genre sits in — C major for lofi, 8bit, jazz and piano, D minor for synthwave and electronic, D major for zen, A minor for drone — so it lands as the resolution of the piece rather than a bell over it. `rain`, `ocean` and `random` have no single key to match and keep the original C major chime.
|
|
376
|
+
|
|
360
377
|
### Reactive mode (opt-in)
|
|
361
378
|
|
|
362
379
|
```bash
|
|
@@ -567,6 +584,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
567
584
|
| `--status` | Show what's installed, running and detected, then exit | — |
|
|
568
585
|
| `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | — |
|
|
569
586
|
| `--notify` / `--no-notify` | Also show a desktop banner naming the project when a turn finishes, fails or needs you. Saved to `config.json` | off |
|
|
587
|
+
| `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
|
|
570
588
|
| `--stop` | Stop the background player, then exit | — |
|
|
571
589
|
| `--mute [minutes]` | Silence everything for a call, then exit | `60` min (`0` = until unmuted) |
|
|
572
590
|
| `--unmute` | Resume normal playback, then exit | — |
|
|
@@ -592,6 +610,7 @@ export VIBE_GRACE_MS=3000 # Wait 3s of thinking before any music
|
|
|
592
610
|
export VIBE_SEED=7 # Same arrangement everywhere, ignoring the directory
|
|
593
611
|
export VIBE_DISABLE=1 # Mute, without uninstalling anything
|
|
594
612
|
export VIBE_NOTIFY=1 # Desktop banner naming the project, for this shell
|
|
613
|
+
export VIBE_NO_HISTORY=1 # Do not log finished turns (what --report reads)
|
|
595
614
|
export VIBE_NO_UPDATE_CHECK=1 # Never ask npm whether a newer version is out
|
|
596
615
|
```
|
|
597
616
|
|
|
@@ -624,6 +643,9 @@ The expiry is deliberate too. A call is a bounded thing; a mute you forget about
|
|
|
624
643
|
**No sound at all**
|
|
625
644
|
Check that a player exists for your platform (see [Requirements](#-requirements)). VibeAudio prints a notice to stderr when it can't find one. On Linux: `sudo apt install pulseaudio-utils` (or `ffmpeg` / `alsa-utils`).
|
|
626
645
|
|
|
646
|
+
**Silent after connecting a monitor or headphones (macOS)**
|
|
647
|
+
`afplay` plays on macOS's *default output device*, which moves when a display or headphones connect. A monitor often has no system volume control at all and speakers much quieter than the laptop's, so a quiet setting — a sparse genre like `piano` at 25% — can fall below what you can hear. `vibe --doctor` shows where sound is going and warns if that output is muted or has no volume control. Then try `vibe --genre lofi --volume 60` for one prompt, and check the monitor's own speaker volume.
|
|
648
|
+
|
|
627
649
|
**Music starts immediately instead of after the grace window**
|
|
628
650
|
It usually is waiting — your AI tool's own startup (auth, session load) just takes longer than 1.5s, so music and the tool's first output appear together. Raise the window: `vibe --grace 3000 claude`.
|
|
629
651
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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/cli.js
CHANGED
|
@@ -77,6 +77,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
77
77
|
--render [file] Write this project's music to a .wav and exit (full scale, ignores --volume)
|
|
78
78
|
--status Show what is installed, running and detected, then exit
|
|
79
79
|
--notify | --no-notify Also show a desktop banner naming the project when a turn finishes or needs you (off by default)
|
|
80
|
+
--report [days] How long you waited on agents, and where (default: 7 days)
|
|
80
81
|
--doctor Check the setup; each problem comes with its fix (exit 1 if any)
|
|
81
82
|
--stop Stop the background player, then exit
|
|
82
83
|
--mute [minutes] Silence everything for a call (default: 60 min, 0 = until unmuted)
|
|
@@ -175,6 +176,7 @@ function parseArgs(argv) {
|
|
|
175
176
|
let statusFlag = false;
|
|
176
177
|
let doctorFlag = false;
|
|
177
178
|
let notifyFlag = null;
|
|
179
|
+
let reportDays = null;
|
|
178
180
|
let stopFlag = false;
|
|
179
181
|
let muteFlag = null;
|
|
180
182
|
let muteMinutes = null;
|
|
@@ -285,6 +287,17 @@ function parseArgs(argv) {
|
|
|
285
287
|
continue;
|
|
286
288
|
}
|
|
287
289
|
|
|
290
|
+
if (arg === "--report") {
|
|
291
|
+
// Optional window in days, like --mute's minutes: `vibe --report 30`.
|
|
292
|
+
i += 1;
|
|
293
|
+
reportDays = 7;
|
|
294
|
+
if (args[i] !== undefined && /^\d+$/.test(args[i])) {
|
|
295
|
+
reportDays = Math.min(Math.max(parseInt(args[i], 10), 1), 365);
|
|
296
|
+
i += 1;
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
288
301
|
if (arg === "--notify" || arg === "--no-notify") {
|
|
289
302
|
notifyFlag = arg === "--notify";
|
|
290
303
|
i += 1;
|
|
@@ -441,6 +454,7 @@ function parseArgs(argv) {
|
|
|
441
454
|
status: statusFlag,
|
|
442
455
|
doctor: doctorFlag,
|
|
443
456
|
notify: notifyFlag,
|
|
457
|
+
report: reportDays,
|
|
444
458
|
stop: stopFlag,
|
|
445
459
|
mute: muteFlag,
|
|
446
460
|
muteMinutes,
|
|
@@ -800,6 +814,24 @@ function doctorChecks() {
|
|
|
800
814
|
add("ok", "Saved settings", "config.json parses (or none saved)");
|
|
801
815
|
}
|
|
802
816
|
|
|
817
|
+
// afplay plays on the default output device, which moves when a monitor or
|
|
818
|
+
// headphones connect - and an external display often has no system volume at
|
|
819
|
+
// all, or speakers far quieter than the laptop's. Say where sound is going.
|
|
820
|
+
const out = require("./output").macOutput();
|
|
821
|
+
if (out) {
|
|
822
|
+
const where = out.device ? `${out.device.name}${out.device.transport ? ` (${out.device.transport})` : ""}` : "the default output";
|
|
823
|
+
if (out.muted) {
|
|
824
|
+
add("warn", "Sound output", `${where} is muted in macOS — nothing will be heard`, "unmute it in the menu bar, or: osascript -e 'set volume output muted false'");
|
|
825
|
+
} else if (out.volume === null) {
|
|
826
|
+
add("warn", "Sound output", `${where} has no system volume control, so VibeAudio's own volume is the only one — and its speakers may be quiet`,
|
|
827
|
+
"raise it with: vibe --volume 60 (and check the monitor's own speaker volume)");
|
|
828
|
+
} else if (out.volume < 10) {
|
|
829
|
+
add("warn", "Sound output", `${where}, system volume ${out.volume}%`, "raise the macOS volume");
|
|
830
|
+
} else {
|
|
831
|
+
add("ok", "Sound output", `${where}, system volume ${out.volume}%`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
803
835
|
const detected = hooks.detectTargets();
|
|
804
836
|
for (const id of Object.keys(hooks.TARGETS)) {
|
|
805
837
|
const t = hooks.TARGETS[id];
|
|
@@ -870,6 +902,93 @@ function doctorChecks() {
|
|
|
870
902
|
return checks;
|
|
871
903
|
}
|
|
872
904
|
|
|
905
|
+
/** 95 -> "1m 35s", 4_000_000 -> "1h 06m". */
|
|
906
|
+
function formatDuration(ms) {
|
|
907
|
+
const s = Math.round(ms / 1000);
|
|
908
|
+
if (s < 60) return `${s}s`;
|
|
909
|
+
const m = Math.floor(s / 60);
|
|
910
|
+
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
|
911
|
+
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* The summary of ~/.vibeaudio/history.jsonl. "You waited" is the agent's own
|
|
916
|
+
* working time - a turn's length minus the stretches it spent blocked on a
|
|
917
|
+
* dialog of yours - because the two are different complaints: one is the
|
|
918
|
+
* agent being slow, the other is you being away.
|
|
919
|
+
*/
|
|
920
|
+
function printReport(days, now = Date.now()) {
|
|
921
|
+
const { readTurns } = require("./history");
|
|
922
|
+
const turns = readTurns(days, now);
|
|
923
|
+
const dim = (s) => `\x1b[90m${s}\x1b[0m`;
|
|
924
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
925
|
+
console.log(`\n\x1b[1m\x1b[36mVibeAudio\x1b[0m report — last ${days === 1 ? "day" : `${days} days`}\n`);
|
|
926
|
+
|
|
927
|
+
if (!turns.length) {
|
|
928
|
+
console.log(` No turns recorded yet.`);
|
|
929
|
+
console.log(dim(` Turns are logged when your agent's hooks fire (vibe --install-hooks). A command wrapped as \`vibe <command>\` is not.`));
|
|
930
|
+
console.log(dim(` The log stays on this machine; VIBE_NO_HISTORY=1 turns it off.`));
|
|
931
|
+
console.log();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const working = (t) => t.ms - (t.blockedMs || 0);
|
|
936
|
+
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
|
|
937
|
+
const sorted = turns.map((t) => t.ms).sort((a, b) => a - b);
|
|
938
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
939
|
+
const longest = turns.reduce((a, b) => (b.ms > a.ms ? b : a));
|
|
940
|
+
const failed = turns.filter((t) => t.outcome === "failure").length;
|
|
941
|
+
const waitedOnAgents = sum(turns.map(working));
|
|
942
|
+
const blocked = sum(turns.map((t) => t.blockedMs || 0));
|
|
943
|
+
|
|
944
|
+
// Two projects called `api` under different parents stay apart.
|
|
945
|
+
const names = new Map();
|
|
946
|
+
for (const t of turns) {
|
|
947
|
+
const parts = String(t.project || "?").split(/[\\/]/).filter(Boolean);
|
|
948
|
+
const clash = [...names.entries()].some(([p, n]) => p !== t.project && n === parts.slice(-1)[0]);
|
|
949
|
+
names.set(t.project, clash ? parts.slice(-2).join("/") : parts.slice(-1)[0] || "?");
|
|
950
|
+
}
|
|
951
|
+
const label = (t) => names.get(t.project);
|
|
952
|
+
|
|
953
|
+
const row = (k, v, extra = "") => console.log(` ${k.padEnd(18)}${bold(v)}${extra ? dim(` ${extra}`) : ""}`);
|
|
954
|
+
row("Turns", String(turns.length), failed ? `${failed} failed` : "");
|
|
955
|
+
row("You waited", formatDuration(waitedOnAgents), `on agents, ${formatDuration(waitedOnAgents / turns.length)} per turn`);
|
|
956
|
+
if (blocked >= 1000) row("Agents waited", formatDuration(blocked), "on you — permission dialogs and questions");
|
|
957
|
+
row("Typical turn", formatDuration(median), "median");
|
|
958
|
+
row("Longest turn", formatDuration(longest.ms), label(longest));
|
|
959
|
+
|
|
960
|
+
const byProject = new Map();
|
|
961
|
+
for (const t of turns) {
|
|
962
|
+
const p = byProject.get(label(t)) || { ms: 0, n: 0 };
|
|
963
|
+
p.ms += working(t);
|
|
964
|
+
p.n += 1;
|
|
965
|
+
byProject.set(label(t), p);
|
|
966
|
+
}
|
|
967
|
+
const top = [...byProject.entries()].sort((a, b) => b[1].ms - a[1].ms).slice(0, 5);
|
|
968
|
+
const widest = top[0][1].ms || 1;
|
|
969
|
+
console.log(`\n${bold("Where the time went")}`);
|
|
970
|
+
for (const [name, p] of top) {
|
|
971
|
+
console.log(` ${name.slice(0, 18).padEnd(19)}${formatDuration(p.ms).padStart(8)} ${"█".repeat(Math.max(1, Math.round((p.ms / widest) * 14))).padEnd(14)} ${dim(`${p.n} ${p.n === 1 ? "turn" : "turns"}`)}`);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (days <= 14) {
|
|
975
|
+
console.log(`\n${bold("By day")}`);
|
|
976
|
+
const byDay = new Map();
|
|
977
|
+
for (const t of turns) {
|
|
978
|
+
const key = new Date(t.at).toLocaleDateString("en-CA");
|
|
979
|
+
byDay.set(key, (byDay.get(key) || 0) + working(t));
|
|
980
|
+
}
|
|
981
|
+
const maxDay = Math.max(...byDay.values()) || 1;
|
|
982
|
+
for (let d = days - 1; d >= 0; d--) {
|
|
983
|
+
const date = new Date(now - d * 86400000);
|
|
984
|
+
const ms = byDay.get(date.toLocaleDateString("en-CA")) || 0;
|
|
985
|
+
const name = `${date.toLocaleDateString("en-US", { weekday: "short" })} ${date.getDate()}`;
|
|
986
|
+
console.log(` ${name.padEnd(8)}${(ms ? formatDuration(ms) : "—").padStart(8)} ${"█".repeat(ms ? Math.max(1, Math.round((ms / maxDay) * 14)) : 0)}`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
console.log();
|
|
990
|
+
}
|
|
991
|
+
|
|
873
992
|
function printDoctor() {
|
|
874
993
|
const mark = { ok: "\x1b[32m✔\x1b[0m", warn: "\x1b[33m!\x1b[0m", fail: "\x1b[31m✘\x1b[0m" };
|
|
875
994
|
const checks = doctorChecks();
|
|
@@ -1163,6 +1282,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
|
|
|
1163
1282
|
volume,
|
|
1164
1283
|
chimeVolume,
|
|
1165
1284
|
noChime,
|
|
1285
|
+
genre,
|
|
1166
1286
|
raw
|
|
1167
1287
|
});
|
|
1168
1288
|
});
|
|
@@ -1355,6 +1475,7 @@ async function run() {
|
|
|
1355
1475
|
status: showStatus,
|
|
1356
1476
|
doctor: showDoctor,
|
|
1357
1477
|
notify: notifyChange,
|
|
1478
|
+
report: reportDays,
|
|
1358
1479
|
stop: shouldStop,
|
|
1359
1480
|
mute: muteChange,
|
|
1360
1481
|
muteMinutes,
|
|
@@ -1442,6 +1563,10 @@ async function run() {
|
|
|
1442
1563
|
return printDoctor();
|
|
1443
1564
|
}
|
|
1444
1565
|
|
|
1566
|
+
if (reportDays !== null) {
|
|
1567
|
+
return printReport(reportDays);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1445
1570
|
if (shouldClear) {
|
|
1446
1571
|
console.log(`\x1b[32m[vibeaudio] Cleared cache at ${clearCache()}\x1b[0m`);
|
|
1447
1572
|
return;
|
package/src/history.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A local log of finished turns - what `vibe --report` reads. One JSON object
|
|
3
|
+
* per line in ~/.vibeaudio/history.jsonl: when the turn ended, which project,
|
|
4
|
+
* how long it took, how much of that the agent spent blocked on you, and how
|
|
5
|
+
* it ended. It never leaves the machine; VIBE_NO_HISTORY=1 turns it off.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
|
|
12
|
+
const HISTORY_FILE = path.join(os.homedir(), ".vibeaudio", "history.jsonl");
|
|
13
|
+
const MAX_BYTES = 1024 * 1024;
|
|
14
|
+
const KEEP_LINES = 4000;
|
|
15
|
+
|
|
16
|
+
/** Appends one turn. Never throws: a log is not worth failing a hook over. */
|
|
17
|
+
function recordTurn({ project, ms, blockedMs = 0, outcome = "success", at = Date.now() }, file = HISTORY_FILE) {
|
|
18
|
+
if (process.env.VIBE_NO_HISTORY) return false;
|
|
19
|
+
if (!Number.isFinite(ms) || ms < 0) return false;
|
|
20
|
+
try {
|
|
21
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
22
|
+
fs.appendFileSync(file, `${JSON.stringify({ at, project, ms, blockedMs: Math.min(Math.max(blockedMs, 0), ms), outcome })}\n`);
|
|
23
|
+
// Bounded, so a year of use is still a small file. Rewritten rarely, from
|
|
24
|
+
// the tail, so the newest turns are always the ones kept.
|
|
25
|
+
if (fs.statSync(file).size > MAX_BYTES) {
|
|
26
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
27
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
28
|
+
fs.writeFileSync(tmp, `${lines.slice(-KEEP_LINES).join("\n")}\n`);
|
|
29
|
+
fs.renameSync(tmp, file);
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Turns that ended within the last `days` days. A line that does not parse is skipped, not fatal. */
|
|
38
|
+
function readTurns(days, now = Date.now(), file = HISTORY_FILE) {
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = fs.readFileSync(file, "utf8");
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const since = now - days * 86400000;
|
|
46
|
+
const turns = [];
|
|
47
|
+
for (const line of raw.split("\n")) {
|
|
48
|
+
if (!line) continue;
|
|
49
|
+
try {
|
|
50
|
+
const t = JSON.parse(line);
|
|
51
|
+
if (t && Number.isFinite(t.at) && Number.isFinite(t.ms) && t.at >= since) turns.push(t);
|
|
52
|
+
} catch (e) { /* a torn or hand-edited line */ }
|
|
53
|
+
}
|
|
54
|
+
return turns;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { HISTORY_FILE, recordTurn, readTurns };
|
package/src/hooks.js
CHANGED
|
@@ -16,6 +16,7 @@ const os = require("os");
|
|
|
16
16
|
const path = require("path");
|
|
17
17
|
const { spawn, execFileSync } = require("child_process");
|
|
18
18
|
const { AudioPlayer, loadConfig, playbackDisabled } = require("./player");
|
|
19
|
+
const { recordTurn } = require("./history");
|
|
19
20
|
|
|
20
21
|
const STATE_DIR = path.join(os.homedir(), ".vibeaudio");
|
|
21
22
|
const PID_FILE = path.join(STATE_DIR, "daemon.pid");
|
|
@@ -624,7 +625,7 @@ function spawnDaemon(genre, volume, reactive) {
|
|
|
624
625
|
function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
|
|
625
626
|
const id = sessionId(turn && turn.session);
|
|
626
627
|
// Before the spawn: the daemon reads it on startup.
|
|
627
|
-
writeSession(id, { transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null });
|
|
628
|
+
writeSession(id, { transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null, started: Date.now(), blockedMs: 0 });
|
|
628
629
|
if (working(listSessions()).some((s) => s.id !== id) && daemonRunning()) return readPid();
|
|
629
630
|
|
|
630
631
|
stopDaemon({ keepSessions: true });
|
|
@@ -729,12 +730,19 @@ function notify(raw, message) {
|
|
|
729
730
|
}
|
|
730
731
|
}
|
|
731
732
|
|
|
732
|
-
function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, raw = "" } = {}) {
|
|
733
|
+
function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, genre, raw = "" } = {}) {
|
|
733
734
|
const id = sessionId(parsePayload(raw).session_id);
|
|
734
735
|
// A turn that ends while paused for the user - a denied tool that nothing
|
|
735
736
|
// resumed after - still finished, so its session counts either way.
|
|
736
|
-
const
|
|
737
|
+
const session = readSession(id);
|
|
738
|
+
const tracked = session !== null;
|
|
737
739
|
fs.rmSync(sessionFile(id), { force: true });
|
|
740
|
+
if (session && Number.isFinite(session.started)) {
|
|
741
|
+
const now = Date.now();
|
|
742
|
+
// A turn that ends while still paused for you has been blocked since then.
|
|
743
|
+
const blockedMs = (session.blockedMs || 0) + (session.waiting != null && session.waitStart ? now - session.waitStart : 0);
|
|
744
|
+
recordTurn({ project: parsePayload(raw).cwd || process.cwd(), ms: now - session.started, blockedMs, outcome, at: now });
|
|
745
|
+
}
|
|
738
746
|
|
|
739
747
|
// The music is every working session's, so it ends with the last of them.
|
|
740
748
|
// Everyone else's carries on, and this session still gets its own chime.
|
|
@@ -746,7 +754,7 @@ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChi
|
|
|
746
754
|
if (noChime) return false;
|
|
747
755
|
|
|
748
756
|
// Chime plays in this short-lived hook process.
|
|
749
|
-
new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume });
|
|
757
|
+
new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume, genre });
|
|
750
758
|
return true;
|
|
751
759
|
}
|
|
752
760
|
|
|
@@ -792,7 +800,7 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
792
800
|
const session = readSession(id);
|
|
793
801
|
if (!session || session.waiting != null) return false;
|
|
794
802
|
|
|
795
|
-
writeSession(id, { ...session, waiting: waitKey(raw) });
|
|
803
|
+
writeSession(id, { ...session, waiting: waitKey(raw), waitStart: Date.now() });
|
|
796
804
|
if (working(listSessions()).length === 0) stopDaemon({ keepSessions: true });
|
|
797
805
|
const tool = payloadToolName(raw);
|
|
798
806
|
notify(raw, tool ? `needs you (${tool})` : "needs you");
|
|
@@ -821,7 +829,8 @@ function hookResume(raw, genre, volume, { reactive = false } = {}) {
|
|
|
821
829
|
// to finish is the first sign of work carrying on.
|
|
822
830
|
if (session.waiting !== "" && session.waiting !== waitKey(raw)) return false;
|
|
823
831
|
|
|
824
|
-
|
|
832
|
+
const blockedMs = (session.blockedMs || 0) + (session.waitStart ? Date.now() - session.waitStart : 0);
|
|
833
|
+
writeSession(id, { ...session, waiting: null, waitStart: null, blockedMs });
|
|
825
834
|
if (!daemonRunning()) spawnDaemon(genre, volume, reactive);
|
|
826
835
|
return true;
|
|
827
836
|
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What macOS is actually sending sound to. `afplay` plays on the default
|
|
3
|
+
* output device, which is not always the one you are listening on: connect a
|
|
4
|
+
* monitor and the default can move to it, where the system volume may be
|
|
5
|
+
* missing or the speakers quiet. Read only by `--doctor`; nothing else asks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { spawnSync } = require("child_process");
|
|
9
|
+
|
|
10
|
+
/** "output volume:39, input volume:88, alert volume:63, output muted:false" */
|
|
11
|
+
function parseVolumeSettings(text) {
|
|
12
|
+
const volume = /output volume:\s*(\d+)/.exec(text);
|
|
13
|
+
const muted = /output muted:\s*(true|false)/.exec(text);
|
|
14
|
+
return {
|
|
15
|
+
// "missing value" is what macOS reports for a device with no software
|
|
16
|
+
// volume control - common for monitors - so absent means no knob, not zero.
|
|
17
|
+
volume: volume ? Number(volume[1]) : null,
|
|
18
|
+
muted: muted ? muted[1] === "true" : null
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TRANSPORTS = {
|
|
23
|
+
coreaudio_device_type_usb: "USB",
|
|
24
|
+
coreaudio_device_type_hdmi: "HDMI",
|
|
25
|
+
coreaudio_device_type_displayport: "DisplayPort",
|
|
26
|
+
coreaudio_device_type_builtin: "built-in",
|
|
27
|
+
coreaudio_device_type_bluetooth: "Bluetooth",
|
|
28
|
+
coreaudio_device_type_airplay: "AirPlay",
|
|
29
|
+
coreaudio_device_type_virtual: "virtual"
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The device `afplay` will use, from `system_profiler SPAudioDataType -json`. */
|
|
33
|
+
function parseDefaultOutput(json) {
|
|
34
|
+
try {
|
|
35
|
+
const items = (JSON.parse(json).SPAudioDataType || []).flatMap((group) => group._items || []);
|
|
36
|
+
const item = items.find((i) => i.coreaudio_default_audio_output_device === "spaudio_yes");
|
|
37
|
+
if (!item) return null;
|
|
38
|
+
return { name: item._name, transport: TRANSPORTS[item.coreaudio_device_transport] || null };
|
|
39
|
+
} catch (e) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** null off macOS, or when either lookup fails: absence of an answer is not a finding. */
|
|
45
|
+
function macOutput() {
|
|
46
|
+
if (process.platform !== "darwin") return null;
|
|
47
|
+
const run = (cmd, args) => {
|
|
48
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 5000 });
|
|
49
|
+
return r.status === 0 ? r.stdout : null;
|
|
50
|
+
};
|
|
51
|
+
const settings = run("osascript", ["-e", "get volume settings"]);
|
|
52
|
+
if (settings === null) return null;
|
|
53
|
+
const profile = run("system_profiler", ["SPAudioDataType", "-json"]);
|
|
54
|
+
return { ...parseVolumeSettings(settings), device: profile ? parseDefaultOutput(profile) : null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { parseVolumeSettings, parseDefaultOutput, macOutput };
|
package/src/player.js
CHANGED
|
@@ -18,7 +18,7 @@ const { generateRainLoop } = require("./synth/rain");
|
|
|
18
18
|
const { generateOceanLoop } = require("./synth/ocean");
|
|
19
19
|
const { generatePianoLoop } = require("./synth/piano");
|
|
20
20
|
const { generateJazzLoop } = require("./synth/jazz");
|
|
21
|
-
const { generateSuccessChime, generateFailureChime, generateAttentionChime } = require("./synth/chime");
|
|
21
|
+
const { generateSuccessChime, generateFailureChime, generateAttentionChime, DEFAULT_CHIME_KEY, SUCCESS_CHIME_NOTES } = require("./synth/chime");
|
|
22
22
|
const { hashString } = require("./synth/generator");
|
|
23
23
|
const pkg = require("../package.json");
|
|
24
24
|
|
|
@@ -633,13 +633,45 @@ const CHIMES = {
|
|
|
633
633
|
attention: generateAttentionChime
|
|
634
634
|
};
|
|
635
635
|
|
|
636
|
-
|
|
636
|
+
/**
|
|
637
|
+
* The key each genre sits in, so the success chime can resolve onto it. Read
|
|
638
|
+
* off the generators: lofi, 8bit, jazz and piano are C major (or its relative
|
|
639
|
+
* A minor, which shares the chord); synthwave and electronic are D minor; zen
|
|
640
|
+
* is D major; drone's roots (D, A, E) all take an A minor chord. Rain and
|
|
641
|
+
* ocean are noise with no pitch, and `random` is resolved by whoever started
|
|
642
|
+
* the music, so those keep the original chime.
|
|
643
|
+
*/
|
|
644
|
+
const GENRE_KEYS = {
|
|
645
|
+
lofi: "C major",
|
|
646
|
+
"8bit": "C major",
|
|
647
|
+
jazz: "C major",
|
|
648
|
+
piano: "C major",
|
|
649
|
+
synthwave: "D minor",
|
|
650
|
+
electronic: "D minor",
|
|
651
|
+
zen: "D major",
|
|
652
|
+
drone: "A minor"
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
function chimeKeyFor(genre) {
|
|
656
|
+
// resolveGenre would roll the dice here, and a chime in a key picked at
|
|
657
|
+
// random is no more matched than the default.
|
|
658
|
+
if (String(genre || "").trim().toLowerCase() === "random") return DEFAULT_CHIME_KEY;
|
|
659
|
+
const key = GENRE_KEYS[resolveGenre(genre || "")];
|
|
660
|
+
return key && SUCCESS_CHIME_NOTES[key] ? key : DEFAULT_CHIME_KEY;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function getChimePath(outcome = "success", gain = 1, key = DEFAULT_CHIME_KEY) {
|
|
637
664
|
ensureCacheDir();
|
|
638
665
|
const kind = outcome === "error" ? "failure" : CHIMES[outcome] ? outcome : "success";
|
|
639
|
-
|
|
666
|
+
// Only the success chime has keys, and the default keeps its original file
|
|
667
|
+
// name so an upgrade does not leave a cached copy behind.
|
|
668
|
+
const keyed = kind === "success" && key !== DEFAULT_CHIME_KEY && SUCCESS_CHIME_NOTES[key];
|
|
669
|
+
const suffix = keyed ? `_${key.replace(" ", "")}` : "";
|
|
670
|
+
const filePath = path.join(CACHE_DIR, `chime_${kind}${suffix}${gainSuffix(gain)}.wav`);
|
|
640
671
|
|
|
641
672
|
if (!fs.existsSync(filePath)) {
|
|
642
|
-
|
|
673
|
+
const wav = keyed ? generateSuccessChime(1.6, key) : CHIMES[kind]();
|
|
674
|
+
writeCacheFileAtomic(filePath, applyGain(wav, gain));
|
|
643
675
|
}
|
|
644
676
|
return filePath;
|
|
645
677
|
}
|
|
@@ -787,7 +819,7 @@ class AudioPlayer {
|
|
|
787
819
|
* that must hand control back at once - an agent waits on its hooks, and a
|
|
788
820
|
* blocking chime held the permission dialog back for its whole length.
|
|
789
821
|
*/
|
|
790
|
-
stop({ playChime = true, outcome = "success", volume = 0.35, chimeVolume = null, detach = false } = {}) {
|
|
822
|
+
stop({ playChime = true, outcome = "success", volume = 0.35, chimeVolume = null, detach = false, genre = this.genre } = {}) {
|
|
791
823
|
const wasPlaying = this.isPlaying;
|
|
792
824
|
this.isPlaying = false;
|
|
793
825
|
|
|
@@ -809,7 +841,7 @@ class AudioPlayer {
|
|
|
809
841
|
|
|
810
842
|
const targetVol = chimeVolume !== null ? chimeVolume : Math.min(0.65, Math.max(0.35, volume * 1.1));
|
|
811
843
|
const clamped = Math.max(0.05, Math.min(1.0, targetVol));
|
|
812
|
-
const chimeFile = getChimePath(outcome, bakedGain(backend, clamped));
|
|
844
|
+
const chimeFile = getChimePath(outcome, bakedGain(backend, clamped), chimeKeyFor(genre));
|
|
813
845
|
|
|
814
846
|
try {
|
|
815
847
|
if (detach) {
|
|
@@ -834,6 +866,8 @@ module.exports = {
|
|
|
834
866
|
pruneSeedDirs,
|
|
835
867
|
writeCacheFileAtomic,
|
|
836
868
|
getChimePath,
|
|
869
|
+
chimeKeyFor,
|
|
870
|
+
GENRE_KEYS,
|
|
837
871
|
clearCache,
|
|
838
872
|
detectPlayer,
|
|
839
873
|
bakedGain,
|
package/src/synth/chime.js
CHANGED
|
@@ -10,16 +10,30 @@ const {
|
|
|
10
10
|
createWavBuffer
|
|
11
11
|
} = require("./generator");
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// The success chime is the tonic chord of whatever key the music is in - root,
|
|
14
|
+
// fifth, octave, third - so it lands as the resolution of the piece instead of
|
|
15
|
+
// a stranger's bell over it. The default is the original C major chime.
|
|
16
|
+
const DEFAULT_CHIME_KEY = "C major";
|
|
17
|
+
const SUCCESS_CHIME_NOTES = {
|
|
18
|
+
"C major": ["C5", "G5", "C6", "E6"],
|
|
19
|
+
"D major": ["D5", "A5", "D6", "F#6"],
|
|
20
|
+
"D minor": ["D5", "A5", "D6", "F6"],
|
|
21
|
+
// Sits low on purpose: A, E and C are consonant over each of the drone's
|
|
22
|
+
// three roots (D, A, E), so one chime serves all of them.
|
|
23
|
+
"A minor": ["A4", "E5", "A5", "C6"]
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function generateSuccessChime(durationSec = 1.6, key = DEFAULT_CHIME_KEY) {
|
|
27
|
+
const names = SUCCESS_CHIME_NOTES[key] || SUCCESS_CHIME_NOTES[DEFAULT_CHIME_KEY];
|
|
14
28
|
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
15
29
|
const left = new Float64Array(totalSamples);
|
|
16
30
|
const right = new Float64Array(totalSamples);
|
|
17
31
|
|
|
18
32
|
const chimeNotes = [
|
|
19
|
-
{ note:
|
|
20
|
-
{ note:
|
|
21
|
-
{ note:
|
|
22
|
-
{ note:
|
|
33
|
+
{ note: names[0], delay: 0.00, pan: 0.4 },
|
|
34
|
+
{ note: names[1], delay: 0.09, pan: 0.6 },
|
|
35
|
+
{ note: names[2], delay: 0.18, pan: 0.45 },
|
|
36
|
+
{ note: names[3], delay: 0.27, pan: 0.55 }
|
|
23
37
|
];
|
|
24
38
|
|
|
25
39
|
for (const c of chimeNotes) {
|
|
@@ -130,6 +144,8 @@ function generateAttentionChime(durationSec = 1.3) {
|
|
|
130
144
|
}
|
|
131
145
|
|
|
132
146
|
module.exports = {
|
|
147
|
+
DEFAULT_CHIME_KEY,
|
|
148
|
+
SUCCESS_CHIME_NOTES,
|
|
133
149
|
generateChime: generateSuccessChime,
|
|
134
150
|
generateSuccessChime,
|
|
135
151
|
generateFailureChime,
|