fdbw commited on
Commit
82a7bb6
·
verified ·
1 Parent(s): a79bdbd

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +545 -0
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()