Starchik commited on
Commit
792d8fe
·
verified ·
1 Parent(s): b428bf0

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +502 -240
main.py CHANGED
@@ -1,54 +1,61 @@
1
  #!/usr/bin/env python3
 
2
  """
3
- Flask single-file app for online audio processing.
4
-
5
- How to use (notes for deployment):
6
- - This file is self-contained Flask app. Save as `app.py`.
7
- - Requirements (install in your Docker image / environment):
8
- pip install flask pydub python-multipart
9
- Additionally you must have ffmpeg installed in the container (pydub uses ffmpeg).
10
- Example Debian Dockerfile snippet:
11
- RUN apt-get update && apt-get install -y ffmpeg
12
- RUN pip install -U pip
13
- RUN pip install flask pydub python-multipart
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 shutil
 
28
  from pathlib import Path
29
- from flask import Flask, request, send_file, redirect, url_for, render_template_string, abort, jsonify
 
 
 
30
  from pydub import AudioSegment, effects, silence
31
 
 
32
  # Configuration
 
33
  TMP_DIR = Path("./tmp")
34
  TMP_DIR.mkdir(exist_ok=True)
35
- MAX_UPLOAD_MB = 200 # maximum upload size
36
- ALLOWED_MIMES = {
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
- if now - p.stat().st_mtime > KEEP_FILES_SEC:
77
- p.unlink()
78
  except Exception:
79
  pass
80
 
81
 
82
- def detect_format_from_bytes(b: bytes) -> str:
83
- # pydub's AudioSegment.from_file requires a file-like object and format hint.
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
- # Try to guess format from filename
96
- filename = file_storage.filename or ""
97
  ext = ""
98
  if "." in filename:
99
  ext = filename.rsplit(".", 1)[1].lower()
100
- try:
101
- if ext:
 
 
102
  return AudioSegment.from_file(buf, format=ext)
103
- else:
104
- # Let ffmpeg autodetect
 
 
105
  buf.seek(0)
106
- return AudioSegment.from_file(buf)
107
- except Exception:
108
- # Last attempt: try common formats
109
- for fmt in ("mp3", "wav", "ogg", "flac", "m4a", "aac"):
110
- try:
111
- buf.seek(0)
112
- return AudioSegment.from_file(buf, format=fmt)
113
- except Exception:
114
- continue
115
- raise
 
 
 
 
 
 
116
 
117
 
118
- # ---------------------------
119
- # Audio processing actions
120
- # ---------------------------
 
121
 
122
- def apply_normalize(seg: AudioSegment) -> AudioSegment:
123
- return effects.normalize(seg)
124
 
 
 
 
 
125
 
126
- def apply_speed_change(seg: AudioSegment, speed: float) -> AudioSegment:
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 apply_lowpass(seg: AudioSegment, cutoff_hz: float) -> AudioSegment:
143
- if cutoff_hz and cutoff_hz > 0:
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
- def apply_trim_silence(seg: AudioSegment, silence_thresh_db=-50, chunk_len_ms=10) -> AudioSegment:
 
 
 
155
  """
156
- Trim silence from the start and end.
157
- silence_thresh_db: threshold in dB relative to max to consider silence (e.g., -50)
158
- chunk_len_ms: granularity for silence detection
159
  """
160
- non_silence_ranges = silence.detect_nonsilent(seg, min_silence_len=chunk_len_ms, silence_thresh=silence_thresh_db)
161
- if not non_silence_ranges:
162
- # Entire segment is silence => return empty short segment
163
- return seg[:0]
164
- start = non_silence_ranges[0][0]
165
- end = non_silence_ranges[-1][1]
166
- return seg[start:end]
 
 
 
 
 
 
 
 
 
167
 
 
 
 
 
 
 
 
 
 
168
 
169
- # ---------------------------
170
- # Flask routes
171
- # ---------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
 
 
 
 
173
  INDEX_HTML = """
174
  <!doctype html>
175
  <html lang="ru">
176
  <head>
177
  <meta charset="utf-8" />
178
- <title>Online Audio Processing</title>
179
  <meta name="viewport" content="width=device-width,initial-scale=1" />
 
180
  <style>
181
- body { font-family: Arial, Helvetica, sans-serif; margin: 32px; }
182
- label { display:block; margin-top:10px; }
183
- .controls { margin-top: 10px; }
184
- .result { margin-top: 20px; }
185
- input[type="number"] { width: 8rem; }
 
 
186
  </style>
187
  </head>
188
  <body>
