AliJabbarpour commited on
Commit
c4dc0b7
·
verified ·
1 Parent(s): b80e2d2

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +463 -0
app.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import uuid
4
+ import asyncio
5
+ import tempfile
6
+ import shutil
7
+ import threading
8
+ import logging
9
+ import torch
10
+ import torchaudio
11
+ from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
12
+
13
+
14
+ class HealthAndStatusFilter(logging.Filter):
15
+ """جلوی چاپ لاگ‌های /status/ و /health رو می‌گیره تا کنسول شلوغ نشه (خودِ درخواست‌ها عادی پردازش میشن)"""
16
+ def filter(self, record):
17
+ message = record.getMessage()
18
+ return "/status/" not in message and "/health" not in message
19
+
20
+
21
+ logging.getLogger("uvicorn.access").addFilter(HealthAndStatusFilter())
22
+ from fastapi.middleware.cors import CORSMiddleware
23
+ from faster_whisper import WhisperModel
24
+ from df.enhance import enhance, init_df, load_audio, save_audio
25
+ from pyannote.audio import Pipeline as DiarizationPipeline
26
+ from pipeline.postprocess import process
27
+
28
+ torch.set_num_threads(2) # جلوی رقابت بی‌فایده‌ی رشته‌ها روی CPU محدود رو می‌گیره
29
+
30
+ app = FastAPI()
31
+
32
+ app.add_middleware(
33
+ CORSMiddleware,
34
+ allow_origins=["*"],
35
+ allow_methods=["*"],
36
+ allow_headers=["*"],
37
+ )
38
+
39
+ print("Loading Whisper model...", flush=True)
40
+ model = WhisperModel("large-v3", device="cpu", compute_type="int8")
41
+ print("Whisper model ready!")
42
+
43
+ print("Loading DeepFilterNet model...", flush=True)
44
+ df_model, df_state, _ = init_df()
45
+ print("DeepFilterNet model ready!")
46
+
47
+ USE_DEEPFILTERNET = False # غیرفعال - شواهد نشون داد کمکی نمی‌کنه و بعضی صداها رو خراب می‌کنه
48
+
49
+ print("Loading word-alignment model...", flush=True)
50
+ align_bundle = torchaudio.pipelines.MMS_FA
51
+ align_model = align_bundle.get_model().to("cpu")
52
+ align_labels = align_bundle.get_labels()
53
+ align_dictionary = {c: i for i, c in enumerate(align_labels)}
54
+ print("Word-alignment model ready!")
55
+
56
+ print("Loading speaker-diarization model...", flush=True)
57
+ HF_TOKEN = os.environ.get("HF_TOKEN")
58
+ diarize_pipeline = DiarizationPipeline.from_pretrained(
59
+ "pyannote/speaker-diarization-3.1", use_auth_token=HF_TOKEN
60
+ )
61
+ print("Speaker-diarization model ready!")
62
+
63
+ MIN_NOISE_BLOCK_SEC = 1.0
64
+ JOB_TTL_SECONDS = 30 * 60 # کارهای قدیمی بعد از ۳۰ دقیقه پاک میشن
65
+ STAGE_TIMEOUT_SEC = 240 # سقف زمانی برای هر مرحله‌ی سنگین
66
+
67
+ # ─── نگهداری وضعیت کارها (Jobs) توی حافظه ───────────────────────────────────
68
+ jobs = {}
69
+ jobs_lock = threading.Lock()
70
+
71
+
72
+ def new_job():
73
+ job_id = uuid.uuid4().hex
74
+ tmp_dir = tempfile.mkdtemp(prefix=f"subflow_{job_id}_")
75
+ with jobs_lock:
76
+ jobs[job_id] = {
77
+ "status": "uploading",
78
+ "detail": "در انتظار دریافت فایل...",
79
+ "created_at": time.time(),
80
+ "tmp_dir": tmp_dir,
81
+ "chunk_paths": [],
82
+ "srt": None,
83
+ "language": None,
84
+ "duration": None,
85
+ "error": None,
86
+ }
87
+ return job_id
88
+
89
+
90
+ def get_job(job_id):
91
+ with jobs_lock:
92
+ job = jobs.get(job_id)
93
+ if job is None:
94
+ raise HTTPException(status_code=404, detail="این کار پیدا نشد یا منقضی شده")
95
+ return job
96
+
97
+
98
+ def update_job(job_id, **kwargs):
99
+ with jobs_lock:
100
+ if job_id in jobs:
101
+ jobs[job_id].update(kwargs)
102
+
103
+
104
+ def cleanup_expired_jobs():
105
+ now = time.time()
106
+ with jobs_lock:
107
+ expired = [jid for jid, j in jobs.items() if now - j["created_at"] > JOB_TTL_SECONDS]
108
+ for jid in expired:
109
+ with jobs_lock:
110
+ job = jobs.pop(jid, None)
111
+ if job and os.path.isdir(job["tmp_dir"]):
112
+ shutil.rmtree(job["tmp_dir"], ignore_errors=True)
113
+ print(f"[cleanup] removed expired job {jid}")
114
+
115
+
116
+ # ─── منطق تشخیص گوینده و هم‌ترازی (بدون تغییر نسبت به نسخه‌ی قبلی) ──────────
117
+
118
+ def build_speaker_lookup(diarization):
119
+ """
120
+ لیست بازه‌های گوینده‌ها رو یه‌بار مرتب می‌کنه تا جست‌وجوی «این لحظه مال کدوم
121
+ گوینده‌ست» برای هر جمله، به‌جای گشتن کامل لیست، سریع (با جست‌وجوی دودویی) انجام بشه.
122
+ """
123
+ turns = sorted(
124
+ [(turn.start, turn.end, speaker) for turn, _, speaker in diarization.itertracks(yield_label=True)],
125
+ key=lambda x: x[0],
126
+ )
127
+ return turns
128
+
129
+
130
+ def get_speaker_at(turns, time_sec):
131
+ import bisect
132
+ starts = [t[0] for t in turns]
133
+ idx = bisect.bisect_right(starts, time_sec) - 1
134
+ if idx >= 0:
135
+ start, end, speaker = turns[idx]
136
+ if start <= time_sec <= end:
137
+ return speaker
138
+ return None
139
+
140
+
141
+ def get_speaker_for_segment(turns, seg_start, seg_end):
142
+ """
143
+ به‌جای اعتماد به یه لحظه‌ی تنها، چند نقطه در طول جمله رو چک می‌کنه
144
+ و گوینده‌ای که بیشترین رأی رو آورده برمی‌گردونه.
145
+ """
146
+ duration = seg_end - seg_start
147
+ if duration <= 0:
148
+ return get_speaker_at(turns, seg_start)
149
+
150
+ sample_points = [seg_start + duration * frac for frac in (0.2, 0.4, 0.5, 0.6, 0.8)]
151
+ votes = {}
152
+ for t in sample_points:
153
+ speaker = get_speaker_at(turns, t)
154
+ if speaker is not None:
155
+ votes[speaker] = votes.get(speaker, 0) + 1
156
+
157
+ if not votes:
158
+ return None
159
+ return max(votes, key=votes.get)
160
+
161
+
162
+ def find_leading_noise_cutoff(turns, whisper_start, search_back_sec=4.0):
163
+ STABLE_SPEAKER_SEC = 0.2
164
+ window_start = max(0, whisper_start - search_back_sec)
165
+ step = 0.05
166
+ t = whisper_start
167
+ noise_end = None
168
+ noise_start = None
169
+ speaker_run_start = None
170
+
171
+ while t > window_start:
172
+ speaker = get_speaker_at(turns, t)
173
+
174
+ if speaker is None:
175
+ speaker_run_start = None
176
+ if noise_end is None:
177
+ noise_end = t
178
+ noise_start = t
179
+ else:
180
+ if speaker_run_start is None:
181
+ speaker_run_start = t
182
+ run_len = speaker_run_start - t
183
+ if run_len >= STABLE_SPEAKER_SEC:
184
+ break
185
+ if noise_end is None:
186
+ noise_end = t
187
+ noise_start = t
188
+
189
+ t -= step
190
+
191
+ if noise_start is not None and noise_end is not None:
192
+ block_len = noise_end - noise_start
193
+ if block_len >= MIN_NOISE_BLOCK_SEC:
194
+ return noise_end
195
+ return None
196
+
197
+
198
+ def realign_word_timestamps(waveform_16k, words, whisper_start, whisper_end, cutoff_start=None):
199
+ try:
200
+ margin = 0.5
201
+ raw_start = whisper_start - margin
202
+ effective_start = max(raw_start, cutoff_start) if cutoff_start is not None else raw_start
203
+
204
+ start_sample = max(0, int(effective_start * 16000))
205
+ end_sample = int((whisper_end + margin) * 16000)
206
+ clip = waveform_16k[:, start_sample:end_sample]
207
+
208
+ clean_words = []
209
+ for w in words:
210
+ token = "".join(ch for ch in w["word"].lower() if ch in align_dictionary)
211
+ if token:
212
+ clean_words.append((w, token))
213
+
214
+ if not clean_words:
215
+ return words
216
+
217
+ transcript = "".join(tok for _, tok in clean_words)
218
+ tokens = [align_dictionary[c] for c in transcript]
219
+
220
+ with torch.inference_mode():
221
+ emission, _ = align_model(clip)
222
+
223
+ targets = torch.tensor([tokens], dtype=torch.int32)
224
+ input_lengths = torch.tensor([emission.shape[1]])
225
+ target_lengths = torch.tensor([len(tokens)])
226
+
227
+ aligned_tokens, scores = torchaudio.functional.forced_align(
228
+ emission, targets, input_lengths, target_lengths, blank=0
229
+ )
230
+ spans = torchaudio.functional.merge_tokens(aligned_tokens[0], scores[0])
231
+
232
+ ratio = clip.shape[1] / emission.shape[1] / 16000
233
+ new_words = []
234
+ span_idx = 0
235
+ for w, tok in clean_words:
236
+ n_chars = len(tok)
237
+ matched = [s for s in spans[span_idx:] if s.token != 0][:n_chars]
238
+ if len(matched) < n_chars:
239
+ new_words.append(w)
240
+ continue
241
+ w_start = matched[0].start * ratio + effective_start
242
+ w_end = matched[-1].end * ratio + effective_start
243
+ new_words.append({"word": w["word"], "start": round(max(0, w_start), 3), "end": round(max(0, w_end), 3)})
244
+ span_idx += n_chars
245
+
246
+ return new_words if len(new_words) == len(words) else words
247
+ except Exception as e:
248
+ print(f" [align] skipped due to: {type(e).__name__}: {e}")
249
+ return words
250
+
251
+
252
+ def run_transcription(audio_path):
253
+ return model.transcribe(
254
+ audio_path,
255
+ language="en",
256
+ word_timestamps=True,
257
+ vad_filter=True,
258
+ vad_parameters=dict(
259
+ min_silence_duration_ms=300,
260
+ speech_pad_ms=200,
261
+ threshold=0.3,
262
+ ),
263
+ )
264
+
265
+
266
+ def run_diarization(audio_path):
267
+ return diarize_pipeline(audio_path, max_speakers=8)
268
+
269
+
270
+ def run_alignment_pass(segments_raw, align_waveform, diarization):
271
+ turns = build_speaker_lookup(diarization)
272
+ segments = []
273
+ for seg in segments_raw:
274
+ words = list(seg.words)
275
+ if not words:
276
+ continue
277
+ word_dicts = [{"word": w.word, "start": w.start, "end": w.end} for w in words]
278
+
279
+ cutoff = find_leading_noise_cutoff(turns, word_dicts[0]["start"])
280
+ if cutoff is not None:
281
+ print(f" [noise-trim] '{seg.text.strip()[:40]}...' cutoff moved from {word_dicts[0]['start']:.2f}s to {cutoff:.2f}s")
282
+
283
+ realigned = realign_word_timestamps(
284
+ align_waveform, word_dicts, word_dicts[0]["start"], word_dicts[-1]["end"], cutoff_start=cutoff
285
+ )
286
+
287
+ speaker = get_speaker_for_segment(turns, realigned[0]["start"], realigned[-1]["end"])
288
+
289
+ segments.append({
290
+ "text": seg.text.strip(),
291
+ "start": realigned[0]["start"],
292
+ "end": realigned[-1]["end"],
293
+ "words": realigned,
294
+ "speaker": speaker,
295
+ })
296
+ return segments
297
+
298
+
299
+ class ProcessingTimeout(Exception):
300
+ pass
301
+
302
+
303
+ def run_blocking_with_timeout(job_id, blocking_fn, *args, status, detail, max_seconds=STAGE_TIMEOUT_SEC):
304
+ """
305
+ نسخه‌ی هم‌زمان (نه async) - چون این خودش داخل یه thread پس‌زمینه اجرا میشه،
306
+ نیازی به هماهنگی با event loop نداره. وضعیت Job رو مستقیم آپدیت می‌کنه
307
+ تا polling بتونه ببینتش.
308
+ """
309
+ result_holder = {}
310
+ error_holder = {}
311
+
312
+ def runner():
313
+ try:
314
+ result_holder["value"] = blocking_fn(*args)
315
+ except Exception as e:
316
+ error_holder["error"] = e
317
+
318
+ update_job(job_id, status=status, detail=detail)
319
+ t = threading.Thread(target=runner, daemon=True)
320
+ t.start()
321
+
322
+ elapsed = 0
323
+ while t.is_alive():
324
+ t.join(timeout=3)
325
+ if t.is_alive():
326
+ elapsed += 3
327
+ if elapsed >= max_seconds:
328
+ raise ProcessingTimeout(f"مرحله‌ی «{detail}» بیشتر از {max_seconds} ثانیه طول کشید و متوقف شد.")
329
+ update_job(job_id, status=status, detail=f"{detail} ({elapsed} ثانیه)")
330
+
331
+ if "error" in error_holder:
332
+ raise error_holder["error"]
333
+ return result_holder.get("value")
334
+
335
+
336
+ def process_job(job_id):
337
+ """کل پایپلاین پردازش - این تابع توی یه thread پس‌زمینه اجرا میشه، نه توی درخواست HTTP."""
338
+ job = get_job(job_id)
339
+ tmp_dir = job["tmp_dir"]
340
+ tmp_path = os.path.join(tmp_dir, "input.wav")
341
+ enhanced_path = None
342
+
343
+ try:
344
+ with open(tmp_path, "wb") as f:
345
+ for chunk_path in job["chunk_paths"]:
346
+ with open(chunk_path, "rb") as c:
347
+ f.write(c.read())
348
+ print(f"Job {job_id}: assembled {len(job['chunk_paths'])} chunks into {tmp_path}")
349
+
350
+ audio_to_transcribe = tmp_path
351
+
352
+ if USE_DEEPFILTERNET:
353
+ update_job(job_id, status="cleaning", detail="در حال حذف نویز پس‌زمینه")
354
+ audio_data, _ = load_audio(tmp_path, sr=df_state.sr())
355
+ enhanced_audio = enhance(df_model, df_state, audio_data, atten_lim_db=20)
356
+ enhanced_path = os.path.join(tmp_dir, "enhanced.wav")
357
+ save_audio(enhanced_path, enhanced_audio, df_state.sr())
358
+ audio_to_transcribe = enhanced_path
359
+
360
+ segments_raw, info = run_blocking_with_timeout(
361
+ job_id, run_transcription, audio_to_transcribe,
362
+ status="transcribing", detail="مدل large-v3 در حال شنیدن صداست",
363
+ )
364
+ update_job(job_id, detail="تشخیص گفتار کامل شد")
365
+
366
+ align_waveform, sr = torchaudio.load(audio_to_transcribe)
367
+ if sr != 16000:
368
+ align_waveform = torchaudio.functional.resample(align_waveform, sr, 16000)
369
+ if align_waveform.shape[0] > 1:
370
+ align_waveform = align_waveform.mean(dim=0, keepdim=True)
371
+
372
+ diarization = run_blocking_with_timeout(
373
+ job_id, run_diarization, audio_to_transcribe,
374
+ status="aligning", detail="در حال تشخیص گوینده‌ها",
375
+ )
376
+ update_job(job_id, detail="گوینده‌ها شناسایی شدند")
377
+
378
+ segments_raw_list = list(segments_raw)
379
+ segments = run_blocking_with_timeout(
380
+ job_id, run_alignment_pass, segments_raw_list, align_waveform, diarization,
381
+ status="aligning", detail=f"در حال هماهنگ‌سازی زمان‌بندی {len(segments_raw_list)} جمله",
382
+ )
383
+ update_job(job_id, detail=f"{len(segments)} خط شناسایی شد")
384
+
385
+ print(f"Job {job_id}: got {len(segments)} segments")
386
+ for i, seg in enumerate(segments):
387
+ print(f" seg[{i}]: '{seg['text']}' | speaker={seg['speaker']} | dur={seg['end']-seg['start']:.2f}s")
388
+
389
+ update_job(job_id, status="processing", detail=f"در حال چیدمان {len(segments)} خط زیرنویس")
390
+ srt_content = process(segments)
391
+ update_job(job_id, detail="زیرنویس ساخته شد")
392
+
393
+ update_job(
394
+ job_id,
395
+ status="done",
396
+ detail="زیرنویس آماده شد",
397
+ srt=srt_content,
398
+ language=info.language,
399
+ duration=info.duration,
400
+ )
401
+ print(f"Job {job_id}: done!")
402
+
403
+ except ProcessingTimeout as e:
404
+ print(f"Job {job_id}: TIMEOUT: {e}")
405
+ update_job(job_id, status="error", error=str(e))
406
+ except Exception as e:
407
+ print(f"Job {job_id}: ERROR: {type(e).__name__}: {e}")
408
+ import traceback
409
+ traceback.print_exc()
410
+ update_job(job_id, status="error", error=str(e))
411
+ finally:
412
+ cleanup_expired_jobs()
413
+
414
+
415
+ @app.get("/health")
416
+ async def health():
417
+ return {"status": "ok", "message": "SubFlow backend is running"}
418
+
419
+
420
+ @app.post("/upload/start")
421
+ async def upload_start():
422
+ cleanup_expired_jobs()
423
+ job_id = new_job()
424
+ return {"job_id": job_id}
425
+
426
+
427
+ @app.post("/upload/chunk/{job_id}")
428
+ async def upload_chunk(job_id: str, request: Request):
429
+ job = get_job(job_id)
430
+ chunk_bytes = await request.body()
431
+ chunk_index = len(job["chunk_paths"])
432
+ chunk_path = os.path.join(job["tmp_dir"], f"chunk_{chunk_index:06d}.bin")
433
+ with open(chunk_path, "wb") as f:
434
+ f.write(chunk_bytes)
435
+ with jobs_lock:
436
+ jobs[job_id]["chunk_paths"].append(chunk_path)
437
+ return {"received": chunk_index}
438
+
439
+
440
+ @app.post("/upload/finish/{job_id}")
441
+ async def upload_finish(job_id: str, background_tasks: BackgroundTasks):
442
+ job = get_job(job_id)
443
+ total_bytes = sum(os.path.getsize(p) for p in job["chunk_paths"])
444
+ print(f"Job {job_id}: upload finished, {len(job['chunk_paths'])} chunks, {total_bytes} bytes total")
445
+ update_job(job_id, status="receiving", detail="فایل آماده‌ی پردازش شد")
446
+ background_tasks.add_task(process_job, job_id)
447
+ return {"status": "processing_started"}
448
+
449
+
450
+ @app.get("/status/{job_id}")
451
+ async def status(job_id: str):
452
+ job = get_job(job_id)
453
+ response = {
454
+ "status": job["status"],
455
+ "detail": job["detail"],
456
+ }
457
+ if job["status"] == "done":
458
+ response["srt"] = job["srt"]
459
+ response["language"] = job["language"]
460
+ response["duration"] = job["duration"]
461
+ elif job["status"] == "error":
462
+ response["message"] = job["error"]
463
+ return response