| import os |
| import uuid |
| import subprocess |
| import gradio as gr |
| import requests |
| from faster_whisper import WhisperModel |
|
|
| API_KEY = os.environ.get("SILICONFLOW_API_KEY", "") |
|
|
| ASR_URL = "https://api.siliconflow.cn/v1/audio/transcriptions" |
| CHAT_URL = "https://api.siliconflow.cn/v1/chat/completions" |
| TTS_URL = "https://api.siliconflow.cn/v1/audio/speech" |
|
|
| ASR_MODEL = "FunAudioLLM/SenseVoiceSmall" |
| MT_MODEL = "tencent/Hunyuan-MT-7B" |
| TTS_MODEL = "FunAudioLLM/CosyVoice2-0.5B" |
|
|
| |
| AUDIO_DIR = "/tmp/chuanshengtong_audio" |
| os.makedirs(AUDIO_DIR, exist_ok=True) |
|
|
| |
| FRENCH_ASR_MODEL_SIZE = "medium" |
| french_asr_model = WhisperModel(FRENCH_ASR_MODEL_SIZE, device="cpu", compute_type="int8") |
|
|
| |
| |
| |
| |
| |
| PIPER_BIN = "piper" |
|
|
| PIPER_MODEL_DIR = "piper_models" |
| os.makedirs(PIPER_MODEL_DIR, exist_ok=True) |
|
|
| PIPER_MODEL_FR = os.path.join(PIPER_MODEL_DIR, "fr_FR-siwis-medium.onnx") |
| PIPER_MODEL_FR_JSON = os.path.join(PIPER_MODEL_DIR, "fr_FR-siwis-medium.onnx.json") |
|
|
| PIPER_MODEL_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx" |
| PIPER_MODEL_JSON_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx.json" |
|
|
|
|
| def ensure_piper_model(): |
| """启动时检查法语模型文件是否存在,不存在就自动下载。""" |
| try: |
| if not os.path.exists(PIPER_MODEL_FR): |
| print("正在下载 Piper 法语模型(onnx)...") |
| r = requests.get(PIPER_MODEL_URL, timeout=120) |
| r.raise_for_status() |
| with open(PIPER_MODEL_FR, "wb") as f: |
| f.write(r.content) |
|
|
| if not os.path.exists(PIPER_MODEL_FR_JSON): |
| print("正在下载 Piper 法语模型配置(json)...") |
| r = requests.get(PIPER_MODEL_JSON_URL, timeout=60) |
| r.raise_for_status() |
| with open(PIPER_MODEL_FR_JSON, "wb") as f: |
| f.write(r.content) |
|
|
| print("Piper 法语模型已就绪") |
| except Exception as e: |
| print(f"⚠️ Piper 法语模型下载失败:{e}(法语合成方向届时会报错,其他方向不受影响)") |
|
|
|
|
| ensure_piper_model() |
|
|
| |
| |
| |
| |
| |
| |
| LANG_MAP = { |
| "英语 → 中文": {"src": "English", "tgt": "Chinese", "src_tag": "EN", "tgt_tag": "中", |
| "asr": "siliconflow", "tts": "siliconflow", "voice": "FunAudioLLM/CosyVoice2-0.5B:alex"}, |
| "汉语 → 英文": {"src": "Chinese", "tgt": "English", "src_tag": "中", "tgt_tag": "EN", |
| "asr": "siliconflow", "tts": "siliconflow", "voice": "FunAudioLLM/CosyVoice2-0.5B:david"}, |
| "汉语 → 法文": {"src": "Chinese", "tgt": "French", "src_tag": "中", "tgt_tag": "FR", |
| "asr": "siliconflow", "tts": "piper_fr", "voice": None}, |
| "法语 → 中文": {"src": "French", "tgt": "Chinese", "src_tag": "FR", "tgt_tag": "中", |
| "asr": "local_french", "tts": "siliconflow", "voice": "FunAudioLLM/CosyVoice2-0.5B:alex"}, |
| "英语 → 法文": {"src": "English", "tgt": "French", "src_tag": "EN", "tgt_tag": "FR", |
| "asr": "siliconflow", "tts": "piper_fr", "voice": None}, |
| "法语 → 英文": {"src": "French", "tgt": "English", "src_tag": "FR", "tgt_tag": "EN", |
| "asr": "local_french", "tts": "siliconflow", "voice": "FunAudioLLM/CosyVoice2-0.5B:david"}, |
| } |
|
|
|
|
| def transcribe_siliconflow(audio_path): |
| with open(audio_path, "rb") as f: |
| files = {"file": (os.path.basename(audio_path), f, "audio/wav")} |
| data = {"model": ASR_MODEL} |
| headers = {"Authorization": "Bearer " + API_KEY} |
| r = requests.post(ASR_URL, headers=headers, data=data, files=files, timeout=60) |
| r.raise_for_status() |
| text = r.json().get("text", "").strip() |
| text = text.replace("<|", "").replace("|>", "") |
| return text |
|
|
|
|
| def transcribe_french_local(audio_path): |
| segments, _info = french_asr_model.transcribe( |
| audio_path, |
| language="fr", |
| task="transcribe", |
| vad_filter=True, |
| ) |
| return "".join(seg.text for seg in segments).strip() |
|
|
|
|
| def transcribe(audio_path, engine): |
| if engine == "local_french": |
| return transcribe_french_local(audio_path) |
| return transcribe_siliconflow(audio_path) |
|
|
|
|
| def translate(text, src, tgt): |
| headers = {"Authorization": "Bearer " + API_KEY, "Content-Type": "application/json"} |
| payload = { |
| "model": MT_MODEL, |
| "messages": [ |
| { |
| "role": "system", |
| "content": f"You are a professional translator. Translate the user text from {src} to {tgt}. " |
| f"Output ONLY the translated text, no explanations, no quotes, no pinyin.", |
| }, |
| {"role": "user", "content": text}, |
| ], |
| "temperature": 0.3, |
| "max_tokens": 512, |
| } |
| r = requests.post(CHAT_URL, headers=headers, json=payload, timeout=60) |
| r.raise_for_status() |
| return r.json()["choices"][0]["message"]["content"].strip() |
|
|
|
|
| def speak_siliconflow(text, voice, out_path): |
| """硅基流动 CosyVoice2 合成,输出 mp3""" |
| headers = {"Authorization": "Bearer " + API_KEY, "Content-Type": "application/json"} |
| payload = { |
| "model": TTS_MODEL, |
| "input": text, |
| "voice": voice, |
| "response_format": "mp3", |
| } |
| r = requests.post(TTS_URL, headers=headers, json=payload, timeout=60) |
| r.raise_for_status() |
| with open(out_path, "wb") as f: |
| f.write(r.content) |
| return out_path |
|
|
|
|
| def speak_piper_french(text, out_path): |
| """本地 Piper 合成法语语音,输出 wav""" |
| if not os.path.exists(PIPER_MODEL_FR): |
| raise RuntimeError(f"未找到 Piper 法语模型文件:{PIPER_MODEL_FR},请检查启动日志中的下载是否成功") |
| cmd = [PIPER_BIN, "--model", PIPER_MODEL_FR, "--output_file", out_path] |
| proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True) |
| if proc.returncode != 0: |
| err = proc.stderr.decode("utf-8", errors="ignore") |
| raise RuntimeError(f"Piper 合成失败:{err}") |
| return out_path |
|
|
|
|
| def speak(text, cfg): |
| """按翻译方向配置,选择硅基流动或本地 Piper 合成语音;文件名唯一,支持历史回放""" |
| uid = uuid.uuid4().hex[:8] |
| if cfg["tts"] == "piper_fr": |
| out_path = os.path.join(AUDIO_DIR, f"{uid}.wav") |
| return speak_piper_french(text, out_path) |
| else: |
| out_path = os.path.join(AUDIO_DIR, f"{uid}.mp3") |
| return speak_siliconflow(text, cfg["voice"], out_path) |
|
|
|
|
| def process(audio_path, mode_label, history): |
| history = history or [] |
|
|
| if not API_KEY: |
| history.append({"role": "assistant", "content": "⚠️ 服务器未配置 SILICONFLOW_API_KEY,请检查 Space 的 Settings -> Repository secrets"}) |
| return history, None, None |
|
|
| if audio_path is None: |
| return history, None, None |
|
|
| cfg = LANG_MAP[mode_label] |
|
|
| try: |
| text = transcribe(audio_path, cfg["asr"]) |
| if not text: |
| return history, None, None |
| trans = translate(text, cfg["src"], cfg["tgt"]) |
| tts_path = speak(trans, cfg) |
| except Exception as e: |
| history.append({"role": "assistant", "content": f"⚠️ 出错了:{e}"}) |
| return history, None, None |
|
|
| |
| history.append({"role": "user", "content": f"[{cfg['src_tag']}] {text}"}) |
| history.append({"role": "assistant", "content": f"[{cfg['tgt_tag']}] {trans}"}) |
| |
| |
| |
| history.append({"role": "assistant", "content": {"path": tts_path}}) |
|
|
| return history, None, tts_path |
|
|
|
|
| with gr.Blocks(title="传声筒") as demo: |
| gr.Markdown( |
| "## 🎙️ 传声筒 · 实时语音传译\n" |
| "点麦克风开始录音,说一句完整的话,再点一次停止,自动识别+翻译+朗读译文。\n" |
| "识别与翻译主要使用硅基流动,法语识别、法语合成使用本地方案补充。\n" |
| "每句译文的语音会自动播放一遍,也会留在下方记录里,随时可以点开重听。" |
| ) |
|
|
| mode = gr.Radio( |
| choices=list(LANG_MAP.keys()), |
| value="英语 → 中文", |
| label="翻译方向", |
| ) |
|
|
| audio_in = gr.Audio( |
| sources=["microphone"], |
| type="filepath", |
| label="点击录音,再点一次停止", |
| ) |
|
|
| chatbot = gr.Chatbot(label="传译记录(含可重听音频)", height=460) |
|
|
| tts_out = gr.Audio( |
| label="最新译文语音(当前这一句,可反复点播放)", |
| autoplay=True, |
| interactive=False, |
| ) |
|
|
| audio_in.stop_recording( |
| fn=process, |
| inputs=[audio_in, mode, chatbot], |
| outputs=[chatbot, audio_in, tts_out], |
| ) |
|
|
| demo.launch() |