-
Notifications
You must be signed in to change notification settings - Fork 116
Expand file tree
/
Copy pathwebui.py
More file actions
4724 lines (4239 loc) · 231 KB
/
Copy pathwebui.py
File metadata and controls
4724 lines (4239 loc) · 231 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
"""
audio.cpp WebUI (Route B) — a thin Gradio frontend that proxies to the local
audiocpp_server HTTP API.
Model loading is on demand: instead of preloading a model at server startup, this
WebUI reads models_catalog.json, lets you pick a model, and (re)starts
audiocpp_server with a single-model config only when you actually load/run it.
One model lives in VRAM at a time — picking a different model swaps it.
Uploaded files are saved by Gradio to local temp paths, which we pass to the
server as `voice_ref` / `audio` (frontend + server run on the same machine).
Just launch this (it starts the server for you):
venv\\Scripts\\python audiocpp-portable\\webui.py
Env overrides:
AUDIOCPP_BACKEND=gpu|cpu which bin dir to launch the server from
(default: auto — gpu when an NVIDIA driver and the
gpu server build are both present, else cpu)
AUDIOCPP_THREADS=N ggml compute threads (default 1; cpu backend
defaults to the physical core count, minus one
above 4 cores — SMT siblings are not counted)
AUDIOCPP_SERVER=http://... talk to an already-running server instead of managing one
AUDIOCPP_LOAD_TIMEOUT=300 seconds to wait for a model to finish loading
AUDIOCPP_NO_BROWSER=1 don't open a browser tab
"""
import atexit
import base64
import glob
import io
import json
import logging
import os
import random
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import warnings
import wave
from urllib.parse import urlparse
import numpy as np
import requests
import gradio as gr
try:
from ui_i18n import (
LANGUAGE_CHOICES,
get_language,
load_language,
param_spec as localized_param_spec,
save_language,
set_language,
text as ui_text,
)
except ImportError as _exc:
# Fall back to the package-qualified name only when ui_i18n itself is not on
# the path (imported as ``webui.webui`` in tests). A missing *transitive*
# dependency of ui_i18n — e.g. opencc — must surface as-is instead of being
# masked by a misleading "no module named webui.ui_i18n".
if _exc.name not in ("ui_i18n", "webui"):
raise
from webui.ui_i18n import (
LANGUAGE_CHOICES,
get_language,
load_language,
param_spec as localized_param_spec,
save_language,
set_language,
text as ui_text,
)
# 降噪:屏蔽 Gradio 内部触发、每次请求都会刷屏的 Starlette 弃用告警。
warnings.filterwarnings("ignore", message=r".*HTTP_422_UNPROCESSABLE.*")
def _silence_proactor_connection_reset():
"""Windows: swallow the benign `ConnectionResetError [WinError 10054]` that
asyncio's proactor prints when a browser/HTTP connection drops abruptly."""
if sys.platform != "win32":
return
try:
from asyncio.proactor_events import _ProactorBasePipeTransport
except Exception:
return
_orig = _ProactorBasePipeTransport._call_connection_lost
def _patched(self, exc):
if isinstance(exc, ConnectionResetError):
exc = None # peer reset == normal close; still run the cleanup below
try:
return _orig(self, exc)
except ConnectionResetError:
# _orig's own sock.shutdown() raced a peer reset (WinError 10054),
# which skips the rest of its cleanup — finish it here.
sock = getattr(self, "_sock", None)
if sock is not None:
try:
sock.close()
except OSError:
pass
self._sock = None
server = getattr(self, "_server", None)
if server is not None:
try:
server._detach()
except Exception:
pass
self._server = None
self._called_connection_lost = True
_ProactorBasePipeTransport._call_connection_lost = _patched
def _silence_h11_content_length_race():
"""Large uploads (e.g. a multi-minute reference wav) can have the browser
abort/replace an in-flight preview fetch for the same file while uvicorn
is still streaming its body; h11 then raises LocalProtocolError trying to
close out that half-sent response. It's a benign race — verified the
aborted request doesn't affect the server or any other request, the
browser's follow-up fetch of the same file completes fine — but uvicorn
logs it as a full "Exception in ASGI application" traceback per occurrence.
Drop just that one exception type from uvicorn's logger instead of hiding
all uvicorn.error output."""
try:
from h11 import LocalProtocolError
except Exception:
return
class _DropContentLengthRace(logging.Filter):
def filter(self, record):
exc = record.exc_info[1] if record.exc_info else None
msg = str(exc or "")
if isinstance(exc, LocalProtocolError) and "declared Content-Length" in msg:
return False
# uvicorn with httptools raises these RuntimeErrors on the same
# preview race: "shorter" when the browser aborts/replaces the
# fetch, "longer" when gradio sized Content-Length off a large
# upload still being written to disk and the file grew mid-stream.
# The request is already dead / the browser refetches; do not spam
# a full ASGI traceback for either direction.
if isinstance(exc, RuntimeError) and "than Content-Length" in msg:
return False
return True
logging.getLogger("uvicorn.error").addFilter(_DropContentLengthRace())
def _patch_gradio_render_config_race():
"""Gradio 6.19 上游竞态(gradio-app/gradio#9991 只冻结了 blocks、漏了 fns):
本页有 7 个 @gr.render 动态参数区,页面加载/模型切换时多个渲染事件并发,
一个事件在 get_config 里遍历 session 的 blocks/fns 字典的同时,另一个渲染
正往里注册组件,偶发 "RuntimeError: dictionary changed size during
iteration"(前端表现为该次渲染丢失/报错)。get_config 是纯只读操作,
撞上竞态时稍等重读即可收敛。"""
try:
BlocksConfig = gr.blocks.BlocksConfig
orig = BlocksConfig.get_config
except AttributeError:
return
def _get_config_retry(self, renderable=None):
for _ in range(10):
try:
return orig(self, renderable)
except RuntimeError:
time.sleep(0.02)
return orig(self, renderable)
BlocksConfig.get_config = _get_config_retry
_silence_proactor_connection_reset()
_silence_h11_content_length_race()
_patch_gradio_render_config_race()
def _t(zh, en, language=None, **values):
"""Select UI copy in the active language."""
return ui_text(zh, en, language=language, **values)
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(HERE)
# WebUI-local working dirs, kept next to this file and created on startup.
CONFIG_DIR = os.path.join(HERE, "configs")
OUTPUT_DIR = os.path.join(HERE, "output")
VOICE_DIR = os.path.join(HERE, "voice")
LOG_DIR = os.path.join(HERE, "logs")
for _d in (CONFIG_DIR, OUTPUT_DIR, VOICE_DIR, LOG_DIR):
os.makedirs(_d, exist_ok=True)
INITIAL_LANGUAGE = load_language()
PROMPTS_DIR = VOICE_DIR # built-in / reference voices
CATALOG_PATH = os.path.join(CONFIG_DIR, "models_catalog.json")
MODEL_PARAMS_PATH = os.path.join(CONFIG_DIR, "model_params.json")
REQUIRED_FILES_PATH = os.path.join(CONFIG_DIR, "required_files.json")
# Executable/name conventions differ per OS: Windows binaries carry a .exe suffix, POSIX ones don't.
EXE_SUFFIX = ".exe" if os.name == "nt" else ""
SERVER_EXE_NAME = "audiocpp_server" + EXE_SUFFIX
GGUF_EXE_NAME = "audiocpp_gguf" + EXE_SUFFIX
# The standalone server executable, named only in messages telling the user what
# may be holding the port.
SERVER_LAUNCHER = SERVER_EXE_NAME
def _cmake_cache_backend(bin_dir):
"""gpu/cpu for a build tree whose directory name doesn't say, read from its
CMakeCache.txt (GGML_CUDA:BOOL=ON). Returns None when there is no cache to
read — an installed tree, or a layout we don't recognize."""
# Walk up from the bin dir to find the build tree's CMakeCache.txt. Single-config
# generators put the exe in build/bin (cache one level up); multi-config ones
# (Visual Studio) nest it in build/bin/Release, so the cache sits two levels up.
cache = None
d = os.path.dirname(bin_dir)
for _ in range(4):
cand = os.path.join(d, "CMakeCache.txt")
if os.path.isfile(cand):
cache = cand
break
parent = os.path.dirname(d)
if parent == d:
break
d = parent
if cache is None:
return None
try:
with open(cache, encoding="utf-8", errors="replace") as fh:
for line in fh:
if line.startswith("GGML_CUDA:"):
return "gpu" if line.rstrip().upper().endswith("ON") else "cpu"
except OSError:
return None
return "cpu"
def _discover_dev_bin_dirs():
"""Locate from-source build outputs, newest-wins per backend.
Two layouts are in the wild. The one README documents is
build/<os>-<backend>-<type>/bin (windows-cuda-release, linux-cpu-release, …),
where the backend is in the directory name. A plain `cmake -B build` instead
lands in build/bin and says nothing about the backend, so that one is
classified from its CMakeCache."""
out = {}
for backend, keyword in (("gpu", "*cuda*"), ("cpu", "*cpu*")):
hits = sorted(d for d in glob.glob(os.path.join(PROJECT_ROOT, "build", keyword, "bin"))
if os.path.isfile(os.path.join(d, SERVER_EXE_NAME)))
if hits:
out[backend] = hits[-1]
# A plain `cmake -B build` lands in build/bin with single-config generators
# (Ninja, Makefiles); multi-config generators (Visual Studio, Xcode) nest the
# exe in a per-config subdir, so also look in build/bin/Release and .../Debug.
for plain in (os.path.join(PROJECT_ROOT, "build", "bin"),
os.path.join(PROJECT_ROOT, "build", "bin", "Release"),
os.path.join(PROJECT_ROOT, "build", "bin", "Debug")):
if not os.path.isfile(os.path.join(plain, SERVER_EXE_NAME)):
continue
backend = _cmake_cache_backend(plain)
# Only fill a backend the named-directory scan didn't already find, so an
# explicit build/linux-cuda-release still wins over a stale plain build/.
if backend and backend not in out:
out[backend] = plain
return out
DEV_BIN_DIRS = _discover_dev_bin_dirs()
def _dev_server_exe(backend):
d = DEV_BIN_DIRS.get(backend)
return os.path.join(d, SERVER_EXE_NAME) if d else ""
def _find_bundle_root():
"""Locate the root that holds models/ (and, when packaged, cpu/ gpu/ tools/).
Three layouts: a from-source dev tree (binaries under build/, models under
PROJECT_ROOT/models), the packaged bundle next to webui/, and webui/ copied
under the bundle for distribution. Override with AUDIOCPP_BUNDLE."""
env = os.environ.get("AUDIOCPP_BUNDLE")
if env:
return env
for c in (HERE, # webui.py directly in the bundle
PROJECT_ROOT, # webui/ shipped under the bundle
os.path.join(PROJECT_ROOT, "audiocpp-portable")):
if os.path.isdir(os.path.join(c, "gpu")) or os.path.isdir(os.path.join(c, "cpu")):
return c
if any(os.path.isfile(_dev_server_exe(b)) for b in DEV_BIN_DIRS):
return PROJECT_ROOT
return os.path.join(PROJECT_ROOT, "audiocpp-portable")
BUNDLE_ROOT = _find_bundle_root()
def _detect_backend():
"""Which bundle build (gpu/ or cpu/) to launch. AUDIOCPP_BACKEND=gpu|cuda|cpu
wins; otherwise auto-detect: gpu when an NVIDIA driver AND the gpu server
build are both present, else cpu (the server runs fine on the cpu backend,
just slower and with lower model coverage)."""
env = os.environ.get("AUDIOCPP_BACKEND", "").strip().lower()
if env in ("gpu", "cuda"):
return "gpu"
if env:
return env
if os.name == "nt":
has_nvidia = os.path.isfile(os.path.join(
os.environ.get("SystemRoot", r"C:\Windows"), "System32", "nvcuda.dll"))
else:
has_nvidia = shutil.which("nvidia-smi") is not None
if has_nvidia and (os.path.isfile(os.path.join(BUNDLE_ROOT, "gpu", SERVER_EXE_NAME))
or os.path.isfile(_dev_server_exe("gpu"))):
return "gpu"
if (os.path.isfile(os.path.join(BUNDLE_ROOT, "cpu", SERVER_EXE_NAME))
or os.path.isfile(_dev_server_exe("cpu"))):
return "cpu"
return "gpu"
BACKEND = _detect_backend()
# The server's own backend name: bin dirs are gpu/ vs cpu/, but the server takes
# cuda|cpu|vulkan|metal, and its config default is "cuda" — which a CPU-only
# build rejects at startup, so the temp config must always spell it out.
SERVER_BACKEND = "cuda" if BACKEND == "gpu" else BACKEND
SERVER_EXE = os.path.join(BUNDLE_ROOT, BACKEND, SERVER_EXE_NAME)
if not os.path.isfile(SERVER_EXE) and os.path.isfile(_dev_server_exe(BACKEND)):
SERVER_EXE = _dev_server_exe(BACKEND)
LOG_PATH = os.path.join(LOG_DIR, "audiocpp_server_webui.log")
LOAD_TIMEOUT = int(os.environ.get("AUDIOCPP_LOAD_TIMEOUT", "300"))
GGUF_TYPES = ("orig", "f16", "bf16", "q8_0", "q2_k", "q3_k", "q4_k", "q5_k", "q6_k")
# Only families with a model package spec can load the runtime GGUF produced by
# audiocpp_gguf; a safetensors file by itself is not evidence that the
# corresponding C++ loader supports GGUF. model_specs/<family>.json is the
# runtime's own list, so read it rather than tracking it by hand — the set grew
# from 15 to 33 families between 0.3 and 0.4 and a hand-copy just goes stale,
# telling users a supported model "does not support GGUF". The literal below is
# the 0.3-era fallback for bundles that ship no model_specs/ directory.
GGUF_NATIVE_FAMILIES_FALLBACK = frozenset({
"citrinet_asr",
"higgs_audio_stt",
"hviske_asr",
"index_tts2",
"irodori_tts",
"moss_tts_local",
"moss_tts_nano",
"nemotron_asr",
"omnivoice",
"qwen3_asr",
"qwen3_forced_aligner",
"qwen3_tts",
"supertonic",
"vibevoice_asr",
})
def _spec_families():
"""Families with a package spec on disk, or the fallback set if there is none."""
for root in (PROJECT_ROOT, BUNDLE_ROOT):
spec_dir = os.path.join(root, "model_specs")
if not os.path.isdir(spec_dir):
continue
families = set()
for name in os.listdir(spec_dir):
if not name.endswith(".json"):
continue
path = os.path.join(spec_dir, name)
try:
with open(path, "r", encoding="utf-8") as f:
family = json.load(f).get("family")
except (OSError, ValueError):
family = None
families.add(family or os.path.splitext(name)[0])
if families:
return frozenset(families)
return GGUF_NATIVE_FAMILIES_FALLBACK
GGUF_NATIVE_FAMILIES = _spec_families()
# These families have input layouts the WebUI can assemble without guessing.
# Other native-GGUF composite packages remain available through the converter
# CLI until their explicit multi-input layout is added here.
GGUF_SIMPLE_MODEL_FAMILIES = frozenset({
"higgs_audio_stt",
"hviske_asr",
"nemotron_asr",
"qwen3_asr",
"qwen3_forced_aligner",
"vibevoice_asr",
})
GGUF_WEBUI_CONVERTIBLE_FAMILIES = GGUF_SIMPLE_MODEL_FAMILIES | {"qwen3_tts"}
def _find_gguf_exe():
"""Find the converter in a development build or an integrated bundle.
Keep this separate from SERVER_EXE: developers normally run the executable
directly from their build tree's bin/, whereas portable users have it beside
the server binary under gpu/ or cpu/. Dev locations come from the same
DEV_BIN_DIRS scan the server uses, so non-Windows build-directory names
(linux-cuda-release, a plain build/bin, …) are covered too; the active
backend is tried first, then the other one.
"""
other = "cpu" if BACKEND == "gpu" else "gpu"
candidates = [
os.environ.get("AUDIOCPP_GGUF"),
*(os.path.join(DEV_BIN_DIRS[b], GGUF_EXE_NAME)
for b in (BACKEND, other) if b in DEV_BIN_DIRS),
os.path.join(BUNDLE_ROOT, BACKEND, GGUF_EXE_NAME),
os.path.join(BUNDLE_ROOT, "gpu", GGUF_EXE_NAME),
os.path.join(BUNDLE_ROOT, "cpu", GGUF_EXE_NAME),
os.path.join(PROJECT_ROOT, "audiocpp-portable", "gpu", GGUF_EXE_NAME),
os.path.join(PROJECT_ROOT, "audiocpp-portable", "cpu", GGUF_EXE_NAME),
]
seen = set()
for candidate in candidates:
if not candidate:
continue
candidate = os.path.normpath(candidate)
key = os.path.normcase(candidate)
if key in seen:
continue
seen.add(key)
if os.path.isfile(candidate):
return candidate
return None
# model_manager_webui.py downloads not-yet-installed models in the background. It
# wraps the upstream tools/model_manager.py CLI with resumable, Torch-free
# downloads (falling back to the upstream tool if the wrapper isn't present).
MODELS_ROOT = os.path.join(BUNDLE_ROOT, "models")
def _find_model_manager():
for c in (os.environ.get("AUDIOCPP_MODEL_MANAGER"),
os.path.join(HERE, "model_manager_webui.py"),
os.path.join(BUNDLE_ROOT, "webui", "model_manager_webui.py"),
os.path.join(PROJECT_ROOT, "webui", "model_manager_webui.py"),
os.path.join(BUNDLE_ROOT, "tools", "model_manager.py"),
os.path.join(PROJECT_ROOT, "tools", "model_manager.py")):
if c and os.path.isfile(c):
return c
return None
MODEL_MANAGER = _find_model_manager()
def _detect_vram_gb():
"""本机 NVIDIA 显卡显存总量(GB,多卡取最大);无 nvidia-smi/无 N 卡返回 None。
用于对照 catalog 条目的 min_vram_gb 估算值,提示“下载了也可能跑不动”。"""
try:
out = subprocess.run(
["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5)
vals = [float(line) for line in out.stdout.split() if line.strip()]
return round(max(vals) / 1024, 1) if vals else None
except Exception:
return None
LOCAL_VRAM_GB = _detect_vram_gb()
def _vram_shortfall(entry):
"""条目估算显存超过本机显存时返回 (需要GB, 本机GB),否则 None。"""
if BACKEND == "cpu":
return None # CPU 后端跑在系统内存里,显存对照不适用
need = entry.get("min_vram_gb")
if need and LOCAL_VRAM_GB and float(need) > LOCAL_VRAM_GB:
return float(need), LOCAL_VRAM_GB
return None
# TTS 语言下拉的语种集合。下拉首位会合成一个 ("Auto", "") 选项(空串=模型默认/
# 自动检测),所以这里不要再放字面量 "Auto"——带 lang_map 的家族会拒绝它,
# 且界面上会出现两个分不清的 Auto。
LANGS = ["", "english", "chinese", "french", "german", "italian",
"japanese", "korean", "portuguese", "russian", "spanish"]
# Which catalog task tokens each tab can drive. do_tts sends text + optional
# reference voice, which fits both plain TTS ("tts") and voice cloning ("clon").
TTS_TASKS = ("tts", "clon")
ASR_TASKS = ("asr",)
GEN_TASKS = ("gen",) # music/SFX generation, served via the generic /v1/tasks/run route
VC_TASKS = ("vc", "svc", "s2s") # 声音转换:源音频 + 目标音色,走 /v1/tasks/run
SEP_TASKS = ("sep",) # 音源分离:多轨 named_audio_outputs
ANALYZE_TASKS = ("vad", "diar", "align") # 音频分析:segments / speaker_turns / words
VDES_TASKS = ("vdes",) # 声音设计:文字 + 音色描述,走 /v1/audio/speech
# Per-family behavior for the TTS tab, keyed by catalog `family`. This is how we
# cope with each model wanting different input formats / options: the shared UI
# stays simple, and each family gets its own hint, optional text transform, and
# default request options. A catalog entry can also carry its own `input_hint` /
# `default_options` to override the family profile without editing this file, and
# the "高级参数 (JSON)" box lets you pass ANY model-specific option at run time.
MODEL_PROFILES = {
"vibevoice": {
"input_hint": (
"**VibeVoice** 多说话人脚本:每行 `Speaker N: 内容`(纯文字自动包装);"
"多音色用高级参数 `voice_samples`,不要传参考音频。"),
"wrap_speaker_script": True,
# VibeVoice has no internal text chunking. VRAM is bounded since the
# layerwise-prefill/gallocr decode-graph fix, so chunks no longer need to be
# tiny; 600 chars keeps each chunk's generation inside the default
# max_tokens=1200 budget (~1.5 frames/CJK char) and the KV capacity tier
# <= 2048 (~7.1 GB peak on 8 GB GPUs).
"chunk_chars": 600,
# Advanced-parameter widgets are sent only when the user edits them, so an
# untouched num_inference_steps control (displayed 10) silently falls back to
# the model config's ddpm_num_inference_steps=20 server-side. Send 10 (the
# official demo default) explicitly; widgets/JSON still override.
"default_options": {"num_inference_steps": 10},
},
"voxcpm2": {
"input_hint": (
"**VoxCPM2** 声音克隆:上传干净的单人参考音色并填『参考文本』;长文本自动分段;"
"支持⚡流式生成(生成模式选『流式』,边生成边播放)。"),
# 服务端流式(server mode=streaming + /v1/audio/speech stream_format=sse):
# delta 事件是 base64 的裸 PCM16,不带采样率——只能客户端自带。取值来自模型
# config.json 的 audio_vae.out_sample_rate(48000)。流式生成要求显式
# retry_badcase=false(generator.cpp:1259,重试逻辑与已推流的音频冲突)。
"supports_streaming": True,
"stream_sample_rate": 48000,
# 8G 4060 实测标定(2026-07-05,CLI --log + nvidia-smi 抓峰值):
# - 峰值 ≈ 固定基线 + audiovae 解码图。基线(权重+KV+生成图)与文本长度/
# max_tokens 无关:max_tokens 128/300/1200 峰值都是 7634MiB(生成会提前
# 遇停止符,解码图按实际帧数而非 max_tokens 建)。所以 chunk_chars / max_tokens
# 都压不动基线——真正的杠杆是**权重量化**(catalog session_options 里已设
# voxcpm2.weight_type=q8_0:bf16 权重 4.6G,峰值 7634→5549MiB)。
# - audiovae 解码图仍随每段音频长度涨:容量取「≥latent_frames 的最小 2 的幂」
# (audiovae.cpp:724),~1.28MB/帧。q8_0 下实测 ~53 字→cap512→6366MiB,
# ~106 字→cap1024→7722MiB(偏紧)。所以每段要短:60 字→cap≤512→~6.4G,
# 留 ~1.7G 给其它占 GPU 的程序。想少接缝可上调,但注意 8G 边界。
"chunk_chars": 60,
},
"qwen3_tts": {
"input_hint": (
"**Qwen3-TTS** 声音克隆:建议上传参考音色并填『参考文本』,否则可能提前截断。"),
},
"pocket_tts": {
"input_hint": "**PocketTTS**:必须提供参考音色(上传/录制/内置)。",
},
"chatterbox": {
"input_hint": (
"**Chatterbox** 声音克隆:必须提供参考音色;语言仅支持英/西/法/德/意/葡/韩"
"(**无中文**),留空=英语。"),
# Chatterbox validates against 2-letter ISO codes (no zh/ja/ru, no auto),
# so the shared dropdown's friendly names are translated here; names not
# listed are genuinely unsupported by the model and rejected up front.
"lang_map": {
"english": "en", "spanish": "es", "french": "fr", "german": "de",
"italian": "it", "portuguese": "pt", "korean": "ko",
},
},
"qwen3_asr": {
# Encoder cap: max_source_positions=1500 tokens at 13 tokens/second
# (qwen3_asr_audio_encoder_token_count) -> ~115 s of audio per request.
# 但 8G 卡上先撞显存:thinker prefill 图随时长超线性膨胀(4060 8G 实测
# 65s 可过、70s 要 13.2GB、75s 要 14.6GB),所以客户端按 max_input_seconds
# =60s 在静音处切段逐段转写再拼接,顺带解决 70~115s 音频原本的 OOM。
"input_hint": ("**Qwen3-ASR**:长音频自动分段转写;"
"语种/上下文/对话模式见『转写选项』。"),
"max_input_seconds": 60,
},
"voxtral_realtime": {
"input_hint": (
"**Voxtral Mini 4B Realtime**:自动语种转写;支持⚡流式转写,"
"勾选后按模型原生音频分块边转边出字;不输出时间戳。"),
"supports_streaming": True,
},
"ace_step": {
"input_hint": (
"**ACE-Step** 音乐生成/编辑:提示词写风格/乐器/情绪(英文最佳),可填歌词。"
"编辑类 route 需上传源音频并建议先点『🔍 分析源音频』;参数详解见 webui/README.zh.md。"),
# 原版 turbo UI 默认 shift=3.0(C++ 端默认 1.0,仅 remix/extract 路由自带 3.0)。
# 控件只发用户改过的项,所以这里显式发送,保证 UI 显示值=实际值。
"default_options": {"shift": 3.0},
},
"stable_audio": {
"input_hint": (
"**Stable Audio**:提示词**仅英文**,不用歌词;"
"上传源音频可做 init/inpaint(高级参数选 audio_input_kind)。"),
},
"heartmula": {
"input_hint": (
"**HeartMuLa**:高级参数 `tags` 必填(逗号分隔),『歌词』填唱词;"
"峰值显存 ~25G,8G 卡跑不动。"),
},
"vevo2": {
"input_hint": (
"**Vevo2**:源语音 + 目标音色,默认只换音色(保留说话风格);"
"风格转换类 route 需 JSON 补 `style_ref` 等,详见 webui/README.zh.md。"
"长音频自动分段,参考音色自动截 ≤10s。"),
# 8G 4060 实测标定(2026-07-04/07-05):FM 图一次建图,序列长度 =
# 目标音色(prompt) + 源(target) 帧数(均 50fps,见 fm.cpp:782 cond_frames =
# prompt_frames + target_frames)。cond≈25s(源15s+参考10s)就把 8G 吃满、
# 峰值溢出到共享显存(idle 已占 7.9G/8G);cond≈20s 勉强、30s 必炸。所以按
# (预算 − 参考时长) 反推每段源时长,并把参考截到 ≤ ref_max,令 cond 稳定
# 落在预算内;各段源仍补零到等长以复用缓存图。带 target_text 的编辑类 route
# 不适合分段(会把文本对不上),这类输入本身也放不进显存。
# 权重加载后约占 5.5-6G,留给图的只有 ~2G;18s 源(cond≈21) 只剩 ~350MB,
# 所以预算取 16(cond≈16,留 ~0.8-1.2G 给峰值/其它占用 GPU 的程序)。
"vc_chunk_seconds": 15, # 每段源时长上限(会被显存预算进一步压低)
"vc_fm_budget_seconds": 16, # 参考 + 每段源 的总时长预算(8G 安全线)
"vc_ref_max_seconds": 10, # 目标音色参考截断上限
"vc_min_chunk_seconds": 6, # 每段源时长下限,避免切得过碎
},
"seed_vc": {
"input_hint": (
"**Seed-VC**:源语音 + 目标音色参考(几秒干净人声);"
"默认 v2 路线,v1 旧路线在高级参数切换。"),
},
"miocodec": {
"input_hint": "**MioCodec**:codec 重建式转换——源提供内容,参考提供音色。",
},
"htdemucs": {
"input_hint": "**HTDemucs**:输出 drums / bass / other / vocals 四轨。",
},
"mel_band_roformer": {
"input_hint": "**Mel-Band RoFormer**:输出人声轨 + 伴奏轨。",
},
"nemotron_asr": {
"input_hint": ("**Nemotron ASR**:100+ 语种;支持⚡流式转写"
"(勾选后边转边出字,长音频不用干等)。"),
"supports_streaming": True,
},
"higgs_audio_stt": {
"input_hint": "**Higgs Audio STT**:支持⚡流式转写(勾选后边转边出字)。",
"supports_streaming": True,
},
"vibevoice_asr": {
"input_hint": "**VibeVoice-ASR**:离线转写,支持自动语种和说话人分段。",
},
"silero_vad": {
"input_hint": "**Silero VAD**:检测音频中的语音段。",
},
"marblenet_vad": {
"input_hint": "**MarbleNet VAD**:帧级语音活动检测,输出语音段列表。",
},
"sortformer_diar": {
"input_hint": "**Sortformer**:说话人分离(谁在何时说话,≤4 人)。",
},
"qwen3_forced_aligner": {
"input_hint": "**Qwen3 强制对齐**:『对齐文本』填音频原文,输出逐词时间戳(≤115 秒)。",
},
"index_tts2": {
"input_hint": (
"**IndexTTS2** 中/英声音克隆:**必须**提供参考音色(上传/录制/内置);"
"情感控制在『高级参数』:emotion_text 填情绪参考文本(如“你吓死我了!”)+ "
"emotion_alpha 调强度,或 use_emotion_text 从朗读文本自动推断。"),
# 模型只认 zh/en 语种标签(docs/tts.md),共享下拉的其它语言直接拒绝。
"lang_map": {"chinese": "zh", "english": "en"},
"require_voice": True,
},
"irodori_tts": {
"input_hint": (
"**Irodori-TTS**(日语):默认无参考直接生成;上传参考音色即自动切换克隆模式。"
"VoiceDesign 版走『声音设计』标签页,用日语 caption 描述音色。"),
"lang_map": {"japanese": "ja"},
# 会话默认 no_ref=true(忽略参考音频),带参考时必须显式关掉才走克隆路径。
"no_ref_toggle": True,
# 声音设计标签页的『音色描述』对本家族要发 options.caption(qwen3_tts 走
# 服务器的 instructions→instruct 映射,irodori session 只读 caption)。
"vdes_option_key": "caption",
},
"moss_tts_local": {
"input_hint": (
"**MOSS-TTS-Local**:纯文本直接生成;克隆时上传参考音色并尽量填『参考文本』。"
"输出 48kHz 立体声;语言下拉可留空(自动)或选择语言作为提示。"),
},
"moss_tts_nano": {
"input_hint": (
"**MOSS-TTS-Nano** 100M 轻量:无参考=文本续写式生成(音色随机);"
"上传参考音色即声音克隆。"),
},
"supertonic": {
"input_hint": (
"**Supertonic 3** 预置音色多语种 TTS:在『高级参数』选 voice(M1-M5 男声 / "
"F1-F5 女声)和语速 speaking_rate;支持⚡流式生成;"
"**不支持**参考音频克隆(**无中文**)。"),
# Supertonic 的 C++ 会话支持 mode=streaming,并通过 pull events 按文本段
# 输出音频。SSE delta 是不带采样率的裸 PCM16,客户端需使用模型的 44.1kHz。
"supports_streaming": True,
"stream_sample_rate": 44100,
# 模型收 ISO 语种码(en/ko/ja/...),共享下拉的友好名在此转换;chinese 不在
# 支持列表所以不映射(选中会被 resolve_language 拒绝并提示)。
"lang_map": {
"english": "en", "french": "fr", "german": "de", "italian": "it",
"japanese": "ja", "korean": "ko", "portuguese": "pt",
"russian": "ru", "spanish": "es",
},
},
}
# Concise English hints. The Chinese catalog/profile copy remains the detailed
# reference; English intentionally keeps these notes short so model cards stay
# aligned on narrower screens.
MODEL_HINTS_EN = {
"vibevoice": "**VibeVoice**: use one `Speaker N:` line per speaker. Use `voice_samples` for multiple voices.",
"voxcpm2": "**VoxCPM2**: upload a clean voice reference and its transcript. Streaming is supported.",
"qwen3_tts": "**Qwen3-TTS**: a voice reference and matching transcript are recommended.",
"pocket_tts": "**PocketTTS** requires a voice reference.",
"chatterbox": "**Chatterbox** requires a voice reference and supports en/es/fr/de/it/pt/ko.",
"qwen3_asr": "**Qwen3-ASR** automatically splits long audio. Language and context are optional.",
"voxtral_realtime": "**Voxtral Mini 4B Realtime** auto-detects language and supports streaming transcription. Timestamps are not exposed.",
"ace_step": "**ACE-Step**: describe style, instruments and mood. Editing routes require source audio.",
"stable_audio": "**Stable Audio** accepts English prompts only. Source audio enables init/inpaint.",
"heartmula": "**HeartMuLa** requires `tags` and lyrics. Estimated peak VRAM is about 25 GB.",
"vevo2": "**Vevo2**: provide source audio and a target voice. Long audio is split automatically.",
"seed_vc": "**Seed-VC**: provide source audio and a clean target voice reference.",
"miocodec": "**MioCodec** reconstructs source content with the reference voice.",
"htdemucs": "**HTDemucs** outputs drums, bass, other and vocals.",
"mel_band_roformer": "**Mel-Band RoFormer** outputs vocals and accompaniment.",
"nemotron_asr": "**Nemotron ASR** supports 100+ languages and streaming transcription.",
"higgs_audio_stt": "**Higgs Audio STT** supports streaming transcription.",
"vibevoice_asr": "**VibeVoice-ASR** uses offline transcription with automatic language and speaker segmentation.",
"silero_vad": "**Silero VAD** detects speech segments.",
"marblenet_vad": "**MarbleNet VAD** detects frame-level speech activity.",
"sortformer_diar": "**Sortformer** identifies who spoke when (up to four speakers).",
"qwen3_forced_aligner": "**Qwen3 Forced Aligner** requires the source transcript and returns word timestamps.",
"index_tts2": "**IndexTTS2** (zh/en) requires a voice reference; emotion controls live in advanced parameters.",
"irodori_tts": "**Irodori-TTS** (Japanese) works without a reference; uploading one enables voice cloning.",
"moss_tts_local": "**MOSS-TTS-Local**: plain text works; add a voice reference and its transcript to clone. 48 kHz stereo output.",
"moss_tts_nano": "**MOSS-TTS-Nano** 100M: continuation mode without a reference, voice clone with one.",
"supertonic": "**Supertonic 3**: preset voices and streaming are supported; no voice cloning, no Chinese.",
}
# Qwen3-ASR 可强制的语种(模型 config.json 的 support_languages,prompt 里用英文名;
# 留空/Auto = 自动检测)。citrinet 等其它 ASR 族忽略该字段。
QWEN3_ASR_LANGUAGES = [
("中文", "Chinese"), ("英语", "English"), ("粤语", "Cantonese"),
("日语", "Japanese"), ("韩语", "Korean"), ("俄语", "Russian"),
("法语", "French"), ("德语", "German"), ("西班牙语", "Spanish"),
("葡萄牙语", "Portuguese"), ("意大利语", "Italian"), ("阿拉伯语", "Arabic"),
("印尼语", "Indonesian"), ("泰语", "Thai"), ("越南语", "Vietnamese"),
("土耳其语", "Turkish"), ("印地语", "Hindi"), ("马来语", "Malay"),
("荷兰语", "Dutch"), ("瑞典语", "Swedish"), ("丹麦语", "Danish"),
("芬兰语", "Finnish"), ("波兰语", "Polish"), ("捷克语", "Czech"),
("菲律宾语", "Filipino"), ("波斯语", "Persian"), ("希腊语", "Greek"),
("罗马尼亚语", "Romanian"), ("匈牙利语", "Hungarian"), ("马其顿语", "Macedonian"),
]
DEFAULT_PROFILE = {"input_hint": "", "input_hint_en": "", "wrap_speaker_script": False,
"default_options": {},
# C++ 会话实现了 IStreamingVoiceTaskSession 的家族(server 需以
# mode=streaming 加载才走流式路由);见 registry 各家族 loader。
"supports_streaming": False,
# Families with internal chunking handle long text fine; the client
# split only exists to bound each HTTP request (no 900 s timeout)
# and surface progress, so the budget can stay coarse.
"chunk_chars": 1000}
# One "Speaker N:" line (any speaker index) is enough to treat text as a script.
_SPEAKER_RE = re.compile(r"^\s*Speaker\s+\d+\s*:", re.IGNORECASE | re.MULTILINE)
def profile_for(entry):
prof = {**DEFAULT_PROFILE, **MODEL_PROFILES.get(entry.get("family", ""), {})}
prof["input_hint_en"] = MODEL_HINTS_EN.get(entry.get("family", ""), "")
if entry.get("input_hint"):
prof["input_hint"] = entry["input_hint"]
if entry.get("input_hint_en"):
prof["input_hint_en"] = entry["input_hint_en"]
if entry.get("default_options"):
prof["default_options"] = {**prof.get("default_options", {}), **entry["default_options"]}
return prof
def supports_streaming(model_id):
entry = catalog_by_id(model_id) if model_id else None
return bool(entry) and bool(profile_for(entry).get("supports_streaming"))
def asr_stream_update(model_id):
"""Reset and show the ASR streaming toggle for the selected model."""
return gr.update(visible=supports_streaming(model_id), value=False)
def model_hint_for(model_id, language=None):
entry = catalog_by_id(model_id) if model_id else None
if not entry:
return ""
language = language or get_language()
prof = profile_for(entry)
hint = _t(prof["input_hint"], prof["input_hint_en"], language)
short = _vram_shortfall(entry)
if short:
warn = _t(
"⚠️ **显存提示**:该模型估算需 **≥{need:g}G** 显存,本机为 **{local:g}G**,运行可能很慢{tail}",
"⚠️ **VRAM**: estimated **≥{need:g} GB**, detected **{local:g} GB**. Performance may be poor{tail}",
language, need=short[0], local=short[1],
tail=(_t(",请谨慎下载。", ".", language) if not entry["installed"] else
_t("。", ".", language)))
hint = warn + ("\n\n" + hint if hint else "")
return hint
def resolve_language(prof, language):
"""Translate the shared language dropdown into what the selected family
expects. Most families (Qwen3-TTS, VibeVoice, …) take the UI's friendly
names as-is, so they have no `lang_map` and the value passes through. A
family with a restricted language set (Chatterbox: 2-letter ISO codes, no
Chinese/Japanese/Russian, no auto-detect) supplies a `lang_map`; its names
are converted and anything it can't do — including "Auto" — is rejected here
with an actionable message instead of a raw server 500."""
lang = (language or "").strip()
lang_map = prof.get("lang_map")
if not lang_map:
return lang
if not lang:
return "" # 留空 = 用模型默认
code = lang_map.get(lang.lower())
if code is not None:
return code
raise gr.Error(_t(
"所选模型不支持语言「{selected}」。请改选:{supported},或选 Auto 使用模型默认。",
"This model does not support {selected}. Choose {supported}, or choose Auto for the model default.",
selected=language, supported=" / ".join(lang_map)))
def _as_speaker_script(text):
"""Wrap plain text into `Speaker 0:` lines when it isn't already a script."""
if _SPEAKER_RE.search(text or ""):
return text
lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
return "\n".join(f"Speaker 0: {ln}" for ln in lines) if lines else text
def _vibevoice_punctuate_script(text):
"""VibeVoice is much less stable on tiny lines without sentence punctuation."""
out = []
for raw in (text or "").splitlines():
line = raw.strip()
if not line:
continue
m = _SPEAKER_LINE_RE.match(line)
if not m:
out.append(line)
continue
prefix, body = m.group(1), m.group(2).strip()
if body and body[-1] not in "。!?!?.,;;:":
body += "。"
out.append(f"{prefix} {body}" if body else prefix)
return "\n".join(out) if out else text
def _vibevoice_text_max_tokens(chunk, ui_default=1200):
"""Estimate VibeVoice speech tokens from text, not reference-prompt length."""
body = re.sub(r"(?im)^\s*Speaker\s+\d+\s*:\s*", "", chunk or "")
cjk = sum(1 for ch in body if "\u4e00" <= ch <= "\u9fff")
non_space = sum(1 for ch in body if not ch.isspace())
non_cjk = max(0, non_space - cjk)
pauses = sum(1 for ch in body if ch in ",。!?;:,.!?;:")
estimate = int(cjk * 2.2 + non_cjk * 0.45 + pauses * 3.0 + 8)
return max(18, min(int(ui_default), estimate))
# VibeVoice 1.5B 对超短脚本从第 1 帧起整段胡言乱语——模型级缺陷(长播客数据训练,
# 短文本 OOD),与参考音色/CFG/扩散步数/中英文/seed 全部无关(2026-07-08 A/B+ASR
# 转写实测:27 字必炸、40 字逐字正确;按上面估算公式 69 token 炸 / 87 token 过)。
# 低于阈值直接拦截;生成出来只会是垃圾,调参数救不了。
_VIBEVOICE_MIN_EST_TOKENS = 85
def _merge_short_vibevoice_tail(chunks):
"""分段时留下的过短尾段同样会胡言乱语:并回前一段。前段最多超预算几十字,
仍在 max_tokens=1200 封顶的显存包络内。"""
while len(chunks) > 1 and (
_vibevoice_text_max_tokens(chunks[-1]) < _VIBEVOICE_MIN_EST_TOKENS):
tail = chunks.pop()
chunks[-1] = chunks[-1] + "\n" + tail
return chunks
# --- client-side long-text chunking ------------------------------------------
# Long text is synthesized as one HTTP request per chunk and concatenated here.
# That keeps every request bounded (no 900 s timeout, works for families without
# internal chunking) and lets the UI show real per-chunk progress.
_SPEAKER_LINE_RE = re.compile(r"^\s*(Speaker\s+\d+\s*:)\s*(.*)$", re.IGNORECASE)
_SENTENCE_RE = re.compile(r"[^。!?!?;;…]*[。!?!?;;…]+|[^。!?!?;;…]+$")
def _split_long_line(line, budget):
"""Split one overlong line at sentence ends into pieces of <= budget chars,
re-attaching its `Speaker N:` prefix (if any) to every piece."""
m = _SPEAKER_LINE_RE.match(line)
prefix, body = (m.group(1) + " ", m.group(2)) if m else ("", line.strip())
pieces, cur = [], ""
for sent in _SENTENCE_RE.findall(body):
if cur and len(cur) + len(sent) > budget:
pieces.append(prefix + cur)
cur = ""
cur += sent
if cur:
pieces.append(prefix + cur)
return pieces or [line]
def _split_tts_chunks(text, budget):
"""Group non-empty lines into chunks of <= budget chars. A line is never
split across chunks unless it alone exceeds the budget (then it is split at
sentence boundaries). Returns a list of chunk strings."""
units = []
for ln in (text or "").splitlines():
if not ln.strip():
continue
units.extend(_split_long_line(ln, budget) if len(ln) > budget else [ln])
chunks, cur, cur_len = [], [], 0
for unit in units:
sep = 1 if cur else 0 # the "\n" join separator counts toward the budget
if cur and cur_len + sep + len(unit) > budget:
chunks.append("\n".join(cur))
cur, cur_len, sep = [], 0, 0
cur.append(unit)
cur_len += sep + len(unit)
if cur:
chunks.append("\n".join(cur))
return chunks or ([text] if (text or "").strip() else [])
def _concat_wavs(blobs, out_path, keep_ratios=None):
"""Concatenate same-format WAV byte blobs into one file at out_path.
keep_ratios[i]:每段只保留前一部分(分段转换把源补零到等长后,
按有效占比截掉对应输出的尾部静音)。"""
params, frames = None, []
for i, blob in enumerate(blobs):
with wave.open(io.BytesIO(blob)) as w:
fmt = (w.getnchannels(), w.getsampwidth(), w.getframerate())
if params is None:
params = fmt
elif fmt != params:
raise gr.Error(_t("分段音频格式不一致:{left} != {right}",
"Audio chunk formats differ: {left} != {right}",
left=fmt, right=params))
n = w.getnframes()
if keep_ratios is not None:
n = max(1, min(n, int(round(n * keep_ratios[i]))))
frames.append(w.readframes(n))
with wave.open(out_path, "wb") as w:
w.setnchannels(params[0])
w.setsampwidth(params[1])
w.setframerate(params[2])
for data in frames:
w.writeframes(data)
def _audio_duration_seconds(path):
"""Duration of a local audio file, or None when not measurable. wave covers
WAV, i.e. Gradio mic recordings and the typical uploads here; other formats
just skip the duration note instead of failing the request."""
try:
with wave.open(path, "rb") as w:
rate = w.getframerate()
return (w.getnframes() / float(rate)) if rate else None
except Exception:
return None
# 已转码文件缓存:gradio 的临时路径按内容哈希命名,同一上传重复运行不重复转码。
_WAV_CACHE = {}
def _find_ffmpeg():
"""转码用的 ffmpeg:随 webui 分发的 ffmpeg(Windows 下为 ffmpeg.exe)优先,其次 PATH。"""
bundled = os.path.join(HERE, "ffmpeg" + EXE_SUFFIX)
if os.path.exists(bundled):
return bundled
found = shutil.which("ffmpeg")
if not found:
raise gr.Error(_t("找不到 ffmpeg,无法转码非 WAV 音频。",
"ffmpeg was not found; non-WAV audio cannot be converted."))
return found
def _ensure_ascii_path(path):
"""若路径含非 ASCII 字符,复制到纯 ASCII 临时文件。
C++ server 在 Windows 上无法打开含中文等字符的路径。"""
try:
path.encode("ascii")
return path
except UnicodeEncodeError:
pass
fd, out = tempfile.mkstemp(prefix="audiocpp_asc_", suffix=".wav")
os.close(fd)
shutil.copy2(path, out)
return out
def _wait_file_stable(path, timeout=8.0, interval=0.08, stable_ticks=3):
"""Wait until a just-uploaded/recorded file stops changing on disk.
On Windows, the browser can start/restart audio preview fetches while Gradio
is still replacing a large temp file. Waiting for a few equal size samples