Spaces:
Runtime error
Runtime error
Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import time
|
| 4 |
+
import uuid
|
| 5 |
+
import queue
|
| 6 |
+
import threading
|
| 7 |
+
import subprocess
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import requests
|
| 13 |
+
import gradio as gr
|
| 14 |
+
from scipy.signal import resample_poly
|
| 15 |
+
from faster_whisper import WhisperModel
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
import webrtcvad
|
| 19 |
+
except ImportError as e:
|
| 20 |
+
raise ImportError(
|
| 21 |
+
"缺少 webrtcvad,请先安装:pip install webrtcvad-wheels\n"
|
| 22 |
+
"(用 webrtcvad-wheels 这个 fork,预编译好的 wheel,装起来比原版 webrtcvad 省心)"
|
| 23 |
+
) from e
|
| 24 |
+
|
| 25 |
+
# =============================================================================
|
| 26 |
+
# 基础配置(与原来一致)
|
| 27 |
+
# =============================================================================
|
| 28 |
+
API_KEY = os.environ.get("SILICONFLOW_API_KEY", "")
|
| 29 |
+
|
| 30 |
+
ASR_URL = "https://api.siliconflow.cn/v1/audio/transcriptions"
|
| 31 |
+
CHAT_URL = "https://api.siliconflow.cn/v1/chat/completions"
|
| 32 |
+
TTS_URL = "https://api.siliconflow.cn/v1/audio/speech"
|
| 33 |
+
|
| 34 |
+
ASR_MODEL = "FunAudioLLM/SenseVoiceSmall"
|
| 35 |
+
MT_MODEL = "tencent/Hunyuan-MT-7B"
|
| 36 |
+
TTS_MODEL = "FunAudioLLM/CosyVoice2-0.5B"
|
| 37 |
+
|
| 38 |
+
AUDIO_DIR = "/tmp/chuanshengtong_audio"
|
| 39 |
+
os.makedirs(AUDIO_DIR, exist_ok=True)
|
| 40 |
+
|
| 41 |
+
FRENCH_ASR_MODEL_SIZE = "medium"
|
| 42 |
+
french_asr_model = WhisperModel(FRENCH_ASR_MODEL_SIZE, device="cpu", compute_type="int8")
|
| 43 |
+
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
# Piper 本地法语 TTS(原样保留)
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
PIPER_BIN = "piper"
|
| 48 |
+
PIPER_MODEL_DIR = "piper_models"
|
| 49 |
+
os.makedirs(PIPER_MODEL_DIR, exist_ok=True)
|
| 50 |
+
|
| 51 |
+
PIPER_MODEL_FR = os.path.join(PIPER_MODEL_DIR, "fr_FR-siwis-medium.onnx")
|
| 52 |
+
PIPER_MODEL_FR_JSON = os.path.join(PIPER_MODEL_DIR, "fr_FR-siwis-medium.onnx.json")
|
| 53 |
+
|
| 54 |
+
PIPER_MODEL_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx"
|
| 55 |
+
PIPER_MODEL_JSON_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx.json"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def ensure_piper_model():
|
| 59 |
+
try:
|
| 60 |
+
if not os.path.exists(PIPER_MODEL_FR):
|
| 61 |
+
print("正在下载 Piper 法语模型(onnx)...")
|
| 62 |
+
r = requests.get(PIPER_MODEL_URL, timeout=120)
|
| 63 |
+
r.raise_for_status()
|
| 64 |
+
with open(PIPER_MODEL_FR, "wb") as f:
|
| 65 |
+
f.write(r.content)
|
| 66 |
+
if not os.path.exists(PIPER_MODEL_FR_JSON):
|
| 67 |
+
print("正在下载 Piper 法语模型配置(json)...")
|
| 68 |
+
r = requests.get(PIPER_MODEL_JSON_URL, timeout=60)
|
| 69 |
+
r.raise_for_status()
|
| 70 |
+
with open(PIPER_MODEL_FR_JSON, "wb") as f:
|
| 71 |
+
f.write(r.content)
|
| 72 |
+
print("Piper 法语模型已就绪")
|
| 73 |
+
except Exception as e:
|
| 74 |
+
print(f"⚠️ Piper 法语模型下载失败:{e}(法语合成方向届时会报错,其他方向不受影响)")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
ensure_piper_model()
|
| 78 |
+
|
| 79 |
+
# =============================================================================
|
| 80 |
+
# 翻译方向配置(原样保留,同传模式只会用到中↔英这两个方向)
|
| 81 |
+
# =============================================================================
|
| 82 |
+
LANG_MAP = {
|
| 83 |
+
"英语 → 中文": {"src": "English", "tgt": "Chinese", "src_tag": "EN", "tgt_tag": "中",
|
| 84 |
+
"asr": "siliconflow", "tts": "siliconflow", "voice": "FunAudioLLM/CosyVoice2-0.5B:alex"},
|
| 85 |
+
"汉语 → 英文": {"src": "Chinese", "tgt": "English", "src_tag": "中", "tgt_tag": "EN",
|
| 86 |
+
"asr": "siliconflow", "tts": "siliconflow", "voice": "FunAudioLLM/CosyVoice2-0.5B:david"},
|
| 87 |
+
"汉语 → 法文": {"src": "Chinese", "tgt": "French", "src_tag": "中", "tgt_tag": "FR",
|
| 88 |
+
"asr": "siliconflow", "tts": "piper_fr", "voice": None},
|
| 89 |
+
"法语 → 中文": {"src": "French", "tgt": "Chinese", "src_tag": "FR", "tgt_tag": "中",
|
| 90 |
+
"asr": "local_french", "tts": "siliconflow", "voice": "FunAudioLLM/CosyVoice2-0.5B:alex"},
|
| 91 |
+
"英语 → 法文": {"src": "English", "tgt": "French", "src_tag": "EN", "tgt_tag": "FR",
|
| 92 |
+
"asr": "siliconflow", "tts": "piper_fr", "voice": None},
|
| 93 |
+
"法语 → 英文": {"src": "French", "tgt": "English", "src_tag": "FR", "tgt_tag": "EN",
|
| 94 |
+
"asr": "local_french", "tts": "siliconflow", "voice": "FunAudioLLM/CosyVoice2-0.5B:david"},
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
# 同传模式目前只开放这两个方向(对应"1A 2A":CPU + 先做中↔英)
|
| 98 |
+
STREAMING_LANG_MAP = {
|
| 99 |
+
"英语 → 中文": LANG_MAP["英语 → 中文"],
|
| 100 |
+
"汉语 → 英文": LANG_MAP["汉语 → 英文"],
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
# =============================================================================
|
| 104 |
+
# 原来的批处理函数(点击录音模式用,原样保留)
|
| 105 |
+
# =============================================================================
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def transcribe_siliconflow(audio_path):
|
| 109 |
+
with open(audio_path, "rb") as f:
|
| 110 |
+
files = {"file": (os.path.basename(audio_path), f, "audio/wav")}
|
| 111 |
+
data = {"model": ASR_MODEL}
|
| 112 |
+
headers = {"Authorization": "Bearer " + API_KEY}
|
| 113 |
+
r = requests.post(ASR_URL, headers=headers, data=data, files=files, timeout=60)
|
| 114 |
+
r.raise_for_status()
|
| 115 |
+
text = r.json().get("text", "").strip()
|
| 116 |
+
text = text.replace("<|", "").replace("|>", "")
|
| 117 |
+
return text
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def transcribe_french_local(audio_path):
|
| 121 |
+
segments, _info = french_asr_model.transcribe(
|
| 122 |
+
audio_path, language="fr", task="transcribe", vad_filter=True,
|
| 123 |
+
)
|
| 124 |
+
return "".join(seg.text for seg in segments).strip()
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def transcribe(audio_path, engine):
|
| 128 |
+
if engine == "local_french":
|
| 129 |
+
return transcribe_french_local(audio_path)
|
| 130 |
+
return transcribe_siliconflow(audio_path)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def translate(text, src, tgt):
|
| 134 |
+
headers = {"Authorization": "Bearer " + API_KEY, "Content-Type": "application/json"}
|
| 135 |
+
payload = {
|
| 136 |
+
"model": MT_MODEL,
|
| 137 |
+
"messages": [
|
| 138 |
+
{
|
| 139 |
+
"role": "system",
|
| 140 |
+
"content": f"You are a professional translator. Translate the user text from {src} to {tgt}. "
|
| 141 |
+
f"Output ONLY the translated text, no explanations, no quotes, no pinyin.",
|
| 142 |
+
},
|
| 143 |
+
{"role": "user", "content": text},
|
| 144 |
+
],
|
| 145 |
+
"temperature": 0.3,
|
| 146 |
+
"max_tokens": 512,
|
| 147 |
+
}
|
| 148 |
+
r = requests.post(CHAT_URL, headers=headers, json=payload, timeout=60)
|
| 149 |
+
r.raise_for_status()
|
| 150 |
+
return r.json()["choices"][0]["message"]["content"].strip()
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def speak_siliconflow(text, voice, out_path):
|
| 154 |
+
headers = {"Authorization": "Bearer " + API_KEY, "Content-Type": "application/json"}
|
| 155 |
+
payload = {"model": TTS_MODEL, "input": text, "voice": voice, "response_format": "mp3"}
|
| 156 |
+
r = requests.post(TTS_URL, headers=headers, json=payload, timeout=60)
|
| 157 |
+
r.raise_for_status()
|
| 158 |
+
with open(out_path, "wb") as f:
|
| 159 |
+
f.write(r.content)
|
| 160 |
+
return out_path
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def speak_piper_french(text, out_path):
|
| 164 |
+
if not os.path.exists(PIPER_MODEL_FR):
|
| 165 |
+
raise RuntimeError(f"未找到 Piper 法语模型文件:{PIPER_MODEL_FR},请检查启动日志中的下载是否成功")
|
| 166 |
+
cmd = [PIPER_BIN, "--model", PIPER_MODEL_FR, "--output_file", out_path]
|
| 167 |
+
proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True)
|
| 168 |
+
if proc.returncode != 0:
|
| 169 |
+
err = proc.stderr.decode("utf-8", errors="ignore")
|
| 170 |
+
raise RuntimeError(f"Piper 合成失败:{err}")
|
| 171 |
+
return out_path
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def speak(text, cfg):
|
| 175 |
+
uid = uuid.uuid4().hex[:8]
|
| 176 |
+
if cfg["tts"] == "piper_fr":
|
| 177 |
+
out_path = os.path.join(AUDIO_DIR, f"{uid}.wav")
|
| 178 |
+
return speak_piper_french(text, out_path)
|
| 179 |
+
else:
|
| 180 |
+
out_path = os.path.join(AUDIO_DIR, f"{uid}.mp3")
|
| 181 |
+
return speak_siliconflow(text, cfg["voice"], out_path)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def process(audio_path, mode_label, history):
|
| 185 |
+
history = history or []
|
| 186 |
+
if not API_KEY:
|
| 187 |
+
history.append({"role": "assistant", "content": "⚠️ 服务器未配置 SILICONFLOW_API_KEY,请检查 Space 的 Settings -> Repository secrets"})
|
| 188 |
+
return history, None, None
|
| 189 |
+
if audio_path is None:
|
| 190 |
+
return history, None, None
|
| 191 |
+
cfg = LANG_MAP[mode_label]
|
| 192 |
+
try:
|
| 193 |
+
text = transcribe(audio_path, cfg["asr"])
|
| 194 |
+
if not text:
|
| 195 |
+
return history, None, None
|
| 196 |
+
trans = translate(text, cfg["src"], cfg["tgt"])
|
| 197 |
+
tts_path = speak(trans, cfg)
|
| 198 |
+
except Exception as e:
|
| 199 |
+
history.append({"role": "assistant", "content": f"⚠️ 出错了:{e}"})
|
| 200 |
+
return history, None, None
|
| 201 |
+
history.append({"role": "user", "content": f"[{cfg['src_tag']}] {text}"})
|
| 202 |
+
history.append({"role": "assistant", "content": f"[{cfg['tgt_tag']}] {trans}"})
|
| 203 |
+
history.append({"role": "assistant", "content": {"path": tts_path}})
|
| 204 |
+
return history, None, tts_path
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# =============================================================================
|
| 208 |
+
# 同声传译模式:新增部分开始
|
| 209 |
+
# =============================================================================
|
| 210 |
+
|
| 211 |
+
SAMPLE_RATE = 16000
|
| 212 |
+
FRAME_MS = 30 # webrtcvad 只接受 10/20/30ms 帧
|
| 213 |
+
FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS // 1000 # 480
|
| 214 |
+
SILENCE_END_MS = 600 # 静音超过这个时长,判定"一句话说完了"——按你实测效果调
|
| 215 |
+
SILENCE_END_FRAMES = SILENCE_END_MS // FRAME_MS
|
| 216 |
+
PARTIAL_UPDATE_SEC = 1.0 # 隔多久刷新一次"正在识别"的临时字幕,太频繁会拖垮 CPU
|
| 217 |
+
VAD_AGGRESSIVENESS = 2 # 0~3,越大越倾向于把不确定的帧判成"非语音"
|
| 218 |
+
|
| 219 |
+
# 中/英同传共用一个本地模型,别分开加载浪费内存
|
| 220 |
+
# CPU 环境:small + int8 是速度和准确率的折中;机器扛不住可以降到 base,效果会差一些
|
| 221 |
+
zh_en_asr_model = WhisperModel("small", device="cpu", compute_type="int8")
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def to_16k_mono_float32(sr: int, audio: np.ndarray) -> np.ndarray:
|
| 225 |
+
"""把 Gradio 传来的任意采样率/声道音频,统一转成 16kHz 单声道 float32 [-1, 1]"""
|
| 226 |
+
if audio.ndim > 1:
|
| 227 |
+
audio = audio.mean(axis=1)
|
| 228 |
+
audio = audio.astype(np.float32)
|
| 229 |
+
if np.abs(audio).max() > 1.0:
|
| 230 |
+
audio = audio / 32768.0 # 原始是 int16 range
|
| 231 |
+
if sr != SAMPLE_RATE:
|
| 232 |
+
audio = resample_poly(audio, SAMPLE_RATE, sr).astype(np.float32)
|
| 233 |
+
return audio
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def float32_to_pcm16_bytes(audio_f32: np.ndarray) -> bytes:
|
| 237 |
+
clipped = np.clip(audio_f32, -1.0, 1.0)
|
| 238 |
+
return (clipped * 32767).astype(np.int16).tobytes()
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
class VadSegmenter:
|
| 242 |
+
"""逐 30ms 帧跑 WebRTC VAD,判断当前是"说话中"还是"停顿到可以收尾了" """
|
| 243 |
+
|
| 244 |
+
def __init__(self, aggressiveness=VAD_AGGRESSIVENESS):
|
| 245 |
+
self.vad = webrtcvad.Vad(aggressiveness)
|
| 246 |
+
self.silence_frames = 0
|
| 247 |
+
self.in_speech = False
|
| 248 |
+
self.last_is_speech = False
|
| 249 |
+
|
| 250 |
+
def feed(self, frame_bytes: bytes) -> str:
|
| 251 |
+
"""返回 'speaking' / 'pause_end' / 'silence'"""
|
| 252 |
+
is_speech = self.vad.is_speech(frame_bytes, SAMPLE_RATE)
|
| 253 |
+
self.last_is_speech = is_speech
|
| 254 |
+
if is_speech:
|
| 255 |
+
self.silence_frames = 0
|
| 256 |
+
self.in_speech = True
|
| 257 |
+
return "speaking"
|
| 258 |
+
if self.in_speech:
|
| 259 |
+
self.silence_frames += 1
|
| 260 |
+
if self.silence_frames >= SILENCE_END_FRAMES:
|
| 261 |
+
self.in_speech = False
|
| 262 |
+
self.silence_frames = 0
|
| 263 |
+
return "pause_end"
|
| 264 |
+
return "speaking" # 短暂停顿,还不算说完一句
|
| 265 |
+
return "silence"
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
class StreamingClauseASR:
|
| 269 |
+
"""
|
| 270 |
+
维护"当前正在说的这一句"的音频缓冲区,随缓冲增长周期性重新识别。
|
| 271 |
+
用 LocalAgreement 策略:前后两次识别结果里一致的稳定前缀才算"确认文本"
|
| 272 |
+
展示出来,剩余部分标为"待确认"(下一轮可能改),这样能做到边说边出字,
|
| 273 |
+
又不会因为 whisper 反复改主意导致画面疯狂抖动。
|
| 274 |
+
"""
|
| 275 |
+
|
| 276 |
+
def __init__(self, model: WhisperModel, language: str):
|
| 277 |
+
self.model = model
|
| 278 |
+
self.language = language # "zh" or "en"
|
| 279 |
+
self.buffer = np.zeros(0, dtype=np.float32)
|
| 280 |
+
self.confirmed_text = ""
|
| 281 |
+
self.last_hyp = ""
|
| 282 |
+
|
| 283 |
+
def add_audio(self, frame_f32: np.ndarray):
|
| 284 |
+
self.buffer = np.concatenate([self.buffer, frame_f32])
|
| 285 |
+
|
| 286 |
+
def partial_transcribe(self):
|
| 287 |
+
if len(self.buffer) < SAMPLE_RATE * 0.3:
|
| 288 |
+
return self.confirmed_text, ""
|
| 289 |
+
segments, _ = self.model.transcribe(
|
| 290 |
+
self.buffer, language=self.language, task="transcribe",
|
| 291 |
+
beam_size=1, vad_filter=False, condition_on_previous_text=False,
|
| 292 |
+
)
|
| 293 |
+
hyp = "".join(s.text for s in segments).strip()
|
| 294 |
+
stable = self._common_prefix(self.last_hyp, hyp)
|
| 295 |
+
self.last_hyp = hyp
|
| 296 |
+
if len(stable) > len(self.confirmed_text):
|
| 297 |
+
self.confirmed_text = stable
|
| 298 |
+
tentative = hyp[len(self.confirmed_text):]
|
| 299 |
+
return self.confirmed_text, tentative
|
| 300 |
+
|
| 301 |
+
@staticmethod
|
| 302 |
+
def _common_prefix(a: str, b: str) -> str:
|
| 303 |
+
n = min(len(a), len(b))
|
| 304 |
+
i = 0
|
| 305 |
+
while i < n and a[i] == b[i]:
|
| 306 |
+
i += 1
|
| 307 |
+
return a[:i]
|
| 308 |
+
|
| 309 |
+
def finalize(self) -> str:
|
| 310 |
+
"""VAD 判定一句话说完了,做一次高质量的完整识别,拿到这句话的最终文本"""
|
| 311 |
+
if len(self.buffer) < SAMPLE_RATE * 0.2:
|
| 312 |
+
text = self.confirmed_text
|
| 313 |
+
else:
|
| 314 |
+
segments, _ = self.model.transcribe(
|
| 315 |
+
self.buffer, language=self.language, task="transcribe",
|
| 316 |
+
beam_size=5, vad_filter=True, condition_on_previous_text=False,
|
| 317 |
+
)
|
| 318 |
+
text = "".join(s.text for s in segments).strip()
|
| 319 |
+
self.buffer = np.zeros(0, dtype=np.float32)
|
| 320 |
+
self.confirmed_text = ""
|
| 321 |
+
self.last_hyp = ""
|
| 322 |
+
return text
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# --- 后台流水线:翻译、合成各跑一条常驻线程,互不阻塞 -----------------------
|
| 326 |
+
|
| 327 |
+
translate_queue: "queue.Queue" = queue.Queue()
|
| 328 |
+
speak_queue: "queue.Queue" = queue.Queue()
|
| 329 |
+
|
| 330 |
+
_session_lock = threading.Lock()
|
| 331 |
+
_session_result_queues: dict[str, "queue.Queue"] = {}
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def get_session_queue(session_id: str) -> "queue.Queue":
|
| 335 |
+
with _session_lock:
|
| 336 |
+
if session_id not in _session_result_queues:
|
| 337 |
+
_session_result_queues[session_id] = queue.Queue()
|
| 338 |
+
return _session_result_queues[session_id]
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def translate_worker():
|
| 342 |
+
while True:
|
| 343 |
+
session_id, clause_id, text, cfg = translate_queue.get()
|
| 344 |
+
try:
|
| 345 |
+
trans = translate(text, cfg["src"], cfg["tgt"])
|
| 346 |
+
speak_queue.put((session_id, clause_id, trans, cfg))
|
| 347 |
+
except Exception as e:
|
| 348 |
+
get_session_queue(session_id).put(("error", clause_id, f"翻译出错:{e}"))
|
| 349 |
+
finally:
|
| 350 |
+
translate_queue.task_done()
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def speak_worker():
|
| 354 |
+
while True:
|
| 355 |
+
session_id, clause_id, trans, cfg = speak_queue.get()
|
| 356 |
+
try:
|
| 357 |
+
tts_path = speak(trans, cfg)
|
| 358 |
+
tagged = f"[{cfg['tgt_tag']}] {trans}"
|
| 359 |
+
get_session_queue(session_id).put(("done", clause_id, tagged, tts_path))
|
| 360 |
+
except Exception as e:
|
| 361 |
+
get_session_queue(session_id).put(("error", clause_id, f"合成出错:{e}"))
|
| 362 |
+
finally:
|
| 363 |
+
speak_queue.task_done()
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
threading.Thread(target=translate_worker, daemon=True).start()
|
| 367 |
+
threading.Thread(target=speak_worker, daemon=True).start()
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
# --- 每个浏览器会话独立的流水线状态 ------------------------------------------
|
| 371 |
+
|
| 372 |
+
@dataclass
|
| 373 |
+
class SessionState:
|
| 374 |
+
session_id: str
|
| 375 |
+
vad: VadSegmenter = field(default_factory=VadSegmenter)
|
| 376 |
+
asr: Optional[StreamingClauseASR] = None
|
| 377 |
+
lang_code: Optional[str] = None
|
| 378 |
+
leftover: np.ndarray = field(default_factory=lambda: np.zeros(0, dtype=np.float32))
|
| 379 |
+
last_partial_ts: float = 0.0
|
| 380 |
+
history: list = field(default_factory=list)
|
| 381 |
+
partial_index: Optional[int] = None
|
| 382 |
+
clause_counter: int = 0
|
| 383 |
+
pending_clause_index: dict = field(default_factory=dict)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def _update_partial_caption(session: SessionState, cfg, confirmed, tentative):
|
| 387 |
+
line = f"[{cfg['src_tag']}] {confirmed}{tentative}"
|
| 388 |
+
if session.partial_index is not None:
|
| 389 |
+
session.history[session.partial_index]["content"] = line
|
| 390 |
+
else:
|
| 391 |
+
session.history.append({"role": "user", "content": line})
|
| 392 |
+
session.partial_index = len(session.history) - 1
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def _finalize_clause(session: SessionState, cfg):
|
| 396 |
+
text = session.asr.finalize()
|
| 397 |
+
if not text.strip():
|
| 398 |
+
return # VAD 误触发(比如咳嗽声、噪音),没识别出实际内容,直接跳过
|
| 399 |
+
if session.partial_index is not None:
|
| 400 |
+
session.history[session.partial_index]["content"] = f"[{cfg['src_tag']}] {text}"
|
| 401 |
+
session.partial_index = None
|
| 402 |
+
else:
|
| 403 |
+
session.history.append({"role": "user", "content": f"[{cfg['src_tag']}] {text}"})
|
| 404 |
+
|
| 405 |
+
session.clause_counter += 1
|
| 406 |
+
clause_id = session.clause_counter
|
| 407 |
+
session.history.append({"role": "assistant", "content": "⏳ 翻译中…"})
|
| 408 |
+
session.pending_clause_index[clause_id] = len(session.history) - 1
|
| 409 |
+
translate_queue.put((session.session_id, clause_id, text, cfg))
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def _flush_session_results(session: SessionState):
|
| 413 |
+
q = get_session_queue(session.session_id)
|
| 414 |
+
latest_audio = None
|
| 415 |
+
while True:
|
| 416 |
+
try:
|
| 417 |
+
item = q.get_nowait()
|
| 418 |
+
except queue.Empty:
|
| 419 |
+
break
|
| 420 |
+
kind = item[0]
|
| 421 |
+
if kind == "error":
|
| 422 |
+
_, clause_id, msg = item
|
| 423 |
+
idx = session.pending_clause_index.pop(clause_id, None)
|
| 424 |
+
if idx is not None:
|
| 425 |
+
session.history[idx]["content"] = f"⚠️ {msg}"
|
| 426 |
+
elif kind == "done":
|
| 427 |
+
_, clause_id, tagged_text, tts_path = item
|
| 428 |
+
idx = session.pending_clause_index.pop(clause_id, None)
|
| 429 |
+
if idx is not None:
|
| 430 |
+
session.history[idx]["content"] = tagged_text
|
| 431 |
+
latest_audio = tts_path
|
| 432 |
+
return latest_audio
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def ingest_audio_chunk(session: Optional[SessionState], mode_label: str, new_chunk):
|
| 436 |
+
"""
|
| 437 |
+
Gradio 流式麦克风的回调,大约每 stream_every 秒被调用一次,
|
| 438 |
+
每次带来一小段新录到的音频。这里做:分帧喂 VAD -> 判断是否说完一句 ->
|
| 439 |
+
刷新临时字幕 -> 顺便把后台线程做完的翻译/合成结果捞回来更新界面。
|
| 440 |
+
"""
|
| 441 |
+
if new_chunk is None:
|
| 442 |
+
return session, (session.history if session else []), None
|
| 443 |
+
|
| 444 |
+
if session is None:
|
| 445 |
+
session = SessionState(session_id=uuid.uuid4().hex)
|
| 446 |
+
|
| 447 |
+
cfg = STREAMING_LANG_MAP[mode_label]
|
| 448 |
+
lang_code = "zh" if cfg["src"] == "Chinese" else "en"
|
| 449 |
+
if session.asr is None or session.lang_code != lang_code:
|
| 450 |
+
# 切换了翻译方向,重建识别器和 VAD 状态,避免残留上一方向的缓冲
|
| 451 |
+
session.asr = StreamingClauseASR(zh_en_asr_model, lang_code)
|
| 452 |
+
session.lang_code = lang_code
|
| 453 |
+
session.vad = VadSegmenter()
|
| 454 |
+
session.leftover = np.zeros(0, dtype=np.float32)
|
| 455 |
+
|
| 456 |
+
if not API_KEY:
|
| 457 |
+
session.history.append({"role": "assistant", "content": "⚠️ 未配置 SILICONFLOW_API_KEY"})
|
| 458 |
+
return session, session.history, None
|
| 459 |
+
|
| 460 |
+
sr, chunk = new_chunk
|
| 461 |
+
audio_f32 = to_16k_mono_float32(sr, chunk)
|
| 462 |
+
session.leftover = np.concatenate([session.leftover, audio_f32])
|
| 463 |
+
|
| 464 |
+
while len(session.leftover) >= FRAME_SAMPLES:
|
| 465 |
+
frame = session.leftover[:FRAME_SAMPLES]
|
| 466 |
+
session.leftover = session.leftover[FRAME_SAMPLES:]
|
| 467 |
+
frame_bytes = float32_to_pcm16_bytes(frame)
|
| 468 |
+
status = session.vad.feed(frame_bytes)
|
| 469 |
+
if session.vad.last_is_speech or session.vad.in_speech:
|
| 470 |
+
session.asr.add_audio(frame)
|
| 471 |
+
if status == "pause_end":
|
| 472 |
+
_finalize_clause(session, cfg)
|
| 473 |
+
|
| 474 |
+
now = time.time()
|
| 475 |
+
if session.vad.in_speech and now - session.last_partial_ts >= PARTIAL_UPDATE_SEC:
|
| 476 |
+
session.last_partial_ts = now
|
| 477 |
+
confirmed, tentative = session.asr.partial_transcribe()
|
| 478 |
+
if confirmed or tentative:
|
| 479 |
+
_update_partial_caption(session, cfg, confirmed, tentative)
|
| 480 |
+
|
| 481 |
+
tts_path = _flush_session_results(session)
|
| 482 |
+
|
| 483 |
+
return session, session.history, (tts_path if tts_path else gr.update())
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
# =============================================================================
|
| 487 |
+
# UI
|
| 488 |
+
# =============================================================================
|
| 489 |
+
|
| 490 |
+
with gr.Blocks(title="传声筒") as demo:
|
| 491 |
+
gr.Markdown("## 🎙️ 传声筒")
|
| 492 |
+
|
| 493 |
+
with gr.Tab("同声传译(中↔英,持续聆听)"):
|
| 494 |
+
gr.Markdown(
|
| 495 |
+
"点一下开始,持续说话即可,不用点停止。程序会自动按停顿分句,"
|
| 496 |
+
"边听边识别、边翻译边播放译文,通常滞后几秒。\n"
|
| 497 |
+
"⚠️ 实验性功能:识别用的是本地 CPU 模型,参数(停顿判定时长、模型大小)"
|
| 498 |
+
"可能需要根据你的机器实际表现调整。"
|
| 499 |
+
)
|
| 500 |
+
mode_stream = gr.Radio(
|
| 501 |
+
choices=list(STREAMING_LANG_MAP.keys()),
|
| 502 |
+
value="英语 → 中文",
|
| 503 |
+
label="翻译方向",
|
| 504 |
+
)
|
| 505 |
+
audio_in_stream = gr.Audio(
|
| 506 |
+
sources=["microphone"],
|
| 507 |
+
type="numpy",
|
| 508 |
+
streaming=True,
|
| 509 |
+
label="持续录音",
|
| 510 |
+
)
|
| 511 |
+
chatbot_stream = gr.Chatbot(label="同传记录", height=460)
|
| 512 |
+
tts_out_stream = gr.Audio(label="译文语音", autoplay=True, interactive=False)
|
| 513 |
+
|
| 514 |
+
session_state = gr.State(None)
|
| 515 |
+
|
| 516 |
+
audio_in_stream.stream(
|
| 517 |
+
fn=ingest_audio_chunk,
|
| 518 |
+
inputs=[session_state, mode_stream, audio_in_stream],
|
| 519 |
+
outputs=[session_state, chatbot_stream, tts_out_stream],
|
| 520 |
+
stream_every=0.5,
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
mode_stream.change(
|
| 524 |
+
fn=lambda: (None, [], None),
|
| 525 |
+
inputs=None,
|
| 526 |
+
outputs=[session_state, chatbot_stream, tts_out_stream],
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
with gr.Tab("点击录音(六个方向,原来的模式)"):
|
| 530 |
+
gr.Markdown(
|
| 531 |
+
"点麦克风开始录音,说一句完整的话,再点一次停止,自动识别+翻译+朗读译文。\n"
|
| 532 |
+
"识别与翻译主要使用硅基流动,法语识别、法语合成使用本地方案补充。"
|
| 533 |
+
)
|
| 534 |
+
mode = gr.Radio(choices=list(LANG_MAP.keys()), value="英语 → 中文", label="翻译方向")
|
| 535 |
+
audio_in = gr.Audio(sources=["microphone"], type="filepath", label="点击录音,再点一次停止")
|
| 536 |
+
chatbot = gr.Chatbot(label="传译记录(含可重听音频)", height=460)
|
| 537 |
+
tts_out = gr.Audio(label="最新译文语音", autoplay=True, interactive=False)
|
| 538 |
+
|
| 539 |
+
audio_in.stop_recording(
|
| 540 |
+
fn=process,
|
| 541 |
+
inputs=[audio_in, mode, chatbot],
|
| 542 |
+
outputs=[chatbot, audio_in, tts_out],
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
demo.launch()
|