VSPAN commited on
Commit
743c0e7
·
verified ·
1 Parent(s): ddd6448

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +186 -129
app.py CHANGED
@@ -6,177 +6,234 @@ 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()
 
6
  import uuid
7
  import re
8
  import shutil
9
+ import emoji
10
  from pydub import AudioSegment
11
 
12
+ # --- ПРОВЕРКА СЕРВЕРА ---
 
13
  if not shutil.which("ffmpeg"):
14
+ print("⚠️ FFmpeg не найден! Склейка может не работать.")
 
 
 
 
 
 
 
 
15
 
16
+ # --- ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ ---
17
+ VOICES_CACHE = []
18
+ LANGUAGES_CACHE = []
19
  TEMP_DIR = tempfile.gettempdir()
20
 
21
+ # --- НАСТРОЙКИ ФЭНТЕЗИ (Для Авто-режима) ---
22
+ FANTASY_PRESETS = {
23
+ "narrator": {"voice": "ru-RU-DmitryNeural", "pitch": "-7Hz", "rate": "-5%"},
24
+ "male": {"voice": "ru-RU-DenisNeural", "pitch": "-2Hz", "rate": "+0%"},
25
+ "female": {"voice": "ru-RU-SvetlanaNeural","pitch": "+5Hz", "rate": "+5%"}
26
+ }
27
 
28
+ # --- ФУНКЦИИ ПОДГОТОВКИ ---
29
+
30
+ def clean_text(text):
31
+ if not text: return ""
32
+ text = re.sub(r'[*_~><^]', '', text)
33
+ text = emoji.replace_emoji(text, replace='')
34
+ text = re.sub(r'\s+', ' ', text).strip()
35
+ return text
36
+
37
+ async def load_voices_init():
38
+ """Загружаем голоса один раз при старте сервера"""
39
+ global VOICES_CACHE, LANGUAGES_CACHE
40
+ try:
41
+ voices = await edge_tts.list_voices()
42
+ VOICES_CACHE = sorted(voices, key=lambda x: x['Locale'])
43
+
44
+ seen = set()
45
+ LANGUAGES_CACHE = []
46
+ for v in VOICES_CACHE:
47
+ if v['Locale'] not in seen:
48
+ seen.add(v['Locale'])
49
+ LANGUAGES_CACHE.append(v['Locale'])
50
+ LANGUAGES_CACHE.sort()
51
+ print(f"✅ [Server] Голоса загружены: {len(VOICES_CACHE)}")
52
+ except Exception as e:
53
+ print(f"❌ Ошибка загрузки голосов: {e}")
54
+ LANGUAGES_CACHE = ["ru-RU", "en-US"]
55
+
56
+ # --- ФИЛЬТРЫ UI ---
57
+ def filter_voices_ui(language):
58
+ if not language: return gr.Dropdown(choices=[])
59
+ filtered = [f"{v['ShortName']} ({v['Gender']})" for v in VOICES_CACHE if v['Locale'] == language]
60
+ # Пытаемся найти Дмитрия или Светлану по дефолту
61
+ def_val = filtered[0] if filtered else None
62
+ for v in filtered:
63
+ if "Dmitry" in v: def_val = v; break
64
+ return gr.Dropdown(choices=filtered, value=def_val)
65
+
66
+ # --- ДВИЖОК ГЕНЕРАЦИИ (SERVER SIDE) ---
67
+
68
+ async def generate_segment_internal(text, voice, rate, pitch):
69
+ """Генерирует один кусок аудио во временную папку сервера"""
70
+ if not text.strip(): return None
71
+
72
+ fname = f"seg_{uuid.uuid4().hex}.mp3"
73
+ fpath = os.path.join(TEMP_DIR, fname)
74
+
75
+ # Убеждаемся в формате параметров
76
+ rate_str = rate if isinstance(rate, str) else f"{int(rate):+d}%"
77
+ pitch_str = pitch if isinstance(pitch, str) else f"{int(pitch):+d}Hz"
78
 
