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 ADDED
@@ -0,0 +1,139 @@
1
+ # readme-waves
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ ![Waveform](./assets/waveform.svg)
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
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
+ }