Spaces:
Sleeping
Sleeping
Download app.py from hugging-apps/hy-simulmt: direct link, hf CLI and curl.
- Browser
- Download file 14.5 kB
-
https://huggingface.co/spaces/hugging-apps/hy-simulmt/resolve/main/app.py
- Command line
-
hf download hf://spaces/hugging-apps/hy-simulmt/app.py
-
curl -L -o app.py https://huggingface.co/spaces/hugging-apps/hy-simulmt/resolve/main/app.py
14.5 kB
| import os | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| import spaces # noqa: E402 (must precede torch) | |
| import json # noqa: E402 | |
| import re # noqa: E402 | |
| import time # noqa: E402 | |
| from pathlib import Path # noqa: E402 | |
| import gradio as gr # noqa: E402 | |
| import librosa # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| import torch # noqa: E402 | |
| from huggingface_hub import snapshot_download # noqa: E402 | |
| from transformers import ( # noqa: E402 | |
| AutoModelForCausalLM, | |
| AutoTokenizer, | |
| LogitsProcessor, | |
| LogitsProcessorList, | |
| WhisperFeatureExtractor, | |
| ) | |
| from hy_simulmt.model import build_llm_view, encode_audio, load_audio_tower, speech_token_count # noqa: E402 | |
| MODEL_ID = "tencent/Hy-SimulMT-30B-A3B" | |
| DTYPE = torch.bfloat16 | |
| SR = 16000 | |
| MAX_SECONDS = 60 | |
| # ---------------------------------------------------------------------------- | |
| # Load (module scope, eager .to("cuda") -- ZeroGPU streams weights in on first call) | |
| # ---------------------------------------------------------------------------- | |
| root = Path( | |
| snapshot_download(MODEL_ID, allow_patterns=["*.safetensors", "*.json", "*.jinja"]) | |
| ) | |
| view = build_llm_view(root, Path("/tmp/hy_simulmt_llm_view")) | |
| tokenizer = AutoTokenizer.from_pretrained(view) | |
| feature_extractor = WhisperFeatureExtractor.from_pretrained(root) | |
| llm = AutoModelForCausalLM.from_pretrained(view, dtype=DTYPE, attn_implementation="sdpa").eval() | |
| encoder, adapter = load_audio_tower(root, DTYPE) | |
| llm.to("cuda") | |
| encoder.to("cuda") | |
| adapter.to("cuda") | |
| print("Hy-SimulMT loaded", flush=True) | |
| # ---------------------------------------------------------------------------- | |
| # Protocol constants (SimulInfer/simulinfer/translation.py + templates/simulst.jinja) | |
| # ---------------------------------------------------------------------------- | |
| HEADER = "<|hy_begin▁of▁sentence|><|reasoning_mode|>reasoning_effort:no_think" | |
| USER, ASSISTANT, EOT = "<|hy_User|>", "<|hy_Assistant|><think></think>", "<|hy_EOT|>" | |
| AUDIO_BEGIN = tokenizer.convert_tokens_to_ids("<|hy_place▁holder▁no▁6|>") | |
| AUDIO_TOKEN = tokenizer.convert_tokens_to_ids("<|hy_place▁holder▁no▁5|>") | |
| AUDIO_END = tokenizer.convert_tokens_to_ids("<|hy_place▁holder▁no▁8|>") | |
| STOP_IDS = [ | |
| tokenizer.convert_tokens_to_ids(EOT), | |
| tokenizer.convert_tokens_to_ids("<|hy_end▁of▁sentence|>"), | |
| tokenizer.eos_token_id, | |
| ] | |
| INSTRUCTIONS = { | |
| "zh-en": "请完成下面的同声传译任务,将陆续收到的语音翻译成英文。请结合上下文边听边译,输出能够确定的译文,不必等待整句结束。", | |
| "en-zh": "请完成下面的同声传译任务,将陆续收到的语音翻译成中文。请结合上下文边听边译,输出能够确定的译文,不必等待整句结束。", | |
| } | |
| ST_INSTRUCTIONS = { | |
| "zh-en": "把这段语音翻译成英文,要全部输出,不要分块。", | |
| "en-zh": "把这段语音翻译成中文,要全部输出,不要分块。", | |
| } | |
| DIRECTIONS = {"Chinese → English": "zh-en", "English → Chinese": "en-zh"} | |
| CHUNK = 1600 * 16 # first_chunk_ms = chunk_ms = 1600 | |
| MAX_TOKENS, ST_MAX_TOKENS = 64, 256 | |
| ST_WAIT_CHUNKS = 4 | |
| SILENCE_RMS = 0.003 | |
| TRAILING_SILENCE = int(2.4 * SR) | |
| # Target-language mask for zh->en (SimulInfer target_language.py: block Han / partial-UTF8 tokens) | |
| def _blocked_han_ids(): | |
| han = re.compile("[⺀-〇㇀-㐀-䶿一-鿿豈-\U00020000-\U0003347f]") | |
| values = list(range(33, 127)) + list(range(161, 173)) + list(range(174, 256)) | |
| chars, extra = values[:], 0 | |
| for v in range(256): | |
| if v not in values: | |
| values.append(v) | |
| chars.append(256 + extra) | |
| extra += 1 | |
| decode = dict(zip(map(chr, chars), values)) | |
| data = json.loads((root / "tokenizer.json").read_text()) | |
| blocked = set() | |
| for tok, tid in data["model"]["vocab"].items(): | |
| try: | |
| text = bytes(decode[c] for c in tok).decode("utf-8", errors="strict") | |
| except (KeyError, UnicodeDecodeError): | |
| blocked.add(tid) | |
| continue | |
| if han.search(text): | |
| blocked.add(tid) | |
| for item in data.get("added_tokens", []): | |
| if not item.get("special") and han.search(item["content"]): | |
| blocked.add(item["id"]) | |
| return sorted(blocked) | |
| HAN_IDS = torch.tensor(_blocked_han_ids(), dtype=torch.long) | |
| class BlockTokens(LogitsProcessor): | |
| def __init__(self, ids): | |
| self.ids = ids | |
| def __call__(self, input_ids, scores): | |
| scores[:, self.ids.to(scores.device)] = -float("inf") | |
| return scores | |
| # ---------------------------------------------------------------------------- | |
| # Inference helpers | |
| # ---------------------------------------------------------------------------- | |
| def pcm16(x: np.ndarray) -> np.ndarray: | |
| """SimulInfer sends model audio as PCM16 WAV; reproduce that quantisation.""" | |
| return (np.clip(np.rint(np.clip(x, -1, 1) * 32768.0), -32768, 32767) / 32768.0).astype(np.float32) | |
| def encode_part(samples: np.ndarray) -> torch.Tensor: | |
| f = feature_extractor([pcm16(samples)], sampling_rate=SR, return_attention_mask=True, | |
| padding="longest", return_tensors="pt") | |
| n = int(f["attention_mask"].sum()) | |
| emb = encode_audio(encoder, adapter, f["input_features"][0], n) | |
| assert emb.shape[0] == speech_token_count(n) | |
| return emb | |
| def render(messages): | |
| """messages: list of ('user', [str | Tensor ...]) / ('assistant', str). Returns ids + audio embeds.""" | |
| ids, audio = [], [] | |
| def text(s): | |
| ids.extend(tokenizer.encode(s, add_special_tokens=False)) | |
| text(HEADER) | |
| for role, content in messages: | |
| if role == "user": | |
| text(USER) | |
| for p in content: | |
| if isinstance(p, str): | |
| text(p) | |
| else: | |
| ids.append(AUDIO_BEGIN) | |
| ids.extend([AUDIO_TOKEN] * p.shape[0]) | |
| ids.append(AUDIO_END) | |
| audio.append(p) | |
| else: | |
| text(ASSISTANT + content + EOT) | |
| text(ASSISTANT) | |
| return ids, audio | |
| def generate(messages, max_new_tokens, direction, repetition_penalty=1.0): | |
| ids, audio = render(messages) | |
| input_ids = torch.tensor([ids], device="cuda") | |
| embeds = llm.get_input_embeddings()(input_ids) | |
| if audio: | |
| mask = input_ids[0] == AUDIO_TOKEN | |
| embeds[0, mask] = torch.cat(audio, 0).to(embeds.dtype) | |
| procs = LogitsProcessorList([BlockTokens(HAN_IDS)] if direction == "zh-en" else []) | |
| out = llm.generate( | |
| inputs_embeds=embeds, | |
| attention_mask=torch.ones_like(input_ids), | |
| max_new_tokens=max_new_tokens, | |
| do_sample=False, | |
| repetition_penalty=repetition_penalty, | |
| no_repeat_ngram_size=3, | |
| eos_token_id=STOP_IDS, | |
| pad_token_id=tokenizer.pad_token_id, | |
| logits_processor=procs, | |
| )[0].tolist() | |
| truncated = not (out and out[-1] in STOP_IDS) | |
| while out and out[-1] in STOP_IDS: | |
| out = out[:-1] | |
| return tokenizer.decode(out, skip_special_tokens=True), truncated | |
| def load_audio(path): | |
| if path is None: | |
| raise gr.Error("Please upload or record an audio clip.") | |
| audio, _ = librosa.load(path, sr=SR, mono=True) | |
| if not len(audio): | |
| raise gr.Error("The audio clip is empty.") | |
| return audio[: MAX_SECONDS * SR].astype(np.float32) | |
| def fmt_t(samples): | |
| return f"{samples / SR:5.1f}s" | |
| def _duration(audio_path, direction="Chinese → English", mode="Simultaneous", *args, **kwargs): | |
| try: | |
| secs = min(MAX_SECONDS, librosa.get_duration(path=audio_path)) | |
| except Exception: | |
| secs = MAX_SECONDS | |
| return int(8 + secs * (0.4 if mode == "Simultaneous" else 0.2)) | |
| def translate(audio_path: str, direction: str = "Chinese → English", mode: str = "Simultaneous"): | |
| """Translate Chinese/English speech with Hy-SimulMT. | |
| Args: | |
| audio_path: path or URL of a speech recording (Chinese or English, up to 60 s). | |
| direction: "Chinese → English" or "English → Chinese". | |
| mode: "Simultaneous" replays the clip in 1.6 s chunks like a live interpreter | |
| (incremental output); "Offline" translates the whole clip at once. | |
| Returns: | |
| The translated text and a per-chunk timeline (markdown). | |
| """ | |
| d = DIRECTIONS.get(direction, direction) | |
| if d not in INSTRUCTIONS: | |
| raise gr.Error("Unknown direction") | |
| audio = load_audio(audio_path) | |
| t0 = time.perf_counter() | |
| if mode != "Simultaneous": | |
| parts = [ST_INSTRUCTIONS[d]] + [encode_part(audio[i:i + 480000]) for i in range(0, len(audio), 480000)] | |
| text, _ = generate([("user", parts)], ST_MAX_TOKENS, d, repetition_penalty=1.05) | |
| yield text, f"Offline translation of {len(audio) / SR:.1f}s audio in {time.perf_counter() - t0:.1f}s." | |
| return | |
| messages, pending, rows = [], [], [] | |
| full = "" | |
| voiced_waits = continuous_silence = 0 | |
| st_count = 0 | |
| def st_flush(start_label): | |
| nonlocal full, voiced_waits, st_count | |
| if not any(not b[2] for b in pending): | |
| return "" | |
| samples = np.concatenate([b[0][: b[1]] for b in pending]) | |
| parts = [ST_INSTRUCTIONS[d]] + [encode_part(samples[i:i + 480000]) for i in range(0, len(samples), 480000)] | |
| text, _ = generate([("user", parts)], ST_MAX_TOKENS, d, repetition_penalty=1.05) | |
| st_count += 1 | |
| if not text.strip(): | |
| return "" | |
| full += text | |
| if messages and messages[-1][0] == "assistant": | |
| messages[-1] = ("assistant", text) | |
| pending.clear() | |
| voiced_waits = 0 | |
| return text | |
| def table(): | |
| head = "| Audio | Action | Output |\n|---|---|---|\n" | |
| return head + "\n".join(rows[-200:]) | |
| cursor = 0 | |
| n_chunks = (len(audio) + CHUNK - 1) // CHUNK | |
| for idx in range(n_chunks): | |
| chunk = audio[idx * CHUNK:(idx + 1) * CHUNK] | |
| valid = len(chunk) | |
| final = idx == n_chunks - 1 | |
| if valid < CHUNK: | |
| chunk = np.pad(chunk, (0, CHUNK - valid)) | |
| begin, cursor = cursor, cursor + valid | |
| label = f"{fmt_t(begin)}–{fmt_t(cursor)}" | |
| silent = float(np.sqrt(np.mean(chunk[:valid] ** 2))) < SILENCE_RMS | |
| continuous_silence = continuous_silence + valid if silent else 0 | |
| if silent and not messages: # skip_initial_silence | |
| rows.append(f"| {label} | skip (silence) | |") | |
| yield full, table() | |
| continue | |
| turn = ("user", ([INSTRUCTIONS[d]] if not messages else []) + [encode_part(chunk)]) | |
| text, truncated = generate(messages + [turn], MAX_TOKENS, d) | |
| messages += [turn, ("assistant", text)] | |
| pending.append((chunk, valid, silent)) | |
| if text.strip(): | |
| full += text | |
| pending.clear() | |
| voiced_waits = 0 | |
| rows.append(f"| {label} | EMIT | {text.strip()} |") | |
| else: | |
| if not silent: | |
| voiced_waits += 1 | |
| rows.append(f"| {label} | WAIT | |") | |
| periodic = voiced_waits >= ST_WAIT_CHUNKS | |
| silence_end = voiced_waits and silent and continuous_silence >= TRAILING_SILENCE | |
| if periodic or silence_end or final: | |
| st = st_flush(label) | |
| if st: | |
| rows.append(f"| {label} | ST recovery | {st.strip()} |") | |
| if truncated and not final: # max_tokens reached -> cut the run | |
| if any(not b[2] for b in pending): | |
| st = st_flush(label) | |
| if st: | |
| rows.append(f"| {label} | ST recovery | {st.strip()} |") | |
| messages = [] | |
| pending.clear() | |
| voiced_waits = 0 | |
| yield full, table() | |
| elapsed = time.perf_counter() - t0 | |
| rows.append(f"| done | {n_chunks} chunks, {st_count} ST recoveries | processed {len(audio) / SR:.1f}s audio in {elapsed:.1f}s |") | |
| yield full, table() | |
| # ---------------------------------------------------------------------------- | |
| # UI | |
| # ---------------------------------------------------------------------------- | |
| CSS = """ | |
| #col-container { max-width: 1100px; margin: 0 auto; } | |
| .dark .gradio-container { color: var(--body-text-color); } | |
| """ | |
| with gr.Blocks() as demo: | |
| with gr.Column(elem_id="col-container"): | |
| gr.Markdown( | |
| "# Hy-SimulMT 30B-A3B\n" | |
| "Tencent Hunyuan's simultaneous speech translation model (Chinese ⇄ English). " | |
| "In **Simultaneous** mode the clip is replayed in 1.6 s chunks, like a live interpreter: " | |
| "for each chunk the model either emits translated text or waits for more context.\n\n" | |
| "[Model](https://huggingface.co/tencent/Hy-SimulMT-30B-A3B) · " | |
| "[GitHub](https://github.com/Tencent-Hunyuan/Hy-SimulMT) · " | |
| "Text output only; the official speech output and voice cloning need a separate TTS service." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| audio_in = gr.Audio(label="Source speech (max 60 s)", sources=["upload", "microphone"], type="filepath") | |
| direction = gr.Radio(list(DIRECTIONS), value="Chinese → English", label="Direction") | |
| mode = gr.Radio(["Simultaneous", "Offline"], value="Simultaneous", label="Mode", | |
| info="Offline translates the whole clip in one pass") | |
| run = gr.Button("Translate", variant="primary") | |
| with gr.Column(): | |
| out_text = gr.Textbox(label="Translation", lines=8) | |
| with gr.Accordion("Chunk timeline", open=True): | |
| timeline = gr.Markdown() | |
| gr.Examples( | |
| examples=[ | |
| ["examples/zh_lunar_south_pole.wav", "Chinese → English"], | |
| ["examples/en_reasonable_measures.wav", "English → Chinese"], | |
| ["examples/zh_womens_basketball.wav", "Chinese → English"], | |
| ["examples/en_learning_culture.wav", "English → Chinese"], | |
| ], | |
| inputs=[audio_in, direction], | |
| outputs=[out_text, timeline], | |
| fn=translate, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| ) | |
| run.click(translate, inputs=[audio_in, direction, mode], outputs=[out_text, timeline], api_name="translate") | |
| if __name__ == "__main__": | |
| demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True) | |