Spaces:
Runtime error
Runtime error
J.B-Lin commited on
Commit ·
ffe2c9f
1
Parent(s): d7f81b5
Stabilize Gradio duplex voice flow
Browse files- api/go_server.py +105 -16
- api/voice_helper.py +2 -2
- research/task_manager/pregopal_gradio_duplex_context.md +43 -0
- research/task_manager/pregopal_gradio_duplex_task.json +53 -0
- start_services.py +1 -1
- ui/app_builder.py +139 -75
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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 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="
|
|
|
|
| 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 =
|
| 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 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
voiceprint_mgr = VoiceprintManager()
|
|
@@ -55,57 +63,110 @@ nutrition_analyzer = NutritionAnalyzer()
|
|
| 55 |
# 全双工语音会话(核心函数)
|
| 56 |
# ============================================================
|
| 57 |
|
| 58 |
-
def
|
| 59 |
-
if chunk is None or len(chunk) == 0:
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
def toggle_duplex(*args):
|
| 64 |
"""点按切换全双工对话状态"""
|
| 65 |
-
global _DUPLEX_ACTIVE
|
| 66 |
_DUPLEX_ACTIVE = not _DUPLEX_ACTIVE
|
| 67 |
if not _DUPLEX_ACTIVE:
|
| 68 |
-
|
| 69 |
return _DUPLEX_ACTIVE
|
| 70 |
|
| 71 |
|
| 72 |
def handle_stream_chunk(audio_chunk, chat_history):
|
| 73 |
"""处理 streaming 音频块: VAD -> 缓冲 -> 静音后调后端"""
|
| 74 |
-
global _DUPLEX_ACTIVE, _AUDIO_BUF,
|
| 75 |
if not _DUPLEX_ACTIVE:
|
| 76 |
return chat_history, _thinking_html("✅ 已就绪"), None
|
| 77 |
-
if not 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 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
full = np.concatenate(_AUDIO_BUF)
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 102 |
try:
|
| 103 |
import soundfile as sf, tempfile, os, base64
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
.duplex-btn {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
</style>""")
|
| 298 |
|
| 299 |
with gr.Group(elem_classes=["voice-duplex-panel"]):
|
| 300 |
with gr.Row():
|
| 301 |
-
# 左侧:控制按钮 +
|
| 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=
|
| 316 |
elem_classes=["voice-input"],
|
|
|
|
|
|
|
| 317 |
)
|
| 318 |
audio_output = gr.Audio(
|
| 319 |
-
type="
|
| 320 |
autoplay=True,
|
| 321 |
visible=True,
|
| 322 |
label="🔊 AI 回复",
|
| 323 |
show_label=False,
|
| 324 |
-
container=
|
| 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
|
| 377 |
_DUPLEX_ACTIVE = not _DUPLEX_ACTIVE
|
| 378 |
if not _DUPLEX_ACTIVE:
|
| 379 |
-
|
| 380 |
-
return (
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|