Starchik commited on
Commit
a6fde8a
·
verified ·
1 Parent(s): 0c62ff0

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +367 -87
main.py CHANGED
@@ -1,22 +1,25 @@
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 (
@@ -26,27 +29,24 @@ from flask import (
26
  from pydub import AudioSegment, effects, silence
27
 
28
  # ----------------------------
29
- # Config
30
  # ----------------------------
31
  TMP_DIR = Path("./tmp")
32
  TMP_DIR.mkdir(exist_ok=True)
33
- KEEP_FILES_SEC = 60 * 60 # 1 hour
34
  MAX_UPLOAD_MB = 200
35
 
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)
@@ -80,11 +80,11 @@ def cleanup_old_files():
80
  def load_audio_from_filestorage(filestorage):
81
  data = filestorage.read()
82
  buf = io.BytesIO(data)
83
- filename = filestorage.filename or ""
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)
@@ -98,12 +98,13 @@ def load_audio_from_filestorage(filestorage):
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:
@@ -139,8 +140,110 @@ def apply_normalize(seg: AudioSegment):
139
  return effects.normalize(seg)
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  # ----------------------------
143
- # Worker thread
144
  # ----------------------------
145
  def post_log(job_id: str, item: dict):
146
  with jobs_lock:
@@ -148,14 +251,13 @@ def post_log(job_id: str, item: dict):
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:
@@ -170,37 +272,73 @@ def processing_worker(job_id: str, input_path: Path, original_filename: str, opt
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})
@@ -209,20 +347,56 @@ def processing_worker(job_id: str, input_path: Path, original_filename: str, opt
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":
@@ -232,7 +406,6 @@ def processing_worker(job_id: str, input_path: Path, original_filename: str, opt
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
 
@@ -242,53 +415,54 @@ def processing_worker(job_id: str, input_path: Path, original_filename: str, opt
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>
256
- body { background: linear-gradient(180deg,#0f172a, #071233); color: #e6eef8; min-height:100vh; }
257
- .card { border-radius: 12px; background: rgba(255,255,255,0.04); box-shadow: 0 8px 30px rgba(2,6,23,0.6); }
258
- .accent { color: #7dd3fc; }
259
- .small-muted { color: #9fb3c8; font-size: 0.9rem; }
 
 
 
 
 
 
 
 
 
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>
267
  <div class="container py-5">
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">
276
- <label class="form-label">Аудиофайл</label>
277
- <input class="form-control" type="file" id="audio_file" name="audio_file" accept="audio/*" required>
278
- </div>
279
-
280
- <div class="row g-2">
281
- <div class="col-md-4">
282
- <div class="form-check">
283
- <input class="form-check-input" type="checkbox" id="normalize" name="normalize">
284
- <label class="form-check-label" for="normalize">Нормализовать</label>
285
- </div>
286
- </div>
287
- <div class="col-md-4">
288
- <label class="form-label">Скорость</label>
289
- <input class="form-control" type="number" step="0.1" name="speed" id="speed" value="1.0">
290
  </div>
291
- <div class="col-md-4">
292
  <label class="form-label">Формат вывода</label>
293
  <select class="form-select" id="out_format" name="out_format">
294
  <option value="wav" selected>WAV</option>
@@ -300,23 +474,105 @@ INDEX_HTML = """
300
  </div>
301
  </div>
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  <div class="row g-2 mt-3">
304
- <div class="col-md-6">
305
- <label class="form-label">Low-pass (Hz, 0 = off)</label>
306
  <input class="form-control" type="number" id="lowpass" name="lowpass" value="0">
307
  </div>
308
- <div class="col-md-6">
309
- <label class="form-label">High-pass (Hz, 0 = off)</label>
310
  <input class="form-control" type="number" id="highpass" name="highpass" value="0">
311
  </div>
 
 
 
 
 
 
 
312
  </div>
313
 
314
- <div class="form-check form-switch mt-3">
315
- <input class="form-check-input" type="checkbox" id="trim_silence" name="trim_silence">
316
- <label class="form-check-label" for="trim_silence">Обрезать тишину в начале/конце</label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  </div>
318
 
319
- <div class="mt-3 d-flex gap-2">
320
  <button type="submit" id="submitBtn" class="btn btn-primary">Обработать</button>
321
  <button type="button" id="resetBtn" class="btn btn-outline-light">Сброс</button>
322
  </div>
@@ -340,12 +596,12 @@ INDEX_HTML = """
340
  </div>
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>
@@ -362,6 +618,7 @@ const player = document.getElementById('player');
362
  const downloadLink = document.getElementById('downloadLink');
363
  const submitBtn = document.getElementById('submitBtn');
364
  const resetBtn = document.getElementById('resetBtn');
 
365
 
366
  function log(msg) {
367
  const time = new Date().toLocaleTimeString();
@@ -371,6 +628,13 @@ function log(msg) {
371
  let pollInterval = null;
372
  let last_idx = 0;
373
 
 
 
 
 
 
 
 
374
  uploadForm.addEventListener('submit', async function(e) {
375
  e.preventDefault();
376
  logEl.innerHTML = '';
@@ -400,8 +664,18 @@ uploadForm.addEventListener('submit', async function(e) {
400
  form.append('lowpass', document.getElementById('lowpass').value);
401
  form.append('highpass', document.getElementById('highpass').value);
402
  form.append('trim_silence', document.getElementById('trim_silence').checked ? '1' : '');
403
- form.append('sil_thresh', -50);
404
  form.append('out_format', document.getElementById('out_format').value);
 
 
 
 
 
 
 
 
 
 
405
 
406
  try {
407
  const resp = await fetch('/start', { method: 'POST', body: form });
@@ -440,7 +714,6 @@ uploadForm.addEventListener('submit', async function(e) {
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 || 'неизвестно'));
@@ -448,9 +721,7 @@ uploadForm.addEventListener('submit', async function(e) {
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;
@@ -479,7 +750,7 @@ uploadForm.addEventListener('submit', async function(e) {
479
  clearInterval(pollInterval);
480
  submitBtn.disabled = false;
481
  }
482
- }, 800); // poll every 800ms
483
 
484
  } catch (err) {
485
  alert('Ошибка при отправке: ' + err);
@@ -503,6 +774,9 @@ resetBtn.addEventListener('click', function() {
503
  """
504
 
505
 
 
 
 
506
  @app.route("/", methods=["GET"])
507
  def index():
508
  cleanup_old_files()
@@ -530,6 +804,16 @@ def start_job():
530
  "trim_silence": bool(request.form.get("trim_silence")),
531
  "sil_thresh": request.form.get("sil_thresh", "-50"),
532
  "out_format": request.form.get("out_format", "wav"),
 
 
 
 
 
 
 
 
 
 
533
  }
534
 
535
  # Save uploaded file to tmp
@@ -537,7 +821,7 @@ def start_job():
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
 
@@ -546,21 +830,14 @@ def start_job():
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
@@ -586,6 +863,9 @@ 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")
 
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
3
  """
4
+ main.py — одностраничный Flask сервер для онлайн-обработки аудио с множеством эффектов.
5
+ Запуск: gunicorn -b 0.0.0.0:7860 main:app
6
 
7
+ Требования (requirements.txt):
8
  flask
9
  pydub
10
  gunicorn
11
  python-multipart
12
 
13
+ ВАЖНО:
14
+ - ffmpeg должен быть установлен в контейнере (apt-get install -y ffmpeg перед сменой пользователя).
15
+ - Этот файл использует pydub и иногда вызывает ffmpeg через subprocess для некоторых эффектов (echo, pitch_shift, reverb).
16
  """
17
  import os
18
  import io
19
  import uuid
20
  import time
21
  import threading
22
+ import subprocess
23
  from pathlib import Path
24
  from typing import List, Dict, Any
25
  from flask import (
 
29
  from pydub import AudioSegment, effects, silence
30
 
31
  # ----------------------------
32
+ # Конфигурация
33
  # ----------------------------
34
  TMP_DIR = Path("./tmp")
35
  TMP_DIR.mkdir(exist_ok=True)
36
+ KEEP_FILES_SEC = 60 * 60 # храним файлы 1 час
37
  MAX_UPLOAD_MB = 200
38
 
39
  app = Flask(__name__)
40
  app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_MB * 1024 * 1024
41
 
42
+ # job state
 
43
  job_logs: Dict[str, List[Dict[str, Any]]] = {}
 
44
  job_files: Dict[str, Path] = {}
 
45
  jobs_lock = threading.Lock()
46
 
47
 
48
  # ----------------------------
49
+ # Утилиты
50
  # ----------------------------
51
  def random_filename(ext: str) -> Path:
52
  return TMP_DIR / (str(uuid.uuid4()) + "." + ext)
 
80
  def load_audio_from_filestorage(filestorage):
81
  data = filestorage.read()
82
  buf = io.BytesIO(data)
83
+ filename = getattr(filestorage, "filename", "") or ""
84
  ext = ""
85
  if "." in filename:
86
  ext = filename.rsplit(".", 1)[1].lower()
87
+ # Try extension-guessing first
88
  if ext:
89
  try:
90
  buf.seek(0)
 
98
  return AudioSegment.from_file(buf, format=fmt)
99
  except Exception:
100
  continue
 
101
  buf.seek(0)
102
  return AudioSegment.from_file(buf)
103
 
104
 
105
+ # ----------------------------
106
+ # Pydub-based эффекты
107
+ # ----------------------------
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:
 
140
  return effects.normalize(seg)
141
 
142
 
143
+ def apply_gain(seg: AudioSegment, db: float):
144
+ try:
145
+ return seg.apply_gain(float(db))
146
+ except Exception:
147
+ return seg
148
+
149
+
150
+ def apply_fade(seg: AudioSegment, fade_in_ms: int, fade_out_ms: int):
151
+ if fade_in_ms and fade_in_ms > 0:
152
+ seg = seg.fade_in(int(fade_in_ms))
153
+ if fade_out_ms and fade_out_ms > 0:
154
+ seg = seg.fade_out(int(fade_out_ms))
155
+ return seg
156
+
157
+
158
+ def apply_reverse(seg: AudioSegment):
159
+ try:
160
+ return seg.reverse()
161
+ except Exception:
162
+ return seg
163
+
164
+
165
+ # ----------------------------
166
+ # FFmpeg-based helpers для echo/reverb/pitch shift
167
+ # ----------------------------
168
+ def ffmpeg_apply_filter_on_segment(seg: AudioSegment, filter_str: str) -> AudioSegment:
169
+ """
170
+ Экспорт сегмента во временный WAV, применить ffmpeg -af filter_str, загрузить обратно.
171
+ """
172
+ in_tmp = random_filename("wav")
173
+ out_tmp = random_filename("wav")
174
+ try:
175
+ seg.export(in_tmp.as_posix(), format="wav")
176
+ cmd = [
177
+ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
178
+ "-i", in_tmp.as_posix(),
179
+ "-af", filter_str,
180
+ out_tmp.as_posix()
181
+ ]
182
+ subprocess.run(cmd, check=True)
183
+ result = AudioSegment.from_file(out_tmp.as_posix(), format="wav")
184
+ except subprocess.CalledProcessError as e:
185
+ raise RuntimeError(f"ffmpeg error: {e}")
186
+ finally:
187
+ try:
188
+ if in_tmp.exists():
189
+ in_tmp.unlink()
190
+ except Exception:
191
+ pass
192
+ # clean out_tmp later by caller (we'll try to remove now)
193
+ try:
194
+ if out_tmp.exists():
195
+ out_tmp.unlink()
196
+ except Exception:
197
+ pass
198
+ return result
199
+
200
+
201
+ def ffmpeg_apply_filter_files(input_path: Path, filter_str: str, out_path: Path):
202
+ cmd = [
203
+ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
204
+ "-i", input_path.as_posix(),
205
+ "-af", filter_str,
206
+ out_path.as_posix()
207
+ ]
208
+ subprocess.run(cmd, check=True)
209
+
210
+
211
+ def apply_echo_with_ffmpeg(seg: AudioSegment, delay_ms: float = 500, decay: float = 0.5):
212
+ # aecho=in_gain:out_gain:delays:decays (delays in ms, decays 0..1)
213
+ delays = str(int(delay_ms))
214
+ decays = str(float(decay))
215
+ filter_str = f"aecho=0.8:0.9:{delays}:{decays}"
216
+ return ffmpeg_apply_filter_on_segment(seg, filter_str)
217
+
218
+
219
+ def apply_reverb_with_ffmpeg(seg: AudioSegment, reverb_level: float = 50.0):
220
+ # Emulate reverb using multiple echoes — simple approach
221
+ # Compose multiple delays and decays
222
+ d1 = 60; d2 = 120; d3 = 180; d4 = 240
223
+ dec1 = max(0.1, min(0.9, reverb_level / 100.0))
224
+ # build aecho with multiple delays/decays: delays separated by '|' and decays same length
225
+ filter_str = f"aecho=0.7:0.75:{d1}|{d2}|{d3}|{d4}:{dec1}|{dec1*0.6}|{dec1*0.4}|{dec1*0.2}"
226
+ return ffmpeg_apply_filter_on_segment(seg, filter_str)
227
+
228
+
229
+ def apply_pitch_shift_with_ffmpeg(seg: AudioSegment, semitones: float):
230
+ """
231
+ Pitch shift preserving tempo using ffmpeg filters:
232
+ asetrate=sr*2^(semitones/12),aresample=sr,atempo=1/2^(semitones/12)
233
+ This approach preserves duration (attempt) but may produce artifacts for large shifts.
234
+ """
235
+ # Export, run ffmpeg, reload
236
+ if abs(semitones) < 0.001:
237
+ return seg
238
+ factor = 2 ** (semitones / 12.0)
239
+ # Build filter string
240
+ # Increase sample rate by factor, then resample back, then adjust tempo by inverse factor
241
+ filter_str = f"asetrate=sample_rate*{factor},aresample=sample_rate,atempo={1.0/factor}"
242
+ return ffmpeg_apply_filter_on_segment(seg, filter_str)
243
+
244
+
245
  # ----------------------------
246
+ # Логирование job
247
  # ----------------------------
248
  def post_log(job_id: str, item: dict):
249
  with jobs_lock:
 
251
  job_logs[job_id].append(item)
252
 
253
 
254
+ # ----------------------------
255
+ # Рабочий поток обработки
256
+ # ----------------------------
257
  def processing_worker(job_id: str, input_path: Path, original_filename: str, options: dict):
 
 
 
 
258
  try:
259
  post_log(job_id, {"type": "progress", "msg": "Получаю файл...", "percent": 2})
260
+ # load
261
  try:
262
  with open(input_path, "rb") as f:
263
  class _FS:
 
272
  post_log(job_id, {"type": "error", "msg": f"Не удалось загрузить аудио: {e}"})
273
  return
274
 
275
+ post_log(job_id, {"type": "progress", "msg": "Анализ аудио...", "percent": 8})
276
+ time.sleep(0.08)
277
 
278
+ # trim silence
279
  if options.get("trim_silence"):
280
+ post_log(job_id, {"type": "progress", "msg": "Обрезаю тишину...", "percent": 18})
281
  try:
282
  seg = apply_trim_silence(seg, silence_thresh_db=int(options.get("sil_thresh", -50)))
283
  except Exception as exc:
284
  post_log(job_id, {"type": "error", "msg": f"Ошибка при обрезке тишины: {exc}"})
285
  return
286
+ time.sleep(0.05)
287
 
288
+ # low/high pass
289
  lp = float(options.get("lowpass", 0) or 0)
290
  hp = float(options.get("highpass", 0) or 0)
291
  if lp > 0:
292
+ post_log(job_id, {"type": "progress", "msg": f"Применяю low-pass {int(lp)} Hz...", "percent": 30})
293
  try:
294
  seg = apply_lowpass(seg, lp)
295
  except Exception as exc:
296
  post_log(job_id, {"type": "error", "msg": f"Ошибка low-pass: {exc}"})
297
  return
298
+ time.sleep(0.04)
299
  if hp > 0:
300
+ post_log(job_id, {"type": "progress", "msg": f"Применяю high-pass {int(hp)} Hz...", "percent": 36})
301
  try:
302
  seg = apply_highpass(seg, hp)
303
  except Exception as exc:
304
  post_log(job_id, {"type": "error", "msg": f"Ошибка high-pass: {exc}"})
305
  return
306
+ time.sleep(0.04)
307
+
308
+ # gain in dB
309
+ gain_db = float(options.get("gain_db", 0) or 0)
310
+ if abs(gain_db) > 0.001:
311
+ post_log(job_id, {"type": "progress", "msg": f"Применяю усиление {gain_db} dB...", "percent": 42})
312
+ try:
313
+ seg = apply_gain(seg, gain_db)
314
+ except Exception as exc:
315
+ post_log(job_id, {"type": "error", "msg": f"Ошибка gain: {exc}"})
316
+ return
317
+ time.sleep(0.03)
318
+
319
+ # fade in/out
320
+ fi = int(options.get("fade_in_ms", 0) or 0)
321
+ fo = int(options.get("fade_out_ms", 0) or 0)
322
+ if fi > 0 or fo > 0:
323
+ post_log(job_id, {"type": "progress", "msg": f"Применяю fade in {fi} ms / fade out {fo} ms...", "percent": 48})
324
+ try:
325
+ seg = apply_fade(seg, fi, fo)
326
+ except Exception as exc:
327
+ post_log(job_id, {"type": "error", "msg": f"Ошибка fade: {exc}"})
328
+ return
329
+ time.sleep(0.03)
330
 
331
+ # reverse
332
+ if options.get("reverse"):
333
+ post_log(job_id, {"type": "progress", "msg": "Реверс трека...", "percent": 52})
334
+ try:
335
+ seg = apply_reverse(seg)
336
+ except Exception as exc:
337
+ post_log(job_id, {"type": "error", "msg": f"Ошибка reverse: {exc}"})
338
+ return
339
+ time.sleep(0.02)
340
+
341
+ # speed change
342
  speed = float(options.get("speed", 1.0) or 1.0)
343
  if abs(speed - 1.0) > 1e-6:
344
  post_log(job_id, {"type": "progress", "msg": f"Изменяю скорость x{speed}...", "percent": 60})
 
347
  except Exception as exc:
348
  post_log(job_id, {"type": "error", "msg": f"Ошибка изменения скорости: {exc}"})
349
  return
350
+ time.sleep(0.03)
351
 
352
+ # normalize
353
  if options.get("normalize"):
354
+ post_log(job_id, {"type": "progress", "msg": "Нормализация громкости...", "percent": 68})
355
  try:
356
  seg = apply_normalize(seg)
357
  except Exception as exc:
358
  post_log(job_id, {"type": "error", "msg": f"Ошибка при нормализации: {exc}"})
359
  return
360
+ time.sleep(0.03)
361
+
362
+ # pitch shift via ffmpeg (preserve tempo)
363
+ pitch = float(options.get("pitch_semitones", 0) or 0)
364
+ if abs(pitch) > 0.001:
365
+ post_log(job_id, {"type": "progress", "msg": f"Сдвиг тона на {pitch} полутонов...", "percent": 75})
366
+ try:
367
+ seg = apply_pitch_shift_with_ffmpeg(seg, pitch)
368
+ except Exception as exc:
369
+ post_log(job_id, {"type": "error", "msg": f"Ошибка pitch shift: {exc}"})
370
+ return
371
+ time.sleep(0.04)
372
+
373
+ # echo
374
+ if options.get("echo"):
375
+ delay_ms = float(options.get("echo_delay_ms", 500) or 500)
376
+ decay = float(options.get("echo_decay", 0.5) or 0.5)
377
+ post_log(job_id, {"type": "progress", "msg": f"Применяю echo: delay {int(delay_ms)} ms, decay {decay}...", "percent": 82})
378
+ try:
379
+ seg = apply_echo_with_ffmpeg(seg, delay_ms=delay_ms, decay=decay)
380
+ except Exception as exc:
381
+ post_log(job_id, {"type": "error", "msg": f"Ошибка echo: {exc}"})
382
+ return
383
+ time.sleep(0.03)
384
+
385
+ # reverb
386
+ if options.get("reverb"):
387
+ rv_level = float(options.get("reverb_level", 50) or 50)
388
+ post_log(job_id, {"type": "progress", "msg": f"Применяю reverb (level {rv_level})...", "percent": 88})
389
+ try:
390
+ seg = apply_reverb_with_ffmpeg(seg, reverb_level=rv_level)
391
+ except Exception as exc:
392
+ post_log(job_id, {"type": "error", "msg": f"Ошибка reverb: {exc}"})
393
+ return
394
+ time.sleep(0.03)
395
 
396
+ # export to selected format
397
  out_fmt = secure_extension_for_format(options.get("out_format", "wav"))
398
  out_path = random_filename(out_fmt)
399
+ post_log(job_id, {"type": "progress", "msg": "Экспортирую файл...", "percent": 94})
400
  try:
401
  export_kwargs = {}
402
  if out_fmt == "mp3":
 
406
  post_log(job_id, {"type": "error", "msg": f"Ошибка при экспорте: {exc}"})
407
  return
408
 
 
409
  with jobs_lock:
410
  job_files[job_id] = out_path
411
 
 
415
 
416
 
417
  # ----------------------------
418
+ # Шаблон UI (улучшен контраст)
419
  # ----------------------------
420
  INDEX_HTML = """
421
  <!doctype html>
422
  <html lang="ru">
423
  <head>
424
  <meta charset="utf-8" />
425
+ <title>Music Processor — Pro</title>
426
  <meta name="viewport" content="width=device-width,initial-scale=1" />
427
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
428
  <style>
429
+ :root{
430
+ --bg:#0b1220;
431
+ --card:#0f1724cc;
432
+ --muted:#cfe8f5;
433
+ --accent:#7dd3fc;
434
+ --text:#eaf6ff;
435
+ --sub:#9fc4d8;
436
+ --panel:#0b1228;
437
+ }
438
+ body { background: linear-gradient(180deg,var(--bg), #071233); color: var(--text); min-height:100vh; }
439
+ .card { border-radius: 12px; background: var(--card); box-shadow: 0 8px 30px rgba(2,6,23,0.6); }
440
+ .accent { color: var(--accent); }
441
+ .small-muted { color: var(--sub); font-size: 0.95rem; }
442
  .progress { height: 18px; }
443
+ .controls label { font-weight: 600; color: var(--text); }
444
+ footer { margin-top: 18px; color: var(--sub); }
445
+ #log { max-height: 220px; overflow:auto; color: var(--muted); }
446
+ .form-control, .form-select { background: rgba(255,255,255,0.03); color: var(--text); border: 1px solid rgba(255,255,255,0.06); }
447
+ .form-check-label { color: var(--text); }
448
+ .btn-primary { background: linear-gradient(90deg,#0ea5e9, #3b82f6); border: none; }
449
  </style>
450
  </head>
451
  <body>
452
  <div class="container py-5">
453
  <div class="row justify-content-center">
454
+ <div class="col-xl-9 col-lg-10">
455
  <div class="card p-4">
456
+ <h2 class="mb-1 accent">Онлайн обработка музыки — Pro</h2>
457
+ <p class="small-muted mb-3">Добавлено много эффектов: gain, fade, reverse, pitch, echo, reverb, фильтры, speed, normalize и др. Результат — в реальном времени через AJAX-поллинг.</p>
458
 
459
  <form id="uploadForm" enctype="multipart/form-data" class="mb-3">
460
+ <div class="row g-3">
461
+ <div class="col-md-6">
462
+ <label class="form-label">Аудиофайл</label>
463
+ <input class="form-control" type="file" id="audio_file" name="audio_file" accept="audio/*" required>
 
 
 
 
 
 
 
 
 
 
 
464
  </div>
465
+ <div class="col-md-6">
466
  <label class="form-label">Формат вывода</label>
467
  <select class="form-select" id="out_format" name="out_format">
468
  <option value="wav" selected>WAV</option>
 
474
  </div>
475
  </div>
476
 
477
+ <hr style="border-color:rgba(255,255,255,0.04)">
478
+
479
+ <div class="row g-2">
480
+ <div class="col-md-3">
481
+ <label class="form-label">Скорость (x)</label>
482
+ <input class="form-control" type="number" step="0.05" name="speed" id="speed" value="1.0">
483
+ </div>
484
+ <div class="col-md-3">
485
+ <label class="form-label">Gain (dB)</label>
486
+ <input class="form-control" type="number" step="0.5" name="gain_db" id="gain_db" value="0">
487
+ </div>
488
+ <div class="col-md-3">
489
+ <label class="form-label">Pitch (полутоны)</label>
490
+ <input class="form-control" type="number" step="0.5" name="pitch_semitones" id="pitch_semitones" value="0">
491
+ </div>
492
+ <div class="col-md-3">
493
+ <label class="form-label">Normalize</label>
494
+ <div class="form-check">
495
+ <input class="form-check-input" type="checkbox" id="normalize" name="normalize">
496
+ <label class="form-check-label" for="normalize">вкл</label>
497
+ </div>
498
+ </div>
499
+ </div>
500
+
501
  <div class="row g-2 mt-3">
502
+ <div class="col-md-4">
503
+ <label class="form-label">Low-pass (Hz)</label>
504
  <input class="form-control" type="number" id="lowpass" name="lowpass" value="0">
505
  </div>
506
+ <div class="col-md-4">
507
+ <label class="form-label">High-pass (Hz)</label>
508
  <input class="form-control" type="number" id="highpass" name="highpass" value="0">
509
  </div>
510
+ <div class="col-md-4">
511
+ <label class="form-label">Trim silence</label>
512
+ <div class="form-check">
513
+ <input class="form-check-input" type="checkbox" id="trim_silence" name="trim_silence">
514
+ <label class="form-check-label" for="trim_silence">вкл</label>
515
+ </div>
516
+ </div>
517
  </div>
518
 
519
+ <div class="row g-2 mt-3">
520
+ <div class="col-md-3">
521
+ <label class="form-label">Fade in (ms)</label>
522
+ <input class="form-control" type="number" id="fade_in_ms" name="fade_in_ms" value="0">
523
+ </div>
524
+ <div class="col-md-3">
525
+ <label class="form-label">Fade out (ms)</label>
526
+ <input class="form-control" type="number" id="fade_out_ms" name="fade_out_ms" value="0">
527
+ </div>
528
+ <div class="col-md-3">
529
+ <label class="form-label">Reverse</label>
530
+ <div class="form-check">
531
+ <input class="form-check-input" type="checkbox" id="reverse" name="reverse">
532
+ <label class="form-check-label" for="reverse">вкл</label>
533
+ </div>
534
+ </div>
535
+ <div class="col-md-3">
536
+ <label class="form-label">Silence thresh (dB)</label>
537
+ <input class="form-control" type="number" id="sil_thresh" name="sil_thresh" value="-50">
538
+ </div>
539
+ </div>
540
+
541
+ <hr style="border-color:rgba(255,255,255,0.04)">
542
+
543
+ <div class="row g-2">
544
+ <div class="col-md-4">
545
+ <label class="form-label">Echo</label>
546
+ <div class="form-check">
547
+ <input class="form-check-input" type="checkbox" id="echo" name="echo">
548
+ <label class="form-check-label" for="echo">вкл</label>
549
+ </div>
550
+ <label class="form-label mt-2">Delay (ms)</label>
551
+ <input class="form-control" type="number" id="echo_delay_ms" name="echo_delay_ms" value="500">
552
+ <label class="form-label mt-2">Decay (0-1)</label>
553
+ <input class="form-control" type="number" step="0.05" id="echo_decay" name="echo_decay" value="0.5">
554
+ </div>
555
+
556
+ <div class="col-md-4">
557
+ <label class="form-label">Reverb</label>
558
+ <div class="form-check">
559
+ <input class="form-check-input" type="checkbox" id="reverb" name="reverb">
560
+ <label class="form-check-label" for="reverb">вкл</label>
561
+ </div>
562
+ <label class="form-label mt-2">Reverb level (0-100)</label>
563
+ <input class="form-control" type="number" id="reverb_level" name="reverb_level" value="40">
564
+ </div>
565
+
566
+ <div class="col-md-4">
567
+ <label class="form-label">Other</label>
568
+ <div class="form-check">
569
+ <input class="form-check-input" type="checkbox" id="enable_all" />
570
+ <label class="form-check-label" for="enable_all">Enable quick demo (echo+reverb+norm)</label>
571
+ </div>
572
+ </div>
573
  </div>
574
 
575
+ <div class="mt-4 d-flex gap-2">
576
  <button type="submit" id="submitBtn" class="btn btn-primary">Обработать</button>
577
  <button type="button" id="resetBtn" class="btn btn-outline-light">Сброс</button>
578
  </div>
 
596
  </div>
597
  </div>
598
 
599
+ <hr style="border-color:rgba(255,255,255,0.04)">
600
  <div class="small-muted">Максимальный размер файла: {{max_mb}} МБ. Временные файлы хранятся ~1 час.</div>
601
  </div>
602
 
603
  <footer class="text-center">
604
+ <small>Made with ♥ — pydub + ffmpeg. Если эффекты (echo/reverb/pitch) не работают — проверь установленность ffmpeg в контейнере.</small>
605
  </footer>
606
  </div>
607
  </div>
 
618
  const downloadLink = document.getElementById('downloadLink');
619
  const submitBtn = document.getElementById('submitBtn');
620
  const resetBtn = document.getElementById('resetBtn');
621
+ const enableAll = document.getElementById('enable_all');
622
 
623
  function log(msg) {
624
  const time = new Date().toLocaleTimeString();
 
628
  let pollInterval = null;
629
  let last_idx = 0;
630
 
631
+ enableAll.addEventListener('change', function(){
632
+ const on = enableAll.checked;
633
+ document.getElementById('echo').checked = on;
634
+ document.getElementById('reverb').checked = on;
635
+ document.getElementById('normalize').checked = on;
636
+ });
637
+
638
  uploadForm.addEventListener('submit', async function(e) {
639
  e.preventDefault();
640
  logEl.innerHTML = '';
 
664
  form.append('lowpass', document.getElementById('lowpass').value);
665
  form.append('highpass', document.getElementById('highpass').value);
666
  form.append('trim_silence', document.getElementById('trim_silence').checked ? '1' : '');
667
+ form.append('sil_thresh', document.getElementById('sil_thresh').value);
668
  form.append('out_format', document.getElementById('out_format').value);
669
+ form.append('gain_db', document.getElementById('gain_db').value);
670
+ form.append('fade_in_ms', document.getElementById('fade_in_ms').value);
671
+ form.append('fade_out_ms', document.getElementById('fade_out_ms').value);
672
+ form.append('reverse', document.getElementById('reverse').checked ? '1' : '');
673
+ form.append('pitch_semitones', document.getElementById('pitch_semitones').value);
674
+ form.append('echo', document.getElementById('echo').checked ? '1' : '');
675
+ form.append('echo_delay_ms', document.getElementById('echo_delay_ms').value);
676
+ form.append('echo_decay', document.getElementById('echo_decay').value);
677
+ form.append('reverb', document.getElementById('reverb').checked ? '1' : '');
678
+ form.append('reverb_level', document.getElementById('reverb_level').value);
679
 
680
  try {
681
  const resp = await fetch('/start', { method: 'POST', body: form });
 
714
  progressBar.style.width = '100%';
715
  progressBar.textContent = '100%';
716
  if (ev.msg) log(ev.msg);
 
717
  } else if (ev.type === 'error') {
718
  log('Ошибка: ' + (ev.msg || 'unknown'));
719
  alert('Ошибка обработки: ' + (ev.msg || 'неизвестно'));
 
721
  submitBtn.disabled = false;
722
  return;
723
  }
 
724
  if (ev.file) {
 
725
  const url = window.location.origin + '/download/' + ev.file;
726
  player.src = url;
727
  downloadLink.href = url;
 
750
  clearInterval(pollInterval);
751
  submitBtn.disabled = false;
752
  }
753
+ }, 800);
754
 
755
  } catch (err) {
756
  alert('Ошибка при отправке: ' + err);
 
774
  """
775
 
776
 
777
+ # ----------------------------
778
+ # Маршруты
779
+ # ----------------------------
780
  @app.route("/", methods=["GET"])
781
  def index():
782
  cleanup_old_files()
 
804
  "trim_silence": bool(request.form.get("trim_silence")),
805
  "sil_thresh": request.form.get("sil_thresh", "-50"),
806
  "out_format": request.form.get("out_format", "wav"),
807
+ "gain_db": request.form.get("gain_db", "0"),
808
+ "fade_in_ms": request.form.get("fade_in_ms", "0"),
809
+ "fade_out_ms": request.form.get("fade_out_ms", "0"),
810
+ "reverse": bool(request.form.get("reverse")),
811
+ "pitch_semitones": request.form.get("pitch_semitones", "0"),
812
+ "echo": bool(request.form.get("echo")),
813
+ "echo_delay_ms": request.form.get("echo_delay_ms", "500"),
814
+ "echo_decay": request.form.get("echo_decay", "0.5"),
815
+ "reverb": bool(request.form.get("reverb")),
816
+ "reverb_level": request.form.get("reverb_level", "40"),
817
  }
818
 
819
  # Save uploaded file to tmp
 
821
  fs.stream.seek(0)
822
  fs.save(temp_in)
823
 
824
+ # start background worker
825
  t = threading.Thread(target=processing_worker, args=(job_id, temp_in, fs.filename, options), daemon=True)
826
  t.start()
827
 
 
830
 
831
  @app.route("/status/<job_id>", methods=["GET"])
832
  def status(job_id):
 
 
 
 
 
833
  last_idx = int(request.args.get("last_idx", "0") or 0)
834
  with jobs_lock:
835
+ logs = job_logs.get(job_id, []).copy()
 
836
  events = logs[last_idx:]
837
  next_index = last_idx + len(events)
838
+ # add file_url based on local download route
839
  for ev in events:
840
  if ev.get("file") and not ev.get("file_url"):
 
841
  download_path = url_for("download_file", filename=ev["file"], _external=False)
842
  host = request.host_url.rstrip('/')
843
  ev["file_url"] = host + download_path
 
863
  return "ok", 200
864
 
865
 
866
+ # ----------------------------
867
+ # Запуск
868
+ # ----------------------------
869
  if __name__ == "__main__":
870
  port = int(os.environ.get("PORT", 7860))
871
  host = os.environ.get("HOST", "0.0.0.0")