VSPAN commited on
Commit
0a59ddb
·
verified ·
1 Parent(s): f81803f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +111 -152
app.py CHANGED
@@ -7,216 +7,175 @@ import uuid
7
  import json
8
  import re
9
  from pydub import AudioSegment
10
- from huggingface_hub import InferenceClient
 
11
 
12
- # --- НАСТРОЙКИ ГОЛОСОВ (ФЭНТЕЗИ ПРЕСЕТ) ---
13
- # Подбираем идеальные параметры, чтобы уши "радовались"
14
  VOICE_CONFIG = {
15
- "narrator": { # Рассказчик (Глубокий, спокойный)
16
- "voice": "ru-RU-DmitryNeural",
17
- "pitch": "-7Hz", # Тот самый приятный низкий бас
18
- "rate": "-5%" # Чуть медленнее для эпичности
19
- },
20
- "male": { # Персонаж мужчина
21
- "voice": "ru-RU-DenisNeural",
22
- "pitch": "-2Hz", # Обычный мужской тон
23
- "rate": "+0%"
24
- },
25
- "female": { # Персонаж женщина
26
- "voice": "ru-RU-SvetlanaNeural",
27
- "pitch": "+5Hz", # Приятный женский, не писклявый (не +30!)
28
- "rate": "+5%" # Женщины часто говорят чуть быстрее
29
- }
30
  }
31
 
32
  TEMP_DIR = tempfile.gettempdir()
33
 
34
- # --- МОЗГ: LLM QWEN ---
35
- # Мы используем Qwen-2.5-72B через бесплатный API HuggingFace.
36
- # Это мощнее, чем модель 2B, и не жрет вашу память.
37
- client = InferenceClient("Qwen/Qwen2.5-72B-Instruct")
 
38
 
39
- def ask_llm_to_parse(text):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  """
41
- Отправляет текст в Qwen, чтобы тот разбил его на роли.
42
  """
43
- system_prompt = """
44
- Ты профессиональный режиссер аудиокниг. Твоя задача - разметить текст для озвучки.
45
-
46
- Правила:
47
- 1. Определи, кто говорит: Рассказчик (narrator), Мужчина (male) или Женщина (female).
48
- 2. Определи интонацию по контексту.
49
- 3. Верни ТОЛЬКО валидный JSON список. Никаких пояснений.
50
-
51
- Формат JSON:
52
- [
53
- {"text": "Текст фрагмента", "role": "narrator/male/female", "mood": "neutral"}
54
- ]
55
 
56
  Пример:
57
- Вход: — Привет, — сказала Анна.
58
- Выход: [{"text": "— Привет,", "role": "female"}, {"text": "— сказала Анна.", "role": "narrator"}]
59
  """
60
-
 
 
 
 
 
 
 
 
61
  try:
62
- messages = [
63
- {"role": "system", "content": system_prompt},
64
- {"role": "user", "content": f"Разметь этот текст:\n{text}"}
65
- ]
 
 
 
66
 
67
- # Запрос к API (выполняется на серверах HF)
68
- response = client.chat_completion(messages, max_tokens=4000, temperature=0.1)
69
- content = response.choices[0].message.content
70
 
71
- # Чистим ответ от возможного markdown (```json ... ```)
72
- content = re.sub(r'```json\s*', '', content)
73
- content = re.sub(r'```', '', content)
74
 
 
75
  data = json.loads(content)
76
  return data
 
77
  except Exception as e:
78
- print(f"⚠️ Ошибка LLM: {e}")
79
- # Фолбэк: если LLM ошиблась, возвращаем весь текст как рассказчика
80
  return [{"text": text, "role": "narrator"}]
81
 
82
- # --- ГЕНЕРАТОР АУДИО ---
83
 
84
  async def generate_segment(text, role):
85
- """Генерирует кусок аудио с нужными настройками."""
86
- if not text or not text.strip(): return None
87
 
88
- # Берем настройки из конфига
89
- config = VOICE_CONFIG.get(role, VOICE_CONFIG["narrator"])
90
-
91
- filename = f"seg_{uuid.uuid4().hex}.mp3"
92
- path = os.path.join(TEMP_DIR, filename)
93
 
94
  try:
95
- communicate = edge_tts.Communicate(
96
- text,
97
- config["voice"],
98
- rate=config["rate"],
99
- pitch=config["pitch"]
100
- )
101
- await communicate.save(path)
102
  return path
103
- except Exception as e:
104
- print(f"Err gen: {e}")
105
  return None
106
 
