mirror of
https://github.com/BillyOutlast/MusicIn2001.git
synced 2026-10-12 02:25:16 +00:00
- Onboarding: Welcome to Music in 2001 - Main menu title: Music in 2001 - How to Use: Get Music in 2001 on a Mac - Fix tip product ID consistency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
307 lines
9.2 KiB
Python
307 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Mixor CLI - Entry point for Swift subprocess calls
|
|
Outputs JSON for Swift to parse progress and results
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import argparse
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
# Ensure proper encoding
|
|
os.environ['PYTHONIOENCODING'] = 'utf-8'
|
|
|
|
|
|
def emit_progress(progress: float, status: str, step: str):
|
|
"""Emit progress update as JSON line for Swift to parse"""
|
|
update = {"progress": progress, "status": status, "step": step}
|
|
print(json.dumps(update), flush=True)
|
|
|
|
|
|
def emit_result(success: bool, instrumental_path: str = None, vocals_path: str = None,
|
|
title: str = None, duration: float = None, error: str = None):
|
|
"""Emit final result as JSON"""
|
|
result = {
|
|
"success": success,
|
|
"instrumentalPath": instrumental_path,
|
|
"vocalsPath": vocals_path,
|
|
"title": title,
|
|
"duration": duration,
|
|
"error": error
|
|
}
|
|
print(json.dumps(result), flush=True)
|
|
|
|
|
|
def get_audio_duration(file_path: Path) -> float:
|
|
"""Get audio duration using ffprobe"""
|
|
try:
|
|
import subprocess
|
|
result = subprocess.run([
|
|
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
|
'-of', 'default=noprint_wrappers=1:nokey=1', str(file_path)
|
|
], capture_output=True, text=True)
|
|
return float(result.stdout.strip())
|
|
except:
|
|
return None
|
|
|
|
|
|
def process_youtube(url: str, output_dir: str):
|
|
"""Process YouTube URL: download and separate vocals"""
|
|
# Debug: print Python info
|
|
print(f"DEBUG: Python executable: {sys.executable}", file=sys.stderr)
|
|
print(f"DEBUG: Python version: {sys.version}", file=sys.stderr)
|
|
|
|
import torch
|
|
|
|
output_path = Path(output_dir)
|
|
output_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
temp_dir = output_path / "temp"
|
|
temp_dir.mkdir(exist_ok=True)
|
|
|
|
instrumentals_dir = output_path / "Instrumentals"
|
|
instrumentals_dir.mkdir(exist_ok=True)
|
|
|
|
acapellas_dir = output_path / "Acapellas"
|
|
acapellas_dir.mkdir(exist_ok=True)
|
|
|
|
# Determine device
|
|
if torch.cuda.is_available():
|
|
device = "cuda"
|
|
elif torch.backends.mps.is_available():
|
|
device = "mps"
|
|
else:
|
|
device = "cpu"
|
|
|
|
try:
|
|
# Step 1: Download
|
|
emit_progress(0.1, "Downloading audio...", "download")
|
|
|
|
import yt_dlp
|
|
|
|
# Find ffmpeg
|
|
ffmpeg_location = None
|
|
if sys.platform == 'darwin':
|
|
for path in ['/opt/homebrew/bin', '/usr/local/bin']:
|
|
if os.path.exists(os.path.join(path, 'ffmpeg')):
|
|
ffmpeg_location = path
|
|
break
|
|
|
|
# Match original working yt-dlp options exactly
|
|
ydl_opts = {
|
|
'format': 'bestaudio/best',
|
|
'postprocessors': [{
|
|
'key': 'FFmpegExtractAudio',
|
|
'preferredcodec': 'mp3',
|
|
'preferredquality': '192',
|
|
}],
|
|
'outtmpl': str(temp_dir / '%(title)s.%(ext)s'),
|
|
'quiet': False,
|
|
'no_warnings': False,
|
|
'extractaudio': True,
|
|
'noplaylist': True,
|
|
'ffmpeg_location': '/opt/homebrew/bin',
|
|
}
|
|
|
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
info = ydl.extract_info(url, download=True)
|
|
title = info.get('title', 'Unknown')
|
|
duration = info.get('duration')
|
|
|
|
# Clean filename
|
|
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
|
|
|
# Find downloaded file
|
|
audio_file = None
|
|
for file in temp_dir.glob("*.mp3"):
|
|
if file.exists():
|
|
audio_file = file
|
|
break
|
|
|
|
if not audio_file:
|
|
emit_result(False, error="Download failed - audio file not found")
|
|
return
|
|
|
|
emit_progress(0.3, "Processing with Demucs...", "demucs")
|
|
|
|
# Step 2: Run Demucs
|
|
import subprocess
|
|
|
|
model = "htdemucs_ft"
|
|
cmd = [
|
|
sys.executable, "-m", "demucs",
|
|
"--two-stems=vocals",
|
|
"-n", model,
|
|
"-o", str(output_path),
|
|
"--device", device,
|
|
"--mp3",
|
|
str(audio_file)
|
|
]
|
|
|
|
env = os.environ.copy()
|
|
env['PYTHONIOENCODING'] = 'utf-8'
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', env=env)
|
|
|
|
if result.returncode != 0:
|
|
emit_result(False, error=f"Demucs error: {result.stderr}")
|
|
return
|
|
|
|
emit_progress(0.9, "Finalizing...", "finalize")
|
|
|
|
# Step 3: Move output files
|
|
output_name = audio_file.stem
|
|
model_dir = output_path / model / output_name
|
|
instrumental_file = model_dir / "no_vocals.mp3"
|
|
vocals_file = model_dir / "vocals.mp3"
|
|
|
|
if not instrumental_file.exists():
|
|
emit_result(False, error="Processing failed - output file not found")
|
|
return
|
|
|
|
# Move to final locations
|
|
final_instrumental = instrumentals_dir / f"{output_name}.mp3"
|
|
final_acapella = acapellas_dir / f"{output_name}.mp3"
|
|
|
|
shutil.move(str(instrumental_file), str(final_instrumental))
|
|
if vocals_file.exists():
|
|
shutil.move(str(vocals_file), str(final_acapella))
|
|
|
|
# Cleanup
|
|
shutil.rmtree(model_dir.parent, ignore_errors=True)
|
|
audio_file.unlink(missing_ok=True)
|
|
|
|
emit_progress(1.0, "Complete!", "done")
|
|
emit_result(
|
|
True,
|
|
instrumental_path=str(final_instrumental),
|
|
vocals_path=str(final_acapella),
|
|
title=safe_title,
|
|
duration=duration
|
|
)
|
|
|
|
except Exception as e:
|
|
emit_result(False, error=str(e))
|
|
|
|
|
|
def process_local_file(file_path: str, output_dir: str):
|
|
"""Process local audio file: separate vocals"""
|
|
import torch
|
|
|
|
input_file = Path(file_path)
|
|
if not input_file.exists():
|
|
emit_result(False, error=f"File not found: {file_path}")
|
|
return
|
|
|
|
output_path = Path(output_dir)
|
|
output_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
instrumentals_dir = output_path / "Instrumentals"
|
|
instrumentals_dir.mkdir(exist_ok=True)
|
|
|
|
acapellas_dir = output_path / "Acapellas"
|
|
acapellas_dir.mkdir(exist_ok=True)
|
|
|
|
# Determine device
|
|
if torch.cuda.is_available():
|
|
device = "cuda"
|
|
elif torch.backends.mps.is_available():
|
|
device = "mps"
|
|
else:
|
|
device = "cpu"
|
|
|
|
try:
|
|
emit_progress(0.1, "Preparing audio...", "prepare")
|
|
|
|
title = input_file.stem
|
|
duration = get_audio_duration(input_file)
|
|
|
|
emit_progress(0.2, "Processing with Demucs...", "demucs")
|
|
|
|
# Run Demucs
|
|
import subprocess
|
|
|
|
model = "htdemucs_ft"
|
|
cmd = [
|
|
sys.executable, "-m", "demucs",
|
|
"--two-stems=vocals",
|
|
"-n", model,
|
|
"-o", str(output_path),
|
|
"--device", device,
|
|
"--mp3",
|
|
str(input_file)
|
|
]
|
|
|
|
env = os.environ.copy()
|
|
env['PYTHONIOENCODING'] = 'utf-8'
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', env=env)
|
|
|
|
if result.returncode != 0:
|
|
emit_result(False, error=f"Demucs error: {result.stderr}")
|
|
return
|
|
|
|
emit_progress(0.9, "Finalizing...", "finalize")
|
|
|
|
# Move output files
|
|
output_name = input_file.stem
|
|
model_dir = output_path / model / output_name
|
|
instrumental_file = model_dir / "no_vocals.mp3"
|
|
vocals_file = model_dir / "vocals.mp3"
|
|
|
|
if not instrumental_file.exists():
|
|
emit_result(False, error="Processing failed - output file not found")
|
|
return
|
|
|
|
# Move to final locations
|
|
final_instrumental = instrumentals_dir / f"{output_name}.mp3"
|
|
final_acapella = acapellas_dir / f"{output_name}.mp3"
|
|
|
|
shutil.move(str(instrumental_file), str(final_instrumental))
|
|
if vocals_file.exists():
|
|
shutil.move(str(vocals_file), str(final_acapella))
|
|
|
|
# Cleanup
|
|
shutil.rmtree(model_dir.parent, ignore_errors=True)
|
|
|
|
emit_progress(1.0, "Complete!", "done")
|
|
emit_result(
|
|
True,
|
|
instrumental_path=str(final_instrumental),
|
|
vocals_path=str(final_acapella),
|
|
title=title,
|
|
duration=duration
|
|
)
|
|
|
|
except Exception as e:
|
|
emit_result(False, error=str(e))
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Mixor CLI for Swift subprocess")
|
|
parser.add_argument("command", choices=["extract_url", "extract_file"])
|
|
parser.add_argument("--url", help="Video URL")
|
|
parser.add_argument("--file", help="Local file path")
|
|
parser.add_argument("--output", default=os.path.expanduser("~/Library/Application Support/Mixor/Output"))
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.command == "extract_url":
|
|
if not args.url:
|
|
emit_result(False, error="URL is required")
|
|
return
|
|
process_youtube(args.url, args.output)
|
|
|
|
elif args.command == "extract_file":
|
|
if not args.file:
|
|
emit_result(False, error="File path is required")
|
|
return
|
|
process_local_file(args.file, args.output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|