@huozhi/tune 0.1.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 +27 -0
- package/dist/cli.js +197 -0
- package/examples/README.md +23 -0
- package/examples/chill.wav +0 -0
- package/package.json +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# tune
|
|
2
|
+
|
|
3
|
+
Generate short, random, procedural background music locally from the terminal. No prompts, API keys, model downloads, or audio library are required.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @huozhi/tune --duration 10 --style chill --output music.wav
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
With Bun:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bunx @huozhi/tune --duration 10 --style upbeat --output music.wav
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bun run generate --duration 10 --output music.wav
|
|
21
|
+
bun run generate --duration 10 --style upbeat --output upbeat.wav
|
|
22
|
+
bun run generate --duration 12 --video input.mp4 --output output.mp4
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Styles are `chill`, `upbeat`, `cinematic`, `lofi`, and `playful`. Use `--seed 123` to reproduce the same track. The video mode requires FFmpeg; audio-only mode does not.
|
|
26
|
+
|
|
27
|
+
See [examples/](examples/) for a generated sample and more commands.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/cli.ts
|
|
5
|
+
import { unlink, writeFile } from "fs/promises";
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
var STYLES = {
|
|
8
|
+
chill: { bpm: [68, 82], chord: [0, 3, 7, 10], pad: 0.07, bass: 0.09, melody: 0.045, percussion: 0.55, melodyDensity: 0.55 },
|
|
9
|
+
upbeat: { bpm: [112, 132], chord: [0, 4, 7, 11], pad: 0.045, bass: 0.14, melody: 0.065, percussion: 1, melodyDensity: 1 },
|
|
10
|
+
cinematic: { bpm: [seventy(), 88], chord: [0, 5, 7, 12], pad: 0.1, bass: 0.12, melody: 0.04, percussion: 0.35, melodyDensity: 0.38 },
|
|
11
|
+
lofi: { bpm: [72, 88], chord: [0, 3, 7, 10], pad: 0.06, bass: 0.13, melody: 0.05, percussion: 0.7, melodyDensity: 0.65 },
|
|
12
|
+
playful: { bpm: [96, 118], chord: [0, 4, 7, 9], pad: 0.04, bass: 0.11, melody: 0.08, percussion: 0.85, melodyDensity: 1 }
|
|
13
|
+
};
|
|
14
|
+
function seventy() {
|
|
15
|
+
return 70;
|
|
16
|
+
}
|
|
17
|
+
var SAMPLE_RATE = 44100;
|
|
18
|
+
var TAU = Math.PI * 2;
|
|
19
|
+
function usage() {
|
|
20
|
+
console.log(`tune \u2014 random procedural background music
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
bun run generate --duration 10 --output music.wav
|
|
24
|
+
bun run generate --duration 12 --video input.mp4 --output output.mp4
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--duration, -d Length in seconds (default: 10)
|
|
28
|
+
--output, -o Output WAV, or MP4 when --video is used
|
|
29
|
+
--video, -v Existing video to attach the generated music to
|
|
30
|
+
--seed, -s Repeatable random seed (optional)
|
|
31
|
+
--style chill, upbeat, cinematic, lofi, or playful (default: chill)
|
|
32
|
+
--help, -h Show this help
|
|
33
|
+
`);
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
function parseArgs(args) {
|
|
37
|
+
let duration = 10;
|
|
38
|
+
let output = "music.wav";
|
|
39
|
+
let video;
|
|
40
|
+
let seed = Math.floor(Math.random() * 2 ** 31);
|
|
41
|
+
let style = "chill";
|
|
42
|
+
for (let i = 0;i < args.length; i++) {
|
|
43
|
+
const arg = args[i];
|
|
44
|
+
if (arg === "--help" || arg === "-h")
|
|
45
|
+
usage();
|
|
46
|
+
const next = args[++i];
|
|
47
|
+
if (arg === "--duration" || arg === "-d")
|
|
48
|
+
duration = Number(next);
|
|
49
|
+
else if (arg === "--output" || arg === "-o")
|
|
50
|
+
output = next;
|
|
51
|
+
else if (arg === "--video" || arg === "-v")
|
|
52
|
+
video = next;
|
|
53
|
+
else if (arg === "--seed" || arg === "-s")
|
|
54
|
+
seed = Number(next);
|
|
55
|
+
else if (arg === "--style")
|
|
56
|
+
style = next;
|
|
57
|
+
else
|
|
58
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
59
|
+
}
|
|
60
|
+
if (!Number.isFinite(duration) || duration <= 0 || duration > 300) {
|
|
61
|
+
throw new Error("Duration must be between 0 and 300 seconds.");
|
|
62
|
+
}
|
|
63
|
+
if (!output)
|
|
64
|
+
throw new Error("An output path is required.");
|
|
65
|
+
if (!(style in STYLES))
|
|
66
|
+
throw new Error(`Unknown style: ${style}. Choose: ${Object.keys(STYLES).join(", ")}`);
|
|
67
|
+
return { duration, output, video, seed: seed >>> 0, style };
|
|
68
|
+
}
|
|
69
|
+
function rng(initial) {
|
|
70
|
+
let state = initial >>> 0;
|
|
71
|
+
return () => {
|
|
72
|
+
state = state * 1664525 + 1013904223 >>> 0;
|
|
73
|
+
return state / 2 ** 32;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function midi(n) {
|
|
77
|
+
return 440 * Math.pow(2, (n - 69) / 12);
|
|
78
|
+
}
|
|
79
|
+
function envelope(t, length) {
|
|
80
|
+
const attack = Math.min(0.04, length * 0.15);
|
|
81
|
+
const release = Math.min(0.18, length * 0.35);
|
|
82
|
+
if (t < attack)
|
|
83
|
+
return t / attack;
|
|
84
|
+
if (t > length - release)
|
|
85
|
+
return Math.max(0, (length - t) / release);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
function addNote(buffer, start, length, frequency, gain, kind, phase = 0) {
|
|
89
|
+
const from = Math.max(0, Math.floor(start * SAMPLE_RATE));
|
|
90
|
+
const to = Math.min(buffer.length, Math.ceil((start + length) * SAMPLE_RATE));
|
|
91
|
+
for (let i = from;i < to; i++) {
|
|
92
|
+
const t = i / SAMPLE_RATE - start;
|
|
93
|
+
const x = TAU * frequency * t + phase;
|
|
94
|
+
const wave = kind === "pad" ? Math.sin(x) * 0.75 + Math.sin(x * 2.01) * 0.18 : kind === "bass" ? Math.sin(x) * 0.8 + Math.sin(x * 2) * 0.2 : Math.sin(x) * 0.72 + Math.sin(x * 2) * 0.2 + Math.sin(x * 3) * 0.08;
|
|
95
|
+
const e = envelope(t, length) * (kind === "pad" ? 0.6 : 1);
|
|
96
|
+
buffer[i] += wave * gain * e;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function addKick(buffer, start, gain) {
|
|
100
|
+
const from = Math.floor(start * SAMPLE_RATE);
|
|
101
|
+
const length = 0.22;
|
|
102
|
+
for (let i = from;i < Math.min(buffer.length, from + length * SAMPLE_RATE); i++) {
|
|
103
|
+
const t = (i - from) / SAMPLE_RATE;
|
|
104
|
+
const frequency = 120 * Math.exp(-t * 18) + 42;
|
|
105
|
+
buffer[i] += Math.sin(TAU * frequency * t) * Math.exp(-t * 22) * gain;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function addHat(buffer, start, gain, random) {
|
|
109
|
+
const from = Math.floor(start * SAMPLE_RATE);
|
|
110
|
+
const length = 0.055;
|
|
111
|
+
for (let i = from;i < Math.min(buffer.length, from + length * SAMPLE_RATE); i++) {
|
|
112
|
+
const t = (i - from) / SAMPLE_RATE;
|
|
113
|
+
buffer[i] += (random() * 2 - 1) * Math.exp(-t * 75) * gain;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function makeTrack(duration, seed, styleName) {
|
|
117
|
+
const random = rng(seed);
|
|
118
|
+
const style = STYLES[styleName];
|
|
119
|
+
const total = Math.ceil(duration * SAMPLE_RATE);
|
|
120
|
+
const buffer = new Float32Array(total);
|
|
121
|
+
const bpm = Math.floor(style.bpm[0] + random() * (style.bpm[1] - style.bpm[0] + 1));
|
|
122
|
+
const beat = 60 / bpm;
|
|
123
|
+
const roots = [48, 45, 50, 43].map((n) => n + Math.floor(random() * 3));
|
|
124
|
+
const chordOffsets = style.chord;
|
|
125
|
+
for (let bar = 0, time = 0;time < duration; bar++, time += beat * 4) {
|
|
126
|
+
const root = roots[bar % roots.length];
|
|
127
|
+
for (const offset of chordOffsets)
|
|
128
|
+
addNote(buffer, time, beat * 4.2, midi(root + offset), style.pad, "pad");
|
|
129
|
+
addNote(buffer, time, beat * 2, midi(root - 12), style.bass, "bass");
|
|
130
|
+
addNote(buffer, time + beat * 2, beat * 2, midi(root - 12), style.bass * 0.85, "bass");
|
|
131
|
+
for (let b = 0;b < 4; b++) {
|
|
132
|
+
const beatTime = time + b * beat;
|
|
133
|
+
if (beatTime < duration)
|
|
134
|
+
addKick(buffer, beatTime, 0.2 * style.percussion);
|
|
135
|
+
if (beatTime + beat / 2 < duration)
|
|
136
|
+
addHat(buffer, beatTime + beat / 2, 0.035 * style.percussion, random);
|
|
137
|
+
}
|
|
138
|
+
for (let step = 0;step < 4; step++) {
|
|
139
|
+
if (random() > style.melodyDensity)
|
|
140
|
+
continue;
|
|
141
|
+
const note = root + 12 + chordOffsets[Math.floor(random() * chordOffsets.length)];
|
|
142
|
+
const noteTime = time + (step + 0.5) * beat;
|
|
143
|
+
if (noteTime < duration)
|
|
144
|
+
addNote(buffer, noteTime, beat * 0.38, midi(note), style.melody, "pluck", random() * TAU);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (let i = 0;i < buffer.length; i++) {
|
|
148
|
+
const t = i / SAMPLE_RATE;
|
|
149
|
+
const fadeIn = Math.min(1, t / 0.35);
|
|
150
|
+
const fadeOut = Math.min(1, (duration - t) / 0.7);
|
|
151
|
+
buffer[i] = Math.max(-1, Math.min(1, buffer[i] * Math.max(0, Math.min(fadeIn, fadeOut))));
|
|
152
|
+
}
|
|
153
|
+
return { buffer, bpm };
|
|
154
|
+
}
|
|
155
|
+
function wavBytes(samples) {
|
|
156
|
+
const bytes = new ArrayBuffer(44 + samples.length * 2);
|
|
157
|
+
const view = new DataView(bytes);
|
|
158
|
+
const text = (offset, value) => [...value].forEach((c, i) => view.setUint8(offset + i, c.charCodeAt(0)));
|
|
159
|
+
text(0, "RIFF");
|
|
160
|
+
view.setUint32(4, 36 + samples.length * 2, true);
|
|
161
|
+
text(8, "WAVE");
|
|
162
|
+
text(12, "fmt ");
|
|
163
|
+
view.setUint32(16, 16, true);
|
|
164
|
+
view.setUint16(20, 1, true);
|
|
165
|
+
view.setUint16(22, 1, true);
|
|
166
|
+
view.setUint32(24, SAMPLE_RATE, true);
|
|
167
|
+
view.setUint32(28, SAMPLE_RATE * 2, true);
|
|
168
|
+
view.setUint16(32, 2, true);
|
|
169
|
+
view.setUint16(34, 16, true);
|
|
170
|
+
text(36, "data");
|
|
171
|
+
view.setUint32(40, samples.length * 2, true);
|
|
172
|
+
samples.forEach((sample, i) => view.setInt16(44 + i * 2, sample < 0 ? sample * 32768 : sample * 32767, true));
|
|
173
|
+
return new Uint8Array(bytes);
|
|
174
|
+
}
|
|
175
|
+
async function main() {
|
|
176
|
+
try {
|
|
177
|
+
const options = parseArgs(process.argv.slice(2));
|
|
178
|
+
const track = makeTrack(options.duration, options.seed, options.style);
|
|
179
|
+
const temp = options.video ? `${options.output}.music.wav` : options.output;
|
|
180
|
+
await writeFile(temp, wavBytes(track.buffer));
|
|
181
|
+
if (options.video) {
|
|
182
|
+
const code = await new Promise((resolve, reject) => {
|
|
183
|
+
const proc = spawn("ffmpeg", ["-y", "-i", options.video, "-i", temp, "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", "-c:a", "aac", "-shortest", options.output], { stdio: ["ignore", "inherit", "inherit"] });
|
|
184
|
+
proc.on("error", reject);
|
|
185
|
+
proc.on("close", (exitCode) => resolve(exitCode ?? 1));
|
|
186
|
+
});
|
|
187
|
+
if (code !== 0)
|
|
188
|
+
throw new Error("FFmpeg failed. Is it installed and on PATH?");
|
|
189
|
+
await unlink(temp);
|
|
190
|
+
}
|
|
191
|
+
console.log(`Created ${options.output} (${options.style}, ${options.duration}s, ${track.bpm} BPM, seed ${options.seed})`);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.error(`Error: ${error instanceof Error ? error.message : error}`);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
await main();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# tune examples
|
|
2
|
+
|
|
3
|
+
This folder contains a generated 10-second `chill` track:
|
|
4
|
+
|
|
5
|
+
<audio controls src="./chill.wav"></audio>
|
|
6
|
+
|
|
7
|
+
If GitHub does not render the player in your context, download [chill.wav](./chill.wav) and play it locally.
|
|
8
|
+
|
|
9
|
+
Generate your own examples from the project root:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun run generate --duration 10 --style chill --seed 11 --output examples/chill.wav
|
|
13
|
+
bun run generate --duration 10 --style upbeat --seed 27 --output examples/upbeat.wav
|
|
14
|
+
bun run generate --duration 10 --style cinematic --seed 93 --output examples/cinematic.wav
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Available styles:
|
|
18
|
+
|
|
19
|
+
- `chill` — soft and spacious
|
|
20
|
+
- `upbeat` — bright and rhythmic
|
|
21
|
+
- `cinematic` — slow and expansive
|
|
22
|
+
- `lofi` — mellow and understated
|
|
23
|
+
- `playful` — bouncy and melodic
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huozhi/tune",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate short random procedural background music from the terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tune": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "bun src/cli.ts",
|
|
11
|
+
"generate": "bun src/cli.ts",
|
|
12
|
+
"build": "bun build src/cli.ts --target=node --format=esm --outfile=dist/cli.js",
|
|
13
|
+
"check": "bun run build && node dist/cli.js --help && npm pack --dry-run",
|
|
14
|
+
"prepublishOnly": "bun run build",
|
|
15
|
+
"web:dev": "cd web && bun devjar dev",
|
|
16
|
+
"web:build": "cd web && bun devjar build"
|
|
17
|
+
},
|
|
18
|
+
"files": ["dist", "README.md", "examples"],
|
|
19
|
+
"keywords": ["audio", "music", "generator", "procedural", "cli", "background-music"],
|
|
20
|
+
"engines": { "node": ">=22" },
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"devjar": "^1.1.0"
|
|
24
|
+
}
|
|
25
|
+
}
|