File size: 9,272 Bytes
6f77435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
954e0aa
6f77435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
"""
ASR Batch Workers โ€” Async batching infrastructure for voice transcription.

Architecture (mirrors Raij/src/smart_search/batch_workers.py, audio workers only):
  - Two asyncio.Queues: audio_en_queue and audio_ar_queue.
  - Two worker coroutines per queue drain jobs in micro-batches.
  - Workers offload heavy inference to a thread via run_in_executor.
  - A shared in-memory job_store tracks job status + results.
  - A warmup loop periodically keeps OpenMP threads alive between requests.
    Parakeet warmup runs every PARAKEET_WARMUP_EVERY cycles (full transcribe,
    must use the full pipeline to avoid corrupting TDT decoder state).
    wav2vec2 warmup runs every cycle (cheap raw forward pass).
"""

import asyncio
import time
import uuid
from loguru import logger
from typing import Any

from .constants import ASR_BATCH_MAX, ASR_BATCH_WINDOW_S, WARMUP_INTERVAL_S, PARAKEET_WARMUP_EVERY
from .schemas import AudioJob


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• Job Store โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

job_store: dict[str, dict[str, Any]] = {}
"""
{
    "<job_id>": {
        "status": "pending" | "processing" | "done" | "error",
        "result": <str transcript> | None,
        "error": <str> | None,
    }
}
"""


def create_job() -> str:
    """Create a new pending job and return its ID."""
    job_id = str(uuid.uuid4())
    job_store[job_id] = {"status": "pending", "result": None, "error": None}
    return job_id


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• Request-in-Flight Gate โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

_request_in_flight_count = 0


def set_request_in_flight(active: bool):
    """Increment/decrement in-flight counter used to gate warmup cycles."""
    global _request_in_flight_count
    if active:
        _request_in_flight_count += 1
    else:
        _request_in_flight_count = max(0, _request_in_flight_count - 1)


def is_request_in_flight() -> bool:
    return _request_in_flight_count > 0


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• Queues โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

audio_en_queue: asyncio.Queue[AudioJob] = asyncio.Queue()
audio_ar_queue: asyncio.Queue[AudioJob] = asyncio.Queue()


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• Workers โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

async def audio_en_worker():
    """
    Drains up to ASR_BATCH_MAX English audio jobs every ASR_BATCH_WINDOW_S seconds.
    Runs one batched Parakeet transcription via run_in_executor.
    Writes transcript into job_store and sets job.done.
    Cleans up temp audio files after processing.
    """
    import os
    from .models import transcribe_en_batch

    loop = asyncio.get_event_loop()

    while True:
        first_job: AudioJob = await audio_en_queue.get()
        batch: list[AudioJob] = [first_job]

        # Collect up to (ASR_BATCH_MAX - 1) more within the time window
        deadline = loop.time() + ASR_BATCH_WINDOW_S
        while len(batch) < ASR_BATCH_MAX:
            remaining = deadline - loop.time()
            if remaining <= 0:
                break
            try:
                job = await asyncio.wait_for(audio_en_queue.get(), timeout=remaining)
                batch.append(job)
            except asyncio.TimeoutError:
                break

        for job in batch:
            job_store[job.job_id]["status"] = "processing"

        try:
            set_request_in_flight(True)
            audio_paths = [job.audio_path for job in batch]
            transcripts = await loop.run_in_executor(None, transcribe_en_batch, audio_paths)

            for job, transcript in zip(batch, transcripts):
                if not transcript.strip():
                    job_store[job.job_id]["status"] = "error"
                    job_store[job.job_id]["error"] = "Could not transcribe audio. Please try again and speak clearly."
                else:
                    job_store[job.job_id]["status"] = "done"
                    job_store[job.job_id]["result"] = transcript
                job.done.set()

        except Exception as e:
            logger.error(f"English ASR batch failed: {e}", exc_info=True)
            for job in batch:
                job_store[job.job_id]["status"] = "error"
                job_store[job.job_id]["error"] = str(e)
                if not job.done.is_set():
                    job.done.set()
        finally:
            set_request_in_flight(False)
            for job in batch:
                try:
                    os.unlink(job.audio_path)
                except Exception:
                    pass


