vibeaudio 0.7.0 → 0.8.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 +37 -1
- package/package.json +15 -6
- package/src/cli.js +89 -3
- package/src/player.js +68 -14
- package/src/render.js +123 -0
- package/src/synth/chiptune.js +3 -3
- package/src/synth/drone.js +3 -3
- package/src/synth/electronic.js +3 -3
- package/src/synth/generator.js +23 -0
- package/src/synth/jazz.js +3 -2
- package/src/synth/lofi.js +3 -2
- package/src/synth/piano.js +3 -2
- package/src/synth/synthwave.js +3 -3
- package/src/synth/zen.js +3 -2
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ AI coding agents take 15–45 seconds to reason, read files and write code. Star
|
|
|
23
23
|
* 🧮 **Pure synthesis, zero MP3s.** Every note, chord and pad is generated in code — no audio assets, no npm dependencies, no `node-gyp`.
|
|
24
24
|
* 🎼 **A different arrangement per project.** Your working directory seeds the progression, bass line and melody, so each repo has its own sound and keeps it.
|
|
25
25
|
* 📈 **Escalating layers.** Tier 1 (0–15s) gentle intro → Tier 2 (15–45s) main groove → Tier 3 (45s+) deep focus. You can hear how deep into the task the agent is.
|
|
26
|
+
* 🔁 **A phrase, not a loop.** Each piece is three bars that rotate through your project's own progressions, so a long turn moves through a ~21-second phrase instead of replaying one 7-second bar for half an hour.
|
|
26
27
|
* 🎛️ **Music that follows the work** ([reactive mode](#reactive-mode-opt-in), opt-in). Calm while the agent reads, fuller while it edits, busiest when it runs commands or hands work to sub-agents — you can hear *what* it's doing, not just for how long.
|
|
27
28
|
* 🔔 **Outcome-aware chimes.** Ascending on success, a soft descending minor chord on failure, and **silence on `Ctrl+C`** — an abort is never reported as done.
|
|
28
29
|
* ✋ **A "your turn" chime.** When Claude Code stops to ask permission (or an MCP server asks for input), the music pauses and a rising two-note chime asks for you; it picks back up once you've answered.
|
|
@@ -60,6 +61,15 @@ That's a deliberate choice. Focus music has one job: **be ignorable.** Music tha
|
|
|
60
61
|
|
|
61
62
|
---
|
|
62
63
|
|
|
64
|
+
### 🎧 Send someone your repo's sound
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
vibe --render # -> vibeaudio-lofi.wav, ~53s
|
|
68
|
+
vibe --genre jazz --render our-api.wav
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A whole session in one file: every bar of the phrase at tier 1, then tier 2, then tier 3, and the success chime at the end — rendered from your project's own seed, at full scale so your player's volume control is the only one in the way. Plain WAV, so nothing has to be installed to play it.
|
|
72
|
+
|
|
63
73
|
## 📋 Requirements
|
|
64
74
|
|
|
65
75
|
* **Node.js ≥ 18**
|
|
@@ -87,6 +97,14 @@ npm i -g vibeaudio
|
|
|
87
97
|
|
|
88
98
|
That puts `vibe` and `vibeaudio` on your `PATH`. Re-run the same command to update, or see [Uninstall](#-uninstall) to remove it cleanly.
|
|
89
99
|
|
|
100
|
+
**Want what's on `main`?** Fixes land here before they reach the registry, and installing from GitHub gets them straight away — no clone, no build:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npm i -g github:kiril6/vibeaudio
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Same command to update it again. Switch back to the released version any time with `npm i -g vibeaudio`.
|
|
107
|
+
|
|
90
108
|
**Staying up to date.** When a newer version is out, `vibe --status`, `vibe --help` and the menu say so in one line. They're the only places it's shown — never in your agent's hooks, and never over a command you wrapped. What changed is on the [releases page](https://github.com/kiril6/vibeaudio/releases).
|
|
91
109
|
|
|
92
110
|
<details>
|
|
@@ -464,7 +482,20 @@ VibeAudio includes **8 procedural music styles** synthesized entirely in code:
|
|
|
464
482
|
| `drone` | 🌫️ **Deep Drone** | A held tone and filtered noise — **no melody at all** |
|
|
465
483
|
| `random` | 🎲 **Shuffle Mode** | Picks a surprise genre for the run — **never `drone`** |
|
|
466
484
|
|
|
467
|
-
Aliases also work
|
|
485
|
+
**Aliases also work**, so you can ask for a genre the way you'd say it — `vibe --preview chill` is `lofi`:
|
|
486
|
+
|
|
487
|
+
| Canonical | Also accepted |
|
|
488
|
+
| :--- | :--- |
|
|
489
|
+
| `lofi` | `lo-fi`, `lofi-hiphop`, `chill`, `chillhop`, `study` |
|
|
490
|
+
| `synthwave` | `retrowave`, `outrun`, `80s` |
|
|
491
|
+
| `8bit` | `chiptune`, `chip`, `nes`, `gameboy`, `retro` |
|
|
492
|
+
| `electronic` | `downtempo`, `techno`, `edm` |
|
|
493
|
+
| `jazz` | `bossa`, `swing`, `lounge` |
|
|
494
|
+
| `zen` | `ambient`, `calm`, `meditation` |
|
|
495
|
+
| `piano` | `sparse`, `satie`, `keys`, `minimal` |
|
|
496
|
+
| `drone` | `noise`, `focus`, `hum`, `whitenoise`, `white-noise` |
|
|
497
|
+
|
|
498
|
+
Matching is case-insensitive, so `BOSSA` works too.
|
|
468
499
|
|
|
469
500
|
> **If any melody distracts you, use `drone`.** Every other genre plays something — notes, a progression, a bass line — and some people can't read while that happens. `drone` holds one low tone under a slow-breathing noise bed and never moves: closer to a fan or rainfall than to music. Tiers add weight rather than movement.
|
|
470
501
|
>
|
|
@@ -526,6 +557,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
526
557
|
| `--no-chime` | Disable the resolution completion chime | `false` |
|
|
527
558
|
| `--no-hud` | Disable terminal window/tab title animation | `false` |
|
|
528
559
|
| `--preview <genre>` | Play one loop of a genre and exit | — |
|
|
560
|
+
| `--render [file]` | Write this project's music to a `.wav` and exit | `vibeaudio-<genre>.wav` |
|
|
529
561
|
| `--status` | Show what's installed, running and detected, then exit | — |
|
|
530
562
|
| `--stop` | Stop the background player, then exit | — |
|
|
531
563
|
| `--mute [minutes]` | Silence everything for a call, then exit | `60` min (`0` = until unmuted) |
|
|
@@ -692,6 +724,10 @@ Apart from those two, the three commands above remove everything VibeAudio write
|
|
|
692
724
|
|
|
693
725
|
---
|
|
694
726
|
|
|
727
|
+
If VibeAudio is useful to you, consider giving it a ⭐ on [GitHub](https://github.com/kiril6/vibeaudio) — it helps others find it.
|
|
728
|
+
|
|
729
|
+
---
|
|
730
|
+
|
|
695
731
|
## 🤝 Contributing
|
|
696
732
|
|
|
697
733
|
Contributions welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the ground rules (zero runtime dependencies, no build step, pure synth modules) and the dev loop. Found a bug? [Open an issue](https://github.com/kiril6/vibeaudio/issues/new) with your **OS**, **Node version**, and which audio player you have installed.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Procedural focus music while your AI coding tools (Claude Code, Codex, Cursor, Grok, Gemini, Copilot) think
|
|
3
|
+
"version": "0.8.1",
|
|
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",
|
|
7
7
|
"vibe": "bin/vibeaudio.js"
|
|
@@ -28,16 +28,25 @@
|
|
|
28
28
|
],
|
|
29
29
|
"keywords": [
|
|
30
30
|
"claude-code",
|
|
31
|
+
"claude",
|
|
31
32
|
"codex",
|
|
32
33
|
"cursor",
|
|
34
|
+
"gemini-cli",
|
|
35
|
+
"copilot",
|
|
33
36
|
"grok",
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
+
"qwen",
|
|
38
|
+
"ai",
|
|
39
|
+
"ai-agent",
|
|
40
|
+
"hooks",
|
|
41
|
+
"mcp",
|
|
42
|
+
"music",
|
|
43
|
+
"focus-music",
|
|
37
44
|
"lofi",
|
|
38
45
|
"synthwave",
|
|
39
46
|
"chiptune",
|
|
40
|
-
"
|
|
47
|
+
"productivity",
|
|
48
|
+
"notification",
|
|
49
|
+
"cli",
|
|
41
50
|
"developer-tools"
|
|
42
51
|
],
|
|
43
52
|
"author": "Kiril Delovski <delovski.office@gmail.com>",
|
package/src/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
getAudioPath,
|
|
12
12
|
clearCache,
|
|
13
13
|
detectPlayer,
|
|
14
|
+
bakedGain,
|
|
14
15
|
resolveGenre,
|
|
15
16
|
isKnownGenre,
|
|
16
17
|
normalizeVolume,
|
|
@@ -19,6 +20,7 @@ const {
|
|
|
19
20
|
projectSettings,
|
|
20
21
|
saveProjectConfig,
|
|
21
22
|
wavDurationMs,
|
|
23
|
+
projectSeed,
|
|
22
24
|
AVAILABLE_GENRES
|
|
23
25
|
} = require("./player");
|
|
24
26
|
const pkg = require("../package.json");
|
|
@@ -56,6 +58,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
56
58
|
vibe --genre 8bit sleep 5
|
|
57
59
|
vibe --volume 30 npm test
|
|
58
60
|
vibe --preview jazz
|
|
61
|
+
vibe --render \x1b[90m# save this repo's sound as a .wav to share\x1b[0m
|
|
59
62
|
vibe \x1b[90m# menu: pick a tool and a sound (p auditions a genre)\x1b[0m
|
|
60
63
|
|
|
61
64
|
\x1b[1mOPTIONS:\x1b[0m
|
|
@@ -71,6 +74,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
71
74
|
--no-chime Disable the resolution completion chime
|
|
72
75
|
--no-hud Disable terminal window/tab title animation
|
|
73
76
|
--preview <genre> Play one loop of a genre and exit
|
|
77
|
+
--render [file] Write this project's music to a .wav and exit
|
|
74
78
|
--status Show what is installed, running and detected, then exit
|
|
75
79
|
--stop Stop the background player, then exit
|
|
76
80
|
--mute [minutes] Silence everything for a call (default: 60 min, 0 = until unmuted)
|
|
@@ -164,6 +168,7 @@ function parseArgs(argv) {
|
|
|
164
168
|
let noChime = false;
|
|
165
169
|
let noHud = false;
|
|
166
170
|
let preview = null;
|
|
171
|
+
let render = null;
|
|
167
172
|
let clearCacheFlag = false;
|
|
168
173
|
let statusFlag = false;
|
|
169
174
|
let stopFlag = false;
|
|
@@ -250,6 +255,20 @@ function parseArgs(argv) {
|
|
|
250
255
|
continue;
|
|
251
256
|
}
|
|
252
257
|
|
|
258
|
+
// Optional filename: `--render` alone names the file after the genre.
|
|
259
|
+
// Only a non-flag counts, so `vibe --render --genre zen` still parses as
|
|
260
|
+
// both, the way `--mute claude` does.
|
|
261
|
+
if (arg === "--render") {
|
|
262
|
+
i += 1;
|
|
263
|
+
if (args[i] !== undefined && !args[i].startsWith("-")) {
|
|
264
|
+
render = args[i];
|
|
265
|
+
i += 1;
|
|
266
|
+
} else {
|
|
267
|
+
render = "";
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
253
272
|
if (arg === "--clear-cache") {
|
|
254
273
|
clearCacheFlag = true;
|
|
255
274
|
i += 1;
|
|
@@ -401,6 +420,7 @@ function parseArgs(argv) {
|
|
|
401
420
|
noChime,
|
|
402
421
|
noHud,
|
|
403
422
|
preview,
|
|
423
|
+
render,
|
|
404
424
|
clearCache: clearCacheFlag,
|
|
405
425
|
status: statusFlag,
|
|
406
426
|
stop: stopFlag,
|
|
@@ -915,6 +935,15 @@ function signalExitCode(signal) {
|
|
|
915
935
|
return 128 + (os.constants.signals[signal] || 0);
|
|
916
936
|
}
|
|
917
937
|
|
|
938
|
+
function previewAudioFile(genre, volume, backend) {
|
|
939
|
+
return getAudioPath(
|
|
940
|
+
resolveGenre(genre),
|
|
941
|
+
2,
|
|
942
|
+
projectSeed(),
|
|
943
|
+
bakedGain(backend, volume)
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
918
947
|
function previewGenre(genre, volume) {
|
|
919
948
|
if (!isKnownGenre(genre)) {
|
|
920
949
|
console.error(`\x1b[31m[vibeaudio] Unknown genre '${genre}'.\x1b[0m Available: ${AVAILABLE_GENRES.join(", ")}, random`);
|
|
@@ -928,13 +957,45 @@ function previewGenre(genre, volume) {
|
|
|
928
957
|
}
|
|
929
958
|
|
|
930
959
|
const resolved = resolveGenre(genre);
|
|
931
|
-
const audioFile =
|
|
960
|
+
const audioFile = previewAudioFile(resolved, volume, backend);
|
|
932
961
|
const seconds = ((wavDurationMs(audioFile) || 6500) / 1000).toFixed(1);
|
|
933
962
|
|
|
934
963
|
console.log(`\x1b[36m♫ Previewing \x1b[1m${resolved}\x1b[0m\x1b[36m (tier 2, ${seconds}s) — Ctrl+C to stop\x1b[0m`);
|
|
935
964
|
spawnSync(backend.cmd, backend.args(audioFile, volume), { stdio: "ignore" });
|
|
936
965
|
}
|
|
937
966
|
|
|
967
|
+
/**
|
|
968
|
+
* Writes this project's music to a file, so the thing that is actually
|
|
969
|
+
* distinctive about VibeAudio - that a repo has its own arrangement - is
|
|
970
|
+
* something a user can hear outside their own terminal, and send to someone.
|
|
971
|
+
*
|
|
972
|
+
* Rendered at full scale rather than at the configured volume: a file is
|
|
973
|
+
* played by something with its own volume control, and a quiet render is not
|
|
974
|
+
* recoverable. WAV because it needs no encoder on any platform; it is large,
|
|
975
|
+
* and the caller can compress it with whatever they already have.
|
|
976
|
+
*/
|
|
977
|
+
function renderToFile(target, genre) {
|
|
978
|
+
if (!isKnownGenre(genre)) {
|
|
979
|
+
console.error(`\x1b[31m[vibeaudio] Unknown genre '${genre}'.\x1b[0m Available: ${AVAILABLE_GENRES.join(", ")}, random`);
|
|
980
|
+
process.exit(1);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const { renderPiece } = require("./render");
|
|
984
|
+
const resolved = resolveGenre(genre);
|
|
985
|
+
const seed = projectSeed();
|
|
986
|
+
const file = path.resolve(target || `vibeaudio-${resolved}.wav`);
|
|
987
|
+
|
|
988
|
+
process.stdout.write(`\x1b[36m♫ Rendering \x1b[1m${resolved}\x1b[0m\x1b[36m for ${path.basename(process.cwd())}…\x1b[0m`);
|
|
989
|
+
const piece = renderPiece({ genre: resolved, seed });
|
|
990
|
+
fs.writeFileSync(file, piece.wav);
|
|
991
|
+
|
|
992
|
+
const seconds = (piece.durationMs / 1000).toFixed(0);
|
|
993
|
+
const mb = (piece.wav.length / 1024 / 1024).toFixed(1);
|
|
994
|
+
console.log(`\r\x1b[32m✔ Wrote ${file}\x1b[0m\x1b[K`);
|
|
995
|
+
console.log(` \x1b[90m${seconds}s, ${mb} MB — all three tiers and the success chime, seed ${seed >>> 0}.\x1b[0m`);
|
|
996
|
+
console.log(` \x1b[90mAnother project's sound: run it there, or vibe --seed <n> --render.\x1b[0m\n`);
|
|
997
|
+
}
|
|
998
|
+
|
|
938
999
|
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun, typed = {} }) {
|
|
939
1000
|
const hooks = require("./hooks");
|
|
940
1001
|
|
|
@@ -1025,6 +1086,20 @@ function hooksAlreadyCover(cmdArgs, settingsFile = null) {
|
|
|
1025
1086
|
}
|
|
1026
1087
|
}
|
|
1027
1088
|
|
|
1089
|
+
/**
|
|
1090
|
+
* Windows needs a shell for .cmd/.bat wrappers and for bare commands that may
|
|
1091
|
+
* resolve to one (npm, npx, etc). Real executables and explicit paths should
|
|
1092
|
+
* be spawned directly: cmd.exe otherwise breaks paths containing spaces.
|
|
1093
|
+
*/
|
|
1094
|
+
function windowsCommandNeedsShell(command) {
|
|
1095
|
+
const ext = path.extname(command).toLowerCase();
|
|
1096
|
+
|
|
1097
|
+
if (ext === ".cmd" || ext === ".bat") return true;
|
|
1098
|
+
if (ext) return false;
|
|
1099
|
+
|
|
1100
|
+
return !/[\\/]/.test(command);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1028
1103
|
function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRACE_PERIOD_MS, noChime, noHud = false) {
|
|
1029
1104
|
const player = new AudioPlayer();
|
|
1030
1105
|
const hookDriven = hooksAlreadyCover(cmdArgs);
|
|
@@ -1066,7 +1141,7 @@ function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRA
|
|
|
1066
1141
|
|
|
1067
1142
|
const child = spawn(command, commandArgs, {
|
|
1068
1143
|
stdio: "inherit",
|
|
1069
|
-
shell: process.platform === "win32"
|
|
1144
|
+
shell: process.platform === "win32" && windowsCommandNeedsShell(command)
|
|
1070
1145
|
});
|
|
1071
1146
|
|
|
1072
1147
|
// Runs exactly once: both the signal path and the close path lead here.
|
|
@@ -1132,6 +1207,7 @@ async function run() {
|
|
|
1132
1207
|
noChime,
|
|
1133
1208
|
noHud,
|
|
1134
1209
|
preview,
|
|
1210
|
+
render,
|
|
1135
1211
|
clearCache: shouldClear,
|
|
1136
1212
|
status: showStatus,
|
|
1137
1213
|
stop: shouldStop,
|
|
@@ -1218,6 +1294,10 @@ async function run() {
|
|
|
1218
1294
|
return previewGenre(preview, volume);
|
|
1219
1295
|
}
|
|
1220
1296
|
|
|
1297
|
+
if (render !== null) {
|
|
1298
|
+
return renderToFile(render, genre);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1221
1301
|
// Settings typed with no command to run: save them, don't open the launcher.
|
|
1222
1302
|
if (cmdArgs.length === 0 && Object.keys(typed).length > 0) {
|
|
1223
1303
|
return saveDefaults(typed, hereOnly);
|
|
@@ -1270,4 +1350,10 @@ async function run() {
|
|
|
1270
1350
|
executeCommand(cmdArgs, genre, volume, chimeVolume, grace, noChime, noHud);
|
|
1271
1351
|
}
|
|
1272
1352
|
|
|
1273
|
-
module.exports = {
|
|
1353
|
+
module.exports = {
|
|
1354
|
+
run,
|
|
1355
|
+
parseArgs,
|
|
1356
|
+
hooksAlreadyCover,
|
|
1357
|
+
previewAudioFile,
|
|
1358
|
+
windowsCommandNeedsShell
|
|
1359
|
+
};
|
package/src/player.js
CHANGED
|
@@ -69,6 +69,26 @@ const LOOP_OVERLAP_MS = 120;
|
|
|
69
69
|
const TIER_2_AFTER_MS = 15000;
|
|
70
70
|
const TIER_3_AFTER_MS = 45000;
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* How many bars one piece is played as.
|
|
74
|
+
*
|
|
75
|
+
* A tier renders one ~7 second loop and the last tier arrives at 45 seconds,
|
|
76
|
+
* so everything past a turn's first minute used to be a single file on
|
|
77
|
+
* repeat: ~85 identical plays across a ten-minute turn, ~250 across half an
|
|
78
|
+
* hour. Music stays in the background only until the ear starts tracking it,
|
|
79
|
+
* and what it does then is send someone to --mute for good.
|
|
80
|
+
*
|
|
81
|
+
* Each bar rotates to the next of the genre's own curated variants (see
|
|
82
|
+
* `rotate` in synth/generator.js), so the phrase is ~21 seconds instead of
|
|
83
|
+
* ~7 and every bar is still hand-written harmony. Three is not a taste
|
|
84
|
+
* setting: every genre carries exactly three variants, and rotation can only
|
|
85
|
+
* guarantee distinct bars while it has one to move to.
|
|
86
|
+
*
|
|
87
|
+
* Bar 0 is the unrotated variant, so a project still opens on the sound it
|
|
88
|
+
* has always had.
|
|
89
|
+
*/
|
|
90
|
+
const LOOP_BARS = 3;
|
|
91
|
+
|
|
72
92
|
/**
|
|
73
93
|
* Playback backends, in preference order. `volume` marks whether the backend
|
|
74
94
|
* can attenuate; the others play at system volume.
|
|
@@ -171,14 +191,37 @@ function clearCache() {
|
|
|
171
191
|
}
|
|
172
192
|
|
|
173
193
|
const GENRE_ALIASES = {
|
|
194
|
+
"lo-fi": "lofi",
|
|
195
|
+
"lofi-hiphop": "lofi",
|
|
196
|
+
chill: "lofi",
|
|
197
|
+
chillhop: "lofi",
|
|
198
|
+
study: "lofi",
|
|
174
199
|
chiptune: "8bit",
|
|
200
|
+
chip: "8bit",
|
|
201
|
+
nes: "8bit",
|
|
202
|
+
gameboy: "8bit",
|
|
203
|
+
retro: "8bit",
|
|
204
|
+
retrowave: "synthwave",
|
|
205
|
+
outrun: "synthwave",
|
|
206
|
+
"80s": "synthwave",
|
|
175
207
|
downtempo: "electronic",
|
|
208
|
+
techno: "electronic",
|
|
209
|
+
edm: "electronic",
|
|
176
210
|
bossa: "jazz",
|
|
211
|
+
swing: "jazz",
|
|
212
|
+
lounge: "jazz",
|
|
177
213
|
ambient: "zen",
|
|
214
|
+
calm: "zen",
|
|
215
|
+
meditation: "zen",
|
|
178
216
|
noise: "drone",
|
|
179
217
|
focus: "drone",
|
|
218
|
+
hum: "drone",
|
|
219
|
+
whitenoise: "drone",
|
|
220
|
+
"white-noise": "drone",
|
|
180
221
|
sparse: "piano",
|
|
181
|
-
satie: "piano"
|
|
222
|
+
satie: "piano",
|
|
223
|
+
keys: "piano",
|
|
224
|
+
minimal: "piano"
|
|
182
225
|
};
|
|
183
226
|
|
|
184
227
|
function isKnownGenre(genre) {
|
|
@@ -212,25 +255,25 @@ function resolveGenre(genre) {
|
|
|
212
255
|
return GENRE_ALIASES[normalized] || normalized;
|
|
213
256
|
}
|
|
214
257
|
|
|
215
|
-
function generateLoop(genre, tier, seed) {
|
|
258
|
+
function generateLoop(genre, tier, seed, bar = 0) {
|
|
216
259
|
switch (genre) {
|
|
217
260
|
case "synthwave":
|
|
218
|
-
return generateSynthwaveLoop(6.8, tier, seed);
|
|
261
|
+
return generateSynthwaveLoop(6.8, tier, seed, bar);
|
|
219
262
|
case "8bit":
|
|
220
|
-
return generateChiptuneLoop(7.5, tier, seed);
|
|
263
|
+
return generateChiptuneLoop(7.5, tier, seed, bar);
|
|
221
264
|
case "electronic":
|
|
222
|
-
return generateElectronicLoop(6.4, tier, seed);
|
|
265
|
+
return generateElectronicLoop(6.4, tier, seed, bar);
|
|
223
266
|
case "jazz":
|
|
224
|
-
return generateJazzLoop(6.26, tier, seed);
|
|
267
|
+
return generateJazzLoop(6.26, tier, seed, bar);
|
|
225
268
|
case "zen":
|
|
226
|
-
return generateZenLoop(7.2, tier, seed);
|
|
269
|
+
return generateZenLoop(7.2, tier, seed, bar);
|
|
227
270
|
case "drone":
|
|
228
|
-
return generateDroneLoop(7.0, tier, seed);
|
|
271
|
+
return generateDroneLoop(7.0, tier, seed, bar);
|
|
229
272
|
case "piano":
|
|
230
|
-
return generatePianoLoop(7.6, tier, seed);
|
|
273
|
+
return generatePianoLoop(7.6, tier, seed, bar);
|
|
231
274
|
case "lofi":
|
|
232
275
|
default:
|
|
233
|
-
return generateLofiLoop(6.4, tier, seed);
|
|
276
|
+
return generateLofiLoop(6.4, tier, seed, bar);
|
|
234
277
|
}
|
|
235
278
|
}
|
|
236
279
|
|
|
@@ -519,17 +562,23 @@ function writeCacheFileAtomic(filePath, buffer) {
|
|
|
519
562
|
}
|
|
520
563
|
}
|
|
521
564
|
|
|
522
|
-
function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1) {
|
|
565
|
+
function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0) {
|
|
523
566
|
ensureCacheDir();
|
|
524
567
|
const normalizedGenre = resolveGenre(genre);
|
|
525
568
|
const safeTier = Math.max(1, Math.min(3, tier));
|
|
569
|
+
const safeBar = ((bar % LOOP_BARS) + LOOP_BARS) % LOOP_BARS;
|
|
526
570
|
|
|
571
|
+
// Bars of one piece share the project's seed directory, so a project's
|
|
572
|
+
// music is still pruned as a unit. Bar 0 keeps the unsuffixed name and
|
|
573
|
+
// renders byte-identically to the pre-bars loop, so a project upgrading
|
|
574
|
+
// into this opens on exactly the bar it has always opened on.
|
|
575
|
+
const barSuffix = safeBar === 0 ? "" : `_b${safeBar}`;
|
|
527
576
|
const dir = seedDir(seed);
|
|
528
|
-
const filePath = path.join(dir, `loop_${normalizedGenre}_t${safeTier}${gainSuffix(gain)}.wav`);
|
|
577
|
+
const filePath = path.join(dir, `loop_${normalizedGenre}_t${safeTier}${barSuffix}${gainSuffix(gain)}.wav`);
|
|
529
578
|
|
|
530
579
|
if (!fs.existsSync(filePath)) {
|
|
531
580
|
fs.mkdirSync(dir, { recursive: true });
|
|
532
|
-
writeCacheFileAtomic(filePath, applyGain(generateLoop(normalizedGenre, safeTier, seed >>> 0), gain));
|
|
581
|
+
writeCacheFileAtomic(filePath, applyGain(generateLoop(normalizedGenre, safeTier, seed >>> 0, safeBar), gain));
|
|
533
582
|
pruneSeedDirs();
|
|
534
583
|
}
|
|
535
584
|
|
|
@@ -592,6 +641,7 @@ class AudioPlayer {
|
|
|
592
641
|
this.intensity = null;
|
|
593
642
|
this.minTier = null;
|
|
594
643
|
this.currentTier = 1;
|
|
644
|
+
this.bar = 0;
|
|
595
645
|
this.nextTimer = null;
|
|
596
646
|
this.watchdog = null;
|
|
597
647
|
}
|
|
@@ -621,6 +671,7 @@ class AudioPlayer {
|
|
|
621
671
|
this.genre = resolved;
|
|
622
672
|
this.volume = targetVolume;
|
|
623
673
|
this.seed = projectSeed();
|
|
674
|
+
this.bar = 0; // Every run opens on the project's own bar.
|
|
624
675
|
this.intensity = intensity;
|
|
625
676
|
this.minTier = minTier;
|
|
626
677
|
|
|
@@ -661,7 +712,9 @@ class AudioPlayer {
|
|
|
661
712
|
// never both, or the volume would be applied twice.
|
|
662
713
|
const backend = detectPlayer();
|
|
663
714
|
const gain = bakedGain(backend, this.volume);
|
|
664
|
-
const audioFile = getAudioPath(this.genre, this.currentTier, this.seed, gain);
|
|
715
|
+
const audioFile = getAudioPath(this.genre, this.currentTier, this.seed, gain, this.bar);
|
|
716
|
+
// Advanced after the choice, so the bar that plays first is bar 0.
|
|
717
|
+
this.bar = (this.bar + 1) % LOOP_BARS;
|
|
665
718
|
const proc = spawn(backend.cmd, backend.args(audioFile, this.volume), { stdio: "ignore" });
|
|
666
719
|
|
|
667
720
|
this.procs.add(proc);
|
|
@@ -748,6 +801,7 @@ module.exports = {
|
|
|
748
801
|
projectSeed,
|
|
749
802
|
wavDurationMs,
|
|
750
803
|
generateLoop,
|
|
804
|
+
LOOP_BARS,
|
|
751
805
|
AVAILABLE_GENRES,
|
|
752
806
|
SHUFFLE_GENRES,
|
|
753
807
|
synthFingerprint,
|
package/src/render.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering a piece to a file, rather than to the speakers.
|
|
3
|
+
*
|
|
4
|
+
* The live player spawns one bar at a time and crossfades the boundary by
|
|
5
|
+
* re-spawning early (LOOP_OVERLAP_MS in player.js); a file has to do that
|
|
6
|
+
* stitching itself, or the seams click. Everything here is pure - buffers in,
|
|
7
|
+
* buffer out - so both callers (`vibe --render` and the landing page's
|
|
8
|
+
* scripts/render-demo.js) get the same audio the player would have produced.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { generateLoop, LOOP_BARS } = require("./player");
|
|
12
|
+
const { generateSuccessChime } = require("./synth/chime");
|
|
13
|
+
const { createWavBuffer } = require("./synth/generator");
|
|
14
|
+
|
|
15
|
+
// Matches the live player's own crossfade window.
|
|
16
|
+
const LOOP_OVERLAP_MS = 120;
|
|
17
|
+
|
|
18
|
+
function parseWav(buf) {
|
|
19
|
+
const numChannels = buf.readUInt16LE(22);
|
|
20
|
+
const sampleRate = buf.readUInt32LE(24);
|
|
21
|
+
const dataSize = buf.readUInt32LE(40);
|
|
22
|
+
const numSamples = Math.floor(dataSize / 2 / numChannels);
|
|
23
|
+
const left = new Float64Array(numSamples);
|
|
24
|
+
const right = numChannels === 2 ? new Float64Array(numSamples) : null;
|
|
25
|
+
let offset = 44;
|
|
26
|
+
for (let i = 0; i < numSamples; i++) {
|
|
27
|
+
left[i] = buf.readInt16LE(offset) / 32768;
|
|
28
|
+
offset += 2;
|
|
29
|
+
if (right) {
|
|
30
|
+
right[i] = buf.readInt16LE(offset) / 32768;
|
|
31
|
+
offset += 2;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { sampleRate, left, right };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Crossfades consecutive clips over `overlapMs`, mirroring the live player's loop boundary. */
|
|
38
|
+
function crossfadeConcat(clips, overlapMs = LOOP_OVERLAP_MS) {
|
|
39
|
+
const sampleRate = clips[0].sampleRate;
|
|
40
|
+
const overlapSamples = Math.floor((sampleRate * overlapMs) / 1000);
|
|
41
|
+
const stereo = !!clips[0].right;
|
|
42
|
+
let totalLen = 0;
|
|
43
|
+
for (const c of clips) totalLen += c.left.length;
|
|
44
|
+
totalLen -= overlapSamples * (clips.length - 1);
|
|
45
|
+
|
|
46
|
+
const outL = new Float64Array(totalLen);
|
|
47
|
+
const outR = stereo ? new Float64Array(totalLen) : null;
|
|
48
|
+
const starts = [];
|
|
49
|
+
let pos = 0;
|
|
50
|
+
|
|
51
|
+
for (let ci = 0; ci < clips.length; ci++) {
|
|
52
|
+
const c = clips[ci];
|
|
53
|
+
starts.push(pos);
|
|
54
|
+
for (let i = 0; i < c.left.length; i++) {
|
|
55
|
+
const idx = pos + i;
|
|
56
|
+
if (idx >= totalLen) break;
|
|
57
|
+
let gL = c.left[i];
|
|
58
|
+
let gR = stereo ? c.right[i] : 0;
|
|
59
|
+
if (ci > 0 && i < overlapSamples) {
|
|
60
|
+
const fadeIn = i / overlapSamples;
|
|
61
|
+
gL *= fadeIn;
|
|
62
|
+
gR *= fadeIn;
|
|
63
|
+
}
|
|
64
|
+
if (ci < clips.length - 1 && i >= c.left.length - overlapSamples) {
|
|
65
|
+
const fadeOut = (c.left.length - i) / overlapSamples;
|
|
66
|
+
gL *= fadeOut;
|
|
67
|
+
gR *= fadeOut;
|
|
68
|
+
}
|
|
69
|
+
outL[idx] += gL;
|
|
70
|
+
if (outR) outR[idx] += gR;
|
|
71
|
+
}
|
|
72
|
+
pos += c.left.length - overlapSamples;
|
|
73
|
+
}
|
|
74
|
+
return { sampleRate, left: outL, right: outR, starts, totalLen };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Plain append, no crossfade - used for the trailing chime. */
|
|
78
|
+
function append(a, b) {
|
|
79
|
+
const totalLen = a.left.length + b.left.length;
|
|
80
|
+
const stereo = !!a.right;
|
|
81
|
+
const outL = new Float64Array(totalLen);
|
|
82
|
+
const outR = stereo ? new Float64Array(totalLen) : null;
|
|
83
|
+
outL.set(a.left, 0);
|
|
84
|
+
outL.set(b.left, a.left.length);
|
|
85
|
+
if (stereo) {
|
|
86
|
+
outR.set(a.right, 0);
|
|
87
|
+
outR.set(b.right, a.left.length);
|
|
88
|
+
}
|
|
89
|
+
return { sampleRate: a.sampleRate, left: outL, right: outR, chimeStart: a.left.length };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A whole session as one file: every bar of the phrase at each tier in turn,
|
|
94
|
+
* then the success chime - what someone would have heard sitting through a
|
|
95
|
+
* turn long enough to reach tier 3.
|
|
96
|
+
*
|
|
97
|
+
* `tiers` repeated per bar rather than per tier, so the escalation is audible
|
|
98
|
+
* without the phrase being cut short at the point it starts to make sense.
|
|
99
|
+
*/
|
|
100
|
+
function renderPiece({ genre = "lofi", seed = 0, tiers = [1, 2, 3], chime = true } = {}) {
|
|
101
|
+
const clips = [];
|
|
102
|
+
const tierStarts = [];
|
|
103
|
+
for (const tier of tiers) {
|
|
104
|
+
tierStarts.push(clips.length);
|
|
105
|
+
for (let bar = 0; bar < LOOP_BARS; bar++) {
|
|
106
|
+
clips.push(parseWav(generateLoop(genre, tier, seed, bar)));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const stitched = crossfadeConcat(clips);
|
|
111
|
+
const piece = chime ? append(stitched, parseWav(generateSuccessChime())) : stitched;
|
|
112
|
+
const wav = createWavBuffer({ left: piece.left, right: piece.right, sampleRate: piece.sampleRate });
|
|
113
|
+
|
|
114
|
+
const msAt = (samples) => Math.round((samples / piece.sampleRate) * 1000);
|
|
115
|
+
return {
|
|
116
|
+
wav,
|
|
117
|
+
durationMs: msAt(piece.left.length),
|
|
118
|
+
tierStartsMs: tierStarts.map((i) => msAt(stitched.starts[i])),
|
|
119
|
+
chimeStartMs: chime ? msAt(piece.chimeStart) : null
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { parseWav, crossfadeConcat, append, renderPiece, LOOP_OVERLAP_MS };
|
package/src/synth/chiptune.js
CHANGED
|
@@ -9,7 +9,7 @@ const {
|
|
|
9
9
|
softPulse,
|
|
10
10
|
triangle,
|
|
11
11
|
makeRng,
|
|
12
|
-
|
|
12
|
+
rotate,
|
|
13
13
|
createWavBuffer
|
|
14
14
|
} = require("./generator");
|
|
15
15
|
|
|
@@ -33,8 +33,8 @@ const VARIANTS = [
|
|
|
33
33
|
}
|
|
34
34
|
];
|
|
35
35
|
|
|
36
|
-
function generateChiptuneLoop(durationSec = 7.5, tier = 2, seed = 0) {
|
|
37
|
-
const variant =
|
|
36
|
+
function generateChiptuneLoop(durationSec = 7.5, tier = 2, seed = 0, bar = 0) {
|
|
37
|
+
const variant = rotate(makeRng(seed), VARIANTS, bar);
|
|
38
38
|
const bpm = 128;
|
|
39
39
|
const secPerBeat = 60.0 / bpm;
|
|
40
40
|
const totalBeats = Math.floor(durationSec / secPerBeat);
|
package/src/synth/drone.js
CHANGED
|
@@ -12,7 +12,7 @@ const {
|
|
|
12
12
|
noteToFreq,
|
|
13
13
|
sine,
|
|
14
14
|
makeRng,
|
|
15
|
-
|
|
15
|
+
rotate,
|
|
16
16
|
ornamentRng,
|
|
17
17
|
createWavBuffer
|
|
18
18
|
} = require("./generator");
|
|
@@ -26,10 +26,10 @@ const DRONE_VARIANTS = [
|
|
|
26
26
|
{ root: "E1", partial: 1.335, cutoff: 0.070, label: "open fourth" } // E + A
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
-
function generateDroneLoop(durationSec = 7.0, tier = 2, seed = 0) {
|
|
29
|
+
function generateDroneLoop(durationSec = 7.0, tier = 2, seed = 0, bar = 0) {
|
|
30
30
|
// Drawn before any tier gating, so gating a layer can't shift these.
|
|
31
31
|
const orn = ornamentRng(seed);
|
|
32
|
-
const variant =
|
|
32
|
+
const variant = rotate(orn, DRONE_VARIANTS, bar);
|
|
33
33
|
const sweepDepth = 0.55 + orn() * 0.35;
|
|
34
34
|
const breathOffset = orn() * Math.PI * 2;
|
|
35
35
|
|
package/src/synth/electronic.js
CHANGED
|
@@ -11,7 +11,7 @@ const {
|
|
|
11
11
|
triangle,
|
|
12
12
|
sine,
|
|
13
13
|
makeRng,
|
|
14
|
-
|
|
14
|
+
rotate,
|
|
15
15
|
ornamentRng,
|
|
16
16
|
pluckEnv,
|
|
17
17
|
createWavBuffer
|
|
@@ -34,8 +34,8 @@ const VARIANTS = [
|
|
|
34
34
|
}
|
|
35
35
|
];
|
|
36
36
|
|
|
37
|
-
function generateElectronicLoop(durationSec = 6.4, tier = 2, seed = 0) {
|
|
38
|
-
const variant =
|
|
37
|
+
function generateElectronicLoop(durationSec = 6.4, tier = 2, seed = 0, bar = 0) {
|
|
38
|
+
const variant = rotate(makeRng(seed), VARIANTS, bar);
|
|
39
39
|
const orn = ornamentRng(seed);
|
|
40
40
|
const bpm = 116;
|
|
41
41
|
const secPerBeat = 60.0 / bpm;
|
package/src/synth/generator.js
CHANGED
|
@@ -59,6 +59,28 @@ function pick(rng, options) {
|
|
|
59
59
|
return options[Math.floor(rng() * options.length) % options.length];
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The seed picks which curated variant a project gets; `bar` steps to the next
|
|
64
|
+
* one along, so one piece can be several bars long without any of them being
|
|
65
|
+
* invented.
|
|
66
|
+
*
|
|
67
|
+
* A loop is ~7 seconds and a tier stops changing at 45, so without this every
|
|
68
|
+
* turn past its first minute was one file on repeat - 85 identical plays
|
|
69
|
+
* across ten minutes, 250 across half an hour. Rotation is what makes the
|
|
70
|
+
* bars *guaranteed* different: salting the seed instead leaves it to chance,
|
|
71
|
+
* and with three variants per genre, three salted draws land on three
|
|
72
|
+
* different ones only 2 times in 9.
|
|
73
|
+
*
|
|
74
|
+
* Draws exactly once, like `pick`, so a stream shared with ornaments below is
|
|
75
|
+
* left in the same place and `bar = 0` renders what the seed alone always
|
|
76
|
+
* rendered.
|
|
77
|
+
*/
|
|
78
|
+
function rotate(rng, options, bar = 0) {
|
|
79
|
+
const index = Math.floor(rng() * options.length) % options.length;
|
|
80
|
+
const step = ((bar % options.length) + options.length) % options.length;
|
|
81
|
+
return options[(index + step) % options.length];
|
|
82
|
+
}
|
|
83
|
+
|
|
62
84
|
/**
|
|
63
85
|
* Ornament placement draws from its own stream so that gating a layer by tier
|
|
64
86
|
* can't shift the choices made by other layers - tier 1 and tier 3 stay the
|
|
@@ -196,6 +218,7 @@ module.exports = {
|
|
|
196
218
|
hashString,
|
|
197
219
|
pick,
|
|
198
220
|
ornamentRng,
|
|
221
|
+
rotate,
|
|
199
222
|
noteToFreq,
|
|
200
223
|
sine,
|
|
201
224
|
triangle,
|
package/src/synth/jazz.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
sine,
|
|
10
10
|
triangle,
|
|
11
11
|
makeRng,
|
|
12
|
+
rotate,
|
|
12
13
|
pick,
|
|
13
14
|
ornamentRng,
|
|
14
15
|
createWavBuffer
|
|
@@ -36,9 +37,9 @@ const CHANGES = [
|
|
|
36
37
|
|
|
37
38
|
const STAB_NOTES = ["D5", "E5", "G5", "A5", "B5"];
|
|
38
39
|
|
|
39
|
-
function generateJazzLoop(durationSec = 6.26, tier = 2, seed = 0) {
|
|
40
|
+
function generateJazzLoop(durationSec = 6.26, tier = 2, seed = 0, bar = 0) {
|
|
40
41
|
const rng = makeRng(seed);
|
|
41
|
-
const changes =
|
|
42
|
+
const changes = rotate(rng, CHANGES, bar);
|
|
42
43
|
const bpm = 92;
|
|
43
44
|
const secPerBeat = 60.0 / bpm;
|
|
44
45
|
const totalBeats = Math.floor(durationSec / secPerBeat);
|
package/src/synth/lofi.js
CHANGED
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
SAMPLE_RATE,
|
|
8
8
|
noteToFreq,
|
|
9
9
|
makeRng,
|
|
10
|
+
rotate,
|
|
10
11
|
pick,
|
|
11
12
|
ornamentRng,
|
|
12
13
|
createWavBuffer
|
|
@@ -24,9 +25,9 @@ const PROGRESSIONS = [
|
|
|
24
25
|
const DROP_NOTES = ["C5", "D5", "E5", "G5", "A5", "B5", "C6"];
|
|
25
26
|
const SHIMMER_NOTES = ["E6", "G6", "A6", "B6"];
|
|
26
27
|
|
|
27
|
-
function generateLofiLoop(durationSec = 6.4, tier = 2, seed = 0) {
|
|
28
|
+
function generateLofiLoop(durationSec = 6.4, tier = 2, seed = 0, bar = 0) {
|
|
28
29
|
const rng = makeRng(seed);
|
|
29
|
-
const progression =
|
|
30
|
+
const progression = rotate(rng, PROGRESSIONS, bar);
|
|
30
31
|
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
31
32
|
const left = new Float64Array(totalSamples);
|
|
32
33
|
const right = new Float64Array(totalSamples);
|
package/src/synth/piano.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
noteToFreq,
|
|
14
14
|
sine,
|
|
15
15
|
makeRng,
|
|
16
|
+
rotate,
|
|
16
17
|
pick,
|
|
17
18
|
ornamentRng,
|
|
18
19
|
createWavBuffer
|
|
@@ -52,9 +53,9 @@ const PARTIALS = [
|
|
|
52
53
|
{ mult: 5.05, amp: 0.035, decay: 4.5 }
|
|
53
54
|
];
|
|
54
55
|
|
|
55
|
-
function generatePianoLoop(durationSec = 7.6, tier = 2, seed = 0) {
|
|
56
|
+
function generatePianoLoop(durationSec = 7.6, tier = 2, seed = 0, bar = 0) {
|
|
56
57
|
const rng = makeRng(seed);
|
|
57
|
-
const variant =
|
|
58
|
+
const variant = rotate(rng, PIANO_VARIANTS, bar);
|
|
58
59
|
|
|
59
60
|
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
60
61
|
const left = new Float64Array(totalSamples);
|
package/src/synth/synthwave.js
CHANGED
|
@@ -9,7 +9,7 @@ const {
|
|
|
9
9
|
analogSaw,
|
|
10
10
|
triangle,
|
|
11
11
|
makeRng,
|
|
12
|
-
|
|
12
|
+
rotate,
|
|
13
13
|
pluckEnv,
|
|
14
14
|
createWavBuffer
|
|
15
15
|
} = require("./generator");
|
|
@@ -52,8 +52,8 @@ const PROGRESSIONS = [
|
|
|
52
52
|
}
|
|
53
53
|
];
|
|
54
54
|
|
|
55
|
-
function generateSynthwaveLoop(durationSec = 6.8, tier = 2, seed = 0) {
|
|
56
|
-
const variant =
|
|
55
|
+
function generateSynthwaveLoop(durationSec = 6.8, tier = 2, seed = 0, bar = 0) {
|
|
56
|
+
const variant = rotate(makeRng(seed), PROGRESSIONS, bar);
|
|
57
57
|
const progression = variant.steps;
|
|
58
58
|
const arps = variant.arps;
|
|
59
59
|
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
package/src/synth/zen.js
CHANGED
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
noteToFreq,
|
|
9
9
|
sine,
|
|
10
10
|
makeRng,
|
|
11
|
+
rotate,
|
|
11
12
|
pick,
|
|
12
13
|
ornamentRng,
|
|
13
14
|
createWavBuffer
|
|
@@ -24,9 +25,9 @@ const PAD_PAIRS = [
|
|
|
24
25
|
// D major pentatonic - the safest set to strike a bowl on over any pad above.
|
|
25
26
|
const BOWL_NOTES = ["D4", "E4", "F#4", "A4", "B4", "D5"];
|
|
26
27
|
|
|
27
|
-
function generateZenLoop(durationSec = 7.2, tier = 2, seed = 0) {
|
|
28
|
+
function generateZenLoop(durationSec = 7.2, tier = 2, seed = 0, bar = 0) {
|
|
28
29
|
const rng = makeRng(seed);
|
|
29
|
-
const pads =
|
|
30
|
+
const pads = rotate(rng, PAD_PAIRS, bar);
|
|
30
31
|
|
|
31
32
|
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
32
33
|
const left = new Float64Array(totalSamples);
|