189
- <h2>Онлайн обработка музыки</h2>
190
- <form id="uploadForm" method="post" action="/process" enctype="multipart/form-data">
191
- <label>Файл (mp3, wav, ogg, m4a, flac):
192
- <input required type="file" name="audio_file" accept="audio/*" />
193
- </label>
194
-
195
- <div class="controls">
196
- <label><input type="checkbox" name="normalize" /> Нормализовать (gain)</label>
197
- <label>
198
- Скорость (0.5 = вдвое медленнее, 1.0 = без изменений, 1.5 = быстрее):
199
- <input type="number" step="0.1" name="speed" value="1.0" />
200
- </label>
201
- <label>
202
- Low-pass cutoff (Hz, 0 отключить):
203
- <input type="number" name="lowpass" value="0" />
204
- </label>
205
- <label>
206
- High-pass cutoff (Hz, 0 отключить):
207
- <input type="number" name="highpass" value="0" />
208
- </label>
209
- <label><input type="checkbox" name="trim_silence" /> Обрезать тишину в начале/конце</label>
210
- <label>
211
- Silence threshold (dB, для обрезки, например -50):
212
- <input type="number" name="sil_thresh" value="-50" />
213
- </label>
214
-
215
- <label>
216
- Экспорт формат:
217
- <select name="out_format">
218
- <option value="mp3">MP3</option>
219
- <option value="wav" selected>WAV</option>
220
- <option value="flac">FLAC</option>
221
- <option value="ogg">OGG</option>
222
- <option value="m4a">M4A</option>
223
- </select>
224
- </label>
225
- </div>
226
-
227
- <div style="margin-top:12px;">
228
- <button type="submit">Обработать и скачать / прослушать</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- document.getElementById('uploadForm').addEventListener('submit', async function(ev){
240
- ev.preventDefault();
241
- const form = ev.target;
242
- const data = new FormData(form);
243
- const resp = await fetch('/process', { method: 'POST', body: data });
244
- if (!resp.ok) {
245
- const text = await resp.text();
246
- alert('Ошибка: ' + resp.status + '\\n' + text);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  return;
248
  }
249
- // server returns JSON with 'file_url' field
250
- const resjson = await resp.json();
251
- const url = resjson.file_url;
252
- const filename = resjson.filename || 'processed_audio';
253
- const area = document.getElementById('resultArea');
254
- area.style.display = 'block';
255
- const player = document.getElementById('player');
256
- player.src = url;
257
- const dl = document.getElementById('downloadLink');
258
- dl.href = url;
259
- dl.download = filename;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return render_template_string(INDEX_HTML)
 
272
 
273
 
274
- @app.route("/process", methods=["POST"])
275
- def process_audio():
276
  cleanup_old_files()
277
  if "audio_file" not in request.files:
278
  return "No file part 'audio_file'", 400
279
- file = request.files["audio_file"]
280
- if file.filename == "":
281
  return "No selected file", 400
282
 
283
- # Basic content-length check is already handled by MAX_CONTENT_LENGTH
284
- # Load audio
285
- try:
286
- seg = load_audio_from_file_storage(file)
287
- except Exception as e:
288
- return f"Не удалось загрузить аудио: {str(e)}", 400
289
-
290
- # Parse options
291
- try:
292
- normalize_flag = request.form.get("normalize") == "on" or request.form.get("normalize") == "true" or bool(request.form.get("normalize"))
293
- speed = float(request.form.get("speed", "1.0") or 1.0)
294
- lowpass = float(request.form.get("lowpass", "0") or 0)
295
- highpass = float(request.form.get("highpass", "0") or 0)
296
- trim_silence_flag = request.form.get("trim_silence") == "on" or request.form.get("trim_silence") == "true" or bool(request.form.get("trim_silence"))
297
- sil_thresh = int(request.form.get("sil_thresh", "-50") or -50)
298
- out_format = secure_extension_for_format(request.form.get("out_format", "wav"))
299
- except Exception as e:
300
- return f"Ошибка в параметрах: {e}", 400
301
-
302
- # Apply chain of processing (order: trim -> filters -> speed -> normalize)
303
- try:
304
- if trim_silence_flag:
305
- seg = apply_trim_silence(seg, silence_thresh_db=sil_thresh)
306
-
307
- if lowpass and lowpass > 0:
308
- seg = apply_lowpass(seg, lowpass)
309
-
310
- if highpass and highpass > 0:
311
- seg = apply_highpass(seg, highpass)
312
-
313
- if abs(speed - 1.0) > 1e-6:
314
- seg = apply_speed_change(seg, speed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
 
316
- if normalize_flag:
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
- # Build file URL for client to download/stream
334
- file_url = url_for("download_file", filename=out_path.name, _external=True)
335
- response_payload = {"file_url": file_url, "filename": out_path.name}
336
- return jsonify(response_payload)
 
 
 
337
 
338
 
339
- @app.route("/download/<filename>", methods=["GET"])
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
- # Small healthcheck
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)