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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/commands/split.js'
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
+ }
@@ -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
+
@@ -0,0 +1,8 @@
1
+ import figlet from 'figlet'
2
+ import chalk from 'chalk'
3
+
4
+ export function printBanner() {
5
+ const text = figlet.textSync('stemit', { font: 'Standard' })
6
+ console.log(chalk.cyan(text))
7
+ console.log(chalk.gray(' stem separator · BPM · key · mix\n'))
8
+ }
@@ -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
+ }