| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374 |
- """Service local Kokoro CPU : API WAV compatible avec le client vocal."""
- import asyncio
- import logging
- import os
- import time
- from contextlib import asynccontextmanager
- from io import BytesIO
- import numpy as np
- import soundfile as sf
- import torch
- from fastapi import FastAPI, HTTPException
- from fastapi.responses import Response
- from kokoro import KPipeline
- from pydantic import BaseModel, Field
- logger = logging.getLogger('uvicorn.error')
- pipeline = None
- generation_lock = asyncio.Lock()
- def generate(text, voice):
- with torch.inference_mode():
- chunks = [result.audio.cpu().numpy() for result in pipeline(text, voice=voice)]
- if not chunks:
- raise ValueError('Aucun audio généré')
- audio = np.concatenate(chunks)
- output = BytesIO()
- sf.write(output, audio, 24000, format='WAV', subtype='PCM_16')
- return output.getvalue(), len(audio) / 24000
- @asynccontextmanager
- async def lifespan(app):
- global pipeline
- torch.set_num_threads(int(os.getenv('TTS_THREADS', '4')))
- torch.set_num_interop_threads(1)
- pipeline = KPipeline(lang_code='f', repo_id='hexgrad/Kokoro-82M', device='cpu')
- await asyncio.to_thread(generate, 'Bonjour, je suis prête.', 'ff_siwis')
- yield
- app = FastAPI(lifespan=lifespan)
- class SpeechRequest(BaseModel):
- input: str = Field(min_length=1, max_length=300)
- voice: str = 'ff_siwis'
- @app.get('/healthz')
- def health():
- return {'ready': pipeline is not None, 'model': 'Kokoro-82M', 'voice': 'ff_siwis', 'device': 'cpu'}
- @app.post('/v1/audio/speech')
- async def speech(request: SpeechRequest):
- if request.voice != 'ff_siwis' or not request.input.strip():
- raise HTTPException(422, 'Voix inconnue ou texte vide')
- if generation_lock.locked():
- raise HTTPException(429, 'Synthèse occupée')
- async with generation_lock:
- started = time.monotonic()
- # Shield : ne pas libérer le verrou pendant une génération annulée par le client.
- task = asyncio.create_task(asyncio.to_thread(generate, request.input, request.voice))
- try:
- payload, duration = await asyncio.shield(task)
- except asyncio.CancelledError:
- await task
- raise
- elapsed = time.monotonic() - started
- logger.info('Synthèse : caractères=%d génération=%.3fs audio=%.3fs', len(request.input), elapsed, duration)
- return Response(payload, media_type='audio/wav', headers={
- 'X-Generation-Seconds': f'{elapsed:.3f}', 'X-Audio-Seconds': f'{duration:.3f}'})
|