gadearjun241 commited on
Commit
f37003f
·
1 Parent(s): cd28200

optmised main and step 1 2 and 4

Browse files
main.py CHANGED
@@ -1,3 +1,953 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # uvicorn main:app --host 0.0.0.0 --port 8000
2
  """
3
  FastAPI service for the 5-step subtitle pipeline.
@@ -50,16 +1000,18 @@ a queue/worker system as preferable for heavy background computation.
50
  from __future__ import annotations
51
 
52
  import asyncio
 
53
  import json
54
  import logging
55
  import os
56
  import re
57
  import shutil
 
58
  import tempfile
59
  import threading
60
  import time
61
  from concurrent.futures import ThreadPoolExecutor
62
- from datetime import datetime, timezone
63
  from email.message import Message
64
  from pathlib import Path
65
  from typing import Any
@@ -68,6 +1020,7 @@ from urllib.parse import urlparse, unquote
68
  import httpx
69
  from fastapi import FastAPI, HTTPException, status
70
  from fastapi.responses import FileResponse
 
71
  from pydantic import BaseModel, Field, HttpUrl
72
 
73
  from src.pipeline.step_001_separate import run_audio_separation
@@ -116,6 +1069,171 @@ MAX_STATUS_LOG_LINES = int(
116
  )
117
  )
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  # Only one whole pipeline job executes at a time in this process.
120
  #
121
  # This is deliberate. Step 2/4 are heavy CPU model workloads, and concurrent
@@ -201,6 +1319,14 @@ app = FastAPI(
201
  ),
202
  )
203
 
 
 
 
 
 
 
 
 
204
 
205
  # ============================================================================
206
  # GLOBAL JOB EXECUTOR
@@ -239,6 +1365,18 @@ class CreateProjectRequest(BaseModel):
239
  description="Public/signed HTTP(S) URL of the source media file.",
240
  )
241
 
 
 
 
 
 
 
 
 
 
 
 
 
242
 
243
  class ResumeProjectResponse(BaseModel):
244
  project_id: str
@@ -251,6 +1389,79 @@ class ResumeProjectResponse(BaseModel):
251
  # LOGGING / PERSISTENT STATE
252
  # ============================================================================
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  class ProjectLogger:
255
  """
256
  File-backed project logger.
@@ -300,16 +1511,31 @@ class ProjectLogger:
300
  self.logger.propagate = False
301
 
302
  if not self.logger.handlers:
303
- handler = logging.FileHandler(
304
  self.log_path,
305
  encoding="utf-8",
306
  )
307
- handler.setFormatter(
308
- logging.Formatter(
309
  "%(asctime)s | %(levelname)s | %(message)s",
 
310
  )
311
  )
