stemit-cli 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 +399 -0
- package/bin/stemit.js +2 -0
- package/package.json +56 -0
- package/src/commands/setup.js +120 -0
- package/src/commands/split.js +173 -0
- package/src/lib/analyzer.js +36 -0
- package/src/lib/converter.js +35 -0
- package/src/lib/downloader.js +53 -0
- package/src/lib/mixer.js +63 -0
- package/src/lib/splitter.js +100 -0
- package/src/ui/banner.js +8 -0
- package/src/ui/progress.js +37 -0
- package/src/ui/summary.js +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# stemit
|
|
2
|
+
|
|
3
|
+
> Separate any song into its stems — vocals, drums, bass, guitar, piano, and more.
|
|
4
|
+
> Analyze BPM & musical key. Mute or solo any instrument. Works with YouTube URLs or local files.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
_ _ _
|
|
8
|
+
___| |_ ___ _ __ ___ (_) |_
|
|
9
|
+
/ __| __/ _ \ '_ ` _ \| | __|
|
|
10
|
+
\__ \ || __/ | | | | | | |_
|
|
11
|
+
|___/\__\___|_| |_| |_|_|\__|
|
|
12
|
+
stem separator · BPM · key · mix
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Table of Contents
|
|
18
|
+
|
|
19
|
+
- [stemit](#stemit)
|
|
20
|
+
- [Table of Contents](#table-of-contents)
|
|
21
|
+
- [What it does](#what-it-does)
|
|
22
|
+
- [Prerequisites](#prerequisites)
|
|
23
|
+
- [Installation](#installation)
|
|
24
|
+
- [Using npx (no install required)](#using-npx-no-install-required)
|
|
25
|
+
- [Global install](#global-install)
|
|
26
|
+
- [Local install (in a project)](#local-install-in-a-project)
|
|
27
|
+
- [Quick Start](#quick-start)
|
|
28
|
+
- [Usage](#usage)
|
|
29
|
+
- [Basic split](#basic-split)
|
|
30
|
+
- [6-stem split (guitar + piano)](#6-stem-split-guitar--piano)
|
|
31
|
+
- [Mute a stem](#mute-a-stem)
|
|
32
|
+
- [Solo a stem](#solo-a-stem)
|
|
33
|
+
- [Export as MP3](#export-as-mp3)
|
|
34
|
+
- [Skip BPM/key analysis](#skip-bpmkey-analysis)
|
|
35
|
+
- [All options](#all-options)
|
|
36
|
+
- [Available Models](#available-models)
|
|
37
|
+
- [Output Structure](#output-structure)
|
|
38
|
+
- [How It Works](#how-it-works)
|
|
39
|
+
- [First-Run Setup](#first-run-setup)
|
|
40
|
+
- [Troubleshooting](#troubleshooting)
|
|
41
|
+
- [`demucs exited with code 1`](#demucs-exited-with-code-1)
|
|
42
|
+
- [`ImportError: TorchCodec is required`](#importerror-torchcodec-is-required)
|
|
43
|
+
- [`ffmpeg not found`](#ffmpeg-not-found)
|
|
44
|
+
- [`python3 not found` / `python not found`](#python3-not-found--python-not-found)
|
|
45
|
+
- [Progress bar cycles multiple times](#progress-bar-cycles-multiple-times)
|
|
46
|
+
- [Slow processing](#slow-processing)
|
|
47
|
+
- [Resetting the venv](#resetting-the-venv)
|
|
48
|
+
- [License](#license)
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## What it does
|
|
53
|
+
|
|
54
|
+
`stemit` takes a song (from a YouTube URL or a local audio file) and:
|
|
55
|
+
|
|
56
|
+
1. **Downloads** it via `yt-dlp` if you pass a URL
|
|
57
|
+
2. **Separates** it into individual instrument stems using [Demucs](https://github.com/facebookresearch/demucs) (Facebook Research's state-of-the-art source separation model)
|
|
58
|
+
3. **Analyzes** the BPM and musical key of the result
|
|
59
|
+
4. **Mixes** stems back together with specific instruments muted or soloed
|
|
60
|
+
5. **Exports** everything as WAV or MP3
|
|
61
|
+
|
|
62
|
+
You get back clean, studio-quality isolated tracks you can drop straight into your DAW.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Prerequisites
|
|
67
|
+
|
|
68
|
+
stemit requires two system-level dependencies. Everything else (including Demucs itself) is installed automatically on first run.
|
|
69
|
+
|
|
70
|
+
| Dependency | Minimum Version | Install |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| **Node.js** | 18+ | https://nodejs.org |
|
|
73
|
+
| **Python** | 3.9+ | https://www.python.org/downloads/ |
|
|
74
|
+
| **ffmpeg** | any | `brew install ffmpeg` / `sudo apt install ffmpeg` / `winget install ffmpeg` |
|
|
75
|
+
|
|
76
|
+
> **Note:** Demucs (the AI model) and all Python dependencies are installed automatically into a private virtualenv at `~/.stemit/venv` the first time you run any `stemit` command. You do not need to `pip install` anything yourself.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
### Using npx (no install required)
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npx stemit-cli split "https://www.youtube.com/watch?v=..."
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Global install
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm install -g stemit-cli
|
|
92
|
+
stemit split "https://www.youtube.com/watch?v=..."
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Local install (in a project)
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
npm install stemit-cli
|
|
99
|
+
npx stemit split ./my-song.wav
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Quick Start
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Split a YouTube video into stems
|
|
108
|
+
stemit split "https://www.youtube.com/watch?v=oTS0LLXaLF0"
|
|
109
|
+
|
|
110
|
+
# Split a local file
|
|
111
|
+
stemit split ./my-song.wav
|
|
112
|
+
|
|
113
|
+
# Get 6 stems (adds guitar + piano)
|
|
114
|
+
stemit split ./my-song.wav --model htdemucs_6s
|
|
115
|
+
|
|
116
|
+
# Produce an instrumental (no vocals)
|
|
117
|
+
stemit split ./my-song.wav --mute vocals
|
|
118
|
+
|
|
119
|
+
# Isolate just the drums
|
|
120
|
+
stemit split ./my-song.wav --solo drums
|
|
121
|
+
|
|
122
|
+
# Export everything as MP3
|
|
123
|
+
stemit split ./my-song.wav --format mp3
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Usage
|
|
129
|
+
|
|
130
|
+
### Basic split
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
stemit split <input> [options]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`<input>` can be:
|
|
137
|
+
- A **YouTube URL** — `stemit split "https://www.youtube.com/watch?v=..."`
|
|
138
|
+
- A **local file path** — `stemit split ./song.wav` or `stemit split ./song.mp3`
|
|
139
|
+
|
|
140
|
+
The command will:
|
|
141
|
+
1. Check dependencies (and auto-install Demucs if needed)
|
|
142
|
+
2. Download the audio if a URL was provided
|
|
143
|
+
3. Run Demucs stem separation with a live progress bar
|
|
144
|
+
4. Analyze BPM and key of the result
|
|
145
|
+
5. Print a summary box with all output file paths
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
### 6-stem split (guitar + piano)
|
|
150
|
+
|
|
151
|
+
The `htdemucs_6s` model separates 6 instruments instead of the default 4, adding `guitar` and `piano` tracks:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
stemit split ./my-song.wav --model htdemucs_6s
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Output stems: `vocals`, `drums`, `bass`, `guitar`, `piano`, `other`
|
|
158
|
+
|
|
159
|
+
> **Caveat:** This model works best on Western pop/rock. For songs without guitar or piano those tracks will be mostly silent, and their content stays in `other`.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
### Mute a stem
|
|
164
|
+
|
|
165
|
+
Muting removes one instrument and mixes the rest back together. Useful for creating instrumentals or karaoke tracks.
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
# Remove vocals → instrumental
|
|
169
|
+
stemit split ./my-song.wav --mute vocals
|
|
170
|
+
|
|
171
|
+
# Remove drums → no-drums mix
|
|
172
|
+
stemit split ./my-song.wav --mute drums
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Valid values: `vocals`, `drums`, `bass`, `guitar`, `piano`, `other`
|
|
176
|
+
|
|
177
|
+
The mixed output is saved as `mute-<stem>.wav` inside the song's stem folder, alongside the individual stem files.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
### Solo a stem
|
|
182
|
+
|
|
183
|
+
Soloing keeps only one instrument and discards the rest. No mixing needed — the file is simply copied.
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
# Isolate only vocals
|
|
187
|
+
stemit split ./my-song.wav --solo vocals
|
|
188
|
+
|
|
189
|
+
# Isolate only drums
|
|
190
|
+
stemit split ./my-song.wav --solo drums
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Valid values: `vocals`, `drums`, `bass`, `guitar`, `piano`, `other`
|
|
194
|
+
|
|
195
|
+
The output is saved as `solo-<stem>.wav` inside the song's stem folder.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
### Export as MP3
|
|
200
|
+
|
|
201
|
+
By default all output files are WAV. Pass `--format mp3` to convert everything to MP3 after splitting:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
stemit split ./my-song.wav --format mp3
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
This converts all stem files (and the mixed output, if `--mute` or `--solo` was used) to MP3 using `ffmpeg` at variable bitrate (`-q:a 0`, highest quality).
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
### Skip BPM/key analysis
|
|
212
|
+
|
|
213
|
+
BPM and key detection uses [essentia.js](https://mtg.github.io/essentia.js/) (WASM) and adds a few seconds. Skip it with `--no-analyze`:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
stemit split ./my-song.wav --no-analyze
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
### All options
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
stemit split <input> [options]
|
|
225
|
+
|
|
226
|
+
Options:
|
|
227
|
+
--model <name> Demucs model to use (default: "htdemucs_ft")
|
|
228
|
+
htdemucs | htdemucs_ft | htdemucs_6s | mdx | mdx_extra | mdx_extra_q
|
|
229
|
+
--out <dir> Output directory (default: "./stemit-output")
|
|
230
|
+
--mute <stem> Mute one stem and mix the rest (vocals|drums|bass|guitar|piano|other)
|
|
231
|
+
--solo <stem> Keep only one stem (vocals|drums|bass|guitar|piano|other)
|
|
232
|
+
--format <fmt> Output format: wav or mp3 (default: "wav")
|
|
233
|
+
--no-analyze Skip BPM and key detection
|
|
234
|
+
-h, --help Show help
|
|
235
|
+
-V, --version Show version
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Available Models
|
|
241
|
+
|
|
242
|
+
| Model | Stems | Notes |
|
|
243
|
+
|---|---|---|
|
|
244
|
+
| `htdemucs_ft` | vocals, drums, bass, other | **Default.** Fine-tuned hybrid transformer — best quality for 4 stems |
|
|
245
|
+
| `htdemucs` | vocals, drums, bass, other | Slightly faster than `_ft`, marginally lower quality |
|
|
246
|
+
| `htdemucs_6s` | vocals, drums, bass, **guitar, piano**, other | 6-stem model — adds guitar and piano separation |
|
|
247
|
+
| `mdx_extra` | vocals, drums, bass, other | Alternative architecture, good for vocals |
|
|
248
|
+
| `mdx` | vocals, drums, bass, other | Faster MDX variant |
|
|
249
|
+
| `mdx_extra_q` | vocals, drums, bass, other | Quantized MDX — smallest model, fastest inference |
|
|
250
|
+
|
|
251
|
+
**Which model should I use?**
|
|
252
|
+
|
|
253
|
+
- Default (`htdemucs_ft`) — best all-round quality, use this unless you have a reason not to
|
|
254
|
+
- `htdemucs_6s` — when you specifically need guitar or piano isolated
|
|
255
|
+
- `mdx_extra` — when vocals quality is the priority
|
|
256
|
+
- `mdx_extra_q` — when speed matters more than quality (e.g. batch processing)
|
|
257
|
+
|
|
258
|
+
**Speed note:** Demucs runs on CPU by default. Processing time is roughly **2–5× the song length** on modern hardware (e.g. a 4-minute song takes 8–20 minutes). The `mdx_extra_q` model is significantly faster.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Output Structure
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
stemit-output/
|
|
266
|
+
└── htdemucs_ft/
|
|
267
|
+
└── My Song Title/
|
|
268
|
+
├── vocals.wav
|
|
269
|
+
├── drums.wav
|
|
270
|
+
├── bass.wav
|
|
271
|
+
├── other.wav
|
|
272
|
+
├── mute-vocals.wav ← produced when --mute vocals is used
|
|
273
|
+
└── solo-drums.wav ← produced when --solo drums is used
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
All files are grouped under `--out/<model>/<song-name>/` (default: `./stemit-output`).
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## How It Works
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
┌─────────────┐ yt-dlp ┌────────────┐
|
|
284
|
+
│ YouTube URL │ ─────────────▶ │ audio.wav │
|
|
285
|
+
└─────────────┘ └─────┬──────┘
|
|
286
|
+
│
|
|
287
|
+
local file ─────────┘
|
|
288
|
+
│
|
|
289
|
+
▼
|
|
290
|
+
┌─────────────────────┐
|
|
291
|
+
│ Demucs (Python) │
|
|
292
|
+
│ htdemucs_ft model │
|
|
293
|
+
│ ~/.stemit/venv │
|
|
294
|
+
└──────────┬──────────┘
|
|
295
|
+
│
|
|
296
|
+
┌──────────────────────┼──────────────────────┐
|
|
297
|
+
▼ ▼ ▼ ▼ ▼
|
|
298
|
+
vocals.wav drums.wav bass.wav guitar.wav other.wav
|
|
299
|
+
│
|
|
300
|
+
▼
|
|
301
|
+
┌─────────────────────┐
|
|
302
|
+
│ essentia.js WASM │
|
|
303
|
+
│ BPM + Key detect │
|
|
304
|
+
└─────────────────────┘
|
|
305
|
+
|
|
306
|
+
optional:
|
|
307
|
+
stems ──▶ ffmpeg amix ──▶ mute-vocals.wav
|
|
308
|
+
stems ──▶ ffmpeg copy ──▶ solo-drums.wav
|
|
309
|
+
*.wav ──▶ ffmpeg ──▶ *.mp3
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Tech stack:**
|
|
313
|
+
|
|
314
|
+
| Component | Library | Notes |
|
|
315
|
+
|---|---|---|
|
|
316
|
+
| CLI framework | [commander](https://github.com/tj/commander.js) | Parses flags and subcommands |
|
|
317
|
+
| YouTube download | [youtube-dl-exec](https://github.com/microlinkhq/youtube-dl-exec) | Wraps yt-dlp, auto-downloads binary |
|
|
318
|
+
| Stem separation | [Demucs](https://github.com/facebookresearch/demucs) via `child_process.spawn` | Runs in managed Python venv |
|
|
319
|
+
| Stem mixing | ffmpeg `amix` filter | `normalize=0` to preserve volume |
|
|
320
|
+
| BPM + Key | [essentia.js](https://mtg.github.io/essentia.js/) | WASM, runs in Node |
|
|
321
|
+
| Audio decode | [audio-decode](https://github.com/audiojs/audio-decode) | Decodes WAV/MP3 to float32 for essentia |
|
|
322
|
+
| WAV→MP3 | ffmpeg | `-q:a 0` (VBR highest quality) |
|
|
323
|
+
| Progress bars | [cli-progress](https://github.com/npkgjs/cli-progress) | Live chunk-by-chunk demucs progress |
|
|
324
|
+
| Spinners | [ora](https://github.com/sindresorhus/ora) | Per-step feedback |
|
|
325
|
+
| Summary panel | [boxen](https://github.com/sindresorhus/boxen) + [chalk](https://github.com/chalk/chalk) | Formatted terminal output |
|
|
326
|
+
| ASCII banner | [figlet](https://github.com/patorjk/figlet.js) | Startup art |
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## First-Run Setup
|
|
331
|
+
|
|
332
|
+
The first time you run `stemit split`, it will:
|
|
333
|
+
|
|
334
|
+
1. Verify Python 3.9+ and ffmpeg are installed
|
|
335
|
+
2. Create a Python virtualenv at `~/.stemit/venv`
|
|
336
|
+
3. Install Demucs and its dependencies into that venv (`pip install demucs soundfile torchcodec`)
|
|
337
|
+
|
|
338
|
+
This one-time setup takes **2–5 minutes** depending on your internet connection (Demucs pulls in PyTorch, which is a large download). After that, the venv is reused on every run.
|
|
339
|
+
|
|
340
|
+
The AI model weights (~300 MB per model) are downloaded by Demucs on first use of each model and cached in `~/.cache/torch/hub/`.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## Troubleshooting
|
|
345
|
+
|
|
346
|
+
### `demucs exited with code 1`
|
|
347
|
+
|
|
348
|
+
Run with a local file first to rule out download issues. The full Demucs error is printed below the message. Common causes:
|
|
349
|
+
|
|
350
|
+
- **Out of memory** — Demucs needs ~4 GB RAM for `htdemucs_ft`. Try `--model mdx_extra_q` which is lighter.
|
|
351
|
+
- **Corrupt audio file** — ensure the file plays correctly before passing it to stemit.
|
|
352
|
+
- **Python version conflict** — stemit uses its own venv at `~/.stemit/venv`, but if you see import errors, try deleting the venv and re-running: `rm -rf ~/.stemit/venv`
|
|
353
|
+
|
|
354
|
+
### `ImportError: TorchCodec is required`
|
|
355
|
+
|
|
356
|
+
This happens with `torchaudio >= 2.5` on a fresh install if `torchcodec` wasn't installed. stemit handles this automatically during setup, but if you hit it manually:
|
|
357
|
+
|
|
358
|
+
```bash
|
|
359
|
+
~/.stemit/venv/bin/pip install torchcodec
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### `ffmpeg not found`
|
|
363
|
+
|
|
364
|
+
Install ffmpeg for your platform:
|
|
365
|
+
|
|
366
|
+
- **macOS:** `brew install ffmpeg`
|
|
367
|
+
- **Linux:** `sudo apt install ffmpeg`
|
|
368
|
+
- **Windows:** `winget install ffmpeg`
|
|
369
|
+
|
|
370
|
+
### `python3 not found` / `python not found`
|
|
371
|
+
|
|
372
|
+
Install Python 3.9+ from https://www.python.org/downloads/ and ensure it's on your PATH.
|
|
373
|
+
|
|
374
|
+
On Windows, the installer adds `python` (not `python3`) to PATH — stemit checks both.
|
|
375
|
+
|
|
376
|
+
### Progress bar cycles multiple times
|
|
377
|
+
|
|
378
|
+
This is normal. Demucs splits audio into overlapping chunks and processes them sequentially. Each chunk shows its own 0→100% progress. The label shows `chunk N/M` so you can track overall progress.
|
|
379
|
+
|
|
380
|
+
### Slow processing
|
|
381
|
+
|
|
382
|
+
Demucs runs on CPU by default — this is expected. A 4-minute song takes 8–20 minutes on modern hardware. There is no GPU acceleration option in stemit currently (Demucs supports CUDA if you have an NVIDIA GPU and install the CUDA version of PyTorch manually into `~/.stemit/venv`).
|
|
383
|
+
|
|
384
|
+
### Resetting the venv
|
|
385
|
+
|
|
386
|
+
If something goes wrong with the Python environment, delete the venv and re-run:
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
rm -rf ~/.stemit/venv
|
|
390
|
+
stemit split ./any-file.wav
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
This re-creates the venv and re-installs Demucs from scratch.
|
|
394
|
+
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
## License
|
|
398
|
+
|
|
399
|
+
MIT © saravanaraja25
|
package/bin/stemit.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stemit-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool to split audio into stems (vocals/drums/bass/other), analyze BPM & key, and mute/solo tracks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"stemit": "bin/stemit.js",
|
|
8
|
+
"stemit-cli": "bin/stemit.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"audio",
|
|
19
|
+
"stem",
|
|
20
|
+
"demucs",
|
|
21
|
+
"music",
|
|
22
|
+
"bpm",
|
|
23
|
+
"key",
|
|
24
|
+
"vocals",
|
|
25
|
+
"split",
|
|
26
|
+
"remix",
|
|
27
|
+
"cli"
|
|
28
|
+
],
|
|
29
|
+
"author": "saravanaraja25",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/saravanaraja25/stemit.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/saravanaraja25/stemit#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/saravanaraja25/stemit/issues"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"start": "node bin/stemit.js"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"audio-decode": "^2.1.4",
|
|
44
|
+
"debug": "^4.3.7",
|
|
45
|
+
"boxen": "^7.1.1",
|
|
46
|
+
"chalk": "^5.3.0",
|
|
47
|
+
"cli-progress": "^3.12.0",
|
|
48
|
+
"commander": "^12.1.0",
|
|
49
|
+
"essentia.js": "^0.1.3",
|
|
50
|
+
"figlet": "^1.7.0",
|
|
51
|
+
"fs-extra": "^11.2.0",
|
|
52
|
+
"ora": "^8.0.1",
|
|
53
|
+
"which": "^4.0.0",
|
|
54
|
+
"youtube-dl-exec": "^3.0.12"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { execSync, spawnSync } from 'child_process'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import fs from 'fs-extra'
|
|
4
|
+
import which from 'which'
|
|
5
|
+
import chalk from 'chalk'
|
|
6
|
+
import os from 'os'
|
|
7
|
+
|
|
8
|
+
const VENV_DIR = path.join(os.homedir(), '.stemit', 'venv')
|
|
9
|
+
const VENV_PYTHON =
|
|
10
|
+
process.platform === 'win32'
|
|
11
|
+
? path.join(VENV_DIR, 'Scripts', 'python.exe')
|
|
12
|
+
: path.join(VENV_DIR, 'bin', 'python3')
|
|
13
|
+
const VENV_PIP =
|
|
14
|
+
process.platform === 'win32'
|
|
15
|
+
? path.join(VENV_DIR, 'Scripts', 'pip.exe')
|
|
16
|
+
: path.join(VENV_DIR, 'bin', 'pip3')
|
|
17
|
+
|
|
18
|
+
const FFMPEG_INSTALL = {
|
|
19
|
+
darwin: 'brew install ffmpeg',
|
|
20
|
+
linux: 'sudo apt install ffmpeg (or your distro package manager)',
|
|
21
|
+
win32: 'winget install ffmpeg (or https://ffmpeg.org/download.html)',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ensure all system + Python dependencies are ready.
|
|
26
|
+
* Creates ~/.stemit/venv and auto-installs demucs into it if needed.
|
|
27
|
+
* @returns {Promise<{ pythonPath: string }>}
|
|
28
|
+
*/
|
|
29
|
+
export async function checkDependencies() {
|
|
30
|
+
const errors = []
|
|
31
|
+
|
|
32
|
+
// ── ffmpeg ──────────────────────────────────────────────────────────────────
|
|
33
|
+
try {
|
|
34
|
+
await which('ffmpeg')
|
|
35
|
+
} catch {
|
|
36
|
+
const hint = FFMPEG_INSTALL[process.platform] ?? 'https://ffmpeg.org/download.html'
|
|
37
|
+
errors.push(`ffmpeg not found.\n Install: ${hint}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── python3 / python (Windows uses "python") ─────────────────────────────────
|
|
41
|
+
let systemPython = null
|
|
42
|
+
try {
|
|
43
|
+
// Try python3 first (macOS/Linux), fall back to python (Windows / some Linux)
|
|
44
|
+
for (const cmd of ['python3', 'python']) {
|
|
45
|
+
try {
|
|
46
|
+
await which(cmd)
|
|
47
|
+
systemPython = cmd
|
|
48
|
+
break
|
|
49
|
+
} catch {
|
|
50
|
+
// try next
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!systemPython) throw new Error('not found')
|
|
55
|
+
|
|
56
|
+
const versionOutput = execSync(`${systemPython} --version`, { encoding: 'utf8' }).trim()
|
|
57
|
+
const match = versionOutput.match(/Python (\d+)\.(\d+)/)
|
|
58
|
+
if (!match) {
|
|
59
|
+
errors.push('Could not parse Python version.')
|
|
60
|
+
} else {
|
|
61
|
+
const [, major, minor] = match.map(Number)
|
|
62
|
+
if (major < 3 || (major === 3 && minor < 9)) {
|
|
63
|
+
errors.push(
|
|
64
|
+
`Python 3.9+ required, found ${versionOutput}.\n Upgrade: https://www.python.org/downloads/`
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
errors.push('Python not found.\n Install Python 3.9+: https://www.python.org/downloads/')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (errors.length > 0) {
|
|
73
|
+
console.error(chalk.red('\n✖ Missing dependencies:\n'))
|
|
74
|
+
errors.forEach((e) => console.error(chalk.red(` • ${e}\n`)))
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── venv + demucs (auto-managed) ─────────────────────────────────────────
|
|
79
|
+
const pythonPath = await ensureDemucsVenv(systemPython)
|
|
80
|
+
return { pythonPath }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function ensureDemucsVenv(systemPython) {
|
|
84
|
+
// Create venv if it doesn't exist yet
|
|
85
|
+
if (!fs.existsSync(VENV_PYTHON)) {
|
|
86
|
+
console.log(chalk.cyan('\n Creating Python venv at ~/.stemit/venv …'))
|
|
87
|
+
await fs.ensureDir(path.dirname(VENV_DIR))
|
|
88
|
+
|
|
89
|
+
const create = spawnSync(systemPython, ['-m', 'venv', VENV_DIR], { encoding: 'utf8' })
|
|
90
|
+
if (create.status !== 0) {
|
|
91
|
+
console.error(chalk.red(`\n Failed to create venv:\n${create.stderr}`))
|
|
92
|
+
process.exit(1)
|
|
93
|
+
}
|
|
94
|
+
console.log(chalk.green(' ✔ venv created'))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Install demucs if not already present in the venv
|
|
98
|
+
const demucsCheck = spawnSync(VENV_PYTHON, ['-m', 'demucs', '--help'], {
|
|
99
|
+
encoding: 'utf8',
|
|
100
|
+
stdio: 'pipe',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
if (demucsCheck.status !== 0) {
|
|
104
|
+
console.log(chalk.cyan(' Installing demucs (one-time setup, may take a minute) …'))
|
|
105
|
+
const install = spawnSync(VENV_PIP, ['install', 'demucs', 'soundfile', 'torchcodec'], {
|
|
106
|
+
encoding: 'utf8',
|
|
107
|
+
stdio: 'inherit',
|
|
108
|
+
})
|
|
109
|
+
if (install.status !== 0) {
|
|
110
|
+
console.error(chalk.red(
|
|
111
|
+
'\n Failed to install demucs. Try manually:\n' +
|
|
112
|
+
' pip install demucs soundfile torchcodec'
|
|
113
|
+
))
|
|
114
|
+
process.exit(1)
|
|
115
|
+
}
|
|
116
|
+
console.log(chalk.green(' ✔ demucs installed'))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return VENV_PYTHON
|
|
120
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import ora from 'ora'
|
|
5
|
+
|
|
6
|
+
import { checkDependencies } from './setup.js'
|
|
7
|
+
import { resolveInput } from '../lib/downloader.js'
|
|
8
|
+
import { splitStems, ALL_STEMS, MODEL_STEMS } from '../lib/splitter.js'
|
|
9
|
+
import { mixStems } from '../lib/mixer.js'
|
|
10
|
+
import { analyzeAudio } from '../lib/analyzer.js'
|
|
11
|
+
import { wavToMp3 } from '../lib/converter.js'
|
|
12
|
+
import { printBanner } from '../ui/banner.js'
|
|
13
|
+
import { createBar, stopAll } from '../ui/progress.js'
|
|
14
|
+
import { printSummary } from '../ui/summary.js'
|
|
15
|
+
|
|
16
|
+
const VALID_FORMATS = ['wav', 'mp3']
|
|
17
|
+
|
|
18
|
+
// Known models listed for help text
|
|
19
|
+
const MODEL_LIST = Object.keys(MODEL_STEMS).join('|')
|
|
20
|
+
|
|
21
|
+
const program = new Command()
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.name('stemit')
|
|
25
|
+
.description('Separate audio into stems, analyze BPM & key, mute/solo tracks')
|
|
26
|
+
.version('1.0.0')
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.command('split <input>')
|
|
30
|
+
.description('Split audio into stems (URL or local file)')
|
|
31
|
+
.option('--model <name>', `demucs model (${MODEL_LIST})`, 'htdemucs_ft')
|
|
32
|
+
.option('--out <dir>', 'output directory', './stemit-output')
|
|
33
|
+
.option('--mute <stem>', `mute a stem (${ALL_STEMS.join('|')})`)
|
|
34
|
+
.option('--solo <stem>', `keep only one stem (${ALL_STEMS.join('|')})`)
|
|
35
|
+
.option('--format <fmt>', `output format (${VALID_FORMATS.join('|')})`, 'wav')
|
|
36
|
+
.option('--no-analyze', 'skip BPM/key analysis')
|
|
37
|
+
.action(async (input, opts) => {
|
|
38
|
+
printBanner()
|
|
39
|
+
|
|
40
|
+
// Validate options
|
|
41
|
+
if (opts.mute && !ALL_STEMS.includes(opts.mute)) {
|
|
42
|
+
console.error(chalk.red(`Invalid --mute value. Choose from: ${ALL_STEMS.join(', ')}`))
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
if (opts.solo && !ALL_STEMS.includes(opts.solo)) {
|
|
46
|
+
console.error(chalk.red(`Invalid --solo value. Choose from: ${ALL_STEMS.join(', ')}`))
|
|
47
|
+
process.exit(1)
|
|
48
|
+
}
|
|
49
|
+
if (opts.mute && opts.solo) {
|
|
50
|
+
console.error(chalk.red('Cannot use --mute and --solo together.'))
|
|
51
|
+
process.exit(1)
|
|
52
|
+
}
|
|
53
|
+
if (!VALID_FORMATS.includes(opts.format)) {
|
|
54
|
+
console.error(chalk.red(`Invalid --format. Choose from: ${VALID_FORMATS.join(', ')}`))
|
|
55
|
+
process.exit(1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 1. Check system deps (auto-installs demucs into ~/.stemit/venv)
|
|
59
|
+
const setupSpinner = ora('Checking dependencies…').start()
|
|
60
|
+
let pythonPath
|
|
61
|
+
try {
|
|
62
|
+
;({ pythonPath } = await checkDependencies())
|
|
63
|
+
setupSpinner.succeed('Dependencies OK')
|
|
64
|
+
} catch (err) {
|
|
65
|
+
setupSpinner.fail('Dependency check failed')
|
|
66
|
+
console.error(chalk.red(err.message))
|
|
67
|
+
process.exit(1)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2. Resolve input (download if URL)
|
|
71
|
+
const resolveSpinner = ora('Resolving input…').start()
|
|
72
|
+
let audioFile
|
|
73
|
+
try {
|
|
74
|
+
audioFile = await resolveInput(input, path.join(opts.out, '.tmp'))
|
|
75
|
+
resolveSpinner.succeed(`Input: ${path.basename(audioFile)}`)
|
|
76
|
+
} catch (err) {
|
|
77
|
+
resolveSpinner.fail('Failed to resolve input')
|
|
78
|
+
console.error(chalk.red(err.message))
|
|
79
|
+
process.exit(1)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3. Split stems via demucs
|
|
83
|
+
const modelStems = MODEL_STEMS[opts.model] ?? ['vocals', 'drums', 'bass', 'other']
|
|
84
|
+
console.log(
|
|
85
|
+
chalk.cyan(`\nSeparating stems…`) +
|
|
86
|
+
chalk.gray(` (model: ${opts.model} → ${modelStems.join(', ')})`)
|
|
87
|
+
)
|
|
88
|
+
const splitBar = createBar('chunk 1 ', 100)
|
|
89
|
+
|
|
90
|
+
let stemsDir, stemFiles
|
|
91
|
+
try {
|
|
92
|
+
const result = await splitStems(
|
|
93
|
+
audioFile,
|
|
94
|
+
opts.out,
|
|
95
|
+
opts.model,
|
|
96
|
+
(chunk, total, pct) => {
|
|
97
|
+
const label = total > 1 ? `chunk ${chunk}/${total}` : 'demucs '
|
|
98
|
+
splitBar.update(pct, { label })
|
|
99
|
+
},
|
|
100
|
+
pythonPath
|
|
101
|
+
)
|
|
102
|
+
stemsDir = result.stemsDir
|
|
103
|
+
stemFiles = result.stems // actual paths from the model, not hardcoded
|
|
104
|
+
splitBar.update(100)
|
|
105
|
+
stopAll()
|
|
106
|
+
console.log(chalk.green(`✔ Stems separated (${stemFiles.length} tracks)`))
|
|
107
|
+
} catch (err) {
|
|
108
|
+
stopAll()
|
|
109
|
+
console.error(chalk.red(`\nStem separation failed: ${err.message}`))
|
|
110
|
+
process.exit(1)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 4. Mix (mute/solo) if requested
|
|
114
|
+
let primaryFile = stemFiles.find((f) => f.endsWith('vocals.wav')) ?? stemFiles[0]
|
|
115
|
+
|
|
116
|
+
if (opts.mute || opts.solo) {
|
|
117
|
+
const mixSpinner = ora('Mixing stems…').start()
|
|
118
|
+
const mixLabel = opts.solo ? `solo-${opts.solo}` : `mute-${opts.mute}`
|
|
119
|
+
const mixOut = path.join(stemsDir, `${mixLabel}.wav`)
|
|
120
|
+
try {
|
|
121
|
+
await mixStems(stemsDir, mixOut, { mute: opts.mute, solo: opts.solo, availableStems: modelStems })
|
|
122
|
+
primaryFile = mixOut
|
|
123
|
+
mixSpinner.succeed(`Mixed output: ${path.basename(mixOut)}`)
|
|
124
|
+
} catch (err) {
|
|
125
|
+
mixSpinner.fail('Mixing failed')
|
|
126
|
+
console.error(chalk.red(err.message))
|
|
127
|
+
process.exit(1)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 5. Convert to MP3 if requested
|
|
132
|
+
let finalFiles = [...stemFiles]
|
|
133
|
+
|
|
134
|
+
if (opts.format === 'mp3') {
|
|
135
|
+
const convertSpinner = ora('Converting to MP3…').start()
|
|
136
|
+
try {
|
|
137
|
+
const converted = await Promise.all(stemFiles.map((f) => wavToMp3(f)))
|
|
138
|
+
if (opts.mute || opts.solo) {
|
|
139
|
+
primaryFile = await wavToMp3(primaryFile)
|
|
140
|
+
}
|
|
141
|
+
finalFiles = converted
|
|
142
|
+
convertSpinner.succeed('Converted to MP3')
|
|
143
|
+
} catch (err) {
|
|
144
|
+
convertSpinner.fail('Conversion failed')
|
|
145
|
+
console.error(chalk.red(err.message))
|
|
146
|
+
process.exit(1)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 6. Analyze BPM + key
|
|
151
|
+
let bpm, key
|
|
152
|
+
if (opts.analyze !== false) {
|
|
153
|
+
const analyzeSpinner = ora('Analyzing BPM & key…').start()
|
|
154
|
+
try {
|
|
155
|
+
const result = await analyzeAudio(primaryFile)
|
|
156
|
+
bpm = result.bpm
|
|
157
|
+
key = result.key
|
|
158
|
+
analyzeSpinner.succeed(`BPM: ${Math.round(bpm)} Key: ${key}`)
|
|
159
|
+
} catch (err) {
|
|
160
|
+
analyzeSpinner.warn(`Analysis skipped: ${err.message}`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 7. Print summary
|
|
165
|
+
printSummary({
|
|
166
|
+
bpm,
|
|
167
|
+
key,
|
|
168
|
+
stems: finalFiles.map((f) => path.relative(process.cwd(), f)),
|
|
169
|
+
output: path.resolve(opts.out),
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
program.parse()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import decode from 'audio-decode'
|
|
3
|
+
|
|
4
|
+
// Init essentia WASM once at module load — avoids 1-2s penalty per call
|
|
5
|
+
let _essentia = null
|
|
6
|
+
|
|
7
|
+
async function getEssentia() {
|
|
8
|
+
if (_essentia) return _essentia
|
|
9
|
+
const { Essentia, EssentiaWASM } = await import('essentia.js')
|
|
10
|
+
_essentia = new Essentia(EssentiaWASM)
|
|
11
|
+
return _essentia
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Analyze BPM and musical key of an audio file.
|
|
16
|
+
* @param {string} filePath - path to WAV or MP3
|
|
17
|
+
* @returns {Promise<{ bpm: number, key: string }>}
|
|
18
|
+
*/
|
|
19
|
+
export async function analyzeAudio(filePath) {
|
|
20
|
+
const essentia = await getEssentia()
|
|
21
|
+
|
|
22
|
+
const buffer = fs.readFileSync(filePath)
|
|
23
|
+
// audio-decode returns an AudioBuffer-like object
|
|
24
|
+
const audio = await decode(buffer)
|
|
25
|
+
// Use mono channel 0
|
|
26
|
+
const channelData = audio.getChannelData(0)
|
|
27
|
+
const data = essentia.arrayToVector(channelData)
|
|
28
|
+
|
|
29
|
+
const { bpm } = essentia.PercivalBpmEstimator(data)
|
|
30
|
+
const { key, scale } = essentia.KeyExtractor(data)
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
bpm,
|
|
34
|
+
key: `${key} ${scale}`,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { spawn } from 'child_process'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import fs from 'fs-extra'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convert a WAV file to MP3 using ffmpeg.
|
|
7
|
+
* @param {string} inputWav - source WAV path
|
|
8
|
+
* @param {string} outputMp3 - destination MP3 path (defaults to same name with .mp3)
|
|
9
|
+
* @returns {Promise<string>} outputMp3 path
|
|
10
|
+
*/
|
|
11
|
+
export async function wavToMp3(inputWav, outputMp3) {
|
|
12
|
+
if (!outputMp3) {
|
|
13
|
+
outputMp3 = inputWav.replace(/\.wav$/i, '.mp3')
|
|
14
|
+
}
|
|
15
|
+
await fs.ensureDir(path.dirname(outputMp3))
|
|
16
|
+
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const args = [
|
|
19
|
+
'-i', inputWav,
|
|
20
|
+
'-q:a', '0',
|
|
21
|
+
'-map', 'a',
|
|
22
|
+
'-y',
|
|
23
|
+
outputMp3,
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
27
|
+
|
|
28
|
+
proc.on('error', (err) => reject(new Error(`ffmpeg error: ${err.message}`)))
|
|
29
|
+
|
|
30
|
+
proc.on('close', (code) => {
|
|
31
|
+
if (code !== 0) return reject(new Error(`ffmpeg exited with code ${code}`))
|
|
32
|
+
resolve(outputMp3)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import youtubedl from 'youtube-dl-exec'
|
|
2
|
+
import fs from 'fs-extra'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
|
|
5
|
+
const URL_REGEX = /^https?:\/\//
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve input to a local WAV file path.
|
|
9
|
+
* If input is a URL, download audio as WAV via yt-dlp.
|
|
10
|
+
* If input is a local path, use it directly.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} input - URL or local file path
|
|
13
|
+
* @param {string} tmpDir - directory to place downloaded files
|
|
14
|
+
* @returns {Promise<string>} resolved path to the audio file
|
|
15
|
+
*/
|
|
16
|
+
export async function resolveInput(input, tmpDir = 'tmp') {
|
|
17
|
+
await fs.ensureDir(tmpDir)
|
|
18
|
+
|
|
19
|
+
if (URL_REGEX.test(input)) {
|
|
20
|
+
return downloadUrl(input, tmpDir)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const resolved = path.resolve(input)
|
|
24
|
+
if (!fs.existsSync(resolved)) {
|
|
25
|
+
throw new Error(`File not found: ${resolved}`)
|
|
26
|
+
}
|
|
27
|
+
return resolved
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function downloadUrl(url, tmpDir) {
|
|
31
|
+
const outputTemplate = path.join(tmpDir, '%(title)s.%(ext)s')
|
|
32
|
+
|
|
33
|
+
await youtubedl(url, {
|
|
34
|
+
extractAudio: true,
|
|
35
|
+
audioFormat: 'wav',
|
|
36
|
+
output: outputTemplate,
|
|
37
|
+
noCheckCertificates: true,
|
|
38
|
+
noWarnings: true,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Find the downloaded wav file
|
|
42
|
+
const files = (await fs.readdir(tmpDir)).filter((f) => f.endsWith('.wav'))
|
|
43
|
+
if (files.length === 0) {
|
|
44
|
+
throw new Error('Download succeeded but no WAV file found in tmp dir.')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Return the most recently modified wav
|
|
48
|
+
const sorted = files
|
|
49
|
+
.map((f) => ({ name: f, mtime: fs.statSync(path.join(tmpDir, f)).mtimeMs }))
|
|
50
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
51
|
+
|
|
52
|
+
return path.resolve(path.join(tmpDir, sorted[0].name))
|
|
53
|
+
}
|
package/src/lib/mixer.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawn } from 'child_process'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import fs from 'fs-extra'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_STEMS = ['vocals', 'drums', 'bass', 'other']
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mix stems according to mute/solo options.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} stemsDir - directory containing stem WAV files
|
|
11
|
+
* @param {string} outputFile - path for the mixed output WAV
|
|
12
|
+
* @param {{ mute?: string, solo?: string, availableStems?: string[] }} options
|
|
13
|
+
* @returns {Promise<string>} outputFile path
|
|
14
|
+
*/
|
|
15
|
+
export async function mixStems(stemsDir, outputFile, { mute, solo, availableStems } = {}) {
|
|
16
|
+
await fs.ensureDir(path.dirname(outputFile))
|
|
17
|
+
|
|
18
|
+
const stemSet = availableStems ?? DEFAULT_STEMS
|
|
19
|
+
let stemsToKeep
|
|
20
|
+
|
|
21
|
+
if (solo) {
|
|
22
|
+
stemsToKeep = [solo]
|
|
23
|
+
} else if (mute) {
|
|
24
|
+
stemsToKeep = stemSet.filter((s) => s !== mute)
|
|
25
|
+
} else {
|
|
26
|
+
stemsToKeep = [...stemSet]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const stemPaths = stemsToKeep.map((s) => path.join(stemsDir, `${s}.wav`))
|
|
30
|
+
|
|
31
|
+
for (const p of stemPaths) {
|
|
32
|
+
if (!fs.existsSync(p)) {
|
|
33
|
+
throw new Error(`Stem file not found: ${p}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (stemPaths.length === 1) {
|
|
38
|
+
// Solo mode — simple copy, no ffmpeg amix needed
|
|
39
|
+
await fs.copy(stemPaths[0], outputFile)
|
|
40
|
+
return outputFile
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const inputs = stemPaths.flatMap((p) => ['-i', p])
|
|
45
|
+
const filter = `amix=inputs=${stemPaths.length}:duration=longest:normalize=0`
|
|
46
|
+
|
|
47
|
+
const args = [
|
|
48
|
+
...inputs,
|
|
49
|
+
'-filter_complex', filter,
|
|
50
|
+
'-y',
|
|
51
|
+
outputFile,
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
55
|
+
|
|
56
|
+
proc.on('error', (err) => reject(new Error(`ffmpeg error: ${err.message}`)))
|
|
57
|
+
|
|
58
|
+
proc.on('close', (code) => {
|
|
59
|
+
if (code !== 0) return reject(new Error(`ffmpeg exited with code ${code}`))
|
|
60
|
+
resolve(outputFile)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { spawn } from 'child_process'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import fs from 'fs'
|
|
4
|
+
|
|
5
|
+
// Stems produced by each model. Falls back to 4-stem list for unknown models.
|
|
6
|
+
export const MODEL_STEMS = {
|
|
7
|
+
htdemucs: ['vocals', 'drums', 'bass', 'other'],
|
|
8
|
+
htdemucs_ft: ['vocals', 'drums', 'bass', 'other'],
|
|
9
|
+
htdemucs_6s: ['vocals', 'drums', 'bass', 'guitar', 'piano', 'other'],
|
|
10
|
+
mdx: ['vocals', 'drums', 'bass', 'other'],
|
|
11
|
+
mdx_extra: ['vocals', 'drums', 'bass', 'other'],
|
|
12
|
+
mdx_extra_q: ['vocals', 'drums', 'bass', 'other'],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_STEMS = ['vocals', 'drums', 'bass', 'other']
|
|
16
|
+
|
|
17
|
+
// All possible stems across all models (for CLI validation)
|
|
18
|
+
export const ALL_STEMS = ['vocals', 'drums', 'bass', 'guitar', 'piano', 'other']
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run demucs to separate stems from inputFile.
|
|
22
|
+
* @param {string} inputFile - path to WAV/MP3
|
|
23
|
+
* @param {string} outputDir - root output directory
|
|
24
|
+
* @param {string} model - demucs model name (e.g. 'htdemucs_ft')
|
|
25
|
+
* @param {function} onProgress - called with (chunk: number, total: number, pct: number)
|
|
26
|
+
* @param {string} pythonPath - path to python binary (defaults to system python3)
|
|
27
|
+
* @returns {Promise<{stemsDir: string, stems: string[]}>}
|
|
28
|
+
*/
|
|
29
|
+
export function splitStems(
|
|
30
|
+
inputFile,
|
|
31
|
+
outputDir,
|
|
32
|
+
model = 'htdemucs_ft',
|
|
33
|
+
onProgress = () => {},
|
|
34
|
+
pythonPath = 'python3'
|
|
35
|
+
) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const args = ['-m', 'demucs', '-n', model, '--out', outputDir, inputFile]
|
|
38
|
+
const proc = spawn(pythonPath, args, {
|
|
39
|
+
env: { ...process.env, TORCHAUDIO_BACKEND: 'soundfile' },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const stderrLines = []
|
|
43
|
+
let chunkIndex = 0
|
|
44
|
+
let totalChunks = 0
|
|
45
|
+
|
|
46
|
+
proc.stderr.on('data', (buf) => {
|
|
47
|
+
const text = buf.toString()
|
|
48
|
+
stderrLines.push(text)
|
|
49
|
+
|
|
50
|
+
// Detect "Separated track … (N chunks)" to know total segments
|
|
51
|
+
// demucs logs e.g.: "Separated track song (4 chunks)"
|
|
52
|
+
const totalMatch = text.match(/\((\d+)\s+chunks?\)/i)
|
|
53
|
+
if (totalMatch) {
|
|
54
|
+
totalChunks = parseInt(totalMatch[1], 10)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Each new tqdm bar starting from 0% signals a new chunk
|
|
58
|
+
const zeroMatch = text.match(/^\s*0%\|/)
|
|
59
|
+
if (zeroMatch && totalChunks > 0) {
|
|
60
|
+
chunkIndex++
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Parse percentage from tqdm-style output: 87%|████...
|
|
64
|
+
const pctMatch = text.match(/\b(\d{1,3})%\|/)
|
|
65
|
+
if (pctMatch) {
|
|
66
|
+
const pct = parseInt(pctMatch[1], 10)
|
|
67
|
+
const effectiveChunk = Math.max(chunkIndex, 1)
|
|
68
|
+
const effectiveTotal = Math.max(totalChunks, 1)
|
|
69
|
+
onProgress(effectiveChunk, effectiveTotal, pct)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
proc.on('error', (err) => reject(new Error(`Failed to start demucs: ${err.message}`)))
|
|
74
|
+
|
|
75
|
+
proc.on('close', (code) => {
|
|
76
|
+
if (code !== 0) {
|
|
77
|
+
const detail = stderrLines.join('').trim().split('\n').slice(-10).join('\n')
|
|
78
|
+
return reject(
|
|
79
|
+
new Error(`demucs exited with code ${code}\n\ndemucs output:\n${detail}`)
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Resolve stems directory: <outputDir>/<model>/<songname>/
|
|
84
|
+
const songName = path.basename(inputFile, path.extname(inputFile))
|
|
85
|
+
const stemsDir = path.join(outputDir, model, songName)
|
|
86
|
+
|
|
87
|
+
if (!fs.existsSync(stemsDir)) {
|
|
88
|
+
return reject(new Error(`Expected stems dir not found: ${stemsDir}`))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Use the known stem list for this model, or discover from actual files
|
|
92
|
+
const expectedStems = MODEL_STEMS[model] ?? DEFAULT_STEMS
|
|
93
|
+
const stems = expectedStems
|
|
94
|
+
.map((s) => path.join(stemsDir, `${s}.wav`))
|
|
95
|
+
.filter((p) => fs.existsSync(p))
|
|
96
|
+
resolve({ stemsDir, stems })
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
package/src/ui/banner.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import cliProgress from 'cli-progress'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
|
|
4
|
+
let multibar = null
|
|
5
|
+
|
|
6
|
+
export function getMultibar() {
|
|
7
|
+
if (!multibar) {
|
|
8
|
+
multibar = new cliProgress.MultiBar(
|
|
9
|
+
{
|
|
10
|
+
clearOnComplete: false,
|
|
11
|
+
hideCursor: true,
|
|
12
|
+
format:
|
|
13
|
+
chalk.cyan('{bar}') +
|
|
14
|
+
' {percentage}% {label}',
|
|
15
|
+
},
|
|
16
|
+
cliProgress.Presets.shades_classic
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
return multibar
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a new progress bar.
|
|
24
|
+
* @param {string} label
|
|
25
|
+
* @param {number} total
|
|
26
|
+
*/
|
|
27
|
+
export function createBar(label, total = 100) {
|
|
28
|
+
const mb = getMultibar()
|
|
29
|
+
return mb.create(total, 0, { label })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function stopAll() {
|
|
33
|
+
if (multibar) {
|
|
34
|
+
multibar.stop()
|
|
35
|
+
multibar = null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import boxen from 'boxen'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Print the final summary panel.
|
|
6
|
+
* @param {{ bpm?: number, key?: string, stems: string[], output: string }} result
|
|
7
|
+
*/
|
|
8
|
+
export function printSummary({ bpm, key, stems, output }) {
|
|
9
|
+
const lines = [
|
|
10
|
+
chalk.bold.cyan('stemit — done!'),
|
|
11
|
+
'',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
if (bpm !== undefined) {
|
|
15
|
+
lines.push(`${chalk.gray('BPM ')} ${chalk.yellow(Math.round(bpm))}`)
|
|
16
|
+
}
|
|
17
|
+
if (key) {
|
|
18
|
+
lines.push(`${chalk.gray('Key ')} ${chalk.yellow(key)}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
lines.push('')
|
|
22
|
+
lines.push(chalk.gray('Stems:'))
|
|
23
|
+
for (const s of stems) {
|
|
24
|
+
lines.push(` ${chalk.green('✔')} ${s}`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
lines.push('')
|
|
28
|
+
lines.push(`${chalk.gray('Output dir')} ${chalk.white(output)}`)
|
|
29
|
+
|
|
30
|
+
console.log(
|
|
31
|
+
boxen(lines.join('\n'), {
|
|
32
|
+
padding: 1,
|
|
33
|
+
margin: 1,
|
|
34
|
+
borderStyle: 'round',
|
|
35
|
+
borderColor: 'cyan',
|
|
36
|
+
})
|
|
37
|
+
)
|
|
38
|
+
}
|