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
This commit is contained in:
2026-06-10 11:51:05 +08:00
parent b45a8e2e85
commit ea0c007441
5 changed files with 47 additions and 132 deletions
+2
View File
@@ -2,3 +2,5 @@
.idea/ .idea/
__pycache__/ __pycache__/
logs/ logs/
resources/
+32 -127
View File
@@ -1,43 +1,35 @@
from __future__ import annotations from __future__ import annotations
import os
import queue import queue
import shutil import shutil
import subprocess import subprocess
import sys import sys
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Any
from loguru import logger from loguru import logger
class RepAnnouncer: class RepAnnouncer:
"""运动次数语音播报器:预生成 0~200 音频文件,运行时直接播放""" """运动次数语音播报器:读取预生成的音频文件直接播放"""
def __init__( def __init__(
self, self,
*, *,
enabled: bool = True, enabled: bool = True,
rate: int = 185,
volume: float = 1.0,
max_count: int = 200, max_count: int = 200,
cache_dir: str | Path = "runtime/tts_cache/reps", audio_dir: str | Path = "resources/audio/reps",
) -> None: ) -> None:
self.enabled = enabled self.enabled = enabled
self.rate = rate
self.volume = volume
self.max_count = max_count 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._queue: queue.Queue[int | None] = queue.Queue()
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._engine: Any | None = None
self._current_process: subprocess.Popen | None = None self._current_process: subprocess.Popen | None = None
self._closed = False self._closed = False
self._use_macos_say = sys.platform == "darwin" self._platform = sys.platform
self._use_windows_winsound = sys.platform.startswith("win")
if self.enabled: if self.enabled:
self._start() self._start()
@@ -46,11 +38,9 @@ class RepAnnouncer:
"""将次数放入队列,后台线程播放对应音频""" """将次数放入队列,后台线程播放对应音频"""
if not self.enabled or self._closed: if not self.enabled or self._closed:
return return
if count <= 0 or count > self.max_count: if count <= 0 or count > self.max_count:
return return
# 保留“只播最新一次”的策略,避免语音堆积
self._clear_pending_counts() self._clear_pending_counts()
self._queue.put(count) self._queue.put(count)
@@ -66,145 +56,62 @@ class RepAnnouncer:
self._thread.join(timeout=1.0) self._thread.join(timeout=1.0)
self._stop_current_playback() self._stop_current_playback()
logger.info("Rep announcer closed") logger.info("Rep announcer closed")
def _start(self) -> None: def _start(self) -> None:
"""初始化并预生成语音缓存""" """启动后台播报线程"""
self.cache_dir.mkdir(parents=True, exist_ok=True) self.audio_dir.mkdir(parents=True, exist_ok=True)
try: self._thread = threading.Thread(target=self._run, name="RepAnnouncer", daemon=True)
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.start() self._thread.start()
logger.info( logger.info("Rep announcer initialized, audio_dir={}", self.audio_dir)
"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")
def _run(self) -> None: def _run(self) -> None:
"""后台线程:只负责播放已经生成好的音频文件""" """后台线程:从队列取次数,播放对应音频文件"""
while True: while True:
count = self._queue.get() count = self._queue.get()
if count is None: if count is None:
return return
audio_file = self._audio_path(count)
if not audio_file.exists():
logger.warning("Rep audio file missing: {}", audio_file)
continue
try: try:
audio_file = self._audio_path(count) self._play(audio_file)
if not audio_file.exists():
logger.warning("Rep audio file missing: {}", audio_file)
continue
self._play_audio(audio_file)
except Exception as exc: except Exception as exc:
logger.warning("Failed to play rep count {}: {}", count, 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() self._stop_current_playback()
if self._use_macos_say: if self._platform == "darwin":
self._current_process = subprocess.Popen( self._current_process = subprocess.Popen(
["afplay", str(audio_file)], ["afplay", str(audio_file)],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
return elif self._platform.startswith("win"):
if self._use_windows_winsound:
import winsound import winsound
# SND_ASYNC 表示异步播放;PURGE 会被 _stop_current_playback 调用中断
winsound.PlaySound(str(audio_file), winsound.SND_FILENAME | winsound.SND_ASYNC) winsound.PlaySound(str(audio_file), winsound.SND_FILENAME | winsound.SND_ASYNC)
return else:
player = shutil.which("paplay") or shutil.which("aplay")
# Linux:优先 paplay,其次 aplay if player is None:
player = shutil.which("paplay") or shutil.which("aplay") logger.warning("No audio player found")
if player is None: return
logger.warning("No audio player found, expected paplay or aplay") self._current_process = subprocess.Popen(
return [player, str(audio_file)],
stdout=subprocess.DEVNULL,
self._current_process = subprocess.Popen( stderr=subprocess.DEVNULL,
[player, str(audio_file)], )
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _stop_current_playback(self) -> None: def _stop_current_playback(self) -> None:
"""中断当前正在播放的声音""" """中断当前正在播放的声音"""
if self._use_windows_winsound: if self._platform.startswith("win"):
try: try:
import winsound import winsound
@@ -215,13 +122,12 @@ class RepAnnouncer:
if self._current_process is not None and self._current_process.poll() is None: if self._current_process is not None and self._current_process.poll() is None:
self._current_process.terminate() self._current_process.terminate()
self._current_process = None self._current_process = None
def _audio_path(self, count: int) -> Path: def _audio_path(self, count: int) -> Path:
"""获取某个次数对应的音频文件路径""" """获取某个次数对应的音频文件路径"""
suffix = ".aiff" if self._use_macos_say else ".wav" suffix = ".aiff" if self._platform == "darwin" else ".wav"
return self.cache_dir / f"{count}{suffix}" return self.audio_dir / f"{count}{suffix}"
def _clear_pending_counts(self) -> None: def _clear_pending_counts(self) -> None:
"""清空队列中等待播放的次数,避免语音堆积""" """清空队列中等待播放的次数,避免语音堆积"""
@@ -229,7 +135,6 @@ class RepAnnouncer:
try: try:
item = self._queue.get_nowait() item = self._queue.get_nowait()
if item is None: if item is None:
# close 信号不要吞掉
self._queue.put(None) self._queue.put(None)
return return
except queue.Empty: except queue.Empty:
+9 -1
View File
@@ -2,10 +2,18 @@ from __future__ import annotations
from app.diagnostics.crash_handler import enable_crash_handler from app.diagnostics.crash_handler import enable_crash_handler
from configs.load import config from configs.load import config
from app.audio.generate import generate_rep_audio_files
def startup() -> None: def startup() -> None:
"""应用启动初始化:开启崩溃日志和日志系统""" """应用启动初始化:开启崩溃日志和日志系统"""
enable_crash_handler(config.logging.dir_path) enable_crash_handler(config.logging.dir_path)
from app.core.logging import setup_logging from app.core.logging import setup_logging
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,
)
+2 -2
View File
@@ -46,8 +46,8 @@ class VideoReceiver:
) )
announcer = RepAnnouncer( announcer = RepAnnouncer(
enabled=config.audio.rep_announcer_enabled, enabled=config.audio.rep_announcer_enabled,
rate=config.audio.rep_announcer_rate, max_count=config.audio.rep_max_count,
volume=config.audio.rep_announcer_volume, audio_dir=config.audio.resolved_audio_dir,
) )
last_announced_rep = 0 last_announced_rep = 0
last_pose_result = None last_pose_result = None
+1 -1
View File
@@ -54,7 +54,7 @@ class AudioConfig:
"""返回语音文件目录的绝对路径""" """返回语音文件目录的绝对路径"""
if self.rep_audio_dir: if self.rep_audio_dir:
return Path(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 @dataclass