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)