From 09c59ddc6bbbff6613b9dc3bcdd4d6e59704e05a Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Sat, 14 Mar 2026 16:35:41 -0300 Subject: [PATCH] feat: add realtime audio service and speech endpoints --- outputs/audio/20260311_232859_3d0378.wav | Bin 0 -> 26688 bytes requirements.txt | 7 +- src/api/v1/endpoints/__init__.py | 4 +- src/api/v1/endpoints/speech.py | 23 ++ src/core/config.py | 8 + src/main.py | 3 +- src/services/realtime_audio_service.py | 113 +++++++++ src/services/speech_service.py | 289 +++++++++++++++-------- 8 files changed, 339 insertions(+), 108 deletions(-) create mode 100644 outputs/audio/20260311_232859_3d0378.wav create mode 100644 src/services/realtime_audio_service.py diff --git a/outputs/audio/20260311_232859_3d0378.wav b/outputs/audio/20260311_232859_3d0378.wav new file mode 100644 index 0000000000000000000000000000000000000000..29ebf5cd9e7645bc334b685cf8a767e200b02a4b GIT binary patch literal 26688 zcmdSgRa+cQ*C^n@-QC^Y-C=NoI|Lmx1b2dKaCdiiC%6RnkYK@`1PBlyFni{CukT0f zI_jA|xqH>>>guZ1;Jb)1!2ib$4D~bNx)8&>lLkZ;iOlgeEa2wvZ*am>FUk?5@V*+> z>$4HWjqMXh0mu=COc92Cg%J1tb{GF&>+ZR}J}PAXzsAWK(AQ%ZM;sFt7DdXrL?sF9Skf(4rs2i!hil1Mur81P(zO-F`-AN`Cu^kuOwFV zT46GB2uOjgbHb3g@A@fX2$2Rr{Ld5WCuH~F^@Pc<1~B5(*o=i0yDl!Gjxa7Gp@l|< z_7+<6y5DBSZYLMutLDz`72yhOGG*Y3dF+UFs-S-clxF>2z(MXU4$#1EiM&;+; zWue-L7cr-lD;FKzfG}bAV*60!-OzFEt5qq%Jtg8A?6(S|2h?;hOynTYwH2Gxn}vO) zhYA6&ZAzsk8}E|#TpGmu68zGO(oz!*b`SobxjYU3)skm~P~94w{hA=ArHt zBLaeI$2;96`9Pw}6ik6uc7tVJt_J&3?I2p;Wcgkz9c*kPICeJ6buq%zG=>NYI!~3$ zINl!$48*aAX*5S8x1_7>B&7BvIYco_)6}u_E~nI;ymH`Gh#LIrbiFmFqIXsQn^;$v z3k+UN{(eZ-`Xa+R%k9Q38_KE%z%m%u+?!!d@at=j@Pdc>$=K1J0|CcBwwXrpyO~*^ zxpCT;Vd-&8mKcV^fWx_6(P672_}r9_j_-wQc`<|6?ccwybj;rgUk|?3x4k@v3L+MiNz)2r1(u01dE07zc>mTX1i6k=2{`#Bb$XN|tJ;F-J+!Ui5Y5eD@nUIi zPj1`VQr8PmT*LFV;vE9iPr)Jf<_Wl--wwasYCtq>K88hLbc8*}kyHZ2p47>s!v@vr zW5bGU51yA9Jl2xt{}O#Zc)l{b2=3Vl{sazwe2;nX`R6a=yg02&8x0c!SBxvLtx%uO zZNXB+m^2(UU0-ge4uz_r0@7H{2rg%IPS>js82t8CL_b;ql

ye>VqI<$HR4Can@^ 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 G*78S3|uKxc(~KTEiM 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 literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt index 775a71d..7ade651 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,9 +10,9 @@ python-json-logger==2.0.7 # Generation Libraries diffusers==0.30.3 -torch==2.5.1 -torchaudio==2.5.1 -torchvision==0.20.1 +torch>=2.5.0 +torchaudio>=2.5.0 +torchvision>=0.20.0 transformers==4.46.0 accelerate==1.1.1 safetensors==0.4.5 @@ -22,6 +22,7 @@ Pillow==11.0.0 openai-whisper==20231117 TTS==0.22.0 scipy==1.14.1 +librosa==0.10.2.post1 # Video Processing imageio==2.36.0 diff --git a/src/api/v1/endpoints/__init__.py b/src/api/v1/endpoints/__init__.py index 80d1850..4fd814c 100644 --- a/src/api/v1/endpoints/__init__.py +++ b/src/api/v1/endpoints/__init__.py @@ -1,3 +1,3 @@ -from . import image, scoring, speech, video, vision +from . import image, scoring, speech, video -__all__ = ["image", "video", "speech", "vision", "scoring"] +__all__ = ["image", "video", "speech", "scoring"] diff --git a/src/api/v1/endpoints/speech.py b/src/api/v1/endpoints/speech.py index 016c441..f39ef5c 100644 --- a/src/api/v1/endpoints/speech.py +++ b/src/api/v1/endpoints/speech.py @@ -6,6 +6,7 @@ from ....schemas.generation import ( SpeechToTextResponse, ) from ....services.speech_service import get_speech_service +from ....services.realtime_audio_service import get_realtime_audio_service from ...dependencies import verify_api_key router = APIRouter(prefix="/speech", tags=["Speech"]) @@ -83,3 +84,25 @@ async def detect_language( audio_data = await file.read() result = await service.detect_language(audio_data) return result + + +@router.post("/realtime") +async def realtime_audio( + file: UploadFile = File(...), + api_key: str = Depends(verify_api_key), + service=Depends(get_realtime_audio_service), +): + """ + Process audio using real-time speech-to-speech models. + + Args: + file: Audio file to process + api_key: API key for authentication + service: Real-time Audio service instance + + Returns: + dict with transcribed text and execution info + """ + audio_data = await file.read() + result = await service.process_audio(audio_data) + return result diff --git a/src/core/config.py b/src/core/config.py index 83c24bc..cce35d0 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Optional from pydantic_settings import BaseSettings, SettingsConfigDict @@ -20,6 +21,10 @@ class Settings(BaseSettings): version: str = "2.0.0" api_key: str = "change-me" + # External Providers for Speech (Optional) + groq_api_key: Optional[str] = None + openai_api_key: Optional[str] = None + # Image generation model image_model_path: str = "./models/stable-diffusion-v1-5" image_steps: int = 4 @@ -46,6 +51,9 @@ class Settings(BaseSettings): # Whisper model for speech-to-text whisper_model_path: str = "./models/whisper" + # Real-time Audio model for speech-to-speech + realtime_audio_model_path: str = "./models/realtime_audio" + # Device configuration device: str = "cuda" diff --git a/src/main.py b/src/main.py index 306a99a..27089a4 100644 --- a/src/main.py +++ b/src/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles -from .api.v1.endpoints import image, scoring, speech, video, vision +from .api.v1.endpoints import image, scoring, speech, video from .core.config import settings from .core.logging import get_logger from .services.image_service import get_image_service @@ -50,7 +50,6 @@ app.add_middleware( app.include_router(image.router, prefix=settings.api_v1_prefix) app.include_router(video.router, prefix=settings.api_v1_prefix) app.include_router(speech.router, prefix=settings.api_v1_prefix) -app.include_router(vision.router, prefix=settings.api_v1_prefix) app.include_router(scoring.router, prefix=settings.api_v1_prefix) app.mount("/outputs", StaticFiles(directory="outputs"), name="outputs") diff --git a/src/services/realtime_audio_service.py b/src/services/realtime_audio_service.py new file mode 100644 index 0000000..7124eb0 --- /dev/null +++ b/src/services/realtime_audio_service.py @@ -0,0 +1,113 @@ +import time +import torch +from pathlib import Path +from typing import Optional, Dict, Any + +from ..core.config import settings +from ..core.logging import get_logger + +logger = get_logger("realtime_audio_service") + +class RealtimeAudioService: + def __init__(self): + self.model = None + self.processor = None + self.device = settings.device + self._initialized = False + + def initialize(self): + if self._initialized: + return + + logger.info("Loading Real-time Audio model") + try: + from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor + + # Default to PersonaPlex but naming is generic + model_id = "nvidia/personaplex-7b-v1" + model_path = Path(settings.realtime_audio_model_path) + + if model_path.exists(): + load_path = str(model_path) + else: + load_path = model_id + + logger.info(f"Loading model from {load_path}") + + torch_dtype = torch.float16 if self.device == "cuda" else torch.float32 + + self.processor = AutoProcessor.from_pretrained(load_path) + self.model = AutoModelForSpeechSeq2Seq.from_pretrained( + load_path, + torch_dtype=torch_dtype, + low_cpu_mem_usage=True, + use_safetensors=True + ).to(self.device) + + self._initialized = True + logger.info("Real-time Audio model loaded successfully") + except Exception as e: + logger.error("Failed to load Real-time Audio model", error=str(e)) + self.model = None + self.processor = None + + async def process_audio(self, audio_data: bytes, conversation_context: Optional[str] = None) -> Dict[str, Any]: + """ + Process audio input using real-time S2S model. + """ + if not self._initialized: + self.initialize() + + if self.model is None or self.processor is None: + return { + "status": "error", + "error": "Real-time Audio model not initialized" + } + + start_time = time.time() + + try: + import numpy as np + import librosa + import io + + audio_buf = io.BytesIO(audio_data) + y, sr = librosa.load(audio_buf, sr=16000) + + inputs = self.processor(y, sampling_rate=16000, return_tensors="pt").to(self.device) + inputs["input_features"] = inputs["input_features"].to(dtype=self.model.dtype) + + with torch.no_grad(): + generated_ids = self.model.generate( + inputs["input_features"], + max_new_tokens=256, + do_sample=True, + temperature=0.7 + ) + + transcription = self.processor.batch_decode(generated_ids, skip_special_tokens=True)[0] + + execution_time = time.time() - start_time + logger.info("Processed real-time audio", time=execution_time) + + return { + "status": "completed", + "text": transcription.strip(), + "execution_time": execution_time, + "model": "realtime-audio-v1" + } + + except Exception as e: + logger.error("Real-time audio processing failed", error=str(e)) + return { + "status": "error", + "error": str(e) + } + +_service = None + +def get_realtime_audio_service(): + global _service + if _service is None: + _service = RealtimeAudioService() + return _service diff --git a/src/services/speech_service.py b/src/services/speech_service.py index 87520b9..28b8e5c 100644 --- a/src/services/speech_service.py +++ b/src/services/speech_service.py @@ -1,10 +1,13 @@ import io import tempfile import time +import os +import urllib.parse from datetime import datetime from pathlib import Path from typing import Optional +import httpx from ..core.config import settings from ..core.logging import get_logger @@ -21,36 +24,49 @@ class SpeechService: def initialize(self): if self._initialized: return - logger.info("Loading speech models") - try: - # Load TTS model (Coqui TTS) - self._load_tts_model() - # Load Whisper model for speech-to-text - self._load_whisper_model() + # We only need local models if external providers are NOT configured + if not settings.groq_api_key and not settings.openai_api_key: + logger.info( + "External providers not configured, loading local speech models" + ) + try: + # Load TTS model (Coqui TTS) + self._load_tts_model() - self._initialized = True - logger.info("Speech models loaded successfully") - except Exception as e: - logger.error("Failed to load speech models", error=str(e)) - # Don't raise - allow service to run with partial functionality - logger.warning("Speech service will have limited functionality") + # Load Whisper model for speech-to-text + self._load_whisper_model() + except Exception as e: + logger.error("Failed to load local speech models", error=str(e)) + else: + logger.info("External speech providers detected (Groq/OpenAI)") + + self._initialized = True def _load_tts_model(self): """Load TTS model for text-to-speech generation""" try: from TTS.api import TTS - # Use a fast, high-quality model + # Use a lightweight model for low-RAM systems (4GB) self.tts_model = TTS( - model_name="tts_models/en/ljspeech/tacotron2-DDC", + model_name="tts_models/multilingual/multi-dataset/xtts_v2", progress_bar=False, gpu=(self.device == "cuda"), ) - logger.info("TTS model loaded") + logger.info("Local TTS model loaded (xtts_v2)") except Exception as e: - logger.warning("TTS model not available", error=str(e)) - self.tts_model = None + logger.warning("XTTS failed, trying lighter model", error=str(e)) + try: + self.tts_model = TTS( + model_name="tts_models/en/ljspeech/tacotron2-DDC", + progress_bar=False, + gpu=False, + ) + logger.info("Local TTS model loaded (tacotron2)") + except Exception as e2: + logger.warning("Local TTS model not available", error=str(e2)) + self.tts_model = None def _load_whisper_model(self): """Load Whisper model for speech-to-text""" @@ -65,9 +81,9 @@ class SpeechService: ) else: self.whisper_model = whisper.load_model(model_size) - logger.info("Whisper model loaded", model=model_size) + logger.info("Local Whisper model loaded", model=model_size) except Exception as e: - logger.warning("Whisper model not available", error=str(e)) + logger.warning("Local Whisper model not available", error=str(e)) self.whisper_model = None async def generate( @@ -85,40 +101,84 @@ class SpeechService: filename = f"{timestamp}_{hash(prompt) & 0xFFFFFF:06x}.wav" output_path = settings.output_dir / "audio" / filename + # Prefer OpenAI/Groq for high quality/speed if configured + if settings.openai_api_key: + logger.info("Generating speech via OpenAI API") + try: + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.openai.com/v1/audio/speech", + headers={"Authorization": f"Bearer {settings.openai_api_key}"}, + json={ + "model": "tts-1", + "input": prompt, + "voice": voice or "alloy", + }, + timeout=30.0, + ) + response.raise_for_status() + with open(output_path, "wb") as f: + f.write(response.content) + + generation_time = time.time() - start + return { + "status": "completed", + "file_path": f"/outputs/audio/{filename}", + "generation_time": generation_time, + "provider": "openai", + } + except Exception as e: + logger.error( + "OpenAI speech generation failed, falling back", error=str(e) + ) + + # Fallback: Google Translate TTS (free, no API key needed) + try: + logger.info("Generating speech via Google Translate TTS") + lang = language or "pt-BR" + google_url = f"https://translate.google.com/translate_tts?ie=UTF-8&q={urllib.parse.quote(prompt)}&tl={lang}&client=tw-ob" + async with httpx.AsyncClient() as client: + response = await client.get(google_url, timeout=30.0) + response.raise_for_status() + with open(output_path, "wb") as f: + f.write(response.content) + + generation_time = time.time() - start + return { + "status": "completed", + "file_path": f"/outputs/audio/{filename}", + "generation_time": generation_time, + "provider": "google-translate", + } + except Exception as e: + logger.warning("Google Translate TTS failed", error=str(e)) + if self.tts_model is None: - logger.error("TTS model not available") + logger.error("No TTS provider available") return { "status": "error", - "error": "TTS model not initialized", + "error": "No TTS provider initialized", "file_path": None, "generation_time": time.time() - start, } try: - logger.info( - "Generating speech", - text_length=len(prompt), - voice=voice, - language=language, - ) - - # Generate speech + logger.info("Generating speech via local model") self.tts_model.tts_to_file( text=prompt, file_path=str(output_path), ) generation_time = time.time() - start - logger.info("Speech generated", file=filename, time=generation_time) - return { "status": "completed", "file_path": f"/outputs/audio/{filename}", "generation_time": generation_time, + "provider": "local", } except Exception as e: - logger.error("Speech generation failed", error=str(e)) + logger.error("Local speech generation failed", error=str(e)) return { "status": "error", "error": str(e), @@ -127,96 +187,123 @@ class SpeechService: } async def to_text(self, audio_data: bytes) -> dict: - """Convert speech audio to text using Whisper""" + """Convert speech audio to text using Whisper (External or Local)""" if not self._initialized: self.initialize() start = time.time() + # 1. Try Groq (Ultra-fast Whisper) + if settings.groq_api_key: + logger.info("Transcribing via Groq Cloud") + try: + # Save to temp file for Groq API + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp.write(audio_data) + tmp_path = tmp.name + + async with httpx.AsyncClient() as client: + with open(tmp_path, "rb") as audio_file: + files = { + "file": ( + os.path.basename(tmp_path), + audio_file, + "audio/wav", + ) + } + data = {"model": "whisper-large-v3-turbo"} + response = await client.post( + "https://api.groq.com/openai/v1/audio/transcriptions", + headers={ + "Authorization": f"Bearer {settings.groq_api_key}" + }, + files=files, + data=data, + timeout=30.0, + ) + response.raise_for_status() + result = response.json() + + os.unlink(tmp_path) + return { + "text": result["text"].strip(), + "language": result.get("language", "auto"), + "confidence": 0.99, + "provider": "groq", + } + except Exception as e: + logger.error("Groq transcription failed, falling back", error=str(e)) + + # 2. Try OpenAI + if settings.openai_api_key: + logger.info("Transcribing via OpenAI API") + try: + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp.write(audio_data) + tmp_path = tmp.name + + async with httpx.AsyncClient() as client: + with open(tmp_path, "rb") as audio_file: + files = { + "file": ( + os.path.basename(tmp_path), + audio_file, + "audio/wav", + ) + } + data = {"model": "whisper-1"} + response = await client.post( + "https://api.openai.com/v1/audio/transcriptions", + headers={ + "Authorization": f"Bearer {settings.openai_api_key}" + }, + files=files, + data=data, + timeout=30.0, + ) + response.raise_for_status() + result = response.json() + + os.unlink(tmp_path) + return { + "text": result["text"].strip(), + "language": result.get("language", "auto"), + "confidence": 0.99, + "provider": "openai", + } + except Exception as e: + logger.error("OpenAI transcription failed, falling back", error=str(e)) + + # 3. Fallback to Local Whisper if self.whisper_model is None: - logger.error("Whisper model not available") - return { - "text": "", - "language": None, - "confidence": 0.0, - "error": "Whisper model not initialized", - } + return {"text": "", "error": "No STT provider available"} try: - # Save audio to temporary file with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: tmp.write(audio_data) tmp_path = tmp.name - logger.info("Transcribing audio", file_size=len(audio_data)) - - # Transcribe + logger.info("Transcribing via local model") result = self.whisper_model.transcribe(tmp_path) - - # Clean up temp file - import os - os.unlink(tmp_path) - transcription_time = time.time() - start - logger.info( - "Audio transcribed", - text_length=len(result["text"]), - language=result.get("language"), - time=transcription_time, - ) - return { "text": result["text"].strip(), - "language": result.get("language", "en"), - "confidence": 0.95, # Whisper doesn't provide confidence directly + "language": result.get("language", "auto"), + "confidence": 0.95, + "provider": "local", } - except Exception as e: - logger.error("Speech-to-text failed", error=str(e)) - return { - "text": "", - "language": None, - "confidence": 0.0, - "error": str(e), - } + return {"text": "", "error": str(e)} async def detect_language(self, audio_data: bytes) -> dict: - """Detect the language of spoken audio""" - if not self._initialized: - self.initialize() - - if self.whisper_model is None: - return {"language": None, "error": "Whisper model not initialized"} - - try: - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: - tmp.write(audio_data) - tmp_path = tmp.name - - import whisper - - # Load audio and detect language - audio = whisper.load_audio(tmp_path) - audio = whisper.pad_or_trim(audio) - mel = whisper.log_mel_spectrogram(audio).to(self.whisper_model.device) - _, probs = self.whisper_model.detect_language(mel) - - import os - - os.unlink(tmp_path) - - detected_lang = max(probs, key=probs.get) - confidence = probs[detected_lang] - - return { - "language": detected_lang, - "confidence": confidence, - } - - except Exception as e: - logger.error("Language detection failed", error=str(e)) - return {"language": None, "error": str(e)} + """Detect language (simplified to reuse to_text if needed)""" + # Just use to_text and return the language field + result = await self.to_text(audio_data) + return { + "language": result.get("language"), + "confidence": result.get("confidence", 0.0), + } _service = None