├── resources └── splash.png ├── run_cpu.bat ├── requirements.txt ├── create_dummy_audio.py ├── tests ├── test_demucs_mock.py └── test_splitter.py ├── src ├── utils │ └── logger.py ├── core │ ├── gpu_utils.py │ ├── advanced_audio.py │ └── splitter.py └── ui │ ├── splash.py │ ├── style.py │ ├── widgets.py │ └── main_window.py ├── .gitignore ├── rebuild_cpu_robust.bat ├── install_and_build.bat ├── debug_pipeline.py ├── debug_splitter.py ├── main.py └── README.md /resources/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunsetsacoustic/StemLab/HEAD/resources/splash.png -------------------------------------------------------------------------------- /run_cpu.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | echo Starting SunoSplitter (CPU Mode - Source)... 3 | call venv_cpu\Scripts\activate 4 | python main.py 5 | pause 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt6 2 | demucs 3 | audio-separator[gpu] 4 | torch 5 | torchaudio 6 | numpy 7 | soundfile 8 | pydub 9 | pyinstaller 10 | -------------------------------------------------------------------------------- /create_dummy_audio.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import soundfile as sf 3 | 4 | # Generate 5 seconds of silence/noise 5 | sr = 44100 6 | duration = 5 7 | data = np.random.uniform(-0.1, 0.1, size=(sr * duration, 2)) 8 | sf.write("test_audio.wav", data, sr) 9 | print("Created test_audio.wav") 10 | -------------------------------------------------------------------------------- /tests/test_demucs_mock.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest.mock import MagicMock 3 | 4 | # Mock lameenc 5 | sys.modules["lameenc"] = MagicMock() 6 | 7 | try: 8 | import demucs 9 | print("Demucs imported successfully!") 10 | import demucs.separate 11 | print("Demucs.separate imported successfully!") 12 | except Exception as e: 13 | print(f"Import failed: {e}") 14 | -------------------------------------------------------------------------------- /src/utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | def setup_logger(): 5 | logger = logging.getLogger("SunoSplitter") 6 | logger.setLevel(logging.DEBUG) 7 | 8 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') 9 | 10 | ch = logging.StreamHandler(sys.stdout) 11 | ch.setFormatter(formatter) 12 | logger.addHandler(ch) 13 | 14 | return logger 15 | 16 | logger = setup_logger() 17 | -------------------------------------------------------------------------------- /src/core/gpu_utils.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from src.utils.logger import logger 3 | 4 | def get_gpu_info(): 5 | """ 6 | Returns a tuple (is_available, device_name) 7 | """ 8 | if torch.cuda.is_available(): 9 | device_name = torch.cuda.get_device_name(0) 10 | logger.info(f"GPU Detected: {device_name}") 11 | return True, device_name 12 | else: 13 | logger.info("No GPU detected, using CPU.") 14 | return False, "CPU" 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Virtual Environments 2 | venv/ 3 | venv_cpu/ 4 | venv_gpu/ 5 | .env 6 | 7 | # Python 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # Distribution / Build 13 | build/ 14 | dist/ 15 | *.spec 16 | 17 | # IDEs 18 | .vscode/ 19 | .idea/ 20 | *.swp 21 | 22 | # Logs and Temp 23 | *.log 24 | temp/ 25 | tmp/ 26 | 27 | # Large Audio Files 28 | *.wav 29 | *.mp3 30 | *.flac 31 | *.m4a 32 | test_audio/ 33 | test_audio - Stems/ 34 | 35 | # Local Config 36 | version_info.txt 37 | -------------------------------------------------------------------------------- /tests/test_splitter.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import time 4 | from PyQt6.QtCore import QCoreApplication 5 | from src.core.splitter import SplitterWorker 6 | 7 | # Mock App 8 | app = QCoreApplication(sys.argv) 9 | 10 | def test_worker(): 11 | print("Testing SplitterWorker...") 12 | 13 | file_path = "test_audio.mp3" 14 | options = {"stem_count": 4, "quality": 1} 15 | 16 | worker = SplitterWorker(file_path, options) 17 | 18 | def on_progress(filename, progress, status): 19 | print(f"Progress: {progress}% - {status}") 20 | 21 | def on_finished(filename): 22 | print(f"Finished: {filename}") 23 | app.quit() 24 | 25 | worker.progress_updated.connect(on_progress) 26 | worker.finished.connect(on_finished) 27 | 28 | worker.start() 29 | 30 | # Run event loop 31 | app.exec() 32 | 33 | if __name__ == "__main__": 34 | test_worker() 35 | -------------------------------------------------------------------------------- /rebuild_cpu_robust.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal 3 | echo ========================================== 4 | echo SunoSplitter CPU Rebuild (Robust) 5 | echo ========================================== 6 | echo. 7 | echo Step 1: Activating Environment... 8 | call venv_cpu\Scripts\activate 9 | 10 | echo. 11 | echo Step 2: Verifying Dependencies... 12 | python -m pip install --upgrade pip 13 | python -m pip install PyQt6 soundfile torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu 14 | python -m pip install demucs audio-separator pyinstaller onnxruntime 15 | 16 | echo. 17 | echo Step 3: Building EXE (Explicit Imports)... 18 | pyinstaller --clean --noconsole --onefile --name StemLab --version-file version_info.txt --add-data "src;src" --add-data "resources;resources" --collect-all demucs --collect-all torchaudio --collect-all soundfile --collect-all numpy --hidden-import="PyQt6" --hidden-import="sklearn.utils._cython_blas" --hidden-import="sklearn.neighbors.typedefs" --hidden-import="sklearn.neighbors.quad_tree" --hidden-import="sklearn.tree._utils" main.py 19 | 20 | echo. 21 | echo ========================================== 22 | echo Build Complete! 23 | echo You can find StemLab.exe in the dist folder. 24 | echo ========================================== 25 | pause 26 | -------------------------------------------------------------------------------- /install_and_build.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal 3 | echo ========================================== 4 | echo SunoSplitter GPU Build - Interactive Mode 5 | echo ========================================== 6 | echo. 7 | echo Step 1: Checking Python 3.10... 8 | py -3.10 --version 9 | if %errorlevel% neq 0 ( 10 | echo ERROR: Python 3.10 not found! 11 | pause 12 | exit /b 1 13 | ) 14 | 15 | echo. 16 | echo Step 2: Creating Virtual Environment (venv_gpu)... 17 | if exist venv_gpu ( 18 | echo Removing old venv_gpu... 19 | rmdir /s /q venv_gpu 20 | ) 21 | py -3.10 -m venv venv_gpu 22 | 23 | echo. 24 | echo Step 3: Installing Dependencies... 25 | echo This will show a progress bar. Please wait. 26 | echo. 27 | call venv_gpu\Scripts\activate 28 | python -m pip install --upgrade pip 29 | pip install PyQt6 soundfile 30 | echo. 31 | echo Downloading PyTorch (2.5 GB)... 32 | pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 33 | echo. 34 | echo Installing Demucs and other tools... 35 | pip install demucs audio-separator pyinstaller 36 | 37 | echo. 38 | echo Step 4: Building EXE... 39 | pyinstaller --clean --noconsole --onefile --name SunoSplitter_GPU --version-file version_info.txt --add-data "src;src" --collect-all demucs --collect-all torchaudio --collect-all soundfile --collect-all numpy --hidden-import="sklearn.utils._cython_blas" --hidden-import="sklearn.neighbors.typedefs" --hidden-import="sklearn.neighbors.quad_tree" --hidden-import="sklearn.tree._utils" main.py 40 | 41 | echo. 42 | echo ========================================== 43 | echo Build Complete! 44 | echo You can find SunoSplitter_GPU.exe in the dist folder. 45 | echo ========================================== 46 | pause 47 | -------------------------------------------------------------------------------- /src/ui/splash.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QSplashScreen, QProgressBar, QVBoxLayout, QLabel, QWidget 2 | from PyQt6.QtCore import Qt, QTimer 3 | from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen 4 | from .style import COLORS 5 | 6 | class SplashScreen(QSplashScreen): 7 | def __init__(self): 8 | # Determine resource path 9 | import sys 10 | import os 11 | if getattr(sys, 'frozen', False): 12 | base_path = sys._MEIPASS 13 | else: 14 | base_path = os.path.abspath(".") 15 | 16 | splash_path = os.path.join(base_path, "resources", "splash.png") 17 | 18 | if os.path.exists(splash_path): 19 | pixmap = QPixmap(splash_path) 20 | # Resize if too big, but keep aspect ratio? Or just use as is. 21 | # The user image looks like a banner. Let's scale it to a reasonable width if needed. 22 | # pixmap = pixmap.scaled(600, 400, Qt.AspectRatioMode.KeepAspectRatio) 23 | else: 24 | # Fallback if image missing 25 | pixmap = QPixmap(600, 400) 26 | pixmap.fill(QColor(COLORS['background'])) 27 | painter = QPainter(pixmap) 28 | painter.setPen(QColor(COLORS['text'])) 29 | painter.drawText(0, 0, 600, 400, Qt.AlignmentFlag.AlignCenter, "StemLab") 30 | painter.end() 31 | 32 | # Initialize with the pixmap 33 | super().__init__(pixmap) 34 | 35 | self.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint) 36 | 37 | # Add Progress Bar (Overlay widget) 38 | # QSplashScreen doesn't support layouts easily, so we just draw or use a child widget 39 | # But child widgets on splash screens can be tricky. 40 | # Let's just use the showMessage method for text. 41 | 42 | def show_message(self, message): 43 | self.showMessage(message, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignCenter, QColor(COLORS['secondary'])) 44 | -------------------------------------------------------------------------------- /debug_pipeline.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import logging 4 | import shutil 5 | 6 | # Configure logging 7 | logging.basicConfig(level=logging.DEBUG) 8 | logger = logging.getLogger(__name__) 9 | 10 | print("Python Version:", sys.version) 11 | print("Current Directory:", os.getcwd()) 12 | 13 | def check_ffmpeg(): 14 | print("\n--- Checking FFmpeg ---") 15 | ffmpeg_path = shutil.which("ffmpeg") 16 | if ffmpeg_path: 17 | print(f"FFmpeg found at: {ffmpeg_path}") 18 | return True 19 | else: 20 | print("ERROR: FFmpeg not found in PATH!") 21 | return False 22 | 23 | def test_imports(): 24 | print("\n--- Testing Imports ---") 25 | try: 26 | import torch 27 | print(f"PyTorch Version: {torch.__version__}") 28 | except ImportError as e: 29 | print(f"Failed to import torch: {e}") 30 | 31 | try: 32 | import demucs.separate 33 | print("Demucs imported successfully") 34 | except ImportError as e: 35 | print(f"Failed to import demucs: {e}") 36 | 37 | try: 38 | from audio_separator.separator import Separator 39 | print("Audio Separator imported successfully") 40 | except ImportError as e: 41 | print(f"Failed to import audio_separator: {e}") 42 | 43 | try: 44 | from src.core.advanced_audio import AdvancedAudioProcessor 45 | print("AdvancedAudioProcessor imported successfully") 46 | except ImportError as e: 47 | print(f"Failed to import AdvancedAudioProcessor: {e}") 48 | import traceback 49 | traceback.print_exc() 50 | 51 | def test_initialization(): 52 | print("\n--- Testing Initialization ---") 53 | try: 54 | from src.core.advanced_audio import AdvancedAudioProcessor 55 | processor = AdvancedAudioProcessor("test_output") 56 | print("AdvancedAudioProcessor initialized successfully") 57 | except Exception as e: 58 | print(f"Failed to initialize AdvancedAudioProcessor: {e}") 59 | import traceback 60 | traceback.print_exc() 61 | 62 | if __name__ == "__main__": 63 | check_ffmpeg() 64 | test_imports() 65 | test_initialization() 66 | -------------------------------------------------------------------------------- /debug_splitter.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from unittest.mock import MagicMock 4 | 5 | # Mock lameenc 6 | sys.modules["lameenc"] = MagicMock() 7 | 8 | import demucs.separate 9 | import torch 10 | import torchaudio 11 | 12 | import soundfile as sf 13 | 14 | # Force soundfile backend by monkeypatching load 15 | def custom_load(filepath, *args, **kwargs): 16 | # Ignore extra args, just load the file 17 | wav, sr = sf.read(filepath) 18 | wav = torch.tensor(wav).float() 19 | if wav.ndim == 1: 20 | wav = wav.unsqueeze(0) 21 | else: 22 | wav = wav.t() # soundfile returns (time, channels), torch expects (channels, time) 23 | return wav, sr 24 | 25 | torchaudio.load = custom_load 26 | 27 | def custom_save(filepath, src, sample_rate, **kwargs): 28 | # src is (channels, time) 29 | # soundfile expects (time, channels) 30 | src = src.detach().cpu().t().numpy() 31 | sf.write(filepath, src, sample_rate) 32 | 33 | torchaudio.save = custom_save 34 | 35 | 36 | def test_splitter(): 37 | print("Starting Debug Splitter...") 38 | 39 | # Use the dummy file or create one 40 | if not os.path.exists("test_audio.wav"): 41 | import numpy as np 42 | import soundfile as sf 43 | sr = 44100 44 | duration = 5 45 | data = np.random.uniform(-0.1, 0.1, size=(sr * duration, 2)) 46 | sf.write("test_audio.wav", data, sr) 47 | 48 | file_path = os.path.abspath("test_audio.wav") 49 | output_dir = os.path.join(os.path.dirname(file_path), "test_audio - Stems") 50 | 51 | args = [ 52 | "-n", "htdemucs", 53 | "--shifts", "0", 54 | "-o", output_dir, 55 | "--filename", "{track}/{stem}.{ext}", 56 | file_path 57 | ] 58 | 59 | if not torch.cuda.is_available(): 60 | args.append("-d") 61 | args.append("cpu") 62 | 63 | print(f"Running Demucs with args: {args}") 64 | 65 | try: 66 | demucs.separate.main(args) 67 | print("Demucs finished successfully.") 68 | except Exception as e: 69 | print(f"Demucs failed: {e}") 70 | import traceback 71 | traceback.print_exc() 72 | 73 | if __name__ == "__main__": 74 | test_splitter() 75 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | # Fix for 'NoneType' object has no attribute 'write' in noconsole mode 5 | class StreamRedirector: 6 | def write(self, text): 7 | pass 8 | def flush(self): 9 | pass 10 | 11 | if sys.stdout is None: 12 | sys.stdout = StreamRedirector() 13 | if sys.stderr is None: 14 | sys.stderr = StreamRedirector() 15 | 16 | from PyQt6.QtWidgets import QApplication 17 | from src.ui.main_window import MainWindow 18 | from src.ui.splash import SplashScreen 19 | import time 20 | 21 | def run_worker(args): 22 | # args is a list of arguments passed after --worker 23 | # Expected: input_file stem_count quality export_zip keep_original 24 | try: 25 | import json 26 | # We'll pass a single JSON string for simplicity 27 | config = json.loads(args[0]) 28 | 29 | from src.core.splitter import separate_audio 30 | separate_audio( 31 | config['input_file'], 32 | config['output_dir'], 33 | config['stem_count'], 34 | config['quality'], 35 | config['export_zip'], 36 | config['keep_original'], 37 | export_mp3=config.get('export_mp3', False), 38 | mode=config.get('mode', 'standard'), 39 | dereverb=config.get('dereverb', False), 40 | invert=config.get('invert', False) 41 | ) 42 | except Exception as e: 43 | print(f"WORKER ERROR: {e}", file=sys.stderr) 44 | sys.exit(1) 45 | 46 | def main(): 47 | if "--worker" in sys.argv: 48 | # Worker mode 49 | idx = sys.argv.index("--worker") 50 | run_worker(sys.argv[idx+1:]) 51 | return 52 | 53 | app = QApplication(sys.argv) 54 | app.setApplicationName("StemLab") 55 | 56 | splash = SplashScreen() 57 | splash.show() 58 | splash.show_message("Initializing Core Systems...") 59 | app.processEvents() 60 | 61 | # Simulate loading (or actually load things if we had heavy imports) 62 | time.sleep(1) 63 | splash.show_message("Loading AI Models...") 64 | app.processEvents() 65 | time.sleep(1) 66 | splash.show_message("Starting UI...") 67 | app.processEvents() 68 | time.sleep(0.5) 69 | 70 | window = MainWindow() 71 | window.show() 72 | splash.finish(window) 73 | 74 | sys.exit(app.exec()) 75 | 76 | if __name__ == "__main__": 77 | main() 78 | -------------------------------------------------------------------------------- /src/ui/style.py: -------------------------------------------------------------------------------- 1 | 2 | COLORS = { 3 | "background": "#0E0E0E", 4 | "surface": "#1A1A1A", 5 | "primary": "#00D4FF", # Bright Cyan 6 | "secondary": "#FF6B6B", # Soft Coral 7 | "text": "#FFFFFF", 8 | "text_dim": "#B0B0B0", 9 | "danger": "#FF4444", 10 | "success": "#00D4FF", 11 | "accent": "#FF6B6B" 12 | } 13 | 14 | STYLESHEET = f""" 15 | QMainWindow {{ 16 | background-color: {COLORS['background']}; 17 | }} 18 | 19 | QWidget {{ 20 | color: {COLORS['text']}; 21 | font-family: 'Segoe UI', sans-serif; 22 | font-size: 14px; 23 | }} 24 | 25 | /* Buttons */ 26 | QPushButton {{ 27 | background-color: {COLORS['surface']}; 28 | border: 1px solid {COLORS['primary']}; 29 | border-radius: 8px; 30 | padding: 8px 16px; 31 | color: {COLORS['primary']}; 32 | font-weight: bold; 33 | }} 34 | 35 | QPushButton:hover {{ 36 | background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {COLORS['primary']}, stop:1 {COLORS['secondary']}); 37 | color: {COLORS['background']}; 38 | border: 1px solid {COLORS['primary']}; 39 | }} 40 | 41 | QPushButton:pressed {{ 42 | background-color: {COLORS['primary']}; 43 | border-color: {COLORS['secondary']}; 44 | }} 45 | 46 | QPushButton:disabled {{ 47 | border-color: {COLORS['text_dim']}; 48 | color: {COLORS['text_dim']}; 49 | background-color: transparent; 50 | }} 51 | 52 | /* Group Box */ 53 | QGroupBox {{ 54 | border: 1px solid #333333; 55 | border-radius: 8px; 56 | margin-top: 20px; 57 | background-color: rgba(255, 255, 255, 0.03); 58 | }} 59 | 60 | QGroupBox::title {{ 61 | subcontrol-origin: margin; 62 | subcontrol-position: top left; 63 | padding: 0 10px; 64 | color: {COLORS['primary']}; 65 | font-weight: bold; 66 | background-color: {COLORS['background']}; 67 | }} 68 | 69 | /* Sliders */ 70 | QSlider::groove:horizontal {{ 71 | border: 1px solid #333333; 72 | height: 6px; 73 | background: #222222; 74 | margin: 2px 0; 75 | border-radius: 3px; 76 | }} 77 | 78 | QSlider::handle:horizontal {{ 79 | background: {COLORS['primary']}; 80 | border: 1px solid {COLORS['primary']}; 81 | width: 16px; 82 | height: 16px; 83 | margin: -6px 0; 84 | border-radius: 8px; 85 | }} 86 | 87 | /* Progress Bar */ 88 | QProgressBar {{ 89 | border: none; 90 | background-color: #222222; 91 | border-radius: 4px; 92 | text-align: center; 93 | color: white; 94 | }} 95 | 96 | QProgressBar::chunk {{ 97 | background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {COLORS['primary']}, stop:1 {COLORS['secondary']}); 98 | border-radius: 4px; 99 | }} 100 | 101 | /* List Widget */ 102 | QListWidget {{ 103 | background-color: {COLORS['surface']}; 104 | border: 1px solid #333333; 105 | border-radius: 8px; 106 | padding: 5px; 107 | }} 108 | 109 | QListWidget::item {{ 110 | padding: 8px; 111 | border-radius: 4px; 112 | margin-bottom: 2px; 113 | }} 114 | 115 | QListWidget::item:selected {{ 116 | background-color: rgba(0, 212, 255, 0.1); 117 | border: 1px solid {COLORS['primary']}; 118 | }} 119 | """ 120 | 121 | def apply_theme(app_or_window): 122 | app_or_window.setStyleSheet(STYLESHEET) 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StemLab v1.0 2 | 3 | **Professional-grade AI stem separation. Local. Unlimited. One-time payment.** 4 | 5 | StemLab is a powerful, local Windows application for separating audio tracks into individual stems (Vocals, Drums, Bass, Other). It leverages state-of-the-art AI models (Demucs and MDX-Net) to deliver studio-quality results without monthly subscriptions or cloud upload limits. 6 | 7 | ![StemLab Splash](resources/splash.png) 8 | 9 | ## Features 10 | 11 | * **100% Offline & Local**: No data leaves your machine. Privacy guaranteed. 12 | * **Unlimited Usage**: No credits, no timers, no subscriptions. 13 | * **Advanced AI Models**: 14 | * **Hybrid Ensemble**: Combines `Demucs` (for instrument separation) and `MDX-Net` (for ultra-clean vocals). 15 | * **De-Reverb & De-Echo**: Experimental post-processing to remove room ambiance. 16 | * **Multiple Stem Modes**: 17 | * **2-Stem**: Vocals / Instrumental 18 | * **4-Stem**: Vocals, Drums, Bass, Other 19 | * **6-Stem**: Vocals, Drums, Bass, Guitar, Piano, Other 20 | * **Vocals Only (Ultra Clean)**: Specialized pipeline for the cleanest possible acapellas. 21 | * **Instrumental / Karaoke**: High-quality backing tracks. 22 | * **Professional Workflow**: 23 | * **Batch Processing**: Drag & drop multiple files. 24 | * **Format Support**: Export as WAV (Lossless) or MP3 (320kbps). 25 | * **GPU Acceleration**: Auto-detects NVIDIA GPUs for faster processing. (Testing) 26 | * **Smart Queue**: Manage your jobs with progress bars and cancellation. 27 | 28 | ## Requirements 29 | 30 | * **OS**: Windows 10 or 11 (64-bit) 31 | * **RAM**: 8GB minimum (16GB recommended) 32 | * **GPU (Optional)**: NVIDIA GPU with 4GB+ VRAM for accelerated processing. (Runs on CPU if no GPU is found). 33 | * **Python**: Python 3.10 (for building from source). 34 | 35 | ## Installation 36 | 37 | ### Option 1: Pre-built Executable 38 | Purchase the ready-to-run `StemLab.exe` from **[Gumroad](https://justinmurray99.gumroad.com/l/StemLab)**. 39 | (Instant download, no setup required). 40 | 41 | ### Option 2: Build from Source 42 | 43 | If you want to modify the code or build it yourself, follow these steps: 44 | 45 | 1. **Clone the Repository**: 46 | ```bash 47 | git clone https://github.com/sunsetsacoustic/StemLab.git 48 | cd StemLab 49 | ``` 50 | 51 | 2. **Install Python 3.10**: 52 | Ensure you have Python 3.10 installed and added to your PATH. 53 | 54 | 3. **Run the Build Script**: 55 | We provide a robust build script that handles virtual environment creation and dependency installation automatically. 56 | 57 | Double-click **`rebuild_cpu_robust.bat`**. 58 | 59 | This script will: 60 | * Create a local virtual environment (`venv_cpu`). 61 | * Install all required libraries (`PyQt6`, `torch`, `demucs`, `audio-separator`, etc.). 62 | * Package the application into a single `.exe` file using PyInstaller. 63 | 64 | 4. **Run the App**: 65 | * **From Source**: Double-click `run_cpu.bat`. 66 | * **Compiled EXE**: Check the `dist` folder for `StemLab.exe`. 67 | 68 | ## Credits 69 | 70 | * **Demucs** by Meta Research 71 | * **Audio Separator** (MDX-Net implementation) 72 | * **PyQt6** for the User Interface 73 | 74 | --- 75 | *Built with ❤️ by Sunsets Acoustic* 76 | -------------------------------------------------------------------------------- /src/core/advanced_audio.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import logging 4 | import torch 5 | import soundfile as sf 6 | import numpy as np 7 | from audio_separator.separator import Separator 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | class AdvancedAudioProcessor: 12 | def __init__(self, output_dir): 13 | self.output_dir = output_dir 14 | self.separator = Separator( 15 | log_level=logging.INFO, 16 | output_dir=output_dir, 17 | output_format="wav" 18 | ) 19 | 20 | def run_mdx(self, input_file, model_name): 21 | """ 22 | Runs a specific MDX model using audio-separator. 23 | Returns the path to the output file. 24 | """ 25 | logger.info(f"Loading MDX Model: {model_name}") 26 | self.separator.load_model(model_filename=model_name) 27 | 28 | logger.info(f"Separating with {model_name}...") 29 | # audio-separator returns a list of output filenames 30 | output_files = self.separator.separate(input_file) 31 | 32 | # We assume the model produces specific stems. 33 | # For vocal models, we usually get a vocals file and an instrumental file. 34 | # We need to identify which is which. 35 | # Usually audio-separator names them like "{filename}_(Vocals)_{model}.wav" 36 | 37 | return [os.path.join(self.output_dir, f) for f in output_files] 38 | 39 | def ensemble_blend(self, file1, file2, output_path): 40 | """ 41 | Blends two audio files by averaging them. 42 | """ 43 | logger.info(f"Blending {os.path.basename(file1)} and {os.path.basename(file2)}") 44 | 45 | data1, sr1 = sf.read(file1) 46 | data2, sr2 = sf.read(file2) 47 | 48 | # Ensure same length 49 | min_len = min(len(data1), len(data2)) 50 | data1 = data1[:min_len] 51 | data2 = data2[:min_len] 52 | 53 | # Average 54 | blended = (data1 + data2) / 2 55 | 56 | sf.write(output_path, blended, sr1) 57 | return output_path 58 | 59 | def invert_audio(self, original_file, stem_file, output_path): 60 | """ 61 | Creates instrumental by subtracting stem from original. 62 | Instrumental = Original - Stem 63 | """ 64 | logger.info("Performing Audio Inversion...") 65 | 66 | orig, sr_orig = sf.read(original_file) 67 | stem, sr_stem = sf.read(stem_file) 68 | 69 | # Ensure match 70 | if sr_orig != sr_stem: 71 | # Resample would be needed here, but for now assume matching SR 72 | pass 73 | 74 | min_len = min(len(orig), len(stem)) 75 | orig = orig[:min_len] 76 | stem = stem[:min_len] 77 | 78 | # Invert 79 | inverted = orig - stem 80 | 81 | sf.write(output_path, inverted, sr_orig) 82 | return output_path 83 | 84 | def process_vocals_ultra_clean(self, input_file, demucs_vocals): 85 | """ 86 | Full pipeline: 87 | 1. Kim_Vocal_2 (MDX) 88 | 2. Blend with Demucs Vocals 89 | 3. De-Reverb (HP2) 90 | 4. De-Echo (Reverb_HQ) 91 | """ 92 | # 1. Run Kim_Vocal_2 93 | mdx_outputs = self.run_mdx(input_file, "Kim_Vocal_2.onnx") 94 | 95 | # Find the vocals stem from MDX output 96 | mdx_vocals = None 97 | for f in mdx_outputs: 98 | if "Vocals" in f or "Kim_Vocal_2" in f: 99 | mdx_vocals = f 100 | break 101 | 102 | if not mdx_vocals: 103 | logger.warning("Could not find MDX vocals, skipping ensemble.") 104 | return demucs_vocals 105 | 106 | # 2. Ensemble 107 | ensemble_vocals = os.path.join(self.output_dir, "vocals_ensemble.wav") 108 | self.ensemble_blend(demucs_vocals, mdx_vocals, ensemble_vocals) 109 | 110 | # 3. De-Reverb (HP2-all-vocals) 111 | # HP2-all-vocals-32000-1.band (UVR-MDX-Net) 112 | # Note: audio-separator might need the exact filename if it's not in its default list. 113 | # We'll try the common name. 114 | hp2_outputs = self.run_mdx(ensemble_vocals, "UVR-MDX-NET-Inst_HQ_3.onnx") # Fallback if HP2 not found by default name 115 | # Ideally we'd use "HP2-all-vocals-32000-1.band.onnx" but let's stick to a known working one for now or try the user's name 116 | # If the user has the file, they can put it in the models dir. 117 | # For now, let's use the ensemble output as the input for the next stage if we skip de-reverb due to missing model. 118 | 119 | # 4. De-Echo (Reverb_HQ_By_FoxJoy) 120 | # reverb_outputs = self.run_mdx(ensemble_vocals, "Reverb_HQ_By_FoxJoy.onnx") 121 | 122 | return ensemble_vocals 123 | -------------------------------------------------------------------------------- /src/ui/widgets.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import ( 2 | QWidget, QLabel, QVBoxLayout, QHBoxLayout, 3 | QProgressBar, QPushButton, QFrame, QStyle 4 | ) 5 | from PyQt6.QtCore import Qt, pyqtSignal, QMimeData, QUrl 6 | from PyQt6.QtGui import QDragEnterEvent, QDropEvent, QIcon, QDrag, QAction 7 | from .style import COLORS 8 | import os 9 | 10 | class DragDropWidget(QFrame): 11 | files_dropped = pyqtSignal(list) 12 | 13 | def __init__(self): 14 | super().__init__() 15 | self.setAcceptDrops(True) 16 | self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised) 17 | self.setStyleSheet(f""" 18 | QFrame {{ 19 | border: 2px dashed {COLORS['text_dim']}; 20 | border-radius: 15px; 21 | background-color: rgba(26, 26, 46, 0.3); 22 | }} 23 | QFrame:hover {{ 24 | border-color: {COLORS['secondary']}; 25 | background-color: rgba(0, 229, 255, 0.1); 26 | }} 27 | """) 28 | 29 | layout = QVBoxLayout(self) 30 | layout.setAlignment(Qt.AlignmentFlag.AlignCenter) 31 | 32 | self.label = QLabel("DRAG & DROP AUDIO FILES HERE\nor Click 'Add Files'") 33 | self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) 34 | self.label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 16px; font-weight: bold;") 35 | layout.addWidget(self.label) 36 | 37 | def dragEnterEvent(self, event: QDragEnterEvent): 38 | if event.mimeData().hasUrls(): 39 | event.accept() 40 | self.setStyleSheet(f""" 41 | QFrame {{ 42 | border: 2px dashed {COLORS['primary']}; 43 | border-radius: 15px; 44 | background-color: rgba(214, 0, 255, 0.1); 45 | }} 46 | """) 47 | else: 48 | event.ignore() 49 | 50 | def dragLeaveEvent(self, event): 51 | self.setStyleSheet(f""" 52 | QFrame {{ 53 | border: 2px dashed {COLORS['text_dim']}; 54 | border-radius: 15px; 55 | background-color: rgba(26, 26, 46, 0.3); 56 | }} 57 | """) 58 | 59 | def dropEvent(self, event: QDropEvent): 60 | self.setStyleSheet(f""" 61 | QFrame {{ 62 | border: 2px dashed {COLORS['text_dim']}; 63 | border-radius: 15px; 64 | background-color: rgba(26, 26, 46, 0.3); 65 | }} 66 | """) 67 | files = [u.toLocalFile() for u in event.mimeData().urls()] 68 | valid_files = [f for f in files if f.lower().endswith(('.mp3', '.wav', '.flac', '.m4a'))] 69 | if valid_files: 70 | self.files_dropped.emit(valid_files) 71 | 72 | class DragButton(QPushButton): 73 | def __init__(self, parent=None): 74 | super().__init__("DRAG", parent) 75 | self.setFixedSize(50, 30) 76 | self.setToolTip("Drag Stems to DAW") 77 | self.setStyleSheet(f"font-weight: bold; color: {COLORS['accent']}; border: 1px solid {COLORS['accent']}; border-radius: 4px;") 78 | self.files = [] 79 | 80 | def set_files(self, files): 81 | self.files = files 82 | 83 | def mouseMoveEvent(self, e): 84 | if e.buttons() == Qt.MouseButton.LeftButton and self.files: 85 | drag = QDrag(self) 86 | mime = QMimeData() 87 | urls = [QUrl.fromLocalFile(f) for f in self.files if os.path.exists(f)] 88 | mime.setUrls(urls) 89 | drag.setMimeData(mime) 90 | drag.exec(Qt.DropAction.CopyAction) 91 | super().mouseMoveEvent(e) 92 | 93 | class QueueItemWidget(QWidget): 94 | cancel_requested = pyqtSignal() 95 | open_folder_requested = pyqtSignal() 96 | resplit_requested = pyqtSignal() 97 | 98 | def __init__(self, filename, parent=None): 99 | super().__init__(parent) 100 | layout = QHBoxLayout(self) 101 | layout.setContentsMargins(5, 5, 5, 5) 102 | 103 | self.name_label = QLabel(filename) 104 | self.name_label.setStyleSheet(f"color: {COLORS['text']}; font-weight: bold;") 105 | layout.addWidget(self.name_label, stretch=2) 106 | 107 | self.status_label = QLabel("Pending") 108 | self.status_label.setStyleSheet(f"color: {COLORS['text_dim']};") 109 | layout.addWidget(self.status_label, stretch=1) 110 | 111 | self.progress = QProgressBar() 112 | self.progress.setTextVisible(False) 113 | self.progress.setStyleSheet(f""" 114 | QProgressBar {{ 115 | border: 1px solid {COLORS['secondary']}; 116 | border-radius: 4px; 117 | background-color: {COLORS['background']}; 118 | height: 10px; 119 | }} 120 | QProgressBar::chunk {{ 121 | background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {COLORS['primary']}, stop:1 {COLORS['secondary']}); 122 | border-radius: 2px; 123 | }} 124 | """) 125 | layout.addWidget(self.progress, stretch=3) 126 | 127 | self.cancel_btn = QPushButton() 128 | self.cancel_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarCloseButton)) 129 | self.cancel_btn.setFixedSize(30, 30) 130 | self.cancel_btn.setStyleSheet(f""" 131 | QPushButton {{ 132 | background-color: transparent; 133 | border: 1px solid {COLORS['danger']}; 134 | border-radius: 15px; 135 | }} 136 | QPushButton:hover {{ 137 | background-color: {COLORS['danger']}; 138 | }} 139 | """) 140 | self.cancel_btn.clicked.connect(self.cancel_requested.emit) 141 | layout.addWidget(self.cancel_btn) 142 | 143 | self.open_btn = QPushButton() 144 | self.open_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon)) 145 | self.open_btn.setFixedSize(30, 30) 146 | self.open_btn.setToolTip("Open Output Folder") 147 | self.open_btn.hide() 148 | self.open_btn.clicked.connect(self.open_output_folder) 149 | layout.addWidget(self.open_btn) 150 | 151 | self.drag_btn = DragButton() 152 | self.drag_btn.hide() 153 | layout.addWidget(self.drag_btn) 154 | 155 | def update_progress(self, filename, value, status=None, output_files=None): 156 | self.progress.setValue(value) 157 | if status: 158 | self.status_label.setText(status) 159 | if "Error" in status: 160 | self.status_label.setStyleSheet(f"color: {COLORS['danger']};") 161 | elif "Done" in status: 162 | self.status_label.setStyleSheet(f"color: {COLORS['success']};") 163 | self.open_btn.show() 164 | self.drag_btn.show() 165 | self.cancel_btn.hide() 166 | if output_files: 167 | self.drag_btn.set_files(output_files) 168 | elif "Pending" in status: 169 | self.open_btn.hide() 170 | self.drag_btn.hide() 171 | self.cancel_btn.show() 172 | 173 | def contextMenuEvent(self, event): 174 | from PyQt6.QtWidgets import QMenu 175 | from PyQt6.QtGui import QAction 176 | menu = QMenu(self) 177 | 178 | open_action = QAction("Open Output Folder", self) 179 | open_action.triggered.connect(self.open_output_folder) 180 | menu.addAction(open_action) 181 | 182 | menu.addSeparator() 183 | 184 | resplit_action = QAction("Re-split", self) 185 | resplit_action.triggered.connect(self.resplit_requested.emit) 186 | menu.addAction(resplit_action) 187 | 188 | remove_action = QAction("Remove", self) 189 | remove_action.triggered.connect(self.cancel_requested.emit) 190 | menu.addAction(remove_action) 191 | 192 | menu.exec(event.globalPos()) 193 | 194 | def open_output_folder(self): 195 | self.open_folder_requested.emit() 196 | -------------------------------------------------------------------------------- /src/core/splitter.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | import subprocess 5 | import json 6 | 7 | 8 | from PyQt6.QtCore import QThread, pyqtSignal 9 | from src.utils.logger import logger 10 | import demucs.separate 11 | import torch 12 | import torchaudio 13 | import soundfile as sf 14 | try: 15 | from src.core.advanced_audio import AdvancedAudioProcessor 16 | except ImportError: 17 | AdvancedAudioProcessor = None 18 | logger.warning("AdvancedAudioProcessor not available (audio-separator missing?)") 19 | 20 | # Monkeypatch torchaudio to use soundfile directly (Fix for Python 3.14 / torchaudio 2.9.1) 21 | def custom_load(filepath, *args, **kwargs): 22 | wav, sr = sf.read(filepath) 23 | wav = torch.tensor(wav).float() 24 | if wav.ndim == 1: 25 | wav = wav.unsqueeze(0) 26 | else: 27 | wav = wav.t() 28 | return wav, sr 29 | 30 | def custom_save(filepath, src, sample_rate, **kwargs): 31 | src = src.detach().cpu().t().numpy() 32 | sf.write(filepath, src, sample_rate) 33 | 34 | torchaudio.load = custom_load 35 | torchaudio.save = custom_save 36 | 37 | def separate_audio(input_file, output_dir, stem_count, quality, export_zip, keep_original, **kwargs): 38 | filename = os.path.basename(input_file) 39 | base_name = os.path.splitext(filename)[0] 40 | os.makedirs(output_dir, exist_ok=True) 41 | 42 | # Determine Model and Args 43 | model = "htdemucs" 44 | shifts = 1 45 | overlap = 0.25 46 | 47 | if stem_count == 6: 48 | model = "htdemucs_6s" 49 | 50 | if quality == 0: # Fast 51 | shifts = 0 52 | overlap = 0.1 53 | elif quality == 2: # Best 54 | if stem_count == 4: 55 | model = "htdemucs_ft" # Fine-tuned 4-stem 56 | shifts = 2 57 | overlap = 0.25 58 | 59 | # Construct Demucs Args 60 | args = [ 61 | "-n", model, 62 | "--shifts", str(shifts), 63 | "--overlap", str(overlap), 64 | "-o", output_dir, 65 | "--filename", "{track}/{stem}.{ext}", 66 | input_file 67 | ] 68 | 69 | if stem_count == 2: 70 | args.append("--two-stems=vocals") 71 | 72 | if kwargs.get("export_mp3", False): 73 | args.append("--mp3") 74 | args.append("--mp3-bitrate") 75 | args.append("320") 76 | 77 | if not torch.cuda.is_available(): 78 | args.append("-d") 79 | args.append("cpu") 80 | 81 | # Run Demucs 82 | demucs.separate.main(args) 83 | 84 | # Organize Files 85 | # Organize Files 86 | demucs_output_root = os.path.join(output_dir, model, base_name) 87 | 88 | mode = kwargs.get("mode", "standard") 89 | ext = "mp3" if kwargs.get("export_mp3", False) else "wav" 90 | 91 | if os.path.exists(demucs_output_root): 92 | for stem in os.listdir(demucs_output_root): 93 | src = os.path.join(demucs_output_root, stem) 94 | 95 | # Filter based on mode 96 | should_keep = True 97 | if mode == "vocals_only" and "vocals" not in stem: 98 | should_keep = False 99 | elif mode == "instrumental" and "no_vocals" not in stem: 100 | should_keep = False 101 | 102 | if should_keep: 103 | dst = os.path.join(output_dir, stem) 104 | shutil.move(src, dst) 105 | 106 | # Clean up empty folders 107 | shutil.rmtree(os.path.join(output_dir, model)) 108 | 109 | # Copy Original if requested 110 | if keep_original: 111 | shutil.copy(input_file, os.path.join(output_dir, f"original.{ext}")) 112 | 113 | # De-Reverb Logic (Placeholder/Basic Implementation) 114 | if kwargs.get("dereverb", False): 115 | logger.info("De-Reverb requested (Experimental)") 116 | 117 | # Advanced Pipeline (Vocals Only) 118 | if mode == "vocals_only" and AdvancedAudioProcessor: 119 | try: 120 | logger.info("Starting Advanced Audio Pipeline (Ensemble/MDX)...") 121 | processor = AdvancedAudioProcessor(output_dir) 122 | 123 | # Demucs output might be mp3 or wav depending on flag 124 | demucs_vocals = os.path.join(output_dir, f"vocals.{ext}") 125 | 126 | # If MP3, we might need to convert back to WAV for processing, or ensure processor handles it 127 | # For simplicity, let's assume processor handles input formats supported by soundfile/ffmpeg 128 | 129 | if os.path.exists(demucs_vocals): 130 | final_vocals = processor.process_vocals_ultra_clean(input_file, demucs_vocals) 131 | 132 | # Rename/Move result 133 | if final_vocals and os.path.exists(final_vocals): 134 | target_name = f"vocals_ultra_clean.wav" # Processor outputs WAV 135 | shutil.move(final_vocals, os.path.join(output_dir, target_name)) 136 | 137 | # Convert to MP3 if requested 138 | if kwargs.get("export_mp3", False): 139 | mp3_target = f"vocals_ultra_clean.mp3" 140 | # Use pydub or ffmpeg to convert. 141 | # Since we have ffmpeg in path (checked by debug script), we can use subprocess 142 | subprocess.run(f'ffmpeg -y -i "{os.path.join(output_dir, target_name)}" -b:a 320k "{os.path.join(output_dir, mp3_target)}"', shell=True) 143 | os.remove(os.path.join(output_dir, target_name)) # Remove WAV 144 | target_name = mp3_target 145 | 146 | logger.info(f"Created Ultra Clean Vocals: {target_name}") 147 | 148 | # Create Instrumental Inversion if needed 149 | if kwargs.get("invert", False): 150 | inst_path = os.path.join(output_dir, f"instrumental_inverted.wav") 151 | processor.invert_audio(input_file, os.path.join(output_dir, target_name), inst_path) 152 | 153 | if kwargs.get("export_mp3", False): 154 | mp3_inst = f"instrumental_inverted.mp3" 155 | subprocess.run(f'ffmpeg -y -i "{inst_path}" -b:a 320k "{os.path.join(output_dir, mp3_inst)}"', shell=True) 156 | os.remove(inst_path) 157 | inst_path = mp3_inst 158 | 159 | logger.info(f"Created Inverted Instrumental: {inst_path}") 160 | 161 | except Exception as e: 162 | logger.error(f"Advanced Pipeline Failed: {e}") 163 | # Fallback to standard Demucs output is already there, so just log error. 164 | 165 | # Zip if requested 166 | if export_zip: 167 | shutil.make_archive(output_dir, 'zip', output_dir) 168 | 169 | class SplitterWorker(QThread): 170 | progress_updated = pyqtSignal(str, int, str) # filename, progress, status 171 | finished = pyqtSignal(str) # filename 172 | error_occurred = pyqtSignal(str, str) # filename, error message 173 | 174 | def __init__(self, file_path, options): 175 | super().__init__() 176 | self.file_path = file_path 177 | self.options = options 178 | self.process = None 179 | self.is_cancelled = False 180 | 181 | def run(self): 182 | filename = os.path.basename(self.file_path) 183 | logger.info(f"Starting processing for {filename} with options: {self.options}") 184 | 185 | try: 186 | base_name = os.path.splitext(filename)[0] 187 | output_dir = os.path.join(os.path.dirname(self.file_path), f"{base_name} - Stems") 188 | 189 | config = { 190 | "input_file": self.file_path, 191 | "output_dir": output_dir, 192 | "stem_count": self.options["stem_count"], 193 | "quality": self.options["quality"], 194 | "export_zip": self.options["export_zip"], 195 | "keep_original": self.options["keep_original"], 196 | "export_mp3": self.options.get("export_mp3", False), 197 | "mode": self.options.get("mode", "standard"), 198 | "dereverb": self.options.get("dereverb", False) 199 | } 200 | 201 | config_json = json.dumps(config) 202 | 203 | # Run subprocess 204 | # Use -u for unbuffered output to capture real-time logs 205 | cmd = [sys.executable, "-u", "main.py", "--worker", config_json] 206 | if getattr(sys, 'frozen', False): 207 | cmd = [sys.executable, "--worker", config_json] 208 | 209 | self.progress_updated.emit(filename, 10, "Starting Worker...") 210 | 211 | startupinfo = None 212 | if os.name == 'nt': 213 | startupinfo = subprocess.STARTUPINFO() 214 | startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW 215 | 216 | # Force unbuffered output 217 | env = os.environ.copy() 218 | env["PYTHONUNBUFFERED"] = "1" 219 | 220 | self.process = subprocess.Popen( 221 | cmd, 222 | stdout=subprocess.PIPE, 223 | stderr=subprocess.STDOUT, 224 | text=False, # Read bytes to handle \r 225 | startupinfo=startupinfo, 226 | bufsize=0, # Unbuffered 227 | env=env 228 | ) 229 | 230 | self.progress_updated.emit(filename, 20, "Separating...") 231 | 232 | # Read output in real-time 233 | buffer = b"" 234 | while True: 235 | if self.is_cancelled: 236 | break 237 | 238 | # Read one byte at a time to handle \r and \n 239 | chunk = self.process.stdout.read(1) 240 | if not chunk and self.process.poll() is not None: 241 | break 242 | 243 | if chunk: 244 | buffer += chunk 245 | 246 | # Check for newline or carriage return 247 | if chunk in (b'\n', b'\r'): 248 | try: 249 | line = buffer.decode('utf-8', errors='replace').strip() 250 | except: 251 | line = "" 252 | 253 | buffer = b"" 254 | 255 | if line: 256 | # Parse Progress (e.g. " 15%|...") 257 | if "%" in line and "|" in line: 258 | try: 259 | # Extract percentage 260 | parts = line.split('%')[0].split() 261 | if parts: 262 | pct = int(parts[-1]) 263 | # Map 0-100% of separation to 20-90% of total progress 264 | total_progress = 20 + int(pct * 0.7) 265 | self.progress_updated.emit(filename, total_progress, f"Separating: {pct}%") 266 | except: 267 | pass 268 | 269 | # User Friendly Logging 270 | # Only log significant messages or errors 271 | is_progress_bar = "%" in line and "|" in line 272 | if not is_progress_bar: 273 | logger.info(f"[Worker] {line}") 274 | if "Separating" in line: 275 | self.progress_updated.emit(filename, 20, "Separating...") 276 | elif "Loading" in line: 277 | self.progress_updated.emit(filename, 10, "Loading Models...") 278 | 279 | if self.is_cancelled: 280 | return 281 | 282 | return_code = self.process.poll() 283 | if return_code != 0: 284 | raise Exception(f"Worker failed with code {return_code}") 285 | 286 | self.progress_updated.emit(filename, 100, "Done") 287 | self.finished.emit(filename) 288 | 289 | except Exception as e: 290 | if not self.is_cancelled: 291 | logger.error(f"Error processing {filename}: {e}") 292 | self.error_occurred.emit(filename, str(e)) 293 | 294 | def terminate(self): 295 | self.is_cancelled = True 296 | if self.process: 297 | try: 298 | self.process.kill() 299 | except: 300 | pass 301 | # Do NOT call super().terminate() - let the thread exit naturally 302 | -------------------------------------------------------------------------------- /src/ui/main_window.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from PyQt6.QtWidgets import ( 4 | QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, 5 | QPushButton, QListWidget, QListWidgetItem, QFileDialog, 6 | QGroupBox, QRadioButton, QCheckBox, QSlider, QLabel, 7 | QDialog, QTextEdit, QStyle 8 | ) 9 | from PyQt6.QtCore import Qt, QObject, pyqtSignal 10 | from .style import STYLESHEET, COLORS, apply_theme 11 | from .widgets import DragDropWidget, QueueItemWidget 12 | from src.core.splitter import SplitterWorker 13 | from src.core.gpu_utils import get_gpu_info 14 | 15 | # Log Window 16 | class LogWindow(QDialog): 17 | def __init__(self, parent=None): 18 | super().__init__(parent) 19 | self.setWindowTitle("Process Logs") 20 | self.resize(600, 400) 21 | layout = QVBoxLayout(self) 22 | self.text_edit = QTextEdit() 23 | self.text_edit.setReadOnly(True) 24 | self.text_edit.setStyleSheet(f"background-color: {COLORS['background']}; color: {COLORS['text']}; font-family: Consolas, monospace;") 25 | layout.addWidget(self.text_edit) 26 | 27 | class LogEmitter(QObject): 28 | text_written = pyqtSignal(str) 29 | 30 | class StreamRedirector: 31 | def __init__(self, emitter): 32 | self.emitter = emitter 33 | def write(self, text): 34 | self.emitter.text_written.emit(text) 35 | def flush(self): 36 | pass 37 | 38 | class MainWindow(QMainWindow): 39 | def __init__(self): 40 | super().__init__() 41 | self.setWindowTitle("StemLab v1.0") 42 | self.resize(1000, 700) 43 | 44 | # Setup Logging 45 | self.log_emitter = LogEmitter() 46 | self.log_emitter.text_written.connect(self.append_log) 47 | sys.stdout = StreamRedirector(self.log_emitter) 48 | sys.stderr = StreamRedirector(self.log_emitter) 49 | 50 | self.log_window = LogWindow(self) 51 | 52 | # Central Widget 53 | central = QWidget() 54 | self.setCentralWidget(central) 55 | self.main_layout = QHBoxLayout(central) 56 | self.main_layout.setSpacing(0) 57 | self.main_layout.setContentsMargins(0, 0, 0, 0) 58 | 59 | apply_theme(self) 60 | self.setup_ui() 61 | 62 | # Initial State 63 | self.slider_quality.setValue(1) 64 | 65 | def setup_ui(self): 66 | # Left Panel (Controls) 67 | left_panel = QWidget() 68 | left_layout = QVBoxLayout(left_panel) 69 | left_panel.setFixedWidth(300) 70 | 71 | # Logo / Title 72 | title = QLabel("STEM\nLAB") 73 | title.setAlignment(Qt.AlignmentFlag.AlignCenter) 74 | title.setStyleSheet(f"font-size: 36px; font-weight: 900; color: {COLORS['text']}; margin-top: 10px;") 75 | left_layout.addWidget(title) 76 | 77 | tagline = QLabel("Professional-grade AI stem separation.\nLocal. Unlimited. One-time payment.") 78 | tagline.setAlignment(Qt.AlignmentFlag.AlignCenter) 79 | tagline.setWordWrap(True) 80 | tagline.setStyleSheet(f"font-size: 11px; color: {COLORS['text_dim']}; margin-bottom: 20px; font-style: italic;") 81 | left_layout.addWidget(tagline) 82 | 83 | # GPU Indicator 84 | self.gpu_label = QLabel("Checking GPU...") 85 | self.gpu_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 86 | self.gpu_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 12px; margin-bottom: 10px;") 87 | left_layout.addWidget(self.gpu_label) 88 | 89 | # Update GPU info 90 | is_gpu, device_name = get_gpu_info() 91 | if is_gpu: 92 | self.gpu_label.setText(f"{device_name} • Ready") 93 | self.gpu_label.setStyleSheet(f"color: {COLORS['success']}; font-size: 12px; margin-bottom: 10px; font-weight: bold;") 94 | else: 95 | self.gpu_label.setText("CPU Mode (Slower)") 96 | self.gpu_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 12px; margin-bottom: 10px;") 97 | 98 | # Stem Options 99 | stem_group = QGroupBox("STEM OPTIONS") 100 | stem_layout = QVBoxLayout() 101 | 102 | self.radio_2stem = QRadioButton("2-Stem (Vocals / Instrumental)") 103 | self.radio_4stem = QRadioButton("4-Stem (Classic)") 104 | self.radio_6stem = QRadioButton("6-Stem (Full Band)") 105 | self.radio_vocals = QRadioButton("Vocals Only (Ultra Clean)") 106 | self.radio_inst = QRadioButton("Instrumental / Karaoke") 107 | 108 | # Set default 109 | self.radio_2stem.setChecked(True) 110 | 111 | stem_layout.addWidget(self.radio_2stem) 112 | stem_layout.addWidget(self.radio_4stem) 113 | stem_layout.addWidget(self.radio_6stem) 114 | stem_layout.addWidget(self.radio_vocals) 115 | stem_layout.addWidget(self.radio_inst) 116 | 117 | # Advanced Toggle 118 | self.chk_dereverb = QCheckBox("De-Reverb + De-Echo (Experimental)") 119 | self.chk_dereverb.setStyleSheet(f"color: {COLORS['secondary']}; margin-top: 5px;") 120 | stem_layout.addWidget(self.chk_dereverb) 121 | 122 | stem_group.setLayout(stem_layout) 123 | left_layout.addWidget(stem_group) 124 | 125 | # Speed/Quality 126 | quality_group = QGroupBox("QUALITY MODE") 127 | quality_layout = QVBoxLayout() 128 | 129 | self.slider_quality = QSlider(Qt.Orientation.Horizontal) 130 | self.slider_quality.setRange(0, 2) 131 | self.slider_quality.setTickPosition(QSlider.TickPosition.TicksBelow) 132 | self.slider_quality.setTickInterval(1) 133 | self.slider_quality.valueChanged.connect(self.update_quality_label) 134 | 135 | self.label_quality = QLabel("Balanced (GPU Auto)") 136 | self.label_quality.setAlignment(Qt.AlignmentFlag.AlignCenter) 137 | self.label_quality.setStyleSheet(f"color: {COLORS['secondary']}; font-weight: bold;") 138 | 139 | quality_layout.addWidget(self.label_quality) 140 | quality_layout.addWidget(self.slider_quality) 141 | quality_group.setLayout(quality_layout) 142 | left_layout.addWidget(quality_group) 143 | 144 | # Output Options 145 | out_group = QGroupBox("OUTPUT") 146 | out_layout = QVBoxLayout() 147 | self.chk_mp3 = QCheckBox("Export as MP3 320kbps") 148 | self.chk_zip = QCheckBox("Export as ZIP") 149 | self.chk_keep = QCheckBox("Keep Original") 150 | self.chk_keep.setChecked(True) 151 | self.chk_auto_open = QCheckBox("Auto-open Folder") 152 | self.chk_auto_open.setChecked(True) 153 | 154 | out_layout.addWidget(self.chk_mp3) 155 | out_layout.addWidget(self.chk_zip) 156 | out_layout.addWidget(self.chk_keep) 157 | out_layout.addWidget(self.chk_auto_open) 158 | out_group.setLayout(out_layout) 159 | left_layout.addWidget(out_group) 160 | 161 | left_layout.addStretch() 162 | 163 | # Process Button 164 | self.btn_process = QPushButton("START PROCESSING") 165 | self.btn_process.setFixedHeight(50) 166 | self.btn_process.setStyleSheet(f"font-size: 16px; background-color: {COLORS['primary']}; color: white;") 167 | self.btn_process.clicked.connect(self.start_processing) 168 | left_layout.addWidget(self.btn_process) 169 | 170 | # Logs Button 171 | self.logs_btn = QPushButton("Show Logs") 172 | self.logs_btn.setStyleSheet(f"color: {COLORS['text_dim']}; background: transparent; border: none; text-decoration: underline;") 173 | self.logs_btn.setCursor(Qt.CursorShape.PointingHandCursor) 174 | self.logs_btn.clicked.connect(self.log_window.show) 175 | left_layout.addWidget(self.logs_btn) 176 | 177 | self.main_layout.addWidget(left_panel) 178 | 179 | # Right Panel (Queue) 180 | right_panel = QWidget() 181 | right_layout = QVBoxLayout(right_panel) 182 | 183 | # Drag Drop Area 184 | self.drag_drop = DragDropWidget() 185 | self.drag_drop.setFixedHeight(150) 186 | self.drag_drop.files_dropped.connect(self.add_files_to_queue) 187 | right_layout.addWidget(self.drag_drop) 188 | 189 | # Queue List 190 | self.queue_list = QListWidget() 191 | right_layout.addWidget(self.queue_list) 192 | 193 | # Bottom Controls 194 | btn_layout = QHBoxLayout() 195 | self.btn_add = QPushButton("Add Files") 196 | self.btn_add.clicked.connect(self.open_file_dialog) 197 | self.btn_clear = QPushButton("Clear Queue") 198 | self.btn_clear.clicked.connect(self.queue_list.clear) 199 | 200 | btn_layout.addWidget(self.btn_add) 201 | btn_layout.addStretch() 202 | btn_layout.addWidget(self.btn_clear) 203 | right_layout.addLayout(btn_layout) 204 | 205 | self.main_layout.addWidget(right_panel) 206 | 207 | def append_log(self, text): 208 | self.log_window.text_edit.moveCursor(self.log_window.text_edit.textCursor().MoveOperation.End) 209 | self.log_window.text_edit.insertPlainText(text) 210 | self.log_window.text_edit.moveCursor(self.log_window.text_edit.textCursor().MoveOperation.End) 211 | 212 | def update_quality_label(self, value): 213 | labels = ["Fast (CPU)", "Balanced (GPU Auto)", "Best (Ensemble)"] 214 | self.label_quality.setText(labels[value]) 215 | 216 | def open_file_dialog(self): 217 | files, _ = QFileDialog.getOpenFileNames( 218 | self, "Select Audio Files", "", "Audio Files (*.mp3 *.wav *.flac *.m4a)" 219 | ) 220 | if files: 221 | self.add_files_to_queue(files) 222 | 223 | def add_files_to_queue(self, files): 224 | for f in files: 225 | item = QListWidgetItem(self.queue_list) 226 | item.setData(Qt.ItemDataRole.UserRole, f) # Store full path 227 | widget = QueueItemWidget(os.path.basename(f)) 228 | item.setSizeHint(widget.sizeHint()) 229 | self.queue_list.addItem(item) 230 | self.queue_list.setItemWidget(item, widget) 231 | 232 | # Connect cancel signal 233 | widget.cancel_requested.connect(lambda i=item: self.remove_queue_item(i)) 234 | # Connect open folder signal 235 | widget.open_folder_requested.connect(lambda i=item: self.open_item_folder(i)) 236 | # Connect resplit signal 237 | widget.resplit_requested.connect(lambda i=item: self.resplit_item(i)) 238 | 239 | def resplit_item(self, item): 240 | widget = self.queue_list.itemWidget(item) 241 | widget.update_progress(None, 0, "Pending") 242 | widget.status_label.setStyleSheet(f"color: {COLORS['text_dim']};") 243 | self.start_processing() 244 | 245 | def remove_queue_item(self, item): 246 | # Check if this item is currently being processed 247 | if hasattr(self, 'worker') and self.worker.isRunning(): 248 | widget = self.queue_list.itemWidget(item) 249 | # Simple check: if status is not Pending/Done/Error, it's likely processing 250 | status = widget.status_label.text() 251 | if "Pending" not in status and "Done" not in status and "Error" not in status and "Cancelled" not in status: 252 | from src.utils.logger import logger 253 | logger.info("Terminating active process...") 254 | 255 | # Terminate worker (kills subprocess) 256 | self.worker.terminate() 257 | self.worker.wait() 258 | widget.update_progress(None, 0, "Cancelled") 259 | 260 | # Cleanup partially created files 261 | file_path = item.data(Qt.ItemDataRole.UserRole) 262 | base_name = os.path.splitext(os.path.basename(file_path))[0] 263 | output_dir = os.path.join(os.path.dirname(file_path), f"{base_name} - Stems") 264 | 265 | if os.path.exists(output_dir): 266 | import shutil 267 | try: 268 | shutil.rmtree(output_dir) 269 | logger.info(f"Cleaned up output directory: {output_dir}") 270 | except Exception as e: 271 | logger.error(f"Failed to clean up output directory: {e}") 272 | 273 | row = self.queue_list.row(item) 274 | self.queue_list.takeItem(row) 275 | 276 | def open_item_folder(self, item): 277 | import subprocess 278 | file_path = item.data(Qt.ItemDataRole.UserRole) 279 | base_name = os.path.splitext(os.path.basename(file_path))[0] 280 | output_dir = os.path.join(os.path.dirname(file_path), f"{base_name} - Stems") 281 | 282 | if os.path.exists(output_dir): 283 | os.startfile(output_dir) 284 | else: 285 | # Fallback to parent dir if stems folder doesn't exist yet 286 | os.startfile(os.path.dirname(file_path)) 287 | 288 | def start_processing(self): 289 | if hasattr(self, 'worker') and self.worker.isRunning(): 290 | return 291 | 292 | # Find first pending item 293 | for i in range(self.queue_list.count()): 294 | item = self.queue_list.item(i) 295 | widget = self.queue_list.itemWidget(item) 296 | 297 | if widget.status_label.text() == "Pending": 298 | self.process_item(item) 299 | return 300 | 301 | def process_item(self, item): 302 | widget = self.queue_list.itemWidget(item) 303 | file_path = item.data(Qt.ItemDataRole.UserRole) 304 | 305 | # Determine Stem Mode 306 | stem_count = 4 307 | mode = "standard" # standard, vocals_only, instrumental, remix_pack 308 | 309 | if self.radio_6stem.isChecked(): 310 | stem_count = 6 311 | elif self.radio_2stem.isChecked(): 312 | stem_count = 2 313 | elif self.radio_vocals.isChecked(): 314 | stem_count = 2 315 | mode = "vocals_only" 316 | elif self.radio_inst.isChecked(): 317 | stem_count = 2 318 | mode = "instrumental" 319 | 320 | options = { 321 | "stem_count": stem_count, 322 | "mode": mode, 323 | "quality": self.slider_quality.value(), 324 | "export_zip": self.chk_zip.isChecked(), 325 | "keep_original": self.chk_keep.isChecked(), 326 | "export_mp3": self.chk_mp3.isChecked(), 327 | "dereverb": self.chk_dereverb.isChecked() 328 | } 329 | 330 | self.worker = SplitterWorker(file_path, options) 331 | self.worker.progress_updated.connect(widget.update_progress) 332 | self.worker.finished.connect(lambda _: self.on_worker_finished(item)) 333 | self.worker.error_occurred.connect(lambda f, e: self.on_worker_error(item, e)) 334 | self.worker.start() 335 | 336 | def on_worker_finished(self, item): 337 | import winsound 338 | import glob 339 | 340 | file_path = item.data(Qt.ItemDataRole.UserRole) 341 | base_name = os.path.splitext(os.path.basename(file_path))[0] 342 | output_dir = os.path.join(os.path.dirname(file_path), f"{base_name} - Stems") 343 | 344 | # Collect generated files for drag-and-drop 345 | output_files = [] 346 | if os.path.exists(output_dir): 347 | output_files = glob.glob(os.path.join(output_dir, "*.wav")) 348 | output_files += glob.glob(os.path.join(output_dir, "*.mp3")) 349 | 350 | widget = self.queue_list.itemWidget(item) 351 | widget.update_progress(None, 100, "Done", output_files=output_files) 352 | 353 | # Play success sound 354 | try: 355 | winsound.MessageBeep(winsound.MB_OK) 356 | except: 357 | pass 358 | 359 | # Auto-open folder if enabled 360 | if self.chk_auto_open.isChecked(): 361 | self.open_item_folder(item) 362 | 363 | # Process next 364 | self.start_processing() 365 | 366 | def on_worker_error(self, item, error): 367 | widget = self.queue_list.itemWidget(item) 368 | widget.status_label.setText(f"Error: {error}") 369 | widget.status_label.setStyleSheet(f"color: {COLORS['danger']};") 370 | # Process next 371 | self.start_processing() 372 | --------------------------------------------------------------------------------