| import os |
| import re |
| import time |
| import uuid |
| import queue |
| import threading |
| import subprocess |
| from dataclasses import dataclass, field |
| from typing import Optional |
|
|
| import numpy as np |
| import requests |
| import gradio as gr |
| from scipy.signal import resample_poly |
| from faster_whisper import WhisperModel |
|
|
| try: |
| import webrtcvad |
| except ImportError as e: |
| raise ImportError( |
| "缺少 webrtcvad,请先安装:pip install webrtcvad-wheels\n" |
| "(用 webrtcvad-wheels 这个 fork,预编译好的 wheel,装起来比原版 webrtcvad 省心)" |
| ) from e |
|
|
| |
| |
| |
| 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"}, |
| } |
|
|
| |
| STREAMING_LANG_MAP = { |
| "英语 → 中文": LANG_MAP["英语 → 中文"], |
| "汉语 → 英文": LANG_MAP["汉语 → 英文"], |
| } |
|
|
| |
| |
| |
|
|
|
|
| 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): |
| 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): |
| 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): |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| SAMPLE_RATE = 16000 |
| FRAME_MS = 30 |
| FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS // 1000 |
| SILENCE_END_MS = 600 |
| SILENCE_END_FRAMES = SILENCE_END_MS // FRAME_MS |
| PARTIAL_UPDATE_SEC = 1.0 |
| VAD_AGGRESSIVENESS = 2 |
|
|
| |
| |
| zh_en_asr_model = WhisperModel("small", device="cpu", compute_type="int8") |
|
|
|
|
| def to_16k_mono_float32(sr: int, audio: np.ndarray) -> np.ndarray: |
| """把 Gradio 传来的任意采样率/声道音频,统一转成 16kHz 单声道 float32 [-1, 1]""" |
| if audio.ndim > 1: |
| audio = audio.mean(axis=1) |
| audio = audio.astype(np.float32) |
| if np.abs(audio).max() > 1.0: |
| audio = audio / 32768.0 |
| if sr != SAMPLE_RATE: |
| audio = resample_poly(audio, SAMPLE_RATE, sr).astype(np.float32) |
| return audio |
|
|
|
|
| def float32_to_pcm16_bytes(audio_f32: np.ndarray) -> bytes: |
| clipped = np.clip(audio_f32, -1.0, 1.0) |
| return (clipped * 32767).astype(np.int16).tobytes() |
|
|
|
|
| class VadSegmenter: |
| """逐 30ms 帧跑 WebRTC VAD,判断当前是"说话中"还是"停顿到可以收尾了" """ |
|
|
| def __init__(self, aggressiveness=VAD_AGGRESSIVENESS): |
| self.vad = webrtcvad.Vad(aggressiveness) |
| self.silence_frames = 0 |
| self.in_speech = False |
| self.last_is_speech = False |
|
|
| def feed(self, frame_bytes: bytes) -> str: |
| """返回 'speaking' / 'pause_end' / 'silence'""" |
| is_speech = self.vad.is_speech(frame_bytes, SAMPLE_RATE) |
| self.last_is_speech = is_speech |
| if is_speech: |
| self.silence_frames = 0 |
| self.in_speech = True |
| return "speaking" |
| if self.in_speech: |
| self.silence_frames += 1 |
| if self.silence_frames >= SILENCE_END_FRAMES: |
| self.in_speech = False |
| self.silence_frames = 0 |
| return "pause_end" |
| return "speaking" |
| return "silence" |
|
|
|
|
| class StreamingClauseASR: |
| """ |
| 维护"当前正在说的这一句"的音频缓冲区,随缓冲增长周期性重新识别。 |
| 用 LocalAgreement 策略:前后两次识别结果里一致的稳定前缀才算"确认文本" |
| 展示出来,剩余部分标为"待确认"(下一轮可能改),这样能做到边说边出字, |
| 又不会因为 whisper 反复改主意导致画面疯狂抖动。 |
| """ |
|
|
| def __init__(self, model: WhisperModel, language: str): |
| self.model = model |
| self.language = language |
| self.buffer = np.zeros(0, dtype=np.float32) |
| self.confirmed_text = "" |
| self.last_hyp = "" |
|
|
| def add_audio(self, frame_f32: np.ndarray): |
| self.buffer = np.concatenate([self.buffer, frame_f32]) |
|
|
| def partial_transcribe(self): |
| if len(self.buffer) < SAMPLE_RATE * 0.3: |
| return self.confirmed_text, "" |
| segments, _ = self.model.transcribe( |
| self.buffer, language=self.language, task="transcribe", |
| beam_size=1, vad_filter=False, condition_on_previous_text=False, |
| ) |
| hyp = "".join(s.text for s in segments).strip() |
| stable = self._common_prefix(self.last_hyp, hyp) |
| self.last_hyp = hyp |
| if len(stable) > len(self.confirmed_text): |
| self.confirmed_text = stable |
| tentative = hyp[len(self.confirmed_text):] |
| return self.confirmed_text, tentative |
|
|
| @staticmethod |
| def _common_prefix(a: str, b: str) -> str: |
| n = min(len(a), len(b)) |
| i = 0 |
| while i < n and a[i] == b[i]: |
| i += 1 |
| return a[:i] |
|
|
| def finalize(self) -> str: |
| """VAD 判定一句话说完了,做一次高质量的完整识别,拿到这句话的最终文本""" |
| if len(self.buffer) < SAMPLE_RATE * 0.2: |
| text = self.confirmed_text |
| else: |
| segments, _ = self.model.transcribe( |
| self.buffer, language=self.language, task="transcribe", |
| beam_size=5, vad_filter=True, condition_on_previous_text=False, |
| ) |
| text = "".join(s.text for s in segments).strip() |
| self.buffer = np.zeros(0, dtype=np.float32) |
| self.confirmed_text = "" |
| self.last_hyp = "" |
| return text |
|
|
|
|
| |
|
|
| translate_queue: "queue.Queue" = queue.Queue() |
| speak_queue: "queue.Queue" = queue.Queue() |
|
|
| _session_lock = threading.Lock() |
| _session_result_queues: dict[str, "queue.Queue"] = {} |
|
|
|
|
| def get_session_queue(session_id: str) -> "queue.Queue": |
| with _session_lock: |
| if session_id not in _session_result_queues: |
| _session_result_queues[session_id] = queue.Queue() |
| return _session_result_queues[session_id] |
|
|
|
|
| def translate_worker(): |
| while True: |
| session_id, clause_id, text, cfg = translate_queue.get() |
| try: |
| trans = translate(text, cfg["src"], cfg["tgt"]) |
| speak_queue.put((session_id, clause_id, trans, cfg)) |
| except Exception as e: |
| get_session_queue(session_id).put(("error", clause_id, f"翻译出错:{e}")) |
| finally: |
| translate_queue.task_done() |
|
|
|
|
| def speak_worker(): |
| while True: |
| session_id, clause_id, trans, cfg = speak_queue.get() |
| try: |
| tts_path = speak(trans, cfg) |
| tagged = f"[{cfg['tgt_tag']}] {trans}" |
| get_session_queue(session_id).put(("done", clause_id, tagged, tts_path)) |
| except Exception as e: |
| get_session_queue(session_id).put(("error", clause_id, f"合成出错:{e}")) |
| finally: |
| speak_queue.task_done() |
|
|
|
|
| threading.Thread(target=translate_worker, daemon=True).start() |
| threading.Thread(target=speak_worker, daemon=True).start() |
|
|
|
|
| |
|
|
| @dataclass |
| class SessionState: |
| session_id: str |
| vad: VadSegmenter = field(default_factory=VadSegmenter) |
| asr: Optional[StreamingClauseASR] = None |
| lang_code: Optional[str] = None |
| leftover: np.ndarray = field(default_factory=lambda: np.zeros(0, dtype=np.float32)) |
| last_partial_ts: float = 0.0 |
| history: list = field(default_factory=list) |
| partial_index: Optional[int] = None |
| clause_counter: int = 0 |
| pending_clause_index: dict = field(default_factory=dict) |
|
|
|
|
| def _update_partial_caption(session: SessionState, cfg, confirmed, tentative): |
| line = f"[{cfg['src_tag']}] {confirmed}{tentative}" |
| if session.partial_index is not None: |
| session.history[session.partial_index]["content"] = line |
| else: |
| session.history.append({"role": "user", "content": line}) |
| session.partial_index = len(session.history) - 1 |
|
|
|
|
| def _finalize_clause(session: SessionState, cfg): |
| text = session.asr.finalize() |
| if not text.strip(): |
| return |
| if session.partial_index is not None: |
| session.history[session.partial_index]["content"] = f"[{cfg['src_tag']}] {text}" |
| session.partial_index = None |
| else: |
| session.history.append({"role": "user", "content": f"[{cfg['src_tag']}] {text}"}) |
|
|
| session.clause_counter += 1 |
| clause_id = session.clause_counter |
| session.history.append({"role": "assistant", "content": "⏳ 翻译中…"}) |
| session.pending_clause_index[clause_id] = len(session.history) - 1 |
| translate_queue.put((session.session_id, clause_id, text, cfg)) |
|
|
|
|
| def _flush_session_results(session: SessionState): |
| q = get_session_queue(session.session_id) |
| latest_audio = None |
| while True: |
| try: |
| item = q.get_nowait() |
| except queue.Empty: |
| break |
| kind = item[0] |
| if kind == "error": |
| _, clause_id, msg = item |
| idx = session.pending_clause_index.pop(clause_id, None) |
| if idx is not None: |
| session.history[idx]["content"] = f"⚠️ {msg}" |
| elif kind == "done": |
| _, clause_id, tagged_text, tts_path = item |
| idx = session.pending_clause_index.pop(clause_id, None) |
| if idx is not None: |
| session.history[idx]["content"] = tagged_text |
| latest_audio = tts_path |
| return latest_audio |
|
|
|
|
| def ingest_audio_chunk(session: Optional[SessionState], mode_label: str, new_chunk): |
| """ |
| Gradio 流式麦克风的回调,大约每 stream_every 秒被调用一次, |
| 每次带来一小段新录到的音频。这里做:分帧喂 VAD -> 判断是否说完一句 -> |
| 刷新临时字幕 -> 顺便把后台线程做完的翻译/合成结果捞回来更新界面。 |
| """ |
| if new_chunk is None: |
| return session, (session.history if session else []), None |
|
|
| if session is None: |
| session = SessionState(session_id=uuid.uuid4().hex) |
|
|
| cfg = STREAMING_LANG_MAP[mode_label] |
| lang_code = "zh" if cfg["src"] == "Chinese" else "en" |
| if session.asr is None or session.lang_code != lang_code: |
| |
| session.asr = StreamingClauseASR(zh_en_asr_model, lang_code) |
| session.lang_code = lang_code |
| session.vad = VadSegmenter() |
| session.leftover = np.zeros(0, dtype=np.float32) |
|
|
| if not API_KEY: |
| session.history.append({"role": "assistant", "content": "⚠️ 未配置 SILICONFLOW_API_KEY"}) |
| return session, session.history, None |
|
|
| sr, chunk = new_chunk |
| audio_f32 = to_16k_mono_float32(sr, chunk) |
| session.leftover = np.concatenate([session.leftover, audio_f32]) |
|
|
| while len(session.leftover) >= FRAME_SAMPLES: |
| frame = session.leftover[:FRAME_SAMPLES] |
| session.leftover = session.leftover[FRAME_SAMPLES:] |
| frame_bytes = float32_to_pcm16_bytes(frame) |
| status = session.vad.feed(frame_bytes) |
| if session.vad.last_is_speech or session.vad.in_speech: |
| session.asr.add_audio(frame) |
| if status == "pause_end": |
| _finalize_clause(session, cfg) |
|
|
| now = time.time() |
| if session.vad.in_speech and now - session.last_partial_ts >= PARTIAL_UPDATE_SEC: |
| session.last_partial_ts = now |
| confirmed, tentative = session.asr.partial_transcribe() |
| if confirmed or tentative: |
| _update_partial_caption(session, cfg, confirmed, tentative) |
|
|
| tts_path = _flush_session_results(session) |
|
|
| return session, session.history, (tts_path if tts_path else gr.update()) |
|
|
|
|
| |
| |
| |
|
|
| with gr.Blocks(title="传声筒") as demo: |
| gr.Markdown("## 🎙️ 传声筒") |
|
|
| with gr.Tab("同声传译(中↔英,持续聆听)"): |
| gr.Markdown( |
| "点一下开始,持续说话即可,不用点停止。程序会自动按停顿分句," |
| "边听边识别、边翻译边播放译文,通常滞后几秒。\n" |
| "⚠️ 实验性功能:识别用的是本地 CPU 模型,参数(停顿判定时长、模型大小)" |
| "可能需要根据你的机器实际表现调整。" |
| ) |
| mode_stream = gr.Radio( |
| choices=list(STREAMING_LANG_MAP.keys()), |
| value="英语 → 中文", |
| label="翻译方向", |
| ) |
| audio_in_stream = gr.Audio( |
| sources=["microphone"], |
| type="numpy", |
| streaming=True, |
| label="持续录音", |
| ) |
| chatbot_stream = gr.Chatbot(label="同传记录", height=460) |
| tts_out_stream = gr.Audio(label="译文语音", autoplay=True, interactive=False) |
|
|
| session_state = gr.State(None) |
|
|
| audio_in_stream.stream( |
| fn=ingest_audio_chunk, |
| inputs=[session_state, mode_stream, audio_in_stream], |
| outputs=[session_state, chatbot_stream, tts_out_stream], |
| stream_every=0.5, |
| ) |
|
|
| mode_stream.change( |
| fn=lambda: (None, [], None), |
| inputs=None, |
| outputs=[session_state, chatbot_stream, tts_out_stream], |
| ) |
|
|
| with gr.Tab("点击录音(六个方向,原来的模式)"): |
| gr.Markdown( |
| "点麦克风开始录音,说一句完整的话,再点一次停止,自动识别+翻译+朗读译文。\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() |