79
+ try:
80
+ comm = edge_tts.Communicate(text, voice, rate=rate_str, pitch=pitch_str)
81
+ await comm.save(fpath)
82
+ if os.path.exists(fpath) and os.path.getsize(fpath) > 0:
83
+ return fpath
84
+ except Exception as e:
85
+ print(f"⚠️ Ошибка фрагмента: {e}")
86
+ return None
87
+
88
+ # === РЕЖИМ 1: ФЭНТЕЗИ АВТО ===
89
+
90
+ def analyze_text_roles(text):
91
+ """Парсит текст и ищет женщин/мужчин по глаголам"""
92
+ segments = []
93
+ # Маркеры женского рода
94
+ female_markers = [r"сказала", r"спросила", r"ответила", r"прошептала", r"крикнула", r"подумала"]
95
 
96
+ paragraphs = text.split('\n')
97
  for p in paragraphs:
98
  p = p.strip()
99
  if not p: continue
100
 
101
+ role = "narrator"
102
+ # Если диалог
103
+ if p.startswith('—') or p.startswith('-') or '"' in p:
104
+ p_low = p.lower()
105
+ if any(re.search(m, p_low) for m in female_markers):
 
 
 
 
106
  role = "female"
107
  else:
108
+ role = "male" # Дефолт для диалога
109
+
 
110
  segments.append({"text": p, "role": role})
 
111
  return segments
112
 
113
+ async def process_fantasy_mode(text):
114
+ if not text.strip(): raise gr.Warning("Текст пуст!")
 
 
 
 
115
 
116
+ print("️ [Fantasy Mode] Анализ текста...")
117
+ segments = analyze_text_roles(text)
118
 
 
 
 
 
119
  full_audio = AudioSegment.empty()
 
 
120
  temp_files = []
121
+ progress = gr.Progress()
122
 
123
+ for item in progress.tqdm(segments, desc="Ковка аудио..."):
124
+ # Берем настройки из пресетов
125
+ conf = FANTASY_PRESETS[item['role']]
 
 
 
 
126
 
127
+ path = await generate_segment_internal(item['text'], conf['voice'], conf['rate'], conf['pitch'])
 
 
128
 
