This commit is contained in:
xsl
2026-03-27 10:49:34 +08:00
commit 22a5f31847
56 changed files with 5145 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
cd /home/xsl/product
mkdir -p certs
openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
-keyout certs/dev-key.pem \
-out certs/dev-cert.pem \
-subj "/C=CN/ST=Local/L=Local/O=VisualChat/OU=Dev/CN=localhost"
echo "Generated:"
echo " /home/xsl/product/certs/dev-cert.pem"
echo " /home/xsl/product/certs/dev-key.pem"
echo "Set SSL_CERTFILE and SSL_KEYFILE in .env to enable HTTPS."
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
OUT="${1:-/home/xsl/product/outputs/gpu-metrics.csv}"
SECONDS_TOTAL="${2:-3600}"
INTERVAL="${3:-5}"
mkdir -p "$(dirname "$OUT")"
echo "ts,index,name,util_gpu,util_mem,mem_used_mb,mem_total_mb,temp_c,power_w" > "$OUT"
end_ts=$(( $(date +%s) + SECONDS_TOTAL ))
while [[ "$(date +%s)" -lt "$end_ts" ]]; do
now="$(date +%s)"
nvidia-smi --query-gpu=index,name,utilization.gpu,utilization.memory,memory.used,memory.total,temperature.gpu,power.draw \
--format=csv,noheader,nounits \
| awk -v ts="$now" -F', ' '{print ts","$1","$2","$3","$4","$5","$6","$7","$8}' >> "$OUT"
sleep "$INTERVAL"
done
echo "saved: $OUT"
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import os
import subprocess
import sys
def main() -> None:
code = r"""
import asyncio
from services.llm import LLMService
async def run():
svc = LLMService()
text, source = await svc.reply_with_meta("测试降级")
print("source:", source)
print("reply:", text)
asyncio.run(run())
"""
env = os.environ.copy()
env["LLM_BASE_URL"] = "http://127.0.0.1:9"
env["LLM_TIMEOUT"] = "1"
proc = subprocess.run([sys.executable, "-c", code], env=env, capture_output=True, text=True)
print(proc.stdout.strip())
if proc.returncode != 0:
print(proc.stderr.strip())
raise SystemExit(proc.returncode)
if "source: fallback" not in proc.stdout:
raise SystemExit("fallback check failed: source is not fallback")
print("fallback check: ok")
if __name__ == "__main__":
main()
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import argparse
import json
import ssl
import time
import urllib.request
def _json_request(url: str, method: str = "GET", payload: dict | None = None, timeout: int = 30) -> dict:
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url=url, method=method, data=body)
if payload is not None:
req.add_header("Content-Type", "application/json")
ctx = ssl._create_unverified_context() if url.startswith("https://") else None
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
return json.loads(resp.read().decode("utf-8"))
def main() -> None:
parser = argparse.ArgumentParser(description="Long-run stability test")
parser.add_argument("--base-url", default="https://127.0.0.1:8080")
parser.add_argument("--minutes", type=int, default=60)
parser.add_argument("--interval-sec", type=int, default=30)
args = parser.parse_args()
end_ts = time.time() + args.minutes * 60
rounds = 0
while time.time() < end_ts:
rounds += 1
data = _json_request(
f"{args.base_url}/chat/text",
method="POST",
payload={"text": f"长稳测试第{rounds}轮,请简短回复。"},
timeout=60,
)
health = _json_request(f"{args.base_url}/health")
print(
f"round={rounds} ok={data.get('ok')} "
f"last_latency={health.get('last_latency_ms')} "
f"llm={health.get('last_llm_latency_ms')} "
f"tts={health.get('last_tts_latency_ms')}"
)
time.sleep(args.interval_sec)
print("longrun done, rounds:", rounds)
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import asyncio
import os
import sys
import numpy as np
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from services.asr import ASRService
from services.tts import TTSService
from services.vad import VADService
async def main() -> None:
asr = ASRService()
tts = TTSService()
vad = VADService()
dummy_16k = np.zeros(16000, dtype=np.float32)
asr_text = await asr.transcribe(dummy_16k)
tts_audio, tts_sr = await tts.synthesize("你好,这是模型探测。")
vad_event = vad.push_chunk(np.zeros(512, dtype=np.float32))
print("asr.health:", asr.health)
print("asr.text:", asr_text[:80])
print("tts.health:", tts.health)
print("tts.samples:", int(tts_audio.shape[0]), "sr:", tts_sr)
print("vad.health:", vad.health, "vad.event:", vad_event)
if __name__ == "__main__":
asyncio.run(main())
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
import argparse
import json
import ssl
import time
import urllib.error
import urllib.request
def _json_request(url: str, method: str = "GET", payload: dict | None = None, timeout: int = 20) -> dict:
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url=url, method=method, data=body)
if payload is not None:
req.add_header("Content-Type", "application/json")
ctx = ssl._create_unverified_context() if url.startswith("https://") else None
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
return json.loads(resp.read().decode("utf-8"))
def main() -> None:
parser = argparse.ArgumentParser(description="Quick QA for text pipeline and metrics")
parser.add_argument("--base-url", default="https://127.0.0.1:8080")
parser.add_argument("--rounds", type=int, default=5)
args = parser.parse_args()
health = _json_request(f"{args.base_url}/health")
print("health.ok:", bool(health.get("llm")))
latencies = []
for i in range(args.rounds):
t0 = time.perf_counter()
data = _json_request(
f"{args.base_url}/chat/text",
method="POST",
payload={"text": f"{i+1}轮压测,请简短回复。"},
timeout=60,
)
dt = int((time.perf_counter() - t0) * 1000)
latencies.append(dt)
print(
f"round={i+1} ok={data.get('ok')} llm={data.get('llm_latency_ms')} "
f"tts={data.get('tts_latency_ms')} total={dt}"
)
p50 = sorted(latencies)[len(latencies) // 2] if latencies else 0
print("rounds:", args.rounds, "p50_ms:", p50, "max_ms:", max(latencies) if latencies else 0)
health2 = _json_request(f"{args.base_url}/health")
print(
"final.health:",
{
"pipeline_runs": health2.get("pipeline_runs"),
"last_latency_ms": health2.get("last_latency_ms"),
"last_llm_latency_ms": health2.get("last_llm_latency_ms"),
"last_tts_latency_ms": health2.get("last_tts_latency_ms"),
},
)
if __name__ == "__main__":
try:
main()
except urllib.error.URLError as exc:
raise SystemExit(f"request failed: {exc}")
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
cd /home/xsl/product
PY="/home/xsl/miniconda3/envs/MuseTalk/bin/python"
echo "[1/6] service status"
bash scripts/service.sh status || true
echo "[2/6] quick pipeline qa"
"$PY" scripts/qa_check.py --base-url "https://127.0.0.1:8080" --rounds 5
echo "[3/6] llm fallback"
"$PY" scripts/llm_fallback_check.py
echo "[4/6] vad check"
"$PY" scripts/vad_check.py
echo "[5/6] tts stress"
"$PY" scripts/tts_stress.py
echo "[6/6] smoke media output"
"$PY" scripts/smoke_test.py --text "全链路验收测试,请简短回复"
echo "all checks finished."
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="/home/xsl/product"
PID_FILE="$ROOT/.run/visual-chat.pid"
LOG_FILE="$ROOT/.run/visual-chat.log"
START_CMD="$ROOT/scripts/start-public.sh"
mkdir -p "$ROOT/.run"
is_running() {
if [[ ! -f "$PID_FILE" ]]; then
return 1
fi
local pid
pid="$(cat "$PID_FILE")"
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
return 0
fi
return 1
}
start() {
if is_running; then
echo "already running: pid=$(cat "$PID_FILE")"
return 0
fi
# If stale process still binds configured port, stop it first.
local port
port="$(awk -F= '/^WEBRTC_PORT=/{print $2}' "$ROOT/.env" 2>/dev/null || true)"
port="${port:-8080}"
local pids
pids="$(ss -ltnp 2>/dev/null | sed -n "s/.*:${port} .*users:((\"python\",pid=\([0-9]\+\),.*/\1/p" | tr '\n' ' ')"
if [[ -n "${pids// /}" ]]; then
echo "killing stale python on port $port: $pids"
# shellcheck disable=SC2086
kill $pids || true
sleep 1
fi
nohup bash "$START_CMD" >>"$LOG_FILE" 2>&1 &
local pid=$!
echo "$pid" >"$PID_FILE"
sleep 1
if kill -0 "$pid" 2>/dev/null; then
echo "started: pid=$pid"
echo "log: $LOG_FILE"
else
echo "failed to start, check log: $LOG_FILE"
rm -f "$PID_FILE"
return 1
fi
}
stop() {
if ! is_running; then
echo "not running"
rm -f "$PID_FILE"
return 0
fi
local pid
pid="$(cat "$PID_FILE")"
kill "$pid" || true
for _ in {1..20}; do
if ! kill -0 "$pid" 2>/dev/null; then
break
fi
sleep 0.2
done
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" || true
fi
rm -f "$PID_FILE"
echo "stopped"
}
status() {
if is_running; then
echo "running: pid=$(cat "$PID_FILE")"
return 0
fi
echo "not running"
return 1
}
restart() {
stop || true
start
}
logs() {
if [[ -f "$LOG_FILE" ]]; then
tail -n 80 "$LOG_FILE"
else
echo "no log file yet"
fi
}
case "${1:-}" in
start) start ;;
stop) stop ;;
restart) restart ;;
status) status ;;
logs) logs ;;
*)
echo "usage: $0 {start|stop|restart|status|logs}"
exit 2
;;
esac
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import argparse
import asyncio
import math
import os
import sys
import wave
import cv2
import numpy as np
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from core.pipeline import ChatPipeline
from core.state_machine import Arbitrator
from services.asr import ASRService
from services.avatar import AvatarService
from services.llm import LLMService
from services.tts import TTSService
def _write_wav(path: str, audio: np.ndarray, sr: int) -> None:
pcm = np.clip(audio * 32767.0, -32768, 32767).astype(np.int16)
with wave.open(path, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sr)
wf.writeframes(pcm.tobytes())
async def _write_avatar_video(
avatar: AvatarService, audio: np.ndarray, sr: int, out_path: str, fps: int
) -> tuple[int, float]:
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
writer = cv2.VideoWriter(out_path, fourcc, float(fps), (avatar.width, avatar.height))
if not writer.isOpened():
raise RuntimeError(f"failed to open video writer: {out_path}")
duration_s = float(audio.shape[0]) / float(sr) if sr > 0 else 0.0
total_frames = max(1, int(math.ceil(duration_s * fps)))
samples_per_frame = max(1, int(sr / fps))
for i in range(total_frames):
s = i * samples_per_frame
e = min(audio.shape[0], (i + 1) * samples_per_frame)
seg = audio[s:e]
if seg.size == 0:
level = 0.0
else:
rms = float(np.sqrt(np.mean(seg.astype(np.float32) ** 2)))
level = float(np.clip(rms * 10.0, 0.0, 1.0))
frame = await avatar.render_frame(mouth_open=level, speaking=True)
writer.write(frame)
# add 0.4s idle tail for easy visual check
tail_frames = max(1, int(0.4 * fps))
for _ in range(tail_frames):
frame = await avatar.render_frame(mouth_open=0.0, speaking=False)
writer.write(frame)
writer.release()
return total_frames + tail_frames, duration_s + 0.4
async def main() -> None:
parser = argparse.ArgumentParser(description="Offline full-pipeline smoke test")
parser.add_argument("--text", default="你好,请做一个十秒内的简短自我介绍。")
parser.add_argument("--out-dir", default="outputs")
parser.add_argument("--fps", type=int, default=25)
args = parser.parse_args()
os.makedirs(args.out_dir, exist_ok=True)
wav_path = os.path.join(args.out_dir, "smoke_reply.wav")
mp4_path = os.path.join(args.out_dir, "smoke_avatar.mp4")
arbitrator = Arbitrator()
pipeline = ChatPipeline(
arbitrator=arbitrator,
asr=ASRService(),
llm=LLMService(),
tts=TTSService(),
)
avatar = AvatarService()
result, audio, sr = await pipeline.process_text_turn(args.text)
_write_wav(wav_path, audio, sr)
frame_count, video_s = await _write_avatar_video(avatar, audio, sr, mp4_path, args.fps)
print("=== smoke test done ===")
print(f"user_text: {result.get('user_text', '')}")
print(f"reply_text: {result.get('reply_text', '')}")
print(f"llm_source: {result.get('llm_source', 'unknown')}")
print(f"audio_samples: {result.get('audio_samples', 0)}, sample_rate: {sr}")
print(f"video_frames: {frame_count}, video_seconds: {video_s:.2f}")
print(f"wav: {wav_path}")
print(f"mp4: {mp4_path}")
if __name__ == "__main__":
asyncio.run(main())
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
cd /home/xsl/product
PY="/home/xsl/miniconda3/envs/MuseTalk/bin/python"
if [[ -f ".env" ]]; then
# shellcheck disable=SC2046
export $(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' .env | xargs)
fi
ARGS=(main:app --host "${WEBRTC_HOST:-0.0.0.0}" --port "${WEBRTC_PORT:-8080}")
if [[ -n "${SSL_CERTFILE:-}" && -n "${SSL_KEYFILE:-}" ]]; then
ARGS+=(--ssl-certfile "$SSL_CERTFILE" --ssl-keyfile "$SSL_KEYFILE")
fi
exec "$PY" -m uvicorn "${ARGS[@]}"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
cd /home/xsl/product
python -m uvicorn main:app --host 0.0.0.0 --port 8080
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import asyncio
import os
import sys
import time
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from services.tts import TTSService
async def main() -> None:
svc = TTSService()
texts = [f"{i+1}句,语音稳定性压力测试。" for i in range(20)]
costs = []
for i, text in enumerate(texts):
t0 = time.perf_counter()
audio, sr = await svc.synthesize(text)
dt = int((time.perf_counter() - t0) * 1000)
costs.append(dt)
print(f"round={i+1} ms={dt} samples={audio.shape[0]} sr={sr}")
p50 = sorted(costs)[len(costs) // 2]
print("tts.health:", svc.health)
print("tts stress p50_ms:", p50, "max_ms:", max(costs))
if __name__ == "__main__":
asyncio.run(main())
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import math
import os
import sys
import time
import numpy as np
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from services.vad import VADService
def main() -> None:
vad = VADService()
sr = 16000
chunk = 512
# 1s silence -> 1s speech-like sine -> 1s silence
t = np.arange(sr, dtype=np.float32) / sr
speech = 0.20 * np.sin(2 * math.pi * 220 * t).astype(np.float32)
seq = [np.zeros(sr, dtype=np.float32), speech, np.zeros(sr, dtype=np.float32)]
stream = np.concatenate(seq)
starts = 0
ends = 0
first_start_ms = -1
t0 = time.perf_counter()
for i in range(0, stream.shape[0] - chunk + 1, chunk):
evt = vad.push_chunk(stream[i : i + chunk])
if evt and "start" in evt:
starts += 1
if first_start_ms < 0:
first_start_ms = int((time.perf_counter() - t0) * 1000)
if evt and "end" in evt:
ends += 1
print("vad.health:", vad.health)
print("vad.starts:", starts, "vad.ends:", ends, "first_start_ms:", first_start_ms)
if starts < 1 or ends < 1:
raise SystemExit("vad check failed: start/end not detected")
print("vad check: ok")
if __name__ == "__main__":
main()