107
- async def process_book(text, api_key_input=None):
108
- """
109
- Главная функция: LLM -> TTS -> Stitching
110
- """
111
- if not text.strip(): raise gr.Warning("Нет текста!")
112
-
113
- # Если пользователь ввел свой ключ (для стабильности), используем его
114
- global client
115
- if api_key_input and api_key_input.strip():
116
- client = InferenceClient("Qwen/Qwen2.5-72B-Instruct", token=api_key_input)
117
 
118
- print("🧠 1. Qwen анализирует сцену...")
 
119
 
120
- # Шаг 1: Анализ текста (выполняется на сервере)
121
- segments_data = ask_llm_to_parse(text)
122
- print(f"📊 Найдено сегментов: {len(segments_data)}")
123
 
124
- # Шаг 2: Генерация кусочков
125
- temp_files = []
126
  full_audio = AudioSegment.empty()
127
-
128
- # Используем прогресс-бар
129
  progress = gr.Progress()
130
-
131
- for item in progress.tqdm(segments_data, desc="Озвучка ролей"):
132
- audio_path = await generate_segment(item["text"], item["role"])
133
 
134
- if audio_path:
135
- segment = AudioSegment.from_mp3(audio_path)
136
- temp_files.append(audio_path)
137
 
138
- # Шаг 3: Умная склейка (удаляем паузы)
 
139
  if len(full_audio) > 0:
140
- # Crossfade (нахлест) в 50мс убирает щелчки и делает переход "бесшовным"
141
- # Мы НЕ добавляем тишину, мы наоборот склеиваем вплотную
142
- full_audio = full_audio.append(segment, crossfade=50)
143
  else:
144
- full_audio = segment
145
-
146
- # Маленькая задержка, чтобы API Microsoft не забанил
147
  await asyncio.sleep(0.1)
148
 
149
- # Шаг 4: Экспорт
150
- print("💾 Финальный рендеринг...")
151
- output_filename = f"fantasy_masterpiece_{uuid.uuid4().hex}.mp3"
152
- output_path = os.path.join(TEMP_DIR, output_filename)
153
 
154
- full_audio.export(output_path, format="mp3")
 
155
 
156
- # Чистка
157
  for f in temp_files:
158
- try:
159
- os.remove(f)
160
  except: pass
161
 
162
- return output_path, segments_data # Возвращаем аудио и JSON для отладки
163
 
164
  # --- ИНТЕРФЕЙС ---
165
 
166
  css = """
167
- body { background-color: #0b0f19; }
168
  .container { max-width: 900px; margin: auto; }
169
- h1 { color: #d4af37; font-family: serif; text-align: center; font-size: 2.5rem; }
170
- .gradio-container { font-family: 'Merriweather', serif; }
171
  """
172
 
173
- theme = gr.themes.Soft(
174
- primary_hue="amber",
175
- secondary_hue="zinc",
176
- neutral_hue="slate"
177
- )
178
-
179
- with gr.Blocks(theme=theme, css=css, title="Fantasy AI Studio") as demo:
180
 
181
- gr.Markdown("# 🐉 Fantasy AI Studio: Qwen Edition")
182
- gr.Markdown("Вся работа происходит на сервере. Ваш ПК только получает результат.")
183
 
184
  with gr.Row():
185
  with gr.Column(scale=2):
186
- text_input = gr.Textbox(
187
- label="Текст главы",
188
- lines=12,
189
- placeholder="Вставьте текст. Qwen сам поймет, где мужчина, а где женщина...",
190
- value='— Тише! — прошептал следопыт, прижимаясь к земле.\nДевушка испуганно оглянулась:\n— Ты что-то слышишь?\n— Дыхание дракона, — мрачно ответил он.'
191
  )
192
-
193
- # Опционально: Ключ HF (если бесплатный лимит кончился)
194
- hf_token = gr.Textbox(
195
- label="HuggingFace Token (Опционально)",
196
- type="password",
197
- placeholder="Если есть свой токен, вставьте сюда для скорости",
198
- info="Можно оставить пустым, используется публичный доступ."
199
- )
200
-
201
  with gr.Column(scale=1):
202
- gr.Markdown("### 🎛️ Параметры Голосов")
203
- gr.JSON(
204
- value=VOICE_CONFIG,
205
- label="Текущие настройки (Narrator -7Hz)"
206
- )
207
-
208
- btn = gr.Button("✨ Создать Шедевр", variant="primary", size="lg")
209
-
210
- audio_output = gr.Audio(label="Результат", type="filepath")
211
-
212
- # Показываем, как Qwen распарсил текст
213
- debug_json = gr.JSON(label="Как ИИ понял текст (Debug)")
214
 
