Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -5,142 +5,178 @@ import tempfile
|
|
| 5 |
import os
|
| 6 |
import uuid
|
| 7 |
import re
|
| 8 |
-
import
|
|
|
|
| 9 |
|
| 10 |
-
# ---
|
| 11 |
-
#
|
| 12 |
-
|
| 13 |
-
|
| 14 |
|
| 15 |
-
# ---
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
return text
|
| 23 |
|
| 24 |
-
#
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
print("⏳ Загрузка голосов...")
|
| 29 |
-
voices = await edge_tts.list_voices()
|
| 30 |
-
VOICES_CACHE = sorted(voices, key=lambda x: x['Locale'])
|
| 31 |
-
|
| 32 |
-
seen = set()
|
| 33 |
-
LANGUAGES_CACHE = []
|
| 34 |
-
for v in VOICES_CACHE:
|
| 35 |
-
if v['Locale'] not in seen:
|
| 36 |
-
seen.add(v['Locale'])
|
| 37 |
-
LANGUAGES_CACHE.append(v['Locale'])
|
| 38 |
-
LANGUAGES_CACHE.sort()
|
| 39 |
-
print(f"✅ Успешно загружено {len(VOICES_CACHE)} голосов.")
|
| 40 |
-
except Exception as e:
|
| 41 |
-
print(f"❌ Ошибка загрузки: {e}")
|
| 42 |
-
LANGUAGES_CACHE = ["ru-RU", "en-US"]
|
| 43 |
|
| 44 |
-
def
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
return
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
if not text.strip():
|
| 61 |
-
raise gr.Warning("
|
| 62 |
-
if not voice_str:
|
| 63 |
-
raise gr.Warning("Выберите голос!")
|
| 64 |
|
| 65 |
-
|
| 66 |
-
clean_input = clean_text(text)
|
| 67 |
|
| 68 |
-
#
|
| 69 |
-
|
| 70 |
-
rate_str = f"{int(rate):+d}%"
|
| 71 |
-
pitch_str = f"{int(pitch):+d}Hz"
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
|
| 76 |
-
|
|
|
|
| 77 |
|
| 78 |
-
# 3
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
try:
|
| 84 |
-
|
| 85 |
-
|
|
|
|
| 86 |
|
| 87 |
-
if os.path.exists(
|
| 88 |
-
|
| 89 |
-
else:
|
| 90 |
-
raise Exception("Файл создан, но пуст (0 байт)")
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
except Exception as e:
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
await asyncio.sleep(
|
|
|
|
| 96 |
|
| 97 |
-
#
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
-
#
|
| 106 |
-
asyncio.run(load_voices_async())
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
START_VOICES = [f"{v['ShortName']} ({v['Gender']})" for v in VOICES_CACHE if v['Locale'] == DEFAULT_LANG]
|
| 113 |
-
|
| 114 |
-
DEFAULT_VOICE = None
|
| 115 |
-
if START_VOICES:
|
| 116 |
-
# Ищем Светлану
|
| 117 |
-
DEFAULT_VOICE = next((v for v in START_VOICES if "Svetlana" in v), START_VOICES[0])
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
-
with gr.Blocks(theme=theme, css=css, title="
|
| 123 |
|
| 124 |
-
gr.Markdown("#
|
|
|
|
| 125 |
|
| 126 |
with gr.Row():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
with gr.Column(scale=1):
|
| 128 |
-
gr.Markdown("###
|
| 129 |
-
|
| 130 |
-
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
audio = gr.Audio(label="Аудио")
|
| 141 |
-
|
| 142 |
-
lang.change(filter_voices, inputs=lang, outputs=voice)
|
| 143 |
-
btn.click(generate_speech, inputs=[txt, voice, slider_rate, slider_pitch], outputs=audio)
|
| 144 |
|
| 145 |
if __name__ == "__main__":
|
|
|
|
| 146 |
demo.queue().launch()
|
|
|
|
| 5 |
import os
|
| 6 |
import uuid
|
| 7 |
import re
|
| 8 |
+
import shutil
|
| 9 |
+
from pydub import AudioSegment
|
| 10 |
|
| 11 |
+
# --- ПРОВЕРКА ОКРУЖЕНИЯ СЕРВЕРА ---
|
| 12 |
+
# Проверяем, готов ли сервер к работе с аудио
|
| 13 |
+
if not shutil.which("ffmpeg"):
|
| 14 |
+
print("⚠️ ВНИМАНИЕ: На сервере не найден FFmpeg. Склейка будет работать медленнее или с ошибками.")
|
| 15 |
|
| 16 |
+
# --- КОНФИГУРАЦИЯ (ФЭНТЕЗИ ПРЕСЕТ) ---
|
| 17 |
+
# Все эти настройки применяются НА СЕРВЕРЕ перед отправкой запроса
|
| 18 |
+
VOICE_CONFIG = {
|
| 19 |
+
"narrator": {"voice": "ru-RU-DmitryNeural", "pitch": "-7Hz", "rate": "-5%"}, # Рассказчик (Эпик)
|
| 20 |
+
"male": {"voice": "ru-RU-DenisNeural", "pitch": "-2Hz", "rate": "+0%"}, # Мужчина
|
| 21 |
+
"female": {"voice": "ru-RU-SvetlanaNeural","pitch": "+5Hz", "rate": "+5%"} # Женщина
|
| 22 |
+
}
|
|
|
|
| 23 |
|
| 24 |
+
# Папка для временных файлов на сервере
|
| 25 |
+
TEMP_DIR = tempfile.gettempdir()
|
| 26 |
+
|
| 27 |
+
# --- ЛОГИКА СЕРВЕРА (Server-Side Logic) ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
def analyze_text_structure(text):
|
| 30 |
+
"""
|
| 31 |
+
Анализирует текст, используя CPU сервера.
|
| 32 |
+
Определяет пол по окончаниям глаголов русского языка.
|
| 33 |
+
"""
|
| 34 |
+
segments = []
|
| 35 |
+
paragraphs = text.split('\n')
|
| 36 |
|
| 37 |
+
# Слова-маркеры (женские)
|
| 38 |
+
female_markers = [
|
| 39 |
+
r"сказала", r"спросила", r"ответила", r"прошептала", r"крикнула",
|
| 40 |
+
r"подумала", r"заметила", r"взглянула", r"обернулась"
|
| 41 |
+
]
|
| 42 |
|
| 43 |
+
for p in paragraphs:
|
| 44 |
+
p = p.strip()
|
| 45 |
+
if not p: continue
|
| 46 |
+
|
| 47 |
+
role = "narrator" # По умолчанию
|
| 48 |
+
|
| 49 |
+
# Если это прямая речь...
|
| 50 |
+
if p.startswith('—') or p.startswith('-') or '"' in p or '«' in p:
|
| 51 |
+
p_lower = p.lower()
|
| 52 |
+
# ...сервер ищет маркеры
|
| 53 |
+
is_female = any(re.search(m, p_lower) for m in female_markers)
|
| 54 |
+
|
| 55 |
+
if is_female:
|
| 56 |
+
role = "female"
|
| 57 |
+
else:
|
| 58 |
+
# Если маркеров нет, но это диалог -> считаем мужчиной (стандарт для фэнтези)
|
| 59 |
+
role = "male"
|
| 60 |
+
|
| 61 |
+
segments.append({"text": p, "role": role})
|
| 62 |
|
| 63 |
+
return segments
|
| 64 |
|
| 65 |
+
async def generate_server_side(text, progress=gr.Progress()):
|
| 66 |
+
"""
|
| 67 |
+
Основная функция. Работает полностью в памяти сервера.
|
| 68 |
+
"""
|
| 69 |
if not text.strip():
|
| 70 |
+
raise gr.Warning("Сервер не получил текст.")
|
|
|
|
|
|
|
| 71 |
|
| 72 |
+
print(f"⚙️ [Server] Начало обработки. RAM занята процессом...")
|
|
|
|
| 73 |
|
| 74 |
+
# 1. Анализ (CPU)
|
| 75 |
+
segments = analyze_text_structure(text)
|
|
|
|
|
|
|
| 76 |
|
| 77 |
+
# 2. Создаем пустой аудио-контейнер в памяти (RAM)
|
| 78 |
+
full_audio = AudioSegment.empty()
|
| 79 |
|
| 80 |
+
# Временный список файлов для очистки
|
| 81 |
+
temp_files = []
|
| 82 |
|
| 83 |
+
# 3. Цикл генерации
|
| 84 |
+
for item in progress.tqdm(segments, desc="Сервер генерирует аудио..."):
|
| 85 |
+
role = item["role"]
|
| 86 |
+
content = item["text"]
|
| 87 |
+
|
| 88 |
+
# Берем настройки
|
| 89 |
+
conf = VOICE_CONFIG.get(role, VOICE_CONFIG["narrator"])
|
| 90 |
+
|
| 91 |
+
# Путь к куску на диске сервера
|
| 92 |
+
segment_filename = f"server_seg_{uuid.uuid4().hex}.mp3"
|
| 93 |
+
segment_path = os.path.join(TEMP_DIR, segment_filename)
|
| 94 |
+
|
| 95 |
+
# Форматируем параметры
|
| 96 |
+
rate_str = conf["rate"]
|
| 97 |
+
pitch_str = conf["pitch"]
|
| 98 |
+
|
| 99 |
try:
|
| 100 |
+
# Запрос от Сервера к Microsoft (Клиент тут не участвует)
|
| 101 |
+
comm = edge_tts.Communicate(content, conf["voice"], rate=rate_str, pitch=pitch_str)
|
| 102 |
+
await comm.save(segment_path)
|
| 103 |
|
| 104 |
+
if os.path.exists(segment_path):
|
| 105 |
+
temp_files.append(segment_path)
|
|
|
|
|
|
|
| 106 |
|
| 107 |
+
# Загружаем кусок в RAM
|
| 108 |
+
seg_audio = AudioSegment.from_mp3(segment_path)
|
| 109 |
+
|
| 110 |
+
# Склейка в RAM (Crossfade 50ms для плавности)
|
| 111 |
+
if len(full_audio) > 0:
|
| 112 |
+
full_audio = full_audio.append(seg_audio, crossfade=50)
|
| 113 |
+
else:
|
| 114 |
+
full_audio = seg_audio
|
| 115 |
+
|
| 116 |
except Exception as e:
|
| 117 |
+
print(f"⚠️ [Server Error] Сбой на фразе '{content[:20]}': {e}")
|
| 118 |
+
# Если сбой, пробуем паузу и идем дальше
|
| 119 |
+
await asyncio.sleep(0.5)
|
| 120 |
+
continue
|
| 121 |
|
| 122 |
+
# 4. Сохранение итогового файла на диск сервера
|
| 123 |
+
output_filename = f"FANTASY_AUDIO_{uuid.uuid4().hex}.mp3"
|
| 124 |
+
output_path = os.path.join(TEMP_DIR, output_filename)
|
| 125 |
+
|
| 126 |
+
print(f"💾 [Server] Сохранение результата: {output_path}")
|
| 127 |
+
full_audio.export(output_path, format="mp3")
|
| 128 |
+
|
| 129 |
+
# 5. Очистка мусора с диска сервера
|
| 130 |
+
for f in temp_files:
|
| 131 |
+
try: os.remove(f)
|
| 132 |
+
except: pass
|
| 133 |
+
|
| 134 |
+
# Возвращаем путь. Gradio сам передаст файл клиенту.
|
| 135 |
+
return output_path
|
| 136 |
|
| 137 |
+
# --- ИНТЕРФЕЙС ---
|
|
|
|
| 138 |
|
| 139 |
+
css = """
|
| 140 |
+
body { background-color: #111827; color: #f3f4f6; }
|
| 141 |
+
.container { max-width: 900px; margin: auto; }
|
| 142 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
+
theme = gr.themes.Soft(
|
| 145 |
+
primary_hue="indigo",
|
| 146 |
+
secondary_hue="slate",
|
| 147 |
+
neutral_hue="slate"
|
| 148 |
+
)
|
| 149 |
|
| 150 |
+
with gr.Blocks(theme=theme, css=css, title="Server-Side TTS Engine") as demo:
|
| 151 |
|
| 152 |
+
gr.Markdown("# 🖥️ Server-Side Fantasy Engine")
|
| 153 |
+
gr.Markdown("Вся обработка (анализ текста, генерация, склейка) выполняется на мощностях сервера.")
|
| 154 |
|
| 155 |
with gr.Row():
|
| 156 |
+
with gr.Column(scale=2):
|
| 157 |
+
text_input = gr.Textbox(
|
| 158 |
+
label="Текст Книги",
|
| 159 |
+
lines=12,
|
| 160 |
+
placeholder="Введите текст. Сервер сам определит роли (Мужчина/Женщина) по контексту.",
|
| 161 |
+
value="— Тише! — прошептала ведьма, глядя в темноту.\nРыцарь обнажил меч и ответил:\n— Я ничего не боюсь."
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
generate_btn = gr.Button("⚡ Запустить обработку на сервере", variant="primary", size="lg")
|
| 165 |
+
|
| 166 |
with gr.Column(scale=1):
|
| 167 |
+
gr.Markdown("### 📊 Монитор")
|
| 168 |
+
gr.Markdown("Используются ресурсы хостинга:")
|
| 169 |
+
gr.Code(value="CPU: Active (Parsing)\nRAM: Active (Audio Stitching)", language="yaml", label="Статус сервера")
|
| 170 |
|
| 171 |
+
audio_output = gr.Audio(label="Готовый файл с сервера", type="filepath")
|
| 172 |
+
|
| 173 |
+
# Привязка события
|
| 174 |
+
generate_btn.click(
|
| 175 |
+
fn=generate_server_side,
|
| 176 |
+
inputs=[text_input],
|
| 177 |
+
outputs=[audio_output]
|
| 178 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
if __name__ == "__main__":
|
| 181 |
+
# Запуск сервера
|
| 182 |
demo.queue().launch()
|