multimodalart HF Staff commited on
Commit
6d2c0ba
·
verified ·
1 Parent(s): 282263e

Upload folder using huggingface_hub

Browse files
Files changed (6) hide show
  1. .gitattributes +1 -0
  2. README.md +23 -7
  3. app.py +334 -0
  4. requirements.txt +46 -0
  5. samples/input_en.wav +0 -0
  6. samples/input_zh.wav +3 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ samples/input_zh.wav filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,29 @@
1
  ---
2
- title: Index Echo S2st 2b
3
- emoji: 🦀
4
- colorFrom: purple
5
- colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.29.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Index-Echo-S2ST-2B
3
+ emoji: 🎙️
4
+ colorFrom: pink
5
+ colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.29.0
 
8
  app_file: app.py
9
+ short_description: Voice-preserving speech dubbing across zh/en/es/ja
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 1h
12
  ---
13
 
14
+ # Index-Echo-S2ST-2B — voice-preserving speech dubbing
15
+
16
+ Speak in **Chinese or English** and get the same sentence back in **English,
17
+ Spanish, Japanese or Chinese** — in your own voice. The model ([IndexTeam /
18
+ Index-Echo-S2ST-2B](https://huggingface.co/IndexTeam/Index-Echo-S2ST-2B),
19
+ Apache-2.0) chains a 2B speech-translation LM → a ~25M lang-conditioned
20
+ Hidden2CVMapper → CosyVoice3 0.5B, with the source audio used as the voice
21
+ prompt so the speaker's timbre is preserved.
22
+
23
+ Six dubbing directions are supported: zh→en/es/ja and en→zh/es/ja. The source
24
+ language is detected automatically from the ST transcript (CJK characters ⇒
25
+ Chinese source, otherwise English). Dubbing into the same language as the
26
+ source is not supported. Input audio must be ≤ 30 s.
27
+
28
+ The sample inputs under `samples/` are the model authors' own verification
29
+ samples (Apache-2.0).
app.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio demo for IndexTeam/Index-Echo-S2ST-2B: voice-preserving speech-to-speech dubbing.
2
+
3
+ Pipeline: 2B speech-translation LM (ST LM, frozen) -> ~25M Hidden2CVMapper
4
+ (lang-conditioned) -> CosyVoice3 0.5B (frozen, source audio as voice prompt).
5
+ 6 directions: zh->en/es/ja + en->zh/es/ja. Source language is auto-detected
6
+ from the ST transcript (CJK chars => zh source, else en source).
7
+
8
+ Disk strategy: the raw snapshot is ~17 GB and the ZeroGPU weight pack adds
9
+ ~14 GB on top; each weight blob is unlinked from disk as soon as its consumer
10
+ has read it into RAM (safe on Linux: open mmaps and even fully-read files
11
+ keep working after unlink), so the pack never overlaps with the raw snapshot.
12
+ """
13
+ import spaces # MUST come before any torch / CUDA-touching import
14
+
15
+ import os
16
+ import sys
17
+ import time
18
+
19
+ import gradio as gr
20
+ import torch
21
+ from huggingface_hub import snapshot_download
22
+
23
+ REPO_ID = "IndexTeam/Index-Echo-S2ST-2B"
24
+ HERE = os.path.dirname(os.path.abspath(__file__))
25
+ MODEL_DIR = os.path.join(HERE, "dubbing_2b_fulldir_cv3")
26
+
27
+ # ---- interposition: unlink weight blobs once loaded into RAM --------------
28
+ _CONSUMED = set()
29
+
30
+
31
+ def _unlink(path):
32
+ """Delete a fully-read weight blob inside MODEL_DIR; mappings stay valid."""
33
+ if not os.path.realpath(path).startswith(os.path.realpath(MODEL_DIR) + os.sep):
34
+ return
35
+ try:
36
+ size_gb = os.path.getsize(path) / 1e9
37
+ os.unlink(path)
38
+ _CONSUMED.add(path)
39
+ print(f"[app] freed {os.path.basename(path)} ({size_gb:.2f} GB)", flush=True)
40
+ except OSError:
41
+ pass
42
+
43
+
44
+ def _install_progressive_cleanup():
45
+ """Patch the loaders the vendored pipeline uses so each safetensors/torch
46
+ blob is unlinked right after its contents reach RAM."""
47
+ import safetensors.torch as st
48
+
49
+ _orig_load_file = st.load_file
50
+
51
+ def load_file_and_unlink(filename, **kw):
52
+ out = _orig_load_file(filename, **kw)
53
+ if isinstance(filename, str) and filename.endswith(".safetensors"):
54
+ _unlink(filename)
55
+ return out
56
+
57
+ st.load_file = load_file_and_unlink
58
+ # code/stlm/stlm_api.py, code/stlm/audio_model.py and the CV3 model's
59
+ # _load_sd do `from safetensors.torch import load_file`, i.e. bind a fresh
60
+ # reference at import time; patching the module attr before those imports
61
+ # run is enough because they happen later, inside DubPipeline.__init__.
62
+
63
+ _orig_torch_load = torch.load
64
+
65
+ def torch_load_and_unlink(f, *a, **kw):
66
+ out = _orig_torch_load(f, *a, **kw)
67
+ if isinstance(f, str) and f.endswith(".pt"):
68
+ _unlink(f)
69
+ return out
70
+
71
+ torch.load = torch_load_and_unlink
72
+
73
+ # transformers keeps the mmap open on from_pretrained safetensors blobs
74
+ # (stlm_llm 4.55 GB via AutoModelForCausalLM, sharded in this package;
75
+ # CosyVoice-BlankEN 1 GB via Qwen2ForCausalLM). Unlink every safetensors
76
+ # blob in the loaded dir right after the call returns — the open mappings
77
+ # stay valid on Linux.
78
+ from transformers import PreTrainedModel
79
+
80
+ _orig_from_pretrained = PreTrainedModel.from_pretrained.__func__
81
+
82
+ def from_pretrained_and_unlink(cls, path, *a, **kw):
83
+ out = _orig_from_pretrained(cls, path, *a, **kw)
84
+ try:
85
+ if os.path.isdir(str(path)):
86
+ for name in sorted(os.listdir(path)):
87
+ if name.endswith(".safetensors"):
88
+ _unlink(os.path.join(path, name))
89
+ except OSError:
90
+ pass
91
+ return out
92
+
93
+ PreTrainedModel.from_pretrained = classmethod(from_pretrained_and_unlink)
94
+
95
+
96
+ # Set to True as the very first statement of the @spaces.GPU function body,
97
+ # i.e. only inside the forked GPU worker where real CUDA exists.
98
+ _REAL_CUDA = [False]
99
+
100
+
101
+ def _install_torchaudio_save_fallback():
102
+ """torchaudio 2.11 delegates .save() to torchcodec (an FFmpeg-bound package
103
+ that is not installed here), so the vendored pipeline's
104
+ `torchaudio.save(out_wav, w, sr)` in code/tts/pipeline.py dies with
105
+ "ImportError: TorchCodec is required for save_with_torchcodec".
106
+ soundfile is installed (and is what the pipeline uses to *read* audio), so
107
+ redirect .save() to it. Called from inside @spaces.GPU only, to keep the
108
+ parent process free of avoidable library mutation."""
109
+ import numpy as np
110
+ import soundfile as sf
111
+ import torchaudio
112
+
113
+ def save(uri, src, sample_rate, **kw):
114
+ if hasattr(src, "detach"):
115
+ src = src.detach().cpu()
116
+ arr = np.asarray(src.numpy() if hasattr(src, "numpy") else src)
117
+ if arr.ndim == 2:
118
+ arr = arr.T # (channels, T) -> (T, channels) for soundfile
119
+ sf.write(uri, arr, int(sample_rate))
120
+
121
+ torchaudio.save = save
122
+ print("[app] torchaudio.save -> soundfile fallback installed", flush=True)
123
+
124
+
125
+ def _install_zerogpu_stream_shim():
126
+ """CosyVoice3Model.__init__ builds a side stream:
127
+ `torch.cuda.stream(torch.cuda.Stream(self.device)) if torch.cuda.is_available()`
128
+ ZeroGPU fakes is_available() -> True on the *boot* CPU, where there is no
129
+ GPU, so constructing a real stream there is a trap twice over:
130
+
131
+ 1. it reaches the real driver and raises "no CUDA-capable device"; and
132
+ 2. worse, the real path calls C++ getStreamFromPool ->
133
+ c10::cuda::device_count(), which caches 0 devices in a process-global
134
+ static. ZeroGPU forks its GPU worker from this process, so the child
135
+ inherits the cached 0 and torch.init() dies with
136
+ "No CUDA GPUs are available" before the user function ever runs.
137
+
138
+ So the parent must NEVER construct a real stream. Here torch.cuda.Stream
139
+ stays a real CLASS (transformers annotates `torch.cuda.Stream | None`, so a
140
+ function would break PEP 604 unions) but instantiation yields an inert
141
+ CPU-side stand-in until _REAL_CUDA is set inside the @spaces.GPU worker,
142
+ where the genuine constructor is used (spaces' unpatch() does not restore
143
+ these two attrs, so we dispatch on the flag instead)."""
144
+ from contextlib import nullcontext
145
+
146
+ real_Stream = torch.cuda.Stream
147
+ real_stream = torch.cuda.stream
148
+
149
+ class _CPUStreamFallback:
150
+ """Inert stand-in used only in the parent, before a GPU is attached."""
151
+
152
+ cuda_stream = 0
153
+ device = torch.device("cuda")
154
+
155
+ def synchronize(self):
156
+ return None
157
+
158
+ def wait_stream(self, *a, **kw):
159
+ return None
160
+
161
+ def query(self):
162
+ return True
163
+
164
+ def __enter__(self):
165
+ return self
166
+
167
+ def __exit__(self, *a):
168
+ return False
169
+
170
+ class Stream(real_Stream):
171
+ def __new__(cls, *a, **kw):
172
+ if _REAL_CUDA[0]:
173
+ return real_Stream.__new__(cls, *a, **kw)
174
+ fb = object.__new__(_CPUStreamFallback)
175
+ fb._zerogpu_fake = True
176
+ return fb
177
+
178
+ # torch's own Stream defines only __new__ (all the work happens there)
179
+ # and inherits object.__init__, relying on CPython's rule that a
180
+ # subclass overriding __new__ is allowed to ignore extra __init__ args.
181
+ # Calling real_Stream.__init__ explicitly would break that rule with
182
+ # "object.__init__() takes exactly one argument". Stay a no-op.
183
+ def __init__(self, *a, **kw):
184
+ pass
185
+
186
+ def stream(s):
187
+ if getattr(s, "_zerogpu_fake", False):
188
+ return nullcontext()
189
+ return real_stream(s)
190
+
191
+ torch.cuda.Stream = Stream
192
+ torch.cuda.stream = stream
193
+
194
+
195
+ _install_zerogpu_stream_shim()
196
+
197
+ _install_progressive_cleanup()
198
+
199
+ print(f"[app] downloading {REPO_ID} snapshot (~17 GB, first boot only)...", flush=True)
200
+ _t0 = time.time()
201
+ snapshot_download(
202
+ repo_id=REPO_ID,
203
+ local_dir=MODEL_DIR,
204
+ max_workers=16,
205
+ ignore_patterns=["bridge/mapper_best.pt", "__pycache__/*", "*.pyc",
206
+ ".ms_upload_cache"],
207
+ )
208
+ print(f"[app] snapshot done in {time.time() - _t0:.0f}s", flush=True)
209
+
210
+ # The vendored inference stack (modeling_dubbing.py + code/) lives inside the
211
+ # model snapshot; DubbingBridgeModel puts code/ and code/tts on sys.path itself.
212
+ sys.path.insert(0, MODEL_DIR)
213
+ from modeling_dubbing import DubbingBridgeModel # noqa: E402
214
+
215
+ print("[app] loading pipeline (ST LM 2B + mapper + CosyVoice3)...", flush=True)
216
+ _t0 = time.time()
217
+ model = DubbingBridgeModel.from_pretrained(MODEL_DIR, device="cuda")
218
+ print(f"[app] pipeline ready in {time.time() - _t0:.0f}s", flush=True)
219
+ print(f"[app] unlinked {len(_CONSUMED)} raw blobs", flush=True)
220
+
221
+ SAMPLE_RATE = model.sample_rate # 24000
222
+ LANG_NAMES = {"en": "English", "es": "Spanish", "ja": "Japanese", "zh": "Chinese"}
223
+ LANG_CODES = {v: k for k, v in LANG_NAMES.items()}
224
+
225
+
226
+ def _has_cjk(text: str) -> bool:
227
+ return any("一" <= ch <= "鿿" for ch in text)
228
+
229
+
230
+ @spaces.GPU(duration=120)
231
+ def dub(audio_path: str, target_lang: str):
232
+ """Dub source speech into a target language, preserving the original voice.
233
+
234
+ Args:
235
+ audio_path: path to source speech (Chinese or English, <= 30 s).
236
+ target_lang: target language — "English", "Spanish", "Japanese" or
237
+ "Chinese" (a code en/es/ja/zh also works).
238
+
239
+ Returns:
240
+ (audio (sample_rate, waveform), source transcript markdown,
241
+ translation markdown) for the dubbed speech.
242
+ """
243
+ if not audio_path:
244
+ raise gr.Error("Please upload or record some source speech first.")
245
+ _REAL_CUDA[0] = True # we are inside the forked GPU worker now
246
+ _install_torchaudio_save_fallback()
247
+ lang = LANG_CODES.get(target_lang, str(target_lang).lower())
248
+ if lang not in LANG_NAMES:
249
+ raise gr.Error(f"Unknown target language: {target_lang!r}")
250
+ t0 = time.time()
251
+ try:
252
+ wav, sr, info = model.dub(audio_path, lang=lang, return_info=True)
253
+ except AssertionError as e:
254
+ msg = str(e) or "unsupported dubbing direction"
255
+ if "30" in msg or "extract speech token" in msg:
256
+ raise gr.Error("Input audio is too long — please keep it under 30 seconds.")
257
+ raise gr.Error(
258
+ "This direction is not supported (the source language was detected "
259
+ "as the same as the target). Supported: zh→en/es/ja, en→zh/es/ja. "
260
+ f"({msg})"
261
+ )
262
+ elapsed = time.time() - t0
263
+ src_text, tgt_raw, tgt_cv = info["zh"], info["tgt_raw"], info["tgt_cv"]
264
+ src_lang = info.get("src_lang", "zh" if _has_cjk(src_text) else "en")
265
+ print(f"[dub] {src_lang}->{lang} in {elapsed:.1f}s: {src_text!r} -> {tgt_raw!r}",
266
+ flush=True)
267
+ audio = (sr, wav.detach().cpu().numpy().flatten())
268
+ transcript_md = (f"**Detected source ({LANG_NAMES.get(src_lang, src_lang)}):** "
269
+ f"{src_text}")
270
+ translation_md = (f"**TTS input ({LANG_NAMES[lang]}):** "
271
+ f"{tgt_cv if lang == 'ja' else tgt_raw}")
272
+ return audio, transcript_md, translation_md
273
+
274
+
275
+ with gr.Blocks(theme=gr.themes.Citrus(), title="Index-Echo-S2ST-2B Dubbing") as demo:
276
+ gr.Markdown(
277
+ "# Index-Echo-S2ST-2B — voice-preserving speech dubbing\n"
278
+ "Speak in **Chinese or English**; get the same sentence back in "
279
+ "**English, Spanish, Japanese or Chinese** — in your own voice.\n\n"
280
+ "2B speech-translation LM → ~25M Hidden2CVMapper → CosyVoice3 0.5B. "
281
+ "Runs on ZeroGPU."
282
+ )
283
+ with gr.Row():
284
+ with gr.Column():
285
+ audio_in = gr.Audio(
286
+ sources=["upload", "microphone"],
287
+ type="filepath",
288
+ label="Source speech (Chinese or English, ≤ 30 s)",
289
+ )
290
+ lang_dd = gr.Dropdown(
291
+ choices=["English", "Spanish", "Japanese", "Chinese"],
292
+ value="English",
293
+ label="Target language",
294
+ info="Source language is detected automatically; dubbing into "
295
+ "the same language as the source is not supported.",
296
+ )
297
+ btn = gr.Button("Dub speech", variant="primary")
298
+ with gr.Column():
299
+ audio_out = gr.Audio(label="Dubbed speech")
300
+ src_md = gr.Markdown()
301
+ tgt_md = gr.Markdown()
302
+ btn.click(
303
+ fn=dub,
304
+ inputs=[audio_in, lang_dd],
305
+ outputs=[audio_out, src_md, tgt_md],
306
+ api_name="dub",
307
+ show_progress="full",
308
+ )
309
+
310
+ with gr.Accordion("Supported directions", open=False):
311
+ gr.Markdown(
312
+ "| Source | Targets |\n|---|---|\n"
313
+ "| Chinese | English · Spanish · Japanese |\n"
314
+ "| English | Chinese · Spanish · Japanese |"
315
+ )
316
+ gr.Examples(
317
+ examples=[
318
+ ["samples/input_zh.wav", "English"],
319
+ ["samples/input_zh.wav", "Spanish"],
320
+ ["samples/input_zh.wav", "Japanese"],
321
+ ["samples/input_en.wav", "Chinese"],
322
+ ["samples/input_en.wav", "Spanish"],
323
+ ["samples/input_en.wav", "Japanese"],
324
+ ],
325
+ inputs=[audio_in, lang_dd],
326
+ fn=dub,
327
+ outputs=[audio_out, src_md, tgt_md],
328
+ cache_examples=True,
329
+ cache_mode="lazy",
330
+ label="Try the authors' own sample inputs (one per direction)",
331
+ )
332
+
333
+ demo.queue(default_concurrency_limit=1)
334
+ demo.launch(mcp_server=True, ssr_mode=False)
requirements.txt ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Index-Echo-S2ST-2B vendored-inference-stack requirements (authors' venv_unify pins, see model card)
2
+ # NOTE: torch/torchaudio pinned to the ZeroGPU-supported set (2.8.0/2.9.1/2.10.0/2.11.0);
3
+ # plain PyPI builds (no +cu129 suffix) so the ZeroGPU version check passes.
4
+ torch==2.11.0
5
+ torchaudio==2.11.0
6
+ transformers==5.6.0
7
+ librosa==1.0.0
8
+ onnxruntime==1.30.0
9
+ wetext==0.0.4
10
+ kaldifst==1.8.0
11
+ conformer==0.3.2
12
+ HyperPyYAML==1.2.3
13
+ lightning==2.6.6
14
+ x-transformers==2.11.24
15
+ einx==0.4.3
16
+ einops>=0.8.0
17
+ frozendict==2.4.7
18
+ pyworld==0.3.5
19
+ soundfile==0.13.1
20
+ modelscope==1.37.1
21
+ safetensors==0.7.0
22
+ numpy==2.4.6
23
+ openai-whisper==20250625
24
+ # Matcha-TTS (vendored in the snapshot, used by CosyVoice3's flow/HiFT) imports
25
+ # GEGLU/GELU/ApproximateGELU and AdaLayerNorm/AdaLayerNormZero from
26
+ # diffusers.models.attention — re-exports that only exist up to 0.34.x
27
+ # (also the authors' own pin, cosyvoice_repo/requirements.txt).
28
+ diffusers==0.29.0
29
+ # matcha/utils/__init__.py is executed on any matcha.utils.* import (parent pkg init):
30
+ # it pulls rich_utils (rich, hydra) and utils (gdown, wget, matplotlib) -> required
31
+ rich==13.7.1
32
+ hydra-core==1.3.7
33
+ gdown==5.1.0
34
+ wget==3.2
35
+ matplotlib==3.10.7
36
+ # wetext/kaldifst deps (pypi metadata), whisper deps, HyperPyYAML deps
37
+ ruamel.yaml>=0.17.28,<0.19.0
38
+ inflect
39
+ tiktoken
40
+ pyarrow
41
+ regex
42
+ scipy
43
+ tqdm
44
+ # NOTE: pyopenjtalk / pykakasi / jaconv / deprecated / wrapt are vendored in the
45
+ # model repo at code/ja_ext/ and put on sys.path by the pipeline itself.
46
+ # Do NOT pip-install them here — installed copies would shadow the vendored ones.
samples/input_en.wav ADDED
Binary file (70.8 kB). View file
 
samples/input_zh.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d01464c28a57ffb40b4d0126f8faf5081b73f6dbb5592fc41108009b14f064e8
3
+ size 198478