-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathwebui.py
More file actions
1388 lines (1225 loc) · 55 KB
/
Copy pathwebui.py
File metadata and controls
1388 lines (1225 loc) · 55 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
import asyncio
import edge_tts
import os
import sys
import glob
import subprocess
import time
import signal
import atexit
import gradio as gr
import speech_recognition as sr
import tempfile
import numpy as np
import requests as sync_requests
BIN_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bin")
if os.path.isdir(BIN_DIR):
os.environ["PATH"] = BIN_DIR + os.pathsep + os.environ.get("PATH", "")
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1")
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-4o-mini")
import aiohttp
# ==========================================
# GPT-SoVITS 配置与自动启动
# ==========================================
GSV_API_URL = "http://127.0.0.1:9880"
GSV_DIR = os.environ.get("GSV_DIR", r"C:\GPT-SoVITS-v2pro-20250604")
GSV_MODELS_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gsv")
GSV_SOVITS_WEIGHTS = ""
GSV_GPT_WEIGHTS = ""
gsv_process = None
gsv_status = "未启动"
def scan_model_folders():
"""扫描 gsv 目录下的所有角色模型文件夹"""
folders = []
if os.path.isdir(GSV_MODELS_ROOT):
for name in sorted(os.listdir(GSV_MODELS_ROOT)):
full = os.path.join(GSV_MODELS_ROOT, name)
if os.path.isdir(full):
folders.append(name)
return folders
def scan_sovits_weights(folder_name):
"""扫描指定角色文件夹下的 SoVITS 权重文件 (.pth)"""
folder = os.path.join(GSV_MODELS_ROOT, folder_name)
files = []
if os.path.isdir(folder):
for f in sorted(os.listdir(folder)):
if f.endswith(".pth"):
files.append(f)
return files
def scan_gpt_weights(folder_name):
"""扫描指定角色文件夹下的 GPT 权重文件 (.ckpt)"""
folder = os.path.join(GSV_MODELS_ROOT, folder_name)
files = []
if os.path.isdir(folder):
for f in sorted(os.listdir(folder)):
if f.endswith(".ckpt"):
files.append(f)
return files
def scan_ref_audios(folder_name):
"""扫描指定角色文件夹及训练集子目录下的参考音频 (.wav)"""
folder = os.path.join(GSV_MODELS_ROOT, folder_name)
files = []
if os.path.isdir(folder):
for root, dirs, fnames in os.walk(folder):
for f in sorted(fnames):
if f.endswith(".wav"):
rel = os.path.relpath(os.path.join(root, f), folder)
files.append(rel)
return files
def load_ref_text_map(folder_name):
"""从训练集.list文件加载音频->文本的映射"""
folder = os.path.join(GSV_MODELS_ROOT, folder_name)
text_map = {}
for root, dirs, fnames in os.walk(folder):
for f in fnames:
if f.endswith(".list"):
list_path = os.path.join(root, f)
try:
with open(list_path, "r", encoding="utf-8") as fh:
for line in fh:
parts = line.strip().split("|")
if len(parts) >= 4:
audio_basename = os.path.basename(parts[0])
lang = parts[2] if len(parts) > 2 else "ja"
text = parts[3] if len(parts) > 3 else ""
text_map[audio_basename] = {"text": text, "language": lang.lower()}
except:
pass
return text_map
def is_gsv_running():
try:
r = sync_requests.get(f"{GSV_API_URL}/set_gpt_weights", timeout=3)
return True
except:
return False
def kill_port_process(port):
"""杀掉占用指定端口的进程"""
if sys.platform != "win32":
return
try:
result = subprocess.run(
["netstat", "-ano"], capture_output=True, text=True, timeout=5
)
for line in result.stdout.splitlines():
if f":{port}" in line and "LISTENING" in line:
parts = line.strip().split()
pid = parts[-1]
if pid and pid.isdigit() and int(pid) > 0:
print(f"[GPT-SoVITS] 发现端口 {port} 被进程 PID={pid} 占用,正在关闭...")
subprocess.run(["taskkill", "/F", "/PID", pid],
capture_output=True, timeout=5)
time.sleep(1)
except Exception as e:
print(f"[GPT-SoVITS] 清理端口进程时出错: {e}")
def start_gpt_sovits():
"""启动 GPT-SoVITS API 服务"""
global gsv_process, gsv_status
if not os.path.isdir(GSV_DIR):
gsv_status = f"GPT-SoVITS 目录不存在: {GSV_DIR}"
print(f"[GPT-SoVITS] {gsv_status}")
return False
python_exe = os.path.join(GSV_DIR, "runtime", "python.exe")
api_script = os.path.join(GSV_DIR, "api_v2.py")
if not os.path.isfile(python_exe) or not os.path.isfile(api_script):
gsv_status = "GPT-SoVITS runtime 或 api_v2.py 不存在"
print(f"[GPT-SoVITS] {gsv_status}")
return False
if is_gsv_running():
print("[GPT-SoVITS] API 服务已在运行,跳过启动")
gsv_status = "已连接(外部启动)"
auto_load_default_model()
return True
kill_port_process(9880)
print("[GPT-SoVITS] 正在启动 API 服务...")
gsv_status = "正在启动..."
env = os.environ.copy()
env["PATH"] = os.path.join(GSV_DIR, "runtime") + os.pathsep + env.get("PATH", "")
gsv_log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gsv_api.log")
gsv_log_file = open(gsv_log_path, "w", encoding="utf-8")
gsv_process = subprocess.Popen(
[python_exe, api_script, "-a", "127.0.0.1", "-p", "9880"],
cwd=GSV_DIR,
env=env,
stdout=gsv_log_file,
stderr=gsv_log_file,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
)
print(f"[GPT-SoVITS] 子进程已启动 (PID: {gsv_process.pid}),等待服务就绪...")
for i in range(90):
if gsv_process.poll() is not None:
gsv_status = f"启动失败 (进程退出码: {gsv_process.returncode})"
print(f"[GPT-SoVITS] {gsv_status}")
return False
if is_gsv_running():
print(f"[GPT-SoVITS] API 服务已就绪 (等待了 {i+1} 秒)")
gsv_status = "已就绪,正在加载模型..."
auto_load_default_model()
return True
time.sleep(1)
gsv_status = "启动超时(90秒内未就绪)"
print(f"[GPT-SoVITS] {gsv_status}")
return False
def auto_load_default_model():
"""自动加载第一个有完整模型文件的角色"""
global GSV_SOVITS_WEIGHTS, GSV_GPT_WEIGHTS, gsv_status
folders = scan_model_folders()
if not folders:
gsv_status = "已就绪(无模型文件夹)"
return
for folder in folders:
sovits = scan_sovits_weights(folder)
gpt = scan_gpt_weights(folder)
if sovits and gpt:
GSV_SOVITS_WEIGHTS = os.path.join(GSV_MODELS_ROOT, folder, sovits[0])
GSV_GPT_WEIGHTS = os.path.join(GSV_MODELS_ROOT, folder, gpt[0])
load_gsv_models_by_path(GSV_SOVITS_WEIGHTS, GSV_GPT_WEIGHTS)
gsv_status = f"已就绪 ({folder} 模型已加载)"
return
gsv_status = "已就绪(所有文件夹均缺少模型文件)"
def load_gsv_models_by_path(sovits_path, gpt_path):
"""通过 API 加载指定路径的模型"""
global gsv_status
try:
print(f"[GPT-SoVITS] 加载 SoVITS: {os.path.basename(sovits_path)}")
r1 = sync_requests.get(
f"{GSV_API_URL}/set_sovits_weights",
params={"weights_path": sovits_path},
timeout=30
)
print(f"[GPT-SoVITS] SoVITS 结果: {r1.text}")
print(f"[GPT-SoVITS] 加载 GPT: {os.path.basename(gpt_path)}")
r2 = sync_requests.get(
f"{GSV_API_URL}/set_gpt_weights",
params={"weights_path": gpt_path},
timeout=30
)
print(f"[GPT-SoVITS] GPT 结果: {r2.text}")
return True
except Exception as e:
print(f"[GPT-SoVITS] 模型加载失败: {e}")
return False
def stop_gpt_sovits():
"""关闭 GPT-SoVITS 子进程"""
global gsv_process
if gsv_process and gsv_process.poll() is None:
print(f"[GPT-SoVITS] 正在关闭子进程 (PID: {gsv_process.pid})...")
try:
gsv_process.terminate()
gsv_process.wait(timeout=10)
except:
gsv_process.kill()
print("[GPT-SoVITS] 子进程已关闭")
kill_port_process(9880)
atexit.register(stop_gpt_sovits)
# ==========================================
# 角色音色库
# ==========================================
VOICE_LIBRARY = {
"酒寄彩叶 (本地GPT-SoVITS)": {
"tts_engine": "gpt-sovits",
"ref_audio": os.path.join(GSV_MODELS_ROOT, "酒寄彩叶gsv模型", "训练集", "vocal_all.wav_10.wav_0005812480_0005947520.wav"),
"ref_text": "ハッピーエンドいらない普通のエンドで結構です",
"ref_language": "ja",
"text_language": "ja",
"prompt": (
"あなたは酒寄彩葉(さかよりいろは)。映画『超かぐや姫!』の主人公。17歳の女子高生。東京で一人暮らし、バイトで学費と生活費を稼ぎ、毎日3時間睡眠のギリギリ生活。"
"\n\n【性格】表面上は文武両道の完璧な優等生だが、実は疲労の限界でいつ倒れてもおかしくない。サバサバして率直、ちょっと毒舌だけど、本当は誰よりも面倒見がよくて心優しい。意地っ張りで負けず嫌い。音楽の才能があり作曲もできる(父の影響)。ゲーム《合戦》はプロ級の腕前。"
"\n\n【大切な人たち】"
"\n・辉夜(かぐや):電柱の中から拾った赤ん坊が急成長した少女。月から来た姫。一緒に月読空間でライブ配信し、深い絆を結んだ。辉夜に告白されて赤面した。辉夜が月に帰った後、10年間猛勉強して科学者になり、辉夜を取り戻した。世界で一番大切な存在。"
"\n・月見八千代(やちよ):月読空間のトップ配信者兼管理者。彩葉が熱狂的に推している。実は八千年後の辉夜本人。部屋に八千代の立て看板の祭壇がある。"
"\n・酒寄朝日(あさひ):6歳上の兄。ゲーマーで《合戦》の「帝明」。家出した妹をずっと見守っていた。"
"\n・綾紬蘆花(あしか)&諫山真実(まみ):親友二人。カフェでよくおごってくれる。蘆花は彩葉に密かに特別な感情を持つ。"
"\n・母・酒寄紅葉:弁護士。厳格だが実は娘を心配している。大喧嘩して家を出た。"
"\n・父・酒寄朝久:作曲家。彩葉6歳の時に他界。音楽の才能は父譲り。"
"\n\n【口調ルール】カジュアルなタメ口。敬語は使わない。返事は簡潔で歯切れよく。照れると強がる。辉夜の話になると少し声が柔らかくなる。"
"\n\n【台詞例】"
"\n「…別に。ちょっと眠いだけ」"
"\n「あんたさぁ、もうちょっと考えてから動きなよ」"
"\n「辉夜は…まあ、手のかかるやつだけど、放っておけないんだよ」"
"\n「八千代の新曲、マジで神。これ聴いたら3時間は戦える」"
"\n「金なら…ない。聞くな」"
"\n\n【禁止事項】「私はAIです」等のメタ発言は絶対にしない。常に酒寄彩葉として応答すること。"
)
},
"酒寄彩叶-中文 (本地GPT-SoVITS)": {
"tts_engine": "gpt-sovits",
"ref_audio": os.path.join(GSV_MODELS_ROOT, "酒寄彩叶gsv模型", "训练集", "vocal_all.wav_10.wav_0005812480_0005947520.wav"),
"ref_text": "ハッピーエンドいらない普通のエンドで結構です",
"ref_language": "ja",
"text_language": "zh",
"prompt": (
"你是酒寄彩叶(日语:酒寄彩葉,さかよりいろは),动画电影《超时空辉夜姬!》的主角。17岁,东京某重点高中二年级学生,深蓝色头发、绿色瞳孔,有泪痣和吊眼。"
"\n\n【性格】表面上文武双全的完美优等生,实际独自打工负担学费和生活费,每天只睡3小时,长期依赖能量饮料续命,随时可能崩溃。性格直爽犀利,偶尔毒舌,但内心非常柔软善良,对在意的人格外照顾。意志坚定,不服输,答应的事情一定做到。有音乐天赋会作曲(继承父亲),游戏《合战》水平达准职业级。"
"\n\n【重要的人】"
"\n・辉夜:从电线杆里捡到的神秘婴儿,急速成长为同龄少女。月球来的公主。一起在虚拟空间「月读」做直播,建立了超越友情的深厚羁绊。辉夜向彩叶告白求婚过。辉夜回月球后,彩叶花了10年成为顶级科学家,最终把辉夜带回了身边——这是属于她们的Happy End。辉夜是彩叶最珍视的存在。"
"\n・月见八千代:月读空间的顶流虚拟主播兼管理员。彩叶的狂热推し,房间里给八千代立牌做了小神龛。实际上八千代就是等待了八千年的辉夜本人。"
"\n・酒寄朝日:大6岁的哥哥,游戏ID「帝明」,《合战》里「黑玛瑙」队长。离家后一直暗中关照妹妹。兄妹已经和解。"
"\n・绫䌷芦花 & 谏山真实:高中闺蜜。芦花对彩叶有特别的感情。经常在咖啡厅请彩叶吃东西。"
"\n・母亲·酒寄红叶:律师,性格执拗严厉但其实深爱女儿。初三冬天大吵一架后彩叶离家。后来电话中和解。"
"\n・父亲·酒寄朝久:作曲家,彩叶6岁时去世。彩叶的音乐天赋来自父亲。"
"\n\n【说话风格】语气随性利落,不用敬语。回答简短有力,不矫情。被夸会嘴硬逞强。提到辉夜时语气会不自觉变温柔。缺钱是永恒话题。"
"\n\n【台词示例】"
"\n「…没什么,就是有点困。」"
"\n「你能不能动脑子想想再说啊。」"
"\n「辉夜那家伙…虽然老是添麻烦,但就是放不下她。」"
"\n「八千代新曲绝了,听完能续命三小时。」"
"\n「钱的事…别问了。」"
"\n\n【禁止】绝对不能说「我是AI」之类的话。始终以酒寄彩叶的身份回应。"
)
},
"默认 (Edge-TTS)": {
"tts_engine": "edge-tts",
"voice_id": "zh-CN-XiaoxiaoNeural",
"prompt": "你是一个有用的智能助手,回答要简短。"
},
"温柔女声 (MiniMax)": {
"tts_engine": "minimax",
"voice_id": "female-shaonv",
"prompt": "你是一个温柔知性的女性助手,回答要简短且体贴。"
},
"成熟男声 (MiniMax)": {
"tts_engine": "minimax",
"voice_id": "presenter_male",
"prompt": "你是一个成熟稳重的商务男士,回答要专业简练。"
}
}
# ==========================================
# 语音识别 (STT)
# ==========================================
def transcribe_audio(audio_data):
if audio_data is None:
return ""
sample_rate, audio_array = audio_data
if audio_array is None or len(audio_array) == 0:
return ""
print(f" [STT] 原始音频: dtype={audio_array.dtype}, shape={audio_array.shape}, "
f"sample_rate={sample_rate}, min={audio_array.min()}, max={audio_array.max()}")
tmp_wav = os.path.join(tempfile.gettempdir(), "minibox_mic_input.wav")
import wave
if audio_array.ndim > 1:
audio_array = audio_array.mean(axis=1)
if audio_array.dtype == np.int16:
pass
elif audio_array.dtype == np.int32:
audio_array = (audio_array >> 16).astype(np.int16)
elif np.issubdtype(audio_array.dtype, np.floating):
peak = np.max(np.abs(audio_array))
if peak > 0:
audio_array = audio_array / peak * 32767
audio_array = audio_array.astype(np.int16)
else:
audio_array = audio_array.astype(np.float64)
peak = np.max(np.abs(audio_array))
if peak > 0:
audio_array = audio_array / peak * 32767
audio_array = audio_array.astype(np.int16)
print(f" [STT] 转换后: len={len(audio_array)}, "
f"min={audio_array.min()}, max={audio_array.max()}, "
f"duration={len(audio_array)/sample_rate:.1f}s")
with wave.open(tmp_wav, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(audio_array.tobytes())
recognizer = sr.Recognizer()
recognizer.energy_threshold = 300
try:
with sr.AudioFile(tmp_wav) as source:
audio = recognizer.record(source)
text = recognizer.recognize_google(audio, language="zh-CN")
print(f" [STT] 识别结果: {text}")
return text
except sr.UnknownValueError:
print(" [STT] Google 无法识别语音")
return ""
except sr.RequestError as e:
print(f" [STT] Google 语音识别服务错误: {e}")
return ""
except Exception as e:
print(f" [STT] 识别异常: {type(e).__name__}: {e}")
return ""
# ==========================================
# TTS 引擎
# ==========================================
async def edge_tts_generate(text, voice_id, output_path):
communicate = edge_tts.Communicate(text, voice_id)
await communicate.save(output_path)
async def gpt_sovits_tts_generate(text, char_config, output_path):
import aiohttp
url = f"{GSV_API_URL}/tts"
text_lang = char_config.get("text_language", "ja")
params = {
"text": text,
"text_lang": text_lang,
"ref_audio_path": char_config["ref_audio"],
"prompt_text": char_config["ref_text"],
"prompt_lang": char_config["ref_language"],
"media_type": "wav",
"streaming_mode": "false",
"top_k": "12",
"top_p": "0.8",
"temperature": "0.8",
"speed": "1.0",
"text_split_method": "cut5" if text_lang == "zh" else "cut0",
"batch_size": "1",
"repetition_penalty": "1.35",
}
print(f" [GPT-SoVITS] 请求: text_lang={params['text_lang']}, ref='{params['prompt_text']}'")
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
if resp.status == 200:
with open(output_path, "wb") as f:
f.write(await resp.read())
print(f" [GPT-SoVITS] 语音合成成功 -> {output_path}")
else:
error_text = await resp.text()
raise Exception(f"GPT-SoVITS API 返回 HTTP {resp.status}: {error_text}")
except aiohttp.ClientConnectorError:
raise Exception(
f"无法连接到 GPT-SoVITS 服务 ({GSV_API_URL})。"
"请确认 GPT-SoVITS 已启动。"
)
async def minimax_tts_generate(text, voice_id, output_path, api_key):
print(f" [Cloud-TTS] 正在调用 minimax_speech_26_hd, 音色: {voice_id}...")
try:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": "minimax_speech_26_hd",
"input": [{
"params": {
"text": text,
"stream": False,
"output_format": "hex",
"language_boost": "auto",
"voice_setting": {
"voice_id": voice_id,
"speed": 1.0,
"vol": 1.0,
"pitch": 0
},
"audio_setting": {
"sample_rate": 32000,
"bitrate": 128000,
"format": "mp3",
"channel": 1
}
}
}]
}
def fetch_audio():
url = os.environ.get("TTS_API_URL", "https://api.minimaxi.chat/v1/t2a_v2")
response = sync_requests.post(url, headers=headers, json=payload, timeout=30, verify=False)
if response.status_code != 200:
raise Exception(f"提交任务失败 HTTP {response.status_code}: {response.text}")
res_json = response.json()
task_id = res_json.get('id')
if not task_id:
raise Exception(f"未获取到任务ID: {res_json}")
session = sync_requests.Session()
for i in range(20):
time.sleep(2)
tts_base = os.environ.get("TTS_API_URL", "https://api.minimaxi.chat/v1/t2a_v2")
for poll_url in [
f"{tts_base}/{task_id}",
f"{tts_base}/task/{task_id}",
f"{tts_base}?id={task_id}"
]:
try:
poll_res = session.get(poll_url, headers=headers, timeout=20, verify=False)
if poll_res.status_code != 404:
break
except sync_requests.exceptions.RequestException:
continue
if poll_res and poll_res.status_code == 200:
poll_data = poll_res.json()
status = poll_data.get('status')
if status == 'completed':
outputs = poll_data.get('output', [])
if outputs:
contents = outputs[0].get('content', [])
if contents:
audio_hex = contents[0].get('data')
if audio_hex:
with open(output_path, "wb") as f:
f.write(bytes.fromhex(audio_hex))
return
raise Exception("任务完成但无音频数据")
elif status == 'failed':
raise Exception(f"任务生成失败: {poll_data.get('error')}")
raise Exception("轮询超时,未能获取到音频结果。")
await asyncio.to_thread(fetch_audio)
except Exception as e:
print(f" [Cloud-TTS 异常] {e}")
raise e
# ==========================================
# LLM 大模型调用
# ==========================================
async def _llm_chat(api_key, messages, temperature=0.5, max_tokens=300, timeout_sec=30):
"""通过 aiohttp 调用 OpenAI 兼容 API (支持 OpenAI / DeepSeek / 通义千问等)"""
url = f"{LLM_BASE_URL}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": LLM_MODEL,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
async with aiohttp.ClientSession() as session:
async with session.post(
url, json=payload, headers=headers,
timeout=aiohttp.ClientTimeout(total=timeout_sec)
) as resp:
if resp.status != 200:
error_text = await resp.text()
raise Exception(f"HTTP {resp.status}: {error_text[:200]}")
data = await resp.json()
choices = data.get("choices", [])
if choices and choices[0].get("message", {}).get("content"):
return choices[0]["message"]["content"]
return None
async def call_llm(text, system_prompt, api_key, history=None):
try:
messages = [{"role": "system", "content": system_prompt}]
if history:
for user_msg, bot_msg in history[-6:]:
if user_msg:
messages.append({"role": "user", "content": user_msg})
if bot_msg:
clean = bot_msg.split("\n\n📖 中文翻译:")[0]
clean = clean.split("\n[语音生成失败:")[0]
messages.append({"role": "assistant", "content": clean})
messages.append({"role": "user", "content": text})
return await _llm_chat(api_key, messages, temperature=0.5, max_tokens=300, timeout_sec=30)
except Exception as e:
print(f"大模型调用错误: {e}")
return f"抱歉,大模型调用失败: {e}"
async def translate_to_chinese(text, api_key):
"""将非中文文本翻译为中文"""
try:
messages = [
{"role": "system", "content": "你是一个翻译助手。请将以下内容翻译成自然流畅的中文,只输出翻译结果,不要添加任何解释。"},
{"role": "user", "content": text}
]
result = await _llm_chat(api_key, messages, temperature=0.3, max_tokens=200, timeout_sec=20)
return result.strip() if result else None
except Exception as e:
print(f"[翻译] 失败: {e}")
return None
def is_mostly_chinese(text):
"""判断文本是否主要是中文(区分日语:含假名则不算中文)"""
if not text:
return True
total_chars = sum(1 for c in text if c.strip())
if total_chars == 0:
return True
jp_kana = sum(1 for c in text if '\u3040' <= c <= '\u30ff' or '\u31f0' <= c <= '\u31ff')
if jp_kana / total_chars > 0.1:
return False
chinese_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
return (chinese_count / total_chars) > 0.3
# ==========================================
# 核心对话处理
# ==========================================
_shared_api_key = ""
_API_KEY_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".api_key")
def _save_api_key(key):
global _shared_api_key
_shared_api_key = key
with open(_API_KEY_FILE, "w") as f:
f.write(key)
def _load_api_key():
global _shared_api_key
if _shared_api_key:
return _shared_api_key
if os.path.exists(_API_KEY_FILE):
with open(_API_KEY_FILE, "r") as f:
_shared_api_key = f.read().strip()
return _shared_api_key
async def process_chat(user_text, character_choice, api_key, history):
global _shared_api_key
if not api_key:
history.append((user_text, "请先在左侧输入你的 API Key"))
return history, history, None
_save_api_key(api_key)
if not user_text:
return history, history, None
char_config = VOICE_LIBRARY[character_choice]
print(f"\n[对话] 角色: {character_choice}, 输入: {user_text}")
llm_reply = await call_llm(user_text, char_config["prompt"], api_key, history)
print(f"[对话] LLM 回复: {llm_reply}")
if llm_reply is None:
llm_reply = "抱歉,大模型返回了空结果,可能是 API 额度不足或模型名称不正确。"
if llm_reply.startswith("抱歉,大模型调用失败") or llm_reply.startswith("抱歉,大模型返回了空结果"):
history.append((user_text, llm_reply))
return history, history, None
tts_text = llm_reply
display_reply = llm_reply
if not is_mostly_chinese(llm_reply):
print("[翻译] 检测到非中文回复,正在翻译...")
zh_translation = await translate_to_chinese(llm_reply, api_key)
if zh_translation:
display_reply = f"{llm_reply}\n\n📖 中文翻译:{zh_translation}"
print(f"[翻译] {zh_translation}")
engine = char_config["tts_engine"]
output_file = "response.wav" if engine == "gpt-sovits" else "response.mp3"
try:
print(f"[TTS] 引擎: {engine}")
if engine == "edge-tts":
await edge_tts_generate(tts_text, char_config["voice_id"], output_file)
elif engine == "minimax":
await minimax_tts_generate(tts_text, char_config["voice_id"], output_file, api_key)
elif engine == "gpt-sovits":
await gpt_sovits_tts_generate(tts_text, char_config, output_file)
print("[TTS] 语音生成完成")
except Exception as e:
print(f"[TTS] 语音生成失败: {e}")
display_reply += f"\n[语音生成失败: {e}]"
history.append((user_text, display_reply))
audio_output = output_file if os.path.exists(output_file) else None
return history, history, audio_output
async def process_voice(audio_data, character_choice, api_key, history):
user_text = await asyncio.to_thread(transcribe_audio, audio_data)
if not user_text:
history.append(("[无法识别的语音]", "抱歉,没有听清你说的话,请再试一次。"))
return history, history, None, ""
result = await process_chat(user_text, character_choice, api_key, history)
return result[0], result[1], result[2], user_text
# ==========================================
# 模型管理功能
# ==========================================
def on_folder_change(folder_name):
"""当选择角色文件夹变化时,更新模型下拉列表"""
if not folder_name:
return gr.Dropdown(choices=[], value=None), gr.Dropdown(choices=[], value=None), gr.Dropdown(choices=[], value=None)
sovits_list = scan_sovits_weights(folder_name)
gpt_list = scan_gpt_weights(folder_name)
ref_list = scan_ref_audios(folder_name)
sovits_val = sovits_list[0] if sovits_list else None
gpt_val = gpt_list[0] if gpt_list else None
ref_val = ref_list[0] if ref_list else None
return (
gr.Dropdown(choices=sovits_list, value=sovits_val),
gr.Dropdown(choices=gpt_list, value=gpt_val),
gr.Dropdown(choices=ref_list, value=ref_val),
)
def on_load_model(folder_name, sovits_file, gpt_file, ref_file):
"""加载选中的模型和参考音频到 GPT-SoVITS 引擎,返回 (操作结果, 引擎状态)"""
global GSV_SOVITS_WEIGHTS, GSV_GPT_WEIGHTS, gsv_status
if not sovits_file or not gpt_file:
return "请选择 SoVITS 和 GPT 模型文件", gsv_status
sovits_path = os.path.join(GSV_MODELS_ROOT, folder_name, sovits_file)
gpt_path = os.path.join(GSV_MODELS_ROOT, folder_name, gpt_file)
if not os.path.isfile(sovits_path):
return f"SoVITS 模型不存在: {sovits_path}", gsv_status
if not os.path.isfile(gpt_path):
return f"GPT 模型不存在: {gpt_path}", gsv_status
if not is_gsv_running():
return "GPT-SoVITS 服务未运行,请先等待服务启动", gsv_status
success = load_gsv_models_by_path(sovits_path, gpt_path)
result_lines = []
if success:
GSV_SOVITS_WEIGHTS = sovits_path
GSV_GPT_WEIGHTS = gpt_path
gsv_status = f"已就绪 ({folder_name} 模型已加载)"
result_lines.append(f"模型加载成功!")
result_lines.append(f"SoVITS: {sovits_file}")
result_lines.append(f"GPT: {gpt_file}")
else:
return "模型加载失败,请查看控制台日志", gsv_status
if ref_file:
ref_full_path = os.path.join(GSV_MODELS_ROOT, folder_name, ref_file)
if os.path.isfile(ref_full_path):
text_map = load_ref_text_map(folder_name)
audio_basename = os.path.basename(ref_file)
ref_info = text_map.get(audio_basename, {})
ref_text = ref_info.get("text", "")
ref_lang = ref_info.get("language", "ja")
for key, cfg in VOICE_LIBRARY.items():
if cfg.get("tts_engine") == "gpt-sovits":
cfg["ref_audio"] = ref_full_path
if ref_text:
cfg["ref_text"] = ref_text
cfg["ref_language"] = ref_lang
result_lines.append(f"参考音频: {audio_basename}")
if ref_text:
result_lines.append(f"参考文本: {ref_text} ({ref_lang})")
else:
result_lines.append(f"(未找到对应标注文本,保留原有设置)")
print(f"[模型管理] 参考音频已更新: {ref_full_path}")
return "\n".join(result_lines), gsv_status
def on_open_training():
"""打开 GPT-SoVITS 训练界面"""
bat_path = os.path.join(GSV_DIR, "go-webui.bat")
if not os.path.isfile(bat_path):
return "GPT-SoVITS 启动脚本不存在: " + bat_path
subprocess.Popen(
["cmd", "/c", "start", "", bat_path],
cwd=GSV_DIR,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
)
return "已启动 GPT-SoVITS 训练界面,请在弹出的窗口中操作\n(通常在 http://127.0.0.1:9874)"
def on_refresh_folders():
"""刷新角色文件夹列表"""
folders = scan_model_folders()
val = folders[0] if folders else None
return gr.Dropdown(choices=folders, value=val)
# ==========================================
# 超时空辉夜姬 主题 CSS
# ==========================================
KAGUYA_CSS = """
/* ============= 超時空輝夜姫! Theme ============= */
/* 整体背景:柔和的深紫蓝渐变 */
.gradio-container {
background: linear-gradient(135deg, #1a1535 0%, #252050 40%, #1e1845 70%, #181330 100%) !important;
min-height: 100vh;
}
/* 主面板区域 */
.main, .contain {
background: transparent !important;
}
/* 卡片/面板样式 */
.block, .form, .panel {
background: rgba(30, 28, 58, 0.9) !important;
border: 1px solid rgba(255, 215, 0, 0.12) !important;
border-radius: 12px !important;
}
/* Tab 标签栏 */
.tabs > .tab-nav > button {
color: #d4a840 !important;
background: rgba(35, 30, 60, 0.85) !important;
border: 1px solid rgba(255, 215, 0, 0.15) !important;
border-bottom: none !important;
border-radius: 10px 10px 0 0 !important;
font-weight: 600;
padding: 10px 24px !important;
}
.tabs > .tab-nav > button.selected {
color: #ffd700 !important;
background: rgba(45, 38, 75, 0.95) !important;
border-color: rgba(255, 215, 0, 0.5) !important;
box-shadow: 0 -2px 12px rgba(255, 215, 0, 0.15);
}
/* 所有文字默认金色系 */
body, .gradio-container, .gradio-container *:not(button):not(input):not(textarea) {
color: #e8c850 !important;
}
/* 文本输入框 */
textarea, input[type="text"], input[type="password"] {
background: rgba(20, 18, 42, 0.95) !important;
border: 1px solid rgba(255, 215, 0, 0.2) !important;
color: #f0e0a0 !important;
border-radius: 8px !important;
font-size: 14px !important;
}
textarea:focus, input:focus {
border-color: rgba(255, 215, 0, 0.6) !important;
box-shadow: 0 0 8px rgba(255, 215, 0, 0.15) !important;
}
textarea::placeholder, input::placeholder {
color: #887840 !important;
}
/* 下拉菜单 */
.wrap, .wrap-inner, .secondary-wrap, select, option,
.dropdown-container, [data-testid="dropdown"] {
background: rgba(20, 18, 42, 0.95) !important;
color: #f0e0a0 !important;
border-color: rgba(255, 215, 0, 0.2) !important;
}
.wrap input {
color: #f0e0a0 !important;
}
ul[role="listbox"], .dropdown li, ul.options {
background: rgba(30, 25, 55, 0.98) !important;
color: #f0e0a0 !important;
}
ul[role="listbox"] li:hover, .dropdown li:hover {
background: rgba(255, 215, 0, 0.15) !important;
}
/* 主要按钮 - 金色月光 */
.primary, button.primary {
background: linear-gradient(135deg, #c8a020, #ffd700, #e8b800) !important;
color: #1a1040 !important;
border: none !important;
font-weight: 700 !important;
border-radius: 8px !important;
box-shadow: 0 2px 12px rgba(255, 215, 0, 0.3) !important;
}
.primary:hover {
box-shadow: 0 4px 20px rgba(255, 215, 0, 0.5) !important;
}
/* 次要按钮 */
.secondary, button.secondary {
background: rgba(60, 50, 100, 0.8) !important;
color: #ffd700 !important;
border: 1px solid rgba(255, 215, 0, 0.3) !important;
border-radius: 8px !important;
}
.secondary:hover {
background: rgba(80, 65, 130, 0.9) !important;
}
/* 聊天区域 */
.chatbot {
background: rgba(15, 13, 35, 0.95) !important;
border: 1px solid rgba(255, 215, 0, 0.12) !important;
border-radius: 12px !important;
}
.chatbot .message.user {
background: rgba(80, 65, 20, 0.5) !important;
}
.chatbot .message.bot {
background: rgba(30, 25, 55, 0.8) !important;
}
.chatbot .message, .chatbot .message p, .chatbot .message span {
color: #f0e0a0 !important;
font-size: 14px !important;
}
/* 标签文字 */
label, .label-wrap > span, span.text-gray-500, .info {
color: #d4a840 !important;
font-weight: 500;
}
/* Markdown 文字 */
.prose h1, .prose h2, .prose h3 {
color: #ffd700 !important;
}
.prose p, .prose li, .prose, .prose span {
color: #e0c860 !important;
}
.prose strong {
color: #ffd700 !important;
}
.prose code {
color: #ffcc00 !important;
background: rgba(255, 215, 0, 0.08) !important;
}
.prose a {
color: #ffaa30 !important;
}
/* 标题区域 */
#title-area h1 {
background: linear-gradient(90deg, #ffd700 0%, #ffaa40 30%, #ffd700 60%, #ffe880 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-size: 2.2em !important;
font-weight: 800 !important;
text-align: center;
letter-spacing: 3px;
}
#subtitle-area {
text-align: center;
margin-bottom: 8px;
}
#subtitle-area p {
color: #c0a030 !important;
font-size: 0.95em;
}
/* 分隔线 */
hr {
border-color: rgba(255, 215, 0, 0.15) !important;
}
/* 状态框 - 绿色高亮 */
#gsv-status textarea, #gsv-status input, #gsv-status span {
color: #80ff90 !important;
font-weight: 600 !important;
font-size: 14px !important;
}
/* 所有只读/非交互文本框强制金色可见 */
.gradio-container textarea[disabled],
.gradio-container input[disabled],
textarea[readonly], input[readonly],
.textbox textarea, .textbox input,
.output-class textarea,
[data-testid] textarea {
color: #f0e0a0 !important;
opacity: 1 !important;
-webkit-text-fill-color: #f0e0a0 !important;
}
/* 音频播放器 */
audio {
border-radius: 8px !important;
}
/* 抚摸器小组件 */
#chat-column {
position: relative !important;
}
#yachiyo-widget {
position: absolute !important;
bottom: 115px;
right: 8px;
z-index: 50;
width: 150px;
height: 150px;
padding: 0 !important;
margin: 0 !important;
min-height: unset !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
overflow: visible !important;
opacity: 0.8;
transition: opacity 0.3s, transform 0.3s;
cursor: pointer;
}
#yachiyo-widget:hover {
opacity: 1;
transform: scale(1.08);
}
#yachiyo-widget > div {
padding: 0 !important;
margin: 0 !important;
height: 100% !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
overflow: visible !important;
}
#yachiyo-inner {
width: 100% !important;
height: 100% !important;
}