daswer123 commited on
Commit
643e012
1 Parent(s): 2aa7777

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +497 -0
  2. requirements.txt +4 -0
  3. sonic_api_wrapper.py +446 -0
app.py ADDED
@@ -0,0 +1,497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+ import gradio as gr
3
+ from pathlib import Path
4
+ from sonic_api_wrapper import CartesiaVoiceManager, VoiceAccessibility, improve_tts_text
5
+ import os
6
+ import json
7
+
8
+ # Инициализация базовых переменных
9
+ DEFAULT_API_KEY = ""
10
+ LANGUAGE_CHOICES = ["all", "ru", "en", "es", "pl", "de", "fr"]
11
+ ACCESS_TYPE_MAP = {
12
+ "Все": VoiceAccessibility.ALL,
13
+ "Только кастомные": VoiceAccessibility.ONLY_CUSTOM,
14
+ "Апи": VoiceAccessibility.ONLY_PUBLIC
15
+ }
16
+ # Обновленные константы
17
+ SPEED_CHOICES = ["Очень медленно", "Медленно", "Нормально", "Быстро", "Очень быстро"]
18
+ EMOTION_CHOICES = ["Нейтрально", "Весело", "Грустно", "Злобно", "Удивленно", "Любопытно"]
19
+ EMOTION_INTENSITY = ["Очень слабая", "Слабая", "Средняя", "Сильная", "Очень сильная"]
20
+
21
+ # Глобальная переменная для хранения экземпляра менеджера
22
+ manager = None
23
+
24
+ import datetime
25
+
26
+ def map_speed(speed_type: str) -> float:
27
+ speed_map = {
28
+ "Очень медленно": -1.0,
29
+ "Медленно": -0.5,
30
+ "Нормально": 0.0,
31
+ "Быстро": 0.5,
32
+ "Очень быстро": 1.0
33
+ }
34
+ return speed_map[speed_type]
35
+
36
+ def generate_output_filename(language: str) -> str:
37
+ """Генерация имени файла с временной меткой и языком"""
38
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
39
+ return f"output/{timestamp}_{language}.wav"
40
+
41
+ def extract_voice_id_from_label(voice_label: str) -> str:
42
+ """
43
+ Извлекает ID голоса из метки в dropdown
44
+ Например: "John (en) [Custom]" -> извлечет ID из словаря голосов
45
+ """
46
+ if not manager:
47
+ return None
48
+
49
+ # Получаем все голоса и их метки
50
+ choices = manager.get_voice_choices()
51
+ # Находим голос по метке и берем его ID
52
+ voice_data = next((c for c in choices if c["label"] == voice_label), None)
53
+ return voice_data["value"] if voice_data else None
54
+
55
+ def initialize_manager(api_key: str) -> str:
56
+ global manager
57
+ try:
58
+ manager = CartesiaVoiceManager(api_key=api_key, base_dir=Path("voice2voice"))
59
+ return "✅ Менеджер инициализирован"
60
+ except Exception as e:
61
+ return f"❌ Ошибка: {str(e)}"
62
+
63
+ def get_initial_voices():
64
+ """Получение начального списка голосов"""
65
+ if not manager:
66
+ initialize_manager(DEFAULT_API_KEY)
67
+ choices = manager.get_voice_choices()
68
+ return [c["label"] for c in choices], choices[0]["label"] if choices else None
69
+
70
+ def update_voice_list (language: str, access_type: str, current_voice: str = None):
71
+ """
72
+ Обновление списка голосов с сохранением текущего выбора
73
+ """
74
+ if not manager:
75
+ return gr.update(choices=[], value=None), "❌ Менеджер не инициализирован"
76
+
77
+ try:
78
+ choices = manager.get_voice_choices(
79
+ language=None if language == "all" else language,
80
+ accessibility=ACCESS_TYPE_MAP[access_type]
81
+ )
82
+
83
+ # Преобразуем в список меток
84
+ choice_labels = [c["label"] for c in choices]
85
+
86
+ # Определяем значение для выбора
87
+ if current_voice in choice_labels:
88
+ # Сохраняем текущий выбор, если он доступен
89
+ new_value = current_voice
90
+ else:
91
+ # Иначе берем первый доступный голос
92
+ new_value = choice_labels[0] if choice_labels else None
93
+
94
+ return gr.update(choices=choice_labels, value=new_value), "✅ Список голосов обновлен"
95
+ except Exception as e:
96
+ return gr.update(choices=[], value=None), f"❌ Ошибка: {str(e)}"
97
+
98
+ def update_voice_info(voice_label: str) -> str:
99
+ """Обновление информации о голосе"""
100
+ if not manager or not voice_label:
101
+ return ""
102
+
103
+ try:
104
+ voice_id = extract_voice_id_from_label(voice_label)
105
+ if not voice_id:
106
+ return "❌ Голос не найден"
107
+
108
+ info = manager.get_voice_info(voice_id)
109
+ return (
110
+ f"Имя: {info['name']}\n"
111
+ f"Язык: {info['language']}\n"
112
+ f"Тип: {'Кастомный' if info.get('is_custom') else 'API'}\n"
113
+ f"ID: {info['id']}"
114
+ )
115
+ except Exception as e:
116
+ return f"❌ Ошибка: {str(e)}"
117
+
118
+ def create_custom_voice(name: str, language: str, audio_data: tuple) -> tuple:
119
+ """
120
+ Создание кастомного голоса и обновление списка голосов
121
+ Возвращает: (статус, обновленный dropdown, информация о голосе)
122
+ """
123
+ if not manager:
124
+ return "❌ Менеджер не инициализирован", gr.update(), ""
125
+
126
+ if not name or not audio_data:
127
+ return "❌ Необходимо указать имя и файл голоса", gr.update(), ""
128
+
129
+ try:
130
+ # Получаем путь к аудио файлу
131
+ audio_path = audio_data[0] if isinstance(audio_data, tuple) else audio_data
132
+
133
+ # Создаем голос
134
+ voice_id = manager.create_custom_embedding(
135
+ file_path=audio_path,
136
+ name=name,
137
+ language=language
138
+ )
139
+
140
+ print(voice_id)
141
+
142
+ # Получаем обновленный список голосов
143
+ choices = manager.get_voice_choices()
144
+ choice_labels = [c["label"] for c in choices]
145
+
146
+ # Находим метку для нового голоса
147
+ new_voice_label = next(c["label"] for c in choices if c["value"] == voice_id)
148
+
149
+ # Получаем информацию о новом голосе
150
+ voice_info = manager.get_voice_info(voice_id)
151
+ info_text = (
152
+ f"Имя: {voice_info['name']}\n"
153
+ f"Язык: {voice_info['language']}\n"
154
+ f"Тип: Кастомный\n"
155
+ f"ID: {voice_info['id']}"
156
+ )
157
+
158
+ return (
159
+ f"✅ Создан кастомный голос: {voice_id}",
160
+ gr.update(choices=choice_labels, value=new_voice_label),
161
+ info_text
162
+ )
163
+
164
+ except Exception as e:
165
+ return f"❌ Ошибка создания голоса: {str(e)}", gr.update(), ""
166
+
167
+ def on_auto_language_change(auto_language: bool):
168
+ """Обработчик изменения галочки автоопределения языка"""
169
+ return gr.update(visible=not auto_language)
170
+
171
+ def map_emotions(selected_emotions, intensity):
172
+ emotion_map = {
173
+ "Весело": "positivity",
174
+ "Грустно": "sadness",
175
+ "Злобно": "anger",
176
+ "Удивленно": "surprise",
177
+ "Любопытно": "curiosity"
178
+ }
179
+
180
+ intensity_map = {
181
+ "Очень слабая": "lowest",
182
+ "Слабая": "low",
183
+ "Средняя": "medium",
184
+ "Сильная": "high",
185
+ "Очень сильная": "highest"
186
+ }
187
+
188
+ emotions = []
189
+ for emotion in selected_emotions:
190
+ if emotion == "Нейтрально":
191
+ continue
192
+ if emotion in emotion_map:
193
+ emotions.append({
194
+ "name": emotion_map[emotion],
195
+ "level": intensity_map[intensity]
196
+ })
197
+ return emotions
198
+
199
+ def generate_speech(
200
+ text: str,
201
+ voice_label: str,
202
+ improve_text: bool,
203
+ auto_language: bool,
204
+ manual_language: str,
205
+ speed_type: str,
206
+ use_custom_speed: bool,
207
+ custom_speed: float,
208
+ emotions: List[str],
209
+ emotion_intensity: str
210
+ ):
211
+ """Генерация речи с учетом настроек языка"""
212
+ if not manager:
213
+ return None, "❌ Менеджер не инициализирован"
214
+
215
+ if not text or not voice_label:
216
+ return None, "❌ Необходимо указать текст и голос"
217
+
218
+ try:
219
+ # Извлекаем ID голоса из метки
220
+ voice_id = extract_voice_id_from_label(voice_label)
221
+ if not voice_id:
222
+ return None, "❌ Голос не найден"
223
+
224
+ # Устанавливаем голос по ID
225
+ manager.set_voice(voice_id)
226
+
227
+ # Если автоопределение выключено, устанавливаем язык вручную
228
+ if not auto_language:
229
+ manager.set_language(manual_language)
230
+
231
+ # В функции generate_speech обновите установку скорости:
232
+ if use_custom_speed:
233
+ manager.speed = custom_speed
234
+ else:
235
+ manager.speed = map_speed(speed_type)
236
+
237
+ # Установка эмоций
238
+ emotion_map = {
239
+ "Нейтрально": None,
240
+ "Весело": "positivity",
241
+ "Грустно": "sadness",
242
+ "Злобно": "anger",
243
+ "Удивленно": "surprise",
244
+ "Любопытно": "curiosity"
245
+ }
246
+
247
+ intensity_map = {
248
+ "Слабая": "low",
249
+ "Средняя": "medium",
250
+ "Сильная": "high"
251
+ }
252
+
253
+ if emotions and emotions != ["Нейтрально"]:
254
+ manager.set_emotions(map_emotions(emotions, emotion_intensity))
255
+ else:
256
+ manager.set_emotions() # Сброс эмоций
257
+
258
+ # Генерация имени файла
259
+ output_file = generate_output_filename(
260
+ manual_language if not auto_language else manager.current_language
261
+ )
262
+
263
+ # Создаем директорию для выходных файлов, если её нет
264
+ os.makedirs("output", exist_ok=True)
265
+
266
+ # Генерация речи
267
+ output_path = manager.speak(
268
+ text=text if not improve_text else improve_tts_text(text, manager.current_language),
269
+ output_file=output_file
270
+ )
271
+
272
+ return output_path, "✅ Аудио сгенерировано успешно"
273
+
274
+ except Exception as e:
275
+ return None, f"❌ Ошибка генерации: {str(e)}"
276
+
277
+ # Создание интерфейса
278
+ with gr.Blocks() as demo:
279
+ # API ключ
280
+ cartesia_api_key = gr.Textbox(
281
+ label="API ключ Cartesia",
282
+ value=DEFAULT_API_KEY,
283
+ type='password'
284
+ )
285
+
286
+ with gr.Row():
287
+ # Левая колонка
288
+ with gr.Column():
289
+ cartesia_text = gr.TextArea(label="Текст")
290
+
291
+ with gr.Accordion(label="Настройки", open=True):
292
+ # Фильтры
293
+ with gr.Accordion("Фильтры", open=True):
294
+ cartesia_setting_filter_lang = gr.Dropdown(
295
+ label="Язык",
296
+ choices=LANGUAGE_CHOICES,
297
+ value="all"
298
+ )
299
+ cartesia_setting_filter_type = gr.Dropdown(
300
+ label="Тип",
301
+ choices=ACCESS_TYPE_MAP,
302
+ value="Все"
303
+ )
304
+
305
+ # Вкладки настроек
306
+ with gr.Tab("Стандарт"):
307
+ cartesia_setting_voice_info = gr.Textbox(
308
+ label="Информация о голосе",
309
+ interactive=False
310
+ )
311
+ with gr.Row():
312
+ initial_choices, initial_value = get_initial_voices()
313
+ cartesia_setting_voice = gr.Dropdown(
314
+ label="Голос",
315
+ choices=initial_choices,
316
+ value=initial_value
317
+ )
318
+ cartesia_setting_voice_update = gr.Button("Обновить")
319
+ cartesia_setting_auto_language = gr.Checkbox(
320
+ label="Автоматически определять язык из голоса",
321
+ value=True
322
+ )
323
+ cartesia_setting_manual_language = gr.Dropdown(
324
+ label="Язык озвучки",
325
+ choices=["ru", "en", "es", "fr", "de", "pl", "it", "ja", "ko", "zh", "hi"],
326
+ value="en",
327
+ visible=False # Изначально скрыт
328
+ )
329
+
330
+ with gr.Tab("Кастомный"):
331
+ cartesia_setting_custom_name = gr.Textbox(label="Имя")
332
+ cartesia_setting_custom_lang = gr.Dropdown(
333
+ label="Язык",
334
+ choices=LANGUAGE_CHOICES[1:] # Исключаем "all"
335
+ )
336
+ cartesia_setting_custom_voice = gr.Audio(label="Файл голоса",type='filepath')
337
+ cartesia_setting_custom_add = gr.Button("Добавить")
338
+
339
+ # with gr.Tab("Микс"):
340
+ # cartesia_setting_custom_mix = gr.Dropdown(
341
+ # label="Выберите голоса",
342
+ # multiselect=True,
343
+ # choices=[]
344
+ # )
345
+ # cartesia_setting_custom_mix_update = gr.Button("Обновить")
346
+ # for i in range(5):
347
+ # setattr(
348
+ # demo,
349
+ # f'mix_voice_{i+1}',
350
+ # gr.Slider(
351
+ # label=f"Голос {i+1}",
352
+ # value=0.5,
353
+ # minimum=0,
354
+ # maximum=1,
355
+ # step=0.01,
356
+ # visible=False
357
+ # )
358
+ # )
359
+
360
+ # Контроль эмоций
361
+ with gr.Accordion(label="Контроль эмоций (Beta)", open=False):
362
+ cartesia_emotions = gr.Dropdown(
363
+ label="Эмоции",
364
+ multiselect=True,
365
+ choices=EMOTION_CHOICES
366
+ )
367
+ cartesia_emotions_intensity = gr.Dropdown(
368
+ label="Интенсивность",
369
+ choices=EMOTION_INTENSITY,
370
+ value="Средняя"
371
+ )
372
+
373
+ # Настройки скорости
374
+ with gr.Accordion("Скорость", open=True):
375
+ cartesia_speed_speed = gr.Dropdown(
376
+ label="Скорость речи",
377
+ choices=SPEED_CHOICES,
378
+ value="Нормально"
379
+ )
380
+ cartesia_speed_speed_allow_custom = gr.Checkbox(
381
+ label="Использовать кастомное значение скорости"
382
+ )
383
+ cartesia_speed_speed_custom = gr.Slider(
384
+ label="Скорость",
385
+ value=0,
386
+ minimum=-1,
387
+ maximum=1,
388
+ step=0.1,
389
+ visible=False
390
+ )
391
+
392
+ cartesia_setting_improve_text = gr.Checkbox(
393
+ label="Улучшить текст согласно рекомендациям",
394
+ value=True
395
+ )
396
+
397
+ # Правая колонка
398
+ with gr.Column():
399
+ cartessia_status_bar = gr.Label(value="Статус")
400
+ cartesia_output_audio = gr.Audio(
401
+ label="Результат",
402
+ interactive=False
403
+ )
404
+ cartesia_output_button = gr.Button("Генерация")
405
+
406
+ # События
407
+ cartesia_api_key.change(
408
+ initialize_manager,
409
+ inputs=[cartesia_api_key],
410
+ outputs=[cartessia_status_bar]
411
+ )
412
+
413
+ cartesia_setting_filter_lang.change(
414
+ update_voice_list,
415
+ inputs=[
416
+ cartesia_setting_filter_lang,
417
+ cartesia_setting_filter_type,
418
+ cartesia_setting_voice # Передаем текущий выбор
419
+ ],
420
+ outputs=[cartesia_setting_voice, cartessia_status_bar]
421
+ )
422
+
423
+ cartesia_setting_filter_type.change(
424
+ update_voice_list,
425
+ inputs=[
426
+ cartesia_setting_filter_lang,
427
+ cartesia_setting_filter_type,
428
+ cartesia_setting_voice # Передаем текущий выбор
429
+ ],
430
+ outputs=[cartesia_setting_voice, cartessia_status_bar]
431
+ )
432
+
433
+ cartesia_setting_voice.change(
434
+ update_voice_info,
435
+ inputs=[cartesia_setting_voice],
436
+ outputs=[cartesia_setting_voice_info]
437
+ )
438
+
439
+ cartesia_setting_voice_update.click(
440
+ update_voice_list,
441
+ inputs=[cartesia_setting_filter_lang, cartesia_setting_filter_type],
442
+ outputs=[cartesia_setting_voice]
443
+ )
444
+
445
+ cartesia_speed_speed_allow_custom.change(
446
+ lambda x: gr.update(visible=x),
447
+ inputs=[cartesia_speed_speed_allow_custom],
448
+ outputs=[cartesia_speed_speed_custom]
449
+ )
450
+
451
+ cartesia_setting_custom_add.click(
452
+ create_custom_voice,
453
+ inputs=[
454
+ cartesia_setting_custom_name,
455
+ cartesia_setting_custom_lang,
456
+ cartesia_setting_custom_voice
457
+ ],
458
+ outputs=[
459
+ cartessia_status_bar,
460
+ cartesia_setting_voice, # Обновляем dropdown
461
+ cartesia_setting_voice_info # Обновляем информацию о голосе
462
+ ]
463
+ )
464
+
465
+ # Обновляем привязки событий
466
+ cartesia_setting_auto_language.change(
467
+ on_auto_language_change,
468
+ inputs=[cartesia_setting_auto_language],
469
+ outputs=[cartesia_setting_manual_language]
470
+ )
471
+
472
+ cartesia_output_button.click(
473
+ generate_speech,
474
+ inputs=[
475
+ cartesia_text,
476
+ cartesia_setting_voice,
477
+ cartesia_setting_improve_text,
478
+ cartesia_setting_auto_language,
479
+ cartesia_setting_manual_language,
480
+ cartesia_speed_speed,
481
+ cartesia_speed_speed_allow_custom,
482
+ cartesia_speed_speed_custom,
483
+ cartesia_emotions,
484
+ cartesia_emotions_intensity
485
+ ],
486
+ outputs=[
487
+ cartesia_output_audio,
488
+ cartessia_status_bar
489
+ ]
490
+ )
491
+
492
+ # Запуск приложения
493
+ if __name__ == "__main__":
494
+ # Инициализация менеджера при запуске
495
+ initialize_manager(DEFAULT_API_KEY)
496
+ # Запуск интерфейса
497
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio
2
+ cartesia
3
+ tqdm
4
+ loguru
sonic_api_wrapper.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from pathlib import Path
4
+ from typing import List, Dict, Union, Optional
5
+ from enum import Enum
6
+ from cartesia import Cartesia
7
+ from tqdm import tqdm
8
+ from loguru import logger
9
+ from datetime import datetime
10
+ import re
11
+
12
+ class VoiceAccessibility(Enum):
13
+ ALL = "all"
14
+ ONLY_PUBLIC = "only_public"
15
+ ONLY_PRIVATE = "only_private"
16
+ ONLY_CUSTOM = "only_custom"
17
+
18
+ class CartesiaVoiceManager:
19
+ SPEED_OPTIONS = {
20
+ "slowest": -1.0,
21
+ "slow": -0.5,
22
+ "normal": 0.0,
23
+ "fast": 0.5,
24
+ "fastest": 1.0
25
+ }
26
+ EMOTION_NAMES = ["anger", "positivity", "surprise", "sadness", "curiosity"]
27
+ EMOTION_LEVELS = ["lowest", "low", "medium", "high", "highest"]
28
+
29
+ def __init__(self, api_key: str = None, base_dir: Path = None):
30
+ self.api_key = api_key or os.environ.get("CARTESIA_API_KEY")
31
+ if not self.api_key:
32
+ raise ValueError("API key is required. Please provide it as an argument or set CARTESIA_API_KEY environment variable.")
33
+
34
+ self.client = Cartesia(api_key=self.api_key)
35
+ self.current_voice = None
36
+ self.current_model = None
37
+ self.current_language = None
38
+ self.current_mix = None
39
+
40
+ # Настройка директорий
41
+ self.base_dir = base_dir or Path("voice2voice")
42
+ self.api_dir = self.base_dir / "api"
43
+ self.custom_dir = self.base_dir / "custom"
44
+
45
+ # Создание необходимых директорий
46
+ self.api_dir.mkdir(parents=True, exist_ok=True)
47
+ self.custom_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ # Инициализация голосов
50
+ self.voices = {}
51
+ self.loaded_voices = set()
52
+
53
+ # Настройки скорости и эмоций
54
+ self._speed = 0.0 # normal speed
55
+ self._emotions = {}
56
+
57
+ logger.add("cartesia_voice_manager.log", rotation="10 MB")
58
+ logger.info("CartesiaVoiceManager initialized")
59
+
60
+ def load_voice(self, voice_id: str) -> Dict:
61
+ if voice_id in self.loaded_voices:
62
+ return self.voices[voice_id]
63
+
64
+ voice_file = None
65
+ # Поиск файла голоса в api и custom директориях
66
+ api_file = self.api_dir / f"{voice_id}.json"
67
+ custom_file = self.custom_dir / f"{voice_id}.json"
68
+
69
+ if api_file.exists():
70
+ voice_file = api_file
71
+ elif custom_file.exists():
72
+ voice_file = custom_file
73
+
74
+ if voice_file:
75
+ with open(voice_file, "r") as f:
76
+ voice_data = json.load(f)
77
+ self.voices[voice_id] = voice_data
78
+ self.loaded_voices.add(voice_id)
79
+ logger.info(f"Loaded voice {voice_id} from {voice_file}")
80
+ return voice_data
81
+ else:
82
+ # Если голос не найден локально, пытаемся загрузить из API
83
+ try:
84
+ voice_data = self.client.voices.get(id=voice_id)
85
+ self._save_voice_to_api(voice_data)
86
+ self.voices[voice_id] = voice_data
87
+ self.loaded_voices.add(voice_id)
88
+ logger.info(f"Loaded voice {voice_id} from API")
89
+ return voice_data
90
+ except Exception as e:
91
+ logger.error(f"Failed to load voice {voice_id}: {e}")
92
+ raise ValueError(f"Voice with id {voice_id} not found")
93
+
94
+ def extract_voice_id_from_label(self, voice_label: str) -> Optional[str]:
95
+ """
96
+ Извлекает ID голоса из метки в dropdown
97
+ Например: "John (en) [Custom]" -> извлечет ID из словаря голосов
98
+ """
99
+ # Получаем все голоса и их метки
100
+ choices = self.get_voice_choices()
101
+ # Находим голос по метке и берем его ID
102
+ voice_data = next((c for c in choices if c["label"] == voice_label), None)
103
+ return voice_data["value"] if voice_data else None
104
+
105
+ def get_voice_choices(self, language: str = None, accessibility: VoiceAccessibility = VoiceAccessibility.ALL) -> List[Dict]:
106
+ """
107
+ Возвращает список голосов для dropdown меню
108
+ """
109
+ voices = self.list_available_voices(
110
+ languages=[language] if language else None,
111
+ accessibility=accessibility
112
+ )
113
+
114
+ choices = []
115
+ for voice in voices:
116
+ # Сохраняем только ID в value
117
+ choices.append({
118
+ "label": f"{voice['name']} ({voice['language']}){' [Custom]' if voice.get('is_custom') else ''}",
119
+ "value": voice['id'] # Здесь только ID
120
+ })
121
+
122
+ return sorted(choices, key=lambda x: x['label'])
123
+
124
+ def get_voice_info(self, voice_id: str) -> Dict:
125
+ """
126
+ Возвращает информацию о голосе для отображения
127
+ """
128
+ voice = self.load_voice(voice_id)
129
+ return {
130
+ "name": voice['name'],
131
+ "language": voice['language'],
132
+ "is_custom": voice.get('is_custom', False),
133
+ "is_public": voice.get('is_public', True),
134
+ "id": voice['id']
135
+ }
136
+
137
+ def _save_voice_to_api(self, voice_data: Dict):
138
+ voice_id = voice_data["id"]
139
+ file_path = self.api_dir / f"{voice_id}.json"
140
+ with open(file_path, "w") as f:
141
+ json.dump(voice_data, f, indent=2)
142
+ logger.info(f"Saved API voice {voice_id} to {file_path}")
143
+
144
+ def _save_voice_to_custom(self, voice_data: Dict):
145
+ voice_id = voice_data["id"]
146
+ file_path = self.custom_dir / f"{voice_id}.json"
147
+ with open(file_path, "w") as f:
148
+ json.dump(voice_data, f, indent=2)
149
+ logger.info(f"Saved custom voice {voice_id} to {file_path}")
150
+
151
+ def update_voices_from_api(self):
152
+ logger.info("Updating voices from API")
153
+ api_voices = self.client.voices.list()
154
+ for voice in tqdm(api_voices, desc="Updating voices"):
155
+ voice_id = voice["id"]
156
+ full_voice_data = self.client.voices.get(id=voice_id)
157
+ self._save_voice_to_api(full_voice_data)
158
+ if voice_id in self.loaded_voices:
159
+ self.voices[voice_id] = full_voice_data
160
+ logger.info(f"Updated {len(api_voices)} voices from API")
161
+
162
+ def list_available_voices(self, languages: List[str] = None, accessibility: VoiceAccessibility = VoiceAccessibility.ALL) -> List[Dict]:
163
+ filtered_voices = []
164
+
165
+ # Получаем только метаданные из API (без эмбеддингов)
166
+ if accessibility in [VoiceAccessibility.ALL, VoiceAccessibility.ONLY_PUBLIC]:
167
+ try:
168
+ api_voices = self.client.voices.list()
169
+ # Сохраняем только метаданные
170
+ for voice in api_voices:
171
+ metadata = {
172
+ 'id': voice['id'],
173
+ 'name': voice['name'],
174
+ 'language': voice['language'],
175
+ 'is_public': True
176
+ }
177
+ if languages is None or metadata['language'] in languages:
178
+ filtered_voices.append(metadata)
179
+ except Exception as e:
180
+ logger.error(f"Failed to fetch voices from API: {e}")
181
+
182
+ # Добавляем кастомные голоса если нужно
183
+ if accessibility in [VoiceAccessibility.ALL, VoiceAccessibility.ONLY_PRIVATE, VoiceAccessibility.ONLY_CUSTOM]:
184
+ for file in self.custom_dir.glob("*.json"):
185
+ with open(file, "r") as f:
186
+ voice_data = json.load(f)
187
+ if languages is None or voice_data['language'] in languages:
188
+ filtered_voices.append({
189
+ 'id': voice_data['id'],
190
+ 'name': voice_data['name'],
191
+ 'language': voice_data['language'],
192
+ 'is_public': False,
193
+ 'is_custom': True
194
+ })
195
+
196
+ logger.info(f"Found {len(filtered_voices)} voices matching criteria")
197
+ return filtered_voices
198
+
199
+ def set_voice(self, voice_id: str):
200
+ # Проверяем наличие локального файла с эмбеддингом
201
+ voice_file = None
202
+ api_file = self.api_dir / f"{voice_id}.json"
203
+ custom_file = self.custom_dir / f"{voice_id}.json"
204
+
205
+ if api_file.exists():
206
+ voice_file = api_file
207
+ elif custom_file.exists():
208
+ voice_file = custom_file
209
+
210
+ if voice_file:
211
+ # Используем локальные данные
212
+ with open(voice_file, "r") as f:
213
+ self.current_voice = json.load(f)
214
+ else:
215
+ # Получаем полные данные с эмбеддингом из API
216
+ try:
217
+ voice_data = self.client.voices.get(id=voice_id)
218
+ # Сохраняем для будущего использования
219
+ self._save_voice_to_api(voice_data)
220
+ self.current_voice = voice_data
221
+ except Exception as e:
222
+ logger.error(f"Failed to get voice {voice_id}: {e}")
223
+ raise ValueError(f"Voice with id {voice_id} not found")
224
+
225
+ self.set_language(self.current_voice['language'])
226
+ logger.info(f"Set current voice to {voice_id}")
227
+
228
+ def set_model(self, language: str):
229
+ if language.lower() in ['en', 'eng', 'english']:
230
+ self.current_model = "sonic-english"
231
+ else:
232
+ self.current_model = "sonic-multilingual"
233
+ self.current_language = language
234
+ logger.info(f"Set model to {self.current_model} for language {language}")
235
+
236
+ def set_language(self, language: str):
237
+ self.current_language = language
238
+ self.set_model(language)
239
+ logger.info(f"Set language to {language}")
240
+
241
+ @property
242
+ def speed(self):
243
+ return self._speed
244
+
245
+ @speed.setter
246
+ def speed(self, value):
247
+ if isinstance(value, str):
248
+ if value not in self.SPEED_OPTIONS:
249
+ raise ValueError(f"Invalid speed value. Use one of: {list(self.SPEED_OPTIONS.keys())}")
250
+ self._speed = self.SPEED_OPTIONS[value]
251
+ elif isinstance(value, (int, float)):
252
+ if not -1 <= value <= 1:
253
+ raise ValueError("Speed value must be between -1 and 1")
254
+ self._speed = value
255
+ else:
256
+ raise ValueError("Speed must be a string from SPEED_OPTIONS or a number between -1 and 1")
257
+ logger.info(f"Set speed to {self._speed}")
258
+
259
+ def set_emotions(self, emotions: List[Dict[str, str]] = None):
260
+ if emotions is None:
261
+ self._emotions = {}
262
+ logger.info("Cleared all emotions")
263
+ return
264
+
265
+ self._emotions = {}
266
+ for emotion in emotions:
267
+ name = emotion.get("name")
268
+ level = emotion.get("level")
269
+
270
+ if name not in self.EMOTION_NAMES:
271
+ raise ValueError(f"Invalid emotion name. Choose from: {self.EMOTION_NAMES}")
272
+ if level not in self.EMOTION_LEVELS:
273
+ raise ValueError(f"Invalid emotion level. Choose from: {self.EMOTION_LEVELS}")
274
+
275
+ self._emotions[name] = level
276
+
277
+ logger.info(f"Set emotions: {self._emotions}")
278
+
279
+ def _get_voice_controls(self):
280
+ controls = {"speed": self._speed}
281
+
282
+ if self._emotions:
283
+ controls["emotion"] = [f"{name}:{level}" for name, level in self._emotions.items()]
284
+
285
+ return controls
286
+
287
+ def speak(self, text: str, output_file: str = None):
288
+ if not self.current_model or not (self.current_voice or self.current_mix):
289
+ raise ValueError("Please set a model and a voice or voice mix before speaking.")
290
+
291
+ voice_embedding = self.current_voice['embedding'] if self.current_voice else self.current_mix
292
+
293
+ improved_text = improve_tts_text(text, self.current_language)
294
+
295
+ output_format = {
296
+ "container": "wav",
297
+ "encoding": "pcm_f32le",
298
+ "sample_rate": 44100,
299
+ }
300
+
301
+ voice_controls = self._get_voice_controls()
302
+
303
+ logger.info(f"Generating audio for text: {text[:50]}... with voice controls: {voice_controls}")
304
+ if self.current_language == 'en':
305
+ audio_data = self.client.tts.bytes(
306
+ model_id='sonic-english',
307
+ transcript=improved_text,
308
+ voice_embedding=voice_embedding,
309
+ duration=None,
310
+ output_format=output_format,
311
+ # language=self.current_language,
312
+ _experimental_voice_controls=voice_controls
313
+ )
314
+ else:
315
+ audio_data = self.client.tts.bytes(
316
+ model_id='sonic-multilingual',
317
+ transcript=improved_text,
318
+ voice_embedding=voice_embedding,
319
+ duration=None,
320
+ output_format=output_format,
321
+ language=self.current_language,
322
+ _experimental_voice_controls=voice_controls)
323
+
324
+ if output_file is None:
325
+ output_file = f"output_{self.current_language}.wav"
326
+
327
+ with open(output_file, "wb") as f:
328
+ f.write(audio_data)
329
+ logger.info(f"Audio saved to {output_file}")
330
+ print(f"Audio generated and saved to {output_file}")
331
+
332
+ return output_file
333
+
334
+ def _get_embedding(self, source: Union[str, Dict]) -> Dict:
335
+ """
336
+ Получает эмбеддинг из различных источников: ID, путь к файлу или существующий эмбеддинг
337
+ """
338
+ if isinstance(source, dict) and 'embedding' in source:
339
+ return source['embedding']
340
+ elif isinstance(source, str):
341
+ if os.path.isfile(source):
342
+ # Если это путь к файлу, создаем новый эмбеддинг
343
+ return self.client.voices.clone(filepath=source)
344
+ else:
345
+ # Если это ID, загружаем голос и возвращаем его эмбеддинг
346
+ voice = self.load_voice(source)
347
+ return voice['embedding']
348
+ else:
349
+ raise ValueError(f"Invalid source type: {type(source)}")
350
+
351
+ def create_mixed_embedding(self, components: List[Dict[str, Union[str, float, Dict]]]) -> Dict:
352
+ """
353
+ Создает смешанный эмбеддинг из нескольких компонентов
354
+
355
+ :param components: Список словарей, каждый содержит 'id' (или 'path', или эмбеддинг) и 'weight'
356
+ :return: Новый смешанный эмбеддинг
357
+ """
358
+ mix_components = []
359
+ for component in components:
360
+ embedding = self._get_embedding(component.get('id') or component.get('path') or component)
361
+ mix_components.append({
362
+ "embedding": embedding,
363
+ "weight": component['weight']
364
+ })
365
+
366
+ return self.client.voices.mix(mix_components)
367
+
368
+ def create_custom_voice(self, name: str, source: Union[str, List[Dict]], description: str = "", language: str = "en"):
369
+ """
370
+ Создает кастомный голос из файла или смеси голосов
371
+
372
+ :param name: Имя нового голоса
373
+ :param source: Путь к файлу или список компонентов для смешивания
374
+ :param description: Описание голоса
375
+ :param language: Язык голоса
376
+ :return: ID нового голоса
377
+ """
378
+ logger.info(f"Creating custom voice: {name}")
379
+
380
+ if isinstance(source, str):
381
+ # Если источник - строка, считаем это путем к файлу
382
+ embedding = self.client.voices.clone(filepath=source)
383
+ elif isinstance(source, list):
384
+ # Если источник - список, создаем смешанный эмбеддинг
385
+ embedding = self.create_mixed_embedding(source)
386
+ else:
387
+ raise ValueError("Invalid source type. Expected file path or list of components.")
388
+
389
+ voice_id = f"custom_{len([f for f in self.custom_dir.glob('*.json')])}"
390
+
391
+ voice_data = {
392
+ "id": voice_id,
393
+ "name": name,
394
+ "description": description,
395
+ "embedding": embedding,
396
+ "language": language,
397
+ "is_public": False,
398
+ "is_custom": True
399
+ }
400
+
401
+ self._save_voice_to_custom(voice_data)
402
+ self.voices[voice_id] = voice_data
403
+ self.loaded_voices.add(voice_id)
404
+
405
+ logger.info(f"Created custom voice with id: {voice_id}")
406
+ return voice_id
407
+
408
+ def get_voice_id_by_name(self, name: str) -> List[str]:
409
+ matching_voices = []
410
+
411
+ # Проверяем оба каталога
412
+ for directory in [self.api_dir, self.custom_dir]:
413
+ for file in directory.glob("*.json"):
414
+ with open(file, "r") as f:
415
+ voice_data = json.load(f)
416
+ if voice_data['name'] == name:
417
+ matching_voices.append(voice_data['id'])
418
+
419
+ if not matching_voices:
420
+ logger.warning(f"No voices found with name: {name}")
421
+ else:
422
+ logger.info(f"Found {len(matching_voices)} voice(s) with name: {name}")
423
+
424
+ return matching_voices
425
+
426
+ def improve_tts_text(text: str, language: str = 'en') -> str:
427
+ text = re.sub(r'(\w+)(\s*)$', r'\1.\2', text)
428
+ text = re.sub(r'(\w+)(\s*\n)', r'\1.\2', text)
429
+
430
+ def format_date(match):
431
+ date = datetime.strptime(match.group(), '%Y-%m-%d')
432
+ return date.strftime('%m/%d/%Y')
433
+
434
+ text = re.sub(r'\d{4}-\d{2}-\d{2}', format_date, text)
435
+ text = text.replace(' - ', ' - - ')
436
+ text = re.sub(r'\?(?![\s\n])', '??', text)
437
+ text = text.replace('"', '')
438
+ text = text.replace("'", '')
439
+ text = re.sub(r'(https?://\S+|\S+@\S+\.\S+)\?', r'\1 ?', text)
440
+
441
+ if language.lower() in ['ru', 'rus', 'russian']:
442
+ text = text.replace('г.', 'году')
443
+ elif language.lower() in ['fr', 'fra', 'french']:
444
+ text = text.replace('M.', 'Monsieur')
445
+
446
+ return text