From ea0c007441c957e7becf61b07bdc04f94d4b8481 Mon Sep 17 00:00:00 2001 From: hjwang <2392948297@qq.com> Date: Wed, 10 Jun 2026 11:51:05 +0800 Subject: [PATCH] Clean up RepAnnouncer: remove TTS code, play pre-generated audio only - RepAnnouncer now only plays audio files, no TTS generation - Removed pyttsx3 dependency, rate/volume params from constructor - Audio generation delegated to app/audio/generate.py (called at startup) - Default audio dir changed to resources/audio/reps - Added resources/ to .gitignore --- .gitignore | 2 + app/audio/rep_announcer.py | 161 +++++++---------------------------- app/core/lifecycle.py | 10 ++- app/webrtc/video_receiver.py | 4 +- configs/models.py | 2 +- 5 files changed, 47 insertions(+), 132 deletions(-) diff --git a/.gitignore b/.gitignore index 589036a..304b48f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .idea/ __pycache__/ logs/ + +resources/ diff --git a/app/audio/rep_announcer.py b/app/audio/rep_announcer.py index 1a00647..41a68fa 100644 --- a/app/audio/rep_announcer.py +++ b/app/audio/rep_announcer.py @@ -1,43 +1,35 @@ from __future__ import annotations -import os import queue import shutil import subprocess import sys import threading from pathlib import Path -from typing import Any from loguru import logger class RepAnnouncer: - """运动次数语音播报器:预生成 0~200 音频文件,运行时直接播放""" + """运动次数语音播报器:读取预生成的音频文件直接播放""" def __init__( self, *, enabled: bool = True, - rate: int = 185, - volume: float = 1.0, max_count: int = 200, - cache_dir: str | Path = "runtime/tts_cache/reps", + audio_dir: str | Path = "resources/audio/reps", ) -> None: self.enabled = enabled - self.rate = rate - self.volume = volume self.max_count = max_count - self.cache_dir = Path(cache_dir) + self.audio_dir = Path(audio_dir) self._queue: queue.Queue[int | None] = queue.Queue() self._thread: threading.Thread | None = None - self._engine: Any | None = None self._current_process: subprocess.Popen | None = None self._closed = False - self._use_macos_say = sys.platform == "darwin" - self._use_windows_winsound = sys.platform.startswith("win") + self._platform = sys.platform if self.enabled: self._start() @@ -46,11 +38,9 @@ class RepAnnouncer: """将次数放入队列,后台线程播放对应音频""" if not self.enabled or self._closed: return - if count <= 0 or count > self.max_count: return - # 保留“只播最新一次”的策略,避免语音堆积 self._clear_pending_counts() self._queue.put(count) @@ -66,145 +56,62 @@ class RepAnnouncer: self._thread.join(timeout=1.0) self._stop_current_playback() - logger.info("Rep announcer closed") def _start(self) -> None: - """初始化并预生成语音缓存""" - self.cache_dir.mkdir(parents=True, exist_ok=True) + """启动后台播报线程""" + self.audio_dir.mkdir(parents=True, exist_ok=True) - try: - self._prepare_audio_cache() - except Exception as exc: - self.enabled = False - logger.warning("Rep announcer disabled, failed to prepare audio cache: {}", exc) - return - - self._thread = threading.Thread( - target=self._run, - name="RepAnnouncer", - daemon=True, - ) + self._thread = threading.Thread(target=self._run, name="RepAnnouncer", daemon=True) self._thread.start() - logger.info( - "Rep announcer initialized with audio cache, platform={}, max_count={}, cache_dir={}", - sys.platform, - self.max_count, - self.cache_dir, - ) - - def _prepare_audio_cache(self) -> None: - """生成 0~max_count 的语音文件,只生成缺失文件""" - if self._use_macos_say: - self._prepare_macos_say_cache() - else: - self._prepare_pyttsx3_cache() - - def _prepare_macos_say_cache(self) -> None: - """macOS: 使用 say 预生成 aiff 文件""" - if shutil.which("say") is None: - raise RuntimeError("macOS say command not found") - - for count in range(0, self.max_count + 1): - audio_file = self._audio_path(count) - - if audio_file.exists(): - continue - - subprocess.run( - [ - "say", - "-r", - str(self.rate), - "-o", - str(audio_file), - str(count), - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=True, - ) - - logger.info("macOS say audio cache prepared") - - def _prepare_pyttsx3_cache(self) -> None: - """非 macOS: 使用 pyttsx3 预生成 wav 文件""" - try: - import pyttsx3 - except Exception as exc: - raise RuntimeError(f"pyttsx3 unavailable: {exc}") from exc - - self._engine = pyttsx3.init() - self._engine.setProperty("rate", self.rate) - self._engine.setProperty("volume", self.volume) - - need_generate = False - - for count in range(0, self.max_count + 1): - if not self._audio_path(count).exists(): - need_generate = True - self._engine.save_to_file(str(count), str(self._audio_path(count))) - - if need_generate: - self._engine.runAndWait() - - logger.info("pyttsx3 audio cache prepared") + logger.info("Rep announcer initialized, audio_dir={}", self.audio_dir) def _run(self) -> None: - """后台线程:只负责播放已经生成好的音频文件""" + """后台线程:从队列取次数,播放对应音频文件""" while True: count = self._queue.get() - if count is None: return + audio_file = self._audio_path(count) + if not audio_file.exists(): + logger.warning("Rep audio file missing: {}", audio_file) + continue + try: - audio_file = self._audio_path(count) - - if not audio_file.exists(): - logger.warning("Rep audio file missing: {}", audio_file) - continue - - self._play_audio(audio_file) - + self._play(audio_file) except Exception as exc: logger.warning("Failed to play rep count {}: {}", count, exc) - def _play_audio(self, audio_file: Path) -> None: - """根据平台播放音频""" + def _play(self, audio_file: Path) -> None: + """播放音频文件(平台自适应)""" self._stop_current_playback() - if self._use_macos_say: + if self._platform == "darwin": self._current_process = subprocess.Popen( ["afplay", str(audio_file)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - return - - if self._use_windows_winsound: + elif self._platform.startswith("win"): import winsound - # SND_ASYNC 表示异步播放;PURGE 会被 _stop_current_playback 调用中断 winsound.PlaySound(str(audio_file), winsound.SND_FILENAME | winsound.SND_ASYNC) - return - - # Linux:优先 paplay,其次 aplay - player = shutil.which("paplay") or shutil.which("aplay") - if player is None: - logger.warning("No audio player found, expected paplay or aplay") - return - - self._current_process = subprocess.Popen( - [player, str(audio_file)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + else: + player = shutil.which("paplay") or shutil.which("aplay") + if player is None: + logger.warning("No audio player found") + return + self._current_process = subprocess.Popen( + [player, str(audio_file)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) def _stop_current_playback(self) -> None: """中断当前正在播放的声音""" - if self._use_windows_winsound: + if self._platform.startswith("win"): try: import winsound @@ -215,13 +122,12 @@ class RepAnnouncer: if self._current_process is not None and self._current_process.poll() is None: self._current_process.terminate() - self._current_process = None def _audio_path(self, count: int) -> Path: """获取某个次数对应的音频文件路径""" - suffix = ".aiff" if self._use_macos_say else ".wav" - return self.cache_dir / f"{count}{suffix}" + suffix = ".aiff" if self._platform == "darwin" else ".wav" + return self.audio_dir / f"{count}{suffix}" def _clear_pending_counts(self) -> None: """清空队列中等待播放的次数,避免语音堆积""" @@ -229,8 +135,7 @@ class RepAnnouncer: try: item = self._queue.get_nowait() if item is None: - # close 信号不要吞掉 self._queue.put(None) return except queue.Empty: - return \ No newline at end of file + return diff --git a/app/core/lifecycle.py b/app/core/lifecycle.py index 9d2138f..4edfa3b 100644 --- a/app/core/lifecycle.py +++ b/app/core/lifecycle.py @@ -2,10 +2,18 @@ from __future__ import annotations from app.diagnostics.crash_handler import enable_crash_handler from configs.load import config - +from app.audio.generate import generate_rep_audio_files def startup() -> None: """应用启动初始化:开启崩溃日志和日志系统""" enable_crash_handler(config.logging.dir_path) from app.core.logging import setup_logging setup_logging() + + # 生成运动次数语音文件 + generate_rep_audio_files( + max_count=config.audio.rep_max_count, + rate=config.audio.rep_announcer_rate, + output_dir=config.audio.resolved_audio_dir, + overwrite=False, + ) diff --git a/app/webrtc/video_receiver.py b/app/webrtc/video_receiver.py index 60c6892..78db8f0 100644 --- a/app/webrtc/video_receiver.py +++ b/app/webrtc/video_receiver.py @@ -46,8 +46,8 @@ class VideoReceiver: ) announcer = RepAnnouncer( enabled=config.audio.rep_announcer_enabled, - rate=config.audio.rep_announcer_rate, - volume=config.audio.rep_announcer_volume, + max_count=config.audio.rep_max_count, + audio_dir=config.audio.resolved_audio_dir, ) last_announced_rep = 0 last_pose_result = None diff --git a/configs/models.py b/configs/models.py index ee71fcc..bc8e910 100644 --- a/configs/models.py +++ b/configs/models.py @@ -54,7 +54,7 @@ class AudioConfig: """返回语音文件目录的绝对路径""" if self.rep_audio_dir: return Path(self.rep_audio_dir) - return Path(__file__).resolve().parent.parent / "app" / "audio" / "reps" + return Path(__file__).resolve().parent.parent / "resources" / "audio" / "reps" @dataclass