async def audio_ar_worker():
    """
    Drains up to ASR_BATCH_MAX Arabic audio jobs every ASR_BATCH_WINDOW_S seconds.
    Runs one batched wav2vec2 transcription via run_in_executor.
    Writes transcript into job_store and sets job.done.
    Cleans up temp audio files after processing.
    """
    import os
    from .models import transcribe_ar_batch

    loop = asyncio.get_event_loop()

    while True:
        first_job: AudioJob = await audio_ar_queue.get()
        batch: list[AudioJob] = [first_job]

        deadline = loop.time() + ASR_BATCH_WINDOW_S
        while len(batch) < ASR_BATCH_MAX:
            remaining = deadline - loop.time()
            if remaining <= 0:
                break
            try:
                job = await asyncio.wait_for(audio_ar_queue.get(), timeout=remaining)
                batch.append(job)
            except asyncio.TimeoutError:
                break

        for job in batch:
            job_store[job.job_id]["status"] = "processing"

        try:
            set_request_in_flight(True)
            audio_paths = [job.audio_path for job in batch]
            transcripts = await loop.run_in_executor(None, transcribe_ar_batch, audio_paths)

            for job, transcript in zip(batch, transcripts):
                if not transcript.strip():
                    job_store[job.job_id]["status"] = "error"
                    job_store[job.job_id]["error"] = "ู„ู… ูŠุชู… ุงู„ุชุนุฑู ุนู„ู‰ ุงู„ุตูˆุช. ุงู„ุฑุฌุงุก ุงู„ู…ุญุงูˆู„ุฉ ู…ุฑุฉ ุฃุฎุฑู‰ ูˆุงู„ุชุญุฏุซ ุจูˆุถูˆุญ."
                else:
                    job_store[job.job_id]["status"] = "done"
                    job_store[job.job_id]["result"] = transcript
                job.done.set()

        except Exception as e:
            logger.error(f"Arabic ASR batch failed: {e}", exc_info=True)
            for job in batch:
                job_store[job.job_id]["status"] = "error"
                job_store[job.job_id]["error"] = str(e)
                if not job.done.is_set():
                    job.done.set()
        finally:
            set_request_in_flight(False)
            for job in batch:
                try:
                    os.unlink(job.audio_path)
                except Exception:
                    pass


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• Warmup Loop โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

async def _asr_warmup_loop():
    """
    Periodically poke both ASR models to prevent OpenMP/MKL thread pool
    spin-down during idle periods.

    - wav2vec2: every WARMUP_INTERVAL_S seconds (raw forward pass, ~5-15ms)
    - Parakeet: every PARAKEET_WARMUP_EVERY cycles (~6 min at 45s/cycle)
      Uses full model.transcribe() to avoid corrupting TDT decoder cache.

    Skipped entirely if a real request is in flight.
    """
    from .models import warmup_parakeet, warmup_wav2vec2

    loop = asyncio.get_event_loop()
    parakeet_cycle = 0

    while True:
        await asyncio.sleep(WARMUP_INTERVAL_S)
        if is_request_in_flight():
            continue

        t0 = time.monotonic()
        try:
            await loop.run_in_executor(None, warmup_wav2vec2)
            parakeet_cycle += 1
            if parakeet_cycle >= PARAKEET_WARMUP_EVERY:
                parakeet_cycle = 0
                await loop.run_in_executor(None, warmup_parakeet)
        except Exception as e:
            logger.warning(f"โš ๏ธ ASR warmup cycle error (non-fatal): {e}")
            continue

        elapsed_ms = (time.monotonic() - t0) * 1000
        logger.info(f"๐Ÿ”ฅ ASR warmup cycle done in {elapsed_ms:.0f}ms")


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• Startup โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

_asr_workers_started = False


def start_asr_workers():
    """
    Launch all ASR async worker coroutines. Call once during app startup.
    - 2 English audio workers (Parakeet)
    - 2 Arabic audio workers (wav2vec2)
    - 1 warmup loop
    """
    global _asr_workers_started
    if _asr_workers_started:
        return
    _asr_workers_started = True

    for i in range(2):
        asyncio.create_task(audio_en_worker(), name=f"asr_en_worker_{i}")
    for i in range(2):
        asyncio.create_task(audio_ar_worker(), name=f"asr_ar_worker_{i}")
    asyncio.create_task(_asr_warmup_loop(), name="asr_warmup_loop")

    logger.info("โœ… ASR batch workers started (2 EN + 2 AR + warmup loop)")