Эх сурвалжийг харах

Extraire le service Kokoro existant du depot Satoko

Andy Esnard 23 цаг өмнө
commit
675742771e
7 өөрчлөгдсөн 368 нэмэгдсэн , 0 устгасан
  1. 13 0
      Dockerfile
  2. 34 0
      README.md
  3. 74 0
      app.py
  4. 28 0
      check_service.py
  5. 118 0
      openshift.yaml
  6. 97 0
      requirements-deployed.txt
  7. 4 0
      requirements.txt

+ 13 - 0
Dockerfile

@@ -0,0 +1,13 @@
+FROM python:3.12-slim
+WORKDIR /app
+ENV PYTHONUNBUFFERED=1 HF_HOME=/opt/models HOME=/tmp OMP_NUM_THREADS=4 MKL_NUM_THREADS=4
+RUN apt-get update && apt-get install -y --no-install-recommends espeak-ng libsndfile1 && rm -rf /var/lib/apt/lists/*
+COPY requirements.txt .
+RUN pip install --no-cache-dir torch==2.7.1 --index-url https://download.pytorch.org/whl/cpu && pip install --no-cache-dir -r requirements.txt
+# Modèle et voix embarqués : aucun téléchargement au démarrage du pod.
+RUN python -c "from kokoro import KPipeline; p=KPipeline(lang_code='f', device='cpu'); list(p('Bonjour.', voice='ff_siwis'))" && chmod -R a+rX /opt/models
+ENV HF_HUB_OFFLINE=1
+COPY app.py .
+USER 1001
+EXPOSE 8000
+CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

+ 34 - 0
README.md

@@ -0,0 +1,34 @@
+# Synthèse vocale Kokoro sur CPU
+
+Service interne `kokoro-tts:8000`, voix française féminine `ff_siwis`.
+Les poids sont embarqués dans l'image ; le modèle est chargé et préchauffé au
+démarrage. Aucun GPU n'est demandé. Le manifeste cible le nœud
+`bureau.local.pentou.ovh` dans le namespace `localai` (à adapter ailleurs).
+
+Depuis la racine du dépôt :
+
+```sh
+oc apply -f tts-service/openshift.yaml
+oc start-build kokoro-tts -n localai --from-dir=tts-service --follow
+```
+
+Après réussite du build, mettre à jour le Deployment `kokoro-tts`, conteneur
+`tts`, avec le digest indiqué par l'ImageStreamTag `kokoro-tts:cpu`, puis vérifier
+son rollout. Le manifeste initial utilise le tag `cpu` ; il ne configure pas
+de déclencheur automatique pour les builds suivants.
+
+API : POST `/v1/audio/speech`, JSON `input` (1 à 300 caractères), `voice`
+(`ff_siwis`), réponse WAV PCM16 mono 24 kHz. GET `/healthz` devient disponible
+après préchauffage. Une génération à la fois ; HTTP 429 si occupé.
+
+`check_service.py` peut être exécuté via stdin dans le pod Satoko. Il vérifie
+la connectivité, le format WAV, la validation de voix et mesure la génération
+de trois phrases sans publier sur Discord.
+
+Mesures sur Ryzen 9 5950X, limite 4 CPU : 1,44 / 2,53 / 2,69 secondes de requête
+pour 3,13 / 5,75 / 5,53 secondes d'audio. Elles excluent STT, LLM et Discord.
+`requirements-deployed.txt` est l'inventaire de l'image testée ; le Dockerfile
+installe PyTorch CPU explicitement puis les dépendances de `requirements.txt`.
+
+La NetworkPolicy autorise seulement les pods `app=satoko-chatbot` du même
+namespace à accéder au service. Aucune route publique n'est créée.

+ 74 - 0
app.py

@@ -0,0 +1,74 @@
+"""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}'})

+ 28 - 0
check_service.py

@@ -0,0 +1,28 @@
+"""À exécuter depuis le pod Satoko ; aucun appel à Discord."""
+import io
+import json
+import time
+import wave
+import httpx
+
+with httpx.Client(timeout=90) as http:
+    health = http.get('http://kokoro-tts:8000/healthz')
+    health.raise_for_status()
+    print(health.json())
+    for text in (
+        'Bonjour, je suis prête à discuter avec vous.',
+        'Tu peux maintenant me parler dans le salon vocal, et je te répondrai directement à voix haute.',
+        'Les trois serveurs fonctionnent correctement. Nous pouvons continuer notre partie tranquillement.',
+    ):
+        started = time.monotonic()
+        response = http.post('http://kokoro-tts:8000/v1/audio/speech', json={'input': text, 'voice': 'ff_siwis'})
+        response.raise_for_status()
+        with wave.open(io.BytesIO(response.content)) as wav:
+            assert wav.getframerate() == 24000 and wav.getnchannels() == 1 and wav.getsampwidth() == 2
+            duration = wav.getnframes() / wav.getframerate()
+        print(json.dumps({'characters': len(text), 'request_seconds': round(time.monotonic()-started, 3),
+                          'generation_seconds': response.headers.get('X-Generation-Seconds'),
+                          'audio_seconds': round(duration, 3)}, ensure_ascii=False))
+    invalid = http.post('http://kokoro-tts:8000/v1/audio/speech', json={'input': 'bonjour', 'voice': '../unknown'})
+    assert invalid.status_code == 422
+    print('WAV et validation de voix : OK')

+ 118 - 0
openshift.yaml

@@ -0,0 +1,118 @@
+apiVersion: image.openshift.io/v1
+kind: ImageStream
+metadata:
+  name: kokoro-tts
+  namespace: localai
+---
+apiVersion: build.openshift.io/v1
+kind: BuildConfig
+metadata:
+  name: kokoro-tts
+  namespace: localai
+spec:
+  runPolicy: Serial
+  source:
+    type: Binary
+    binary: {}
+  strategy:
+    type: Docker
+    dockerStrategy: {}
+  output:
+    to:
+      kind: ImageStreamTag
+      name: kokoro-tts:cpu
+  resources:
+    requests:
+      cpu: '1'
+      memory: 2Gi
+    limits:
+      cpu: '4'
+      memory: 6Gi
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: kokoro-tts
+  namespace: localai
+spec:
+  replicas: 1
+  strategy:
+    type: Recreate
+  selector:
+    matchLabels:
+      app: kokoro-tts
+  template:
+    metadata:
+      labels:
+        app: kokoro-tts
+    spec:
+      nodeSelector:
+        kubernetes.io/hostname: bureau.local.pentou.ovh
+      automountServiceAccountToken: false
+      containers:
+        - name: tts
+          image: image-registry.openshift-image-registry.svc:5000/localai/kokoro-tts:cpu
+          ports:
+            - containerPort: 8000
+          resources:
+            requests:
+              cpu: '2'
+              memory: 2Gi
+            limits:
+              cpu: '4'
+              memory: 4Gi
+          securityContext:
+            allowPrivilegeEscalation: false
+            capabilities:
+              drop: [ALL]
+            runAsNonRoot: true
+            seccompProfile:
+              type: RuntimeDefault
+          startupProbe:
+            httpGet:
+              path: /healthz
+              port: 8000
+            periodSeconds: 5
+            failureThreshold: 60
+          readinessProbe:
+            httpGet:
+              path: /healthz
+              port: 8000
+            periodSeconds: 5
+          livenessProbe:
+            httpGet:
+              path: /healthz
+              port: 8000
+            periodSeconds: 20
+            timeoutSeconds: 5
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: kokoro-tts
+  namespace: localai
+spec:
+  selector:
+    app: kokoro-tts
+  ports:
+    - port: 8000
+      targetPort: 8000
+---
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+  name: kokoro-tts-ingress
+  namespace: localai
+spec:
+  podSelector:
+    matchLabels:
+      app: kokoro-tts
+  policyTypes: [Ingress]
+  ingress:
+    - from:
+        - podSelector:
+            matchLabels:
+              app: satoko-chatbot
+      ports:
+        - protocol: TCP
+          port: 8000

+ 97 - 0
requirements-deployed.txt

@@ -0,0 +1,97 @@
+addict==2.4.0
+annotated-doc==0.0.5
+annotated-types==0.8.0
+anyio==4.16.0
+attrs==26.1.0
+babel==2.18.0
+blis==1.3.4
+catalogue==2.0.10
+certifi==2026.10.23
+cffi==2.1.1
+charset-normalizer==3.5.2
+click==8.5.0
+cloudpathlib==0.26.0
+cloudpickle==3.1.2
+confection==1.3.4
+csvw==4.1.1
+curated-tokenizers==0.0.10
+curated-transformers==0.1.1
+cymem==2.0.16
+dlinfo==2.0.0
+docopt==0.6.2
+espeakng-loader==0.2.4
+fastapi==0.115.12
+filelock==4.2.4
+fsspec==2026.9.0
+h11==0.16.0
+hf-xet==1.8.0
+httpcore==1.0.9
+httpcore2==2.16.0
+httpx==0.28.1
+httpx2==2.16.0
+huggingface_hub==2.4.0
+idna==3.20
+isodate==0.7.2
+Jinja2==3.1.6
+joblib==1.6.0
+jsonschema==4.26.0
+jsonschema-specifications==2025.9.1
+kokoro==0.9.4
+language-tags==1.3.2
+loguru==0.7.3
+markdown-it-py==4.2.0
+MarkupSafe==3.0.3
+mdurl==0.1.2
+misaki==0.9.4
+mpmath==1.3.0
+murmurhash==1.0.16
+networkx==3.7
+num2words==0.5.14
+numpy==2.5.4
+packaging==26.3
+phonemizer-fork==3.3.2
+preshed==3.0.15
+pycparser==3.12
+pydantic==2.14.2
+pydantic_core==2.50.2
+Pygments==2.21.0
+pyparsing==3.3.3
+python-dateutil==4.0.0
+PyYAML==6.0.3
+rdflib==7.6.0
+referencing==0.37.0
+regex==2026.9.29
+requests==2.34.2
+rfc3986==1.5.0
+rich==15.0.0
+rpds-py==2026.9.1
+safetensors==0.8.0
+segments==2.4.0
+setuptools==78.1.0
+shellingham==1.5.4
+smart_open==9.0.2
+soundfile==0.13.1
+spacy==3.8.18
+spacy-curated-transformers==0.3.1
+spacy-legacy==3.0.12
+spacy-loggers==1.0.5
+srsly==2.5.6
+starlette==0.46.2
+sympy==1.14.0
+termcolor==3.3.0
+thinc==8.3.14
+tokenizers==0.23.3
+torch==2.7.1+cpu
+tqdm==4.70.1
+transformers==5.22.1
+truststore==0.10.5
+typer==0.27.3
+typing-inspection==0.4.5
+typing_extensions==4.16.0
+tzdata==2026.6
+uritemplate==4.2.0
+urllib3==2.8.0
+uvicorn==0.34.2
+wasabi==1.1.3
+weasel==1.0.1
+wrapt==2.5.2

+ 4 - 0
requirements.txt

@@ -0,0 +1,4 @@
+kokoro==0.9.4
+soundfile==0.13.1
+fastapi==0.115.12
+uvicorn==0.34.2