Spaces:
Sleeping
Sleeping
File size: 6,873 Bytes
85db227 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | 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)
@app.get("/")
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})
@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
return await create_separation_job(file)
@app.post("/separate")
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)
@app.get("/jobs/{job_id}")
async def get_job(job_id: str):
"""
Canonical resource endpoint. Frontend (index.html:572) polls this path.
"""
return await _get_job(job_id)
@app.get("/status/{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)
|