129
+ if path:
130
+ temp_files.append(path)
131
+ seg = AudioSegment.from_mp3(path)
132
+ # Склейка с нахлестом (Crossfade)
133
+ if len(full_audio) > 0:
134
+ full_audio = full_audio.append(seg, crossfade=50)
135
+ else:
136
+ full_audio = seg
137
+ # Пауза для API
138
+ await asyncio.sleep(0.1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
+ out_path = os.path.join(TEMP_DIR, f"fantasy_{uuid.uuid4().hex}.mp3")
141
+ full_audio.export(out_path, format="mp3")
 
142
 
143
+ # Уборка
 
 
 
144
  for f in temp_files:
145
  try: os.remove(f)
146
  except: pass
147
 
148
+ return out_path, segments
149
+
150
+ # === РЕЖИМ 2: РУЧНОЙ КОНТРОЛЬ ===
151
+
152
+ async def process_manual_mode(text, voice_raw, rate, pitch):
153
+ if not text.strip(): raise gr.Warning("Текст пуст!")
154
+ if not voice_raw: raise gr.Warning("Выберите голос!")
155
+
156
+ print("🛠️ [Manual Mode] Генерация...")
157
+ voice = voice_raw.split(" (")[0]
158
+
159
+ # Здесь мы тоже используем pydub и Server-Side сохранение,
160
+ # чтобы браузер пользователя не напрягался.
161
+
162
+ out_path = await generate_segment_internal(text, voice, rate, pitch)
163
+
164
+ if not out_path:
165
+ raise gr.Error("Ошибка генерации. Попробуйте другой голос или текст.")
166
+
167
+ return out_path
168
 
169
  # --- ИНТЕРФЕЙС ---
170
 
171
+ # Запуск загрузки голосов
172
+ asyncio.run(load_voices_init())
173
+
174
+ # Дефолты
175
+ DEF_LANG = "ru-RU"
176
+ DEF_VOICES = [f"{v['ShortName']} ({v['Gender']})" for v in VOICES_CACHE if v['Locale'] == DEF_LANG]
177
+ DEF_VAL = next((v for v in DEF_VOICES if "Dmitry" in v), DEF_VOICES[0] if DEF_VOICES else None)
178
+
179
  css = """
180
+ body {background-color: #111827; color: #e5e7eb;}
181
+ .container {max-width: 950px; margin: auto;}
182
+ h1 {color: #fbbf24; font-family: serif; text-align: center; font-size: 2.5em;}
183
  """
184
 
185
+ theme = gr.themes.Soft(primary_hue="amber", secondary_hue="slate")
 
 
 
 
186
 
187
+ with gr.Blocks(theme=theme, css=css, title="Final TTS Studio") as demo:
188
 
189
+ gr.Markdown("# 🍺 Fantasy TTS: Final Cut")
 
190
 
191
+ with gr.Tabs():
 
 
 
 
 
 
 
 
 
192
 
193
+ # --- Вкладка 1: Фэнтези Авто ---
194
+ with gr.TabItem("🧙‍♂️ Фэнтези Авто"):
195
+ with gr.Row():
196
+ with gr.Column(scale=2):
197
+ f_text = gr.Textbox(
198
+ label="Текст Легенды", lines=12,
199
+ placeholder="— Кто там? — спросила ведьма.\nРыцарь ответил: — Твоя судьба.",
200
+ value='— Стой! — крикнул рыцарь.\nВедьма обернулась и прошептала:\n— Тебе не пройти.'
201
+ )
202
+ f_btn = gr.Button("✨ Сотворить Магию (Auto)", variant="primary", size="lg")
203
+
204
+ with gr.Column(scale=1):
205
+ gr.Markdown("### 📜 Пресеты (Вшито)")
206
+ gr.Markdown("- **Рассказчик:** Дмитрий (-7Hz, -5%)")
207
+ gr.Markdown("- **Мужчины:** Денис (-2Hz)")
208
+ gr.Markdown("- **Женщины:** Светлана (+5Hz)")
209
+ f_audio = gr.Audio(label="Результат", type="filepath")
210
+ f_debug = gr.JSON(label="Разбор ролей")
211
 
212
+ f_btn.click(process_fantasy_mode, inputs=f_text, outputs=[f_audio, f_debug])
213
+
214
+ # --- Вкладка 2: Полный Ручной Контроль ---
215
+ with gr.TabItem("⚙️ Ручной Режим"):
216
+ with gr.Row():
217
+ with gr.Column(scale=1):
218
+ gr.Markdown("### Настройки Голоса")
219
+ m_lang = gr.Dropdown(choices=LANGUAGES_CACHE, value=DEF_LANG, label="1. Язык")
220
+ m_voice = gr.Dropdown(choices=DEF_VOICES, value=DEF_VAL, label="2. Голос")
221
+
222
+ gr.Markdown("---")
223
+ m_rate = gr.Slider(-50, 50, value=0, step=1, label="Скорость (%)")
224
+ m_pitch = gr.Slider(-20, 20, value=0, step=1, label="Тон (Hz)")
225
+
226
+ with gr.Column(scale=2):
227
+ m_text = gr.Textbox(
228
+ label="Текст", lines=10,
229
+ value="Привет! Это ручной режим. Здесь ты сам себе режиссер."
230
+ )
231
+ m_btn = gr.Button("🔊 Озвучить (Manual)", variant="secondary", size="lg")
232
+ m_audio = gr.Audio(label="Результат", type="filepath")
233
+
234
+ # Привязка событий ручного режима
235
+ m_lang.change(filter_voices_ui, inputs=m_lang, outputs=m_voice)
236
+ m_btn.click(process_manual_mode, inputs=[m_text, m_voice, m_rate, m_pitch], outputs=m_audio)
237
 
238
  if __name__ == "__main__":
 
239
  demo.queue().launch()