Spaces:
Sleeping
Sleeping
| from fastapi import FastAPI, UploadFile, File, HTTPException | |
| from fastapi.responses import JSONResponse, FileResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.staticfiles import StaticFiles | |
| import asyncio | |
| import uuid | |
| import os | |
| import re | |
| from urllib.parse import quote | |
| app = FastAPI() | |
| # Configure CORS to allow all origins, which is useful for development. | |
| # For production, you should restrict this to your frontend's domain. | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # Directory to store the separated audio files. | |
| OUTPUT_DIR = "separated" | |
| if not os.path.exists(OUTPUT_DIR): | |
| os.makedirs(OUTPUT_DIR) | |
| app.mount(f"/{OUTPUT_DIR}", StaticFiles(directory=OUTPUT_DIR), name=OUTPUT_DIR) | |
| async def serve_index(): | |
| """ | |
| Serves the frontend entry point. Fails loud if the asset is missing | |
| from the container rather than falling through to a silent 404. | |
| """ | |
| index_path = os.path.join(os.path.dirname(__file__), "index.html") | |
| if not os.path.isfile(index_path): | |
| raise HTTPException(status_code=500, detail="index.html not found in container — check COPY step in Dockerfile") | |
| return FileResponse(index_path) | |
| MAX_UPLOAD_SIZE = 100 * 1024 * 1024 | |
| # In-memory dictionary to store the status and progress of processing jobs. | |
| # In a production environment, you might want to use a more persistent storage | |
| # like Redis or a database. | |
| jobs = {} | |
| def sanitize_filename(filename: str) -> str: | |
| """ | |
| Sanitizes an uploaded filename while preserving the extension for Demucs. | |
| """ | |
| filename = os.path.basename(filename or "upload") | |
| name, ext = os.path.splitext(filename) | |
| safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._-") | |
| safe_ext = re.sub(r"[^A-Za-z0-9.]+", "", ext) | |
| return f"{safe_name or 'upload'}{safe_ext}" | |
| def get_separated_outputs(job_id: str): | |
| """ | |
| Finds all files Demucs generated for a job and returns StaticFiles URLs. | |
| """ | |
| job_output_dir = os.path.join(OUTPUT_DIR, job_id) | |
| outputs = [] | |
| if not os.path.isdir(job_output_dir): | |
| return outputs | |
| for root, _, filenames in os.walk(job_output_dir): | |
| for filename in sorted(filenames): | |
| file_path = os.path.join(root, filename) | |
| if not os.path.isfile(file_path): | |
| continue | |
| relative_path = os.path.relpath(file_path, job_output_dir) | |
| relative_url_path = relative_path.replace(os.sep, "/") | |
| url = f"/{OUTPUT_DIR}/{quote(job_id)}/{quote(relative_url_path, safe='/')}" | |
| outputs.append({ | |
| "filename": filename, | |
| "relative_path": relative_url_path, | |
| "size": os.path.getsize(file_path), | |
| "url": url | |
| }) | |
| outputs.sort(key=lambda output: output["relative_path"]) | |
| return outputs | |
| async def run_demucs(job_id: str, file_path: str, original_filename: str): | |
| """ | |
| Runs the demucs command in a subprocess and updates the job status. | |
| """ | |
| try: | |
| output_path = os.path.join(OUTPUT_DIR, job_id) | |
| # The command to run demucs. We're using the CPU version for broader compatibility. | |
| # You can adjust the separation model as needed. | |
| process = await asyncio.create_subprocess_exec( | |
| "python3", | |
| "-m", | |
| "demucs.separate", | |
| "-d", | |
| "cpu", | |
| "-n", | |
| "htdemucs_6s", | |
| file_path, | |
| "-o", | |
| output_path, | |
| stdout=asyncio.subprocess.PIPE, | |
| stderr=asyncio.subprocess.PIPE | |
| ) | |
| # Regex to capture the percentage from the tqdm progress bar used by demucs. | |
| progress_regex = re.compile(r"(\d+)\%") | |
| # Read the stderr stream to capture progress updates. | |
| while process.returncode is None: | |
| line = await process.stderr.readline() | |
| if not line: | |
| break | |
| line = line.decode().strip() | |
| match = progress_regex.search(line) | |
| if match: | |
| progress = int(match.group(1)) | |
| jobs[job_id]["progress"] = progress | |
| await asyncio.sleep(0.1) | |
| # Wait for the process to finish. | |
| stdout, stderr = await process.communicate() | |
| if process.returncode == 0: | |
| # If the process is successful, update the status and progress. | |
| jobs[job_id]["status"] = "complete" | |
| jobs[job_id]["progress"] = 100 | |
| jobs[job_id]["files"] = get_separated_outputs(job_id) | |
| else: | |
| # If there's an error, capture the error message. | |
| jobs[job_id]["status"] = "error" | |
| jobs[job_id]["error"] = stderr.decode().strip() | |
| except Exception as e: | |
| jobs[job_id]["status"] = "error" | |
| jobs[job_id]["error"] = str(e) | |
| finally: | |
| # Clean up the temporary uploaded file. | |
| if os.path.exists(file_path): | |
| os.remove(file_path) | |
| async def create_separation_job(file: UploadFile): | |
| """ | |
| Uploads an audio file, saves it temporarily, and starts the demucs processing. | |
| """ | |
| job_id = str(uuid.uuid4()) | |
| sanitized_filename = sanitize_filename(file.filename) | |
| file_path = f"{job_id}_{sanitized_filename}" | |
| # Save the uploaded file to a temporary location. | |
| size = 0 | |
| with open(file_path, "wb") as buffer: | |
| while chunk := await file.read(1024 * 1024): | |
| size += len(chunk) | |
| if size > MAX_UPLOAD_SIZE: | |
| os.remove(file_path) | |
| raise HTTPException(status_code=413, detail="File exceeds 100MB upload limit") | |
| buffer.write(chunk) | |
| # Initialize the job status. | |
| jobs[job_id] = {"status": "processing", "progress": 0} | |
| # Start the demucs process in the background. | |
| asyncio.create_task(run_demucs(job_id, file_path, sanitized_filename)) | |
| return JSONResponse(content={"job_id": job_id}) | |
| async def upload_file(file: UploadFile = File(...)): | |
| return await create_separation_job(file) | |
| async def separate_file(file: UploadFile = File(...)): | |
| return await create_separation_job(file) | |
| async def _get_job(job_id: str) -> JSONResponse: | |
| job = jobs.get(job_id) | |
| if not job: | |
| raise HTTPException(status_code=404, detail="Job not found") | |
| return JSONResponse(content=job) | |
| async def get_job(job_id: str): | |
| """ | |
| Canonical resource endpoint. Frontend (index.html:572) polls this path. | |
| """ | |
| return await _get_job(job_id) | |
| async def get_status(job_id: str): | |
| """ | |
| Legacy alias — kept for any external consumer already wired to /status. | |
| """ | |
| return await _get_job(job_id) | |