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())