Spaces:
Sleeping
Sleeping
Upload 3 files
Browse files- README.md +19 -8
- app.py +148 -5
- test_app.py +60 -0
README.md
CHANGED
|
@@ -9,14 +9,17 @@ pinned: false
|
|
| 9 |
|
| 10 |
FastAPI-сервис для Hugging Face Docker Space, который заменяет тяжёлый Qwen TTS на более лёгкий CPU-стек:
|
| 11 |
|
| 12 |
-
- `POST /podcast-script` —
|
| 13 |
-
- `POST /tts` —
|
| 14 |
|
| 15 |
## Что важно
|
| 16 |
|
| 17 |
- Space остаётся тем же: можно обновить файлы существующего `menhaus-velosearch`.
|
| 18 |
-
- Цель v1 — стабильность на CPU Space,
|
| 19 |
- LLM не должна выдумывать факты: prompt требует использовать только данные из запроса.
|
|
|
|
|
|
|
|
|
|
| 20 |
- На момент проверки репозиторий `rhasspy/piper-voices` указывает лицензию MIT, но для релиза стоит сохранить ссылку на карточку и файл лицензии выбранного голоса.
|
| 21 |
- Если LLM или TTS недоступны, клиент должен использовать шаблонный подкаст и браузерный голос.
|
| 22 |
|
|
@@ -30,11 +33,13 @@ FastAPI-сервис для Hugging Face Docker Space, который замен
|
|
| 30 |
| `PODCAST_LLM_MAX_FACTS` | `4` | Максимум точек маршрута в одном сценарии |
|
| 31 |
| `PODCAST_LLM_MAX_INPUT_CHARS` | `7000` | Лимит prompt-пакета фактов |
|
| 32 |
| `PODCAST_LLM_MAX_NEW_TOKENS` | `420` | Лимит ответа LLM |
|
|
|
|
| 33 |
| `TTS_ENGINE` | `piper` | Сейчас поддерживается `piper` |
|
| 34 |
-
| `
|
|
|
|
|
|
|
| 35 |
| `PIPER_VOICE_REPO` | `rhasspy/piper-voices` | Репозиторий голосов Piper |
|
| 36 |
-
| `
|
| 37 |
-
| `PIPER_VOICE_NAME` | `ru_RU-dmitri-medium` | Имя `.onnx` и `.onnx.json` файлов |
|
| 38 |
|
| 39 |
## API
|
| 40 |
|
|
@@ -82,6 +87,8 @@ Content-Type: application/json
|
|
| 82 |
}
|
| 83 |
```
|
| 84 |
|
|
|
|
|
|
|
| 85 |
```http
|
| 86 |
POST /tts
|
| 87 |
Content-Type: application/json
|
|
@@ -89,12 +96,13 @@ Content-Type: application/json
|
|
| 89 |
{
|
| 90 |
"text": "Короткий текст на русском",
|
| 91 |
"role": "guide",
|
|
|
|
| 92 |
"speed": 1,
|
| 93 |
"format": "wav"
|
| 94 |
}
|
| 95 |
```
|
| 96 |
|
| 97 |
-
Ответ: `audio/wav` или `audio/ogg`.
|
| 98 |
|
| 99 |
## Локальная проверка
|
| 100 |
|
|
@@ -117,5 +125,8 @@ curl -X POST http://127.0.0.1:7860/podcast-script ^
|
|
| 117 |
curl -X POST http://127.0.0.1:7860/tts ^
|
| 118 |
-H "Content-Type: application/json" ^
|
| 119 |
-o sample.wav ^
|
| 120 |
-
-d "{\"text\":\"Привет! Это тест аудиогида.\",\"role\":\"guide\",\"format\":\"wav\"}"
|
| 121 |
```
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
FastAPI-сервис для Hugging Face Docker Space, который заменяет тяжёлый Qwen TTS на более лёгкий CPU-стек:
|
| 11 |
|
| 12 |
+
- `POST /podcast-script` — быстрый фактологический сценарий по данным маршрута; LLM можно включить отдельно на более мощном hardware.
|
| 13 |
+
- `POST /tts` — русская озвучка через Piper, по умолчанию женским голосом `ru_RU-irina-medium`; длинная допустимая реплика синтезируется полностью.
|
| 14 |
|
| 15 |
## Что важно
|
| 16 |
|
| 17 |
- Space остаётся тем же: можно обновить файлы существующего `menhaus-velosearch`.
|
| 18 |
+
- Цель v1 — стабильность на бесплатном CPU Space, поэтому по умолчанию подкаст собирается шаблонно по фактам без ожидания LLM.
|
| 19 |
- LLM не должна выдумывать факты: prompt требует использовать только данные из запроса.
|
| 20 |
+
- Основной голос аудиогида — женский Piper `ru_RU-irina-medium`; `ru_RU-dmitri-medium` оставлен в allowlist как технический fallback.
|
| 21 |
+
- Перед Piper применяется произносительная нормализация: адресные формы (`г.`, `ул.`, `д.`) и единицы маршрута раскрываются для речи, не изменяя исходные данные приложения.
|
| 22 |
+
- Реплика длиннее внутреннего чанка синтезируется по частям и возвращается одним WAV/OGG-файлом, поэтому клиент получает одно событие завершения речи.
|
| 23 |
- На момент проверки репозиторий `rhasspy/piper-voices` указывает лицензию MIT, но для релиза стоит сохранить ссылку на карточку и файл лицензии выбранного голоса.
|
| 24 |
- Если LLM или TTS недоступны, клиент должен использовать шаблонный подкаст и браузерный голос.
|
| 25 |
|
|
|
|
| 33 |
| `PODCAST_LLM_MAX_FACTS` | `4` | Максимум точек маршрута в одном сценарии |
|
| 34 |
| `PODCAST_LLM_MAX_INPUT_CHARS` | `7000` | Лимит prompt-пакета фактов |
|
| 35 |
| `PODCAST_LLM_MAX_NEW_TOKENS` | `420` | Лимит ответа LLM |
|
| 36 |
+
| `PODCAST_LLM_ENABLED` | `false` | Включить LLM-сценарист; для `CPU Basic` рекомендуется оставить `false` |
|
| 37 |
| `TTS_ENGINE` | `piper` | Сейчас поддерживается `piper` |
|
| 38 |
+
| `TTS_MAX_REQUEST_CHARS` | `1800` | Максимальная длина полного текста одного TTS-запроса; при превышении возвращается ошибка |
|
| 39 |
+
| `TTS_CHUNK_MAX_CHARS` | `420` | Внутренний размер фрагмента Piper; полный аудиоответ склеивается из всех фрагментов |
|
| 40 |
+
| `TTS_RUSSIAN_NORMALIZATION_ENABLED` | `true` | Раскрывать русские адресные сокращения и единицы перед синтезом |
|
| 41 |
| `PIPER_VOICE_REPO` | `rhasspy/piper-voices` | Репозиторий голосов Piper |
|
| 42 |
+
| `PIPER_DEFAULT_VOICE` | `ru_RU-irina-medium` | Основной голос из разрешённого списка |
|
|
|
|
| 43 |
|
| 44 |
## API
|
| 45 |
|
|
|
|
| 87 |
}
|
| 88 |
```
|
| 89 |
|
| 90 |
+
На бесплатном `CPU Basic` ответ по умолчанию содержит `provider: template-fallback` и формируется без загрузки Qwen. После перевода Space на более мощный hardware можно установить переменную окружения `PODCAST_LLM_ENABLED=true` и сравнить время ответа.
|
| 91 |
+
|
| 92 |
```http
|
| 93 |
POST /tts
|
| 94 |
Content-Type: application/json
|
|
|
|
| 96 |
{
|
| 97 |
"text": "Короткий текст на русском",
|
| 98 |
"role": "guide",
|
| 99 |
+
"voice": "ru_RU-irina-medium",
|
| 100 |
"speed": 1,
|
| 101 |
"format": "wav"
|
| 102 |
}
|
| 103 |
```
|
| 104 |
|
| 105 |
+
Ответ: `audio/wav` или `audio/ogg`; заголовок `X-TTS-Voice` сообщает фактически выбранный голос. Допустимые значения `voice`: `ru_RU-irina-medium`, `ru_RU-dmitri-medium`. Например, текст `г. Иваново, ул. Ленина, д. 4, 3 км` в серверной озвучке произносится в раскрытом русском виде, но исходная строка в UI не меняется.
|
| 106 |
|
| 107 |
## Локальная проверка
|
| 108 |
|
|
|
|
| 125 |
curl -X POST http://127.0.0.1:7860/tts ^
|
| 126 |
-H "Content-Type: application/json" ^
|
| 127 |
-o sample.wav ^
|
| 128 |
+
-d "{\"text\":\"Привет! Это тест аудиогида.\",\"role\":\"guide\",\"voice\":\"ru_RU-irina-medium\",\"format\":\"wav\"}"
|
| 129 |
```
|
| 130 |
+
|
| 131 |
+
Для проверки выбранного голоса используйте ответ с заголовками и убедитесь, что сервис возвращает
|
| 132 |
+
`X-TTS-Voice: ru_RU-irina-medium`.
|
app.py
CHANGED
|
@@ -32,7 +32,9 @@ LLM_MAX_NEW_TOKENS = int(os.getenv('PODCAST_LLM_MAX_NEW_TOKENS', '420'))
|
|
| 32 |
PODCAST_LLM_ENABLED = os.getenv('PODCAST_LLM_ENABLED', 'false').lower() in {'1', 'true', 'yes', 'on'}
|
| 33 |
|
| 34 |
TTS_ENGINE = os.getenv('TTS_ENGINE', 'piper').lower()
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
PIPER_VOICE_REPO = os.getenv('PIPER_VOICE_REPO', 'rhasspy/piper-voices')
|
| 37 |
PIPER_DEFAULT_VOICE = os.getenv('PIPER_DEFAULT_VOICE', 'ru_RU-irina-medium')
|
| 38 |
PIPER_FALLBACK_VOICE = 'ru_RU-dmitri-medium'
|
|
@@ -128,12 +130,131 @@ def clean_text(value: str | None, limit: int = 900) -> str:
|
|
| 128 |
return shortened.rsplit(' ', 1)[0].strip()
|
| 129 |
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
def normalize_tts_text(text: str) -> str:
|
| 132 |
-
normalized =
|
| 133 |
if not normalized:
|
| 134 |
raise HTTPException(status_code=400, detail='Text is required.')
|
| 135 |
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
|
| 139 |
def resolve_piper_voice(voice: str, role: str) -> str:
|
|
@@ -536,7 +657,7 @@ def piper_paths(voice_name: str) -> tuple[str, str]:
|
|
| 536 |
return model_path, config_path
|
| 537 |
|
| 538 |
|
| 539 |
-
def
|
| 540 |
model_path, config_path = piper_paths(voice_name)
|
| 541 |
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as wav_file:
|
| 542 |
wav_path = wav_file.name
|
|
@@ -595,6 +716,25 @@ def synthesize_with_piper(text: str, speed: float, voice_name: str) -> tuple[np.
|
|
| 595 |
pass
|
| 596 |
|
| 597 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
def write_audio(audio: np.ndarray, sample_rate: int, requested_format: str) -> tuple[bytes, str]:
|
| 599 |
buffer = io.BytesIO()
|
| 600 |
requested_format = requested_format.lower()
|
|
@@ -627,6 +767,7 @@ def health():
|
|
| 627 |
'podcast_llm_enabled': PODCAST_LLM_ENABLED,
|
| 628 |
'tts_engine': TTS_ENGINE,
|
| 629 |
'tts_voice': PIPER_DEFAULT_VOICE if TTS_ENGINE == 'piper' else 'disabled',
|
|
|
|
| 630 |
}
|
| 631 |
|
| 632 |
|
|
@@ -648,7 +789,9 @@ def diagnostics():
|
|
| 648 |
'last_script_error': LAST_SCRIPT_ERROR,
|
| 649 |
'last_script_error_type': LAST_SCRIPT_ERROR_TYPE,
|
| 650 |
'tts_engine': TTS_ENGINE,
|
| 651 |
-
'
|
|
|
|
|
|
|
| 652 |
'piper_voice_repo': PIPER_VOICE_REPO,
|
| 653 |
'piper_default_voice': PIPER_DEFAULT_VOICE,
|
| 654 |
'piper_allowed_voices': list(PIPER_VOICES.keys()),
|
|
|
|
| 32 |
PODCAST_LLM_ENABLED = os.getenv('PODCAST_LLM_ENABLED', 'false').lower() in {'1', 'true', 'yes', 'on'}
|
| 33 |
|
| 34 |
TTS_ENGINE = os.getenv('TTS_ENGINE', 'piper').lower()
|
| 35 |
+
TTS_MAX_REQUEST_CHARS = int(os.getenv('TTS_MAX_REQUEST_CHARS', os.getenv('TTS_MAX_TEXT_CHARS', '1800')))
|
| 36 |
+
TTS_CHUNK_MAX_CHARS = int(os.getenv('TTS_CHUNK_MAX_CHARS', '420'))
|
| 37 |
+
TTS_RUSSIAN_NORMALIZATION_ENABLED = os.getenv('TTS_RUSSIAN_NORMALIZATION_ENABLED', 'true').lower() in {'1', 'true', 'yes', 'on'}
|
| 38 |
PIPER_VOICE_REPO = os.getenv('PIPER_VOICE_REPO', 'rhasspy/piper-voices')
|
| 39 |
PIPER_DEFAULT_VOICE = os.getenv('PIPER_DEFAULT_VOICE', 'ru_RU-irina-medium')
|
| 40 |
PIPER_FALLBACK_VOICE = 'ru_RU-dmitri-medium'
|
|
|
|
| 130 |
return shortened.rsplit(' ', 1)[0].strip()
|
| 131 |
|
| 132 |
|
| 133 |
+
def clean_full_text(value: str | None) -> str:
|
| 134 |
+
if not value:
|
| 135 |
+
return ''
|
| 136 |
+
|
| 137 |
+
text = re.sub(r'[\x00-\x1f]+', ' ', str(value))
|
| 138 |
+
return re.sub(r'\s+', ' ', text).strip()
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def russian_number_unit(raw_number: str, forms: tuple[str, str, str]) -> str:
|
| 142 |
+
spoken_number = raw_number.replace('.', ',')
|
| 143 |
+
if ',' in spoken_number:
|
| 144 |
+
return f'{spoken_number} {forms[1]}'
|
| 145 |
+
|
| 146 |
+
value = int(spoken_number)
|
| 147 |
+
remainder_100 = value % 100
|
| 148 |
+
remainder_10 = value % 10
|
| 149 |
+
if 11 <= remainder_100 <= 14:
|
| 150 |
+
form = forms[2]
|
| 151 |
+
elif remainder_10 == 1:
|
| 152 |
+
form = forms[0]
|
| 153 |
+
elif 2 <= remainder_10 <= 4:
|
| 154 |
+
form = forms[1]
|
| 155 |
+
else:
|
| 156 |
+
form = forms[2]
|
| 157 |
+
return f'{spoken_number} {form}'
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def normalize_spoken_russian(text: str) -> str:
|
| 161 |
+
if not TTS_RUSSIAN_NORMALIZATION_ENABLED:
|
| 162 |
+
return text
|
| 163 |
+
|
| 164 |
+
normalized = text
|
| 165 |
+
address_rules = [
|
| 166 |
+
(r'\bв\s+г\.\s*(?=[А-ЯЁ])', 'в городе '),
|
| 167 |
+
(r'\bиз\s+г\.\s*(?=[А-ЯЁ])', 'из города '),
|
| 168 |
+
(r'\bпо\s+г\.\s*(?=[А-ЯЁ])', 'по городу '),
|
| 169 |
+
(r'\bг\.\s*(?=[А-ЯЁ])', 'город '),
|
| 170 |
+
(r'\bна\s+ул\.\s*', 'на улице '),
|
| 171 |
+
(r'\bпо\s+ул\.\s*', 'по улице '),
|
| 172 |
+
(r'\bс\s+ул\.\s*', 'с улицы '),
|
| 173 |
+
(r'\bул\.\s*', 'улица '),
|
| 174 |
+
(r'\bна\s+(?:пр-т|просп\.)\s*', 'на проспекте '),
|
| 175 |
+
(r'\bпо\s+(?:пр-т|просп\.)\s*', 'по проспекту '),
|
| 176 |
+
(r'\b(?:пр-т|просп\.)\s*', 'проспект '),
|
| 177 |
+
(r'\bв\s+пер\.\s*', 'в переулке '),
|
| 178 |
+
(r'\bпо\s+пер\.\s*', 'по переулку '),
|
| 179 |
+
(r'\bпер\.\s*', 'переулок '),
|
| 180 |
+
(r'\bна\s+пл\.\s*', 'на площади '),
|
| 181 |
+
(r'\bпл\.\s*', 'площадь '),
|
| 182 |
+
(r'\bд\.\s*(?=\d)', 'дом '),
|
| 183 |
+
(r'\bкорп\.\s*(?=\d)', 'корпус '),
|
| 184 |
+
(r'\bстр\.\s*(?=\d)', 'строение '),
|
| 185 |
+
]
|
| 186 |
+
normalized = re.sub(r'\bв\s+(\d{3,4})\s*г\.\s*', r'в \1 году ', normalized)
|
| 187 |
+
normalized = re.sub(r'\b(\d{3,4})\s*г\.\s*', r'\1 год ', normalized)
|
| 188 |
+
for pattern, replacement in address_rules:
|
| 189 |
+
normalized = re.sub(pattern, replacement, normalized, flags=re.IGNORECASE)
|
| 190 |
+
|
| 191 |
+
unit_rules = [
|
| 192 |
+
(r'(\d+(?:[.,]\d+)?)\s*км\s*/\s*ч\b', ('километр в час', 'километра в час', 'километров в час')),
|
| 193 |
+
(r'(\d+(?:[.,]\d+)?)\s*км\b', ('километр', 'километра', 'километров')),
|
| 194 |
+
(r'(\d+(?:[.,]\d+)?)\s*мин\b', ('минута', 'минуты', 'минут')),
|
| 195 |
+
(r'(\d+(?:[.,]\d+)?)\s*ч\b', ('час', 'часа', 'часов')),
|
| 196 |
+
(r'(\d+(?:[.,]\d+)?)\s*м\b', ('метр', 'метра', 'метров')),
|
| 197 |
+
]
|
| 198 |
+
for pattern, forms in unit_rules:
|
| 199 |
+
normalized = re.sub(pattern, lambda match: russian_number_unit(match.group(1), forms), normalized)
|
| 200 |
+
return re.sub(r'\s+', ' ', normalized).strip()
|
| 201 |
+
|
| 202 |
+
|
| 203 |
def normalize_tts_text(text: str) -> str:
|
| 204 |
+
normalized = clean_full_text(text)
|
| 205 |
if not normalized:
|
| 206 |
raise HTTPException(status_code=400, detail='Text is required.')
|
| 207 |
|
| 208 |
+
if len(normalized) > TTS_MAX_REQUEST_CHARS:
|
| 209 |
+
raise HTTPException(status_code=400, detail='TTS text exceeds maximum request length.')
|
| 210 |
+
|
| 211 |
+
return normalize_spoken_russian(normalized)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def split_tts_chunks(text: str, max_chars: int = TTS_CHUNK_MAX_CHARS) -> list[str]:
|
| 215 |
+
if max_chars < 20:
|
| 216 |
+
raise ValueError('TTS chunk size must be at least 20 characters.')
|
| 217 |
+
|
| 218 |
+
chunks: list[str] = []
|
| 219 |
+
current = ''
|
| 220 |
+
phrases = re.findall(r'.+?(?:[.!?;:]+(?=\s|$)|$)', text)
|
| 221 |
+
for phrase in phrases:
|
| 222 |
+
phrase = phrase.strip()
|
| 223 |
+
if not phrase:
|
| 224 |
+
continue
|
| 225 |
+
if len(phrase) > max_chars:
|
| 226 |
+
words = phrase.split()
|
| 227 |
+
pieces: list[str] = []
|
| 228 |
+
part = ''
|
| 229 |
+
for word in words:
|
| 230 |
+
if len(word) > max_chars:
|
| 231 |
+
if part:
|
| 232 |
+
pieces.append(part)
|
| 233 |
+
part = ''
|
| 234 |
+
# An unusually long token is preserved rather than read as broken fragments.
|
| 235 |
+
pieces.append(word)
|
| 236 |
+
elif not part or len(part) + len(word) + 1 <= max_chars:
|
| 237 |
+
part = f'{part} {word}'.strip()
|
| 238 |
+
else:
|
| 239 |
+
pieces.append(part)
|
| 240 |
+
part = word
|
| 241 |
+
if part:
|
| 242 |
+
pieces.append(part)
|
| 243 |
+
else:
|
| 244 |
+
pieces = [phrase]
|
| 245 |
+
|
| 246 |
+
for piece in pieces:
|
| 247 |
+
if not current:
|
| 248 |
+
current = piece
|
| 249 |
+
elif len(current) + len(piece) + 1 <= max_chars:
|
| 250 |
+
current = f'{current} {piece}'
|
| 251 |
+
else:
|
| 252 |
+
chunks.append(current)
|
| 253 |
+
current = piece
|
| 254 |
+
|
| 255 |
+
if current:
|
| 256 |
+
chunks.append(current)
|
| 257 |
+
return chunks or [text]
|
| 258 |
|
| 259 |
|
| 260 |
def resolve_piper_voice(voice: str, role: str) -> str:
|
|
|
|
| 657 |
return model_path, config_path
|
| 658 |
|
| 659 |
|
| 660 |
+
def synthesize_piper_chunk(text: str, speed: float, voice_name: str) -> tuple[np.ndarray, int]:
|
| 661 |
model_path, config_path = piper_paths(voice_name)
|
| 662 |
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as wav_file:
|
| 663 |
wav_path = wav_file.name
|
|
|
|
| 716 |
pass
|
| 717 |
|
| 718 |
|
| 719 |
+
def synthesize_with_piper(text: str, speed: float, voice_name: str) -> tuple[np.ndarray, int]:
|
| 720 |
+
chunks = split_tts_chunks(text, TTS_CHUNK_MAX_CHARS)
|
| 721 |
+
rendered_chunks: list[np.ndarray] = []
|
| 722 |
+
sample_rate = DEFAULT_SAMPLE_RATE
|
| 723 |
+
|
| 724 |
+
for index, chunk in enumerate(chunks):
|
| 725 |
+
chunk_audio, chunk_sample_rate = synthesize_piper_chunk(chunk, speed, voice_name)
|
| 726 |
+
if index == 0:
|
| 727 |
+
sample_rate = chunk_sample_rate
|
| 728 |
+
elif chunk_sample_rate != sample_rate:
|
| 729 |
+
raise HTTPException(status_code=502, detail='Piper returned mismatched audio sample rates.')
|
| 730 |
+
|
| 731 |
+
rendered_chunks.append(np.asarray(chunk_audio, dtype='float32'))
|
| 732 |
+
if index < len(chunks) - 1:
|
| 733 |
+
rendered_chunks.append(np.zeros(max(1, int(sample_rate * 0.06)), dtype='float32'))
|
| 734 |
+
|
| 735 |
+
return np.concatenate(rendered_chunks), sample_rate
|
| 736 |
+
|
| 737 |
+
|
| 738 |
def write_audio(audio: np.ndarray, sample_rate: int, requested_format: str) -> tuple[bytes, str]:
|
| 739 |
buffer = io.BytesIO()
|
| 740 |
requested_format = requested_format.lower()
|
|
|
|
| 767 |
'podcast_llm_enabled': PODCAST_LLM_ENABLED,
|
| 768 |
'tts_engine': TTS_ENGINE,
|
| 769 |
'tts_voice': PIPER_DEFAULT_VOICE if TTS_ENGINE == 'piper' else 'disabled',
|
| 770 |
+
'tts_russian_normalization_enabled': TTS_RUSSIAN_NORMALIZATION_ENABLED,
|
| 771 |
}
|
| 772 |
|
| 773 |
|
|
|
|
| 789 |
'last_script_error': LAST_SCRIPT_ERROR,
|
| 790 |
'last_script_error_type': LAST_SCRIPT_ERROR_TYPE,
|
| 791 |
'tts_engine': TTS_ENGINE,
|
| 792 |
+
'tts_max_request_chars': TTS_MAX_REQUEST_CHARS,
|
| 793 |
+
'tts_chunk_max_chars': TTS_CHUNK_MAX_CHARS,
|
| 794 |
+
'tts_russian_normalization_enabled': TTS_RUSSIAN_NORMALIZATION_ENABLED,
|
| 795 |
'piper_voice_repo': PIPER_VOICE_REPO,
|
| 796 |
'piper_default_voice': PIPER_DEFAULT_VOICE,
|
| 797 |
'piper_allowed_voices': list(PIPER_VOICES.keys()),
|
test_app.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from unittest.mock import patch
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
from fastapi import HTTPException
|
| 6 |
+
|
| 7 |
+
import app
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SpokenRussianTests(unittest.TestCase):
|
| 11 |
+
def test_address_forms_are_expanded_for_speech(self):
|
| 12 |
+
text = 'В г. Иваново, на ул. Ленина, д. 4 и корп. 2.'
|
| 13 |
+
|
| 14 |
+
self.assertEqual(
|
| 15 |
+
app.normalize_spoken_russian(text),
|
| 16 |
+
'В городе Иваново, на улице Ленина, дом 4 и корпус 2.',
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
def test_route_units_are_expanded(self):
|
| 20 |
+
text = 'Маршрут: 3 км, 250 м, 21 мин, скорость 12 км/ч.'
|
| 21 |
+
|
| 22 |
+
self.assertEqual(
|
| 23 |
+
app.normalize_spoken_russian(text),
|
| 24 |
+
'Маршрут: 3 километра, 250 метров, 21 минута, скорость 12 километров в час.',
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
def test_request_over_limit_fails_instead_of_truncating(self):
|
| 28 |
+
with self.assertRaises(HTTPException):
|
| 29 |
+
app.normalize_tts_text('а' * (app.TTS_MAX_REQUEST_CHARS + 1))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class PiperChunkTests(unittest.TestCase):
|
| 33 |
+
def test_long_text_keeps_tail_in_last_chunk(self):
|
| 34 |
+
text = 'Первая фраза достаточно длинная. Финальная фраза должна прозвучать полностью.'
|
| 35 |
+
chunks = app.split_tts_chunks(text, 35)
|
| 36 |
+
|
| 37 |
+
self.assertGreater(len(chunks), 1)
|
| 38 |
+
self.assertIn('полностью.', chunks[-1])
|
| 39 |
+
self.assertEqual(' '.join(chunks), text)
|
| 40 |
+
|
| 41 |
+
def test_multiple_chunks_are_returned_as_one_audio_array(self):
|
| 42 |
+
with patch.object(app, 'TTS_CHUNK_MAX_CHARS', 30), patch.object(
|
| 43 |
+
app,
|
| 44 |
+
'synthesize_piper_chunk',
|
| 45 |
+
side_effect=[(np.array([0.1, 0.2], dtype='float32'), 100), (np.array([0.3], dtype='float32'), 100)],
|
| 46 |
+
) as synthesizer:
|
| 47 |
+
audio, sample_rate = app.synthesize_with_piper(
|
| 48 |
+
'Первая длинная реплика. Вторая длинная реплика.',
|
| 49 |
+
1.0,
|
| 50 |
+
'ru_RU-irina-medium',
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
self.assertEqual(synthesizer.call_count, 2)
|
| 54 |
+
self.assertEqual(sample_rate, 100)
|
| 55 |
+
self.assertGreater(len(audio), 3)
|
| 56 |
+
self.assertAlmostEqual(float(audio[-1]), 0.3, places=6)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
if __name__ == '__main__':
|
| 60 |
+
unittest.main()
|