readme-waves 1.0.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 +139 -0
- package/dist/cli/index.js +330 -0
- package/dist/cli/video-7X5JNMOZ.js +132 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# readme-waves
|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
|
|
5
|
+
Generate animated music equalizer visualizations for GitHub profile READMEs. Built in Rust.
|
|
6
|
+
|
|
7
|
+
Renders your music as a contribution-graph-style equalizer — frequency bands from left (bass) to right (treble), with GitHub's green color levels stacking from bottom to top based on intensity.
|
|
8
|
+
|
|
9
|
+
## Output Modes
|
|
10
|
+
|
|
11
|
+
| Mode | Format | Audio | Auto-play in README | Best for |
|
|
12
|
+
|------|--------|-------|---------------------|----------|
|
|
13
|
+
| SVG | `.svg` | No | Yes (CSS animation) | Visual-only, lightweight |
|
|
14
|
+
| Video | `.mp4` | Yes | Requires manual embed | Full experience with sound |
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
### CLI
|
|
19
|
+
|
|
20
|
+
Requires [Rust](https://rustup.rs/) and [yt-dlp](https://github.com/yt-dlp/yt-dlp) (for YouTube mode).
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Build
|
|
24
|
+
cargo build --release
|
|
25
|
+
|
|
26
|
+
# From local audio file → SVG
|
|
27
|
+
./target/release/readme-waves song.mp3 -o waveform.svg
|
|
28
|
+
|
|
29
|
+
# From local audio file → MP4 video with audio
|
|
30
|
+
./target/release/readme-waves song.mp3 --video -o waveform.mp4
|
|
31
|
+
|
|
32
|
+
# From YouTube playlist (picks a random song)
|
|
33
|
+
./target/release/readme-waves "https://www.youtube.com/playlist?list=PLxxxxx" -o waveform.svg
|
|
34
|
+
|
|
35
|
+
# Video from YouTube playlist
|
|
36
|
+
./target/release/readme-waves "https://www.youtube.com/playlist?list=PLxxxxx" --video -o waveform.mp4
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### CLI Options
|
|
40
|
+
|
|
41
|
+
| Flag | Description | Default |
|
|
42
|
+
|------|-------------|---------|
|
|
43
|
+
| `<input>` | Audio file path or YouTube playlist URL | (required) |
|
|
44
|
+
| `-o, --output` | Output file path | `waveform.svg` |
|
|
45
|
+
| `--video` | Generate MP4 video with audio | `false` |
|
|
46
|
+
| `--rows` | Amplitude levels (height in squares) | `7` |
|
|
47
|
+
| `--cols` | Frequency bands (number of columns) | `52` |
|
|
48
|
+
| `--frames` | Animation frames (SVG mode) | `60` |
|
|
49
|
+
| `--fps` | Video frame rate | `24` |
|
|
50
|
+
| `--duration` | SVG animation loop (seconds) | `10` |
|
|
51
|
+
| `--theme` | Color theme: `dark`, `light` | `dark` |
|
|
52
|
+
| `--background` | Background color | `transparent` |
|
|
53
|
+
|
|
54
|
+
### GitHub Action
|
|
55
|
+
|
|
56
|
+
> **Note:** The action currently generates SVG files. For video mode, use the CLI locally.
|
|
57
|
+
|
|
58
|
+
Add to `.github/workflows/waveform.yml`:
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
name: Generate Waveform
|
|
62
|
+
|
|
63
|
+
on:
|
|
64
|
+
schedule:
|
|
65
|
+
- cron: '0 0 * * *'
|
|
66
|
+
workflow_dispatch:
|
|
67
|
+
|
|
68
|
+
jobs:
|
|
69
|
+
generate:
|
|
70
|
+
runs-on: ubuntu-latest
|
|
71
|
+
steps:
|
|
72
|
+
- uses: actions/checkout@v4
|
|
73
|
+
|
|
74
|
+
- uses: TakalaWang/readme-waves@main
|
|
75
|
+
with:
|
|
76
|
+
playlist_url: 'https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID'
|
|
77
|
+
output_path: assets/waveform.svg
|
|
78
|
+
rows: '7'
|
|
79
|
+
cols: '52'
|
|
80
|
+
|
|
81
|
+
- uses: stefanzweifel/git-auto-commit-action@v5
|
|
82
|
+
with:
|
|
83
|
+
commit_message: 'chore: update waveform'
|
|
84
|
+
file_pattern: 'assets/*'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Embed in your README:
|
|
88
|
+
|
|
89
|
+
```markdown
|
|
90
|
+

|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Action Inputs
|
|
94
|
+
|
|
95
|
+
| Input | Description | Default |
|
|
96
|
+
|-------|-------------|---------|
|
|
97
|
+
| `audio_file` | Local audio file path | - |
|
|
98
|
+
| `playlist_url` | YouTube playlist URL (random song daily) | - |
|
|
99
|
+
| `output_path` | Output SVG path | `assets/waveform.svg` |
|
|
100
|
+
| `rows` | Amplitude levels (grid height) | `7` |
|
|
101
|
+
| `cols` | Frequency bands (grid width) | `52` |
|
|
102
|
+
| `frames` | Animation frames | `60` |
|
|
103
|
+
| `animation_duration` | Animation loop (seconds) | `10` |
|
|
104
|
+
| `theme` | Color theme (`dark` or `light`) | `dark` |
|
|
105
|
+
| `background_color` | SVG background | `transparent` |
|
|
106
|
+
|
|
107
|
+
## How It Works
|
|
108
|
+
|
|
109
|
+
1. Decodes audio using [symphonia](https://github.com/pdeljanov/Symphonia) (pure Rust, no ffmpeg)
|
|
110
|
+
2. Applies Short-Time Fourier Transform (STFT) via [rustfft](https://github.com/ejmahler/RustFFT)
|
|
111
|
+
3. Trims to the lower 30% of the frequency range (where music energy lives)
|
|
112
|
+
4. Per-band normalization with gamma correction for even dynamic range
|
|
113
|
+
5. Generates equalizer visualization:
|
|
114
|
+
- **SVG mode**: per-cell CSS `@keyframes` animation
|
|
115
|
+
- **Video mode**: raw pixel frames piped to ffmpeg with the original audio
|
|
116
|
+
|
|
117
|
+
## Color Themes
|
|
118
|
+
|
|
119
|
+
**Dark** (default) — GitHub dark mode contribution colors:
|
|
120
|
+
```
|
|
121
|
+
#161b22 #0e4429 #006d32 #26a641 #39d353
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Light** — GitHub light mode contribution colors:
|
|
125
|
+
```
|
|
126
|
+
#ebedf0 #9be9a8 #40c463 #30a14e #216e39
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Tech Stack
|
|
130
|
+
|
|
131
|
+
- [symphonia](https://github.com/pdeljanov/Symphonia) — pure Rust audio decoding (mp3, wav, flac, ogg, aac)
|
|
132
|
+
- [rustfft](https://github.com/ejmahler/RustFFT) — FFT spectrum analysis
|
|
133
|
+
- [clap](https://github.com/clap-rs/clap) — CLI argument parsing
|
|
134
|
+
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — YouTube audio download (optional)
|
|
135
|
+
- [ffmpeg](https://ffmpeg.org/) — video encoding (video mode only)
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// cli/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// lib/audio.ts
|
|
7
|
+
var FFT_SIZE = 1024;
|
|
8
|
+
function fft(re, im) {
|
|
9
|
+
const n = re.length;
|
|
10
|
+
for (let i = 1, j = 0; i < n; i++) {
|
|
11
|
+
let bit = n >> 1;
|
|
12
|
+
while (j & bit) {
|
|
13
|
+
j ^= bit;
|
|
14
|
+
bit >>= 1;
|
|
15
|
+
}
|
|
16
|
+
j ^= bit;
|
|
17
|
+
if (i < j) {
|
|
18
|
+
[re[i], re[j]] = [re[j], re[i]];
|
|
19
|
+
[im[i], im[j]] = [im[j], im[i]];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
for (let len = 2; len <= n; len *= 2) {
|
|
23
|
+
const halfLen = len / 2;
|
|
24
|
+
const angle = -2 * Math.PI / len;
|
|
25
|
+
const wRe = Math.cos(angle);
|
|
26
|
+
const wIm = Math.sin(angle);
|
|
27
|
+
for (let i = 0; i < n; i += len) {
|
|
28
|
+
let curRe = 1;
|
|
29
|
+
let curIm = 0;
|
|
30
|
+
for (let j = 0; j < halfLen; j++) {
|
|
31
|
+
const a = i + j;
|
|
32
|
+
const b = a + halfLen;
|
|
33
|
+
const tRe = curRe * re[b] - curIm * im[b];
|
|
34
|
+
const tIm = curRe * im[b] + curIm * re[b];
|
|
35
|
+
re[b] = re[a] - tRe;
|
|
36
|
+
im[b] = im[a] - tIm;
|
|
37
|
+
re[a] += tRe;
|
|
38
|
+
im[a] += tIm;
|
|
39
|
+
const nextRe = curRe * wRe - curIm * wIm;
|
|
40
|
+
curIm = curRe * wIm + curIm * wRe;
|
|
41
|
+
curRe = nextRe;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function hannWindow(buf) {
|
|
47
|
+
const n = buf.length;
|
|
48
|
+
for (let i = 0; i < n; i++) {
|
|
49
|
+
buf[i] *= 0.5 * (1 - Math.cos(2 * Math.PI * i / (n - 1)));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function magnitudeSpectrum(samples) {
|
|
53
|
+
const re = new Float64Array(samples);
|
|
54
|
+
const im = new Float64Array(FFT_SIZE);
|
|
55
|
+
hannWindow(re);
|
|
56
|
+
fft(re, im);
|
|
57
|
+
const half = FFT_SIZE / 2;
|
|
58
|
+
const mag = new Float64Array(half);
|
|
59
|
+
for (let i = 0; i < half; i++) {
|
|
60
|
+
mag[i] = Math.sqrt(re[i] * re[i] + im[i] * im[i]);
|
|
61
|
+
}
|
|
62
|
+
return mag;
|
|
63
|
+
}
|
|
64
|
+
function logBands(bars, maxBin) {
|
|
65
|
+
const usableBins = Math.floor(maxBin * 0.3);
|
|
66
|
+
const edges = [];
|
|
67
|
+
const logMin = Math.log(1);
|
|
68
|
+
const logMax = Math.log(usableBins + 1);
|
|
69
|
+
for (let i = 0; i <= bars; i++) {
|
|
70
|
+
const val = Math.exp(logMin + (logMax - logMin) * (i / bars));
|
|
71
|
+
edges.push(Math.round(val));
|
|
72
|
+
}
|
|
73
|
+
return edges;
|
|
74
|
+
}
|
|
75
|
+
function extractSpectrumFromPCM(samples, _sampleRate, bars, frames) {
|
|
76
|
+
const totalSamples = samples.length;
|
|
77
|
+
const hopSize = Math.max(1, Math.floor((totalSamples - FFT_SIZE) / (frames - 1)));
|
|
78
|
+
const halfBins = FFT_SIZE / 2;
|
|
79
|
+
const edges = logBands(bars, halfBins);
|
|
80
|
+
const raw = [];
|
|
81
|
+
for (let f = 0; f < frames; f++) {
|
|
82
|
+
const offset = Math.min(f * hopSize, totalSamples - FFT_SIZE);
|
|
83
|
+
const chunk = new Float64Array(FFT_SIZE);
|
|
84
|
+
for (let i = 0; i < FFT_SIZE; i++) {
|
|
85
|
+
chunk[i] = samples[offset + i] ?? 0;
|
|
86
|
+
}
|
|
87
|
+
const mag = magnitudeSpectrum(chunk);
|
|
88
|
+
const bandValues = [];
|
|
89
|
+
for (let b = 0; b < bars; b++) {
|
|
90
|
+
const lo = Math.max(edges[b], 1);
|
|
91
|
+
const hi = Math.max(edges[b + 1], lo + 1);
|
|
92
|
+
let sum = 0;
|
|
93
|
+
let count = 0;
|
|
94
|
+
for (let k = lo; k < hi && k < halfBins; k++) {
|
|
95
|
+
sum += mag[k];
|
|
96
|
+
count++;
|
|
97
|
+
}
|
|
98
|
+
bandValues.push(count > 0 ? sum / count : 0);
|
|
99
|
+
}
|
|
100
|
+
raw.push(bandValues);
|
|
101
|
+
}
|
|
102
|
+
const bandMax = new Float64Array(bars);
|
|
103
|
+
let globalMax = 0;
|
|
104
|
+
for (let b = 0; b < bars; b++) {
|
|
105
|
+
for (let f = 0; f < frames; f++) {
|
|
106
|
+
if (raw[f][b] > bandMax[b]) bandMax[b] = raw[f][b];
|
|
107
|
+
}
|
|
108
|
+
if (bandMax[b] > globalMax) globalMax = bandMax[b];
|
|
109
|
+
}
|
|
110
|
+
if (globalMax === 0) {
|
|
111
|
+
return raw;
|
|
112
|
+
}
|
|
113
|
+
const result = [];
|
|
114
|
+
for (let f = 0; f < frames; f++) {
|
|
115
|
+
const row = [];
|
|
116
|
+
for (let b = 0; b < bars; b++) {
|
|
117
|
+
if (bandMax[b] === 0) {
|
|
118
|
+
row.push(0);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const weight = Math.pow(bandMax[b] / globalMax, 0.6);
|
|
122
|
+
const normalized = raw[f][b] / bandMax[b] * weight;
|
|
123
|
+
row.push(Math.sqrt(Math.min(normalized, 1)));
|
|
124
|
+
}
|
|
125
|
+
result.push(row);
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
async function extractSpectrumFromFile(filePath, bars, frames) {
|
|
130
|
+
const { execFile: execFile2 } = await import("child_process");
|
|
131
|
+
const { promisify: promisify2 } = await import("util");
|
|
132
|
+
const exec2 = promisify2(execFile2);
|
|
133
|
+
const SAMPLE_RATE = 16e3;
|
|
134
|
+
const { stdout } = await exec2(
|
|
135
|
+
"ffmpeg",
|
|
136
|
+
["-i", filePath, "-ac", "1", "-ar", String(SAMPLE_RATE), "-f", "f32le", "-v", "quiet", "pipe:1"],
|
|
137
|
+
{ encoding: "buffer", maxBuffer: 100 * 1024 * 1024 }
|
|
138
|
+
);
|
|
139
|
+
const samples = new Float32Array(
|
|
140
|
+
stdout.buffer,
|
|
141
|
+
stdout.byteOffset,
|
|
142
|
+
stdout.byteLength / 4
|
|
143
|
+
);
|
|
144
|
+
return extractSpectrumFromPCM(samples, SAMPLE_RATE, bars, frames);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// lib/types.ts
|
|
148
|
+
var DEFAULT_OPTIONS = {
|
|
149
|
+
rows: 7,
|
|
150
|
+
cols: 52,
|
|
151
|
+
cellSize: 10,
|
|
152
|
+
cellGap: 3,
|
|
153
|
+
cellRadius: 2,
|
|
154
|
+
animationDuration: 10,
|
|
155
|
+
theme: "dark",
|
|
156
|
+
backgroundColor: "transparent"
|
|
157
|
+
};
|
|
158
|
+
var THEMES = {
|
|
159
|
+
dark: ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"],
|
|
160
|
+
light: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"]
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// lib/svg.ts
|
|
164
|
+
function escapeXml(s) {
|
|
165
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
166
|
+
}
|
|
167
|
+
function litRows(amp, rows) {
|
|
168
|
+
return Math.round(amp * rows);
|
|
169
|
+
}
|
|
170
|
+
function rowColorLevel(r, rows) {
|
|
171
|
+
if (rows <= 1) return 4;
|
|
172
|
+
const ratio = r / (rows - 1);
|
|
173
|
+
if (ratio < 0.33) return 2;
|
|
174
|
+
if (ratio < 0.66) return 3;
|
|
175
|
+
return 4;
|
|
176
|
+
}
|
|
177
|
+
function generateSvg(spectrum, options, metadata) {
|
|
178
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
179
|
+
const { rows, cols, cellSize, cellGap, cellRadius, animationDuration, theme, backgroundColor } = opts;
|
|
180
|
+
const colors = THEMES[theme];
|
|
181
|
+
const frames = spectrum.length;
|
|
182
|
+
const step = cellSize + cellGap;
|
|
183
|
+
const gridWidth = cols * step - cellGap;
|
|
184
|
+
const gridHeight = rows * step - cellGap;
|
|
185
|
+
const headerHeight = metadata ? 45 : 0;
|
|
186
|
+
const svgWidth = gridWidth;
|
|
187
|
+
const svgHeight = gridHeight + headerHeight;
|
|
188
|
+
const lines = [];
|
|
189
|
+
lines.push(
|
|
190
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${svgWidth}" height="${svgHeight}" viewBox="0 0 ${svgWidth} ${svgHeight}">`
|
|
191
|
+
);
|
|
192
|
+
if (backgroundColor !== "transparent") {
|
|
193
|
+
lines.push(`<rect width="100%" height="100%" fill="${backgroundColor}"/>`);
|
|
194
|
+
}
|
|
195
|
+
if (metadata) {
|
|
196
|
+
const cx = 16;
|
|
197
|
+
const cy = 22;
|
|
198
|
+
const r = 14;
|
|
199
|
+
lines.push(`<g>`);
|
|
200
|
+
lines.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${colors[3]}" opacity="0.9"/>`);
|
|
201
|
+
const tx = cx - 4;
|
|
202
|
+
const ty = cy - 7;
|
|
203
|
+
lines.push(
|
|
204
|
+
`<polygon points="${tx + 3},${ty} ${tx + 3 + 12},${ty + 7} ${tx + 3},${ty + 14}" fill="${theme === "dark" ? "#fff" : "#000"}"/>`
|
|
205
|
+
);
|
|
206
|
+
const textX = cx + r + 8;
|
|
207
|
+
const textColor = theme === "dark" ? "#c9d1d9" : "#24292f";
|
|
208
|
+
lines.push(
|
|
209
|
+
`<a href="${escapeXml(metadata.url)}" target="_blank"><text x="${textX}" y="${cy + 5}" font-family="Segoe UI,Helvetica,Arial,sans-serif" font-size="13" fill="${textColor}">${escapeXml(metadata.title)} \u2014 ${escapeXml(metadata.artist)}</text></a>`
|
|
210
|
+
);
|
|
211
|
+
lines.push(`</g>`);
|
|
212
|
+
}
|
|
213
|
+
const cssLines = [];
|
|
214
|
+
for (let c = 0; c < cols; c++) {
|
|
215
|
+
for (let r = 0; r < rows; r++) {
|
|
216
|
+
const keyframeName = `c${c}r${r}`;
|
|
217
|
+
const kfParts = [];
|
|
218
|
+
for (let f = 0; f < frames; f++) {
|
|
219
|
+
const pct = (f / (frames - 1) * 100).toFixed(2);
|
|
220
|
+
const amp = spectrum[f]?.[c] ?? 0;
|
|
221
|
+
const lit = litRows(amp, rows);
|
|
222
|
+
const isLit = r < lit;
|
|
223
|
+
let colorIdx;
|
|
224
|
+
if (isLit) {
|
|
225
|
+
colorIdx = rowColorLevel(r, rows);
|
|
226
|
+
} else {
|
|
227
|
+
colorIdx = 0;
|
|
228
|
+
}
|
|
229
|
+
kfParts.push(`${pct}%{fill:${colors[colorIdx]}}`);
|
|
230
|
+
}
|
|
231
|
+
cssLines.push(`@keyframes ${keyframeName}{${kfParts.join("")}}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
lines.push(`<style>`);
|
|
235
|
+
for (const css of cssLines) {
|
|
236
|
+
lines.push(css);
|
|
237
|
+
}
|
|
238
|
+
lines.push(`</style>`);
|
|
239
|
+
const gridY = headerHeight;
|
|
240
|
+
for (let c = 0; c < cols; c++) {
|
|
241
|
+
for (let r = 0; r < rows; r++) {
|
|
242
|
+
const x = c * step;
|
|
243
|
+
const y = gridY + (rows - 1 - r) * step;
|
|
244
|
+
const keyframeName = `c${c}r${r}`;
|
|
245
|
+
lines.push(
|
|
246
|
+
`<rect x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="${cellRadius}" fill="${colors[0]}" style="animation:${keyframeName} ${animationDuration}s ease-in-out infinite"/>`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
lines.push(`</svg>`);
|
|
251
|
+
return lines.join("\n");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// cli/youtube.ts
|
|
255
|
+
import { execFile } from "child_process";
|
|
256
|
+
import { promisify } from "util";
|
|
257
|
+
var exec = promisify(execFile);
|
|
258
|
+
async function getVideoMetadata(videoUrl) {
|
|
259
|
+
const { stdout } = await exec("yt-dlp", ["--no-download", "-j", "--no-playlist", videoUrl]);
|
|
260
|
+
const data = JSON.parse(stdout);
|
|
261
|
+
return {
|
|
262
|
+
id: data.id ?? "",
|
|
263
|
+
title: data.title ?? data.fulltitle ?? "Unknown",
|
|
264
|
+
artist: data.artist ?? data.uploader ?? data.channel ?? "Unknown",
|
|
265
|
+
url: data.webpage_url ?? videoUrl
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
async function downloadAudio(videoUrl, outputPath) {
|
|
269
|
+
await exec("yt-dlp", ["-x", "--audio-format", "mp3", "-o", outputPath, "--no-playlist", videoUrl], {
|
|
270
|
+
timeout: 3e5
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// cli/index.ts
|
|
275
|
+
import { writeFile, mkdir, unlink } from "fs/promises";
|
|
276
|
+
import { dirname, join } from "path";
|
|
277
|
+
import { tmpdir } from "os";
|
|
278
|
+
var program = new Command().name("readme-waves").description("Generate animated music equalizer SVGs").argument("<input>", "Audio file path or YouTube URL").option("-o, --output <path>", "Output path", "waveform.svg").option("--video", "Generate MP4 video with audio instead of SVG").option("--rows <n>", "Amplitude levels", "7").option("--cols <n>", "Frequency bands", "52").option("--frames <n>", "Animation frames (SVG mode)", "60").option("--fps <n>", "Video frame rate", "24").option("--duration <s>", "Animation duration in seconds (SVG mode)", "10").option("--theme <theme>", "Color theme: dark, light", "dark").action(async (input, opts) => {
|
|
279
|
+
const isYoutube = input.includes("youtube.com") || input.includes("youtu.be");
|
|
280
|
+
let audioPath = input;
|
|
281
|
+
let metadata;
|
|
282
|
+
let tempFile;
|
|
283
|
+
if (isYoutube) {
|
|
284
|
+
console.log("Fetching video info...");
|
|
285
|
+
const info = await getVideoMetadata(input);
|
|
286
|
+
metadata = { title: info.title, artist: info.artist, url: info.url };
|
|
287
|
+
console.log(`${info.title} - ${info.artist}`);
|
|
288
|
+
tempFile = join(tmpdir(), `readme-waves-${info.id}.mp3`);
|
|
289
|
+
console.log("Downloading audio...");
|
|
290
|
+
await downloadAudio(input, tempFile);
|
|
291
|
+
audioPath = tempFile;
|
|
292
|
+
}
|
|
293
|
+
if (opts.video) {
|
|
294
|
+
const { generateVideo, getAudioDuration } = await import("./video-7X5JNMOZ.js");
|
|
295
|
+
const output = opts.output.endsWith(".svg") ? opts.output.replace(".svg", ".mp4") : opts.output;
|
|
296
|
+
const duration = await getAudioDuration(audioPath);
|
|
297
|
+
const fps = +opts.fps;
|
|
298
|
+
const totalFrames = Math.floor(duration * fps);
|
|
299
|
+
console.log(`Audio: ${duration.toFixed(1)}s, generating ${totalFrames} frames...`);
|
|
300
|
+
const spectrum = await extractSpectrumFromFile(audioPath, +opts.cols, totalFrames);
|
|
301
|
+
await mkdir(dirname(output), { recursive: true });
|
|
302
|
+
await generateVideo(spectrum, audioPath, output, +opts.rows, +opts.cols, fps);
|
|
303
|
+
console.log(`Video saved to: ${output}`);
|
|
304
|
+
} else {
|
|
305
|
+
console.log(`Extracting spectrum (${opts.cols} bands, ${opts.frames} frames)...`);
|
|
306
|
+
const spectrum = await extractSpectrumFromFile(audioPath, +opts.cols, +opts.frames);
|
|
307
|
+
console.log("Generating SVG...");
|
|
308
|
+
const svg = generateSvg(
|
|
309
|
+
spectrum,
|
|
310
|
+
{
|
|
311
|
+
rows: +opts.rows,
|
|
312
|
+
cols: +opts.cols,
|
|
313
|
+
animationDuration: +opts.duration,
|
|
314
|
+
theme: opts.theme
|
|
315
|
+
},
|
|
316
|
+
metadata
|
|
317
|
+
);
|
|
318
|
+
await mkdir(dirname(opts.output), { recursive: true });
|
|
319
|
+
await writeFile(opts.output, svg);
|
|
320
|
+
console.log(`Saved to: ${opts.output}`);
|
|
321
|
+
}
|
|
322
|
+
if (metadata) {
|
|
323
|
+
const jsonPath = opts.output.replace(/\.(svg|mp4)$/, ".json");
|
|
324
|
+
await writeFile(jsonPath, JSON.stringify(metadata, null, 2));
|
|
325
|
+
console.log(`Metadata: ${jsonPath}`);
|
|
326
|
+
}
|
|
327
|
+
if (tempFile) await unlink(tempFile).catch(() => {
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
program.parse();
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// cli/video.ts
|
|
2
|
+
import { execFile } from "child_process";
|
|
3
|
+
var COLORS = [
|
|
4
|
+
[22, 27, 34],
|
|
5
|
+
// level 0: empty
|
|
6
|
+
[0, 109, 50],
|
|
7
|
+
// level 1 (skip #0e4429, too dark)
|
|
8
|
+
[38, 166, 65],
|
|
9
|
+
// level 2
|
|
10
|
+
[57, 211, 83],
|
|
11
|
+
// level 3
|
|
12
|
+
[57, 211, 83]
|
|
13
|
+
// level 4 (same as 3 for top)
|
|
14
|
+
];
|
|
15
|
+
var BG = [13, 17, 23];
|
|
16
|
+
function rowColor(r, rows) {
|
|
17
|
+
if (rows <= 1) return COLORS[3];
|
|
18
|
+
const ratio = r / (rows - 1);
|
|
19
|
+
if (ratio < 0.33) return COLORS[1];
|
|
20
|
+
if (ratio < 0.66) return COLORS[2];
|
|
21
|
+
return COLORS[3];
|
|
22
|
+
}
|
|
23
|
+
async function generateVideo(spectrum, audioPath, outputPath, rows, cols, fps) {
|
|
24
|
+
if (spectrum.length === 0 || spectrum[0].length === 0) {
|
|
25
|
+
throw new Error("Empty spectrum data");
|
|
26
|
+
}
|
|
27
|
+
const cellSize = 12;
|
|
28
|
+
const cellGap = 3;
|
|
29
|
+
const cellPitch = cellSize + cellGap;
|
|
30
|
+
const padding = 20;
|
|
31
|
+
const numBands = spectrum[0].length;
|
|
32
|
+
const gridWidth = numBands * cellPitch - cellGap;
|
|
33
|
+
const gridHeight = rows * cellPitch - cellGap;
|
|
34
|
+
let width = gridWidth + padding * 2;
|
|
35
|
+
let height = gridHeight + padding * 2;
|
|
36
|
+
width = width + 1 & ~1;
|
|
37
|
+
height = height + 1 & ~1;
|
|
38
|
+
const totalFrames = spectrum.length;
|
|
39
|
+
console.log(`Rendering video: ${width}x${height} @ ${fps}fps, ${totalFrames} frames, ${numBands} bands...`);
|
|
40
|
+
const ffmpeg = execFile("ffmpeg", [
|
|
41
|
+
"-y",
|
|
42
|
+
"-f",
|
|
43
|
+
"rawvideo",
|
|
44
|
+
"-pix_fmt",
|
|
45
|
+
"rgb24",
|
|
46
|
+
"-s",
|
|
47
|
+
`${width}x${height}`,
|
|
48
|
+
"-r",
|
|
49
|
+
String(fps),
|
|
50
|
+
"-i",
|
|
51
|
+
"pipe:0",
|
|
52
|
+
"-i",
|
|
53
|
+
audioPath,
|
|
54
|
+
"-c:v",
|
|
55
|
+
"libx264",
|
|
56
|
+
"-preset",
|
|
57
|
+
"fast",
|
|
58
|
+
"-pix_fmt",
|
|
59
|
+
"yuv420p",
|
|
60
|
+
"-c:a",
|
|
61
|
+
"aac",
|
|
62
|
+
"-b:a",
|
|
63
|
+
"192k",
|
|
64
|
+
"-shortest",
|
|
65
|
+
"-movflags",
|
|
66
|
+
"+faststart",
|
|
67
|
+
outputPath
|
|
68
|
+
]);
|
|
69
|
+
const stdin = ffmpeg.stdin;
|
|
70
|
+
const frameSize = width * height * 3;
|
|
71
|
+
const buf = Buffer.alloc(frameSize);
|
|
72
|
+
for (let f = 0; f < totalFrames; f++) {
|
|
73
|
+
for (let i = 0; i < frameSize; i += 3) {
|
|
74
|
+
buf[i] = BG[0];
|
|
75
|
+
buf[i + 1] = BG[1];
|
|
76
|
+
buf[i + 2] = BG[2];
|
|
77
|
+
}
|
|
78
|
+
for (let band = 0; band < numBands; band++) {
|
|
79
|
+
const amp = spectrum[f][band];
|
|
80
|
+
const lit = Math.min(Math.round(amp * rows), rows);
|
|
81
|
+
for (let r = 0; r < rows; r++) {
|
|
82
|
+
const isLit = rows - r <= lit;
|
|
83
|
+
const color = isLit ? rowColor(r, rows) : COLORS[0];
|
|
84
|
+
const x0 = padding + band * cellPitch;
|
|
85
|
+
const y0 = padding + r * cellPitch;
|
|
86
|
+
for (let dy = 0; dy < cellSize; dy++) {
|
|
87
|
+
for (let dx = 0; dx < cellSize; dx++) {
|
|
88
|
+
const px = x0 + dx;
|
|
89
|
+
const py = y0 + dy;
|
|
90
|
+
if (px < width && py < height) {
|
|
91
|
+
const idx = (py * width + px) * 3;
|
|
92
|
+
buf[idx] = color[0];
|
|
93
|
+
buf[idx + 1] = color[1];
|
|
94
|
+
buf[idx + 2] = color[2];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
stdin.write(buf);
|
|
101
|
+
if (f % 100 === 0) {
|
|
102
|
+
console.log(` frame ${f + 1}/${totalFrames}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
await new Promise((resolve, reject) => {
|
|
106
|
+
stdin.end(() => {
|
|
107
|
+
ffmpeg.on("close", (code) => {
|
|
108
|
+
if (code === 0) resolve();
|
|
109
|
+
else reject(new Error(`ffmpeg exited with code ${code}`));
|
|
110
|
+
});
|
|
111
|
+
ffmpeg.on("error", reject);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async function getAudioDuration(filePath) {
|
|
116
|
+
const { promisify } = await import("util");
|
|
117
|
+
const exec = promisify(execFile);
|
|
118
|
+
const { stdout } = await exec("ffprobe", [
|
|
119
|
+
"-v",
|
|
120
|
+
"quiet",
|
|
121
|
+
"-show_entries",
|
|
122
|
+
"format=duration",
|
|
123
|
+
"-of",
|
|
124
|
+
"csv=p=0",
|
|
125
|
+
filePath
|
|
126
|
+
]);
|
|
127
|
+
return parseFloat(stdout.trim());
|
|
128
|
+
}
|
|
129
|
+
export {
|
|
130
|
+
generateVideo,
|
|
131
|
+
getAudioDuration
|
|
132
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "readme-waves",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Generate animated music equalizer SVGs for GitHub profile READMEs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"readme-waves": "./dist/cli/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "next dev",
|
|
14
|
+
"build": "next build",
|
|
15
|
+
"build:cli": "tsup cli/index.ts --format esm --out-dir dist/cli --target node22",
|
|
16
|
+
"start": "next start"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"github",
|
|
20
|
+
"readme",
|
|
21
|
+
"music",
|
|
22
|
+
"equalizer",
|
|
23
|
+
"waveform",
|
|
24
|
+
"svg"
|
|
25
|
+
],
|
|
26
|
+
"author": "TakalaWang",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@ffmpeg/ffmpeg": "^0.12.15",
|
|
30
|
+
"@ffmpeg/util": "^0.12.2",
|
|
31
|
+
"commander": "^14.0.3",
|
|
32
|
+
"next": "^16.2.2",
|
|
33
|
+
"react": "^19.2.4",
|
|
34
|
+
"react-dom": "^19.2.4"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@tailwindcss/postcss": "^4.2.2",
|
|
38
|
+
"@types/node": "^25.5.2",
|
|
39
|
+
"@types/react": "^19.2.14",
|
|
40
|
+
"@types/react-dom": "^19.2.3",
|
|
41
|
+
"postcss": "^8.5.8",
|
|
42
|
+
"tailwindcss": "^4.2.2",
|
|
43
|
+
"tsup": "^8.5.1",
|
|
44
|
+
"typescript": "^6.0.2"
|
|
45
|
+
}
|
|
46
|
+
}
|