Update main.py
Browse files
main.py
CHANGED
|
@@ -1,35 +1,32 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
# -*- coding: utf-8 -*-
|
| 3 |
"""
|
| 4 |
-
main.py — single-file Flask app
|
| 5 |
-
Save as main.py and run in Docker
|
|
|
|
| 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
|
| 28 |
)
|
| 29 |
from pydub import AudioSegment, effects, silence
|
| 30 |
|
| 31 |
# ----------------------------
|
| 32 |
-
#
|
| 33 |
# ----------------------------
|
| 34 |
TMP_DIR = Path("./tmp")
|
| 35 |
TMP_DIR.mkdir(exist_ok=True)
|
|
@@ -39,16 +36,17 @@ MAX_UPLOAD_MB = 200
|
|
| 39 |
app = Flask(__name__)
|
| 40 |
app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_MB * 1024 * 1024
|
| 41 |
|
| 42 |
-
#
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
job_files =
|
| 46 |
-
|
|
|
|
| 47 |
jobs_lock = threading.Lock()
|
| 48 |
|
| 49 |
|
| 50 |
# ----------------------------
|
| 51 |
-
#
|
| 52 |
# ----------------------------
|
| 53 |
def random_filename(ext: str) -> Path:
|
| 54 |
return TMP_DIR / (str(uuid.uuid4()) + "." + ext)
|
|
@@ -86,25 +84,26 @@ def load_audio_from_filestorage(filestorage):
|
|
| 86 |
ext = ""
|
| 87 |
if "." in filename:
|
| 88 |
ext = filename.rsplit(".", 1)[1].lower()
|
| 89 |
-
# Try with extension first
|
| 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 |
-
#
|
| 103 |
buf.seek(0)
|
| 104 |
return AudioSegment.from_file(buf)
|
| 105 |
|
| 106 |
|
| 107 |
-
# Processing
|
| 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:
|
|
@@ -130,7 +129,6 @@ 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)
|
|
@@ -142,127 +140,116 @@ def apply_normalize(seg: AudioSegment):
|
|
| 142 |
|
| 143 |
|
| 144 |
# ----------------------------
|
| 145 |
-
#
|
| 146 |
# ----------------------------
|
| 147 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
"""
|
| 149 |
-
|
| 150 |
-
|
| 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 |
-
|
| 171 |
# load audio
|
| 172 |
-
fs_file.stream.seek(0)
|
| 173 |
try:
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
except Exception as e:
|
| 176 |
-
|
| 177 |
return
|
| 178 |
|
| 179 |
-
|
| 180 |
-
time.sleep(0.
|
| 181 |
|
| 182 |
-
# trim
|
| 183 |
if options.get("trim_silence"):
|
| 184 |
-
|
| 185 |
try:
|
| 186 |
seg = apply_trim_silence(seg, silence_thresh_db=int(options.get("sil_thresh", -50)))
|
| 187 |
except Exception as exc:
|
| 188 |
-
|
| 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 |
-
|
| 197 |
try:
|
| 198 |
seg = apply_lowpass(seg, lp)
|
| 199 |
except Exception as exc:
|
| 200 |
-
|
| 201 |
return
|
| 202 |
time.sleep(0.1)
|
| 203 |
if hp > 0:
|
| 204 |
-
|
| 205 |
try:
|
| 206 |
seg = apply_highpass(seg, hp)
|
| 207 |
except Exception as exc:
|
| 208 |
-
|
| 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 |
-
|
| 216 |
try:
|
| 217 |
seg = apply_speed_change(seg, speed)
|
| 218 |
except Exception as exc:
|
| 219 |
-
|
| 220 |
return
|
| 221 |
time.sleep(0.1)
|
| 222 |
|
| 223 |
-
# normalize
|
| 224 |
if options.get("normalize"):
|
| 225 |
-
|
| 226 |
try:
|
| 227 |
seg = apply_normalize(seg)
|
| 228 |
except Exception as exc:
|
| 229 |
-
|
| 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 |
-
|
| 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 |
-
|
| 244 |
return
|
| 245 |
|
| 246 |
# register file
|
| 247 |
with jobs_lock:
|
| 248 |
job_files[job_id] = out_path
|
| 249 |
|
| 250 |
-
|
| 251 |
-
post("done", "Готово", 100, filename=out_path.name)
|
| 252 |
-
post("file_url", file_url)
|
| 253 |
except Exception as e:
|
| 254 |
-
|
| 255 |
|
| 256 |
|
| 257 |
# ----------------------------
|
| 258 |
-
#
|
| 259 |
# ----------------------------
|
| 260 |
INDEX_HTML = """
|
| 261 |
<!doctype html>
|
| 262 |
<html lang="ru">
|
| 263 |
<head>
|
| 264 |
<meta charset="utf-8" />
|
| 265 |
-
<title>Music Processor —
|
| 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>
|
|
@@ -273,6 +260,7 @@ INDEX_HTML = """
|
|
| 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>
|
|
@@ -280,8 +268,8 @@ INDEX_HTML = """
|
|
| 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">Онлайн обработка
|
| 284 |
-
<p class="small-muted mb-3">Загрузите трек,
|
| 285 |
|
| 286 |
<form id="uploadForm" enctype="multipart/form-data" class="mb-3">
|
| 287 |
<div class="mb-3">
|
|
@@ -341,7 +329,7 @@ INDEX_HTML = """
|
|
| 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:
|
| 345 |
|
| 346 |
<div id="resultArea" class="mt-3" style="display:none;">
|
| 347 |
<h5 class="accent">Готово — прослушать / скачать</h5>
|
|
@@ -353,11 +341,11 @@ INDEX_HTML = """
|
|
| 353 |
</div>
|
| 354 |
|
| 355 |
<hr class="text-muted mt-4">
|
| 356 |
-
<div class="small-muted"
|
| 357 |
</div>
|
| 358 |
|
| 359 |
<footer class="text-center">
|
| 360 |
-
<small>Made with ♥ —
|
| 361 |
</footer>
|
| 362 |
</div>
|
| 363 |
</div>
|
|
@@ -380,6 +368,9 @@ function log(msg) {
|
|
| 380 |
logEl.innerHTML = `<div>[${time}] ${msg}</div>` + logEl.innerHTML;
|
| 381 |
}
|
| 382 |
|
|
|
|
|
|
|
|
|
|
| 383 |
uploadForm.addEventListener('submit', async function(e) {
|
| 384 |
e.preventDefault();
|
| 385 |
logEl.innerHTML = '';
|
|
@@ -387,6 +378,7 @@ uploadForm.addEventListener('submit', async function(e) {
|
|
| 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) {
|
|
@@ -401,7 +393,6 @@ uploadForm.addEventListener('submit', async function(e) {
|
|
| 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' : '');
|
|
@@ -424,51 +415,72 @@ uploadForm.addEventListener('submit', async function(e) {
|
|
| 424 |
const job_id = js.job_id;
|
| 425 |
log('Задача создана: ' + job_id);
|
| 426 |
|
| 427 |
-
//
|
| 428 |
-
|
| 429 |
-
|
| 430 |
try {
|
| 431 |
-
const
|
| 432 |
-
if (
|
| 433 |
-
const
|
| 434 |
-
|
| 435 |
-
|
| 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('
|
|
|
|
|
|
|
|
|
|
| 464 |
}
|
| 465 |
-
};
|
| 466 |
-
|
| 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;
|
|
@@ -482,6 +494,8 @@ resetBtn.addEventListener('click', function() {
|
|
| 482 |
logEl.innerHTML = '';
|
| 483 |
progressBar.style.width = '0%';
|
| 484 |
progressBar.textContent = '0%';
|
|
|
|
|
|
|
| 485 |
});
|
| 486 |
</script>
|
| 487 |
</body>
|
|
@@ -492,8 +506,7 @@ resetBtn.addEventListener('click', function() {
|
|
| 492 |
@app.route("/", methods=["GET"])
|
| 493 |
def index():
|
| 494 |
cleanup_old_files()
|
| 495 |
-
|
| 496 |
-
return rendered
|
| 497 |
|
| 498 |
|
| 499 |
@app.route("/start", methods=["POST"])
|
|
@@ -506,11 +519,9 @@ def start_job():
|
|
| 506 |
return "No selected file", 400
|
| 507 |
|
| 508 |
job_id = str(uuid.uuid4())
|
| 509 |
-
q = queue.Queue()
|
| 510 |
with jobs_lock:
|
| 511 |
-
|
| 512 |
|
| 513 |
-
# gather options
|
| 514 |
options = {
|
| 515 |
"normalize": bool(request.form.get("normalize")),
|
| 516 |
"speed": request.form.get("speed", "1.0"),
|
|
@@ -521,88 +532,42 @@ def start_job():
|
|
| 521 |
"out_format": request.form.get("out_format", "wav"),
|
| 522 |
}
|
| 523 |
|
| 524 |
-
#
|
| 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 |
-
|
| 532 |
-
|
| 533 |
-
|
| 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("/
|
| 550 |
-
def
|
| 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 |
-
|
| 599 |
-
|
|
|
|
| 600 |
"""
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
def download_file(filename):
|
| 607 |
cleanup_old_files()
|
| 608 |
safe_path = (TMP_DIR / filename).resolve()
|
|
@@ -616,14 +581,11 @@ def download_file(filename):
|
|
| 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")
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
# -*- coding: utf-8 -*-
|
| 3 |
"""
|
| 4 |
+
main.py — single-file Flask app с AJAX-поллингом для реального статуса обработки аудио.
|
| 5 |
+
Save as main.py and run in Docker.
|
| 6 |
+
|
| 7 |
Requirements (requirements.txt):
|
| 8 |
flask
|
| 9 |
pydub
|
| 10 |
gunicorn
|
| 11 |
python-multipart
|
| 12 |
|
| 13 |
+
Убедитесь, что в контейнере установлен ffmpeg.
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
|
|
|
| 15 |
import os
|
| 16 |
import io
|
| 17 |
import uuid
|
| 18 |
import time
|
|
|
|
| 19 |
import threading
|
| 20 |
from pathlib import Path
|
| 21 |
+
from typing import List, Dict, Any
|
| 22 |
from flask import (
|
| 23 |
Flask, request, jsonify, url_for, send_file,
|
| 24 |
+
render_template_string, abort
|
| 25 |
)
|
| 26 |
from pydub import AudioSegment, effects, silence
|
| 27 |
|
| 28 |
# ----------------------------
|
| 29 |
+
# Config
|
| 30 |
# ----------------------------
|
| 31 |
TMP_DIR = Path("./tmp")
|
| 32 |
TMP_DIR.mkdir(exist_ok=True)
|
|
|
|
| 36 |
app = Flask(__name__)
|
| 37 |
app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_MB * 1024 * 1024
|
| 38 |
|
| 39 |
+
# job state storage
|
| 40 |
+
# job_logs[job_id] = [event_dict, ...]
|
| 41 |
+
job_logs: Dict[str, List[Dict[str, Any]]] = {}
|
| 42 |
+
# job_files[job_id] = Path to output file
|
| 43 |
+
job_files: Dict[str, Path] = {}
|
| 44 |
+
# lock for thread-safety
|
| 45 |
jobs_lock = threading.Lock()
|
| 46 |
|
| 47 |
|
| 48 |
# ----------------------------
|
| 49 |
+
# Helpers
|
| 50 |
# ----------------------------
|
| 51 |
def random_filename(ext: str) -> Path:
|
| 52 |
return TMP_DIR / (str(uuid.uuid4()) + "." + ext)
|
|
|
|
| 84 |
ext = ""
|
| 85 |
if "." in filename:
|
| 86 |
ext = filename.rsplit(".", 1)[1].lower()
|
| 87 |
+
# Try with extension first
|
| 88 |
if ext:
|
| 89 |
try:
|
| 90 |
buf.seek(0)
|
| 91 |
return AudioSegment.from_file(buf, format=ext)
|
| 92 |
except Exception:
|
| 93 |
pass
|
| 94 |
+
# Try common formats
|
| 95 |
for fmt in ("mp3", "wav", "ogg", "flac", "m4a", "aac"):
|
| 96 |
try:
|
| 97 |
buf.seek(0)
|
| 98 |
return AudioSegment.from_file(buf, format=fmt)
|
| 99 |
except Exception:
|
| 100 |
continue
|
| 101 |
+
# Final attempt: rely on ffmpeg autodetect
|
| 102 |
buf.seek(0)
|
| 103 |
return AudioSegment.from_file(buf)
|
| 104 |
|
| 105 |
|
| 106 |
+
# Processing functions
|
| 107 |
def apply_trim_silence(seg: AudioSegment, silence_thresh_db=-50, chunk_len_ms=10):
|
| 108 |
non_silence = silence.detect_nonsilent(seg, min_silence_len=chunk_len_ms, silence_thresh=silence_thresh_db)
|
| 109 |
if not non_silence:
|
|
|
|
| 129 |
if speed <= 0 or abs(speed - 1.0) < 1e-6:
|
| 130 |
return seg
|
| 131 |
try:
|
|
|
|
| 132 |
return effects.speedup(seg, playback_speed=speed)
|
| 133 |
except Exception:
|
| 134 |
new_frame_rate = int(seg.frame_rate * speed)
|
|
|
|
| 140 |
|
| 141 |
|
| 142 |
# ----------------------------
|
| 143 |
+
# Worker thread
|
| 144 |
# ----------------------------
|
| 145 |
+
def post_log(job_id: str, item: dict):
|
| 146 |
+
with jobs_lock:
|
| 147 |
+
if job_id in job_logs:
|
| 148 |
+
job_logs[job_id].append(item)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def processing_worker(job_id: str, input_path: Path, original_filename: str, options: dict):
|
| 152 |
"""
|
| 153 |
+
Background worker: reads input_path, performs processing, writes output file,
|
| 154 |
+
and appends events into job_logs[job_id].
|
| 155 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
try:
|
| 157 |
+
post_log(job_id, {"type": "progress", "msg": "Получаю файл...", "percent": 2})
|
| 158 |
# load audio
|
|
|
|
| 159 |
try:
|
| 160 |
+
with open(input_path, "rb") as f:
|
| 161 |
+
class _FS:
|
| 162 |
+
def __init__(self, data, filename):
|
| 163 |
+
self._data = data
|
| 164 |
+
self.filename = filename
|
| 165 |
+
def read(self):
|
| 166 |
+
return self._data
|
| 167 |
+
fs_wrapper = _FS(f.read(), original_filename)
|
| 168 |
+
seg = load_audio_from_filestorage(fs_wrapper)
|
| 169 |
except Exception as e:
|
| 170 |
+
post_log(job_id, {"type": "error", "msg": f"Не удалось загрузить аудио: {e}"})
|
| 171 |
return
|
| 172 |
|
| 173 |
+
post_log(job_id, {"type": "progress", "msg": "Анализ аудио...", "percent": 10})
|
| 174 |
+
time.sleep(0.15)
|
| 175 |
|
|
|
|
| 176 |
if options.get("trim_silence"):
|
| 177 |
+
post_log(job_id, {"type": "progress", "msg": "Обрезаю тишину...", "percent": 20})
|
| 178 |
try:
|
| 179 |
seg = apply_trim_silence(seg, silence_thresh_db=int(options.get("sil_thresh", -50)))
|
| 180 |
except Exception as exc:
|
| 181 |
+
post_log(job_id, {"type": "error", "msg": f"Ошибка при обрезке тишины: {exc}"})
|
| 182 |
return
|
| 183 |
time.sleep(0.1)
|
| 184 |
|
|
|
|
| 185 |
lp = float(options.get("lowpass", 0) or 0)
|
| 186 |
hp = float(options.get("highpass", 0) or 0)
|
| 187 |
if lp > 0:
|
| 188 |
+
post_log(job_id, {"type": "progress", "msg": f"Применяю low-pass {int(lp)} Hz...", "percent": 40})
|
| 189 |
try:
|
| 190 |
seg = apply_lowpass(seg, lp)
|
| 191 |
except Exception as exc:
|
| 192 |
+
post_log(job_id, {"type": "error", "msg": f"Ошибка low-pass: {exc}"})
|
| 193 |
return
|
| 194 |
time.sleep(0.1)
|
| 195 |
if hp > 0:
|
| 196 |
+
post_log(job_id, {"type": "progress", "msg": f"Применяю high-pass {int(hp)} Hz...", "percent": 50})
|
| 197 |
try:
|
| 198 |
seg = apply_highpass(seg, hp)
|
| 199 |
except Exception as exc:
|
| 200 |
+
post_log(job_id, {"type": "error", "msg": f"Ошибка high-pass: {exc}"})
|
| 201 |
return
|
| 202 |
time.sleep(0.1)
|
| 203 |
|
|
|
|
| 204 |
speed = float(options.get("speed", 1.0) or 1.0)
|
| 205 |
if abs(speed - 1.0) > 1e-6:
|
| 206 |
+
post_log(job_id, {"type": "progress", "msg": f"Изменяю скорость x{speed}...", "percent": 60})
|
| 207 |
try:
|
| 208 |
seg = apply_speed_change(seg, speed)
|
| 209 |
except Exception as exc:
|
| 210 |
+
post_log(job_id, {"type": "error", "msg": f"Ошибка изменения скорости: {exc}"})
|
| 211 |
return
|
| 212 |
time.sleep(0.1)
|
| 213 |
|
|
|
|
| 214 |
if options.get("normalize"):
|
| 215 |
+
post_log(job_id, {"type": "progress", "msg": "Нормализация громкости...", "percent": 75})
|
| 216 |
try:
|
| 217 |
seg = apply_normalize(seg)
|
| 218 |
except Exception as exc:
|
| 219 |
+
post_log(job_id, {"type": "error", "msg": f"Ошибка при нормализации: {exc}"})
|
| 220 |
return
|
| 221 |
time.sleep(0.1)
|
| 222 |
|
|
|
|
| 223 |
out_fmt = secure_extension_for_format(options.get("out_format", "wav"))
|
| 224 |
out_path = random_filename(out_fmt)
|
| 225 |
+
post_log(job_id, {"type": "progress", "msg": "Экспортирую файл...", "percent": 90})
|
| 226 |
try:
|
| 227 |
export_kwargs = {}
|
| 228 |
if out_fmt == "mp3":
|
| 229 |
export_kwargs["bitrate"] = "192k"
|
| 230 |
seg.export(out_path.as_posix(), format=out_fmt, **export_kwargs)
|
| 231 |
except Exception as exc:
|
| 232 |
+
post_log(job_id, {"type": "error", "msg": f"Ошибка при экспорте: {exc}"})
|
| 233 |
return
|
| 234 |
|
| 235 |
# register file
|
| 236 |
with jobs_lock:
|
| 237 |
job_files[job_id] = out_path
|
| 238 |
|
| 239 |
+
post_log(job_id, {"type": "done", "msg": "Готово", "percent": 100, "file": out_path.name})
|
|
|
|
|
|
|
| 240 |
except Exception as e:
|
| 241 |
+
post_log(job_id, {"type": "error", "msg": f"Внутренняя ошибка: {e}"})
|
| 242 |
|
| 243 |
|
| 244 |
# ----------------------------
|
| 245 |
+
# Routes
|
| 246 |
# ----------------------------
|
| 247 |
INDEX_HTML = """
|
| 248 |
<!doctype html>
|
| 249 |
<html lang="ru">
|
| 250 |
<head>
|
| 251 |
<meta charset="utf-8" />
|
| 252 |
+
<title>Music Processor — AJAX</title>
|
| 253 |
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 254 |
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 255 |
<style>
|
|
|
|
| 260 |
.progress { height: 18px; }
|
| 261 |
.controls label { font-weight: 600; color: #cdeefb; }
|
| 262 |
footer { margin-top: 24px; color: #8aa7bd; }
|
| 263 |
+
#log { max-height: 220px; overflow:auto; }
|
| 264 |
</style>
|
| 265 |
</head>
|
| 266 |
<body>
|
|
|
|
| 268 |
<div class="row justify-content-center">
|
| 269 |
<div class="col-lg-8">
|
| 270 |
<div class="card p-4">
|
| 271 |
+
<h2 class="mb-1 accent">Онлайн обработка музыки (AJAX)</h2>
|
| 272 |
+
<p class="small-muted mb-3">Загрузите трек, прогресс обновляется через AJAX-поллинг.</p>
|
| 273 |
|
| 274 |
<form id="uploadForm" enctype="multipart/form-data" class="mb-3">
|
| 275 |
<div class="mb-3">
|
|
|
|
| 329 |
<div id="progressBar" class="progress-bar bg-info" role="progressbar" style="width: 0%">0%</div>
|
| 330 |
</div>
|
| 331 |
</div>
|
| 332 |
+
<div id="log" class="small-muted mb-2" style="min-height:64px;"></div>
|
| 333 |
|
| 334 |
<div id="resultArea" class="mt-3" style="display:none;">
|
| 335 |
<h5 class="accent">Готово — прослушать / скачать</h5>
|
|
|
|
| 341 |
</div>
|
| 342 |
|
| 343 |
<hr class="text-muted mt-4">
|
| 344 |
+
<div class="small-muted">Максимальный размер файла: {{max_mb}} МБ. Временные файлы хранятся ~1 час.</div>
|
| 345 |
</div>
|
| 346 |
|
| 347 |
<footer class="text-center">
|
| 348 |
+
<small>Made with ♥ — pydub + ffmpeg. Используется AJAX-поллинг для статуса.</small>
|
| 349 |
</footer>
|
| 350 |
</div>
|
| 351 |
</div>
|
|
|
|
| 368 |
logEl.innerHTML = `<div>[${time}] ${msg}</div>` + logEl.innerHTML;
|
| 369 |
}
|
| 370 |
|
| 371 |
+
let pollInterval = null;
|
| 372 |
+
let last_idx = 0;
|
| 373 |
+
|
| 374 |
uploadForm.addEventListener('submit', async function(e) {
|
| 375 |
e.preventDefault();
|
| 376 |
logEl.innerHTML = '';
|
|
|
|
| 378 |
statusArea.style.display = 'block';
|
| 379 |
progressBar.style.width = '0%';
|
| 380 |
progressBar.textContent = '0%';
|
| 381 |
+
last_idx = 0;
|
| 382 |
|
| 383 |
const fileInput = document.getElementById('audio_file');
|
| 384 |
if (!fileInput.files || fileInput.files.length === 0) {
|
|
|
|
| 393 |
|
| 394 |
submitBtn.disabled = true;
|
| 395 |
|
|
|
|
| 396 |
const form = new FormData();
|
| 397 |
form.append('audio_file', file);
|
| 398 |
form.append('normalize', document.getElementById('normalize').checked ? '1' : '');
|
|
|
|
| 415 |
const job_id = js.job_id;
|
| 416 |
log('Задача создана: ' + job_id);
|
| 417 |
|
| 418 |
+
// start polling
|
| 419 |
+
if (pollInterval) clearInterval(pollInterval);
|
| 420 |
+
pollInterval = setInterval(async () => {
|
| 421 |
try {
|
| 422 |
+
const sresp = await fetch('/status/' + job_id + '?last_idx=' + last_idx);
|
| 423 |
+
if (!sresp.ok) {
|
| 424 |
+
const t = await sresp.text();
|
| 425 |
+
log('Ошибка статуса: ' + sresp.status + ' ' + t);
|
| 426 |
+
clearInterval(pollInterval);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
submitBtn.disabled = false;
|
| 428 |
+
return;
|
| 429 |
+
}
|
| 430 |
+
const data = await sresp.json();
|
| 431 |
+
const events = data.events || [];
|
| 432 |
+
last_idx = data.next_index || last_idx;
|
| 433 |
+
for (const ev of events) {
|
| 434 |
+
if (ev.type === 'progress') {
|
| 435 |
+
const pct = ev.percent || 0;
|
| 436 |
+
progressBar.style.width = pct + '%';
|
| 437 |
+
progressBar.textContent = pct + '%';
|
| 438 |
+
if (ev.msg) log(ev.msg);
|
| 439 |
+
} else if (ev.type === 'done') {
|
| 440 |
+
progressBar.style.width = '100%';
|
| 441 |
+
progressBar.textContent = '100%';
|
| 442 |
+
if (ev.msg) log(ev.msg);
|
| 443 |
+
// waiting for file_url event below (server will include file in done event)
|
| 444 |
+
} else if (ev.type === 'error') {
|
| 445 |
+
log('Ошибка: ' + (ev.msg || 'unknown'));
|
| 446 |
+
alert('Ошибка обработки: ' + (ev.msg || 'неизвестно'));
|
| 447 |
+
clearInterval(pollInterval);
|
| 448 |
+
submitBtn.disabled = false;
|
| 449 |
+
return;
|
| 450 |
+
}
|
| 451 |
+
// If server included file name (or file_url) in event, open it
|
| 452 |
+
if (ev.file) {
|
| 453 |
+
// construct download url
|
| 454 |
+
const url = window.location.origin + '/download/' + ev.file;
|
| 455 |
+
player.src = url;
|
| 456 |
+
downloadLink.href = url;
|
| 457 |
+
downloadLink.download = 'processed_' + (file.name || 'file.' + (ev.file.split('.').pop() || 'wav'));
|
| 458 |
+
resultArea.style.display = 'block';
|
| 459 |
+
log('Файл доступен: ' + url);
|
| 460 |
+
clearInterval(pollInterval);
|
| 461 |
+
submitBtn.disabled = false;
|
| 462 |
+
break;
|
| 463 |
+
}
|
| 464 |
+
if (ev.file_url) {
|
| 465 |
+
const url = ev.file_url;
|
| 466 |
+
player.src = url;
|
| 467 |
+
downloadLink.href = url;
|
| 468 |
+
downloadLink.download = 'processed_' + (file.name || 'file');
|
| 469 |
+
resultArea.style.display = 'block';
|
| 470 |
+
log('Файл доступен: ' + url);
|
| 471 |
+
clearInterval(pollInterval);
|
| 472 |
+
submitBtn.disabled = false;
|
| 473 |
+
break;
|
| 474 |
+
}
|
| 475 |
}
|
| 476 |
} catch (err) {
|
| 477 |
+
console.error('poll err', err);
|
| 478 |
+
log('Ошибка запроса статуса: ' + err);
|
| 479 |
+
clearInterval(pollInterval);
|
| 480 |
+
submitBtn.disabled = false;
|
| 481 |
}
|
| 482 |
+
}, 800); // poll every 800ms
|
| 483 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
} catch (err) {
|
| 485 |
alert('Ошибка при отправке: ' + err);
|
| 486 |
submitBtn.disabled = false;
|
|
|
|
| 494 |
logEl.innerHTML = '';
|
| 495 |
progressBar.style.width = '0%';
|
| 496 |
progressBar.textContent = '0%';
|
| 497 |
+
if (pollInterval) clearInterval(pollInterval);
|
| 498 |
+
submitBtn.disabled = false;
|
| 499 |
});
|
| 500 |
</script>
|
| 501 |
</body>
|
|
|
|
| 506 |
@app.route("/", methods=["GET"])
|
| 507 |
def index():
|
| 508 |
cleanup_old_files()
|
| 509 |
+
return render_template_string(INDEX_HTML, max_mb=MAX_UPLOAD_MB)
|
|
|
|
| 510 |
|
| 511 |
|
| 512 |
@app.route("/start", methods=["POST"])
|
|
|
|
| 519 |
return "No selected file", 400
|
| 520 |
|
| 521 |
job_id = str(uuid.uuid4())
|
|
|
|
| 522 |
with jobs_lock:
|
| 523 |
+
job_logs[job_id] = []
|
| 524 |
|
|
|
|
| 525 |
options = {
|
| 526 |
"normalize": bool(request.form.get("normalize")),
|
| 527 |
"speed": request.form.get("speed", "1.0"),
|
|
|
|
| 532 |
"out_format": request.form.get("out_format", "wav"),
|
| 533 |
}
|
| 534 |
|
| 535 |
+
# Save uploaded file to tmp
|
|
|
|
|
|
|
|
|
|
| 536 |
temp_in = TMP_DIR / (job_id + "_in")
|
| 537 |
fs.stream.seek(0)
|
| 538 |
fs.save(temp_in)
|
| 539 |
+
|
| 540 |
+
# Start background worker
|
| 541 |
+
t = threading.Thread(target=processing_worker, args=(job_id, temp_in, fs.filename, options), daemon=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
t.start()
|
| 543 |
|
| 544 |
return jsonify({"job_id": job_id})
|
| 545 |
|
| 546 |
|
| 547 |
+
@app.route("/status/<job_id>", methods=["GET"])
|
| 548 |
+
def status(job_id):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
"""
|
| 550 |
+
AJAX polling endpoint.
|
| 551 |
+
Query param: last_idx (int) — index of last event client already received.
|
| 552 |
+
Response: {"events": [...], "next_index": N}
|
| 553 |
"""
|
| 554 |
+
last_idx = int(request.args.get("last_idx", "0") or 0)
|
| 555 |
+
with jobs_lock:
|
| 556 |
+
logs = job_logs.get(job_id, [])
|
| 557 |
+
# copy slice
|
| 558 |
+
events = logs[last_idx:]
|
| 559 |
+
next_index = last_idx + len(events)
|
| 560 |
+
# If any event has 'file' (filename), convert it to full URL as file_url
|
| 561 |
+
for ev in events:
|
| 562 |
+
if ev.get("file") and not ev.get("file_url"):
|
| 563 |
+
# build full URL
|
| 564 |
+
download_path = url_for("download_file", filename=ev["file"], _external=False)
|
| 565 |
+
host = request.host_url.rstrip('/')
|
| 566 |
+
ev["file_url"] = host + download_path
|
| 567 |
+
return jsonify({"events": events, "next_index": next_index})
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
@app.route("/download/<filename>", methods=["GET"])
|
| 571 |
def download_file(filename):
|
| 572 |
cleanup_old_files()
|
| 573 |
safe_path = (TMP_DIR / filename).resolve()
|
|
|
|
| 581 |
return f"Error serving file: {e}", 500
|
| 582 |
|
| 583 |
|
| 584 |
+
@app.route("/healthz", methods=["GET"])
|
| 585 |
def health():
|
| 586 |
return "ok", 200
|
| 587 |
|
| 588 |
|
|
|
|
|
|
|
|
|
|
| 589 |
if __name__ == "__main__":
|
| 590 |
port = int(os.environ.get("PORT", 7860))
|
| 591 |
host = os.environ.get("HOST", "0.0.0.0")
|