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 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
+ [![npm](https://img.shields.io/npm/v/readme-waves)](https://www.npmjs.com/package/readme-waves)
3
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
5
 
5
- Generate animated music equalizer visualizations for GitHub profile READMEs. Built in Rust.
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 [Rust](https://rustup.rs/) and [yt-dlp](https://github.com/yt-dlp/yt-dlp) (for YouTube mode).
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
- # Build
24
- cargo build --release
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
- # From local audio file → MP4 video with audio
30
- ./target/release/readme-waves song.mp3 --video -o waveform.mp4
29
+ # MP4 video with audio
30
+ npx readme-waves song.mp3 --video -o waveform.mp4
31
31
 
32
- # From YouTube playlist (picks a random song)
33
- ./target/release/readme-waves "https://www.youtube.com/playlist?list=PLxxxxx" -o waveform.svg
32
+ # From YouTube URL
33
+ npx readme-waves "https://www.youtube.com/watch?v=xxxxx" -o waveform.svg
34
34
 
35
- # Video from YouTube playlist
36
- ./target/release/readme-waves "https://www.youtube.com/playlist?list=PLxxxxx" --video -o waveform.mp4
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 playlist URL | (required) |
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
- - 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:
57
+ ## Embed in GitHub README
88
58
 
89
59
  ```markdown
90
60
  ![Waveform](./assets/waveform.svg)
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 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)
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 with the original audio
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 #0e4429 #006d32 #26a641 #39d353
77
+ #161b22 #006d32 #26a641 #39d353
122
78
  ```
123
79
 
124
80
  **Light** — GitHub light mode contribution colors:
125
81
  ```
126
- #ebedf0 #9be9a8 #40c463 #30a14e #216e39
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
- - [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)
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, _sampleRate, bars, frames) {
75
+ function extractSpectrumFromPCM(samples, sampleRate, bars, frames, fps) {
76
76
  const totalSamples = samples.length;
77
- const hopSize = Math.max(1, Math.floor((totalSamples - FFT_SIZE) / (frames - 1)));
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 * hopSize, totalSamples - FFT_SIZE);
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] === 0) {
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", "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
+ 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 metadata;
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-7X5JNMOZ.js");
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(output), { recursive: true });
302
- await generateVideo(spectrum, audioPath, output, +opts.rows, +opts.cols, fps);
303
- console.log(`Video saved to: ${output}`);
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(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}`);
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[1];
19
+ if (ratio < 0.33) return COLORS[3];
20
20
  if (ratio < 0.66) return COLORS[2];
21
- return COLORS[3];
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
- 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);
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.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",