Update main.py
Browse files
main.py
CHANGED
|
@@ -1,54 +1,61 @@
|
|
| 1 |
#!/usr/bin/env python3
|
|
|
|
| 2 |
"""
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
Features:
|
| 16 |
-
- Upload audio (mp3, wav, ogg, m4a, flac, etc.)
|
| 17 |
-
- Options: normalize, change speed, lowpass/highpass filter, trim silence, export format
|
| 18 |
-
- In-browser upload form + playback of processed file
|
| 19 |
-
- Processes audio using pydub (ffmpeg required).
|
| 20 |
-
- Single-file, stores temp files in ./tmp (created automatically). Cleans up old files on each request.
|
| 21 |
"""
|
| 22 |
|
| 23 |
import os
|
| 24 |
import io
|
| 25 |
import uuid
|
| 26 |
import time
|
| 27 |
-
import
|
|
|
|
| 28 |
from pathlib import Path
|
| 29 |
-
from flask import
|
|
|
|
|
|
|
|
|
|
| 30 |
from pydub import AudioSegment, effects, silence
|
| 31 |
|
|
|
|
| 32 |
# Configuration
|
|
|
|
| 33 |
TMP_DIR = Path("./tmp")
|
| 34 |
TMP_DIR.mkdir(exist_ok=True)
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
"audio/mpeg", "audio/wav", "audio/x-wav", "audio/ogg", "audio/mp4", "audio/x-m4a",
|
| 38 |
-
"audio/flac", "audio/x-flac", "application/octet-stream"
|
| 39 |
-
}
|
| 40 |
-
KEEP_FILES_SEC = 60 * 60 # keep processed files for 1 hour (auto-clean)
|
| 41 |
|
| 42 |
app = Flask(__name__)
|
| 43 |
app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_MB * 1024 * 1024
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
# ---------------------------
|
| 47 |
-
# Utility helpers
|
| 48 |
-
# ---------------------------
|
| 49 |
|
| 50 |
def secure_extension_for_format(fmt: str) -> str:
|
| 51 |
-
fmt = fmt.lower().strip()
|
| 52 |
if fmt in ("mp3", "mpeg"):
|
| 53 |
return "mp3"
|
| 54 |
if fmt in ("wav", "wave"):
|
|
@@ -59,207 +66,424 @@ def secure_extension_for_format(fmt: str) -> str:
|
|
| 59 |
return "ogg"
|
| 60 |
if fmt in ("m4a", "mp4", "aac"):
|
| 61 |
return "m4a"
|
| 62 |
-
# default
|
| 63 |
return "wav"
|
| 64 |
|
| 65 |
|
| 66 |
-
def random_filename(ext: str) -> Path:
|
| 67 |
-
return TMP_DIR / (str(uuid.uuid4()) + "." + ext)
|
| 68 |
-
|
| 69 |
-
|
| 70 |
def cleanup_old_files():
|
| 71 |
-
"""Remove files older than KEEP_FILES_SEC"""
|
| 72 |
now = time.time()
|
| 73 |
for p in list(TMP_DIR.iterdir()):
|
| 74 |
try:
|
| 75 |
-
if p.is_file():
|
| 76 |
-
|
| 77 |
-
p.unlink()
|
| 78 |
except Exception:
|
| 79 |
pass
|
| 80 |
|
| 81 |
|
| 82 |
-
def
|
| 83 |
-
|
| 84 |
-
# We'll attempt with no hint; if fails, client-specified extension will be used.
|
| 85 |
-
return ""
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
def load_audio_from_file_storage(file_storage):
|
| 89 |
-
"""
|
| 90 |
-
Load an AudioSegment from Flask's FileStorage object.
|
| 91 |
-
Tries to use filename extension; falls back to letting ffmpeg autodetect.
|
| 92 |
-
"""
|
| 93 |
-
data = file_storage.read()
|
| 94 |
buf = io.BytesIO(data)
|
| 95 |
-
|
| 96 |
-
filename = file_storage.filename or ""
|
| 97 |
ext = ""
|
| 98 |
if "." in filename:
|
| 99 |
ext = filename.rsplit(".", 1)[1].lower()
|
| 100 |
-
try
|
| 101 |
-
|
|
|
|
|
|
|
| 102 |
return AudioSegment.from_file(buf, format=ext)
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
| 105 |
buf.seek(0)
|
| 106 |
-
return AudioSegment.from_file(buf)
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
| 121 |
|
| 122 |
-
def apply_normalize(seg: AudioSegment) -> AudioSegment:
|
| 123 |
-
return effects.normalize(seg)
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
Change playback speed while preserving pitch approximately using pydub's speedup (it slices frames).
|
| 129 |
-
For large changes, artifacts may appear. Speed must be >0.
|
| 130 |
-
"""
|
| 131 |
if speed <= 0 or abs(speed - 1.0) < 1e-6:
|
| 132 |
return seg
|
| 133 |
-
# pydub.effects.speedup accepts playback_speed parameter
|
| 134 |
try:
|
|
|
|
| 135 |
return effects.speedup(seg, playback_speed=speed)
|
| 136 |
except Exception:
|
| 137 |
-
# Fallback: change frame_rate (this changes pitch)
|
| 138 |
new_frame_rate = int(seg.frame_rate * speed)
|
| 139 |
return seg._spawn(seg.raw_data, overrides={"frame_rate": new_frame_rate}).set_frame_rate(seg.frame_rate)
|
| 140 |
|
| 141 |
|
| 142 |
-
def
|
| 143 |
-
|
| 144 |
-
return seg.low_pass_filter(int(cutoff_hz))
|
| 145 |
-
return seg
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def apply_highpass(seg: AudioSegment, cutoff_hz: float) -> AudioSegment:
|
| 149 |
-
if cutoff_hz and cutoff_hz > 0:
|
| 150 |
-
return seg.high_pass_filter(int(cutoff_hz))
|
| 151 |
-
return seg
|
| 152 |
|
| 153 |
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
| 155 |
"""
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
chunk_len_ms: granularity for silence detection
|
| 159 |
"""
|
| 160 |
-
|
| 161 |
-
if
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
INDEX_HTML = """
|
| 174 |
<!doctype html>
|
| 175 |
<html lang="ru">
|
| 176 |
<head>
|
| 177 |
<meta charset="utf-8" />
|
| 178 |
-
<title>
|
| 179 |
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
|
|
| 180 |
<style>
|
| 181 |
-
body {
|
| 182 |
-
|
| 183 |
-
.
|
| 184 |
-
.
|
| 185 |
-
|
|
|
|
|
|
|
| 186 |
</style>
|
| 187 |
</head>
|
| 188 |
<body>
|
| 189 |
-
|
| 190 |
-
<
|
| 191 |
-
<
|
| 192 |
-
<
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
</div>
|
| 230 |
-
</form>
|
| 231 |
-
|
| 232 |
-
<div class="result" id="resultArea" style="display:none;">
|
| 233 |
-
<h3>Результат</h3>
|
| 234 |
-
<audio id="player" controls></audio>
|
| 235 |
-
<p><a id="downloadLink" href="#" download>Скачать обработанный файл</a></p>
|
| 236 |
</div>
|
|
|
|
| 237 |
|
| 238 |
<script>
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
return;
|
| 248 |
}
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
const
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
});
|
| 261 |
-
</script>
|
| 262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
</body>
|
| 264 |
</html>
|
| 265 |
"""
|
|
@@ -268,77 +492,118 @@ document.getElementById('uploadForm').addEventListener('submit', async function(
|
|
| 268 |
@app.route("/", methods=["GET"])
|
| 269 |
def index():
|
| 270 |
cleanup_old_files()
|
| 271 |
-
|
|
|
|
| 272 |
|
| 273 |
|
| 274 |
-
@app.route("/
|
| 275 |
-
def
|
| 276 |
cleanup_old_files()
|
| 277 |
if "audio_file" not in request.files:
|
| 278 |
return "No file part 'audio_file'", 400
|
| 279 |
-
|
| 280 |
-
if
|
| 281 |
return "No selected file", 400
|
| 282 |
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
#
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
|
| 316 |
-
|
| 317 |
-
seg = apply_normalize(seg)
|
| 318 |
-
except Exception as e:
|
| 319 |
-
return f"Ошибка при обработке аудио: {e}", 500
|
| 320 |
|
| 321 |
-
# Export to temporary file
|
| 322 |
-
out_path = random_filename(out_format)
|
| 323 |
-
try:
|
| 324 |
-
# Export parameters: for mp3, use bitrate; for wav default; flac default
|
| 325 |
-
export_kwargs = {}
|
| 326 |
-
if out_format == "mp3":
|
| 327 |
-
export_kwargs["bitrate"] = "192k"
|
| 328 |
-
# pydub will call ffmpeg
|
| 329 |
-
seg.export(out_path.as_posix(), format=out_format, **export_kwargs)
|
| 330 |
-
except Exception as e:
|
| 331 |
-
return f"Не удалось экспортировать аудио: {e}", 500
|
| 332 |
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
| 337 |
|
| 338 |
|
| 339 |
-
@app.route("/download/<filename>"
|
| 340 |
def download_file(filename):
|
| 341 |
-
# security: only allow files in TMP_DIR
|
| 342 |
cleanup_old_files()
|
| 343 |
safe_path = (TMP_DIR / filename).resolve()
|
| 344 |
try:
|
|
@@ -351,18 +616,15 @@ def download_file(filename):
|
|
| 351 |
return f"Error serving file: {e}", 500
|
| 352 |
|
| 353 |
|
| 354 |
-
|
| 355 |
-
@app.route("/healthz", methods=["GET"])
|
| 356 |
def health():
|
| 357 |
return "ok", 200
|
| 358 |
|
| 359 |
|
| 360 |
-
#
|
| 361 |
-
# Run
|
| 362 |
-
#
|
| 363 |
-
|
| 364 |
if __name__ == "__main__":
|
| 365 |
-
# On Hugging Face Spaces with Docker, they often expect the app to listen on 0.0.0.0 and port from env
|
| 366 |
port = int(os.environ.get("PORT", 7860))
|
| 367 |
host = os.environ.get("HOST", "0.0.0.0")
|
| 368 |
app.run(host=host, port=port, debug=False)
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
"""
|
| 4 |
+
main.py — single-file Flask app with a polished web UI and real-time processing updates (SSE).
|
| 5 |
+
Save as main.py and run in Docker (see earlier Dockerfile with ffmpeg installed).
|
| 6 |
+
Requirements (requirements.txt):
|
| 7 |
+
flask
|
| 8 |
+
pydub
|
| 9 |
+
gunicorn
|
| 10 |
+
python-multipart
|
| 11 |
+
|
| 12 |
+
Запуск локально:
|
| 13 |
+
python main.py
|
| 14 |
+
В контейнере (gunicorn):
|
| 15 |
+
gunicorn -b 0.0.0.0:7860 main:app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
import os
|
| 19 |
import io
|
| 20 |
import uuid
|
| 21 |
import time
|
| 22 |
+
import queue
|
| 23 |
+
import threading
|
| 24 |
from pathlib import Path
|
| 25 |
+
from flask import (
|
| 26 |
+
Flask, request, jsonify, url_for, send_file,
|
| 27 |
+
render_template_string, abort, Response
|
| 28 |
+
)
|
| 29 |
from pydub import AudioSegment, effects, silence
|
| 30 |
|
| 31 |
+
# ----------------------------
|
| 32 |
# Configuration
|
| 33 |
+
# ----------------------------
|
| 34 |
TMP_DIR = Path("./tmp")
|
| 35 |
TMP_DIR.mkdir(exist_ok=True)
|
| 36 |
+
KEEP_FILES_SEC = 60 * 60 # 1 hour
|
| 37 |
+
MAX_UPLOAD_MB = 200
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
app = Flask(__name__)
|
| 40 |
app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_MB * 1024 * 1024
|
| 41 |
|
| 42 |
+
# job_queues: job_id -> Queue of event dicts
|
| 43 |
+
job_queues = {}
|
| 44 |
+
# job_files: job_id -> output Path
|
| 45 |
+
job_files = {}
|
| 46 |
+
# simple lock for dicts
|
| 47 |
+
jobs_lock = threading.Lock()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ----------------------------
|
| 51 |
+
# Utility helpers & processors
|
| 52 |
+
# ----------------------------
|
| 53 |
+
def random_filename(ext: str) -> Path:
|
| 54 |
+
return TMP_DIR / (str(uuid.uuid4()) + "." + ext)
|
| 55 |
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
def secure_extension_for_format(fmt: str) -> str:
|
| 58 |
+
fmt = (fmt or "").lower().strip()
|
| 59 |
if fmt in ("mp3", "mpeg"):
|
| 60 |
return "mp3"
|
| 61 |
if fmt in ("wav", "wave"):
|
|
|
|
| 66 |
return "ogg"
|
| 67 |
if fmt in ("m4a", "mp4", "aac"):
|
| 68 |
return "m4a"
|
|
|
|
| 69 |
return "wav"
|
| 70 |
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
def cleanup_old_files():
|
|
|
|
| 73 |
now = time.time()
|
| 74 |
for p in list(TMP_DIR.iterdir()):
|
| 75 |
try:
|
| 76 |
+
if p.is_file() and (now - p.stat().st_mtime) > KEEP_FILES_SEC:
|
| 77 |
+
p.unlink()
|
|
|
|
| 78 |
except Exception:
|
| 79 |
pass
|
| 80 |
|
| 81 |
|
| 82 |
+
def load_audio_from_filestorage(filestorage):
|
| 83 |
+
data = filestorage.read()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
buf = io.BytesIO(data)
|
| 85 |
+
filename = filestorage.filename or ""
|
|
|
|
| 86 |
ext = ""
|
| 87 |
if "." in filename:
|
| 88 |
ext = filename.rsplit(".", 1)[1].lower()
|
| 89 |
+
# Try with extension first, else try common formats
|
| 90 |
+
if ext:
|
| 91 |
+
try:
|
| 92 |
+
buf.seek(0)
|
| 93 |
return AudioSegment.from_file(buf, format=ext)
|
| 94 |
+
except Exception:
|
| 95 |
+
pass
|
| 96 |
+
for fmt in ("mp3", "wav", "ogg", "flac", "m4a", "aac"):
|
| 97 |
+
try:
|
| 98 |
buf.seek(0)
|
| 99 |
+
return AudioSegment.from_file(buf, format=fmt)
|
| 100 |
+
except Exception:
|
| 101 |
+
continue
|
| 102 |
+
# final attempt letting ffmpeg autodetect
|
| 103 |
+
buf.seek(0)
|
| 104 |
+
return AudioSegment.from_file(buf)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# Processing steps (return AudioSegment)
|
| 108 |
+
def apply_trim_silence(seg: AudioSegment, silence_thresh_db=-50, chunk_len_ms=10):
|
| 109 |
+
non_silence = silence.detect_nonsilent(seg, min_silence_len=chunk_len_ms, silence_thresh=silence_thresh_db)
|
| 110 |
+
if not non_silence:
|
| 111 |
+
return seg[:0]
|
| 112 |
+
start = non_silence[0][0]
|
| 113 |
+
end = non_silence[-1][1]
|
| 114 |
+
return seg[start:end]
|
| 115 |
|
| 116 |
|
| 117 |
+
def apply_lowpass(seg: AudioSegment, cutoff_hz: float):
|
| 118 |
+
if cutoff_hz and cutoff_hz > 0:
|
| 119 |
+
return seg.low_pass_filter(int(cutoff_hz))
|
| 120 |
+
return seg
|
| 121 |
|
|
|
|
|
|
|
| 122 |
|
| 123 |
+
def apply_highpass(seg: AudioSegment, cutoff_hz: float):
|
| 124 |
+
if cutoff_hz and cutoff_hz > 0:
|
| 125 |
+
return seg.high_pass_filter(int(cutoff_hz))
|
| 126 |
+
return seg
|
| 127 |
|
| 128 |
+
|
| 129 |
+
def apply_speed_change(seg: AudioSegment, speed: float):
|
|
|
|
|
|
|
|
|
|
| 130 |
if speed <= 0 or abs(speed - 1.0) < 1e-6:
|
| 131 |
return seg
|
|
|
|
| 132 |
try:
|
| 133 |
+
# small speed changes: use pydub.effects.speedup
|
| 134 |
return effects.speedup(seg, playback_speed=speed)
|
| 135 |
except Exception:
|
|
|
|
| 136 |
new_frame_rate = int(seg.frame_rate * speed)
|
| 137 |
return seg._spawn(seg.raw_data, overrides={"frame_rate": new_frame_rate}).set_frame_rate(seg.frame_rate)
|
| 138 |
|
| 139 |
|
| 140 |
+
def apply_normalize(seg: AudioSegment):
|
| 141 |
+
return effects.normalize(seg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
|
| 144 |
+
# ----------------------------
|
| 145 |
+
# Background processing worker
|
| 146 |
+
# ----------------------------
|
| 147 |
+
def processing_worker(job_id, fs_file, options):
|
| 148 |
"""
|
| 149 |
+
Runs in a separate thread. Posts progress events into the job queue.
|
| 150 |
+
Each event is a dict: {"type": "progress"/"error"/"done", "msg": "...", "percent": 0-100, "file": filename_if_done}
|
|
|
|
| 151 |
"""
|
| 152 |
+
q = job_queues.get(job_id)
|
| 153 |
+
if q is None:
|
| 154 |
+
return
|
| 155 |
+
|
| 156 |
+
def post(event_type, msg=None, percent=None, filename=None):
|
| 157 |
+
payload = {"type": event_type}
|
| 158 |
+
if msg is not None:
|
| 159 |
+
payload["msg"] = msg
|
| 160 |
+
if percent is not None:
|
| 161 |
+
payload["percent"] = percent
|
| 162 |
+
if filename is not None:
|
| 163 |
+
payload["file"] = filename
|
| 164 |
+
try:
|
| 165 |
+
q.put(payload, timeout=1)
|
| 166 |
+
except Exception:
|
| 167 |
+
pass
|
| 168 |
|
| 169 |
+
try:
|
| 170 |
+
post("progress", "Получаю файл...", 2)
|
| 171 |
+
# load audio
|
| 172 |
+
fs_file.stream.seek(0)
|
| 173 |
+
try:
|
| 174 |
+
seg = load_audio_from_filestorage(fs_file)
|
| 175 |
+
except Exception as e:
|
| 176 |
+
post("error", f"Не удалось загрузить аудио: {e}")
|
| 177 |
+
return
|
| 178 |
|
| 179 |
+
post("progress", "Анализ аудио...", 10)
|
| 180 |
+
time.sleep(0.2)
|
| 181 |
+
|
| 182 |
+
# trim
|
| 183 |
+
if options.get("trim_silence"):
|
| 184 |
+
post("progress", "Обрезаю тишину...", 20)
|
| 185 |
+
try:
|
| 186 |
+
seg = apply_trim_silence(seg, silence_thresh_db=int(options.get("sil_thresh", -50)))
|
| 187 |
+
except Exception as exc:
|
| 188 |
+
post("error", f"Ошибка при обрезке тишины: {exc}")
|
| 189 |
+
return
|
| 190 |
+
time.sleep(0.1)
|
| 191 |
+
|
| 192 |
+
# filters
|
| 193 |
+
lp = float(options.get("lowpass", 0) or 0)
|
| 194 |
+
hp = float(options.get("highpass", 0) or 0)
|
| 195 |
+
if lp > 0:
|
| 196 |
+
post("progress", f"Применяю low-pass {int(lp)} Hz...", 40)
|
| 197 |
+
try:
|
| 198 |
+
seg = apply_lowpass(seg, lp)
|
| 199 |
+
except Exception as exc:
|
| 200 |
+
post("error", f"Ошибка low-pass: {exc}")
|
| 201 |
+
return
|
| 202 |
+
time.sleep(0.1)
|
| 203 |
+
if hp > 0:
|
| 204 |
+
post("progress", f"Применяю high-pass {int(hp)} Hz...", 50)
|
| 205 |
+
try:
|
| 206 |
+
seg = apply_highpass(seg, hp)
|
| 207 |
+
except Exception as exc:
|
| 208 |
+
post("error", f"Ошибка high-pass: {exc}")
|
| 209 |
+
return
|
| 210 |
+
time.sleep(0.1)
|
| 211 |
+
|
| 212 |
+
# speed
|
| 213 |
+
speed = float(options.get("speed", 1.0) or 1.0)
|
| 214 |
+
if abs(speed - 1.0) > 1e-6:
|
| 215 |
+
post("progress", f"Изменяю скорость x{speed}...", 60)
|
| 216 |
+
try:
|
| 217 |
+
seg = apply_speed_change(seg, speed)
|
| 218 |
+
except Exception as exc:
|
| 219 |
+
post("error", f"Ошибка изменения скорости: {exc}")
|
| 220 |
+
return
|
| 221 |
+
time.sleep(0.1)
|
| 222 |
+
|
| 223 |
+
# normalize
|
| 224 |
+
if options.get("normalize"):
|
| 225 |
+
post("progress", "Нормализация громкости...", 75)
|
| 226 |
+
try:
|
| 227 |
+
seg = apply_normalize(seg)
|
| 228 |
+
except Exception as exc:
|
| 229 |
+
post("error", f"Ошибка при нормализации: {exc}")
|
| 230 |
+
return
|
| 231 |
+
time.sleep(0.1)
|
| 232 |
+
|
| 233 |
+
# export
|
| 234 |
+
out_fmt = secure_extension_for_format(options.get("out_format", "wav"))
|
| 235 |
+
out_path = random_filename(out_fmt)
|
| 236 |
+
post("progress", "Экспортирую файл...", 90)
|
| 237 |
+
try:
|
| 238 |
+
export_kwargs = {}
|
| 239 |
+
if out_fmt == "mp3":
|
| 240 |
+
export_kwargs["bitrate"] = "192k"
|
| 241 |
+
seg.export(out_path.as_posix(), format=out_fmt, **export_kwargs)
|
| 242 |
+
except Exception as exc:
|
| 243 |
+
post("error", f"Ошибка при экспорте: {exc}")
|
| 244 |
+
return
|
| 245 |
+
|
| 246 |
+
# register file
|
| 247 |
+
with jobs_lock:
|
| 248 |
+
job_files[job_id] = out_path
|
| 249 |
+
|
| 250 |
+
file_url = url_for("download_file", filename=out_path.name, _external=True)
|
| 251 |
+
post("done", "Готово", 100, filename=out_path.name)
|
| 252 |
+
post("file_url", file_url)
|
| 253 |
+
except Exception as e:
|
| 254 |
+
post("error", f"Внутренняя ошибка: {e}")
|
| 255 |
|
| 256 |
+
|
| 257 |
+
# ----------------------------
|
| 258 |
+
# Flask routes
|
| 259 |
+
# ----------------------------
|
| 260 |
INDEX_HTML = """
|
| 261 |
<!doctype html>
|
| 262 |
<html lang="ru">
|
| 263 |
<head>
|
| 264 |
<meta charset="utf-8" />
|
| 265 |
+
<title>Music Processor — Онлайн</title>
|
| 266 |
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 267 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 268 |
<style>
|
| 269 |
+
body { background: linear-gradient(180deg,#0f172a, #071233); color: #e6eef8; min-height:100vh; }
|
| 270 |
+
.card { border-radius: 12px; background: rgba(255,255,255,0.04); box-shadow: 0 8px 30px rgba(2,6,23,0.6); }
|
| 271 |
+
.accent { color: #7dd3fc; }
|
| 272 |
+
.small-muted { color: #9fb3c8; font-size: 0.9rem; }
|
| 273 |
+
.progress { height: 18px; }
|
| 274 |
+
.controls label { font-weight: 600; color: #cdeefb; }
|
| 275 |
+
footer { margin-top: 24px; color: #8aa7bd; }
|
| 276 |
</style>
|
| 277 |
</head>
|
| 278 |
<body>
|
| 279 |
+
<div class="container py-5">
|
| 280 |
+
<div class="row justify-content-center">
|
| 281 |
+
<div class="col-lg-8">
|
| 282 |
+
<div class="card p-4">
|
| 283 |
+
<h2 class="mb-1 accent">Онлайн обработка музыки</h2>
|
| 284 |
+
<p class="small-muted mb-3">Загрузите трек, примените эффекты в реальном времени — прогресс будет отображаться прямо в интерфейсе.</p>
|
| 285 |
+
|
| 286 |
+
<form id="uploadForm" enctype="multipart/form-data" class="mb-3">
|
| 287 |
+
<div class="mb-3">
|
| 288 |
+
<label class="form-label">Аудиофайл</label>
|
| 289 |
+
<input class="form-control" type="file" id="audio_file" name="audio_file" accept="audio/*" required>
|
| 290 |
+
</div>
|
| 291 |
+
|
| 292 |
+
<div class="row g-2">
|
| 293 |
+
<div class="col-md-4">
|
| 294 |
+
<div class="form-check">
|
| 295 |
+
<input class="form-check-input" type="checkbox" id="normalize" name="normalize">
|
| 296 |
+
<label class="form-check-label" for="normalize">Нормализовать</label>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
<div class="col-md-4">
|
| 300 |
+
<label class="form-label">Скорость</label>
|
| 301 |
+
<input class="form-control" type="number" step="0.1" name="speed" id="speed" value="1.0">
|
| 302 |
+
</div>
|
| 303 |
+
<div class="col-md-4">
|
| 304 |
+
<label class="form-label">Формат вывода</label>
|
| 305 |
+
<select class="form-select" id="out_format" name="out_format">
|
| 306 |
+
<option value="wav" selected>WAV</option>
|
| 307 |
+
<option value="mp3">MP3</option>
|
| 308 |
+
<option value="flac">FLAC</option>
|
| 309 |
+
<option value="ogg">OGG</option>
|
| 310 |
+
<option value="m4a">M4A</option>
|
| 311 |
+
</select>
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
|
| 315 |
+
<div class="row g-2 mt-3">
|
| 316 |
+
<div class="col-md-6">
|
| 317 |
+
<label class="form-label">Low-pass (Hz, 0 = off)</label>
|
| 318 |
+
<input class="form-control" type="number" id="lowpass" name="lowpass" value="0">
|
| 319 |
+
</div>
|
| 320 |
+
<div class="col-md-6">
|
| 321 |
+
<label class="form-label">High-pass (Hz, 0 = off)</label>
|
| 322 |
+
<input class="form-control" type="number" id="highpass" name="highpass" value="0">
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
<div class="form-check form-switch mt-3">
|
| 327 |
+
<input class="form-check-input" type="checkbox" id="trim_silence" name="trim_silence">
|
| 328 |
+
<label class="form-check-label" for="trim_silence">Обрезать тишину в начале/конце</label>
|
| 329 |
+
</div>
|
| 330 |
+
|
| 331 |
+
<div class="mt-3 d-flex gap-2">
|
| 332 |
+
<button type="submit" id="submitBtn" class="btn btn-primary">Обработать</button>
|
| 333 |
+
<button type="button" id="resetBtn" class="btn btn-outline-light">Сброс</button>
|
| 334 |
+
</div>
|
| 335 |
+
</form>
|
| 336 |
+
|
| 337 |
+
<div id="statusArea" style="display:none;">
|
| 338 |
+
<div class="mb-2 small-muted">Статус обработки:</div>
|
| 339 |
+
<div class="mb-2">
|
| 340 |
+
<div class="progress">
|
| 341 |
+
<div id="progressBar" class="progress-bar bg-info" role="progressbar" style="width: 0%">0%</div>
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
<div id="log" class="small-muted" style="min-height:48px;"></div>
|
| 345 |
+
|
| 346 |
+
<div id="resultArea" class="mt-3" style="display:none;">
|
| 347 |
+
<h5 class="accent">Готово — прослушать / скачать</h5>
|
| 348 |
+
<audio id="player" controls style="width:100%"></audio>
|
| 349 |
+
<div class="mt-2">
|
| 350 |
+
<a id="downloadLink" class="btn btn-outline-light" href="#" download>Скачать</a>
|
| 351 |
+
</div>
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
|
| 355 |
+
<hr class="text-muted mt-4">
|
| 356 |
+
<div class="small-muted">Подсказка: обработка выполняется в контейнере с ffmpeg. Если загрузка не проходит — убедитесь, что файл меньше {{max_mb}} МБ.</div>
|
| 357 |
+
</div>
|
| 358 |
+
|
| 359 |
+
<footer class="text-center">
|
| 360 |
+
<small>Made with ♥ — быстрый аудио-пайплайн (pydub + ffmpeg). Временные файлы хранятся ~1 час.</small>
|
| 361 |
+
</footer>
|
| 362 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
</div>
|
| 364 |
+
</div>
|
| 365 |
|
| 366 |
<script>
|
| 367 |
+
const maxMB = {{max_mb}};
|
| 368 |
+
const uploadForm = document.getElementById('uploadForm');
|
| 369 |
+
const statusArea = document.getElementById('statusArea');
|
| 370 |
+
const logEl = document.getElementById('log');
|
| 371 |
+
const progressBar = document.getElementById('progressBar');
|
| 372 |
+
const resultArea = document.getElementById('resultArea');
|
| 373 |
+
const player = document.getElementById('player');
|
| 374 |
+
const downloadLink = document.getElementById('downloadLink');
|
| 375 |
+
const submitBtn = document.getElementById('submitBtn');
|
| 376 |
+
const resetBtn = document.getElementById('resetBtn');
|
| 377 |
+
|
| 378 |
+
function log(msg) {
|
| 379 |
+
const time = new Date().toLocaleTimeString();
|
| 380 |
+
logEl.innerHTML = `<div>[${time}] ${msg}</div>` + logEl.innerHTML;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
uploadForm.addEventListener('submit', async function(e) {
|
| 384 |
+
e.preventDefault();
|
| 385 |
+
logEl.innerHTML = '';
|
| 386 |
+
resultArea.style.display = 'none';
|
| 387 |
+
statusArea.style.display = 'block';
|
| 388 |
+
progressBar.style.width = '0%';
|
| 389 |
+
progressBar.textContent = '0%';
|
| 390 |
+
|
| 391 |
+
const fileInput = document.getElementById('audio_file');
|
| 392 |
+
if (!fileInput.files || fileInput.files.length === 0) {
|
| 393 |
+
alert('Выберите аудио-файл');
|
| 394 |
+
return;
|
| 395 |
+
}
|
| 396 |
+
const file = fileInput.files[0];
|
| 397 |
+
if (file.size > maxMB * 1024 * 1024) {
|
| 398 |
+
alert('Файл слишком большой (макс ' + maxMB + ' MB)');
|
| 399 |
return;
|
| 400 |
}
|
| 401 |
+
|
| 402 |
+
submitBtn.disabled = true;
|
| 403 |
+
|
| 404 |
+
// Prepare form
|
| 405 |
+
const form = new FormData();
|
| 406 |
+
form.append('audio_file', file);
|
| 407 |
+
form.append('normalize', document.getElementById('normalize').checked ? '1' : '');
|
| 408 |
+
form.append('speed', document.getElementById('speed').value);
|
| 409 |
+
form.append('lowpass', document.getElementById('lowpass').value);
|
| 410 |
+
form.append('highpass', document.getElementById('highpass').value);
|
| 411 |
+
form.append('trim_silence', document.getElementById('trim_silence').checked ? '1' : '');
|
| 412 |
+
form.append('sil_thresh', -50);
|
| 413 |
+
form.append('out_format', document.getElementById('out_format').value);
|
| 414 |
+
|
| 415 |
+
try {
|
| 416 |
+
const resp = await fetch('/start', { method: 'POST', body: form });
|
| 417 |
+
if (!resp.ok) {
|
| 418 |
+
const txt = await resp.text();
|
| 419 |
+
alert('Ошибка: ' + resp.status + '\\n' + txt);
|
| 420 |
+
submitBtn.disabled = false;
|
| 421 |
+
return;
|
| 422 |
+
}
|
| 423 |
+
const js = await resp.json();
|
| 424 |
+
const job_id = js.job_id;
|
| 425 |
+
log('Задача создана: ' + job_id);
|
| 426 |
+
|
| 427 |
+
// Connect to SSE for live updates
|
| 428 |
+
const evtSource = new EventSource('/events/' + job_id);
|
| 429 |
+
evtSource.onmessage = function(e) {
|
| 430 |
+
try {
|
| 431 |
+
const data = JSON.parse(e.data);
|
| 432 |
+
if (data.type === 'progress') {
|
| 433 |
+
const pct = data.percent || 0;
|
| 434 |
+
progressBar.style.width = pct + '%';
|
| 435 |
+
progressBar.textContent = pct + '%';
|
| 436 |
+
if (data.msg) log(data.msg);
|
| 437 |
+
} else if (data.type === 'done') {
|
| 438 |
+
progressBar.style.width = '100%';
|
| 439 |
+
progressBar.textContent = '100%';
|
| 440 |
+
log(data.msg || 'Готово');
|
| 441 |
+
} else if (data.type === 'file_url') {
|
| 442 |
+
const fileurl = data;
|
| 443 |
+
// in case server sends a full object with file_url
|
| 444 |
+
let url = data;
|
| 445 |
+
if (data.file) url = '/download/' + data.file;
|
| 446 |
+
if (typeof data === 'object' && data.file_url) url = data.file_url;
|
| 447 |
+
// Set player and download link
|
| 448 |
+
const finalUrl = url;
|
| 449 |
+
player.src = finalUrl;
|
| 450 |
+
downloadLink.href = finalUrl;
|
| 451 |
+
downloadLink.download = 'processed_' + file.name;
|
| 452 |
+
resultArea.style.display = 'block';
|
| 453 |
+
log('Файл доступен: ' + finalUrl);
|
| 454 |
+
evtSource.close();
|
| 455 |
+
submitBtn.disabled = false;
|
| 456 |
+
} else if (data.type === 'error') {
|
| 457 |
+
log('Ошибка: ' + (data.msg || 'unknown'));
|
| 458 |
+
alert('Ошибка обработки: ' + (data.msg || 'неизвестно'));
|
| 459 |
+
evtSource.close();
|
| 460 |
+
submitBtn.disabled = false;
|
| 461 |
+
}
|
| 462 |
+
} catch (err) {
|
| 463 |
+
console.error('SSE parse error', err, e.data);
|
| 464 |
+
}
|
| 465 |
+
};
|
| 466 |
+
evtSource.onerror = function(ev) {
|
| 467 |
+
console.warn('SSE connection error', ev);
|
| 468 |
+
log('Соединение SSE прервано');
|
| 469 |
+
evtSource.close();
|
| 470 |
+
submitBtn.disabled = false;
|
| 471 |
+
};
|
| 472 |
+
} catch (err) {
|
| 473 |
+
alert('Ошибка при отправке: ' + err);
|
| 474 |
+
submitBtn.disabled = false;
|
| 475 |
+
}
|
| 476 |
});
|
|
|
|
| 477 |
|
| 478 |
+
resetBtn.addEventListener('click', function() {
|
| 479 |
+
uploadForm.reset();
|
| 480 |
+
statusArea.style.display = 'none';
|
| 481 |
+
resultArea.style.display = 'none';
|
| 482 |
+
logEl.innerHTML = '';
|
| 483 |
+
progressBar.style.width = '0%';
|
| 484 |
+
progressBar.textContent = '0%';
|
| 485 |
+
});
|
| 486 |
+
</script>
|
| 487 |
</body>
|
| 488 |
</html>
|
| 489 |
"""
|
|
|
|
| 492 |
@app.route("/", methods=["GET"])
|
| 493 |
def index():
|
| 494 |
cleanup_old_files()
|
| 495 |
+
rendered = render_template_string(INDEX_HTML, max_mb=MAX_UPLOAD_MB)
|
| 496 |
+
return rendered
|
| 497 |
|
| 498 |
|
| 499 |
+
@app.route("/start", methods=["POST"])
|
| 500 |
+
def start_job():
|
| 501 |
cleanup_old_files()
|
| 502 |
if "audio_file" not in request.files:
|
| 503 |
return "No file part 'audio_file'", 400
|
| 504 |
+
fs = request.files["audio_file"]
|
| 505 |
+
if fs.filename == "":
|
| 506 |
return "No selected file", 400
|
| 507 |
|
| 508 |
+
job_id = str(uuid.uuid4())
|
| 509 |
+
q = queue.Queue()
|
| 510 |
+
with jobs_lock:
|
| 511 |
+
job_queues[job_id] = q
|
| 512 |
+
|
| 513 |
+
# gather options
|
| 514 |
+
options = {
|
| 515 |
+
"normalize": bool(request.form.get("normalize")),
|
| 516 |
+
"speed": request.form.get("speed", "1.0"),
|
| 517 |
+
"lowpass": request.form.get("lowpass", "0"),
|
| 518 |
+
"highpass": request.form.get("highpass", "0"),
|
| 519 |
+
"trim_silence": bool(request.form.get("trim_silence")),
|
| 520 |
+
"sil_thresh": request.form.get("sil_thresh", "-50"),
|
| 521 |
+
"out_format": request.form.get("out_format", "wav"),
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
# We must keep a copy of the uploaded file stream for the background worker.
|
| 525 |
+
# Easiest: read bytes into BytesIO-like object stored on disk or memory.
|
| 526 |
+
# We'll save the uploaded file into memory-backed object but keep a small temp file on disk to avoid memory blow.
|
| 527 |
+
# Save a temp copy to pass into thread as a FileStorage-like object:
|
| 528 |
+
temp_in = TMP_DIR / (job_id + "_in")
|
| 529 |
+
fs.stream.seek(0)
|
| 530 |
+
fs.save(temp_in)
|
| 531 |
+
# reopen as a file object with necessary attributes for loader
|
| 532 |
+
class _FSWrapper:
|
| 533 |
+
def __init__(self, path, filename):
|
| 534 |
+
self.path = path
|
| 535 |
+
self.filename = filename
|
| 536 |
+
self.stream = open(path, "rb")
|
| 537 |
+
def read(self):
|
| 538 |
+
self.stream.seek(0)
|
| 539 |
+
return self.stream.read()
|
| 540 |
+
fs_wrapper = _FSWrapper(temp_in, fs.filename)
|
| 541 |
+
|
| 542 |
+
# start thread
|
| 543 |
+
t = threading.Thread(target=processing_worker, args=(job_id, fs_wrapper, options), daemon=True)
|
| 544 |
+
t.start()
|
| 545 |
+
|
| 546 |
+
return jsonify({"job_id": job_id})
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
@app.route("/events/<job_id>")
|
| 550 |
+
def events(job_id):
|
| 551 |
+
# Stream Server-Sent Events from the job queue
|
| 552 |
+
def gen():
|
| 553 |
+
q = None
|
| 554 |
+
with jobs_lock:
|
| 555 |
+
q = job_queues.get(job_id)
|
| 556 |
+
if q is None:
|
| 557 |
+
yield f"data: {json_safe({'type': 'error', 'msg': 'Job not found'})}\n\n"
|
| 558 |
+
return
|
| 559 |
+
# keep streaming until we see done or error and queue empties
|
| 560 |
+
finished = False
|
| 561 |
+
while True:
|
| 562 |
+
try:
|
| 563 |
+
item = q.get(timeout=0.5)
|
| 564 |
+
except queue.Empty:
|
| 565 |
+
# if job completed and queue empty, break
|
| 566 |
+
with jobs_lock:
|
| 567 |
+
if job_files.get(job_id) is not None and q.empty():
|
| 568 |
+
break
|
| 569 |
+
continue
|
| 570 |
+
# send JSON string
|
| 571 |
+
yield f"data: {json_safe(item)}\n\n"
|
| 572 |
+
if item.get("type") in ("done", "error"):
|
| 573 |
+
# continue loop to potentially send file_url event after done
|
| 574 |
+
if item.get("type") == "error":
|
| 575 |
+
break
|
| 576 |
+
# after done, if file exists, send file_url
|
| 577 |
+
with jobs_lock:
|
| 578 |
+
outp = job_files.get(job_id)
|
| 579 |
+
if outp:
|
| 580 |
+
file_url = url_for("download_file", filename=outp.name, _external=True)
|
| 581 |
+
yield f"data: {json_safe({'type': 'file_url', 'file_url': file_url})}\n\n"
|
| 582 |
+
# cleanup queue and small temp input file
|
| 583 |
+
with jobs_lock:
|
| 584 |
+
job_queues.pop(job_id, None)
|
| 585 |
+
# remove temp input file if present
|
| 586 |
+
try:
|
| 587 |
+
temp_in = TMP_DIR / (job_id + "_in")
|
| 588 |
+
if temp_in.exists():
|
| 589 |
+
temp_in.unlink()
|
| 590 |
+
except Exception:
|
| 591 |
+
pass
|
| 592 |
|
| 593 |
+
return Response(gen(), mimetype="text/event-stream")
|
|
|
|
|
|
|
|
|
|
| 594 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
|
| 596 |
+
def json_safe(obj):
|
| 597 |
+
"""
|
| 598 |
+
Very small JSON serialization without importing json in the template string path.
|
| 599 |
+
Use python's json.dumps normally, but keep this helper for convenience.
|
| 600 |
+
"""
|
| 601 |
+
import json
|
| 602 |
+
return json.dumps(obj, ensure_ascii=False)
|
| 603 |
|
| 604 |
|
| 605 |
+
@app.route("/download/<filename>")
|
| 606 |
def download_file(filename):
|
|
|
|
| 607 |
cleanup_old_files()
|
| 608 |
safe_path = (TMP_DIR / filename).resolve()
|
| 609 |
try:
|
|
|
|
| 616 |
return f"Error serving file: {e}", 500
|
| 617 |
|
| 618 |
|
| 619 |
+
@app.route("/healthz")
|
|
|
|
| 620 |
def health():
|
| 621 |
return "ok", 200
|
| 622 |
|
| 623 |
|
| 624 |
+
# ----------------------------
|
| 625 |
+
# Run (for dev)
|
| 626 |
+
# ----------------------------
|
|
|
|
| 627 |
if __name__ == "__main__":
|
|
|
|
| 628 |
port = int(os.environ.get("PORT", 7860))
|
| 629 |
host = os.environ.get("HOST", "0.0.0.0")
|
| 630 |
app.run(host=host, port=port, debug=False)
|