215
- btn.click(
216
- fn=process_book,
217
- inputs=[text_input, hf_token],
218
- outputs=[audio_output, debug_json]
219
- )
220
 
221
  if __name__ == "__main__":
222
- demo.queue(max_size=10).launch()
 
 
7
  import json
8
  import re
9
  from pydub import AudioSegment
10
+ from huggingface_hub import hf_hub_download
11
+ from llama_cpp import Llama # Библиотека для локального запуска нейросети
12
 
13
+ # --- НАСТРОЙКИ ГОЛОСОВ (ФЭНТЕЗИ) ---
 
14
  VOICE_CONFIG = {
15
+ "narrator": {"voice": "ru-RU-DmitryNeural", "pitch": "-7Hz", "rate": "-5%"},
16
+ "male": {"voice": "ru-RU-DenisNeural", "pitch": "-2Hz", "rate": "+0%"},
17
+ "female": {"voice": "ru-RU-SvetlanaNeural","pitch": "+5Hz", "rate": "+5%"}
 
 
 
 
 
 
 
 
 
 
 
 
18
  }
19
 
20
  TEMP_DIR = tempfile.gettempdir()
21
 
22
+ # --- ЛОКАЛЬНАЯ НЕЙРОСЕТЬ (QWEN) ---
23
+ # Мы используем Qwen 2.5 3B Instruct в формате GGUF.
24
+ # Это позволяет запускать модель прямо на CPU сервера быстро и бесплатно.
25
+ REPO_ID = "Qwen/Qwen2.5-3B-Instruct-GGUF"
26
+ FILENAME = "qwen2.5-3b-instruct-q4_k_m.gguf"
27
 
28
+ print("⏳ Загрузка нейросети в память сервера... Это может занять минуту.")
29
+ try:
30
+ # Скачиваем модель локально (кэшируется)
31
+ model_path = hf_hub_download(repo_id=REPO_ID, filename=FILENAME)
32
+
33
+ # Инициализируем модель (n_ctx=2048 - длина контекста)
34
+ llm = Llama(
35
+ model_path=model_path,
36
+ n_ctx=4096, # Память контекста
37
+ n_threads=4, # Используем 4 ядра процессора
38
+ verbose=False # Отключаем лишний шум в консоли
39
+ )
40
+ print("✅ Нейросеть успешно запущена локально!")
41
+ except Exception as e:
42
+ print(f"❌ Ошибка запуска нейросети: {e}")
43
+ llm = None
44
+
45
+ def ask_local_llm(text):
46
  """
47
+ Обрабатывает текст через локальную модель Qwen.
48
  """
49
+ if not llm:
50
+ return [{"text": text, "role": "narrator"}]
51
+
52
+ system_prompt = """Ты помощник режиссера. Твоя задача - проанализировать текст и разбить его на реплики для озвучки.
53
+ 1. Определи, кто говорит: "narrator" (автор), "male" (мужчина), "female" енщина).
54
+ 2. Верни ТОЛЬКО JSON массив. Без лишних слов.
 
 
 
 
 
 
55
 
56
  Пример:
57
+ Вход: — Привет! — сказала Аня.
58
+ Выход: [{"text": "— Привет!", "role": "female"}, {"text": "— сказала Аня.", "role": "narrator"}]
59
  """
60
+
61
+ user_prompt = f"Текст для анализа:\n{text}"
62
+
63
+ # Формируем диалог в формате ChatML (понятный для Qwen)
64
+ messages = [
65
+ {"role": "system", "content": system_prompt},
66
+ {"role": "user", "content": user_prompt}
67
+ ]
68
+
69
  try:
70
+ # Генерация ответа
71
+ response = llm.create_chat_completion(
72
+ messages=messages,
73
+ max_tokens=2000, # Максимальная длина ответа
74
+ temperature=0.1, # Минимальная креативность (для точности JSON)
75
+ top_p=0.9
76
+ )
77
 
78
+ content = response["choices"][0]["message"]["content"]
 
 
79
 
80
+ # Очистка ответа от мусора (если модель решила поболтать)
81
+ content = re.sub(r'```json', '', content)
82
+ content = re.sub(r'```', '', content).strip()
83
 
84
+ # Попытка распарсить JSON
85
  data = json.loads(content)
86
  return data
87
+
88
  except Exception as e:
