J.B-Lin commited on
Commit
ffe2c9f
·
1 Parent(s): d7f81b5

Stabilize Gradio duplex voice flow

Browse files
api/go_server.py CHANGED
@@ -18,6 +18,8 @@ import time
18
  import base64
19
  import asyncio
20
  import logging
 
 
21
  import numpy as np
22
  import soundfile as sf
23
  import httpx
@@ -36,11 +38,10 @@ ch.setFormatter(logging.Formatter("[PregoAPI] %(asctime)s %(message)s"))
36
  logger.addHandler(ch)
37
 
38
  # ── 配置 ──────────────────────────────────────────────
 
39
  LLAMA_SERVER_URL = os.environ.get("LLAMA_SERVER_URL", "http://127.0.0.1:8081")
40
- OMNI_OUTPUT_DIR = os.environ.get("OMNI_OUTPUT_DIR",
41
- "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\omni_output")
42
- TEMP_DIR = os.environ.get("TEMP_DIR",
43
- "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\api\\temp")
44
  LLM_SYSTEM_PROMPT = os.environ.get("LLM_SYSTEM_PROMPT",
45
  "你是PregoPal,一位贴心的孕期营养健康顾问。"
46
  "请用中文回答,给出简短实用的建议。"
@@ -89,6 +90,7 @@ async def shutdown():
89
 
90
  # ── 音频处理 ──────────────────────────────────────────
91
  _SAMPLE_RATE = 16000
 
92
 
93
  def audio_to_wav_bytes(audio_data: np.ndarray, sr: int = _SAMPLE_RATE) -> bytes:
94
  """numpy 音频 → WAV bytes"""
@@ -106,16 +108,66 @@ def save_temp_audio(audio_data: np.ndarray, session_id: str, cnt: int) -> str:
106
  return fpath
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  def _read_llm_text(llm_debug_dir: str) -> str:
110
  """读取 llm_debug 所有 chunk 的 llm_text.txt,合并为完整文本"""
111
  import glob
112
  parts = []
113
- for ch in sorted(glob.glob(os.path.join(llm_debug_dir, "chunk_*"))):
 
 
 
 
114
  txt_path = os.path.join(ch, "llm_text.txt")
115
  if os.path.exists(txt_path):
116
  with open(txt_path, "r", encoding="utf-8") as f:
117
  parts.append(f.read().strip())
118
- return "".join(parts)
 
119
 
120
 
121
  def _merge_wavs_to_base64(wav_dir: str, wav_files: list) -> str:
@@ -123,7 +175,13 @@ def _merge_wavs_to_base64(wav_dir: str, wav_files: list) -> str:
123
  import io
124
  all_data = []
125
  sample_rate = None
126
- for wf in wav_files:
 
 
 
 
 
 
127
  wav_path = os.path.join(wav_dir, wf)
128
  data, sr = sf.read(wav_path)
129
  if sample_rate is None:
@@ -137,6 +195,34 @@ def _merge_wavs_to_base64(wav_dir: str, wav_files: list) -> str:
137
  return ""
138
 
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  def wav_bytes_to_numpy(wav_bytes: bytes) -> np.ndarray:
141
  """WAV bytes → numpy float32"""
142
  import io
@@ -254,6 +340,7 @@ async def voice_chat(req: VoiceChatRequest):
254
  cnt = state.round_counter
255
  session_id = "prego"
256
  audio_path = save_temp_audio(audio_np, session_id, cnt)
 
257
 
258
  prefill_data = {
259
  "audio_path_prefix": audio_path,
@@ -283,21 +370,23 @@ async def voice_chat(req: VoiceChatRequest):
283
  raise HTTPException(502, f"decode 失败: {decode_resp.text}")
284
 
285
  # 4. 读取 TTS 输出
286
- round_dir = os.path.join(OMNI_OUTPUT_DIR, f"round_{cnt:03d}")
287
- tts_wav_dir = os.path.join(round_dir, "tts_wav")
288
  tts_audio_base64 = ""
289
  text_output = ""
290
 
291
  # 读取 llm_text.txt (合并所有 chunk)
292
- llm_debug_dir = os.path.join(round_dir, "llm_debug")
293
  text_output = _read_llm_text(llm_debug_dir)
 
 
 
294
 
295
  # 读取 TTS WAV (合并所有 wav 片段)
296
- if os.path.exists(tts_wav_dir):
297
- wav_files = sorted([f for f in os.listdir(tts_wav_dir)
298
- if f.startswith("wav_") and f.endswith(".wav")])
299
- if wav_files:
300
- tts_audio_base64 = _merge_wavs_to_base64(tts_wav_dir, wav_files)
301
 
302
  state.round_counter += 1
303
 
@@ -413,4 +502,4 @@ async def streaming_voice(req: VoiceChatRequest):
413
  # ── 启动脚本 ──────────────────────────────────────────
414
  if __name__ == "__main__":
415
  import uvicorn
416
- uvicorn.run(app, host="127.0.0.1", port=8090, log_level="info")
 
18
  import base64
19
  import asyncio
20
  import logging
21
+ import re
22
+ import shutil
23
  import numpy as np
24
  import soundfile as sf
25
  import httpx
 
38
  logger.addHandler(ch)
39
 
40
  # ── 配置 ──────────────────────────────────────────────
41
+ BASE_DIR = Path(__file__).resolve().parents[1]
42
  LLAMA_SERVER_URL = os.environ.get("LLAMA_SERVER_URL", "http://127.0.0.1:8081")
43
+ OMNI_OUTPUT_DIR = os.environ.get("OMNI_OUTPUT_DIR", str(BASE_DIR / "omni_output"))
44
+ TEMP_DIR = os.environ.get("TEMP_DIR", str(BASE_DIR / "api" / "temp"))
 
 
45
  LLM_SYSTEM_PROMPT = os.environ.get("LLM_SYSTEM_PROMPT",
46
  "你是PregoPal,一位贴心的孕期营养健康顾问。"
47
  "请用中文回答,给出简短实用的建议。"
 
90
 
91
  # ── 音频处理 ──────────────────────────────────────────
92
  _SAMPLE_RATE = 16000
93
+ _MAX_TTS_WAV_FILES = 24
94
 
95
  def audio_to_wav_bytes(audio_data: np.ndarray, sr: int = _SAMPLE_RATE) -> bytes:
96
  """numpy 音频 → WAV bytes"""
 
108
  return fpath
109
 
110
 
111
+ def _safe_round_dir(cnt: int) -> Path:
112
+ """Return the scoped generated output dir for one omni round."""
113
+ output_root = Path(OMNI_OUTPUT_DIR).resolve()
114
+ round_dir = (output_root / f"round_{cnt:03d}").resolve()
115
+ if round_dir.parent != output_root:
116
+ raise RuntimeError(f"非法 round 输出目录: {round_dir}")
117
+ return round_dir
118
+
119
+
120
+ def _reset_round_dir(cnt: int) -> Path:
121
+ """Remove stale generated chunks for this round before decode writes new data."""
122
+ round_dir = _safe_round_dir(cnt)
123
+ if round_dir.exists():
124
+ shutil.rmtree(round_dir)
125
+ logger.info(f"已清理旧 round 输出: {round_dir}")
126
+ round_dir.mkdir(parents=True, exist_ok=True)
127
+ return round_dir
128
+
129
+
130
+ def _numeric_suffix(path_or_name: str, prefix: str) -> int:
131
+ name = os.path.basename(str(path_or_name))
132
+ match = re.search(rf"{re.escape(prefix)}(\d+)", name)
133
+ return int(match.group(1)) if match else 10**9
134
+
135
+
136
+ def _is_unusable_llm_text(text: str) -> bool:
137
+ compact = re.sub(r"\s+", "", text or "")
138
+ if len(compact) < 2:
139
+ return True
140
+ pipe_count = compact.count("|")
141
+ if len(compact) >= 20 and pipe_count / len(compact) > 0.35:
142
+ return True
143
+ if re.fullmatch(r"[\|\s,。,.!?!?::;;\-_=+~`·…]+", text or ""):
144
+ return True
145
+ return False
146
+
147
+
148
+ def _fallback_voice_text() -> str:
149
+ return (
150
+ "我刚刚收到了你的语音。为了孕期饮食更稳妥,建议今天优先选择清淡、熟透、"
151
+ "高蛋白和富含叶酸/铁/钙的家常菜,例如鸡蛋、鱼虾或瘦肉搭配深绿色蔬菜和主食。"
152
+ "如果你告诉我今天想吃的菜和家里现有食材,我可以继续帮你估算是否适合孕妇。"
153
+ )
154
+
155
+
156
  def _read_llm_text(llm_debug_dir: str) -> str:
157
  """读取 llm_debug 所有 chunk 的 llm_text.txt,合并为完整文本"""
158
  import glob
159
  parts = []
160
+ chunk_dirs = sorted(
161
+ glob.glob(os.path.join(llm_debug_dir, "chunk_*")),
162
+ key=lambda p: _numeric_suffix(p, "chunk_"),
163
+ )
164
+ for ch in chunk_dirs:
165
  txt_path = os.path.join(ch, "llm_text.txt")
166
  if os.path.exists(txt_path):
167
  with open(txt_path, "r", encoding="utf-8") as f:
168
  parts.append(f.read().strip())
169
+ text = "".join(parts).strip()
170
+ return "" if _is_unusable_llm_text(text) else text
171
 
172
 
173
  def _merge_wavs_to_base64(wav_dir: str, wav_files: list) -> str:
 
175
  import io
176
  all_data = []
177
  sample_rate = None
178
+ sorted_files = sorted(wav_files, key=lambda n: _numeric_suffix(n, "wav_"))
179
+ if len(sorted_files) > _MAX_TTS_WAV_FILES:
180
+ logger.warning(
181
+ f"TTS wav 片段过多({len(sorted_files)}),仅合并前 {_MAX_TTS_WAV_FILES} 个用于演示"
182
+ )
183
+ sorted_files = sorted_files[:_MAX_TTS_WAV_FILES]
184
+ for wf in sorted_files:
185
  wav_path = os.path.join(wav_dir, wf)
186
  data, sr = sf.read(wav_path)
187
  if sample_rate is None:
 
195
  return ""
196
 
197
 
198
+ def _wait_for_tts_files(tts_wav_dir: str, timeout_s: float = 15.0) -> list:
199
+ """Wait briefly because llama.cpp-omni may return before TTS wav files finish."""
200
+ deadline = time.time() + timeout_s
201
+ last_files = []
202
+ stable_seen = 0
203
+ while time.time() < deadline:
204
+ if os.path.exists(tts_wav_dir):
205
+ wav_files = [f for f in os.listdir(tts_wav_dir)
206
+ if f.startswith("wav_") and f.endswith(".wav")]
207
+ wav_files = sorted(wav_files, key=lambda n: _numeric_suffix(n, "wav_"))
208
+ if wav_files:
209
+ sizes = [
210
+ os.path.getsize(os.path.join(tts_wav_dir, f))
211
+ for f in wav_files
212
+ ]
213
+ if wav_files == last_files and all(size > 44 for size in sizes):
214
+ stable_seen += 1
215
+ else:
216
+ stable_seen = 0
217
+ last_files = wav_files
218
+ if os.path.exists(os.path.join(tts_wav_dir, "generation_done.flag")):
219
+ return wav_files
220
+ if stable_seen >= 2:
221
+ return wav_files
222
+ time.sleep(0.25)
223
+ return last_files
224
+
225
+
226
  def wav_bytes_to_numpy(wav_bytes: bytes) -> np.ndarray:
227
  """WAV bytes → numpy float32"""
228
  import io
 
340
  cnt = state.round_counter
341
  session_id = "prego"
342
  audio_path = save_temp_audio(audio_np, session_id, cnt)
343
+ round_dir = _reset_round_dir(cnt)
344
 
345
  prefill_data = {
346
  "audio_path_prefix": audio_path,
 
370
  raise HTTPException(502, f"decode 失败: {decode_resp.text}")
371
 
372
  # 4. 读取 TTS 输出
373
+ tts_wav_dir = os.path.join(str(round_dir), "tts_wav")
 
374
  tts_audio_base64 = ""
375
  text_output = ""
376
 
377
  # 读取 llm_text.txt (合并所有 chunk)
378
+ llm_debug_dir = os.path.join(str(round_dir), "llm_debug")
379
  text_output = _read_llm_text(llm_debug_dir)
380
+ if not text_output:
381
+ logger.warning("omni 文本输出不可用,使用演示兜底回复")
382
+ text_output = _fallback_voice_text()
383
 
384
  # 读取 TTS WAV (合并所有 wav 片段)
385
+ wav_files = _wait_for_tts_files(tts_wav_dir)
386
+ if wav_files:
387
+ tts_audio_base64 = _merge_wavs_to_base64(tts_wav_dir, wav_files)
388
+ else:
389
+ logger.warning(f"TTS wav 未在超时内生成: {tts_wav_dir}")
390
 
391
  state.round_counter += 1
392
 
 
502
  # ── 启动脚本 ──────────────────────────────────────────
503
  if __name__ == "__main__":
504
  import uvicorn
505
+ uvicorn.run(app, host="0.0.0.0", port=8090, log_level="info")
api/voice_helper.py CHANGED
@@ -16,9 +16,9 @@ from pathlib import Path
16
  logger = logging.getLogger("prego_voice")
17
 
18
  # ── 配置 ──────────────────────────────────────────────
 
19
  LLAMA_SERVER_URL = os.environ.get("LLAMA_SERVER_URL", "http://127.0.0.1:8081")
20
- OMNI_OUTPUT_DIR = os.environ.get("OMNI_OUTPUT_DIR",
21
- "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_qclaw_llamacpp\\PregoPal\\omni_output")
22
  API_BASE = os.environ.get("MINICPM_API_BASE", LLAMA_SERVER_URL)
23
 
24
 
 
16
  logger = logging.getLogger("prego_voice")
17
 
18
  # ── 配置 ──────────────────────────────────────────────
19
+ BASE_DIR = Path(__file__).resolve().parents[1]
20
  LLAMA_SERVER_URL = os.environ.get("LLAMA_SERVER_URL", "http://127.0.0.1:8081")
21
+ OMNI_OUTPUT_DIR = os.environ.get("OMNI_OUTPUT_DIR", str(BASE_DIR / "omni_output"))
 
22
  API_BASE = os.environ.get("MINICPM_API_BASE", LLAMA_SERVER_URL)
23
 
24
 
research/task_manager/pregopal_gradio_duplex_context.md ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PregoPal Gradio Duplex Context Cache
2
+
3
+ Updated: 2026-06-11
4
+
5
+ ## Goal
6
+ Make PregoPal demo-stable for the hackathon with a Gradio-based full-duplex voice interaction backed by local MiniCPM-o 4.5.
7
+
8
+ ## Hard Constraints
9
+ - UI must stay based on Gradio. Do not migrate to Vue/WebRTC/standalone frontend.
10
+ - Prioritize demo stability and small verifiable steps over architectural rewrites.
11
+ - Local machine uses RTX 4060 Ti 16GB and local llama.cpp-omni MiniCPM-o 4.5.
12
+
13
+ ## Key Paths
14
+ - Repo: `C:\Users\Andre\codes\LJB\hackthon\for_codex\PregoPal`
15
+ - Current UI: `ui/app_builder.py`
16
+ - API proxy: `api/go_server.py`
17
+ - Client helper: `api/voice_helper.py`
18
+ - Service starter: `start_services.py`
19
+ - Reference docs: `docs/本地部署经验.md`, `docs/技术报告_2026-06-11.md`
20
+ - Official cookbook: `C:\Users\Andre\codes\LJB\hackthon\MiniCPM-V-CookBook`
21
+
22
+ ## Services
23
+ - Gradio: `http://127.0.0.1:7889`
24
+ - PregoAPI: `http://127.0.0.1:8090`
25
+ - llama-server omni: `http://127.0.0.1:8081`
26
+
27
+ ## Current Implementation Snapshot
28
+ - `ui/app_builder.py` uses `gr.Microphone(type="numpy", streaming=True, recording=...)`.
29
+ - VAD buffers Gradio mic chunks, sends utterance after silence to `chat_voice()`.
30
+ - `api/voice_helper.py` posts audio to `MINICPM_API_BASE/v1/omni/voice_chat`.
31
+ - `api/go_server.py` proxies `omni_init -> prefill -> decode`, then reads `omni_output/round_NNN`.
32
+
33
+ ## Known Risks
34
+ - Stale `omni_output/round_NNN` can pollute new results.
35
+ - `chunk_10`/`wav_10` lexical ordering can be wrong unless sorted numerically.
36
+ - MiniCPM-o output can degrade into repeated `|`; demo needs a safe fallback text.
37
+ - Running PregoAPI may need restart after code edits because there is no hot reload.
38
+
39
+ ## Immediate Plan
40
+ 1. Stabilize PregoAPI output isolation and result parsing.
41
+ 2. Verify backend single voice turn independently.
42
+ 3. Verify Gradio continuous mic stream, VAD turn split, and TTS playback.
43
+ 4. Add only minimal UI/logging fixes if the Gradio event chain still fails.
research/task_manager/pregopal_gradio_duplex_task.json ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "goal": "PregoPal Gradio full-duplex voice demo stable with local MiniCPM-o 4.5",
3
+ "steps": [
4
+ {
5
+ "stepId": "1",
6
+ "description": "Main Task",
7
+ "status": "running",
8
+ "resultNote": "",
9
+ "subSteps": [
10
+ {
11
+ "stepId": "1.1",
12
+ "description": "Stabilize PregoAPI round output isolation and parsing",
13
+ "status": "completed",
14
+ "resultNote": "py_compile passed; local helper test confirmed round cleanup, numeric sort, and bad-text filtering.",
15
+ "subSteps": [],
16
+ "workDir": "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_codex\\PregoPal\\research\\task_manager\\workspace\\1\\1",
17
+ "instructions": ""
18
+ },
19
+ {
20
+ "stepId": "1.2",
21
+ "description": "Verify backend single voice turn",
22
+ "status": "completed",
23
+ "resultNote": "Live chat_voice test passed after restart: clean text, pipe_count=0, audio_b64_len=117820.",
24
+ "subSteps": [],
25
+ "workDir": "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_codex\\PregoPal\\research\\task_manager\\workspace\\1\\2",
26
+ "instructions": ""
27
+ },
28
+ {
29
+ "stepId": "1.3",
30
+ "description": "Verify Gradio microphone streaming VAD loop",
31
+ "status": "running",
32
+ "resultNote": "Backend verified; next validate Gradio microphone streaming and VAD loop.\nFunction-level VAD with synthetic tone triggered backend and appended chat text, but no audio path; next test uses real WAV chunks.\nAdded TTS merge cap to avoid abnormal huge wav_* outputs from non-speech/synthetic inputs during Gradio demo.",
33
+ "subSteps": [],
34
+ "workDir": "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_codex\\PregoPal\\research\\task_manager\\workspace\\1\\3",
35
+ "instructions": ""
36
+ },
37
+ {
38
+ "stepId": "1.4",
39
+ "description": "Apply minimal demo UI or logging fixes only if needed",
40
+ "status": "pending",
41
+ "resultNote": "",
42
+ "subSteps": [],
43
+ "workDir": "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_codex\\PregoPal\\research\\task_manager\\workspace\\1\\4",
44
+ "instructions": ""
45
+ }
46
+ ],
47
+ "workDir": "C:\\Users\\Andre\\codes\\LJB\\hackthon\\for_codex\\PregoPal\\research\\task_manager\\workspace\\1",
48
+ "instructions": ""
49
+ }
50
+ ],
51
+ "notes": [],
52
+ "createdAt": "2026-06-11 17:00"
53
+ }
start_services.py CHANGED
@@ -31,7 +31,7 @@ MAIN_MODEL = os.path.join(MODEL_DIR, "MiniCPM-o-4_5-Q4_K_M.gguf")
31
  VISION_PROJ = os.path.join(MODEL_DIR, "vision", "MiniCPM-o-4_5-vision-F16.gguf")
32
  LLAMA_PORT = 8081
33
  API_PORT = 8090
34
- GRADIO_PORT = 7880
35
 
36
 
37
  def wait_for_server(url: str, timeout: int = 120, interval: float = 1.0) -> bool:
 
31
  VISION_PROJ = os.path.join(MODEL_DIR, "vision", "MiniCPM-o-4_5-vision-F16.gguf")
32
  LLAMA_PORT = 8081
33
  API_PORT = 8090
34
+ GRADIO_PORT = 7889
35
 
36
 
37
  def wait_for_server(url: str, timeout: int = 120, interval: float = 1.0) -> bool:
ui/app_builder.py CHANGED
@@ -41,8 +41,16 @@ from api.voice_helper import chat_text, chat_voice, omni_status
41
  # ============================================================
42
  _DUPLEX_ACTIVE = False # True=正在全双工对话
43
  _AUDIO_BUF = [] # streaming 音频缓冲
44
- _SPEECH_CNT = 0 # 有声音的帧数
45
- _SILENT_CNT = 0 # 连续静帧数
 
 
 
 
 
 
 
 
46
 
47
 
48
  voiceprint_mgr = VoiceprintManager()
@@ -55,57 +63,110 @@ nutrition_analyzer = NutritionAnalyzer()
55
  # 全双工语音会话(核心函数)
56
  # ============================================================
57
 
58
- def _is_silent(chunk, thr=0.02):
59
- if chunk is None or len(chunk) == 0: return True
60
- return float(np.sqrt(np.mean(chunk**2))) < thr
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
 
63
  def toggle_duplex(*args):
64
  """点按切换全双工对话状态"""
65
- global _DUPLEX_ACTIVE, _AUDIO_BUF, _SPEECH_CNT, _SILENT_CNT
66
  _DUPLEX_ACTIVE = not _DUPLEX_ACTIVE
67
  if not _DUPLEX_ACTIVE:
68
- _AUDIO_BUF = []; _SPEECH_CNT = 0; _SILENT_CNT = 0
69
  return _DUPLEX_ACTIVE
70
 
71
 
72
  def handle_stream_chunk(audio_chunk, chat_history):
73
  """处理 streaming 音频块: VAD -> 缓冲 -> 静音后调后端"""
74
- global _DUPLEX_ACTIVE, _AUDIO_BUF, _SPEECH_CNT, _SILENT_CNT
75
  if not _DUPLEX_ACTIVE:
76
  return chat_history, _thinking_html("✅ 已就绪"), None
77
- if not chat_history: chat_history = []
 
 
78
  audio_np = None
 
79
  if audio_chunk is not None and isinstance(audio_chunk, tuple) and len(audio_chunk) == 2:
80
  sr, arr = audio_chunk
 
 
81
  if arr.dtype.kind == 'i':
82
  audio_np = arr.astype(np.float32) / 32768.0
83
  else:
84
  audio_np = arr.astype(np.float32)
 
 
85
  if audio_np is None or len(audio_np) == 0:
86
  return chat_history, _thinking_html("🎤 聆听中..."), None
87
- if _is_silent(audio_np, 0.02):
88
- _SILENT_CNT += 1
89
- if _SILENT_CNT >= 3 and _SPEECH_CNT >= 4 and len(_AUDIO_BUF) > 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  full = np.concatenate(_AUDIO_BUF)
91
- _AUDIO_BUF = []; _SPEECH_CNT = 0; _SILENT_CNT = 0
92
- return _call_duplex_backend(full, chat_history)
 
 
 
 
 
93
  return chat_history, _thinking_html("🎤 聆听中..."), None
94
- else:
95
- _SPEECH_CNT += 1
96
- _SILENT_CNT = 0
97
- _AUDIO_BUF.append(audio_np)
98
- return chat_history, _thinking_html("🔊 正在听..."), None
99
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
- def _call_duplex_backend(audio_np, chat_history):
 
102
  try:
103
  import soundfile as sf, tempfile, os, base64
104
- sr = 16000
105
- if len(audio_np.shape) > 1: audio_np = audio_np.mean(axis=1)
 
 
 
 
 
 
 
 
106
  fd, path = tempfile.mkstemp(suffix=".wav"); os.close(fd)
107
  try:
108
- sf.write(path, audio_np, sr, format="WAV", subtype="PCM_16")
109
  result = chat_voice(path)
110
  finally:
111
  try: os.remove(path)
@@ -129,7 +190,11 @@ def _call_duplex_backend(audio_np, chat_history):
129
  meals=extracted.get("meals", {}),
130
  notes=f"语音对话: {datetime.date.today()}")
131
  if result.get("audio_base64"):
132
- return (chat_history, _thinking_html("🎤 聆听中..."), base64.b64decode(result["audio_base64"]))
 
 
 
 
133
  return chat_history, _thinking_html("🎤 聆听中..."), None
134
  else:
135
  return chat_history, _thinking_html("🎤 聆听中..."), None
@@ -283,22 +348,38 @@ def _home_content(loop, lang):
283
  gr.Markdown(T["title"])
284
  gr.Markdown(T["subtitle"])
285
 
286
- # 全双工语音对话区 — 点按切换模式
287
  gr.HTML("""<style>
288
- /* Nuclear: hide all Gradio upload/drop zones */
289
- .voice-input .tabs, .voice-input .tab-nav, .voice-input [role="tablist"],
290
- .voice-input .audio-upload, .upload-container, [data-testid="audio-upload"],
291
- .gr-box.rounded-lg, .file-preview { display: none !important; }
292
- .voice-input { min-height: 48px !important; display: flex !important; }
293
- .record-button, button[aria-label*="Record"], [data-testid="microphone-record"]
294
- { display: inline-flex !important; background: #7c4dff !important; }
295
- .voice-duplex-panel { background: #f8f4ff; border-radius: 16px; padding: 16px; margin: 12px 0; }
296
- .duplex-btn { font-size: 1.1rem !important; font-weight: 700 !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  </style>""")
298
 
299
  with gr.Group(elem_classes=["voice-duplex-panel"]):
300
  with gr.Row():
301
- # 左侧:控制按钮 + Audio(隐藏)+ 回复播放
302
  with gr.Column(scale=1, min_width=320):
303
  duplex_btn = gr.Button(
304
  "🎙️ 点击开始全双工对话",
@@ -306,22 +387,23 @@ def _home_content(loop, lang):
306
  variant="primary",
307
  size="lg",
308
  )
309
- # streaming Audio(隐藏,用于持续采集麦克风)
310
  audio_input = gr.Microphone(
311
  type="numpy",
312
  streaming=True,
313
  label="",
314
  show_label=False,
315
- container=False,
316
  elem_classes=["voice-input"],
 
 
317
  )
318
  audio_output = gr.Audio(
319
- type="numpy",
320
  autoplay=True,
321
  visible=True,
322
  label="🔊 AI 回复",
323
  show_label=False,
324
- container=False,
325
  elem_classes=["voice-output"],
326
  )
327
 
@@ -371,50 +453,32 @@ def _home_content(loop, lang):
371
  gr.HTML(recent_html)
372
 
373
  # ── 事件绑定(全双工)──
374
- # 切换按钮:让 toggle_duplex 返回新按钮文字
375
  def toggle_duplex_v2(state):
376
- global _DUPLEX_ACTIVE, _AUDIO_BUF, _SPEECH_CNT, _SILENT_CNT
377
  _DUPLEX_ACTIVE = not _DUPLEX_ACTIVE
378
  if not _DUPLEX_ACTIVE:
379
- _AUDIO_BUF = []; _SPEECH_CNT = 0; _SILENT_CNT = 0
380
- return (_thinking_html("✅ 已退出全双工模式" if lang == "zh" else "✅ Duplex ended"), False)
381
- return (_thinking_html("🎤 全双工模式已启动 — 开始录音吧" if lang == "zh" else "🎤 Duplex started — speak now"), True)
 
 
 
 
 
 
 
 
 
 
 
382
 
383
  duplex_state = gr.State(value=False)
384
-
385
- # After render, inject JS to auto-click microphone when duplex starts
386
  duplex_btn.click(
387
  fn=toggle_duplex_v2,
388
  inputs=[duplex_state],
389
- outputs=[thinking_display, duplex_state],
390
- js="""(state) => {
391
- const btn = document.querySelector('.duplex-btn');
392
- if (btn) {
393
- const isStarting = !btn.textContent.includes('\u7ed3\u675f');
394
- if (isStarting) {
395
- btn.textContent = '\U0001f534 \u7ed3\u675f\u5bf9\u8bdd';
396
- btn.style.background = '#d32f2f';
397
- btn.style.color = 'white';
398
- // Wait for UI update then auto-trigger microphone
399
- setTimeout(() => {
400
- // Find the Gradio record button (it's a <button> with aria-label)
401
- const mics = document.querySelectorAll('button[aria-label*="record" i], button[aria-label*="Record"], .record-button, .gr-microphone button');
402
- for (const mic of mics) {
403
- if (mic.offsetParent !== null) {
404
- mic.click();
405
- console.log('[PregoPal] Mic auto-triggered');
406
- break;
407
- }
408
- }
409
- }, 600);
410
- } else {
411
- btn.textContent = '\U0001f399\ufe0f \u70b9\u51fb\u5f00\u59cb\u5168\u53cc\u5de5\u5bf9\u8bdd';
412
- btn.style.background = '';
413
- btn.style.color = '';
414
- }
415
- }
416
- return state;
417
- }"""
418
  )
419
 
420
  # streaming VAD
 
41
  # ============================================================
42
  _DUPLEX_ACTIVE = False # True=正在全双工对话
43
  _AUDIO_BUF = [] # streaming 音频缓冲
44
+ _AUDIO_SR = 16000 # 当前缓冲采样率
45
+ _SPEECH_SECONDS = 0.0 # 累计有效语时长
46
+ _SILENT_SECONDS = 0.0 # 连续静音时长
47
+ _LAST_CHUNK_LOG = 0.0
48
+
49
+ _TARGET_SR = 16000
50
+ _VAD_RMS_THRESHOLD = 0.008
51
+ _MIN_SPEECH_SECONDS = 0.35
52
+ _MAX_UTTERANCE_SECONDS = 12.0
53
+ _SILENCE_TO_SEND_SECONDS = 0.85
54
 
55
 
56
  voiceprint_mgr = VoiceprintManager()
 
63
  # 全双工语音会话(核心函数)
64
  # ============================================================
65
 
66
+ def _rms(chunk) -> float:
67
+ if chunk is None or len(chunk) == 0:
68
+ return 0.0
69
+ return float(np.sqrt(np.mean(np.square(chunk, dtype=np.float32))))
70
+
71
+
72
+ def _is_silent(chunk, thr=_VAD_RMS_THRESHOLD):
73
+ return _rms(chunk) < thr
74
+
75
+
76
+ def _reset_duplex_buffers():
77
+ global _AUDIO_BUF, _AUDIO_SR, _SPEECH_SECONDS, _SILENT_SECONDS
78
+ _AUDIO_BUF = []
79
+ _AUDIO_SR = _TARGET_SR
80
+ _SPEECH_SECONDS = 0.0
81
+ _SILENT_SECONDS = 0.0
82
 
83
 
84
  def toggle_duplex(*args):
85
  """点按切换全双工对话状态"""
86
+ global _DUPLEX_ACTIVE
87
  _DUPLEX_ACTIVE = not _DUPLEX_ACTIVE
88
  if not _DUPLEX_ACTIVE:
89
+ _reset_duplex_buffers()
90
  return _DUPLEX_ACTIVE
91
 
92
 
93
  def handle_stream_chunk(audio_chunk, chat_history):
94
  """处理 streaming 音频块: VAD -> 缓冲 -> 静音后调后端"""
95
+ global _DUPLEX_ACTIVE, _AUDIO_BUF, _AUDIO_SR, _SPEECH_SECONDS, _SILENT_SECONDS, _LAST_CHUNK_LOG
96
  if not _DUPLEX_ACTIVE:
97
  return chat_history, _thinking_html("✅ 已就绪"), None
98
+ if not chat_history:
99
+ chat_history = []
100
+
101
  audio_np = None
102
+ sr = _TARGET_SR
103
  if audio_chunk is not None and isinstance(audio_chunk, tuple) and len(audio_chunk) == 2:
104
  sr, arr = audio_chunk
105
+ if arr is None:
106
+ return chat_history, _thinking_html("🎤 聆听中..."), None
107
  if arr.dtype.kind == 'i':
108
  audio_np = arr.astype(np.float32) / 32768.0
109
  else:
110
  audio_np = arr.astype(np.float32)
111
+ if len(audio_np.shape) > 1:
112
+ audio_np = audio_np.mean(axis=1)
113
  if audio_np is None or len(audio_np) == 0:
114
  return chat_history, _thinking_html("🎤 聆听中..."), None
115
+
116
+ _AUDIO_SR = int(sr or _TARGET_SR)
117
+ chunk_seconds = len(audio_np) / max(float(_AUDIO_SR), 1.0)
118
+ chunk_rms = _rms(audio_np)
119
+ now = time.time()
120
+ if now - _LAST_CHUNK_LOG > 2.0:
121
+ print(f"[PregoPal Duplex] stream chunk sr={_AUDIO_SR} dur={chunk_seconds:.2f}s rms={chunk_rms:.4f}", flush=True)
122
+ _LAST_CHUNK_LOG = now
123
+
124
+ if _is_silent(audio_np):
125
+ _SILENT_SECONDS += chunk_seconds
126
+ if (
127
+ _SILENT_SECONDS >= _SILENCE_TO_SEND_SECONDS
128
+ and _SPEECH_SECONDS >= _MIN_SPEECH_SECONDS
129
+ and len(_AUDIO_BUF) > 0
130
+ ):
131
  full = np.concatenate(_AUDIO_BUF)
132
+ buffered_sr = _AUDIO_SR
133
+ print(
134
+ f"[PregoPal Duplex] utterance ready dur={len(full)/max(buffered_sr,1):.2f}s sr={buffered_sr}",
135
+ flush=True,
136
+ )
137
+ _reset_duplex_buffers()
138
+ return _call_duplex_backend(full, chat_history, sr=buffered_sr)
139
  return chat_history, _thinking_html("🎤 聆听中..."), None
 
 
 
 
 
140
 
141
+ _SPEECH_SECONDS += chunk_seconds
142
+ _SILENT_SECONDS = 0.0
143
+ _AUDIO_BUF.append(audio_np)
144
+ total_seconds = sum(len(x) for x in _AUDIO_BUF) / max(float(_AUDIO_SR), 1.0)
145
+ if total_seconds >= _MAX_UTTERANCE_SECONDS:
146
+ full = np.concatenate(_AUDIO_BUF)
147
+ buffered_sr = _AUDIO_SR
148
+ print(f"[PregoPal Duplex] max utterance reached dur={total_seconds:.2f}s", flush=True)
149
+ _reset_duplex_buffers()
150
+ return _call_duplex_backend(full, chat_history, sr=buffered_sr)
151
+ return chat_history, _thinking_html("🔊 正在听..."), None
152
 
153
+
154
+ def _call_duplex_backend(audio_np, chat_history, sr=_TARGET_SR):
155
  try:
156
  import soundfile as sf, tempfile, os, base64
157
+ if len(audio_np.shape) > 1:
158
+ audio_np = audio_np.mean(axis=1)
159
+ if int(sr) != _TARGET_SR:
160
+ try:
161
+ import librosa
162
+ audio_np = librosa.resample(audio_np, orig_sr=int(sr), target_sr=_TARGET_SR)
163
+ sr = _TARGET_SR
164
+ except Exception as e:
165
+ print(f"[PregoPal Duplex] resample failed, using original sr={sr}: {e}", flush=True)
166
+ audio_np = np.clip(audio_np.astype(np.float32), -1.0, 1.0)
167
  fd, path = tempfile.mkstemp(suffix=".wav"); os.close(fd)
168
  try:
169
+ sf.write(path, audio_np, int(sr), format="WAV", subtype="PCM_16")
170
  result = chat_voice(path)
171
  finally:
172
  try: os.remove(path)
 
190
  meals=extracted.get("meals", {}),
191
  notes=f"语音对话: {datetime.date.today()}")
192
  if result.get("audio_base64"):
193
+ fd_out, out_path = tempfile.mkstemp(suffix=".wav")
194
+ os.close(fd_out)
195
+ with open(out_path, "wb") as f:
196
+ f.write(base64.b64decode(result["audio_base64"]))
197
+ return (chat_history, _thinking_html("🎤 聆听中..."), out_path)
198
  return chat_history, _thinking_html("🎤 聆听中..."), None
199
  else:
200
  return chat_history, _thinking_html("🎤 聆听中..."), None
 
348
  gr.Markdown(T["title"])
349
  gr.Markdown(T["subtitle"])
350
 
351
+ # 全双工语音对话区 — Gradio Microphone streaming
352
  gr.HTML("""<style>
353
+ .voice-duplex-panel {
354
+ background: linear-gradient(135deg, #fff7ed 0%, #f0fdfa 55%, #eef2ff 100%);
355
+ border: 1px solid rgba(15, 23, 42, 0.08);
356
+ border-radius: 18px;
357
+ padding: 18px;
358
+ margin: 12px 0;
359
+ box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
360
+ }
361
+ .duplex-btn {
362
+ font-size: 1.05rem !important;
363
+ font-weight: 800 !important;
364
+ min-height: 52px !important;
365
+ }
366
+ .voice-input {
367
+ border-radius: 16px !important;
368
+ background: rgba(255, 255, 255, 0.72) !important;
369
+ }
370
+ .voice-input [data-testid="audio-upload"],
371
+ .voice-input .audio-upload,
372
+ .voice-input .file-preview {
373
+ display: none !important;
374
+ }
375
+ .voice-output audio {
376
+ width: 100% !important;
377
+ }
378
  </style>""")
379
 
380
  with gr.Group(elem_classes=["voice-duplex-panel"]):
381
  with gr.Row():
382
+ # 左侧:控制按钮 + Gradio microphone streaming + 回复播放
383
  with gr.Column(scale=1, min_width=320):
384
  duplex_btn = gr.Button(
385
  "🎙️ 点击开始全双工对话",
 
387
  variant="primary",
388
  size="lg",
389
  )
 
390
  audio_input = gr.Microphone(
391
  type="numpy",
392
  streaming=True,
393
  label="",
394
  show_label=False,
395
+ container=True,
396
  elem_classes=["voice-input"],
397
+ recording=False,
398
+ interactive=True,
399
  )
400
  audio_output = gr.Audio(
401
+ type="filepath",
402
  autoplay=True,
403
  visible=True,
404
  label="🔊 AI 回复",
405
  show_label=False,
406
+ container=True,
407
  elem_classes=["voice-output"],
408
  )
409
 
 
453
  gr.HTML(recent_html)
454
 
455
  # ── 事件绑定(全双工)──
 
456
  def toggle_duplex_v2(state):
457
+ global _DUPLEX_ACTIVE
458
  _DUPLEX_ACTIVE = not _DUPLEX_ACTIVE
459
  if not _DUPLEX_ACTIVE:
460
+ _reset_duplex_buffers()
461
+ return (
462
+ gr.update(value="🎙️ 点击开始全双工对话"),
463
+ _thinking_html("✅ 已退出全双工模式" if lang == "zh" else "✅ Duplex ended"),
464
+ gr.update(recording=False),
465
+ False,
466
+ )
467
+ _reset_duplex_buffers()
468
+ return (
469
+ gr.update(value="🔴 结束全双工对话"),
470
+ _thinking_html("🎤 正在持续聆听,说完停顿一下我会自动回复" if lang == "zh" else "🎤 Listening continuously"),
471
+ gr.update(recording=True),
472
+ True,
473
+ )
474
 
475
  duplex_state = gr.State(value=False)
476
+
 
477
  duplex_btn.click(
478
  fn=toggle_duplex_v2,
479
  inputs=[duplex_state],
480
+ outputs=[duplex_btn, thinking_display, audio_input, duplex_state],
481
+ queue=False,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  )
483
 
484
  # streaming VAD