312
- self.logger.addHandler(handler)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
  def write(
315
  self,
@@ -322,6 +1548,31 @@ class ProjectLogger:
322
  message,
323
  )
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  def recent_lines(
326
  self,
327
  limit: int = MAX_STATUS_LOG_LINES,
@@ -340,6 +1591,41 @@ class ProjectLogger:
340
 
341
  return lines[-limit:]
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
 
344
  # ============================================================================
345
  # PROJECT STATE
@@ -375,12 +1661,14 @@ def _default_step_state() -> dict[str, Any]:
375
  def _initial_state(
376
  project_id: str,
377
  source_url: str,
 
378
  ) -> dict[str, Any]:
379
  return {
380
  "schema_version": "1.0",
381
  "project_id": project_id,
382
  "source_url": source_url,
383
  "source_file": None,
 
384
  "status": "queued",
385
  "current_step": None,
386
  "current_step_name": None,
@@ -502,7 +1790,7 @@ def _record_event(
502
 
503
  logs.append(
504
  {
505
- "timestamp": utc_now(),
506
  "message": message,
507
  }
508
  )
@@ -715,8 +2003,11 @@ def download_source_file(
715
  A .part file is used until the download completes. This makes resume/retry
716
  safe: an interrupted download is never mistaken for a usable source.
717
  """
718
- logger.write(
719
- f"Downloading source media: {source_url}"
 
 
 
720
  )
721
 
722
  with httpx.Client(
@@ -869,10 +2160,13 @@ def download_source_file(
869
  "Downloaded media file is empty."
870
  )
871
 
872
- logger.write(
 
 
873
  "Download complete: "
874
  f"{destination} "
875
- f"({destination.stat().st_size:,} bytes)"
 
876
  )
877
 
878
  return destination
@@ -947,6 +2241,181 @@ def _result_to_jsonable(
947
  )
948
 
949
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
950
  def _run_step_1(
951
  project_id: str,
952
  source_file: Path,
@@ -1228,7 +2697,12 @@ def _run_pipeline_job(
1228
  _record_event(
1229
  state,
1230
  logger,
1231
- f"Pipeline starting from Step {start_step}.",
 
 
 
 
 
1232
  )
1233
 
1234
  try:
@@ -1252,7 +2726,11 @@ def _run_pipeline_job(
1252
  _record_event(
1253
  state,
1254
  logger,
1255
- f"STEP {number:03d} STARTED | {step_name}",
 
 
 
 
1256
  )
1257
 
1258
  try:
@@ -1280,13 +2758,25 @@ def _run_pipeline_job(
1280
  _record_event(
1281
  state,
1282
  logger,
1283
- (
1284
- f"STEP {number:03d} FAILED | "
1285
- f"{step_name} | {exc}"
 
 
1286
  ),
1287
  level=logging.ERROR,
1288
  )
1289
 
 
 
 
 
 
 
 
 
 
 
1290
  return
1291
 
1292
  _mark_step_success(
@@ -1298,7 +2788,21 @@ def _run_pipeline_job(
1298
  _record_event(
1299
  state,
1300
  logger,
1301
- f"STEP {number:03d} COMPLETED | {step_name}",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1302
  )
1303
 
1304
  state["status"] = "completed"
@@ -1314,7 +2818,20 @@ def _run_pipeline_job(
1314
  _record_event(
1315
  state,
1316
  logger,
1317
- "PIPELINE COMPLETED SUCCESSFULLY.",
 
 
 
 
 
 
 
 
 
 
 
 
 
1318
  )
1319
 
1320
  finally:
@@ -1361,6 +2878,42 @@ def _submit_pipeline(
1361
  # API ROUTES
1362
  # ============================================================================
1363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1364
  @app.get(
1365
  "/health",
1366
  )
@@ -1398,6 +2951,14 @@ async def create_project(
1398
  request.file_url
1399
  )
1400
 
 
 
 
 
 
 
 
 
1401
  paths = project_paths(
1402
  project_id
1403
  )
@@ -1431,14 +2992,25 @@ async def create_project(
1431
  state = _initial_state(
1432
  project_id,
1433
  source_url,
 
1434
  )
1435
 
1436
  _write_state(
1437
  state
1438
  )
1439
 
1440
- logger.write(
1441
- f"Project created: {project_id}"
 
 
 
 
 
 
 
 
 
 
1442
  )
1443
 
1444
  try:
@@ -1471,9 +3043,11 @@ async def create_project(
1471
  _record_event(
1472
  state,
1473
  logger,
1474
- (
 
 
1475
  "Source media downloaded successfully. "
1476
- "Submitting pipeline."
1477
  ),
1478
  )
1479
 
@@ -1495,9 +3069,12 @@ async def create_project(
1495
  state
1496
  )
1497
 
1498
- logger.write(
1499
- f"PROJECT CREATION FAILED | {exc}",
1500
- logging.ERROR,
 
 
 
1501
  )
1502
 
1503
  raise HTTPException(
@@ -1706,9 +3283,12 @@ async def resume_project(
1706
  _record_event(
1707
  state,
1708
  logger,
1709
- (
1710
- f"RESUME REQUESTED | restarting from Step "
1711
- f"{start_step:03d} | {STEPS[start_step - 1]['name']}"
 
 
 
1712
  ),
1713
  )
1714
 
@@ -1837,5 +3417,4 @@ async def get_project_subtitle(project_id: str):
1837
  # PIPELINE_EXECUTOR.shutdown(
1838
  # wait=False,
1839
  # cancel_futures=False,
1840
- # )
1841
-
 
1
+ # # uvicorn main:app --host 0.0.0.0 --port 8000
2
+ # """
3
+ # FastAPI service for the 5-step subtitle pipeline.
4
+
5
+ # Project storage contract:
6
+
7
+ # src/
8
+ # ├── input/
9
+ # │ └── <project_id>/
10
+ # │ └── file.<original-extension>
11
+ # └── output/
12
+ # └── <project_id>/
13
+ # ├── audios/
14
+ # ├── diarization/
15
+ # ├── segments/
16
+ # ├── transcribe/
17
+ # ├── subtitles/
18
+ # ├── service/
19
+ # │ ├── status.json
20
+ # │ └── pipeline.log
21
+ # └── ...
22
+
23
+ # API:
24
+
25
+ # POST /projects
26
+ # Start a new project from a remote HTTP/HTTPS media URL.
27
+
28
+ # GET /projects/{project_id}
29
+ # Return current persistent project status.
30
+
31
+ # GET /projects/{project_id}/logs
32
+ # Return recent project log lines.
33
+
34
+ # POST /projects/{project_id}/resume
35
+ # Resume the failed/interrupted project from the first incomplete step.
36
+
37
+ # GET /health
38
+ # Health check.
39
+
40
+ # The processing job is intentionally single-worker inside this process.
41
+ # That is important for your CPU-heavy ML pipeline because Step 2/4 should not
42
+ # be duplicated into multiple independent OS processes.
43
+
44
+ # For production across multiple machines/processes, move the job execution to
45
+ # a durable queue such as Celery/RQ/Arq with Redis. FastAPI's BackgroundTasks
46
+ # are suitable for smaller/simple background work but FastAPI itself documents
47
+ # a queue/worker system as preferable for heavy background computation.
48
+ # """
49
+
50
+ # from __future__ import annotations
51
+
52
+ # import asyncio
53
+ # import json
54
+ # import logging
55
+ # import os
56
+ # import re
57
+ # import shutil
58
+ # import tempfile
59
+ # import threading
60
+ # import time
61
+ # from concurrent.futures import ThreadPoolExecutor
62
+ # from datetime import datetime, timezone
63
+ # from email.message import Message
64
+ # from pathlib import Path
65
+ # from typing import Any
66
+ # from urllib.parse import urlparse, unquote
67
+
68
+ # import httpx
69
+ # from fastapi import FastAPI, HTTPException, status
70
+ # from fastapi.responses import FileResponse
71
+ # from pydantic import BaseModel, Field, HttpUrl
72
+
73
+ # from src.pipeline.step_001_separate import run_audio_separation
74
+ # from src.pipeline.step_002_diarize import run_diarization
75
+ # from src.pipeline.step_003_segment import run_segment_preparation
76
+ # from src.pipeline.step_004_transcribe import run_transcription
77
+ # from src.pipeline.step_005_original_subtitle import run_step_5
78
+
79
+ # # ============================================================================
80
+ # # CONSOLE & FILE LOGGING SETUP
81
+ # # ============================================================================
82
+
83
+ # logging.basicConfig(
84
+ # level=logging.INFO,
85
+ # format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
86
+ # )
87
+ # console_logger = logging.getLogger("subtitle-pipeline-console")
88
+
89
+ # # ============================================================================
90
+ # # CONFIGURATION
91
+ # # ============================================================================
92
+
93
+ # BASE_DIR = Path(__file__).resolve().parent
94
+ # SRC_DIR = BASE_DIR / "src"
95
+
96
+ # INPUT_ROOT = SRC_DIR / "input"
97
+ # OUTPUT_ROOT = SRC_DIR / "output"
98
+
99
+ # MAX_DOWNLOAD_BYTES = int(
100
+ # os.getenv(
101
+ # "MAX_DOWNLOAD_BYTES",
102
+ # str(4 * 1024 * 1024 * 1024),
103
+ # )
104
+ # )
105
+
106
+ # DOWNLOAD_CHUNK_BYTES = 1024 * 1024 # 1 MiB
107
+ # HTTP_TIMEOUT_SECONDS = float(
108
+ # os.getenv(
109
+ # "HTTP_TIMEOUT_SECONDS",
110
+ # "120",
111
+ # )
112
+ # )
113
+
114
+ # MAX_LOG_LINES = int(
115
+ # os.getenv(
116
+ # "MAX_LOG_LINES",
117
+ # "5000",
118
+ # )
119
+ # )
120
+
121
+ # MAX_STATUS_LOG_LINES = int(
122
+ # os.getenv(
123
+ # "MAX_STATUS_LOG_LINES",
124
+ # "100",
125
+ # )
126
+ # )
127
+
128
+ # MAX_CONCURRENT_PIPELINES = 1
129
+
130
+ # SUPPORTED_EXTENSIONS = {
131
+ # ".mp3", ".wav", ".flac", ".m4a", ".aac", ".ogg", ".opus", ".wma",
132
+ # ".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v", ".mpeg", ".mpg",
133
+ # ".ts", ".mts", ".m2ts", ".3gp",
134
+ # }
135
+
136
+ # EXTENSION_BY_CONTENT_TYPE = {
137
+ # "audio/mpeg": ".mp3",
138
+ # "audio/wav": ".wav",
139
+ # "audio/x-wav": ".wav",
140
+ # "audio/flac": ".flac",
141
+ # "audio/x-flac": ".flac",
142
+ # "audio/mp4": ".m4a",
143
+ # "audio/aac": ".aac",
144
+ # "audio/ogg": ".ogg",
145
+ # "audio/opus": ".opus",
146
+ # "video/mp4": ".mp4",
147
+ # "video/quicktime": ".mov",
148
+ # "video/x-matroska": ".mkv",
149
+ # "video/webm": ".webm",
150
+ # "video/mpeg": ".mpg",
151
+ # }
152
+
153
+ # MEDIA_MAGIC_FALLBACK_EXTENSION = ".bin"
154
+
155
+ # STEPS = [
156
+ # {"number": 1, "name": "audio_separation"},
157
+ # {"number": 2, "name": "speaker_diarization"},
158
+ # {"number": 3, "name": "speaker_segment_preparation"},
159
+ # {"number": 4, "name": "speaker_aware_transcription"},
160
+ # {"number": 5, "name": "original_subtitle_generation"},
161
+ # ]
162
+
163
+ # # ============================================================================
164
+ # # FASTAPI APP
165
+ # # ============================================================================
166
+
167
+ # app = FastAPI(
168
+ # title="Subtitle Pipeline Service",
169
+ # version="1.0.0",
170
+ # description=(
171
+ # "Project-based FastAPI orchestration for audio separation, "
172
+ # "speaker diarization, segmentation, transcription, and subtitles."
173
+ # ),
174
+ # )
175
+
176
+ # # ============================================================================
177
+ # # GLOBAL JOB EXECUTOR
178
+ # # ============================================================================
179
+
180
+ # PIPELINE_EXECUTOR = ThreadPoolExecutor(
181
+ # max_workers=MAX_CONCURRENT_PIPELINES,
182
+ # thread_name_prefix="subtitle-pipeline",
183
+ # )
184
+
185
+ # ACTIVE_PROJECTS: set[str] = set()
186
+ # ACTIVE_PROJECTS_LOCK = threading.Lock()
187
+ # PROJECT_EVENTS: dict[str, asyncio.Event] = {}
188
+ # PROJECT_EVENTS_LOCK = threading.Lock()
189
+
190
+ # # ============================================================================
191
+ # # REQUEST MODELS
192
+ # # ============================================================================
193
+
194
+ # class CreateProjectRequest(BaseModel):
195
+ # project_id: str = Field(
196
+ # ...,
197
+ # min_length=1,
198
+ # max_length=128,
199
+ # description="Directory-safe project identifier.",
200
+ # )
201
+ # file_url: HttpUrl = Field(
202
+ # ...,
203
+ # description="Public/signed HTTP(S) URL of the source media file.",
204
+ # )
205
+
206
+ # class ResumeProjectResponse(BaseModel):
207
+ # project_id: str
208
+ # status: str
209
+ # resumed_from_step: int | None
210
+ # message: str
211
+
212
+ # # ============================================================================
213
+ # # LOGGING / PERSISTENT STATE
214
+ # # ============================================================================
215
+
216
+ # class ProjectLogger:
217
+ # def __init__(self, project_id: str) -> None:
218
+ # self.project_id = project_id
219
+ # self.project_dir = OUTPUT_ROOT / project_id
220
+ # self.service_dir = self.project_dir / "service"
221
+ # self.log_path = self.service_dir / "pipeline.log"
222
+ # self.status_path = self.service_dir / "status.json"
223
+
224
+ # self.service_dir.mkdir(parents=True, exist_ok=True)
225
+ # self._lock = threading.Lock()
226
+
227
+ # self.logger = logging.getLogger(f"subtitle-project.{project_id}")
228
+ # self.logger.setLevel(logging.INFO)
229
+ # self.logger.propagate = False
230
+
231
+ # if not self.logger.handlers:
232
+ # handler = logging.FileHandler(self.log_path, encoding="utf-8")
233
+ # handler.setFormatter(
234
+ # logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
235
+ # )
236
+ # self.logger.addHandler(handler)
237
+
238
+ # def write(self, message: str, level: int = logging.INFO) -> None:
239
+ # with self._lock:
240
+ # self.logger.log(level, message)
241
+ # # Detailed Console Output with Timestamp
242
+ # timestamp = datetime.now(timezone.utc).isoformat()
243
+ # console_msg = f"[{timestamp}] [Project: {self.project_id}] {message}"
244
+ # if level == logging.ERROR:
245
+ # console_logger.error(console_msg)
246
+ # elif level == logging.WARNING:
247
+ # console_logger.warning(console_msg)
248
+ # else:
249
+ # console_logger.info(console_msg)
250
+
251
+ # def recent_lines(self, limit: int = MAX_STATUS_LOG_LINES) -> list[str]:
252
+ # if not self.log_path.exists():
253
+ # return []
254
+ # with self.log_path.open("r", encoding="utf-8", errors="replace") as handle:
255
+ # lines = handle.readlines()
256
+ # return lines[-limit:]
257
+
258
+ # # ============================================================================
259
+ # # PROJECT STATE UTILITIES
260
+ # # ============================================================================
261
+
262
+ # def utc_now() -> str:
263
+ # return datetime.now(timezone.utc).isoformat()
264
+
265
+ # def _project_state_path(project_id: str) -> Path:
266
+ # return OUTPUT_ROOT / project_id / "service" / "status.json"
267
+
268
+ # def _default_step_state() -> dict[str, Any]:
269
+ # return {
270
+ # "status": "pending",
271
+ # "started_at": None,
272
+ # "completed_at": None,
273
+ # "error": None,
274
+ # "result": None,
275
+ # }
276
+
277
+ # def _initial_state(project_id: str, source_url: str) -> dict[str, Any]:
278
+ # return {
279
+ # "schema_version": "1.0",
280
+ # "project_id": project_id,
281
+ # "source_url": source_url,
282
+ # "source_file": None,
283
+ # "status": "queued",
284
+ # "current_step": None,
285
+ # "current_step_name": None,
286
+ # "last_completed_step": 0,
287
+ # "created_at": utc_now(),
288
+ # "updated_at": utc_now(),
289
+ # "completed_at": None,
290
+ # "error": None,
291
+ # "steps": {
292
+ # str(step["number"]): {**step, **_default_step_state()}
293
+ # for step in STEPS
294
+ # },
295
+ # "logs": [],
296
+ # }
297
+
298
+ # def _read_state(project_id: str) -> dict[str, Any] | None:
299
+ # path = _project_state_path(project_id)
300
+ # if not path.exists():
301
+ # return None
302
+ # try:
303
+ # with path.open("r", encoding="utf-8") as handle:
304
+ # return json.load(handle)
305
+ # except json.JSONDecodeError:
306
+ # return None
307
+
308
+ # def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
309
+ # path.parent.mkdir(parents=True, exist_ok=True)
310
+ # temp = path.with_name(f".{path.name}.tmp-{os.getpid()}")
311
+ # try:
312
+ # with temp.open("w", encoding="utf-8") as handle:
313
+ # json.dump(payload, handle, indent=2, ensure_ascii=False)
314
+ # handle.write("\n")
315
+ # os.replace(temp, path)
316
+ # finally:
317
+ # temp.unlink(missing_ok=True)
318
+
319
+ # def _write_state(state: dict[str, Any]) -> None:
320
+ # state["updated_at"] = utc_now()
321
+ # state["logs"] = state.get("logs", [])[-MAX_STATUS_LOG_LINES:]
322
+ # _atomic_write_json(_project_state_path(state["project_id"]), state)
323
+
324
+ # with PROJECT_EVENTS_LOCK:
325
+ # event = PROJECT_EVENTS.get(state["project_id"])
326
+ # if event:
327
+ # event.set()
328
+
329
+ # def _record_event(
330
+ # state: dict[str, Any],
331
+ # logger: ProjectLogger,
332
+ # message: str,
333
+ # *,
334
+ # level: int = logging.INFO,
335
+ # ) -> None:
336
+ # logger.write(message, level=level)
337
+ # logs = state.setdefault("logs", [])
338
+ # logs.append({"timestamp": utc_now(), "message": message})
339
+ # state["updated_at"] = utc_now()
340
+ # _write_state(state)
341
+
342
+ # # ============================================================================
343
+ # # PROJECT ID / PATH SAFETY
344
+ # # ============================================================================
345
+
346
+ # PROJECT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
347
+
348
+ # def validate_project_id(project_id: str) -> str:
349
+ # value = str(project_id).strip()
350
+ # if not PROJECT_ID_PATTERN.fullmatch(value):
351
+ # raise HTTPException(
352
+ # status_code=status.HTTP_400_BAD_REQUEST,
353
+ # detail=(
354
+ # "project_id must contain only letters, numbers, "
355
+ # "dot, underscore, and hyphen, and must not exceed 128 characters."
356
+ # ),
357
+ # )
358
+ # return value
359
+
360
+ # def project_paths(project_id: str) -> dict[str, Path]:
361
+ # project_id = validate_project_id(project_id)
362
+ # root = OUTPUT_ROOT / project_id
363
+ # return {
364
+ # "project_dir": root,
365
+ # "input_dir": INPUT_ROOT / project_id,
366
+ # "output_dir": root,
367
+ # "audios": root / "audios",
368
+ # "diarization": root / "diarization",
369
+ # "segments": root / "segments",
370
+ # "transcribe": root / "transcribe",
371
+ # "subtitles": root / "subtitles",
372
+ # "service": root / "service",
373
+ # }
374
+
375
+ # # ============================================================================
376
+ # # URL DOWNLOAD
377
+ # # ============================================================================
378
+
379
+ # def _extension_from_url(url: str) -> str | None:
380
+ # parsed = urlparse(url)
381
+ # name = unquote(Path(parsed.path).name)
382
+ # suffix = Path(name).suffix.lower()
383
+ # if suffix in SUPPORTED_EXTENSIONS:
384
+ # return suffix
385
+ # return None
386
+
387
+ # def _extension_from_content_type(content_type: str | None) -> str | None:
388
+ # if not content_type:
389
+ # return None
390
+ # value = content_type.split(";", 1)[0].strip().lower()
391
+ # return EXTENSION_BY_CONTENT_TYPE.get(value)
392
+
393
+ # def _extension_from_content_disposition(header: str | None) -> str | None:
394
+ # if not header:
395
+ # return None
396
+ # message = Message()
397
+ # message["content-disposition"] = header
398
+ # filename = message.get_filename()
399
+ # if not filename:
400
+ # return None
401
+ # suffix = Path(filename).suffix.lower()
402
+ # if suffix in SUPPORTED_EXTENSIONS:
403
+ # return suffix
404
+ # return None
405
+
406
+ # def _sniff_extension(first_bytes: bytes) -> str | None:
407
+ # if first_bytes.startswith(b"RIFF") and first_bytes[8:12] == b"WAVE":
408
+ # return ".wav"
409
+ # if first_bytes.startswith(b"ID3"):
410
+ # return ".mp3"
411
+ # if first_bytes.startswith(b"OggS"):
412
+ # return ".ogg"
413
+ # if first_bytes.startswith(b"fLaC"):
414
+ # return ".flac"
415
+ # if len(first_bytes) >= 12:
416
+ # if first_bytes[4:8] == b"ftyp":
417
+ # return ".mp4"
418
+ # if first_bytes.startswith(b"\x1a\x45\xdf\xa3"):
419
+ # return ".mkv"
420
+ # return None
421
+
422
+ # def _safe_download_destination(project_id: str, extension: str) -> Path:
423
+ # paths = project_paths(project_id)
424
+ # input_dir = paths["input_dir"]
425
+ # input_dir.mkdir(parents=True, exist_ok=True)
426
+ # return input_dir / f"file{extension}"
427
+
428
+ # def download_source_file(
429
+ # project_id: str,
430
+ # source_url: str,
431
+ # logger: ProjectLogger,
432
+ # ) -> Path:
433
+ # logger.write(f"Downloading source media: {source_url}")
434
+ # with httpx.Client(
435
+ # follow_redirects=True,
436
+ # timeout=httpx.Timeout(HTTP_TIMEOUT_SECONDS),
437
+ # headers={"User-Agent": "subtitle-pipeline/1.0 (media downloader)"},
438
+ # ) as client:
439
+ # try:
440
+ # with client.stream("GET", source_url) as response:
441
+ # response.raise_for_status()
442
+ # content_type = response.headers.get("content-type")
443
+ # content_length_header = response.headers.get("content-length")
444
+
445
+ # if content_length_header:
446
+ # try:
447
+ # content_length = int(content_length_header)
448
+ # except ValueError:
449
+ # content_length = None
450
+
451
+ # if content_length is not None and content_length > MAX_DOWNLOAD_BYTES:
452
+ # raise ValueError("Remote media exceeds MAX_DOWNLOAD_BYTES.")
453
+
454
+ # extension = (
455
+ # _extension_from_content_disposition(response.headers.get("content-disposition"))
456
+ # or _extension_from_url(source_url)
457
+ # or _extension_from_content_type(content_type)
458
+ # )
459
+
460
+ # if not extension:
461
+ # extension = MEDIA_MAGIC_FALLBACK_EXTENSION
462
+
463
+ # destination = _safe_download_destination(project_id, extension)
464
+ # partial = destination.with_suffix(destination.suffix + ".part")
465
+ # partial.unlink(missing_ok=True)
466
+
467
+ # total = 0
468
+ # sniff = bytearray()
469
+
470
+ # with partial.open("wb") as output:
471
+ # for chunk in response.iter_bytes(chunk_size=DOWNLOAD_CHUNK_BYTES):
472
+ # if not chunk:
473
+ # continue
474
+ # if len(sniff) < 64:
475
+ # needed = 64 - len(sniff)
476
+ # sniff.extend(chunk[:needed])
477
+ # total += len(chunk)
478
+ # if total > MAX_DOWNLOAD_BYTES:
479
+ # raise ValueError("Remote media exceeded MAX_DOWNLOAD_BYTES.")
480
+ # output.write(chunk)
481
+
482
+ # if destination.suffix == MEDIA_MAGIC_FALLBACK_EXTENSION:
483
+ # sniffed = _sniff_extension(bytes(sniff))
484
+ # if sniffed:
485
+ # sniffed_destination = destination.with_suffix(sniffed)
486
+ # sniffed_destination.unlink(missing_ok=True)
487
+ # os.replace(partial, sniffed_destination)
488
+ # destination = sniffed_destination
489
+ # else:
490
+ # os.replace(partial, destination)
491
+ # else:
492
+ # os.replace(partial, destination)
493
+
494
+ # if destination.stat().st_size <= 0:
495
+ # raise ValueError("Downloaded media file is empty.")
496
+
497
+ # logger.write(
498
+ # f"Download complete: {destination} ({destination.stat().st_size:,} bytes)"
499
+ # )
500
+ # return destination
501
+
502
+ # except Exception:
503
+ # try:
504
+ # if "partial" in locals():
505
+ # partial.unlink(missing_ok=True)
506
+ # except Exception:
507
+ # pass
508
+ # raise
509
+
510
+ # # ============================================================================
511
+ # # PIPELINE EXECUTION
512
+ # # ============================================================================
513
+
514
+ # def _result_to_jsonable(value: Any) -> Any:
515
+ # if value is None:
516
+ # return None
517
+ # if isinstance(value, (str, int, float, bool)):
518
+ # return value
519
+ # if isinstance(value, Path):
520
+ # return str(value)
521
+ # if isinstance(value, dict):
522
+ # return {str(key): _result_to_jsonable(item) for key, item in value.items()}
523
+ # if isinstance(value, (list, tuple)):
524
+ # return [_result_to_jsonable(item) for item in value]
525
+ # return str(value)
526
+
527
+ # def _run_step_1(project_id: str, source_file: Path) -> dict[str, Any]:
528
+ # paths = project_paths(project_id)
529
+ # vocal_path, instrument_path = run_audio_separation(
530
+ # input_file_path=source_file,
531
+ # output_dir=paths["audios"],
532
+ # )
533
+ # return {"vocal_path": str(vocal_path), "instrument_path": str(instrument_path)}
534
+
535
+ # def _run_step_2(project_id: str) -> dict[str, Any]:
536
+ # return run_diarization(project_id)
537
+
538
+ # def _run_step_3(project_id: str) -> dict[str, Any]:
539
+ # return run_segment_preparation(project_id)
540
+
541
+ # def _run_step_4(project_id: str) -> dict[str, Any]:
542
+ # return run_transcription(project_id)
543
+
544
+ # def _run_step_5(project_id: str) -> dict[str, Any]:
545
+ # return run_step_5(project_id)
546
+
547
+ # def _step_function(number: int):
548
+ # return {
549
+ # 1: _run_step_1,
550
+ # 2: _run_step_2,
551
+ # 3: _run_step_3,
552
+ # 4: _run_step_4,
553
+ # 5: _run_step_5,
554
+ # }[number]
555
+
556
+ # def _first_incomplete_step(state: dict[str, Any]) -> int | None:
557
+ # for step in STEPS:
558
+ # step_state = state["steps"][str(step["number"])]
559
+ # if step_state.get("status") != "success":
560
+ # return step["number"]
561
+ # return None
562
+
563
+ # def _mark_step_started(state: dict[str, Any], step_number: int) -> None:
564
+ # step_state = state["steps"][str(step_number)]
565
+ # step_state["status"] = "running"
566
+ # step_state["started_at"] = utc_now()
567
+ # step_state["completed_at"] = None
568
+ # step_state["error"] = None
569
+
570
+ # state["status"] = "running"
571
+ # state["current_step"] = step_number
572
+ # state["current_step_name"] = STEPS[step_number - 1]["name"]
573
+ # _write_state(state)
574
+
575
+ # def _mark_step_success(state: dict[str, Any], step_number: int, result: Any) -> None:
576
+ # step_state = state["steps"][str(step_number)]
577
+ # step_state["status"] = "success"
578
+ # step_state["completed_at"] = utc_now()
579
+ # step_state["error"] = None
580
+ # step_state["result"] = _result_to_jsonable(result)
581
+
582
+ # state["last_completed_step"] = max(state.get("last_completed_step", 0), step_number)
583
+ # state["error"] = None
584
+ # _write_state(state)
585
+
586
+ # def _mark_step_failed(state: dict[str, Any], step_number: int, error: Exception) -> None:
587
+ # step_state = state["steps"][str(step_number)]
588
+ # step_state["status"] = "failed"
589
+ # step_state["completed_at"] = utc_now()
590
+ # step_state["error"] = str(error)
591
+
592
+ # state["status"] = "failed"
593
+ # state["error"] = str(error)
594
+ # state["current_step"] = step_number
595
+ # state["current_step_name"] = STEPS[step_number - 1]["name"]
596
+ # _write_state(state)
597
+
598
+ # def _run_pipeline_job(project_id: str) -> None:
599
+ # logger = ProjectLogger(project_id)
600
+ # state = _read_state(project_id)
601
+
602
+ # if not state:
603
+ # logger.write("Cannot start pipeline: project state missing.", logging.ERROR)
604
+ # return
605
+
606
+ # source_file_value = state.get("source_file")
607
+ # if not source_file_value:
608
+ # state["status"] = "failed"
609
+ # state["error"] = "source_file missing from project state."
610
+ # _write_state(state)
611
+ # logger.write("Pipeline aborted: source_file missing.", logging.ERROR)
612
+ # return
613
+
614
+ # source_file = Path(source_file_value)
615
+ # if not source_file.exists():
616
+ # state["status"] = "failed"
617
+ # state["error"] = f"Source file not found: {source_file}"
618
+ # _write_state(state)
619
+ # logger.write(state["error"], logging.ERROR)
620
+ # return
621
+
622
+ # start_step = _first_incomplete_step(state)
623
+ # if start_step is None:
624
+ # state["status"] = "completed"
625
+ # state["current_step"] = None
626
+ # state["current_step_name"] = None
627
+ # state["completed_at"] = state.get("completed_at") or utc_now()
628
+ # _write_state(state)
629
+ # logger.write("Project already completed; nothing to do.")
630
+ # return
631
+
632
+ # _record_event(state, logger, f"Pipeline starting from Step {start_step}.")
633
+
634
+ # try:
635
+ # for number in range(start_step, 6):
636
+ # step = STEPS[number - 1]
637
+ # step_name = step["name"]
638
+
639
+ # _mark_step_started(state, number)
640
+ # _record_event(state, logger, f"STEP {number:03d} STARTED | {step_name}")
641
+
642
+ # try:
643
+ # if number == 1:
644
+ # result = _step_function(number)(project_id, source_file)
645
+ # else:
646
+ # result = _step_function(number)(project_id)
647
+ # except Exception as exc:
648
+ # _mark_step_failed(state, number, exc)
649
+ # _record_event(
650
+ # state,
651
+ # logger,
652
+ # f"STEP {number:03d} FAILED | {step_name} | {exc}",
653
+ # level=logging.ERROR,
654
+ # )
655
+ # return
656
+
657
+ # _mark_step_success(state, number, result)
658
+ # _record_event(state, logger, f"STEP {number:03d} COMPLETED | {step_name}")
659
+
660
+ # state["status"] = "completed"
661
+ # state["current_step"] = None
662
+ # state["current_step_name"] = None
663
+ # state["completed_at"] = utc_now()
664
+ # state["error"] = None
665
+ # _write_state(state)
666
+ # _record_event(state, logger, "PIPELINE COMPLETED SUCCESSFULLY.")
667
+
668
+ # finally:
669
+ # with ACTIVE_PROJECTS_LOCK:
670
+ # ACTIVE_PROJECTS.discard(project_id)
671
+
672
+ # def _submit_pipeline(project_id: str) -> None:
673
+ # with ACTIVE_PROJECTS_LOCK:
674
+ # if project_id in ACTIVE_PROJECTS:
675
+ # raise RuntimeError("Project is already running.")
676
+ # ACTIVE_PROJECTS.add(project_id)
677
+
678
+ # try:
679
+ # PIPELINE_EXECUTOR.submit(_run_pipeline_job, project_id)
680
+ # except Exception:
681
+ # with ACTIVE_PROJECTS_LOCK:
682
+ # ACTIVE_PROJECTS.discard(project_id)
683
+ # raise
684
+
685
+ # # ============================================================================
686
+ # # API ROUTES
687
+ # # ============================================================================
688
+
689
+ # @app.get("/")
690
+ # async def root() -> dict[str, Any]:
691
+ # console_logger.info(f"[{utc_now()}] Root endpoint '/' accessed.")
692
+ # with ACTIVE_PROJECTS_LOCK:
693
+ # active_count = len(ACTIVE_PROJECTS)
694
+ # return {
695
+ # "message": "Subtitle Pipeline Service is active and running.",
696
+ # "version": "1.0.0",
697
+ # "active_projects_count": active_count,
698
+ # "docs_url": "/docs",
699
+ # "health_check": "/health"
700
+ # }
701
+
702
+ # @app.get("/health")
703
+ # async def health() -> dict[str, Any]:
704
+ # with ACTIVE_PROJECTS_LOCK:
705
+ # active = sorted(ACTIVE_PROJECTS)
706
+ # console_logger.info(f"[{utc_now()}] Health check endpoint accessed.")
707
+ # return {
708
+ # "status": "ok",
709
+ # "service": "subtitle-pipeline",
710
+ # "active_projects": active,
711
+ # "max_concurrent_pipelines": MAX_CONCURRENT_PIPELINES,
712
+ # "output_root": str(OUTPUT_ROOT.resolve()),
713
+ # }
714
+
715
+ # @app.post("/projects", status_code=status.HTTP_202_ACCEPTED)
716
+ # async def create_project(request: CreateProjectRequest) -> dict[str, Any]:
717
+ # project_id = validate_project_id(request.project_id)
718
+ # source_url = str(request.file_url)
719
+ # paths = project_paths(project_id)
720
+
721
+ # paths["service"].mkdir(parents=True, exist_ok=True)
722
+ # existing = _read_state(project_id)
723
+
724
+ # if existing:
725
+ # raise HTTPException(
726
+ # status_code=status.HTTP_409_CONFLICT,
727
+ # detail=f"Project '{project_id}' already exists. Use GET for status or POST /resume.",
728
+ # )
729
+
730
+ # logger = ProjectLogger(project_id)
731
+ # state = _initial_state(project_id, source_url)
732
+ # _write_state(state)
733
+ # logger.write(f"Project created: {project_id}")
734
+
735
+ # try:
736
+ # source_file = await asyncio.to_thread(
737
+ # download_source_file,
738
+ # project_id,
739
+ # source_url,
740
+ # logger,
741
+ # )
742
+
743
+ # state = _read_state(project_id)
744
+ # if not state:
745
+ # raise RuntimeError("Project state disappeared after download.")
746
+
747
+ # state["source_file"] = str(source_file.resolve())
748
+ # state["status"] = "queued"
749
+ # state["error"] = None
750
+ # _write_state(state)
751
+
752
+ # _record_event(state, logger, "Source media downloaded successfully. Submitting pipeline.")
753
+ # _submit_pipeline(project_id)
754
+
755
+ # except Exception as exc:
756
+ # state = _read_state(project_id) or state
757
+ # state["status"] = "failed"
758
+ # state["error"] = str(exc)
759
+ # _write_state(state)
760
+ # logger.write(f"PROJECT CREATION FAILED | {exc}", level=logging.ERROR)
761
+ # raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
762
+
763
+ # return {
764
+ # "project_id": project_id,
765
+ # "status": "queued",
766
+ # "source_file": state["source_file"],
767
+ # "status_url": f"/projects/{project_id}",
768
+ # "logs_url": f"/projects/{project_id}/logs",
769
+ # "resume_url": f"/projects/{project_id}/resume",
770
+ # }
771
+
772
+ # @app.get("/projects/{project_id}")
773
+ # async def get_project(project_id: str) -> dict[str, Any]:
774
+ # project_id = validate_project_id(project_id)
775
+ # state = _read_state(project_id)
776
+
777
+ # if not state:
778
+ # raise HTTPException(
779
+ # status_code=status.HTTP_404_NOT_FOUND,
780
+ # detail=f"Project '{project_id}' does not exist.",
781
+ # )
782
+
783
+ # with ACTIVE_PROJECTS_LOCK:
784
+ # running = project_id in ACTIVE_PROJECTS
785
+
786
+ # state["runtime"] = {"running_in_this_process": running}
787
+ # return state
788
+
789
+ # @app.get("/projects/{project_id}/logs")
790
+ # async def get_project_logs(project_id: str, limit: int = 100) -> dict[str, Any]:
791
+ # project_id = validate_project_id(project_id)
792
+ # state = _read_state(project_id)
793
+
794
+ # if not state:
795
+ # raise HTTPException(
796
+ # status_code=status.HTTP_404_NOT_FOUND,
797
+ # detail=f"Project '{project_id}' does not exist.",
798
+ # )
799
+
800
+ # limit = max(1, min(limit, MAX_LOG_LINES))
801
+ # logger = ProjectLogger(project_id)
802
+
803
+ # return {
804
+ # "project_id": project_id,
805
+ # "log_file": str(logger.log_path.resolve()),
806
+ # "lines": logger.recent_lines(limit),
807
+ # }
808
+
809
+ # @app.post("/projects/{project_id}/resume", response_model=ResumeProjectResponse)
810
+ # async def resume_project(project_id: str) -> ResumeProjectResponse:
811
+ # project_id = validate_project_id(project_id)
812
+ # state = _read_state(project_id)
813
+
814
+ # if not state:
815
+ # raise HTTPException(
816
+ # status_code=status.HTTP_404_NOT_FOUND,
817
+ # detail=f"Project '{project_id}' does not exist.",
818
+ # )
819
+
820
+ # if not state.get("source_file"):
821
+ # raise HTTPException(
822
+ # status_code=status.HTTP_409_CONFLICT,
823
+ # detail="Cannot resume because the source media file is missing from project state.",
824
+ # )
825
+
826
+ # if not Path(state["source_file"]).exists():
827
+ # raise HTTPException(
828
+ # status_code=status.HTTP_409_CONFLICT,
829
+ # detail="Cannot resume because the downloaded source file no longer exists.",
830
+ # )
831
+
832
+ # with ACTIVE_PROJECTS_LOCK:
833
+ # if project_id in ACTIVE_PROJECTS:
834
+ # raise HTTPException(
835
+ # status_code=status.HTTP_409_CONFLICT,
836
+ # detail="Project is already running.",
837
+ # )
838
+
839
+ # start_step = _first_incomplete_step(state)
840
+ # if start_step is None:
841
+ # return ResumeProjectResponse(
842
+ # project_id=project_id,
843
+ # status="completed",
844
+ # resumed_from_step=None,
845
+ # message="Project is already complete.",
846
+ # )
847
+
848
+ # step_state = state["steps"][str(start_step)]
849
+ # step_state["status"] = "pending"
850
+ # step_state["error"] = None
851
+ # step_state["started_at"] = None
852
+ # step_state["completed_at"] = None
853
+
854
+ # state["status"] = "queued"
855
+ # state["error"] = None
856
+ # state["current_step"] = start_step
857
+ # state["current_step_name"] = STEPS[start_step - 1]["name"]
858
+ # _write_state(state)
859
+
860
+ # logger = ProjectLogger(project_id)
861
+ # _record_event(
862
+ # state,
863
+ # logger,
864
+ # f"RESUME REQUESTED | restarting from Step {start_step:03d} | {STEPS[start_step - 1]['name']}",
865
+ # )
866
+
867
+ # try:
868
+ # _submit_pipeline(project_id)
869
+ # except RuntimeError as exc:
870
+ # raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
871
+
872
+ # return ResumeProjectResponse(
873
+ # project_id=project_id,
874
+ # status="queued",
875
+ # resumed_from_step=start_step,
876
+ # message=f"Project queued for resume from Step {start_step:03d}.",
877
+ # )
878
+
879
+ # @app.get("/projects/{project_id}/subtitle")
880
+ # async def get_project_subtitle(project_id: str):
881
+ # project_id = validate_project_id(project_id)
882
+ # state = _read_state(project_id)
883
+
884
+ # if not state:
885
+ # raise HTTPException(
886
+ # status_code=status.HTTP_404_NOT_FOUND,
887
+ # detail={
888
+ # "status": "not_found",
889
+ # "message": f"Project '{project_id}' does not exist.",
890
+ # },
891
+ # )
892
+
893
+ # step_5 = state.get("steps", {}).get("5", {})
894
+ # step_5_result = step_5.get("result") or {}
895
+ # srt_path_value = step_5_result.get("srt_path")
896
+
897
+ # if srt_path_value:
898
+ # srt_path = Path(srt_path_value)
899
+ # if srt_path.exists() and srt_path.is_file():
900
+ # return FileResponse(
901
+ # path=str(srt_path),
902
+ # media_type="application/x-subrip",
903
+ # filename=srt_path.name,
904
+ # headers={"Content-Disposition": f'attachment; filename="{srt_path.name}"'},
905
+ # )
906
+
907
+ # subtitles_dir = OUTPUT_ROOT / project_id / "subtitles"
908
+ # if subtitles_dir.exists():
909
+ # srt_files = sorted(
910
+ # subtitles_dir.glob("*.srt"),
911
+ # key=lambda path: path.stat().st_mtime,
912
+ # reverse=True,
913
+ # )
914
+ # if srt_files:
915
+ # srt_path = srt_files[0]
916
+ # return FileResponse(
917
+ # path=str(srt_path),
918
+ # media_type="application/x-subrip",
919
+ # filename=srt_path.name,
920
+ # headers={"Content-Disposition": f'attachment; filename="{srt_path.name}"'},
921
+ # )
922
+
923
+ # logger = ProjectLogger(project_id)
924
+ # return {
925
+ # "status": "not_ready",
926
+ # "message": "Subtitle file has not been created yet.",
927
+ # "project_id": project_id,
928
+ # "project_status": state.get("status"),
929
+ # "current_step": state.get("current_step"),
930
+ # "current_step_name": state.get("current_step_name"),
931
+ # "last_completed_step": state.get("last_completed_step", 0),
932
+ # "subtitle_exists": False,
933
+ # "error": state.get("error"),
934
+ # "logs": logger.recent_lines(20),
935
+ # }
936
+
937
+ # '''
938
+
939
+
940
+ # curl -X POST \
941
+ # "https://friendly-space-train-xrvvggqpg95jc57j-8000.app.github.dev/projects" \
942
+ # -H "Content-Type: application/json" \
943
+ # -d '{
944
+ # "project_id": "test-conversation-002",
945
+ # "file_url": "https://huggingface.co/datasets/zhangjinyang/TalkingHead-1KH-audio/resolve/main/val_cropped_clips/1lSejjfNHpw_0075_S1_E728_L671_T47_R1471_B847.mp4"
946
+ # }'
947
+
948
+ # '''
949
+
950
+
951
  # uvicorn main:app --host 0.0.0.0 --port 8000
952
  """
953
  FastAPI service for the 5-step subtitle pipeline.
 
1000
  from __future__ import annotations
1001
 
1002
  import asyncio
1003
+ import copy
1004
  import json
1005
  import logging
1006
  import os
1007
  import re
1008
  import shutil
1009
+ import sys
1010
  import tempfile
1011
  import threading
1012
  import time
1013
  from concurrent.futures import ThreadPoolExecutor
1014
+ from datetime import datetime, timedelta, timezone
1015
  from email.message import Message
1016
  from pathlib import Path
1017
  from typing import Any
 
1020
  import httpx
1021
  from fastapi import FastAPI, HTTPException, status
1022
  from fastapi.responses import FileResponse
1023
+ from fastapi.middleware.cors import CORSMiddleware
1024
  from pydantic import BaseModel, Field, HttpUrl
1025
 
1026
  from src.pipeline.step_001_separate import run_audio_separation
 
1069
  )
1070
  )
1071
 
1072
+ # Webhook (optional, sent per-project) delivery timeout.
1073
+ WEBHOOK_TIMEOUT_SECONDS = float(
1074
+ os.getenv(
1075
+ "WEBHOOK_TIMEOUT_SECONDS",
1076
+ "15",
1077
+ )
1078
+ )
1079
+
1080
+ # Whether logs are also mirrored to stdout (colorized) in addition to the
1081
+ # per-project log file on disk.
1082
+ LOG_TO_CONSOLE = os.getenv(
1083
+ "LOG_TO_CONSOLE",
1084
+ "true",
1085
+ ).strip().lower() in {
1086
+ "1",
1087
+ "true",
1088
+ "yes",
1089
+ }
1090
+
1091
+ # ============================================================================
1092
+ # TIMEZONE (INDIAN STANDARD TIME) — all human-facing log timestamps use IST.
1093
+ # ============================================================================
1094
+
1095
+ IST_TZ = timezone(
1096
+ timedelta(
1097
+ hours=5,
1098
+ minutes=30,
1099
+ ),
1100
+ name="IST",
1101
+ )
1102
+
1103
+
1104
+ def ist_now() -> datetime:
1105
+ return datetime.now(
1106
+ IST_TZ
1107
+ )
1108
+
1109
+
1110
+ def ist_now_iso() -> str:
1111
+ return ist_now().isoformat()
1112
+
1113
+
1114
+ def ist_now_display() -> str:
1115
+ return ist_now().strftime(
1116
+ "%Y-%m-%d %H:%M:%S.%f"
1117
+ )[:-3] + " IST"
1118
+
1119
+
1120
+ # ============================================================================
1121
+ # ANSI COLORS — used to make per-step / per-event log lines colorful.
1122
+ # ============================================================================
1123
+
1124
+ class Ansi:
1125
+ RESET = "\033[0m"
1126
+ BOLD = "\033[1m"
1127
+ DIM = "\033[2m"
1128
+
1129
+ BLACK = "\033[30m"
1130
+ RED = "\033[31m"
1131
+ GREEN = "\033[32m"
1132
+ YELLOW = "\033[33m"
1133
+ BLUE = "\033[34m"
1134
+ MAGENTA = "\033[35m"
1135
+ CYAN = "\033[36m"
1136
+ WHITE = "\033[37m"
1137
+
1138
+ BRIGHT_RED = "\033[91m"
1139
+ BRIGHT_GREEN = "\033[92m"
1140
+ BRIGHT_YELLOW = "\033[93m"
1141
+ BRIGHT_BLUE = "\033[94m"
1142
+ BRIGHT_MAGENTA = "\033[95m"
1143
+ BRIGHT_CYAN = "\033[96m"
1144
+ BRIGHT_WHITE = "\033[97m"
1145
+
1146
+
1147
+ # Each pipeline step gets its own distinct color so its log lines are
1148
+ # instantly recognizable when scanning pipeline.log or the console.
1149
+ STEP_COLORS: dict[int, str] = {
1150
+ 1: Ansi.BRIGHT_CYAN,
1151
+ 2: Ansi.BRIGHT_MAGENTA,
1152
+ 3: Ansi.BRIGHT_YELLOW,
1153
+ 4: Ansi.BRIGHT_BLUE,
1154
+ 5: Ansi.BRIGHT_GREEN,
1155
+ }
1156
+
1157
+ # Event-type colors (STARTED / COMPLETED / FAILED / etc.) layered on top of
1158
+ # the step color.
1159
+ EVENT_COLORS: dict[str, str] = {
1160
+ "STARTED": Ansi.BLUE,
1161
+ "RUNNING": Ansi.CYAN,
1162
+ "COMPLETED": Ansi.GREEN,
1163
+ "SUCCESS": Ansi.GREEN,
1164
+ "FAILED": Ansi.RED,
1165
+ "ERROR": Ansi.RED,
1166
+ "INFO": Ansi.WHITE,
1167
+ "WEBHOOK": Ansi.MAGENTA,
1168
+ "RESUME": Ansi.YELLOW,
1169
+ "PROJECT": Ansi.BRIGHT_WHITE,
1170
+ }
1171
+
1172
+ LEVEL_COLORS: dict[int, str] = {
1173
+ logging.DEBUG: Ansi.WHITE,
1174
+ logging.INFO: Ansi.CYAN,
1175
+ logging.WARNING: Ansi.YELLOW,
1176
+ logging.ERROR: Ansi.RED,
1177
+ logging.CRITICAL: Ansi.BOLD + Ansi.BRIGHT_RED,
1178
+ }
1179
+
1180
+
1181
+ def _colorize(
1182
+ text: str,
1183
+ color: str,
1184
+ ) -> str:
1185
+ return f"{color}{text}{Ansi.RESET}"
1186
+
1187
+
1188
+ def step_log_message(
1189
+ step_number: int | None,
1190
+ event_type: str,
1191
+ message: str,
1192
+ ) -> str:
1193
+ """
1194
+ Build a colorful, per-step log message such as:
1195
+
1196
+ [STEP 002] STARTED | speaker_diarization
1197
+
1198
+ Colors are ANSI escape codes. They render nicely in any terminal that
1199
+ tails pipeline.log (e.g. `tail -f` / `cat`) and in the console handler.
1200
+ They are harmless (ignored/visible as-is) in non-ANSI viewers.
1201
+ """
1202
+ step_color = (
1203
+ STEP_COLORS.get(
1204
+ step_number,
1205
+ Ansi.WHITE,
1206
+ )
1207
+ if step_number is not None
1208
+ else Ansi.BRIGHT_WHITE
1209
+ )
1210
+
1211
+ event_color = EVENT_COLORS.get(
1212
+ event_type,
1213
+ Ansi.WHITE,
1214
+ )
1215
+
1216
+ step_tag = (
1217
+ _colorize(
1218
+ f"[STEP {step_number:03d}]",
1219
+ Ansi.BOLD + step_color,
1220
+ )
1221
+ if step_number is not None
1222
+ else _colorize(
1223
+ "[PROJECT]",
1224
+ Ansi.BOLD + Ansi.BRIGHT_WHITE,
1225
+ )
1226
+ )
1227
+
1228
+ event_tag = _colorize(
1229
+ event_type,
1230
+ Ansi.BOLD + event_color,
1231
+ )
1232
+
1233
+ return (
1234
+ f"{step_tag} {event_tag} | {message}"
1235
+ )
1236
+
1237
  # Only one whole pipeline job executes at a time in this process.
1238
  #
1239
  # This is deliberate. Step 2/4 are heavy CPU model workloads, and concurrent
 
1319
  ),
1320
  )
1321
 
1322
+ # Configure CORS to allow requests from anywhere
1323
+ app.add_middleware(
1324
+ CORSMiddleware,
1325
+ allow_origins=["*"], # Allows requests from any origin
1326
+ allow_credentials=True, # Allows cookies/auth headers
1327
+ allow_methods=["*"], # Allows all HTTP methods (GET, POST, etc.)
1328
+ allow_headers=["*"], # Allows all headers
1329
+ )
1330
 
1331
  # ============================================================================
1332
  # GLOBAL JOB EXECUTOR
 
1365
  description="Public/signed HTTP(S) URL of the source media file.",
1366
  )
1367
 
1368
+ webhook_url: HttpUrl | None = Field(
1369
+ default=None,
1370
+ description=(
1371
+ "Optional HTTP(S) webhook URL. If present, the service will "
1372
+ "POST a JSON payload to it whenever a pipeline step completes "
1373
+ "(with that step's detailed logs plus all previous logs for "
1374
+ "this project), and once more when the whole pipeline "
1375
+ "completes (with all logs and the subtitle file path/"
1376
+ "download link)."
1377
+ ),
1378
+ )
1379
+
1380
 
1381
  class ResumeProjectResponse(BaseModel):
1382
  project_id: str
 
1389
  # LOGGING / PERSISTENT STATE
1390
  # ============================================================================
1391
 
1392
+ class ISTColorFormatter(logging.Formatter):
1393
+ """
1394
+ Logging formatter that:
1395
+ 1. Renders timestamps in Indian Standard Time (IST, UTC+05:30).
1396
+ 2. Colorizes the level name using ANSI escape codes so pipeline.log
1397
+ (and the console mirror) is colorful and easy to scan.
1398
+
1399
+ The per-step / per-event coloring (STARTED / COMPLETED / FAILED, etc.)
1400
+ is applied separately, in the message text itself, via
1401
+ `step_log_message()`, so a single log line ends up carrying both a
1402
+ level color and a step/event color.
1403
+ """
1404
+
1405
+ def __init__(
1406
+ self,
1407
+ fmt: str,
1408
+ colorize: bool = True,
1409
+ ) -> None:
1410
+ super().__init__(fmt)
1411
+ self.colorize = colorize
1412
+
1413
+ def formatTime(
1414
+ self,
1415
+ record: logging.LogRecord,
1416
+ datefmt: str | None = None,
1417
+ ) -> str:
1418
+ dt = datetime.fromtimestamp(
1419
+ record.created,
1420
+ tz=IST_TZ,
1421
+ )
1422
+
1423
+ if datefmt:
1424
+ return dt.strftime(
1425
+ datefmt
1426
+ )
1427
+
1428
+ return (
1429
+ dt.strftime(
1430
+ "%Y-%m-%d %H:%M:%S.%f"
1431
+ )[:-3]
1432
+ + " IST"
1433
+ )
1434
+
1435
+ def format(
1436
+ self,
1437
+ record: logging.LogRecord,
1438
+ ) -> str:
1439
+ if not self.colorize:
1440
+ return super().format(
1441
+ record
1442
+ )
1443
+
1444
+ # Work on a shallow copy so we never mutate the shared LogRecord
1445
+ # (multiple handlers format the same record).
1446
+ colored_record = copy.copy(
1447
+ record
1448
+ )
1449
+
1450
+ level_color = LEVEL_COLORS.get(
1451
+ colored_record.levelno,
1452
+ Ansi.WHITE,
1453
+ )
1454
+
1455
+ colored_record.levelname = _colorize(
1456
+ f"{colored_record.levelname:<8}",
1457
+ level_color,
1458
+ )
1459
+
1460
+ return super().format(
1461
+ colored_record
1462
+ )
1463
+
1464
+
1465
  class ProjectLogger:
1466
  """
1467
  File-backed project logger.
 
1511
  self.logger.propagate = False
1512
 
1513
  if not self.logger.handlers:
1514
+ file_handler = logging.FileHandler(
1515
  self.log_path,
1516
  encoding="utf-8",
1517
  )
1518
+ file_handler.setFormatter(
1519
+ ISTColorFormatter(
1520
  "%(asctime)s | %(levelname)s | %(message)s",
1521
+ colorize=True,
1522
  )
1523
  )
1524
+ self.logger.addHandler(file_handler)
1525
+
1526
+ if LOG_TO_CONSOLE:
1527
+ console_handler = logging.StreamHandler(
1528
+ sys.stdout
1529
+ )
1530
+ console_handler.setFormatter(
1531
+ ISTColorFormatter(
1532
+ "%(asctime)s | %(levelname)s | "
1533
+ f"[{project_id}] "
1534
+ "%(message)s",
1535
+ colorize=True,
1536
+ )
1537
+ )
1538
+ self.logger.addHandler(console_handler)
1539
 
1540
  def write(
1541
  self,
 
1548
  message,
1549
  )
1550
 
1551
+ def write_step(
1552
+ self,
1553
+ step_number: int | None,
1554
+ event_type: str,
1555
+ message: str,
1556
+ level: int = logging.INFO,
1557
+ ) -> str:
1558
+ """
1559
+ Write a colorful, per-step log line (both to file and, if enabled,
1560
+ console) and return the fully-composed message text so callers can
1561
+ reuse the exact same text (e.g. for state["logs"] / webhooks).
1562
+ """
1563
+ composed = step_log_message(
1564
+ step_number,
1565
+ event_type,
1566
+ message,
1567
+ )
1568
+
1569
+ self.write(
1570
+ composed,
1571
+ level=level,
1572
+ )
1573
+
1574
+ return composed
1575
+
1576
  def recent_lines(
1577
  self,
1578
  limit: int = MAX_STATUS_LOG_LINES,
 
1591
 
1592
  return lines[-limit:]
1593
 
1594
+ def all_lines(
1595
+ self,
1596
+ ) -> list[str]:
1597
+ """
1598
+ Full project log, unbounded. Used for the "all previous logs
1599
+ related to this project" payload sent to the webhook, and for the
1600
+ final completed-pipeline webhook event.
1601
+ """
1602
+ if not self.log_path.exists():
1603
+ return []
1604
+
1605
+ with self.log_path.open(
1606
+ "r",
1607
+ encoding="utf-8",
1608
+ errors="replace",
1609
+ ) as handle:
1610
+ return handle.readlines()
1611
+
1612
+ def lines_for_step(
1613
+ self,
1614
+ step_number: int,
1615
+ ) -> list[str]:
1616
+ """
1617
+ Just the lines related to a single step (STARTED/COMPLETED/FAILED
1618
+ and anything else logged with that step's [STEP NNN] tag).
1619
+ Used for the "this step's detailed logs" webhook payload.
1620
+ """
1621
+ marker = f"STEP {step_number:03d}]"
1622
+
1623
+ return [
1624
+ line
1625
+ for line in self.all_lines()
1626
+ if marker in line
1627
+ ]
1628
+
1629
 
1630
  # ============================================================================
1631
  # PROJECT STATE
 
1661
  def _initial_state(
1662
  project_id: str,
1663
  source_url: str,
1664
+ webhook_url: str | None = None,
1665
  ) -> dict[str, Any]:
1666
  return {
1667
  "schema_version": "1.0",
1668
  "project_id": project_id,
1669
  "source_url": source_url,
1670
  "source_file": None,
1671
+ "webhook_url": webhook_url,
1672
  "status": "queued",
1673
  "current_step": None,
1674
  "current_step_name": None,
 
1790
 
1791
  logs.append(
1792
  {
1793
+ "timestamp_ist": ist_now_iso(),
1794
  "message": message,
1795
  }
1796
  )
 
2003
  A .part file is used until the download completes. This makes resume/retry
2004
  safe: an interrupted download is never mistaken for a usable source.
2005
  """
2006
+ logger.write_step(
2007
+ None,
2008
+ "PROJECT",
2009
+ f"Downloading source media: {source_url} "
2010
+ f"| started_at={ist_now_display()}",
2011
  )
2012
 
2013
  with httpx.Client(
 
2160
  "Downloaded media file is empty."
2161
  )
2162
 
2163
+ logger.write_step(
2164
+ None,
2165
+ "PROJECT",
2166
  "Download complete: "
2167
  f"{destination} "
2168
+ f"({destination.stat().st_size:,} bytes) "
2169
+ f"| completed_at={ist_now_display()}",
2170
  )
2171
 
2172
  return destination
 
2241
  )
2242
 
2243
 
2244
+ # ============================================================================
2245
+ # OPTIONAL PROJECT WEBHOOK
2246
+ # ============================================================================
2247
+
2248
+ def _subtitle_download_info(
2249
+ project_id: str,
2250
+ state: dict[str, Any],
2251
+ ) -> dict[str, Any]:
2252
+ """
2253
+ Resolve the generated subtitle file path (if any) plus the existing
2254
+ download route for it, for inclusion in webhook payloads.
2255
+ """
2256
+ step_5 = state.get(
2257
+ "steps",
2258
+ {},
2259
+ ).get(
2260
+ "5",
2261
+ {},
2262
+ )
2263
+
2264
+ step_5_result = (
2265
+ step_5.get("result")
2266
+ or {}
2267
+ )
2268
+
2269
+ srt_path_value = step_5_result.get(
2270
+ "srt_path"
2271
+ )
2272
+
2273
+ subtitle_path: str | None = None
2274
+
2275
+ if srt_path_value and Path(
2276
+ srt_path_value
2277
+ ).exists():
2278
+ subtitle_path = str(
2279
+ Path(
2280
+ srt_path_value
2281
+ ).resolve()
2282
+ )
2283
+ else:
2284
+ subtitles_dir = (
2285
+ OUTPUT_ROOT
2286
+ / project_id
2287
+ / "subtitles"
2288
+ )
2289
+
2290
+ if subtitles_dir.exists():
2291
+ srt_files = sorted(
2292
+ subtitles_dir.glob(
2293
+ "*.srt"
2294
+ ),
2295
+ key=lambda path: path.stat().st_mtime,
2296
+ reverse=True,
2297
+ )
2298
+
2299
+ if srt_files:
2300
+ subtitle_path = str(
2301
+ srt_files[0].resolve()
2302
+ )
2303
+
2304
+ return {
2305
+ "subtitle_file_path": subtitle_path,
2306
+ "subtitle_ready": subtitle_path is not None,
2307
+ # This route already exists in this API and streams the .srt file.
2308
+ "subtitle_download_url": (
2309
+ f"/projects/{project_id}/subtitle"
2310
+ ),
2311
+ }
2312
+
2313
+
2314
+ def _send_webhook(
2315
+ state: dict[str, Any],
2316
+ logger: ProjectLogger,
2317
+ event: str,
2318
+ *,
2319
+ step_number: int | None = None,
2320
+ step_state: dict[str, Any] | None = None,
2321
+ ) -> None:
2322
+ """
2323
+ Best-effort webhook delivery.
2324
+
2325
+ Fired:
2326
+ - after every step completes (success or failure): includes that
2327
+ step's own detailed logs, plus every log line for the project so
2328
+ far ("previous logs too, related to that project").
2329
+ - once more when the whole pipeline finishes: includes ALL logs for
2330
+ the project plus the subtitle file path / download link.
2331
+
2332
+ Failures here are logged but NEVER raised — a webhook outage must not
2333
+ break the pipeline.
2334
+ """
2335
+ webhook_url = state.get(
2336
+ "webhook_url"
2337
+ )
2338
+
2339
+ if not webhook_url:
2340
+ return
2341
+
2342
+ project_id = state[
2343
+ "project_id"
2344
+ ]
2345
+
2346
+ payload: dict[str, Any] = {
2347
+ "event": event,
2348
+ "project_id": project_id,
2349
+ "sent_at_ist": ist_now_iso(),
2350
+ "status": state.get("status"),
2351
+ "current_step": state.get("current_step"),
2352
+ "current_step_name": state.get("current_step_name"),
2353
+ "last_completed_step": state.get(
2354
+ "last_completed_step",
2355
+ 0,
2356
+ ),
2357
+ "error": state.get("error"),
2358
+ "logs_so_far": logger.all_lines(),
2359
+ }
2360
+
2361
+ if step_number is not None:
2362
+ payload["step"] = {
2363
+ "number": step_number,
2364
+ "name": STEPS[step_number - 1]["name"],
2365
+ "status": (step_state or {}).get("status"),
2366
+ "started_at": (step_state or {}).get("started_at"),
2367
+ "completed_at": (step_state or {}).get("completed_at"),
2368
+ "error": (step_state or {}).get("error"),
2369
+ "result": (step_state or {}).get("result"),
2370
+ }
2371
+ payload["step_logs"] = logger.lines_for_step(
2372
+ step_number
2373
+ )
2374
+
2375
+ if event == "pipeline_completed":
2376
+ payload.update(
2377
+ _subtitle_download_info(
2378
+ project_id,
2379
+ state,
2380
+ )
2381
+ )
2382
+ payload["completed_at"] = state.get(
2383
+ "completed_at"
2384
+ )
2385
+
2386
+ try:
2387
+ with httpx.Client(
2388
+ timeout=httpx.Timeout(
2389
+ WEBHOOK_TIMEOUT_SECONDS
2390
+ ),
2391
+ ) as client:
2392
+ response = client.post(
2393
+ webhook_url,
2394
+ json=payload,
2395
+ )
2396
+
2397
+ logger.write_step(
2398
+ step_number,
2399
+ "WEBHOOK",
2400
+ (
2401
+ f"Webhook delivered | event={event} | "
2402
+ f"url={webhook_url} | "
2403
+ f"response_status={response.status_code}"
2404
+ ),
2405
+ )
2406
+
2407
+ except Exception as exc:
2408
+ logger.write_step(
2409
+ step_number,
2410
+ "WEBHOOK",
2411
+ (
2412
+ f"Webhook delivery FAILED | event={event} | "
2413
+ f"url={webhook_url} | error={exc}"
2414
+ ),
2415
+ level=logging.WARNING,
2416
+ )
2417
+
2418
+
2419
  def _run_step_1(
2420
  project_id: str,
2421
  source_file: Path,
 
2697
  _record_event(
2698
  state,
2699
  logger,
2700
+ step_log_message(
2701
+ None,
2702
+ "PROJECT",
2703
+ f"Pipeline starting from Step {start_step:03d} "
2704
+ f"at {ist_now_display()}.",
2705
+ ),
2706
  )
2707
 
2708
  try:
 
2726
  _record_event(
2727
  state,
2728
  logger,
2729
+ step_log_message(
2730
+ number,
2731
+ "STARTED",
2732
+ f"{step_name} | started_at={ist_now_display()}",
2733
+ ),
2734
  )
2735
 
2736
  try:
 
2758
  _record_event(
2759
  state,
2760
  logger,
2761
+ step_log_message(
2762
+ number,
2763
+ "FAILED",
2764
+ f"{step_name} | error={exc} | "
2765
+ f"failed_at={ist_now_display()}",
2766
  ),
2767
  level=logging.ERROR,
2768
  )
2769
 
2770
+ # Notify the optional webhook: this step's detailed logs +
2771
+ # every previous log line for this project.
2772
+ _send_webhook(
2773
+ state,
2774
+ logger,
2775
+ event="step_failed",
2776
+ step_number=number,
2777
+ step_state=state["steps"][str(number)],
2778
+ )
2779
+
2780
  return
2781
 
2782
  _mark_step_success(
 
2788
  _record_event(
2789
  state,
2790
  logger,
2791
+ step_log_message(
2792
+ number,
2793
+ "COMPLETED",
2794
+ f"{step_name} | completed_at={ist_now_display()}",
2795
+ ),
2796
+ )
2797
+
2798
+ # Notify the optional webhook: this step's detailed logs +
2799
+ # every previous log line for this project so far.
2800
+ _send_webhook(
2801
+ state,
2802
+ logger,
2803
+ event="step_completed",
2804
+ step_number=number,
2805
+ step_state=state["steps"][str(number)],
2806
  )
2807
 
2808
  state["status"] = "completed"
 
2818
  _record_event(
2819
  state,
2820
  logger,
2821
+ step_log_message(
2822
+ None,
2823
+ "SUCCESS",
2824
+ "PIPELINE COMPLETED SUCCESSFULLY at "
2825
+ f"{ist_now_display()}.",
2826
+ ),
2827
+ )
2828
+
2829
+ # Final webhook: all logs for the project + subtitle file path /
2830
+ # download link.
2831
+ _send_webhook(
2832
+ state,
2833
+ logger,
2834
+ event="pipeline_completed",
2835
  )
2836
 
2837
  finally:
 
2878
  # API ROUTES
2879
  # ============================================================================
2880
 
2881
+ @app.get(
2882
+ "/",
2883
+ )
2884
+ async def root() -> dict[str, Any]:
2885
+ """
2886
+ Root endpoint — simple service banner + a directory of available
2887
+ routes, so hitting the base URL is never a 404.
2888
+ """
2889
+ with ACTIVE_PROJECTS_LOCK:
2890
+ active = sorted(
2891
+ ACTIVE_PROJECTS
2892
+ )
2893
+
2894
+ return {
2895
+ "service": "Subtitle Pipeline Service",
2896
+ "version": app.version,
2897
+ "status": "ok",
2898
+ "message": (
2899
+ "Subtitle Pipeline API is running. "
2900
+ "See /docs for interactive API documentation."
2901
+ ),
2902
+ "server_time_ist": ist_now_display(),
2903
+ "active_projects": active,
2904
+ "endpoints": {
2905
+ "root": "GET /",
2906
+ "health": "GET /health",
2907
+ "docs": "GET /docs",
2908
+ "create_project": "POST /projects",
2909
+ "project_status": "GET /projects/{project_id}",
2910
+ "project_logs": "GET /projects/{project_id}/logs",
2911
+ "resume_project": "POST /projects/{project_id}/resume",
2912
+ "download_subtitle": "GET /projects/{project_id}/subtitle",
2913
+ },
2914
+ }
2915
+
2916
+
2917
  @app.get(
2918
  "/health",
2919
  )
 
2951
  request.file_url
2952
  )
2953
 
2954
+ webhook_url = (
2955
+ str(
2956
+ request.webhook_url
2957
+ )
2958
+ if request.webhook_url
2959
+ else None
2960
+ )
2961
+
2962
  paths = project_paths(
2963
  project_id
2964
  )
 
2992
  state = _initial_state(
2993
  project_id,
2994
  source_url,
2995
+ webhook_url,
2996
  )
2997
 
2998
  _write_state(
2999
  state
3000
  )
3001
 
3002
+ logger.write_step(
3003
+ None,
3004
+ "PROJECT",
3005
+ (
3006
+ f"Project created: {project_id} | source_url={source_url} | "
3007
+ "webhook_url="
3008
+ + (
3009
+ webhook_url
3010
+ if webhook_url
3011
+ else "(none)"
3012
+ )
3013
+ ),
3014
  )
3015
 
3016
  try:
 
3043
  _record_event(
3044
  state,
3045
  logger,
3046
+ step_log_message(
3047
+ None,
3048
+ "PROJECT",
3049
  "Source media downloaded successfully. "
3050
+ f"Submitting pipeline at {ist_now_display()}.",
3051
  ),
3052
  )
3053
 
 
3069
  state
3070
  )
3071
 
3072
+ logger.write_step(
3073
+ None,
3074
+ "FAILED",
3075
+ f"PROJECT CREATION FAILED | {exc} | "
3076
+ f"failed_at={ist_now_display()}",
3077
+ level=logging.ERROR,
3078
  )
3079
 
3080
  raise HTTPException(
 
3283
  _record_event(
3284
  state,
3285
  logger,
3286
+ step_log_message(
3287
+ start_step,
3288
+ "RESUME",
3289
+ f"Restarting from Step {start_step:03d} | "
3290
+ f"{STEPS[start_step - 1]['name']} | "
3291
+ f"requested_at={ist_now_display()}",
3292
  ),
3293
  )
3294
 
 
3417
  # PIPELINE_EXECUTOR.shutdown(
3418
  # wait=False,
3419
  # cancel_futures=False,
3420
+ # )
 
src/pipeline/step_001_separate.py CHANGED
@@ -20,44 +20,52 @@ from dataclasses import dataclass
20
  from pathlib import Path
21
  from typing import Final
22
 
23
- from audio_separator.separator import Separator
24
 
25
 
26
  # ---------------------------------------------------------------------------
27
- # Configuration
28
  # ---------------------------------------------------------------------------
29
 
30
- # Exact registered model name in audio-separator.
31
- # This triggers automatic download and uses ONNX for 10x-15x faster CPU execution.
32
- DEFAULT_MODEL: Final[str] = "UVR-MDX-NET-Voc_FT.onnx"
33
- DEFAULT_SAMPLE_RATE: Final[int] = 44_100
34
- DEFAULT_NORMALIZATION: Final[float] = 0.9
35
- DEFAULT_MODEL_DIR: Final[Path] = Path("models/audio-separator")
36
- DEFAULT_FFMPEG_AUDIO_CHANNELS: Final[int] = 2
 
 
37
 
38
- VOCAL_OUTPUT_NAME: Final[str] = "vocal.wav"
39
- INSTRUMENT_OUTPUT_NAME: Final[str] = "instrument.wav"
 
 
40
 
41
- LOGGER = logging.getLogger(__name__)
42
 
43
 
44
  def _configure_cpu_threads() -> int:
45
- """Configure PyTorch, OpenMP, and ONNX Runtime thread pools for CPU execution."""
46
- cpu_count = os.cpu_count() or 1
47
- str_cpus = str(cpu_count)
48
-
49
- # Force CPU thread pools to match actual hardware core count
50
- os.environ["OMP_NUM_THREADS"] = str_cpus
51
- os.environ["MKL_NUM_THREADS"] = str_cpus
52
- os.environ["OPENBLAS_NUM_THREADS"] = str_cpus
53
- os.environ["NUMEXPR_NUM_THREADS"] = str_cpus
54
- os.environ["VECLIB_MAXIMUM_THREADS"] = str_cpus
55
- os.environ["NUMPY_NUM_THREADS"] = str_cpus
 
 
 
 
56
 
57
  try:
58
  import torch
59
 
60
- torch.set_num_threads(cpu_count)
61
  try:
62
  torch.set_num_interop_threads(1)
63
  except RuntimeError:
@@ -65,8 +73,29 @@ def _configure_cpu_threads() -> int:
65
  except Exception as exc:
66
  LOGGER.warning("Could not configure PyTorch CPU threads: %s", exc)
67
 
68
- LOGGER.info("CPU optimization: using %d logical CPU cores", cpu_count)
69
- return cpu_count
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
 
72
  @dataclass(frozen=True)
@@ -171,7 +200,7 @@ def _extract_to_wav(input_path: Path, temp_dir: Path, sample_rate: int) -> Path:
171
  "-vn",
172
  "-ac", str(DEFAULT_FFMPEG_AUDIO_CHANNELS),
173
  "-ar", str(sample_rate),
174
- "-threads", "0",
175
  "-c:a", "pcm_s16le",
176
  str(output_path),
177
  ]
@@ -213,7 +242,7 @@ def _make_separator(
213
  mdx_params={
214
  "hop_length": 1024,
215
  "segment_size": 256,
216
- "overlap": 0.25, # Drastically speeds up execution
217
  "batch_size": 1,
218
  "enable_denoise": False,
219
  },
@@ -334,7 +363,6 @@ def run_audio_separation(
334
  input_file_path: str | os.PathLike[str],
335
  output_dir: str | os.PathLike[str],
336
  ) -> tuple[str, str]:
337
- _configure_cpu_threads()
338
 
339
  input_path = Path(input_file_path).expanduser().resolve()
340
  output_path = Path(output_dir).expanduser().resolve()
@@ -373,7 +401,6 @@ def run_audio_separation(
373
  model_to_use = os.getenv("AUDIO_SEPARATOR_MODEL", DEFAULT_MODEL)
374
 
375
  try:
376
- # audio-separator handles the download internally if the model is missing
377
  separator.load_model(model_filename=model_to_use)
378
  except TypeError:
379
  separator.load_model(model_to_use)
@@ -382,7 +409,7 @@ def run_audio_separation(
382
  f"Failed to load separation model '{model_to_use}'."
383
  ) from exc
384
 
385
- LOGGER.info("Separating with %s", model_to_use)
386
 
387
  try:
388
  try:
 
20
  from pathlib import Path
21
  from typing import Final
22
 
23
+ LOGGER = logging.getLogger(__name__)
24
 
25
 
26
  # ---------------------------------------------------------------------------
27
+ # Dynamic Thread & Core Configuration (Executed BEFORE C++ Runtime Imports)
28
  # ---------------------------------------------------------------------------
29
 
30
+ def _detect_physical_cores() -> int:
31
+ """Dynamically detect physical CPU cores to prevent thread thrashing."""
32
+ try:
33
+ import psutil
34
+ physical_cores = psutil.cpu_count(logical=False)
35
+ if physical_cores and physical_cores > 0:
36
+ return physical_cores
37
+ except Exception:
38
+ pass
39
 
40
+ # Fallback using logical count / 2 (standard hyperthreading ratio)
41
+ logical_cores = os.cpu_count()
42
+ if logical_cores and logical_cores > 1:
43
+ return max(2, logical_cores // 2)
44
 
45
+ return 2 # Safe default core limit
46
 
47
 
48
  def _configure_cpu_threads() -> int:
49
+ """Configure PyTorch, OpenMP, HuggingFace, and ONNX Runtime thread pools for CPU execution."""
50
+ cores = _detect_physical_cores()
51
+ str_cores = str(cores)
52
+
53
+ # Force C/C++ underlying thread pools to match physical CPU core count
54
+ os.environ["OMP_NUM_THREADS"] = str_cores
55
+ os.environ["MKL_NUM_THREADS"] = str_cores
56
+ os.environ["OPENBLAS_NUM_THREADS"] = str_cores
57
+ os.environ["NUMEXPR_NUM_THREADS"] = str_cores
58
+ os.environ["VECLIB_MAXIMUM_THREADS"] = str_cores
59
+ os.environ["NUMPY_NUM_THREADS"] = str_cores
60
+
61
+ # ONNX & Hugging Face threading control
62
+ os.environ["OMP_WAIT_POLICY"] = "PASSIVE"
63
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
64
 
65
  try:
66
  import torch
67
 
68
+ torch.set_num_threads(cores)
69
  try:
70
  torch.set_num_interop_threads(1)
71
  except RuntimeError:
 
73
  except Exception as exc:
74
  LOGGER.warning("Could not configure PyTorch CPU threads: %s", exc)
75
 
76
+ LOGGER.info("CPU optimization: dynamically locked execution to %d physical CPU cores", cores)
77
+ return cores
78
+
79
+
80
+ # Initialize thread locks prior to loading heavy model dependencies
81
+ PHYSICAL_CPU_CORES = _configure_cpu_threads()
82
+
83
+ # Lazy import after environment variables are set
84
+ from audio_separator.separator import Separator # noqa: E402
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Configuration
89
+ # ---------------------------------------------------------------------------
90
+
91
+ DEFAULT_MODEL: Final[str] = "UVR-MDX-NET-Voc_FT.onnx"
92
+ DEFAULT_SAMPLE_RATE: Final[int] = 44_100
93
+ DEFAULT_NORMALIZATION: Final[float] = 0.9
94
+ DEFAULT_MODEL_DIR: Final[Path] = Path("models/audio-separator")
95
+ DEFAULT_FFMPEG_AUDIO_CHANNELS: Final[int] = 2
96
+
97
+ VOCAL_OUTPUT_NAME: Final[str] = "vocal.wav"
98
+ INSTRUMENT_OUTPUT_NAME: Final[str] = "instrument.wav"
99
 
100
 
101
  @dataclass(frozen=True)
 
200
  "-vn",
201
  "-ac", str(DEFAULT_FFMPEG_AUDIO_CHANNELS),
202
  "-ar", str(sample_rate),
203
+ "-threads", str(PHYSICAL_CPU_CORES),
204
  "-c:a", "pcm_s16le",
205
  str(output_path),
206
  ]
 
242
  mdx_params={
243
  "hop_length": 1024,
244
  "segment_size": 256,
245
+ "overlap": 0.25, # Drastically speeds up CPU execution
246
  "batch_size": 1,
247
  "enable_denoise": False,
248
  },
 
363
  input_file_path: str | os.PathLike[str],
364
  output_dir: str | os.PathLike[str],
365
  ) -> tuple[str, str]:
 
366
 
367
  input_path = Path(input_file_path).expanduser().resolve()
368
  output_path = Path(output_dir).expanduser().resolve()
 
401
  model_to_use = os.getenv("AUDIO_SEPARATOR_MODEL", DEFAULT_MODEL)
402
 
403
  try:
 
404
  separator.load_model(model_filename=model_to_use)
405
  except TypeError:
406
  separator.load_model(model_to_use)
 
409
  f"Failed to load separation model '{model_to_use}'."
410
  ) from exc
411
 
412
+ LOGGER.info("Separating with %s on %d physical cores", model_to_use, PHYSICAL_CPU_CORES)
413
 
414
  try:
415
  try:
src/pipeline/step_004_transcribe.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Pipeline Step 004 - Speaker-Aware Transcription.
3
 
4
  Project layout used by this pipeline:
5
 
@@ -27,8 +27,8 @@ Step 004:
27
  2. Uses every prepared speaker WAV referenced by the manifest.
28
  3. Uses faster-whisper + CTranslate2.
29
  4. Uses Whisper medium.
30
- 5. Uses INT8 CPU inference.
31
- 6. Uses the available physical CPU cores by default.
32
  7. Detects language automatically when WHISPER_LANGUAGE is unset.
33
  8. Stores language and confidence in the output.
34
  9. Produces word-level timestamps.
@@ -46,19 +46,6 @@ Important:
46
 
47
  from __future__ import annotations
48
 
49
- # ============================================================================
50
- # CPU THREAD ENVIRONMENT
51
- # ============================================================================
52
- #
53
- # CTranslate2 owns the model execution threads. Environment variables are set
54
- # before importing faster-whisper so native math libraries do not silently
55
- # create competing thread pools.
56
- #
57
- # Prefer physical cores. On virtualized environments os.cpu_count() often
58
- # reports logical CPUs, while physical-core count is usually the better default
59
- # for one heavy CPU inference job.
60
- # ============================================================================
61
-
62
  import gc
63
  import json
64
  import logging
@@ -66,8 +53,10 @@ import math
66
  import os
67
  import time
68
  from collections import Counter
 
69
  from dataclasses import dataclass
70
  from pathlib import Path
 
71
  from typing import Any, Final
72
 
73
  try:
@@ -103,26 +92,21 @@ def _detect_cpu_threads() -> int:
103
 
104
  CPU_THREADS: Final[int] = _detect_cpu_threads()
105
 
106
- os.environ.setdefault("OMP_NUM_THREADS", str(CPU_THREADS))
107
- os.environ.setdefault("MKL_NUM_THREADS", str(CPU_THREADS))
108
- os.environ.setdefault("OPENBLAS_NUM_THREADS", str(CPU_THREADS))
109
- os.environ.setdefault("NUMEXPR_NUM_THREADS", str(CPU_THREADS))
 
 
 
 
 
110
  os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
111
 
112
  # ============================================================================
113
  # CONFIGURATION
114
  # ============================================================================
115
 
116
- # This file lives at:
117
- # subtitle/src/pipeline/step_004_transcribe.py
118
- #
119
- # parents[1] is:
120
- # subtitle/src
121
- #
122
- # Therefore every Step 4 project is rooted at:
123
- # subtitle/src/output/<project_id>
124
- #
125
- # This is the path rule used by Step 3 as well.
126
  SRC_ROOT: Final[Path] = Path(__file__).resolve().parents[1]
127
  DEFAULT_OUTPUT_ROOT: Final[Path] = SRC_ROOT / "output"
128
 
@@ -136,9 +120,6 @@ COMPUTE_TYPE: Final[str] = os.getenv(
136
  "int8",
137
  )
138
 
139
- # One model copy per process. CTranslate2 uses CPU threads inside that model.
140
- NUM_WORKERS: Final[int] = 1
141
-
142
  BEAM_SIZE: Final[int] = max(
143
  1,
144
  int(os.getenv("WHISPER_BEAM_SIZE", "5")),
@@ -148,20 +129,15 @@ WORD_TIMESTAMPS: Final[bool] = True
148
 
149
  TASK: Final[str] = "transcribe"
150
 
151
- # Empty => automatic language detection.
152
  REQUESTED_LANGUAGE: Final[str | None] = (
153
  os.getenv("WHISPER_LANGUAGE") or None
154
  )
155
 
156
- # Set to 1 only for genuinely multilingual/code-switching content.
157
  DETECT_LANGUAGE_PER_CHUNK: Final[bool] = (
158
  os.getenv("DETECT_LANGUAGE_PER_CHUNK", "0").lower()
159
  in {"1", "true", "yes", "on"}
160
  )
161
 
162
- # VAD is optional. For these short speaker-isolated chunks it is useful for
163
- # long internal silence and hallucination control, but it is not needed to
164
- # reconstruct the original timeline because Step 003 supplies the offset.
165
  ENABLE_VAD: Final[bool] = (
166
  os.getenv("WHISPER_VAD", "1").lower()
167
  in {"1", "true", "yes", "on"}
@@ -174,12 +150,10 @@ VAD_MIN_SILENCE_MS: Final[int] = int(
174
  )
175
  )
176
 
177
- # Whisper/faster-whisper decoding safeguards.
178
  NO_SPEECH_THRESHOLD: Final[float] = 0.6
179
  LOG_PROB_THRESHOLD: Final[float] = -1.0
180
  COMPRESSION_RATIO_THRESHOLD: Final[float] = 2.4
181
 
182
- # Native faster-whisper language detection.
183
  LANGUAGE_DETECTION_SEGMENTS: Final[int] = max(
184
  1,
185
  int(
@@ -197,8 +171,6 @@ LANGUAGE_DETECTION_THRESHOLD: Final[float] = float(
197
  )
198
  )
199
 
200
- # If auto-detection on a chunk is below this probability, keep detecting on
201
- # later chunks instead of permanently locking the project to a weak guess.
202
  LANGUAGE_LOCK_THRESHOLD: Final[float] = float(
203
  os.getenv(
204
  "LANGUAGE_LOCK_THRESHOLD",
@@ -206,7 +178,6 @@ LANGUAGE_LOCK_THRESHOLD: Final[float] = float(
206
  )
207
  )
208
 
209
- # Manifest is checkpointed after every processed chunk.
210
  CHECKPOINT_EVERY_CHUNKS: Final[int] = 1
211
 
212
  FORCE_RETRANSCRIBE: Final[bool] = (
@@ -247,7 +218,6 @@ def _validate_project_id(project_id: str) -> str:
247
  if value in {".", ".."}:
248
  raise ValueError("Invalid project_id.")
249
 
250
- # A project ID is a directory name, never a nested path.
251
  if Path(value).name != value:
252
  raise ValueError(
253
  "project_id must be a single directory-safe name."
@@ -494,7 +464,6 @@ def _resolve_audio_path(
494
  if path.is_absolute():
495
  return path.resolve()
496
 
497
- # Step 3 stores relative audio paths relative to project_dir.
498
  project_dir = (
499
  manifest_path.parent.parent
500
  )
@@ -506,7 +475,6 @@ def _resolve_audio_path(
506
  if candidate.exists():
507
  return candidate.resolve()
508
 
509
- # Backward-compatible fallback.
510
  return (
511
  manifest_path.parent / path
512
  ).resolve()
@@ -539,7 +507,7 @@ def _load_model() -> Any:
539
  "cpu_threads=%d num_workers=%d",
540
  MODEL_NAME,
541
  COMPUTE_TYPE,
542
- CPU_THREADS,
543
  NUM_WORKERS,
544
  )
545
 
@@ -552,8 +520,8 @@ def _load_model() -> Any:
552
  MODEL_NAME,
553
  device="cpu",
554
  compute_type=COMPUTE_TYPE,
555
- cpu_threads=CPU_THREADS,
556
- num_workers=NUM_WORKERS,
557
  )
558
  except Exception as exc:
559
  raise RuntimeError(
@@ -624,14 +592,6 @@ def _transcribe_one(
624
  audio_path: Path,
625
  language: str | None,
626
  ):
627
- """
628
- Run one speaker chunk.
629
-
630
- When language=None, faster-whisper performs native Whisper language
631
- detection and returns info.language / info.language_probability.
632
-
633
- Word timestamps are requested because Step 005 needs them.
634
- """
635
  kwargs: dict[str, Any] = {
636
  "language": language,
637
  "task": TASK,
@@ -1002,28 +962,12 @@ def _summarize_languages(
1002
  def run_transcription(
1003
  project_id: str,
1004
  ) -> dict[str, Any]:
1005
- """
1006
- Run Step 004 for one project.
1007
-
1008
- Only project_id is required.
1009
-
1010
- Input:
1011
- src/output/<project_id>/segments/segments_manifest.json
1012
-
1013
- Output:
1014
- src/output/<project_id>/transcribe/transcription.json
1015
- src/output/<project_id>/transcribe/step_004_manifest.json
1016
-
1017
- Also updates:
1018
- src/output/<project_id>/segments/segments_manifest.json
1019
- """
1020
  started = time.perf_counter()
1021
 
1022
  paths = _get_paths(
1023
  project_id
1024
  )
1025
 
1026
- # Fail early and clearly before loading the model.
1027
  _validate_file(
1028
  paths.segments_manifest,
1029
  "Step 003 segments_manifest.json",
@@ -1097,8 +1041,6 @@ def run_transcription(
1097
  REQUESTED_LANGUAGE
1098
  )
1099
 
1100
- # When auto-detecting, do not immediately lock the entire project to a
1101
- # low-confidence first chunk.
1102
  project_language: str | None = requested_language
1103
  project_language_probability: float | None = None
1104
  language_votes: list[tuple[str, float]] = []
@@ -1109,92 +1051,32 @@ def run_transcription(
1109
  total_words = 0
1110
  total_inference_seconds = 0.0
1111
 
1112
- # ========================================================================
1113
- # CHUNK LOOP
1114
- # ========================================================================
1115
-
1116
- for index, chunk in enumerate(
1117
- chunks,
1118
- start=1,
1119
- ):
1120
- chunk_id = chunk.get(
1121
- "chunk_id",
1122
- index,
1123
- )
1124
 
1125
- speaker = str(
1126
- chunk.get(
1127
- "speaker",
1128
- "UNKNOWN",
1129
- )
1130
- )
1131
-
1132
- # ----------------------------------------------------
1133
- # Resume
1134
- # ----------------------------------------------------
1135
-
1136
- if (
1137
- not FORCE_RETRANSCRIBE
1138
- and _chunk_successful(
1139
- chunk
1140
- )
1141
- ):
1142
- skipped += 1
1143
-
1144
- previous_language = (
1145
- chunk.get(
1146
- "transcription",
1147
- {},
1148
- ).get(
1149
- "language"
1150
- )
1151
- )
1152
 
 
 
1153
  previous_probability = _safe_float(
1154
- chunk.get(
1155
- "transcription",
1156
- {},
1157
- ).get(
1158
- "language_probability",
1159
- 0.0,
1160
- )
1161
- )
1162
-
1163
- if previous_language:
1164
- language_votes.append(
1165
- (
1166
- str(
1167
- previous_language
1168
- ),
1169
- previous_probability,
1170
- )
1171
- )
1172
-
1173
- LOGGER.info(
1174
- "[%d/%d] chunk=%s speaker=%s -> SKIP",
1175
- index,
1176
- len(chunks),
1177
- chunk_id,
1178
- speaker,
1179
  )
1180
- continue
1181
-
1182
- # ----------------------------------------------------
1183
- # Resolve chunk audio
1184
- # ----------------------------------------------------
1185
-
1186
- audio = chunk.get(
1187
- "audio",
1188
- {}
1189
- )
1190
 
1191
- audio_path_value = audio.get(
1192
- "path"
1193
- )
1194
 
1195
  if not audio_path_value:
1196
- failed += 1
1197
-
1198
  chunk["transcription"] = {
1199
  "status": "failed",
1200
  "completed": False,
@@ -1203,41 +1085,14 @@ def run_transcription(
1203
  "language_probability": None,
1204
  "words": [],
1205
  "word_count": 0,
1206
- "error": (
1207
- "Missing audio.path "
1208
- "in segments_manifest.json."
1209
- ),
1210
  }
 
1211
 
1212
- _checkpoint_manifest(
1213
- paths.segments_manifest,
1214
- manifest,
1215
- )
1216
- continue
1217
-
1218
- audio_path = _resolve_audio_path(
1219
- paths.segments_manifest,
1220
- str(
1221
- audio_path_value
1222
- ),
1223
- )
1224
 
1225
  if not audio_path.exists():
1226
- failed += 1
1227
-
1228
- error = (
1229
- "Chunk audio not found: "
1230
- f"{audio_path}"
1231
- )
1232
-
1233
- LOGGER.error(
1234
- "[%d/%d] chunk=%s -> %s",
1235
- index,
1236
- len(chunks),
1237
- chunk_id,
1238
- error,
1239
- )
1240
-
1241
  chunk["transcription"] = {
1242
  "status": "failed",
1243
  "completed": False,
@@ -1248,157 +1103,40 @@ def run_transcription(
1248
  "word_count": 0,
1249
  "error": error,
1250
  }
 
1251
 
1252
- _checkpoint_manifest(
1253
- paths.segments_manifest,
1254
- manifest,
1255
- )
1256
- continue
1257
-
1258
- # ----------------------------------------------------
1259
- # Source timeline from Step 003
1260
- # ----------------------------------------------------
1261
-
1262
- timeline = chunk.get(
1263
- "source_timeline",
1264
- {}
1265
- )
1266
-
1267
- source_start = max(
1268
- 0.0,
1269
- _safe_float(
1270
- timeline.get(
1271
- "start",
1272
- 0.0,
1273
- )
1274
- ),
1275
- )
1276
-
1277
- source_end = max(
1278
- source_start,
1279
- _safe_float(
1280
- timeline.get(
1281
- "end",
1282
- source_start,
1283
- )
1284
- ),
1285
- )
1286
-
1287
- LOGGER.info(
1288
- "[%d/%d] chunk=%s speaker=%s %.3f -> %.3f",
1289
- index,
1290
- len(chunks),
1291
- chunk_id,
1292
- speaker,
1293
- source_start,
1294
- source_end,
1295
- )
1296
-
1297
- # ----------------------------------------------------
1298
- # Choose language for this chunk
1299
- # ----------------------------------------------------
1300
 
1301
  if DETECT_LANGUAGE_PER_CHUNK:
1302
  language_for_chunk = None
1303
  else:
1304
- language_for_chunk = project_language
1305
-
1306
- # Auto detection has not yet found a sufficiently confident
1307
- # project language.
1308
- if (
1309
- requested_language is None
1310
- and (
1311
- project_language is None
1312
- or (
1313
- project_language_probability is not None
1314
- and project_language_probability
1315
- < LANGUAGE_LOCK_THRESHOLD
1316
- )
1317
- )
1318
- ):
1319
- language_for_chunk = None
1320
 
1321
  inference_started = time.perf_counter()
1322
-
1323
  try:
1324
  decoded_segments, info = _transcribe_one(
1325
  model,
1326
  audio_path,
1327
  language_for_chunk,
1328
  )
 
1329
 
1330
- inference_seconds = (
1331
- time.perf_counter()
1332
- - inference_started
1333
- )
1334
-
1335
- text = _extract_text(
1336
- decoded_segments
1337
- )
1338
 
1339
- detected_language, language_probability = (
1340
- _language_from_info(
1341
- info
1342
- )
1343
- )
1344
-
1345
- if detected_language:
1346
- language_votes.append(
1347
- (
1348
- detected_language,
1349
- language_probability or 0.0,
1350
- )
1351
- )
1352
-
1353
- # Lock the project language only after sufficient confidence.
1354
- if (
1355
- requested_language is None
1356
- and not DETECT_LANGUAGE_PER_CHUNK
1357
- and (
1358
- project_language is None
1359
- or (
1360
- language_probability is not None
1361
- and language_probability
1362
- > (
1363
- project_language_probability
1364
- or 0.0
1365
- )
1366
- )
1367
- )
1368
- ):
1369
- project_language = detected_language
1370
- project_language_probability = (
1371
- language_probability
1372
- )
1373
-
1374
- final_chunk_language = (
1375
- detected_language
1376
- or project_language
1377
- )
1378
-
1379
- words = _extract_word_timestamps(
1380
- decoded_segments,
1381
- source_start,
1382
- source_end,
1383
- )
1384
-
1385
- transcript_segments = _extract_segments(
1386
- decoded_segments,
1387
- source_start,
1388
- source_end,
1389
- )
1390
 
1391
  word_probabilities = [
1392
- float(
1393
- item["probability"]
1394
- )
1395
- for item in words
1396
- if "probability" in item
1397
  ]
1398
-
1399
  average_word_probability = (
1400
- sum(word_probabilities)
1401
- / len(word_probabilities)
1402
  if word_probabilities
1403
  else None
1404
  )
@@ -1409,20 +1147,9 @@ def run_transcription(
1409
  "text": text,
1410
  "language": final_chunk_language,
1411
  "language_probability": (
1412
- round(
1413
- language_probability,
1414
- 4,
1415
- )
1416
  if language_probability is not None
1417
- else (
1418
- round(
1419
- project_language_probability,
1420
- 4,
1421
- )
1422
- if project_language_probability
1423
- is not None
1424
- else None
1425
- )
1426
  ),
1427
  "model": MODEL_NAME,
1428
  "engine": "faster-whisper",
@@ -1436,73 +1163,33 @@ def run_transcription(
1436
  "word_count": len(words),
1437
  "segments": transcript_segments,
1438
  "average_word_probability": (
1439
- round(
1440
- average_word_probability,
1441
- 4,
1442
- )
1443
- if average_word_probability
1444
- is not None
1445
  else None
1446
  ),
1447
- "inference_time_seconds": round(
1448
- inference_seconds,
1449
- 3,
1450
- ),
1451
  }
1452
 
1453
  chunk["subtitle"] = {
1454
  "ready": bool(words),
1455
- "absolute_start": round(
1456
- source_start,
1457
- 3,
1458
- ),
1459
- "absolute_end": round(
1460
- source_end,
1461
- 3,
1462
- ),
1463
- "word_timestamps_available": bool(
1464
- words
1465
- ),
1466
  }
1467
 
1468
- successful += 1
1469
- total_words += len(words)
1470
- total_inference_seconds += (
1471
- inference_seconds
1472
- )
1473
-
1474
- LOGGER.info(
1475
- "[%d/%d] OK chunk=%s words=%d language=%s "
1476
- "prob=%.3f inference=%.2fs",
1477
- index,
1478
- len(chunks),
1479
- chunk_id,
1480
- len(words),
1481
- final_chunk_language or "unknown",
1482
- language_probability
1483
- if language_probability is not None
1484
- else (
1485
- project_language_probability
1486
- or 0.0
1487
- ),
1488
- inference_seconds,
1489
- )
1490
 
1491
  except Exception as exc:
1492
- inference_seconds = (
1493
- time.perf_counter()
1494
- - inference_started
1495
- )
1496
-
1497
- failed += 1
1498
-
1499
- LOGGER.exception(
1500
- "[%d/%d] FAILED chunk=%s",
1501
- index,
1502
- len(chunks),
1503
- chunk_id,
1504
- )
1505
-
1506
  chunk["transcription"] = {
1507
  "status": "failed",
1508
  "completed": False,
@@ -1517,55 +1204,102 @@ def run_transcription(
1517
  "words": [],
1518
  "word_count": 0,
1519
  "segments": [],
1520
- "inference_time_seconds": round(
1521
- inference_seconds,
1522
- 3,
1523
- ),
1524
  "error": str(exc),
1525
  }
1526
-
1527
  chunk["subtitle"] = {
1528
  "ready": False,
1529
- "absolute_start": round(
1530
- source_start,
1531
- 3,
1532
- ),
1533
- "absolute_end": round(
1534
- source_end,
1535
- 3,
1536
- ),
1537
  "word_timestamps_available": False,
1538
  }
 
1539
 
1540
- finally:
1541
- gc.collect()
 
1542
 
1543
- # ----------------------------------------------------
1544
- # Checkpoint after every chunk
1545
- # ----------------------------------------------------
 
 
1546
 
1547
- if (
1548
- index % CHECKPOINT_EVERY_CHUNKS == 0
1549
- ):
1550
- manifest["step_004_progress"] = {
1551
- "processed": index,
1552
- "total": len(chunks),
1553
- "successful": successful,
1554
- "failed": failed,
1555
- "skipped": skipped,
1556
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1557
 
1558
- _checkpoint_manifest(
1559
- paths.segments_manifest,
1560
- manifest,
1561
- )
 
 
 
 
 
 
 
1562
 
1563
- if (
1564
- index % MEMORY_LOG_INTERVAL == 0
1565
- ):
1566
- _log_memory(
1567
- f"after chunk {index}/{len(chunks)}"
1568
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1569
 
1570
  # =========================================================================
1571
  # FINAL LANGUAGE
@@ -1664,7 +1398,6 @@ def run_transcription(
1664
  ),
1665
  }
1666
 
1667
- # Remove temporary progress marker after completion.
1668
  manifest.pop(
1669
  "step_004_progress",
1670
  None,
@@ -1992,4 +1725,4 @@ if __name__ == "__main__":
1992
  LOGGER.exception(
1993
  "Step 004 failed."
1994
  )
1995
- raise SystemExit(1)
 
1
  """
2
+ Pipeline Step 004 - Speaker-Aware Transcription (Optimized Concurrency).
3
 
4
  Project layout used by this pipeline:
5
 
 
27
  2. Uses every prepared speaker WAV referenced by the manifest.
28
  3. Uses faster-whisper + CTranslate2.
29
  4. Uses Whisper medium.
30
+ 5. Uses INT8 CPU inference with optimized multi-worker concurrency.
31
+ 6. Uses the available physical CPU cores by default, split efficiently.
32
  7. Detects language automatically when WHISPER_LANGUAGE is unset.
33
  8. Stores language and confidence in the output.
34
  9. Produces word-level timestamps.
 
46
 
47
  from __future__ import annotations
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  import gc
50
  import json
51
  import logging
 
53
  import os
54
  import time
55
  from collections import Counter
56
+ from concurrent.futures import ThreadPoolExecutor, as_completed
57
  from dataclasses import dataclass
58
  from pathlib import Path
59
+ from threading import Lock
60
  from typing import Any, Final
61
 
62
  try:
 
92
 
93
  CPU_THREADS: Final[int] = _detect_cpu_threads()
94
 
95
+ # Optimize concurrent execution: split physical cores across parallel workers
96
+ # to maximize CPU throughput without thread contention.
97
+ NUM_WORKERS: Final[int] = max(1, min(CPU_THREADS // 4, 4))
98
+ THREADS_PER_WORKER: Final[int] = max(1, CPU_THREADS // NUM_WORKERS)
99
+
100
+ os.environ.setdefault("OMP_NUM_THREADS", str(THREADS_PER_WORKER))
101
+ os.environ.setdefault("MKL_NUM_THREADS", str(THREADS_PER_WORKER))
102
+ os.environ.setdefault("OPENBLAS_NUM_THREADS", str(THREADS_PER_WORKER))
103
+ os.environ.setdefault("NUMEXPR_NUM_THREADS", str(THREADS_PER_WORKER))
104
  os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
105
 
106
  # ============================================================================
107
  # CONFIGURATION
108
  # ============================================================================
109
 
 
 
 
 
 
 
 
 
 
 
110
  SRC_ROOT: Final[Path] = Path(__file__).resolve().parents[1]
111
  DEFAULT_OUTPUT_ROOT: Final[Path] = SRC_ROOT / "output"
112
 
 
120
  "int8",
121
  )
122
 
 
 
 
123
  BEAM_SIZE: Final[int] = max(
124
  1,
125
  int(os.getenv("WHISPER_BEAM_SIZE", "5")),
 
129
 
130
  TASK: Final[str] = "transcribe"
131
 
 
132
  REQUESTED_LANGUAGE: Final[str | None] = (
133
  os.getenv("WHISPER_LANGUAGE") or None
134
  )
135
 
 
136
  DETECT_LANGUAGE_PER_CHUNK: Final[bool] = (
137
  os.getenv("DETECT_LANGUAGE_PER_CHUNK", "0").lower()
138
  in {"1", "true", "yes", "on"}
139
  )
140
 
 
 
 
141
  ENABLE_VAD: Final[bool] = (
142
  os.getenv("WHISPER_VAD", "1").lower()
143
  in {"1", "true", "yes", "on"}
 
150
  )
151
  )
152
 
 
153
  NO_SPEECH_THRESHOLD: Final[float] = 0.6
154
  LOG_PROB_THRESHOLD: Final[float] = -1.0
155
  COMPRESSION_RATIO_THRESHOLD: Final[float] = 2.4
156
 
 
157
  LANGUAGE_DETECTION_SEGMENTS: Final[int] = max(
158
  1,
159
  int(
 
171
  )
172
  )
173
 
 
 
174
  LANGUAGE_LOCK_THRESHOLD: Final[float] = float(
175
  os.getenv(
176
  "LANGUAGE_LOCK_THRESHOLD",
 
178
  )
179
  )
180
 
 
181
  CHECKPOINT_EVERY_CHUNKS: Final[int] = 1
182
 
183
  FORCE_RETRANSCRIBE: Final[bool] = (
 
218
  if value in {".", ".."}:
219
  raise ValueError("Invalid project_id.")
220
 
 
221
  if Path(value).name != value:
222
  raise ValueError(
223
  "project_id must be a single directory-safe name."
 
464
  if path.is_absolute():
465
  return path.resolve()
466
 
 
467
  project_dir = (
468
  manifest_path.parent.parent
469
  )
 
475
  if candidate.exists():
476
  return candidate.resolve()
477
 
 
478
  return (
479
  manifest_path.parent / path
480
  ).resolve()
 
507
  "cpu_threads=%d num_workers=%d",
508
  MODEL_NAME,
509
  COMPUTE_TYPE,
510
+ THREADS_PER_WORKER,
511
  NUM_WORKERS,
512
  )
513
 
 
520
  MODEL_NAME,
521
  device="cpu",
522
  compute_type=COMPUTE_TYPE,
523
+ cpu_threads=THREADS_PER_WORKER,
524
+ num_workers=1,
525
  )
526
  except Exception as exc:
527
  raise RuntimeError(
 
592
  audio_path: Path,
593
  language: str | None,
594
  ):
 
 
 
 
 
 
 
 
595
  kwargs: dict[str, Any] = {
596
  "language": language,
597
  "task": TASK,
 
962
  def run_transcription(
963
  project_id: str,
964
  ) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
965
  started = time.perf_counter()
966
 
967
  paths = _get_paths(
968
  project_id
969
  )
970
 
 
971
  _validate_file(
972
  paths.segments_manifest,
973
  "Step 003 segments_manifest.json",
 
1041
  REQUESTED_LANGUAGE
1042
  )
1043
 
 
 
1044
  project_language: str | None = requested_language
1045
  project_language_probability: float | None = None
1046
  language_votes: list[tuple[str, float]] = []
 
1051
  total_words = 0
1052
  total_inference_seconds = 0.0
1053
 
1054
+ manifest_lock = Lock()
1055
+ completed_counter = 0
 
 
 
 
 
 
 
 
 
 
1056
 
1057
+ def process_chunk(index: int, chunk: dict[str, Any]) -> dict[str, Any]:
1058
+ chunk_id = chunk.get("chunk_id", index)
1059
+ speaker = str(chunk.get("speaker", "UNKNOWN"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1060
 
1061
+ if not FORCE_RETRANSCRIBE and _chunk_successful(chunk):
1062
+ previous_language = chunk.get("transcription", {}).get("language")
1063
  previous_probability = _safe_float(
1064
+ chunk.get("transcription", {}).get("language_probability", 0.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1065
  )
1066
+ return {
1067
+ "status": "skipped",
1068
+ "index": index,
1069
+ "chunk_id": chunk_id,
1070
+ "speaker": speaker,
1071
+ "language": previous_language,
1072
+ "probability": previous_probability,
1073
+ }
 
 
1074
 
1075
+ audio = chunk.get("audio", {})
1076
+ audio_path_value = audio.get("path")
 
1077
 
1078
  if not audio_path_value:
1079
+ error = "Missing audio.path in segments_manifest.json."
 
1080
  chunk["transcription"] = {
1081
  "status": "failed",
1082
  "completed": False,
 
1085
  "language_probability": None,
1086
  "words": [],
1087
  "word_count": 0,
1088
+ "error": error,
 
 
 
1089
  }
1090
+ return {"status": "failed", "index": index, "chunk_id": chunk_id, "error": error}
1091
 
1092
+ audio_path = _resolve_audio_path(paths.segments_manifest, str(audio_path_value))
 
 
 
 
 
 
 
 
 
 
 
1093
 
1094
  if not audio_path.exists():
1095
+ error = f"Chunk audio not found: {audio_path}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1096
  chunk["transcription"] = {
1097
  "status": "failed",
1098
  "completed": False,
 
1103
  "word_count": 0,
1104
  "error": error,
1105
  }
1106
+ return {"status": "failed", "index": index, "chunk_id": chunk_id, "error": error}
1107
 
1108
+ timeline = chunk.get("source_timeline", {})
1109
+ source_start = max(0.0, _safe_float(timeline.get("start", 0.0)))
1110
+ source_end = max(source_start, _safe_float(timeline.get("end", source_start)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1111
 
1112
  if DETECT_LANGUAGE_PER_CHUNK:
1113
  language_for_chunk = None
1114
  else:
1115
+ language_for_chunk = requested_language
1116
+ if requested_language is None and project_language is not None:
1117
+ language_for_chunk = project_language
 
 
 
 
 
 
 
 
 
 
 
 
 
1118
 
1119
  inference_started = time.perf_counter()
 
1120
  try:
1121
  decoded_segments, info = _transcribe_one(
1122
  model,
1123
  audio_path,
1124
  language_for_chunk,
1125
  )
1126
+ inference_seconds = time.perf_counter() - inference_started
1127
 
1128
+ text = _extract_text(decoded_segments)
1129
+ detected_language, language_probability = _language_from_info(info)
1130
+ final_chunk_language = detected_language or project_language
 
 
 
 
 
1131
 
1132
+ words = _extract_word_timestamps(decoded_segments, source_start, source_end)
1133
+ transcript_segments = _extract_segments(decoded_segments, source_start, source_end)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1134
 
1135
  word_probabilities = [
1136
+ float(item["probability"]) for item in words if "probability" in item
 
 
 
 
1137
  ]
 
1138
  average_word_probability = (
1139
+ sum(word_probabilities) / len(word_probabilities)
 
1140
  if word_probabilities
1141
  else None
1142
  )
 
1147
  "text": text,
1148
  "language": final_chunk_language,
1149
  "language_probability": (
1150
+ round(language_probability, 4)
 
 
 
1151
  if language_probability is not None
1152
+ else None
 
 
 
 
 
 
 
 
1153
  ),
1154
  "model": MODEL_NAME,
1155
  "engine": "faster-whisper",
 
1163
  "word_count": len(words),
1164
  "segments": transcript_segments,
1165
  "average_word_probability": (
1166
+ round(average_word_probability, 4)
1167
+ if average_word_probability is not None
 
 
 
 
1168
  else None
1169
  ),
1170
+ "inference_time_seconds": round(inference_seconds, 3),
 
 
 
1171
  }
1172
 
1173
  chunk["subtitle"] = {
1174
  "ready": bool(words),
1175
+ "absolute_start": round(source_start, 3),
1176
+ "absolute_end": round(source_end, 3),
1177
+ "word_timestamps_available": bool(words),
 
 
 
 
 
 
 
 
1178
  }
1179
 
1180
+ return {
1181
+ "status": "success",
1182
+ "index": index,
1183
+ "chunk_id": chunk_id,
1184
+ "speaker": speaker,
1185
+ "words_len": len(words),
1186
+ "inference_seconds": inference_seconds,
1187
+ "language": detected_language,
1188
+ "probability": language_probability,
1189
+ }
 
 
 
 
 
 
 
 
 
 
 
 
1190
 
1191
  except Exception as exc:
1192
+ inference_seconds = time.perf_counter() - inference_started
 
 
 
 
 
 
 
 
 
 
 
 
 
1193
  chunk["transcription"] = {
1194
  "status": "failed",
1195
  "completed": False,
 
1204
  "words": [],
1205
  "word_count": 0,
1206
  "segments": [],
1207
+ "inference_time_seconds": round(inference_seconds, 3),
 
 
 
1208
  "error": str(exc),
1209
  }
 
1210
  chunk["subtitle"] = {
1211
  "ready": False,
1212
+ "absolute_start": round(source_start, 3),
1213
+ "absolute_end": round(source_end, 3),
 
 
 
 
 
 
1214
  "word_timestamps_available": False,
1215
  }
1216
+ return {"status": "failed_exception", "index": index, "chunk_id": chunk_id, "error": str(exc)}
1217
 
1218
+ # ========================================================================
1219
+ # CONCURRENT CHUNK EXECUTION
1220
+ # ========================================================================
1221
 
1222
+ with ThreadPoolExecutor(max_workers=NUM_WORKERS) as executor:
1223
+ futures = {
1224
+ executor.submit(process_chunk, index, chunk): (index, chunk)
1225
+ for index, chunk in enumerate(chunks, start=1)
1226
+ }
1227
 
1228
+ for future in as_completed(futures):
1229
+ res = future.result()
1230
+ status = res["status"]
1231
+ index = res["index"]
1232
+ chunk_id = res["chunk_id"]
1233
+
1234
+ with manifest_lock:
1235
+ completed_counter += 1
1236
+
1237
+ if status == "success":
1238
+ successful += 1
1239
+ total_words += res["words_len"]
1240
+ total_inference_seconds += res["inference_seconds"]
1241
+ lang = res["language"]
1242
+ prob = res["probability"]
1243
+ if lang:
1244
+ language_votes.append((lang, prob or 0.0))
1245
+ if (
1246
+ requested_language is None
1247
+ and not DETECT_LANGUAGE_PER_CHUNK
1248
+ and (
1249
+ project_language is None
1250
+ or (prob is not None and prob > (project_language_probability or 0.0))
1251
+ )
1252
+ ):
1253
+ project_language = lang
1254
+ project_language_probability = prob
1255
+
1256
+ LOGGER.info(
1257
+ "[%d/%d] OK chunk=%s words=%d language=%s prob=%.3f inference=%.2fs",
1258
+ index,
1259
+ len(chunks),
1260
+ chunk_id,
1261
+ res["words_len"],
1262
+ res["language"] or project_language or "unknown",
1263
+ prob or project_language_probability or 0.0,
1264
+ res["inference_seconds"],
1265
+ )
1266
 
1267
+ elif status == "skipped":
1268
+ skipped += 1
1269
+ if res["language"]:
1270
+ language_votes.append((str(res["language"]), res["probability"]))
1271
+ LOGGER.info(
1272
+ "[%d/%d] chunk=%s speaker=%s -> SKIP",
1273
+ index,
1274
+ len(chunks),
1275
+ chunk_id,
1276
+ res["speaker"],
1277
+ )
1278
 
1279
+ else:
1280
+ failed += 1
1281
+ LOGGER.error(
1282
+ "[%d/%d] chunk=%s -> %s",
1283
+ index,
1284
+ len(chunks),
1285
+ chunk_id,
1286
+ res.get("error", "Unknown error"),
1287
+ )
1288
+
1289
+ if completed_counter % CHECKPOINT_EVERY_CHUNKS == 0:
1290
+ manifest["step_004_progress"] = {
1291
+ "processed": completed_counter,
1292
+ "total": len(chunks),
1293
+ "successful": successful,
1294
+ "failed": failed,
1295
+ "skipped": skipped,
1296
+ }
1297
+ _checkpoint_manifest(paths.segments_manifest, manifest)
1298
+
1299
+ if completed_counter % MEMORY_LOG_INTERVAL == 0:
1300
+ _log_memory(f"after chunk {completed_counter}/{len(chunks)}")
1301
+
1302
+ gc.collect()
1303
 
1304
  # =========================================================================
1305
  # FINAL LANGUAGE
 
1398
  ),
1399
  }
1400
 
 
1401
  manifest.pop(
1402
  "step_004_progress",
1403
  None,
 
1725
  LOGGER.exception(
1726
  "Step 004 failed."
1727
  )
1728
+ raise SystemExit(1)