89
+ print(f"⚠️ Ошибка парсинга LLM: {e}\nОтвет был: {content if 'content' in locals() else 'Пусто'}")
90
+ # Если нейронка ошиблась, возвращаем весь текст как рассказчика
91
  return [{"text": text, "role": "narrator"}]
92
 
93
+ # --- ГЕНЕРАЦИЯ АУДИО (С КЭШИРОВАНИЕМ И СКЛЕЙКОЙ) ---
94
 
95
  async def generate_segment(text, role):
96
+ if not text.strip(): return None
97
+ conf = VOICE_CONFIG.get(role, VOICE_CONFIG["narrator"])
98
 
99
+ fname = f"{uuid.uuid4().hex}.mp3"
100
+ path = os.path.join(TEMP_DIR, fname)
 
 
 
101
 
102
  try:
103
+ comm = edge_tts.Communicate(text, conf["voice"], rate=conf["rate"], pitch=conf["pitch"])
104
+ await comm.save(path)
 
 
 
 
 
105
  return path
106
+ except:
 
107
  return None
108
 
109
+ async def process_audiobook(text):
110
+ if not text.strip(): raise gr.Warning("Введите текст!")
 
 
 
 
 
 
 
 
111
 
112
+ print("🧠 Локальная нейросеть анализирует текст...")
113
+ segments = ask_local_llm(text)
114
 
115
+ print(f"📊 Найдено фрагментов: {len(segments)}")
 
 
116
 
 
 
117
  full_audio = AudioSegment.empty()
118
+ temp_files = []
 
119
  progress = gr.Progress()
120
+
121
+ for item in progress.tqdm(segments, desc="Озвучивание"):
122
+ path = await generate_segment(item["text"], item.get("role", "narrator"))
123
 
124
+ if path:
125
+ temp_files.append(path)
126
+ seg_audio = AudioSegment.from_mp3(path)
127
 
128
+ # УМНАЯ СКЛЕЙКА (Crossfade)
129
+ # Убирает паузы, накладывая конец одного куска на начало другого (50мс)
130
  if len(full_audio) > 0:
131
+ full_audio = full_audio.append(seg_audio, crossfade=50)
 
 
132
  else:
133
+ full_audio = seg_audio
134
+
135
+ # Микро-пауза для стабильности
136
  await asyncio.sleep(0.1)
137
 
138
+ out_name = f"fantasy_local_{uuid.uuid4().hex}.mp3"
139
+ out_path = os.path.join(TEMP_DIR, out_name)
 
 
140
 
141
+ print("💾 Сохранение файла...")
142
+ full_audio.export(out_path, format="mp3")
143
 
144
+ # Уборка
145
  for f in temp_files:
146
+ try: os.remove(f)
 
147
  except: pass
148
 
149
+ return out_path, segments
150
 
151
  # --- ИНТЕРФЕЙС ---
152
 
153
  css = """
154
+ body { background-color: #0f172a; }
155
  .container { max-width: 900px; margin: auto; }
156
+ h1 { color: #fbbf24; text-align: center; font-family: serif; }
 
157
  """
158
 
159
+ with gr.Blocks(theme=gr.themes.Soft(primary_hue="amber"), css=css, title="Local AI Narrator") as demo:
 
 
 
 
 
 
160
 
161
+ gr.Markdown("# 🏰 Fantasy TTS: Local AI Edition")
162
+ gr.Markdown("Нейросеть Qwen работает прямо на этом сервере. Данные никуда не уходят.")
163
 
164
  with gr.Row():
165
  with gr.Column(scale=2):
166
+ inp = gr.Textbox(
167
+ label="Текст книги", lines=10,
168
+ value='— Стой! — крикнул рыцарь.\nДевушка обернулась и тихо спросила:\n— Зачем мне останавливаться?\nВетер выл в ушах.',
169
+ placeholder="Вставьте текст..."
 
170
  )
171
+ btn = gr.Button("✨ Создать (Обработка на сервере)", variant="primary", size="lg")
172
+
 
 
 
 
 
 
 
173
  with gr.Column(scale=1):
174
+ out_audio = gr.Audio(label="Результат", type="filepath")
175
+ out_debug = gr.JSON(label="Как нейросеть увидела роли")
 
 
 
 
 
 
 
 
 
 
176
 
177
+ btn.click(process_audiobook, inputs=inp, outputs=[out_audio, out_debug])
 
 
 
 
178
 
179
  if __name__ == "__main__":
180
+ # max_size=5 ограничивает очередь, чтобы сервер не завис
181
+ demo.queue(max_size=5).launch()