Update app.py
Browse files
app.py
CHANGED
|
@@ -9,6 +9,11 @@ import logging
|
|
| 9 |
import torch
|
| 10 |
import torchaudio
|
| 11 |
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
class HealthAndStatusFilter(logging.Filter):
|
|
@@ -19,11 +24,6 @@ class HealthAndStatusFilter(logging.Filter):
|
|
| 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 |
|
|
@@ -62,14 +62,14 @@ print("Speaker-diarization model ready!")
|
|
| 62 |
|
| 63 |
MIN_NOISE_BLOCK_SEC = 1.0
|
| 64 |
JOB_TTL_SECONDS = 3 * 60 * 60 # کارهای قدیمی بعد از ۳ ساعت پاک میشن (برای فایلهای سنگین که ممکنه پردازششون طولانی بشه)
|
| 65 |
-
STAGE_TIMEOUT_SEC = 1200 # سقف زمانی برای هر مرحلهی سنگین (۲۰ دقیقه - فقط محافظ آخر در برابر گیرکردن واقعی
|
| 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:
|
|
@@ -83,6 +83,7 @@ def new_job():
|
|
| 83 |
"language": None,
|
| 84 |
"duration": None,
|
| 85 |
"error": None,
|
|
|
|
| 86 |
}
|
| 87 |
return job_id
|
| 88 |
|
|
@@ -113,7 +114,7 @@ def cleanup_expired_jobs():
|
|
| 113 |
print(f"[cleanup] removed expired job {jid}")
|
| 114 |
|
| 115 |
|
| 116 |
-
# ─── منطق تشخیص گوینده و همترازی
|
| 117 |
|
| 118 |
def build_speaker_lookup(diarization):
|
| 119 |
"""
|
|
@@ -248,6 +249,7 @@ def realign_word_timestamps(waveform_16k, words, whisper_start, whisper_end, cut
|
|
| 248 |
print(f" [align] skipped due to: {type(e).__name__}: {e}")
|
| 249 |
return words
|
| 250 |
|
|
|
|
| 251 |
def run_transcription(audio_path):
|
| 252 |
"""
|
| 253 |
نکتهی مهم: model.transcribe فوراً برنمیگرده - یه نتیجهی "تنبل" میده که کار
|
|
@@ -269,52 +271,11 @@ def run_transcription(audio_path):
|
|
| 269 |
return segments_list, info
|
| 270 |
|
| 271 |
|
| 272 |
-
def probe_speaker_count(audio_path, job_id=None):
|
| 273 |
-
"""
|
| 274 |
-
قبل از اجرای تشخیص گویندهی کامل (که سنگین و کنده)، یه آزمایش سریع انجام میده:
|
| 275 |
-
از ۳ نقطهی پخششده در طول فایل (نه فقط اول)، هرکدوم یه تیکهی ۲۰ ثانیهای برمیداره
|
| 276 |
-
و روی همون تیکههای کوتاه تشخیص گوینده اجرا میکنه.
|
| 277 |
-
اگه هر سه فقط یه گوینده نشون بدن، برمیگردونه True (یعنی «بهاحتمال زیاد تکگویندهست»)
|
| 278 |
-
وگرنه False (یعنی «باید تشخیص کامل روی کل فایل اجرا بشه»).
|
| 279 |
-
"""
|
| 280 |
-
info = torchaudio.info(audio_path)
|
| 281 |
-
duration = info.num_frames / info.sample_rate
|
| 282 |
-
probe_len = 20.0
|
| 283 |
-
|
| 284 |
-
if duration <= probe_len * 3:
|
| 285 |
-
# فایل خیلی کوتاهه، آزمایش جداگانه فایدهای نداره - مستقیم برو سراغ تشخیص کامل
|
| 286 |
-
return False
|
| 287 |
-
|
| 288 |
-
fractions = (0.1, 0.5, 0.85)
|
| 289 |
-
waveform, sr = torchaudio.load(audio_path)
|
| 290 |
-
|
| 291 |
-
for frac in fractions:
|
| 292 |
-
center = duration * frac
|
| 293 |
-
start_sec = max(0, center - probe_len / 2)
|
| 294 |
-
end_sec = min(duration, start_sec + probe_len)
|
| 295 |
-
start_sample = int(start_sec * sr)
|
| 296 |
-
end_sample = int(end_sec * sr)
|
| 297 |
-
clip = waveform[:, start_sample:end_sample]
|
| 298 |
-
|
| 299 |
-
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
| 300 |
-
torchaudio.save(tmp.name, clip, sr)
|
| 301 |
-
clip_path = tmp.name
|
| 302 |
-
|
| 303 |
-
try:
|
| 304 |
-
probe_result = diarize_pipeline(clip_path, max_speakers=8)
|
| 305 |
-
speakers_found = {speaker for _, _, speaker in probe_result.itertracks(yield_label=True)}
|
| 306 |
-
print(f" [speaker-probe] window at {frac:.0%} of file: {len(speakers_found)} speaker(s)")
|
| 307 |
-
if len(speakers_found) > 1:
|
| 308 |
-
return False
|
| 309 |
-
finally:
|
| 310 |
-
os.remove(clip_path)
|
| 311 |
-
|
| 312 |
-
return True
|
| 313 |
-
|
| 314 |
class SingleSpeakerResult:
|
| 315 |
"""
|
| 316 |
یه نتیجهی «قلابی» که همون شکل خروجی diarize_pipeline رو تقلید میکنه (متد itertracks داره)
|
| 317 |
ولی هیچ پردازش واقعیای روی کل فایل انجام نداده - فقط میگه «همهجا همون یه گویندهست».
|
|
|
|
| 318 |
"""
|
| 319 |
def __init__(self, duration):
|
| 320 |
self.duration = duration
|
|
@@ -327,18 +288,13 @@ class SingleSpeakerResult:
|
|
| 327 |
yield FakeTurn(0, self.duration), None, "SPEAKER_00"
|
| 328 |
|
| 329 |
|
| 330 |
-
def run_diarization(audio_path, job_id=None):
|
| 331 |
-
if
|
| 332 |
-
|
| 333 |
-
single_speaker = probe_speaker_count(audio_path, job_id)
|
| 334 |
-
|
| 335 |
-
if single_speaker:
|
| 336 |
-
print(" [speaker-probe] looks single-speaker across all 3 windows — skipping full diarization entirely")
|
| 337 |
info = torchaudio.info(audio_path)
|
| 338 |
duration = info.num_frames / info.sample_rate
|
| 339 |
return SingleSpeakerResult(duration)
|
| 340 |
|
| 341 |
-
print(" [speaker-probe] multiple speakers detected — running full search")
|
| 342 |
return diarize_pipeline(audio_path, max_speakers=8)
|
| 343 |
|
| 344 |
|
|
@@ -377,9 +333,10 @@ class ProcessingTimeout(Exception):
|
|
| 377 |
|
| 378 |
def run_blocking_with_timeout(job_id, blocking_fn, *args, status, detail, max_seconds=STAGE_TIMEOUT_SEC):
|
| 379 |
"""
|
| 380 |
-
|
| 381 |
-
ن
|
| 382 |
-
تا
|
|
|
|
| 383 |
"""
|
| 384 |
result_holder = {}
|
| 385 |
error_holder = {}
|
|
@@ -412,6 +369,7 @@ def process_job(job_id):
|
|
| 412 |
"""کل پایپلاین پردازش - این تابع توی یه thread پسزمینه اجرا میشه، نه توی درخواست HTTP."""
|
| 413 |
job = get_job(job_id)
|
| 414 |
tmp_dir = job["tmp_dir"]
|
|
|
|
| 415 |
tmp_path = os.path.join(tmp_dir, "input.wav")
|
| 416 |
enhanced_path = None
|
| 417 |
|
|
@@ -431,23 +389,27 @@ def process_job(job_id):
|
|
| 431 |
enhanced_path = os.path.join(tmp_dir, "enhanced.wav")
|
| 432 |
save_audio(enhanced_path, enhanced_audio, df_state.sr())
|
| 433 |
audio_to_transcribe = enhanced_path
|
| 434 |
-
|
| 435 |
segments_raw_list, info = run_blocking_with_timeout(
|
| 436 |
job_id, run_transcription, audio_to_transcribe,
|
| 437 |
status="transcribing", detail="مدل large-v3 در حال شنیدن صداست",
|
| 438 |
)
|
| 439 |
update_job(job_id, detail=f"{len(segments_raw_list)} جمله تشخیص داده شد")
|
| 440 |
-
|
| 441 |
align_waveform, sr = torchaudio.load(audio_to_transcribe)
|
| 442 |
if sr != 16000:
|
| 443 |
align_waveform = torchaudio.functional.resample(align_waveform, sr, 16000)
|
| 444 |
if align_waveform.shape[0] > 1:
|
| 445 |
align_waveform = align_waveform.mean(dim=0, keepdim=True)
|
| 446 |
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
status="aligning", detail="در حال
|
| 450 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
update_job(job_id, detail="گویندهها شناسایی شدند")
|
| 452 |
|
| 453 |
segments = run_blocking_with_timeout(
|
|
@@ -492,9 +454,15 @@ async def health():
|
|
| 492 |
|
| 493 |
|
| 494 |
@app.post("/upload/start")
|
| 495 |
-
async def upload_start():
|
| 496 |
cleanup_expired_jobs()
|
| 497 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
return {"job_id": job_id}
|
| 499 |
|
| 500 |
|
|
|
|
| 9 |
import torch
|
| 10 |
import torchaudio
|
| 11 |
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
| 12 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
+
from faster_whisper import WhisperModel
|
| 14 |
+
from df.enhance import enhance, init_df, load_audio, save_audio
|
| 15 |
+
from pyannote.audio import Pipeline as DiarizationPipeline
|
| 16 |
+
from pipeline.postprocess import process
|
| 17 |
|
| 18 |
|
| 19 |
class HealthAndStatusFilter(logging.Filter):
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
logging.getLogger("uvicorn.access").addFilter(HealthAndStatusFilter())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
torch.set_num_threads(2) # جلوی رقابت بیفایدهی رشتهها روی CPU محدود رو میگیره
|
| 29 |
|
|
|
|
| 62 |
|
| 63 |
MIN_NOISE_BLOCK_SEC = 1.0
|
| 64 |
JOB_TTL_SECONDS = 3 * 60 * 60 # کارهای قدیمی بعد از ۳ ساعت پاک میشن (برای فایلهای سنگین که ممکنه پردازششون طولانی بشه)
|
| 65 |
+
STAGE_TIMEOUT_SEC = 1200 # سقف زمانی برای هر مرحلهی سنگین (۲۰ دقیقه - فقط محافظ آخر در برابر گیرکردن واقعی)
|
| 66 |
|
| 67 |
# ─── نگهداری وضعیت کارها (Jobs) توی حافظه ───────────────────────────────────
|
| 68 |
jobs = {}
|
| 69 |
jobs_lock = threading.Lock()
|
| 70 |
|
| 71 |
|
| 72 |
+
def new_job(single_speaker_mode=False):
|
| 73 |
job_id = uuid.uuid4().hex
|
| 74 |
tmp_dir = tempfile.mkdtemp(prefix=f"subflow_{job_id}_")
|
| 75 |
with jobs_lock:
|
|
|
|
| 83 |
"language": None,
|
| 84 |
"duration": None,
|
| 85 |
"error": None,
|
| 86 |
+
"single_speaker_mode": single_speaker_mode,
|
| 87 |
}
|
| 88 |
return job_id
|
| 89 |
|
|
|
|
| 114 |
print(f"[cleanup] removed expired job {jid}")
|
| 115 |
|
| 116 |
|
| 117 |
+
# ─── منطق تشخیص گوینده و همترازی ────────────────────────────────────────────
|
| 118 |
|
| 119 |
def build_speaker_lookup(diarization):
|
| 120 |
"""
|
|
|
|
| 249 |
print(f" [align] skipped due to: {type(e).__name__}: {e}")
|
| 250 |
return words
|
| 251 |
|
| 252 |
+
|
| 253 |
def run_transcription(audio_path):
|
| 254 |
"""
|
| 255 |
نکتهی مهم: model.transcribe فوراً برنمیگرده - یه نتیجهی "تنبل" میده که کار
|
|
|
|
| 271 |
return segments_list, info
|
| 272 |
|
| 273 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
class SingleSpeakerResult:
|
| 275 |
"""
|
| 276 |
یه نتیجهی «قلابی» که همون شکل خروجی diarize_pipeline رو تقلید میکنه (متد itertracks داره)
|
| 277 |
ولی هیچ پردازش واقعیای روی کل فایل انجام نداده - فقط میگه «همهجا همون یه گویندهست».
|
| 278 |
+
فقط وقتی استفاده میشه که خودِ کاربر صراحتاً بگه فایل تکگویندهست.
|
| 279 |
"""
|
| 280 |
def __init__(self, duration):
|
| 281 |
self.duration = duration
|
|
|
|
| 288 |
yield FakeTurn(0, self.duration), None, "SPEAKER_00"
|
| 289 |
|
| 290 |
|
| 291 |
+
def run_diarization(audio_path, single_speaker_mode=False, job_id=None):
|
| 292 |
+
if single_speaker_mode:
|
| 293 |
+
print(" [diarization] single-speaker mode selected by user — skipping full diarization entirely")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
info = torchaudio.info(audio_path)
|
| 295 |
duration = info.num_frames / info.sample_rate
|
| 296 |
return SingleSpeakerResult(duration)
|
| 297 |
|
|
|
|
| 298 |
return diarize_pipeline(audio_path, max_speakers=8)
|
| 299 |
|
| 300 |
|
|
|
|
| 333 |
|
| 334 |
def run_blocking_with_timeout(job_id, blocking_fn, *args, status, detail, max_seconds=STAGE_TIMEOUT_SEC):
|
| 335 |
"""
|
| 336 |
+
یه تابع سنگین و بلاککننده رو توی یه thread جدا اجرا میکنه، و تا وقتی تموم بشه،
|
| 337 |
+
هر چند ثانیه یه پیام "هنوز کار میکنم" روی همون status میفرسته - این هم جلوی
|
| 338 |
+
قطعشدن اتصال بهخاطر سکوت طولانی رو میگیره، هم UI رو زنده نگه میداره.
|
| 339 |
+
اگه از max_seconds بیشتر طول بکشه، بهجای قفل ابدی، خطای مشخص میده.
|
| 340 |
"""
|
| 341 |
result_holder = {}
|
| 342 |
error_holder = {}
|
|
|
|
| 369 |
"""کل پایپلاین پردازش - این تابع توی یه thread پسزمینه اجرا میشه، نه توی درخواست HTTP."""
|
| 370 |
job = get_job(job_id)
|
| 371 |
tmp_dir = job["tmp_dir"]
|
| 372 |
+
single_speaker_mode = job.get("single_speaker_mode", False)
|
| 373 |
tmp_path = os.path.join(tmp_dir, "input.wav")
|
| 374 |
enhanced_path = None
|
| 375 |
|
|
|
|
| 389 |
enhanced_path = os.path.join(tmp_dir, "enhanced.wav")
|
| 390 |
save_audio(enhanced_path, enhanced_audio, df_state.sr())
|
| 391 |
audio_to_transcribe = enhanced_path
|
| 392 |
+
|
| 393 |
segments_raw_list, info = run_blocking_with_timeout(
|
| 394 |
job_id, run_transcription, audio_to_transcribe,
|
| 395 |
status="transcribing", detail="مدل large-v3 در حال شنیدن صداست",
|
| 396 |
)
|
| 397 |
update_job(job_id, detail=f"{len(segments_raw_list)} جمله تشخیص داده شد")
|
| 398 |
+
|
| 399 |
align_waveform, sr = torchaudio.load(audio_to_transcribe)
|
| 400 |
if sr != 16000:
|
| 401 |
align_waveform = torchaudio.functional.resample(align_waveform, sr, 16000)
|
| 402 |
if align_waveform.shape[0] > 1:
|
| 403 |
align_waveform = align_waveform.mean(dim=0, keepdim=True)
|
| 404 |
|
| 405 |
+
if single_speaker_mode:
|
| 406 |
+
diarization = run_diarization(audio_to_transcribe, single_speaker_mode=True)
|
| 407 |
+
update_job(job_id, status="aligning", detail="تشخیص گوینده رد شد (حالت تکگوینده)")
|
| 408 |
+
else:
|
| 409 |
+
diarization = run_blocking_with_timeout(
|
| 410 |
+
job_id, run_diarization, audio_to_transcribe, False, job_id,
|
| 411 |
+
status="aligning", detail="در حال تشخیص گویندهها",
|
| 412 |
+
)
|
| 413 |
update_job(job_id, detail="گویندهها شناسایی شدند")
|
| 414 |
|
| 415 |
segments = run_blocking_with_timeout(
|
|
|
|
| 454 |
|
| 455 |
|
| 456 |
@app.post("/upload/start")
|
| 457 |
+
async def upload_start(request: Request):
|
| 458 |
cleanup_expired_jobs()
|
| 459 |
+
body = {}
|
| 460 |
+
try:
|
| 461 |
+
body = await request.json()
|
| 462 |
+
except Exception:
|
| 463 |
+
pass
|
| 464 |
+
single_speaker_mode = bool(body.get("single_speaker_mode", False))
|
| 465 |
+
job_id = new_job(single_speaker_mode)
|
| 466 |
return {"job_id": job_id}
|
| 467 |
|
| 468 |
|