hy-simulmt / app.py
multimodalart's picture
multimodalart HF Staff
Tighten GPU duration; theme/css via launch
17d4352 verified
Raw History Blame Contribute Delete
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
@torch.inference_mode()
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))
@spaces.GPU(duration=_duration, size="xlarge")
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)