Full backend implementation with custom Odoo modules: - encoach_api: Core API, user management, JWT auth - encoach_exam: Exam generation (reading, writing, listening, speaking) - encoach_evaluate: AI-powered evaluation (writing, speaking) - encoach_training: Training tips and walkthrough - encoach_storage: File storage management - encoach_payment: Stripe, PayPal, Paymob integration - encoach_mail: Email notifications Made-with: Cursor
168 lines
5.6 KiB
Python
168 lines
5.6 KiB
Python
import logging
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
import numpy as np
|
|
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
|
|
from odoo.addons.encoach_ai.models.constants import GPT_MODELS, TEMPERATURE
|
|
from odoo.addons.encoach_ai.services.openai_service import EncoachOpenAIService
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
SAMPLE_RATE = 16000
|
|
CHUNK_SAMPLES = 480000 # 30 seconds at 16 kHz
|
|
OVERLAP_RATIO = 0.25
|
|
WHISPER_OPTIONS = {
|
|
"fp16": False,
|
|
"language": "English",
|
|
"verbose": False,
|
|
}
|
|
|
|
OVERLAP_CLEANUP_PROMPT = (
|
|
"The following are two transcribed segments from the same audio. "
|
|
"They overlap at the boundary. Remove any duplicated words at the "
|
|
"junction and return the cleaned, combined text as JSON: "
|
|
'{"text": "cleaned text"}'
|
|
)
|
|
|
|
DIALOG_DETECTION_PROMPT = (
|
|
"You are a helpful assistant designed to output JSON on either one of "
|
|
"these formats:\n"
|
|
'1 - {"dialog": [{"name": "name", "gender": "gender", "text": "text"}]}\n'
|
|
'2 - {"dialog": "text"}\n\n'
|
|
"A transcription of an audio file will be provided to you. Based on that "
|
|
"transcription you will need to determine whether the transcription is a "
|
|
"conversation or a monologue. If it is a conversation, output format 1. "
|
|
"If it is a monologue, output format 2."
|
|
)
|
|
|
|
_model_pool = []
|
|
_pool_executor = None
|
|
_pool_lock = None
|
|
|
|
|
|
def _get_pool(num_workers=4):
|
|
global _model_pool, _pool_executor, _pool_lock
|
|
import threading
|
|
if _pool_lock is None:
|
|
_pool_lock = threading.Lock()
|
|
|
|
with _pool_lock:
|
|
if _pool_executor is None:
|
|
import whisper
|
|
_logger.info("Loading %d Whisper model instances...", num_workers)
|
|
_model_pool = [whisper.load_model("base") for _ in range(num_workers)]
|
|
_pool_executor = ThreadPoolExecutor(max_workers=num_workers)
|
|
_logger.info("Whisper pool ready with %d workers", num_workers)
|
|
return _pool_executor, _model_pool
|
|
|
|
|
|
class EncoachWhisperService:
|
|
|
|
def __init__(self, env):
|
|
self.env = env
|
|
self.ai = EncoachOpenAIService(env)
|
|
self._num_workers = int(
|
|
env["ir.config_parameter"].sudo().get_param("encoach.whisper_workers", "4")
|
|
)
|
|
self._worker_idx = 0
|
|
import threading
|
|
self._idx_lock = threading.Lock()
|
|
|
|
def _get_model(self):
|
|
executor, pool = _get_pool(self._num_workers)
|
|
with self._idx_lock:
|
|
idx = self._worker_idx % len(pool)
|
|
self._worker_idx += 1
|
|
return pool[idx]
|
|
|
|
def transcribe(self, audio_data):
|
|
if not isinstance(audio_data, np.ndarray):
|
|
audio_data = np.frombuffer(audio_data, dtype=np.float32)
|
|
|
|
audio_data = audio_data.astype(np.float32)
|
|
if audio_data.max() > 1.0:
|
|
audio_data = audio_data / np.abs(audio_data).max()
|
|
|
|
chunks = self._chunk_audio(audio_data)
|
|
|
|
if len(chunks) == 1:
|
|
return self._transcribe_chunk(chunks[0])
|
|
|
|
executor, pool = _get_pool(self._num_workers)
|
|
futures = []
|
|
for i, chunk in enumerate(chunks):
|
|
model = pool[i % len(pool)]
|
|
future = executor.submit(self._transcribe_chunk_with_model, chunk, model)
|
|
futures.append(future)
|
|
|
|
segments = [f.result() for f in futures]
|
|
return self._merge_segments(segments)
|
|
|
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
|
|
def _transcribe_chunk(self, chunk):
|
|
model = self._get_model()
|
|
result = model.transcribe(chunk, **WHISPER_OPTIONS)
|
|
return result.get("text", "").strip()
|
|
|
|
@staticmethod
|
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
|
|
def _transcribe_chunk_with_model(chunk, model):
|
|
result = model.transcribe(chunk, **WHISPER_OPTIONS)
|
|
return result.get("text", "").strip()
|
|
|
|
def _merge_segments(self, segments):
|
|
if len(segments) <= 1:
|
|
return segments[0] if segments else ""
|
|
|
|
merged = segments[0]
|
|
for i in range(1, len(segments)):
|
|
result = self.ai.prediction(
|
|
model=GPT_MODELS["grading"],
|
|
messages=[
|
|
{"role": "system", "content": OVERLAP_CLEANUP_PROMPT},
|
|
{
|
|
"role": "user",
|
|
"content": (
|
|
f'Segment A (end): "...{merged[-200:]}"\n'
|
|
f'Segment B (start): "{segments[i][:200]}..."'
|
|
),
|
|
},
|
|
],
|
|
temperature=TEMPERATURE["grading"],
|
|
check_blacklisted=False,
|
|
)
|
|
if result and "text" in result:
|
|
merged = result["text"]
|
|
else:
|
|
merged = f"{merged} {segments[i]}"
|
|
return merged
|
|
|
|
def detect_dialog(self, transcript):
|
|
return self.ai.prediction(
|
|
model=GPT_MODELS["grading"],
|
|
messages=[
|
|
{"role": "system", "content": DIALOG_DETECTION_PROMPT},
|
|
{"role": "user", "content": transcript},
|
|
],
|
|
temperature=TEMPERATURE["grading"],
|
|
check_blacklisted=False,
|
|
)
|
|
|
|
@staticmethod
|
|
def _chunk_audio(audio, chunk_size=CHUNK_SAMPLES, overlap=OVERLAP_RATIO):
|
|
total = len(audio)
|
|
if total <= chunk_size:
|
|
return [audio]
|
|
|
|
step = int(chunk_size * (1 - overlap))
|
|
chunks = []
|
|
start = 0
|
|
while start < total:
|
|
end = min(start + chunk_size, total)
|
|
chunks.append(audio[start:end])
|
|
if end >= total:
|
|
break
|
|
start += step
|
|
return chunks
|