Spaces:
Paused
Paused
Upload 12 files
Browse files- .gitattributes +3 -0
- Ani-Voice-API.7z +3 -0
- README.md +62 -10
- api.py +53 -0
- client_example.py +27 -0
- demo1_conversation.wav +3 -0
- demo2_numbers.wav +3 -0
- demo3_expressive.wav +3 -0
- gitattributes +38 -0
- normalizer.py +41 -0
- requirements.txt +11 -0
- tts_engine.py +157 -0
- voice_pipeline.py +108 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
demo1_conversation.wav filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
demo2_numbers.wav filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
demo3_expressive.wav filter=lfs diff=lfs merge=lfs -text
|
Ani-Voice-API.7z
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cf77670c80c71675c2bdfe9404fbe7ddcd045ea3ec7f72bd34751a9a584fa033
|
| 3 |
+
size 140639241
|
README.md
CHANGED
|
@@ -1,14 +1,66 @@
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
colorFrom: pink
|
| 5 |
-
colorTo: green
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: 6.17.3
|
| 8 |
-
python_version: '3.13'
|
| 9 |
-
app_file: app.py
|
| 10 |
-
pinned: false
|
| 11 |
license: mit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
---
|
|
|
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
language:
|
| 3 |
+
- bg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
license: mit
|
| 5 |
+
tags:
|
| 6 |
+
- text-to-speech
|
| 7 |
+
- tts
|
| 8 |
+
- bulgarian
|
| 9 |
+
- fastapi
|
| 10 |
+
pipeline_tag: text-to-speech
|
| 11 |
---
|
| 12 |
+
# Ani Voice API
|
| 13 |
|
| 14 |
+
Завършен TTS (Text-to-Speech) пакет за български език, базиран на BgTTS и Supertonic, обвит в гъвкаво FastAPI приложение.
|
| 15 |
+
|
| 16 |
+
*Проектът е създаден и разработен от **Ani-Antigravity** по идея и желание на **Наско (@beleata74)**.*
|
| 17 |
+
|
| 18 |
+
## Инсталация
|
| 19 |
+
|
| 20 |
+
1. Уверете се, че имате Python 3.10+
|
| 21 |
+
2. Инсталирайте нужните зависимости:
|
| 22 |
+
```bash
|
| 23 |
+
pip install -r requirements.txt
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
## Стартиране на API сървъра
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
python api.py
|
| 30 |
+
```
|
| 31 |
+
Сървърът ще тръгне на `http://localhost:8000`. Можете да разгледате автоматичната документация на `http://localhost:8000/docs`.
|
| 32 |
+
|
| 33 |
+
## Използване
|
| 34 |
+
|
| 35 |
+
### 1. Генериране на цял аудио файл
|
| 36 |
+
Изпраща текст и връща завършен `.wav` файл.
|
| 37 |
+
|
| 38 |
+
**Пример:**
|
| 39 |
+
```bash
|
| 40 |
+
curl -X POST "http://localhost:8000/api/v1/synthesize" \
|
| 41 |
+
-H "Content-Type: application/json" \
|
| 42 |
+
-d '{"text": "Здравей, свят!", "voice_style": "F5", "speed": 1.6}' \
|
| 43 |
+
--output response.wav
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### 2. Стрийминг на аудио (NDJSON)
|
| 47 |
+
Изпраща аудиото на малки парчета (chunks), докато се генерират, кодирани в base64. Полезно за дълги текстове, където искате да пускате аудиото веднага.
|
| 48 |
+
|
| 49 |
+
Връща редове във формат:
|
| 50 |
+
```json
|
| 51 |
+
{"chunk_index": 0, "audio_base64": "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEA..."}
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Вижте файла `client_example.py` за пример как да интегрирате API-то в Python код.
|
| 55 |
+
Вижте файла `voice_pipeline.py` за пример на работещ клиент-демон (daemon), който комуникира с API-то и пуска звука в реално време!
|
| 56 |
+
|
| 57 |
+
## Аудио Демонстрации
|
| 58 |
+
|
| 59 |
+
В репозиторито можете да намерите няколко предварително генерирани аудио файла, за да чуете как звучи моделът:
|
| 60 |
+
|
| 61 |
+
1. **`demo1_conversation.wav`**
|
| 62 |
+
- *Транскрипция:* "Здравейте! Това е тестов запис от нашия нов български TTS модел. Надявам се да ви хареса как звучи гласът ми!"
|
| 63 |
+
2. **`demo2_numbers.wav`** (Демонстрира нормализацията на числа и дати)
|
| 64 |
+
- *Транскрипция:* "Днес е 15 май 2026 година. Температурата навън е 23.5 градуса, а вятърът духа със скорост 5.4 километра в час. Цената е 1500 лв. за м²."
|
| 65 |
+
3. **`demo3_expressive.wav`**
|
| 66 |
+
- *Транскрипция:* "Супер! Наистина много се радвам, че всичко най-накрая работи гладко. Усилията определено си заслужаваха!"
|
api.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException
|
| 2 |
+
from fastapi.responses import Response, StreamingResponse
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
import uvicorn
|
| 5 |
+
|
| 6 |
+
# Импортираме tts_engine - това автоматично ще зареди моделите в паметта при стартиране!
|
| 7 |
+
from tts_engine import engine
|
| 8 |
+
|
| 9 |
+
app = FastAPI(title="Ani Voice API", version="1.0.0")
|
| 10 |
+
|
| 11 |
+
class SynthesizeRequest(BaseModel):
|
| 12 |
+
text: str
|
| 13 |
+
voice_style: str = "F5"
|
| 14 |
+
speed: float = 1.6
|
| 15 |
+
|
| 16 |
+
@app.post("/api/v1/synthesize")
|
| 17 |
+
def synthesize_full_audio(request: SynthesizeRequest):
|
| 18 |
+
"""
|
| 19 |
+
Генерира аудио за целия текст и го връща като един WAV файл.
|
| 20 |
+
Подходящо за кратки съобщения.
|
| 21 |
+
"""
|
| 22 |
+
try:
|
| 23 |
+
audio_bytes = engine.synthesize_full(request.text, request.voice_style, request.speed)
|
| 24 |
+
if not audio_bytes:
|
| 25 |
+
raise HTTPException(status_code=400, detail="Неуспешно генериране на аудио (празен текст?).")
|
| 26 |
+
|
| 27 |
+
return Response(content=audio_bytes, media_type="audio/wav")
|
| 28 |
+
except Exception as e:
|
| 29 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 30 |
+
|
| 31 |
+
import base64
|
| 32 |
+
import json
|
| 33 |
+
|
| 34 |
+
@app.post("/api/v1/synthesize/stream")
|
| 35 |
+
def synthesize_stream_audio(request: SynthesizeRequest):
|
| 36 |
+
"""
|
| 37 |
+
Стрийминг endpoint, който връща аудио на парчета (chunks).
|
| 38 |
+
Всеки ред е JSON обект: {"chunk_index": i, "audio_base64": "..."}
|
| 39 |
+
"""
|
| 40 |
+
def generate():
|
| 41 |
+
try:
|
| 42 |
+
for i, audio_bytes in enumerate(engine.synthesize_stream(request.text, request.voice_style, request.speed)):
|
| 43 |
+
encoded = base64.b64encode(audio_bytes).decode("utf-8")
|
| 44 |
+
yield json.dumps({"chunk_index": i, "audio_base64": encoded}) + "\n"
|
| 45 |
+
except Exception as e:
|
| 46 |
+
print(f"Грешка по време на стрийминг: {e}")
|
| 47 |
+
yield json.dumps({"error": str(e)}) + "\n"
|
| 48 |
+
|
| 49 |
+
return StreamingResponse(generate(), media_type="application/x-ndjson")
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
print("Стартиране на Ani Voice API сървър на порт 8000...")
|
| 53 |
+
uvicorn.run("api:app", host="0.0.0.0", port=8000, reload=False)
|
client_example.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
|
| 3 |
+
API_URL = "http://localhost:8000/api/v1/synthesize"
|
| 4 |
+
|
| 5 |
+
def synthesize_text(text: str, output_file: str):
|
| 6 |
+
"""
|
| 7 |
+
Изпраща текст към API-то и запазва резултата като WAV файл.
|
| 8 |
+
"""
|
| 9 |
+
print(f"Изпращане на заявка за: '{text}'...")
|
| 10 |
+
|
| 11 |
+
response = requests.post(API_URL, json={
|
| 12 |
+
"text": text,
|
| 13 |
+
"voice_style": "F5",
|
| 14 |
+
"speed": 1.6
|
| 15 |
+
})
|
| 16 |
+
|
| 17 |
+
if response.status_code == 200:
|
| 18 |
+
with open(output_file, "wb") as f:
|
| 19 |
+
f.write(response.content)
|
| 20 |
+
print(f"✅ Аудиото е запазено успешно в: {output_file}")
|
| 21 |
+
else:
|
| 22 |
+
print(f"❌ Грешка: {response.status_code} - {response.text}")
|
| 23 |
+
|
| 24 |
+
if __name__ == "__main__":
|
| 25 |
+
text_to_say = "Здравей! Това е тестов запис, създаден чрез новото Ani Voice API."
|
| 26 |
+
output_filename = "test_api_output.wav"
|
| 27 |
+
synthesize_text(text_to_say, output_filename)
|
demo1_conversation.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5395b9f0d2f01685b82c1592dd236999deec3de7e2a4e1a3ab25611a8f1d01d6
|
| 3 |
+
size 332204
|
demo2_numbers.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f7cdfc164606f698053e9264117e15093f4d15d0d2d7621eee131d03141a4130
|
| 3 |
+
size 762284
|
demo3_expressive.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ed299d60ff6f02ed78d7bce2322fb949e74daf0fbecfb3eae8775425943a36d7
|
| 3 |
+
size 318764
|
gitattributes
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
demo1_conversation.wav filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
demo2_numbers.wav filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
demo3_expressive.wav filter=lfs diff=lfs merge=lfs -text
|
normalizer.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from bg_text_normalizer import normalize_text as bg_norm
|
| 3 |
+
|
| 4 |
+
# Допълнителни специфични замени, които bg_text_normalizer изпуска
|
| 5 |
+
EXTRA_ABBREVIATIONS = {
|
| 6 |
+
r"\bм²\b": "квадратен метър",
|
| 7 |
+
r"\bкв\.м\.\b": "квадратен метър",
|
| 8 |
+
r"\bт\.е\.\b": "тоест",
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
def normalize_text(text: str) -> str:
|
| 12 |
+
"""
|
| 13 |
+
Нормализира текста, използвайки bg-text-normalizer + наши специфични правила.
|
| 14 |
+
"""
|
| 15 |
+
# 0.5 Предварителна обработка на десетични дроби: заменяме точката със запетая
|
| 16 |
+
# bg-text-normalizer бърка '1.4' с '1 април'. За да го чете като дроб, му трябва запетая '1,4'.
|
| 17 |
+
text = re.sub(r'(\d)\.(\d)', r'\1,\2', text)
|
| 18 |
+
|
| 19 |
+
# 1. Първо прилагаме библиотеката bg_text_normalizer
|
| 20 |
+
text = bg_norm(text)
|
| 21 |
+
|
| 22 |
+
# 2. Оправяме точките след съкращения като "лв." и "гр.", които библиотеката е превърнала в "лева."
|
| 23 |
+
text = text.replace("лева.", "лева")
|
| 24 |
+
text = text.replace("стотинки.", "стотинки")
|
| 25 |
+
|
| 26 |
+
# 3. Прилагаме нашите допълнителни правила
|
| 27 |
+
for pattern, replacement in EXTRA_ABBREVIATIONS.items():
|
| 28 |
+
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
| 29 |
+
|
| 30 |
+
# Допълнително хващаме м² (без \b, защото ² не е дума)
|
| 31 |
+
text = text.replace("м²", "квадратен метър")
|
| 32 |
+
|
| 33 |
+
# Махане на двойни интервали
|
| 34 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 35 |
+
|
| 36 |
+
return text
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
test_text = "Цената е 1500 лв. за м² в кв. Лозенец."
|
| 40 |
+
print("Original:", test_text)
|
| 41 |
+
print("Normalized:", normalize_text(test_text))
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch==2.13.0.dev20260603+cu132
|
| 2 |
+
torchaudio==2.11.0.dev20260608+cu132
|
| 3 |
+
torchvision==0.28.0.dev20260608+cu132
|
| 4 |
+
numpy<2.0.0
|
| 5 |
+
supertonic==1.3.1
|
| 6 |
+
bg-text-normalizer==1.1.0
|
| 7 |
+
num2cyrillic==1.0.0
|
| 8 |
+
fastapi>=0.110.0
|
| 9 |
+
uvicorn>=0.29.0
|
| 10 |
+
pydantic>=2.7.0
|
| 11 |
+
requests>=2.31.0
|
tts_engine.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import io
|
| 3 |
+
import re
|
| 4 |
+
import wave
|
| 5 |
+
import torch
|
| 6 |
+
import numpy as np
|
| 7 |
+
import tempfile
|
| 8 |
+
import sys
|
| 9 |
+
import supertonic
|
| 10 |
+
|
| 11 |
+
# Добавяме BgTTS към sys.path, за да може вътрешните му импорти да работят
|
| 12 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), 'BgTTS'))
|
| 13 |
+
from inference import synthesize
|
| 14 |
+
from normalizer import normalize_text
|
| 15 |
+
|
| 16 |
+
class TTSEngine:
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 19 |
+
print(f"Зареждам TTS Engine на устройство: {self.device}")
|
| 20 |
+
|
| 21 |
+
# Supertonic (Референтно аудио)
|
| 22 |
+
from supertonic import TTS
|
| 23 |
+
self.engine = TTS(auto_download=True)
|
| 24 |
+
|
| 25 |
+
# BgTTS (Основен модел)
|
| 26 |
+
self.bgtts_checkpoint = os.path.join(os.path.dirname(__file__), "BgTTS", "checkpoint_inference.pt")
|
| 27 |
+
# BgTTS inference.synthesize зарежда модела всеки път, ако не му подадем модела.
|
| 28 |
+
# В текущия BgTTS/inference.py synthesize() вика load_for_inference(), ако се подаде път.
|
| 29 |
+
# За сега ще ползваме пътя, тъй като така е написан BgTTS.
|
| 30 |
+
# Ако искаме пълно кеширане, може да се наложи леко пренаписване на BgTTS/inference.py.
|
| 31 |
+
# Но засега ще ползваме оригиналната synthesize функция.
|
| 32 |
+
|
| 33 |
+
print("TTS Engine зареден успешно.")
|
| 34 |
+
|
| 35 |
+
def split_text_for_tts(self, text: str) -> list[str]:
|
| 36 |
+
text = text.strip()
|
| 37 |
+
if not text:
|
| 38 |
+
return []
|
| 39 |
+
raw = re.split(r'(?<=[\.\!\?…])\s+|\n+', text)
|
| 40 |
+
chunks = []
|
| 41 |
+
buf = ""
|
| 42 |
+
for part in raw:
|
| 43 |
+
part = part.strip()
|
| 44 |
+
if not part: continue
|
| 45 |
+
|
| 46 |
+
if not buf or len(buf) < 80 or len(buf) + len(part) + 1 <= 200:
|
| 47 |
+
buf = (buf + " " + part).strip()
|
| 48 |
+
else:
|
| 49 |
+
chunks.append(buf)
|
| 50 |
+
buf = part
|
| 51 |
+
if buf: chunks.append(buf)
|
| 52 |
+
return chunks
|
| 53 |
+
|
| 54 |
+
def generate_chunk(self, chunk_text: str, voice_style: str = "F5", speed: float = 1.6) -> bytes:
|
| 55 |
+
"""
|
| 56 |
+
Генерира аудио за едно изречение (chunk) и го връща като WAV байтове.
|
| 57 |
+
"""
|
| 58 |
+
clean_text = chunk_text.replace('"', '').replace('„', '').replace('“', '') \
|
| 59 |
+
.replace("’", "'").replace("–", "-").replace("—", "-") \
|
| 60 |
+
.replace("*", "")
|
| 61 |
+
|
| 62 |
+
if not clean_text.strip():
|
| 63 |
+
return b""
|
| 64 |
+
|
| 65 |
+
# 1. Генериране на референтно аудио
|
| 66 |
+
# Ако voice_style е стринг (напр. "F5"), взимаме съответния обект
|
| 67 |
+
if isinstance(voice_style, str):
|
| 68 |
+
v_style = self.engine.get_voice_style(voice_name=voice_style)
|
| 69 |
+
else:
|
| 70 |
+
v_style = voice_style
|
| 71 |
+
|
| 72 |
+
wav_array, _ = self.engine.synthesize(clean_text, voice_style=v_style, lang="bg", speed=speed)
|
| 73 |
+
wav_data = np.asarray(wav_array).flatten()
|
| 74 |
+
wav_max = np.max(np.abs(wav_data))
|
| 75 |
+
if wav_max > 0:
|
| 76 |
+
wav_data = wav_data / wav_max
|
| 77 |
+
pcm_data = (wav_data * 32767).astype(np.int16)
|
| 78 |
+
|
| 79 |
+
# Записваме временно референтното аудио (тъй като BgTTS изисква файл)
|
| 80 |
+
fd, ref_path = tempfile.mkstemp(suffix=".wav")
|
| 81 |
+
os.close(fd)
|
| 82 |
+
with wave.open(ref_path, "wb") as wf:
|
| 83 |
+
wf.setnchannels(1)
|
| 84 |
+
wf.setsampwidth(2)
|
| 85 |
+
wf.setframerate(44100)
|
| 86 |
+
wf.writeframes(pcm_data.tobytes())
|
| 87 |
+
|
| 88 |
+
# 2. Генериране на крайното аудио
|
| 89 |
+
fd, final_path = tempfile.mkstemp(suffix=".wav")
|
| 90 |
+
os.close(fd)
|
| 91 |
+
|
| 92 |
+
try:
|
| 93 |
+
synthesize(checkpoint=self.bgtts_checkpoint,
|
| 94 |
+
text=clean_text,
|
| 95 |
+
output=final_path,
|
| 96 |
+
speaker_wav=ref_path,
|
| 97 |
+
device=self.device)
|
| 98 |
+
|
| 99 |
+
# Прочитане на резултата
|
| 100 |
+
with open(final_path, "rb") as f:
|
| 101 |
+
audio_bytes = f.read()
|
| 102 |
+
|
| 103 |
+
return audio_bytes
|
| 104 |
+
|
| 105 |
+
finally:
|
| 106 |
+
try:
|
| 107 |
+
os.remove(ref_path)
|
| 108 |
+
os.remove(final_path)
|
| 109 |
+
except OSError:
|
| 110 |
+
pass
|
| 111 |
+
|
| 112 |
+
def synthesize_stream(self, text: str, voice_style: str = "F5", speed: float = 1.6):
|
| 113 |
+
"""
|
| 114 |
+
Генератор, който нормализира текста, цепи го на парчета и връща WAV байтове за всяко парче.
|
| 115 |
+
"""
|
| 116 |
+
normalized_text = normalize_text(text)
|
| 117 |
+
chunks = self.split_text_for_tts(normalized_text)
|
| 118 |
+
|
| 119 |
+
for chunk in chunks:
|
| 120 |
+
audio_bytes = self.generate_chunk(chunk, voice_style, speed)
|
| 121 |
+
if audio_bytes:
|
| 122 |
+
yield audio_bytes
|
| 123 |
+
|
| 124 |
+
def synthesize_full(self, text: str, voice_style: str = "F5", speed: float = 1.6) -> bytes:
|
| 125 |
+
"""
|
| 126 |
+
Нормализира текста, цепи го, генерира всички парчета и ги слепва в един общ WAV файл.
|
| 127 |
+
"""
|
| 128 |
+
normalized_text = normalize_text(text)
|
| 129 |
+
chunks = self.split_text_for_tts(normalized_text)
|
| 130 |
+
|
| 131 |
+
all_frames = b""
|
| 132 |
+
params = None
|
| 133 |
+
|
| 134 |
+
for chunk in chunks:
|
| 135 |
+
audio_bytes = self.generate_chunk(chunk, voice_style, speed)
|
| 136 |
+
if not audio_bytes:
|
| 137 |
+
continue
|
| 138 |
+
|
| 139 |
+
# Парсване на WAV данните, за да можем да ги слеем без да дублираме хедъри
|
| 140 |
+
with wave.open(io.BytesIO(audio_bytes), "rb") as wf:
|
| 141 |
+
if not params:
|
| 142 |
+
params = wf.getparams()
|
| 143 |
+
all_frames += wf.readframes(wf.getnframes())
|
| 144 |
+
|
| 145 |
+
if not params:
|
| 146 |
+
return b""
|
| 147 |
+
|
| 148 |
+
# Създаване на крайния WAV
|
| 149 |
+
out_io = io.BytesIO()
|
| 150 |
+
with wave.open(out_io, "wb") as wf:
|
| 151 |
+
wf.setparams(params)
|
| 152 |
+
wf.writeframes(all_frames)
|
| 153 |
+
|
| 154 |
+
return out_io.getvalue()
|
| 155 |
+
|
| 156 |
+
# Глобална инстанция за по-лесно преизползване
|
| 157 |
+
engine = TTSEngine()
|
voice_pipeline.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import json
|
| 4 |
+
import base64
|
| 5 |
+
import requests
|
| 6 |
+
import tempfile
|
| 7 |
+
import threading
|
| 8 |
+
import queue
|
| 9 |
+
import winsound
|
| 10 |
+
|
| 11 |
+
TRANSCRIPT_PATH = r"C:\Users\nasko\.gemini\antigravity\brain\695897cf-1c58-4886-a686-e9d8c406ebef\.system_generated\logs\transcript.jsonl"
|
| 12 |
+
API_URL = "http://localhost:8000/api/v1/synthesize/stream"
|
| 13 |
+
|
| 14 |
+
audio_queue = queue.Queue()
|
| 15 |
+
|
| 16 |
+
def player_worker():
|
| 17 |
+
"""
|
| 18 |
+
Взима готови WAV файлове от опашката и ги пуска.
|
| 19 |
+
"""
|
| 20 |
+
while True:
|
| 21 |
+
file_path = audio_queue.get()
|
| 22 |
+
if file_path is None: break
|
| 23 |
+
|
| 24 |
+
print(f"🔊 Възпроизвеждам от API...")
|
| 25 |
+
winsound.PlaySound(file_path, winsound.SND_FILENAME)
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
os.remove(file_path)
|
| 29 |
+
except OSError:
|
| 30 |
+
pass
|
| 31 |
+
|
| 32 |
+
audio_queue.task_done()
|
| 33 |
+
|
| 34 |
+
def process_text(text: str):
|
| 35 |
+
"""
|
| 36 |
+
Изпраща текста към API-то и чака за стрийминг на аудио парчета.
|
| 37 |
+
"""
|
| 38 |
+
print(f"📡 Изпращане към API: {text[:50]}...")
|
| 39 |
+
try:
|
| 40 |
+
response = requests.post(API_URL, json={
|
| 41 |
+
"text": text,
|
| 42 |
+
"voice_style": "F5",
|
| 43 |
+
"speed": 1.6
|
| 44 |
+
}, stream=True)
|
| 45 |
+
|
| 46 |
+
if response.status_code != 200:
|
| 47 |
+
print(f"Грешка от API: {response.status_code} - {response.text}")
|
| 48 |
+
return
|
| 49 |
+
|
| 50 |
+
for line in response.iter_lines():
|
| 51 |
+
if line:
|
| 52 |
+
data = json.loads(line)
|
| 53 |
+
if "error" in data:
|
| 54 |
+
print(f"API Грешка: {data['error']}")
|
| 55 |
+
continue
|
| 56 |
+
|
| 57 |
+
chunk_index = data.get("chunk_index")
|
| 58 |
+
audio_base64 = data.get("audio_base64")
|
| 59 |
+
|
| 60 |
+
if audio_base64:
|
| 61 |
+
audio_bytes = base64.b64decode(audio_base64)
|
| 62 |
+
|
| 63 |
+
# Записваме временно файла и го пускаме в опашката
|
| 64 |
+
fd, file_path = tempfile.mkstemp(suffix=f"_chunk_{chunk_index}.wav")
|
| 65 |
+
os.close(fd)
|
| 66 |
+
|
| 67 |
+
with open(file_path, "wb") as f:
|
| 68 |
+
f.write(audio_bytes)
|
| 69 |
+
|
| 70 |
+
audio_queue.put(file_path)
|
| 71 |
+
except requests.exceptions.ConnectionError:
|
| 72 |
+
print("Не мога да се свържа с API-то! Увери се, че `api.py` работи на порт 8000.")
|
| 73 |
+
except Exception as e:
|
| 74 |
+
print(f"Грешка при комуникация с API: {e}")
|
| 75 |
+
|
| 76 |
+
def tail_file():
|
| 77 |
+
"""
|
| 78 |
+
Следи чата (transcript.jsonl) за нови съобщения от модела.
|
| 79 |
+
"""
|
| 80 |
+
if not os.path.exists(TRANSCRIPT_PATH):
|
| 81 |
+
print(f"Файлът не съществува: {TRANSCRIPT_PATH}")
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
with open(TRANSCRIPT_PATH, "r", encoding="utf-8") as f:
|
| 85 |
+
f.seek(0, 2)
|
| 86 |
+
|
| 87 |
+
while True:
|
| 88 |
+
line = f.readline()
|
| 89 |
+
if not line:
|
| 90 |
+
time.sleep(0.5)
|
| 91 |
+
continue
|
| 92 |
+
|
| 93 |
+
try:
|
| 94 |
+
data = json.loads(line)
|
| 95 |
+
if data.get("source") == "MODEL" and data.get("type") in ["PLANNER_RESPONSE", "GENERIC"]:
|
| 96 |
+
full_text = data.get("content", "")
|
| 97 |
+
if full_text and not full_text.startswith("Created At:"):
|
| 98 |
+
print("\n📝 Получен нов текст от чата.")
|
| 99 |
+
process_text(full_text)
|
| 100 |
+
except Exception as e:
|
| 101 |
+
pass
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
t_play = threading.Thread(target=player_worker, daemon=True)
|
| 105 |
+
t_play.start()
|
| 106 |
+
|
| 107 |
+
print("Ani Voice Client слуша за съобщения и чака API-то...")
|
| 108 |
+
tail_file()
|