readme-waves 1.0.0 → 1.0.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/LICENSE +21 -0
- package/README.md +38 -73
- package/dist/cli/index.js +22 -25
- package/dist/cli/{video-7X5JNMOZ.js → video-ZMHOXSZH.js} +12 -10
- package/package.json +5 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TakalaWang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# readme-waves
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/readme-waves)
|
|
3
4
|
[](https://opensource.org/licenses/MIT)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
**[Web App](https://readme-wave.takalawang.dev)** | **[npm](https://www.npmjs.com/package/readme-waves)**
|
|
7
|
+
|
|
8
|
+
Generate animated music equalizer visualizations for GitHub profile READMEs.
|
|
6
9
|
|
|
7
10
|
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
11
|
|
|
@@ -17,30 +20,31 @@ Renders your music as a contribution-graph-style equalizer — frequency bands f
|
|
|
17
20
|
|
|
18
21
|
### CLI
|
|
19
22
|
|
|
20
|
-
Requires [
|
|
23
|
+
Requires [ffmpeg](https://ffmpeg.org/) and [yt-dlp](https://github.com/yt-dlp/yt-dlp) (for YouTube mode).
|
|
21
24
|
|
|
22
25
|
```bash
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# From local audio file → SVG
|
|
27
|
-
./target/release/readme-waves song.mp3 -o waveform.svg
|
|
26
|
+
# From local audio file
|
|
27
|
+
npx readme-waves song.mp3 -o waveform.svg
|
|
28
28
|
|
|
29
|
-
#
|
|
30
|
-
|
|
29
|
+
# MP4 video with audio
|
|
30
|
+
npx readme-waves song.mp3 --video -o waveform.mp4
|
|
31
31
|
|
|
32
|
-
# From YouTube
|
|
33
|
-
|
|
32
|
+
# From YouTube URL
|
|
33
|
+
npx readme-waves "https://www.youtube.com/watch?v=xxxxx" -o waveform.svg
|
|
34
34
|
|
|
35
|
-
#
|
|
36
|
-
|
|
35
|
+
# YouTube → MP4
|
|
36
|
+
npx readme-waves "https://www.youtube.com/watch?v=xxxxx" --video -o waveform.mp4
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
### Web App
|
|
40
|
+
|
|
41
|
+
Visit **[readme-wave.takalawang.dev](https://readme-wave.takalawang.dev)** — upload an audio file to generate SVG or MP4 directly in your browser. No server processing, everything runs client-side.
|
|
42
|
+
|
|
39
43
|
### CLI Options
|
|
40
44
|
|
|
41
45
|
| Flag | Description | Default |
|
|
42
46
|
|------|-------------|---------|
|
|
43
|
-
| `<input>` | Audio file path or YouTube
|
|
47
|
+
| `<input>` | Audio file path or YouTube URL | (required) |
|
|
44
48
|
| `-o, --output` | Output file path | `waveform.svg` |
|
|
45
49
|
| `--video` | Generate MP4 video with audio | `false` |
|
|
46
50
|
| `--rows` | Amplitude levels (height in squares) | `7` |
|
|
@@ -49,90 +53,51 @@ cargo build --release
|
|
|
49
53
|
| `--fps` | Video frame rate | `24` |
|
|
50
54
|
| `--duration` | SVG animation loop (seconds) | `10` |
|
|
51
55
|
| `--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
56
|
|
|
74
|
-
|
|
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:
|
|
57
|
+
## Embed in GitHub README
|
|
88
58
|
|
|
89
59
|
```markdown
|
|
90
60
|

|
|
91
61
|
```
|
|
92
62
|
|
|
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
63
|
## How It Works
|
|
108
64
|
|
|
109
|
-
1. Decodes audio
|
|
110
|
-
2. Applies Short-Time Fourier Transform (STFT)
|
|
65
|
+
1. Decodes audio via ffmpeg (CLI) or Web Audio API (browser)
|
|
66
|
+
2. Applies Short-Time Fourier Transform (STFT) with pure TypeScript FFT
|
|
111
67
|
3. Trims to the lower 30% of the frequency range (where music energy lives)
|
|
112
68
|
4. Per-band normalization with gamma correction for even dynamic range
|
|
113
69
|
5. Generates equalizer visualization:
|
|
114
70
|
- **SVG mode**: per-cell CSS `@keyframes` animation
|
|
115
|
-
- **Video mode**: raw pixel frames piped to ffmpeg
|
|
71
|
+
- **Video mode**: raw pixel frames piped to ffmpeg (CLI) or ffmpeg.wasm (browser)
|
|
116
72
|
|
|
117
73
|
## Color Themes
|
|
118
74
|
|
|
119
75
|
**Dark** (default) — GitHub dark mode contribution colors:
|
|
120
76
|
```
|
|
121
|
-
#161b22 #
|
|
77
|
+
#161b22 #006d32 #26a641 #39d353
|
|
122
78
|
```
|
|
123
79
|
|
|
124
80
|
**Light** — GitHub light mode contribution colors:
|
|
125
81
|
```
|
|
126
|
-
#ebedf0 #
|
|
82
|
+
#ebedf0 #40c463 #30a14e #216e39
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Local Development
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pnpm install
|
|
89
|
+
pnpm dev # Next.js dev server
|
|
90
|
+
pnpm build:cli # Build CLI
|
|
127
91
|
```
|
|
128
92
|
|
|
129
93
|
## Tech Stack
|
|
130
94
|
|
|
131
|
-
- [
|
|
132
|
-
- [
|
|
133
|
-
- [
|
|
134
|
-
- [
|
|
135
|
-
- [
|
|
95
|
+
- [Next.js](https://nextjs.org/) — web app
|
|
96
|
+
- [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API) — browser audio decoding
|
|
97
|
+
- [@ffmpeg/ffmpeg](https://github.com/ffmpegwasm/ffmpeg.wasm) — browser video encoding
|
|
98
|
+
- [commander](https://github.com/tj/commander.js) — CLI
|
|
99
|
+
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — YouTube download (CLI)
|
|
100
|
+
- [ffmpeg](https://ffmpeg.org/) — audio/video processing (CLI)
|
|
136
101
|
|
|
137
102
|
## License
|
|
138
103
|
|
package/dist/cli/index.js
CHANGED
|
@@ -72,14 +72,14 @@ function logBands(bars, maxBin) {
|
|
|
72
72
|
}
|
|
73
73
|
return edges;
|
|
74
74
|
}
|
|
75
|
-
function extractSpectrumFromPCM(samples,
|
|
75
|
+
function extractSpectrumFromPCM(samples, sampleRate, bars, frames, fps) {
|
|
76
76
|
const totalSamples = samples.length;
|
|
77
|
-
const
|
|
77
|
+
const maxOffset = Math.max(0, totalSamples - FFT_SIZE);
|
|
78
78
|
const halfBins = FFT_SIZE / 2;
|
|
79
79
|
const edges = logBands(bars, halfBins);
|
|
80
80
|
const raw = [];
|
|
81
81
|
for (let f = 0; f < frames; f++) {
|
|
82
|
-
const offset = Math.min(f *
|
|
82
|
+
const offset = fps ? Math.min(Math.round(f * sampleRate / fps), maxOffset) : frames <= 1 ? 0 : Math.min(Math.round(f * maxOffset / (frames - 1)), maxOffset);
|
|
83
83
|
const chunk = new Float64Array(FFT_SIZE);
|
|
84
84
|
for (let i = 0; i < FFT_SIZE; i++) {
|
|
85
85
|
chunk[i] = samples[offset + i] ?? 0;
|
|
@@ -110,11 +110,12 @@ function extractSpectrumFromPCM(samples, _sampleRate, bars, frames) {
|
|
|
110
110
|
if (globalMax === 0) {
|
|
111
111
|
return raw;
|
|
112
112
|
}
|
|
113
|
+
const noiseFloor = globalMax * 0.03;
|
|
113
114
|
const result = [];
|
|
114
115
|
for (let f = 0; f < frames; f++) {
|
|
115
116
|
const row = [];
|
|
116
117
|
for (let b = 0; b < bars; b++) {
|
|
117
|
-
if (bandMax[b]
|
|
118
|
+
if (bandMax[b] <= noiseFloor) {
|
|
118
119
|
row.push(0);
|
|
119
120
|
continue;
|
|
120
121
|
}
|
|
@@ -126,7 +127,7 @@ function extractSpectrumFromPCM(samples, _sampleRate, bars, frames) {
|
|
|
126
127
|
}
|
|
127
128
|
return result;
|
|
128
129
|
}
|
|
129
|
-
async function extractSpectrumFromFile(filePath, bars, frames) {
|
|
130
|
+
async function extractSpectrumFromFile(filePath, bars, frames, fps) {
|
|
130
131
|
const { execFile: execFile2 } = await import("child_process");
|
|
131
132
|
const { promisify: promisify2 } = await import("util");
|
|
132
133
|
const exec2 = promisify2(execFile2);
|
|
@@ -141,7 +142,7 @@ async function extractSpectrumFromFile(filePath, bars, frames) {
|
|
|
141
142
|
stdout.byteOffset,
|
|
142
143
|
stdout.byteLength / 4
|
|
143
144
|
);
|
|
144
|
-
return extractSpectrumFromPCM(samples, SAMPLE_RATE, bars, frames);
|
|
145
|
+
return extractSpectrumFromPCM(samples, SAMPLE_RATE, bars, frames, fps);
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
// lib/types.ts
|
|
@@ -275,32 +276,34 @@ async function downloadAudio(videoUrl, outputPath) {
|
|
|
275
276
|
import { writeFile, mkdir, unlink } from "fs/promises";
|
|
276
277
|
import { dirname, join } from "path";
|
|
277
278
|
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", "
|
|
279
|
+
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").option("-n, --name <name>", "Output filename (without extension)").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
280
|
const isYoutube = input.includes("youtube.com") || input.includes("youtu.be");
|
|
280
281
|
let audioPath = input;
|
|
281
|
-
let
|
|
282
|
+
let baseName = "waveform";
|
|
282
283
|
let tempFile;
|
|
283
284
|
if (isYoutube) {
|
|
284
285
|
console.log("Fetching video info...");
|
|
285
286
|
const info = await getVideoMetadata(input);
|
|
286
|
-
metadata = { title: info.title, artist: info.artist, url: info.url };
|
|
287
287
|
console.log(`${info.title} - ${info.artist}`);
|
|
288
|
+
baseName = info.title.replace(/[/\\?%*:|"<>]/g, "-");
|
|
288
289
|
tempFile = join(tmpdir(), `readme-waves-${info.id}.mp3`);
|
|
289
290
|
console.log("Downloading audio...");
|
|
290
291
|
await downloadAudio(input, tempFile);
|
|
291
292
|
audioPath = tempFile;
|
|
292
293
|
}
|
|
294
|
+
if (opts.name) baseName = opts.name;
|
|
295
|
+
const ext = opts.video ? ".mp4" : ".svg";
|
|
296
|
+
const outputPath = opts.output || `${baseName}${ext}`;
|
|
293
297
|
if (opts.video) {
|
|
294
|
-
const { generateVideo, getAudioDuration } = await import("./video-
|
|
295
|
-
const output = opts.output.endsWith(".svg") ? opts.output.replace(".svg", ".mp4") : opts.output;
|
|
298
|
+
const { generateVideo, getAudioDuration } = await import("./video-ZMHOXSZH.js");
|
|
296
299
|
const duration = await getAudioDuration(audioPath);
|
|
297
300
|
const fps = +opts.fps;
|
|
298
301
|
const totalFrames = Math.floor(duration * fps);
|
|
299
302
|
console.log(`Audio: ${duration.toFixed(1)}s, generating ${totalFrames} frames...`);
|
|
300
|
-
const spectrum = await extractSpectrumFromFile(audioPath, +opts.cols, totalFrames);
|
|
301
|
-
await mkdir(dirname(
|
|
302
|
-
await generateVideo(spectrum, audioPath,
|
|
303
|
-
console.log(`Video saved to: ${
|
|
303
|
+
const spectrum = await extractSpectrumFromFile(audioPath, +opts.cols, totalFrames, fps);
|
|
304
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
305
|
+
await generateVideo(spectrum, audioPath, outputPath, +opts.rows, +opts.cols, fps);
|
|
306
|
+
console.log(`Video saved to: ${outputPath}`);
|
|
304
307
|
} else {
|
|
305
308
|
console.log(`Extracting spectrum (${opts.cols} bands, ${opts.frames} frames)...`);
|
|
306
309
|
const spectrum = await extractSpectrumFromFile(audioPath, +opts.cols, +opts.frames);
|
|
@@ -312,17 +315,11 @@ var program = new Command().name("readme-waves").description("Generate animated
|
|
|
312
315
|
cols: +opts.cols,
|
|
313
316
|
animationDuration: +opts.duration,
|
|
314
317
|
theme: opts.theme
|
|
315
|
-
}
|
|
316
|
-
metadata
|
|
318
|
+
}
|
|
317
319
|
);
|
|
318
|
-
await mkdir(dirname(
|
|
319
|
-
await writeFile(
|
|
320
|
-
console.log(`Saved to: ${
|
|
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}`);
|
|
320
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
321
|
+
await writeFile(outputPath, svg);
|
|
322
|
+
console.log(`Saved to: ${outputPath}`);
|
|
326
323
|
}
|
|
327
324
|
if (tempFile) await unlink(tempFile).catch(() => {
|
|
328
325
|
});
|
|
@@ -16,9 +16,9 @@ var BG = [13, 17, 23];
|
|
|
16
16
|
function rowColor(r, rows) {
|
|
17
17
|
if (rows <= 1) return COLORS[3];
|
|
18
18
|
const ratio = r / (rows - 1);
|
|
19
|
-
if (ratio < 0.33) return COLORS[
|
|
19
|
+
if (ratio < 0.33) return COLORS[3];
|
|
20
20
|
if (ratio < 0.66) return COLORS[2];
|
|
21
|
-
return COLORS[
|
|
21
|
+
return COLORS[1];
|
|
22
22
|
}
|
|
23
23
|
async function generateVideo(spectrum, audioPath, outputPath, rows, cols, fps) {
|
|
24
24
|
if (spectrum.length === 0 || spectrum[0].length === 0) {
|
|
@@ -68,8 +68,8 @@ async function generateVideo(spectrum, audioPath, outputPath, rows, cols, fps) {
|
|
|
68
68
|
]);
|
|
69
69
|
const stdin = ffmpeg.stdin;
|
|
70
70
|
const frameSize = width * height * 3;
|
|
71
|
-
const buf = Buffer.alloc(frameSize);
|
|
72
71
|
for (let f = 0; f < totalFrames; f++) {
|
|
72
|
+
const buf = Buffer.alloc(frameSize);
|
|
73
73
|
for (let i = 0; i < frameSize; i += 3) {
|
|
74
74
|
buf[i] = BG[0];
|
|
75
75
|
buf[i + 1] = BG[1];
|
|
@@ -97,19 +97,21 @@ async function generateVideo(spectrum, audioPath, outputPath, rows, cols, fps) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
-
stdin.write(buf);
|
|
100
|
+
const ok = stdin.write(buf);
|
|
101
|
+
if (!ok) {
|
|
102
|
+
await new Promise((resolve) => stdin.once("drain", resolve));
|
|
103
|
+
}
|
|
101
104
|
if (f % 100 === 0) {
|
|
102
105
|
console.log(` frame ${f + 1}/${totalFrames}`);
|
|
103
106
|
}
|
|
104
107
|
}
|
|
105
108
|
await new Promise((resolve, reject) => {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
else reject(new Error(`ffmpeg exited with code ${code}`));
|
|
110
|
-
});
|
|
111
|
-
ffmpeg.on("error", reject);
|
|
109
|
+
ffmpeg.on("close", (code) => {
|
|
110
|
+
if (code === 0) resolve();
|
|
111
|
+
else reject(new Error(`ffmpeg exited with code ${code}`));
|
|
112
112
|
});
|
|
113
|
+
ffmpeg.on("error", reject);
|
|
114
|
+
stdin.end();
|
|
113
115
|
});
|
|
114
116
|
}
|
|
115
117
|
async function getAudioDuration(filePath) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "readme-waves",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Generate animated music equalizer SVGs for GitHub profile READMEs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,9 +25,11 @@
|
|
|
25
25
|
],
|
|
26
26
|
"author": "TakalaWang",
|
|
27
27
|
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/TakalaWang/readme-waves"
|
|
31
|
+
},
|
|
28
32
|
"dependencies": {
|
|
29
|
-
"@ffmpeg/ffmpeg": "^0.12.15",
|
|
30
|
-
"@ffmpeg/util": "^0.12.2",
|
|
31
33
|
"commander": "^14.0.3",
|
|
32
34
|
"next": "^16.2.2",
|
|
33
35
|
"react": "^19.2.4",
|