AliJabbarpour commited on
Commit
07bf71d
·
verified ·
1 Parent(s): bcf2b95

Upload app.py

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