stemforge / app.py
Ryanrealaf's picture
Rename app (2).py to app.py
21256cc verified
Raw
History Blame Contribute Delete
6.87 kB
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)