app.py 2.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
  1. """Service local Kokoro CPU : API WAV compatible avec le client vocal."""
  2. import asyncio
  3. import logging
  4. import os
  5. import time
  6. from contextlib import asynccontextmanager
  7. from io import BytesIO
  8. import numpy as np
  9. import soundfile as sf
  10. import torch
  11. from fastapi import FastAPI, HTTPException
  12. from fastapi.responses import Response
  13. from kokoro import KPipeline
  14. from pydantic import BaseModel, Field
  15. logger = logging.getLogger('uvicorn.error')
  16. pipeline = None
  17. generation_lock = asyncio.Lock()
  18. def generate(text, voice):
  19. with torch.inference_mode():
  20. chunks = [result.audio.cpu().numpy() for result in pipeline(text, voice=voice)]
  21. if not chunks:
  22. raise ValueError('Aucun audio généré')
  23. audio = np.concatenate(chunks)
  24. output = BytesIO()
  25. sf.write(output, audio, 24000, format='WAV', subtype='PCM_16')
  26. return output.getvalue(), len(audio) / 24000
  27. @asynccontextmanager
  28. async def lifespan(app):
  29. global pipeline
  30. torch.set_num_threads(int(os.getenv('TTS_THREADS', '4')))
  31. torch.set_num_interop_threads(1)
  32. pipeline = KPipeline(lang_code='f', repo_id='hexgrad/Kokoro-82M', device='cpu')
  33. await asyncio.to_thread(generate, 'Bonjour, je suis prête.', 'ff_siwis')
  34. yield
  35. app = FastAPI(lifespan=lifespan)
  36. class SpeechRequest(BaseModel):
  37. input: str = Field(min_length=1, max_length=300)
  38. voice: str = 'ff_siwis'
  39. @app.get('/healthz')
  40. def health():
  41. return {'ready': pipeline is not None, 'model': 'Kokoro-82M', 'voice': 'ff_siwis', 'device': 'cpu'}
  42. @app.post('/v1/audio/speech')
  43. async def speech(request: SpeechRequest):
  44. if request.voice != 'ff_siwis' or not request.input.strip():
  45. raise HTTPException(422, 'Voix inconnue ou texte vide')
  46. if generation_lock.locked():
  47. raise HTTPException(429, 'Synthèse occupée')
  48. async with generation_lock:
  49. started = time.monotonic()
  50. # Shield : ne pas libérer le verrou pendant une génération annulée par le client.
  51. task = asyncio.create_task(asyncio.to_thread(generate, request.input, request.voice))
  52. try:
  53. payload, duration = await asyncio.shield(task)
  54. except asyncio.CancelledError:
  55. await task
  56. raise
  57. elapsed = time.monotonic() - started
  58. logger.info('Synthèse : caractères=%d génération=%.3fs audio=%.3fs', len(request.input), elapsed, duration)
  59. return Response(payload, media_type='audio/wav', headers={
  60. 'X-Generation-Seconds': f'{elapsed:.3f}', 'X-Audio-Seconds': f'{duration:.3f}'})