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

Delete app.py

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